mirror of
				https://github.com/crater-invoice/crater.git
				synced 2025-10-30 13:11:08 -04:00 
			
		
		
		
	
		
			
				
	
	
		
			627 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			627 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
| import { ref, toRefs, computed, watch, nextTick } from 'vue'
 | |
| import normalize from './../utils/normalize'
 | |
| import isObject from './../utils/isObject'
 | |
| import isNullish from './../utils/isNullish'
 | |
| import arraysEqual from './../utils/arraysEqual'
 | |
| 
 | |
| export default function useOptions(props, context, dep) {
 | |
|   const {
 | |
|     options, mode, trackBy, limit, hideSelected, createTag, label,
 | |
|     appendNewTag, multipleLabel, object, loading, delay, resolveOnLoad,
 | |
|     minChars, filterResults, clearOnSearch, clearOnSelect, valueProp,
 | |
|     canDeselect, max, strict, closeOnSelect, groups: groupped, groupLabel,
 | |
|     groupOptions, groupHideEmpty, groupSelect,
 | |
|   } = toRefs(props)
 | |
| 
 | |
|   // ============ DEPENDENCIES ============
 | |
| 
 | |
|   const iv = dep.iv
 | |
|   const ev = dep.ev
 | |
|   const search = dep.search
 | |
|   const clearSearch = dep.clearSearch
 | |
|   const update = dep.update
 | |
|   const pointer = dep.pointer
 | |
|   const clearPointer = dep.clearPointer
 | |
|   const blur = dep.blur
 | |
|   const deactivate = dep.deactivate
 | |
| 
 | |
|   // ================ DATA ================
 | |
| 
 | |
|   // no export
 | |
|   // appendedOptions
 | |
|   const ap = ref([])
 | |
| 
 | |
|   // no export
 | |
|   // resolvedOptions
 | |
|   const ro = ref([])
 | |
| 
 | |
|   const resolving = ref(false)
 | |
| 
 | |
|   // ============== COMPUTED ==============
 | |
| 
 | |
|   // no export
 | |
|   // extendedOptions
 | |
|   const eo = computed(() => {
 | |
|     if (groupped.value) {
 | |
|       let groups = ro.value || /* istanbul ignore next */[]
 | |
| 
 | |
|       let eo = []
 | |
| 
 | |
|       groups.forEach((group) => {
 | |
|         optionsToArray(group[groupOptions.value]).forEach((option) => {
 | |
|           eo.push(Object.assign({}, option, group.disabled ? { disabled: true } : {}))
 | |
|         })
 | |
|       })
 | |
| 
 | |
|       return eo
 | |
|     } else {
 | |
|       let eo = optionsToArray(ro.value || [])
 | |
| 
 | |
|       if (ap.value.length) {
 | |
|         eo = eo.concat(ap.value)
 | |
|       }
 | |
| 
 | |
|       return eo
 | |
|     }
 | |
|   })
 | |
| 
 | |
|   const fg = computed(() => {
 | |
|     if (!groupped.value) {
 | |
|       return []
 | |
|     }
 | |
| 
 | |
|     return filterGroups((ro.value || /* istanbul ignore next */[]).map((group) => {
 | |
|       const arrayOptions = optionsToArray(group[groupOptions.value])
 | |
| 
 | |
|       return {
 | |
|         ...group,
 | |
|         group: true,
 | |
|         [groupOptions.value]: filterOptions(arrayOptions, false).map(o => Object.assign({}, o, group.disabled ? { disabled: true } : {})),
 | |
|         __VISIBLE__: filterOptions(arrayOptions).map(o => Object.assign({}, o, group.disabled ? { disabled: true } : {})),
 | |
|       }
 | |
|       // Difference between __VISIBLE__ and {groupOptions}: visible does not contain selected options when hideSelected=true
 | |
|     }))
 | |
|   })
 | |
| 
 | |
|   // filteredOptions
 | |
|   const fo = computed(() => {
 | |
|     let options = eo.value
 | |
| 
 | |
|     if (createdTag.value.length) {
 | |
|       options = createdTag.value.concat(options)
 | |
|     }
 | |
| 
 | |
|     options = filterOptions(options)
 | |
| 
 | |
|     if (limit.value > 0) {
 | |
|       options = options.slice(0, limit.value)
 | |
|     }
 | |
| 
 | |
|     return options
 | |
|   })
 | |
| 
 | |
|   const hasSelected = computed(() => {
 | |
|     switch (mode.value) {
 | |
|       case 'single':
 | |
|         return !isNullish(iv.value[valueProp.value])
 | |
| 
 | |
|       case 'multiple':
 | |
|       case 'tags':
 | |
|         return !isNullish(iv.value) && iv.value.length > 0
 | |
|     }
 | |
|   })
 | |
| 
 | |
|   const multipleLabelText = computed(() => {
 | |
|     return multipleLabel !== undefined && multipleLabel.value !== undefined
 | |
|       ? multipleLabel.value(iv.value)
 | |
|       : (iv.value && iv.value.length > 1 ? `${iv.value.length} options selected` : `1 option selected`)
 | |
|   })
 | |
| 
 | |
|   const noOptions = computed(() => {
 | |
|     return !eo.value.length && !resolving.value && !createdTag.value.length
 | |
|   })
 | |
| 
 | |
| 
 | |
|   const noResults = computed(() => {
 | |
|     return eo.value.length > 0 && fo.value.length == 0 && ((search.value && groupped.value) || !groupped.value)
 | |
|   })
 | |
| 
 | |
|   // no export
 | |
|   const createdTag = computed(() => {
 | |
|     if (createTag.value === false || !search.value) {
 | |
|       return []
 | |
|     }
 | |
| 
 | |
|     return getOptionByTrackBy(search.value) !== -1 ? [] : [{
 | |
|       [valueProp.value]: search.value,
 | |
|       [label.value]: search.value,
 | |
|       [trackBy.value]: search.value,
 | |
|     }]
 | |
|   })
 | |
| 
 | |
|   // no export
 | |
|   const nullValue = computed(() => {
 | |
|     switch (mode.value) {
 | |
|       case 'single':
 | |
|         return null
 | |
| 
 | |
|       case 'multiple':
 | |
|       case 'tags':
 | |
|         return []
 | |
|     }
 | |
|   })
 | |
| 
 | |
|   const busy = computed(() => {
 | |
|     return loading.value || resolving.value
 | |
|   })
 | |
| 
 | |
|   // =============== METHODS ==============
 | |
| 
 | |
|   /**
 | |
|    * @param {array|object|string|number} option
 | |
|    */
 | |
|   const select = (option) => {
 | |
|     if (typeof option !== 'object') {
 | |
|       option = getOption(option)
 | |
|     }
 | |
| 
 | |
|     switch (mode.value) {
 | |
|       case 'single':
 | |
|         update(option)
 | |
|         break
 | |
| 
 | |
|       case 'multiple':
 | |
|       case 'tags':
 | |
|         update((iv.value).concat(option))
 | |
|         break
 | |
|     }
 | |
| 
 | |
|     context.emit('select', finalValue(option), option)
 | |
|   }
 | |
| 
 | |
|   const deselect = (option) => {
 | |
|     if (typeof option !== 'object') {
 | |
|       option = getOption(option)
 | |
|     }
 | |
| 
 | |
|     switch (mode.value) {
 | |
|       case 'single':
 | |
|         clear()
 | |
|         break
 | |
| 
 | |
|       case 'tags':
 | |
|       case 'multiple':
 | |
|         update(Array.isArray(option)
 | |
|           ? iv.value.filter(v => option.map(o => o[valueProp.value]).indexOf(v[valueProp.value]) === -1)
 | |
|           : iv.value.filter(v => v[valueProp.value] != option[valueProp.value]))
 | |
|         break
 | |
|     }
 | |
| 
 | |
|     context.emit('deselect', finalValue(option), option)
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const finalValue = (option) => {
 | |
|     return object.value ? option : option[valueProp.value]
 | |
|   }
 | |
| 
 | |
|   const remove = (option) => {
 | |
|     deselect(option)
 | |
|   }
 | |
| 
 | |
|   const handleTagRemove = (option, e) => {
 | |
|     if (e.button !== 0) {
 | |
|       e.preventDefault()
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     remove(option)
 | |
|   }
 | |
| 
 | |
|   const clear = () => {
 | |
|     context.emit('clear')
 | |
|     update(nullValue.value)
 | |
|   }
 | |
| 
 | |
|   const isSelected = (option) => {
 | |
|     if (option.group !== undefined) {
 | |
|       return mode.value === 'single' ? false : areAllSelected(option[groupOptions.value]) && option[groupOptions.value].length
 | |
|     }
 | |
| 
 | |
|     switch (mode.value) {
 | |
|       case 'single':
 | |
|         return !isNullish(iv.value) && iv.value[valueProp.value] == option[valueProp.value]
 | |
| 
 | |
|       case 'tags':
 | |
|       case 'multiple':
 | |
|         return !isNullish(iv.value) && iv.value.map(o => o[valueProp.value]).indexOf(option[valueProp.value]) !== -1
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const isDisabled = (option) => {
 | |
|     return option.disabled === true
 | |
|   }
 | |
| 
 | |
|   const isMax = () => {
 | |
|     if (max === undefined || max.value === -1 || (!hasSelected.value && max.value > 0)) {
 | |
|       return false
 | |
|     }
 | |
| 
 | |
|     return iv.value.length >= max.value
 | |
|   }
 | |
| 
 | |
|   const handleOptionClick = (option) => {
 | |
|     if (isDisabled(option)) {
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     switch (mode.value) {
 | |
|       case 'single':
 | |
|         if (isSelected(option)) {
 | |
|           if (canDeselect.value) {
 | |
|             deselect(option)
 | |
|           }
 | |
|           return
 | |
|         }
 | |
| 
 | |
|         blur()
 | |
|         select(option)
 | |
|         break
 | |
| 
 | |
|       case 'multiple':
 | |
|         if (isSelected(option)) {
 | |
|           deselect(option)
 | |
|           return
 | |
|         }
 | |
| 
 | |
|         if (isMax()) {
 | |
|           return
 | |
|         }
 | |
| 
 | |
|         select(option)
 | |
| 
 | |
|         if (clearOnSelect.value) {
 | |
|           clearSearch()
 | |
|         }
 | |
| 
 | |
|         if (hideSelected.value) {
 | |
|           clearPointer()
 | |
|         }
 | |
| 
 | |
|         // If we need to close the dropdown on select we also need
 | |
|         // to blur the input, otherwise further searches will not
 | |
|         // display any options
 | |
|         if (closeOnSelect.value) {
 | |
|           blur()
 | |
|         }
 | |
|         break
 | |
| 
 | |
|       case 'tags':
 | |
|         if (isSelected(option)) {
 | |
|           deselect(option)
 | |
|           return
 | |
|         }
 | |
| 
 | |
|         if (isMax()) {
 | |
|           return
 | |
|         }
 | |
| 
 | |
|         if (getOption(option[valueProp.value]) === undefined && createTag.value) {
 | |
|           context.emit('tag', option[valueProp.value])
 | |
| 
 | |
|           if (appendNewTag.value) {
 | |
|             appendOption(option)
 | |
|           }
 | |
| 
 | |
|           clearSearch()
 | |
|         }
 | |
| 
 | |
|         if (clearOnSelect.value) {
 | |
|           clearSearch()
 | |
|         }
 | |
| 
 | |
|         select(option)
 | |
| 
 | |
|         if (hideSelected.value) {
 | |
|           clearPointer()
 | |
|         }
 | |
| 
 | |
|         // If we need to close the dropdown on select we also need
 | |
|         // to blur the input, otherwise further searches will not
 | |
|         // display any options
 | |
|         if (closeOnSelect.value) {
 | |
|           blur()
 | |
|         }
 | |
|         break
 | |
|     }
 | |
| 
 | |
|     if (closeOnSelect.value) {
 | |
|       deactivate()
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const handleGroupClick = (group) => {
 | |
|     if (isDisabled(group) || mode.value === 'single' || !groupSelect.value) {
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     switch (mode.value) {
 | |
|       case 'multiple':
 | |
|       case 'tags':
 | |
|         if (areAllEnabledSelected(group[groupOptions.value])) {
 | |
|           deselect(group[groupOptions.value])
 | |
|         } else {
 | |
|           select(group[groupOptions.value]
 | |
|             .filter(o => iv.value.map(v => v[valueProp.value]).indexOf(o[valueProp.value]) === -1)
 | |
|             .filter(o => !o.disabled)
 | |
|             .filter((o, k) => iv.value.length + 1 + k <= max.value || max.value === -1)
 | |
|           )
 | |
|         }
 | |
|         break
 | |
|     }
 | |
| 
 | |
|     if (closeOnSelect.value) {
 | |
|       deactivate()
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const areAllEnabledSelected = (options) => {
 | |
|     return options.find(o => !isSelected(o) && !o.disabled) === undefined
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const areAllSelected = (options) => {
 | |
|     return options.find(o => !isSelected(o)) === undefined
 | |
|   }
 | |
| 
 | |
|   const getOption = (val) => {
 | |
|     return eo.value[eo.value.map(o => String(o[valueProp.value])).indexOf(String(val))]
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const getOptionByTrackBy = (val, norm = true) => {
 | |
|     return eo.value.map(o => o[trackBy.value]).indexOf(val)
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const shouldHideOption = (option) => {
 | |
|     return ['tags', 'multiple'].indexOf(mode.value) !== -1 && hideSelected.value && isSelected(option)
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const appendOption = (option) => {
 | |
|     ap.value.push(option)
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const filterGroups = (groups) => {
 | |
|     // If the search has value we need to filter among
 | |
|     // he ones that are visible to the user to avoid
 | |
|     // displaying groups which technically have options
 | |
|     // based on search but that option is already selected.
 | |
|     return groupHideEmpty.value
 | |
|       ? groups.filter(g => search.value
 | |
|         ? g.__VISIBLE__.length
 | |
|         : g[groupOptions.value].length
 | |
|       )
 | |
|       : groups.filter(g => search.value ? g.__VISIBLE__.length : true)
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const filterOptions = (options, excludeHideSelected = true) => {
 | |
|     let fo = options
 | |
| 
 | |
|     if (search.value && filterResults.value) {
 | |
|       fo = fo.filter((option) => {
 | |
|         return normalize(option[trackBy.value], strict.value).indexOf(normalize(search.value, strict.value)) !== -1
 | |
|       })
 | |
|     }
 | |
| 
 | |
|     if (hideSelected.value && excludeHideSelected) {
 | |
|       fo = fo.filter((option) => !shouldHideOption(option))
 | |
|     }
 | |
| 
 | |
|     return fo
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const optionsToArray = (options) => {
 | |
|     let uo = options
 | |
| 
 | |
|     // Transforming an object to an array of objects
 | |
|     if (isObject(uo)) {
 | |
|       uo = Object.keys(uo).map((key) => {
 | |
|         let val = uo[key]
 | |
| 
 | |
|         return { [valueProp.value]: key, [trackBy.value]: val, [label.value]: val }
 | |
|       })
 | |
|     }
 | |
| 
 | |
|     // Transforming an plain arrays to an array of objects
 | |
|     uo = uo.map((val) => {
 | |
|       return typeof val === 'object' ? val : { [valueProp.value]: val, [trackBy.value]: val, [label.value]: val }
 | |
|     })
 | |
| 
 | |
|     return uo
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const initInternalValue = () => {
 | |
|     if (!isNullish(ev.value)) {
 | |
|       iv.value = makeInternal(ev.value)
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const resolveOptions = (callback) => {
 | |
|     resolving.value = true
 | |
| 
 | |
|     options.value(search.value).then((response) => {
 | |
|       ro.value = response
 | |
| 
 | |
|       if (typeof callback == 'function') {
 | |
|         callback(response)
 | |
|       }
 | |
| 
 | |
|       resolving.value = false
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const refreshLabels = () => {
 | |
|     if (!hasSelected.value) {
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     if (mode.value === 'single') {
 | |
|       let newLabel = getOption(iv.value[valueProp.value])[label.value]
 | |
| 
 | |
|       iv.value[label.value] = newLabel
 | |
| 
 | |
|       if (object.value) {
 | |
|         ev.value[label.value] = newLabel
 | |
|       }
 | |
|     } else {
 | |
|       iv.value.forEach((val, i) => {
 | |
|         let newLabel = getOption(iv.value[i][valueProp.value])[label.value]
 | |
| 
 | |
|         iv.value[i][label.value] = newLabel
 | |
| 
 | |
|         if (object.value) {
 | |
|           ev.value[i][label.value] = newLabel
 | |
|         }
 | |
|       })
 | |
|     }
 | |
|   }
 | |
| 
 | |
|   const refreshOptions = (callback) => {
 | |
|     resolveOptions(callback)
 | |
|   }
 | |
| 
 | |
|   // no export
 | |
|   const makeInternal = (val) => {
 | |
|     if (isNullish(val)) {
 | |
|       return mode.value === 'single' ? {} : []
 | |
|     }
 | |
| 
 | |
|     if (object.value) {
 | |
|       return val
 | |
|     }
 | |
| 
 | |
|     // If external should be plain transform
 | |
|     // value object to plain values
 | |
|     return mode.value === 'single' ? getOption(val) || {} : val.filter(v => !!getOption(v)).map(v => getOption(v))
 | |
|   }
 | |
| 
 | |
|   // ================ HOOKS ===============
 | |
| 
 | |
|   if (mode.value !== 'single' && !isNullish(ev.value) && !Array.isArray(ev.value)) {
 | |
|     throw new Error(`v-model must be an array when using "${mode.value}" mode`)
 | |
|   }
 | |
| 
 | |
|   if (options && typeof options.value == 'function') {
 | |
|     if (resolveOnLoad.value) {
 | |
|       resolveOptions(initInternalValue)
 | |
|     } else if (object.value == true) {
 | |
|       initInternalValue()
 | |
|     }
 | |
|   }
 | |
|   else {
 | |
|     ro.value = options.value
 | |
| 
 | |
|     initInternalValue()
 | |
|   }
 | |
| 
 | |
|   // ============== WATCHERS ==============
 | |
| 
 | |
|   if (delay.value > -1) {
 | |
|     watch(search, (query) => {
 | |
|       if (query.length < minChars.value) {
 | |
|         return
 | |
|       }
 | |
| 
 | |
|       resolving.value = true
 | |
| 
 | |
|       if (clearOnSearch.value) {
 | |
|         ro.value = []
 | |
|       }
 | |
|       setTimeout(() => {
 | |
|         if (query != search.value) {
 | |
|           return
 | |
|         }
 | |
| 
 | |
|         options.value(search.value).then((response) => {
 | |
|           if (query == search.value) {
 | |
|             ro.value = response
 | |
|             pointer.value = fo.value.filter(o => o.disabled !== true)[0] || null
 | |
|             resolving.value = false
 | |
|           }
 | |
|         })
 | |
|       }, delay.value)
 | |
| 
 | |
|     }, { flush: 'sync' })
 | |
|   }
 | |
| 
 | |
|   watch(ev, (newValue) => {
 | |
|     if (isNullish(newValue)) {
 | |
|       iv.value = makeInternal(newValue)
 | |
|       return
 | |
|     }
 | |
| 
 | |
|     switch (mode.value) {
 | |
|       case 'single':
 | |
|         if (object.value ? newValue[valueProp.value] != iv.value[valueProp.value] : newValue != iv.value[valueProp.value]) {
 | |
|           iv.value = makeInternal(newValue)
 | |
|         }
 | |
|         break
 | |
| 
 | |
|       case 'multiple':
 | |
|       case 'tags':
 | |
|         if (!arraysEqual(object.value ? newValue.map(o => o[valueProp.value]) : newValue, iv.value.map(o => o[valueProp.value]))) {
 | |
|           iv.value = makeInternal(newValue)
 | |
|         }
 | |
|         break
 | |
|     }
 | |
|   }, { deep: true })
 | |
| 
 | |
|   if (typeof props.options !== 'function') {
 | |
|     watch(options, (n, o) => {
 | |
|       ro.value = props.options
 | |
| 
 | |
|       if (!Object.keys(iv.value).length) {
 | |
|         initInternalValue()
 | |
|       }
 | |
| 
 | |
|       refreshLabels()
 | |
|     })
 | |
|   }
 | |
| 
 | |
|   return {
 | |
|     fo,
 | |
|     filteredOptions: fo,
 | |
|     hasSelected,
 | |
|     multipleLabelText,
 | |
|     eo,
 | |
|     extendedOptions: eo,
 | |
|     fg,
 | |
|     filteredGroups: fg,
 | |
|     noOptions,
 | |
|     noResults,
 | |
|     resolving,
 | |
|     busy,
 | |
|     select,
 | |
|     deselect,
 | |
|     remove,
 | |
|     clear,
 | |
|     isSelected,
 | |
|     isDisabled,
 | |
|     isMax,
 | |
|     getOption,
 | |
|     handleOptionClick,
 | |
|     handleGroupClick,
 | |
|     handleTagRemove,
 | |
|     refreshOptions,
 | |
|     resolveOptions,
 | |
|   }
 | |
| }
 |