123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302 |
- <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
- <script setup lang="ts">
- import { useLocalStorage } from '@vueuse/core'
- import { ignorableWatch } from '@vueuse/shared'
- import { debounce } from 'lodash-es'
- import { computed, onMounted, reactive, ref, watch } from 'vue'
- import { useRoute, useRouter } from 'vue-router'
- import type { CommonInputSearchExpose } from '#shared/components/CommonInputSearch/CommonInputSearch.vue'
- import CommonInputSearch from '#shared/components/CommonInputSearch/CommonInputSearch.vue'
- import { useStickyHeader } from '#shared/composables/useStickyHeader.ts'
- import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
- import CommonButtonGroup from '#mobile/components/CommonButtonGroup/CommonButtonGroup.vue'
- import type { CommonButtonOption } from '#mobile/components/CommonButtonGroup/types.ts'
- import CommonSectionMenu from '#mobile/components/CommonSectionMenu/CommonSectionMenu.vue'
- import type { MenuItem } from '#mobile/components/CommonSectionMenu/index.ts'
- import SearchResults from '../components/SearchResults.vue'
- import { useSearchLazyQuery } from '../graphql/queries/searchOverview.api.ts'
- import { useSearchPlugins } from '../plugins/index.ts'
- import type { LocationQueryRaw } from 'vue-router'
- interface SearchTypeItem extends MenuItem {
- value: string
- }
- const LAST_SEARCHES_LENGTH_MAX = 5
- const props = defineProps<{ type?: string }>()
- const route = useRoute()
- const router = useRouter()
- const searchPlugins = useSearchPlugins()
- const search = ref(String(route.query.search || ''))
- // we need a separate debounced value to not trigger query
- const filter = ref(search.value)
- const canSearch = computed(() => filter.value.length >= 1)
- const found = reactive({} as Record<string, Record<string, unknown>[]>)
- const lastSearches = useLocalStorage<string[]>('lastSearches', [])
- const model = computed(() => {
- if (!props.type) return undefined
- return searchPlugins[props.type]?.model
- })
- const searchQuery = new QueryHandler(
- useSearchLazyQuery(
- () => ({
- search: filter.value,
- onlyIn: model.value,
- }),
- () => ({ enabled: canSearch.value }),
- ),
- )
- const loading = searchQuery.loading()
- searchQuery.watchOnResult((data) => {
- if (!props.type) return
- found[props.type] = data.search
- })
- const replaceQuery = (query: LocationQueryRaw) => {
- return router.replace({
- query: {
- ...route.query,
- ...query,
- },
- })
- }
- const searchInput = ref<CommonInputSearchExpose>()
- const focusSearch = () => searchInput.value?.focus()
- const selectType = async (selectedType: string) => {
- await router.replace({ params: { type: selectedType } })
- // focus on tab that was selected
- // it's useful when user selected type from the main screen (without tab controls)
- // and after that we focus on tab controls, so user can easily change current type
- const tabOption = document.querySelector(
- `[data-value="${selectedType}"]`,
- ) as HTMLElement | null
- tabOption?.focus()
- }
- onMounted(() => {
- focusSearch()
- })
- const loadByFilter = async (filterQuery: string) => {
- filter.value = filterQuery
- replaceQuery({ search: filterQuery })
- if (!canSearch.value || !props.type) {
- return
- }
- searchQuery.abort()
- lastSearches.value = lastSearches.value.filter((item) => item !== filterQuery)
- lastSearches.value.push(filterQuery)
- if (lastSearches.value.length > LAST_SEARCHES_LENGTH_MAX) {
- lastSearches.value.shift()
- }
- searchQuery.load()
- }
- // load data after a few ms to not overload the api
- const debouncedLoad = debounce(loadByFilter, 600)
- const { ignoreUpdates } = ignorableWatch(search, async (search) => {
- if (!search || !props.type) {
- await loadByFilter(search)
- return
- }
- await debouncedLoad(search)
- })
- // load data immidiately when type changes or when last search selected
- watch(
- () => props.type,
- () => loadByFilter(search.value),
- { immediate: true },
- )
- const selectLastSearch = async (lastSearch: string) => {
- ignoreUpdates(() => {
- search.value = lastSearch
- })
- focusSearch()
- await loadByFilter(lastSearch)
- }
- const pluginsArray = Object.entries(searchPlugins).map(([name, plugin]) => ({
- name,
- ...plugin,
- }))
- const searchPills: CommonButtonOption[] = pluginsArray.map((plugin) => ({
- value: plugin.name,
- label: plugin.headerLabel,
- }))
- const menuSearchTypes = computed<SearchTypeItem[]>(() =>
- pluginsArray.map((plugin) => {
- return {
- label: plugin.searchLabel,
- labelPlaceholder: [search.value],
- type: 'link',
- value: plugin.name,
- icon: plugin.icon,
- iconBg: plugin.iconBg,
- onClick: () => selectType(plugin.name),
- }
- }),
- )
- const canShowLastSearches = computed(() => {
- if (loading.value) return false
- return (props.type && !found[props.type]?.length) || !canSearch.value
- })
- const { headerElement, stickyStyles } = useStickyHeader([
- loading,
- () => !!props.type,
- ])
- const showLoader = computed(() => {
- if (!loading.value) return false
- return !props.type || !found[props.type]
- })
- </script>
- <script lang="ts">
- export default {
- beforeRouteEnter(to) {
- const { type } = to.params
- const searchPlugins = useSearchPlugins()
- if (!type) {
- const pluginsArray = Object.entries(searchPlugins)
- // if no type is selected, and only one type is available, select it
- if (pluginsArray.length === 1) {
- return { ...to, params: { type: pluginsArray[0][0] } }
- }
- return undefined
- }
- if (Array.isArray(type) || !searchPlugins[type as string]) {
- return { ...to, params: {} }
- }
- return undefined
- },
- }
- </script>
- <template>
- <div>
- <header ref="headerElement" class="bg-black" :style="stickyStyles.header">
- <div class="flex p-4">
- <CommonInputSearch
- ref="searchInput"
- v-model="search"
- wrapper-class="flex-1"
- class="!h-10"
- :aria-label="$t('Enter search and select a type to search for')"
- />
- <CommonLink
- link="/"
- class="text-blue flex items-center justify-center text-base ltr:pl-3 rtl:pr-3"
- >
- {{ $t('Cancel') }}
- </CommonLink>
- </div>
- <h1 class="sr-only">{{ $t('Search') }}</h1>
- <CommonButtonGroup
- v-if="type"
- class="border-b border-white/10 px-4 pb-4"
- as="tabs"
- :options="searchPills"
- :model-value="type"
- @update:model-value="selectType($event as string)"
- />
- <div
- v-else-if="canSearch"
- class="mt-8 px-4"
- data-test-id="selectTypesSection"
- >
- <CommonSectionMenu
- :header-label="__('Search for…')"
- :items="menuSearchTypes"
- />
- </div>
- </header>
- <div :style="stickyStyles.body">
- <div
- v-if="showLoader"
- class="flex h-14 w-full items-center justify-center"
- >
- <CommonIcon name="loading" animation="spin" />
- </div>
- <div
- v-else-if="canSearch && type && found[type]?.length"
- id="search-results"
- aria-live="polite"
- role="tabpanel"
- :aria-busy="showLoader"
- >
- <SearchResults :data="found[type]" :type="type" />
- </div>
- <div v-else-if="canSearch && type" class="px-4 pt-4">
- {{ $t('No entries') }}
- </div>
- <div
- v-if="canShowLastSearches"
- class="px-4 pt-8"
- data-test-id="lastSearches"
- >
- <div class="text-white/50">{{ $t('Last searches') }}</div>
- <ul class="pt-3">
- <li
- v-for="searchItem in [...lastSearches].reverse()"
- :key="searchItem"
- class="pb-4"
- >
- <button
- type="button"
- class="flex items-center"
- @click="selectLastSearch(searchItem)"
- >
- <span>
- <CommonIcon
- name="clock"
- size="small"
- class="mx-2 text-white/50"
- decorative
- />
- </span>
- <span class="text-left text-base">{{ searchItem }}</span>
- </button>
- </li>
- <li v-if="!lastSearches.length">{{ $t('No previous searches') }}</li>
- </ul>
- </div>
- </div>
- </div>
- </template>
|