feat(core): Change workflow deletions to soft deletes (#14894)

Adds soft‑deletion support for workflows through a new boolean column `isArchived`.

When a workflow is archived we now set `isArchived` flag to true and the workflows
stays in the database and is omitted from the default workflow listing query.

Archived workflows can be viewed in read-only mode, but they cannot be activated.

Archived workflows are still available by ID and can be invoked as sub-executions,
so existing Execute Workflow nodes continue to work. Execution engine doesn't
care about isArchived flag.

Users can restore workflows via Unarchive action at the UI.
This commit is contained in:
Jaakko Husso
2025-05-06 17:48:24 +03:00
committed by GitHub
parent 32b72011e6
commit 3a13139f78
64 changed files with 1616 additions and 124 deletions

View File

@@ -251,6 +251,7 @@ function hideGithubButton() {
:active="workflow.active"
:read-only="readOnly"
:current-folder="parentFolderForBreadcrumbs"
:is-archived="workflow.isArchived"
/>
<div v-if="showGitHubButton" :class="[$style['github-button'], 'hidden-sm-and-down']">
<div :class="$style['github-button-container']">

View File

@@ -1,19 +1,30 @@
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
import { createComponentRenderer } from '@/__tests__/render';
import { EnterpriseEditionFeature, STORES, WORKFLOW_SHARE_MODAL_KEY } from '@/constants';
import { type MockedStore, mockedStore } from '@/__tests__/utils';
import {
EnterpriseEditionFeature,
MODAL_CONFIRM,
STORES,
VIEWS,
WORKFLOW_SHARE_MODAL_KEY,
} from '@/constants';
import { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event';
import { useUIStore } from '@/stores/ui.store';
import { useRoute } from 'vue-router';
import type { Mock } from 'vitest';
import { useRoute, useRouter } from 'vue-router';
import { useMessage } from '@/composables/useMessage';
import { useToast } from '@/composables/useToast';
import { useWorkflowsStore } from '@/stores/workflows.store';
vi.mock('vue-router', async (importOriginal) => ({
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
...(await importOriginal<typeof import('vue-router')>()),
useRoute: vi.fn().mockReturnValue({}),
useRouter: vi.fn(() => ({
useRouter: vi.fn().mockReturnValue({
replace: vi.fn(),
})),
push: vi.fn().mockResolvedValue(undefined),
}),
}));
vi.mock('@/stores/pushConnection.store', () => ({
@@ -22,6 +33,26 @@ vi.mock('@/stores/pushConnection.store', () => ({
}),
}));
vi.mock('@/composables/useToast', () => {
const showError = vi.fn();
const showMessage = vi.fn();
return {
useToast: () => ({
showError,
showMessage,
}),
};
});
vi.mock('@/composables/useMessage', () => {
const confirm = vi.fn(async () => MODAL_CONFIRM);
return {
useMessage: () => ({
confirm,
}),
};
});
const initialState = {
[STORES.SETTINGS]: {
settings: {
@@ -59,17 +90,33 @@ const renderComponent = createComponentRenderer(WorkflowDetails, {
});
let uiStore: ReturnType<typeof useUIStore>;
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
let message: ReturnType<typeof useMessage>;
let toast: ReturnType<typeof useToast>;
let router: ReturnType<typeof useRouter>;
const workflow = {
id: '1',
name: 'Test Workflow',
tags: ['1', '2'],
active: false,
isArchived: false,
};
describe('WorkflowDetails', () => {
beforeEach(() => {
uiStore = useUIStore();
workflowsStore = mockedStore(useWorkflowsStore);
message = useMessage();
toast = useToast();
router = useRouter();
});
afterEach(() => {
vi.clearAllMocks();
});
it('renders workflow name and tags', async () => {
(useRoute as Mock).mockReturnValue({
query: { parentFolderId: '1' },
@@ -123,4 +170,229 @@ describe('WorkflowDetails', () => {
data: { id: '1' },
});
});
describe('Workflow menu', () => {
beforeEach(() => {
(useRoute as Mock).mockReturnValue({
meta: {
nodeView: true,
},
query: { parentFolderId: '1' },
});
});
it("should have disabled 'Archive' option on new workflow", async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
...workflow,
id: 'new',
readOnly: false,
isArchived: false,
scopes: ['workflow:delete'],
},
});
await userEvent.click(getByTestId('workflow-menu'));
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
expect(getByTestId('workflow-menu-item-archive')).toBeInTheDocument();
expect(getByTestId('workflow-menu-item-archive')).toHaveClass('disabled');
});
it("should have 'Archive' option on non archived workflow", async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
...workflow,
readOnly: false,
isArchived: false,
scopes: ['workflow:delete'],
},
});
await userEvent.click(getByTestId('workflow-menu'));
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
expect(getByTestId('workflow-menu-item-archive')).toBeInTheDocument();
expect(getByTestId('workflow-menu-item-archive')).not.toHaveClass('disabled');
});
it("should not have 'Archive' option on non archived readonly workflow", async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
...workflow,
readOnly: true,
isArchived: false,
scopes: ['workflow:delete'],
},
});
await userEvent.click(getByTestId('workflow-menu'));
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument();
});
it("should not have 'Archive' option on non archived workflow without permission", async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
...workflow,
readOnly: false,
isArchived: false,
scopes: ['workflow:update'],
},
});
await userEvent.click(getByTestId('workflow-menu'));
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument();
});
it("should have 'Unarchive' and 'Delete' options on archived workflow", async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
...workflow,
isArchived: true,
readOnly: false,
scopes: ['workflow:delete'],
},
});
await userEvent.click(getByTestId('workflow-menu'));
expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument();
expect(getByTestId('workflow-menu-item-delete')).toBeInTheDocument();
expect(getByTestId('workflow-menu-item-delete')).not.toHaveClass('disabled');
expect(getByTestId('workflow-menu-item-unarchive')).toBeInTheDocument();
expect(getByTestId('workflow-menu-item-unarchive')).not.toHaveClass('disabled');
});
it("should not have 'Unarchive' or 'Delete' options on archived readonly workflow", async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
...workflow,
isArchived: true,
readOnly: true,
scopes: ['workflow:delete'],
},
});
await userEvent.click(getByTestId('workflow-menu'));
expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
});
it("should not have 'Unarchive' or 'Delete' options on archived workflow without permission", async () => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
...workflow,
isArchived: true,
readOnly: false,
scopes: ['workflow:update'],
},
});
await userEvent.click(getByTestId('workflow-menu'));
expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
});
it("should call onWorkflowMenuSelect on 'Archive' option click", async () => {
const { getByTestId } = renderComponent({
props: {
...workflow,
readOnly: false,
isArchived: false,
scopes: ['workflow:delete'],
},
});
workflowsStore.archiveWorkflow.mockResolvedValue(undefined);
await userEvent.click(getByTestId('workflow-menu'));
await userEvent.click(getByTestId('workflow-menu-item-archive'));
expect(message.confirm).toHaveBeenCalledTimes(1);
expect(toast.showError).toHaveBeenCalledTimes(0);
expect(toast.showMessage).toHaveBeenCalledTimes(1);
expect(workflowsStore.archiveWorkflow).toHaveBeenCalledTimes(1);
expect(workflowsStore.archiveWorkflow).toHaveBeenCalledWith(workflow.id);
expect(router.push).toHaveBeenCalledTimes(1);
expect(router.push).toHaveBeenCalledWith({
name: VIEWS.WORKFLOWS,
});
});
it("should call onWorkflowMenuSelect on 'Unarchive' option click", async () => {
const { getByTestId } = renderComponent({
props: {
...workflow,
readOnly: false,
isArchived: true,
scopes: ['workflow:delete'],
},
});
await userEvent.click(getByTestId('workflow-menu'));
await userEvent.click(getByTestId('workflow-menu-item-unarchive'));
expect(toast.showError).toHaveBeenCalledTimes(0);
expect(toast.showMessage).toHaveBeenCalledTimes(1);
expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledTimes(1);
expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledWith(workflow.id);
});
it("should call onWorkflowMenuSelect on 'Delete' option click", async () => {
const { getByTestId } = renderComponent({
props: {
...workflow,
readOnly: false,
isArchived: true,
scopes: ['workflow:delete'],
},
});
await userEvent.click(getByTestId('workflow-menu'));
await userEvent.click(getByTestId('workflow-menu-item-delete'));
expect(message.confirm).toHaveBeenCalledTimes(1);
expect(toast.showError).toHaveBeenCalledTimes(0);
expect(toast.showMessage).toHaveBeenCalledTimes(1);
expect(workflowsStore.deleteWorkflow).toHaveBeenCalledTimes(1);
expect(workflowsStore.deleteWorkflow).toHaveBeenCalledWith(workflow.id);
expect(router.push).toHaveBeenCalledTimes(1);
expect(router.push).toHaveBeenCalledWith({
name: VIEWS.WORKFLOWS,
});
});
});
describe('Archived badge', () => {
it('should show badge on archived workflow', async () => {
const { getByTestId } = renderComponent({
props: {
...workflow,
readOnly: false,
isArchived: true,
scopes: ['workflow:delete'],
},
});
expect(getByTestId('workflow-archived-tag')).toBeVisible();
});
it('should not show badge on non archived workflow', async () => {
const { queryByTestId } = renderComponent({
props: {
...workflow,
readOnly: false,
isArchived: false,
scopes: ['workflow:delete'],
},
});
expect(queryByTestId('workflow-archived-tag')).not.toBeInTheDocument();
});
});
});

