mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-20 03:12:15 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user