FieldAutoCompleteInputDialog.vue 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  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 { refDebounced, watchOnce } from '@vueuse/core'
  5. import gql from 'graphql-tag'
  6. import { cloneDeep } from 'lodash-es'
  7. import { computed, nextTick, onMounted, ref, toRef } from 'vue'
  8. import { useRouter } from 'vue-router'
  9. import useValue from '#shared/components/Form/composables/useValue.ts'
  10. import type {
  11. AutoCompleteOption,
  12. AutoCompleteProps,
  13. AutocompleteSelectValue,
  14. } from '#shared/components/Form/fields/FieldAutocomplete/types.ts'
  15. import type { FormFieldContext } from '#shared/components/Form/types/field.ts'
  16. import useSelectOptions from '#shared/composables/useSelectOptions.ts'
  17. import { useTraverseOptions } from '#shared/composables/useTraverseOptions.ts'
  18. import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
  19. import CommonButton from '#mobile/components/CommonButton/CommonButton.vue'
  20. import CommonDialog from '#mobile/components/CommonDialog/CommonDialog.vue'
  21. import { closeDialog } from '#mobile/composables/useDialog.ts'
  22. import FieldAutoCompleteOptionIcon from './FieldAutoCompleteOptionIcon.vue'
  23. import type { FormKitNode } from '@formkit/core'
  24. import type { NameNode, OperationDefinitionNode, SelectionNode } from 'graphql'
  25. import type { ConcreteComponent, Ref } from 'vue'
  26. const props = defineProps<{
  27. context: FormFieldContext<AutoCompleteProps>
  28. name: string
  29. options: AutoCompleteOption[]
  30. optionIconComponent?: ConcreteComponent | null
  31. noCloseOnSelect?: boolean
  32. }>()
  33. const contextReactive = toRef(props, 'context')
  34. const { isCurrentValue } = useValue<AutocompleteSelectValue>(contextReactive)
  35. const emit = defineEmits<{
  36. updateOptions: [AutoCompleteOption[]]
  37. action: []
  38. }>()
  39. const { sortedOptions, selectOption, appendedOptions } = useSelectOptions<
  40. AutoCompleteOption[]
  41. >(toRef(props, 'options'), contextReactive)
  42. let areLocalOptionsReplaced = false
  43. const replacementLocalOptions: Ref<AutoCompleteOption[]> = ref(
  44. cloneDeep(props.options),
  45. )
  46. const filter = ref('')
  47. const filterInput = ref(null)
  48. const focusFirstTarget = () => {
  49. const filterInputFormKit = filterInput.value as null | { node: FormKitNode }
  50. if (!filterInputFormKit) return
  51. const filterInputElement = document.getElementById(
  52. filterInputFormKit.node.context?.id as string,
  53. )
  54. if (!filterInputElement) return
  55. filterInputElement.focus()
  56. }
  57. const clearFilter = () => {
  58. filter.value = ''
  59. }
  60. onMounted(() => {
  61. if (areLocalOptionsReplaced) {
  62. replacementLocalOptions.value = [...props.options]
  63. }
  64. nextTick(() => focusFirstTarget())
  65. })
  66. const close = () => {
  67. if (props.context.multiple) {
  68. emit('updateOptions', [...replacementLocalOptions.value])
  69. replacementLocalOptions.value = []
  70. areLocalOptionsReplaced = true
  71. }
  72. closeDialog(props.name)
  73. clearFilter()
  74. }
  75. const trimmedFilter = computed(() => filter.value.trim())
  76. const debouncedFilter = refDebounced(
  77. trimmedFilter,
  78. props.context.debounceInterval ?? 500,
  79. )
  80. const AutocompleteSearchDocument = gql`
  81. ${props.context.gqlQuery}
  82. `
  83. const additionalQueryParams = () => {
  84. if (typeof props.context.additionalQueryParams === 'function') {
  85. return props.context.additionalQueryParams()
  86. }
  87. return props.context.additionalQueryParams || {}
  88. }
  89. const autocompleteQueryHandler = new QueryHandler(
  90. useLazyQuery(
  91. AutocompleteSearchDocument,
  92. () => ({
  93. input: {
  94. query: debouncedFilter.value || props.context.defaultFilter || '',
  95. limit: props.context.limit,
  96. ...(additionalQueryParams() || {}),
  97. },
  98. }),
  99. () => ({
  100. enabled: !!(debouncedFilter.value || props.context.defaultFilter),
  101. cachePolicy: 'no-cache', // Do not use cache, because we want always up-to-date results.
  102. }),
  103. ),
  104. )
  105. if (props.context.defaultFilter) {
  106. autocompleteQueryHandler.load()
  107. } else {
  108. watchOnce(
  109. () => debouncedFilter.value,
  110. (newValue) => {
  111. if (!newValue.length) return
  112. autocompleteQueryHandler.load()
  113. },
  114. )
  115. }
  116. const autocompleteQueryResultKey = (
  117. (AutocompleteSearchDocument.definitions[0] as OperationDefinitionNode)
  118. .selectionSet.selections[0] as SelectionNode & { name: NameNode }
  119. ).name.value
  120. const autocompleteQueryResultOptions = computed(
  121. () =>
  122. autocompleteQueryHandler.result().value?.[
  123. autocompleteQueryResultKey
  124. ] as unknown as AutoCompleteOption[],
  125. )
  126. const autocompleteOptions = computed(() => {
  127. const result = cloneDeep(autocompleteQueryResultOptions.value) || []
  128. const filterInputFormKit = filterInput.value as null | { node: FormKitNode }
  129. if (
  130. props.context.allowUnknownValues &&
  131. filterInputFormKit &&
  132. filterInputFormKit.node.context?.state.complete &&
  133. !result.some((option) => option.value === trimmedFilter.value)
  134. ) {
  135. result.unshift({
  136. value: trimmedFilter.value,
  137. label: trimmedFilter.value,
  138. })
  139. }
  140. return result
  141. })
  142. const { sortedOptions: sortedAutocompleteOptions } = useSelectOptions(
  143. autocompleteOptions,
  144. toRef(props, 'context'),
  145. )
  146. const select = (option: AutoCompleteOption) => {
  147. selectOption(option)
  148. if (props.context.multiple) {
  149. // If the current value contains the selected option, make sure it's added to the replacement list
  150. // if it's not already there.
  151. if (
  152. isCurrentValue(option.value) &&
  153. !replacementLocalOptions.value.some(
  154. (replacementLocalOption) =>
  155. replacementLocalOption.value === option.value,
  156. )
  157. ) {
  158. replacementLocalOptions.value.push(option)
  159. }
  160. // Remove any extra options from the replacement list.
  161. replacementLocalOptions.value = replacementLocalOptions.value.filter(
  162. (replacementLocalOption) => isCurrentValue(replacementLocalOption.value),
  163. )
  164. if (!sortedOptions.value.some((elem) => elem.value === option.value)) {
  165. appendedOptions.value.push(option)
  166. }
  167. appendedOptions.value = appendedOptions.value.filter((elem) =>
  168. isCurrentValue(elem.value),
  169. )
  170. // Sort the replacement list according to the original order.
  171. replacementLocalOptions.value.sort(
  172. (a, b) =>
  173. sortedOptions.value.findIndex((option) => option.value === a.value) -
  174. sortedOptions.value.findIndex((option) => option.value === b.value),
  175. )
  176. return
  177. }
  178. emit('updateOptions', [option])
  179. if (!props.noCloseOnSelect) {
  180. close()
  181. }
  182. }
  183. const OptionIconComponent =
  184. props.optionIconComponent ?? FieldAutoCompleteOptionIcon
  185. const router = useRouter()
  186. const executeAction = () => {
  187. emit('action')
  188. if (!props.context.action) return
  189. router.push(props.context.action)
  190. }
  191. const autocompleteList = ref<HTMLElement>()
  192. useTraverseOptions(autocompleteList)
  193. </script>
  194. <template>
  195. <CommonDialog
  196. :name="name"
  197. :label="context.label"
  198. class="field-autocomplete-dialog"
  199. @close="close"
  200. >
  201. <template v-if="context.action || context.onActionClick" #before-label>
  202. <CommonButton
  203. class="grow"
  204. transparent-background
  205. @click="close"
  206. @keypress.space="close"
  207. >
  208. {{ $t('Cancel') }}
  209. </CommonButton>
  210. </template>
  211. <template #after-label>
  212. <button
  213. v-if="context.action || context.onActionClick"
  214. tabindex="0"
  215. :aria-label="context.actionLabel"
  216. @click="executeAction"
  217. @keypress.space="executeAction"
  218. >
  219. <CommonIcon
  220. :name="context.actionIcon ? context.actionIcon : 'external-link'"
  221. class="cursor-pointer text-white"
  222. size="base"
  223. />
  224. </button>
  225. <CommonButton
  226. v-else
  227. class="grow"
  228. variant="primary"
  229. transparent-background
  230. @click="close()"
  231. @keypress.space="close()"
  232. >
  233. {{ $t('Done') }}
  234. </CommonButton>
  235. </template>
  236. <div class="w-full p-4">
  237. <FormKit
  238. ref="filterInput"
  239. v-model="filter"
  240. :delay="context.node.props.delay"
  241. :placeholder="context.filterInputPlaceholder"
  242. :validation="context.filterInputValidation"
  243. type="search"
  244. validation-visibility="live"
  245. role="searchbox"
  246. />
  247. </div>
  248. <div
  249. v-if="filter ? autocompleteOptions.length : options.length"
  250. ref="autocompleteList"
  251. :aria-label="$t('Select…')"
  252. class="flex grow flex-col items-start self-stretch overflow-y-auto"
  253. role="listbox"
  254. :aria-multiselectable="context.multiple"
  255. >
  256. <div
  257. v-for="(option, index) in filter || context.defaultFilter
  258. ? sortedAutocompleteOptions
  259. : sortedOptions"
  260. :key="String(option.value)"
  261. :class="{
  262. 'pointer-events-none': option.disabled,
  263. }"
  264. aria-setsize="-1"
  265. :aria-posinset="options.findIndex((o) => o.value === option.value) + 1"
  266. tabindex="0"
  267. :aria-selected="isCurrentValue(option.value)"
  268. class="focus:bg-blue-highlight relative flex h-[58px] cursor-pointer items-center self-stretch px-6 py-5 text-base leading-[19px] text-white focus:outline-none"
  269. role="option"
  270. @click="select(option as AutoCompleteOption)"
  271. @keyup.space="select(option as AutoCompleteOption)"
  272. >
  273. <div
  274. v-if="index !== 0"
  275. :class="{
  276. 'ltr:left-4 rtl:right-4': !context.multiple && !option.icon,
  277. 'ltr:left-[60px] rtl:right-[60px]':
  278. context.multiple && !option.icon,
  279. 'ltr:left-[72px] rtl:right-[72px]':
  280. !context.multiple && option.icon,
  281. 'ltr:left-[108px] rtl:right-[108px]':
  282. context.multiple && option.icon,
  283. }"
  284. class="absolute top-0 h-0 border-t border-white/10 ltr:right-4 rtl:left-4"
  285. />
  286. <CommonIcon
  287. v-if="context.multiple"
  288. :class="{
  289. '!text-white': isCurrentValue(option.value),
  290. 'opacity-30': option.disabled,
  291. }"
  292. :name="
  293. isCurrentValue(option.value) ? 'check-box-yes' : 'check-box-no'
  294. "
  295. class="text-white/50 ltr:mr-3 rtl:ml-3"
  296. size="base"
  297. decorative
  298. />
  299. <OptionIconComponent :option="option" />
  300. <div
  301. v-if="(option as AutoCompleteOption).heading"
  302. class="flex grow flex-col overflow-hidden"
  303. >
  304. <span
  305. :class="{
  306. 'opacity-30': option.disabled,
  307. }"
  308. class="flex-1 truncate text-sm text-gray-100"
  309. >
  310. <span>{{ (option as AutoCompleteOption).heading }}</span>
  311. </span>
  312. <span
  313. :class="{
  314. 'opacity-30': option.disabled,
  315. }"
  316. class="grow truncate text-lg font-semibold leading-[22px]"
  317. >
  318. {{ option.label || option.value }}
  319. </span>
  320. </div>
  321. <span
  322. v-else
  323. :class="{
  324. 'font-semibold !text-white': isCurrentValue(option.value),
  325. 'opacity-30': option.disabled,
  326. }"
  327. class="grow truncate text-white/80"
  328. >
  329. {{ option.label || option.value }}
  330. </span>
  331. <CommonIcon
  332. v-if="!context.multiple && isCurrentValue(option.value)"
  333. :class="{
  334. 'opacity-30': option.disabled,
  335. }"
  336. size="tiny"
  337. name="check"
  338. decorative
  339. />
  340. </div>
  341. </div>
  342. <div
  343. v-if="
  344. debouncedFilter &&
  345. autocompleteQueryResultOptions &&
  346. !autocompleteOptions.length
  347. "
  348. class="relative flex h-[58px] items-center justify-center self-stretch px-4 py-5 text-base leading-[19px] text-white/50"
  349. role="alert"
  350. >
  351. {{ $t(context.dialogNotFoundMessage || __('No results found')) }}
  352. </div>
  353. <div
  354. v-else-if="!debouncedFilter && !options.length"
  355. class="relative flex h-[58px] items-center justify-center self-stretch px-4 py-5 text-base leading-[19px] text-white/50"
  356. role="alert"
  357. >
  358. {{ $t(context.dialogEmptyMessage || __('Start typing to search…')) }}
  359. </div>
  360. </CommonDialog>
  361. </template>
  362. <style>
  363. .field-autocomplete-dialog {
  364. .formkit-wrapper {
  365. @apply px-0;
  366. }
  367. }
  368. </style>