View File

@@ -69,6 +69,7 @@ const props = defineProps<{
scopes: IWorkflowDb['scopes'];
active: IWorkflowDb['active'];
currentFolder?: FolderShortInfo;
isArchived: IWorkflowDb['isArchived'];
}>();
const $style = useCssModule();
@@ -144,14 +145,20 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
label: locale.baseText('menuActions.download'),
disabled: !onWorkflowPage.value,
},
{
];
if (!props.readOnly && !props.isArchived) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.RENAME,
label: locale.baseText('generic.rename'),
disabled: !onWorkflowPage.value || workflowPermissions.value.update !== true,
},
];
});
}
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
if (
(workflowPermissions.value.delete === true && !props.readOnly && !props.isArchived) ||
isNewWorkflow.value
) {
actions.unshift({
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
label: locale.baseText('menuActions.duplicate'),
@@ -190,14 +197,29 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
disabled: !onWorkflowPage.value || isNewWorkflow.value,
});
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.DELETE,
label: locale.baseText('menuActions.delete'),
disabled: !onWorkflowPage.value || isNewWorkflow.value,
customClass: $style.deleteItem,
divided: true,
});
if ((workflowPermissions.value.delete === true && !props.readOnly) || isNewWorkflow.value) {
if (props.isArchived) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.UNARCHIVE,
label: locale.baseText('menuActions.unarchive'),
disabled: !onWorkflowPage.value || isNewWorkflow.value,
});
actions.push({
id: WORKFLOW_MENU_ACTIONS.DELETE,
label: locale.baseText('menuActions.delete'),
disabled: !onWorkflowPage.value || isNewWorkflow.value,
customClass: $style.deleteItem,
divided: true,
});
} else {
actions.push({
id: WORKFLOW_MENU_ACTIONS.ARCHIVE,
label: locale.baseText('menuActions.archive'),
disabled: !onWorkflowPage.value || isNewWorkflow.value,
customClass: $style.deleteItem,
divided: true,
});
}
}
return actions;
@@ -512,6 +534,56 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
break;
}
case WORKFLOW_MENU_ACTIONS.ARCHIVE: {
const archiveConfirmed = await message.confirm(
locale.baseText('mainSidebar.confirmMessage.workflowArchive.message', {
interpolate: { workflowName: props.name },
}),
locale.baseText('mainSidebar.confirmMessage.workflowArchive.headline'),
{
type: 'warning',
confirmButtonText: locale.baseText(
'mainSidebar.confirmMessage.workflowArchive.confirmButtonText',
),
cancelButtonText: locale.baseText(
'mainSidebar.confirmMessage.workflowArchive.cancelButtonText',
),
},
);
if (archiveConfirmed !== MODAL_CONFIRM) {
return;
}
try {
await workflowsStore.archiveWorkflow(props.id);
} catch (error) {
toast.showError(error, locale.baseText('generic.archiveWorkflowError'));
return;
}
uiStore.stateIsDirty = false;
toast.showMessage({
title: locale.baseText('mainSidebar.showMessage.handleArchive.title', {
interpolate: { workflowName: props.name },
}),
type: 'success',
});
await router.push({ name: VIEWS.WORKFLOWS });
break;
}
case WORKFLOW_MENU_ACTIONS.UNARCHIVE: {
await workflowsStore.unarchiveWorkflow(props.id);
toast.showMessage({
title: locale.baseText('mainSidebar.showMessage.handleUnarchive.title', {
interpolate: { workflowName: props.name },
}),
type: 'success',
});
break;
}
case WORKFLOW_MENU_ACTIONS.DELETE: {
const deleteConfirmed = await message.confirm(
locale.baseText('mainSidebar.confirmMessage.workflowDelete.message', {
@@ -543,7 +615,9 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
// Reset tab title since workflow is deleted.
documentTitle.reset();
toast.showMessage({
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title'),
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title', {
interpolate: { workflowName: props.name },
}),
type: 'success',
});
@@ -628,7 +702,9 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
:preview-value="shortenedName"
:is-edit-enabled="isNameEditEnabled"
:max-length="MAX_WORKFLOW_NAME_LENGTH"
:disabled="readOnly || (!isNewWorkflow && !workflowPermissions.update)"
:disabled="
readOnly || isArchived || (!isNewWorkflow && !workflowPermissions.update)
"
placeholder="Enter workflow name"
class="name"
@toggle="onNameToggle"
@@ -641,42 +717,62 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
</template>
</BreakpointsObserver>
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
<WorkflowTagsDropdown
v-if="isTagsEditEnabled && !readOnly && (isNewWorkflow || workflowPermissions.update)"
ref="dropdown"
v-model="appliedTagIds"
:event-bus="tagsEventBus"
:placeholder="i18n.baseText('workflowDetails.chooseOrCreateATag')"
class="tags-edit"
data-test-id="workflow-tags-dropdown"
@blur="onTagsBlur"
@esc="onTagsEditEsc"
/>
<div
v-else-if="
(tags ?? []).length === 0 && !readOnly && (isNewWorkflow || workflowPermissions.update)
"
>
<span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable">
+ {{ i18n.baseText('workflowDetails.addTag') }}
</span>
</div>
<WorkflowTagsContainer
v-else
:key="id"
:tag-ids="workflowTagIds"
:clickable="true"
:responsive="true"
data-test-id="workflow-tags"
@click="onTagsEditEnable"
/>
<span class="tags" data-test-id="workflow-tags-container">
<template v-if="settingsStore.areTagsEnabled">
<WorkflowTagsDropdown
v-if="
isTagsEditEnabled &&
!(readOnly || isArchived) &&
(isNewWorkflow || workflowPermissions.update)
"
ref="dropdown"
v-model="appliedTagIds"
:event-bus="tagsEventBus"
:placeholder="i18n.baseText('workflowDetails.chooseOrCreateATag')"
class="tags-edit"
data-test-id="workflow-tags-dropdown"
@blur="onTagsBlur"
@esc="onTagsEditEsc"
/>
<div
v-else-if="
(tags ?? []).length === 0 &&
!(readOnly || isArchived) &&
(isNewWorkflow || workflowPermissions.update)
"
>
<span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable">
+ {{ i18n.baseText('workflowDetails.addTag') }}
</span>
</div>
<WorkflowTagsContainer
v-else
:key="id"
:tag-ids="workflowTagIds"
:clickable="true"
:responsive="true"
data-test-id="workflow-tags"
@click="onTagsEditEnable"
/>
</template>
<span class="archived">
<N8nBadge
v-if="isArchived"
class="ml-3xs"
theme="tertiary"
bold
data-test-id="workflow-archived-tag"
>
{{ locale.baseText('workflows.item.archived') }}
</N8nBadge>
</span>
</span>
<span v-else class="tags"></span>
<PushConnectionTracker class="actions">
<span :class="`activator ${$style.group}`">
<WorkflowActivator
:is-archived="isArchived"
:workflow-active="active"
:workflow-id="id"
:workflow-permissions="workflowPermissions"
@@ -726,10 +822,13 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
type="primary"
:saved="!uiStore.stateIsDirty && !isNewWorkflow"
:disabled="
isWorkflowSaving || readOnly || (!isNewWorkflow && !workflowPermissions.update)
isWorkflowSaving ||
readOnly ||
isArchived ||
(!isNewWorkflow && !workflowPermissions.update)
"
:is-saving="isWorkflowSaving"
:with-shortcut="!readOnly && workflowPermissions.update"
:with-shortcut="!readOnly && !isArchived && workflowPermissions.update"
:shortcut-tooltip="i18n.baseText('saveWorkflowButton.hint')"
data-test-id="workflow-save-button"
@click="onSaveButtonClick"
@@ -814,6 +913,14 @@ $--header-spacing: 20px;
max-width: 460px;
}
.archived {
display: flex;
align-items: center;
width: 100%;
flex: 1;
margin-right: $--header-spacing;
}
.actions {
display: flex;
align-items: center;