Maintenance: Desktop view - Danger styling on buttons does not reliably signal irrecoverable actions.

Approved-by: Benjamin Scharf <bs@zammad.com>
This commit is contained in:
Dusan Vuckovic
2026-10-06 09:19:54 +01:00
parent 416fe41626
commit 9fd8a6ad87
60 changed files with 678 additions and 260 deletions

View File

@@ -308,6 +308,19 @@ or translation quality for some users.
other `$t(...)` / `i18n.t(...)` calls or the base locale file. If a near-identical string
exists, flag the new one as a duplicate and suggest reusing it — every distinct string adds
translator workload across all supported languages.
- **Danger styling and confirmations** — `danger` and `remove` (which counts as danger) mark the
click that cannot be undone, so they stay only on controls that act immediately. Flag a `danger`
or `remove` trigger whose handler ends in `waitForConfirmation` / `waitForVariantConfirmation`,
or opens a flyout that asks first. What replaces it depends on the kind of trigger: a `danger`
button becomes `tertiary` with an icon naming the action (`trash3` for delete and discard,
`arrow-counterclockwise` for reset); a menu item carries no variant at all, icon included; a
`remove` icon button loses the variant, keeps its icon, and takes the variant UX decides per
place (`tertiary` so far), so do not assume one. Flag a confirmation for irrecoverable loss
(deleting records, resetting user settings, discarding unsaved changes, clearing filters or
recent lists) whose confirm button is not `danger`: the `delete` and `unsaved` variants of
`CommonConfirmationDialog` already render one, so only the default `confirm` variant needs
`buttonVariant: 'danger'`. A control that asks only in some states (a dirty taskbar tab) follows
the state.
- Never flag a change to `i18n/*.po` as the fix. Those files are managed externally.
### Backend — `[backend]`

View File

@@ -67,6 +67,13 @@ small methods are preferred over explanation.
- Translatable strings: use complete, meaningful source strings,
sentence case for English source strings, and check for existing
near-duplicate strings before adding new ones.
- Red marks the point of no return: `danger` and `remove` stay only on
controls that act immediately. A trigger that only opens a confirmation
dialog or a confirming flyout is not red: a button becomes `tertiary`
with an icon naming the action (`trash3` for delete and discard,
`arrow-counterclockwise` for reset), a menu item carries no variant, a
`remove` icon button keeps its icon and takes the variant UX names per
place. The decisive button in the dialog or flyout is `danger`.
## Essential Commands

View File

