convert md to html when editing

convert md to html when editing

<script lang="coffee">
import tippy from 'tippy.js'
import Records from '@/shared/services/records'
import {concat, sortBy, isString, filter, uniq, map, forEach} from 'lodash'
import FileUploader from '@/shared/services/file_uploader'
import FilesList from './files_list.vue'

import { Editor, EditorContent, EditorMenuBar, EditorMenuBubble } from 'tiptap'

import {
  Blockquote,
  CodeBlock,
  HardBreak,
  Heading,
  HorizontalRule,
  OrderedList,
  BulletList,
  ListItem,
  Table,
  TableHeader,
  TableCell,
  TableRow,
  TodoList,
  Bold,
  Code,
  Italic,
  Link,
  Strike,
  Underline,
  History,
  Mention,
  Placeholder } from 'tiptap-extensions'

import TodoItem from './todo_item'

import { insertText } from 'tiptap-commands'
import Image from '@/shared/tiptap_extentions/image.js'

import marked from 'marked'
import {customRenderer, options} from '@/shared/helpers/marked.coffee'
marked.setOptions Object.assign({renderer: customRenderer()}, options)

export default
  props:
    model: Object
    field: String
    label: String
    placeholder: String
    shouldReset: Boolean
    autoFocus:
      type: Boolean
      default: false

  components:
    EditorContent: EditorContent
    EditorMenuBar: EditorMenuBar
    FilesList: FilesList
    EditorMenuBubble: EditorMenuBubble

  data: ->
    query: null
    suggestionRange: null
    files: []
    imageFiles: []
    mentionableUserIds: []
    navigatedUserIndex: 0
    closeEmojiMenu: false
    linkUrl: null
    linkDialogIsOpen: false
    insertMention: () => {}
    editor: new Editor
      extensions: [
        new Mention(
          # is called when a suggestion starts
          onEnter: ({ query, range, command, virtualNode }) =>
            @query = query
            @suggestionRange = range
            @insertMention = command
            @renderPopup(virtualNode)
            @fetchMentionable()

          # is called when a suggestion has changed
          onChange: ({query, range, virtualNode}) =>
            @query = query
            @suggestionRange = range
            @navigatedUserIndex = 0
            @renderPopup(virtualNode)
            @fetchMentionable()

          # is called when a suggestion is cancelled
          onExit: =>
            @query = null
            @suggestionRange = null
            @navigatedUserIndex = 0
            @destroyPopup()

          # is called on every keyDown event while a suggestion is active
          onKeyDown: ({ event }) =>
            # pressing up arrow
            if (event.keyCode == 38)
              @upHandler()
              return true

            # pressing down arrow
            if (event.keyCode == 40)
              @downHandler()
              return true

            # pressing enter
            if (event.keyCode == 13)
              @enterHandler()
              return true

            return false
        ),

        new Blockquote(),
        new BulletList(),
        new CodeBlock(),
        new HardBreak(),
        new Image({attachFile: @attachFile, attachImageFile: @attachImageFile}),
        new Heading({ levels: [1, 2, 3] }),
        new HorizontalRule(),
        new ListItem(),
        new OrderedList(),
        new TodoItem(),
        new TodoList(),
        new Table(),
        new TableHeader(),
        new TableCell(),
        new TableRow(),
        new Bold(),
        new Code(),
        new Italic(),
        new Link(),
        new Strike(),
        new Underline(),
        new History(),
        new Placeholder({
          emptyClass: 'is-empty',
          emptyNodeText: @placeholder,
          showOnlyWhenEditable: true,
        })
      ]
      content: (if @model[@field+'Format'] == "md"
        @model[@field+'Format'] = 'html'
        @model[@field] = marked(@model[@field])
      else
        @model[@field])
      onUpdate: @updateModel
      autoFocus: @autoFocus

  computed:
    hasResults: -> @filteredUsers.length
    showSuggestions: -> @query || @hasResults
    filteredUsers: ->
      unsorted = filter Records.users.collection.chain().find(@mentionableUserIds).data(), (u) =>
        isString(u.username) &&
        ((u.name || '').toLowerCase().startsWith(@query) or
        (u.username || '').toLowerCase().startsWith(@query) or
        (u.name || '').toLowerCase().includes(" #{@query}"))
      sortBy(unsorted, (u) -> (0 - Records.events.find(actorId: u.id).length))
    format: ->
      @model["#{@field}Format"]

  mounted: ->
    @model.files = []
    @model.imageFiles = []

  methods:
    setLinkUrl: (command) ->
      command({ href: @linkUrl })
      @linkUrl = null
      @linkDialogIsOpen = false
      @editor.focus()

    emitUploading: ->
      @$emit('is-uploading', !(@model.files.length == @files.length && @model.imageFiles.length == @imageFiles.length))

    emojiPicked: (shortcode, unicode) ->
      { view } = this.editor
      insertText(unicode)(view.state, view.dispatch, view)
      @closeEmojiMenu = false
      @editor.focus()

    updateModel: ->
      @model[@field] = @editor.getHTML()
      @model.files = @files.filter((w) => w.blob).map (wrapper) => wrapper.blob.signed_id
      @model.imageFiles = @imageFiles.filter((w) => w.blob).map (wrapper) => wrapper.blob.signed_id
      @emitUploading()

    removeFile: (name) ->
      @files = filter @files, (wrapper) -> wrapper.file.name != name

    attachFile: ({file}) ->
      wrapper = {file: file, key: file.name+file.size, percentComplete: 0, blob: null}
      @files.push(wrapper)
      @emitUploading()
      uploader = new FileUploader onProgress: (e) ->
        wrapper.percentComplete = parseInt(e.loaded / e.total * 100)

      uploader.upload(file).then (blob) =>
        wrapper.blob = blob
        @updateModel()
      ,
      (e) ->
        console.log "attachment failed to upload"

    attachImageFile: ({file, onProgress, onComplete, onFailure}) ->
      wrapper = {file: file, blob: null}
      @imageFiles.push(wrapper)
      @emitUploading()
      uploader = new FileUploader onProgress: onProgress
      uploader.upload(file).then((blob) =>
        wrapper.blob = blob
        onComplete(blob)
        @updateModel()
      , onFailure)

    fileSelected: ->
      forEach(@$refs.filesField.files, (file) => @attachFile(file: file))

    fetchMentionable: ->
      Records.users.fetchMentionable(@query, @model).then (response) =>
        @mentionableUserIds.concat(uniq @mentionableUserIds + map(response.users, 'id'))

    # mentioning methods
    upHandler: ->
      @navigatedUserIndex = ((@navigatedUserIndex + @filteredUsers.length) - 1) % @filteredUsers.length

    downHandler: ->
      @navigatedUserIndex = (@navigatedUserIndex + 1) % @filteredUsers.length

    enterHandler: ->
      user = @filteredUsers[@navigatedUserIndex]
      @selectUser(user) if user

    selectUser: (user) ->
      @insertMention
        range: @suggestionRange
        attrs:
          id: user.id,
          label: user.name
      @editor.focus()

    renderPopup: (node) ->
      return if @popup
      @popup = tippy(node, {
        content: @$refs.suggestions,
        trigger: 'mouseenter',
        interactive: true,
        theme: 'dark',
        placement: 'top-start',
        inertia: true,
        duration: [400, 200],
        showOnInit: true,
        arrow: true,
        arrowType: 'round'
      })

      if MutationObserver
        @observer = new MutationObserver => @popup.popperInstance.scheduleUpdate()
        @observer.observe(@$refs.suggestions, {
          childList: true,
          subtree: true,
          characterData: true,
        })

    destroyPopup: ->
      if @popup
        @popup.destroy()
        @popup = null
      @observer.disconnect() if @observer

  watch:
    files: -> @updateModel()
    imageFiles: -> @updateModel()
    shouldReset: ->
      @editor.clearContent()
      @files = []
      @imageFiles = []

  beforeDestroy: ->
    @editor.destroy()
</script>

