Browse Source

Feature - Desktop view: Pre-Calculation and automatic updates for ticket overviews.

Co-authored-by: Martin Gruner <mg@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dusan Vuckovic <dv@zammad.com>
Benjamin Scharf 1 month ago
parent
commit
92cad4d4f0

+ 1 - 0
Gemfile

@@ -47,6 +47,7 @@ gem 'pundit'
 # core - graphql handling
 gem 'graphql'
 gem 'graphql-batch', require: 'graphql/batch'
+gem 'graphql-fragment_cache'
 
 # core - image processing
 gem 'rszr'

+ 3 - 0
Gemfile.lock

@@ -284,6 +284,8 @@ GEM
     graphql-batch (0.6.0)
       graphql (>= 1.12.18, < 3)
       promise.rb (~> 0.7.2)
+    graphql-fragment_cache (1.21.0)
+      graphql (>= 2.1.4)
     hashdiff (1.1.2)
     hashie (5.0.0)
     hiredis (0.6.3)
@@ -862,6 +864,7 @@ DEPENDENCIES
   faker
   graphql
   graphql-batch
+  graphql-fragment_cache
   hiredis
   htmlentities
   icalendar

+ 2 - 2
app/frontend/apps/desktop/components/CommonSkeleton/CommonSkeleton.vue

@@ -13,7 +13,7 @@ const props = withDefaults(defineProps<Props>(), {
 })
 
 const roundedClass = computed(() =>
-  props.rounded ? 'rounded-full' : 'rounded-lg',
+  props.rounded ? 'rounded-full' : 'rounded-md',
 )
 </script>
 
@@ -26,7 +26,7 @@ const roundedClass = computed(() =>
     aria-valuemax="100"
     :aria-label="$t(label)"
     :aria-valuetext="$t('Please wait until content is loaded')"
-    class="bg-primary animate-pulse bg-blue-200 focus:outline-none focus-visible:rounded-sm focus-visible:outline-none focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:bg-gray-700"
+    class="bg-primary animate-pulse bg-blue-200 focus:outline-none focus-visible:rounded-md focus-visible:outline-none focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800 dark:bg-gray-700"
     :class="[roundedClass]"
   />
 </template>

+ 37 - 22
app/frontend/apps/desktop/components/CommonTable/CommonAdvancedTable.vue

@@ -18,9 +18,9 @@ import {
   watch,
   type Ref,
 } from 'vue'
+import { onBeforeRouteUpdate } from 'vue-router'
 
 import ObjectAttributeContent from '#shared/components/ObjectAttributes/ObjectAttribute.vue'
-import { useDebouncedLoading } from '#shared/composables/useDebouncedLoading.ts'
 import { useObjectAttributes } from '#shared/entities/object-attributes/composables/useObjectAttributes.ts'
 import type { ObjectAttribute } from '#shared/entities/object-attributes/types/store.ts'
 import type { TicketById } from '#shared/entities/ticket/types.ts'
@@ -141,7 +141,6 @@ const tableAttributes = computed(() => {
 
     table.push(mergedAttribute)
   })
-
   return table
 })
 
