From 98ec56ad77dfd4bbba1ecbbc8a06554a80448ea9 Mon Sep 17 00:00:00 2001 From: Charlie Kolb Date: Tue, 28 Apr 2026 08:32:12 +0200 Subject: [PATCH] feat(editor): Add Delete permanently link to workflow archive toast (#29157) --- .../frontend/@n8n/i18n/src/locales/en.json | 1 + .../MainHeader/WorkflowDetails.test.ts | 31 +++++++++- .../components/MainHeader/WorkflowDetails.vue | 49 +++++++++++++++- .../src/app/components/WorkflowCard.test.ts | 57 ++++++++++++++++++- .../src/app/components/WorkflowCard.vue | 26 +++++++-- 5 files changed, 152 insertions(+), 12 deletions(-) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 0b9630dc1de..b57937e8867 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -1619,6 +1619,7 @@ "mainSidebar.showMessage.handleSelect2.title": "Workflow created", "mainSidebar.showMessage.handleSelect3.title": "Workflow created", "mainSidebar.showMessage.handleArchive.title": "Workflow '{workflowName}' archived", + "mainSidebar.showMessage.handleArchive.message": "Delete permanently", "mainSidebar.showMessage.handleUnarchive.title": "Workflow '{workflowName}' unarchived", "mainSidebar.showMessage.stopExecution.title": "Execution stopped", "mainSidebar.workflows": "Workflows", diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.test.ts b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.test.ts index 972d36b664a..51ed5acd097 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.test.ts +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.test.ts @@ -64,10 +64,12 @@ vi.mock('@/app/stores/pushConnection.store', () => ({ vi.mock('@/app/composables/useToast', () => { const showError = vi.fn(); const showMessage = vi.fn(); + const showToast = vi.fn(); return { useToast: () => ({ showError, showMessage, + showToast, }), }; }); @@ -505,7 +507,7 @@ describe('WorkflowDetails', () => { expect(message.confirm).toHaveBeenCalledTimes(0); expect(toast.showError).toHaveBeenCalledTimes(0); - expect(toast.showMessage).toHaveBeenCalledTimes(1); + expect(toast.showToast).toHaveBeenCalledTimes(1); expect(workflowsStore.archiveWorkflow).toHaveBeenCalledTimes(1); expect(workflowsStore.archiveWorkflow).toHaveBeenCalledWith(workflow.id, 'test-checksum'); expect(router.push).toHaveBeenCalledTimes(1); @@ -599,7 +601,7 @@ describe('WorkflowDetails', () => { expect(message.confirm).toHaveBeenCalledTimes(1); expect(toast.showError).toHaveBeenCalledTimes(0); - expect(toast.showMessage).toHaveBeenCalledTimes(1); + expect(toast.showToast).toHaveBeenCalledTimes(1); expect(workflowsStore.archiveWorkflow).toHaveBeenCalledTimes(1); expect(workflowsStore.archiveWorkflow).toHaveBeenCalledWith(workflow.id, 'test-checksum'); expect(router.push).toHaveBeenCalledTimes(1); @@ -609,6 +611,31 @@ describe('WorkflowDetails', () => { expect(workflowDocumentStoreRef.value?.active).toBe(false); }); + it('should show a "Delete permanently" link in the archive toast that deletes the archived workflow', async () => { + workflowDocumentStoreRef.value?.setScopes(['workflow:delete']); + const { getByTestId } = renderComponent({ + props: { ...defaultProps, isArchived: false }, + }); + + workflowsStore.archiveWorkflow.mockResolvedValue(undefined); + + await userEvent.click(getByTestId('workflow-menu')); + await userEvent.click(getByTestId('workflow-menu-item-archive')); + + expect(toast.showToast).toHaveBeenCalledTimes(1); + const toastConfig = vi.mocked(toast.showToast).mock.calls[0][0]; + expect(toastConfig.message).toContain('archive-toast-delete-permanently-link'); + expect(toastConfig.onClick).toBeDefined(); + + const anchor = document.createElement('a'); + toastConfig.onClick?.({ target: anchor, preventDefault: vi.fn() } as unknown as MouseEvent); + + await vi.waitFor(() => { + expect(workflowsListStore.deleteWorkflow).toHaveBeenCalledTimes(1); + }); + expect(workflowsListStore.deleteWorkflow).toHaveBeenCalledWith(workflow.id); + }); + it("should call onWorkflowMenuSelect on 'Unarchive' option click", async () => { workflowDocumentStoreRef.value?.setScopes(['workflow:delete']); const { getByTestId } = renderComponent({ diff --git a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue index 25fc35298b9..87f86183a2a 100644 --- a/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue +++ b/packages/frontend/editor-ui/src/app/components/MainHeader/WorkflowDetails.vue @@ -257,10 +257,19 @@ async function handleArchiveWorkflow() { } uiStore.markStateClean(); - toast.showMessage({ + const archivedWorkflowId = props.id; + const archivedWorkflowName = props.name; + toast.showToast({ title: locale.baseText('mainSidebar.showMessage.handleArchive.title', { - interpolate: { workflowName: props.name }, + interpolate: { workflowName: archivedWorkflowName }, }), + message: `${locale.baseText('mainSidebar.showMessage.handleArchive.message')}`, + onClick: (event) => { + if (event?.target instanceof HTMLAnchorElement) { + event.preventDefault(); + void deleteArchivedWorkflow(archivedWorkflowId, archivedWorkflowName); + } + }, type: 'success', }); @@ -276,6 +285,42 @@ async function handleArchiveWorkflow() { } } +async function deleteArchivedWorkflow(id: IWorkflowDb['id'], name: IWorkflowDb['name']) { + const deleteConfirmed = await message.confirm( + locale.baseText('mainSidebar.confirmMessage.workflowDelete.message', { + interpolate: { workflowName: name }, + }), + locale.baseText('mainSidebar.confirmMessage.workflowDelete.headline'), + { + type: 'warning', + confirmButtonText: locale.baseText( + 'mainSidebar.confirmMessage.workflowDelete.confirmButtonText', + ), + cancelButtonText: locale.baseText( + 'mainSidebar.confirmMessage.workflowDelete.cancelButtonText', + ), + }, + ); + + if (deleteConfirmed !== MODAL_CONFIRM) { + return; + } + + try { + await workflowsListStore.deleteWorkflow(id); + } catch (error) { + toast.showError(error, locale.baseText('generic.deleteWorkflowError')); + return; + } + + toast.showMessage({ + title: locale.baseText('mainSidebar.showMessage.handleSelect1.title', { + interpolate: { workflowName: name }, + }), + type: 'success', + }); +} + async function handleUnarchiveWorkflow() { await workflowsStore.unarchiveWorkflow(props.id); toast.showMessage({ diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts index abf84ba5433..a91da705780 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts @@ -38,10 +38,12 @@ vi.mock('vue-router', () => { vi.mock('@/app/composables/useToast', () => { const showError = vi.fn(); const showMessage = vi.fn(); + const showToast = vi.fn(); return { useToast: () => ({ showError, showMessage, + showToast, }), }; }); @@ -370,7 +372,7 @@ describe('WorkflowCard', () => { expect(workflowsStore.archiveWorkflow).toHaveBeenCalledTimes(1); expect(workflowsStore.archiveWorkflow).toHaveBeenCalledWith(data.id); expect(toast.showError).not.toHaveBeenCalled(); - expect(toast.showMessage).toHaveBeenCalledTimes(1); + expect(toast.showToast).toHaveBeenCalledTimes(1); expect(emitted()['workflow:archived']).toHaveLength(1); }); @@ -406,10 +408,61 @@ describe('WorkflowCard', () => { expect(workflowsStore.archiveWorkflow).toHaveBeenCalledTimes(1); expect(workflowsStore.archiveWorkflow).toHaveBeenCalledWith(data.id); expect(toast.showError).not.toHaveBeenCalled(); - expect(toast.showMessage).toHaveBeenCalledTimes(1); + expect(toast.showToast).toHaveBeenCalledTimes(1); expect(emitted()['workflow:archived']).toHaveLength(1); }); + it('should show a "Delete permanently" link in the archive toast that deletes the archived workflow', async () => { + const data = createWorkflow({ + active: false, + isArchived: false, + scopes: ['workflow:delete'], + }); + + const { getByTestId, emitted, rerender } = renderComponent({ props: { data } }); + await userEvent.click(getByTestId('workflow-card-actions')); + await userEvent.click(getByTestId('action-archive')); + + expect(toast.showToast).toHaveBeenCalledTimes(1); + const toastConfig = vi.mocked(toast.showToast).mock.calls[0][0]; + expect(toastConfig.message).toContain('archive-toast-delete-permanently-link'); + expect(toastConfig.onClick).toBeDefined(); + + // Simulate v-for reuse: parent replaces props.data with a different workflow. + // The toast onClick must still delete the originally archived workflow. + await rerender({ data: createWorkflow({ id: 'different-id', name: 'Other Workflow' }) }); + + const anchor = document.createElement('a'); + toastConfig.onClick?.({ target: anchor, preventDefault: vi.fn() } as unknown as MouseEvent); + await waitFor(() => { + expect(workflowsListStore.deleteWorkflow).toHaveBeenCalledTimes(1); + }); + expect(workflowsListStore.deleteWorkflow).toHaveBeenCalledWith(data.id); + expect(emitted()['workflow:deleted']).toHaveLength(1); + }); + + it('should not delete when "Delete permanently" confirmation is cancelled', async () => { + const data = createWorkflow({ + active: false, + isArchived: false, + scopes: ['workflow:delete'], + }); + + const { getByTestId } = renderComponent({ props: { data } }); + await userEvent.click(getByTestId('workflow-card-actions')); + await userEvent.click(getByTestId('action-archive')); + + const toastConfig = vi.mocked(toast.showToast).mock.calls[0][0]; + vi.mocked(message.confirm).mockResolvedValueOnce('cancel'); + + const anchor = document.createElement('a'); + toastConfig.onClick?.({ target: anchor, preventDefault: vi.fn() } as unknown as MouseEvent); + await waitFor(() => { + expect(message.confirm).toHaveBeenCalled(); + }); + expect(workflowsListStore.deleteWorkflow).not.toHaveBeenCalled(); + }); + it("should have 'Unarchive' action on archived workflows", async () => { const data = createWorkflow({ isArchived: true, diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue index 31cc31b63ac..9a8599d036c 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.vue @@ -454,9 +454,13 @@ async function toggleMCPAccess(enabled: boolean) { } async function deleteWorkflow() { + await deleteWorkflowById(props.data.id, props.data.name); +} + +async function deleteWorkflowById(id: WorkflowResource['id'], name: WorkflowResource['name']) { const deleteConfirmed = await message.confirm( locale.baseText('mainSidebar.confirmMessage.workflowDelete.message', { - interpolate: { workflowName: props.data.name }, + interpolate: { workflowName: name }, }), locale.baseText('mainSidebar.confirmMessage.workflowDelete.headline'), { @@ -475,7 +479,7 @@ async function deleteWorkflow() { } try { - await workflowsListStore.deleteWorkflow(props.data.id); + await workflowsListStore.deleteWorkflow(id); } catch (error) { toast.showError(error, locale.baseText('generic.deleteWorkflowError')); return; @@ -484,7 +488,7 @@ async function deleteWorkflow() { // Reset tab title since workflow is deleted. toast.showMessage({ title: locale.baseText('mainSidebar.showMessage.handleSelect1.title', { - interpolate: { workflowName: props.data.name }, + interpolate: { workflowName: name }, }), type: 'success', }); @@ -514,17 +518,27 @@ async function archiveWorkflow() { } } + const archivedWorkflowId = props.data.id; + const archivedWorkflowName = props.data.name; + try { - await workflowsStore.archiveWorkflow(props.data.id); + await workflowsStore.archiveWorkflow(archivedWorkflowId); } catch (error) { toast.showError(error, locale.baseText('generic.archiveWorkflowError')); return; } - toast.showMessage({ + toast.showToast({ title: locale.baseText('mainSidebar.showMessage.handleArchive.title', { - interpolate: { workflowName: props.data.name }, + interpolate: { workflowName: archivedWorkflowName }, }), + message: `${locale.baseText('mainSidebar.showMessage.handleArchive.message')}`, + onClick: (event) => { + if (event?.target instanceof HTMLAnchorElement) { + event.preventDefault(); + void deleteWorkflowById(archivedWorkflowId, archivedWorkflowName); + } + }, type: 'success', }); emit('workflow:archived');