// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ import { getByRole } from '@testing-library/vue' import { storeToRefs } from 'pinia' import { type RouteRecordRaw } from 'vue-router' import { getAllByIconName, getByIconName, } from '#tests/support/components/iconQueries.ts' import { renderComponent } from '#tests/support/components/index.ts' import { getTestRouter } from '#tests/support/components/renderComponent.ts' import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts' import { waitForNextTick } from '#tests/support/utils.ts' import { EnumTaskbarEntity, EnumTaskbarEntityAccess, EnumTicketStateColorCode, } from '#shared/graphql/types.ts' import { convertToGraphQLId } from '#shared/graphql/utils.ts' import { waitForUserCurrentTaskbarItemDeleteMutationCalls } from '#desktop/entities/user/current/graphql/mutations/userCurrentTaskbarItemDelete.mocks.ts' import { mockUserCurrentTaskbarItemListQuery, waitForUserCurrentTaskbarItemListQueryCalls, } from '#desktop/entities/user/current/graphql/queries/userCurrentTaskbarItemList.mocks.ts' import { getUserCurrentTaskbarItemListUpdatesSubscriptionHandler } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentTaskbarItemListUpdates.mocks.ts' import { getUserCurrentTaskbarItemUpdatesSubscriptionHandler } from '#desktop/entities/user/current/graphql/subscriptions/userCurrentTaskbarItemUpdates.mocks.ts' import { useUserCurrentTaskbarTabsStore } from '#desktop/entities/user/current/stores/taskbarTabs.ts' import UserTaskbarTabs, { type Props } from '../UserTaskbarTabs.vue' import '#tests/graphql/builders/mocks.ts' const waitForVariantConfirmationMock = vi .fn() .mockImplementation((variant) => variant === 'unsaved') vi.mock('#shared/composables/useConfirmation.ts', async () => ({ useConfirmation: () => ({ waitForVariantConfirmation: waitForVariantConfirmationMock, }), })) const renderUserTaskbarTabs = async ( options: { props?: Partial routerRoutes?: RouteRecordRaw[] } = {}, ) => { const wrapper = renderComponent(UserTaskbarTabs, { router: true, store: true, dialog: true, ...options, }) await waitForUserCurrentTaskbarItemListQueryCalls() await waitForNextTick() // initial list render await waitForNextTick() // computed async for teleport content return wrapper } describe('UserTaskbarTabs.vue', () => { beforeAll(() => { mockApplicationConfig({ ticket_hook: 'Ticket#', ui_task_mananger_max_task_count: 30, }) }) it.each([{ collapsed: false }, { collapsed: true }])( 'does not render anything in case taskbar is empty (collapsed: $collapsed)', async ({ collapsed }) => { mockUserCurrentTaskbarItemListQuery({ userCurrentTaskbarItemList: [], }) const wrapper = await renderUserTaskbarTabs({ props: { collapsed, }, }) expect(wrapper.queryByText('Tabs')).not.toBeInTheDocument() expect( wrapper.queryByRole('button', { name: 'List of all user taskbar tabs', }), ).not.toBeInTheDocument() }, ) it('renders ticket tab', async () => { // Rely on the default ticket tab from the `UserTaskbarItem` factory. const wrapper = await renderUserTaskbarTabs({ routerRoutes: [ { path: '/', name: 'Main', component: { template: '
' }, }, { path: '/tickets/1', name: 'Test', component: { template: '
' }, }, ], }) expect(wrapper.getByText('Tabs')).toBeInTheDocument() const tab = wrapper.getByRole('listitem') expect(getByIconName(tab, 'check-circle-no')).toBeInTheDocument() expect(tab).toHaveTextContent('Welcome to Zammad!') expect(tab).toHaveAccessibleDescription( 'Drag and drop to reorder your tabs.', ) const link = getByRole(tab, 'link') expect(link).toHaveAttribute('href', '/desktop/tickets/1') expect(link).toHaveAccessibleName('Ticket#53001 - Welcome to Zammad!') expect(link).not.toHaveClass('!bg-yellow-500') // Test active background based on the open state. const router = getTestRouter() await router.push('/tickets/1') expect(link).toHaveClass('!bg-yellow-500') expect( getByRole(tab, 'button', { name: 'Close this tab' }), ).toBeInTheDocument() }) it('renders ticket create tab', async () => { mockUserCurrentTaskbarItemListQuery({ userCurrentTaskbarItemList: [ { __typename: 'UserTaskbarItem', id: convertToGraphQLId('Taskbar', 1), key: 'TicketCreateScreen-999', callback: EnumTaskbarEntity.TicketCreate, entityAccess: EnumTaskbarEntityAccess.Granted, entity: { __typename: 'UserTaskbarItemEntityTicketCreate', uid: '999', title: 'Test title', createArticleTypeKey: 'phone-in', }, }, ], }) const wrapper = await renderUserTaskbarTabs() expect(wrapper.getByText('Tabs')).toBeInTheDocument() const tab = wrapper.getByRole('listitem') expect(getByIconName(tab, 'pencil')).toBeInTheDocument() expect(tab).toHaveTextContent('Received Call: Test title') expect(tab).toHaveAccessibleDescription( 'Drag and drop to reorder your tabs.', ) const link = getByRole(tab, 'link') expect(link).toHaveAttribute('href', '/desktop/tickets/create/999') expect(link).toHaveAccessibleName('Received Call: Test title') expect( getByRole(tab, 'button', { name: 'Close this tab' }), ).toBeInTheDocument() }) it('renders forbidden tab', async () => { mockUserCurrentTaskbarItemListQuery({ userCurrentTaskbarItemList: [ { __typename: 'UserTaskbarItem', id: convertToGraphQLId('Taskbar', 999), key: 'Ticket-999', callback: EnumTaskbarEntity.TicketZoom, entityAccess: EnumTaskbarEntityAccess.Forbidden, entity: null, }, ], }) const wrapper = await renderUserTaskbarTabs() expect(wrapper.getByText('Tabs')).toBeInTheDocument() const tab = wrapper.getByRole('listitem') expect(getAllByIconName(tab, 'x-lg')[0]).toHaveClass('text-red-500') expect(tab).toHaveTextContent('Access denied') expect(tab).toHaveAccessibleDescription( 'Drag and drop to reorder your tabs.', ) const link = getByRole(tab, 'link') expect(link).toHaveAttribute('href', '/desktop/tickets/999') expect(link).toHaveAccessibleName( 'You have insufficient rights to view this object.', ) expect( getByRole(tab, 'button', { name: 'Close this tab' }), ).toBeInTheDocument() }) it('renders not found tab', async () => { mockUserCurrentTaskbarItemListQuery({ userCurrentTaskbarItemList: [ { __typename: 'UserTaskbarItem', id: convertToGraphQLId('Taskbar', 999), key: 'Ticket-999', callback: EnumTaskbarEntity.TicketZoom, entityAccess: EnumTaskbarEntityAccess.NotFound, entity: null, }, ], }) const wrapper = await renderUserTaskbarTabs() expect(wrapper.getByText('Tabs')).toBeInTheDocument() const tab = wrapper.getByRole('listitem') expect(getAllByIconName(tab, 'x-lg')[0]).toHaveClass('text-red-500') expect(tab).toHaveTextContent('Not found') expect(tab).toHaveAccessibleDescription( 'Drag and drop to reorder your tabs.', ) const link = getByRole(tab, 'link') expect(link).toHaveAttribute('href', '/desktop/tickets/999') expect(link).toHaveAccessibleName('This object could not be found.') expect( getByRole(tab, 'button', { name: 'Close this tab' }), ).toBeInTheDocument() }) it('renders popover button in collapsed mode', async () => { // Rely on the default ticket tab from the `UserTaskbarItem` factory. const wrapper = await renderUserTaskbarTabs({ props: { collapsed: true, }, }) expect(wrapper.queryByText('Tabs')).not.toBeInTheDocument() const popoverButton = wrapper.getByRole('button', { name: 'List of all user taskbar tabs', }) expect(getByIconName(popoverButton, 'card-list')).toBeInTheDocument() await wrapper.events.click(popoverButton) const popover = wrapper.getByRole('region', { name: 'List of all user taskbar tabs', }) const tab = getByRole(popover, 'listitem') expect(getByIconName(tab, 'check-circle-no')).toBeInTheDocument() expect(tab).toHaveTextContent('Welcome to Zammad!') expect(tab).not.toHaveAccessibleDescription( 'Drag and drop to reorder your tabs.', ) const link = getByRole(tab, 'link') expect(link).toHaveAttribute('href', '/desktop/tickets/1') expect(link).toHaveAccessibleName('Ticket#53001 - Welcome to Zammad!') expect(link).toHaveClass('!bg-yellow-500') expect( getByRole(tab, 'button', { name: 'Close this tab' }), ).toBeInTheDocument() }) it('implements tab updates subscription', async () => { mockUserCurrentTaskbarItemListQuery({ userCurrentTaskbarItemList: [], }) const wrapper = await renderUserTaskbarTabs() expect(wrapper.queryByText('Tabs')).not.toBeInTheDocument() // Add item. await getUserCurrentTaskbarItemUpdatesSubscriptionHandler().trigger({ userCurrentTaskbarItemUpdates: { addItem: { __typename: 'UserTaskbarItem', id: convertToGraphQLId('Taskbar', 42), key: 'Ticket-42', callback: EnumTaskbarEntity.TicketZoom, entityAccess: EnumTaskbarEntityAccess.Granted, entity: { __typename: 'Ticket', id: convertToGraphQLId('Ticket', 42), internalId: 42, number: '53042', title: 'Test ticket title', stateColorCode: EnumTicketStateColorCode.Pending, state: { __typename: 'TicketState', name: 'pending reminder', }, }, }, updateItem: null, removeItem: null, }, }) expect(wrapper.queryByText('Tabs')).toBeInTheDocument() expect(wrapper.getByText('Test ticket title')).toBeInTheDocument() // Update item. await getUserCurrentTaskbarItemUpdatesSubscriptionHandler().trigger({ userCurrentTaskbarItemUpdates: { addItem: null, updateItem: { __typename: 'UserTaskbarItem', id: convertToGraphQLId('Taskbar', 42), key: 'Ticket-42', callback: EnumTaskbarEntity.TicketZoom, entityAccess: EnumTaskbarEntityAccess.Granted, entity: { __typename: 'Ticket', id: convertToGraphQLId('Ticket', 42), internalId: 42, number: '53042', title: 'New ticket title', stateColorCode: EnumTicketStateColorCode.Open, state: { __typename: 'TicketState', name: 'open', }, }, }, removeItem: null, }, }) expect(wrapper.queryByText('Test ticket title')).not.toBeInTheDocument() expect(wrapper.getByText('New ticket title')).toBeInTheDocument() // Remove item. await getUserCurrentTaskbarItemUpdatesSubscriptionHandler().trigger({ userCurrentTaskbarItemUpdates: { addItem: null, updateItem: null, removeItem: convertToGraphQLId('Taskbar', 42), }, }) expect(wrapper.queryByText('New ticket title')).not.toBeInTheDocument() }) it('supports updating the tabs after they got reordered', async () => { mockUserCurrentTaskbarItemListQuery({ userCurrentTaskbarItemList: [ { __typename: 'UserTaskbarItem', id: convertToGraphQLId('Taskbar', 1), key: 'TicketCreateScreen-999', callback: EnumTaskbarEntity.TicketCreate, entityAccess: EnumTaskbarEntityAccess.Granted, entity: { __typename: 'UserTaskbarItemEntityTicketCreate', uid: '999', title: 'First ticket', createArticleTypeKey: 'phone-in', }, prio: 1, formId: 'foo', changed: false, dirty: false, notify: false, updatedAt: '2024-07-24T15:42:28.212Z', }, { __typename: 'UserTaskbarItem', id: convertToGraphQLId('Taskbar', 2), key: 'Ticket-42', callback: EnumTaskbarEntity.TicketZoom, entityAccess: EnumTaskbarEntityAccess.Granted, entity: { __typename: 'Ticket', id: convertToGraphQLId('Ticket', 42), internalId: 42, number: '53042', title: 'Second ticket', stateColorCode: EnumTicketStateColorCode.Pending, state: { __typename: 'TicketState', name: 'pending reminder', }, }, prio: 2, formId: 'bar', changed: false, dirty: false, notify: false, updatedAt: '2024-07-24T15:42:28.212Z', }, ], }) const wrapper = await renderUserTaskbarTabs() expect(wrapper.getByText('Tabs')).toBeInTheDocument() let tabs = wrapper.getAllByRole('listitem') expect(tabs).toHaveLength(2) expect(tabs[0]).toHaveTextContent('First ticket') expect(tabs[1]).toHaveTextContent('Second ticket') // Reorder tabs. await getUserCurrentTaskbarItemListUpdatesSubscriptionHandler().trigger({ userCurrentTaskbarItemListUpdates: { __typename: 'UserCurrentTaskbarItemListUpdatesPayload', taskbarItemList: [ { __typename: 'UserTaskbarItem', id: convertToGraphQLId('Taskbar', 2), prio: 1, }, { __typename: 'UserTaskbarItem', id: convertToGraphQLId('Taskbar', 1), prio: 2, }, ], }, }) tabs = wrapper.getAllByRole('listitem') expect(tabs).toHaveLength(2) expect(tabs[0]).toHaveTextContent('Second ticket') expect(tabs[1]).toHaveTextContent('First ticket') }) it('supports closing tabs', async () => { // Rely on the default ticket tab from the `UserTaskbarItem` factory. const wrapper = await renderUserTaskbarTabs() expect(wrapper.getByText('Tabs')).toBeInTheDocument() const tab = wrapper.getByRole('listitem') await wrapper.events.click( getByRole(tab, 'button', { name: 'Close this tab' }), ) const calls = await waitForUserCurrentTaskbarItemDeleteMutationCalls() expect(waitForVariantConfirmationMock).toHaveBeenCalled() expect(calls.at(-1)?.variables).toEqual({ id: convertToGraphQLId('Taskbar', 1), }) expect(tab).not.toBeInTheDocument() }) it('supports redirecting to previous route when the current tab is closed', async () => { mockUserCurrentTaskbarItemListQuery({ userCurrentTaskbarItemList: [ { __typename: 'UserTaskbarItem', id: convertToGraphQLId('Taskbar', 1), key: 'TicketCreateScreen-999', callback: EnumTaskbarEntity.TicketCreate, entityAccess: EnumTaskbarEntityAccess.Granted, entity: { __typename: 'UserTaskbarItemEntityTicketCreate', uid: '999', title: 'First ticket', createArticleTypeKey: 'phone-in', }, prio: 1, formId: 'foo', changed: false, dirty: false, notify: false, updatedAt: '2024-07-24T15:42:28.212Z', }, { __typename: 'UserTaskbarItem', id: convertToGraphQLId('Taskbar', 2), key: 'Ticket-42', callback: EnumTaskbarEntity.TicketZoom, entityAccess: EnumTaskbarEntityAccess.Granted, entity: { __typename: 'Ticket', id: convertToGraphQLId('Ticket', 42), internalId: 42, number: '53042', title: 'Second ticket', stateColorCode: EnumTicketStateColorCode.Pending, state: { __typename: 'TicketState', name: 'pending reminder', }, }, prio: 2, formId: 'bar', changed: false, dirty: false, notify: false, updatedAt: '2024-07-24T15:42:28.212Z', }, ], }) const wrapper = await renderUserTaskbarTabs() // Simulate currently active tab by changing the store state directly. // Normally, this would be set by the route navigation guard (`activeTaskbarTab`). const { activeTaskbarTabEntityKey } = storeToRefs( useUserCurrentTaskbarTabsStore(), ) activeTaskbarTabEntityKey.value = 'TicketCreateScreen-999' // Simulate the history stack by visiting different routes before the current one. const router = getTestRouter() await router.push('/tickets/42') await router.push('/tickets/create/999') const tabs = wrapper.getAllByRole('listitem') expect(tabs).toHaveLength(2) await wrapper.events.click( getByRole(tabs[0], 'button', { name: 'Close this tab' }), ) await waitForUserCurrentTaskbarItemDeleteMutationCalls() expect(tabs[0]).not.toBeInTheDocument() await vi.waitFor(() => { expect( wrapper, 'correctly redirects to the previous route', ).toHaveCurrentUrl('/tickets/42') }) }) })