Browse Source

Follow up 93ae0195 - Improve ticket merge table stylings.

Co-authored-by: Benjamin Scharf <bs@zammad.com>
Co-authored-by: Dominik Klein <dk@zammad.com>
Benjamin Scharf 4 months ago
parent
commit
56a75faf1e

+ 83 - 82
app/frontend/apps/desktop/components/CommonSimpleTable/CommonSimpleTable.vue

@@ -1,10 +1,9 @@
 <!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
 
 <script setup lang="ts">
-import { computed } from 'vue'
-
 import CommonActionMenu from '#desktop/components/CommonActionMenu/CommonActionMenu.vue'
 import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts'
+import SimpleTableRow from '#desktop/components/CommonSimpleTable/SimpleTableRow.vue'
 
 import type { TableHeader, TableItem } from './types.ts'
 
@@ -12,13 +11,14 @@ export interface Props {
   headers: TableHeader[]
   items: TableItem[]
   actions?: MenuItem[]
-  onClickRow?: (tableItem: TableItem, event: MouseEvent | KeyboardEvent) => void
+  onClickRow?: (tableItem: TableItem) => void
+  selectedRowId?: string
 }
 
 const props = defineProps<Props>()
 
-const emit = defineEmits<{
-  'click-row': [TableItem, MouseEvent | KeyboardEvent]
+defineEmits<{
+  'click-row': [TableItem]
 }>()
 
 // :INFO - This would only would work on runtime, when keys are computed
@@ -46,26 +46,8 @@ const getTooltipText = (item: TableItem, header: TableHeader) => {
   return header.truncate ? item[header.key] : undefined
 }
 
-const rowEventHandler = computed(() => {
-  if (!props.onClickRow) return { attrs: {}, getEvents: () => ({}) }
-
-  // We bind this only if component instance receives event handler
-  return {
-    attrs: {
-      role: 'button',
-      tabindex: 0,
-      ariaLabel: __('Select table row'),
-      class:
-        'focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-blue-800',
-    },
-    getEvents: (item: TableItem) => ({
-      click: (event: MouseEvent) => emit('click-row', item, event),
-      keydown: (event: KeyboardEvent) => {
-        if (event.key !== 'Enter') return
-        emit('click-row', item, event)
-      },
-    }),
-  }
+defineExpose({
+  getTooltipText,
 })
 </script>
 
@@ -75,7 +57,7 @@ const rowEventHandler = computed(() => {
       <th
         v-for="header in headers"
         :key="header.key"
-        class="h-10 p-2.5 text-xs font-normal text-stone-200 ltr:text-left rtl:text-right dark:text-neutral-500"
+        class="h-10 p-2.5 text-xs ltr:text-left rtl:text-right"
         :class="[
           header.columnClass,
           header.columnSeparator && columnSeparatorClasses,
@@ -83,8 +65,11 @@ const rowEventHandler = computed(() => {
       >
         <slot :name="`column-header-${header.key}`" :header="header">
           <CommonLabel
-            class="font-normal text-stone-200 dark:text-neutral-500"
-            :class="[cellAlignmentClasses[header.alignContent || 'left']]"
+            class="-:font-normal -:text-stone-200 -:dark:text-neutral-500"
+            :class="[
+              cellAlignmentClasses[header.alignContent || 'left'],
+              header.labelClass || '',
+            ]"
             size="small"
           >
             {{ $t(header.label, ...(header.labelPlaceholder || [])) }}
@@ -102,66 +87,82 @@ const rowEventHandler = computed(() => {
       </th>
     </thead>
     <tbody>
-      <tr
+      <SimpleTableRow
         v-for="item in items"
         :key="item.id"
-        class="odd:bg-blue-200 odd:dark:bg-gray-700"
-        v-bind="rowEventHandler.attrs"
-        v-on="rowEventHandler.getEvents(item)"
+        :item="item"
+        :is-row-selected="item.id === props.selectedRowId"
+        @click-row="onClickRow"
       >
-        <td
-          v-for="header in headers"
-          :key="`${item.id}-${header.key}`"
-          class="h-10 p-2.5 text-sm first:rounded-s-md last:rounded-e-md"
-          :class="[
-            header.columnSeparator && columnSeparatorClasses,
-            cellAlignmentClasses[header.alignContent || 'left'],
-            {
-              'max-w-32 truncate text-black dark:text-white': header.truncate,
-            },
-          ]"
-        >
-          <slot
-            :name="`column-cell-${header.key}`"
-            :item="item"
-            :header="header"
+        <template #default="{ isRowSelected }">
+          <td
+            v-for="header in headers"
+            :key="`${item.id}-${header.key}`"
+            class="h-10 p-2.5 text-sm first:rounded-s-md last:rounded-e-md"
+            :class="[
+              header.columnSeparator && columnSeparatorClasses,
+              cellAlignmentClasses[header.alignContent || 'left'],
+              {
+                'max-w-32 truncate text-black dark:text-white': header.truncate,
+              },
+            ]"
           >
-            <CommonLabel
-              v-tooltip.truncate="getTooltipText(item, header)"
-              class="inline text-black dark:text-white"
+            <slot
+              :name="`column-cell-${header.key}`"
+              :item="item"
+              :is-row-selected="isRowSelected"
+              :header="header"
             >
-              <template v-if="!item[header.key]">-</template>
-              <template v-else-if="header.type === 'timestamp_absolute'">
-                <CommonDateTime
-                  :date-time="item[header.key] as string"
-                  type="absolute"
-                />
-              </template>
-              <template v-else-if="header.type === 'timestamp'">
-                <CommonDateTime :date-time="item[header.key] as string" />
-              </template>
-              <template v-else>
-                {{ item[header.key] }}
-              </template>
-            </CommonLabel>
-          </slot>
+              <CommonLabel
+                v-tooltip.truncate="getTooltipText(item, header)"
+                class="-:text-gray-100 -:dark:text-neutral-400 inline group-hover:text-black group-active:text-black group-hover:dark:text-white group-active:dark:text-white"
+                :class="[
+                  {
+                    'text-black dark:text-white': isRowSelected,
+                  },
+                ]"
+              >
+                <template v-if="!item[header.key]">-</template>
+                <template v-else-if="header.type === 'timestamp_absolute'">
+                  <CommonDateTime
+                    :class="{
+                      'text-black dark:text-white': isRowSelected,
+                    }"
+                    :date-time="item[header.key] as string"
+                    type="absolute"
+                  />
+                </template>
+                <template v-else-if="header.type === 'timestamp'">
+                  <CommonDateTime
+                    :class="{
+                      'text-black dark:text-white': isRowSelected,
+                    }"
+                    :date-time="item[header.key] as string"
+                  />
+                </template>
+                <template v-else>
+                  {{ item[header.key] }}
+                </template>
+              </CommonLabel>
+            </slot>
 
-          <slot :name="`item-suffix-${header.key}`" :item="item" />
-        </td>
-        <td
-          v-if="actions"
-          class="h-10 p-2.5 text-center first:rounded-s-md last:rounded-e-md"
-        >
-          <slot name="actions" v-bind="{ actions, item }">
-            <CommonActionMenu
-              class="flex items-center justify-center"
-              :actions="actions"
-              :entity="item"
-              button-size="medium"
-            />
-          </slot>
-        </td>
-      </tr>
+            <slot :name="`item-suffix-${header.key}`" :item="item" />
+          </td>
+          <td
+            v-if="actions"
+            class="h-10 p-2.5 text-center first:rounded-s-md last:rounded-e-md"
+          >
+            <slot name="actions" v-bind="{ actions, item }">
+              <CommonActionMenu
+                class="flex items-center justify-center"
+                :actions="actions"
+                :entity="item"
+                button-size="medium"
+              />
+            </slot>
+          </td>
+        </template>
+      </SimpleTableRow>
     </tbody>
   </table>
 </template>

+ 54 - 0
app/frontend/apps/desktop/components/CommonSimpleTable/SimpleTableRow.vue

@@ -0,0 +1,54 @@
+<!-- Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ -->
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { TableItem } from '#desktop/components/CommonSimpleTable/types.ts'
+
+export interface Props {
+  item: TableItem
+  onClickRow?: (tableItem: TableItem) => void
+  isRowSelected?: boolean
+}
+
+const props = defineProps<Props>()
+
+const emit = defineEmits<{
+  'click-row': [TableItem]
+}>()
+
+const rowEventHandler = computed(() =>
+  props.onClickRow
+    ? {
+        attrs: {
+          role: 'button',
+          tabindex: 0,
+          ariaLabel: __('Select table row'),
+          class:
+            'group focus-visible:outline-1 focus-visible:outline focus-visible:rounded-md active:bg-blue-800 active:dark:bg-blue-800 focus-visible:outline-blue-800 hover:bg-blue-600 dark:hover:bg-blue-900',
+        },
+        events: {
+          click: () => emit('click-row', props.item),
+          keydown: (event: KeyboardEvent) => {
+            if (event.key !== 'Enter') return
+            emit('click-row', props.item)
+          },
+        },
+      }
+    : { attrs: {}, events: {} },
+)
+</script>
+
+<template>
+  <tr
+    class="odd:bg-blue-200 odd:dark:bg-gray-700"
+    :class="{
+      '!bg-blue-800': isRowSelected,
+    }"
+    data-test-id="simple-table-row"
+    v-bind="rowEventHandler.attrs"
+    v-on="rowEventHandler.events"
+  >
+    <slot :is-row-selected="isRowSelected" />
+  </tr>
+</template>

+ 92 - 22
app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts

@@ -1,6 +1,6 @@
 // Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
 
-import { waitFor } from '@testing-library/vue'
+import { waitFor, within } from '@testing-library/vue'
 import { vi } from 'vitest'
 
 import { renderComponent } from '#tests/support/components/index.ts'
@@ -56,20 +56,20 @@ beforeEach(() => {
 
 describe('CommonSimpleTable', () => {
   it('displays the table without actions', async () => {
-    const view = renderTable({
+    const wrapper = renderTable({
       headers: tableHeaders,
       items: tableItems,
     })
 
-    expect(view.getByText('User name')).toBeInTheDocument()
-    expect(view.getByText('Rolle')).toBeInTheDocument()
-    expect(view.getByText('Lindsay Walton')).toBeInTheDocument()
-    expect(view.getByText('Member')).toBeInTheDocument()
-    expect(view.queryByText('Actions')).toBeNull()
+    expect(wrapper.getByText('User name')).toBeInTheDocument()
+    expect(wrapper.getByText('Rolle')).toBeInTheDocument()
+    expect(wrapper.getByText('Lindsay Walton')).toBeInTheDocument()
+    expect(wrapper.getByText('Member')).toBeInTheDocument()
+    expect(wrapper.queryByText('Actions')).toBeNull()
   })
 
   it('displays the table with actions', async () => {
-    const view = renderTable(
+    const wrapper = renderTable(
       {
         headers: tableHeaders,
         items: tableItems,
@@ -78,12 +78,12 @@ describe('CommonSimpleTable', () => {
       { router: true },
     )
 
-    expect(view.getByText('Actions')).toBeInTheDocument()
-    expect(view.getByLabelText('Action menu button')).toBeInTheDocument()
+    expect(wrapper.getByText('Actions')).toBeInTheDocument()
+    expect(wrapper.getByLabelText('Action menu button')).toBeInTheDocument()
   })
 
   it('displays the additional data with the item suffix slot', async () => {
-    const view = renderTable(
+    const wrapper = renderTable(
       {
         headers: tableHeaders,
         items: tableItems,
@@ -97,7 +97,7 @@ describe('CommonSimpleTable', () => {
       },
     )
 
-    expect(view.getByText('Additional Example')).toBeInTheDocument()
+    expect(wrapper.getByText('Additional Example')).toBeInTheDocument()
   })
 
   it('generates expected DOM', async () => {
@@ -121,7 +121,7 @@ describe('CommonSimpleTable', () => {
   })
 
   it('supports text truncation in cell content', async () => {
-    const view = renderTable({
+    const wrapper = renderTable({
       headers: [
         ...tableHeaders,
         {
@@ -141,13 +141,13 @@ describe('CommonSimpleTable', () => {
       ],
     })
 
-    const truncatedText = view.getByText('Some text to be truncated')
+    const truncatedText = wrapper.getByText('Some text to be truncated')
 
     expect(truncatedText.parentElement).toHaveClass('truncate')
   })
 
   it('supports tooltip on truncated cell content', async () => {
-    const view = renderTable({
+    const wrapper = renderTable({
       headers: [
         ...tableHeaders,
         {
@@ -167,18 +167,18 @@ describe('CommonSimpleTable', () => {
       ],
     })
 
-    await view.events.hover(view.getByText('Max Mustermann'))
+    await wrapper.events.hover(wrapper.getByText('Max Mustermann'))
 
     await waitFor(() => {
-      expect(view.getByText('Some text to be truncated')).toBeInTheDocument()
+      expect(wrapper.getByText('Some text to be truncated')).toBeInTheDocument()
       expect(
-        view.getByLabelText('Some text to be truncated'),
+        wrapper.getByLabelText('Some text to be truncated'),
       ).toBeInTheDocument()
     })
   })
 
   it('supports header slot', () => {
-    const view = renderTable(
+    const wrapper = renderTable(
       {
         headers: tableHeaders,
         items: tableItems,
@@ -191,7 +191,7 @@ describe('CommonSimpleTable', () => {
       },
     )
 
-    expect(view.getByText('Custom header')).toBeInTheDocument()
+    expect(wrapper.getByText('Custom header')).toBeInTheDocument()
   })
 
   it('supports listening for row click events', async () => {
@@ -216,12 +216,82 @@ describe('CommonSimpleTable', () => {
 
     await wrapper.events.click(wrapper.getByText('Lindsay Walton'))
 
-    expect(mockedCallback).toHaveBeenCalledWith(item, expect.any(MouseEvent))
+    expect(mockedCallback).toHaveBeenCalledWith(item)
 
     wrapper.getByRole('button', { name: 'Select table row' }).focus()
 
     await wrapper.events.keyboard('{enter}')
 
-    expect(mockedCallback).toHaveBeenCalledWith(item, expect.any(MouseEvent))
+    expect(mockedCallback).toHaveBeenCalledWith(item)
+  })
+
+  it('supports marking row in active color', () => {
+    const wrapper = renderTable({
+      headers: [
+        ...tableHeaders,
+        {
+          key: 'name',
+          label: 'name',
+        },
+      ],
+      selectedRowId: '2',
+      items: [
+        {
+          id: '2',
+          name: 'foo',
+        },
+      ],
+    })
+
+    const row = wrapper.getByTestId('simple-table-row')
+
+    expect(row).toHaveClass('!bg-blue-800')
+  })
+
+  it('supports marking row in active color', () => {
+    const wrapper = renderTable({
+      headers: [
+        {
+          key: 'name',
+          label: 'name',
+        },
+      ],
+      selectedRowId: '2',
+      items: [
+        {
+          id: '2',
+          name: 'foo cell',
+        },
+      ],
+    })
+
+    const row = wrapper.getByTestId('simple-table-row')
+
+    expect(row).toHaveClass('!bg-blue-800')
+    expect(within(row).getByText('foo cell')).toHaveClass(
+      'text-black dark:text-white',
+    )
+  })
+
+  it('supports adding class to table header', () => {
+    const wrapper = renderTable({
+      headers: [
+        {
+          key: 'name',
+          label: 'Awesome Cell Header',
+          labelClass: 'text-red-500 font-bold',
+        },
+      ],
+      items: [
+        {
+          id: '2',
+          name: 'foo cell',
+        },
+      ],
+    })
+
+    expect(wrapper.getByText('Awesome Cell Header')).toHaveClass(
+      'text-red-500 font-bold',
+    )
   })
 })

+ 8 - 57
app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt

@@ -4,11 +4,11 @@
   <thead>
     
     <th
-      class="h-10 p-2.5 text-xs font-normal text-stone-200 ltr:text-left rtl:text-right dark:text-neutral-500"
+      class="h-10 p-2.5 text-xs ltr:text-left rtl:text-right"
     >
       
       <common-label-stub
-        class="font-normal text-stone-200 dark:text-neutral-500 text-left"
+        class="-:font-normal -:text-stone-200 -:dark:text-neutral-500 text-left"
         size="small"
         tag="span"
       />
@@ -17,11 +17,11 @@
       
     </th>
     <th
-      class="h-10 p-2.5 text-xs font-normal text-stone-200 ltr:text-left rtl:text-right dark:text-neutral-500"
+      class="h-10 p-2.5 text-xs ltr:text-left rtl:text-right"
     >
       
       <common-label-stub
-        class="font-normal text-stone-200 dark:text-neutral-500 text-left"
+        class="-:font-normal -:text-stone-200 -:dark:text-neutral-500 text-left"
         size="small"
         tag="span"
       />
@@ -42,59 +42,10 @@
   </thead>
   <tbody>
     
-    <tr
-      class="odd:bg-blue-200 odd:dark:bg-gray-700"
-    >
-      
-      <td
-        class="h-10 p-2.5 text-sm first:rounded-s-md last:rounded-e-md text-left"
-      >
-        
-        <common-label-stub
-          class="inline text-black dark:text-white"
-          size="medium"
-          tag="span"
-        />
-        
-        
-        
-      </td>
-      <td
-        class="h-10 p-2.5 text-sm first:rounded-s-md last:rounded-e-md text-left"
-      >
-        
-        <common-label-stub
-          class="inline text-black dark:text-white"
-          size="medium"
-          tag="span"
-        />
-        
-        
-        
-      </td>
-      
-      <td
-        class="h-10 p-2.5 text-center first:rounded-s-md last:rounded-e-md"
-      >
-        
-        <common-action-menu-stub
-          actions="[object Object],[object Object]"
-          buttonsize="medium"
-          class="flex items-center justify-center"
-          defaultbuttonvariant="neutral"
-          defaulticon="three-dots-vertical"
-          disabled="false"
-          entity="[object Object]"
-          hidearrow="false"
-          nopaddeddefaultbutton="true"
-          nosingleactionmode="false"
-          nosmallroundingdefaultbutton="false"
-          orientation="autoVertical"
-          placement="arrowStart"
-        />
-        
-      </td>
-    </tr>
+    <simple-table-row-stub
+      isrowselected="false"
+      item="[object Object]"
+    />
     
   </tbody>
 </table>

+ 1 - 0
app/frontend/apps/desktop/components/CommonSimpleTable/types.ts

@@ -11,6 +11,7 @@ export interface TableHeader<K = string> {
   alignContent?: 'center' | 'right'
   type?: TableColumnType
   truncate?: boolean
+  labelClass?: string
   [key: string]: unknown
 }
 export interface TableItem {

+ 13 - 19
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketRelationAndRecentLists/TicketRelationAndRecentLists.vue

@@ -3,23 +3,23 @@
 <script setup lang="ts">
 import { computed } from 'vue'
 
-import type { TicketById } from '#shared/entities/ticket/types.ts'
 import { QueryHandler } from '#shared/server/apollo/handler/index.ts'
 
 import CommonLoader from '#desktop/components/CommonLoader/CommonLoader.vue'
 import TicketSimpleTable from '#desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/TicketSimpleTable.vue'
-import type { TicketTableData } from '#desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/types.ts'
+import type { TicketRelationAndRecentListItem } from '#desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/types.ts'
 import { useTicketRelationAndRecentTicketListsQuery } from '#desktop/pages/ticket/graphql/queries/ticketRelationAndRecentTicketLists.api.ts'
 
 interface Props {
   customerId: string
   internalTicketId: number
+  selectedTicketId?: string
 }
 
 const props = defineProps<Props>()
 
 defineEmits<{
-  'click-ticket': [TicketById]
+  'click-ticket': [TicketRelationAndRecentListItem]
 }>()
 
 const ticketMergeRelevantQuery = new QueryHandler(
@@ -40,24 +40,16 @@ const isLoading = ticketMergeRelevantQuery.loading()
 
 const tableData = ticketMergeRelevantQuery.result()
 
-const ticketsByCustomer = computed(() =>
-  tableData.value?.ticketsRecentByCustomer?.map(
-    (ticket) =>
-      ({
-        ...ticket,
-        truncate: true,
-      }) as unknown as TicketTableData, // :TODO - This type💥
-  ),
+const ticketsByCustomer = computed(
+  () =>
+    tableData.value
+      ?.ticketsRecentByCustomer as unknown as TicketRelationAndRecentListItem[],
 )
 
-const ticketsRecentlyViewed = computed(() =>
-  tableData.value?.ticketsRecentlyViewed?.map(
-    (ticket) =>
-      ({
-        ...ticket,
-        truncate: true,
-      }) as unknown as TicketTableData, // :TODO - This type💥
-  ),
+const ticketsRecentlyViewed = computed(
+  () =>
+    tableData.value
+      ?.ticketsRecentlyViewed as unknown as TicketRelationAndRecentListItem[],
 )
 </script>
 
@@ -68,12 +60,14 @@ const ticketsRecentlyViewed = computed(() =>
         v-if="ticketsByCustomer && ticketsByCustomer.length > 0"
         :label="$t('Recent Customer Tickets')"
         :tickets="ticketsByCustomer"
+        :selected-ticket-id="selectedTicketId"
         @click-ticket="$emit('click-ticket', $event)"
       />
 
       <TicketSimpleTable
         v-if="ticketsRecentlyViewed && ticketsRecentlyViewed.length > 0"
         :label="$t('Recently Viewed Tickets')"
+        :selected-ticket-id="selectedTicketId"
         :tickets="ticketsRecentlyViewed"
         @click-ticket="$emit('click-ticket', $event)"
       />

+ 64 - 48
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/TicketSimpleTable.vue

@@ -2,37 +2,68 @@
 
 <script setup lang="ts">
 import { storeToRefs } from 'pinia'
-import { computed } from 'vue'
+import { computed, useTemplateRef } from 'vue'
 
 import type { TicketById } from '#shared/entities/ticket/types.ts'
 import { useApplicationStore } from '#shared/stores/application.ts'
 
 import CommonSimpleTable from '#desktop/components/CommonSimpleTable/CommonSimpleTable.vue'
-import type { TableHeader } from '#desktop/components/CommonSimpleTable/types.ts'
+import type {
+  TableHeader,
+  TableItem,
+} from '#desktop/components/CommonSimpleTable/types.ts'
 import CommonTicketStateIndicatorIcon from '#desktop/components/CommonTicketStateIndicatorIcon/CommonTicketStateIndicatorIcon.vue'
-import type { TicketTableData } from '#desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/types.ts'
+import type { TicketRelationAndRecentListItem } from '#desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/types.ts'
 
 interface Props {
-  tickets: TicketTableData[]
+  tickets: TicketRelationAndRecentListItem[]
   label: string
+  selectedTicketId?: string
 }
 
-defineEmits<{
-  'click-ticket': [TicketById, MouseEvent | KeyboardEvent]
+const emit = defineEmits<{
+  'click-ticket': [TicketRelationAndRecentListItem]
 }>()
 
 const { config } = storeToRefs(useApplicationStore())
 
+const simpleTableInstance = useTemplateRef('simple-table')
+
 const headers = computed<TableHeader[]>(() => [
-  { key: 'state', label: '' },
-  { key: 'number', label: config.value.ticket_hook },
-  { key: 'title', label: __('Title') },
-  { key: 'customer', label: __('Customer') },
-  { key: 'group', label: __('Group') },
-  { key: 'createdAt', label: __('Created at') },
+  { key: 'state', label: '', truncate: true },
+  {
+    key: 'number',
+    label: config.value.ticket_hook,
+    labelClass: 'font-normal text-gray-100 dark:text-neutral-400',
+    truncate: true,
+  },
+  { key: 'title', label: __('Title'), truncate: true },
+  { key: 'customer', label: __('Customer'), truncate: true },
+  { key: 'group', label: __('Group'), truncate: true },
+  { key: 'createdAt', label: __('Created at'), truncate: true },
 ])
 
-const { tickets } = defineProps<Props>()
+const props = defineProps<Props>()
+
+const items = computed<Array<TableItem>>(() =>
+  props.tickets.map((ticket) => ({
+    createdAt: ticket.createdAt,
+    customer: ticket.organization?.name || ticket.customer?.fullname,
+    group: ticket.group?.name,
+    id: ticket.id,
+    key: ticket.id,
+    number: ticket.number,
+    organization: ticket.organization,
+    title: ticket.title,
+    stateColorCode: ticket.stateColorCode,
+    state: ticket.state,
+  })),
+)
+
+const handleRowClick = (row: TableItem) => {
+  const ticket = props.tickets.find((ticket) => ticket.id === row.id)
+  emit('click-ticket', ticket!)
+}
 </script>
 
 <template>
@@ -40,60 +71,45 @@ const { tickets } = defineProps<Props>()
     <CommonLabel class="mb-2" tag="h3">{{ label }}</CommonLabel>
 
     <CommonSimpleTable
+      ref="simple-table"
       class="w-full"
       :headers="headers"
-      :items="tickets"
-      @click-row="
-        (ticket, event) => {
-          $emit('click-ticket', ticket as TicketById, event)
-        }
-      "
+      :items="items"
+      :selected-row-id="selectedTicketId"
+      @click-row="handleRowClick"
     >
-      <template #column-header-number="{ header }">
-        <CommonLabel
-          class="font-normal text-gray-100 dark:text-neutral-400"
-          size="small"
-        >
-          {{ $t(header.label) }}
-        </CommonLabel>
-      </template>
-
-      <template #column-cell-number="{ item }">
+      <template #column-cell-number="{ item, header, isRowSelected }">
         <CommonLink
+          v-tooltip.truncate="simpleTableInstance?.getTooltipText(item, header)"
           :link="`/tickets/${(item as TicketById).internalId}`"
+          :class="{
+            'ltr:text-black rtl:text-black dark:text-white': isRowSelected,
+          }"
+          class="truncate hover:no-underline group-hover:text-black group-active:text-black group-hover:dark:text-white group-active:dark:text-white"
           internal
           target="_blank"
+          @click.stop
+          @keydown.stop
           >{{ item.number }}
         </CommonLink>
       </template>
 
-      <template #column-cell-group="{ item }">
-        <CommonLabel class="text-gray-100 dark:text-neutral-400">
-          {{ (item as TicketById)?.group.name }}
-        </CommonLabel>
-      </template>
-
-      <template #column-cell-customer="{ item }">
-        <CommonLabel class="text-gray-100 dark:text-neutral-400"
-          >{{
-            (item as TicketById)?.organization?.name ||
-            (item as TicketById)?.customer.fullname
-          }}
-        </CommonLabel>
-      </template>
-
-      <template #column-cell-createdAt="{ item }">
+      <template #column-cell-createdAt="{ item, isRowSelected }">
         <CommonDateTime
-          class="text-gray-100 dark:text-neutral-400"
+          class="-:text-gray-100 -:dark:text-neutral-400 group-hover:text-black group-active:text-black group-hover:dark:text-white group-active:dark:text-white"
+          :class="{ 'text-black dark:text-white': isRowSelected }"
           :date-time="item['createdAt'] as string"
           type="absolute"
           absolute-format="date"
         />
       </template>
 
-      <template #column-cell-state="{ item }">
+      <template #column-cell-state="{ item, isRowSelected }">
         <CommonTicketStateIndicatorIcon
-          class="shrink-0"
+          class="shrink-0 group-hover:text-black group-active:text-black group-hover:dark:text-white group-active:dark:text-white"
+          :class="{
+            'ltr:text-black rtl:text-black dark:text-white': isRowSelected,
+          }"
           :color-code="(item as TicketById).stateColorCode"
           :label="(item as TicketById).state.name"
           :aria-labelledby="(item as TicketById).id"

+ 53 - 5
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/__tests__/TicketSimpleTable.spec.ts

@@ -9,13 +9,13 @@ import TicketSimpleTable from '#desktop/pages/ticket/components/TicketDetailView
 describe('TicketSimpleData', () => {
   it('displays a table with ticket data', () => {
     beforeEach(() => {
-      // tell vitest we use mocked time
       vi.useFakeTimers()
     })
 
     mockApplicationConfig({
       ticket_hook: 'hook#',
     })
+
     const wrapper = renderComponent(TicketSimpleTable, {
       props: {
         tickets: [
@@ -51,21 +51,69 @@ describe('TicketSimpleData', () => {
   })
 
   it('emits table data on row click', async () => {
-    const testTicket = createDummyTicket({
+    const fullTicket = createDummyTicket({
       ticketId: '2',
       number: '1111',
       title: 'Dummy',
     })
+
+    const ticket = {
+      id: fullTicket.id,
+      internalId: fullTicket.internalId,
+      createdAt: fullTicket.createdAt,
+      organization: {
+        name: fullTicket.organization?.name,
+        id: fullTicket.organization?.id,
+      },
+      customer: {
+        fullname: fullTicket.customer.fullname,
+        id: fullTicket.customer.id,
+      },
+      group: {
+        name: fullTicket.group?.name,
+        id: fullTicket.group.id,
+      },
+      state: fullTicket.state,
+      number: fullTicket.number,
+      stateColorCode: fullTicket.stateColorCode,
+      title: fullTicket.title,
+    }
+
     const wrapper = renderComponent(TicketSimpleTable, {
       props: {
-        tickets: [testTicket, createDummyTicket()],
+        tickets: [ticket],
         label: 'ROCK YOUR TICKET TABLE',
       },
       router: true,
     })
 
-    await wrapper.events.click(wrapper.getByText('1111'))
+    await wrapper.events.click(
+      wrapper.getByRole('button', { name: 'Select table row' }),
+    )
+
+    expect(wrapper.emitted('click-ticket')).toStrictEqual([[ticket]])
+  })
 
-    expect(wrapper.emitted('click-ticket')).toBeTruthy()
+  it('marks ticket row as active if ticket got selected', () => {
+    const testTicket = createDummyTicket({
+      ticketId: '2',
+      number: '1111',
+      title: 'Dummy',
+    })
+
+    const wrapper = renderComponent(TicketSimpleTable, {
+      props: {
+        tickets: [testTicket],
+        selectedTicketId: testTicket.id,
+        label: 'ROCK YOUR TICKET TABLE',
+      },
+      router: true,
+    })
+
+    expect(
+      wrapper.getByRole('button', { name: 'Select table row' }),
+    ).toHaveClass(
+      '!bg-blue-800 active:bg-blue-800 active:dark:bg-blue-800 focus-visible:outline-blue-800 hover:bg-blue-600 dark:hover:bg-blue-900',
+    )
   })
 })

+ 10 - 7
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/types.ts

@@ -3,19 +3,22 @@
 import type { TicketById } from '#shared/entities/ticket/types.ts'
 import type { Organization, User } from '#shared/graphql/types.ts'
 
-import type { TableItem } from '#desktop/components/CommonSimpleTable/types.ts'
+type OrganizationType = Record<
+  'organization',
+  Pick<Organization, 'name' | 'id'>
+>
 
-type OrganizationType = Pick<Organization, 'name'>
+type CustomerType = Record<'customer', Pick<User, 'fullname' | 'id'>>
 
-type CustomerType = Pick<User, 'fullname'>
+type State = Record<'state', Pick<TicketById['state'], 'name' | 'id'>>
 
-type State = Pick<TicketById['state'], 'name'>
+type Group = Record<'group', Pick<TicketById['group'], 'name' | 'id'>>
 
-export type TicketTableData = Pick<
+export type TicketRelationAndRecentListItem = Pick<
   TicketById,
   'number' | 'internalId' | 'id' | 'title' | 'createdAt' | 'stateColorCode'
 > &
-  TableItem &
   OrganizationType &
   CustomerType &
-  State
+  State &
+  Group

+ 9 - 3
app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/actions/TicketMerge/TicketMergeFlyout.vue

@@ -2,7 +2,7 @@
 
 <script setup lang="ts">
 import { storeToRefs } from 'pinia'
-import { computed, shallowRef } from 'vue'
+import { computed, ref, shallowRef } from 'vue'
 import { useRouter } from 'vue-router'
 
 import {
@@ -23,6 +23,7 @@ import type { ActionFooterOptions } from '#desktop/components/CommonFlyout/types
 import { closeFlyout } from '#desktop/components/CommonFlyout/useFlyout.ts'
 import { useUserCurrentTaskbarTabsStore } from '#desktop/entities/user/current/stores/taskbarTabs.ts'
 import TicketRelationAndRecentLists from '#desktop/pages/ticket/components/TicketDetailView/TicketRelationAndRecentLists/TicketRelationAndRecentLists.vue'
+import type { TicketRelationAndRecentListItem } from '#desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/types.ts'
 import { getTicketNumberWithHook } from '#desktop/pages/ticket/composables/getTicketNumber.ts'
 
 interface Props {
@@ -40,7 +41,7 @@ const { name, ticket: sourceTicket } = defineProps<Props>()
 
 const { form, updateFieldValues, onChangedField } = useForm()
 
-const fromListTargetTicket = shallowRef<TicketById>()
+const fromListTargetTicket = shallowRef<TicketRelationAndRecentListItem>()
 const formListTargetTicketOptions = computed(() => {
   if (!fromListTargetTicket.value) return
 
@@ -73,9 +74,11 @@ const isWaitingForMerge = mergeMutation.loading()
 
 const router = useRouter()
 
+const targetTicketId = ref<string>()
+
 const { notify } = useNotifications()
 
-const handleTicketClick = (ticket: TicketById) => {
+const handleTicketClick = (ticket: TicketRelationAndRecentListItem) => {
   updateFieldValues({
     targetTicketId: ticket.id,
   })
@@ -83,6 +86,8 @@ const handleTicketClick = (ticket: TicketById) => {
 }
 
 onChangedField('targetTicketId', (value) => {
+  targetTicketId.value = (value as string) ?? undefined
+
   if (fromListTargetTicket.value?.id === value) return
   fromListTargetTicket.value = undefined
 })
@@ -142,6 +147,7 @@ const footerActionOptions = computed<ActionFooterOptions>(() => ({
       <TicketRelationAndRecentLists
         :customer-id="sourceTicket.customer.id"
         :internal-ticket-id="sourceTicket.internalId"
+        :selected-ticket-id="targetTicketId"
         @click-ticket="handleTicketClick"
       />
     </div>

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