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

@@ -62,13 +62,13 @@ describe('Workflows', () => {
cy.contains('No workflows found').should('be.visible');
});
it('should delete all the workflows', () => {
it('should archive all the workflows', () => {
WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 1);
for (let i = 0; i < multipleWorkflowsCount + 1; i++) {
cy.getByTestId('workflow-card-actions').first().click();
WorkflowsPage.getters.workflowDeleteButton().click();
cy.get('button').contains('delete').click();
WorkflowsPage.getters.workflowArchiveButton().click();
cy.get('button').contains('archive').click();
successToast().should('be.visible');
}
@@ -141,4 +141,40 @@ describe('Workflows', () => {
WorkflowsPage.getters.workflowActionItem('share').click();
workflowSharingModal.getters.modal().should('be.visible');
});
it('should delete archived workflows', () => {
cy.visit(WorkflowsPage.url);
// Toggle "Show archived workflows" filter
WorkflowsPage.getters.workflowFilterButton().click();
WorkflowsPage.getters.workflowArchivedCheckbox().click();
WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 3);
cy.reload();
WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 3);
// Archive -> Unarchive -> Archive -> Delete on the first workflow
cy.getByTestId('workflow-card-actions').first().click();
WorkflowsPage.getters.workflowArchiveButton().click();
cy.get('button').contains('archive').click();
successToast().should('be.visible');
cy.getByTestId('workflow-card-actions').first().click();
WorkflowsPage.getters.workflowUnarchiveButton().click();
successToast().should('be.visible');
cy.getByTestId('workflow-card-actions').first().click();
WorkflowsPage.getters.workflowArchiveButton().click();
cy.get('button').contains('archive').click();
successToast().should('be.visible');
cy.getByTestId('workflow-card-actions').first().click();
WorkflowsPage.getters.workflowDeleteButton().click();
cy.get('button').contains('delete').click();
successToast().should('be.visible');
WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 2);
});
});

View File