@@ -71,11 +71,41 @@ describe('dialog confirm behaviour', () => {
expect(wrapper.getByRole('dialog', { name: 'Delete object' })).toBeInTheDocument()
expect(wrapper.getByText('Are you sure you want to delete this object?')).toBeInTheDocument()
expect(wrapper.getByRole('button', { name: 'Delete object' })).toHaveClass('bg-pink-100')
await wrapper.events.click(wrapper.getByRole('button', { name: 'Delete object' }))
expect(confirmCallbackSpy).toHaveBeenCalledTimes(1)
})
it('renders the unsaved changes variant with a danger confirm button', async () => {
const confirmCallbackSpy = vi.fn()
const wrapper = renderComponent(CommonConfirmationDialog, {
props: {
uniqueId: 'confirmation',
},
router: true,
})
confirmationOptions.value.set('confirmation', {
confirmationVariant: 'unsaved',
confirmCallback: confirmCallbackSpy,
cancelCallback: vi.fn(),
closeCallback: vi.fn(),
})
await waitForNextTick()
expect(wrapper.getByRole('dialog', { name: 'Unsaved changes' })).toBeInTheDocument()
expect(
wrapper.getByText('Are you sure? You have unsaved changes that will get lost.'),
).toBeInTheDocument()
expect(wrapper.getByRole('button', { name: 'Discard changes' })).toHaveClass('bg-pink-100')
await wrapper.events.click(wrapper.getByRole('button', { name: 'Discard changes' }))
expect(confirmCallbackSpy).toHaveBeenCalledTimes(1)
})
it('renders confirmation dialog with custom values', async () => {
const confirmCallbackSpy = vi.fn()

View File

@@ -204,7 +204,7 @@ const onPrimaryClick = () => {
v-tooltip="$t('Remove file: %s', file.name)"
icon="x-lg"
size="small"
variant="remove"
variant="tertiary"
@click.stop="$emit('remove')"
@mousedown.stop
/>

View File

@@ -155,7 +155,13 @@ describe('CommonFileListItem', () => {
it('emits remove when the remove button is used', async () => {
const view = renderItem(image)
await view.events.click(view.getByRole('button', { name: 'Remove file: IMG_1234.png' }))
const removeButton = view.getByRole('button', { name: 'Remove file: IMG_1234.png' })
// The field asks before it removes, so the button is not red.
expect(removeButton).toHaveClass('bg-green-200')
expect(removeButton).not.toHaveClass('bg-red-400')
await view.events.click(removeButton)
expect(view.emitted().remove).toBeTruthy()
})

View File

@@ -80,7 +80,13 @@ describe('FieldFileList', () => {
it('emits remove with the original file object', async () => {
const view = renderFileList()
await view.events.click(view.getByRole('button', { name: 'Remove file: IMG_1234.png' }))
const removeButton = view.getByRole('button', { name: 'Remove file: IMG_1234.png' })
// The field asks before it removes, so the button is not red.
expect(removeButton).toHaveClass('bg-green-200')
expect(removeButton).not.toHaveClass('bg-red-400')
await view.events.click(removeButton)
// The very same object goes back out, so the field can drop it from its own list.
expect(view.emitted().remove).toEqual([[image]])

View File

@@ -82,7 +82,7 @@ const { notify } = useNotifications()
const confirmRemoveRecentSearch = async (searchQuery: string) => {
const confirmed = await waitForConfirmation(
__('Are you sure? This recent search will be lost.'),
{ fullscreen: true },
{ fullscreen: true, buttonVariant: 'danger' },
)
if (!confirmed) return
@@ -99,7 +99,7 @@ const confirmRemoveRecentSearch = async (searchQuery: string) => {
const confirmClearRecentSearches = async () => {
const confirmed = await waitForConfirmation(
__('Are you sure? Your recent searches will be lost.'),
{ fullscreen: true },
{ fullscreen: true, buttonVariant: 'danger' },
)
if (!confirmed) return
@@ -118,7 +118,7 @@ const recentCloseResetMutation = new MutationHandler(useUserCurrentRecentCloseRe
const confirmClearRecentlyClosed = async () => {
const confirmed = await waitForConfirmation(
__('Are you sure? Your recently closed items will get lost.'),
{ fullscreen: true },
{ fullscreen: true, buttonVariant: 'danger' },
)
if (!confirmed) return
@@ -183,7 +183,7 @@ const { resetQuickSearchInputField } = useQuickSearchInput()
class="absolute inset-e-2 top-3 justify-end group-hover/recent-search:opacity-100 focus:opacity-100"
icon="x-lg"
size="small"
variant="remove"
variant="tertiary"
@click.stop.prevent="confirmRemoveRecentSearch(searchQuery)"
/>
</CommonLink>

View File

@@ -22,6 +22,19 @@ import { getUserCurrentRecentCloseUpdatesSubscriptionHandler } from '#desktop/en
import { mockQuickSearchQuery } from '../../graphql/queries/quickSearch.mocks.ts'
import QuickSearch from '../QuickSearch.vue'
// The recent-search prompts go through the shared composable; the dialog itself is not mounted
// here, so the examples assert what it is asked with.
const mockWaitForConfirmation = vi.hoisted(() => vi.fn())
vi.mock('#shared/composables/useConfirmation.ts', () => ({
useConfirmation: () => ({ waitForConfirmation: mockWaitForConfirmation }),
}))
beforeEach(() => {
mockWaitForConfirmation.mockReset()
mockWaitForConfirmation.mockResolvedValue(true)
})
const renderQuickSearch = async (search: string = '') => {
const wrapper = renderComponent(QuickSearch, {
props: {
@@ -118,6 +131,40 @@ describe('QuickSearch', () => {
removeIcons = wrapper.getAllByIconName('x-lg')
expect(removeIcons.length).toBe(1)
})
// Removing asks first, so the trigger is not red; the confirm button inside the dialog is.
it('asks before removing a recent search, with a danger confirm button', async () => {
const wrapper = await renderQuickSearch()
addSearch('Foobar')
await waitForNextTick()
const removeButton = wrapper.getByRole('button', { name: 'Delete this recent search' })
expect(removeButton).toHaveClass('bg-green-200')
expect(removeButton).not.toHaveClass('bg-red-400')
await wrapper.events.click(removeButton)
expect(mockWaitForConfirmation).toHaveBeenCalledWith(
'Are you sure? This recent search will be lost.',
expect.objectContaining({ buttonVariant: 'danger' }),
)
})
it('asks before clearing the recent searches, with a danger confirm button', async () => {
const wrapper = await renderQuickSearch()
addSearch('Foobar')
await waitForNextTick()
await wrapper.events.click(wrapper.getByRole('link', { name: 'Clear recent searches' }))
expect(mockWaitForConfirmation).toHaveBeenCalledWith(
'Are you sure? Your recent searches will be lost.',
expect.objectContaining({ buttonVariant: 'danger' }),
)
})
})
describe('recently closed items', () => {
@@ -166,6 +213,24 @@ describe('QuickSearch', () => {
expect(wrapper.getByRole('link', { name: 'Organization 1' })).toBeInTheDocument()
})
// The same loss as clearing the recent searches, so this dialog confirms in danger as well.
it('asks before clearing the recently closed items, with a danger confirm button', async () => {
mockPermissions(['ticket.agent'])
mockUserCurrentRecentCloseListQuery({
userCurrentRecentCloseList: recentlyClosedItems,
})
mockWaitForConfirmation.mockResolvedValue(false)
const wrapper = await renderQuickSearch()
await wrapper.events.click(wrapper.getByRole('link', { name: 'Clear recently closed' }))
expect(mockWaitForConfirmation).toHaveBeenCalledWith(
'Are you sure? Your recently closed items will get lost.',
expect.objectContaining({ buttonVariant: 'danger' }),
)
})
it('allows clearing all recently closed items', async () => {
mockUserCurrentRecentCloseListQuery({
userCurrentRecentCloseList: recentlyClosedItems,

View File

@@ -1,7 +1,7 @@
<!-- Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ -->
<script setup lang="ts">
import { toRef } from 'vue'
import { computed, toRef } from 'vue'
import { useRouter } from 'vue-router'
import { useConfirmation } from '#shared/composables/useConfirmation.ts'
@@ -28,6 +28,12 @@ const { isTouchDevice } = useTouchDevice()
const router = useRouter()
// Red only where the click closes the tab right away: a dirty tab of a plugin that asks first
// gets the question below, so its button is not the point of no return.
const variant = computed(() =>
props.plugin?.confirmTabRemove && props.dirty ? 'tertiary' : 'remove',
)
const confirmRemoveUserTaskbarTab = async () => {
if (!props.taskbarTab.taskbarTabId) return
@@ -72,7 +78,7 @@ const confirmRemoveUserTaskbarTab = async () => {
class="absolute inset-e-2 top-3 group-hover/tab:opacity-100 focus:opacity-100"
icon="x-lg"
size="small"
variant="remove"
:variant="variant"
@click.stop="confirmRemoveUserTaskbarTab"
/>
</template>

View File

@@ -413,6 +413,10 @@ describe('UserTaskbarTabs.vue', () => {
expect(tabs[0]).toHaveTextContent('First ticket')
expect(tabs[1]).toHaveTextContent('Second ticket')
// Neither tab has anything to lose, so closing needs no question and the buttons stay red.
expect(getByRole(tabs[0], 'button', { name: 'Close this tab' })).toHaveClass('bg-red-400')
expect(getByRole(tabs[1], 'button', { name: 'Close this tab' })).toHaveClass('bg-red-400')
// Reorder tabs.
await getUserCurrentTaskbarItemListUpdatesSubscriptionHandler().trigger({
userCurrentTaskbarItemListUpdates: {
@@ -566,7 +570,13 @@ describe('UserTaskbarTabs.vue', () => {
const tab = wrapper.getByRole('treeitem')
await wrapper.events.click(getByRole(tab, 'button', { name: 'Close this tab' }))
const closeButton = getByRole(tab, 'button', { name: 'Close this tab' })
// A dirty tab asks before it closes, so its button is not red.
expect(closeButton).toHaveClass('bg-green-200')
expect(closeButton).not.toHaveClass('bg-red-400')
await wrapper.events.click(closeButton)
const calls = await waitForUserCurrentTaskbarItemDeleteMutationCalls()

View File

@@ -2,6 +2,7 @@
import { within } from '@testing-library/vue'
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import { type ExtendedRenderResult } from '#tests/support/components/index.ts'
import { getTestRouter } from '#tests/support/components/renderComponent.ts'
import { visitView } from '#tests/support/components/visitView.ts'
@@ -305,6 +306,27 @@ describe('knowledge base answer create', () => {
).not.toBeInTheDocument()
})
// Discarding asks first, so the trigger is not red; the confirm button inside the dialog is.
it('offers a discard button that is not red once there is something to discard', async () => {
const view = await visitCreateView()
await view.events.type(await view.findByLabelText('Title'), 'How to reset a password')
const discardButton = await view.findByRole('button', { name: 'Discard changes' })
expect(discardButton).toHaveClass('bg-green-200')
expect(discardButton).not.toHaveClass('bg-pink-100')
expect(getByIconName(discardButton, 'trash3')).toBeInTheDocument()
await view.events.click(discardButton)
const dialog = await view.findByRole('dialog', { name: 'Unsaved changes' })
expect(within(dialog).getByRole('button', { name: 'Discard changes' })).toHaveClass(
'bg-pink-100',
)
})
// The breadcrumb is this view's heading, and the category it names comes from a form field.
// Unlike in the browse view - which opens a category from its parent's listing, so its path
// travels along in the cache - a category picked here was never browsed, and fetching its path

View File

@@ -9,6 +9,7 @@ import {
mockedApolloClient,
waitForGraphQLMockCalls,
} from '#tests/graphql/builders/mocks.ts'
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import { getTestRouter } from '#tests/support/components/renderComponent.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
@@ -705,12 +706,21 @@ describe('knowledge base answer edit', () => {
const router = getTestRouter()
router.mockMethods()
await view.events.click(
await view.findByRole('button', { name: 'Discard your unsaved changes' }),
)
// The trigger only opens the dialog, so it is not red; the confirm button inside is.
const discardButton = await view.findByRole('button', { name: 'Discard your unsaved changes' })
expect(discardButton).toHaveClass('bg-green-200')
expect(discardButton).not.toHaveClass('bg-pink-100')
expect(getByIconName(discardButton, 'trash3')).toBeInTheDocument()
await view.events.click(discardButton)
const dialog = await view.findByRole('dialog', { name: 'Unsaved changes' })
await view.events.click(within(dialog).getByRole('button', { name: 'Discard changes' }))
const confirmButton = within(dialog).getByRole('button', { name: 'Discard changes' })
expect(confirmButton).toHaveClass('bg-pink-100')
await view.events.click(confirmButton)
await waitFor(() => expect(title).toHaveDisplayValue(TITLE))
expect(view.getByRole('radio', { name: 'Public' })).toBeChecked()
@@ -738,9 +748,14 @@ describe('knowledge base answer edit', () => {
await view.events.click(within(sidebar).getByRole('button', { name: 'Additional actions' }))
expect(
within(await view.findByRole('menu')).getByRole('button', { name: 'Delete answer' }),
).toBeInTheDocument()
const deleteItem = within(await view.findByRole('menu')).getByRole('button', {
name: 'Delete answer',
})
expect(deleteItem).toBeInTheDocument()
// Deleting asks first, so the menu item is not red.
expect(getByIconName(deleteItem, 'trash3')).not.toHaveClass('text-red-500')
})
it('does not offer deleting when the policy denies it', async () => {

View File

@@ -3,6 +3,7 @@
import { within } from '@testing-library/vue'
import { getGraphQLMockCalls, mockedApolloClient } from '#tests/graphql/builders/mocks.ts'
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
@@ -162,9 +163,14 @@ describe('knowledge base delete answer', () => {
})
const confirmDeletion = async (view: Awaited<ReturnType<typeof visitView>>) => {
await view.events.click(
within(await view.findByRole('dialog')).getByRole('button', { name: 'Delete object' }),
)
const confirmButton = within(await view.findByRole('dialog')).getByRole('button', {
name: 'Delete object',
})
// The decisive button is the red one.
expect(confirmButton).toHaveClass('bg-pink-100')
await view.events.click(confirmButton)
}
describe('from the answer view', () => {
@@ -188,7 +194,12 @@ describe('knowledge base delete answer', () => {
mockSuccessfulDelete()
const menu = await openHeaderMenu(view)
await view.events.click(menu.getByRole('button', { name: 'Delete answer' }))
const deleteItem = menu.getByRole('button', { name: 'Delete answer' })
// Deleting asks first, so the menu item is not red.
expect(getByIconName(deleteItem, 'trash3')).not.toHaveClass('text-red-500')
await view.events.click(deleteItem)
expect(
await view.findByText(`Do you really want to delete "${ANSWER_TITLE}"?`),

View File

@@ -131,13 +131,23 @@ describe('knowledge base delete category', () => {
await view.events.click(await view.findByRole('button', { name: 'Category actions' }))
// The click handler sits on the item's button, not on the `menuitem` wrapper.
await view.events.click(await view.findByText('Delete category'))
const deleteItem = await view.findByText('Delete category')
// Deleting asks first, so the menu item is not red.
expect(deleteItem).not.toHaveClass('text-red-500')
await view.events.click(deleteItem)
}
const confirmDeletion = async (view: Awaited<ReturnType<typeof visitView>>) => {
await view.events.click(
within(await view.findByRole('dialog')).getByRole('button', { name: 'Delete object' }),
)
const confirmButton = within(await view.findByRole('dialog')).getByRole('button', {
name: 'Delete object',
})
// The decisive button is the red one.
expect(confirmButton).toHaveClass('bg-pink-100')
await view.events.click(confirmButton)
}
describe('from a category card', () => {
@@ -157,6 +167,9 @@ describe('knowledge base delete category', () => {
const dialog = within(view.getByRole('dialog'))
expect(dialog.queryByRole('button', { name: 'Cancel & go back' })).not.toBeInTheDocument()
// Nothing can be lost here, so the button keeps the primary variant.
expect(dialog.getByRole('button', { name: 'OK' })).toHaveClass('bg-blue-800')
await view.events.click(dialog.getByRole('button', { name: 'OK' }))
expect(deleteMutationCallCount()).toBe(0)
@@ -238,7 +251,13 @@ describe('knowledge base delete category', () => {
const deleteOpenedCategory = async (view: Awaited<ReturnType<typeof visitView>>) => {
const header = within(await view.findByTestId('knowledge-base-header-full'))
await view.events.click(header.getByRole('button', { name: 'Additional actions' }))
await view.events.click(await view.findByText('Delete category'))
const deleteItem = await view.findByText('Delete category')
// Deleting asks first, so the menu item is not red.
expect(deleteItem).not.toHaveClass('text-red-500')
await view.events.click(deleteItem)
await confirmDeletion(view)
await waitForKnowledgeBaseCategoryDeleteMutationCalls()

View File

@@ -125,7 +125,7 @@ const removeSchedule = async (visibility: EnumKnowledgeBaseSchedulableVisibility
class="group-hover:opacity-100 focus:opacity-100"
icon="x-lg"
size="small"
variant="remove"
variant="tertiary"
@click.stop="removeSchedule(schedule.visibility)"
/>
</KnowledgeBaseAnswerScheduledVisibilityItem>

View File

@@ -222,13 +222,19 @@ describe('KnowledgeBaseAnswerScheduledVisibility', () => {
it('names the state it is about in the prompt', async () => {
const view = renderSection([internalInTwoDays], true)
await view.events.click(
view.getByRole('button', { name: 'Remove this scheduled visibility change' }),
)
const removeButton = view.getByRole('button', {
name: 'Remove this scheduled visibility change',
})
// Removing asks first, so the button is not red; the prompt's delete variant is.
expect(removeButton).toHaveClass('bg-green-200')
expect(removeButton).not.toHaveClass('bg-red-400')
await view.events.click(removeButton)
expect(mockWaitForConfirmation).toHaveBeenCalledWith(
'Do you really want to remove the scheduled change to %s?',
expect.objectContaining({ textPlaceholder: ['Internal'] }),
expect.objectContaining({ confirmationVariant: 'delete', textPlaceholder: ['Internal'] }),
)
})

View File

@@ -122,7 +122,12 @@ describe('KnowledgeBaseAnswerTags', () => {
const view = renderTags(['vip', 'billing'], true)
await view.events.click(view.getAllByRole('button', { name: 'Remove this tag' })[0])
const removeButton = view.getAllByRole('button', { name: 'Remove this tag' })[0]
// The click itself removes the tag, with no dialog, so the button stays red.
expect(removeButton).toHaveClass('bg-red-400')
await view.events.click(removeButton)
const calls = await waitForTagAssignmentRemoveMutationCalls()

View File

@@ -227,7 +227,8 @@ const submitCreateAnswer = async (data: FormSubmitData<KnowledgeBaseAnswerCreate
<CommonButton
v-if="isDirty"
size="large"
variant="danger"
variant="tertiary"
prefix-icon="trash3"
:disabled="isDisabled"
@click="discardChanges"
>

View File

@@ -225,7 +225,6 @@ const sidebarActions = computed<MenuItem[]>(() => {
key: 'delete-answer',
label: __('Delete answer'),
icon: 'trash3',
variant: 'danger',
onClick: () =>
confirmAnswerDelete(
{ id: currentAnswer.id, title: currentAnswer.translation?.title },
@@ -679,7 +678,8 @@ const submitUpdateAnswer = async (data: FormSubmitData<KnowledgeBaseAnswerEditFo
<CommonButton
v-if="isInitialSettled && isDirty"
size="large"
variant="danger"
variant="tertiary"
prefix-icon="trash3"
:disabled="isDisabled"
@click="discardChanges"
>

View File

@@ -87,7 +87,6 @@ const actions = computed<MenuItem[]>(() => {
key: 'delete-answer',
label: __('Delete answer'),
icon: 'trash3',
variant: 'danger',
separatorTop: true,
// The category is handed over for the cache scope, not to navigate: a card is never the
// answer being read, so this page stays. It is what lowers the count of a *cached* listing

View File

@@ -120,7 +120,6 @@ const actions = computed<MenuItem[]>(() => {
key: 'delete-category',
label: __('Delete category'),
icon: 'trash3',
variant: 'danger',
separatorTop: true,
show: () => props.policy.destroy,
onClick: () =>

View File

@@ -4,6 +4,7 @@ import '#tests/graphql/builders/mocks.ts'
import { flushPromises } from '@vue/test-utils'
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import renderComponent, { getTestRouter } from '#tests/support/components/renderComponent.ts'
import { EnumKnowledgeBaseVisibility } from '#shared/graphql/types.ts'
@@ -88,4 +89,15 @@ describe('KnowledgeBaseAnswerCard', () => {
expect(wrapper.queryByIconName('translate')).not.toBeInTheDocument()
})
// Deleting asks first, so the menu item is not red.
it('offers deleting without the danger variant', async () => {
const wrapper = await renderCard({ canDelete: true })
await wrapper.events.click(wrapper.getByRole('button', { name: 'Answer actions' }))
const deleteItem = await wrapper.findByRole('button', { name: 'Delete answer' })
expect(getByIconName(deleteItem, 'trash3')).not.toHaveClass('text-red-500')
})
})

View File

@@ -1,5 +1,6 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import { renderComponent } from '#tests/support/components/index.ts'
import { getTestRouter } from '#tests/support/components/renderComponent.ts'
import { waitFor } from '#tests/support/vitest-wrapper.ts'
@@ -168,6 +169,15 @@ describe('KnowledgeBaseCategoryCard', () => {
expect(wrapper.queryByText('Delete category')).not.toBeInTheDocument()
})
// Deleting asks first, so the menu item is not red.
it('offers deleting without the danger variant', async () => {
const wrapper = await openMenu()
const deleteItem = await wrapper.findByRole('button', { name: 'Delete category' })
expect(getByIconName(deleteItem, 'trash3')).not.toHaveClass('text-red-500')
})
it('lists creating before the actions on the category itself', async () => {
const wrapper = await openMenu()

View File

@@ -196,7 +196,6 @@ const actions = computed<MenuItem[]>(() => {
key: 'delete-category',
label: __('Delete category'),
icon: 'trash3',
variant: 'danger',
separatorTop: true,
// Deleting the page the user is standing on: hand over the breadcrumb parent as
// the navigation target — the localised root when the category is top level.

View File

@@ -111,7 +111,6 @@ const actions = computed<MenuItem[]>(() => {
key: 'delete-answer',
label: __('Delete answer'),
icon: 'trash3',
variant: 'danger',
separatorTop: true,
// Deleting the page being read, so it hands over where to go instead.
onClick: () =>

View File

@@ -153,6 +153,10 @@ describe('avatar personal settings', () => {
})
expect(deleteButton).toBeInTheDocument()
// Deleting asks first, so the trigger is not red; the confirm button inside the dialog is.
expect(deleteButton).toHaveClass('bg-green-200')
expect(deleteButton).not.toHaveClass('bg-red-400')
mockUserCurrentAvatarDeleteMutation({
userCurrentAvatarDelete: {
success: true,
@@ -165,7 +169,11 @@ describe('avatar personal settings', () => {
expect(await view.findByRole('dialog', { name: 'Delete object' })).toBeInTheDocument()
await view.events.click(view.getByRole('button', { name: 'Delete object' }))
const confirmButton = view.getByRole('button', { name: 'Delete object' })
expect(confirmButton).toHaveClass('bg-pink-100')
await view.events.click(confirmButton)
const calls = await waitForUserCurrentAvatarDeleteMutationCalls()
expect(calls).toHaveLength(1)

View File

@@ -95,6 +95,10 @@ describe('devices personal settings', () => {
name: 'Delete this device',
})
// Deleting asks first, so the trigger is not red.
expect(deleteButton).toHaveClass('text-muted!')
expect(deleteButton).not.toHaveClass('text-red-500!')
mockUserCurrentDeviceDeleteMutation({
userCurrentDeviceDelete: {
success: true,

View File

@@ -70,13 +70,23 @@ describe('linked accounts page', () => {
it('removes an authorization provider', async () => {
const view = await visitView('/personal-setting/linked-accounts')
await view.events.click(view.getByLabelText('Remove account link on GitHub'))
// Removing asks first, so the trigger is not red; the confirm button inside the dialog is.
const removeButton = view.getByLabelText('Remove account link on GitHub')
expect(removeButton).toHaveClass('text-muted!')
expect(removeButton).not.toHaveClass('text-red-500')
await view.events.click(removeButton)
expect(await view.findByRole('dialog', { name: 'Delete object' })).toBeInTheDocument()
expect(view.getByText('Are you sure you want to delete this object?')).toBeInTheDocument()
await view.events.click(view.getByRole('button', { name: 'Delete object' }))
const confirmButton = view.getByRole('button', { name: 'Delete object' })
expect(confirmButton).toHaveClass('bg-pink-100')
await view.events.click(confirmButton)
const mockCalls = await waitForUserCurrentRemoveLinkedAccountMutationCalls()

View File

@@ -1,5 +1,6 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { mockUserCurrent } from '#tests/support/mock-userCurrent.ts'
@@ -197,7 +198,14 @@ describe('personal notifications settings', () => {
},
})
await view.events.click(view.getByRole('button', { name: 'Reset to default settings' }))
// The trigger only opens the dialog, so it is not red; the confirm button inside is.
const resetButton = view.getByRole('button', { name: 'Reset to defaults' })
expect(resetButton).toHaveClass('bg-green-200')
expect(resetButton).not.toHaveClass('bg-pink-100')
expect(getByIconName(resetButton, 'arrow-counterclockwise')).toBeInTheDocument()
await view.events.click(resetButton)
expect(await view.findByRole('dialog', { name: 'Confirmation' })).toBeInTheDocument()
@@ -205,7 +213,11 @@ describe('personal notifications settings', () => {
view.getByText('Are you sure? Your notifications settings will be reset to default.'),
).toBeInTheDocument()
await view.events.click(view.getByRole('button', { name: 'Yes' }))
const confirmButton = view.getByRole('button', { name: 'Yes' })
expect(confirmButton).toHaveClass('bg-pink-100')
await view.events.click(confirmButton)
const mocks = await waitForUserCurrentNotificationPreferencesResetMutationCalls()
@@ -240,7 +252,7 @@ describe('personal notifications settings', () => {
},
})
await view.events.click(view.getByRole('button', { name: 'Save notification settings' }))
await view.events.click(view.getByRole('button', { name: 'Save changes' }))
const previousMockedData = mockPersonalSettings()

View File

@@ -124,7 +124,7 @@ describe('Out of Office page', () => {
it('shows success notification', async () => {
const view = await visitView('/personal-setting/out-of-office')
await view.events.click(view.getByText('Save out of office'))
await view.events.click(view.getByText('Save changes'))
expect(
view.getByText('Out of office settings have been saved successfully'),
@@ -137,7 +137,7 @@ describe('Out of Office page', () => {
const input = view.getByLabelText('Reason for absence')
await view.events.clear(input)
await view.events.click(view.getByText('Save out of office'))
await view.events.click(view.getByText('Save changes'))
const calls = await waitForUserCurrentOutOfOfficeMutationCalls()
@@ -157,7 +157,7 @@ describe('Out of Office page', () => {
const button = getByRole(input.parentElement!, 'button')
await view.events.click(button)
await view.events.click(view.getByText('Save out of office'))
await view.events.click(view.getByText('Save changes'))
expect(input).toBeDescribedBy('This field is required.')
})
@@ -169,7 +169,7 @@ describe('Out of Office page', () => {
const button = getByRole(input, 'button')
await view.events.click(button)
await view.events.click(view.getByText('Save out of office'))
await view.events.click(view.getByText('Save changes'))
expect(input).toBeDescribedBy('This field is required.')
})
@@ -204,7 +204,7 @@ describe('Out of Office page', () => {
const input = view.getByLabelText('Active')
await view.events.click(input)
await view.events.click(view.getByText('Save out of office'))
await view.events.click(view.getByText('Save changes'))
const calls = await waitForUserCurrentOutOfOfficeMutationCalls()
@@ -234,7 +234,7 @@ describe('Out of Office page', () => {
const buttonAgent = getByRole(inputAgent, 'button')
await view.events.click(buttonAgent)
await view.events.click(view.getByText('Save out of office'))
await view.events.click(view.getByText('Save changes'))
const calls = await waitForUserCurrentOutOfOfficeMutationCalls()
@@ -282,7 +282,7 @@ describe('Out of Office page', () => {
await view.events.type(input, '2024-01-02 - 2024-02-02')
await view.events.keyboard('{Enter}')
await view.events.click(view.getByText('Save out of office'))
await view.events.click(view.getByText('Save changes'))
const calls = await waitForUserCurrentOutOfOfficeMutationCalls()
@@ -315,7 +315,7 @@ describe('Out of Office page', () => {
await view.events.click(view.getAllByRole('option')[0])
await view.events.click(view.getByText('Save out of office'))
await view.events.click(view.getByText('Save changes'))
const calls = await waitForUserCurrentOutOfOfficeMutationCalls()
@@ -354,7 +354,7 @@ describe('Out of Office page', () => {
await view.events.click(view.getAllByRole('option')[0])
await view.events.click(view.getByText('Save out of office'))
await view.events.click(view.getByText('Save changes'))
const calls = await waitForUserCurrentOutOfOfficeMutationCalls()
@@ -376,7 +376,7 @@ describe('Out of Office page', () => {
const input = view.getByLabelText('Active')
await view.events.click(input)
await view.events.click(view.getByText('Save out of office'))
await view.events.click(view.getByText('Save changes'))
const inputDate = view.getByLabelText('Start and end date')
expect(inputDate).toBeDescribedBy('This field is required.')

View File

@@ -2,6 +2,7 @@
import { getAllByRole } from '@testing-library/vue'
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockUserCurrent } from '#tests/support/mock-userCurrent.ts'
import { waitForNextTick } from '#tests/support/utils.ts'
@@ -88,13 +89,22 @@ describe('personal settings for token access', () => {
expect(resetButton).toBeVisible()
// The trigger only opens the dialog, so it is not red; the confirm button inside is.
expect(resetButton).toHaveClass('bg-green-200')
expect(resetButton).not.toHaveClass('bg-pink-100')
expect(getByIconName(resetButton, 'arrow-counterclockwise')).toBeInTheDocument()
await view.events.click(resetButton)
await waitForNextTick()
expect(await view.findByRole('dialog', { name: 'Confirmation' })).toBeVisible()
await view.events.click(view.getByRole('button', { name: 'Yes' }))
const confirmButton = view.getByRole('button', { name: 'Yes' })
expect(confirmButton).toHaveClass('bg-pink-100')
await view.events.click(confirmButton)
await waitForNextTick()

View File

@@ -137,6 +137,10 @@ describe('personal settings for token access', () => {
name: 'Delete this access token',
})
// Deleting asks first, so the trigger is not red.
expect(deleteButton).toHaveClass('text-muted!')
expect(deleteButton).not.toHaveClass('text-red-500!')
mockUserCurrentAccessTokenDeleteMutation({
userCurrentAccessTokenDelete: {
success: true,

View File

@@ -1,5 +1,6 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { waitFor } from '#tests/support/vitest-wrapper.ts'
@@ -78,6 +79,33 @@ describe('password personal settings', () => {
expect(flyout).not.toBeInTheDocument()
})
// Removing asks for the password first, so the menu item is not red.
it('offers removal without the danger variant', async () => {
const view = await visitView('/personal-setting/two-factor-auth')
await getUserCurrentTwoFactorUpdatesSubscriptionHandler().trigger({
userCurrentTwoFactorUpdates: {
configuration: {
enabledAuthenticationMethods: [
{
authenticationMethod: EnumTwoFactorAuthenticationMethod.AuthenticatorApp,
configured: true,
},
],
recoveryCodesExist: false,
},
},
})
await view.events.click(
view.getByRole('button', { name: 'Action menu button for authenticator app' }),
)
const removeItem = view.getByRole('button', { name: 'Remove' })
expect(getByIconName(removeItem, 'trash3')).not.toHaveClass('text-red-500')
})
it('edit', async () => {
const { flyoutContent } = await visitViewAndMockPasswordConfirmation(false, {
type: 'authenticatorApp',

View File

@@ -160,7 +160,12 @@ describe('Two-factor Authentication - Security Keys', () => {
userCurrentTwoFactorGetMethodConfiguration: null,
})
await view.events.click(view.getByRole('button', { name: 'Remove' }))
// Removing a credential happens on the click itself, with no dialog, so the button stays red.
const removeButton = view.getByRole('button', { name: 'Remove' })
expect(removeButton).toHaveClass('text-red-500!')
await view.events.click(removeButton)
await waitForUserCurrentTwoFactorRemoveMethodCredentialsMutationCalls()
await waitForUserCurrentTwoFactorGetMethodConfigurationQueryCalls()

View File

@@ -345,10 +345,10 @@ const { tabs, activeTab } = usePersonalSettingTabs()
v-if="avatar.deletable"
v-tooltip="$t('Delete this avatar')"
:class="{ 'opacity-0 transition-opacity': !isTouchDevice }"
class="absolute -inset-e-2 -top-1 text-white group-hover/avatar:opacity-100 focus:opacity-100"
class="absolute -inset-e-2 -top-1 group-hover/avatar:opacity-100 focus:opacity-100"
icon="x-lg"
size="small"
variant="remove"
variant="tertiary"
@click.stop="confirmDeleteAvatar(avatar)"
/>
</div>

View File

@@ -112,7 +112,6 @@ const tableActions: MenuItem[] = [
key: 'delete',
label: __('Delete this device'),
icon: 'trash3',
variant: 'danger',
show: (data) => !data?.current,
onClick: (data) => {
confirmDeleteDevice(data as UserDevice)

View File

@@ -168,7 +168,6 @@ const tableActions = computed((): MenuItem[] => [
{
key: 'delete',
icon: 'trash3',
variant: 'danger',
ariaLabel: (provider) => i18n.t('Remove account link on %s', provider?.application),
show: (provider) => !!provider?.username,
onClick: (provider) => unlinkAccount(provider as LinkedAccountTableItem),
@@ -215,7 +214,7 @@ const { tabs, activeTab } = usePersonalSettingTabs()
v-tooltip="$t((action?.ariaLabel as Function)(item))"
:icon="action.icon"
:disabled="loading"
:class="{ 'bg-transparent!': action.variant === 'danger' }"
:class="{ 'text-muted!': !action.variant }"
size="medium"
:variant="action.variant"
@click="action.onClick?.(item)"

View File

@@ -151,6 +151,7 @@ const onSubmit = async (form: FormSubmitData<NotificationFormData>) => {
const onResetToDefaultSettings = async () => {
const confirmed = await waitForConfirmation(
__('Are you sure? Your notifications settings will be reset to default.'),
{ buttonVariant: 'danger' },
)
if (!confirmed) return
@@ -209,14 +210,15 @@ const { tabs, activeTab } = usePersonalSettingTabs()
<div class="flex justify-end gap-2">
<CommonButton
size="medium"
variant="danger"
variant="tertiary"
prefix-icon="arrow-counterclockwise"
:disabled="loading"
@click="onResetToDefaultSettings"
>
{{ $t('Reset to default settings') }}
{{ $t('Reset to defaults') }}
</CommonButton>
<CommonButton size="medium" type="submit" variant="submit" :disabled="loading">
{{ $t('Save notification settings') }}
{{ $t('Save changes') }}
</CommonButton>
</div>
</template>

View File

@@ -177,7 +177,7 @@ const { tabs, activeTab } = usePersonalSettingTabs()
<template #after-fields>
<div class="mt-5 flex items-center justify-end gap-2">
<CommonButton variant="submit" type="submit" size="medium" :disabled="isDisabled">
{{ $t('Save out of office') }}
{{ $t('Save changes') }}
</CommonButton>
</div>
</template>

View File

@@ -110,7 +110,7 @@ const resetOverviewOrder = () => {
}
const confirmResetOverviewOrder = async () => {
const confirmed = await waitForVariantConfirmation('confirm')
const confirmed = await waitForVariantConfirmation('confirm', { buttonVariant: 'danger' })
if (confirmed) resetOverviewOrder()
}
@@ -144,7 +144,8 @@ const { tabs, activeTab } = usePersonalSettingTabs()
<div class="flex flex-col items-end">
<CommonButton
class="mt-4"
variant="danger"
variant="tertiary"
prefix-icon="arrow-counterclockwise"
size="medium"
@click.stop="confirmResetOverviewOrder"
>

View File

@@ -146,7 +146,6 @@ const tableActions: MenuItem[] = [
key: 'delete',
label: __('Delete this access token'),
icon: 'trash3',
variant: 'danger',
onClick: (data) => {
confirmDeleteDevice(data as Token)
},

View File

@@ -178,7 +178,7 @@ const actions = computed<MenuItem[]>(() => [
label: __('Remove'),
ariaLabel: (entity) => lookUpA11yActionLabel(entity!, 'remove'),
icon: 'trash3',
variant: 'danger',
separatorTop: true,
show: (entity) => Boolean(entity?.configured),
onClick: (entity) => submitTwoFactorMethodRemoval(entity),
},

View File

@@ -5,6 +5,7 @@ import { beforeEach } from 'vitest'
import ticketArticleCustomerObjectAttributes from '#tests/graphql/factories/fixtures/ticket-article-customer-object-attributes.ts'
import ticketCustomerObjectAttributes from '#tests/graphql/factories/fixtures/ticket-customer-object-attributes.ts'
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import { getTestRouter } from '#tests/support/components/renderComponent.ts'
import { visitView } from '#tests/support/components/visitView.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
@@ -299,7 +300,14 @@ describe('ticket create view', () => {
expect(view.queryByRole('button', { name: 'Cancel & go back' })).not.toBeInTheDocument(),
)
await view.events.click(await view.findByRole('button', { name: 'Discard changes' }))
// The trigger only opens the dialog, so it is not red; the confirm button inside is.
const discardButton = await view.findByRole('button', { name: 'Discard changes' })
expect(discardButton).toHaveClass('bg-green-200')
expect(discardButton).not.toHaveClass('bg-pink-100')
expect(getByIconName(discardButton, 'trash3')).toBeInTheDocument()
await view.events.click(discardButton)
const dialog = await view.findByRole('dialog', {
name: 'Unsaved changes',
@@ -313,7 +321,11 @@ describe('ticket create view', () => {
await dialogView.findByText('Are you sure? You have unsaved changes that will get lost.'),
)
await view.events.click(dialogView.getByRole('button', { name: 'Discard changes' }))
const confirmButton = dialogView.getByRole('button', { name: 'Discard changes' })
expect(confirmButton).toHaveClass('bg-pink-100')
await view.events.click(confirmButton)
// should not be in the document anymore
await waitFor(() => expect(view.queryByLabelText('Title')).not.toBeInTheDocument())

View File

@@ -407,7 +407,8 @@ const submitCreateTicket = async (event: FormSubmitData<TicketFormData>) => {
<CommonButton
v-if="isDirty"
size="large"
variant="danger"
variant="tertiary"
prefix-icon="trash3"
:disabled="isDisabled"
@click="discardChanges"
>{{ $t('Discard changes') }}</CommonButton

View File

@@ -158,6 +158,7 @@ const actions = computed(() => {
label: action.label,
icon: action.icon,
link: action.link,
separatorTop: action.separatorTop,
...(action.perform
? {
onClick: () => {

View File

@@ -135,8 +135,7 @@ onMounted(() => {
>
<CommonButton
v-tooltip="$t('Discard unsaved reply')"
class="text-red-500"
variant="none"
variant="neutral"
icon="trash"
@click="$emit('discard-form')"
/>

View File

@@ -52,7 +52,8 @@ const { ticket } = useTicketInformation()
<CommonButton
v-if="dirty"
size="large"
variant="danger"
variant="tertiary"
prefix-icon="trash3"
:disabled="disabled"
@click="$emit('discard', $event)"
>{{ $t('Discard your unsaved changes') }}

View File

@@ -3,6 +3,7 @@
import { within } from '@testing-library/vue'
import { ref } from 'vue'
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import renderComponent from '#tests/support/components/renderComponent.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
import { waitForNextTick } from '#tests/support/utils.ts'
@@ -92,9 +93,14 @@ describe('TicketDetailBottomBar', () => {
}
if (eventName === 'discard') {
await wrapper.events.click(
wrapper.getByRole('button', { name: 'Discard your unsaved changes' }),
)
const discardButton = wrapper.getByRole('button', { name: 'Discard your unsaved changes' })
// Discarding asks first, so the button is not red.
expect(discardButton).toHaveClass('bg-green-200')
expect(discardButton).not.toHaveClass('bg-pink-100')
expect(getByIconName(discardButton, 'trash3')).toBeInTheDocument()
await wrapper.events.click(discardButton)
expect(wrapper.emitted('discard')).toBeTruthy()
}

View File

@@ -217,7 +217,12 @@ const headerTitle = computed(() => {
<CommonButton size="large" variant="secondary" @click="close">
{{ $t('Cancel & go back') }}
</CommonButton>
<CommonButton size="large" variant="danger" @click="deleteSharedDraft(sharedDraftId)">
<CommonButton
size="large"
variant="tertiary"
prefix-icon="trash3"
@click="deleteSharedDraft(sharedDraftId)"
>
{{ $t('Delete') }}
</CommonButton>
<CommonButton size="large" variant="primary" @click="applySharedDraft(sharedDraftId)">

View File

@@ -378,8 +378,8 @@ const checklistActions: MenuItem[] = [
{
key: 'remove',
label: __('Remove checklist'),
variant: 'danger',
icon: 'trash3',
separatorTop: true,
onClick: () => removeChecklist(),
show: () => !!checklist.value,
},

View File

@@ -46,7 +46,7 @@ const editItem = async (newValue: string) => {
const isEditing = ref(false)
const actions: MenuItem[] = [
const actions = computed<MenuItem[]>(() => [
{
key: 'check',
label: __('Check item'),
@@ -71,11 +71,14 @@ const actions: MenuItem[] = [
{
key: 'remove',
label: __('Remove item'),
variant: 'danger',
icon: 'trash3',
separatorTop: true,
// An empty item is removed on the click itself, without the question a filled one gets, so
// only then is this action the point of no return.
variant: props.item.text?.length ? undefined : 'danger',
onClick: () => removeItem(),
},
]
])
defineExpose({
focusInput: () => inlineEditInstance.value?.activateEditing(),

View File

@@ -1,5 +1,8 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { within } from '@testing-library/vue'
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import renderComponent from '#tests/support/components/renderComponent.ts'
import { createDummyTicket } from '#shared/entities/ticket-article/__tests__/mocks/ticket.ts'
@@ -83,6 +86,39 @@ describe('ChecklistItems', () => {
expect(wrapper.getByIconName('plus-square-fill')).toBeInTheDocument()
})
// Removing asks first, so the menu item is not red.
it('offers removing an item without the danger variant', async () => {
const wrapper = renderChecklistItems(items)
await wrapper.events.click(wrapper.getAllByRole('button', { name: 'Action menu button' })[0])
const removeItem = within(await wrapper.findByRole('menu')).getByRole('button', {
name: 'Remove item',
})
expect(getByIconName(removeItem, 'trash3')).not.toHaveClass('text-red-500')
})
// An empty item is removed without a question, so there the action stays red.
it('keeps the danger variant on removing an item that is still empty', async () => {
const wrapper = renderChecklistItems([
{
id: convertToGraphQLId('ChecklistItem', 4),
text: '',
ticketReference: null,
checked: false,
},
])
await wrapper.events.click(wrapper.getByRole('button', { name: 'Action menu button' }))
const removeItem = within(await wrapper.findByRole('menu')).getByRole('button', {
name: 'Remove item',
})
expect(getByIconName(removeItem, 'trash3')).toHaveClass('text-red-500')
})
it('displays of ticket checklist item', async () => {
const ticket = createDummyTicket<Ticket>()
@@ -186,7 +222,12 @@ describe('ChecklistItems', () => {
expect(wrapper.queryByLabelText('Action menu button')).not.toBeInTheDocument()
expect(wrapper.getByLabelText('Remove item')).toBeInTheDocument()
// Removing asks first, so the trigger is not red.
const removeButton = wrapper.getByLabelText('Remove item')
expect(removeButton).toBeInTheDocument()
expect(removeButton).toHaveClass('text-muted!')
expect(removeButton).not.toHaveClass('text-red-500!')
})
it('displays content in readonly mode', async () => {

View File

@@ -1,8 +1,9 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { waitFor } from '@testing-library/vue'
import { waitFor, within } from '@testing-library/vue'
import { computed, ref } from 'vue'
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import { type ExtendedRenderResult, renderComponent } from '#tests/support/components/index.ts'
import { mockApplicationConfig } from '#tests/support/mock-applicationConfig.ts'
import { mockPermissions } from '#tests/support/mock-permissions.ts'
@@ -468,6 +469,30 @@ describe('TicketSidebarChecklist', () => {
await verifyChecked(checkboxes, false)
})
// Removing asks first, so the menu item is not red.
it('offers removing the checklist without the danger variant', async () => {
mockTicketChecklistQuery({
ticketChecklist: {
id: convertToGraphQLId('Checklist', 1),
name: 'Checklist title',
items: checklistItemsMock,
},
})
const wrapper = await renderChecklist()
await wrapper.findByRole('heading', { level: 3, name: 'Checklist title' })
await wrapper.events.click(
wrapper.getAllByRole('button', { name: 'Action menu button' }).at(0) as HTMLElement,
)
const removeItem = within(await wrapper.findByRole('menu')).getByRole('button', {
name: 'Remove checklist',
})
expect(getByIconName(removeItem, 'trash3')).not.toHaveClass('text-red-500')
})
it('removes entire checklist', async () => {
mockTicketChecklistQuery({
ticketChecklist: {

View File

@@ -66,17 +66,6 @@ const actions = computed<MenuItem[]>(() => [
show: () => isTicketAgent.value,
onClick: () => openTicketHistoryFlyout(ticket.value!.id),
},
{
key: ticketMergeFlyoutName,
label: __('Merge'),
icon: 'merge',
show: () => isTicketAgent.value && isTicketEditable.value,
onClick: () =>
openTicketMergeFlyout({
ticket,
currentTaskbarTabId: props.context.currentTaskbarTabId,
}),
},
{
key: ticketChangeCustomerFlyoutName,
label: __('Change customer'),
@@ -87,6 +76,18 @@ const actions = computed<MenuItem[]>(() => [
ticket,
}),
},
{
key: ticketMergeFlyoutName,
label: __('Merge'),
icon: 'merge',
separatorTop: true,
show: () => isTicketAgent.value && isTicketEditable.value,
onClick: () =>
openTicketMergeFlyout({
ticket,
currentTaskbarTabId: props.context.currentTaskbarTabId,
}),
},
])
// Agent read access is a per-ticket matter: an agent who is the customer of a ticket in a group

View File

@@ -1,5 +1,6 @@
// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/
import { getByIconName } from '#tests/support/components/iconQueries.ts'
import { renderComponent } from '#tests/support/components/index.ts'
import { waitForNextTick } from '#tests/support/utils.ts'
@@ -127,11 +128,14 @@ describe('TicketSharedDraftFlyout.vue', () => {
it('supports deleting shared draft', async () => {
const wrapper = await renderTicketSharedDraftFlyout()
await wrapper.events.click(
wrapper.getByRole('button', {
name: 'Delete',
}),
)
// The button only asks first, so it is not red.
const deleteButton = wrapper.getByRole('button', { name: 'Delete' })
expect(deleteButton).toHaveClass('bg-green-200')
expect(deleteButton).not.toHaveClass('bg-pink-100')
expect(getByIconName(deleteButton, 'trash3')).toBeInTheDocument()
await wrapper.events.click(deleteButton)
const calls = await waitForTicketSharedDraftStartDeleteMutationCalls()

View File

@@ -10,7 +10,8 @@ export default <DetailViewActionPlugin>{
key: 'delete-user',
label: __('Delete'),
icon: 'trash',
order: 400,
separatorTop: true,
order: 500,
permission: ['admin.data_privacy', 'admin.user'],
onClick: (user?: User) => {
if (!user) return

View File

@@ -77,6 +77,7 @@ const actionPlugin: TicketArticleActionPlugin = {
label: __('Delete article'),
name: 'articleDelete',
icon: 'trash',
separatorTop: true,
perform: () => deleteAction(article),
view: {
agent: ['change'],

View File

@@ -74,6 +74,7 @@ export interface TicketArticleAction {
view: TicketViewPolicyMap
link?: string
alwaysVisible?: boolean
separatorTop?: boolean // desktop popover menu only
perform?(ticket: TicketById, article: TicketArticle, options: TicketArticlePerformOptions): void
}

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,7 @@ RSpec.describe 'Desktop > Ticket > Online Notifications', app: :desktop_view, au
find('label', text: 'New ticket - All tickets').click
find('label', text: 'Ticket update - All tickets').click
click_on 'Save notification settings'
click_on 'Save changes'
wait_for_mutation('userCurrentNotificationPreferencesUpdate', number: 1)