diff --git a/app/frontend/apps/desktop/components/CommonActionMenu/CommonActionMenu.vue b/app/frontend/apps/desktop/components/CommonActionMenu/CommonActionMenu.vue new file mode 100644 index 000000000000..901fef403573 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonActionMenu/CommonActionMenu.vue @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + diff --git a/app/frontend/apps/desktop/components/CommonActionMenu/__tests__/CommonActionMenu.spec.ts b/app/frontend/apps/desktop/components/CommonActionMenu/__tests__/CommonActionMenu.spec.ts new file mode 100644 index 000000000000..16e43933c01b --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonActionMenu/__tests__/CommonActionMenu.spec.ts @@ -0,0 +1,104 @@ +// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +import CommonActionMenu from '#desktop/components/CommonActionMenu/CommonActionMenu.vue' +import renderComponent from '#tests/support/components/renderComponent.ts' + +const fn = vi.fn() +describe('CommonActionMenu', () => { + let view: ReturnType + + const actions = [ + { + key: 'delete-foo', + label: 'Delete Foo', + icon: 'trash3', + onClick: ({ id }: { id: string }) => { + fn(id) + }, + }, + { + key: 'change-foo', + label: 'Change Foo', + icon: 'person-gear', + onClick: ({ id }: { id: string }) => { + fn(id) + }, + }, + ] + + beforeEach(() => { + view = renderComponent(CommonActionMenu, { + props: { + entity: { + id: 'foo-test-action', + }, + actions, + }, + }) + }) + + it('shows action menu button by default', () => { + expect(view.getByIconName('three-dots-vertical')).toBeInTheDocument() + }) + + it('calls onClick handler when action is clicked', async () => { + await view.events.click(view.getByIconName('three-dots-vertical')) + + expect(view.getByIconName('trash3')).toBeInTheDocument() + expect(view.getByIconName('person-gear')).toBeInTheDocument() + + await view.events.click(view.getByText('Change Foo')) + + expect(fn).toHaveBeenCalledWith('foo-test-action') + }) + + it('finds corresponding a11y controls', async () => { + const id = view + .getByLabelText('Action menu button') + .getAttribute('aria-controls') + + await view.events.click(view.getByIconName('three-dots-vertical')) + + const popover = document.getElementById(id as string) + + expect(popover?.getAttribute('id')).toEqual(id) + }) + + describe('single action mode', () => { + beforeEach(async () => { + await view.rerender({ + actions: [actions[0]], + }) + }) + + it('adds aria label on single action button', () => { + expect(view.getByLabelText('Delete Foo')).toBeInTheDocument() + }) + + it('supports single action mode', () => { + expect( + view.queryByIconName('three-dots-vertical'), + ).not.toBeInTheDocument() + + expect(view.getByIconName('trash3')).toBeInTheDocument() + }) + + it('calls onClick handler when action is clicked', async () => { + await view.events.click(view.getByIconName('trash3')) + + expect(fn).toHaveBeenCalledWith('foo-test-action') + }) + + it('renders single action if prop is set', async () => { + await view.rerender({ + noSingleActionMode: true, + }) + + expect(view.queryByIconName('trash3')).not.toBeInTheDocument() + + await view.events.click(view.getByIconName('three-dots-vertical')) + + expect(view.getByIconName('trash3')).toBeInTheDocument() + }) + }) +}) diff --git a/app/frontend/apps/desktop/components/CommonPopover/CommonPopover.vue b/app/frontend/apps/desktop/components/CommonPopover/CommonPopover.vue index e9c3fa495ba9..ecb91043dfcd 100644 --- a/app/frontend/apps/desktop/components/CommonPopover/CommonPopover.vue +++ b/app/frontend/apps/desktop/components/CommonPopover/CommonPopover.vue @@ -1,7 +1,13 @@ @@ -61,33 +55,35 @@ const onClickItem = (event: MouseEvent, item: MenuItem) => { aria-level="2" class="p-2 leading-3" > - {{ i18n.t(headerLabel) }} + + {{ i18n.t(headerLabel) }} + + - + - + @@ -21,9 +40,10 @@ defineProps() data-test-id="popover-menu-item" > {{ i18n.t(label, ...(labelPlaceholder || [])) }} diff --git a/app/frontend/apps/desktop/components/CommonPopover/__tests__/CommonPopoverMenu.spec.ts b/app/frontend/apps/desktop/components/CommonPopover/__tests__/CommonPopoverMenu.spec.ts index b65d9859585e..301462532e8d 100644 --- a/app/frontend/apps/desktop/components/CommonPopover/__tests__/CommonPopoverMenu.spec.ts +++ b/app/frontend/apps/desktop/components/CommonPopover/__tests__/CommonPopoverMenu.spec.ts @@ -9,6 +9,7 @@ import { usePopover } from '../usePopover.ts' import type { MenuItem } from '../types.ts' const html = String.raw +const fn = vi.fn() describe('rendering section', () => { it('no output without default slot and items', () => { @@ -187,4 +188,30 @@ describe('rendering section', () => { expect(view.getByText('Example Menu item')).toBeInTheDocument() }) + + it('yields entity data on show if prop is passed', async () => { + renderComponent(CommonPopoverMenu, { + props: { + popover: null, + headerLabel: 'Test Header', + entity: { + id: 'example', + name: 'vitest', + }, + items: [ + { + label: 'Example', + show: (event: { id: string; name: string }) => { + fn(event) + return true + }, + }, + ], + }, + router: true, + store: true, + }) + + expect(fn).toBeCalledWith({ id: 'example', name: 'vitest' }) + }) }) diff --git a/app/frontend/apps/desktop/components/CommonPopover/types.ts b/app/frontend/apps/desktop/components/CommonPopover/types.ts index 91d45622ff67..78d8258098b1 100644 --- a/app/frontend/apps/desktop/components/CommonPopover/types.ts +++ b/app/frontend/apps/desktop/components/CommonPopover/types.ts @@ -4,6 +4,7 @@ import type { Ref, Component } from 'vue' import type { RequiredPermission } from '#shared/types/permission.ts' +import type { ObjectLike } from '#shared/types/utils.ts' import { type Props as ItemProps } from './CommonPopoverMenuItem.vue' export interface CommonPopoverInstance { @@ -28,12 +29,15 @@ export type Orientation = export type Placement = 'start' | 'end' +export type Variant = 'danger' + export interface MenuItem extends ItemProps { key: string permission?: RequiredPermission - show?: () => boolean + show?: (entity?: ObjectLike) => boolean separatorTop?: boolean - onClick?(event: MouseEvent): void + onClick?: (entity?: ObjectLike) => void noCloseOnClick?: boolean component?: Component + variant?: Variant } diff --git a/app/frontend/apps/desktop/components/CommonPopover/usePopoverMenu.ts b/app/frontend/apps/desktop/components/CommonPopover/usePopoverMenu.ts new file mode 100644 index 000000000000..ea77c0b9dc7a --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonPopover/usePopoverMenu.ts @@ -0,0 +1,68 @@ +// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +import { inject, computed, provide } from 'vue' +import type { ComputedRef, Ref } from 'vue' +import { useSessionStore } from '#shared/stores/session.ts' +import type { MenuItem } from '#desktop/components/CommonPopover/types.ts' +import type { ObjectLike } from '#shared/types/utils.ts' + +const POPOVER_MENU_SYMBOL = Symbol('popover-menu') + +interface UsePopoverMenuReturn { + filteredMenuItems: ComputedRef + singleMenuItemPresent: ComputedRef + singleMenuItem: ComputedRef +} + +export const usePopoverMenu = ( + items: Ref, + entity: Ref, + options: { provides?: boolean } = {}, +) => { + const injectPopoverMenu = inject>( + POPOVER_MENU_SYMBOL, + null, + ) + + if (injectPopoverMenu) return injectPopoverMenu + + const { provides = false } = options + + const session = useSessionStore() + + const filteredMenuItems = computed(() => { + if (!items.value || !items.value.length) return + + return items.value.filter((item) => { + if (item.permission) { + return session.hasPermission(item.permission) + } + + if (item.show) { + return item.show(entity?.value) + } + + return true + }) + }) + + const singleMenuItemPresent = computed(() => { + return filteredMenuItems.value?.length === 1 + }) + + const singleMenuItem = computed(() => { + if (!singleMenuItemPresent.value) return + + return filteredMenuItems.value?.[0] + }) + + const providePopoverMenu = { + filteredMenuItems, + singleMenuItemPresent, + singleMenuItem, + } + + if (provides) provide(POPOVER_MENU_SYMBOL, providePopoverMenu) + + return providePopoverMenu +} diff --git a/app/frontend/apps/desktop/components/CommonSimpleTable/CommonSimpleTable.vue b/app/frontend/apps/desktop/components/CommonSimpleTable/CommonSimpleTable.vue new file mode 100644 index 000000000000..ca4194db8845 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonSimpleTable/CommonSimpleTable.vue @@ -0,0 +1,54 @@ + + + + + + + + + {{ $t(header.label, ...(header.labelPlaceholder || [])) }} + + + {{ $t('Actions') }} + + + + + + {{ item[header.key] || '-' }} + + + + + + + + diff --git a/app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts b/app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts new file mode 100644 index 000000000000..5a3fe438ca52 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts @@ -0,0 +1,87 @@ +// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +import { i18n } from '#shared/i18n.ts' +import { renderComponent } from '#tests/support/components/index.ts' +import type { MenuItem } from '#desktop/components/CommonPopover/types.ts' +import CommonSimpleTable, { type Props } from '../CommonSimpleTable.vue' + +const tableHeaders = [ + { + key: 'name', + label: 'User name', + }, + { + key: 'role', + label: 'Role', + }, +] + +const tableItems = [ + { + id: 1, + name: 'Lindsay Walton', + role: 'Member', + }, +] + +const tableActions: MenuItem[] = [ + { + key: 'download', + label: 'Download this row', + icon: 'download', + }, + { + key: 'delete', + label: 'Delete this row', + icon: 'trash3', + }, +] + +const renderTable = (props: Props) => { + return renderComponent(CommonSimpleTable, { + props, + }) +} + +beforeEach(() => { + i18n.setTranslationMap(new Map([['Role', 'Rolle']])) +}) + +describe('CommonSimpleTable.vue', () => { + it('displays the table without actions', async () => { + const view = renderTable({ + headers: tableHeaders, + items: tableItems, + }) + + expect(view.getByText('User name')).toBeInTheDocument() + expect(view.getByText('Rolle')).toBeInTheDocument() + expect(view.getByText('Lindsay Walton')).toBeInTheDocument() + expect(view.getByText('Member')).toBeInTheDocument() + expect(view.queryByText('Actions')).toBeNull() + }) + + it('displays the table with actions', async () => { + const view = renderTable({ + headers: tableHeaders, + items: tableItems, + actions: tableActions, + }) + + expect(view.getByText('Actions')).toBeInTheDocument() + expect(view.getByLabelText('Action menu button')).toBeInTheDocument() + }) + + it('generates expected DOM', async () => { + // TODO: check if such snappshot test is really the way we want to go. + const view = renderTable({ + headers: tableHeaders, + items: tableItems, + actions: tableActions, + }) + + expect(view.baseElement.querySelector('table')).toMatchFileSnapshot( + `${__filename}.snapshot.txt`, + ) + }) +}) 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 new file mode 100644 index 000000000000..c020d9cef79e --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonSimpleTable/__tests__/CommonSimpleTable.spec.ts.snapshot.txt @@ -0,0 +1,83 @@ + + + + + + User name + + + + + Rolle + + + + + + Actions + + + + + + + + + + Lindsay Walton + + + + + Member + + + + + + + + + + + + + + + + + + + + \ 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 new file mode 100644 index 000000000000..f1ff55317091 --- /dev/null +++ b/app/frontend/apps/desktop/components/CommonSimpleTable/types.ts @@ -0,0 +1,12 @@ +// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/ + +export interface TableHeader { + key: string + label: string + labelPlaceholder?: string[] +} + +export interface TableItem { + [key: string]: unknown + id: string | number +} diff --git a/app/frontend/apps/desktop/initializer/3RD-PARTY-ICONS.md b/app/frontend/apps/desktop/initializer/3RD-PARTY-ICONS.md index 4e5a0fbfa86a..fd1af4feb3b6 100644 --- a/app/frontend/apps/desktop/initializer/3RD-PARTY-ICONS.md +++ b/app/frontend/apps/desktop/initializer/3RD-PARTY-ICONS.md @@ -49,6 +49,7 @@ - `assets/square.svg` - `assets/sun.svg` - `assets/upload.svg` +- `assets/three-dots-vertical.svg` - `assets/trash3.svg` - `assets/x-circle.svg` - `assets/x-lg.svg` diff --git a/app/frontend/apps/desktop/initializer/assets/three-dots-vertical.svg b/app/frontend/apps/desktop/initializer/assets/three-dots-vertical.svg new file mode 100644 index 000000000000..e7df3ffc892e --- /dev/null +++ b/app/frontend/apps/desktop/initializer/assets/three-dots-vertical.svg @@ -0,0 +1,3 @@ + + + diff --git a/app/frontend/apps/desktop/pages/dashboard/views/Playground.vue b/app/frontend/apps/desktop/pages/dashboard/views/Playground.vue index 895205aed844..bc361f2eac61 100644 --- a/app/frontend/apps/desktop/pages/dashboard/views/Playground.vue +++ b/app/frontend/apps/desktop/pages/dashboard/views/Playground.vue @@ -2,7 +2,7 @@ @@ -637,6 +728,18 @@ const vip = ref(false) + Table + + Change row + + + Avatar @@ -794,6 +897,46 @@ const vip = ref(false) + + Common Action Menu + + Single Action Item + + + Flyout and Dialog