SearchOverview.vue 8.1 KB

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