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:
@@ -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]`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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}"?`),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'] }),
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: () =>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.')
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -146,7 +146,6 @@ const tableActions: MenuItem[] = [
|
||||
key: 'delete',
|
||||
label: __('Delete this access token'),
|
||||
icon: 'trash3',
|
||||
variant: 'danger',
|
||||
onClick: (data) => {
|
||||
confirmDeleteDevice(data as Token)
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -158,6 +158,7 @@ const actions = computed(() => {
|
||||
label: action.label,
|
||||
icon: action.icon,
|
||||
link: action.link,
|
||||
separatorTop: action.separatorTop,
|
||||
...(action.perform
|
||||
? {
|
||||
onClick: () => {
|
||||
|
||||
@@ -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')"
|
||||
/>
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -378,8 +378,8 @@ const checklistActions: MenuItem[] = [
|
||||
{
|
||||
key: 'remove',
|
||||
label: __('Remove checklist'),
|
||||
variant: 'danger',
|
||||
icon: 'trash3',
|
||||
separatorTop: true,
|
||||
onClick: () => removeChecklist(),
|
||||
show: () => !!checklist.value,
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -77,6 +77,7 @@ const actionPlugin: TicketArticleActionPlugin = {
|
||||
label: __('Delete article'),
|
||||
name: 'articleDelete',
|
||||
icon: 'trash',
|
||||
separatorTop: true,
|
||||
perform: () => deleteAction(article),
|
||||
view: {
|
||||
agent: ['change'],
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
307
i18n/zammad.pot
307
i18n/zammad.pot
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user