<template lang="pug">
div
  label.caption.v-label.v-label--active.theme--light {{label}}
  div(v-if="format == 'md'")
    v-textarea(lmo_textarea v-model="model[field]" :placeholder="$t('comment_form.say_something')")
    v-layout
      v-spacer
      slot(name="actions")
  .editor.mb-3(v-if="format == 'html'")
    editor-content.editor__content(:editor='editor').lmo-markdown-wrapper
    editor-menu-bubble(:editor='editor' v-slot='{ commands, isActive, menu }')
      .menububble(:class="{'is-active': menu.isActive}" :style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`")
        v-btn(small icon :class="{ 'is-active': isActive.bold() }", @click='commands.bold')
          v-icon mdi-format-bold
        v-btn(small icon :class="{ 'is-active': isActive.italic() }", @click='commands.italic')
          v-icon mdi-format-italic
        v-btn(small icon :class="{ 'is-active': isActive.strike() }", @click='commands.strike')
          v-icon mdi-format-strikethrough
        v-btn(small icon :class="{ 'is-active': isActive.underline() }", @click='commands.underline')
          v-icon mdi-format-underline
        //- v-btn(small icon :class="{ 'is-active': isActive.code() }", @click='commands.code')
        //-   v-icon mdi-code-tags
        v-dialog(v-model="linkDialogIsOpen" max-width="600px")
          template(v-slot:activator="{on}")
            v-btn(small icon v-on="on")
              v-icon mdi-link-variant
          v-card
            v-card-title.title(v-t="'text_editor.insert_link'")
            v-card-text
              v-text-field(type="url" label="https://www.example.com" v-model="linkUrl")
            v-card-actions
              v-spacer
              v-btn(color="primary" @click="setLinkUrl(commands.link)" v-t="'common.action.apply'")

    editor-menu-bar(:editor='editor' v-slot='{ commands, isActive, focused }')
      v-layout.menubar(wrap align-center)
        span
          v-menu(:close-on-content-click="false" v-model="closeEmojiMenu")
            template(v-slot:activator="{on}")
              v-btn.emoji-picker__toggle(v-on="on" small icon :class="{ 'is-active': isActive.underline() }")
                v-icon mdi-emoticon-outline
            emoji-picker(:insert="emojiPicked")
          v-btn(icon :class="{ 'is-active': isActive.underline() }", @click='$refs.filesField.click()')
            v-icon mdi-paperclip
          v-btn(icon :class="{ 'is-active': isActive.heading({ level: 1 }) }", @click='commands.heading({ level: 1 })')
            v-icon mdi-format-header-1
          v-btn(icon :class="{ 'is-active': isActive.heading({ level: 2 }) }", @click='commands.heading({ level: 2 })')
            v-icon mdi-format-header-2
          v-btn(icon :class="{ 'is-active': isActive.heading({ level: 3 }) }", @click='commands.heading({ level: 3 })')
            v-icon mdi-format-header-3
          v-btn(icon :class="{ 'is-active': isActive.bullet_list() }", @click='commands.bullet_list')
            v-icon mdi-format-list-bulleted
          v-btn(icon :class="{ 'is-active': isActive.ordered_list() }", @click='commands.ordered_list')
            v-icon mdi-format-list-numbered
          v-btn(icon @click='commands.todo_list')
            v-icon mdi-format-list-checks
          v-btn(small icon :class="{ 'is-active': isActive.blockquote() }", @click='commands.blockquote')
            v-icon mdi-format-quote-close
          v-btn(small icon :class="{ 'is-active': isActive.code_block() }", @click='commands.code_block')
            v-icon mdi-code-braces
          v-btn(icon @click='commands.horizontal_rule')
            v-icon mdi-minus
          v-btn(icon @click="commands.createTable({rowsCount: 3, colsCount: 3, withHeaderRow: false })")
            v-icon mdi-table
          span(v-if="isActive.table()")
            v-btn(icon @click="commands.deleteTable")
              v-icon mdi-table-remove
            v-btn(icon @click="commands.addColumnBefore")
              v-icon mdi-table-column-plus-before
            v-btn(icon @click="commands.addColumnAfter")
              v-icon mdi-table-column-plus-after
            v-btn(icon @click="commands.deleteColumn")
              v-icon mdi-table-column-remove
            v-btn(icon @click="commands.addRowBefore")
              v-icon mdi-table-row-plus-before
            v-btn(icon @click="commands.addRowAfter")
              v-icon mdi-table-row-plus-after
            v-btn(icon @click="commands.deleteRow")
              v-icon mdi-table-row-remove
            v-btn(icon @click="commands.toggleCellMerge")
              v-icon mdi-table-merge-cells
        v-layout
          v-spacer
          slot(name="actions")

  .suggestion-list(v-show='showSuggestions', ref='suggestions')
    template(v-if='hasResults')
      .suggestion-list__item(v-for='(user, index) in filteredUsers', :key='user.id', :class="{ 'is-selected': navigatedUserIndex === index }", @click='selectUser(user)')
        | {{ user.name }}
    .suggestion-list__item.is-empty(v-else) No users found

  files-list(:files="files" v-on:removeFile="removeFile")

  form(style="display: block" @change="fileSelected")
    input(ref="filesField" type="file" name="files" multiple=true)
</template>

<style lang="sass">
.menububble.is-hidden
  visibility: hidden
  opacity: 0

.menububble.is-active
  visibility: visible
  opacity: 1

.menububble
  position: absolute
  display: flex
  z-index: 20
  background: #fff
  border: 1px solid #ccc
  border-radius: 5px
  padding: .3rem
  margin-bottom: .5rem
  transform: translateX(-50%)
  visibility: hidden
  opacity: 0
  transition: opacity .2s,visibility .2s

.lmo-markdown-wrapper
  h1
    line-height: 2.75rem
    font-size: 2rem
    font-weight: 400
    letter-spacing: .0125em
    margin-bottom: 0.5em

  h2
    line-height: 2rem
    font-size: 1.25rem
    font-weight: 500
    letter-spacing: .0125em
    margin-bottom: 0.75em

  h3
    line-height: 2.5rem
    font-size: 1rem
    font-weight: 700
    letter-spacing: .009375em

  p
    margin-bottom: 12px

  hr
    border: 0
    border-bottom: 2px solid rgba(0,0,0,0.1)
    margin: 16px 0

  word-wrap: break-word

  img
    max-width: 100%

  ol, ul
    padding-left: 24px
    margin-bottom: 16px
    ul
      margin-bottom: 0

  ul
    list-style: disc

  ol
    list-style: decimal

  li p
    margin-bottom: 0

  pre
    overflow: auto
    padding: 10px

  blockquote
    font-style: italic
    border-left: 3px solid rgba(0,0,0,.1)
    color: rgba(0,0,0,.8)
    padding-left: .8rem

  table
    table-layout: fixed
    width: 100%
    margin-bottom: 10px

  table td
    padding: 6px 13px
    border: 1px solid #ddd

  thead td
    font-weight: bold

progress
  -webkit-appearance: none
  appearance: none
  background-color: #fff
  border: 1px solid #ccc

progress::-webkit-progress-bar
  background-color: #fff
  border: 1px solid #ccc

progress::-webkit-progress-value
  background-color: lightblue
  border: 0
  transition: width 120ms ease-out, opacity 60ms 60ms ease-in

progress::-moz-progress-bar
  background-color: lightblue
  border: 0
  transition: width 120ms ease-out, opacity 60ms 60ms ease-in

.menubar
  position: sticky
  bottom: 0
  background-color: #fff

.menubar, .menububble
  .v-btn--icon
    width: 32px
    height: 32px

  .v-btn
    min-width: 0
    margin-left: 0
    margin-right: 0
    .v-icon
      font-size: 16px

.ProseMirror
  border-bottom: 1px solid #999
  padding: 4px 0px
  margin: 4px 0px
  outline: none
  overflow-y: scroll

.ProseMirror:focus
  border-bottom: 2px solid var(--v-primary-base)

.ProseMirror img
  display: block

.ProseMirror progress
  // display: block;

ul[data-type="todo_list"]
  padding-left: 0
li[data-type="todo_item"]
  display: flex
  flex-direction: row

.todo-checkbox
  border: 1px solid #999
  height: 1em
  width: 1em
  box-sizing: border-box
  margin-right: 8px
  margin-top: 4px
  user-select: none
  border-radius: 0.2em
  background-color: transparent

.ProseMirror .todo-checkbox
  cursor: pointer

.todo-content
  flex: 1
  > p:last-of-type
    margin-bottom: 0
  > ul[data-type="todo_list"]
    margin: .5rem 0
  p
    margin: 0

li[data-done="true"]
  > .todo-content
    > p
      text-decoration: line-through
  > .todo-checkbox::before
    position: relative
    top: -7px
    color: var(--v-primary-base)
    font-size: 1.3rem
    content: "✓"

li[data-done="false"]
  text-decoration: none

.editor p.is-empty:first-child::before
  content: attr(data-empty-text)
  float: left
  color: #aaa
  pointer-events: none
  height: 0

.editor p.is-empty
  font-size: 16px
  padding-bottom: 16px

.mention
  background: rgba(#ffb300, 0.3)
  border-radius: 5px
  padding: 0.2rem 0.5rem
  white-space: nowrap

.mention-suggestion
  color: rgba(#000, 0.6)

.suggestion-list
  padding: 0.2rem
  border: 2px solid rgba(#000, 0.1)
  &__no-results
    padding: 0.2rem 0.5rem
  &__item
    font-family: roboto, sans-serif
    border-radius: 5px
    padding: 0.2rem 0.5rem
    margin-bottom: 0.2rem
    cursor: pointer
    &:last-child
      margin-bottom: 0
    &.is-selected, &:hover
      background-color: rgba(#fff, 0.2)
    &.is-empty
      opacity: 0.5

.tippy-tooltip.dark-theme
  background-color: #000
  padding: 0
  font-size: 1rem
  text-align: inherit
  color: #fff
  border-radius: 5px
  .tippy-backdrop
    display: none
  .tippy-roundarrow
    fill: #000
  .tippy-popper[x-placement^=top] & .tippy-arrow
    border-top-color: #000
  .tippy-popper[x-placement^=bottom] & .tippy-arrow
    border-bottom-color: #000
  .tippy-popper[x-placement^=left] & .tippy-arrow
    border-left-color: #000
  .tippy-popper[x-placement^=right] & .tippy-arrow
    border-right-color: #000

input[type="file"]
  display: none
</style>

<script lang="coffee">
import tippy from 'tippy.js'
# import 'tippy.js/dist/tippy.css'
import Records from '@/shared/services/records'
import {concat, sortBy, isString, filter, uniq, map, forEach} from 'lodash'
import FileUploader from '@/shared/services/file_uploader'
import FilesList from './files_list.vue'

import { Editor, EditorContent, EditorMenuBar, EditorMenuBubble } from 'tiptap'

import {
  Blockquote,
  CodeBlock,
  HardBreak,
  Heading,
  HorizontalRule,
  OrderedList,
  BulletList,
  ListItem,
  Table,
  TableHeader,
  TableCell,
  TableRow,
  TodoList,
  Bold,
  Code,
  Italic,
  Link,
  Strike,
  Underline,
  History,
  Mention,
  Placeholder } from 'tiptap-extensions'

import TodoItem from './todo_item'

import { insertText } from 'tiptap-commands'
import Image from '@/shared/tiptap_extentions/image.js'

export default
  props:
    model: Object
    field: String
    label: String
    placeholder: String
    shouldReset: Boolean
    autoFocus:
      type: Boolean
      default: false

  components:
    EditorContent: EditorContent
    EditorMenuBar: EditorMenuBar
    FilesList: FilesList
    EditorMenuBubble: EditorMenuBubble

  data: ->
    query: null
    suggestionRange: null
    files: []
    imageFiles: []
    mentionableUserIds: []
    navigatedUserIndex: 0
    closeEmojiMenu: false
    linkUrl: null
    linkDialogIsOpen: false
    insertMention: () => {}
    editor: new Editor
      extensions: [
        new Mention(
          # is called when a suggestion starts
          onEnter: ({ query, range, command, virtualNode }) =>
            @query = query
            @suggestionRange = range
            @insertMention = command
            @renderPopup(virtualNode)
            @fetchMentionable()

          # is called when a suggestion has changed
          onChange: ({query, range, virtualNode}) =>
            @query = query
            @suggestionRange = range
            @navigatedUserIndex = 0
            @renderPopup(virtualNode)
            @fetchMentionable()

          # is called when a suggestion is cancelled
          onExit: =>
            @query = null
            @suggestionRange = null
            @navigatedUserIndex = 0
            @destroyPopup()

          # is called on every keyDown event while a suggestion is active
          onKeyDown: ({ event }) =>
            # pressing up arrow
            if (event.keyCode == 38)
              @upHandler()
              return true

            # pressing down arrow
            if (event.keyCode == 40)
              @downHandler()
              return true

            # pressing enter
            if (event.keyCode == 13)
              @enterHandler()
              return true

            return false
        ),

        new Blockquote(),
        new BulletList(),
        new CodeBlock(),
        new HardBreak(),
        new Image({attachFile: @attachFile, attachImageFile: @attachImageFile}),
        new Heading({ levels: [1, 2, 3] }),
        new HorizontalRule(),
        new ListItem(),
        new OrderedList(),
        new TodoItem(),
        new TodoList(),
        new Table(),
        new TableHeader(),
        new TableCell(),
        new TableRow(),
        new Bold(),
        new Code(),
        new Italic(),
        new Link(),
        new Strike(),
        new Underline(),
        new History(),
        new Placeholder({
          emptyClass: 'is-empty',
          emptyNodeText: @placeholder,
          showOnlyWhenEditable: true,
        })
      ]
      content: @model[@field]
      onUpdate: @updateModel
      autoFocus: @autoFocus

  computed:
    hasResults: -> @filteredUsers.length
    showSuggestions: -> @query || @hasResults
    filteredUsers: ->
      unsorted = filter Records.users.collection.chain().find(@mentionableUserIds).data(), (u) =>
        isString(u.username) &&
        ((u.name || '').toLowerCase().startsWith(@query) or
        (u.username || '').toLowerCase().startsWith(@query) or
        (u.name || '').toLowerCase().includes(" #{@query}"))
      sortBy(unsorted, (u) -> (0 - Records.events.find(actorId: u.id).length))
    format: ->
      @model["#{@field}Format"]

  mounted: ->
    @model.files = []
    @model.imageFiles = []

  methods:
    setLinkUrl: (command) ->
      command({ href: @linkUrl })
      @linkUrl = null
      @linkDialogIsOpen = false
      @editor.focus()

    emitUploading: ->
      @$emit('is-uploading', !(@model.files.length == @files.length && @model.imageFiles.length == @imageFiles.length))

    emojiPicked: (shortcode, unicode) ->
      { view } = this.editor
      insertText(unicode)(view.state, view.dispatch, view)
      @closeEmojiMenu = false
      @editor.focus()

    updateModel: ->
      @model[@field] = @editor.getHTML()
      @model.files = @files.filter((w) => w.blob).map (wrapper) => wrapper.blob.signed_id
      @model.imageFiles = @imageFiles.filter((w) => w.blob).map (wrapper) => wrapper.blob.signed_id
      @emitUploading()

    removeFile: (name) ->
      @files = filter @files, (wrapper) -> wrapper.file.name != name

    attachFile: ({file}) ->
      wrapper = {file: file, key: file.name+file.size, percentComplete: 0, blob: null}
      @files.push(wrapper)
      @emitUploading()
      uploader = new FileUploader onProgress: (e) ->
        wrapper.percentComplete = parseInt(e.loaded / e.total * 100)

      uploader.upload(file).then (blob) =>
        wrapper.blob = blob
        @updateModel()
      ,
      (e) ->
        console.log "attachment failed to upload"

    attachImageFile: ({file, onProgress, onComplete, onFailure}) ->
      wrapper = {file: file, blob: null}
      @imageFiles.push(wrapper)
      @emitUploading()
      uploader = new FileUploader onProgress: onProgress
      uploader.upload(file).then((blob) =>
        wrapper.blob = blob
        onComplete(blob)
        @updateModel()
      , onFailure)

    fileSelected: ->
      forEach(@$refs.filesField.files, (file) => @attachFile(file: file))

    fetchMentionable: ->
      Records.users.fetchMentionable(@query, @model).then (response) =>
        @mentionableUserIds.concat(uniq @mentionableUserIds + map(response.users, 'id'))

    # mentioning methods
    upHandler: ->
      @navigatedUserIndex = ((@navigatedUserIndex + @filteredUsers.length) - 1) % @filteredUsers.length

    downHandler: ->
      @navigatedUserIndex = (@navigatedUserIndex + 1) % @filteredUsers.length

    enterHandler: ->
      user = @filteredUsers[@navigatedUserIndex]
      @selectUser(user) if user

    selectUser: (user) ->
      @insertMention
        range: @suggestionRange
        attrs:
          id: user.id,
          label: user.name
      @editor.focus()

    renderPopup: (node) ->
      return if @popup
      @popup = tippy(node, {
        content: @$refs.suggestions,
        trigger: 'mouseenter',
        interactive: true,
        theme: 'dark',
        placement: 'top-start',
        inertia: true,
        duration: [400, 200],
        showOnInit: true,
        arrow: true,
        arrowType: 'round'
      })

      if MutationObserver
        @observer = new MutationObserver => @popup.popperInstance.scheduleUpdate()
        @observer.observe(@$refs.suggestions, {
          childList: true,
          subtree: true,
          characterData: true,
        })

    destroyPopup: ->
      if @popup
        @popup.destroy()
        @popup = null
      @observer.disconnect() if @observer

  watch:
    files: -> @updateModel()
    imageFiles: -> @updateModel()
    shouldReset: ->
      @editor.clearContent()
      @files = []
      @imageFiles = []

  beforeDestroy: ->
    @editor.destroy()
</script>

<template lang="pug">
div
  label.caption.v-label.v-label--active.theme--light {{label}}
  div(v-if="format == 'md'")
    v-textarea(lmo_textarea v-model="model[field]" :placeholder="$t('comment_form.say_something')")
    v-layout
      v-spacer
      slot(name="actions")
  .editor.mb-3(v-if="format == 'html'")
    editor-content.editor__content(:editor='editor').lmo-markdown-wrapper
    editor-menu-bubble(:editor='editor' v-slot='{ commands, isActive, menu }')
      .menububble(:class="{'is-active': menu.isActive}" :style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`")
        v-btn(small icon :class="{ 'is-active': isActive.bold() }", @click='commands.bold')
          v-icon mdi-format-bold
        v-btn(small icon :class="{ 'is-active': isActive.italic() }", @click='commands.italic')
          v-icon mdi-format-italic
        v-btn(small icon :class="{ 'is-active': isActive.strike() }", @click='commands.strike')
          v-icon mdi-format-strikethrough
        v-btn(small icon :class="{ 'is-active': isActive.underline() }", @click='commands.underline')
          v-icon mdi-format-underline
        //- v-btn(small icon :class="{ 'is-active': isActive.code() }", @click='commands.code')
        //-   v-icon mdi-code-tags
        v-dialog(v-model="linkDialogIsOpen" max-width="600px")
          template(v-slot:activator="{on}")
            v-btn(small icon v-on="on")
              v-icon mdi-link-variant
          v-card
            v-card-title.title(v-t="'text_editor.insert_link'")
            v-card-text
              v-text-field(type="url" label="https://www.example.com" v-model="linkUrl")
            v-card-actions
              v-spacer
              v-btn(color="primary" @click="setLinkUrl(commands.link)" v-t="'common.action.apply'")

    editor-menu-bar(:editor='editor' v-slot='{ commands, isActive, focused }')
      v-layout.menubar(wrap align-center)
        span
          v-menu(:close-on-content-click="false" v-model="closeEmojiMenu")
            template(v-slot:activator="{on}")
              v-btn.emoji-picker__toggle(v-on="on" small icon :class="{ 'is-active': isActive.underline() }")
                v-icon mdi-emoticon-outline
            emoji-picker(:insert="emojiPicked")
          v-btn(icon :class="{ 'is-active': isActive.underline() }", @click='$refs.filesField.click()')
            v-icon mdi-paperclip
          v-btn(icon :class="{ 'is-active': isActive.heading({ level: 1 }) }", @click='commands.heading({ level: 1 })')
            v-icon mdi-format-header-1
          v-btn(icon :class="{ 'is-active': isActive.heading({ level: 2 }) }", @click='commands.heading({ level: 2 })')
            v-icon mdi-format-header-2
          v-btn(icon :class="{ 'is-active': isActive.heading({ level: 3 }) }", @click='commands.heading({ level: 3 })')
            v-icon mdi-format-header-3
          v-btn(icon :class="{ 'is-active': isActive.bullet_list() }", @click='commands.bullet_list')
            v-icon mdi-format-list-bulleted
          v-btn(icon :class="{ 'is-active': isActive.ordered_list() }", @click='commands.ordered_list')
            v-icon mdi-format-list-numbered
          v-btn(icon @click='commands.todo_list')
            v-icon mdi-format-list-checks
          v-btn(small icon :class="{ 'is-active': isActive.blockquote() }", @click='commands.blockquote')
            v-icon mdi-format-quote-close
          v-btn(small icon :class="{ 'is-active': isActive.code_block() }", @click='commands.code_block')
            v-icon mdi-code-braces
          v-btn(icon @click='commands.horizontal_rule')
            v-icon mdi-minus
          v-btn(icon @click="commands.createTable({rowsCount: 3, colsCount: 3, withHeaderRow: false })")
            v-icon mdi-table
          span(v-if="isActive.table()")
            v-btn(icon @click="commands.deleteTable")
              v-icon mdi-table-remove
            v-btn(icon @click="commands.addColumnBefore")
              v-icon mdi-table-column-plus-before
            v-btn(icon @click="commands.addColumnAfter")
              v-icon mdi-table-column-plus-after
            v-btn(icon @click="commands.deleteColumn")
              v-icon mdi-table-column-remove
            v-btn(icon @click="commands.addRowBefore")
              v-icon mdi-table-row-plus-before
            v-btn(icon @click="commands.addRowAfter")
              v-icon mdi-table-row-plus-after
            v-btn(icon @click="commands.deleteRow")
              v-icon mdi-table-row-remove
            v-btn(icon @click="commands.toggleCellMerge")
              v-icon mdi-table-merge-cells
        v-layout
          v-spacer
          slot(name="actions")

  .suggestion-list(v-show='showSuggestions', ref='suggestions')
    template(v-if='hasResults')
      .suggestion-list__item(v-for='(user, index) in filteredUsers', :key='user.id', :class="{ 'is-selected': navigatedUserIndex === index }", @click='selectUser(user)')
        | {{ user.name }}
    .suggestion-list__item.is-empty(v-else) No users found

  files-list(:files="files" v-on:removeFile="removeFile")

  form(style="display: block" @change="fileSelected")
    input(ref="filesField" type="file" name="files" multiple=true)
</template>

<style lang="sass">
.menububble.is-hidden
  visibility: hidden
  opacity: 0

.menububble.is-active
  visibility: visible
  opacity: 1

.menububble
  position: absolute
  display: flex
  z-index: 20
  background: #fff
  border: 1px solid #ccc
  border-radius: 5px
  padding: .3rem
  margin-bottom: .5rem
  transform: translateX(-50%)
  visibility: hidden
  opacity: 0
  transition: opacity .2s,visibility .2s

.lmo-markdown-wrapper
  h1
    line-height: 3.5rem
    font-size: 1.75rem
    font-weight: 400
    letter-spacing: .0125em

  h2
    line-height: 2.5rem
    font-size: 1.25rem
    font-weight: 500
    letter-spacing: .0125em

  h3
    line-height: 2.5rem
    font-size: 1rem
    font-weight: 700
    letter-spacing: .009375em

  p
    margin-bottom: 12px

  hr
    border: 0
    border-bottom: 2px solid rgba(0,0,0,0.1)
    margin: 16px 0

  word-wrap: break-word

  img
    max-width: 100%

  ol, ul
    padding-left: 24px
    margin-bottom: 16px
    ul
      margin-bottom: 0

  ul
    list-style: disc

  ol
    list-style: decimal

  li p
    margin-bottom: 0

  pre
    overflow: auto
    padding: 10px

  blockquote
    font-style: italic
    border-left: 3px solid rgba(0,0,0,.1)
    color: rgba(0,0,0,.8)
    padding-left: .8rem

  table
    table-layout: fixed
    width: 100%
    margin-bottom: 10px

  table td
    padding: 6px 13px
    border: 1px solid #ddd

  thead td
    font-weight: bold

progress
  -webkit-appearance: none
  appearance: none
  background-color: #fff
  border: 1px solid #ccc

progress::-webkit-progress-bar
  background-color: #fff
  border: 1px solid #ccc

progress::-webkit-progress-value
  background-color: lightblue
  border: 0
  transition: width 120ms ease-out, opacity 60ms 60ms ease-in

progress::-moz-progress-bar
  background-color: lightblue
  border: 0
  transition: width 120ms ease-out, opacity 60ms 60ms ease-in

.menubar
  position: sticky
  bottom: 0
  background-color: #fff

.menubar, .menububble
  .v-btn--icon
    width: 32px
    height: 32px

  .v-btn
    min-width: 0
    margin-left: 0
    margin-right: 0
    .v-icon
      font-size: 16px

.ProseMirror
  border-bottom: 1px solid #999
  padding: 4px 0px
  margin: 4px 0px
  outline: none
  overflow-y: scroll

.ProseMirror:focus
  border-bottom: 2px solid var(--v-primary-base)

.ProseMirror img
  display: block

.ProseMirror progress
  // display: block;

ul[data-type="todo_list"]
  padding-left: 0
li[data-type="todo_item"]
  display: flex
  flex-direction: row

.todo-checkbox
  border: 1px solid #999
  height: 1em
  width: 1em
  box-sizing: border-box
  margin-right: 8px
  margin-top: 4px
  user-select: none
  border-radius: 0.2em
  background-color: transparent

.ProseMirror .todo-checkbox
  cursor: pointer

.todo-content
  flex: 1
  > p:last-of-type
    margin-bottom: 0
  > ul[data-type="todo_list"]
    margin: .5rem 0
  p
    margin: 0

li[data-done="true"]
  > .todo-content
    > p
      text-decoration: line-through
  > .todo-checkbox::before
    position: relative
    top: -7px
    color: var(--v-primary-base)
    font-size: 1.3rem
    content: "✓"

li[data-done="false"]
  text-decoration: none

.editor p.is-empty:first-child::before
  content: attr(data-empty-text)
  float: left
  color: #aaa
  pointer-events: none
  height: 0

.editor p.is-empty
  font-size: 16px
  padding-bottom: 16px

.mention
  background: rgba(#ffb300, 0.3)
  border-radius: 5px
  padding: 0.2rem 0.5rem
  white-space: nowrap

.mention-suggestion
  color: rgba(#000, 0.6)

.suggestion-list
  padding: 0.2rem
  border: 2px solid rgba(#000, 0.1)
  &__no-results
    padding: 0.2rem 0.5rem
  &__item
    font-family: roboto, sans-serif
    border-radius: 5px
    padding: 0.2rem 0.5rem
    margin-bottom: 0.2rem
    cursor: pointer
    &:last-child
      margin-bottom: 0
    &.is-selected, &:hover
      background-color: rgba(#fff, 0.2)
    &.is-empty
      opacity: 0.5

.tippy-tooltip.dark-theme
  background-color: #000
  padding: 0
  font-size: 1rem
  text-align: inherit
  color: #fff
  border-radius: 5px
  .tippy-backdrop
    display: none
  .tippy-roundarrow
    fill: #000
  .tippy-popper[x-placement^=top] & .tippy-arrow
    border-top-color: #000
  .tippy-popper[x-placement^=bottom] & .tippy-arrow
    border-bottom-color: #000
  .tippy-popper[x-placement^=left] & .tippy-arrow
    border-left-color: #000
  .tippy-popper[x-placement^=right] & .tippy-arrow
    border-right-color: #000

input[type="file"]
  display: none
</style>


fix: use the `InputAddress` component in the transfer form (#311)

<template>
  <form
    class="flex flex-col"
    @submit.prevent="onSubmit"
  >
    <InputAddress
      v-model="$v.form.recipientId.$model"
      :label="$t('TRANSACTION.RECIPIENT')"
      :pub-key-hash="currentNetwork.version"
      name="recipientId"
      class="mb-5"
    />

      <InputText
        v-model="$v.form.amount.$model"
        :is-invalid="$v.form.amount.$dirty && $v.form.amount.$invalid"
        :label="$t('TRANSACTION.AMOUNT')"
        name="amount"
        class="flex-1 mr-3"
      />

      <button
        type="button"
        class="action-button"
        @click="onSendAll"
      >
        {{ $t('TRANSACTION.SEND_ALL') }}
      </button>

    <InputText
      v-model="$v.form.vendorField.$model"
      :is-invalid="$v.form.vendorField.$dirty && $v.form.vendorField.$invalid"
      :label="$t('TRANSACTION.VENDOR_FIELD')"
      name="vendorField"
      class="mb-5"
    />

    <InputFee
      v-if="currentNetwork.apiVersion === 2"
      v-model="$v.form.fee.$model"
      :transaction-type="$options.transactionType"
      @input="onFee"
    />

    <PassphraseInput
      ref="passphrase"
      v-model="$v.form.passphrase.$model"
      :address="$v.form.recipientId.$model"
      :pub-key-hash="currentNetwork.version"
      class="mb-10"
    />

      <button
        :disabled="$v.form.$invalid"
        class="blue-button"
        type="button"
      >
        {{ $t('COMMON.NEXT') }}
      </button>

  </form>
</template>

<template>
  <form
    class="flex flex-col"
    @submit.prevent="onSubmit"
  >
    <InputText
      v-model="$v.form.recipientId.$model"
      :is-invalid="$v.form.recipientId.$dirty && $v.form.recipientId.$invalid"
      :label="$t('TRANSACTION.RECIPIENT')"
      name="recipientId"
      class="mb-5"
    />

      <InputText
        v-model="$v.form.amount.$model"
        :is-invalid="$v.form.amount.$dirty && $v.form.amount.$invalid"
        :label="$t('TRANSACTION.AMOUNT')"
        name="amount"
        class="flex-1 mr-3"
      />

      <button
        type="button"
        class="action-button"
        @click="onSendAll"
      >
        {{ $t('TRANSACTION.SEND_ALL') }}
      </button>

    <InputText
      v-model="$v.form.vendorField.$model"
      :is-invalid="$v.form.vendorField.$dirty && $v.form.vendorField.$invalid"
      :label="$t('TRANSACTION.VENDOR_FIELD')"
      name="vendorField"
      class="mb-5"
    />

    <InputFee
      v-if="currentNetwork.apiVersion === 2"
      v-model="$v.form.fee.$model"
      :transaction-type="$options.transactionType"
      @input="onFee"
    />

    <PassphraseInput
      ref="passphrase"
      v-model="$v.form.passphrase.$model"
      :pub-key-hash="currentNetwork.version"
      class="mb-10"
    />

      <button
        :disabled="$v.form.$invalid"
        class="blue-button"
        type="button"
      >
        {{ $t('COMMON.NEXT') }}
      </button>

  </form>
</template>


Rewrite the sidebar to be fully responsive

<template>

    <router-link :to="dispatchLink" class="logo">

    </router-link>

      <icon name="bars" />

      <router-link class="action" :to="{ name: 'tournaments'}">

🔥

Tournaments

      </router-link>

      <router-link class="action" :to="{ name: 'archers'}">

🏹

Archers

      </router-link>

      <router-link v-if="user.isProducer" class="action" :to="{ name: 'admin'}">

💪

Superpowers

      </router-link>

          <icon name="cog"></icon>

          <icon name="sign-out"></icon>

{{user.nick}}

            <icon name="facebook"></icon>

Sign in

</template>

<template>

      <router-link :to="dispatchLink">

      </router-link>

          <router-link class="action"
            :to="{ name: 'tournaments'}">

              🔥

Tournaments

          </router-link>

          <router-link class="action"
            :to="{ name: 'archers'}">

              🏹

Archers

          </router-link>

          <router-link v-if="user.isProducer" class="action"
            :to="{ name: 'admin'}">

              💪

Superpowers

          </router-link>

Actions

            <router-link class="action"
              :to="{ name: 'new'}">

                <icon name="plus"></icon>

New tournament

            </router-link>

                <icon name="trash"></icon>

Clear test

          <icon name="cog"></icon>

          <icon name="sign-out"></icon>

{{user.nick}}

              <icon name="facebook"></icon>

Sign in

</template>


New TD: Update check in whole class concept flag (#467)

<script>
  import { mapGetters, mapActions, mapMutations } from 'vuex'
  import { COMPONENT_NAMES } from '../common/constants.js'
  import SecondaryTeacherNavigation from '../common/SecondaryTeacherNavigation'
  import TitleBar from '../common/TitleBar'
  import LoadingBar from '../common/LoadingBar'
  import Guidelines from './Guidelines'
  import ViewAndMange from './ViewAndManage'
  import TableClassFrame from './table/TableClassFrame'
  import ModalEditStudent from '../modals/ModalEditStudent'

  import utils from 'app/core/utils'
  import User from 'models/User'

  import _ from 'lodash'
  import TableStudentListVue from './table/TableStudentList.vue'

  export default {
    name: COMPONENT_NAMES.MY_CLASSES_SINGLE,
    components: {
      'secondary-teacher-navigation': SecondaryTeacherNavigation,
      'title-bar': TitleBar,
      'guidelines': Guidelines,
      'view-and-manage': ViewAndMange,
      'table-class-frame': TableClassFrame,
      'loading-bar': LoadingBar,
      ModalEditStudent
    },
    props: {
      classroomId: {
        type: String,
        default: '',
        required: true
      }
    },

    data: () => ({
      isGuidelinesVisible: true,
      sortMethod: 'Name'
    }),

    computed: {
      ...mapGetters({
        loading: 'teacherDashboard/getLoadingState',
        activeClassrooms: 'teacherDashboard/getActiveClassrooms',
        classroom: 'teacherDashboard/getCurrentClassroom',
        classroomCourses: 'teacherDashboard/getCoursesCurrentClassroom',
        selectedCourseId: 'teacherDashboard/getSelectedCourseIdCurrentClassroom',
        levelSessionsMapByUser: 'teacherDashboard/getLevelSessionsMapCurrentClassroom',
        getInteractiveSessionsForClass: 'interactives/getInteractiveSessionsForClass',
        classroomMembers: 'teacherDashboard/getMembersCurrentClassroom',
        gameContent: 'teacherDashboard/getGameContentCurrentClassroom',
        editingStudent: 'baseSingleClass/currentEditingStudent'
      }),

      modules () {
        const modules = (this.gameContent[this.selectedCourseId] || {}).modules

        if (modules === undefined) {
          return []
        }

        const modulesForTable = []

        // Get the name and content list of a module.
        for (const [moduleNum, moduleContent] of Object.entries(modules)) {
          const moduleDisplayName = `${this.$t(`teacher.module${moduleNum}`)}${utils.courseModules[this.selectedCourseId][moduleNum]}`
          const moduleStatsForTable = {
            displayName: moduleDisplayName,
            contentList: moduleContent.map(({ displayName, type, _id, name, ozariaType }) => {
              let normalizedType = type

              if (ozariaType) {
                if (ozariaType === 'challenge') {
                  normalizedType = 'challengelvl'
                } else if (ozariaType === 'practice') {
                  normalizedType = 'practicelvl'
                } else if (ozariaType === 'capstone') {
                  normalizedType = 'capstone'
                }
              } else {
                normalizedType = type;
              }

              if (!['cutscene', 'cinematic', 'capstone', 'interactive', 'practicelvl', 'challengelvl'].includes(normalizedType)) {
                throw new Error(`Didn't handle normalized content type: '${normalizedType}'`)
              }

              return ({
                displayName: displayName || name,
                type: normalizedType,
                _id,
                description: '' // TODO: Where do we store this?
              })
            }),
            studentSessions: {},
            classSummaryProgress: []
          }

          // Track summary stats to display in the header of the table
          const classSummaryProgressMap = new Map(moduleContent.map((content) => {
            return [content._id, { status: 'assigned', flagCount: 0 }]
          }))

          // Iterate over all the students and all the sessions for the student.
          for (const student of this.students) {
            const studentSessions = this.levelSessionsMapByUser[student._id] || {}
            const levelOriginalCompletionMap = {}

            for (const session of Object.values(studentSessions)) {
              levelOriginalCompletionMap[session.level.original] = session.state
            }

            moduleStatsForTable.studentSessions[student.displayName] = moduleContent.map((content) => {
              const { original, fromIntroLevelOriginal } = content
              let normalizedOriginal = original || fromIntroLevelOriginal
              const defaultProgressDot = {
                status: 'assigned',
                normalizedType: content.type
              }

              if (content.type === 'game-dev') {
                defaultProgressDot.normalizedType = 'capstone'
              } else if (content.type === undefined) {
                defaultProgressDot.normalizedType = 'practicelvl'
              } else if (content.type === 'course') {
                defaultProgressDot.normalizedType = 'practicelvl'
              }

              if (studentSessions === undefined) {
                return defaultProgressDot
              }

              if (normalizedOriginal) {
                if (!studentSessions[normalizedOriginal]) {
                  return defaultProgressDot
                } else if (studentSessions[normalizedOriginal].state.complete === true) {
                  defaultProgressDot.status = 'complete'
                  classSummaryProgressMap.get(content._id).status = defaultProgressDot.status
                } else {
                  defaultProgressDot.status = 'progress'
                }

                // Allows for incremental completion of an intro level.
                // We do not need to check if an interactive is in progress here.
                if (['interactive', 'cinematic', 'cutscene'].includes(defaultProgressDot.normalizedType)) {
                  defaultProgressDot.status = 'assigned'

                  if (levelOriginalCompletionMap[fromIntroLevelOriginal]?.introContentSessionComplete?.[content._id]?.complete) {
                    defaultProgressDot.status = 'complete'
                    classSummaryProgressMap.get(content._id).status = defaultProgressDot.status
                  }

                  // Preserve backwards compatability if there is no introContentSessionComplete property.
                  if (
                    levelOriginalCompletionMap[fromIntroLevelOriginal]?.complete === true
                    && defaultProgressDot.status !== 'complete'
                    && (levelOriginalCompletionMap[fromIntroLevelOriginal]?.introContentSessionComplete?.[content._id]) === undefined
                  ) {
                    defaultProgressDot.status = 'complete'
                  }

                  if (classSummaryProgressMap.get(content._id) !== 'complete' && defaultProgressDot.status !== 'assigned') {
                    classSummaryProgressMap.get(content._id).status = defaultProgressDot.status
                  }
                }

                // Figure out if concept flag needs to be set on an interactive,
                // and whether an interactive is in progress.
                if (defaultProgressDot.normalizedType === 'interactive') {
                  const interactiveSession = this.getInteractiveSessionsForClass(this.classroomId)?.[student._id]?.[content._id]
                  if (interactiveSession !== undefined) {
                    const dateFirstCompleted = interactiveSession.dateFirstCompleted || undefined
                    let submissionsBeforeCompletion = []

                    // Makes sure to mark interactives for which we have sessions. We would have
                    // already marked for completion earlier. Here we are checking for progress.
                    if (defaultProgressDot.status === 'assigned') {
                      defaultProgressDot.status = 'progress'
                    }

                    if (dateFirstCompleted) {
                      submissionsBeforeCompletion = interactiveSession.submissions.filter((s) => new Date(s.submissionDate).getTime() <= new Date(dateFirstCompleted).getTime()) || []
                    } else {
                      submissionsBeforeCompletion = interactiveSession.submissions || []
                    }

                    if (submissionsBeforeCompletion.length >= 3) {
                      // Used by TableModuleGrid file to assign a border on the session.
                      defaultProgressDot.flag = 'concept'
                      classSummaryProgressMap.get(content._id).flagCount += 1
                    }
                  } else {
                    // If there are no interactive sessions we ensure no progress is shown.
                    // This check is requires because with backwards compatibility we cannot have
                    // an interactive without sessions be shown as assigned or complete.
                    defaultProgressDot.status === 'assigned'
                  }
                }

                // Level types that teacher can open TeacherDashboardPanel on.
                // We also need to make sure that teachers can only click if a session exists.
                if (['practicelvl', 'capstone', 'interactive'].includes(defaultProgressDot.normalizedType) && defaultProgressDot.status !== 'assigned') {
                  defaultProgressDot.clickHandler = () => {
                    this.showPanelSessionContent({
                      student: student,
                      classroomId: this.classroomId, // TODO remove and use classroomId from teacherDashboard vuex
                      selectedCourseId: this.selectedCourseId,
                      moduleNum: moduleNum,
                      contentId: content._id
                    })
                  }

                  if (classSummaryProgressMap.get(content._id) !== 'complete' && defaultProgressDot.status !== 'assigned') {
                    classSummaryProgressMap.get(content._id).status = defaultProgressDot.status
                  }

                  defaultProgressDot.selectedKey = `${student._id}_${content._id}`
                }
              } else {
                console.error(`Invariant violated: Content has neither original nor _id: ${content}`)
              }
              return defaultProgressDot
            })
          }

          moduleStatsForTable.classSummaryProgress = Array.from(classSummaryProgressMap.values())
            .map(({ status, flagCount }) => ({
              status,
              border: flagCount >= (this.classroomMembers?.length || 1) / 2 ? 'red' : ''
            }))

          modulesForTable.push(moduleStatsForTable)
        }

        return modulesForTable
      },

      students () {
        if (!this.classroomMembers || this.classroomMembers.length === 0) {
          return []
        }

        const modules = (this.gameContent[this.selectedCourseId] || {}).modules
        if (!modules) {
          return []
        }

        const students = this.classroomMembers.map(userObj => {
          const isEnrolled = (new User(userObj)).isEnrolled()
          return {
            displayName: userObj.name,
            _id: userObj._id,
            isEnrolled
          }
        })

        // Sort based on table view options.
        // We count the number of completed sessions here before using the student list elsewhere.
        // The student array is a dependency for other functions, and needs to be ordered prior
        // to other calculations occuring.
        if (this.sortMethod === 'Name') {
          return students
        } else {
          const originalsInModule = Object.values(modules).flat().map(({ fromIntroLevelOriginal, original }) => fromIntroLevelOriginal || original)
          const studentProgression = new Map(students.map(({ _id }) => ([_id, 0])))

          for (const { _id } of students) {
            let completedCount = 0;
            for (const original of originalsInModule) {
              if (this.levelSessionsMapByUser[_id]?.[original]?.state.complete === true) {
                completedCount ++;
              }
            }
            studentProgression.set(_id, completedCount)
          }

          students.sort((a, b) => {
            return studentProgression.get(b._id) - studentProgression.get(a._id)
          })
          if (this.sortMethod === 'Progress (reversed)') {
            students.reverse()
          }

          return students
        }
        return []
      }
    },

    watch: {
      classroomId (newId) {
        this.setClassroomId(newId)
        this.fetchData()
      }
    },

    mounted () {
      this.setTeacherId(me.get('_id'))
      this.setClassroomId(this.classroomId)
      this.fetchData()
    },

    beforeRouteUpdate (to, from, next) {
      this.closePanel()
      this.clearSelectedStudents()
      next()
    },

    beforeRouteLeave (to, from, next) {
      this.closePanel()
      next()
    },

    destroyed () {
      this.resetLoadingState()
    },

    methods: {
      ...mapActions({
        fetchData: 'baseSingleClass/fetchData',
        setPanelSessionContent: 'teacherDashboardPanel/setPanelSessionContent',
        showPanelSessionContent: 'teacherDashboardPanel/showPanelSessionContent',
        clearSelectedStudents: 'baseSingleClass/clearSelectedStudents',
        addStudentSelectedId: 'baseSingleClass/addStudentSelectedId'
      }),

      ...mapMutations({
        resetLoadingState: 'teacherDashboard/resetLoadingState',
        setTeacherId: 'teacherDashboard/setTeacherId',
        setClassroomId: 'teacherDashboard/setClassroomId',
        setSelectedCourseId: 'teacherDashboard/setSelectedCourseIdCurrentClassroom',
        closePanel: 'teacherDashboardPanel/closePanel'
      }),

      onChangeCourse (courseId) {
        this.setSelectedCourseId({ courseId: courseId })
      },

      onChangeStudentSort (sortMethod) {
        this.sortMethod = sortMethod
      },

      clickGuidelineArrow: _.throttle(function () {
        this.isGuidelinesVisible = !this.isGuidelinesVisible
      }, 300),

      toggleAllStudents (event) {
        if (event.target.checked) {
          for (const { _id } of this.students) {
            this.addStudentSelectedId({ studentId: _id })
          }
        } else {
          this.clearSelectedStudents()
        }
      }
    }
  }
</script>

<template>
  <div>
    <secondary-teacher-navigation
      :classrooms="activeClassrooms"
    />
    <title-bar
      :title="classroom.name || ''"
      :show-class-info="true"
      :classroom="classroom"
      :courses="classroomCourses"
      :selected-course-id="selectedCourseId"
      @change-course="onChangeCourse"
    />
    <loading-bar
      :key="loading"
      :loading="loading"
    />
    <guidelines :visible="isGuidelinesVisible" v-on:click-arrow="clickGuidelineArrow" />
    <view-and-manage
      :arrow-visible="!isGuidelinesVisible"

      @change-sort-by="onChangeStudentSort"
      @click-arrow="clickGuidelineArrow"
      @assignContent="$emit('assignContent')"
      @addStudents="$emit('addStudents')"
      @removeStudents="$emit('removeStudents')"
    />

    <table-class-frame
      v-if="modules && students"
      :students="students"
      :modules="modules"

      @toggle-all-students="toggleAllStudents"
    />
    <modal-edit-student v-if="editingStudent" />
  </div>
</template>

<script>
  import { mapGetters, mapActions, mapMutations } from 'vuex'
  import { COMPONENT_NAMES } from '../common/constants.js'
  import SecondaryTeacherNavigation from '../common/SecondaryTeacherNavigation'
  import TitleBar from '../common/TitleBar'
  import LoadingBar from '../common/LoadingBar'
  import Guidelines from './Guidelines'
  import ViewAndMange from './ViewAndManage'
  import TableClassFrame from './table/TableClassFrame'
  import ModalEditStudent from '../modals/ModalEditStudent'

  import utils from 'app/core/utils'
  import User from 'models/User'

  import _ from 'lodash'
  import TableStudentListVue from './table/TableStudentList.vue'

  export default {
    name: COMPONENT_NAMES.MY_CLASSES_SINGLE,
    components: {
      'secondary-teacher-navigation': SecondaryTeacherNavigation,
      'title-bar': TitleBar,
      'guidelines': Guidelines,
      'view-and-manage': ViewAndMange,
      'table-class-frame': TableClassFrame,
      'loading-bar': LoadingBar,
      ModalEditStudent
    },
    props: {
      classroomId: {
        type: String,
        default: '',
        required: true
      }
    },

    data: () => ({
      isGuidelinesVisible: true,
      sortMethod: 'Name'
    }),

    computed: {
      ...mapGetters({
        loading: 'teacherDashboard/getLoadingState',
        activeClassrooms: 'teacherDashboard/getActiveClassrooms',
        classroom: 'teacherDashboard/getCurrentClassroom',
        classroomCourses: 'teacherDashboard/getCoursesCurrentClassroom',
        selectedCourseId: 'teacherDashboard/getSelectedCourseIdCurrentClassroom',
        levelSessionsMapByUser: 'teacherDashboard/getLevelSessionsMapCurrentClassroom',
        getInteractiveSessionsForClass: 'interactives/getInteractiveSessionsForClass',
        classroomMembers: 'teacherDashboard/getMembersCurrentClassroom',
        gameContent: 'teacherDashboard/getGameContentCurrentClassroom',
        editingStudent: 'baseSingleClass/currentEditingStudent'
      }),

      modules () {
        const modules = (this.gameContent[this.selectedCourseId] || {}).modules

        if (modules === undefined) {
          return []
        }

        const modulesForTable = []

        // Get the name and content list of a module.
        for (const [moduleNum, moduleContent] of Object.entries(modules)) {
          const moduleDisplayName = `${this.$t(`teacher.module${moduleNum}`)}${utils.courseModules[this.selectedCourseId][moduleNum]}`
          const moduleStatsForTable = {
            displayName: moduleDisplayName,
            contentList: moduleContent.map(({ displayName, type, _id, name, ozariaType }) => {
              let normalizedType = type

              if (ozariaType) {
                if (ozariaType === 'challenge') {
                  normalizedType = 'challengelvl'
                } else if (ozariaType === 'practice') {
                  normalizedType = 'practicelvl'
                } else if (ozariaType === 'capstone') {
                  normalizedType = 'capstone'
                }
              } else {
                normalizedType = type;
              }

              if (!['cutscene', 'cinematic', 'capstone', 'interactive', 'practicelvl', 'challengelvl'].includes(normalizedType)) {
                throw new Error(`Didn't handle normalized content type: '${normalizedType}'`)
              }

              return ({
                displayName: displayName || name,
                type: normalizedType,
                _id,
                description: '' // TODO: Where do we store this?
              })
            }),
            studentSessions: {},
            classSummaryProgress: []
          }

          // Track summary stats to display in the header of the table
          const classSummaryProgressMap = new Map(moduleContent.map((content) => {
            return [content._id, { status: 'assigned', flagCount: 0 }]
          }))

          // Iterate over all the students and all the sessions for the student.
          for (const student of this.students) {
            const studentSessions = this.levelSessionsMapByUser[student._id] || {}
            const levelOriginalCompletionMap = {}

            for (const session of Object.values(studentSessions)) {
              levelOriginalCompletionMap[session.level.original] = session.state
            }

            moduleStatsForTable.studentSessions[student.displayName] = moduleContent.map((content) => {
              const { original, fromIntroLevelOriginal } = content
              let normalizedOriginal = original || fromIntroLevelOriginal
              const defaultProgressDot = {
                status: 'assigned',
                normalizedType: content.type
              }

              if (content.type === 'game-dev') {
                defaultProgressDot.normalizedType = 'capstone'
              } else if (content.type === undefined) {
                defaultProgressDot.normalizedType = 'practicelvl'
              } else if (content.type === 'course') {
                defaultProgressDot.normalizedType = 'practicelvl'
              }

              if (studentSessions === undefined) {
                return defaultProgressDot
              }

              if (normalizedOriginal) {
                if (!studentSessions[normalizedOriginal]) {
                  return defaultProgressDot
                } else if (studentSessions[normalizedOriginal].state.complete === true) {
                  defaultProgressDot.status = 'complete'
                  classSummaryProgressMap.get(content._id).status = defaultProgressDot.status
                } else {
                  defaultProgressDot.status = 'progress'
                }

                // Allows for incremental completion of an intro level.
                // We do not need to check if an interactive is in progress here.
                if (['interactive', 'cinematic', 'cutscene'].includes(defaultProgressDot.normalizedType)) {
                  defaultProgressDot.status = 'assigned'

                  if (levelOriginalCompletionMap[fromIntroLevelOriginal]?.introContentSessionComplete?.[content._id]?.complete) {
                    defaultProgressDot.status = 'complete'
                    classSummaryProgressMap.get(content._id).status = defaultProgressDot.status
                  }

                  // Preserve backwards compatability if there is no introContentSessionComplete property.
                  if (
                    levelOriginalCompletionMap[fromIntroLevelOriginal]?.complete === true
                    && defaultProgressDot.status !== 'complete'
                    && (levelOriginalCompletionMap[fromIntroLevelOriginal]?.introContentSessionComplete?.[content._id]) === undefined
                  ) {
                    defaultProgressDot.status = 'complete'
                  }

                  if (classSummaryProgressMap.get(content._id) !== 'complete' && defaultProgressDot.status !== 'assigned') {
                    classSummaryProgressMap.get(content._id).status = defaultProgressDot.status
                  }
                }

                // Figure out if concept flag needs to be set on an interactive,
                // and whether an interactive is in progress.
                if (defaultProgressDot.normalizedType === 'interactive') {
                  const interactiveSession = this.getInteractiveSessionsForClass(this.classroomId)?.[student._id]?.[content._id]
                  if (interactiveSession !== undefined) {
                    const dateFirstCompleted = interactiveSession.dateFirstCompleted || undefined
                    let submissionsBeforeCompletion = []

                    // Makes sure to mark interactives for which we have sessions. We would have
                    // already marked for completion earlier. Here we are checking for progress.
                    if (defaultProgressDot.status === 'assigned') {
                      defaultProgressDot.status = 'progress'
                    }

                    if (dateFirstCompleted) {
                      submissionsBeforeCompletion = interactiveSession.submissions.filter((s) => new Date(s.submissionDate).getTime() <= new Date(dateFirstCompleted).getTime()) || []
                    } else {
                      submissionsBeforeCompletion = interactiveSession.submissions || []
                    }

                    if (submissionsBeforeCompletion.length >= 3) {
                      // Used by TableModuleGrid file to assign a border on the session.
                      defaultProgressDot.flag = 'concept'
                      classSummaryProgressMap.get(content._id).flagCount += 1
                    }
                  } else {
                    // If there are no interactive sessions we ensure no progress is shown.
                    // This check is requires because with backwards compatibility we cannot have
                    // an interactive without sessions be shown as assigned or complete.
                    defaultProgressDot.status === 'assigned'
                  }
                }

                // Level types that teacher can open TeacherDashboardPanel on.
                // We also need to make sure that teachers can only click if a session exists.
                if (['practicelvl', 'capstone', 'interactive'].includes(defaultProgressDot.normalizedType) && defaultProgressDot.status !== 'assigned') {
                  defaultProgressDot.clickHandler = () => {
                    this.showPanelSessionContent({
                      student: student,
                      classroomId: this.classroomId, // TODO remove and use classroomId from teacherDashboard vuex
                      selectedCourseId: this.selectedCourseId,
                      moduleNum: moduleNum,
                      contentId: content._id
                    })
                  }

                  if (classSummaryProgressMap.get(content._id) !== 'complete' && defaultProgressDot.status !== 'assigned') {
                    classSummaryProgressMap.get(content._id).status = defaultProgressDot.status
                  }

                  defaultProgressDot.selectedKey = `${student._id}_${content._id}`
                }
              } else {
                console.error(`Invariant violated: Content has neither original nor _id: ${content}`)
              }
              return defaultProgressDot
            })
          }

          moduleStatsForTable.classSummaryProgress = Array.from(classSummaryProgressMap.values())
            .map(({ status, flagCount }) => ({
              status,
              border: flagCount > (this.classroomMembers?.length || 1) / 2 ? 'red' : ''
            }))

          modulesForTable.push(moduleStatsForTable)
        }

        return modulesForTable
      },

      students () {
        if (!this.classroomMembers || this.classroomMembers.length === 0) {
          return []
        }

        const modules = (this.gameContent[this.selectedCourseId] || {}).modules
        if (!modules) {
          return []
        }

        const students = this.classroomMembers.map(userObj => {
          const isEnrolled = (new User(userObj)).isEnrolled()
          return {
            displayName: userObj.name,
            _id: userObj._id,
            isEnrolled
          }
        })

        // Sort based on table view options.
        // We count the number of completed sessions here before using the student list elsewhere.
        // The student array is a dependency for other functions, and needs to be ordered prior
        // to other calculations occuring.
        if (this.sortMethod === 'Name') {
          return students
        } else {
          const originalsInModule = Object.values(modules).flat().map(({ fromIntroLevelOriginal, original }) => fromIntroLevelOriginal || original)
          const studentProgression = new Map(students.map(({ _id }) => ([_id, 0])))

          for (const { _id } of students) {
            let completedCount = 0;
            for (const original of originalsInModule) {
              if (this.levelSessionsMapByUser[_id]?.[original]?.state.complete === true) {
                completedCount ++;
              }
            }
            studentProgression.set(_id, completedCount)
          }

          students.sort((a, b) => {
            return studentProgression.get(b._id) - studentProgression.get(a._id)
          })
          if (this.sortMethod === 'Progress (reversed)') {
            students.reverse()
          }

          return students
        }
        return []
      }
    },

    watch: {
      classroomId (newId) {
        this.setClassroomId(newId)
        this.fetchData()
      }
    },

    mounted () {
      this.setTeacherId(me.get('_id'))
      this.setClassroomId(this.classroomId)
      this.fetchData()
    },

    beforeRouteUpdate (to, from, next) {
      this.closePanel()
      this.clearSelectedStudents()
      next()
    },

    beforeRouteLeave (to, from, next) {
      this.closePanel()
      next()
    },

    destroyed () {
      this.resetLoadingState()
    },

    methods: {
      ...mapActions({
        fetchData: 'baseSingleClass/fetchData',
        setPanelSessionContent: 'teacherDashboardPanel/setPanelSessionContent',
        showPanelSessionContent: 'teacherDashboardPanel/showPanelSessionContent',
        clearSelectedStudents: 'baseSingleClass/clearSelectedStudents',
        addStudentSelectedId: 'baseSingleClass/addStudentSelectedId'
      }),

      ...mapMutations({
        resetLoadingState: 'teacherDashboard/resetLoadingState',
        setTeacherId: 'teacherDashboard/setTeacherId',
        setClassroomId: 'teacherDashboard/setClassroomId',
        setSelectedCourseId: 'teacherDashboard/setSelectedCourseIdCurrentClassroom',
        closePanel: 'teacherDashboardPanel/closePanel'
      }),

      onChangeCourse (courseId) {
        this.setSelectedCourseId({ courseId: courseId })
      },

      onChangeStudentSort (sortMethod) {
        this.sortMethod = sortMethod
      },

      clickGuidelineArrow: _.throttle(function () {
        this.isGuidelinesVisible = !this.isGuidelinesVisible
      }, 300),

      toggleAllStudents (event) {
        if (event.target.checked) {
          for (const { _id } of this.students) {
            this.addStudentSelectedId({ studentId: _id })
          }
        } else {
          this.clearSelectedStudents()
        }
      }
    }
  }
</script>

<template>
  <div>
    <secondary-teacher-navigation
      :classrooms="activeClassrooms"
    />
    <title-bar
      :title="classroom.name || ''"
      :show-class-info="true"
      :classroom="classroom"
      :courses="classroomCourses"
      :selected-course-id="selectedCourseId"
      @change-course="onChangeCourse"
    />
    <loading-bar
      :key="loading"
      :loading="loading"
    />
    <guidelines :visible="isGuidelinesVisible" v-on:click-arrow="clickGuidelineArrow" />
    <view-and-manage
      :arrow-visible="!isGuidelinesVisible"

      @change-sort-by="onChangeStudentSort"
      @click-arrow="clickGuidelineArrow"
      @assignContent="$emit('assignContent')"
      @addStudents="$emit('addStudents')"
      @removeStudents="$emit('removeStudents')"
    />

    <table-class-frame
      v-if="modules && students"
      :students="students"
      :modules="modules"

      @toggle-all-students="toggleAllStudents"
    />
    <modal-edit-student v-if="editingStudent" />
  </div>
</template>


Truncate long groupnames in truncated item list

<template>

  <KEmptyPlaceholder v-if="!items.length" />

      {{ items[0] }}

      {{ $tr('twoItems', {item1: items[0], item2: items[1]}) }}

      {{ $tr('threeItems', {item1: items[0], item2: items[1], item3: items[2]}) }}

      {{ $tr('manyItems', {item1: items[0], item2: items[1], count: items.length - 2}) }}

</template>

<template>

  <KEmptyPlaceholder v-if="!items.length" />

    {{ items[0] }}

    {{ $tr('twoItems', {item1: items[0], item2: items[1]}) }}

    {{ $tr('threeItems', {item1: items[0], item2: items[1], item3: items[2]}) }}

    {{ $tr('manyItems', {item1: items[0], item2: items[1], count: items.length-2}) }}

</template>


tweaks to output formatting

tweaks to output formatting

<template>

    <template v-if="isError">

{{ $tr('importError') }}

          {{ message }}

    </template>

    <template v-else>

        {{ $tr('changesMade') }}

        {{ $tr('summary') }}

              {{ $tr('updated') }}

              {{ $tr('added') }}

              {{ $tr('deleted') }}

              {{ $tr('skipped') }}

            {{ $tr('users') }}

              {{ users_report.updated }}

              {{ users_report.created }}

              {{ users_report.deleted }}

              {{ numSkipped }}

            {{ $tr('classes') }}

              {{ classes_report.updated }}

              {{ classes_report.created }}

              {{ classes_report.cleared }}

      <template v-if="per_line_errors.length">

          {{ $tr('someSkipped') }}

          {{ $tr('someRowErrors') }}

              {{ $tr('rowNumber') }}
              {{ $tr('columnName') }}
              {{ $tr('value') }}
              {{ $tr('error') }}

              {{ obj.row }}
              {{ obj.field }}
              {{ obj.value }}
              {{ obj.message }}

      </template>

    </template>

    <template v-if="isFinished && !isError">
      <UiAlert :dismissible="false" type="success" style="margin-top: 16px;">
        {{ $tr('success') }}
      </UiAlert>

        <KButton
          :text="$tr('close')"
          appearance="raised-button"
          primary
          @click="$emit('done')"
        />

    </template>

      <KButton
        :text="$tr('back')"
        appearance="raised-button"
        style="margin-left: 0;"
        @click="reset"
      />

        <KButton
          :text="$tr('import')"
          appearance="raised-button"
          primary
          @click="startSavingUsers"
        />

</template>

<template>

    <template v-if="isError">

{{ $tr('importError') }}

          {{ message }}

    </template>

    <template v-else>
      <template v-if="isFinished">
        <UiAlert :dismissible="false" type="success">
          {{ $tr('success') }}
        </UiAlert>

{{ $tr('changesMade') }}

      </template>
      <template v-else>

{{ $tr('summary') }}

      </template>

              {{ $tr('updated') }}

              {{ $tr('added') }}

              {{ $tr('deleted') }}

              {{ $tr('skipped') }}

            {{ $tr('users') }}

              {{ users_report.updated }}

              {{ users_report.created }}

              {{ users_report.deleted }}

              {{ numSkipped }}

            {{ $tr('classes') }}

              {{ classes_report.updated }}

              {{ classes_report.created }}

              {{ classes_report.cleared }}

      <template v-if="per_line_errors.length">

          {{ $tr('someSkipped') }}

          {{ $tr('someRowErrors') }}

              {{ $tr('rowNumber') }}
              {{ $tr('columnName') }}
              {{ $tr('value') }}
              {{ $tr('error') }}

              {{ obj.row }}
              {{ obj.field }}
              {{ obj.value }}
              {{ obj.message }}

      </template>

    </template>

      <KButton
        :text="$tr('close')"
        appearance="raised-button"
        primary
        @click="$emit('done')"
      />

      <KButton
        :text="$tr('back')"
        appearance="raised-button"
        style="margin-left: 0;"
        @click="reset"
      />

        <KButton
          :text="$tr('import')"
          appearance="raised-button"
          primary
          @click="startSavingUsers"
        />

</template>


Fixed MUWM-4776 finals tab time when there are no finals

<template>

              {{ time.format('ha') }}

              {{ time.format('h:mma') }}

          <div v-for="day in Object.keys(period.daySlots)" :key="day"
               :aria-labelledby="`${day}-${period.id}`"
               role="group"
               class="day-column-desktop flex-even"
          >

              <div :id="`${day}-${period.id}`">
                {{ days[day] }}

                  {{ period.daySlots[day].format('MMM D') }}

 0
              )" class="d-flex"
              >
                <uw-course-section
                  v-for="(meetingData, j) in
                    meetingMap[day][formatToUnique(time)]"
                  :key="j" :meeting-data="meetingData"
                  :is-finals-card="isFinalsTab" :day="day"
                />

 0
            )" class="d-flex"
            >
              <uw-course-section
                v-for="(meetingData, j) in
                  meetingMap[mobile['current']][formatToUnique(time)]"
                :key="j" :meeting-data="meetingData"
                :is-finals-card="isFinalsTab" :day="mobile['current']"
              />

 0 && !isFinalsTab" class="mb-2">

        Meeting time notes:

      <uw-course-section
        v-for="(eosSection, i) in period.eosData" :key="i"
        :meeting-data="{section: eosSection}"
        :is-finals-card="false" class="d-inline-block w-auto mr-2"
      >

            ,

              {{ formatDate(meeting.eos_start_date) }}

                – {{ formatDate(meeting.eos_end_date) }}

              (Class does not meet)

              (Online learning)

                ({{ formatTime(meeting.start_time) }} –
                {{ formatTime(meeting.end_time) }})

      </uw-course-section>

 0">

        No meeting time specified:

        Courses with final exam meeting times to be determined or courses with
        no final exam:

        <uw-course-section :meeting-data="meeting" />

</template>


fix(img-lazy): alt tag was being removed when image loaded

<template>
    <b-img :src="computedSrc"
           :alt="alt"
           :blank="computedBlank"
           :blank-color="blankColor"
           :width="computedWidth"
           :height="computedHeight"
           :fluid="fluid"
           :fluid-grow="fluidGrow"
           :block="block"
           :thumbnail="thumbnail"
           :rounded="rounded"
           :left="left"
           :right="right"
           :center="center"
    ></b-img>
</template>

<script>
    import bImg from './img';
    import { isVisible, getBCR, eventOn, eventOff } from '../utils/dom';

    const THROTTLE = 100;

    export default {
        components: { bImg },
        data() {
            return {
              isShown: false,
              scrollTimeout: null
          }
        },
        props: {
            src: {
                type: String,
                default: null,
                rqeuired: true
            },
            alt: {
                type: String,
                default: null
            },
            width: {
                type: [Number, String],
                default: null
            },
            height: {
                type: [Number, String],
                default: null
            },
            blankSrc: {
                // If null, a blank image is generated
                type: String,
                default: null
            },
            blankColor: {
                type: String,
                default: 'transparent'
            },
            blankWidth: {
                type: [Number, String],
                default: null
            },
            blankHeight: {
                type: [Number, String],
                default: null
            },
            fluid: {
                type: Boolean,
                default: false
            },
            fluidGrow: {
                type: Boolean,
                default: false
            },
            block: {
                type: Boolean,
                default: false
            },
            thumbnail: {
                type: Boolean,
                default: false
            },
            rounded: {
                type: [Boolean, String],
                default: false
            },
            left: {
                type: Boolean,
                default: false
            },
            right: {
                type: Boolean,
                default: false
            },
            center: {
                type: Boolean,
                default: false
            },
            offset: {
                type: [Number, String],
                default: 360
            },
            throttle: {
                type: [Number, String],
                default: THROTTLE
            }
        },
        computed: {
            computedSrc() {
                return (!this.blankSrc || this.isShown) ? this.src : this.blankSrc;
            },
            computedBlank() {
                return (this.isShown || this.blankSrc) ? false : true;
            },
            computedWidth() {
                return this.isShown ? this.width : (this.blankWidth || this.width);
            },
            computedHeight() {
                return this.isShown ? this.height : (this.blankHeight || this.height);
            }
        },
        mounted() {
            this.setListeners(true);
            this.checkView();
        },
        activated() {
            this.setListeners(true);
            this.checkView();
        },
        deactivated() {
            this.setListeners(false);
        },
        beforeDdestroy() {
            this.setListeners(false);
        },
        methods: {
            setListeners(on) {
                clearTimeout(this.scrollTimer);
                this.scrollTimout = null;
                const root = window;
                if (on) {
                    eventOn(root, 'scroll', this.onScroll);
                    eventOn(root, 'resize', this.onScroll);
                    eventOn(root, 'orientationchange', this.onScroll);
                } else {
                    eventOff(root, 'scroll', this.onScroll);
                    eventOff(root, 'resize', this.onScroll);
                    eventOff(root, 'orientationchange', this.onScroll);
                }
            },
            checkView() {
                // check bounding box + offset to see if we should show
                if (!isVisible(this.$el)) {
                    // Element is hidden, so skip for now
                    return;
                }
                const offset = parseInt(this.offset,10) || 0;
                const docElement = document.documentElement;
                const view = {
                    l: 0 - offset,
                    t: 0 - offset,
                    b: docElement.clientHeight + offset,
                    r: docElement.clientWidth + offset
                };
                const box = getBCR(this.$el);
                if (box.right >= view.l && box.bottom >= view.t && box.left <= view.r && box.top <= view.b) {
                     // image is in view (or about to be in view)
                     this.isShown = true;
                     this.setListeners(false);
                }
            },
            onScroll() {
                if (this.isShown) {
                    this.setListeners(false);
                } else {
                    clearTimeout(this.scrollTimeout);
                    this.scrollTimeout = setTimeout(this.checkView, parseInt(this.throttle, 10) || THROTTLE);
                }
            }
        }
    };
</script>

<template>
    <b-img :src="computedSrc"
           :blank="computedBlank"
           :blank-color="blankColor"
           :width="computedWidth"
           :height="computedHeight"
           :fluid="fluid"
           :fluid-grow="fluidGrow"
           :block="block"
           :thumbnail="thumbnail"
           :rounded="rounded"
           :left="left"
           :right="right"
           :center="center"
    ></b-img>
</template>

<script>
    import bImg from './img';
    import { isVisible, getBCR, eventOn, eventOff } from '../utils/dom';

    const THROTTLE = 100;

    export default {
        components: { bImg },
        data() {
            return {
              isShown: false,
              scrollTimeout: null
          }
        },
        props: {
            src: {
                type: String,
                default: null,
                rqeuired: true
            },
            width: {
                type: [Number, String],
                default: null
            },
            height: {
                type: [Number, String],
                default: null
            },
            blankSrc: {
                // If null, a blank image is generated
                type: String,
                default: null
            },
            blankColor: {
                type: String,
                default: 'transparent'
            },
            blankWidth: {
                type: [Number, String],
                default: null
            },
            blankHeight: {
                type: [Number, String],
                default: null
            },
            fluid: {
                type: Boolean,
                default: false
            },
            fluidGrow: {
                type: Boolean,
                default: false
            },
            block: {
                type: Boolean,
                default: false
            },
            thumbnail: {
                type: Boolean,
                default: false
            },
            rounded: {
                type: [Boolean, String],
                default: false
            },
            left: {
                type: Boolean,
                default: false
            },
            right: {
                type: Boolean,
                default: false
            },
            center: {
                type: Boolean,
                default: false
            },
            offset: {
                type: [Number, String],
                default: 360
            },
            throttle: {
                type: [Number, String],
                default: THROTTLE
            }
        },
        computed: {
            computedSrc() {
                return (!this.blankSrc || this.isShown) ? this.src : this.blankSrc;
            },
            computedBlank() {
                return (this.isShown || this.blankSrc) ? false : true;
            },
            computedWidth() {
                return this.isShown ? this.width : (this.blankWidth || this.width);
            },
            computedHeight() {
                return this.isShown ? this.height : (this.blankHeight || this.height);
            }
        },
        mounted() {
            this.setListeners(true);
            this.checkView();
        },
        activated() {
            this.setListeners(true);
            this.checkView();
        },
        deactivated() {
            this.setListeners(false);
        },
        beforeDdestroy() {
            this.setListeners(false);
        },
        methods: {
            setListeners(on) {
                clearTimeout(this.scrollTimer);
                this.scrollTimout = null;
                const root = window;
                if (on) {
                    eventOn(root, 'scroll', this.onScroll);
                    eventOn(root, 'resize', this.onScroll);
                    eventOn(root, 'orientationchange', this.onScroll);
                } else {
                    eventOff(root, 'scroll', this.onScroll);
                    eventOff(root, 'resize', this.onScroll);
                    eventOff(root, 'orientationchange', this.onScroll);
                }
            },
            checkView() {
                // check bounding box + offset to see if we should show
                if (!isVisible(this.$el)) {
                    // Element is hidden, so skip for now
                    return;
                }
                const offset = parseInt(this.offset,10) || 0;
                const docElement = document.documentElement;
                const view = {
                    l: 0 - offset,
                    t: 0 - offset,
                    b: docElement.clientHeight + offset,
                    r: docElement.clientWidth + offset
                };
                const box = getBCR(this.$el);
                if (box.right >= view.l && box.bottom >= view.t && box.left <= view.r && box.top <= view.b) {
                     // image is in view (or about to be in view)
                     this.isShown = true;
                     this.setListeners(false);
                }
            },
            onScroll() {
                if (this.isShown) {
                    this.setListeners(false);
                } else {
                    clearTimeout(this.scrollTimeout);
                    this.scrollTimeout = setTimeout(this.checkView, parseInt(this.throttle, 10) || THROTTLE);
                }
            }
        }
    };
</script>


closing tag (span) matches nothing

closing tag (span) matches nothing

<template>

        <typeahead is-rounded
            source="core.search.index"
            :placeholder="__('Search')"
            :searching="__('Searching') + '..'"
            :no-results="__('Nothing found')"
            v-model="query"
            @update="redirect">
            <template slot="option"
                slot-scope="{ highlight, item }">

                        {{ __(item['group']) }}

                            <fa :icon="route.icon" size="sm"/>

            </template>
        </typeahead>

</template>


fix: improve link click tracking and proper link URLs for direct timetap linsk

<template>

    <page-parents-jumbotron :type="type" @cta-clicked="onCtaClicked"/>

{{ $t('parents_landing_2.live_classes') }}

{{ $t('parents_landing_2.learn_with_instructor') }}

{{ $t('parents_landing_2.live_classes_offered') }}

{{ $t('parents_landing_2.live_class_details_1') }}

{{ $t('parents_landing_2.live_class_details_2') }}

{{ $t('parents_landing_2.live_class_details_3') }}

{{ $t('parents_landing_2.live_class_details_4') }}

{{ $t('parents_landing_2.live_class_details_5') }}

            <button class="default-top-spacing" @click="onCtaClicked">
              {{ $t('parents_landing_2.try_free_class') }}
            </button>

{{ $t('parents_landing_2.small_group_classes') }}

{{ $t('parents_landing_2.small_group_classes_detail') }}

{{ $t('parents_landing_2.small_group_classes_price') }}

{{ $t('parents_landing_2.per_student') }}

                  {{ $t('parents_landing_2.choose_plan') }}

                <button v-else @click="onCtaClicked">
                  {{ $t('parents_landing_2.choose_plan') }}
                </button>

{{ $t('parents_landing_2.small_group_classes_detail_1') }}

{{ $t('parents_landing_2.small_group_classes_detail_2') }}

{{ $t('parents_landing_2.small_group_classes_detail_3') }}

{{ $t('parents_landing_2.small_group_classes_detail_4') }}

{{ $t('parents_landing_2.sibling_discount') }}

{{ $t('parents_landing_2.private_classes') }}

{{ $t('parents_landing_2.four_sessions_per_month') }}

{{ $t('parents_landing_2.four_private_classes_price') }}

{{ $t('parents_landing_2.per_student') }}

                  {{ $t('parents_landing_2.choose_plan') }}

                <button v-else @click="onCtaClicked">
                  {{ $t('parents_landing_2.choose_plan') }}
                </button>

{{ $t('parents_landing_2.private_classes_detail_1') }}

{{ $t('parents_landing_2.private_classes_detail_2') }}

{{ $t('parents_landing_2.private_classes_detail_3') }}

{{ $t('parents_landing_2.private_classes_detail_4') }}

                {{ $t('parents_landing_2.sibling_discount') }}

{{ $t('parents_landing_2.best_seller') }}

{{ $t('parents_landing_2.eight_sessions_per_month') }}

{{ $t('parents_landing_2.eight_private_classes_price') }}

{{ $t('parents_landing_2.per_student') }}

                  {{ $t('parents_landing_2.choose_plan') }}

                <button v-else @click="onCtaClicked">
                  {{ $t('parents_landing_2.choose_plan') }}
                </button>

{{ $t('parents_landing_2.private_classes_detail_1') }}

{{ $t('parents_landing_2.private_classes_detail_2') }}

{{ $t('parents_landing_2.private_classes_detail_3') }}

{{ $t('parents_landing_2.private_classes_detail_4') }}

                {{ $t('parents_landing_2.sibling_discount') }}

{{ $t('parents_landing_2.best_value') }}

    <page-parents-section-premium v-if="showPremium"/>

{{ $t('parents_landing_2.not_sure_kid') }}

{{ $t('parents_landing_2.share_trailer') }}

{{ $t('parents_landing_2.why_kids_love') }}

{{ $t('parents_landing_2.learn_through_play') }}

{{ $t('parents_landing_2.learn_through_play_detail') }}

{{ $t('parents_landing_2.skills_they_can_share') }}

{{ $t('parents_landing_2.skills_they_can_share_details') }}

{{ $t('parents_landing_2.help_when_needed') }}

{{ $t('parents_landing_2.help_when_needed_detail') }}

            <button @click="onCtaClicked">
              {{ $t('parents_landing_2.book_first_class') }}
            </button>

{{ $t('parents_landing_2.why_parents_love') }}

{{ $t('parents_landing_2.most_engaging') }}

                {{ $t('parents_landing_2.most_engaging_detail') }}

{{ $t('parents_landing_2.critical_skills') }}

                {{ $t('parents_landing_2.critical_skills_detail') }}

{{ $t('parents_landing_2.parent_support') }}

                {{ $t('parents_landing_2.parent_support_detail') }}

            <button @click="onCtaClicked">
              {{ $t('parents_landing_2.book_first_class') }}
            </button>

{{ $t('parents_landing_2.everything_they_need') }}

{{ $t('parents_landing_2.beginner_concepts') }}

{{ $t('parents_landing_2.beginner_concepts_1') }}

{{ $t('parents_landing_2.beginner_concepts_2') }}

{{ $t('parents_landing_2.beginner_concepts_3') }}

{{ $t('parents_landing_2.beginner_concepts_4') }}

{{ $t('parents_landing_2.beginner_concepts_5') }}

{{ $t('parents_landing_2.beginner_concepts_6') }}

{{ $t('parents_landing_2.intermediate_concepts') }}

{{ $t('parents_landing_2.intermediate_concepts_1') }}

{{ $t('parents_landing_2.intermediate_concepts_2') }}

{{ $t('parents_landing_2.intermediate_concepts_3') }}

{{ $t('parents_landing_2.intermediate_concepts_4') }}

{{ $t('parents_landing_2.intermediate_concepts_5') }}

{{ $t('parents_landing_2.intermediate_concepts_6') }}

{{ $t('parents_landing_2.advanced_concepts') }}

{{ $t('parents_landing_2.advanced_concepts_1') }}

{{ $t('parents_landing_2.advanced_concepts_2') }}

{{ $t('parents_landing_2.advanced_concepts_3') }}

{{ $t('parents_landing_2.advanced_concepts_4') }}

{{ $t('parents_landing_2.advanced_concepts_5') }}

{{ $t('parents_landing_2.advanced_concepts_6') }}

{{ $t('parents_landing_2.advanced_concepts_7') }}

          <button @click="onCtaClicked">{{ $t('parents_landing_2.get_started') }}</button>

{{ $t('parents_landing_2.quotes_title') }}

{{ $t('parents_landing_2.quote_1') }}

Francisco Navarro

{{ $t('parents_landing_2.parent') }}, Chicago, IL

{{ $t('parents_landing_2.quote_2') }}

Avery

{{ $t('parents_landing_2.student') }}, {{ $t('parents_landing_2.grade') }} 8

{{ $t('parents_landing_2.quote_3') }}

Alex Bain

Parent, Chicago, IL

{{ $t('parents_landing_2.quote_4') }}

Caleb S.

{{ $t('parents_landing_2.parent') }}, {{ $t('parents_landing_2.grade') }} 7

    <button class="sticky-footer" @click="onCtaClicked">{{ $t('parents_landing_2.book_first_class') }}</button>

</template>


Update searchInputValue to always be empty string for no value.

<template>

  <form
    class="search-box"
    @submit.prevent="updateSearchQuery"
    @keydown.esc.prevent="handleEscKey"
  >

      {{ coreString('searchLabel') }}
      <input
        :id="id"
        ref="searchInput"
        v-model.trim="newSearchTerm"
        type="search"
        class="search-input"
        :class="$computedClass(searchInputStyle)"
        dir="auto"
        :placeholder="coreString(placeholder)"
      >

        <KIconButton
          icon="clear"
          :color="$themeTokens.text"
          size="small"
          class="search-clear-button"
          :class="searchInputValue === '' ? '' : 'search-clear-button-visible'"
          :ariaLabel="coreString('clearAction')"
          @click="handleClickClear"
        />

          <KIconButton
            :icon="icon"
            color="white"
            class="search-submit-button"
            :disabled="searchBarDisabled"
            :class="{ 'rtl-icon': icon === 'forward' && isRtl }"
            :style="{ fill: $themeTokens.textInverted }"
            :ariaLabel="coreString('startSearchButtonLabel')"
            type="submit"
          />

  </form>

</template>


(SpacedRepetitionForm): fix validation classes, per Bootstrap 5

<template>

      Number of boxes

        How many boxes in total,
        excluding the leftmost box which contains New and Failed cards.

      <select
        id="srs_max_box"
        v-model="srs_max_box"
        name="opt_srs_max_box"
        class="form-select max-w-[10em]"
      >
        <option v-for="(o, i) in srs_max_box_values" :key="i" :value="o[0]">
          {{
            o[1]
          }}
        </option>
      </select>

      Review interval multiplier

        The multiplier determines the spacing between each successive review.
        The first interval is always 3 days.

        Intervals (days):
        {{ i.days }}

      <select id="srs_mult" v-model="srs_mult" name="opt_srs_mult" class="form-select max-w-[10em]">
        <option v-for="(o, i) in srs_mult_values" :key="i" :value="o[0]">{{ o[1] || o[0] }}</option>
      </select>

      Maximum box for cards marked 'Hard'

        Here, you can chose the maximum interval for a Hard answer by limiting
        the upper box. So for example if you chose to use 10 boxes and a Hard
        answer limit of 5 then a card in box 6,7,8,9 and 10 will always drop
        down to 5.

        Max interval for Hard answer:

          {{
            srs_hard_box > 0
              ? nthInterval(srs_hard_box) + " days"
              : "(default : drop card by one box, use the lower box interval)"
          }}

      <select
        id="srs_hard_box"
        v-model="srs_hard_box"
        name="opt_srs_hard_box"
        class="form-select max-w-[10em]"
        :class="{
          'is-invalid': !isValidHardBox
        }"
      >
        <option
          v-for="(o, index) in srs_hard_box_values"
          :key="index"
          :value="o[0]"
        >{{ o[1] || o[0] }}</option>
      </select>

        ^ Max Hard Box must be lower than the number of boxes total.

</template>

<template>

      Number of boxes

        How many boxes in total,
        excluding the leftmost box which contains New and Failed cards.

      <select
        id="srs_max_box"
        v-model="srs_max_box"
        name="opt_srs_max_box"
        class="form-select max-w-[10em]"
      >
        <option v-for="(o, i) in srs_max_box_values" :key="i" :value="o[0]">
          {{
            o[1]
          }}
        </option>
      </select>

      Review interval multiplier

        The multiplier determines the spacing between each successive review.
        The first interval is always 3 days.

        Intervals (days):
        {{ i.days }}

      <select id="srs_mult" v-model="srs_mult" name="opt_srs_mult" class="form-select max-w-[10em]">
        <option v-for="(o, i) in srs_mult_values" :key="i" :value="o[0]">{{ o[1] || o[0] }}</option>
      </select>

      Maximum box for cards marked 'Hard'

        Here, you can chose the maximum interval for a Hard answer by limiting
        the upper box. So for example if you chose to use 10 boxes and a Hard
        answer limit of 5 then a card in box 6,7,8,9 and 10 will always drop
        down to 5.

        Max interval for Hard answer:

          {{
            srs_hard_box > 0
              ? nthInterval(srs_hard_box) + " days"
              : "(default : drop card by one box, use the lower box interval)"
          }}

      <select
        id="srs_hard_box"
        v-model="srs_hard_box"
        name="opt_srs_hard_box"
        class="form-select max-w-[10em]"
      >
        <option
          v-for="(o, index) in srs_hard_box_values"
          :key="index"
          :value="o[0]"
        >{{ o[1] || o[0] }}</option>
      </select>

        Max Hard Box must be lower than the number of boxes total.

</template>