SearchOverview.vue 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
  2. <script setup lang="ts">
  3. import { useLocalStorage } from '@vueuse/core'
  4. import { ignorableWatch } from '@vueuse/shared'
  5. import { debounce } from 'lodash-es'
  6. import { computed, onMounted, reactive, ref, watch } from 'vue'
  7. import { useRoute, useRouter } from 'vue-router'
  8. import type { CommonInputSearchExpose } from '#shared/components/CommonInputSearch/CommonInputSearch.vue'
  9. import CommonInputSearch from '#shared/components/CommonInputSearch/CommonInputSearch.vue'
  10. import { useStickyHeader } from '#shared/composables/useStickyHeader.ts'
  11. import { EnumSearchableModels } from '#shared/graphql/types.ts'
  12. import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
  13. import { useSessionStore } from '#shared/stores/session.ts'
  14. import CommonButtonGroup from '#mobile/components/CommonButtonGroup/CommonButtonGroup.vue'
  15. import type { CommonButtonOption } from '#mobile/components/CommonButtonGroup/types.ts'
  16. import CommonSectionMenu from '#mobile/components/CommonSectionMenu/CommonSectionMenu.vue'
  17. import type { MenuItem } from '#mobile/components/CommonSectionMenu/index.ts'
  18. import SearchResults from '../components/SearchResults.vue'
  19. import { useSearchLazyQuery } from '../graphql/queries/searchOverview.api.ts'
  20. import { useSearchPlugins } from '../plugins/index.ts'
  21. import type { LocationQueryRaw } from 'vue-router'
  22. interface SearchTypeItem extends MenuItem {
  23. value: string
  24. }
  25. const LAST_SEARCHES_LENGTH_MAX = 5
  26. const props = defineProps<{ type?: string }>()
  27. const route = useRoute()
  28. const router = useRouter()
  29. const searchPlugins = useSearchPlugins()
  30. const search = ref(String(route.query.search || ''))
  31. // we need a separate debounced value to not trigger query
  32. const filter = ref(search.value)
  33. const canSearch = computed(() => filter.value.length >= 1)
  34. const found = reactive({} as Record<string, Record<string, unknown>[]>)
  35. const { userId } = useSessionStore()
  36. const recentSearches = useLocalStorage<string[]>(`${userId}-recentSearches`, [])
  37. const model = computed(() => {
  38. return props.type
  39. ? searchPlugins[props.type]?.model
  40. : EnumSearchableModels.Ticket // default passed by router
  41. })
  42. const searchQuery = new QueryHandler(
  43. useSearchLazyQuery(
  44. () => ({
  45. search: filter.value,
  46. onlyIn: model.value,
  47. }),
  48. () => ({ enabled: canSearch.value }),
  49. ),
  50. )
  51. const loading = searchQuery.loading()
  52. searchQuery.watchOnResult((data) => {
  53. if (!props.type) return
  54. if (!data.search) return
  55. found[props.type] = data.search.items
  56. })
  57. const replaceQuery = (query: LocationQueryRaw) => {
  58. return router.replace({
  59. query: {
  60. ...route.query,
  61. ...query,
  62. },
  63. })
  64. }
  65. const searchInput = ref<CommonInputSearchExpose>()
  66. const focusSearch = () => searchInput.value?.focus()
  67. const selectType = async (selectedType: string) => {
  68. await router.replace({ params: { type: selectedType } })
  69. // focus on tab that was selected
  70. // it's useful when user selected type from the main screen (without tab controls)
  71. // and after that we focus on tab controls, so user can easily change current type
  72. const tabOption = document.querySelector(
  73. `[data-value="${selectedType}"]`,
  74. ) as HTMLElement | null
  75. tabOption?.focus()
  76. }
  77. onMounted(() => {
  78. focusSearch()
  79. })
  80. const loadByFilter = async (filterQuery: string) => {
  81. filter.value = filterQuery
  82. replaceQuery({ search: filterQuery })
  83. if (!canSearch.value || !props.type) {
  84. return
  85. }
  86. recentSearches.value = recentSearches.value.filter(
  87. (item) => item !== filterQuery,
  88. )
  89. recentSearches.value.push(filterQuery)
  90. if (recentSearches.value.length > LAST_SEARCHES_LENGTH_MAX) {
  91. recentSearches.value.shift()
  92. }
  93. if (searchQuery.isFirstRun()) {
  94. searchQuery.load()
  95. }
  96. }
  97. // load data after a few ms to not overload the api
  98. const debouncedLoad = debounce(loadByFilter, 600)
  99. const { ignoreUpdates } = ignorableWatch(search, async (search) => {
  100. if (!search || !props.type) {
  101. await loadByFilter(search)
  102. return
  103. }
  104. await debouncedLoad(search)
  105. })
  106. // load data immidiately when type changes or when recent search selected
  107. watch(
  108. () => props.type,
  109. () => loadByFilter(search.value),
  110. { immediate: true },
  111. )
  112. const selectRecentSearch = async (recentSearch: string) => {
  113. ignoreUpdates(() => {
  114. search.value = recentSearch
  115. })
  116. focusSearch()
  117. await loadByFilter(recentSearch)
  118. }
  119. const pluginsArray = Object.entries(searchPlugins).map(([name, plugin]) => ({
  120. name,
  121. ...plugin,
  122. }))
  123. const searchPills: CommonButtonOption[] = pluginsArray.map((plugin) => ({
  124. value: plugin.name,
  125. label: plugin.headerLabel,
  126. }))
  127. const menuSearchTypes = computed<SearchTypeItem[]>(() =>
  128. pluginsArray.map((plugin) => {
  129. return {
  130. label: plugin.searchLabel,
  131. labelPlaceholder: [search.value],
  132. type: 'link',
  133. value: plugin.name,
  134. icon: plugin.icon,
  135. iconBg: plugin.iconBg,
  136. onClick: () => selectType(plugin.name),
  137. }
  138. }),
  139. )
  140. const canShowLastSearches = computed(() => {
  141. if (loading.value) return false
  142. return (props.type && !found[props.type]?.length) || !canSearch.value
  143. })
  144. const { headerElement, stickyStyles } = useStickyHeader([
  145. loading,
  146. () => !!props.type,
  147. ])
  148. const showLoader = computed(() => {
  149. if (!loading.value) return false
  150. return !props.type || !found[props.type]
  151. })
  152. </script>
  153. <script lang="ts">
  154. export default {
  155. beforeRouteEnter(to) {
  156. const { type } = to.params
  157. const searchPlugins = useSearchPlugins()
  158. if (!type) {
  159. const pluginsArray = Object.entries(searchPlugins)
  160. // if no type is selected, and only one type is available, select it
  161. if (pluginsArray.length === 1) {
  162. return { ...to, params: { type: pluginsArray[0][0] } }
  163. }
  164. return undefined
  165. }
  166. if (Array.isArray(type) || !searchPlugins[type as string]) {
  167. return { ...to, params: {} }
  168. }
  169. return undefined
  170. },
  171. }
  172. </script>
  173. <template>
  174. <div>
  175. <header ref="headerElement" class="bg-black" :style="stickyStyles.header">
  176. <div class="flex p-4">
  177. <CommonInputSearch
  178. ref="searchInput"
  179. v-model="search"
  180. wrapper-class="flex-1"
  181. class="!h-10"
  182. :aria-label="$t('Enter search and select a type to search for')"
  183. />
  184. <CommonLink
  185. link="/"
  186. class="text-blue flex items-center justify-center text-base ltr:pl-3 rtl:pr-3"
  187. >
  188. {{ $t('Cancel') }}
  189. </CommonLink>
  190. </div>
  191. <h1 class="sr-only">{{ $t('Search') }}</h1>
  192. <CommonButtonGroup
  193. v-if="type"
  194. class="border-b border-[rgba(255,255,255,0.1)] px-4 pb-4"
  195. as="tabs"
  196. :options="searchPills"
  197. :model-value="type"
  198. @update:model-value="selectType($event as string)"
  199. />
  200. <div
  201. v-else-if="canSearch"
  202. class="mt-8 px-4"
  203. data-test-id="selectTypesSection"
  204. >
  205. <CommonSectionMenu
  206. :header-label="__('Search for…')"
  207. :items="menuSearchTypes"
  208. />
  209. </div>
  210. </header>
  211. <div :style="stickyStyles.body">
  212. <div
  213. v-if="showLoader"
  214. class="flex h-14 w-full items-center justify-center"
  215. >
  216. <CommonIcon name="loading" animation="spin" />
  217. </div>
  218. <div
  219. v-else-if="canSearch && type && found[type]?.length"
  220. id="search-results"
  221. aria-live="polite"
  222. role="tabpanel"
  223. :aria-busy="showLoader"
  224. >
  225. <SearchResults :data="found[type]" :type="type" />
  226. </div>
  227. <div v-else-if="canSearch && type" class="px-4 pt-4">
  228. {{ $t('No entries') }}
  229. </div>
  230. <div
  231. v-if="canShowLastSearches"
  232. class="px-4 pt-8"
  233. data-test-id="recentSearches"
  234. >
  235. <div class="text-white/50">{{ $t('Recent searches') }}</div>
  236. <ul class="pt-3">
  237. <li
  238. v-for="searchItem in [...recentSearches].reverse()"
  239. :key="searchItem"
  240. class="pb-4"
  241. >
  242. <button
  243. type="button"
  244. class="flex items-center"
  245. @click="selectRecentSearch(searchItem)"
  246. >
  247. <span>
  248. <CommonIcon
  249. name="clock"
  250. size="small"
  251. class="mx-2 text-white/50"
  252. decorative
  253. />
  254. </span>
  255. <span class="text-left text-base">{{ searchItem }}</span>
  256. </button>
  257. </li>
  258. <li v-if="!recentSearches.length">{{ $t('No recent searches') }}</li>
  259. </ul>
  260. </div>
  261. </div>
  262. </div>
  263. </template>