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'); 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); WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 1);
for (let i = 0; i < multipleWorkflowsCount + 1; i++) { for (let i = 0; i < multipleWorkflowsCount + 1; i++) {
cy.getByTestId('workflow-card-actions').first().click(); cy.getByTestId('workflow-card-actions').first().click();
WorkflowsPage.getters.workflowDeleteButton().click(); WorkflowsPage.getters.workflowArchiveButton().click();
cy.get('button').contains('delete').click(); cy.get('button').contains('archive').click();
successToast().should('be.visible'); successToast().should('be.visible');
} }
@@ -141,4 +141,40 @@ describe('Workflows', () => {
WorkflowsPage.getters.workflowActionItem('share').click(); WorkflowsPage.getters.workflowActionItem('share').click();
workflowSharingModal.getters.modal().should('be.visible'); 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'); }).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().should('be.visible');
WorkflowPage.getters.workflowMenu().click(); 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.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().should('be.visible');
WorkflowPage.getters.workflowMenu().click(); WorkflowPage.getters.workflowMenu().click();
WorkflowPage.getters.workflowMenuItemDelete().click(); WorkflowPage.getters.workflowMenuItemDelete().click();
cy.get('div[role=dialog][aria-modal=true]').should('be.visible'); WorkflowPage.actions.acceptConfirmModal();
cy.get('button.btn--confirm').should('be.visible').click();
successToast().should('exist'); successToast().should('exist');
cy.url().should('include', WorkflowPages.url); 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', () => { describe('duplicate workflow', () => {
function duplicateWorkflow() { function duplicateWorkflow() {
WorkflowPage.getters.workflowMenu().should('be.visible'); WorkflowPage.getters.workflowMenu().should('be.visible');

View File

@@ -84,6 +84,8 @@ export class WorkflowPage extends BasePage {
firstStepButton: () => cy.getByTestId('canvas-add-button'), firstStepButton: () => cy.getByTestId('canvas-add-button'),
isWorkflowSaved: () => this.getters.saveButton().should('match', 'span'), // In Element UI, disabled button turn into spans 🤷‍♂️ isWorkflowSaved: () => this.getters.saveButton().should('match', 'span'), // In Element UI, disabled button turn into spans 🤷‍♂️
isWorkflowActivated: () => this.getters.activatorSwitch().should('have.class', 'is-checked'), 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]'), expressionModalInput: () => cy.getByTestId('expression-modal-input').find('[role=textbox]'),
expressionModalOutput: () => cy.getByTestId('expression-modal-output'), expressionModalOutput: () => cy.getByTestId('expression-modal-output'),
@@ -117,6 +119,8 @@ export class WorkflowPage extends BasePage {
workflowMenuItemImportFromFile: () => cy.getByTestId('workflow-menu-item-import-from-file'), workflowMenuItemImportFromFile: () => cy.getByTestId('workflow-menu-item-import-from-file'),
workflowMenuItemSettings: () => cy.getByTestId('workflow-menu-item-settings'), workflowMenuItemSettings: () => cy.getByTestId('workflow-menu-item-settings'),
workflowMenuItemDelete: () => cy.getByTestId('workflow-menu-item-delete'), 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'), workflowMenuItemGitPush: () => cy.getByTestId('workflow-menu-item-push'),
// Workflow settings dialog elements // Workflow settings dialog elements
workflowSettingsModal: () => cy.getByTestId('workflow-settings-dialog'), workflowSettingsModal: () => cy.getByTestId('workflow-settings-dialog'),
@@ -136,6 +140,7 @@ export class WorkflowPage extends BasePage {
workflowSettingsSaveButton: () => workflowSettingsSaveButton: () =>
cy.getByTestId('workflow-settings-save-button').find('button'), cy.getByTestId('workflow-settings-save-button').find('button'),
archivedTag: () => cy.getByTestId('workflow-archived-tag'),
shareButton: () => cy.getByTestId('workflow-share-button'), shareButton: () => cy.getByTestId('workflow-share-button'),
duplicateWorkflowModal: () => cy.getByTestId('duplicate-modal'), duplicateWorkflowModal: () => cy.getByTestId('duplicate-modal'),
@@ -214,6 +219,7 @@ export class WorkflowPage extends BasePage {
} }
return parseFloat(element.css('top')); return parseFloat(element.css('top'));
}, },
confirmModal: () => cy.get('div[role=dialog][aria-modal=true]'),
}; };
actions = { actions = {
@@ -551,5 +557,9 @@ export class WorkflowPage extends BasePage {
top: +$el[0].style.top.replace('px', ''), 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) => workflowCardActions: (workflowName: string) =>
this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-actions'), this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-actions'),
workflowActionItem: (action: string) => cy.getByTestId(`action-${action}`).filter(':visible'), 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: () => workflowDeleteButton: () =>
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'), cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'),
workflowMoveButton: () => workflowMoveButton: () =>
@@ -47,6 +51,7 @@ export class WorkflowsPage extends BasePage {
workflowStatusItem: (status: string) => cy.getByTestId('status').contains(status), workflowStatusItem: (status: string) => cy.getByTestId('status').contains(status),
workflowOwnershipDropdown: () => cy.getByTestId('user-select-trigger'), workflowOwnershipDropdown: () => cy.getByTestId('user-select-trigger'),
workflowOwner: (email: string) => cy.getByTestId('user-email').contains(email), workflowOwner: (email: string) => cy.getByTestId('user-email').contains(email),
workflowArchivedCheckbox: () => cy.getByTestId('show-archived-checkbox'),
workflowResetFilters: () => cy.getByTestId('workflows-filter-reset'), workflowResetFilters: () => cy.getByTestId('workflows-filter-reset'),
workflowSortDropdown: () => cy.getByTestId('resources-list-sort'), workflowSortDropdown: () => cy.getByTestId('resources-list-sort'),
workflowSortItem: (sort: string) => workflowSortItem: (sort: string) =>

View File

@@ -34,6 +34,16 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl
@Column() @Column()
active: boolean; 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() @JsonColumn()
nodes: INode[]; nodes: INode[];

View File

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

View File

@@ -108,7 +108,7 @@ export class Reset extends BaseCommand {
const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials); const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials);
for (const { workflowId } of ownedSharedWorkflows) { 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) { for (const credential of ownedCredentials) {

View File

@@ -238,7 +238,7 @@ export class UsersController {
const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials); const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials);
for (const { workflowId } of ownedSharedWorkflows) { for (const { workflowId } of ownedSharedWorkflows) {
await this.workflowService.delete(userToDelete, workflowId); await this.workflowService.delete(userToDelete, workflowId, true);
} }
for (const credential of ownedCredentials) { 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 { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys'; import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount'; import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn'; import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
export const mysqlMigrations: Migration[] = [ export const mysqlMigrations: Migration[] = [
@@ -174,4 +175,5 @@ export const mysqlMigrations: Migration[] = [
RenameAnalyticsToInsights1741167584277, RenameAnalyticsToInsights1741167584277,
AddScopesColumnToApiKeys1742918400000, AddScopesColumnToApiKeys1742918400000,
AddWorkflowStatisticsRootCount1745587087521, AddWorkflowStatisticsRootCount1745587087521,
AddWorkflowArchivedColumn1745934666076,
]; ];

View File

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

View File

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

View File

@@ -394,6 +394,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
): void { ): void {
this.applyNameFilter(qb, filter); this.applyNameFilter(qb, filter);
this.applyActiveFilter(qb, filter); this.applyActiveFilter(qb, filter);
this.applyIsArchivedFilter(qb, filter);
this.applyTagsFilter(qb, filter); this.applyTagsFilter(qb, filter);
this.applyProjectFilter(qb, filter); this.applyProjectFilter(qb, filter);
this.applyParentFolderFilter(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( private applyTagsFilter(
qb: SelectQueryBuilder<WorkflowEntity>, qb: SelectQueryBuilder<WorkflowEntity>,
filter: ListQuery.Options['filter'], filter: ListQuery.Options['filter'],
@@ -508,6 +518,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
'workflow.id', 'workflow.id',
'workflow.name', 'workflow.name',
'workflow.active', 'workflow.active',
'workflow.isArchived',
'workflow.createdAt', 'workflow.createdAt',
'workflow.updatedAt', 'workflow.updatedAt',
'workflow.versionId', 'workflow.versionId',

View File

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

View File

@@ -310,7 +310,14 @@ export class SourceControlImportService {
continue; continue;
} }
const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); 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 ?? ''; const parentFolderId = importedWorkflow.parentFolderId ?? '';
@@ -353,14 +360,17 @@ export class SourceControlImportService {
// remove active pre-import workflow // remove active pre-import workflow
this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`); this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`);
await workflowManager.remove(existingWorkflow.id); await workflowManager.remove(existingWorkflow.id);
// try activating the imported workflow
this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`); if (importedWorkflow.active) {
await workflowManager.add(existingWorkflow.id, 'activate'); // try activating the imported workflow
// update the versionId of the workflow to match the imported workflow this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`);
await workflowManager.add(existingWorkflow.id, 'activate');
}
} catch (e) { } catch (e) {
const error = ensureError(e); const error = ensureError(e);
this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, { error }); this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, { error });
} finally { } finally {
// update the versionId of the workflow to match the imported workflow
await this.workflowRepository.update( await this.workflowRepository.update(
{ id: existingWorkflow.id }, { id: existingWorkflow.id },
{ versionId: importedWorkflow.versionId }, { versionId: importedWorkflow.versionId },
@@ -639,7 +649,7 @@ export class SourceControlImportService {
async deleteWorkflowsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) { async deleteWorkflowsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
for (const candidate of candidates) { 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; versionId?: string;
owner: ResourceOwner; owner: ResourceOwner;
parentFolderId: string | null; parentFolderId: string | null;
isArchived: boolean;
} }

View File

@@ -59,6 +59,8 @@ export const eventNamesAudit = [
'n8n.audit.workflow.created', 'n8n.audit.workflow.created',
'n8n.audit.workflow.deleted', 'n8n.audit.workflow.deleted',
'n8n.audit.workflow.updated', 'n8n.audit.workflow.updated',
'n8n.audit.workflow.archived',
'n8n.audit.workflow.unarchived',
] as const; ] as const;
export type EventNamesWorkflowType = (typeof eventNamesWorkflow)[number]; 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', () => { it('should log on `workflow-deleted` event', () => {
const event: RelayEventMap['workflow-deleted'] = { const event: RelayEventMap['workflow-deleted'] = {
user: { user: {

View File

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

View File

@@ -20,6 +20,8 @@ export class LogStreamingEventRelay extends EventRelay {
this.setupListeners({ this.setupListeners({
'workflow-created': (event) => this.workflowCreated(event), 'workflow-created': (event) => this.workflowCreated(event),
'workflow-deleted': (event) => this.workflowDeleted(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-saved': (event) => this.workflowSaved(event),
'workflow-pre-execute': (event) => this.workflowPreExecute(event), 'workflow-pre-execute': (event) => this.workflowPreExecute(event),
'workflow-post-execute': (event) => this.workflowPostExecute(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() @Redactable()
private workflowSaved({ user, workflow }: RelayEventMap['workflow-saved']) { private workflowSaved({ user, workflow }: RelayEventMap['workflow-saved']) {
void this.eventBus.sendAuditEvent({ void this.eventBus.sendAuditEvent({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -114,7 +114,7 @@ export class ProjectService {
); );
} else { } else {
for (const sharedWorkflow of ownedSharedWorkflows) { 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', id: 'abc',
name: 'test', name: 'test',
active: false, active: false,
isArchived: false,
pinData: { pinData: {
[pinnedTrigger.name]: [{ json: {} }], [pinnedTrigger.name]: [{ json: {} }],
}, },
@@ -301,6 +302,7 @@ describe('WorkflowExecutionService', () => {
id: 'abc', id: 'abc',
name: 'test', name: 'test',
active: false, active: false,
isArchived: false,
pinData: { pinData: {
[pinnedTrigger.name]: [{ json: {} }], [pinnedTrigger.name]: [{ json: {} }],
}, },

View File

@@ -54,8 +54,6 @@ export declare namespace WorkflowRequest {
listQueryOptions: ListQuery.Options; listQueryOptions: ListQuery.Options;
}; };
type Delete = Get;
type Update = AuthenticatedRequest< type Update = AuthenticatedRequest<
{ workflowId: string }, { 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 * If the user does not have the permissions to delete the workflow this does
* nothing and returns void. * 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]); await this.externalHooks.run('workflow.delete', [workflowId]);
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
@@ -393,6 +393,10 @@ export class WorkflowService {
return; return;
} }
if (!workflow.isArchived && !force) {
throw new BadRequestError('Workflow must be archived before it can be deleted.');
}
if (workflow.active) { if (workflow.active) {
// deactivate before deleting // deactivate before deleting
await this.activeWorkflowManager.remove(workflowId); await this.activeWorkflowManager.remove(workflowId);
@@ -414,6 +418,65 @@ export class WorkflowService {
return workflow; 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[]> { async getWorkflowScopes(user: User, workflowId: string): Promise<Scope[]> {
const userProjectRelations = await this.projectService.getProjectRelationsForUser(user); const userProjectRelations = await this.projectService.getProjectRelationsForUser(user);
const shared = await this.sharedWorkflowRepository.find({ const shared = await this.sharedWorkflowRepository.find({

View File

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

View File

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

View File

@@ -2328,9 +2328,197 @@ describe('POST /workflows/:workflowId/run', () => {
}); });
}); });
describe('DELETE /workflows/:workflowId', () => { describe('POST /workflows/:workflowId/archive', () => {
test('deletes a workflow owned by the user', async () => { test('should archive workflow', async () => {
const workflow = await createWorkflow({}, owner); 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); await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200);
@@ -2343,8 +2531,15 @@ describe('DELETE /workflows/:workflowId', () => {
expect(sharedWorkflowsInDb).toHaveLength(0); expect(sharedWorkflowsInDb).toHaveLength(0);
}); });
test('deletes a workflow owned by the user, even if the user is just a member', async () => { test('should not delete missing workflow', async () => {
const workflow = await createWorkflow({}, member); 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); await testServer.authAgentFor(member).delete(`/workflows/${workflow.id}`).send().expect(200);
@@ -2357,8 +2552,23 @@ describe('DELETE /workflows/:workflowId', () => {
expect(sharedWorkflowsInDb).toHaveLength(0); expect(sharedWorkflowsInDb).toHaveLength(0);
}); });
test('does not delete a workflow that is not owned by the user', async () => { test('does not delete a workflow that is not archived', async () => {
const workflow = await createWorkflow({}, member); 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 await testServer
.authAgentFor(anotherMember) .authAgentFor(anotherMember)
@@ -2375,8 +2585,8 @@ describe('DELETE /workflows/:workflowId', () => {
expect(sharedWorkflowsInDb).toHaveLength(1); expect(sharedWorkflowsInDb).toHaveLength(1);
}); });
test("allows the owner to delete workflows they don't own", async () => { test("allows the owner to delete archived workflows they don't own", async () => {
const workflow = await createWorkflow({}, member); const workflow = await createWorkflow({ isArchived: true }, member);
await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200); await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200);

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,6 @@ const DEFAULT_FOLDER: FolderResource = {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
resourceType: 'folder', resourceType: 'folder',
readOnly: false,
workflowCount: 2, workflowCount: 2,
subFolderCount: 2, subFolderCount: 2,
homeProject: { homeProject: {

View File

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

View File

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

View File

@@ -1,19 +1,30 @@
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue'; import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
import { createComponentRenderer } from '@/__tests__/render'; 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 { createTestingPinia } from '@pinia/testing';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useUIStore } from '@/stores/ui.store'; import { useUIStore } from '@/stores/ui.store';
import { useRoute } from 'vue-router';
import type { Mock } from 'vitest'; 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) => ({ vi.mock('vue-router', async (importOriginal) => ({
// eslint-disable-next-line @typescript-eslint/consistent-type-imports // eslint-disable-next-line @typescript-eslint/consistent-type-imports
...(await importOriginal<typeof import('vue-router')>()), ...(await importOriginal<typeof import('vue-router')>()),
useRoute: vi.fn().mockReturnValue({}), useRoute: vi.fn().mockReturnValue({}),
useRouter: vi.fn(() => ({ useRouter: vi.fn().mockReturnValue({
replace: vi.fn(), replace: vi.fn(),
})), push: vi.fn().mockResolvedValue(undefined),
}),
})); }));
vi.mock('@/stores/pushConnection.store', () => ({ 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 = { const initialState = {
[STORES.SETTINGS]: { [STORES.SETTINGS]: {
settings: { settings: {
@@ -59,17 +90,33 @@ const renderComponent = createComponentRenderer(WorkflowDetails, {
}); });
let uiStore: ReturnType<typeof useUIStore>; 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 = { const workflow = {
id: '1', id: '1',
name: 'Test Workflow', name: 'Test Workflow',
tags: ['1', '2'], tags: ['1', '2'],
active: false, active: false,
isArchived: false,
}; };
describe('WorkflowDetails', () => { describe('WorkflowDetails', () => {
beforeEach(() => { beforeEach(() => {
uiStore = useUIStore(); uiStore = useUIStore();
workflowsStore = mockedStore(useWorkflowsStore);
message = useMessage();
toast = useToast();
router = useRouter();
}); });
afterEach(() => {
vi.clearAllMocks();
});
it('renders workflow name and tags', async () => { it('renders workflow name and tags', async () => {
(useRoute as Mock).mockReturnValue({ (useRoute as Mock).mockReturnValue({
query: { parentFolderId: '1' }, query: { parentFolderId: '1' },
@@ -123,4 +170,229 @@ describe('WorkflowDetails', () => {
data: { id: '1' }, 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']; scopes: IWorkflowDb['scopes'];
active: IWorkflowDb['active']; active: IWorkflowDb['active'];
currentFolder?: FolderShortInfo; currentFolder?: FolderShortInfo;
isArchived: IWorkflowDb['isArchived'];
}>(); }>();
const $style = useCssModule(); const $style = useCssModule();
@@ -144,14 +145,20 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
label: locale.baseText('menuActions.download'), label: locale.baseText('menuActions.download'),
disabled: !onWorkflowPage.value, disabled: !onWorkflowPage.value,
}, },
{ ];
if (!props.readOnly && !props.isArchived) {
actions.push({
id: WORKFLOW_MENU_ACTIONS.RENAME, id: WORKFLOW_MENU_ACTIONS.RENAME,
label: locale.baseText('generic.rename'), label: locale.baseText('generic.rename'),
disabled: !onWorkflowPage.value || workflowPermissions.value.update !== true, 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({ actions.unshift({
id: WORKFLOW_MENU_ACTIONS.DUPLICATE, id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
label: locale.baseText('menuActions.duplicate'), label: locale.baseText('menuActions.duplicate'),
@@ -190,14 +197,29 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
disabled: !onWorkflowPage.value || isNewWorkflow.value, disabled: !onWorkflowPage.value || isNewWorkflow.value,
}); });
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) { if ((workflowPermissions.value.delete === true && !props.readOnly) || isNewWorkflow.value) {
actions.push({ if (props.isArchived) {
id: WORKFLOW_MENU_ACTIONS.DELETE, actions.push({
label: locale.baseText('menuActions.delete'), id: WORKFLOW_MENU_ACTIONS.UNARCHIVE,
disabled: !onWorkflowPage.value || isNewWorkflow.value, label: locale.baseText('menuActions.unarchive'),
customClass: $style.deleteItem, disabled: !onWorkflowPage.value || isNewWorkflow.value,
divided: true, });
}); 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; return actions;
@@ -512,6 +534,56 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY); uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
break; 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: { case WORKFLOW_MENU_ACTIONS.DELETE: {
const deleteConfirmed = await message.confirm( const deleteConfirmed = await message.confirm(
locale.baseText('mainSidebar.confirmMessage.workflowDelete.message', { 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. // Reset tab title since workflow is deleted.
documentTitle.reset(); documentTitle.reset();
toast.showMessage({ toast.showMessage({
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title'), title: locale.baseText('mainSidebar.showMessage.handleSelect1.title', {
interpolate: { workflowName: props.name },
}),
type: 'success', type: 'success',
}); });
@@ -628,7 +702,9 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
:preview-value="shortenedName" :preview-value="shortenedName"
:is-edit-enabled="isNameEditEnabled" :is-edit-enabled="isNameEditEnabled"
:max-length="MAX_WORKFLOW_NAME_LENGTH" :max-length="MAX_WORKFLOW_NAME_LENGTH"
:disabled="readOnly || (!isNewWorkflow && !workflowPermissions.update)" :disabled="
readOnly || isArchived || (!isNewWorkflow && !workflowPermissions.update)
"
placeholder="Enter workflow name" placeholder="Enter workflow name"
class="name" class="name"
@toggle="onNameToggle" @toggle="onNameToggle"
@@ -641,42 +717,62 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
</template> </template>
</BreakpointsObserver> </BreakpointsObserver>
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container"> <span class="tags" data-test-id="workflow-tags-container">
<WorkflowTagsDropdown <template v-if="settingsStore.areTagsEnabled">
v-if="isTagsEditEnabled && !readOnly && (isNewWorkflow || workflowPermissions.update)" <WorkflowTagsDropdown
ref="dropdown" v-if="
v-model="appliedTagIds" isTagsEditEnabled &&
:event-bus="tagsEventBus" !(readOnly || isArchived) &&
:placeholder="i18n.baseText('workflowDetails.chooseOrCreateATag')" (isNewWorkflow || workflowPermissions.update)
class="tags-edit" "
data-test-id="workflow-tags-dropdown" ref="dropdown"
@blur="onTagsBlur" v-model="appliedTagIds"
@esc="onTagsEditEsc" :event-bus="tagsEventBus"
/> :placeholder="i18n.baseText('workflowDetails.chooseOrCreateATag')"
<div class="tags-edit"
v-else-if=" data-test-id="workflow-tags-dropdown"
(tags ?? []).length === 0 && !readOnly && (isNewWorkflow || workflowPermissions.update) @blur="onTagsBlur"
" @esc="onTagsEditEsc"
> />
<span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable"> <div
+ {{ i18n.baseText('workflowDetails.addTag') }} v-else-if="
</span> (tags ?? []).length === 0 &&
</div> !(readOnly || isArchived) &&
<WorkflowTagsContainer (isNewWorkflow || workflowPermissions.update)
v-else "
:key="id" >
:tag-ids="workflowTagIds" <span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable">
:clickable="true" + {{ i18n.baseText('workflowDetails.addTag') }}
:responsive="true" </span>
data-test-id="workflow-tags" </div>
@click="onTagsEditEnable" <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>
<span v-else class="tags"></span>
<PushConnectionTracker class="actions"> <PushConnectionTracker class="actions">
<span :class="`activator ${$style.group}`"> <span :class="`activator ${$style.group}`">
<WorkflowActivator <WorkflowActivator
:is-archived="isArchived"
:workflow-active="active" :workflow-active="active"
:workflow-id="id" :workflow-id="id"
:workflow-permissions="workflowPermissions" :workflow-permissions="workflowPermissions"
@@ -726,10 +822,13 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
type="primary" type="primary"
:saved="!uiStore.stateIsDirty && !isNewWorkflow" :saved="!uiStore.stateIsDirty && !isNewWorkflow"
:disabled=" :disabled="
isWorkflowSaving || readOnly || (!isNewWorkflow && !workflowPermissions.update) isWorkflowSaving ||
readOnly ||
isArchived ||
(!isNewWorkflow && !workflowPermissions.update)
" "
:is-saving="isWorkflowSaving" :is-saving="isWorkflowSaving"
:with-shortcut="!readOnly && workflowPermissions.update" :with-shortcut="!readOnly && !isArchived && workflowPermissions.update"
:shortcut-tooltip="i18n.baseText('saveWorkflowButton.hint')" :shortcut-tooltip="i18n.baseText('saveWorkflowButton.hint')"
data-test-id="workflow-save-button" data-test-id="workflow-save-button"
@click="onSaveButtonClick" @click="onSaveButtonClick"
@@ -814,6 +913,14 @@ $--header-spacing: 20px;
max-width: 460px; max-width: 460px;
} }
.archived {
display: flex;
align-items: center;
width: 100%;
flex: 1;
margin-right: $--header-spacing;
}
.actions { .actions {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -250,6 +250,7 @@ const { isSubNodeType } = useNodeType({
node, node,
}); });
const isArchivedWorkflow = computed(() => workflowsStore.workflow.isArchived);
const isReadOnlyRoute = computed(() => route.meta.readOnlyCanvas === true); const isReadOnlyRoute = computed(() => route.meta.readOnlyCanvas === true);
const isWaitNodeWaiting = computed(() => { const isWaitNodeWaiting = computed(() => {
return ( return (
@@ -549,7 +550,8 @@ const pinButtonDisabled = computed(
(!rawInputData.value.length && !pinnedData.hasData.value) || (!rawInputData.value.length && !pinnedData.hasData.value) ||
!!binaryData.value?.length || !!binaryData.value?.length ||
isReadOnlyRoute.value || isReadOnlyRoute.value ||
readOnlyEnv.value, readOnlyEnv.value ||
isArchivedWorkflow.value,
); );
const activeTaskMetadata = computed((): ITaskMetadata | null => { const activeTaskMetadata = computed((): ITaskMetadata | null => {
@@ -847,7 +849,13 @@ function showPinDataDiscoveryTooltip(value: IDataObject[]) {
const pinDataDiscoveryFlag = useStorage(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG).value; 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(); pinDataDiscoveryComplete();
setTimeout(() => { setTimeout(() => {
@@ -1367,7 +1375,7 @@ defineExpose({ enterEditMode });
data-test-id="ndv-pinned-data-callout" data-test-id="ndv-pinned-data-callout"
> >
{{ i18n.baseText('runData.pindata.thisDataIsPinned') }} {{ i18n.baseText('runData.pindata.thisDataIsPinned') }}
<span v-if="!isReadOnlyRoute && !readOnlyEnv" class="ml-4xs"> <span v-if="!isReadOnlyRoute && !isArchivedWorkflow && !readOnlyEnv" class="ml-4xs">
<N8nLink <N8nLink
theme="secondary" theme="secondary"
size="small" size="small"

View File

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

View File

@@ -41,6 +41,7 @@ describe('WorkflowActivator', () => {
it('renders correctly', () => { it('renders correctly', () => {
const renderOptions = { const renderOptions = {
props: { props: {
isArchived: false,
workflowActive: false, workflowActive: false,
workflowId: '1', workflowId: '1',
workflowPermissions: { update: true }, workflowPermissions: { update: true },
@@ -50,6 +51,7 @@ describe('WorkflowActivator', () => {
const { getByTestId, getByRole } = renderComponent(renderOptions); const { getByTestId, getByRole } = renderComponent(renderOptions);
expect(getByTestId('workflow-activator-status')).toBeInTheDocument(); expect(getByTestId('workflow-activator-status')).toBeInTheDocument();
expect(getByRole('switch')).toBeInTheDocument(); expect(getByRole('switch')).toBeInTheDocument();
expect(getByRole('switch')).not.toBeDisabled();
}); });
it('display an inactive tooltip when there are no nodes available', async () => { it('display an inactive tooltip when there are no nodes available', async () => {
@@ -57,6 +59,7 @@ describe('WorkflowActivator', () => {
const { getByTestId, getByRole } = renderComponent({ const { getByTestId, getByRole } = renderComponent({
props: { props: {
isArchived: false,
workflowActive: false, workflowActive: false,
workflowId: '1', workflowId: '1',
workflowPermissions: { update: true }, workflowPermissions: { update: true },
@@ -80,6 +83,7 @@ describe('WorkflowActivator', () => {
const { getByTestId, getByRole } = renderComponent({ const { getByTestId, getByRole } = renderComponent({
props: { props: {
isArchived: false,
workflowActive: false, workflowActive: false,
workflowId: '1', workflowId: '1',
workflowPermissions: { update: true }, workflowPermissions: { update: true },
@@ -143,6 +147,7 @@ describe('WorkflowActivator', () => {
const { rerender } = renderComponent({ const { rerender } = renderComponent({
props: { props: {
isArchived: false,
workflowActive: false, workflowActive: false,
workflowId: '1', workflowId: '1',
workflowPermissions: { update: true }, workflowPermissions: { update: true },
@@ -210,6 +215,7 @@ describe('WorkflowActivator', () => {
const { rerender } = renderComponent({ const { rerender } = renderComponent({
props: { props: {
isArchived: false,
workflowActive: false, workflowActive: false,
workflowId: '1', workflowId: '1',
workflowPermissions: { update: true }, workflowPermissions: { update: true },
@@ -251,6 +257,7 @@ describe('WorkflowActivator', () => {
const { rerender } = renderComponent({ const { rerender } = renderComponent({
props: { props: {
isArchived: false,
workflowActive: false, workflowActive: false,
workflowId: '1', workflowId: '1',
workflowPermissions: { update: true }, workflowPermissions: { update: true },
@@ -261,4 +268,27 @@ describe('WorkflowActivator', () => {
expect(toast.showMessage).not.toHaveBeenCalled(); 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'; import { useRouter } from 'vue-router';
const props = defineProps<{ const props = defineProps<{
isArchived: boolean;
workflowActive: boolean; workflowActive: boolean;
workflowId: string; workflowId: string;
workflowPermissions: PermissionsRecord['workflow']; workflowPermissions: PermissionsRecord['workflow'];
@@ -87,6 +88,10 @@ const isNewWorkflow = computed(
); );
const disabled = computed((): boolean => { const disabled = computed((): boolean => {
if (props.isArchived) {
return true;
}
if (isNewWorkflow.value || isCurrentWorkflow.value) { if (isNewWorkflow.value || isCurrentWorkflow.value) {
return !props.workflowActive && !containsTrigger.value; return !props.workflowActive && !containsTrigger.value;
} }
@@ -221,9 +226,11 @@ watch(
<div> <div>
{{ {{
i18n.baseText( i18n.baseText(
containsOnlyExecuteWorkflowTrigger isArchived
? 'workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode' ? 'workflowActivator.thisWorkflowIsArchived'
: 'workflowActivator.thisWorkflowHasNoTriggerNodes', : containsOnlyExecuteWorkflowTrigger
? 'workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode'
: 'workflowActivator.thisWorkflowHasNoTriggerNodes',
) )
}} }}
</div> </div>

View File

@@ -1,13 +1,17 @@
import type { MockInstance } from 'vitest'; import type { MockInstance } from 'vitest';
import { setActivePinia, createPinia } from 'pinia';
import { waitFor, within } from '@testing-library/vue'; import { waitFor, within } from '@testing-library/vue';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render'; 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 WorkflowCard from '@/components/WorkflowCard.vue';
import type { IWorkflowDb } from '@/Interface'; import type { IWorkflowDb } from '@/Interface';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { useProjectsStore } from '@/stores/projects.store'; 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', () => { vi.mock('vue-router', () => {
const push = vi.fn(); 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 => ({ const createWorkflow = (overrides = {}): IWorkflowDb => ({
id: '1', id: '1',
@@ -32,21 +58,26 @@ const createWorkflow = (overrides = {}): IWorkflowDb => ({
nodes: [], nodes: [],
connections: {}, connections: {},
active: true, active: true,
isArchived: false,
versionId: '1', versionId: '1',
...overrides, ...overrides,
}); });
describe('WorkflowCard', () => { describe('WorkflowCard', () => {
let pinia: ReturnType<typeof createPinia>;
let windowOpenSpy: MockInstance; let windowOpenSpy: MockInstance;
let router: ReturnType<typeof useRouter>; 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 () => { beforeEach(async () => {
pinia = createPinia();
setActivePinia(pinia);
router = useRouter(); router = useRouter();
projectsStore = useProjectsStore(); projectsStore = mockedStore(useProjectsStore);
workflowsStore = mockedStore(useWorkflowsStore);
message = useMessage();
toast = useToast();
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
}); });
@@ -171,6 +202,110 @@ describe('WorkflowCard', () => {
expect(actions).toHaveTextContent('Change owner'); 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 () => { it('should show Read only mode', async () => {
const data = createWorkflow(); const data = createWorkflow();
const { getByRole } = renderComponent({ props: { data } }); const { getByRole } = renderComponent({ props: { data } });
@@ -178,4 +313,18 @@ describe('WorkflowCard', () => {
const heading = getByRole('heading'); const heading = getByRole('heading');
expect(heading).toHaveTextContent('Read only'); 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', SHARE: 'share',
DUPLICATE: 'duplicate', DUPLICATE: 'duplicate',
DELETE: 'delete', DELETE: 'delete',
ARCHIVE: 'archive',
UNARCHIVE: 'unarchive',
MOVE: 'move', MOVE: 'move',
MOVE_TO_FOLDER: 'moveToFolder', MOVE_TO_FOLDER: 'moveToFolder',
}; };
@@ -57,6 +59,8 @@ const emit = defineEmits<{
'expand:tags': []; 'expand:tags': [];
'click:tag': [tagId: string, e: PointerEvent]; 'click:tag': [tagId: string, e: PointerEvent];
'workflow:deleted': []; 'workflow:deleted': [];
'workflow:archived': [];
'workflow:unarchived': [];
'workflow:active-toggle': [value: { id: string; active: boolean }]; 'workflow:active-toggle': [value: { id: string; active: boolean }];
'action:move-to-folder': [value: { id: string; name: string; parentFolderId?: string }]; '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({ items.push({
label: locale.baseText('workflows.item.duplicate'), label: locale.baseText('workflows.item.duplicate'),
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE, value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
@@ -151,10 +155,21 @@ const actions = computed(() => {
} }
if (workflowPermissions.value.delete && !props.readOnly) { if (workflowPermissions.value.delete && !props.readOnly) {
items.push({ if (!props.data.isArchived) {
label: locale.baseText('workflows.item.delete'), items.push({
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE, 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; return items;
@@ -234,6 +249,12 @@ async function onAction(action: string) {
case WORKFLOW_LIST_ITEM_ACTIONS.DELETE: case WORKFLOW_LIST_ITEM_ACTIONS.DELETE:
await deleteWorkflow(); await deleteWorkflow();
break; 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: case WORKFLOW_LIST_ITEM_ACTIONS.MOVE:
moveResource(); moveResource();
break; break;
@@ -277,12 +298,68 @@ async function deleteWorkflow() {
// Reset tab title since workflow is deleted. // Reset tab title since workflow is deleted.
toast.showMessage({ toast.showMessage({
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title'), title: locale.baseText('mainSidebar.showMessage.handleSelect1.title', {
interpolate: { workflowName: props.data.name },
}),
type: 'success', type: 'success',
}); });
emit('workflow:deleted'); 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 () => { const fetchHiddenBreadCrumbsItems = async () => {
if (!props.data.homeProject?.id || !projectName.value || !props.data.parentFolder) { if (!props.data.homeProject?.id || !projectName.value || !props.data.parentFolder) {
hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]); hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]);
@@ -331,6 +408,15 @@ const onBreadcrumbItemClick = async (item: PathItem) => {
<N8nBadge v-if="!workflowPermissions.update" class="ml-3xs" theme="tertiary" bold> <N8nBadge v-if="!workflowPermissions.update" class="ml-3xs" theme="tertiary" bold>
{{ locale.baseText('workflows.item.readonly') }} {{ locale.baseText('workflows.item.readonly') }}
</N8nBadge> </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> </n8n-text>
</template> </template>
<div :class="$style.cardDescription"> <div :class="$style.cardDescription">
@@ -388,6 +474,7 @@ const onBreadcrumbItemClick = async (item: PathItem) => {
</ProjectCardBadge> </ProjectCardBadge>
<WorkflowActivator <WorkflowActivator
class="mr-s" class="mr-s"
:is-archived="data.isArchived"
:workflow-active="data.active" :workflow-active="data.active"
:workflow-id="data.id" :workflow-id="data.id"
:workflow-permissions="workflowPermissions" :workflow-permissions="workflowPermissions"

View File

@@ -60,6 +60,7 @@ describe('WorkflowSettingsVue', () => {
id: '1', id: '1',
name: 'Test Workflow', name: 'Test Workflow',
active: true, active: true,
isArchived: false,
nodes: [], nodes: [],
connections: {}, connections: {},
createdAt: 1, createdAt: 1,
@@ -71,6 +72,7 @@ describe('WorkflowSettingsVue', () => {
id: '1', id: '1',
name: 'Test Workflow', name: 'Test Workflow',
active: true, active: true,
isArchived: false,
nodes: [], nodes: [],
connections: {}, connections: {},
createdAt: 1, createdAt: 1,
@@ -273,6 +275,7 @@ describe('WorkflowSettingsVue', () => {
id: '1', id: '1',
name: 'Test Workflow', name: 'Test Workflow',
active: true, active: true,
isArchived: false,
nodes: [], nodes: [],
connections: {}, connections: {},
createdAt: 1, createdAt: 1,

View File

@@ -22,7 +22,6 @@ type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders';
export type FolderResource = BaseFolderItem & { export type FolderResource = BaseFolderItem & {
resourceType: 'folder'; resourceType: 'folder';
readOnly: boolean;
}; };
export type WorkflowResource = BaseResource & { export type WorkflowResource = BaseResource & {
@@ -30,6 +29,7 @@ export type WorkflowResource = BaseResource & {
updatedAt: string; updatedAt: string;
createdAt: string; createdAt: string;
active: boolean; active: boolean;
isArchived: boolean;
homeProject?: ProjectSharingData; homeProject?: ProjectSharingData;
scopes?: Scope[]; scopes?: Scope[];
tags?: ITag[] | string[]; tags?: ITag[] | string[];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,7 +98,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
async function fetchTotalWorkflowsAndFoldersCount(projectId?: string): Promise<number> { async function fetchTotalWorkflowsAndFoldersCount(projectId?: string): Promise<number> {
const { count } = await workflowsApi.getWorkflowsAndFolders( const { count } = await workflowsApi.getWorkflowsAndFolders(
rootStore.restApiContext, rootStore.restApiContext,
{ projectId }, { projectId, isArchived: false },
{ skip: 0, take: 1 }, { skip: 0, take: 1 },
true, 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() { function getMockEditFieldsNode() {
@@ -886,6 +964,7 @@ function generateMockExecutionEvents() {
nodes: [], nodes: [],
connections: {}, connections: {},
active: false, active: false,
isArchived: false,
versionId: '1', versionId: '1',
}, },
finished: false, 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']> } = { const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
name: '', name: '',
active: false, active: false,
isArchived: false,
createdAt: -1, createdAt: -1,
updatedAt: -1, updatedAt: -1,
connections: {}, connections: {},
@@ -541,7 +542,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
page = 1, page = 1,
pageSize = DEFAULT_WORKFLOW_PAGE_SIZE, pageSize = DEFAULT_WORKFLOW_PAGE_SIZE,
sortBy?: string, 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, includeFolders: boolean = false,
): Promise<WorkflowListResource[]> { ): Promise<WorkflowListResource[]> {
const filter = { ...filters, projectId }; const filter = { ...filters, projectId };
@@ -734,6 +741,42 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
workflowsById.value = 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) { function addWorkflow(workflow: IWorkflowDb) {
workflowsById.value = { workflowsById.value = {
...workflowsById.value, ...workflowsById.value,
@@ -781,6 +824,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
workflow.value.active = active; workflow.value.active = active;
} }
function setIsArchived(isArchived: boolean) {
workflow.value.isArchived = isArchived;
}
async function getDuplicateCurrentWorkflowName(currentWorkflowName: string): Promise<string> { async function getDuplicateCurrentWorkflowName(currentWorkflowName: string): Promise<string> {
if ( if (
currentWorkflowName && currentWorkflowName &&
@@ -1849,11 +1896,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
replaceInvalidWorkflowCredentials, replaceInvalidWorkflowCredentials,
setWorkflows, setWorkflows,
deleteWorkflow, deleteWorkflow,
archiveWorkflow,
unarchiveWorkflow,
addWorkflow, addWorkflow,
setWorkflowActive, setWorkflowActive,
setWorkflowInactive, setWorkflowInactive,
fetchActiveWorkflows, fetchActiveWorkflows,
setActive, setActive,
setIsArchived,
getDuplicateCurrentWorkflowName, getDuplicateCurrentWorkflowName,
setWorkflowExecutionData, setWorkflowExecutionData,
setWorkflowExecutionRunData, setWorkflowExecutionRunData,

View File

@@ -258,7 +258,8 @@ const isCanvasReadOnly = computed(() => {
return ( return (
isDemoRoute.value || isDemoRoute.value ||
isReadOnlyEnvironment.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() { async function onSaveWorkflow() {
const workflowIsSaved = !uiStore.stateIsDirty; const workflowIsSaved = !uiStore.stateIsDirty;
const workflowIsArchived = workflowsStore.workflow.isArchived;
if (workflowIsSaved) { if (workflowIsSaved || workflowIsArchived) {
return; return;
} }
const saved = await workflowHelpers.saveCurrentWorkflow(); const saved = await workflowHelpers.saveCurrentWorkflow();

View File

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

View File

@@ -156,6 +156,7 @@ describe('WorkflowsView', () => {
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
tags: [TEST_TAG.name], tags: [TEST_TAG.name],
isArchived: false,
}), }),
expect.any(Boolean), expect.any(Boolean),
); );
@@ -176,6 +177,7 @@ describe('WorkflowsView', () => {
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
name: 'one', name: 'one',
isArchived: false,
}), }),
expect.any(Boolean), expect.any(Boolean),
); );
@@ -196,6 +198,7 @@ describe('WorkflowsView', () => {
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
active: true, active: true,
isArchived: false,
}), }),
expect.any(Boolean), expect.any(Boolean),
); );
@@ -216,6 +219,27 @@ describe('WorkflowsView', () => {
expect.any(String), expect.any(String),
expect.objectContaining({ expect.objectContaining({
active: false, 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), expect.any(Boolean),
); );
@@ -299,6 +323,7 @@ describe('Folders', () => {
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
active: true, active: true,
isArchived: false,
versionId: '1', versionId: '1',
homeProject: { homeProject: {
id: '1', id: '1',

View File

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

View File

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

View File

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