@@ -164,11 +163,19 @@ const setHeaderWidths = (reset?: boolean) => {
 
   tableElement.value.style.width = `${tableWidth}px`
 
+  let shouldReset = reset
+
+  if (
+    tableAttributes.value.length !==
+    Object.keys(headerWidthsRelativeStorage.value).length
+  )
+    shouldReset = true
+
   tableAttributes.value.forEach((tableAttribute) => {
     const header = document.getElementById(`${tableAttribute.name}-header`)
     if (!header) return
 
-    if (reset) {
+    if (shouldReset) {
       if (tableAttribute.headerPreferences.displayWidth)
         header.style.width = `${tableAttribute.headerPreferences.displayWidth}px`
       else header.style.width = '' // reflow
@@ -228,7 +235,7 @@ const initializeHeaderWidths = (storageKeyId?: string) => {
 
   nextTick(() => {
     setHeaderWidths()
-    calculateHeaderWidths()
+    delay(calculateHeaderWidths, 500)
   })
 }
 
@@ -239,10 +246,9 @@ const resetHeaderWidths = () => {
 
 watch(() => props.storageKeyId, initializeHeaderWidths)
 
-watch(
-  () => props.headers,
-  () => initializeHeaderWidths,
-)
+watch(localHeaders, () => {
+  initializeHeaderWidths()
+})
 
 onMounted(() => {
   if (!props.storageKeyId) return
@@ -291,7 +297,6 @@ const localItems = computed(() => {
 const remainingItems = computed(() => {
   const itemCount =
     props.totalItems >= props.maxItems ? props.maxItems : props.totalItems
-
   return itemCount - localItems.value.length
 })
 
@@ -387,6 +392,10 @@ const showGroupByRow = (item: TableAdvancedItem) => {
 
 const hasLoadedMore = ref(false)
 
+onBeforeRouteUpdate(() => {
+  hasLoadedMore.value = false
+})
+
 const { isLoading } = useInfiniteScroll(
   toRef(props, 'scrollContainer'),
   async () => {
@@ -396,11 +405,12 @@ const { isLoading } = useInfiniteScroll(
   {
     distance: 100,
     canLoadMore: () => remainingItems.value > 0,
+    eventListenerOptions: {
+      passive: true,
+    },
   },
 )
 
-const { debouncedLoading } = useDebouncedLoading({ isLoading })
-
 whenever(
   isLoading,
   () => {
@@ -413,15 +423,13 @@ const endOfListMessage = computed(() => {
   if (!hasLoadedMore.value) return ''
   if (remainingItems.value !== 0) return ''
 
-  if (props.totalItems > props.maxItems) {
-    return i18n.t(
-      'You reached the table limit of %s tickets (%s remaining).',
-      props.maxItems,
-      props.totalItems - localItems.value.length,
-    )
-  }
-
-  return i18n.t("You don't have more tickets to load.")
+  return props.totalItems > props.maxItems
+    ? i18n.t(
+        'You reached the table limit of %s tickets (%s remaining).',
+        props.maxItems,
+        props.totalItems - localItems.value.length,
+      )
+    : i18n.t("You don't have more tickets to load.")
 })
 
 const getLinkColorClasses = (item: TableAdvancedItem) => {
@@ -567,7 +575,14 @@ const getLinkColorClasses = (item: TableAdvancedItem) => {
     </thead>
     <!--    :TODO tabindex should be -1 re-evaluate when we work on bulk action with checkbox  -->
     <!--    SR should not be able to focus the row but each action node  -->
-    <tbody>
+    <tbody
+      class="relative"
+      :inert="isSorting"
+      :class="{
+        'opacity-50 before:absolute before:z-20 before:h-full before:w-full':
+          isSorting,
+      }"
+    >
       <template v-for="item in localItems" :key="item.id">
         <TableRowGroupBy
           v-if="groupByAttribute && showGroupByRow(item)"
@@ -703,7 +718,7 @@ const getLinkColorClasses = (item: TableAdvancedItem) => {
 
       <Transition leave-active-class="absolute">
         <div
-          v-if="debouncedLoading"
+          v-if="isLoading"
           :class="{ 'pt-10': localItems.length % 2 !== 0 }"
           class="absolute w-full pb-4"
         >

+ 8 - 9
app/frontend/apps/desktop/components/CommonTable/Skeleton/CommonTableRowsSkeleton.vue

@@ -8,17 +8,16 @@ interface Props {
 }
 
 withDefaults(defineProps<Props>(), {
-  rows: 5,
+  rows: 10,
 })
 </script>
 
 <template>
-  <div class="space-y-10">
-    <CommonSkeleton
-      v-for="n in rows"
-      :key="n"
-      :style="{ 'animation-delay': `${n * 0.1}s` }"
-      class="h-10"
-    />
-  </div>
+  <CommonSkeleton
+    v-for="(n, i) in rows"
+    :key="n"
+    :style="{ 'animation-delay': `${n * 0.1}s` }"
+    class="h-10"
+    :class="{ invisible: i % 2 }"
+  />
 </template>

+ 9 - 3
app/frontend/apps/desktop/components/CommonTable/Skeleton/CommonTableSkeleton.vue

@@ -4,10 +4,16 @@
 import CommonSkeleton from '#desktop/components/CommonSkeleton/CommonSkeleton.vue'
 import CommonTableRowsSkeleton from '#desktop/components/CommonTable/Skeleton/CommonTableRowsSkeleton.vue'
 
+interface Props {
+  rows?: number
+}
+
+defineProps<Props>()
+
 const headerClasses = {
   1: 'w-5 flex-shrink-0',
   2: 'w-36',
-  3: 'w-15',
+  3: 'w-5',
   4: 'w-24',
   5: 'w-16',
   6: 'w-20',
@@ -17,7 +23,7 @@ const headerClasses = {
 
 <template>
   <div>
-    <div class="flex justify-between gap-2 py-3">
+    <div class="flex justify-between gap-3 px-2.5 py-3">
       <CommonSkeleton
         v-for="n in 7"
         :key="n"
@@ -26,6 +32,6 @@ const headerClasses = {
         :class="headerClasses[n as keyof typeof headerClasses]"
       />
     </div>
-    <CommonTableRowsSkeleton />
+    <CommonTableRowsSkeleton :rows="rows || 10" />
   </div>
 </template>

+ 150 - 7
app/frontend/apps/desktop/components/CommonTable/__tests__/CommonAdvancedTable.spec.ts

@@ -1,5 +1,6 @@
 // Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
 
+import { faker } from '@faker-js/faker'
 import { waitFor, within } from '@testing-library/vue'
 import { vi } from 'vitest'
 import { ref } from 'vue'
@@ -9,9 +10,11 @@ import {
   type ExtendedMountingOptions,
   renderComponent,
 } from '#tests/support/components/index.ts'
+import { mockRouterHooks } from '#tests/support/mock-vue-router.ts'
 import { waitForNextTick } from '#tests/support/utils.ts'
 
 import { mockObjectManagerFrontendAttributesQuery } from '#shared/entities/object-attributes/graphql/queries/objectManagerFrontendAttributes.mocks.ts'
+import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
 import { EnumObjectManagerObjects } from '#shared/graphql/types.ts'
 import {
   convertToGraphQLId,
@@ -26,6 +29,8 @@ import CommonAdvancedTable from '../CommonAdvancedTable.vue'
 
 import type { AdvancedTableProps, TableAdvancedItem } from '../types.ts'
 
+mockRouterHooks()
+
 const tableHeaders = ['title', 'owner', 'state', 'priority', 'created_at']
 
 const tableItems: TableAdvancedItem[] = [
@@ -67,11 +72,26 @@ const tableActions: MenuItem[] = [
   },
 ]
 
+vi.mock('@vueuse/core', async (importOriginal) => {
+  const modules = await importOriginal<typeof import('@vueuse/core')>()
+  return {
+    ...modules,
+    useInfiniteScroll: (
+      scrollContainer: HTMLElement,
+      callback: () => Promise<void>,
+    ) => {
+      callback()
+      return { reset: vi.fn(), isLoading: ref(false) }
+    },
+  }
+})
+
 const renderTable = async (
   props: AdvancedTableProps,
   options: ExtendedMountingOptions<AdvancedTableProps> = { form: true },
 ) => {
   const wrapper = renderComponent(CommonAdvancedTable, {
+    router: true,
     ...options,
     props: {
       object: EnumObjectManagerObjects.Ticket,
@@ -275,7 +295,9 @@ describe('CommonAdvancedTable', () => {
             items: [item],
           }
         },
-        template: `<CommonAdvancedTable @click-row="mockedCallback" :headers="tableHeaders" :attributes="attributes" :items="items" :total-items="100" caption="Table caption" />`,
+        template: `
+          <CommonAdvancedTable @click-row="mockedCallback" :headers="tableHeaders" :attributes="attributes"
+                               :items="items" :total-items="100" caption="Table caption" />`,
       },
       { form: true },
     )
@@ -446,7 +468,7 @@ describe('CommonAdvancedTable', () => {
         id: convertToGraphQLId('Ticket', 2),
         checked: true,
         disabled: true,
-        label: 'selection data 1',
+        label: 'selection data 2',
       },
     ]
 
@@ -477,9 +499,130 @@ describe('CommonAdvancedTable', () => {
     expect(checkedRows.value).toEqual([])
   })
 
-  // TODO: ...
-  // it.todo('supports sorting')
-  // it.todo('supports grouping')
-  // it.todo('informs the user about reached limits')
-  // it.todo('informs the user about table end')
+  it('supports sorting', async () => {
+    mockObjectManagerFrontendAttributesQuery({
+      objectManagerFrontendAttributes: ticketObjectAttributes(),
+    })
+
+    const items = [
+      {
+        id: convertToGraphQLId('Ticket', 1),
+        checked: false,
+        disabled: false,
+        title: 'selection data 1',
+      },
+    ]
+
+    const wrapper = await renderTable({
+      headers: ['title'],
+      items,
+      hasCheckboxColumn: true,
+      totalItems: 100,
+      caption: 'Table caption',
+      orderBy: 'label',
+    })
+
+    const sortButton = await wrapper.findByRole('button', {
+      name: 'Sorted descending',
+    })
+
+    await wrapper.events.click(sortButton)
+
+    expect(wrapper.emitted('sort').at(-1)).toEqual(['title', 'ASCENDING'])
+  })
+
+  it('informs the user about reached limits', async () => {
+    const items = Array.from({ length: 30 }, () => ({
+      id: convertToGraphQLId('Ticket', faker.number.int()),
+      checked: false,
+      disabled: false,
+      title: faker.word.words(),
+    }))
+
+    const scrollContainer = document.createElement('div')
+    document.body.appendChild(scrollContainer)
+
+    const wrapper = await renderTable({
+      headers: ['title'],
+      items,
+      hasCheckboxColumn: true,
+      totalItems: 30,
+      maxItems: 20,
+      scrollContainer,
+      caption: 'Table caption',
+      orderBy: 'label',
+    })
+
+    expect(
+      wrapper.getByText(
+        'You reached the table limit of 20 tickets (10 remaining).',
+      ),
+    ).toBeInTheDocument()
+
+    scrollContainer.remove()
+  })
+
+  it('informs the user about table end', async () => {
+    const items = Array.from({ length: 30 }, () => ({
+      id: convertToGraphQLId('Ticket', faker.number.int()),
+      checked: false,
+      disabled: false,
+      title: faker.word.sample(),
+    }))
+
+    const scrollContainer = document.createElement('div')
+    document.body.appendChild(scrollContainer)
+
+    const wrapper = await renderTable({
+      headers: ['title'],
+      items,
+      hasCheckboxColumn: true,
+      totalItems: 30,
+      maxItems: 30,
+      scrollContainer,
+      caption: 'Table caption',
+      orderBy: 'label',
+    })
+
+    expect(
+      wrapper.getByText("You don't have more tickets to load."),
+    ).toBeInTheDocument()
+
+    scrollContainer.remove()
+  })
+
+  it('supports grouping and shows incomplete count', async () => {
+    mockObjectManagerFrontendAttributesQuery({
+      objectManagerFrontendAttributes: ticketObjectAttributes(),
+    })
+
+    const items = [
+      createDummyTicket(),
+      createDummyTicket({ ticketId: '2', title: faker.word.sample() }),
+    ]
+
+    const wrapper = await renderTable({
+      headers: [
+        'priorityIcon',
+        'stateIcon',
+        'title',
+        'customer',
+        'organization',
+        'group',
+        'owner',
+        'state',
+        'created_at',
+      ],
+      items,
+      hasCheckboxColumn: true,
+      totalItems: 30,
+      maxItems: 30,
+      groupBy: 'customer',
+      caption: 'Table caption',
+    })
+
+    expect(
+      wrapper.getByRole('row', { name: 'Nicole Braun 2+' }),
+    ).toBeInTheDocument()
+  })
 })

+ 2 - 0
app/frontend/apps/desktop/components/CommonTable/__tests__/CommonAdvancedTable.spec.ts.snapshot.txt

@@ -144,7 +144,9 @@
   <!--    :TODO tabindex should be -1 re-evaluate when we work on bulk action with checkbox  -->
   <!--    SR should not be able to focus the row but each action node  -->
   <tbody
+    class="relative"
     data-v-fd04734e=""
+    inert="false"
   >
     
     

+ 2 - 0
app/frontend/apps/desktop/components/CommonTable/types.ts

@@ -123,4 +123,6 @@ export interface AdvancedTableProps extends BaseTableProps {
 
   orderBy?: string
   orderDirection?: EnumOrderDirection
+
+  isSorting?: boolean
 }

+ 72 - 0
app/frontend/apps/desktop/entities/ticket/composables/useTicketsCachedByOverviewCache.ts

@@ -0,0 +1,72 @@
+// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/
+
+import type {
+  TicketsCachedByOverviewQuery,
+  TicketsCachedByOverviewQueryVariables,
+} from '#shared/graphql/types'
+import { getApolloClient } from '#shared/server/apollo/client.ts'
+
+import { TicketsCachedByOverviewDocument } from '../graphql/queries/ticketsCachedByOverview.api.ts'
+
+export const useTicketsCachedByOverviewCache = () => {
+  const apolloClient = getApolloClient()
+
+  const readTicketsByOverviewCache = (
+    variables: TicketsCachedByOverviewQueryVariables,
+  ) => {
+    return apolloClient.readQuery<TicketsCachedByOverviewQuery>({
+      query: TicketsCachedByOverviewDocument,
+      variables,
+    })
+  }
+
+  const writeTicketsByOverviewCache = (
+    variables: TicketsCachedByOverviewQueryVariables,
+    data: TicketsCachedByOverviewQuery,
+  ) => {
+    return apolloClient.writeQuery<TicketsCachedByOverviewQuery>({
+      query: TicketsCachedByOverviewDocument,
+      variables,
+      data,
+    })
+  }
+
+  const forceTicketsByOverviewCacheOnlyFirstPage = (
+    variables: TicketsCachedByOverviewQueryVariables,
+    collectionSignature: string,
+    pageSize: number,
+  ) => {
+    const currentTicketsCachedByOverview = readTicketsByOverviewCache(variables)
+
+    if (!currentTicketsCachedByOverview) return
+
+    const currentTickets =
+      currentTicketsCachedByOverview?.ticketsCachedByOverview?.edges
+
+    const currentTicketsEdgesCount = currentTickets?.length
+
+    if (!currentTicketsEdgesCount || currentTicketsEdgesCount <= pageSize)
+      return
+
+    const slicedTickets = currentTickets?.slice(0, pageSize)
+
+    writeTicketsByOverviewCache(variables, {
+      ticketsCachedByOverview: {
+        ...currentTicketsCachedByOverview.ticketsCachedByOverview,
+        collectionSignature,
+        edges: slicedTickets,
+        pageInfo: {
+          ...currentTicketsCachedByOverview.ticketsCachedByOverview.pageInfo,
+          hasNextPage: true,
+          endCursor: slicedTickets[slicedTickets.length - 1].cursor,
+        },
+      },
+    })
+  }
+
+  return {
+    readTicketsByOverviewCache,
+    writeTicketsByOverviewCache,
+    forceTicketsByOverviewCacheOnlyFirstPage,
+  }
+}

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