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');