mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-17 18:12:04 +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,13 +1,17 @@
|
||||
import type { MockInstance } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { waitFor, within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { type MockedStore, mockedStore } from '@/__tests__/utils';
|
||||
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||
import WorkflowCard from '@/components/WorkflowCard.vue';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
const push = vi.fn();
|
||||
@@ -22,7 +26,29 @@ vi.mock('vue-router', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowCard);
|
||||
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 renderComponent = createComponentRenderer(WorkflowCard, {
|
||||
pinia: createTestingPinia({}),
|
||||
});
|
||||
|
||||
const createWorkflow = (overrides = {}): IWorkflowDb => ({
|
||||
id: '1',
|
||||
@@ -32,21 +58,26 @@ const createWorkflow = (overrides = {}): IWorkflowDb => ({
|
||||
nodes: [],
|
||||
connections: {},
|
||||
active: true,
|
||||
isArchived: false,
|
||||
versionId: '1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('WorkflowCard', () => {
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
let windowOpenSpy: MockInstance;
|
||||
let router: ReturnType<typeof useRouter>;
|
||||
let projectsStore: ReturnType<typeof useProjectsStore>;
|
||||
let projectsStore: MockedStore<typeof useProjectsStore>;
|
||||
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
|
||||
let message: ReturnType<typeof useMessage>;
|
||||
let toast: ReturnType<typeof useToast>;
|
||||
|
||||
beforeEach(async () => {
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
router = useRouter();
|
||||
projectsStore = useProjectsStore();
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
message = useMessage();
|
||||
toast = useToast();
|
||||
|
||||
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
@@ -171,6 +202,110 @@ describe('WorkflowCard', () => {
|
||||
expect(actions).toHaveTextContent('Change owner');
|
||||
});
|
||||
|
||||
it("should have 'Archive' action on non archived workflows", async () => {
|
||||
const data = createWorkflow({
|
||||
isArchived: false,
|
||||
scopes: ['workflow:delete'],
|
||||
});
|
||||
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: { data },
|
||||
});
|
||||
const cardActions = getByTestId('workflow-card-actions');
|
||||
expect(cardActions).toBeInTheDocument();
|
||||
|
||||
const cardActionsOpener = within(cardActions).getByRole('button');
|
||||
expect(cardActionsOpener).toBeInTheDocument();
|
||||
|
||||
const controllingId = cardActionsOpener.getAttribute('aria-controls');
|
||||
await userEvent.click(cardActions);
|
||||
const actions = document.querySelector(`#${controllingId}`);
|
||||
if (!actions) {
|
||||
throw new Error('Actions menu not found');
|
||||
}
|
||||
expect(actions).toHaveTextContent('Archive');
|
||||
expect(actions).not.toHaveTextContent('Unarchive');
|
||||
expect(actions).not.toHaveTextContent('Delete');
|
||||
|
||||
await userEvent.click(getByTestId('action-archive'));
|
||||
|
||||
expect(message.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.archiveWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.archiveWorkflow).toHaveBeenCalledWith(data.id);
|
||||
expect(toast.showError).toHaveBeenCalledTimes(0);
|
||||
expect(toast.showMessage).toHaveBeenCalledTimes(1);
|
||||
expect(emitted()['workflow:archived']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should have 'Unarchive' action on archived workflows", async () => {
|
||||
const data = createWorkflow({
|
||||
isArchived: true,
|
||||
scopes: ['workflow:delete'],
|
||||
});
|
||||
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: { data },
|
||||
});
|
||||
const cardActions = getByTestId('workflow-card-actions');
|
||||
expect(cardActions).toBeInTheDocument();
|
||||
|
||||
const cardActionsOpener = within(cardActions).getByRole('button');
|
||||
expect(cardActionsOpener).toBeInTheDocument();
|
||||
|
||||
const controllingId = cardActionsOpener.getAttribute('aria-controls');
|
||||
await userEvent.click(cardActions);
|
||||
const actions = document.querySelector(`#${controllingId}`);
|
||||
if (!actions) {
|
||||
throw new Error('Actions menu not found');
|
||||
}
|
||||
expect(actions).not.toHaveTextContent('Archive');
|
||||
expect(actions).toHaveTextContent('Unarchive');
|
||||
expect(actions).toHaveTextContent('Delete');
|
||||
|
||||
await userEvent.click(getByTestId('action-unarchive'));
|
||||
|
||||
expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledWith(data.id);
|
||||
expect(toast.showError).toHaveBeenCalledTimes(0);
|
||||
expect(toast.showMessage).toHaveBeenCalledTimes(1);
|
||||
expect(emitted()['workflow:unarchived']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should show 'Delete' action on archived workflows", async () => {
|
||||
const data = createWorkflow({
|
||||
isArchived: true,
|
||||
scopes: ['workflow:delete'],
|
||||
});
|
||||
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: { data },
|
||||
});
|
||||
const cardActions = getByTestId('workflow-card-actions');
|
||||
expect(cardActions).toBeInTheDocument();
|
||||
|
||||
const cardActionsOpener = within(cardActions).getByRole('button');
|
||||
expect(cardActionsOpener).toBeInTheDocument();
|
||||
|
||||
const controllingId = cardActionsOpener.getAttribute('aria-controls');
|
||||
await userEvent.click(cardActions);
|
||||
const actions = document.querySelector(`#${controllingId}`);
|
||||
if (!actions) {
|
||||
throw new Error('Actions menu not found');
|
||||
}
|
||||
expect(actions).not.toHaveTextContent('Archive');
|
||||
expect(actions).toHaveTextContent('Unarchive');
|
||||
expect(actions).toHaveTextContent('Delete');
|
||||
|
||||
await userEvent.click(getByTestId('action-delete'));
|
||||
|
||||
expect(message.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.deleteWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.deleteWorkflow).toHaveBeenCalledWith(data.id);
|
||||
expect(toast.showError).toHaveBeenCalledTimes(0);
|
||||
expect(toast.showMessage).toHaveBeenCalledTimes(1);
|
||||
expect(emitted()['workflow:deleted']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should show Read only mode', async () => {
|
||||
const data = createWorkflow();
|
||||
const { getByRole } = renderComponent({ props: { data } });
|
||||
@@ -178,4 +313,18 @@ describe('WorkflowCard', () => {
|
||||
const heading = getByRole('heading');
|
||||
expect(heading).toHaveTextContent('Read only');
|
||||
});
|
||||
|
||||
it('should show Archived badge on archived workflows', async () => {
|
||||
const data = createWorkflow({ isArchived: true });
|
||||
const { getByTestId } = renderComponent({ props: { data } });
|
||||
|
||||
expect(getByTestId('workflow-archived-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show Archived badge on non archived workflows', async () => {
|
||||
const data = createWorkflow({ isArchived: false });
|
||||
const { queryByTestId } = renderComponent({ props: { data } });
|
||||
|
||||
expect(queryByTestId('workflow-archived-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user