useSelectOptions.ts 11 KB

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