<commit_before>// Styles
import '../../stylus/components/_text-fields.styl'
import '../../stylus/components/_select.styl'

// Components
import VChip from '../VChip'
import VMenu from '../VMenu'
import VSelectList from './VSelectList'

// Extensions
import VTextField from '../VTextField/VTextField'

// Mixins
import Comparable from '../../mixins/comparable'
import Dependent from '../../mixins/dependent'
import Filterable from '../../mixins/filterable'
import Menuable from '../../mixins/menuable'

// Directives
import ClickOutside from '../../directives/click-outside'

// Helpers
import { getPropertyFromItem, keyCodes } from '../../util/helpers'

export default {
  name: 'v-select',

  extends: VTextField,

  directives: {
    ClickOutside
  },

  mixins: [
    Comparable,
    Dependent,
    Filterable
  ],

  data: vm => ({
    attrsInput: { role: 'combobox' },
    cachedItems: vm.cacheItems ? vm.items : [],
    isMenuActive: false,
    // As long as a value is defined, show it
    // Otherwise, check if multiple
    // to determine which default to provide
    lazyValue: vm.value != null
      ? vm.value
      : vm.multiple ? [] : undefined,
    selectedIndex: -1,
    selectedItems: []
  }),

  props: {
    ...VMenu.props, // TODO: remove some, just for testing,
    ...Menuable.props,
    appendIcon: {
      type: String,
      default: 'arrow_drop_down'
    },
    appendIconCb: Function,
    attach: Boolean,
    auto: Boolean,
    browserAutocomplete: {
      type: String,
      default: 'on'
    },
    cacheItems: Boolean,
    chips: Boolean,
    clearable: Boolean,
    contentClass: String,
    deletableChips: Boolean,
    dense: Boolean,
    hideNoData: Boolean,
    hideSelected: Boolean,
    items: {
      type: Array,
      default: () => []
    },
    itemAvatar: {
      type: [String, Array, Function],
      default: 'avatar'
    },
    itemDisabled: {
      type: [String, Array, Function],
      default: 'disabled'
    },
    itemText: {
      type: [String, Array, Function],
      default: 'text'
    },
    itemValue: {
      type: [String, Array, Function],
      default: 'value'
    },
    maxHeight: {
      type: [Number, String],
      default: 300
    },
    minWidth: {
      type: [Boolean, Number, String],
      default: 0
    },
    multiple: Boolean,
    multiLine: Boolean,
    openOnClear: Boolean,
    returnObject: Boolean,
    searchInput: {
      default: null
    },
    smallChips: Boolean,
    singleLine: Boolean
  },

  computed: {
    classes () {
      return Object.assign({}, VTextField.computed.classes.call(this), {
        'v-select': true,
        'v-select--chips': this.chips,
        'v-select--is-menu-active': this.isMenuActive
      })
    },
    computedItems () {
      return this.filterDuplicates(this.cachedItems.concat(this.items))
    },
    directives () {
      return [{
        name: 'click-outside',
        // TODO: Check into this firing when it shouldn't
        value: e => {
          if (this.isMenuActive) {
            this.onKeyDown(e)
          }

          this.isMenuActive = false
          this.isFocused = false
          this.selectedIndex = -1
        },
        args: {
          closeConditional: e => {
            return !this.$refs.menu.$refs.content.contains(e.target) &&
              !this.$el.contains(e.target) &&
              e.target !== this.$el
          }
        }
      }]
    },
    dynamicHeight () {
      return 'auto'
    },
    hasSlot () {
      return Boolean(this.chips || this.$slots.item)
    },
    isDirty () {
      return this.selectedItems.length > 0
    },
    isMulti () {
      return this.multiple
    },
    menuProps () {
      return {
        closeOnClick: false,
        closeOnContentClick: false,
        openOnClick: false,
        value: this.isMenuActive,
        offsetY: this.offsetY,
        nudgeBottom: this.offsetY ? 1 : 0 // convert to int
      }
    }
  },

  watch: {
    internalValue: 'setSelectedItems',
    items (val) {
      if (this.cacheItems) {
        this.cachedItems = this.filterDuplicates(this.cachedItems.concat(val))
      }

      this.setSelectedItems()
    }
  },

  created () {
    this.setSelectedItems()
  },

  methods: {
    clearableCallback () {
      this.internalValue = this.isMulti ? [] : null
      this.$emit('change', this.internalValue)
      this.$nextTick(() => this.$refs.input.focus())

      if (this.openOnClear) this.isMenuActive = true
    },
    filterDuplicates (arr) {
      const uniqueValues = new Map()
      for (let index = 0; index < arr.length; ++index) {
        const item = arr[index]
        const val = this.getValue(item)

        // TODO: comparator
        !uniqueValues.has(val) && uniqueValues.set(val, item)
      }
      return Array.from(uniqueValues.values())
    },
    findExistingIndex (item) {
      const itemValue = this.getValue(item)

      return (this.internalValue || []).findIndex(i => this.valueComparator(this.getValue(i), itemValue))
    },
    genChipSelection (item, index) {
      const isDisabled = (
        this.disabled ||
        this.readonly ||
        this.getDisabled(item)
      )
      const click = e => {
        if (isDisabled) return

        e.stopPropagation()
        this.selectedIndex = index
        this.onFocus()
      }

      return this.$createElement(VChip, {
        staticClass: 'v-chip--select-multi',
        props: {
          close: this.deletableChips && !isDisabled,
          dark: this.dark,
          disabled: isDisabled,
          selected: index === this.selectedIndex,
          small: this.smallChips
        },
        on: {
          click: click,
          focus: click,
          input: () => this.onChipInput(item)
        },
        key: this.getValue(item)
      }, this.getText(item))
    },
    genClearIcon () {
      if (!this.clearable ||
        !this.isDirty
      ) return null

      return this.genSlot('append', 'inner', [
        this.genIcon('clear', this.clearIconCb || this.clearableCallback)
      ])
    },
    genCommaSelection (item, index, last) {
      // Item may be an object
      // TODO: Remove JSON.stringify
      const key = JSON.stringify(this.getValue(item))

      const isDisabled = (
        this.disabled ||
        this.readonly ||
        this.getDisabled(item)
      )

      const classes = index === this.selectedIndex
        ? this.addTextColorClassChecks()
        : {}

      classes['v-select__selection--disabled'] = isDisabled

      return this.$createElement('div', {
        staticClass: 'v-select__selection v-select__selection--comma',
        'class': classes,
        key
      }, `${this.getText(item)}${last ? '' : ', '}`)
    },
    genDefaultSlot () {
      const selections = this.genSelections()
      const input = this.genInput()

      // If the return is an empty array
      // push the input
      if (Array.isArray(selections)) {
        selections.push(input)
      // Otherwise push it into children
      } else {
        selections.children = selections.children || []
        selections.children.push(input)
      }

      const activator = this.genSelectSlot([
        this.genLabel(),
        this.prefix ? this.genAffix('prefix') : null,
        selections,
        this.suffix ? this.genAffix('suffix') : null,
        this.genClearIcon(),
        this.genSlot('append', 'inner', [this.genIcon('append')])
      ])

      return [this.genMenu(activator)]
    },
    genInput () {
      const input = VTextField.methods.genInput.call(this)

      input.data.domProps.value = null

      return input
    },
    genList () {
      return this.$createElement(VSelectList, {
        props: {
          action: this.isMulti && !this.isHidingSelected,
          color: this.color,
          dark: this.dark,
          dense: this.dense,
          hideSelected: this.hideSelected,
          items: this.items,
          light: this.light,
          noDataText: this.noDataText,
          selectedItems: this.selectedItems,
          itemAvatar: this.itemAvatar,
          itemDisabled: this.itemDisabled,
          itemValue: this.itemValue,
          itemText: this.itemText
        },
        on: {
          select: this.selectItem
        },
        scopedSlots: {
          item: this.$scopedSlots.item
        }
      }, [
        this.$slots['no-data'] ? this.$createElement('div', {
          slot: 'no-data'
        }, this.$slots['no-data']) : null
      ])
    },
    genMenu (activator) {
      const props = {
        contentClass: this.contentClass
      }
      const inheritedProps = Object.keys(VMenu.props).concat(Object.keys(Menuable.props))

      // Later this might be filtered
      for (let prop of inheritedProps) {
        props[prop] = this[prop]
      }

      // These styles encompass the prepend
      // and append icons. Change activator
      // to the entire component
      if (this.isSolo) {
        props.activator = this.$el
      } else {
        props.activator = this.$refs['input-slot']
      }

      Object.assign(props, this.menuProps)

      // Attach to root el so that
      // menu covers prepend/append icons
      if (
        // TODO: make this a computed property or helper or something
        this.attach === '' || // If used as a boolean prop (<v-menu attach>)
        this.attach === true || // If bound to a boolean (<v-menu :attach="true">)
        this.attach === 'attach' // If bound as boolean prop in pug (v-menu(attach))
      ) {
        props.attach = this.$el
      }

      return this.$createElement(VMenu, {
        props,
        on: {
          input: val => {
            this.isMenuActive = val
            this.isFocused = val
          }
        },
        ref: 'menu'
      }, [activator, this.genList()])
    },
    genSelections () {
      let length = this.selectedItems.length
      const children = new Array(length)

      let genSelection
      if (this.$scopedSlots.selection) {
        genSelection = this.genSlotSelection
      } else if (this.chips) {
        genSelection = this.genChipSelection
      } else {
        genSelection = this.genCommaSelection
      }

      while (length--) {
        children[length] = genSelection(
          this.selectedItems[length],
          length,
          length === children.length - 1
        )
      }

      return this.$createElement('div', {
        staticClass: 'v-select__selections'
      }, children)
    },
    genSelectSlot (children) {
      return this.$createElement('div', {
        staticClass: 'v-select__slot',
        directives: this.directives,
        slot: 'activator'
      }, children)
    },
    genSlotSelection (item, index) {
      return this.$scopedSlots.selection({
        parent: this,
        item,
        index,
        selected: index === this.selectedIndex,
        disabled: this.disabled || this.readonly
      })
    },
    getMenuIndex () {
      return this.$refs.menu ? this.$refs.menu.listIndex : -1
    },
    getDisabled (item) {
      return getPropertyFromItem(item, this.itemDisabled, false)
    },
    getText (item) {
      return getPropertyFromItem(item, this.itemText, item)
    },
    getValue (item) {
      return getPropertyFromItem(item, this.itemValue, item)
    },
    onBlur (e) {
      this.$emit('blur', e)
    },
    onChipInput (item) {
      if (this.isMulti) this.selectItem(item)
      else this.internalValue = null

      // If all items have been deleted,
      // open `v-menu`
      if (this.selectedItems.length === 0) {
        this.isMenuActive = true
      }

      this.selectedIndex = -1
    },
    onClick () {
      if (this.isDisabled) return

      this.onFocus()
      this.isMenuActive = true
    },
    // Detect tab and call original onBlur method
    onKeyDown (e) {
      if (e.keyCode === keyCodes.tab) {
        VTextField.methods.onBlur.call(this, e)
      } else if ([
        keyCodes.enter,
        keyCodes.space,
        keyCodes.up,
        keyCodes.down
      ].includes(e.keyCode)) {
        this.isMenuActive = true
      }
    },
    onMouseUp (e) {
      if (this.isSolo || this.hasOutline) {
        this.isMenuActive = true
      }

      VTextField.methods.onMouseUp.call(this, e)
    },
    selectItem (item) {
      if (!this.isMulti) {
        this.internalValue = this.returnObject ? item : this.getValue(item)
        this.isMenuActive = false
      } else {
        const internalValue = (this.internalValue || []).slice()
        const i = this.findExistingIndex(item)

        i !== -1 ? internalValue.splice(i, 1) : internalValue.push(item)
        this.internalValue = internalValue.map(i => {
          return this.returnObject ? i : this.getValue(i)
        })
      }

      this.$emit('change', this.internalValue)

      this.$nextTick(() => {
        this.$refs.menu &&
          this.$refs.menu.updateDimensions()
      })
    },
    setMenuIndex (index) {
      this.$refs.menu && (this.$refs.menu.listIndex = index)
    },
    setSelectedItems () {
      const fn = !this.isMulti
        ? i => this.valueComparator(
          this.getValue(i),
          this.getValue(this.internalValue)
        )
        : i => this.findExistingIndex(i) > -1

      this.selectedItems = this.computedItems.filter(fn)
    }
  }
}
<commit_msg>Fixed the one remaining hard-coded internal icon name in v-select (#4014)<commit_after>// Styles
import '../../stylus/components/_text-fields.styl'
import '../../stylus/components/_select.styl'

// Components
import VChip from '../VChip'
import VMenu from '../VMenu'
import VSelectList from './VSelectList'

// Extensions
import VTextField from '../VTextField/VTextField'

// Mixins
import Comparable from '../../mixins/comparable'
import Dependent from '../../mixins/dependent'
import Filterable from '../../mixins/filterable'
import Menuable from '../../mixins/menuable'

// Directives
import ClickOutside from '../../directives/click-outside'

// Helpers
import { getPropertyFromItem, keyCodes } from '../../util/helpers'

export default {
  name: 'v-select',

  extends: VTextField,

  directives: {
    ClickOutside
  },

  mixins: [
    Comparable,
    Dependent,
    Filterable
  ],

  data: vm => ({
    attrsInput: { role: 'combobox' },
    cachedItems: vm.cacheItems ? vm.items : [],
    isMenuActive: false,
    // As long as a value is defined, show it
    // Otherwise, check if multiple
    // to determine which default to provide
    lazyValue: vm.value != null
      ? vm.value
      : vm.multiple ? [] : undefined,
    selectedIndex: -1,
    selectedItems: []
  }),

  props: {
    ...VMenu.props, // TODO: remove some, just for testing,
    ...Menuable.props,
    appendIcon: {
      type: String,
      default: '$vuetify.icons.dropdown'
    },
    appendIconCb: Function,
    attach: Boolean,
    auto: Boolean,
    browserAutocomplete: {
      type: String,
      default: 'on'
    },
    cacheItems: Boolean,
    chips: Boolean,
    clearable: Boolean,
    contentClass: String,
    deletableChips: Boolean,
    dense: Boolean,
    hideNoData: Boolean,
    hideSelected: Boolean,
    items: {
      type: Array,
      default: () => []
    },
    itemAvatar: {
      type: [String, Array, Function],
      default: 'avatar'
    },
    itemDisabled: {
      type: [String, Array, Function],
      default: 'disabled'
    },
    itemText: {
      type: [String, Array, Function],
      default: 'text'
    },
    itemValue: {
      type: [String, Array, Function],
      default: 'value'
    },
    maxHeight: {
      type: [Number, String],
      default: 300
    },
    minWidth: {
      type: [Boolean, Number, String],
      default: 0
    },
    multiple: Boolean,
    multiLine: Boolean,
    openOnClear: Boolean,
    returnObject: Boolean,
    searchInput: {
      default: null
    },
    smallChips: Boolean,
    singleLine: Boolean
  },

  computed: {
    classes () {
      return Object.assign({}, VTextField.computed.classes.call(this), {
        'v-select': true,
        'v-select--chips': this.chips,
        'v-select--is-menu-active': this.isMenuActive
      })
    },
    computedItems () {
      return this.filterDuplicates(this.cachedItems.concat(this.items))
    },
    directives () {
      return [{
        name: 'click-outside',
        // TODO: Check into this firing when it shouldn't
        value: e => {
          if (this.isMenuActive) {
            this.onKeyDown(e)
          }

          this.isMenuActive = false
          this.isFocused = false
          this.selectedIndex = -1
        },
        args: {
          closeConditional: e => {
            return !this.$refs.menu.$refs.content.contains(e.target) &&
              !this.$el.contains(e.target) &&
              e.target !== this.$el
          }
        }
      }]
    },
    dynamicHeight () {
      return 'auto'
    },
    hasSlot () {
      return Boolean(this.chips || this.$slots.item)
    },
    isDirty () {
      return this.selectedItems.length > 0
    },
    isMulti () {
      return this.multiple
    },
    menuProps () {
      return {
        closeOnClick: false,
        closeOnContentClick: false,
        openOnClick: false,
        value: this.isMenuActive,
        offsetY: this.offsetY,
        nudgeBottom: this.offsetY ? 1 : 0 // convert to int
      }
    }
  },

  watch: {
    internalValue: 'setSelectedItems',
    items (val) {
      if (this.cacheItems) {
        this.cachedItems = this.filterDuplicates(this.cachedItems.concat(val))
      }

      this.setSelectedItems()
    }
  },

  created () {
    this.setSelectedItems()
  },

  methods: {
    clearableCallback () {
      this.internalValue = this.isMulti ? [] : null
      this.$emit('change', this.internalValue)
      this.$nextTick(() => this.$refs.input.focus())

      if (this.openOnClear) this.isMenuActive = true
    },
    filterDuplicates (arr) {
      const uniqueValues = new Map()
      for (let index = 0; index < arr.length; ++index) {
        const item = arr[index]
        const val = this.getValue(item)

        // TODO: comparator
        !uniqueValues.has(val) && uniqueValues.set(val, item)
      }
      return Array.from(uniqueValues.values())
    },
    findExistingIndex (item) {
      const itemValue = this.getValue(item)

      return (this.internalValue || []).findIndex(i => this.valueComparator(this.getValue(i), itemValue))
    },
    genChipSelection (item, index) {
      const isDisabled = (
        this.disabled ||
        this.readonly ||
        this.getDisabled(item)
      )
      const click = e => {
        if (isDisabled) return

        e.stopPropagation()
        this.selectedIndex = index
        this.onFocus()
      }

      return this.$createElement(VChip, {
        staticClass: 'v-chip--select-multi',
        props: {
          close: this.deletableChips && !isDisabled,
          dark: this.dark,
          disabled: isDisabled,
          selected: index === this.selectedIndex,
          small: this.smallChips
        },
        on: {
          click: click,
          focus: click,
          input: () => this.onChipInput(item)
        },
        key: this.getValue(item)
      }, this.getText(item))
    },
    genClearIcon () {
      if (!this.clearable ||
        !this.isDirty
      ) return null

      return this.genSlot('append', 'inner', [
        this.genIcon('clear', this.clearIconCb || this.clearableCallback)
      ])
    },
    genCommaSelection (item, index, last) {
      // Item may be an object
      // TODO: Remove JSON.stringify
      const key = JSON.stringify(this.getValue(item))

      const isDisabled = (
        this.disabled ||
        this.readonly ||
        this.getDisabled(item)
      )

      const classes = index === this.selectedIndex
        ? this.addTextColorClassChecks()
        : {}

      classes['v-select__selection--disabled'] = isDisabled

      return this.$createElement('div', {
        staticClass: 'v-select__selection v-select__selection--comma',
        'class': classes,
        key
      }, `${this.getText(item)}${last ? '' : ', '}`)
    },
    genDefaultSlot () {
      const selections = this.genSelections()
      const input = this.genInput()

      // If the return is an empty array
      // push the input
      if (Array.isArray(selections)) {
        selections.push(input)
      // Otherwise push it into children
      } else {
        selections.children = selections.children || []
        selections.children.push(input)
      }

      const activator = this.genSelectSlot([
        this.genLabel(),
        this.prefix ? this.genAffix('prefix') : null,
        selections,
        this.suffix ? this.genAffix('suffix') : null,
        this.genClearIcon(),
        this.genSlot('append', 'inner', [this.genIcon('append')])
      ])

      return [this.genMenu(activator)]
    },
    genInput () {
      const input = VTextField.methods.genInput.call(this)

      input.data.domProps.value = null

      return input
    },
    genList () {
      return this.$createElement(VSelectList, {
        props: {
          action: this.isMulti && !this.isHidingSelected,
          color: this.color,
          dark: this.dark,
          dense: this.dense,
          hideSelected: this.hideSelected,
          items: this.items,
          light: this.light,
          noDataText: this.noDataText,
          selectedItems: this.selectedItems,
          itemAvatar: this.itemAvatar,
          itemDisabled: this.itemDisabled,
          itemValue: this.itemValue,
          itemText: this.itemText
        },
        on: {
          select: this.selectItem
        },
        scopedSlots: {
          item: this.$scopedSlots.item
        }
      }, [
        this.$slots['no-data'] ? this.$createElement('div', {
          slot: 'no-data'
        }, this.$slots['no-data']) : null
      ])
    },
    genMenu (activator) {
      const props = {
        contentClass: this.contentClass
      }
      const inheritedProps = Object.keys(VMenu.props).concat(Object.keys(Menuable.props))

      // Later this might be filtered
      for (let prop of inheritedProps) {
        props[prop] = this[prop]
      }

      // These styles encompass the prepend
      // and append icons. Change activator
      // to the entire component
      if (this.isSolo) {
        props.activator = this.$el
      } else {
        props.activator = this.$refs['input-slot']
      }

      Object.assign(props, this.menuProps)

      // Attach to root el so that
      // menu covers prepend/append icons
      if (
        // TODO: make this a computed property or helper or something
        this.attach === '' || // If used as a boolean prop (<v-menu attach>)
        this.attach === true || // If bound to a boolean (<v-menu :attach="true">)
        this.attach === 'attach' // If bound as boolean prop in pug (v-menu(attach))
      ) {
        props.attach = this.$el
      }

      return this.$createElement(VMenu, {
        props,
        on: {
          input: val => {
            this.isMenuActive = val
            this.isFocused = val
          }
        },
        ref: 'menu'
      }, [activator, this.genList()])
    },
    genSelections () {
      let length = this.selectedItems.length
      const children = new Array(length)

      let genSelection
      if (this.$scopedSlots.selection) {
        genSelection = this.genSlotSelection
      } else if (this.chips) {
        genSelection = this.genChipSelection
      } else {
        genSelection = this.genCommaSelection
      }

      while (length--) {
        children[length] = genSelection(
          this.selectedItems[length],
          length,
          length === children.length - 1
        )
      }

      return this.$createElement('div', {
        staticClass: 'v-select__selections'
      }, children)
    },
    genSelectSlot (children) {
      return this.$createElement('div', {
        staticClass: 'v-select__slot',
        directives: this.directives,
        slot: 'activator'
      }, children)
    },
    genSlotSelection (item, index) {
      return this.$scopedSlots.selection({
        parent: this,
        item,
        index,
        selected: index === this.selectedIndex,
        disabled: this.disabled || this.readonly
      })
    },
    getMenuIndex () {
      return this.$refs.menu ? this.$refs.menu.listIndex : -1
    },
    getDisabled (item) {
      return getPropertyFromItem(item, this.itemDisabled, false)
    },
    getText (item) {
      return getPropertyFromItem(item, this.itemText, item)
    },
    getValue (item) {
      return getPropertyFromItem(item, this.itemValue, item)
    },
    onBlur (e) {
      this.$emit('blur', e)
    },
    onChipInput (item) {
      if (this.isMulti) this.selectItem(item)
      else this.internalValue = null

      // If all items have been deleted,
      // open `v-menu`
      if (this.selectedItems.length === 0) {
        this.isMenuActive = true
      }

      this.selectedIndex = -1
    },
    onClick () {
      if (this.isDisabled) return

      this.onFocus()
      this.isMenuActive = true
    },
    // Detect tab and call original onBlur method
    onKeyDown (e) {
      if (e.keyCode === keyCodes.tab) {
        VTextField.methods.onBlur.call(this, e)
      } else if ([
        keyCodes.enter,
        keyCodes.space,
        keyCodes.up,
        keyCodes.down
      ].includes(e.keyCode)) {
        this.isMenuActive = true
      }
    },
    onMouseUp (e) {
      if (this.isSolo || this.hasOutline) {
        this.isMenuActive = true
      }

      VTextField.methods.onMouseUp.call(this, e)
    },
    selectItem (item) {
      if (!this.isMulti) {
        this.internalValue = this.returnObject ? item : this.getValue(item)
        this.isMenuActive = false
      } else {
        const internalValue = (this.internalValue || []).slice()
        const i = this.findExistingIndex(item)

        i !== -1 ? internalValue.splice(i, 1) : internalValue.push(item)
        this.internalValue = internalValue.map(i => {
          return this.returnObject ? i : this.getValue(i)
        })
      }

      this.$emit('change', this.internalValue)

      this.$nextTick(() => {
        this.$refs.menu &&
          this.$refs.menu.updateDimensions()
      })
    },
    setMenuIndex (index) {
      this.$refs.menu && (this.$refs.menu.listIndex = index)
    },
    setSelectedItems () {
      const fn = !this.isMulti
        ? i => this.valueComparator(
          this.getValue(i),
          this.getValue(this.internalValue)
        )
        : i => this.findExistingIndex(i) > -1

      this.selectedItems = this.computedItems.filter(fn)
    }
  }
}
<|endoftext|>


<commit_before>/**
 * Copyright 2018 The Subscribe with Google Authors. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS-IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {ActivityIframePort, ActivityPorts} from './activities';
import {
  ActivityResult,
  ActivityIframePort as WebActivityIframePort,
  ActivityPort as WebActivityPort,
  ActivityPorts as WebActivityPorts,
} from 'web-activities/activity-ports';
import {AnalyticsEvent, AnalyticsRequest} from '../proto/api_messages';
import {AnalyticsService} from '../runtime/analytics-service';
import {ClientEventManager} from '../runtime/client-event-manager';
import {Dialog} from '../components/dialog';
import {GlobalDoc} from '../model/doc';
import {PageConfig} from '../model/page-config';
import {tick} from '../../test/tick';

const publicationId = 'PUB_ID';
const TOKEN = 'abc';

describes.realWin('Activity Components', {}, (env) => {
  let win, iframe, url, dialog, doc, deps, pageConfig, analytics, activityPorts;
  let eventManager;

  beforeEach(() => {
    url = 'https://www.google.com/';
    win = env.win;
    doc = new GlobalDoc(win);
    dialog = new Dialog(new GlobalDoc(win), {height: '100px'});
    iframe = dialog.getElement();
    doc.getBody().appendChild(iframe);
    eventManager = new ClientEventManager(Promise.resolve());

    pageConfig = new PageConfig(publicationId, false);
    deps = {
      win: () => win,
      pageConfig: () => pageConfig,
      doc: () => doc,
      eventManager: () => eventManager,
      storage: () => ({get: () => Promise.resolve(TOKEN)}),
    };
    activityPorts = new ActivityPorts(deps);
    deps['activities'] = () => activityPorts;
    analytics = new AnalyticsService(deps);
    deps['analytics'] = () => analytics;
  });

  afterEach(() => {});

  describe('ActivityPorts', () => {
    describe('default arguments', () => {
      let expectedDefaults;

      beforeEach(() => {
        expectedDefaults = {
          'analyticsContext': analytics.getContext().toArray(),
          'publicationId': pageConfig.getPublicationId(),
          'productId': pageConfig.getProductId(),
          '_client': 'SwG $internalRuntimeVersion$',
          'supportsEventManager': true,
        };
      });

      it('should accept no arguments', () => {
        expect(activityPorts.addDefaultArguments()).to.deep.equal(
          expectedDefaults
        );
      });

      it('should fill in an empty object', () => {
        expect(activityPorts.addDefaultArguments({})).to.deep.equal(
          expectedDefaults
        );
      });

      it('should add to an existing object', () => {
        const newArgs = {
          aVal: 1,
        };
        const expectedObject = Object.assign({}, expectedDefaults, newArgs);

        expect(activityPorts.addDefaultArguments(newArgs)).to.deep.equal(
          expectedObject
        );
      });

      it('should not override passed values', () => {
        const newArgs = {
          productId: 55555555,
        };
        const expectedObject = Object.assign({}, expectedDefaults, newArgs);

        expect(activityPorts.addDefaultArguments(newArgs)).to.deep.equal(
          expectedObject
        );
      });

      it('should not add them to open', () => {
        let passedArgs = null;
        sandbox
          .stub(activityPorts.activityPorts_, 'open')
          .callsFake((_requestId, _url, _target, args) => {
            passedArgs = args;
          });
        activityPorts.open('', '', '');

        expect(passedArgs).to.be.undefined;
      });

      it('should add them to open', () => {
        let passedArgs;
        sandbox
          .stub(activityPorts.activityPorts_, 'open')
          .callsFake((_requestId, _url, _target, args) => {
            passedArgs = args;
          });
        activityPorts.open('', '', '', null, {}, true);

        expect(passedArgs).to.deep.equal(expectedDefaults);
      });

      it('should not add them to openIframe', async () => {
        // The best test I could come up with was just to ensure it passed the
        // arguments to addDefaultArguments and used the result.
        let receivedArgs = null;
        const sentArgs = {
          a: 1,
        };
        sandbox
          .stub(activityPorts, 'openActivityIframePort_')
          .callsFake((_iframe, _url, args) => {
            receivedArgs = args;
            return args;
          });

        await activityPorts.openIframe(iframe, url, sentArgs);

        expect(receivedArgs).to.deep.equal(sentArgs);
      });

      it('should add them to openIframe', async () => {
        // The best test I could come up with was just to ensure it passed the
        // arguments to addDefaultArguments and used the result.
        let receivedArgs;
        const sentArgs = {
          a: 1,
        };
        sandbox
          .stub(activityPorts, 'openActivityIframePort_')
          .callsFake((_iframe, _url, args) => {
            receivedArgs = args;
            return args;
          });
        await activityPorts.openIframe(iframe, url, sentArgs, true);

        expect(receivedArgs).to.deep.equal(
          activityPorts.addDefaultArguments(sentArgs)
        );
      });
    });

    describe('function delegation', () => {
      it('should delegate openIframe to ActivityIframePort', async () => {
        sandbox
          .stub(ActivityIframePort.prototype, 'connect')
          .callsFake(() => Promise.resolve());
        const port = await activityPorts.openIframe(iframe, url);

        expect(port instanceof ActivityIframePort).to.be.true;
      });

      it('should delegate open', () => {
        sandbox.stub(WebActivityPorts.prototype, 'open').callsFake(() => {
          return {targetWin: null};
        });
        const opener = activityPorts.open(
          'some_request_id',
          '/someUrl',
          '_top',
          {}
        );

        expect(opener.targetWin).to.be.null;
      });

      it('must delegate onResult', async () => {
        let result;
        const resultHandler = async (portDef) => {
          result = await portDef.acceptResult();
        };
        let cb;
        let requestId;
        sandbox
          .stub(WebActivityPorts.prototype, 'onResult')
          .callsFake((reqId, handler) => {
            requestId = reqId;
            cb = handler;
          });
        activityPorts.onResult('result', resultHandler);
        const activityPort = new WebActivityPort();
        activityPort.acceptResult = () => {
          const result = new ActivityResult();
          result.data = 'test';
          return Promise.resolve(result);
        };
        await cb(activityPort);

        expect(requestId).to.equal('result');
        expect(result.data).to.equal('test');
      });

      it('must delegate onRedirectError', () => {
        let actualHandler;
        sandbox
          .stub(WebActivityPorts.prototype, 'onRedirectError')
          .callsFake((handler) => {
            actualHandler = handler;
          });

        const expectedHandler = sandbox.mock();
        activityPorts.onRedirectError(expectedHandler);

        expect(actualHandler).to.equal(expectedHandler);
      });

      it('makes original activity ports available', () => {
        const original = activityPorts.getOriginalWebActivityPorts();
        expect(original).to.be.instanceof(WebActivityPorts);
      });
    });

    describe('openIframe', () => {
      it('adds sut and publicationId', async () => {
        const callMock = sandbox
          .mock(activityPorts, 'openActivityIframePort_')
          .expects('openActivityIframePort_')
          .withExactArgs(
            iframe,
            `${url}?sut=${TOKEN}&publicationId=${publicationId}`,
            {}
          )
          .once();
        await activityPorts.openIframe(iframe, url, {});
        callMock.verify();
      });

      it('does not duplicate already existing sut and publicationId', async () => {
        const callMock = sandbox
          .mock(activityPorts, 'openActivityIframePort_')
          .expects('openActivityIframePort_')
          .withExactArgs(
            iframe,
            `${url}?sut=Original${TOKEN}&publicationId=Original${publicationId}`,
            {}
          )
          .once();
        await activityPorts.openIframe(
          iframe,
          `${url}?sut=Original${TOKEN}&publicationId=Original${publicationId}`,
          {}
        );
        callMock.verify();
      });
    });
  });

  describe('ActivityIframePort', () => {
    let activityIframePort;
    let connected;
    let handler;
    let analyticsRequest;
    let serializedRequest;

    beforeEach(() => {
      handler = null;
      connected = false;
      sandbox.stub(WebActivityIframePort.prototype, 'connect').callsFake(() => {
        connected = true;
        return Promise.resolve();
      });
      sandbox
        .stub(WebActivityIframePort.prototype, 'onMessage')
        .callsFake((args) => {
          handler = args;
        });

      analyticsRequest = new AnalyticsRequest();
      analyticsRequest.setEvent(AnalyticsEvent.UNKNOWN);
      serializedRequest = analyticsRequest.toArray();
      activityIframePort = new ActivityIframePort(iframe, url, deps);
    });

    it('must delegate connect, disconnect and ready', async () => {
      let ready = false;
      sandbox
        .stub(WebActivityIframePort.prototype, 'disconnect')
        .callsFake(() => {
          connected = false;
        });
      sandbox
        .stub(WebActivityIframePort.prototype, 'whenReady')
        .callsFake(() => {
          ready = true;
          return Promise.resolve();
        });
      await activityIframePort.connect();
      expect(connected).to.be.true;

      await activityIframePort.whenReady();
      expect(ready).to.be.true;

      activityIframePort.disconnect();
      expect(connected).to.be.false;
    });

    it('should delegate getMode and attach callback to connect', async () => {
      const value = 'Hi!';
      sandbox.stub(WebActivityIframePort.prototype, 'getMode').returns(value);
      expect(activityIframePort.getMode()).to.equal(value);

      expect(handler).to.be.null;
      await activityIframePort.connect();
      expect(handler).to.not.be.null;
    });

    it('delegates acceptResult', async () => {
      const value = 'Hi!';
      sandbox
        .stub(WebActivityIframePort.prototype, 'acceptResult')
        .returns(value);
      expect(activityIframePort.acceptResult()).to.equal(value);
    });

    it('should handle resize request and delegate resized', () => {
      let resized = false;
      sandbox.stub(WebActivityIframePort.prototype, 'resized').callsFake(() => {
        resized = true;
      });
      activityIframePort.resized();
      expect(resized).to.be.true;

      let handler = null;
      sandbox
        .stub(WebActivityIframePort.prototype, 'onResizeRequest')
        .callsFake((arg) => {
          handler = arg;
        });
      let num;

      expect(handler).to.be.null;
      activityIframePort.onResizeRequest((n) => {
        num = n;
      });
      expect(handler).to.not.be.null;

      handler(1);
      expect(num).to.equal(1);
    });

    it('should allow registering callback after connect', async () => {
      expect(handler).to.be.null;
      await activityIframePort.connect();
      expect(handler).to.not.be.null;

      handler({'sku': 'daily'});
      await tick();
      handler({'sku': 'daily'});
    });

    it('should test new messaging APIs and auto register logging', async () => {
      let payload;
      let event = null;
      sandbox
        .stub(WebActivityIframePort.prototype, 'message')
        .callsFake((args) => {
          payload = args;
        });
      sandbox.stub(deps.eventManager(), 'logEvent').callsFake((clientEvent) => {
        event = clientEvent.eventType;
      });
      activityIframePort.execute(analyticsRequest);
      expect(payload).to.deep.equal({'REQUEST': serializedRequest});

      expect(handler).to.be.null;
      await activityIframePort.connect();
      expect(handler).to.not.be.null;

      handler({'RESPONSE': serializedRequest});
      expect(event).to.equal(AnalyticsEvent.UNKNOWN);
    });

    it('should support on APIs and register logging', async () => {
      expect(handler).to.be.null;
      await activityIframePort.connect();
      expect(handler).to.not.be.null;

      let event;
      sandbox.stub(deps.eventManager(), 'logEvent').callsFake((clientEvent) => {
        event = clientEvent.eventType;
      });
      handler({'RESPONSE': serializedRequest});
      expect(event).to.equal(AnalyticsEvent.UNKNOWN);
    });

    it('should complain about multiple listeners', async () => {
      await activityIframePort.connect();
      expect(() => activityIframePort.on(AnalyticsRequest, () => {})).to.throw(
        /Invalid type or duplicate callback for /
      );
    });

    it('should complain about bad request types', async () => {
      class fakeMe {}
      expect(() => activityIframePort.on(fakeMe, () => {})).to.throw(
        /Invalid data type/
      );
    });
  });
});
<commit_msg>Sperate tests for failure conditions<commit_after>/**
 * Copyright 2018 The Subscribe with Google Authors. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS-IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

import {ActivityIframePort, ActivityPorts} from './activities';
import {
  ActivityResult,
  ActivityIframePort as WebActivityIframePort,
  ActivityPort as WebActivityPort,
  ActivityPorts as WebActivityPorts,
} from 'web-activities/activity-ports';
import {AnalyticsEvent, AnalyticsRequest} from '../proto/api_messages';
import {AnalyticsService} from '../runtime/analytics-service';
import {ClientEventManager} from '../runtime/client-event-manager';
import {Dialog} from '../components/dialog';
import {GlobalDoc} from '../model/doc';
import {PageConfig} from '../model/page-config';
import {tick} from '../../test/tick';

const publicationId = 'PUB_ID';
const TOKEN = 'abc';

describes.realWin('Activity Components', {}, (env) => {
  let win, iframe, url, dialog, doc, deps, pageConfig, analytics, activityPorts;
  let eventManager;

  beforeEach(() => {
    url = 'https://www.google.com/';
    win = env.win;
    doc = new GlobalDoc(win);
    dialog = new Dialog(new GlobalDoc(win), {height: '100px'});
    iframe = dialog.getElement();
    doc.getBody().appendChild(iframe);
    eventManager = new ClientEventManager(Promise.resolve());

    pageConfig = new PageConfig(publicationId, false);
    deps = {
      win: () => win,
      pageConfig: () => pageConfig,
      doc: () => doc,
      eventManager: () => eventManager,
      storage: () => ({get: () => Promise.resolve(TOKEN)}),
    };
    activityPorts = new ActivityPorts(deps);
    deps['activities'] = () => activityPorts;
    analytics = new AnalyticsService(deps);
    deps['analytics'] = () => analytics;
  });

  afterEach(() => {});

  describe('ActivityPorts', () => {
    describe('default arguments', () => {
      let expectedDefaults;

      beforeEach(() => {
        expectedDefaults = {
          'analyticsContext': analytics.getContext().toArray(),
          'publicationId': pageConfig.getPublicationId(),
          'productId': pageConfig.getProductId(),
          '_client': 'SwG $internalRuntimeVersion$',
          'supportsEventManager': true,
        };
      });

      it('should accept no arguments', () => {
        expect(activityPorts.addDefaultArguments()).to.deep.equal(
          expectedDefaults
        );
      });

      it('should fill in an empty object', () => {
        expect(activityPorts.addDefaultArguments({})).to.deep.equal(
          expectedDefaults
        );
      });

      it('should add to an existing object', () => {
        const newArgs = {
          aVal: 1,
        };
        const expectedObject = Object.assign({}, expectedDefaults, newArgs);

        expect(activityPorts.addDefaultArguments(newArgs)).to.deep.equal(
          expectedObject
        );
      });

      it('should not override passed values', () => {
        const newArgs = {
          productId: 55555555,
        };
        const expectedObject = Object.assign({}, expectedDefaults, newArgs);

        expect(activityPorts.addDefaultArguments(newArgs)).to.deep.equal(
          expectedObject
        );
      });

      it('should not add them to open', () => {
        let passedArgs = null;
        sandbox
          .stub(activityPorts.activityPorts_, 'open')
          .callsFake((_requestId, _url, _target, args) => {
            passedArgs = args;
          });
        activityPorts.open('', '', '');

        expect(passedArgs).to.be.undefined;
      });

      it('should add them to open', () => {
        let passedArgs;
        sandbox
          .stub(activityPorts.activityPorts_, 'open')
          .callsFake((_requestId, _url, _target, args) => {
            passedArgs = args;
          });
        activityPorts.open('', '', '', null, {}, true);

        expect(passedArgs).to.deep.equal(expectedDefaults);
      });

      it('should not add them to openIframe', async () => {
        // The best test I could come up with was just to ensure it passed the
        // arguments to addDefaultArguments and used the result.
        let receivedArgs = null;
        const sentArgs = {
          a: 1,
        };
        sandbox
          .stub(activityPorts, 'openActivityIframePort_')
          .callsFake((_iframe, _url, args) => {
            receivedArgs = args;
            return args;
          });

        await activityPorts.openIframe(iframe, url, sentArgs);

        expect(receivedArgs).to.deep.equal(sentArgs);
      });

      it('should add them to openIframe', async () => {
        // The best test I could come up with was just to ensure it passed the
        // arguments to addDefaultArguments and used the result.
        let receivedArgs;
        const sentArgs = {
          a: 1,
        };
        sandbox
          .stub(activityPorts, 'openActivityIframePort_')
          .callsFake((_iframe, _url, args) => {
            receivedArgs = args;
            return args;
          });
        await activityPorts.openIframe(iframe, url, sentArgs, true);

        expect(receivedArgs).to.deep.equal(
          activityPorts.addDefaultArguments(sentArgs)
        );
      });
    });

    describe('function delegation', () => {
      it('should delegate openIframe to ActivityIframePort', async () => {
        sandbox
          .stub(ActivityIframePort.prototype, 'connect')
          .callsFake(() => Promise.resolve());
        const port = await activityPorts.openIframe(iframe, url);

        expect(port instanceof ActivityIframePort).to.be.true;
      });

      it('should delegate open', () => {
        sandbox.stub(WebActivityPorts.prototype, 'open').callsFake(() => {
          return {targetWin: null};
        });
        const opener = activityPorts.open(
          'some_request_id',
          '/someUrl',
          '_top',
          {}
        );

        expect(opener.targetWin).to.be.null;
      });

      it('must delegate onResult', async () => {
        let result;
        const resultHandler = async (portDef) => {
          result = await portDef.acceptResult();
        };
        let cb;
        let requestId;
        sandbox
          .stub(WebActivityPorts.prototype, 'onResult')
          .callsFake((reqId, handler) => {
            requestId = reqId;
            cb = handler;
          });
        activityPorts.onResult('result', resultHandler);
        const activityPort = new WebActivityPort();
        activityPort.acceptResult = () => {
          const result = new ActivityResult();
          result.data = 'test';
          return Promise.resolve(result);
        };
        await cb(activityPort);

        expect(requestId).to.equal('result');
        expect(result.data).to.equal('test');
      });

      it('must delegate onRedirectError', () => {
        let actualHandler;
        sandbox
          .stub(WebActivityPorts.prototype, 'onRedirectError')
          .callsFake((handler) => {
            actualHandler = handler;
          });

        const expectedHandler = sandbox.mock();
        activityPorts.onRedirectError(expectedHandler);

        expect(actualHandler).to.equal(expectedHandler);
      });

      it('makes original activity ports available', () => {
        const original = activityPorts.getOriginalWebActivityPorts();
        expect(original).to.be.instanceof(WebActivityPorts);
      });
    });

    describe('openIframe', () => {
      it('adds sut and publicationId', async () => {
        const callMock = sandbox
          .mock(activityPorts, 'openActivityIframePort_')
          .expects('openActivityIframePort_')
          .withExactArgs(
            iframe,
            `${url}?sut=${TOKEN}&publicationId=${publicationId}`,
            {}
          )
          .once();
        await activityPorts.openIframe(iframe, url, {});
        callMock.verify();
      });

      it('does not add sut if it does not exist', async () => {
        deps.storage = () => ({get: () => Promise.resolve(null)});
        const callMock = sandbox
          .mock(activityPorts, 'openActivityIframePort_')
          .expects('openActivityIframePort_')
          .withExactArgs(iframe, `${url}?publicationId=${publicationId}`, {})
          .once();
        await activityPorts.openIframe(iframe, url, {});
        callMock.verify();
      });

      it('does not add publicationId if it does not exist', async () => {
        deps.pageConfig = () => new PageConfig('', false);
        const callMock = sandbox
          .mock(activityPorts, 'openActivityIframePort_')
          .expects('openActivityIframePort_')
          .withExactArgs(iframe, `${url}?sut=${TOKEN}`, {})
          .once();
        await activityPorts.openIframe(iframe, url, {});
        callMock.verify();
      });

      it('does not replace or duplicate already existing sut', async () => {
        const callMock = sandbox
          .mock(activityPorts, 'openActivityIframePort_')
          .expects('openActivityIframePort_')
          .withExactArgs(
            iframe,
            `${url}?sut=Original${TOKEN}&publicationId=${publicationId}`,
            {}
          )
          .once();
        await activityPorts.openIframe(
          iframe,
          `${url}?sut=Original${TOKEN}`,
          {}
        );
        callMock.verify();
      });

      it('does not replace or duplicate already existing publicationId', async () => {
        const callMock = sandbox
          .mock(activityPorts, 'openActivityIframePort_')
          .expects('openActivityIframePort_')
          .withExactArgs(
            iframe,
            `${url}?publicationId=Original${publicationId}&sut=${TOKEN}`,
            {}
          )
          .once();
        await activityPorts.openIframe(
          iframe,
          `${url}?publicationId=Original${publicationId}`,
          {}
        );
        callMock.verify();
      });
    });
  });

  describe('ActivityIframePort', () => {
    let activityIframePort;
    let connected;
    let handler;
    let analyticsRequest;
    let serializedRequest;

    beforeEach(() => {
      handler = null;
      connected = false;
      sandbox.stub(WebActivityIframePort.prototype, 'connect').callsFake(() => {
        connected = true;
        return Promise.resolve();
      });
      sandbox
        .stub(WebActivityIframePort.prototype, 'onMessage')
        .callsFake((args) => {
          handler = args;
        });

      analyticsRequest = new AnalyticsRequest();
      analyticsRequest.setEvent(AnalyticsEvent.UNKNOWN);
      serializedRequest = analyticsRequest.toArray();
      activityIframePort = new ActivityIframePort(iframe, url, deps);
    });

    it('must delegate connect, disconnect and ready', async () => {
      let ready = false;
      sandbox
        .stub(WebActivityIframePort.prototype, 'disconnect')
        .callsFake(() => {
          connected = false;
        });
      sandbox
        .stub(WebActivityIframePort.prototype, 'whenReady')
        .callsFake(() => {
          ready = true;
          return Promise.resolve();
        });
      await activityIframePort.connect();
      expect(connected).to.be.true;

      await activityIframePort.whenReady();
      expect(ready).to.be.true;

      activityIframePort.disconnect();
      expect(connected).to.be.false;
    });

    it('should delegate getMode and attach callback to connect', async () => {
      const value = 'Hi!';
      sandbox.stub(WebActivityIframePort.prototype, 'getMode').returns(value);
      expect(activityIframePort.getMode()).to.equal(value);

      expect(handler).to.be.null;
      await activityIframePort.connect();
      expect(handler).to.not.be.null;
    });

    it('delegates acceptResult', async () => {
      const value = 'Hi!';
      sandbox
        .stub(WebActivityIframePort.prototype, 'acceptResult')
        .returns(value);
      expect(activityIframePort.acceptResult()).to.equal(value);
    });

    it('should handle resize request and delegate resized', () => {
      let resized = false;
      sandbox.stub(WebActivityIframePort.prototype, 'resized').callsFake(() => {
        resized = true;
      });
      activityIframePort.resized();
      expect(resized).to.be.true;

      let handler = null;
      sandbox
        .stub(WebActivityIframePort.prototype, 'onResizeRequest')
        .callsFake((arg) => {
          handler = arg;
        });
      let num;

      expect(handler).to.be.null;
      activityIframePort.onResizeRequest((n) => {
        num = n;
      });
      expect(handler).to.not.be.null;

      handler(1);
      expect(num).to.equal(1);
    });

    it('should allow registering callback after connect', async () => {
      expect(handler).to.be.null;
      await activityIframePort.connect();
      expect(handler).to.not.be.null;

      handler({'sku': 'daily'});
      await tick();
      handler({'sku': 'daily'});
    });

    it('should test new messaging APIs and auto register logging', async () => {
      let payload;
      let event = null;
      sandbox
        .stub(WebActivityIframePort.prototype, 'message')
        .callsFake((args) => {
          payload = args;
        });
      sandbox.stub(deps.eventManager(), 'logEvent').callsFake((clientEvent) => {
        event = clientEvent.eventType;
      });
      activityIframePort.execute(analyticsRequest);
      expect(payload).to.deep.equal({'REQUEST': serializedRequest});

      expect(handler).to.be.null;
      await activityIframePort.connect();
      expect(handler).to.not.be.null;

      handler({'RESPONSE': serializedRequest});
      expect(event).to.equal(AnalyticsEvent.UNKNOWN);
    });

    it('should support on APIs and register logging', async () => {
      expect(handler).to.be.null;
      await activityIframePort.connect();
      expect(handler).to.not.be.null;

      let event;
      sandbox.stub(deps.eventManager(), 'logEvent').callsFake((clientEvent) => {
        event = clientEvent.eventType;
      });
      handler({'RESPONSE': serializedRequest});
      expect(event).to.equal(AnalyticsEvent.UNKNOWN);
    });

    it('should complain about multiple listeners', async () => {
      await activityIframePort.connect();
      expect(() => activityIframePort.on(AnalyticsRequest, () => {})).to.throw(
        /Invalid type or duplicate callback for /
      );
    });

    it('should complain about bad request types', async () => {
      class fakeMe {}
      expect(() => activityIframePort.on(fakeMe, () => {})).to.throw(
        /Invalid data type/
      );
    });
  });
});
<|endoftext|>


<commit_before>import Icon from '../icon'
import Input from '../input'
import s from './dual-list.scss'

const DualList = {
  name: 'DualList',
  props: {
    items: {
      type: Array,
      default: () => {
        return []
      }
    },
    selectedItems: {
      type: Array,
      default: () => {
        return []
      }
    },
    caseInsensitive: {
      type: Boolean,
      default: false
    },
    apiMode: {
      type: Boolean,
      default: false
    },
    leftLoading: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      listToAdd: {},
      listToRemove: {},
      highlightLeft: '',
      highlightRight: '',
      leftList: {},
      rightList: {}
    }
  },
  mounted() {
    const { handleChange, handleClickOnX } = this
    const items = this.items.reduce((preVal, curVal, idx) => {
      return { ...preVal, [idx]: curVal }
    }, {})
    this.listToAdd = { ...items }
    this.leftList = { ...items }

    const leftNum = this.items.length
    const selectedItems = this.selectedItems.reduce((preVal, curVal, idx) => {
      return { ...preVal, [idx+leftNum]: curVal }
    }, {})
    this.rightList = { ...selectedItems }
    this.listToRemove = { ...selectedItems }
    this.$on('handleSingleDualListChange', handleChange)
    this.$on('handleClickOnX', handleClickOnX)
  },
  watch: {
    items() {
      this.leftList = this.listToAdd = this.items.reduce((preVal, curVal, idx) => {
        return { ...preVal, [idx]: curVal }
      }, {})
    }
  },
  methods: {
    handleChange(list) {
      if (list[0] === 'add') {
        delete this.leftList[list[2]]
        this.$set(this.rightList, list[2], list[1])
        delete this.listToAdd[list[2]]
        this.$set(this.listToRemove, list[2], list[1])
      } else if (list[0] === 'remove') {
        delete this.rightList[list[2]]
        this.$set(this.leftList, list[2], list[1])
        delete this.listToRemove[list[2]]
        this.$set(this.listToAdd, list[2], list[1])
      }
      this.$emit('change', this.rightList)
    },
    handleClickOnX(data) {
      if (data === 'left') {
        const { apiMode } = this
        if (apiMode) {
          this.$emit('remove')
        } else {
          this.listToAdd = this.leftList
          this.highlightLeft = ''
        }
      } else if (data === 'right') {
        this.listToRemove = this.rightList
        this.highlightRight = ''
      }
    },
    handleSelectAll() {
      const keys = Object.keys(this.listToAdd)
      keys.map((key) => {
        delete this.leftList[key]
      })
      this.rightList = { ...this.rightList, ...this.listToAdd }
      this.listToRemove = { ...this.listToRemove, ...this.listToAdd }
      this.listToAdd = {}
      this.$emit('change', this.rightList)
    },
    handleUnselectAll() {
      const keys = Object.keys(this.listToRemove)
      keys.map((key) => {
        delete this.rightList[key]
      })
      this.leftList = { ...this.leftList, ...this.listToRemove }
      this.listToAdd = { ...this.listToAdd, ...this.listToRemove }
      this.listToRemove = {}
      this.$emit('change', this.rightList)
    },
    handleSearchListLeft(e) {
      const { apiMode, caseInsensitive } = this
      if (e.charCode === 13) {
        if (apiMode) {
          this.$emit('inputchange', this.$refs.searchListToAdd.value)
        } else {
          const keys = (caseInsensitive)
          ? Object.keys(this.leftList).filter((key) => {
            return this.leftList[key].toLowerCase().includes(this.$refs.searchListToAdd.value.toLowerCase())
          })
          : Object.keys(this.leftList).filter((key) => {
            return this.leftList[key].includes(this.$refs.searchListToAdd.value)
          })

          this.listToAdd = keys.reduce((preVal, curVal) => {
            return { ...preVal, [curVal]: this.leftList[curVal] }
          }, {})
          this.highlightLeft = this.$refs.searchListToAdd.value
        }
      }
    },
    handleSearchListRight(e) {
      if (e.charCode === 13) {
        const { caseInsensitive } = this
        const keys = (caseInsensitive)
        ? Object.keys(this.rightList).filter((key) => {
          return this.rightList[key].toLowerCase().includes(this.$refs.searchListToRemove.value.toLowerCase())
        })
        : Object.keys(this.rightList).filter((key) => {
          return this.rightList[key].includes(this.$refs.searchListToRemove.value)
        })
        this.listToRemove = keys.reduce((preVal, curVal) => {
          return { ...preVal, [curVal]: this.rightList[curVal] }
        }, {})
        this.highlightRight = this.$refs.searchListToRemove.value
      }
    }
  },
  render(h) {
    const { handleSelectAll, handleUnselectAll, handleSearchListLeft, handleSearchListRight, highlightLeft, highlightRight, leftLoading } = this
    return (

          <Input nativeOn-keypress={handleSearchListLeft} name="left" class={[s.input]} ref="searchListToAdd" icon={['search', '#919799']} x={true} loading={leftLoading} />

            {
              Object.keys(this.listToAdd).map((label) => {
                return <List id={label} name={this.listToAdd[label]} highlight={highlightLeft} icon={['circle-add', '#b9e617']} />
              })
            }

          <Icon nativeOn-click={handleSelectAll} class={[s.doubleRight]} typ="double-right" size={20} fill="#b8bdbf" />
          <Icon nativeOn-click={handleUnselectAll} class={[s.doubleLeft]} typ="double-left" size={20} fill="#b8bdbf" />

          <Input nativeOn-keypress={handleSearchListRight} name="right" class={[s.input]} ref="searchListToRemove" icon={['search', '#919799']} x={true} />

            {
              Object.keys(this.listToRemove).map((label) => {
                return <List id={label} name={this.listToRemove[label]} highlight={highlightRight} icon={['circle-minus', '#e6175c']} />
              })
            }

    )
  }
}

const List = {
  name: 'List',
  props: {
    highlight: {
      type: String,
      default: '',
    },
    id: {
      type: String,
      default: '',
    },
    name: {
      type: String,
      default: '',
    },
    icon: {
      type: Array,
      default: ['circle-add', '#b9e617']
    }
  },

  computed: {
    highlightText() {
      const { highlight, name } = this
      let highlightText = ''
      if (highlight) {
        const highlightReg = new RegExp(`${highlight}`, 'gi')
        highlightText = `${name}`.replace(highlightReg, (replacement) => {
          return `<span class="${s.highlight}">${replacement}</span>`
        })
      }
      return highlightText
    }
  },

  methods: {
    handleClick() {
      const { $parent, name, id } = this
      const manipulation = (this.icon[0] === 'circle-add') ? 'add' : 'remove'
      const data = [manipulation, name, id]
      $parent.$emit('handleSingleDualListChange', data)
    },
  },

  render(h) {
    const { name, icon, highlight, handleClick, highlightText } = this
    return (

        {highlight
          ?
          :
        }
        <Icon class={[s.icon]} typ={icon[0]} size={18} fill={icon[1]} />

    )
  }
}

module.exports = DualList
<commit_msg>[DualList] change `items` and `selectedItems` to Array Object: component<commit_after>import Icon from '../icon'
import Input from '../input'
import s from './dual-list.scss'

const DualList = {
  name: 'DualList',
  props: {
    items: {
      type: Array,
      default: () => {
        return []
      }
    },
    selectedItems: {
      type: Array,
      default: () => {
        return []
      }
    },
    displayKey: {
      type: String,
      default: '',
      required: true
    },
    caseInsensitive: {
      type: Boolean,
      default: false
    },
    apiMode: {
      type: Boolean,
      default: false
    },
    leftLoading: {
      type: Boolean,
      default: false
    }
  },
  data() {
    return {
      listToAdd: {},
      listToRemove: {},
      highlightLeft: '',
      highlightRight: '',
      leftList: {},
      rightList: {}
    }
  },
  mounted() {
    const { handleChange, handleClickOnX } = this
    const items = this.items.reduce((preVal, curVal, idx) => {
      return { ...preVal, [idx]: curVal }
    }, {})
    this.listToAdd = { ...items }
    this.leftList = { ...items }

    const leftNum = this.items.length
    const selectedItems = this.selectedItems.reduce((preVal, curVal, idx) => {
      return { ...preVal, [idx+leftNum]: curVal }
    }, {})
    this.rightList = { ...selectedItems }
    this.listToRemove = { ...selectedItems }
    this.$on('handleSingleDualListChange', handleChange)
    this.$on('handleClickOnX', handleClickOnX)
  },
  watch: {
    items() {
      this.leftList = this.listToAdd = this.items.reduce((preVal, curVal, idx) => {
        return { ...preVal, [idx]: curVal }
      }, {})
    }
  },
  methods: {
    handleChange(list) {
      if (list[0] === 'add') {
        const shiftItem = this.leftList[list[2]]
        delete this.leftList[list[2]]
        this.rightList = { ...this.rightList, [list[2]]: shiftItem }
        delete this.listToAdd[list[2]]
        this.listToRemove = { ...this.listToRemove, [list[2]]: shiftItem }
      } else if (list[0] === 'remove') {
        const shiftItem = this.rightList[list[2]]
        delete this.rightList[list[2]]
        this.leftList = { ...this.leftList, [list[2]]: shiftItem }
        delete this.listToRemove[list[2]]
        this.listToAdd = { ...this.listToAdd, [list[2]]: shiftItem }
      }
      this.$emit('change', this.rightList)
    },
    handleClickOnX(data) {
      if (data === 'left') {
        const { apiMode } = this
        if (apiMode) {
          this.$emit('remove')
        } else {
          this.listToAdd = this.leftList
          this.highlightLeft = ''
        }
      } else if (data === 'right') {
        this.listToRemove = this.rightList
        this.highlightRight = ''
      }
    },
    handleSelectAll() {
      const keys = Object.keys(this.listToAdd)
      keys.map((key) => {
        delete this.leftList[key]
      })
      this.rightList = { ...this.rightList, ...this.listToAdd }
      this.listToRemove = { ...this.listToRemove, ...this.listToAdd }
      this.listToAdd = {}
      this.$emit('change', this.rightList)
    },
    handleUnselectAll() {
      const keys = Object.keys(this.listToRemove)
      keys.map((key) => {
        delete this.rightList[key]
      })
      this.leftList = { ...this.leftList, ...this.listToRemove }
      this.listToAdd = { ...this.listToAdd, ...this.listToRemove }
      this.listToRemove = {}
      this.$emit('change', this.rightList)
    },
    handleSearchListLeft(e) {
      const { apiMode, caseInsensitive, displayKey } = this
      if (e.charCode === 13) {
        if (apiMode) {
          this.$emit('inputchange', this.$refs.searchListToAdd.value)
        } else {
          const keys = (caseInsensitive)
          ? Object.keys(this.leftList).filter((key) => {
            return this.leftList[key][displayKey].toLowerCase().includes(this.$refs.searchListToAdd.value.toLowerCase())
          })
          : Object.keys(this.leftList).filter((key) => {
            return this.leftList[key][displayKey].includes(this.$refs.searchListToAdd.value)
          })

          this.listToAdd = keys.reduce((preVal, curVal) => {
            return { ...preVal, [curVal]: this.leftList[curVal] }
          }, {})
          this.highlightLeft = this.$refs.searchListToAdd.value
        }
      }
    },
    handleSearchListRight(e) {
      if (e.charCode === 13) {
        const { caseInsensitive, displayKey } = this
        const keys = (caseInsensitive)
        ? Object.keys(this.rightList).filter((key) => {
          return this.rightList[key][displayKey].toLowerCase().includes(this.$refs.searchListToRemove.value.toLowerCase())
        })
        : Object.keys(this.rightList).filter((key) => {
          return this.rightList[key][displayKey].includes(this.$refs.searchListToRemove.value)
        })
        this.listToRemove = keys.reduce((preVal, curVal) => {
          return { ...preVal, [curVal]: this.rightList[curVal] }
        }, {})
        this.highlightRight = this.$refs.searchListToRemove.value
      }
    }
  },
  render(h) {
    const { handleSelectAll, handleUnselectAll, handleSearchListLeft, handleSearchListRight, highlightLeft, highlightRight, leftLoading, displayKey } = this
    return (

          <Input nativeOn-keypress={handleSearchListLeft} name="left" class={[s.input]} ref="searchListToAdd" icon={['search', '#919799']} x={true} loading={leftLoading} />

            {
              Object.keys(this.listToAdd).map((label) => {
                return <List id={label} name={this.listToAdd[label][displayKey]} highlight={highlightLeft} icon={['circle-add', '#b9e617']} />
              })
            }

          <Icon nativeOn-click={handleSelectAll} class={[s.doubleRight]} typ="double-right" size={20} fill="#b8bdbf" />
          <Icon nativeOn-click={handleUnselectAll} class={[s.doubleLeft]} typ="double-left" size={20} fill="#b8bdbf" />

          <Input nativeOn-keypress={handleSearchListRight} name="right" class={[s.input]} ref="searchListToRemove" icon={['search', '#919799']} x={true} />

            {
              Object.keys(this.listToRemove).map((label) => {
                return <List id={label} name={this.listToRemove[label][displayKey]} highlight={highlightRight} icon={['circle-minus', '#e6175c']} />
              })
            }

    )
  }
}

const List = {
  name: 'List',
  props: {
    highlight: {
      type: String,
      default: '',
    },
    id: {
      type: String,
      default: '',
    },
    name: {
      type: String,
      default: '',
    },
    icon: {
      type: Array,
      default: ['circle-add', '#b9e617']
    }
  },

  computed: {
    highlightText() {
      const { highlight, name } = this
      let highlightText = ''
      if (highlight) {
        const highlightReg = new RegExp(`${highlight}`, 'gi')
        highlightText = `${name}`.replace(highlightReg, (replacement) => {
          return `<span class="${s.highlight}">${replacement}</span>`
        })
      }
      return highlightText
    }
  },

  methods: {
    handleClick() {
      const { $parent, name, id } = this
      const manipulation = (this.icon[0] === 'circle-add') ? 'add' : 'remove'
      const data = [manipulation, name, id]
      $parent.$emit('handleSingleDualListChange', data)
    },
  },

  render(h) {
    const { name, icon, highlight, handleClick, highlightText } = this
    return (

        {highlight
          ?
          :
        }
        <Icon class={[s.icon]} typ={icon[0]} size={18} fill={icon[1]} />

    )
  }
}

module.exports = DualList
<|endoftext|>


<commit_before>import React, { Component } from 'react'
import PropTypes from 'prop-types'

import config from '../config'
import { convert as convertMarkdown } from '../lib/markdown'
import bindEditor from '../lib/bind-editor'
import mergeAliases from '../lib/merge-aliases'
import takeSnapshot from '../lib/take-snapshot'

import Header from './header/Header'
import ViewMode from './header/ViewMode'
import { NewButton, PeersButton, NotificationsButton } from './header/buttons'
import EditorArea from './EditorArea'
import Status from './Status'
import DocViewer from './DocViewer'
import { toSnapshotUrl } from './SnapshotLink'

const debugScope = 'peer-star:collaboration:*'

const initialDocument = `
#### Welcome to PeerPad

This service allows you to write, collaborate and export markdown documents
directly in your browser!

Get started by start typing in the blue-pane to the right of this text. This
area will automatically start to reflect your new changes.

If you find any issues, please report them via GitHub here:
https://github.com/ipfs-shipyard/peer-pad/issues/new
`

// Status messages for the user to know what's going on
const stateStatuses = {
  IDLE: 'IDLE',
  NEEDS_SAVING: 'Needs saving...',
  SAVING: 'Saving...',
  SAVED: 'Saved!',
  TIMEOUT: 'Save timed out (NOT saved)',
  RECEIVING: 'Receiving data'
}

// How long time in MS we should wait before assuming the pinner timed out
const SAVE_TIMEOUT_MS = 1000 * 10

const stateColors = {
  [stateStatuses.NEEDS_SAVING]: '#e67e22',
  [stateStatuses.SAVING]: '#e67e22',
  [stateStatuses.SAVED]: '#2ecc71',
  [stateStatuses.TIMEOUT]: '#e74c3c',
  [stateStatuses.RECEIVING]: '#3498db'
}

const StatusIcon = ({stateStatus}) => {
  const size = '10px'
  return

}

const SavedStatus = ({stateStatus}) => {
  if (stateStatus === stateStatuses.IDLE) {
    return

  }
  return
<StatusIcon stateStatus={stateStatus}/> {stateStatus}

}

class Edit extends Component {
  constructor (props) {
    super(props)

    this._backend = props.backend
    const { type, name, keys } = props.match.params

    this.state = {
      name: decodeURIComponent(name),
      type: type,
      documentText: initialDocument,
      status: 'offline',
      room: {},
      canEdit: keys.split('-').length >= 2,
      encodedKeys: keys,
      viewMode: 'both',
      snapshots: [],
      alias: window.localStorage.getItem('alias'),
      doc: null,
      isDebuggingEnabled: !!window.localStorage.getItem('debug'),
      stateStatus: stateStatuses.IDLE
    }

    this.onViewModeChange = this.onViewModeChange.bind(this)
    this.onEditor = this.onEditor.bind(this)
    this.onEditorValueChange = this.onEditorValueChange.bind(this)
    this.onTakeSnapshot = this.onTakeSnapshot.bind(this)
    this.onAliasChange = this.onAliasChange.bind(this)
    this.onDebuggingStart = this.onDebuggingStart.bind(this)
    this.onDebuggingStop = this.onDebuggingStop.bind(this)
  }

  onViewModeChange (viewMode) {
    this.setState({ viewMode })
  }

  onEditor (nextEditor) {
    const { doc } = this.state

    // Unbind current editor if we have a current editor and a document
    if (this._editorBinding) {
      this._editorBinding() // release binding
      this._editorBinding = null
    }

    // Save the reference to the editor so we can unbind later or so we can
    // bind if there's no doc available yet
    this._editor = nextEditor

    // Bind new editor if not null and we have a document
    if (doc && nextEditor) {
      this._editorBinding = bindEditor(doc, this._titleRef, nextEditor, this.state.type)
    }
  }

  onEditorValueChange (documentText) {
    this.setState({ documentText })
  }

  async onTakeSnapshot () {
    try {
      const docScript = await (await window.fetch('static/js/viewer.bundle.js')).text()
      const options = {
        type: this.state.type,
        docScript,
        DocViewer
      }
      const keys = (await import('peer-star-app')).keys
      const snapshot = await takeSnapshot(keys, this.state.doc, options)
      snapshot.createdAt = new Date().toISOString()
      this.setState(({ snapshots }) => ({ snapshots: [snapshot, ...snapshots] }))
      this.prefetchSnapshot(snapshot)
      this.storeSnapshot(snapshot)
    } catch (err) {
      console.error(err)
      window.alert('Error taking snapshot: ' + err.message)
    }
  }

  loadSnapshots () {
    const key = `${this.state.name}-snapshots`
    const val = window.localStorage.getItem(key)
    if (!val) return []
    try {
      return JSON.parse(val)
    } catch (err) {
      console.error('Failed to load snapshots for pad', key, err)
      // bad data. clear out for a better future.
      window.localStorage.removeItem(key)
      return []
    }
  }

  storeSnapshot (snapshot) {
    const snapshots = this.loadSnapshots()
    const key = `${this.state.name}-snapshots`
    const val = JSON.stringify([snapshot, ...snapshots])
    window.localStorage.setItem(key, val)
  }

  // Prefetch snapshot from gateway to makes it load faster when user clicks a snap shot link
  async prefetchSnapshot (snapshot) {
    const url = toSnapshotUrl(snapshot)
    return window.fetch(url)
      .then((response) => {
        if (!response.ok) {
          throw new Error('Gateway response was not ok')
        }
      }).catch((err) => {
        console.log('Failed to pre-fetch snapshot', url, err)
      })
  }

  async onAliasChange (alias) {
    this.setState({ alias })
    const doc = this.state.doc
    // cache globally for other pads to be able to use
    window.localStorage.setItem('alias', alias)
    const aliasesCollab = await doc.sub('aliases', 'mvreg')
    let aliases = mergeAliases(aliasesCollab.shared.value())
    const myPeerId = (await doc.app.ipfs.id()).id
    aliases[myPeerId] = alias
    aliasesCollab.shared.write(aliases)
  }

  async onDebuggingStart () {
    (await import('peer-star-app')).debug.enable(debugScope)
    window.localStorage.setItem('debug', debugScope)
    console.log('debugging started')
    this.setState({isDebuggingEnabled: true})
  }

  async onDebuggingStop () {
    (await import('peer-star-app')).debug.disable()
    window.localStorage.setItem('debug', '')
    console.log('debugging stopped')
    this.setState({isDebuggingEnabled: false})
  }

  render () {
    const {
      name,
      type,
      // The editor contents is updated directly by peer-pad-core.
      // `documentText` is a cache of the last value we received.
      documentText,
      encodedKeys,
      status,
      canEdit,
      viewMode,
      snapshots,
      alias,
      isDebuggingEnabled
    } = this.state

    const {
      onEditor,
      onEditorValueChange,
      onViewModeChange,
      onTakeSnapshot,
      onDebuggingStart,
      onDebuggingStop
    } = this

    return (

            {type === 'richtext' ? null : (
              <ViewMode mode={viewMode} onChange={onViewModeChange} />
            )}

            <Status status={status} />

              <NewButton />

              <PeersButton doc={this.state.doc} alias={alias} onAliasChange={this.onAliasChange} canEdit={this.state.canEdit} />

              <NotificationsButton />

                  <input
                    ref={(ref) => { this._titleRef = ref }}
                    type='text'
                    className='input-reset sans-serif bw0 f4 blue-bayox w-100 pa0'
                    placeholder='Document Title'
                    readOnly={!canEdit}
                    data-id='document-title-input'
                  />

                  Last change: today, 12:00AM

                <SavedStatus stateStatus={this.state.stateStatus} />

            <EditorArea
              docName={name}
              docType={type}
              encodedKeys={encodedKeys}
              viewMode={viewMode}
              onEditor={onEditor}
              onEditorValueChange={onEditorValueChange}
              snapshots={snapshots}
              onTakeSnapshot={onTakeSnapshot}
              docText={documentText}
              convertMarkdown={(md) => convertMarkdown(md, type)}
              onDebuggingStart={onDebuggingStart}
              onDebuggingStop={onDebuggingStop}
              isDebuggingEnabled={isDebuggingEnabled}
            />

    )
  }

  async componentDidMount () {
    const PeerStar = await import('peer-star-app')

    if (!this._backend) {
      const peerStarConfig = window.__peerStarConfig ? window.__peerStarConfig : config.peerStar
      console.log('peer star config:', peerStarConfig)
      this._backend = PeerStar('peer-pad/2', peerStarConfig)
      this._backend.on('error', (err) => {
        console.error(err)
        window.alert(err.message)
      })
      await this._backend.start()
      this.props.onBackend(this._backend)
    }

    const keys = await PeerStar.keys.uriDecode(this.state.encodedKeys)

    const doc = await this._backend.collaborate(
      this.state.name,
      'rga',
      {
        keys
        // maxDeltaRetention: 0
      })

    let timeoutID = null
    doc.on('state changed', (fromSelf) => {
      if (fromSelf) {
        this.setState({stateStatus: stateStatuses.DIRTY})
      }
    })

    doc.replication.on('pinning', () => {
      this.setState({stateStatus: stateStatuses.SAVING})
      clearTimeout(timeoutID)
      timeoutID = setTimeout(() => {
        this.setState({stateStatus: stateStatuses.TIMEOUT})
      }, SAVE_TIMEOUT_MS)
    })

    doc.replication.on('receiving', () => {
      this.setState({stateStatus: stateStatuses.RECEIVING})
    })

    doc.replication.on('pinned', () => {
      clearTimeout(timeoutID)
      this.setState({stateStatus: stateStatuses.SAVED})
    })

    this.setState({ doc })

    doc.on('error', (err) => {
      console.log(err)
      window.alert(err.message)
    })

    // Watch for out local ipfs node to come online.
    if (this._backend.ipfs.isOnline()) {
      this.setState({ status: 'online' })
    } else {
      this._backend.ipfs.once('started', () => {
        this.onDebuggingStart() // activate debugging
        this.setState({ status: 'online' })
      })
    }

    await doc.start()

    this.maybeActivateEditor()

    // Bind the editor if we got an instance while the doc was starting

    // TODO: bind the editor to the document
    // if (this._editor) doc.bindEditor(this._editor)

    // Turn the doc title into a peer editable input.

    // Pull snapshots array out of localStorage into state.
    const snapshots = this.loadSnapshots()
    this.setState({snapshots})
  }

  componentWillUnmount () {
    if (this.state.doc) {
      this.state.doc.stop()
    }

    this._editor = null
  }

  maybeActivateEditor () {
    if (!this._editorBinding && this._editor) {
      this._editorBinding = bindEditor(this.state.doc, this._titleRef, this._editor, this.state.type)
      if (this.state.canEdit) {
        this._editor.setOption('readOnly', false)
      }
      this._editor.refresh()
    }

    // if (this._editor && this.state.canEdit) {
    //   switch (this.state.type) {
    //     case 'richtext':
    //       this._editor.enable()
    //       this._editor.focus()
    //       break
    //   }
    // }
  }
}

Edit.propTypes = {
  backend: PropTypes.object,
  onBackend: PropTypes.func
}

export default Edit
<commit_msg>fixes typo in edit status for needs saving<commit_after>import React, { Component } from 'react'
import PropTypes from 'prop-types'

import config from '../config'
import { convert as convertMarkdown } from '../lib/markdown'
import bindEditor from '../lib/bind-editor'
import mergeAliases from '../lib/merge-aliases'
import takeSnapshot from '../lib/take-snapshot'

import Header from './header/Header'
import ViewMode from './header/ViewMode'
import { NewButton, PeersButton, NotificationsButton } from './header/buttons'
import EditorArea from './EditorArea'
import Status from './Status'
import DocViewer from './DocViewer'
import { toSnapshotUrl } from './SnapshotLink'

const debugScope = 'peer-star:collaboration:*'

const initialDocument = `
#### Welcome to PeerPad

This service allows you to write, collaborate and export markdown documents
directly in your browser!

Get started by start typing in the blue-pane to the right of this text. This
area will automatically start to reflect your new changes.

If you find any issues, please report them via GitHub here:
https://github.com/ipfs-shipyard/peer-pad/issues/new
`

// Status messages for the user to know what's going on
const stateStatuses = {
  IDLE: 'IDLE',
  NEEDS_SAVING: 'Needs saving...',
  SAVING: 'Saving...',
  SAVED: 'Saved!',
  TIMEOUT: 'Save timed out (NOT saved)',
  RECEIVING: 'Receiving data'
}

// How long time in MS we should wait before assuming the pinner timed out
const SAVE_TIMEOUT_MS = 1000 * 10

const stateColors = {
  [stateStatuses.NEEDS_SAVING]: '#e67e22',
  [stateStatuses.SAVING]: '#e67e22',
  [stateStatuses.SAVED]: '#2ecc71',
  [stateStatuses.TIMEOUT]: '#e74c3c',
  [stateStatuses.RECEIVING]: '#3498db'
}

const StatusIcon = ({stateStatus}) => {
  const size = '10px'
  return

}

const SavedStatus = ({stateStatus}) => {
  if (stateStatus === stateStatuses.IDLE) {
    return

  }
  return
<StatusIcon stateStatus={stateStatus}/> {stateStatus}

}

class Edit extends Component {
  constructor (props) {
    super(props)

    this._backend = props.backend
    const { type, name, keys } = props.match.params

    this.state = {
      name: decodeURIComponent(name),
      type: type,
      documentText: initialDocument,
      status: 'offline',
      room: {},
      canEdit: keys.split('-').length >= 2,
      encodedKeys: keys,
      viewMode: 'both',
      snapshots: [],
      alias: window.localStorage.getItem('alias'),
      doc: null,
      isDebuggingEnabled: !!window.localStorage.getItem('debug'),
      stateStatus: stateStatuses.IDLE
    }

    this.onViewModeChange = this.onViewModeChange.bind(this)
    this.onEditor = this.onEditor.bind(this)
    this.onEditorValueChange = this.onEditorValueChange.bind(this)
    this.onTakeSnapshot = this.onTakeSnapshot.bind(this)
    this.onAliasChange = this.onAliasChange.bind(this)
    this.onDebuggingStart = this.onDebuggingStart.bind(this)
    this.onDebuggingStop = this.onDebuggingStop.bind(this)
  }

  onViewModeChange (viewMode) {
    this.setState({ viewMode })
  }

  onEditor (nextEditor) {
    const { doc } = this.state

    // Unbind current editor if we have a current editor and a document
    if (this._editorBinding) {
      this._editorBinding() // release binding
      this._editorBinding = null
    }

    // Save the reference to the editor so we can unbind later or so we can
    // bind if there's no doc available yet
    this._editor = nextEditor

    // Bind new editor if not null and we have a document
    if (doc && nextEditor) {
      this._editorBinding = bindEditor(doc, this._titleRef, nextEditor, this.state.type)
    }
  }

  onEditorValueChange (documentText) {
    this.setState({ documentText })
  }

  async onTakeSnapshot () {
    try {
      const docScript = await (await window.fetch('static/js/viewer.bundle.js')).text()
      const options = {
        type: this.state.type,
        docScript,
        DocViewer
      }
      const keys = (await import('peer-star-app')).keys
      const snapshot = await takeSnapshot(keys, this.state.doc, options)
      snapshot.createdAt = new Date().toISOString()
      this.setState(({ snapshots }) => ({ snapshots: [snapshot, ...snapshots] }))
      this.prefetchSnapshot(snapshot)
      this.storeSnapshot(snapshot)
    } catch (err) {
      console.error(err)
      window.alert('Error taking snapshot: ' + err.message)
    }
  }

  loadSnapshots () {
    const key = `${this.state.name}-snapshots`
    const val = window.localStorage.getItem(key)
    if (!val) return []
    try {
      return JSON.parse(val)
    } catch (err) {
      console.error('Failed to load snapshots for pad', key, err)
      // bad data. clear out for a better future.
      window.localStorage.removeItem(key)
      return []
    }
  }

  storeSnapshot (snapshot) {
    const snapshots = this.loadSnapshots()
    const key = `${this.state.name}-snapshots`
    const val = JSON.stringify([snapshot, ...snapshots])
    window.localStorage.setItem(key, val)
  }

  // Prefetch snapshot from gateway to makes it load faster when user clicks a snap shot link
  async prefetchSnapshot (snapshot) {
    const url = toSnapshotUrl(snapshot)
    return window.fetch(url)
      .then((response) => {
        if (!response.ok) {
          throw new Error('Gateway response was not ok')
        }
      }).catch((err) => {
        console.log('Failed to pre-fetch snapshot', url, err)
      })
  }

  async onAliasChange (alias) {
    this.setState({ alias })
    const doc = this.state.doc
    // cache globally for other pads to be able to use
    window.localStorage.setItem('alias', alias)
    const aliasesCollab = await doc.sub('aliases', 'mvreg')
    let aliases = mergeAliases(aliasesCollab.shared.value())
    const myPeerId = (await doc.app.ipfs.id()).id
    aliases[myPeerId] = alias
    aliasesCollab.shared.write(aliases)
  }

  async onDebuggingStart () {
    (await import('peer-star-app')).debug.enable(debugScope)
    window.localStorage.setItem('debug', debugScope)
    console.log('debugging started')
    this.setState({isDebuggingEnabled: true})
  }

  async onDebuggingStop () {
    (await import('peer-star-app')).debug.disable()
    window.localStorage.setItem('debug', '')
    console.log('debugging stopped')
    this.setState({isDebuggingEnabled: false})
  }

  render () {
    const {
      name,
      type,
      // The editor contents is updated directly by peer-pad-core.
      // `documentText` is a cache of the last value we received.
      documentText,
      encodedKeys,
      status,
      canEdit,
      viewMode,
      snapshots,
      alias,
      isDebuggingEnabled
    } = this.state

    const {
      onEditor,
      onEditorValueChange,
      onViewModeChange,
      onTakeSnapshot,
      onDebuggingStart,
      onDebuggingStop
    } = this

    return (

            {type === 'richtext' ? null : (
              <ViewMode mode={viewMode} onChange={onViewModeChange} />
            )}

            <Status status={status} />

              <NewButton />

              <PeersButton doc={this.state.doc} alias={alias} onAliasChange={this.onAliasChange} canEdit={this.state.canEdit} />

              <NotificationsButton />

                  <input
                    ref={(ref) => { this._titleRef = ref }}
                    type='text'
                    className='input-reset sans-serif bw0 f4 blue-bayox w-100 pa0'
                    placeholder='Document Title'
                    readOnly={!canEdit}
                    data-id='document-title-input'
                  />

                  Last change: today, 12:00AM

                <SavedStatus stateStatus={this.state.stateStatus} />

            <EditorArea
              docName={name}
              docType={type}
              encodedKeys={encodedKeys}
              viewMode={viewMode}
              onEditor={onEditor}
              onEditorValueChange={onEditorValueChange}
              snapshots={snapshots}
              onTakeSnapshot={onTakeSnapshot}
              docText={documentText}
              convertMarkdown={(md) => convertMarkdown(md, type)}
              onDebuggingStart={onDebuggingStart}
              onDebuggingStop={onDebuggingStop}
              isDebuggingEnabled={isDebuggingEnabled}
            />

    )
  }

  async componentDidMount () {
    const PeerStar = await import('peer-star-app')

    if (!this._backend) {
      const peerStarConfig = window.__peerStarConfig ? window.__peerStarConfig : config.peerStar
      console.log('peer star config:', peerStarConfig)
      this._backend = PeerStar('peer-pad/2', peerStarConfig)
      this._backend.on('error', (err) => {
        console.error(err)
        window.alert(err.message)
      })
      await this._backend.start()
      this.props.onBackend(this._backend)
    }

    const keys = await PeerStar.keys.uriDecode(this.state.encodedKeys)

    const doc = await this._backend.collaborate(
      this.state.name,
      'rga',
      {
        keys
        // maxDeltaRetention: 0
      })

    let timeoutID = null
    doc.on('state changed', (fromSelf) => {
      if (fromSelf) {
        this.setState({stateStatus: stateStatuses.NEEDS_SAVING})
      }
    })

    doc.replication.on('pinning', () => {
      this.setState({stateStatus: stateStatuses.SAVING})
      clearTimeout(timeoutID)
      timeoutID = setTimeout(() => {
        this.setState({stateStatus: stateStatuses.TIMEOUT})
      }, SAVE_TIMEOUT_MS)
    })

    doc.replication.on('receiving', () => {
      this.setState({stateStatus: stateStatuses.RECEIVING})
    })

    doc.replication.on('pinned', () => {
      clearTimeout(timeoutID)
      this.setState({stateStatus: stateStatuses.SAVED})
    })

    this.setState({ doc })

    doc.on('error', (err) => {
      console.log(err)
      window.alert(err.message)
    })

    // Watch for out local ipfs node to come online.
    if (this._backend.ipfs.isOnline()) {
      this.setState({ status: 'online' })
    } else {
      this._backend.ipfs.once('started', () => {
        this.onDebuggingStart() // activate debugging
        this.setState({ status: 'online' })
      })
    }

    await doc.start()

    this.maybeActivateEditor()

    // Bind the editor if we got an instance while the doc was starting

    // TODO: bind the editor to the document
    // if (this._editor) doc.bindEditor(this._editor)

    // Turn the doc title into a peer editable input.

    // Pull snapshots array out of localStorage into state.
    const snapshots = this.loadSnapshots()
    this.setState({snapshots})
  }

  componentWillUnmount () {
    if (this.state.doc) {
      this.state.doc.stop()
    }

    this._editor = null
  }

  maybeActivateEditor () {
    if (!this._editorBinding && this._editor) {
      this._editorBinding = bindEditor(this.state.doc, this._titleRef, this._editor, this.state.type)
      if (this.state.canEdit) {
        this._editor.setOption('readOnly', false)
      }
      this._editor.refresh()
    }

    // if (this._editor && this.state.canEdit) {
    //   switch (this.state.type) {
    //     case 'richtext':
    //       this._editor.enable()
    //       this._editor.focus()
    //       break
    //   }
    // }
  }
}

Edit.propTypes = {
  backend: PropTypes.object,
  onBackend: PropTypes.func
}

export default Edit
<|endoftext|>