FieldAutoCompleteInputDialog.vue 11 KB

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