Browse Source

Feature - Desktop view: Quick search

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Co-authored-by: Florian Liebe <fl@zammad.com>
Co-authored-by: Martin Gruner <mg@zammad.com>
Benjamin Scharf 3 weeks ago
parent
commit
8f154579b7

+ 1 - 0
app/frontend/apps/desktop/components/CommonConfirmationDialog/CommonConfirmationDialog.vue

@@ -102,6 +102,7 @@ const headerTitle = computed(() => {
     :content="currentConfirmationOptions?.text || confirmationVariant.content"
     :content-placeholder="currentConfirmationOptions?.textPlaceholder"
     :footer-action-options="confirmationVariant.footerActionOptions"
+    :fullscreen="currentConfirmationOptions?.fullscreen ?? false"
     global
     @close="handleConfirmation"
   />

+ 17 - 16
app/frontend/apps/desktop/components/CommonInputSearch/CommonInputSearch.vue

@@ -16,6 +16,10 @@ export interface CommonInputSearchExpose {
   focus(): void
 }
 
+defineOptions({
+  inheritAttrs: false,
+})
+
 const props = withDefaults(defineProps<CommonInputSearchProps>(), {
   placeholder: __('Search…'),
 })
@@ -23,17 +27,19 @@ const props = withDefaults(defineProps<CommonInputSearchProps>(), {
 const emit = defineEmits<{
   'update:modelValue': [filter: string]
   keydown: [event: KeyboardEvent]
+  'focus-input': []
+  'blur-input': []
 }>()
 
 const filter = useVModel(props, 'modelValue', emit)
 
 const filterInput = useTemplateRef('filter-input')
 
-const focus = () => {
-  filterInput.value?.focus()
-}
+const focus = () => filterInput.value?.focus()
+
+const blur = () => filterInput.value?.blur()
 
-defineExpose({ focus })
+defineExpose({ focus, blur })
 
 const clearFilter = () => {
   filter.value = ''
@@ -59,20 +65,12 @@ const maybeAcceptSuggestion = (event: Event) => {
   filter.value = props.suggestion
 }
 
-const onKeydown = (event: KeyboardEvent) => {
-  emit('keydown', event)
-}
-</script>
-
-<script lang="ts">
-export default {
-  inheritAttrs: false,
-}
+const onKeydown = (event: KeyboardEvent) => emit('keydown', event)
 </script>
 
 <template>
   <div
-    class="inline-flex grow items-center justify-start gap-1"
+    class="inline-flex grow items-center justify-start gap-1 text-sm"
     :class="wrapperClass"
   >
     <CommonIcon
@@ -96,10 +94,13 @@ export default {
           }"
           type="text"
           role="searchbox"
+          autocomplete="off"
           @keydown.right="maybeAcceptSuggestion"
           @keydown.end="maybeAcceptSuggestion"
           @keydown.tab="maybeAcceptSuggestion"
           @keydown="onKeydown"
+          @focus="emit('focus-input')"
+          @blur="emit('blur-input')"
         />
       </div>
       <div
@@ -120,10 +121,10 @@ export default {
         :class="{
           invisible: !filter?.length,
         }"
-        :aria-label="i18n.t('Clear Search')"
+        :aria-label="$t('Clear Search')"
         :aria-hidden="!filter?.length ? 'true' : undefined"
         name="backspace2"
-        size="tiny"
+        size="xs"
         role="button"
         :tabindex="!filter?.length ? '-1' : '0'"
         @click.stop="clearFilter()"

+ 5 - 2
app/frontend/apps/desktop/components/CommonSectionCollapse/CommonSectionCollapse.vue

@@ -64,7 +64,10 @@ watch(
 
 <template>
   <!--  eslint-disable vuejs-accessibility/no-static-element-interactions-->
-  <div class="flex flex-col gap-1" :class="{ 'overflow-y-auto': scrollable }">
+  <div
+    class="flex flex-col gap-1"
+    :class="{ 'overflow-y-auto outline-none': scrollable }"
+  >
     <header
       v-if="!noHeader"
       :id="headerId"
@@ -112,7 +115,7 @@ watch(
         v-show="!isCollapsed || noHeader"
         :id="id"
         :data-test-id="id"
-        :class="{ 'overflow-y-auto': scrollable }"
+        :class="{ 'overflow-y-auto outline-none': scrollable }"
       >
         <slot :header-id="headerId" />
       </div>

+ 22 - 0
app/frontend/apps/desktop/components/PageNavigation/PageNavigation.vue

@@ -1,8 +1,12 @@
 <!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
+import { nextTick } from 'vue'
 import { useRouter } from 'vue-router'
 
+import { useSessionStore } from '#shared/stores/session.ts'
+import emitter from '#shared/utils/emitter.ts'
+
 import CommonSectionCollapse from '#desktop/components/CommonSectionCollapse/CommonSectionCollapse.vue'
 import { sortedFirstLevelRoutes } from '#desktop/components/PageNavigation/firstLevelRoutes.ts'
 
@@ -18,6 +22,13 @@ interface Props {
 defineProps<Props>()
 
 const router = useRouter()
+
+const { userId } = useSessionStore()
+
+const openSearch = () => {
+  emitter.emit('expand-collapsed-content', `${userId}-left`)
+  nextTick(() => emitter.emit('focus-quick-search-field'))
+}
 </script>
 
 <template>
@@ -30,6 +41,17 @@ const router = useRouter()
       <template #default="{ headerId }">
         <nav :aria-labelledby="headerId">
           <ul class="m-0 flex basis-full flex-col gap-1 p-0">
+            <li class="flex justify-center">
+              <CommonButton
+                v-if="collapsed"
+                class="flex-shrink-0 text-neutral-400 hover:outline-blue-900"
+                size="large"
+                variant="neutral"
+                :aria-label="$t('Open quick search')"
+                icon="search"
+                @click="openSearch"
+              />
+            </li>
             <li
               v-for="route in sortedFirstLevelRoutes"
               :key="route.path"

+ 225 - 0
app/frontend/apps/desktop/components/QuickSearch/QuickSearch.vue

@@ -0,0 +1,225 @@
+<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import {
+  NotificationTypes,
+  useNotifications,
+} from '#shared/components/CommonNotifications/index.ts'
+import { useConfirmation } from '#shared/composables/useConfirmation.ts'
+import { useTouchDevice } from '#shared/composables/useTouchDevice.ts'
+import MutationHandler from '#shared/server/apollo/handler/MutationHandler.ts'
+import QueryHandler from '#shared/server/apollo/handler/QueryHandler.ts'
+import SubscriptionHandler from '#shared/server/apollo/handler/SubscriptionHandler.ts'
+
+import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
+import CommonSectionCollapse from '#desktop/components/CommonSectionCollapse/CommonSectionCollapse.vue'
+import QuickSearchResultList from '#desktop/components/QuickSearch/QuickSearchResultList/QuickSearchResultList.vue'
+import { useRecentSearches } from '#desktop/composables/useRecentSearches.ts'
+import { useUserCurrentRecentViewResetMutation } from '#desktop/entities/user/current/graphql/mutations/userCurrentRecentViewReset.api.ts'
+import { useUserCurrentRecentViewListQuery } from '#desktop/entities/user/current/graphql/queries/userCurrentRecentViewList.api.ts'
+import { useUserCurrentRecentViewUpdatesSubscription } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentRecentViewUpdates.api.ts'
+
+import { useQuickSearchInput } from './composables/useQuickSearchInput.ts'
+import { lookupQuickSearchPluginComponent } from './plugins/index.ts'
+
+interface Props {
+  collapsed?: boolean
+  search: string
+}
+
+const props = defineProps<Props>()
+
+const hasSearchInput = computed(() => props.search?.length > 0)
+
+const { isTouchDevice } = useTouchDevice()
+
+const { recentSearches, clearSearches, removeSearch } = useRecentSearches()
+
+const recentViewListQuery = new QueryHandler(
+  useUserCurrentRecentViewListQuery({
+    limit: 10,
+  }),
+)
+
+const recentViewListQueryResult = recentViewListQuery.result()
+
+const recentViewListItems = computed(
+  () => recentViewListQueryResult.value?.userCurrentRecentViewList ?? [],
+)
+
+const recentViewUpdatesSubscription = new SubscriptionHandler(
+  useUserCurrentRecentViewUpdatesSubscription(),
+)
+
+recentViewUpdatesSubscription.onResult(({ data }) => {
+  if (data?.userCurrentRecentViewUpdates) {
+    recentViewListQuery.refetch()
+  }
+})
+
+const { waitForConfirmation } = useConfirmation()
+const { notify } = useNotifications()
+
+const confirmRemoveRecentSearch = async (searchQuery: string) => {
+  const confirmed = await waitForConfirmation(
+    __('Are you sure? This recent search will get lost.'),
+    { fullscreen: true },
+  )
+
+  if (!confirmed) return
+
+  removeSearch(searchQuery)
+
+  notify({
+    id: 'recent-search-removed',
+    type: NotificationTypes.Success,
+    message: __('Recent search was removed successfully.'),
+  })
+}
+
+const confirmClearRecentSearches = async () => {
+  const confirmed = await waitForConfirmation(
+    __('Are you sure? Your recent searches will get lost.'),
+    { fullscreen: true },
+  )
+
+  if (!confirmed) return
+
+  clearSearches()
+
+  notify({
+    id: 'recent-searches-cleared',
+    type: NotificationTypes.Success,
+    message: __('Recent searches were cleared successfully.'),
+  })
+}
+
+const recentViewResetMutation = new MutationHandler(
+  useUserCurrentRecentViewResetMutation(),
+)
+
+const confirmClearRecentViewed = async () => {
+  const confirmed = await waitForConfirmation(
+    __('Are you sure? Your recently viewed items will get lost.'),
+    { fullscreen: true },
+  )
+
+  if (!confirmed) return
+
+  recentViewResetMutation.send().then(() => {
+    notify({
+      id: 'recent-viewed-cleared',
+      type: NotificationTypes.Success,
+      message: __('Recently viewed items were cleared successfully.'),
+    })
+  })
+}
+
+const { resetQuickSearchInputField } = useQuickSearchInput()
+</script>
+
+<template>
+  <div class="overflow-x-hidden overflow-y-auto px-3 py-2.5 outline-none">
+    <QuickSearchResultList v-if="hasSearchInput" :search="search" />
+
+    <template
+      v-else-if="recentSearches.length > 0 || recentViewListItems.length > 0"
+    >
+      <CommonSectionCollapse
+        v-if="recentSearches.length > 0"
+        id="page-recent-searches"
+        :title="__('Recent searches')"
+        :no-header="collapsed"
+        no-collapse
+      >
+        <template #default="{ headerId }">
+          <nav :aria-labelledby="headerId">
+            <ul class="m-0 flex flex-col gap-1 p-0">
+              <li
+                v-for="searchQuery in recentSearches"
+                :key="searchQuery"
+                class="group/recent-search flex justify-center"
+              >
+                <CommonLink
+                  class="relative flex grow items-center gap-2 rounded-md px-2 py-3 text-neutral-400 hover:bg-blue-900 hover:no-underline!"
+                  :link="`/search/${searchQuery}`"
+                  exact-active-class="bg-blue-800! w-full text-white!"
+                  internal
+                  @click="resetQuickSearchInputField"
+                >
+                  <CommonIcon name="search" size="tiny" />
+                  <CommonLabel class="gap-2 text-white!">
+                    {{ searchQuery }}
+                  </CommonLabel>
+                  <CommonButton
+                    :aria-label="$t('Delete this recent search')"
+                    :class="{
+                      'opacity-0 transition-opacity': !isTouchDevice,
+                    }"
+                    class="absolute end-2 top-3 justify-end group-hover/recent-search:opacity-100 focus:opacity-100"
+                    icon="x-lg"
+                    size="small"
+                    variant="remove"
+                    @click.stop.prevent="confirmRemoveRecentSearch(searchQuery)"
+                  />
+                </CommonLink>
+              </li>
+            </ul>
+            <div class="mt-2 mb-1 flex justify-end">
+              <CommonLink
+                link="#"
+                size="small"
+                @click="confirmClearRecentSearches"
+              >
+                {{ $t('Clear recent searches') }}
+              </CommonLink>
+            </div>
+          </nav>
+        </template>
+      </CommonSectionCollapse>
+
+      <CommonSectionCollapse
+        v-if="recentViewListItems.length > 0"
+        id="page-recently-viewed"
+        :title="__('Recently viewed')"
+        :no-header="collapsed"
+        no-collapse
+      >
+        <template #default="{ headerId }">
+          <nav :aria-labelledby="headerId">
+            <ul class="m-0 flex flex-col gap-1 p-0">
+              <li
+                v-for="item in recentViewListItems"
+                :key="item.id"
+                class="relative"
+              >
+                <component
+                  :is="lookupQuickSearchPluginComponent(item.__typename!)"
+                  :item="item"
+                  mode="recently-viewed"
+                  @click="resetQuickSearchInputField"
+                />
+              </li>
+            </ul>
+            <div class="mt-2 mb-1 flex justify-end">
+              <CommonLink
+                link="#"
+                size="small"
+                @click="confirmClearRecentViewed"
+              >
+                {{ $t('Clear recently viewed') }}
+              </CommonLink>
+            </div>
+          </nav>
+        </template>
+      </CommonSectionCollapse>
+    </template>
+    <CommonLabel v-else>
+      {{
+        $t('Start typing i.e. the name of a ticket, an organization or a user.')
+      }}
+    </CommonLabel>
+  </div>
+</template>

+ 60 - 0
app/frontend/apps/desktop/components/QuickSearch/QuickSearchInput/QuickSearchInput.vue

@@ -0,0 +1,60 @@
+<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { onBeforeUnmount, useTemplateRef, watch } from 'vue'
+
+import emitter from '#shared/utils/emitter.ts'
+
+import CommonButton from '#desktop/components/CommonButton/CommonButton.vue'
+import CommonInputSearch from '#desktop/components/CommonInputSearch/CommonInputSearch.vue'
+
+const searchValue = defineModel<string>()
+
+const isSearchActive = defineModel<boolean>('search-active', {
+  default: false,
+})
+
+const inputSearchInstance = useTemplateRef('input-search')
+
+const resetInput = () => {
+  searchValue.value = ''
+  isSearchActive.value = false
+  // Blur input to make sure it does not get refocuses automatically and focus event is not emitted
+  inputSearchInstance.value?.blur()
+}
+
+const handleEscapeKey = (event: KeyboardEvent) => {
+  if (event.code === 'Escape') resetInput()
+}
+
+watch(isSearchActive, (isActive) =>
+  isActive
+    ? window.addEventListener('keydown', handleEscapeKey)
+    : window.removeEventListener('keydown', handleEscapeKey),
+)
+
+onBeforeUnmount(() => {
+  window.removeEventListener('keydown', handleEscapeKey)
+})
+
+emitter.on('focus-quick-search-field', () => inputSearchInstance.value?.focus())
+emitter.on('reset-quick-search-field', () => resetInput())
+</script>
+
+<template>
+  <div class="flex items-center gap-3">
+    <CommonInputSearch
+      ref="input-search"
+      v-model="searchValue"
+      wrapper-class="rounded-lg bg-blue-200 px-2.5 py-2 outline-offset-1 outline-blue-800 focus-within:outline dark:bg-gray-700"
+      @focus-input="isSearchActive = true"
+    />
+    <CommonButton
+      v-if="isSearchActive"
+      :aria-label="$t('Reset Search')"
+      icon="x-lg"
+      variant="neutral"
+      @click="resetInput"
+    />
+  </div>
+</template>

+ 87 - 0
app/frontend/apps/desktop/components/QuickSearch/QuickSearchInput/__tests__/QuickSearchInput.spec.ts

@@ -0,0 +1,87 @@
+// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
+import { ref } from 'vue'
+
+import renderComponent from '#tests/support/components/renderComponent.ts'
+import { waitForNextTick } from '#tests/support/utils.ts'
+
+import QuickSearchInput from '#desktop/components/QuickSearch/QuickSearchInput/QuickSearchInput.vue'
+
+const renderQuickSearchInput = () => {
+  const modelValue = ref('test')
+  const searchActive = ref(false)
+
+  const wrapper = renderComponent(QuickSearchInput, {
+    vModel: {
+      modelValue,
+      searchActive,
+    },
+  })
+
+  return { wrapper, modelValue, searchActive }
+}
+
+describe('QuickSearchInput', () => {
+  it('models search value', async () => {
+    const { wrapper } = renderQuickSearchInput()
+
+    expect(wrapper.getByRole('searchbox')).toHaveValue('test')
+  })
+
+  it('shows clear input button when focused', async () => {
+    const { wrapper } = renderQuickSearchInput()
+
+    wrapper.getByRole('searchbox', { name: 'Search…' }).focus()
+
+    await waitForNextTick()
+
+    expect(
+      wrapper.getByRole('button', { name: 'Reset Search' }),
+    ).toBeInTheDocument()
+  })
+
+  it('emits search active when focused', () => {
+    const { wrapper, searchActive } = renderQuickSearchInput()
+
+    expect(searchActive.value).toBe(false)
+
+    wrapper.getByRole('searchbox', { name: 'Search…' }).focus()
+
+    expect(searchActive.value).toBe(true)
+  })
+
+  it('emits search active when focused', async () => {
+    const { wrapper, searchActive } = renderQuickSearchInput()
+
+    const searchField = wrapper.getByRole('searchbox', { name: 'Search…' })
+
+    searchField.focus()
+
+    expect(searchActive.value).toBe(true)
+
+    await wrapper.events.type(wrapper.baseElement, '{escape}')
+
+    expect(searchActive.value).toBe(false)
+
+    expect(searchField).not.toHaveFocus()
+  })
+
+  it('allows clearing and resetting of the search input', async () => {
+    const { wrapper, modelValue, searchActive } = renderQuickSearchInput()
+
+    const searchField = wrapper.getByRole('searchbox', { name: 'Search…' })
+
+    await wrapper.events.click(
+      wrapper.getByRole('button', { name: 'Clear Search' }),
+    )
+
+    expect(modelValue.value).toBe('')
+    expect(searchField).toHaveFocus()
+    expect(searchActive.value).toBe(true)
+
+    await wrapper.events.click(
+      wrapper.getByRole('button', { name: 'Reset Search' }),
+    )
+
+    expect(searchActive.value).toBe(false)
+  })
+})

+ 170 - 0
app/frontend/apps/desktop/components/QuickSearch/QuickSearchResultList/QuickSearchResultList.vue

@@ -0,0 +1,170 @@
+<!-- Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { refDebounced } from '@vueuse/core'
+import { whenever } from '@vueuse/shared'
+import { computed, toRef } from 'vue'
+
+import { useDebouncedLoading } from '#shared/composables/useDebouncedLoading.ts'
+import QueryHandler from '#shared/server/apollo/handler/QueryHandler.ts'
+
+import CommonSectionCollapse from '#desktop/components/CommonSectionCollapse/CommonSectionCollapse.vue'
+import CommonSkeleton from '#desktop/components/CommonSkeleton/CommonSkeleton.vue'
+import { useQuickSearchLazyQuery } from '#desktop/components/QuickSearch/graphql/queries/quickSearch.api.ts'
+import type { QuickSearchResultData } from '#desktop/components/QuickSearch/types.ts'
+
+import { useQuickSearchInput } from '../composables/useQuickSearchInput.ts'
+import { sortedQuickSearchPlugins } from '../plugins/index.ts'
+
+const RESULT_LIMIT = 10
+const DEBOUNCE_TIME = 400
+
+interface Props {
+  search: string
+}
+
+const props = defineProps<Props>()
+
+const userSearchInput = toRef(props, 'search')
+
+const debouncedSearch = refDebounced<string>(userSearchInput, DEBOUNCE_TIME)
+
+const quickSearchQuery = new QueryHandler(
+  useQuickSearchLazyQuery(
+    () => ({
+      search: debouncedSearch.value,
+      limit: RESULT_LIMIT,
+    }),
+    {
+      fetchPolicy: 'no-cache',
+    },
+  ),
+)
+
+const quickSearchResult = quickSearchQuery.result()
+const searchResultsLoading = quickSearchQuery.loading()
+
+whenever(
+  debouncedSearch,
+  () => {
+    quickSearchQuery.load()
+  },
+  { once: true, immediate: true },
+)
+
+const mappedQuickSearchResults = computed(() => {
+  const currentResult = quickSearchResult.value
+
+  if (!currentResult) return
+
+  const searchResults: QuickSearchResultData[] = []
+
+  sortedQuickSearchPlugins.forEach((plugin) => {
+    if (!currentResult[plugin.searchResultKey]) return
+
+    const searchResult = currentResult[plugin.searchResultKey]
+    if (!searchResult || searchResult.totalCount === 0) return
+
+    searchResults.push({
+      name: plugin.name,
+      component: plugin.component,
+      items: searchResult.items,
+      label: plugin.searchResultLabel,
+      remainingItemCount: searchResult.totalCount - searchResult.items.length,
+      totalCount: searchResult.totalCount,
+    })
+  })
+
+  return searchResults
+})
+
+const isLoadingSearchResults = computed(() => {
+  if (mappedQuickSearchResults.value !== undefined) return false
+
+  return searchResultsLoading.value
+})
+
+const { debouncedLoading } = useDebouncedLoading({
+  isLoading: isLoadingSearchResults,
+  ms: 150,
+})
+
+const hasResults = computed(() =>
+  Boolean(mappedQuickSearchResults.value?.length),
+)
+
+const { resetQuickSearchInputField } = useQuickSearchInput()
+</script>
+
+<template>
+  <div v-if="debouncedLoading" class="mt-4 flex flex-col gap-8">
+    <div v-for="i in 2" :key="i" class="flex flex-col gap-4">
+      <CommonSkeleton
+        v-for="j in 3"
+        :key="j"
+        class="block rounded-lg"
+        :class="{
+          'h-5 w-25': j === 1,
+          'h-6 w-full': j !== 1,
+        }"
+        :style="{ 'animation-delay': `${(i * 3 + j) * 0.1}s` }"
+      />
+    </div>
+  </div>
+  <template v-else>
+    <!-- TODO: Exchange the link to the proper route when ready. -->
+    <CommonLink
+      v-if="!isLoadingSearchResults"
+      class="group/link mb-4 block"
+      link="#"
+    >
+      <CommonLabel
+        class="text-blue-800! group-hover/link:underline"
+        prefix-icon="search-detail"
+        size="small"
+      >
+        {{ $t('detailed search') }}
+      </CommonLabel>
+    </CommonLink>
+
+    <div v-if="hasResults" class="space-y-1">
+      <CommonSectionCollapse
+        v-for="(searchResult, index) in mappedQuickSearchResults"
+        :id="`${searchResult.name}-${index}`"
+        :key="`${searchResult.name}-${index}`"
+        no-collapse
+        :title="$t(searchResult.label)"
+      >
+        <div class="flex flex-col">
+          <ol class="space-y-1.5">
+            <li v-for="item in searchResult.items" :key="item.id">
+              <component
+                :is="searchResult.component"
+                :item="item"
+                mode="quick-search-result"
+                @click="resetQuickSearchInputField"
+              />
+            </li>
+          </ol>
+
+          <CommonLink
+            v-if="searchResult.remainingItemCount > 0"
+            class="group/link my-1.5 ms-auto"
+            link="#"
+          >
+            <CommonLabel
+              class="text-blue-800! group-hover/link:underline"
+              prefix-icon="search-detail"
+              size="small"
+            >
+              {{ $t('%s more', searchResult.remainingItemCount) }}
+            </CommonLabel>
+          </CommonLink>
+        </div>
+      </CommonSectionCollapse>
+    </div>
+    <CommonLabel v-else-if="!isLoadingSearchResults">{{
+      $t('No results for this query.')
+    }}</CommonLabel>
+  </template>
+</template>

+ 189 - 0
app/frontend/apps/desktop/components/QuickSearch/QuickSearchResultList/__tests__/QuickSearchResultList.spec.ts

@@ -0,0 +1,189 @@
+// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
+
+import '#tests/graphql/builders/mocks.ts'
+
+import renderComponent from '#tests/support/components/renderComponent.ts'
+import { waitForNextTick } from '#tests/support/utils.ts'
+
+import { EnumTicketStateColorCode } from '#shared/graphql/types.ts'
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
+
+import {
+  mockQuickSearchQuery,
+  waitForQuickSearchQueryCalls,
+} from '../../graphql/queries/quickSearch.mocks.ts'
+import QuickSearchResultList from '../QuickSearchResultList.vue'
+
+const renderQuickSearchResultList = async (search: string) => {
+  const wrapper = renderComponent(QuickSearchResultList, {
+    props: {
+      search,
+    },
+    router: true,
+  })
+
+  await waitForNextTick()
+
+  return wrapper
+}
+
+describe('QuickSearchResultList', async () => {
+  it('renders by default the sections with an empty state', async () => {
+    mockQuickSearchQuery({
+      quickSearchOrganizations: {},
+      quickSearchUsers: {},
+      quickSearchTickets: {},
+    })
+
+    const wrapper = await renderQuickSearchResultList('')
+
+    expect(wrapper.queryByText('Found organizations')).not.toBeInTheDocument()
+    expect(wrapper.queryByText('Found users')).not.toBeInTheDocument()
+    expect(wrapper.queryByText('Found tickets')).not.toBeInTheDocument()
+
+    expect(
+      await wrapper.findByText('No results for this query.'),
+    ).toBeInTheDocument()
+  })
+
+  it('renders the sections with the results', async () => {
+    mockQuickSearchQuery({
+      quickSearchOrganizations: {
+        totalCount: 1,
+        items: [
+          {
+            __typename: 'Organization',
+            id: convertToGraphQLId('Organization', 1),
+            internalId: 1,
+            name: 'Organization 1',
+          },
+        ],
+      },
+      quickSearchUsers: {
+        totalCount: 1,
+        items: [
+          {
+            __typename: 'User',
+            id: convertToGraphQLId('User', 1),
+            internalId: 1,
+            fullname: 'User 1',
+          },
+        ],
+      },
+      quickSearchTickets: {
+        totalCount: 100,
+        items: [
+          {
+            __typename: 'Ticket',
+            id: convertToGraphQLId('Ticket', 1),
+            internalId: 1,
+            title: 'Ticket 1',
+            number: '1',
+            state: {
+              __typename: 'TicketState',
+              id: convertToGraphQLId('TicketState', 1),
+              name: 'open',
+            },
+            stateColorCode: EnumTicketStateColorCode.Open,
+          },
+        ],
+      },
+    })
+
+    const wrapper = await renderQuickSearchResultList('1')
+
+    await waitForQuickSearchQueryCalls()
+
+    expect(wrapper.getByRole('link', { name: '99 more' })).toBeInTheDocument()
+
+    expect(wrapper.getByText('Found organizations')).toBeInTheDocument()
+    expect(wrapper.getByText('Found users')).toBeInTheDocument()
+    expect(wrapper.getByText('Found tickets')).toBeInTheDocument()
+
+    expect(wrapper.getByText('Found organizations')).toBeInTheDocument()
+    expect(wrapper.getByText('Found users')).toBeInTheDocument()
+    expect(wrapper.getByText('Found tickets')).toBeInTheDocument()
+
+    expect(
+      wrapper.queryByText(
+        'Start typing i.e. the name of a ticket, an organization or a user.',
+      ),
+    ).not.toBeInTheDocument()
+  })
+
+  it('renders inactive users', async () => {
+    mockQuickSearchQuery({
+      quickSearchUsers: {
+        totalCount: 1,
+        items: [
+          {
+            __typename: 'User',
+            active: false,
+            id: convertToGraphQLId('User', 1),
+            internalId: 1,
+            fullname: 'User 1',
+          },
+        ],
+      },
+      quickSearchOrganizations: {
+        totalCount: 0,
+        items: [],
+      },
+      quickSearchTickets: {
+        totalCount: 0,
+        items: [],
+      },
+    })
+
+    const wrapper = await renderQuickSearchResultList('User 1')
+
+    await waitForQuickSearchQueryCalls()
+
+    await wrapper.findByIconName('user-inactive')
+
+    const userLink = wrapper.getByRole('link', { name: 'User 1' })
+
+    expect(wrapper.getByText('User 1')).toHaveClass('text-neutral-500!')
+
+    expect(userLink).toHaveAttribute('aria-description', 'User is inactive.')
+  })
+
+  it('renders inactive organization', async () => {
+    mockQuickSearchQuery({
+      quickSearchOrganizations: {
+        totalCount: 1,
+        items: [
+          {
+            __typename: 'Organization',
+            active: false,
+            id: convertToGraphQLId('Organization', 1),
+            internalId: 1,
+            name: 'Organization 1',
+          },
+        ],
+      },
+      quickSearchTickets: {
+        totalCount: 0,
+        items: [],
+      },
+      quickSearchUsers: {
+        totalCount: 0,
+        items: [],
+      },
+    })
+
+    const wrapper = await renderQuickSearchResultList('Organization')
+
+    await waitForQuickSearchQueryCalls()
+
+    await wrapper.findByIconName('buildings-slash')
+
+    const userLink = wrapper.getByRole('link', { name: 'Organization 1' })
+
+    expect(wrapper.getByText('Organization 1')).toHaveClass('text-neutral-500!')
+    expect(userLink).toHaveAttribute(
+      'aria-description',
+      'Organization is inactive.',
+    )
+  })
+})

+ 210 - 0
app/frontend/apps/desktop/components/QuickSearch/__tests__/QuickSearch.spec.ts

@@ -0,0 +1,210 @@
+// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
+
+import '#tests/graphql/builders/mocks.ts'
+import { createPinia, setActivePinia } from 'pinia'
+
+import renderComponent from '#tests/support/components/renderComponent.ts'
+import { waitForNextTick } from '#tests/support/utils.ts'
+
+import {
+  EnumTicketStateColorCode,
+  type Organization,
+} from '#shared/graphql/types.ts'
+import { convertToGraphQLId } from '#shared/graphql/utils.ts'
+
+import QuickSearch from '#desktop/components/QuickSearch/QuickSearch.vue'
+import { useRecentSearches } from '#desktop/composables/useRecentSearches.ts'
+import { mockUserCurrentRecentViewResetMutation } from '#desktop/entities/user/current/graphql/mutations/userCurrentRecentViewReset.mocks.ts'
+import { mockUserCurrentRecentViewListQuery } from '#desktop/entities/user/current/graphql/queries/userCurrentRecentViewList.mocks.ts'
+import { getUserCurrentRecentViewUpdatesSubscriptionHandler } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentRecentViewUpdates.mocks.ts'
+
+const renderQuickSearch = async () => {
+  const wrapper = renderComponent(QuickSearch, {
+    props: {
+      collapsed: false,
+      search: '',
+    },
+    router: true,
+    store: true,
+    dialog: true,
+  })
+
+  await waitForNextTick()
+
+  return wrapper
+}
+
+setActivePinia(createPinia()) // Because we haven't set up the store before useRecentSearches is called
+
+describe('QuickSearch', () => {
+  const { addSearch, removeSearch, clearSearches } = useRecentSearches()
+
+  beforeEach(() => {
+    clearSearches()
+  })
+
+  describe('default state', () => {
+    it('shows empty state message when no searches or recently viewed items exist', async () => {
+      mockUserCurrentRecentViewListQuery({ userCurrentRecentViewList: [] })
+
+      const wrapper = await renderQuickSearch()
+
+      expect(
+        wrapper.getByText(
+          'Start typing i.e. the name of a ticket, an organization or a user.',
+        ),
+      ).toBeInTheDocument()
+
+      expect(
+        wrapper.queryByRole('button', { name: 'Clear All' }),
+      ).not.toBeInTheDocument()
+    })
+  })
+
+  describe('recent searches', () => {
+    beforeEach(() => {
+      mockUserCurrentRecentViewListQuery({ userCurrentRecentViewList: [] })
+    })
+
+    it('displays recent searches when added', async () => {
+      const wrapper = await renderQuickSearch()
+
+      addSearch('Foobar')
+      addSearch('Dummy')
+      await waitForNextTick()
+
+      expect(
+        wrapper.getByRole('heading', { level: 3, name: 'Recent searches' }),
+      ).toBeInTheDocument()
+
+      expect(wrapper.getByText('Foobar')).toBeInTheDocument()
+      expect(wrapper.getByText('Dummy')).toBeInTheDocument()
+
+      expect(
+        wrapper.getByRole('link', { name: 'Clear recent searches' }),
+      ).toBeInTheDocument()
+    })
+
+    it('allows clearing all recent searches', async () => {
+      const wrapper = await renderQuickSearch()
+
+      addSearch('Foobar')
+      addSearch('Dummy')
+      await waitForNextTick()
+
+      expect(
+        wrapper.getByRole('link', { name: 'Clear recent searches' }),
+      ).toBeInTheDocument()
+
+      clearSearches()
+      await waitForNextTick()
+
+      expect(
+        wrapper.getByText(
+          'Start typing i.e. the name of a ticket, an organization or a user.',
+        ),
+      ).toBeInTheDocument()
+
+      expect(
+        wrapper.queryByRole('link', { name: 'Clear recent searches' }),
+      ).not.toBeInTheDocument()
+    })
+
+    it('allows removing individual recent searches', async () => {
+      const wrapper = await renderQuickSearch()
+
+      addSearch('Foobar')
+      addSearch('Dummy')
+      await waitForNextTick()
+
+      let removeIcons = wrapper.getAllByIconName('x-lg')
+      expect(removeIcons.length).toBe(2)
+
+      removeSearch('Foobar')
+      await waitForNextTick()
+
+      removeIcons = wrapper.getAllByIconName('x-lg')
+      expect(removeIcons.length).toBe(1)
+    })
+  })
+
+  describe('recently viewed items', () => {
+    const recentlyViewedItems = [
+      {
+        __typename: 'Ticket',
+        id: convertToGraphQLId('Ticket', 1),
+        title: 'Ticket 1',
+        number: '1',
+        stateColorCode: EnumTicketStateColorCode.Open,
+      },
+      {
+        __typename: 'User',
+        id: convertToGraphQLId('User', 1),
+        internalId: 1,
+        fullname: 'User 1',
+      },
+      {
+        __typename: 'Organization',
+        id: convertToGraphQLId('Organization', 1),
+        internalId: 1,
+        name: 'Organization 1',
+      },
+    ]
+
+    it('displays recently viewed items', async () => {
+      mockUserCurrentRecentViewListQuery({
+        userCurrentRecentViewList: recentlyViewedItems as Organization[],
+      })
+
+      const wrapper = await renderQuickSearch()
+
+      expect(
+        wrapper.getByRole('heading', { level: 3, name: 'Recently viewed' }),
+      ).toBeInTheDocument()
+
+      expect(
+        wrapper.getByRole('link', { name: 'check-circle-no Ticket 1' }),
+      ).toBeInTheDocument()
+      expect(wrapper.getByRole('link', { name: 'User 1' })).toBeInTheDocument()
+      expect(
+        wrapper.getByRole('link', { name: 'Organization 1' }),
+      ).toBeInTheDocument()
+    })
+
+    it('allows clearing all recently viewed items', async () => {
+      mockUserCurrentRecentViewListQuery({
+        userCurrentRecentViewList: recentlyViewedItems as Organization[],
+      })
+
+      mockUserCurrentRecentViewResetMutation({
+        userCurrentRecentViewReset: { success: true },
+      })
+
+      const wrapper = await renderQuickSearch()
+
+      expect(
+        wrapper.getByRole('link', { name: 'Clear recently viewed' }),
+      ).toBeInTheDocument()
+
+      mockUserCurrentRecentViewListQuery({ userCurrentRecentViewList: [] })
+
+      await getUserCurrentRecentViewUpdatesSubscriptionHandler().trigger({
+        userCurrentRecentViewUpdates: {
+          recentViewsUpdated: true,
+        },
+      })
+
+      await waitForNextTick()
+
+      expect(
+        wrapper.getByText(
+          'Start typing i.e. the name of a ticket, an organization or a user.',
+        ),
+      ).toBeInTheDocument()
+
+      expect(
+        wrapper.queryByRole('link', { name: 'Clear recently viewed' }),
+      ).not.toBeInTheDocument()
+    })
+  })
+})

Some files were not shown because too many files changed in this diff