useSelectOptions.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. // Copyright (C) 2012-2023 Zammad Foundation, https://zammad-foundation.org/
  2. import { computed, ref, type Ref, watch } from 'vue'
  3. import { i18n } from '@shared/i18n'
  4. import { cloneDeep, keyBy } from 'lodash-es'
  5. import type {
  6. SelectOptionSorting,
  7. SelectOption,
  8. SelectValue,
  9. } from '../fields/FieldSelect'
  10. import type { FormFieldContext } from '../types/field'
  11. import type { FlatSelectOption } from '../fields/FieldTreeSelect'
  12. import type { AutoCompleteOption } from '../fields/FieldAutoComplete'
  13. import useValue from './useValue'
  14. const useSelectOptions = <
  15. T extends SelectOption[] | FlatSelectOption[] | AutoCompleteOption[],
  16. >(
  17. options: Ref<T>,
  18. context: Ref<
  19. FormFieldContext<{
  20. historicalOptions?: Record<string, string>
  21. multiple?: boolean
  22. noOptionsLabelTranslation?: boolean
  23. rejectNonExistentValues?: boolean
  24. sorting?: SelectOptionSorting
  25. }>
  26. >,
  27. ) => {
  28. const dialog = ref<HTMLElement>()
  29. const { currentValue, hasValue, valueContainer, clearValue } =
  30. useValue(context)
  31. const appendedOptions = ref<T>([] as unknown as T) as Ref<T>
  32. const availableOptions = computed(() => [
  33. ...(options.value || []),
  34. ...appendedOptions.value,
  35. ])
  36. const hasStatusProperty = computed(() =>
  37. availableOptions.value?.some(
  38. (option) => (option as SelectOption | FlatSelectOption).status,
  39. ),
  40. )
  41. const translatedOptions = computed(() => {
  42. if (!availableOptions.value) return []
  43. const { noOptionsLabelTranslation } = context.value
  44. return availableOptions.value.map((option) => {
  45. const label = noOptionsLabelTranslation
  46. ? option.label
  47. : i18n.t(option.label, ...(option.labelPlaceholder || []))
  48. const variant = option as AutoCompleteOption
  49. const heading = noOptionsLabelTranslation
  50. ? variant.heading
  51. : i18n.t(variant.heading, ...(variant.headingPlaceholder || []))
  52. return {
  53. ...option,
  54. label,
  55. heading,
  56. } as SelectOption | AutoCompleteOption
  57. })
  58. })
  59. const optionValueLookup = computed(() =>
  60. keyBy(translatedOptions.value, 'value'),
  61. )
  62. const sortedOptions = computed(() => {
  63. const { sorting } = context.value
  64. if (!sorting) return translatedOptions.value
  65. if (sorting !== 'label' && sorting !== 'value') {
  66. console.warn(`Unsupported sorting option "${sorting}"`)
  67. return translatedOptions.value
  68. }
  69. return [...translatedOptions.value]?.sort((a, b) => {
  70. const aLabelOrValue = a[sorting] || a.value
  71. const bLabelOrValue = b[sorting] || a.value
  72. return String(aLabelOrValue).localeCompare(String(bLabelOrValue))
  73. })
  74. })
  75. const getSelectedOption = (selectedValue: SelectValue) => {
  76. const key = selectedValue.toString()
  77. return optionValueLookup.value[key]
  78. }
  79. const getSelectedOptionIcon = (selectedValue: SelectValue) => {
  80. const option = getSelectedOption(selectedValue)
  81. return option?.icon as string
  82. }
  83. const getSelectedOptionLabel = (selectedValue: SelectValue) => {
  84. const option = getSelectedOption(selectedValue)
  85. return option?.label
  86. }
  87. const getSelectedOptionStatus = (selectedValue: SelectValue) => {
  88. const option = getSelectedOption(selectedValue) as
  89. | SelectOption
  90. | FlatSelectOption
  91. return option?.status
  92. }
  93. const selectOption = (option: T extends Array<infer V> ? V : never) => {
  94. if (!context.value.multiple) {
  95. context.value.node.input(option.value)
  96. return
  97. }
  98. const selectedValues = cloneDeep(currentValue.value) || []
  99. const optionIndex = selectedValues.indexOf(option.value)
  100. if (optionIndex !== -1) selectedValues.splice(optionIndex, 1)
  101. else selectedValues.push(option.value)
  102. selectedValues.sort(
  103. (a: string | number, b: string | number) =>
  104. sortedOptions.value.findIndex((option) => option.value === a) -
  105. sortedOptions.value.findIndex((option) => option.value === b),
  106. )
  107. context.value.node.input(selectedValues)
  108. }
  109. const getDialogFocusTargets = (optionsOnly?: boolean): HTMLElement[] => {
  110. const containerElement = dialog.value?.parentElement
  111. if (!containerElement) return []
  112. const targetElements = Array.from(
  113. containerElement.querySelectorAll<HTMLElement>('[tabindex="0"]'),
  114. )
  115. if (!targetElements) return []
  116. if (optionsOnly)
  117. return targetElements.filter(
  118. (targetElement) =>
  119. targetElement.attributes.getNamedItem('role')?.value === 'option',
  120. )
  121. return targetElements
  122. }
  123. const handleValuesForNonExistingOptions = () => {
  124. if (!hasValue.value) return
  125. if (context.value.multiple) {
  126. const availableValues = currentValue.value.filter(
  127. (selectValue: string | number) =>
  128. typeof optionValueLookup.value[selectValue] !== 'undefined',
  129. ) as SelectValue[]
  130. if (availableValues.length !== currentValue.value.length) {
  131. context.value.node.input(availableValues, false)
  132. }
  133. return
  134. }
  135. if (typeof optionValueLookup.value[currentValue.value] === 'undefined')
  136. clearValue(false)
  137. }
  138. // Setup a mechanism to handle missing options, including:
  139. // - appending historical options for current values
  140. // - clearing value in case options are missing
  141. const setupMissingOptionHandling = () => {
  142. const { historicalOptions } = context.value
  143. // Append historical options to the list of available options, if:
  144. // - non-existent values are not supposed to be rejected
  145. // - we have a current value
  146. // - we have a list of historical options
  147. if (
  148. !context.value.rejectNonExistentValues &&
  149. hasValue.value &&
  150. historicalOptions
  151. ) {
  152. appendedOptions.value = valueContainer.value.reduce(
  153. (accumulator: SelectOption[], value: SelectValue) => {
  154. const label = historicalOptions[value.toString()]
  155. // Make sure the options are not duplicated!
  156. if (
  157. label &&
  158. !options.value.some((option) => option.value === value)
  159. ) {
  160. accumulator.push({ value, label })
  161. }
  162. return accumulator
  163. },
  164. [],
  165. )
  166. }
  167. // Reject non-existent values during the initialization phase.
  168. // Note that this behavior is controlled by a dedicated flag.
  169. if (context.value.rejectNonExistentValues)
  170. handleValuesForNonExistingOptions()
  171. // Set up a watcher that clears a missing option value on subsequent mutations of the options prop.
  172. // In this case, the dedicated flag is ignored.
  173. watch(() => options.value, handleValuesForNonExistingOptions)
  174. }
  175. return {
  176. dialog,
  177. hasStatusProperty,
  178. translatedOptions,
  179. optionValueLookup,
  180. sortedOptions,
  181. getSelectedOption,
  182. getSelectedOptionIcon,
  183. getSelectedOptionLabel,
  184. getSelectedOptionStatus,
  185. selectOption,
  186. getDialogFocusTargets,
  187. setupMissingOptionHandling,
  188. appendedOptions,
  189. }
  190. }
  191. export default useSelectOptions