useSelectOptions.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322
  1. // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
  2. import { computed, ref, type Ref, watch } from 'vue'
  3. import { i18n } from '#shared/i18n.ts'
  4. import { cloneDeep, keyBy } from 'lodash-es'
  5. import type { AutoCompleteOption } from '#shared/components/Form/fields/FieldAutocomplete/types'
  6. import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
  7. import useValue from '#shared/components/Form/composables/useValue.ts'
  8. import type {
  9. SelectOption,
  10. SelectValue,
  11. } from '#shared/components/CommonSelect/types.ts'
  12. import type { SelectOptionSorting } from '#shared/components/Form/fields/FieldSelect/types.ts'
  13. import type { FlatSelectOption } from '#shared/components/Form/fields/FieldTreeSelect/types.ts'
  14. type AllowedSelectValue = SelectValue | Record<string, unknown>
  15. const useSelectOptions = <
  16. T extends SelectOption[] | FlatSelectOption[] | AutoCompleteOption[],
  17. >(
  18. options: Ref<T>,
  19. context: Ref<
  20. FormFieldContext<{
  21. historicalOptions?: Record<string, string>
  22. multiple?: boolean
  23. noOptionsLabelTranslation?: boolean
  24. rejectNonExistentValues?: boolean
  25. sorting?: SelectOptionSorting
  26. complexValue?: boolean
  27. }>
  28. >,
  29. ) => {
  30. const dialog = ref<HTMLElement>()
  31. const { currentValue, hasValue, valueContainer, clearValue } =
  32. useValue(context)
  33. const appendedOptions = ref<T>([] as unknown as T) as Ref<T>
  34. const availableOptions = computed(() => [
  35. ...(options.value || []),
  36. ...appendedOptions.value,
  37. ])
  38. const hasStatusProperty = computed(() =>
  39. availableOptions.value?.some(
  40. (option) => (option as SelectOption | FlatSelectOption).status,
  41. ),
  42. )
  43. const translatedOptions = computed(() => {
  44. if (!availableOptions.value) return []
  45. const { noOptionsLabelTranslation } = context.value
  46. return availableOptions.value.map((option) => {
  47. const label = noOptionsLabelTranslation
  48. ? option.label
  49. : i18n.t(option.label, ...(option.labelPlaceholder || []))
  50. const variant = option as AutoCompleteOption
  51. const heading = noOptionsLabelTranslation
  52. ? variant.heading
  53. : i18n.t(variant.heading, ...(variant.headingPlaceholder || []))
  54. return {
  55. ...option,
  56. label,
  57. heading,
  58. }
  59. })
  60. })
  61. const optionValueLookup = computed(() =>
  62. keyBy(translatedOptions.value, 'value'),
  63. )
  64. const sortedOptions = computed(() => {
  65. const { sorting } = context.value
  66. if (!sorting) return translatedOptions.value
  67. if (sorting !== 'label' && sorting !== 'value') {
  68. console.warn(`Unsupported sorting option "${sorting}"`)
  69. return translatedOptions.value
  70. }
  71. return [...translatedOptions.value]?.sort((a, b) => {
  72. const aLabelOrValue = a[sorting] || a.value
  73. const bLabelOrValue = b[sorting] || a.value
  74. return String(aLabelOrValue).localeCompare(String(bLabelOrValue))
  75. })
  76. })
  77. const getSelectedOption = (selectedValue: AllowedSelectValue): T[number] => {
  78. if (typeof selectedValue === 'object' && selectedValue !== null)
  79. return selectedValue as unknown as T[number]
  80. const key = selectedValue.toString()
  81. return optionValueLookup.value[key]
  82. }
  83. const getSelectedOptionIcon = (selectedValue: AllowedSelectValue) => {
  84. const option = getSelectedOption(selectedValue)
  85. return option?.icon as string
  86. }
  87. const getSelectedOptionValue = (selectedValue: AllowedSelectValue) => {
  88. if (typeof selectedValue !== 'object') return selectedValue
  89. const option = getSelectedOption(selectedValue)
  90. return option?.value
  91. }
  92. const getSelectedOptionLabel = (selectedValue: AllowedSelectValue) => {
  93. const option = getSelectedOption(selectedValue)
  94. return option?.label
  95. }
  96. const getSelectedOptionStatus = (selectedValue: AllowedSelectValue) => {
  97. const option = getSelectedOption(selectedValue) as
  98. | SelectOption
  99. | FlatSelectOption
  100. return option?.status
  101. }
  102. const getSelectedOptionParents = (
  103. selectedValue: string | number,
  104. ): SelectValue[] =>
  105. (optionValueLookup.value[selectedValue] &&
  106. (optionValueLookup.value[selectedValue] as FlatSelectOption).parents) ||
  107. []
  108. const getSelectedOptionFullPath = (selectedValue: string | number) =>
  109. getSelectedOptionParents(selectedValue)
  110. .map((parentValue) => `${getSelectedOptionLabel(parentValue)} \u203A `)
  111. .join('') +
  112. (getSelectedOptionLabel(selectedValue) ||
  113. i18n.t('%s (unknown)', selectedValue.toString()))
  114. const valueBuilder = (option: SelectOption): AllowedSelectValue => {
  115. return context.value.complexValue
  116. ? { value: option.value, label: option.label }
  117. : option.value
  118. }
  119. const selectOption = (option: T extends Array<infer V> ? V : never) => {
  120. if (!context.value.multiple) {
  121. context.value.node.input(valueBuilder(option))
  122. return
  123. }
  124. const selectedValues = cloneDeep(currentValue.value) || []
  125. const optionIndex = selectedValues.indexOf(option.value)
  126. if (optionIndex !== -1) selectedValues.splice(optionIndex, 1)
  127. else selectedValues.push(valueBuilder(option))
  128. selectedValues.sort(
  129. (a: string | number, b: string | number) =>
  130. sortedOptions.value.findIndex((option) => option.value === a) -
  131. sortedOptions.value.findIndex((option) => option.value === b),
  132. )
  133. context.value.node.input(selectedValues)
  134. }
  135. const getDialogFocusTargets = (optionsOnly?: boolean): HTMLElement[] => {
  136. const containerElement = dialog.value?.parentElement
  137. if (!containerElement) return []
  138. const targetElements = Array.from(
  139. containerElement.querySelectorAll<HTMLElement>('[tabindex="0"]'),
  140. )
  141. if (!targetElements) return []
  142. if (optionsOnly)
  143. return targetElements.filter(
  144. (targetElement) =>
  145. targetElement.attributes.getNamedItem('role')?.value === 'option',
  146. )
  147. return targetElements
  148. }
  149. const handleValuesForNonExistingOrDisabledOptions = (
  150. rejectNonExistentValues?: boolean,
  151. ) => {
  152. if (!hasValue.value || context.value.pendingValueUpdate) return
  153. const localRejectNonExistentValues = rejectNonExistentValues ?? true
  154. if (context.value.multiple) {
  155. const availableValues = currentValue.value.filter(
  156. (selectValue: string | number) => {
  157. const selectValueOption = optionValueLookup.value[selectValue]
  158. return (
  159. (localRejectNonExistentValues &&
  160. typeof selectValueOption !== 'undefined' &&
  161. selectValueOption?.disabled !== true) ||
  162. (!localRejectNonExistentValues &&
  163. selectValueOption?.disabled !== true)
  164. )
  165. },
  166. ) as SelectValue[]
  167. if (availableValues.length !== currentValue.value.length) {
  168. context.value.node.input(availableValues, false)
  169. }
  170. return
  171. }
  172. const currentValueOption = optionValueLookup.value[currentValue.value]
  173. if (
  174. (localRejectNonExistentValues &&
  175. typeof currentValueOption === 'undefined') ||
  176. currentValueOption?.disabled
  177. )
  178. clearValue(false)
  179. }
  180. // Setup a mechanism to handle missing and disabled options, including:
  181. // - appending historical options for current values
  182. // - clearing value in case options are missing
  183. const setupMissingOrDisabledOptionHandling = () => {
  184. const { historicalOptions } = context.value
  185. // When we are in a "create" form situation and no 'rejectNonExistentValues' flag
  186. // is given, it should be activated.
  187. if (context.value.rejectNonExistentValues === undefined) {
  188. const rootNode = context.value.node.at('$root')
  189. context.value.rejectNonExistentValues =
  190. rootNode &&
  191. rootNode.name !== context.value.node.name &&
  192. !rootNode.context?.initialEntityObject
  193. }
  194. // Remember current optionValueLookup in node context.
  195. context.value.optionValueLookup = optionValueLookup
  196. // TODO: Workaround for empty string, because currently the "nulloption" exists also for multiselect fields (#4513).
  197. if (context.value.multiple) {
  198. watch(
  199. () =>
  200. hasValue.value &&
  201. valueContainer.value.includes('') &&
  202. context.value.clearable &&
  203. !options.value.some((option) => option.value === ''),
  204. () => {
  205. const emptyOption: SelectOption = {
  206. value: '',
  207. label: '-',
  208. }
  209. ;(appendedOptions.value as SelectOption[]).unshift(emptyOption)
  210. },
  211. )
  212. }
  213. // Append historical options to the list of available options, if:
  214. // - non-existent values are not supposed to be rejected
  215. // - we have a current value
  216. // - we have a list of historical options
  217. if (
  218. !context.value.rejectNonExistentValues &&
  219. hasValue.value &&
  220. historicalOptions
  221. ) {
  222. appendedOptions.value = valueContainer.value.reduce(
  223. (accumulator: SelectOption[], value: SelectValue) => {
  224. const label = historicalOptions[value.toString()]
  225. // Make sure the options are not duplicated!
  226. if (
  227. label &&
  228. !options.value.some((option) => option.value === value)
  229. ) {
  230. accumulator.push({ value, label })
  231. }
  232. // TODO: Workaround, because currently the "nulloption" exists also for multiselect fields (#4513).
  233. else if (
  234. context.value.multiple &&
  235. !label &&
  236. value === '' &&
  237. !options.value.some((option) => option.value === value)
  238. ) {
  239. accumulator.unshift({ value, label: '-' })
  240. }
  241. return accumulator
  242. },
  243. [],
  244. )
  245. }
  246. // Reject non-existent or disabled option values during the initialization phase (note that
  247. // the non-existent values behavior is controlled by a dedicated flag).
  248. handleValuesForNonExistingOrDisabledOptions(
  249. context.value.rejectNonExistentValues,
  250. )
  251. // Set up a watcher that clears a missing option value or disabled options on subsequent mutations
  252. // of the options prop (in this case, the dedicated "rejectNonExistentValues" flag is ignored).
  253. watch(options, () => handleValuesForNonExistingOrDisabledOptions())
  254. }
  255. return {
  256. dialog,
  257. hasStatusProperty,
  258. translatedOptions,
  259. optionValueLookup,
  260. sortedOptions,
  261. getSelectedOption,
  262. getSelectedOptionValue,
  263. getSelectedOptionIcon,
  264. getSelectedOptionLabel,
  265. getSelectedOptionStatus,
  266. getSelectedOptionParents,
  267. getSelectedOptionFullPath,
  268. selectOption,
  269. getDialogFocusTargets,
  270. setupMissingOrDisabledOptionHandling,
  271. appendedOptions,
  272. }
  273. }
  274. export default useSelectOptions