FieldAutoCompleteInput.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627
  1. <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { useLazyQuery } from '@vue/apollo-composable'
  4. import {
  5. refDebounced,
  6. useElementBounding,
  7. useWindowSize,
  8. watchOnce,
  9. } from '@vueuse/core'
  10. import gql from 'graphql-tag'
  11. import { cloneDeep, escapeRegExp, isEqual } from 'lodash-es'
  12. import {
  13. computed,
  14. markRaw,
  15. nextTick,
  16. onMounted,
  17. ref,
  18. toRef,
  19. watch,
  20. type ConcreteComponent,
  21. type Ref,
  22. } from 'vue'
  23. import type { SelectOption } from '#shared/components/CommonSelect/types'
  24. import useValue from '#shared/components/Form/composables/useValue.ts'
  25. import type {
  26. AutoCompleteOption,
  27. AutocompleteSelectValue,
  28. } from '#shared/components/Form/fields/FieldAutocomplete/types.ts'
  29. import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
  30. import useSelectOptions from '#shared/composables/useSelectOptions.ts'
  31. import { useTrapTab } from '#shared/composables/useTrapTab.ts'
  32. import { useFormBlock } from '#shared/form/useFormBlock.ts'
  33. import { i18n } from '#shared/i18n.ts'
  34. import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
  35. import type { ObjectLike } from '#shared/types/utils.ts'
  36. import CommonInputSearch from '#desktop/components/CommonInputSearch/CommonInputSearch.vue'
  37. import CommonSelect from '#desktop/components/CommonSelect/CommonSelect.vue'
  38. import type { CommonSelectInstance } from '#desktop/components/CommonSelect/types'
  39. import FieldAutoCompleteOptionIcon from './FieldAutoCompleteOptionIcon.vue'
  40. import type {
  41. AutoCompleteProps,
  42. SelectOptionFunction,
  43. AutoCompleteOptionValueDictionary,
  44. } from './types.ts'
  45. import type { FormKitNode } from '@formkit/core'
  46. import type { NameNode, OperationDefinitionNode, SelectionNode } from 'graphql'
  47. interface Props {
  48. context: FormFieldContext<AutoCompleteProps>
  49. }
  50. const emit = defineEmits<{
  51. searchInteractionUpdate: [
  52. filter: string,
  53. optionValues: AutoCompleteOptionValueDictionary,
  54. selectOption: SelectOptionFunction,
  55. ]
  56. }>()
  57. const props = defineProps<Props>()
  58. const contextReactive = toRef(props, 'context')
  59. const { hasValue, valueContainer, currentValue, isCurrentValue, clearValue } =
  60. useValue<AutocompleteSelectValue>(contextReactive)
  61. // TODO: I think clearValue needs to wrapper for the full clear of the field (to remove some of the remembered stuff).
  62. const localOptions = ref(props.context.options || [])
  63. const {
  64. sortedOptions,
  65. appendedOptions,
  66. optionValueLookup,
  67. getSelectedOption,
  68. getSelectedOptionLabel,
  69. } = useSelectOptions<AutoCompleteOption[]>(localOptions, contextReactive)
  70. let areLocalOptionsReplaced = false
  71. const replacementLocalOptions: Ref<AutoCompleteOption[]> = ref(
  72. cloneDeep(localOptions),
  73. )
  74. onMounted(() => {
  75. if (props.context.options && areLocalOptionsReplaced) {
  76. replacementLocalOptions.value = [...props.context.options]
  77. }
  78. })
  79. watch(
  80. () => props.context.options,
  81. (options) => {
  82. localOptions.value = options || []
  83. },
  84. )
  85. // Remember current optionValueLookup in node context.
  86. contextReactive.value.optionValueLookup = optionValueLookup
  87. // Initial options prefill for non-multiple fields (multiple fields needs to be handled in
  88. // the form updater or via options prop).
  89. let rememberedInitialOptionFromBuilder: AutoCompleteOption | undefined
  90. const initialOptionBuilderHandler = (rootNode: FormKitNode) => {
  91. if (
  92. hasValue.value &&
  93. props.context.initialOptionBuilder &&
  94. !getSelectedOptionLabel(currentValue.value)
  95. ) {
  96. const initialOption = props.context.initialOptionBuilder(
  97. rootNode?.context?.initialEntityObject as ObjectLike,
  98. currentValue.value,
  99. props.context,
  100. )
  101. if (initialOption) {
  102. localOptions.value.push(initialOption)
  103. if (rememberedInitialOptionFromBuilder) {
  104. const rememberedOptionValue = rememberedInitialOptionFromBuilder.value
  105. localOptions.value = localOptions.value.filter(
  106. (option) => option.value !== rememberedOptionValue,
  107. )
  108. }
  109. rememberedInitialOptionFromBuilder = initialOption
  110. }
  111. }
  112. }
  113. if (!props.context.multiple && props.context.initialOptionBuilder) {
  114. const rootNode = props.context.node.at('$root')
  115. if (rootNode) {
  116. initialOptionBuilderHandler(rootNode)
  117. rootNode?.on('reset', ({ origin }) => {
  118. initialOptionBuilderHandler(origin)
  119. })
  120. }
  121. }
  122. const input = ref<HTMLDivElement>()
  123. const outputElement = ref<HTMLOutputElement>()
  124. const filter = ref('')
  125. const filterInput = ref<HTMLInputElement>()
  126. const select = ref<CommonSelectInstance>()
  127. const { activateTabTrap, deactivateTabTrap } = useTrapTab(input, true)
  128. const clearFilter = () => {
  129. filter.value = ''
  130. }
  131. const trimmedFilter = computed(() => filter.value.trim())
  132. const debouncedFilter = refDebounced(
  133. trimmedFilter,
  134. props.context.debounceInterval ?? 500,
  135. )
  136. const AutocompleteSearchDocument = gql`
  137. ${props.context.gqlQuery}
  138. `
  139. const additionalQueryParams = () => {
  140. if (typeof props.context.additionalQueryParams === 'function') {
  141. return props.context.additionalQueryParams()
  142. }
  143. return props.context.additionalQueryParams || {}
  144. }
  145. const autocompleteQueryHandler = new QueryHandler(
  146. useLazyQuery(
  147. AutocompleteSearchDocument,
  148. () => ({
  149. input: {
  150. query: debouncedFilter.value || props.context.defaultFilter || '',
  151. limit: props.context.limit,
  152. ...(additionalQueryParams() || {}),
  153. },
  154. }),
  155. () => ({
  156. enabled: !!(debouncedFilter.value || props.context.defaultFilter),
  157. cachePolicy: 'no-cache', // Do not use cache, because we want always up-to-date results.
  158. }),
  159. ),
  160. )
  161. if (props.context.defaultFilter) {
  162. autocompleteQueryHandler.load()
  163. } else {
  164. watchOnce(
  165. () => debouncedFilter.value,
  166. (newValue) => {
  167. if (!newValue.length) return
  168. autocompleteQueryHandler.load()
  169. },
  170. )
  171. }
  172. const autocompleteQueryResultKey = (
  173. (AutocompleteSearchDocument.definitions[0] as OperationDefinitionNode)
  174. .selectionSet.selections[0] as SelectionNode & { name: NameNode }
  175. ).name.value
  176. const autocompleteQueryResultOptions = computed<AutoCompleteOption[]>(
  177. (oldValue) => {
  178. const resultOptions =
  179. autocompleteQueryHandler.result().value?.[autocompleteQueryResultKey] ||
  180. []
  181. if (oldValue && isEqual(oldValue, resultOptions)) return oldValue
  182. return resultOptions
  183. },
  184. )
  185. const autocompleteOptions = computed(
  186. () => cloneDeep(autocompleteQueryResultOptions.value) || [],
  187. )
  188. const {
  189. sortedOptions: sortedAutocompleteOptions,
  190. selectOption: selectAutocompleteOption,
  191. getSelectedOption: getSelectedAutocompleteOption,
  192. getSelectedOptionIcon: getSelectedAutocompleteOptionIcon,
  193. optionValueLookup: autocompleteOptionValueLookup,
  194. } = useSelectOptions<AutoCompleteOption[]>(
  195. autocompleteOptions,
  196. toRef(props, 'context'),
  197. )
  198. const preprocessedAutocompleteOptions = computed(() => {
  199. if (!props.context.autocompleteOptionsPreprocessor)
  200. return sortedAutocompleteOptions.value
  201. return props.context.autocompleteOptionsPreprocessor(
  202. sortedAutocompleteOptions.value,
  203. )
  204. })
  205. const selectOption = (option: SelectOption, focus = false) => {
  206. selectAutocompleteOption(option as AutoCompleteOption)
  207. if (!props.context.multiple) {
  208. localOptions.value = [option as AutoCompleteOption]
  209. select.value?.closeDropdown()
  210. return
  211. }
  212. // If the current value contains the selected option, make sure it's added to the replacement list
  213. // if it's not already there.
  214. if (
  215. isCurrentValue(option.value) &&
  216. !replacementLocalOptions.value.some(
  217. (replacementLocalOption) => replacementLocalOption.value === option.value,
  218. )
  219. ) {
  220. replacementLocalOptions.value.push(option as AutoCompleteOption)
  221. }
  222. // Remove any extra options from the replacement list.
  223. replacementLocalOptions.value = replacementLocalOptions.value.filter(
  224. (replacementLocalOption) => isCurrentValue(replacementLocalOption.value),
  225. )
  226. if (!sortedOptions.value.some((elem) => elem.value === option.value)) {
  227. appendedOptions.value.push(option as AutoCompleteOption)
  228. }
  229. appendedOptions.value = appendedOptions.value.filter((elem) =>
  230. isCurrentValue(elem.value),
  231. )
  232. // Sort the replacement list according to the original order.
  233. replacementLocalOptions.value.sort(
  234. (a, b) =>
  235. sortedOptions.value.findIndex((option) => option.value === a.value) -
  236. sortedOptions.value.findIndex((option) => option.value === b.value),
  237. )
  238. if (focus !== true) return
  239. filterInput.value?.focus()
  240. }
  241. const availableOptions = computed<AutoCompleteOption[]>((oldValue) => {
  242. const currentOptions =
  243. filter.value || props.context.defaultFilter
  244. ? preprocessedAutocompleteOptions.value
  245. : sortedOptions.value
  246. if (oldValue && isEqual(oldValue, currentOptions)) return oldValue
  247. return currentOptions
  248. })
  249. const emitResultUpdated = () => {
  250. nextTick(() => {
  251. emit(
  252. 'searchInteractionUpdate',
  253. debouncedFilter.value,
  254. { ...autocompleteOptionValueLookup.value, ...optionValueLookup.value },
  255. selectOption,
  256. )
  257. })
  258. }
  259. watch(debouncedFilter, (newValue) => {
  260. if (newValue !== '' || props.context.defaultFilter) return
  261. emitResultUpdated()
  262. })
  263. watch(autocompleteQueryHandler.loading(), (newValue, oldValue) => {
  264. // We need not to trigger when query was started.
  265. if (newValue && !oldValue) return
  266. emitResultUpdated()
  267. })
  268. const deaccent = (s: string) =>
  269. s.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
  270. const availableOptionsWithMatches = computed(() => {
  271. // Trim and de-accent search keywords and compile them as a case-insensitive regex.
  272. // Make sure to escape special regex characters!
  273. const filterRegex = new RegExp(
  274. escapeRegExp(deaccent(filter.value.trim())),
  275. 'i',
  276. )
  277. return availableOptions.value.map(
  278. (option) =>
  279. ({
  280. ...option,
  281. // Match options via their de-accented labels.
  282. match: filterRegex.exec(deaccent(option.label || String(option.value))),
  283. }) as AutoCompleteOption,
  284. )
  285. })
  286. const childOptions = ref<AutoCompleteOption[]>([])
  287. const showChildOptions = (option: AutoCompleteOption) => {
  288. if (!option.children) return
  289. childOptions.value = option.children
  290. }
  291. const clearChildOptions = () => {
  292. if (!childOptions.value.length) return
  293. childOptions.value = []
  294. }
  295. const displayOptions = computed(() => {
  296. if (childOptions.value.length) return childOptions.value
  297. return availableOptionsWithMatches.value
  298. })
  299. const suggestedOptionLabel = computed(() => {
  300. if (!filter.value || !availableOptionsWithMatches.value.length)
  301. return undefined
  302. const exactMatches = availableOptionsWithMatches.value.filter(
  303. (option) =>
  304. (option.label || option.value.toString())
  305. .toLowerCase()
  306. .indexOf(filter.value.toLowerCase()) === 0 &&
  307. (option.label || option.value.toString()).length > filter.value.length,
  308. )
  309. if (!exactMatches.length) return undefined
  310. return exactMatches[0].label || exactMatches[0].value.toString()
  311. })
  312. const inputElementBounds = useElementBounding(input)
  313. const windowSize = useWindowSize()
  314. const isBelowHalfScreen = computed(() => {
  315. return inputElementBounds.y.value > windowSize.height.value / 2
  316. })
  317. const openSelectDropdown = () => {
  318. if (select.value?.isOpen || props.context.disabled) return
  319. select.value?.openDropdown(inputElementBounds, windowSize.height)
  320. requestAnimationFrame(() => {
  321. activateTabTrap()
  322. if (props.context.noFiltering) outputElement.value?.focus()
  323. else filterInput.value?.focus()
  324. })
  325. }
  326. const openOrMoveFocusToDropdown = (lastOption = false) => {
  327. if (!select.value?.isOpen) {
  328. openSelectDropdown()
  329. return
  330. }
  331. deactivateTabTrap()
  332. nextTick(() => {
  333. requestAnimationFrame(() => {
  334. select.value?.moveFocusToDropdown(lastOption)
  335. })
  336. })
  337. }
  338. const onCloseDropdown = () => {
  339. if (props.context.multiple) {
  340. replacementLocalOptions.value = []
  341. areLocalOptionsReplaced = true
  342. }
  343. clearChildOptions()
  344. clearFilter()
  345. deactivateTabTrap()
  346. }
  347. const onFocusFilterInput = () => {
  348. filterInput.value?.focus()
  349. }
  350. const OptionIconComponent =
  351. props.context.optionIconComponent ??
  352. (FieldAutoCompleteOptionIcon as ConcreteComponent)
  353. useFormBlock(contextReactive, openSelectDropdown)
  354. </script>
  355. <template>
  356. <div
  357. ref="input"
  358. class="flex h-auto min-h-10 hover:outline hover:outline-1 hover:outline-offset-1 hover:outline-blue-600 has-[output:focus,input:focus]:outline has-[output:focus,input:focus]:outline-1 has-[output:focus,input:focus]:outline-offset-1 has-[output:focus,input:focus]:outline-blue-800 dark:hover:outline-blue-900 dark:has-[output:focus,input:focus]:outline-blue-800"
  359. :class="[
  360. context.classes.input,
  361. {
  362. 'rounded-lg': !select?.isOpen,
  363. 'rounded-t-lg': select?.isOpen && !isBelowHalfScreen,
  364. 'rounded-b-lg': select?.isOpen && isBelowHalfScreen,
  365. 'bg-blue-200 dark:bg-gray-700': !context.alternativeBackground,
  366. 'bg-white dark:bg-gray-500': context.alternativeBackground,
  367. },
  368. ]"
  369. data-test-id="field-autocomplete"
  370. >
  371. <CommonSelect
  372. ref="select"
  373. #default="{ state: expanded, close: closeDropdown }"
  374. :model-value="currentValue"
  375. :options="displayOptions"
  376. :multiple="context.multiple"
  377. :owner="context.id"
  378. :filter="filter"
  379. :option-icon-component="markRaw(OptionIconComponent)"
  380. :empty-initial-label-text="contextReactive.emptyInitialLabelText"
  381. :actions="context.actions"
  382. :is-child-page="childOptions.length > 0"
  383. no-options-label-translation
  384. no-close
  385. passive
  386. initially-empty
  387. @select="selectOption"
  388. @push="showChildOptions"
  389. @pop="clearChildOptions"
  390. @close="onCloseDropdown"
  391. @focus-filter-input="onFocusFilterInput"
  392. >
  393. <output
  394. :id="context.id"
  395. ref="outputElement"
  396. role="combobox"
  397. aria-controls="common-select"
  398. aria-owns="common-select"
  399. aria-haspopup="menu"
  400. :aria-expanded="expanded"
  401. :name="context.node.name"
  402. class="formkit-disabled:pointer-events-none flex grow items-center gap-2.5 px-2.5 py-2 text-black focus:outline-none dark:text-white"
  403. :aria-labelledby="`label-${context.id}`"
  404. :aria-disabled="context.disabled"
  405. :aria-describedby="context.describedBy"
  406. aria-autocomplete="none"
  407. :data-multiple="context.multiple"
  408. tabindex="0"
  409. v-bind="context.attrs"
  410. @keydown.escape.prevent="closeDropdown()"
  411. @keypress.enter.prevent="openSelectDropdown()"
  412. @keydown.down.prevent="openOrMoveFocusToDropdown()"
  413. @keydown.up.prevent="openOrMoveFocusToDropdown(true)"
  414. @keypress.space.prevent="openSelectDropdown()"
  415. @blur="context.handlers.blur"
  416. >
  417. <div
  418. v-if="hasValue && context.multiple"
  419. class="flex flex-wrap gap-1.5"
  420. role="list"
  421. >
  422. <div
  423. v-for="selectedValue in valueContainer"
  424. :key="selectedValue.toString()"
  425. class="flex items-center gap-1.5"
  426. role="listitem"
  427. >
  428. <div
  429. class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-xs text-black dark:text-white"
  430. :class="{
  431. 'bg-white dark:bg-gray-200': !context.alternativeBackground,
  432. 'bg-neutral-100 dark:bg-gray-200':
  433. context.alternativeBackground,
  434. }"
  435. >
  436. <CommonIcon
  437. v-if="getSelectedAutocompleteOptionIcon(selectedValue)"
  438. :name="getSelectedAutocompleteOptionIcon(selectedValue)"
  439. class="shrink-0 fill-gray-100 dark:fill-neutral-400"
  440. size="xs"
  441. decorative
  442. />
  443. <span
  444. class="line-clamp-3 whitespace-pre-wrap break-words"
  445. :title="
  446. getSelectedOptionLabel(selectedValue) ||
  447. i18n.t('%s (unknown)', selectedValue.toString())
  448. "
  449. >
  450. {{
  451. getSelectedOptionLabel(selectedValue) ||
  452. i18n.t('%s (unknown)', selectedValue.toString())
  453. }}
  454. </span>
  455. <CommonIcon
  456. :aria-label="i18n.t('Unselect Option')"
  457. class="shrink-0 fill-stone-200 hover:fill-black focus:outline-none focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:fill-neutral-500 dark:hover:fill-white"
  458. name="x-lg"
  459. size="xs"
  460. role="button"
  461. tabindex="0"
  462. @click.stop="
  463. selectOption(
  464. getSelectedAutocompleteOption(selectedValue) ||
  465. getSelectedOption(selectedValue),
  466. )
  467. "
  468. @keypress.enter.prevent.stop="
  469. selectOption(
  470. getSelectedAutocompleteOption(selectedValue) ||
  471. getSelectedOption(selectedValue),
  472. )
  473. "
  474. @keypress.space.prevent.stop="
  475. selectOption(
  476. getSelectedAutocompleteOption(selectedValue) ||
  477. getSelectedOption(selectedValue),
  478. )
  479. "
  480. />
  481. </div>
  482. </div>
  483. </div>
  484. <CommonInputSearch
  485. v-if="expanded || !hasValue"
  486. ref="filterInput"
  487. v-model="filter"
  488. :class="{ 'pointer-events-none': !expanded }"
  489. :suggestion="suggestedOptionLabel"
  490. :alternative-background="context.alternativeBackground"
  491. @keypress.space.stop
  492. />
  493. <div v-if="!expanded" class="flex grow flex-wrap gap-1" role="list">
  494. <div
  495. v-if="hasValue && !context.multiple"
  496. class="flex items-center gap-1.5 text-sm"
  497. role="listitem"
  498. >
  499. <CommonIcon
  500. v-if="getSelectedAutocompleteOptionIcon(currentValue)"
  501. :name="getSelectedAutocompleteOptionIcon(currentValue)"
  502. class="shrink-0 fill-gray-100 dark:fill-neutral-400"
  503. size="tiny"
  504. decorative
  505. />
  506. <span
  507. class="line-clamp-3 whitespace-pre-wrap break-words"
  508. :title="
  509. getSelectedOptionLabel(currentValue) ||
  510. i18n.t('%s (unknown)', currentValue.toString())
  511. "
  512. >
  513. {{
  514. getSelectedOptionLabel(currentValue) ||
  515. i18n.t('%s (unknown)', currentValue.toString())
  516. }}
  517. </span>
  518. </div>
  519. </div>
  520. <CommonIcon
  521. v-if="context.clearable && hasValue && !context.disabled"
  522. :aria-label="i18n.t('Clear Selection')"
  523. class="shrink-0 fill-stone-200 hover:fill-black focus:outline-none focus-visible:rounded-sm focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:fill-neutral-500 dark:hover:fill-white"
  524. name="x-lg"
  525. size="xs"
  526. role="button"
  527. tabindex="0"
  528. @click.stop="clearValue()"
  529. @keypress.enter.prevent.stop="clearValue()"
  530. @keypress.space.prevent.stop="clearValue()"
  531. />
  532. <CommonIcon
  533. class="shrink-0 fill-stone-200 dark:fill-neutral-500"
  534. name="chevron-down"
  535. size="xs"
  536. decorative
  537. />
  538. </output>
  539. </CommonSelect>
  540. </div>
  541. </template>