From d741a731628c9d3eb1b5dd9a4050787141b67a4d Mon Sep 17 00:00:00 2001 From: Florian Liebe Date: Fri, 24 Jan 2025 09:08:23 +0100 Subject: [PATCH] Feature - Desktop view: Implement ticket overviews (iteration 1) Co-authored-by: Benjamin Scharf Co-authored-by: Dominik Klein Co-authored-by: Dusan Vuckovic Co-authored-by: Florian Liebe Co-authored-by: Martin Gruner --- app/channels/graphql_channel.rb | 7 +- app/controllers/graphql_controller.rb | 7 +- app/frontend/apps/desktop/AppDesktop.vue | 2 + .../CommonBreadcrumb/CommonBreadcrumb.vue | 9 + .../components/CommonBreadcrumb/types.ts | 1 + .../CommonCalendarPreviewFlyout.vue | 7 +- .../CommonObjectAttribute.vue | 6 +- .../CommonSimpleTable/CommonSimpleTable.vue | 266 ------- .../CommonSimpleTable.spec.ts.snapshot.txt | 52 -- .../components/CommonSimpleTable/types.ts | 21 - .../CommonSkeleton/CommonSkeleton.vue | 32 + .../__test__/CommonSkeleton.spec.ts | 42 + .../CommonTable/CellContent/Default.vue | 11 + .../CommonTable/CellContent/Timestamp.vue | 18 + .../CellContent/TimestampAbsolute.vue | 19 + .../CommonTable/CommonAdvancedTable.vue | 732 ++++++++++++++++++ .../CommonTable/CommonSimpleTable.vue | 224 ++++++ .../CommonTable/HeaderResizeLine.vue | 159 ++++ .../Skeleton/CommonTableRowsSkeleton.vue | 24 + .../Skeleton/CommonTableSkeleton.vue | 31 + .../components/CommonTable/TableCaption.vue | 18 + .../TableRow.vue} | 37 +- .../CommonTable/TableRowGroupBy.vue | 60 ++ .../__tests__/CommonAdvancedTable.spec.ts | 485 ++++++++++++ .../CommonAdvancedTable.spec.ts.snapshot.txt | 172 ++++ .../__tests__/CommonSimpleTable.spec.ts | 192 +---- .../CommonSimpleTable.spec.ts.snapshot.txt | 58 ++ .../CommonTable/composables/useCellContent.ts | 27 + .../composables}/useTableCheckboxes.ts | 2 +- .../desktop/components/CommonTable/types.ts | 126 +++ .../CommonTicketLabel/CommonTicketLabel.vue | 2 +- .../CommonTicketPriorityIndicator.vue | 24 +- .../CommonTicketPriorityIndicatorIcon.vue | 46 ++ .../CommonTicketPriorityIndicator.spec.ts | 4 +- .../CommonTicketStateIndicator.vue | 2 +- .../CommonTicketStateIndicatorIcon.vue | 0 .../CommonTicketStateIndicatorIcon.spec.ts | 0 .../FieldNotificationsInput.vue | 17 +- .../FieldTicket/FieldTicketOptionIcon.vue | 2 +- .../NavigationMenu/NavigationMenuList.vue | 47 +- .../PageNavigation/PageNavigation.vue | 14 +- .../TwoFactorConfigurationSecurityKeys.vue | 7 +- .../UserTaskbarTabs/Ticket/Ticket.vue | 2 +- .../components/layout/LayoutContent.vue | 17 +- .../components/layout/LayoutSidebar.vue | 29 +- .../layout/__tests__/LayoutContent.spec.ts | 39 + .../layout/__tests__/LayoutSidebar.spec.ts | 17 + .../graphql/queries/ticketsByOverview.api.ts | 23 +- .../graphql/queries/ticketsByOverview.graphql | 83 ++ .../queries/ticketsByOverview.mocks.ts | 0 .../queries/userCurrentTicketOverviews.api.ts | 22 + .../userCurrentTicketOverviews.graphql | 5 + .../userCurrentTicketOverviews.mocks.ts | 17 + ...erviewOrderingFullAttributesUpdates.api.ts | 21 + ...rviewOrderingFullAttributesUpdates.graphql | 7 + ...viewOrderingFullAttributesUpdates.mocks.ts | 9 + .../userCurrentOverviewOrderingUpdates.api.ts | 4 +- ...userCurrentOverviewOrderingUpdates.graphql | 10 + ...serCurrentOverviewOrderingUpdates.mocks.ts | 0 ...TicketOverviewFullAttributesUpdates.api.ts | 21 + ...icketOverviewFullAttributesUpdates.graphql | 7 + ...cketOverviewFullAttributesUpdates.mocks.ts | 9 + .../userCurrentTicketOverviewUpdates.api.ts | 21 + .../userCurrentTicketOverviewUpdates.graphql | 8 + .../userCurrentTicketOverviewUpdates.mocks.ts | 9 + .../entities/ticket/stores/ticketOverviews.ts | 109 +++ .../useLifetimeCustomerTicketsCount.ts | 19 + .../initializer/assets/all-tickets.svg | 12 + .../initializer/assets/arrow-down-short.svg | 3 + .../initializer/assets/arrow-up-short.svg | 3 + .../assets/list-columns-reverse.svg | 3 + .../pages/dashboard/views/Playground.vue | 65 +- .../personal-setting-devices.spec.ts | 4 +- .../personal-setting-overviews-a11y.spec.ts | 4 +- .../personal-setting-overviews.spec.ts | 19 +- .../personal-setting-token-access.spec.ts | 2 +- .../queries/userCurrentOverviewList.api.ts | 12 +- .../queries/userCurrentOverviewList.graphql | 4 +- ...userCurrentOverviewOrderingUpdates.graphql | 10 - .../views/PersonalSetting.vue | 2 +- .../views/PersonalSettingDevices.vue | 9 +- .../views/PersonalSettingLinkedAccounts.vue | 9 +- .../views/PersonalSettingOverviews.vue | 18 +- .../views/PersonalSettingTokenAccess.vue | 10 +- .../__tests__/ticket-overviews-a11y.spec.ts | 77 ++ .../__tests__/ticket-overviews.spec.ts | 394 ++++++++++ .../components/TicketList.vue | 284 +++++++ .../components/TicketOverviewsEmptyText.vue | 33 + .../components/TicketOverviewsSidebar.vue | 36 + .../TicketOverviewsList.vue | 29 + .../components/__tests__/TicketList.spec.ts | 90 +++ .../TicketOverviewsEmptyText.spec.ts | 24 + .../__tests__/TicketOverviewsSidebar.spec.ts | 135 ++++ .../composables/useTicketOverviews.ts | 17 + .../desktop/pages/ticket-overviews/routes.ts | 24 + .../views/TicketOverviews.vue | 130 ++++ .../ticket-create/ticket-create-idoit.spec.ts | 3 +- .../ticket-detail-view-links.spec.ts | 4 +- .../TicketRelationAndRecentLists.spec.ts | 6 +- .../TicketSimpleTable/TicketSimpleTable.vue | 18 +- .../__tests__/TicketSimpleTable.spec.ts | 6 +- .../__tests__/TicketMergeFlyout.spec.ts | 13 +- .../TicketSidebarIdoit/IdoitFlyout.vue | 2 +- .../IdoitFlyout/IdoitObjectList.vue | 10 +- .../__tests__/IdoitObjectList.spec.ts | 11 +- .../__tests__/TicketLinksFlyout.spec.ts | 10 +- .../CommonTicketPriorityIndicator.vue | 4 +- .../CommonTicketPriorityIndicator.spec.ts | 4 +- .../ticket/composables/useTicketOverviews.ts | 13 +- .../queries/ticketOverviewTicketCount.api.ts | 31 - .../queries/ticketOverviewTicketCount.graphql | 15 - .../ticket/stores/ticketOverviewOrder.ts | 17 +- .../entities/ticket/stores/ticketOverviews.ts | 21 +- .../pages/ticket/__tests__/mocks/overview.ts | 22 +- .../components/TicketList/TicketList.vue | 6 +- .../queries/ticketsByOverviewSlim.api.ts | 79 ++ ....graphql => ticketsByOverviewSlim.graphql} | 2 +- .../queries/ticketsByOverviewSlim.mocks.ts | 17 + .../TicketInformationDetails.vue | 8 +- .../components/CommonBadge/CommonBadge.vue | 7 + .../shared/components/CommonBadge/types.ts | 2 +- .../components/CommonLabel/CommonLabel.vue | 2 +- .../components/CommonLink/CommonLink.vue | 2 +- .../fields/FieldEditor/extensions/List.ts | 4 +- .../ObjectAttributes/ObjectAttribute.vue | 52 ++ .../ObjectAttributes/ObjectAttributes.vue | 10 +- .../__tests__/ObjectAttribute.spec.ts | 247 ++++++ .../__tests__/ObjectAttributes.spec.ts | 46 -- .../__tests__/attributes.json | 11 - .../AttributeInput/AttributeInput.vue | 5 +- .../attributes/AttributeInput/index.ts | 9 +- .../AttributeTextarea/AttributeTextarea.vue | 3 +- .../components/ObjectAttributes/types.ts | 3 + .../useDisplayObjectAttributes.ts | 107 ++- .../components/ObjectAttributes/utils.ts | 62 ++ .../__tests__/useDebouncedLoading.spec.ts | 61 ++ .../shared/composables/list/useSorting.ts | 55 ++ .../shared/composables/useDebouncedLoading.ts | 12 +- .../composables/usePagination.ts | 4 +- app/frontend/shared/entities/group/entity.ts | 14 + .../entities/object-attributes/types/store.ts | 3 +- .../shared/entities/organization/entity.ts | 17 + .../shared/entities/ticket-priority/entity.ts | 14 + .../shared/entities/ticket-state/entity.ts | 14 + .../fragments/overviewAttributes.api.ts | 2 + .../fragments/overviewAttributes.graphql | 2 + .../queries/ticket/overviewOrder.api.ts | 11 +- .../queries/ticket/overviewOrder.graphql | 11 +- .../queries/ticket/overviewTicketCount.api.ts | 22 + .../ticket/overviewTicketCount.graphql | 6 + .../ticket/overviewTicketCount.mocks.ts} | 2 +- .../graphql/queries/ticket/overviews.api.ts | 11 +- .../graphql/queries/ticket/overviews.graphql | 11 +- .../ticketOverviewUpdates.api.ts | 13 +- .../ticketOverviewUpdates.graphql | 11 +- .../ticketOverviewUpdates.mocks.ts | 0 .../ticket/stores/objectAttributes.ts | 6 +- app/frontend/shared/entities/useEntity.ts | 33 + app/frontend/shared/entities/user/entity.ts | 14 + app/frontend/shared/graphql/types.ts | 179 +++-- app/frontend/tests/graphql/builders/index.ts | 1 - .../components/checkSimpleTableContent.ts | 10 +- .../tests/support/mocks/ticket-overviews.ts | 184 +++-- .../user/current/overview/reset_order.rb | 2 +- app/graphql/gql/queries/ticket/overviews.rb | 4 +- .../gql/queries/user/current/overview/list.rb | 20 - .../queries/user/current/ticket/overviews.rb | 16 + .../article_updates.rb} | 2 +- .../live_user_updates.rb} | 2 +- .../overview_updates.rb} | 6 +- .../user/current/overview_ordering_updates.rb | 28 +- .../user/current/ticket/overview_updates.rb | 22 + app/graphql/gql/types/overview_type.rb | 12 +- app/graphql/gql/types/ticket_type.rb | 3 +- app/graphql/graphql_introspection.json | 408 +++++----- app/models/overview/triggers_subscriptions.rb | 5 +- app/models/taskbar/triggers_subscriptions.rb | 2 +- .../ticket/article/triggers_subscriptions.rb | 6 +- app/services/service/user/overview/list.rb | 5 +- i18n/zammad.pot | 233 ++++-- .../gql/queries/ticket/overviews_spec.rb | 84 +- .../user/current/overview/list_spec.rb | 58 -- .../user/current/ticket/overviews_spec.rb | 119 +++ .../article_updates_spec.rb} | 2 +- .../live_user_updates_spec.rb} | 2 +- .../ticket/overview_updates_spec.rb | 53 ++ .../ticket_overview_updates_spec.rb | 56 -- .../current/overview_ordering_updates_spec.rb | 22 +- .../current/ticket/overview_updates_spec.rb | 56 ++ spec/models/taskbar/list_examples.rb | 4 +- .../taskbar/triggers_subscriptions_spec.rb | 18 +- .../service/user/overview/list_spec.rb | 2 +- spec/support/graphql.rb | 3 +- .../desktop/personal_setting/profile_spec.rb | 5 +- 194 files changed, 6175 insertions(+), 1684 deletions(-) delete mode 100644 app/frontend/apps/desktop/components/CommonSimpleTable/CommonSimpleTable.vue delete mode 100644 app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt delete mode 100644 app/frontend/apps/desktop/components/CommonSimpleTable/types.ts create mode 100644 app/frontend/apps/desktop/components/CommonSkeleton/CommonSkeleton.vue create mode 100644 app/frontend/apps/desktop/components/CommonSkeleton/__test__/CommonSkeleton.spec.ts create mode 100644 app/frontend/apps/desktop/components/CommonTable/CellContent/Default.vue create mode 100644 app/frontend/apps/desktop/components/CommonTable/CellContent/Timestamp.vue create mode 100644 app/frontend/apps/desktop/components/CommonTable/CellContent/TimestampAbsolute.vue create mode 100644 app/frontend/apps/desktop/components/CommonTable/CommonAdvancedTable.vue create mode 100644 app/frontend/apps/desktop/components/CommonTable/CommonSimpleTable.vue create mode 100644 app/frontend/apps/desktop/components/CommonTable/HeaderResizeLine.vue create mode 100644 app/frontend/apps/desktop/components/CommonTable/Skeleton/CommonTableRowsSkeleton.vue create mode 100644 app/frontend/apps/desktop/components/CommonTable/Skeleton/CommonTableSkeleton.vue create mode 100644 app/frontend/apps/desktop/components/CommonTable/TableCaption.vue rename app/frontend/apps/desktop/components/{CommonSimpleTable/SimpleTableRow.vue => CommonTable/TableRow.vue} (61%) create mode 100644 app/frontend/apps/desktop/components/CommonTable/TableRowGroupBy.vue create mode 100644 app/frontend/apps/desktop/components/CommonTable/__tests__/CommonAdvancedTable.spec.ts create mode 100644 app/frontend/apps/desktop/components/CommonTable/__tests__/CommonAdvancedTable.spec.ts.snapshot.txt rename app/frontend/apps/desktop/components/{CommonSimpleTable => CommonTable}/__tests__/CommonSimpleTable.spec.ts (60%) create mode 100644 app/frontend/apps/desktop/components/CommonTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt create mode 100644 app/frontend/apps/desktop/components/CommonTable/composables/useCellContent.ts rename app/frontend/apps/desktop/components/{CommonSimpleTable => CommonTable/composables}/useTableCheckboxes.ts (94%) create mode 100644 app/frontend/apps/desktop/components/CommonTable/types.ts create mode 100644 app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/CommonTicketPriorityIndicatorIcon.vue rename app/frontend/apps/desktop/components/{CommonTicketStateIndicatorIcon => CommonTicketStateIndicator}/CommonTicketStateIndicatorIcon.vue (100%) rename app/frontend/apps/desktop/components/{CommonTicketStateIndicatorIcon => CommonTicketStateIndicator}/__tests__/CommonTicketStateIndicatorIcon.spec.ts (100%) rename app/frontend/apps/{mobile/pages => desktop/entities}/ticket/graphql/queries/ticketsByOverview.api.ts (87%) create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/queries/ticketsByOverview.graphql rename app/frontend/apps/{mobile/pages => desktop/entities}/ticket/graphql/queries/ticketsByOverview.mocks.ts (100%) create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/queries/userCurrentTicketOverviews.api.ts create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/queries/userCurrentTicketOverviews.graphql create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/queries/userCurrentTicketOverviews.mocks.ts create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/subscriptions/useCurrentOverviewOrderingFullAttributesUpdates.api.ts create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/subscriptions/useCurrentOverviewOrderingFullAttributesUpdates.graphql create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/subscriptions/useCurrentOverviewOrderingFullAttributesUpdates.mocks.ts rename app/frontend/apps/desktop/{pages/personal-setting => entities/ticket}/graphql/subscriptions/userCurrentOverviewOrderingUpdates.api.ts (90%) create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/subscriptions/userCurrentOverviewOrderingUpdates.graphql rename app/frontend/apps/desktop/{pages/personal-setting => entities/ticket}/graphql/subscriptions/userCurrentOverviewOrderingUpdates.mocks.ts (100%) create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/subscriptions/userCurrentTicketOverviewFullAttributesUpdates.api.ts create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/subscriptions/userCurrentTicketOverviewFullAttributesUpdates.graphql create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/subscriptions/userCurrentTicketOverviewFullAttributesUpdates.mocks.ts create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/subscriptions/userCurrentTicketOverviewUpdates.api.ts create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/subscriptions/userCurrentTicketOverviewUpdates.graphql create mode 100644 app/frontend/apps/desktop/entities/ticket/graphql/subscriptions/userCurrentTicketOverviewUpdates.mocks.ts create mode 100644 app/frontend/apps/desktop/entities/ticket/stores/ticketOverviews.ts create mode 100644 app/frontend/apps/desktop/entities/user/current/composables/useLifetimeCustomerTicketsCount.ts create mode 100644 app/frontend/apps/desktop/initializer/assets/all-tickets.svg create mode 100644 app/frontend/apps/desktop/initializer/assets/arrow-down-short.svg create mode 100644 app/frontend/apps/desktop/initializer/assets/arrow-up-short.svg create mode 100644 app/frontend/apps/desktop/initializer/assets/list-columns-reverse.svg delete mode 100644 app/frontend/apps/desktop/pages/personal-setting/graphql/subscriptions/userCurrentOverviewOrderingUpdates.graphql create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/__tests__/ticket-overviews-a11y.spec.ts create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/__tests__/ticket-overviews.spec.ts create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/components/TicketList.vue create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsEmptyText.vue create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsSidebar.vue create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsSidebar/TicketOverviewsList.vue create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketList.spec.ts create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketOverviewsEmptyText.spec.ts create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketOverviewsSidebar.spec.ts create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/composables/useTicketOverviews.ts create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/routes.ts create mode 100644 app/frontend/apps/desktop/pages/ticket-overviews/views/TicketOverviews.vue delete mode 100644 app/frontend/apps/mobile/entities/ticket/graphql/queries/ticketOverviewTicketCount.api.ts delete mode 100644 app/frontend/apps/mobile/entities/ticket/graphql/queries/ticketOverviewTicketCount.graphql create mode 100644 app/frontend/apps/mobile/pages/ticket/graphql/queries/ticketsByOverviewSlim.api.ts rename app/frontend/apps/mobile/pages/ticket/graphql/queries/{ticketsByOverview.graphql => ticketsByOverviewSlim.graphql} (97%) create mode 100644 app/frontend/apps/mobile/pages/ticket/graphql/queries/ticketsByOverviewSlim.mocks.ts create mode 100644 app/frontend/shared/components/ObjectAttributes/ObjectAttribute.vue create mode 100644 app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttribute.spec.ts create mode 100644 app/frontend/shared/composables/__tests__/useDebouncedLoading.spec.ts create mode 100644 app/frontend/shared/composables/list/useSorting.ts rename app/frontend/{apps/mobile => shared}/composables/usePagination.ts (98%) create mode 100644 app/frontend/shared/entities/group/entity.ts create mode 100644 app/frontend/shared/entities/organization/entity.ts create mode 100644 app/frontend/shared/entities/ticket-priority/entity.ts create mode 100644 app/frontend/shared/entities/ticket-state/entity.ts create mode 100644 app/frontend/shared/entities/ticket/graphql/queries/ticket/overviewTicketCount.api.ts create mode 100644 app/frontend/shared/entities/ticket/graphql/queries/ticket/overviewTicketCount.graphql rename app/frontend/{apps/mobile/entities/ticket/graphql/queries/ticketOverviewTicketCount.mocks.ts => shared/entities/ticket/graphql/queries/ticket/overviewTicketCount.mocks.ts} (92%) rename app/frontend/{apps/mobile => shared}/entities/ticket/graphql/subscriptions/ticketOverviewUpdates.api.ts (85%) rename app/frontend/{apps/mobile => shared}/entities/ticket/graphql/subscriptions/ticketOverviewUpdates.graphql (57%) rename app/frontend/{apps/mobile => shared}/entities/ticket/graphql/subscriptions/ticketOverviewUpdates.mocks.ts (100%) create mode 100644 app/frontend/shared/entities/useEntity.ts create mode 100644 app/frontend/shared/entities/user/entity.ts delete mode 100644 app/graphql/gql/queries/user/current/overview/list.rb create mode 100644 app/graphql/gql/queries/user/current/ticket/overviews.rb rename app/graphql/gql/subscriptions/{ticket_article_updates.rb => ticket/article_updates.rb} (98%) rename app/graphql/gql/subscriptions/{ticket_live_user_updates.rb => ticket/live_user_updates.rb} (96%) rename app/graphql/gql/subscriptions/{ticket_overview_updates.rb => ticket/overview_updates.rb} (58%) create mode 100644 app/graphql/gql/subscriptions/user/current/ticket/overview_updates.rb delete mode 100644 spec/graphql/gql/queries/user/current/overview/list_spec.rb create mode 100644 spec/graphql/gql/queries/user/current/ticket/overviews_spec.rb rename spec/graphql/gql/subscriptions/{ticket_article_updates_spec.rb => ticket/article_updates_spec.rb} (98%) rename spec/graphql/gql/subscriptions/{ticket_live_user_updates_spec.rb => ticket/live_user_updates_spec.rb} (97%) create mode 100644 spec/graphql/gql/subscriptions/ticket/overview_updates_spec.rb delete mode 100644 spec/graphql/gql/subscriptions/ticket_overview_updates_spec.rb create mode 100644 spec/graphql/gql/subscriptions/user/current/ticket/overview_updates_spec.rb diff --git a/app/channels/graphql_channel.rb b/app/channels/graphql_channel.rb index 86e7895734cb..8a33d70b6e2f 100644 --- a/app/channels/graphql_channel.rb +++ b/app/channels/graphql_channel.rb @@ -12,10 +12,11 @@ def execute(data) # context must be kept in sync with GraphqlController! context = { - sid: sid, - current_user: current_user, + sid: sid, + current_user: current_user, + current_user_id: current_user&.id, # :channel is required for ActionCableSubscriptions and MUST NOT be used otherwise. - channel: self, + channel: self, } result = UserInfo.with_user_id(current_user&.id) do diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb index 70dfa90ce9b7..f9bb2090b592 100644 --- a/app/controllers/graphql_controller.rb +++ b/app/controllers/graphql_controller.rb @@ -49,10 +49,11 @@ def single_query def context # context must be kept in sync with GraphqlChannel! { - sid: session.id, - current_user: current_user, + sid: session.id, + current_user: current_user, + current_user_id: current_user&.id, # :controller is used by login/logout mutations and MUST NOT be used otherwise. - controller: self, + controller: self, } end diff --git a/app/frontend/apps/desktop/AppDesktop.vue b/app/frontend/apps/desktop/AppDesktop.vue index c088ec637c16..088f68f8fd4a 100644 --- a/app/frontend/apps/desktop/AppDesktop.vue +++ b/app/frontend/apps/desktop/AppDesktop.vue @@ -20,6 +20,7 @@ import { useSessionStore } from '#shared/stores/session.ts' import emitter from '#shared/utils/emitter.ts' import { initializeConfirmationDialog } from '#desktop/components/CommonConfirmationDialog/initializeConfirmationDialog.ts' +import { useTicketOverviewsStore } from '#desktop/entities/ticket/stores/ticketOverviews.ts' import { useUserCurrentTaskbarTabsStore } from '#desktop/entities/user/current/stores/taskbarTabs.ts' const router = useRouter() @@ -71,6 +72,7 @@ watch( (newValue, oldValue) => { if (!newValue || oldValue) return + useTicketOverviewsStore() useUserCurrentTaskbarTabsStore() initializeDefaultObjectAttributes() }, diff --git a/app/frontend/apps/desktop/components/CommonBreadcrumb/CommonBreadcrumb.vue b/app/frontend/apps/desktop/components/CommonBreadcrumb/CommonBreadcrumb.vue index ad870ecdd6bd..cdc6ecc50ef5 100644 --- a/app/frontend/apps/desktop/components/CommonBreadcrumb/CommonBreadcrumb.vue +++ b/app/frontend/apps/desktop/components/CommonBreadcrumb/CommonBreadcrumb.vue @@ -68,6 +68,15 @@ const sizeClasses = computed(() => { }} + + {{ item.count }} + + () {{ $t(label) }} - + {{ body }} diff --git a/app/frontend/apps/desktop/components/CommonSimpleTable/CommonSimpleTable.vue b/app/frontend/apps/desktop/components/CommonSimpleTable/CommonSimpleTable.vue deleted file mode 100644 index 5c0cdeb4bd67..000000000000 --- a/app/frontend/apps/desktop/components/CommonSimpleTable/CommonSimpleTable.vue +++ /dev/null @@ -1,266 +0,0 @@ - - - - - diff --git a/app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt b/app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt deleted file mode 100644 index 420080858fbe..000000000000 --- a/app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - -
- - - - - - - - - - - - - -
\ No newline at end of file diff --git a/app/frontend/apps/desktop/components/CommonSimpleTable/types.ts b/app/frontend/apps/desktop/components/CommonSimpleTable/types.ts deleted file mode 100644 index fad88dd1dae6..000000000000 --- a/app/frontend/apps/desktop/components/CommonSimpleTable/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -import type { Props as CommonLinkProps } from '#shared/components/CommonLink/CommonLink.vue' - -type TableColumnType = 'timestamp' | 'timestamp_absolute' | 'link' - -export interface TableHeader { - key: K - label: string - labelPlaceholder?: string[] - columnClass?: string - columnSeparator?: boolean - alignContent?: 'center' | 'right' - type?: TableColumnType - truncate?: boolean - labelClass?: string - [key: string]: unknown -} -export interface TableItem { - [key: string]: unknown | Partial - id: string | number -} diff --git a/app/frontend/apps/desktop/components/CommonSkeleton/CommonSkeleton.vue b/app/frontend/apps/desktop/components/CommonSkeleton/CommonSkeleton.vue new file mode 100644 index 000000000000..7760f3934d16 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonSkeleton/CommonSkeleton.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/app/frontend/apps/desktop/components/CommonSkeleton/__test__/CommonSkeleton.spec.ts b/app/frontend/apps/desktop/components/CommonSkeleton/__test__/CommonSkeleton.spec.ts new file mode 100644 index 000000000000..5ca8a06c3b34 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonSkeleton/__test__/CommonSkeleton.spec.ts @@ -0,0 +1,42 @@ +// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ + +import { expect } from 'vitest' + +import { renderComponent } from '#tests/support/components/index.ts' + +import CommonSkeleton from '#desktop/components/CommonSkeleton/CommonSkeleton.vue' + +describe('CommonSkeleton', () => { + it('renders CommonSkeleton', () => { + const wrapper = renderComponent(CommonSkeleton) + + expect(wrapper.getByRole('progressbar')).toHaveClass('animate-pulse') + }) + + it('supports to make the skeleton completly round', () => { + const wrapper = renderComponent(CommonSkeleton, { + props: { rounded: true }, + }) + expect(wrapper.getByRole('progressbar')).toHaveClass('rounded-full') + }) + + describe('a11y', () => { + it('should have no accessibility violations', async () => { + const wrapper = renderComponent(CommonSkeleton, { + label: 'Avatar skeleton', + }) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + expect(wrapper.getByRole('progressbar'), { + busy: true, + name: 'Avatar skeleton', + value: { + min: 0, + max: 100, + text: 'Please wait until content is loaded', + }, + }).toBeInTheDocument() + }) + }) +}) diff --git a/app/frontend/apps/desktop/components/CommonTable/CellContent/Default.vue b/app/frontend/apps/desktop/components/CommonTable/CellContent/Default.vue new file mode 100644 index 000000000000..b4380286b536 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/CellContent/Default.vue @@ -0,0 +1,11 @@ + + + + + diff --git a/app/frontend/apps/desktop/components/CommonTable/CellContent/Timestamp.vue b/app/frontend/apps/desktop/components/CommonTable/CellContent/Timestamp.vue new file mode 100644 index 000000000000..90db76cda47f --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/CellContent/Timestamp.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/app/frontend/apps/desktop/components/CommonTable/CellContent/TimestampAbsolute.vue b/app/frontend/apps/desktop/components/CommonTable/CellContent/TimestampAbsolute.vue new file mode 100644 index 000000000000..ab04be4dd657 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/CellContent/TimestampAbsolute.vue @@ -0,0 +1,19 @@ + + + + + diff --git a/app/frontend/apps/desktop/components/CommonTable/CommonAdvancedTable.vue b/app/frontend/apps/desktop/components/CommonTable/CommonAdvancedTable.vue new file mode 100644 index 000000000000..46b33a6ebd3e --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/CommonAdvancedTable.vue @@ -0,0 +1,732 @@ + + + + + + + diff --git a/app/frontend/apps/desktop/components/CommonTable/CommonSimpleTable.vue b/app/frontend/apps/desktop/components/CommonTable/CommonSimpleTable.vue new file mode 100644 index 000000000000..3f56c75f96e1 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/CommonSimpleTable.vue @@ -0,0 +1,224 @@ + + + + + diff --git a/app/frontend/apps/desktop/components/CommonTable/HeaderResizeLine.vue b/app/frontend/apps/desktop/components/CommonTable/HeaderResizeLine.vue new file mode 100644 index 000000000000..d13050d0e55c --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/HeaderResizeLine.vue @@ -0,0 +1,159 @@ + + + + + diff --git a/app/frontend/apps/desktop/components/CommonTable/Skeleton/CommonTableRowsSkeleton.vue b/app/frontend/apps/desktop/components/CommonTable/Skeleton/CommonTableRowsSkeleton.vue new file mode 100644 index 000000000000..b44b75e7510c --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/Skeleton/CommonTableRowsSkeleton.vue @@ -0,0 +1,24 @@ + + + + + diff --git a/app/frontend/apps/desktop/components/CommonTable/Skeleton/CommonTableSkeleton.vue b/app/frontend/apps/desktop/components/CommonTable/Skeleton/CommonTableSkeleton.vue new file mode 100644 index 000000000000..df9be3eaf607 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/Skeleton/CommonTableSkeleton.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/app/frontend/apps/desktop/components/CommonTable/TableCaption.vue b/app/frontend/apps/desktop/components/CommonTable/TableCaption.vue new file mode 100644 index 000000000000..33791b71e79b --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/TableCaption.vue @@ -0,0 +1,18 @@ + + + + + diff --git a/app/frontend/apps/desktop/components/CommonSimpleTable/SimpleTableRow.vue b/app/frontend/apps/desktop/components/CommonTable/TableRow.vue similarity index 61% rename from app/frontend/apps/desktop/components/CommonSimpleTable/SimpleTableRow.vue rename to app/frontend/apps/desktop/components/CommonTable/TableRow.vue index f6d3e6d6bf08..2b63c0a5fae6 100644 --- a/app/frontend/apps/desktop/components/CommonSimpleTable/SimpleTableRow.vue +++ b/app/frontend/apps/desktop/components/CommonTable/TableRow.vue @@ -3,13 +3,14 @@ diff --git a/app/frontend/apps/desktop/components/CommonTable/TableRowGroupBy.vue b/app/frontend/apps/desktop/components/CommonTable/TableRowGroupBy.vue new file mode 100644 index 000000000000..a0a47362fff7 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/TableRowGroupBy.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/app/frontend/apps/desktop/components/CommonTable/__tests__/CommonAdvancedTable.spec.ts b/app/frontend/apps/desktop/components/CommonTable/__tests__/CommonAdvancedTable.spec.ts new file mode 100644 index 000000000000..977f3b7a7be1 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/__tests__/CommonAdvancedTable.spec.ts @@ -0,0 +1,485 @@ +// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ + +import { waitFor, within } from '@testing-library/vue' +import { vi } from 'vitest' +import { ref } from 'vue' + +import ticketObjectAttributes from '#tests/graphql/factories/fixtures/ticket-object-attributes.ts' +import { + type ExtendedMountingOptions, + renderComponent, +} from '#tests/support/components/index.ts' +import { waitForNextTick } from '#tests/support/utils.ts' + +import { mockObjectManagerFrontendAttributesQuery } from '#shared/entities/object-attributes/graphql/queries/objectManagerFrontendAttributes.mocks.ts' +import { EnumObjectManagerObjects } from '#shared/graphql/types.ts' +import { + convertToGraphQLId, + getIdFromGraphQLId, +} from '#shared/graphql/utils.ts' +import { i18n } from '#shared/i18n.ts' +import type { ObjectWithId } from '#shared/types/utils.ts' + +import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts' + +import CommonAdvancedTable from '../CommonAdvancedTable.vue' + +import type { AdvancedTableProps, TableAdvancedItem } from '../types.ts' + +const tableHeaders = ['title', 'owner', 'state', 'priority', 'created_at'] + +const tableItems: TableAdvancedItem[] = [ + { + id: convertToGraphQLId('Ticket', 1), + title: 'Dummy ticket', + owner: { + __type: 'User', + id: convertToGraphQLId('User', 1), + internalId: 2, + firstname: 'Agent 1', + lastname: 'Test', + fullname: 'Agent 1 Test', + }, + state: { + __typename: 'TicketState', + id: convertToGraphQLId('TicketState', 1), + name: 'open', + }, + priority: { + __typename: 'TicketPriority', + id: convertToGraphQLId('TicketPriority', 3), + name: '3 high', + }, + created_at: '2021-01-01T12:00:00Z', + }, +] + +const tableActions: MenuItem[] = [ + { + key: 'download', + label: 'Download this row', + icon: 'download', + }, + { + key: 'delete', + label: 'Delete this row', + icon: 'trash3', + }, +] + +const renderTable = async ( + props: AdvancedTableProps, + options: ExtendedMountingOptions = { form: true }, +) => { + const wrapper = renderComponent(CommonAdvancedTable, { + ...options, + props: { + object: EnumObjectManagerObjects.Ticket, + ...props, + }, + }) + + await waitForNextTick() + + return wrapper +} + +beforeEach(() => { + mockObjectManagerFrontendAttributesQuery({ + objectManagerFrontendAttributes: ticketObjectAttributes(), + }) + + i18n.setTranslationMap(new Map([['Priority', 'Wichtigkeit']])) +}) + +describe('CommonAdvancedTable', () => { + it('displays the table without actions', async () => { + const wrapper = await renderTable({ + headers: tableHeaders, + items: tableItems, + totalItems: 100, + caption: 'Table caption', + }) + + expect(wrapper.getByText('Title')).toBeInTheDocument() + expect(wrapper.getByText('Owner')).toBeInTheDocument() + expect(wrapper.getByText('Wichtigkeit')).toBeInTheDocument() + expect(wrapper.getByText('State')).toBeInTheDocument() + + expect(wrapper.getByText('Dummy ticket')).toBeInTheDocument() + expect(wrapper.getByText('Agent 1 Test')).toBeInTheDocument() + expect(wrapper.getByText('open')).toBeInTheDocument() + expect(wrapper.getByText('3 high')).toBeInTheDocument() + expect(wrapper.queryByText('Actions')).toBeNull() + }) + + it('displays the table with actions', async () => { + const wrapper = await renderTable( + { + headers: tableHeaders, + items: tableItems, + totalItems: 100, + actions: tableActions, + caption: 'Table caption', + }, + { + router: true, + form: true, + }, + ) + + expect(wrapper.getByText('Actions')).toBeInTheDocument() + expect(wrapper.getByLabelText('Action menu button')).toBeInTheDocument() + }) + + it('displays the additional data with the item suffix slot', async () => { + const wrapper = await renderTable( + { + headers: tableHeaders, + items: tableItems, + totalItems: 100, + actions: tableActions, + caption: 'Table caption', + }, + { + router: true, + form: true, + slots: { + 'item-suffix-title': 'Additional Example', + }, + }, + ) + + expect(wrapper.getByText('Additional Example')).toBeInTheDocument() + }) + + it('generates expected DOM', async () => { + // TODO: check if such snapshot test is really the way we want to go. + const view = await renderTable( + { + headers: tableHeaders, + items: tableItems, + totalItems: 100, + actions: tableActions, + caption: 'Table caption', + }, + // NB: Please don't remove this, otherwise snapshot would contain markup of many more components other than the + // one under the test, which can lead to false positives. + { + shallow: true, + form: true, + }, + ) + + expect(view.baseElement.querySelector('table')).toMatchFileSnapshot( + `${__filename}.snapshot.txt`, + ) + }) + + it('supports text truncation in cell content', async () => { + const wrapper = await renderTable({ + headers: [...tableHeaders, 'truncated', 'untruncated'], + attributes: [ + { + name: 'truncated', + label: 'Truncated', + headerPreferences: { + truncate: true, + }, + columnPreferences: {}, + dataOption: { + type: 'text', + }, + dataType: 'input', + }, + { + name: 'untruncated', + label: 'Untruncated', + headerPreferences: { + truncate: false, + }, + columnPreferences: {}, + dataOption: { + type: 'text', + }, + dataType: 'input', + }, + ], + items: [ + ...tableItems, + { + id: convertToGraphQLId('Ticket', 2), + name: 'Max Mustermann', + role: 'Admin', + truncated: 'Some text to be truncated', + untruncated: 'Some text not to be truncated', + }, + ], + totalItems: 100, + caption: 'Table caption', + }) + + const truncatedText = wrapper.getByText('Some text to be truncated') + + expect(truncatedText).toHaveAttribute('data-tooltip', 'true') + expect(truncatedText.parentElement).toHaveClass('truncate') + + const untruncatedText = wrapper.getByText('Some text not to be truncated') + + expect(untruncatedText).not.toHaveAttribute('data-tooltip') + expect(untruncatedText.parentElement).not.toHaveClass('truncate') + }) + + it('supports header slot', async () => { + const wrapper = await renderTable( + { + headers: tableHeaders, + items: tableItems, + actions: tableActions, + totalItems: 100, + caption: 'Table caption', + }, + { + form: true, + slots: { + 'column-header-title': '
Custom header
', + }, + }, + ) + + expect(wrapper.getByText('Custom header')).toBeInTheDocument() + }) + + it('supports listening for row click events', async () => { + const mockedCallback = vi.fn() + + const item = tableItems[0] + + const wrapper = renderComponent( + { + components: { CommonAdvancedTable }, + setup() { + return { + mockedCallback, + tableHeaders, + attributes: [ + { + name: 'title', + label: 'Title', + headerPreferences: {}, + columnPreferences: {}, + dataOption: {}, + dataType: 'input', + }, + ], + items: [item], + } + }, + template: ``, + }, + { form: true }, + ) + + await waitForNextTick() + + await wrapper.events.click(wrapper.getByText('Dummy ticket')) + + expect(mockedCallback).toHaveBeenCalledWith(item) + + mockedCallback.mockClear() + + wrapper.getByRole('row', { description: 'Select table row' }).focus() + + await wrapper.events.keyboard('{enter}') + + expect(mockedCallback).toHaveBeenCalledWith(item) + }) + + it('supports marking row in active color', async () => { + const wrapper = await renderTable({ + headers: tableHeaders, + selectedRowId: '2', + items: [ + { + id: '2', + name: 'foo', + }, + ], + totalItems: 100, + caption: 'Table caption', + }) + + const row = wrapper.getByTestId('table-row') + + expect(row).toHaveClass('!bg-blue-800') + + expect(within(row).getAllByRole('cell')[1].children[0]).toHaveClass( + 'text-black dark:text-white', + ) + }) + + it('supports adding class to table header', async () => { + const wrapper = await renderTable({ + headers: ['name'], + attributes: [ + { + name: 'name', + label: 'Awesome Cell Header', + headerPreferences: { + labelClass: 'text-red-500 font-bold', + }, + columnPreferences: {}, + dataOption: { + type: 'text', + }, + dataType: 'input', + }, + ], + items: [], + totalItems: 100, + caption: 'Table caption', + }) + + expect(wrapper.getByText('Awesome Cell Header')).toHaveClass( + 'text-red-500 font-bold', + ) + }) + + it('supports adding a link to a cell', async () => { + const wrapper = await renderTable( + { + headers: ['title'], + attributeExtensions: { + title: { + columnPreferences: { + link: { + internal: true, + getLink: (item: ObjectWithId) => + `/tickets/${getIdFromGraphQLId(item.id)}`, + }, + }, + }, + }, + items: [tableItems[0]], + totalItems: 100, + caption: 'Table caption', + }, + { + form: true, + router: true, + }, + ) + + const linkCell = wrapper.getByRole('link') + + expect(linkCell).toHaveTextContent('Dummy ticket') + expect(linkCell).toHaveAttribute('href', '/desktop/tickets/1') + expect(linkCell).not.toHaveAttribute('target') + }) + + it.todo('supports row selection', async () => { + const checkedRows = ref([]) + + const items = [ + { + id: convertToGraphQLId('Ticket', 1), + label: 'selection data 1', + }, + { + id: convertToGraphQLId('Ticket', 2), + label: 'selection data 2', + }, + ] + + const wrapper = await renderTable( + { + headers: ['label'], + items, + hasCheckboxColumn: true, + totalItems: 100, + caption: 'Table caption', + }, + { form: true, vModel: { checkedRows } }, + ) + + expect(wrapper.getAllByRole('checkbox')).toHaveLength(3) + + const selectAllCheckbox = wrapper.getByLabelText('Select all entries') + + expect(selectAllCheckbox).not.toHaveAttribute('checked') + + const rowCheckboxes = wrapper.getAllByRole('checkbox', { + name: 'Select this entry', + }) + + await wrapper.events.click(rowCheckboxes[0]) + expect(rowCheckboxes[0]).toHaveAttribute('checked') + + await wrapper.events.click(rowCheckboxes[1]) + + await waitFor(() => expect(checkedRows.value).toEqual(items)) + await waitFor(() => expect(selectAllCheckbox).toHaveAttribute('checked')) + + await wrapper.events.click(wrapper.getByLabelText('Deselect all entries')) + + await waitFor(() => expect(rowCheckboxes[0]).not.toHaveAttribute('checked')) + expect(rowCheckboxes[1]).not.toHaveAttribute('checked') + + await wrapper.events.click(rowCheckboxes[1]) + + expect( + await wrapper.findByLabelText('Deselect this entry'), + ).toBeInTheDocument() + }) + + it.todo('supports disabling checkbox item for specific rows', async () => { + const checkedRows = ref([]) + + const items = [ + { + id: convertToGraphQLId('Ticket', 1), + checked: false, + disabled: true, + label: 'selection data 1', + }, + { + id: convertToGraphQLId('Ticket', 2), + checked: true, + disabled: true, + label: 'selection data 1', + }, + ] + + const wrapper = await renderTable( + { + headers: ['label'], + items, + hasCheckboxColumn: true, + totalItems: 100, + caption: 'Table caption', + }, + { form: true, vModel: { checkedRows } }, + ) + + const checkboxes = wrapper.getAllByRole('checkbox') + expect(checkboxes).toHaveLength(3) + + expect(checkboxes[1]).toBeDisabled() + expect(checkboxes[1]).not.toBeChecked() + expect(checkboxes[2]).toHaveAttribute('value', 'true') + + await wrapper.events.click(checkboxes[1]) + + expect(checkedRows.value).toEqual([]) + + await wrapper.events.click(checkboxes[0]) + + 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') +}) diff --git a/app/frontend/apps/desktop/components/CommonTable/__tests__/CommonAdvancedTable.spec.ts.snapshot.txt b/app/frontend/apps/desktop/components/CommonTable/__tests__/CommonAdvancedTable.spec.ts.snapshot.txt new file mode 100644 index 000000000000..4309d7109bda --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/__tests__/CommonAdvancedTable.spec.ts.snapshot.txt @@ -0,0 +1,172 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
\ No newline at end of file diff --git a/app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts b/app/frontend/apps/desktop/components/CommonTable/__tests__/CommonSimpleTable.spec.ts similarity index 60% rename from app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts rename to app/frontend/apps/desktop/components/CommonTable/__tests__/CommonSimpleTable.spec.ts index 98fffecf5d21..130fd2f7b272 100644 --- a/app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts +++ b/app/frontend/apps/desktop/components/CommonTable/__tests__/CommonSimpleTable.spec.ts @@ -1,8 +1,7 @@ // Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ -import { waitFor, within } from '@testing-library/vue' +import { waitFor } from '@testing-library/vue' import { vi } from 'vitest' -import { ref } from 'vue' import { type ExtendedMountingOptions, @@ -13,7 +12,9 @@ import { i18n } from '#shared/i18n.ts' import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts' -import CommonSimpleTable, { type Props } from '../CommonSimpleTable.vue' +import CommonSimpleTable from '../CommonSimpleTable.vue' + +import type { SimpleTableProps } from '../types.ts' const tableHeaders = [ { @@ -28,7 +29,7 @@ const tableHeaders = [ const tableItems = [ { - id: 1, + id: '1', name: 'Lindsay Walton', role: 'Member', }, @@ -48,8 +49,8 @@ const tableActions: MenuItem[] = [ ] const renderTable = ( - props: Props, - options: ExtendedMountingOptions = { form: true }, + props: SimpleTableProps, + options: ExtendedMountingOptions = { form: true }, ) => { return renderComponent(CommonSimpleTable, { ...options, @@ -66,6 +67,7 @@ describe('CommonSimpleTable', () => { const wrapper = renderTable({ headers: tableHeaders, items: tableItems, + caption: 'test', }) expect(wrapper.getByText('User name')).toBeInTheDocument() @@ -78,6 +80,7 @@ describe('CommonSimpleTable', () => { it('displays the table with actions', async () => { const wrapper = renderTable( { + caption: 'test', headers: tableHeaders, items: tableItems, actions: tableActions, @@ -95,6 +98,7 @@ describe('CommonSimpleTable', () => { headers: tableHeaders, items: tableItems, actions: tableActions, + caption: 'test', }, { router: true, @@ -114,6 +118,7 @@ describe('CommonSimpleTable', () => { headers: tableHeaders, items: tableItems, actions: tableActions, + caption: 'test', }, // NB: Please don't remove this, otherwise snapshot would contain markup of many more components other than the // one under the test, which can lead to false positives. @@ -129,6 +134,7 @@ describe('CommonSimpleTable', () => { it('supports text truncation in cell content', async () => { const wrapper = renderTable({ + caption: 'test', headers: [ ...tableHeaders, { @@ -140,7 +146,7 @@ describe('CommonSimpleTable', () => { items: [ ...tableItems, { - id: 2, + id: '2', name: 'Max Mustermann', role: 'Admin', truncated: 'Some text to be truncated', @@ -155,6 +161,7 @@ describe('CommonSimpleTable', () => { it('supports tooltip on truncated cell content', async () => { const wrapper = renderTable({ + caption: 'test', headers: [ ...tableHeaders, { @@ -166,7 +173,7 @@ describe('CommonSimpleTable', () => { items: [ ...tableItems, { - id: 2, + id: '2', name: 'Max Mustermann', role: 'Admin', truncated: 'Some text to be truncated', @@ -187,6 +194,7 @@ describe('CommonSimpleTable', () => { it('supports header slot', () => { const wrapper = renderTable( { + caption: 'test', headers: tableHeaders, items: tableItems, actions: tableActions, @@ -214,74 +222,21 @@ describe('CommonSimpleTable', () => { items: [item], } }, - template: ``, + template: ``, }) - expect( - wrapper.getByRole('button', { name: 'Select table row' }), - ).toBeInTheDocument() - await wrapper.events.click(wrapper.getByText('Lindsay Walton')) expect(mockedCallback).toHaveBeenCalledWith(item) - wrapper.getByRole('button', { name: 'Select table row' }).focus() - await wrapper.events.keyboard('{enter}') 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({ + caption: 'test', headers: [ { key: 'name', @@ -305,6 +260,7 @@ describe('CommonSimpleTable', () => { it('supports adding a link to a cell', () => { const wrapper = renderTable( { + caption: 'test', headers: [ { key: 'urlTest', @@ -314,7 +270,7 @@ describe('CommonSimpleTable', () => { ], items: [ { - id: 1, + id: '1', urlTest: { label: 'Example', link: 'https://example.com', @@ -333,112 +289,4 @@ describe('CommonSimpleTable', () => { expect(linkCell).toHaveAttribute('href', 'https://example.com') expect(linkCell).toHaveAttribute('target', '_blank') }) - - it('adds hover a') - - it('supports row selection', async () => { - const checkedRows = ref([]) - - const items = [ - { - id: 1, - label: 'selection data 1', - }, - { - id: 2, - label: 'selection data 2', - }, - ] - - const wrapper = renderTable( - { - headers: [ - { - key: 'urlTest', - label: 'Link Row', - }, - ], - items, - hasCheckboxColumn: true, - }, - { form: true, vModel: { checkedRows } }, - ) - - expect(wrapper.getAllByRole('checkbox')).toHaveLength(3) - - const selectAllCheckbox = wrapper.getByLabelText('Select all entries') - - expect(selectAllCheckbox).not.toHaveAttribute('checked') - - const rowCheckboxes = wrapper.getAllByRole('checkbox', { - name: 'Select this entry', - }) - - await wrapper.events.click(rowCheckboxes[0]) - expect(rowCheckboxes[0]).toHaveAttribute('checked') - - await wrapper.events.click(rowCheckboxes[1]) - - await waitFor(() => expect(checkedRows.value).toEqual(items)) - await waitFor(() => expect(selectAllCheckbox).toHaveAttribute('checked')) - - await wrapper.events.click(wrapper.getByLabelText('Deselect all entries')) - - await waitFor(() => expect(rowCheckboxes[0]).not.toHaveAttribute('checked')) - expect(rowCheckboxes[1]).not.toHaveAttribute('checked') - - await wrapper.events.click(rowCheckboxes[1]) - - expect( - await wrapper.findByLabelText('Deselect this entry'), - ).toBeInTheDocument() - }) - - it('supports disabling checkbox item for specific rows', async () => { - const checkedRows = ref([]) - - const items = [ - { - id: 1, - checked: false, - disabled: true, - label: 'selection data 1', - }, - { - id: 1, - checked: true, - disabled: true, - label: 'selection data 1', - }, - ] - - const wrapper = renderTable( - { - headers: [ - { - key: 'urlTest', - label: 'Link Row', - }, - ], - items, - hasCheckboxColumn: true, - }, - { form: true, vModel: { checkedRows } }, - ) - - const checkboxes = wrapper.getAllByRole('checkbox') - expect(checkboxes).toHaveLength(3) - - expect(checkboxes[1]).toBeDisabled() - expect(checkboxes[1]).not.toBeChecked() - expect(checkboxes[2]).toHaveAttribute('value', 'true') - - await wrapper.events.click(checkboxes[1]) - - expect(checkedRows.value).toEqual([]) - - await wrapper.events.click(checkboxes[0]) - - expect(checkedRows.value).toEqual([]) - }) }) diff --git a/app/frontend/apps/desktop/components/CommonTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt b/app/frontend/apps/desktop/components/CommonTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt new file mode 100644 index 000000000000..225eafb42316 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + +
\ No newline at end of file diff --git a/app/frontend/apps/desktop/components/CommonTable/composables/useCellContent.ts b/app/frontend/apps/desktop/components/CommonTable/composables/useCellContent.ts new file mode 100644 index 000000000000..f29a5453681e --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/composables/useCellContent.ts @@ -0,0 +1,27 @@ +// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ + +import Default from '../CellContent/Default.vue' +import Timestamp from '../CellContent/Timestamp.vue' +import TimestampAbsolute from '../CellContent/TimestampAbsolute.vue' + +import type { Component } from 'vue' + +export const useCellContent = () => { + const typeComponents: Record = { + default: Default, + timestamp: Timestamp, + timestamp_absolute: TimestampAbsolute, + } + + const getCellContentComponent = (headerType?: string) => { + if (headerType && typeComponents[headerType]) { + return typeComponents[headerType] + } + + return typeComponents.default + } + + return { + getCellContentComponent, + } +} diff --git a/app/frontend/apps/desktop/components/CommonSimpleTable/useTableCheckboxes.ts b/app/frontend/apps/desktop/components/CommonTable/composables/useTableCheckboxes.ts similarity index 94% rename from app/frontend/apps/desktop/components/CommonSimpleTable/useTableCheckboxes.ts rename to app/frontend/apps/desktop/components/CommonTable/composables/useTableCheckboxes.ts index f740b1282306..f123b511631f 100644 --- a/app/frontend/apps/desktop/components/CommonSimpleTable/useTableCheckboxes.ts +++ b/app/frontend/apps/desktop/components/CommonTable/composables/useTableCheckboxes.ts @@ -2,7 +2,7 @@ import { computed, type ModelRef, type Ref } from 'vue' -import type { TableItem } from '#desktop/components/CommonSimpleTable/types.ts' +import type { TableItem } from '#desktop/components/CommonTable/types' export const useTableCheckboxes = ( checkedRowItems: ModelRef, diff --git a/app/frontend/apps/desktop/components/CommonTable/types.ts b/app/frontend/apps/desktop/components/CommonTable/types.ts new file mode 100644 index 000000000000..7feeb2e9cb40 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTable/types.ts @@ -0,0 +1,126 @@ +// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ + +import type { Props as CommonLinkProps } from '#shared/components/CommonLink/CommonLink.vue' +import type { ObjectAttribute } from '#shared/entities/object-attributes/types/store.ts' +import type { + EnumObjectManagerObjects, + EnumOrderDirection, +} from '#shared/graphql/types.ts' + +import type { MenuItem } from '#desktop/components/CommonPopoverMenu/types.ts' + +import type { Awaitable } from '@vueuse/shared' + +export const MINIMUM_COLUMN_WIDTH = 50 +export const MINIMUM_TABLE_WIDTH = 600 + +type TableColumnType = 'timestamp' | 'timestamp_absolute' | 'link' + +type TableHeaderPreference = { + headerClass?: string + labelClass?: string + hideLabel?: boolean + truncate?: boolean + noResize?: boolean + displayWidth?: number + noSorting?: boolean +} + +type TableColumnPreference = { + alignContent?: 'center' | 'right' +} + +export interface TableSimpleHeader + extends TableHeaderPreference, + TableColumnPreference { + key: K + label: string + labelPlaceholder?: string[] + type?: TableColumnType + columnSeparator?: boolean + [key: string]: unknown +} + +export type TableItemLinkValue = Partial & { label: string } + +export interface TableItem { + [key: string]: unknown | TableItemLinkValue + id: ID | number +} + +export interface TableAdvancedItem { + [key: string]: unknown + id: ID +} + +interface BaseTableProps { + actions?: MenuItem[] + caption: string + showCaption?: boolean +} + +export interface SimpleTableProps extends BaseTableProps { + items: TableItem[] + headers: TableSimpleHeader[] + onClickRow?: (tableItem: TableItem) => void + /** + * Used to set a default selected row + * Is not used for checkbox + * */ + selectedRowId?: string + hasCheckboxColumn?: boolean +} + +export interface CellContentProps { + value?: string | number + isRowSelected?: boolean +} + +export interface TableAttribute { + name: string + label: string + labelPlaceholder?: string[] + headerPreferences: TableHeaderPreference + columnPreferences: TableColumnPreference & { + link?: Pick & { + getLink: ( + item: TableAdvancedItem, + tableAttribute: TableAttribute, + ) => string + } + } + dataType: ObjectAttribute['dataType'] + dataOption?: ObjectAttribute['dataOption'] +} + +export interface AdvancedTableProps extends BaseTableProps { + items: TableAdvancedItem[] + headers: string[] + attributes?: TableAttribute[] + attributeExtensions?: Record> + object?: EnumObjectManagerObjects + /** + * Used to set a default selected row + * Is not used for checkbox + * */ + selectedRowId?: string + hasCheckboxColumn?: boolean // TODO: rename this prop, related to bulk??? + + totalItems: number + maxItems?: number + + onClickRow?: (tableItem: TableAdvancedItem) => void + + reachedScrollTop?: boolean + + onLoadMore?: () => Awaitable + + storageKeyId?: string + + scrollContainer?: HTMLElement | null + + groupBy?: string + + orderBy?: string + orderDirection?: EnumOrderDirection +} diff --git a/app/frontend/apps/desktop/components/CommonTicketLabel/CommonTicketLabel.vue b/app/frontend/apps/desktop/components/CommonTicketLabel/CommonTicketLabel.vue index e3389f16568a..c0c7f9b17d6e 100644 --- a/app/frontend/apps/desktop/components/CommonTicketLabel/CommonTicketLabel.vue +++ b/app/frontend/apps/desktop/components/CommonTicketLabel/CommonTicketLabel.vue @@ -6,7 +6,7 @@ import { computed } from 'vue' import type { TicketById } from '#shared/entities/ticket/types.ts' import { EnumTicketStateColorCode } from '#shared/graphql/types.ts' -import CommonTicketStateIndicatorIcon from '#desktop/components/CommonTicketStateIndicatorIcon/CommonTicketStateIndicatorIcon.vue' +import CommonTicketStateIndicatorIcon from '#desktop/components/CommonTicketStateIndicator/CommonTicketStateIndicatorIcon.vue' interface Props { ticket?: Partial | null diff --git a/app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/CommonTicketPriorityIndicator.vue b/app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/CommonTicketPriorityIndicator.vue index 46bf89286cee..4af2f240a79a 100644 --- a/app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/CommonTicketPriorityIndicator.vue +++ b/app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/CommonTicketPriorityIndicator.vue @@ -6,6 +6,8 @@ import { computed } from 'vue' import { useApplicationStore } from '#shared/stores/application.ts' +import CommonTicketPriorityIndicatorIcon from './CommonTicketPriorityIndicatorIcon.vue' + import type { TicketPriority } from './types.ts' export interface Props { @@ -21,20 +23,9 @@ const badgeVariant = computed(() => { case 'high-priority': return 'danger' case 'low-priority': - return 'info' - default: - return 'warning' - } -}) - -const badgeIcon = computed(() => { - switch (props.priority?.uiColor) { - case 'high-priority': - return 'priority-high' - case 'low-priority': - return 'priority-low' + return 'tertiary' default: - return 'priority-normal' + return 'info' } }) @@ -46,12 +37,9 @@ const badgeIcon = computed(() => { role="status" aria-live="polite" > - {{ $t(priority?.name) }} diff --git a/app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/CommonTicketPriorityIndicatorIcon.vue b/app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/CommonTicketPriorityIndicatorIcon.vue new file mode 100644 index 000000000000..407f08ad6e94 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/CommonTicketPriorityIndicatorIcon.vue @@ -0,0 +1,46 @@ + + + + + diff --git a/app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/__tests__/CommonTicketPriorityIndicator.spec.ts b/app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/__tests__/CommonTicketPriorityIndicator.spec.ts index f6568290a44c..3fae10f13fb0 100644 --- a/app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/__tests__/CommonTicketPriorityIndicator.spec.ts +++ b/app/frontend/apps/desktop/components/CommonTicketPriorityIndicator/__tests__/CommonTicketPriorityIndicator.spec.ts @@ -28,7 +28,7 @@ describe('CommonTicketPriorityIndicator.vue', () => { }, }) - expect(view.getByText('1 low')).toHaveClass('common-badge-info') + expect(view.getByText('1 low')).toHaveClass('common-badge-tertiary') }) it('renders high priority correctly', () => { @@ -52,7 +52,7 @@ describe('CommonTicketPriorityIndicator.vue', () => { }, }) - expect(view.getByText('2 normal')).toHaveClass('common-badge-warning') + expect(view.getByText('2 normal')).toHaveClass('common-badge-info') }) it('supports accessibility features', () => { diff --git a/app/frontend/apps/desktop/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue b/app/frontend/apps/desktop/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue index 3a5857b06043..ef044396c041 100644 --- a/app/frontend/apps/desktop/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue +++ b/app/frontend/apps/desktop/components/CommonTicketStateIndicator/CommonTicketStateIndicator.vue @@ -5,7 +5,7 @@ import { computed } from 'vue' import { EnumTicketStateColorCode } from '#shared/graphql/types.ts' -import CommonTicketStateIndicatorIcon from '../CommonTicketStateIndicatorIcon/CommonTicketStateIndicatorIcon.vue' +import CommonTicketStateIndicatorIcon from './CommonTicketStateIndicatorIcon.vue' export interface Props { colorCode: EnumTicketStateColorCode diff --git a/app/frontend/apps/desktop/components/CommonTicketStateIndicatorIcon/CommonTicketStateIndicatorIcon.vue b/app/frontend/apps/desktop/components/CommonTicketStateIndicator/CommonTicketStateIndicatorIcon.vue similarity index 100% rename from app/frontend/apps/desktop/components/CommonTicketStateIndicatorIcon/CommonTicketStateIndicatorIcon.vue rename to app/frontend/apps/desktop/components/CommonTicketStateIndicator/CommonTicketStateIndicatorIcon.vue diff --git a/app/frontend/apps/desktop/components/CommonTicketStateIndicatorIcon/__tests__/CommonTicketStateIndicatorIcon.spec.ts b/app/frontend/apps/desktop/components/CommonTicketStateIndicator/__tests__/CommonTicketStateIndicatorIcon.spec.ts similarity index 100% rename from app/frontend/apps/desktop/components/CommonTicketStateIndicatorIcon/__tests__/CommonTicketStateIndicatorIcon.spec.ts rename to app/frontend/apps/desktop/components/CommonTicketStateIndicator/__tests__/CommonTicketStateIndicatorIcon.spec.ts diff --git a/app/frontend/apps/desktop/components/Form/fields/FieldNotifications/FieldNotificationsInput.vue b/app/frontend/apps/desktop/components/Form/fields/FieldNotifications/FieldNotificationsInput.vue index f4889f3e5b71..1c7a607c9d38 100644 --- a/app/frontend/apps/desktop/components/Form/fields/FieldNotifications/FieldNotificationsInput.vue +++ b/app/frontend/apps/desktop/components/Form/fields/FieldNotifications/FieldNotificationsInput.vue @@ -7,8 +7,8 @@ import { toRef } from 'vue' import useValue from '#shared/components/Form/composables/useValue.ts' import type { FormFieldContext } from '#shared/components/Form/types/field.ts' -import CommonSimpleTable from '#desktop/components/CommonSimpleTable/CommonSimpleTable.vue' -import type { TableHeader } from '#desktop/components/CommonSimpleTable/types.ts' +import CommonSimpleTable from '#desktop/components/CommonTable/CommonSimpleTable.vue' +import type { TableSimpleHeader } from '#desktop/components/CommonTable/types.ts' import { NotificationMatrixColumnKey, @@ -24,7 +24,7 @@ const context = toRef(props, 'context') const { localValue } = useValue(context) -const tableHeaders: TableHeader[] = [ +const tableHeaders: TableSimpleHeader[] = [ { key: 'name', label: __('Name'), @@ -34,28 +34,28 @@ const tableHeaders: TableHeader[] = [ path: NotificationMatrixPathKey.Criteria, label: __('My tickets'), alignContent: 'center', - columnClass: 'w-20', + headerClass: 'w-20', }, { key: NotificationMatrixColumnKey.NotAssigned, path: NotificationMatrixPathKey.Criteria, label: __('Not assigned'), alignContent: 'center', - columnClass: 'w-20', + headerClass: 'w-20', }, { key: NotificationMatrixColumnKey.SubscribedTickets, path: NotificationMatrixPathKey.Criteria, label: __('Subscribed tickets'), alignContent: 'center', - columnClass: 'w-20', + headerClass: 'w-20', }, { key: NotificationMatrixColumnKey.AllTickets, path: NotificationMatrixPathKey.Criteria, label: __('All tickets'), alignContent: 'center', - columnClass: 'w-20', + headerClass: 'w-20', columnSeparator: true, }, { @@ -63,7 +63,7 @@ const tableHeaders: TableHeader[] = [ path: NotificationMatrixPathKey.Channel, label: __('Also notify via email'), alignContent: 'center', - columnClass: 'w-20', + headerClass: 'w-20', }, ] @@ -127,6 +127,7 @@ const updateValue = ( v-bind="context.attrs" > + + diff --git a/app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsEmptyText.vue b/app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsEmptyText.vue new file mode 100644 index 000000000000..4a9f6cbaba50 --- /dev/null +++ b/app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsEmptyText.vue @@ -0,0 +1,33 @@ + + + diff --git a/app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsSidebar.vue b/app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsSidebar.vue new file mode 100644 index 000000000000..44629bff216b --- /dev/null +++ b/app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsSidebar.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsSidebar/TicketOverviewsList.vue b/app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsSidebar/TicketOverviewsList.vue new file mode 100644 index 000000000000..8efd91021186 --- /dev/null +++ b/app/frontend/apps/desktop/pages/ticket-overviews/components/TicketOverviewsSidebar/TicketOverviewsList.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketList.spec.ts b/app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketList.spec.ts new file mode 100644 index 000000000000..ac76bc42a1ab --- /dev/null +++ b/app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketList.spec.ts @@ -0,0 +1,90 @@ +// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ + +import renderComponent from '#tests/support/components/renderComponent.ts' +import { mockRouterHooks } from '#tests/support/mock-vue-router.ts' + +import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts' +import { convertToGraphQLId } from '#shared/graphql/utils.ts' + +import { mockTicketsByOverviewQuery } from '#desktop/entities/ticket/graphql/queries/ticketsByOverview.mocks.ts' +import TicketList from '#desktop/pages/ticket-overviews/components/TicketList.vue' + +mockRouterHooks() + +vi.hoisted(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2011-11-11T12:00:00Z')) +}) + +describe('TicketList', () => { + afterAll(() => { + vi.resetAllMocks() + }) + + describe('loading states', () => { + it('displays the skeleton for the table on initial load', async () => { + mockTicketsByOverviewQuery({ + ticketsByOverview: { + edges: [{ node: createDummyTicket() }], + pageInfo: { + endCursor: 'MjU', + hasNextPage: true, + }, + totalCount: 207, + }, + }) + + const wrapper = renderComponent(TicketList, { + props: { + overviewId: convertToGraphQLId('Overview', 1), + headers: [ + 'title', + 'customer', + 'group', + 'owner', + 'state', + 'created_at', + ], + orderBy: 'group', + orderDirection: 'ASCENDING', + }, + router: true, + form: true, + }) + + expect(await wrapper.findByTestId('table-skeleton')).toBeInTheDocument() + }) + }) + + it.todo('displays a table overview with tickets', async () => { + mockTicketsByOverviewQuery({ + ticketsByOverview: { + edges: [{ node: createDummyTicket() }], + pageInfo: { + endCursor: 'MjU', + hasNextPage: true, + }, + totalCount: 1, + }, + }) + + const wrapper = renderComponent(TicketList, { + props: { + overviewId: convertToGraphQLId('Overview', 1), + headers: ['title', 'customer', 'group', 'owner', 'state', 'created_at'], + orderBy: 'group', + orderDirection: 'ASCENDING', + }, + router: true, + form: true, + }) + + const table = await wrapper.findByRole('table', { name: 'Ticket Overview' }) + + expect(table).toHaveTextContent( + 'Ticket OverviewState Icon Created at in 4 weeks', + ) + + // :TODO should we check for more specific content? + }) +}) diff --git a/app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketOverviewsEmptyText.spec.ts b/app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketOverviewsEmptyText.spec.ts new file mode 100644 index 000000000000..c8c71d5d4458 --- /dev/null +++ b/app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketOverviewsEmptyText.spec.ts @@ -0,0 +1,24 @@ +// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ + +import renderComponent from '#tests/support/components/renderComponent.ts' + +import TicketOverviewsEmptyText from '#desktop/pages/ticket-overviews/components/TicketOverviewsEmptyText.vue' + +describe('TicketOverviewsEmptyText', () => { + it('renders empty text', () => { + const wrapper = renderComponent(TicketOverviewsEmptyText, { + props: { + title: 'No tickets found', + text: 'Nothing to golden to find in this overview.', + }, + }) + + expect(wrapper.getByRole('heading', { level: 2 })).toHaveTextContent( + 'No tickets found', + ) + + expect( + wrapper.getByText('Nothing to golden to find in this overview.'), + ).toBeInTheDocument() + }) +}) diff --git a/app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketOverviewsSidebar.spec.ts b/app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketOverviewsSidebar.spec.ts new file mode 100644 index 000000000000..41f9240b6863 --- /dev/null +++ b/app/frontend/apps/desktop/pages/ticket-overviews/components/__tests__/TicketOverviewsSidebar.spec.ts @@ -0,0 +1,135 @@ +// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ + +import { beforeEach } from 'vitest' + +import renderComponent from '#tests/support/components/renderComponent.ts' +import { mockPermissions } from '#tests/support/mock-permissions.ts' + +import { mockTicketOverviewTicketCountQuery } from '#shared/entities/ticket/graphql/queries/ticket/overviewTicketCount.mocks.ts' +import { EnumOrderDirection } from '#shared/graphql/types.ts' +import { convertToGraphQLId } from '#shared/graphql/utils.ts' + +import { mockUserCurrentTicketOverviewsQuery } from '#desktop/entities/ticket/graphql/queries/userCurrentTicketOverviews.mocks.ts' +import TicketOverviewsSidebar from '#desktop/pages/ticket-overviews/components/TicketOverviewsSidebar.vue' + +const renderSidebar = () => + renderComponent(TicketOverviewsSidebar, { + router: true, + }) + +describe('TicketOverviewsSidebar', () => { + beforeEach(() => { + mockUserCurrentTicketOverviewsQuery({ + userCurrentTicketOverviews: [ + { + id: convertToGraphQLId('Overview', 1), + name: 'My Assigned Tickets', + link: 'my_assigned', + prio: 1000, + orderBy: 'created_at', + orderDirection: EnumOrderDirection.Ascending, + viewColumns: [ + { + key: 'title', + value: 'Title', + }, + { + key: 'customer', + value: 'Customer', + }, + { + key: 'group', + value: 'Group', + }, + { + key: 'created_at', + value: 'Created at', + }, + ], + orderColumns: [ + { + key: 'title', + value: 'Title', + }, + { + key: 'customer', + value: 'Customer', + }, + { + key: 'group', + value: 'Group', + }, + { + key: 'created_at', + value: 'Created at', + }, + ], + active: true, + ticketCount: 2, + }, + { + id: convertToGraphQLId('Overview', 2), + name: 'Unassigned & Open Tickets', + link: 'all_unassigned', + prio: 1010, + orderBy: 'created_at', + orderDirection: EnumOrderDirection.Ascending, + viewColumns: [], + orderColumns: [], + active: true, + ticketCount: 12, + }, + ], + }) + + mockTicketOverviewTicketCountQuery({ + ticketOverviews: [ + { + id: convertToGraphQLId('Overview', 1), + ticketCount: 0, + }, + { + id: convertToGraphQLId('Overview', 2), + ticketCount: 15, + }, + ], + }) + }) + + it('hides reorder items if user is has not overview sorting preference', () => { + const wrapper = renderSidebar() + + expect( + wrapper.queryByRole('link', { name: 'reorder items' }), + ).not.toBeInTheDocument() + }) + + it('displays link which redirects to personal settings overview', async () => { + const wrapper = renderSidebar() + + mockPermissions(['user_preferences.overview_sorting']) + + expect(await wrapper.findByRole('link')).toHaveTextContent('reorder items') + expect(wrapper.getByRole('link')).toHaveAttribute( + 'href', + '/desktop/personal-setting/ticket-overviews', + ) + + expect(wrapper.getByIconName('list-columns-reverse')).toBeInTheDocument() + }) + + it('displays overview items', async () => { + const wrapper = renderSidebar() + + expect(await wrapper.findByText('My Assigned Tickets')).toBeInTheDocument() + expect(wrapper.getByText('Unassigned & Open Tickets')).toBeInTheDocument() + + expect( + wrapper.getByRole('link', { name: 'My Assigned Tickets 0' }), + ).toBeInTheDocument() + + expect( + wrapper.getByRole('link', { name: 'Unassigned & Open Tickets 15' }), + ).toBeInTheDocument() + }) +}) diff --git a/app/frontend/apps/desktop/pages/ticket-overviews/composables/useTicketOverviews.ts b/app/frontend/apps/desktop/pages/ticket-overviews/composables/useTicketOverviews.ts new file mode 100644 index 000000000000..c8d295ea754f --- /dev/null +++ b/app/frontend/apps/desktop/pages/ticket-overviews/composables/useTicketOverviews.ts @@ -0,0 +1,17 @@ +// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ + +import { storeToRefs } from 'pinia' + +import { useTicketOverviewsStore } from '#desktop/entities/ticket/stores/ticketOverviews.ts' + +export const useTicketOverviews = () => { + const store = useTicketOverviewsStore() + const { setPreviousTicketOverviewLink } = store + + const state = storeToRefs(store) + + return { + setPreviousTicketOverviewLink, + ...state, + } +} diff --git a/app/frontend/apps/desktop/pages/ticket-overviews/routes.ts b/app/frontend/apps/desktop/pages/ticket-overviews/routes.ts new file mode 100644 index 000000000000..185cd675136b --- /dev/null +++ b/app/frontend/apps/desktop/pages/ticket-overviews/routes.ts @@ -0,0 +1,24 @@ +// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ + +import type { RouteRecordRaw } from 'vue-router' + +const route: RouteRecordRaw[] = [ + { + path: '/tickets/view/:overviewLink?', + name: 'TicketOverview', + component: () => import('./views/TicketOverviews.vue'), + alias: '/ticket/view/:overviewLink?', + props: true, + meta: { + title: __('Overviews'), + requiresAuth: true, + icon: 'all-tickets', + requiredPermission: ['ticket.agent', 'ticket.customer'], + level: 1, + pageKey: 'ticket-overviews', + permanentItem: true, + }, + }, +] + +export default route diff --git a/app/frontend/apps/desktop/pages/ticket-overviews/views/TicketOverviews.vue b/app/frontend/apps/desktop/pages/ticket-overviews/views/TicketOverviews.vue new file mode 100644 index 000000000000..143e5497f0de --- /dev/null +++ b/app/frontend/apps/desktop/pages/ticket-overviews/views/TicketOverviews.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/app/frontend/apps/desktop/pages/ticket/__tests__/ticket-create/ticket-create-idoit.spec.ts b/app/frontend/apps/desktop/pages/ticket/__tests__/ticket-create/ticket-create-idoit.spec.ts index 6a24316bcf97..8823af615b89 100644 --- a/app/frontend/apps/desktop/pages/ticket/__tests__/ticket-create/ticket-create-idoit.spec.ts +++ b/app/frontend/apps/desktop/pages/ticket/__tests__/ticket-create/ticket-create-idoit.spec.ts @@ -20,7 +20,7 @@ import { mockTicketExternalReferencesIdoitObjectSearchQuery } from '#desktop/pag describe('Ticket create i-doit links', () => { describe('ticket creation', () => { - it('submits a new ticket with i-doit objects', async () => { + it.todo('submits a new ticket with i-doit objects', async () => { await mockApplicationConfig({ idoit_integration: true, ui_task_mananger_max_task_count: 30, @@ -108,6 +108,7 @@ describe('Ticket create i-doit links', () => { await view.events.click(view.getByRole('button', { name: 'Create' })) + // :TODO FIXME const calls = await waitForTicketCreateMutationCalls() expect(calls.at(-1)?.variables.input).toEqual( expect.objectContaining({ diff --git a/app/frontend/apps/desktop/pages/ticket/__tests__/ticket-detail-view/ticket-detail-view-links.spec.ts b/app/frontend/apps/desktop/pages/ticket/__tests__/ticket-detail-view/ticket-detail-view-links.spec.ts index f48693b8f65e..57ac08f7d2d6 100644 --- a/app/frontend/apps/desktop/pages/ticket/__tests__/ticket-detail-view/ticket-detail-view-links.spec.ts +++ b/app/frontend/apps/desktop/pages/ticket/__tests__/ticket-detail-view/ticket-detail-view-links.spec.ts @@ -64,7 +64,9 @@ describe('Ticket detail view links', () => { expect(view.getByText('Recently Viewed Tickets')).toBeInTheDocument() expect(view.getByText('Foo Car')).toBeInTheDocument() - const rows = view.getAllByLabelText('Select table row') + const rows = view.getAllByRole('row', { + description: 'Select table row', + }) mockLinkAddMutation({ linkAdd: { diff --git a/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketRelationAndRecentLists/__tests__/TicketRelationAndRecentLists.spec.ts b/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketRelationAndRecentLists/__tests__/TicketRelationAndRecentLists.spec.ts index 39da93b20666..ff26482128a6 100644 --- a/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketRelationAndRecentLists/__tests__/TicketRelationAndRecentLists.spec.ts +++ b/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketRelationAndRecentLists/__tests__/TicketRelationAndRecentLists.spec.ts @@ -35,16 +35,14 @@ describe('TicketSimpleTableWrapper', () => { }) expect( - await wrapper.findByRole('heading', { + await wrapper.findByRole('table', { name: 'Recent Customer Tickets', - level: 3, }), ).toBeInTheDocument() expect( - wrapper.getByRole('heading', { + wrapper.getByRole('table', { name: 'Recently Viewed Tickets', - level: 3, }), ).toBeInTheDocument() diff --git a/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/TicketSimpleTable.vue b/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/TicketSimpleTable.vue index 606edd0832e3..eea7bd14a17e 100644 --- a/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/TicketSimpleTable.vue +++ b/app/frontend/apps/desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/TicketSimpleTable.vue @@ -7,12 +7,12 @@ import { computed } 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 CommonSimpleTable from '#desktop/components/CommonTable/CommonSimpleTable.vue' import type { - TableHeader, + TableSimpleHeader, TableItem, -} from '#desktop/components/CommonSimpleTable/types.ts' -import CommonTicketStateIndicatorIcon from '#desktop/components/CommonTicketStateIndicatorIcon/CommonTicketStateIndicatorIcon.vue' +} from '#desktop/components/CommonTable/types' +import CommonTicketStateIndicatorIcon from '#desktop/components/CommonTicketStateIndicator/CommonTicketStateIndicatorIcon.vue' import type { TicketRelationAndRecentListItem } from '#desktop/pages/ticket/components/TicketDetailView/TicketSimpleTable/types.ts' interface Props { @@ -27,7 +27,7 @@ const emit = defineEmits<{ const { config } = storeToRefs(useApplicationStore()) -const headers: TableHeader[] = [ +const headers: TableSimpleHeader[] = [ { key: 'state', label: '', truncate: true, type: 'link' }, { key: 'number', @@ -53,9 +53,8 @@ const items = computed>(() => key: ticket.id, number: { link: `/tickets/${ticket.internalId}`, - text: ticket.number, + label: ticket.number, internal: true, - openInNewTab: true, }, organization: ticket.organization, title: ticket.title, @@ -72,11 +71,12 @@ const handleRowClick = (row: TableItem) => { diff --git a/app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttribute.spec.ts b/app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttribute.spec.ts new file mode 100644 index 000000000000..46741c377898 --- /dev/null +++ b/app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttribute.spec.ts @@ -0,0 +1,247 @@ +// Copyright (C) 2012-2025 Zammad Foundation, https://zammad-foundation.org/ + +import { keyBy } from 'lodash-es' + +import { renderComponent } from '#tests/support/components/index.ts' +import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts' + +import { i18n } from '#shared/i18n.ts' + +import ObjectAttribute from '../ObjectAttribute.vue' + +import attributes from './attributes.json' + +vi.hoisted(() => { + vi.setSystemTime('2021-04-09T10:11:12Z') +}) + +const attributesByKey = keyBy(attributes, 'name') + +const object = { + login: 'some_object', + address: 'Berlin\nStreet\nHouse', + vip: true, + note: 'note', + active: true, + objectAttributeValues: [ + { + attribute: attributesByKey.date_attribute, + value: '2022-08-19', + __typename: 'ObjectAttributeValue', + }, + { + attribute: attributesByKey.textarea_field, + value: 'textarea text', + }, + { + attribute: { + ...attributesByKey.integer_field, + dataOption: { + ...attributesByKey.integer_field.dataOption, + linktemplate: 'https://integer.com/#{render}', + }, + }, + value: 600, + renderedLink: 'https://integer.com/rendered', + }, + { + attribute: attributesByKey.date_time_field, + value: '2022-08-11T05:00:00.000Z', + }, + { + attribute: attributesByKey.single_select, + value: 'key1', + }, + { + attribute: attributesByKey.multi_select_field, + value: ['key1', 'key2'], + }, + { + attribute: attributesByKey.single_tree_select, + value: 'key1::key1_child1', + }, + { + attribute: attributesByKey.multi_tree_select, + value: ['key1', 'key2', 'key2::key2_child1'], + }, + { + attribute: attributesByKey.some_url, + value: 'https://url.com', + }, + { + attribute: attributesByKey.some_email, + value: 'email@email.com', + }, + { + attribute: attributesByKey.phone, + value: '+49 123456789', + }, + { + attribute: attributesByKey.external_attribute, + value: { value: 1, label: 'Display External' }, + }, + ], +} + +describe('common object attributes interface', () => { + beforeEach(() => { + mockApplicationConfig({ + pretty_date_format: 'absolute', + }) + }) + + test('renders all available attributes', () => { + i18n.setTranslationMap( + new Map([ + ['FORMAT_DATE', 'dd/mm/yyyy'], + ['FORMAT_DATETIME', 'dd/mm/yyyy HH:MM'], + ]), + ) + + const view = renderComponent(ObjectAttribute, { + props: { + object, + attribute: attributesByKey.login, + }, + router: true, + store: true, + }) + + expect(view.getByText(object.login)).toBeInTheDocument() + }) + + test('show dash for empty fields', () => { + const view = renderComponent(ObjectAttribute, { + props: { + object: { + login: '', + }, + attribute: attributesByKey.login, + }, + }) + + expect(view.getByText('-')).toBeInTheDocument() + }) + + it('translates translatable', () => { + const translatable = (attr: any) => ({ + ...attr, + dataOption: { + ...attr.dataOption, + translate: true, + }, + }) + + i18n.setTranslationMap(new Map([['Display1', 'llave1']])) + + const view = renderComponent(ObjectAttribute, { + props: { + object, + attribute: translatable(attributesByKey.single_select), + }, + router: true, + }) + + expect(view.getByText('llave1')).toBeInTheDocument() + }) + + it('renders links', () => { + const view = renderComponent(ObjectAttribute, { + props: { + object, + attribute: attributesByKey.integer_field, + }, + router: true, + }) + + expect(view.getByRole('link', { name: '600' })).toHaveAttribute( + 'href', + 'https://integer.com/rendered', + ) + }) + + it('renders user relation', () => { + const view = renderComponent(ObjectAttribute, { + props: { + object: { + customer: { + fullname: 'John Doe', + }, + }, + attribute: { + name: 'customer_id', + dataType: 'user_autocompletion', + dataOption: { + relation: 'User', + belongs_to: 'customer', + }, + }, + }, + router: true, + }) + + expect(view.getByText('John Doe')).toBeInTheDocument() + }) + + it('renders user secondary organizations', () => { + const view = renderComponent(ObjectAttribute, { + props: { + object: { + secondaryOrganizations: { + edges: [ + { + node: { + name: 'Example', + }, + }, + { + node: { + name: 'Test', + }, + }, + ], + totalCount: 1, + }, + }, + attribute: { + name: 'organization_ids', + dataType: 'autocompletion_ajax', + dataOption: { + relation: 'Organization', + belongs_to: 'secondaryOrganizations', + }, + }, + }, + router: true, + }) + + expect(view.getByText('Example, Test')).toBeInTheDocument() + }) + + it('renders textarea in table mode', () => { + const view = renderComponent(ObjectAttribute, { + props: { + attribute: attributesByKey.address, + object, + mode: 'table', + }, + router: true, + }) + + expect(view.getByText('Berlin Street House')).toBeInTheDocument() + }) + + it('renders textarea in view mode', () => { + const view = renderComponent(ObjectAttribute, { + props: { + attribute: attributesByKey.address, + object, + }, + router: true, + }) + + expect(view.getByText('Berlin')).toHaveTextContent(/^Berlin$/) + expect(view.getByText('Street')).toHaveTextContent(/^Street$/) + expect(view.getByText('House')).toHaveTextContent(/^House$/) + }) +}) diff --git a/app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttributes.spec.ts b/app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttributes.spec.ts index 20e95698ce79..20a30542bc42 100644 --- a/app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttributes.spec.ts +++ b/app/frontend/shared/components/ObjectAttributes/__tests__/ObjectAttributes.spec.ts @@ -36,7 +36,6 @@ describe('common object attributes interface', () => { vip: true, note: 'note', active: true, - invisible: 'invisible', objectAttributeValues: [ { attribute: attributesByKey.date_attribute, @@ -142,30 +141,11 @@ describe('common object attributes interface', () => { getByRole(getRegion('Url'), 'link', { name: 'https://url.com' }), ).toHaveAttribute('href', 'https://url.com') - expect( - view.queryByRole('region', { name: 'Invisible' }), - ).not.toBeInTheDocument() expect( view.queryByRole('region', { name: 'Hidden Boolean' }), ).not.toBeInTheDocument() }) - test('hides attributes without permission', () => { - mockPermissions([]) - - const object = { - active: true, - } - const view = renderComponent(ObjectAttributes, { - props: { - object, - attributes: [attributesByKey.active], - }, - }) - - expect(view.queryAllByRole('region')).toHaveLength(0) - }) - test("don't show empty fields", () => { const object = { login: '', @@ -190,32 +170,6 @@ describe('common object attributes interface', () => { expect(view.queryAllByRole('region')).toHaveLength(0) }) - test('show default, if not defined', () => { - const object = { - login: '', - } - const attribute = { - ...attributesByKey.login, - name: 'login', - display: 'Login', - } - const view = renderComponent(ObjectAttributes, { - props: { - object, - attributes: [ - { - ...attribute, - dataOption: { ...attribute.dataOption, default: 'default' }, - }, - ], - }, - }) - - expect(view.getByRole('region', { name: 'Login' })).toHaveTextContent( - 'default', - ) - }) - it('translates translatable', () => { mockPermissions(['admin.user', 'ticket.agent']) diff --git a/app/frontend/shared/components/ObjectAttributes/__tests__/attributes.json b/app/frontend/shared/components/ObjectAttributes/__tests__/attributes.json index 100692800e1a..81e9be4eaa41 100644 --- a/app/frontend/shared/components/ObjectAttributes/__tests__/attributes.json +++ b/app/frontend/shared/components/ObjectAttributes/__tests__/attributes.json @@ -263,17 +263,6 @@ }, "__typename": "ObjectManagerFrontendAttribute" }, - { - "name": "invisible", - "display": "Invisible", - "dataType": "input", - "dataOption": { - "null": false, - "item_class": "checkbox", - "permission": ["invisible.*"] - }, - "__typename": "ObjectManagerFrontendAttribute" - }, { "name": "some_url", "display": "Url", diff --git a/app/frontend/shared/components/ObjectAttributes/attributes/AttributeInput/AttributeInput.vue b/app/frontend/shared/components/ObjectAttributes/attributes/AttributeInput/AttributeInput.vue index f2e5975a05b3..e47d7e1b3b94 100644 --- a/app/frontend/shared/components/ObjectAttributes/attributes/AttributeInput/AttributeInput.vue +++ b/app/frontend/shared/components/ObjectAttributes/attributes/AttributeInput/AttributeInput.vue @@ -29,9 +29,12 @@ const title = computed(() => { const link = computed(() => { const { linktemplate, type } = props.attribute.dataOption || {} + // link is processed in common component if (linktemplate) return null + const value = String(primitiveValue.value) + // app/assets/javascripts/app/index.coffee:135 if (type === 'tel') return `tel:${phoneify(value)}` if (type === 'url') return value @@ -41,7 +44,7 @@ const link = computed(() => {