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:
@@ -31,7 +31,6 @@ const DEFAULT_FOLDER: FolderResource = {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
resourceType: 'folder',
|
||||
readOnly: false,
|
||||
workflowCount: 2,
|
||||
subFolderCount: 2,
|
||||
homeProject: {
|
||||
|
||||
@@ -70,6 +70,7 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
|
||||
id: '',
|
||||
name: '',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
nodes,
|
||||
|
||||
@@ -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']">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -250,6 +250,7 @@ const { isSubNodeType } = useNodeType({
|
||||
node,
|
||||
});
|
||||
|
||||
const isArchivedWorkflow = computed(() => workflowsStore.workflow.isArchived);
|
||||
const isReadOnlyRoute = computed(() => route.meta.readOnlyCanvas === true);
|
||||
const isWaitNodeWaiting = computed(() => {
|
||||
return (
|
||||
@@ -549,7 +550,8 @@ const pinButtonDisabled = computed(
|
||||
(!rawInputData.value.length && !pinnedData.hasData.value) ||
|
||||
!!binaryData.value?.length ||
|
||||
isReadOnlyRoute.value ||
|
||||
readOnlyEnv.value,
|
||||
readOnlyEnv.value ||
|
||||
isArchivedWorkflow.value,
|
||||
);
|
||||
|
||||
const activeTaskMetadata = computed((): ITaskMetadata | null => {
|
||||
@@ -847,7 +849,13 @@ function showPinDataDiscoveryTooltip(value: IDataObject[]) {
|
||||
|
||||
const pinDataDiscoveryFlag = useStorage(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG).value;
|
||||
|
||||
if (value && value.length > 0 && !isReadOnlyRoute.value && !pinDataDiscoveryFlag) {
|
||||
if (
|
||||
value &&
|
||||
value.length > 0 &&
|
||||
!isReadOnlyRoute.value &&
|
||||
!isArchivedWorkflow.value &&
|
||||
!pinDataDiscoveryFlag
|
||||
) {
|
||||
pinDataDiscoveryComplete();
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -1367,7 +1375,7 @@ defineExpose({ enterEditMode });
|
||||
data-test-id="ndv-pinned-data-callout"
|
||||
>
|
||||
{{ i18n.baseText('runData.pindata.thisDataIsPinned') }}
|
||||
<span v-if="!isReadOnlyRoute && !readOnlyEnv" class="ml-4xs">
|
||||
<span v-if="!isReadOnlyRoute && !isArchivedWorkflow && !readOnlyEnv" class="ml-4xs">
|
||||
<N8nLink
|
||||
theme="secondary"
|
||||
size="small"
|
||||
|
||||
@@ -31,6 +31,7 @@ const EMPTY_WORKFLOW = {
|
||||
versionId: '1',
|
||||
name: 'Email Summary Agent ',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
connections: {},
|
||||
nodes: [],
|
||||
usedCredentials: [],
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('WorkflowActivator', () => {
|
||||
it('renders correctly', () => {
|
||||
const renderOptions = {
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -50,6 +51,7 @@ describe('WorkflowActivator', () => {
|
||||
const { getByTestId, getByRole } = renderComponent(renderOptions);
|
||||
expect(getByTestId('workflow-activator-status')).toBeInTheDocument();
|
||||
expect(getByRole('switch')).toBeInTheDocument();
|
||||
expect(getByRole('switch')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('display an inactive tooltip when there are no nodes available', async () => {
|
||||
@@ -57,6 +59,7 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
const { getByTestId, getByRole } = renderComponent({
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -80,6 +83,7 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
const { getByTestId, getByRole } = renderComponent({
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -143,6 +147,7 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
const { rerender } = renderComponent({
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -210,6 +215,7 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
const { rerender } = renderComponent({
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -251,6 +257,7 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
const { rerender } = renderComponent({
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -261,4 +268,27 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
expect(toast.showMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should be disabled on archived workflow', async () => {
|
||||
const renderOptions = {
|
||||
props: {
|
||||
isArchived: true,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
},
|
||||
};
|
||||
|
||||
const { getByTestId, getByRole } = renderComponent(renderOptions);
|
||||
expect(getByTestId('workflow-activator-status')).toBeInTheDocument();
|
||||
expect(getByRole('switch')).toBeInTheDocument();
|
||||
expect(getByRole('switch')).toBeDisabled();
|
||||
|
||||
await userEvent.hover(getByRole('switch'));
|
||||
expect(getByRole('tooltip')).toBeInTheDocument();
|
||||
expect(getByRole('tooltip')).toHaveTextContent(
|
||||
'This workflow is archived so it cannot be activated',
|
||||
);
|
||||
expect(getByTestId('workflow-activator-status')).toHaveTextContent('Inactive');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const props = defineProps<{
|
||||
isArchived: boolean;
|
||||
workflowActive: boolean;
|
||||
workflowId: string;
|
||||
workflowPermissions: PermissionsRecord['workflow'];
|
||||
@@ -87,6 +88,10 @@ const isNewWorkflow = computed(
|
||||
);
|
||||
|
||||
const disabled = computed((): boolean => {
|
||||
if (props.isArchived) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isNewWorkflow.value || isCurrentWorkflow.value) {
|
||||
return !props.workflowActive && !containsTrigger.value;
|
||||
}
|
||||
@@ -221,9 +226,11 @@ watch(
|
||||
<div>
|
||||
{{
|
||||
i18n.baseText(
|
||||
containsOnlyExecuteWorkflowTrigger
|
||||
? 'workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode'
|
||||
: 'workflowActivator.thisWorkflowHasNoTriggerNodes',
|
||||
isArchived
|
||||
? 'workflowActivator.thisWorkflowIsArchived'
|
||||
: containsOnlyExecuteWorkflowTrigger
|
||||
? 'workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode'
|
||||
: 'workflowActivator.thisWorkflowHasNoTriggerNodes',
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ const WORKFLOW_LIST_ITEM_ACTIONS = {
|
||||
SHARE: 'share',
|
||||
DUPLICATE: 'duplicate',
|
||||
DELETE: 'delete',
|
||||
ARCHIVE: 'archive',
|
||||
UNARCHIVE: 'unarchive',
|
||||
MOVE: 'move',
|
||||
MOVE_TO_FOLDER: 'moveToFolder',
|
||||
};
|
||||
@@ -57,6 +59,8 @@ const emit = defineEmits<{
|
||||
'expand:tags': [];
|
||||
'click:tag': [tagId: string, e: PointerEvent];
|
||||
'workflow:deleted': [];
|
||||
'workflow:archived': [];
|
||||
'workflow:unarchived': [];
|
||||
'workflow:active-toggle': [value: { id: string; active: boolean }];
|
||||
'action:move-to-folder': [value: { id: string; name: string; parentFolderId?: string }];
|
||||
}>();
|
||||
@@ -129,7 +133,7 @@ const actions = computed(() => {
|
||||
},
|
||||
];
|
||||
|
||||
if (workflowPermissions.value.create && !props.readOnly) {
|
||||
if (workflowPermissions.value.create && !props.readOnly && !props.data.isArchived) {
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.duplicate'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
|
||||
@@ -151,10 +155,21 @@ const actions = computed(() => {
|
||||
}
|
||||
|
||||
if (workflowPermissions.value.delete && !props.readOnly) {
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.delete'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
|
||||
});
|
||||
if (!props.data.isArchived) {
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.archive'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.ARCHIVE,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.delete'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
|
||||
});
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.unarchive'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.UNARCHIVE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
@@ -234,6 +249,12 @@ async function onAction(action: string) {
|
||||
case WORKFLOW_LIST_ITEM_ACTIONS.DELETE:
|
||||
await deleteWorkflow();
|
||||
break;
|
||||
case WORKFLOW_LIST_ITEM_ACTIONS.ARCHIVE:
|
||||
await archiveWorkflow();
|
||||
break;
|
||||
case WORKFLOW_LIST_ITEM_ACTIONS.UNARCHIVE:
|
||||
await unarchiveWorkflow();
|
||||
break;
|
||||
case WORKFLOW_LIST_ITEM_ACTIONS.MOVE:
|
||||
moveResource();
|
||||
break;
|
||||
@@ -277,12 +298,68 @@ async function deleteWorkflow() {
|
||||
|
||||
// Reset tab title since workflow is deleted.
|
||||
toast.showMessage({
|
||||
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title'),
|
||||
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title', {
|
||||
interpolate: { workflowName: props.data.name },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
emit('workflow:deleted');
|
||||
}
|
||||
|
||||
async function archiveWorkflow() {
|
||||
const archiveConfirmed = await message.confirm(
|
||||
locale.baseText('mainSidebar.confirmMessage.workflowArchive.message', {
|
||||
interpolate: { workflowName: props.data.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.data.id);
|
||||
} catch (error) {
|
||||
toast.showError(error, locale.baseText('generic.archiveWorkflowError'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.showMessage({
|
||||
title: locale.baseText('mainSidebar.showMessage.handleArchive.title', {
|
||||
interpolate: { workflowName: props.data.name },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
emit('workflow:archived');
|
||||
}
|
||||
|
||||
async function unarchiveWorkflow() {
|
||||
try {
|
||||
await workflowsStore.unarchiveWorkflow(props.data.id);
|
||||
} catch (error) {
|
||||
toast.showError(error, locale.baseText('generic.unarchiveWorkflowError'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.showMessage({
|
||||
title: locale.baseText('mainSidebar.showMessage.handleUnarchive.title', {
|
||||
interpolate: { workflowName: props.data.name },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
emit('workflow:unarchived');
|
||||
}
|
||||
|
||||
const fetchHiddenBreadCrumbsItems = async () => {
|
||||
if (!props.data.homeProject?.id || !projectName.value || !props.data.parentFolder) {
|
||||
hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]);
|
||||
@@ -331,6 +408,15 @@ const onBreadcrumbItemClick = async (item: PathItem) => {
|
||||
<N8nBadge v-if="!workflowPermissions.update" class="ml-3xs" theme="tertiary" bold>
|
||||
{{ locale.baseText('workflows.item.readonly') }}
|
||||
</N8nBadge>
|
||||
<N8nBadge
|
||||
v-if="data.isArchived"
|
||||
class="ml-3xs"
|
||||
theme="tertiary"
|
||||
bold
|
||||
data-test-id="workflow-archived-tag"
|
||||
>
|
||||
{{ locale.baseText('workflows.item.archived') }}
|
||||
</N8nBadge>
|
||||
</n8n-text>
|
||||
</template>
|
||||
<div :class="$style.cardDescription">
|
||||
@@ -388,6 +474,7 @@ const onBreadcrumbItemClick = async (item: PathItem) => {
|
||||
</ProjectCardBadge>
|
||||
<WorkflowActivator
|
||||
class="mr-s"
|
||||
:is-archived="data.isArchived"
|
||||
:workflow-active="data.active"
|
||||
:workflow-id="data.id"
|
||||
:workflow-permissions="workflowPermissions"
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('WorkflowSettingsVue', () => {
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
isArchived: false,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
createdAt: 1,
|
||||
@@ -71,6 +72,7 @@ describe('WorkflowSettingsVue', () => {
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
isArchived: false,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
createdAt: 1,
|
||||
@@ -273,6 +275,7 @@ describe('WorkflowSettingsVue', () => {
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
isArchived: false,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
createdAt: 1,
|
||||
|
||||
@@ -22,7 +22,6 @@ type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders';
|
||||
|
||||
export type FolderResource = BaseFolderItem & {
|
||||
resourceType: 'folder';
|
||||
readOnly: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowResource = BaseResource & {
|
||||
@@ -30,6 +29,7 @@ export type WorkflowResource = BaseResource & {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
active: boolean;
|
||||
isArchived: boolean;
|
||||
homeProject?: ProjectSharingData;
|
||||
scopes?: Scope[];
|
||||
tags?: ITag[] | string[];
|
||||
|
||||
Reference in New Issue
Block a user