feat(editor): Add Delete permanently link to workflow archive toast (#29157)

This commit is contained in:
Charlie Kolb 2026-04-28 08:32:12 +02:00 committed by GitHub
parent 048e01e060
commit 98ec56ad77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 152 additions and 12 deletions

View File

@ -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",

View File

@ -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({

View File

@ -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: `<a href="#" data-test-id="archive-toast-delete-permanently-link">${locale.baseText('mainSidebar.showMessage.handleArchive.message')}</a>`,
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({

View File

@ -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,

View File

@ -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: `<a href="#" data-test-id="archive-toast-delete-permanently-link">${locale.baseText('mainSidebar.showMessage.handleArchive.message')}</a>`,
onClick: (event) => {
if (event?.target instanceof HTMLAnchorElement) {
event.preventDefault();
void deleteWorkflowById(archivedWorkflowId, archivedWorkflowName);
}
},
type: 'success',
});
emit('workflow:archived');