@@ -257,23 +257,103 @@ describe('Workflow Actions', () => {
}).as('loadWorkflows');
});
it('should not be able to delete unsaved workflow', () => {
it('should not be able to archive or delete unsaved workflow', () => {
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemDelete().closest('li').should('have.class', 'is-disabled');
WorkflowPage.getters.workflowMenuItemDelete().should('not.exist');
WorkflowPage.getters
.workflowMenuItemArchive()
.closest('li')
.should('have.class', 'is-disabled');
});
it('should delete workflow', () => {
it('should archive workflow and then delete it', () => {
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.archivedTag().should('not.exist');
// Archive the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemArchive().click();
WorkflowPage.actions.acceptConfirmModal();
successToast().should('exist');
cy.url().should('include', WorkflowPages.url);
// Return back to the workflow
cy.go('back');
WorkflowPage.getters.archivedTag().should('be.visible');
WorkflowPage.getters.nodeCreatorPlusButton().should('not.exist');
// Delete the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemDelete().click();
cy.get('div[role=dialog][aria-modal=true]').should('be.visible');
cy.get('button.btn--confirm').should('be.visible').click();
WorkflowPage.actions.acceptConfirmModal();
successToast().should('exist');
cy.url().should('include', WorkflowPages.url);
});
it('should archive workflow and then unarchive it', () => {
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.getters.archivedTag().should('not.exist');
// Archive the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemArchive().click();
WorkflowPage.actions.acceptConfirmModal();
successToast().should('exist');
cy.url().should('include', WorkflowPages.url);
// Return back to the workflow
cy.go('back');
WorkflowPage.getters.archivedTag().should('be.visible');
WorkflowPage.getters.nodeCreatorPlusButton().should('not.exist');
// Unarchive the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemUnarchive().click();
successToast().should('exist');
WorkflowPage.getters.archivedTag().should('not.exist');
WorkflowPage.getters.nodeCreatorPlusButton().should('be.visible');
});
it('should deactivate active workflow on archive', () => {
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
WorkflowPage.actions.saveWorkflowOnButtonClick();
WorkflowPage.actions.activateWorkflow();
WorkflowPage.getters.isWorkflowActivated();
// Archive the workflow
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemArchive().click();
WorkflowPage.actions.acceptConfirmModal();
successToast().should('exist');
cy.url().should('include', WorkflowPages.url);
// Return back to the workflow
cy.go('back');
WorkflowPage.getters.archivedTag().should('be.visible');
WorkflowPage.getters.isWorkflowDeactivated();
WorkflowPage.getters.activatorSwitch().find('input').first().should('be.disabled');
// Unarchive the workflow
WorkflowPage.getters.workflowMenu().should('be.visible');
WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemUnarchive().click();
successToast().should('exist');
WorkflowPage.getters.archivedTag().should('not.exist');
// Activate the workflow again
WorkflowPage.actions.activateWorkflow();
WorkflowPage.getters.isWorkflowActivated();
});
describe('duplicate workflow', () => {
function duplicateWorkflow() {
WorkflowPage.getters.workflowMenu().should('be.visible');

View File

@@ -84,6 +84,8 @@ export class WorkflowPage extends BasePage {
firstStepButton: () => cy.getByTestId('canvas-add-button'),
isWorkflowSaved: () => this.getters.saveButton().should('match', 'span'), // In Element UI, disabled button turn into spans 🤷‍♂️
isWorkflowActivated: () => this.getters.activatorSwitch().should('have.class', 'is-checked'),
isWorkflowDeactivated: () =>
this.getters.activatorSwitch().should('not.have.class', 'is-checked'),
expressionModalInput: () => cy.getByTestId('expression-modal-input').find('[role=textbox]'),
expressionModalOutput: () => cy.getByTestId('expression-modal-output'),
@@ -117,6 +119,8 @@ export class WorkflowPage extends BasePage {
workflowMenuItemImportFromFile: () => cy.getByTestId('workflow-menu-item-import-from-file'),
workflowMenuItemSettings: () => cy.getByTestId('workflow-menu-item-settings'),
workflowMenuItemDelete: () => cy.getByTestId('workflow-menu-item-delete'),
workflowMenuItemArchive: () => cy.getByTestId('workflow-menu-item-archive'),
workflowMenuItemUnarchive: () => cy.getByTestId('workflow-menu-item-unarchive'),
workflowMenuItemGitPush: () => cy.getByTestId('workflow-menu-item-push'),
// Workflow settings dialog elements
workflowSettingsModal: () => cy.getByTestId('workflow-settings-dialog'),
@@ -136,6 +140,7 @@ export class WorkflowPage extends BasePage {
workflowSettingsSaveButton: () =>
cy.getByTestId('workflow-settings-save-button').find('button'),
archivedTag: () => cy.getByTestId('workflow-archived-tag'),
shareButton: () => cy.getByTestId('workflow-share-button'),
duplicateWorkflowModal: () => cy.getByTestId('duplicate-modal'),
@@ -214,6 +219,7 @@ export class WorkflowPage extends BasePage {
}
return parseFloat(element.css('top'));
},
confirmModal: () => cy.get('div[role=dialog][aria-modal=true]'),
};
actions = {
@@ -551,5 +557,9 @@ export class WorkflowPage extends BasePage {
top: +$el[0].style.top.replace('px', ''),
}));
},
acceptConfirmModal: () => {
this.getters.confirmModal().should('be.visible');
cy.get('button.btn--confirm').should('be.visible').click();
},
};
}

View File

@@ -36,6 +36,10 @@ export class WorkflowsPage extends BasePage {
workflowCardActions: (workflowName: string) =>
this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-actions'),
workflowActionItem: (action: string) => cy.getByTestId(`action-${action}`).filter(':visible'),
workflowArchiveButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Archive'),
workflowUnarchiveButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Unarchive'),
workflowDeleteButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'),
workflowMoveButton: () =>
@@ -47,6 +51,7 @@ export class WorkflowsPage extends BasePage {
workflowStatusItem: (status: string) => cy.getByTestId('status').contains(status),
workflowOwnershipDropdown: () => cy.getByTestId('user-select-trigger'),
workflowOwner: (email: string) => cy.getByTestId('user-email').contains(email),
workflowArchivedCheckbox: () => cy.getByTestId('show-archived-checkbox'),
workflowResetFilters: () => cy.getByTestId('workflows-filter-reset'),
workflowSortDropdown: () => cy.getByTestId('resources-list-sort'),
workflowSortItem: (sort: string) =>

View File

@@ -34,6 +34,16 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl
@Column()
active: boolean;
/**
* Indicates whether the workflow has been soft-deleted (`true`) or not (`false`).
*
* Archived workflows can be restored (unarchived) or deleted permanently,
* and they can still be executed as sub workflow executions, but they
* cannot be activated or modified.
*/
@Column({ default: false })
isArchived: boolean;
@JsonColumn()
nodes: INode[];

View File

@@ -53,6 +53,7 @@ describe('ActiveExecutions', () => {
id: '123',
name: 'Test workflow 1',
active: false,
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
nodes: [],

View File

@@ -108,7 +108,7 @@ export class Reset extends BaseCommand {
const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials);
for (const { workflowId } of ownedSharedWorkflows) {
await Container.get(WorkflowService).delete(owner, workflowId);
await Container.get(WorkflowService).delete(owner, workflowId, true);
}
for (const credential of ownedCredentials) {

View File

@@ -238,7 +238,7 @@ export class UsersController {
const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials);
for (const { workflowId } of ownedSharedWorkflows) {
await this.workflowService.delete(userToDelete, workflowId);
await this.workflowService.delete(userToDelete, workflowId, true);
}
for (const credential of ownedCredentials) {

View File

@@ -0,0 +1,22 @@
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
const columnName = 'isArchived';
const tableName = 'workflow_entity';
export class AddWorkflowArchivedColumn1745934666076 implements ReversibleMigration {
async up({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(
`ALTER TABLE ${escapedTableName} ADD COLUMN ${escapedColumnName} BOOLEAN NOT NULL DEFAULT FALSE`,
);
}
async down({ escape, runQuery }: MigrationContext) {
const escapedTableName = escape.tableName(tableName);
const escapedColumnName = escape.columnName(columnName);
await runQuery(`ALTER TABLE ${escapedTableName} DROP COLUMN ${escapedColumnName}`);
}
}

View File

@@ -85,6 +85,7 @@ import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-Crea
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
export const mysqlMigrations: Migration[] = [
@@ -174,4 +175,5 @@ export const mysqlMigrations: Migration[] = [
RenameAnalyticsToInsights1741167584277,
AddScopesColumnToApiKeys1742918400000,
AddWorkflowStatisticsRootCount1745587087521,
AddWorkflowArchivedColumn1745934666076,
];

View File

@@ -85,6 +85,7 @@ import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-Crea
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
export const postgresMigrations: Migration[] = [
InitialMigration1587669153312,
@@ -172,4 +173,5 @@ export const postgresMigrations: Migration[] = [
RenameAnalyticsToInsights1741167584277,
AddScopesColumnToApiKeys1742918400000,
AddWorkflowStatisticsRootCount1745587087521,
AddWorkflowArchivedColumn1745934666076,
];

View File

@@ -82,6 +82,7 @@ import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
const sqliteMigrations: Migration[] = [
InitialMigration1588102412422,
@@ -166,6 +167,7 @@ const sqliteMigrations: Migration[] = [
RenameAnalyticsToInsights1741167584277,
AddScopesColumnToApiKeys1742918400000,
AddWorkflowStatisticsRootCount1745587087521,
AddWorkflowArchivedColumn1745934666076,
];
export { sqliteMigrations };

View File

@@ -394,6 +394,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
): void {
this.applyNameFilter(qb, filter);
this.applyActiveFilter(qb, filter);
this.applyIsArchivedFilter(qb, filter);
this.applyTagsFilter(qb, filter);
this.applyProjectFilter(qb, filter);
this.applyParentFolderFilter(qb, filter);
@@ -440,6 +441,15 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
}
}
private applyIsArchivedFilter(
qb: SelectQueryBuilder<WorkflowEntity>,
filter: ListQuery.Options['filter'],
): void {
if (typeof filter?.isArchived === 'boolean') {
qb.andWhere('workflow.isArchived = :isArchived', { isArchived: filter.isArchived });
}
}
private applyTagsFilter(
qb: SelectQueryBuilder<WorkflowEntity>,
filter: ListQuery.Options['filter'],
@@ -508,6 +518,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
'workflow.id',
'workflow.name',
'workflow.active',
'workflow.isArchived',
'workflow.createdAt',
'workflow.updatedAt',
'workflow.versionId',

View File

@@ -106,6 +106,7 @@ export class SourceControlExportService {
versionId: e.versionId,
owner: owners[e.id],
parentFolderId: e.parentFolder?.id ?? null,
isArchived: e.isArchived,
};
this.logger.debug(`Writing workflow ${e.id} to ${fileName}`);
return await fsWriteFile(fileName, JSON.stringify(sanitizedWorkflow, null, 2));

View File

@@ -310,7 +310,14 @@ export class SourceControlImportService {
continue;
}
const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id);
importedWorkflow.active = existingWorkflow?.active ?? false;
// Workflow's active status is not saved in the remote workflow files, and the field is missing despite
// IWorkflowToImport having it typed as boolean. Imported workflows are always inactive if they are new,
// and existing workflows use the existing workflow's active status unless they have been archived on the remote.
// In that case, we deactivate the existing workflow on pull and turn it archived.
importedWorkflow.active = existingWorkflow
? existingWorkflow.active && !importedWorkflow.isArchived
: false;
const parentFolderId = importedWorkflow.parentFolderId ?? '';
@@ -353,14 +360,17 @@ export class SourceControlImportService {
// remove active pre-import workflow
this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`);
await workflowManager.remove(existingWorkflow.id);
// try activating the imported workflow
this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`);
await workflowManager.add(existingWorkflow.id, 'activate');
// update the versionId of the workflow to match the imported workflow
if (importedWorkflow.active) {
// try activating the imported workflow
this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`);
await workflowManager.add(existingWorkflow.id, 'activate');
}
} catch (e) {
const error = ensureError(e);
this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, { error });
} finally {
// update the versionId of the workflow to match the imported workflow
await this.workflowRepository.update(
{ id: existingWorkflow.id },
{ versionId: importedWorkflow.versionId },
@@ -639,7 +649,7 @@ export class SourceControlImportService {
async deleteWorkflowsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
for (const candidate of candidates) {
await this.workflowService.delete(user, candidate.id);
await this.workflowService.delete(user, candidate.id, true);
}
}

View File

@@ -12,4 +12,5 @@ export interface ExportableWorkflow {
versionId?: string;
owner: ResourceOwner;
parentFolderId: string | null;
isArchived: boolean;
}

View File

@@ -59,6 +59,8 @@ export const eventNamesAudit = [
'n8n.audit.workflow.created',
'n8n.audit.workflow.deleted',
'n8n.audit.workflow.updated',
'n8n.audit.workflow.archived',
'n8n.audit.workflow.unarchived',
] as const;
export type EventNamesWorkflowType = (typeof eventNamesWorkflow)[number];

View File

@@ -51,6 +51,62 @@ describe('LogStreamingEventRelay', () => {
});
});
it('should log on `workflow-archived` event', () => {
const event: RelayEventMap['workflow-archived'] = {
user: {
id: '456',
email: 'jane@n8n.io',
firstName: 'Jane',
lastName: 'Smith',
role: 'user',
},
workflowId: 'wf789',
publicApi: false,
};
eventService.emit('workflow-archived', event);
expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({
eventName: 'n8n.audit.workflow.archived',
payload: {
userId: '456',
_email: 'jane@n8n.io',
_firstName: 'Jane',
_lastName: 'Smith',
globalRole: 'user',
workflowId: 'wf789',
},
});
});
it('should log on `workflow-unarchived` event', () => {
const event: RelayEventMap['workflow-unarchived'] = {
user: {
id: '456',
email: 'jane@n8n.io',
firstName: 'Jane',
lastName: 'Smith',
role: 'user',
},
workflowId: 'wf789',
publicApi: false,
};
eventService.emit('workflow-unarchived', event);
expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({
eventName: 'n8n.audit.workflow.unarchived',
payload: {
userId: '456',
_email: 'jane@n8n.io',
_firstName: 'Jane',
_lastName: 'Smith',
globalRole: 'user',
workflowId: 'wf789',
},
});
});
it('should log on `workflow-deleted` event', () => {
const event: RelayEventMap['workflow-deleted'] = {
user: {

View File

@@ -68,6 +68,18 @@ export type RelayEventMap = {
publicApi: boolean;
};
'workflow-archived': {
user: UserLike;
workflowId: string;
publicApi: boolean;
};
'workflow-unarchived': {
user: UserLike;
workflowId: string;
publicApi: boolean;
};
'workflow-saved': {
user: UserLike;
workflow: IWorkflowDb;

View File

@@ -20,6 +20,8 @@ export class LogStreamingEventRelay extends EventRelay {
this.setupListeners({
'workflow-created': (event) => this.workflowCreated(event),
'workflow-deleted': (event) => this.workflowDeleted(event),
'workflow-archived': (event) => this.workflowArchived(event),
'workflow-unarchived': (event) => this.workflowUnarchived(event),
'workflow-saved': (event) => this.workflowSaved(event),
'workflow-pre-execute': (event) => this.workflowPreExecute(event),
'workflow-post-execute': (event) => this.workflowPostExecute(event),
@@ -86,6 +88,22 @@ export class LogStreamingEventRelay extends EventRelay {
});
}
@Redactable()
private workflowArchived({ user, workflowId }: RelayEventMap['workflow-archived']) {
void this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.workflow.archived',
payload: { ...user, workflowId },
});
}
@Redactable()
private workflowUnarchived({ user, workflowId }: RelayEventMap['workflow-unarchived']) {
void this.eventBus.sendAuditEvent({
eventName: 'n8n.audit.workflow.unarchived',
payload: { ...user, workflowId },
});
}
@Redactable()
private workflowSaved({ user, workflow }: RelayEventMap['workflow-saved']) {
void this.eventBus.sendAuditEvent({

View File

@@ -61,6 +61,7 @@ describe('Execution Lifecycle Hooks', () => {
id: workflowId,
name: 'Test Workflow',
active: true,
isArchived: false,
connections: {},
nodes: [],
settings: {},

View File

@@ -38,6 +38,7 @@ export function prepareExecutionDataForDbUpdate(parameters: {
'id',
'name',
'active',
'isArchived',
'createdAt',
'updatedAt',
'nodes',

View File

@@ -68,6 +68,8 @@ type ExternalHooksMap = {
'workflow.afterUpdate': [updatedWorkflow: IWorkflowBase];
'workflow.delete': [workflowId: string];
'workflow.afterDelete': [workflowId: string];
'workflow.afterArchive': [workflowId: string];
'workflow.afterUnarchive': [workflowId: string];
'workflow.preExecute': [workflow: Workflow, mode: WorkflowExecuteMode];
'workflow.postExecute': [

View File

@@ -14,6 +14,11 @@ export class WorkflowFilter extends BaseFilter {
@Expose()
active?: boolean;
@IsBoolean()
@IsOptional()
@Expose()
isArchived?: boolean;
@IsArray()
@IsString({ each: true })
@IsOptional()

View File

@@ -97,7 +97,7 @@ export = {
async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => {
const { id: workflowId } = req.params;
const workflow = await Container.get(WorkflowService).delete(req.user, workflowId);
const workflow = await Container.get(WorkflowService).delete(req.user, workflowId, true);
if (!workflow) {
// user trying to access a workflow they do not own
// or workflow does not exist

View File

@@ -162,6 +162,7 @@ describe('WorkflowStatisticsService', () => {
id: '1',
name: '',
active: false,
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
nodes: [],
@@ -191,6 +192,7 @@ describe('WorkflowStatisticsService', () => {
id: '1',
name: '',
active: false,
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
nodes: [],
@@ -213,6 +215,7 @@ describe('WorkflowStatisticsService', () => {
id: '1',
name: '',
active: false,
isArchived: false,
createdAt: new Date(),
updatedAt: new Date(),
nodes: [],

View File

@@ -114,7 +114,7 @@ export class ProjectService {
);
} else {
for (const sharedWorkflow of ownedSharedWorkflows) {
await workflowService.delete(user, sharedWorkflow.workflowId);
await workflowService.delete(user, sharedWorkflow.workflowId, true);
}
}

View File

@@ -234,6 +234,7 @@ describe('WorkflowExecutionService', () => {
id: 'abc',
name: 'test',
active: false,
isArchived: false,
pinData: {
[pinnedTrigger.name]: [{ json: {} }],
},
@@ -301,6 +302,7 @@ describe('WorkflowExecutionService', () => {
id: 'abc',
name: 'test',
active: false,
isArchived: false,
pinData: {
[pinnedTrigger.name]: [{ json: {} }],
},

View File

@@ -54,8 +54,6 @@ export declare namespace WorkflowRequest {
listQueryOptions: ListQuery.Options;
};
type Delete = Get;
type Update = AuthenticatedRequest<
{ workflowId: string },
{},

View File

@@ -382,7 +382,7 @@ export class WorkflowService {
* If the user does not have the permissions to delete the workflow this does
* nothing and returns void.
*/
async delete(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
async delete(user: User, workflowId: string, force = false): Promise<WorkflowEntity | undefined> {
await this.externalHooks.run('workflow.delete', [workflowId]);
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
@@ -393,6 +393,10 @@ export class WorkflowService {
return;
}
if (!workflow.isArchived && !force) {
throw new BadRequestError('Workflow must be archived before it can be deleted.');
}
if (workflow.active) {
// deactivate before deleting
await this.activeWorkflowManager.remove(workflowId);
@@ -414,6 +418,65 @@ export class WorkflowService {
return workflow;
}
async archive(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:delete',
]);
if (!workflow) {
return;
}
if (workflow.isArchived) {
throw new BadRequestError('Workflow is already archived.');
}
if (workflow.active) {
await this.activeWorkflowManager.remove(workflowId);
}
const versionId = uuid();
await this.workflowRepository.update(workflowId, {
isArchived: true,
active: false,
versionId,
});
this.eventService.emit('workflow-archived', { user, workflowId, publicApi: false });
await this.externalHooks.run('workflow.afterArchive', [workflowId]);
workflow.isArchived = true;
workflow.active = false;
workflow.versionId = versionId;
return workflow;
}
async unarchive(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
'workflow:delete',
]);
if (!workflow) {
return;
}
if (!workflow.isArchived) {
throw new BadRequestError('Workflow is not archived.');
}
const versionId = uuid();
await this.workflowRepository.update(workflowId, { isArchived: false, versionId });
this.eventService.emit('workflow-unarchived', { user, workflowId, publicApi: false });
await this.externalHooks.run('workflow.afterUnarchive', [workflowId]);
workflow.isArchived = false;
workflow.versionId = versionId;
return workflow;
}
async getWorkflowScopes(user: User, workflowId: string): Promise<Scope[]> {
const userProjectRelations = await this.projectService.getProjectRelationsForUser(user);
const shared = await this.sharedWorkflowRepository.find({

View File

@@ -382,23 +382,63 @@ export class WorkflowsController {
@Delete('/:workflowId')
@ProjectScope('workflow:delete')
async delete(req: WorkflowRequest.Delete) {
const { workflowId } = req.params;
async delete(req: AuthenticatedRequest, _res: Response, @Param('workflowId') workflowId: string) {
const workflow = await this.workflowService.delete(req.user, workflowId);
if (!workflow) {
this.logger.warn('User attempted to delete a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new BadRequestError(
'Could not delete the workflow - you can only remove workflows owned by you',
throw new ForbiddenError(
'Could not delete the workflow - workflow was not found in your projects',
);
}
return true;
}
@Post('/:workflowId/archive')
@ProjectScope('workflow:delete')
async archive(
req: AuthenticatedRequest,
_res: Response,
@Param('workflowId') workflowId: string,
) {
const workflow = await this.workflowService.archive(req.user, workflowId);
if (!workflow) {
this.logger.warn('User attempted to archive a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new ForbiddenError(
'Could not archive the workflow - workflow was not found in your projects',
);
}
return workflow;
}
@Post('/:workflowId/unarchive')
@ProjectScope('workflow:delete')
async unarchive(
req: AuthenticatedRequest,
_res: Response,
@Param('workflowId') workflowId: string,
) {
const workflow = await this.workflowService.unarchive(req.user, workflowId);
if (!workflow) {
this.logger.warn('User attempted to unarchive a workflow without permissions', {
workflowId,
userId: req.user.id,
});
throw new ForbiddenError(
'Could not unarchive the workflow - workflow was not found in your projects',
);
}
return workflow;
}
@Post('/:workflowId/run')
@ProjectScope('workflow:execute')
async runManually(

View File

@@ -25,10 +25,11 @@ export async function createManyWorkflows(
}
export function newWorkflow(attributes: Partial<IWorkflowDb> = {}): IWorkflowDb {
const { active, name, nodes, connections, versionId, settings } = attributes;
const { active, isArchived, name, nodes, connections, versionId, settings } = attributes;
const workflowEntity = Container.get(WorkflowRepository).create({
active: active ?? false,
isArchived: isArchived ?? false,
name: name ?? 'test workflow',
nodes: nodes ?? [
{

View File

@@ -2328,9 +2328,197 @@ describe('POST /workflows/:workflowId/run', () => {
});
});
describe('DELETE /workflows/:workflowId', () => {
test('deletes a workflow owned by the user', async () => {
describe('POST /workflows/:workflowId/archive', () => {
test('should archive workflow', async () => {
const workflow = await createWorkflow({}, owner);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/archive`)
.send()
.expect(200);
const {
data: { isArchived, versionId },
} = response.body;
expect(isArchived).toBe(true);
expect(versionId).not.toBe(workflow.versionId);
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
expect(updatedWorkflow).not.toBeNull();
expect(updatedWorkflow!.isArchived).toBe(true);
});
test('should deactivate active workflow on archive', async () => {
const workflow = await createWorkflow({ active: true }, owner);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/archive`)
.send()
.expect(200);
const {
data: { isArchived, versionId, active },
} = response.body;
expect(isArchived).toBe(true);
expect(active).toBe(false);
expect(versionId).not.toBe(workflow.versionId);
expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id);
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
expect(updatedWorkflow).not.toBeNull();
expect(updatedWorkflow!.isArchived).toBe(true);
});
test('should not archive workflow that is already archived', async () => {
const workflow = await createWorkflow({ isArchived: true }, owner);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/archive`)
.send()
.expect(400);
expect(response.body.message).toBe('Workflow is already archived.');
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
expect(updatedWorkflow).not.toBeNull();
expect(updatedWorkflow!.isArchived).toBe(true);
});
test('should not archive missing workflow', async () => {
const response = await authOwnerAgent.post('/workflows/404/archive').send().expect(403);
expect(response.body.message).toBe(
'Could not archive the workflow - workflow was not found in your projects',
);
});
test('should not archive a workflow that is not owned by the user', async () => {
const workflow = await createWorkflow({ isArchived: false }, member);
await testServer
.authAgentFor(anotherMember)
.post(`/workflows/${workflow.id}/archive`)
.send()
.expect(403);
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(workflowsInDb).not.toBeNull();
expect(workflowsInDb!.isArchived).toBe(false);
expect(sharedWorkflowsInDb).toHaveLength(1);
});
test("should allow the owner to archive workflows they don't own", async () => {
const workflow = await createWorkflow({ isArchived: false }, member);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/archive`)
.send()
.expect(200);
const {
data: { isArchived, versionId },
} = response.body;
expect(isArchived).toBe(true);
expect(versionId).not.toBe(workflow.versionId);
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(workflowsInDb).not.toBeNull();
expect(workflowsInDb!.isArchived).toBe(true);
expect(sharedWorkflowsInDb).toHaveLength(1);
});
});
describe('POST /workflows/:workflowId/unarchive', () => {
test('should unarchive workflow', async () => {
const workflow = await createWorkflow({ isArchived: true }, owner);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/unarchive`)
.send()
.expect(200);
const {
data: { isArchived, versionId },
} = response.body;
expect(isArchived).toBe(false);
expect(versionId).not.toBe(workflow.versionId);
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
expect(updatedWorkflow).not.toBeNull();
expect(updatedWorkflow!.isArchived).toBe(false);
});
test('should not unarchive workflow that is already not archived', async () => {
const workflow = await createWorkflow({ isArchived: false }, owner);
await authOwnerAgent.post(`/workflows/${workflow.id}/unarchive`).send().expect(400);
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
expect(updatedWorkflow).not.toBeNull();
expect(updatedWorkflow!.isArchived).toBe(false);
});
test('should not unarchive missing workflow', async () => {
const response = await authOwnerAgent.post('/workflows/404/unarchive').send().expect(403);
expect(response.body.message).toBe(
'Could not unarchive the workflow - workflow was not found in your projects',
);
});
test('should not unarchive a workflow that is not owned by the user', async () => {
const workflow = await createWorkflow({ isArchived: true }, member);
await testServer
.authAgentFor(anotherMember)
.post(`/workflows/${workflow.id}/unarchive`)
.send()
.expect(403);
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(workflowsInDb).not.toBeNull();
expect(workflowsInDb!.isArchived).toBe(true);
expect(sharedWorkflowsInDb).toHaveLength(1);
});
test("should allow the owner to unarchive workflows they don't own", async () => {
const workflow = await createWorkflow({ isArchived: true }, member);
const response = await authOwnerAgent
.post(`/workflows/${workflow.id}/unarchive`)
.send()
.expect(200);
const {
data: { isArchived, versionId },
} = response.body;
expect(isArchived).toBe(false);
expect(versionId).not.toBe(workflow.versionId);
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(workflowsInDb).not.toBeNull();
expect(workflowsInDb!.isArchived).toBe(false);
expect(sharedWorkflowsInDb).toHaveLength(1);
});
});
describe('DELETE /workflows/:workflowId', () => {
test('deletes an archived workflow owned by the user', async () => {
const workflow = await createWorkflow({ isArchived: true }, owner);
await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200);
@@ -2343,8 +2531,15 @@ describe('DELETE /workflows/:workflowId', () => {
expect(sharedWorkflowsInDb).toHaveLength(0);
});
test('deletes a workflow owned by the user, even if the user is just a member', async () => {
const workflow = await createWorkflow({}, member);
test('should not delete missing workflow', async () => {
const response = await authOwnerAgent.delete('/workflows/404').send().expect(403);
expect(response.body.message).toBe(
'Could not delete the workflow - workflow was not found in your projects',
);
});
test('deletes an archived workflow owned by the user, even if the user is just a member', async () => {
const workflow = await createWorkflow({ isArchived: true }, member);
await testServer.authAgentFor(member).delete(`/workflows/${workflow.id}`).send().expect(200);
@@ -2357,8 +2552,23 @@ describe('DELETE /workflows/:workflowId', () => {
expect(sharedWorkflowsInDb).toHaveLength(0);
});
test('does not delete a workflow that is not owned by the user', async () => {
const workflow = await createWorkflow({}, member);
test('does not delete a workflow that is not archived', async () => {
const workflow = await createWorkflow({}, owner);
const response = await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(400);
expect(response.body.message).toBe('Workflow must be archived before it can be deleted.');
const workflowInDb = await Container.get(WorkflowRepository).findById(workflow.id);
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
workflowId: workflow.id,
});
expect(workflowInDb).not.toBeNull();
expect(sharedWorkflowsInDb).toHaveLength(1);
});
test('does not delete an archived workflow that is not owned by the user', async () => {
const workflow = await createWorkflow({ isArchived: true }, member);
await testServer
.authAgentFor(anotherMember)
@@ -2375,8 +2585,8 @@ describe('DELETE /workflows/:workflowId', () => {
expect(sharedWorkflowsInDb).toHaveLength(1);
});
test("allows the owner to delete workflows they don't own", async () => {
const workflow = await createWorkflow({}, member);
test("allows the owner to delete archived workflows they don't own", async () => {
const workflow = await createWorkflow({ isArchived: true }, member);
await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200);

View File

@@ -315,6 +315,7 @@ export interface IWorkflowDb {
id: string;
name: string;
active: boolean;
isArchived: boolean;
createdAt: number | string;
updatedAt: number | string;
nodes: INodeUi[];

View File

@@ -177,6 +177,7 @@ export function createTestWorkflow({
nodes = [],
connections = {},
active = false,
isArchived = false,
settings = {
timezone: 'DEFAULT',
executionOrder: 'v1',
@@ -192,6 +193,7 @@ export function createTestWorkflow({
nodes,
connections,
active,
isArchived,
settings,
versionId: '1',
meta: {},

View File

@@ -12,6 +12,9 @@ export const workflowFactory = Factory.extend<IWorkflowDb>({
active() {
return faker.datatype.boolean();
},
isArchived() {
return faker.datatype.boolean();
},
nodes() {
return [];
},

View File

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

View File

@@ -70,6 +70,7 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
id: '',
name: '',
active: false,
isArchived: false,
createdAt: '',
updatedAt: '',
nodes,

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;

View File

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

View File

@@ -31,6 +31,7 @@ const EMPTY_WORKFLOW = {
versionId: '1',
name: 'Email Summary Agent ',
active: false,
isArchived: false,
connections: {},
nodes: [],
usedCredentials: [],

View File

@@ -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');
});
});

View File

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

View File

@@ -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();
});
});

View File

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

View File

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

View File

@@ -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[];

View File

@@ -398,6 +398,7 @@ const testWorkflow: IWorkflowDb = {
id: 'MokOcBHON6KkPq6Y',
name: 'My Sub-Workflow 3',
active: false,
isArchived: false,
createdAt: -1,
updatedAt: -1,
connections: {

View File

@@ -57,7 +57,8 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
() =>
sourceControlStore.preferences.branchReadOnly ||
uiStore.isReadOnlyView ||
!workflowPermissions.value.update,
!workflowPermissions.value.update ||
workflowsStore.workflow.isArchived,
);
const targetNodeIds = computed(() => {

View File

@@ -1161,6 +1161,7 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
function initState(workflowData: IWorkflowDb) {
workflowsStore.addWorkflow(workflowData);
workflowsStore.setActive(workflowData.active || false);
workflowsStore.setIsArchived(workflowData.isArchived);
workflowsStore.setWorkflowId(workflowData.id);
workflowsStore.setWorkflowName({
newName: workflowData.name,

View File

@@ -32,9 +32,8 @@ export function useWorkflowSaving({ router }: { router: ReturnType<typeof useRou
cancel?: () => Promise<void>;
} = {},
) {
if (!uiStore.stateIsDirty) {
if (!uiStore.stateIsDirty || workflowsStore.workflow.isArchived) {
next();
return;
}

View File

@@ -614,6 +614,8 @@ export const enum WORKFLOW_MENU_ACTIONS {
PUSH = 'push',
SETTINGS = 'settings',
DELETE = 'delete',
ARCHIVE = 'archive',
UNARCHIVE = 'unarchive',
SWITCH_NODE_VIEW_VERSION = 'switch-node-view-version',
RENAME = 'rename',
}

View File

@@ -38,6 +38,8 @@
"generic.create": "Create",
"generic.create.workflow": "Create Workflow",
"generic.deleteWorkflowError": "Problem deleting workflow",
"generic.archiveWorkflowError": "Problem archiving workflow",
"generic.unarchiveWorkflowError": "Problem unarchiving workflow",
"generic.filtersApplied": "Filters are currently applied.",
"generic.field": "field",
"generic.fields": "fields",
@@ -1016,6 +1018,10 @@
"logs.details.body.multipleInputs": "Multiple inputs. View them by {button}",
"logs.details.body.multipleInputs.openingTheNode": "opening the node",
"mainSidebar.aboutN8n": "About n8n",
"mainSidebar.confirmMessage.workflowArchive.cancelButtonText": "",
"mainSidebar.confirmMessage.workflowArchive.confirmButtonText": "Yes, archive",
"mainSidebar.confirmMessage.workflowArchive.headline": "Archive Workflow?",
"mainSidebar.confirmMessage.workflowArchive.message": "Are you sure that you want to archive '{workflowName}'?",
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",
"mainSidebar.confirmMessage.workflowDelete.headline": "Delete Workflow?",
@@ -1040,9 +1046,11 @@
"mainSidebar.showError.stopExecution.title": "Problem stopping execution",
"mainSidebar.showMessage.handleFileImport.message": "The file does not contain valid JSON data",
"mainSidebar.showMessage.handleFileImport.title": "Could not import file",
"mainSidebar.showMessage.handleSelect1.title": "Workflow deleted",
"mainSidebar.showMessage.handleSelect1.title": "Workflow '{workflowName}' deleted",
"mainSidebar.showMessage.handleSelect2.title": "Workflow created",
"mainSidebar.showMessage.handleSelect3.title": "Workflow created",
"mainSidebar.showMessage.handleArchive.title": "Workflow '{workflowName}' archived",
"mainSidebar.showMessage.handleUnarchive.title": "Workflow '{workflowName}' unarchived",
"mainSidebar.showMessage.stopExecution.title": "Execution stopped",
"mainSidebar.templates": "Templates",
"mainSidebar.workflows": "Workflows",
@@ -1056,6 +1064,8 @@
"menuActions.importFromUrl": "Import from URL...",
"menuActions.importFromFile": "Import from File...",
"menuActions.delete": "Delete",
"menuActions.archive": "Archive",
"menuActions.unarchive": "Unarchive",
"multipleParameter.addItem": "Add item",
"multipleParameter.currentlyNoItemsExist": "Currently no items exist",
"multipleParameter.deleteItem": "Delete item",
@@ -2334,6 +2344,7 @@
"workflowActivator.showMessage.displayActivationError.title": "Problem activating workflow",
"workflowActivator.theWorkflowIsSetToBeActiveBut": "The workflow is activated but could not be started.<br />Click to display error message.",
"workflowActivator.thisWorkflowHasNoTriggerNodes": "This workflow has no trigger nodes that require activation",
"workflowActivator.thisWorkflowIsArchived": "This workflow is archived so it cannot be activated",
"workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode": "'Execute Workflow Trigger' doesn't require activation as it is triggered by another workflow",
"workflowDetails.share": "Share",
"workflowDetails.active": "Active",
@@ -2466,11 +2477,14 @@
"workflows.item.share": "Share...",
"workflows.item.duplicate": "Duplicate",
"workflows.item.delete": "Delete",
"workflows.item.archive": "Archive",
"workflows.item.unarchive": "Unarchive",
"workflows.item.move": "Move",
"workflows.item.changeOwner": "Change owner",
"workflows.item.updated": "Last updated",
"workflows.item.created": "Created",
"workflows.item.readonly": "Read only",
"workflows.item.archived": "Archived",
"workflows.search.placeholder": "Search",
"workflows.filters": "Filters",
"workflows.filters.tags": "Tags",
@@ -2478,6 +2492,7 @@
"workflows.filters.status.all": "All",
"workflows.filters.status.active": "Active",
"workflows.filters.status.deactivated": "Deactivated",
"workflows.filters.showArchived": "Show archived workflows",
"workflows.filters.ownedBy": "Owned by",
"workflows.filters.sharedWith": "Shared with",
"workflows.filters.apply": "Apply filters",

View File

@@ -98,7 +98,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
async function fetchTotalWorkflowsAndFoldersCount(projectId?: string): Promise<number> {
const { count } = await workflowsApi.getWorkflowsAndFolders(
rootStore.restApiContext,
{ projectId },
{ projectId, isArchived: false },
{ skip: 0, take: 1 },
true,
);

View File

@@ -853,6 +853,84 @@ describe('useWorkflowsStore', () => {
},
);
});
describe('archiveWorkflow', () => {
it('should call the API to archive the workflow', async () => {
const workflowId = '1';
const versionId = '00000000-0000-0000-0000-000000000000';
const updatedVersionId = '11111111-1111-1111-1111-111111111111';
workflowsStore.workflowsById = {
'1': { active: true, isArchived: false, versionId } as IWorkflowDb,
};
workflowsStore.workflow.active = true;
workflowsStore.workflow.isArchived = false;
workflowsStore.workflow.id = workflowId;
workflowsStore.workflow.versionId = versionId;
const makeRestApiRequestSpy = vi
.spyOn(apiUtils, 'makeRestApiRequest')
.mockImplementation(async () => ({
versionId: updatedVersionId,
}));
await workflowsStore.archiveWorkflow(workflowId);
expect(workflowsStore.workflowsById['1'].active).toBe(false);
expect(workflowsStore.workflowsById['1'].isArchived).toBe(true);
expect(workflowsStore.workflowsById['1'].versionId).toBe(updatedVersionId);
expect(workflowsStore.workflow.active).toBe(false);
expect(workflowsStore.workflow.isArchived).toBe(true);
expect(workflowsStore.workflow.versionId).toBe(updatedVersionId);
expect(makeRestApiRequestSpy).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: '/rest',
pushRef: expect.any(String),
}),
'POST',
`/workflows/${workflowId}/archive`,
);
});
});
describe('unarchiveWorkflow', () => {
it('should call the API to unarchive the workflow', async () => {
const workflowId = '1';
const versionId = '00000000-0000-0000-0000-000000000000';
const updatedVersionId = '11111111-1111-1111-1111-111111111111';
workflowsStore.workflowsById = {
'1': { active: false, isArchived: true, versionId } as IWorkflowDb,
};
workflowsStore.workflow.active = false;
workflowsStore.workflow.isArchived = true;
workflowsStore.workflow.id = workflowId;
workflowsStore.workflow.versionId = versionId;
const makeRestApiRequestSpy = vi
.spyOn(apiUtils, 'makeRestApiRequest')
.mockImplementation(async () => ({
versionId: updatedVersionId,
}));
await workflowsStore.unarchiveWorkflow(workflowId);
expect(workflowsStore.workflowsById['1'].active).toBe(false);
expect(workflowsStore.workflowsById['1'].isArchived).toBe(false);
expect(workflowsStore.workflowsById['1'].versionId).toBe(updatedVersionId);
expect(workflowsStore.workflow.active).toBe(false);
expect(workflowsStore.workflow.isArchived).toBe(false);
expect(workflowsStore.workflow.versionId).toBe(updatedVersionId);
expect(makeRestApiRequestSpy).toHaveBeenCalledWith(
expect.objectContaining({
baseUrl: '/rest',
pushRef: expect.any(String),
}),
'POST',
`/workflows/${workflowId}/unarchive`,
);
});
});
});
function getMockEditFieldsNode() {
@@ -886,6 +964,7 @@ function generateMockExecutionEvents() {
nodes: [],
connections: {},
active: false,
isArchived: false,
versionId: '1',
},
finished: false,

View File

@@ -99,6 +99,7 @@ import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '',
active: false,
isArchived: false,
createdAt: -1,
updatedAt: -1,
connections: {},
@@ -541,7 +542,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
page = 1,
pageSize = DEFAULT_WORKFLOW_PAGE_SIZE,
sortBy?: string,
filters: { name?: string; tags?: string[]; active?: boolean; parentFolderId?: string } = {},
filters: {
name?: string;
tags?: string[];
active?: boolean;
isArchived?: boolean;
parentFolderId?: string;
} = {},
includeFolders: boolean = false,
): Promise<WorkflowListResource[]> {
const filter = { ...filters, projectId };
@@ -734,6 +741,42 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
workflowsById.value = workflows;
}
async function archiveWorkflow(id: string) {
const updatedWorkflow = await makeRestApiRequest<IWorkflowDb>(
rootStore.restApiContext,
'POST',
`/workflows/${id}/archive`,
);
if (workflowsById.value[id]) {
workflowsById.value[id].isArchived = true;
workflowsById.value[id].versionId = updatedWorkflow.versionId;
}
setWorkflowInactive(id);
if (id === workflow.value.id) {
setIsArchived(true);
setWorkflowVersionId(updatedWorkflow.versionId);
}
}
async function unarchiveWorkflow(id: string) {
const updatedWorkflow = await makeRestApiRequest<IWorkflowDb>(
rootStore.restApiContext,
'POST',
`/workflows/${id}/unarchive`,
);
if (workflowsById.value[id]) {
workflowsById.value[id].isArchived = false;
workflowsById.value[id].versionId = updatedWorkflow.versionId;
}
if (id === workflow.value.id) {
setIsArchived(false);
setWorkflowVersionId(updatedWorkflow.versionId);
}
}
function addWorkflow(workflow: IWorkflowDb) {
workflowsById.value = {
...workflowsById.value,
@@ -781,6 +824,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
workflow.value.active = active;
}
function setIsArchived(isArchived: boolean) {
workflow.value.isArchived = isArchived;
}
async function getDuplicateCurrentWorkflowName(currentWorkflowName: string): Promise<string> {
if (
currentWorkflowName &&
@@ -1849,11 +1896,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
replaceInvalidWorkflowCredentials,
setWorkflows,
deleteWorkflow,
archiveWorkflow,
unarchiveWorkflow,
addWorkflow,
setWorkflowActive,
setWorkflowInactive,
fetchActiveWorkflows,
setActive,
setIsArchived,
getDuplicateCurrentWorkflowName,
setWorkflowExecutionData,
setWorkflowExecutionRunData,

View File

@@ -258,7 +258,8 @@ const isCanvasReadOnly = computed(() => {
return (
isDemoRoute.value ||
isReadOnlyEnvironment.value ||
!(workflowPermissions.value.update ?? projectPermissions.value.workflow.update)
!(workflowPermissions.value.update ?? projectPermissions.value.workflow.update) ||
editableWorkflow.value.isArchived
);
});
@@ -759,8 +760,9 @@ function onPinNodes(ids: string[], source: PinDataSource) {
async function onSaveWorkflow() {
const workflowIsSaved = !uiStore.stateIsDirty;
const workflowIsArchived = workflowsStore.workflow.isArchived;
if (workflowIsSaved) {
if (workflowIsSaved || workflowIsArchived) {
return;
}
const saved = await workflowHelpers.saveCurrentWorkflow();

View File

@@ -16,6 +16,7 @@ describe('TestDefinitionRootView', () => {
id: 'different-id',
name: 'Test Workflow',
active: false,
isArchived: false,
createdAt: Date.now(),
updatedAt: Date.now(),
nodes: [],

View File

@@ -156,6 +156,7 @@ describe('WorkflowsView', () => {
expect.any(String),
expect.objectContaining({
tags: [TEST_TAG.name],
isArchived: false,
}),
expect.any(Boolean),
);
@@ -176,6 +177,7 @@ describe('WorkflowsView', () => {
expect.any(String),
expect.objectContaining({
name: 'one',
isArchived: false,
}),
expect.any(Boolean),
);
@@ -196,6 +198,7 @@ describe('WorkflowsView', () => {
expect.any(String),
expect.objectContaining({
active: true,
isArchived: false,
}),
expect.any(Boolean),
);
@@ -216,6 +219,27 @@ describe('WorkflowsView', () => {
expect.any(String),
expect.objectContaining({
active: false,
isArchived: false,
}),
expect.any(Boolean),
);
});
it('should unset isArchived filter based on query parameters', async () => {
await router.replace({ query: { showArchived: 'true' } });
workflowsStore.fetchWorkflowsPage.mockResolvedValue([]);
renderComponent({ pinia });
await waitAllPromises();
expect(workflowsStore.fetchWorkflowsPage).toHaveBeenCalledWith(
expect.any(String),
expect.any(Number),
expect.any(Number),
expect.any(String),
expect.objectContaining({
isArchived: undefined,
}),
expect.any(Boolean),
);
@@ -299,6 +323,7 @@ describe('Folders', () => {
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
active: true,
isArchived: false,
versionId: '1',
homeProject: {
id: '1',

View File

@@ -72,13 +72,14 @@ const FILTERS_DEBOUNCE_TIME = 100;
interface Filters extends BaseFilters {
status: string | boolean;
showArchived: boolean;
tags: string[];
}
const StatusFilter = {
ALL: '',
ACTIVE: 'active',
DEACTIVATED: 'deactivated',
ALL: '',
};
/** Maps sort values from the ResourcesListLayout component to values expected by workflows endpoint */
@@ -121,6 +122,7 @@ const filters = ref<Filters>({
search: '',
homeProject: '',
status: StatusFilter.ALL,
showArchived: false,
tags: [],
});
@@ -281,13 +283,14 @@ const workflowListResources = computed<Resource[]>(() => {
workflowCount: resource.workflowCount,
subFolderCount: resource.subFolderCount,
parentFolder: resource.parentFolder,
} as FolderResource;
} satisfies FolderResource;
} else {
return {
resourceType: 'workflow',
id: resource.id,
name: resource.name,
active: resource.active ?? false,
isArchived: resource.isArchived,
updatedAt: resource.updatedAt.toString(),
createdAt: resource.createdAt.toString(),
homeProject: resource.homeProject,
@@ -296,7 +299,7 @@ const workflowListResources = computed<Resource[]>(() => {
readOnly: !getResourcePermissions(resource.scopes).workflow.update,
tags: resource.tags,
parentFolder: resource.parentFolder,
} as WorkflowResource;
} satisfies WorkflowResource;
}
});
return resources;
@@ -345,6 +348,7 @@ const hasFilters = computed(() => {
return !!(
filters.value.search ||
filters.value.status !== StatusFilter.ALL ||
filters.value.showArchived ||
filters.value.tags.length
);
});
@@ -375,6 +379,7 @@ watch(
async (newVal) => {
currentFolderId.value = newVal as string;
filters.value.search = '';
saveFiltersOnQueryString();
await fetchWorkflows();
},
);
@@ -384,7 +389,7 @@ sourceControlStore.$onAction(({ name, after }) => {
after(async () => await initialize());
});
const onWorkflowDeleted = async () => {
const refreshWorkflows = async () => {
await Promise.all([
fetchWorkflows(),
foldersStore.fetchTotalWorkflowsAndFoldersCount(route.params.projectId as string | undefined),
@@ -483,11 +488,14 @@ const fetchWorkflows = async () => {
const tags = filters.value.tags.length
? filters.value.tags.map((tagId) => tagsStore.tagsById[tagId]?.name)
: [];
const activeFilter =
filters.value.status === StatusFilter.ALL
? undefined
: filters.value.status === StatusFilter.ACTIVE;
const archivedFilter = filters.value.showArchived ? undefined : false;
// Only fetch folders if showFolders is enabled and there are not tags or active filter applied
const fetchFolders = showFolders.value && !tags.length && activeFilter === undefined;
@@ -500,6 +508,7 @@ const fetchWorkflows = async () => {
{
name: filters.value.search || undefined,
active: activeFilter,
isArchived: archivedFilter,
tags: tags.length ? tags : undefined,
parentFolderId:
parentFolder ??
@@ -609,6 +618,12 @@ const saveFiltersOnQueryString = () => {
delete currentQuery.status;
}
if (filters.value.showArchived) {
currentQuery.showArchived = 'true';
} else {
delete currentQuery.showArchived;
}
if (filters.value.tags.length) {
currentQuery.tags = filters.value.tags.join(',');
} else {
@@ -628,7 +643,7 @@ const saveFiltersOnQueryString = () => {
const setFiltersFromQueryString = async () => {
const newQuery: LocationQueryRaw = { ...route.query };
const { tags, status, search, homeProject, sort } = route.query ?? {};
const { tags, status, search, homeProject, sort, showArchived } = route.query ?? {};
// Helper to check if string value is not empty
const isValidString = (value: unknown): value is string =>
@@ -673,8 +688,7 @@ const setFiltersFromQueryString = async () => {
}
// Handle status
const validStatusValues = ['true', 'false'];
if (isValidString(status) && validStatusValues.includes(status)) {
if (isValidString(status)) {
newQuery.status = status;
filters.value.status = status === 'true' ? StatusFilter.ACTIVE : StatusFilter.DEACTIVATED;
} else {
@@ -690,6 +704,14 @@ const setFiltersFromQueryString = async () => {
delete newQuery.sort;
}
if (isValidString(showArchived)) {
newQuery.showArchived = showArchived;
filters.value.showArchived = showArchived === 'true';
} else {
delete newQuery.showArchived;
filters.value.showArchived = false;
}
void router.replace({ query: newQuery });
};
@@ -1531,7 +1553,9 @@ const onNameSubmit = async ({
:show-ownership-badge="showCardsBadge"
data-target="workflow"
@click:tag="onClickTag"
@workflow:deleted="onWorkflowDeleted"
@workflow:deleted="refreshWorkflows"
@workflow:archived="refreshWorkflows"
@workflow:unarchived="refreshWorkflows"
@workflow:moved="fetchWorkflows"
@workflow:duplicated="fetchWorkflows"
@workflow:active-toggle="onWorkflowActiveToggle"
@@ -1623,6 +1647,14 @@ const onNameSubmit = async ({
</N8nOption>
</N8nSelect>
</div>
<div class="mb-s">
<N8nCheckbox
:label="i18n.baseText('workflows.filters.showArchived')"
:model-value="filters.showArchived || false"
data-test-id="show-archived-checkbox"
@update:model-value="setKeyValue('showArchived', $event)"
/>
</div>
</template>
<template #postamble>
<div

View File

@@ -2262,6 +2262,7 @@ export interface IWorkflowBase {
id: string;
name: string;
active: boolean;
isArchived: boolean;
createdAt: Date;
startedAt?: Date;
updatedAt: Date;

View File

@@ -94,6 +94,7 @@ describe('generateNodesGraph', () => {
id: 'NfV4GV9aQTifSLc2',
name: 'My workflow 26',
active: false,
isArchived: false,
nodes: [
{
parameters: {},
@@ -157,6 +158,7 @@ describe('generateNodesGraph', () => {
id: 'NfV4GV9aQTifSLc2',
name: 'My workflow 26',
active: false,
isArchived: false,
nodes: [],
connections: {},
settings: { executionOrder: 'v1' },
@@ -198,6 +200,7 @@ describe('generateNodesGraph', () => {
id: 'NfV4GV9aQTifSLc2',
name: 'My workflow 26',
active: false,
isArchived: false,
nodes: [
{
parameters: {},
@@ -263,6 +266,7 @@ describe('generateNodesGraph', () => {
id: 'NfV4GV9aQTifSLc2',
name: 'My workflow 26',
active: false,
isArchived: false,
nodes: [
{
parameters: {},
@@ -339,6 +343,7 @@ describe('generateNodesGraph', () => {
id: 'NfV4GV9aQTifSLc2',
name: 'My workflow 26',
active: false,
isArchived: false,
nodes: [
{
parameters: {},