useSelectOptions.ts 6.9 KB

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