mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
feat(core): Change workflow deletions to soft deletes (#14894)
Adds soft‑deletion support for workflows through a new boolean column `isArchived`. When a workflow is archived we now set `isArchived` flag to true and the workflows stays in the database and is omitted from the default workflow listing query. Archived workflows can be viewed in read-only mode, but they cannot be activated. Archived workflows are still available by ID and can be invoked as sub-executions, so existing Execute Workflow nodes continue to work. Execution engine doesn't care about isArchived flag. Users can restore workflows via Unarchive action at the UI.
This commit is contained in:
@@ -62,13 +62,13 @@ describe('Workflows', () => {
|
||||
cy.contains('No workflows found').should('be.visible');
|
||||
});
|
||||
|
||||
it('should delete all the workflows', () => {
|
||||
it('should archive all the workflows', () => {
|
||||
WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 1);
|
||||
|
||||
for (let i = 0; i < multipleWorkflowsCount + 1; i++) {
|
||||
cy.getByTestId('workflow-card-actions').first().click();
|
||||
WorkflowsPage.getters.workflowDeleteButton().click();
|
||||
cy.get('button').contains('delete').click();
|
||||
WorkflowsPage.getters.workflowArchiveButton().click();
|
||||
cy.get('button').contains('archive').click();
|
||||
successToast().should('be.visible');
|
||||
}
|
||||
|
||||
@@ -141,4 +141,40 @@ describe('Workflows', () => {
|
||||
WorkflowsPage.getters.workflowActionItem('share').click();
|
||||
workflowSharingModal.getters.modal().should('be.visible');
|
||||
});
|
||||
|
||||
it('should delete archived workflows', () => {
|
||||
cy.visit(WorkflowsPage.url);
|
||||
|
||||
// Toggle "Show archived workflows" filter
|
||||
WorkflowsPage.getters.workflowFilterButton().click();
|
||||
WorkflowsPage.getters.workflowArchivedCheckbox().click();
|
||||
|
||||
WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 3);
|
||||
|
||||
cy.reload();
|
||||
|
||||
WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 3);
|
||||
|
||||
// Archive -> Unarchive -> Archive -> Delete on the first workflow
|
||||
cy.getByTestId('workflow-card-actions').first().click();
|
||||
WorkflowsPage.getters.workflowArchiveButton().click();
|
||||
cy.get('button').contains('archive').click();
|
||||
successToast().should('be.visible');
|
||||
|
||||
cy.getByTestId('workflow-card-actions').first().click();
|
||||
WorkflowsPage.getters.workflowUnarchiveButton().click();
|
||||
successToast().should('be.visible');
|
||||
|
||||
cy.getByTestId('workflow-card-actions').first().click();
|
||||
WorkflowsPage.getters.workflowArchiveButton().click();
|
||||
cy.get('button').contains('archive').click();
|
||||
successToast().should('be.visible');
|
||||
|
||||
cy.getByTestId('workflow-card-actions').first().click();
|
||||
WorkflowsPage.getters.workflowDeleteButton().click();
|
||||
cy.get('button').contains('delete').click();
|
||||
successToast().should('be.visible');
|
||||
|
||||
WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -257,23 +257,103 @@ describe('Workflow Actions', () => {
|
||||
}).as('loadWorkflows');
|
||||
});
|
||||
|
||||
it('should not be able to delete unsaved workflow', () => {
|
||||
it('should not be able to archive or delete unsaved workflow', () => {
|
||||
WorkflowPage.getters.workflowMenu().should('be.visible');
|
||||
WorkflowPage.getters.workflowMenu().click();
|
||||
WorkflowPage.getters.workflowMenuItemDelete().closest('li').should('have.class', 'is-disabled');
|
||||
WorkflowPage.getters.workflowMenuItemDelete().should('not.exist');
|
||||
WorkflowPage.getters
|
||||
.workflowMenuItemArchive()
|
||||
.closest('li')
|
||||
.should('have.class', 'is-disabled');
|
||||
});
|
||||
|
||||
it('should delete workflow', () => {
|
||||
it('should archive workflow and then delete it', () => {
|
||||
WorkflowPage.actions.saveWorkflowOnButtonClick();
|
||||
WorkflowPage.getters.archivedTag().should('not.exist');
|
||||
|
||||
// Archive the workflow
|
||||
WorkflowPage.getters.workflowMenu().should('be.visible');
|
||||
WorkflowPage.getters.workflowMenu().click();
|
||||
WorkflowPage.getters.workflowMenuItemArchive().click();
|
||||
WorkflowPage.actions.acceptConfirmModal();
|
||||
|
||||
successToast().should('exist');
|
||||
cy.url().should('include', WorkflowPages.url);
|
||||
|
||||
// Return back to the workflow
|
||||
cy.go('back');
|
||||
|
||||
WorkflowPage.getters.archivedTag().should('be.visible');
|
||||
WorkflowPage.getters.nodeCreatorPlusButton().should('not.exist');
|
||||
|
||||
// Delete the workflow
|
||||
WorkflowPage.getters.workflowMenu().should('be.visible');
|
||||
WorkflowPage.getters.workflowMenu().click();
|
||||
WorkflowPage.getters.workflowMenuItemDelete().click();
|
||||
cy.get('div[role=dialog][aria-modal=true]').should('be.visible');
|
||||
cy.get('button.btn--confirm').should('be.visible').click();
|
||||
WorkflowPage.actions.acceptConfirmModal();
|
||||
successToast().should('exist');
|
||||
cy.url().should('include', WorkflowPages.url);
|
||||
});
|
||||
|
||||
it('should archive workflow and then unarchive it', () => {
|
||||
WorkflowPage.actions.saveWorkflowOnButtonClick();
|
||||
WorkflowPage.getters.archivedTag().should('not.exist');
|
||||
|
||||
// Archive the workflow
|
||||
WorkflowPage.getters.workflowMenu().should('be.visible');
|
||||
WorkflowPage.getters.workflowMenu().click();
|
||||
WorkflowPage.getters.workflowMenuItemArchive().click();
|
||||
WorkflowPage.actions.acceptConfirmModal();
|
||||
successToast().should('exist');
|
||||
cy.url().should('include', WorkflowPages.url);
|
||||
|
||||
// Return back to the workflow
|
||||
cy.go('back');
|
||||
|
||||
WorkflowPage.getters.archivedTag().should('be.visible');
|
||||
WorkflowPage.getters.nodeCreatorPlusButton().should('not.exist');
|
||||
|
||||
// Unarchive the workflow
|
||||
WorkflowPage.getters.workflowMenu().should('be.visible');
|
||||
WorkflowPage.getters.workflowMenu().click();
|
||||
WorkflowPage.getters.workflowMenuItemUnarchive().click();
|
||||
successToast().should('exist');
|
||||
WorkflowPage.getters.archivedTag().should('not.exist');
|
||||
WorkflowPage.getters.nodeCreatorPlusButton().should('be.visible');
|
||||
});
|
||||
|
||||
it('should deactivate active workflow on archive', () => {
|
||||
WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME);
|
||||
WorkflowPage.actions.saveWorkflowOnButtonClick();
|
||||
WorkflowPage.actions.activateWorkflow();
|
||||
WorkflowPage.getters.isWorkflowActivated();
|
||||
|
||||
// Archive the workflow
|
||||
WorkflowPage.getters.workflowMenu().click();
|
||||
WorkflowPage.getters.workflowMenuItemArchive().click();
|
||||
WorkflowPage.actions.acceptConfirmModal();
|
||||
successToast().should('exist');
|
||||
cy.url().should('include', WorkflowPages.url);
|
||||
|
||||
// Return back to the workflow
|
||||
cy.go('back');
|
||||
|
||||
WorkflowPage.getters.archivedTag().should('be.visible');
|
||||
WorkflowPage.getters.isWorkflowDeactivated();
|
||||
WorkflowPage.getters.activatorSwitch().find('input').first().should('be.disabled');
|
||||
|
||||
// Unarchive the workflow
|
||||
WorkflowPage.getters.workflowMenu().should('be.visible');
|
||||
WorkflowPage.getters.workflowMenu().click();
|
||||
WorkflowPage.getters.workflowMenuItemUnarchive().click();
|
||||
successToast().should('exist');
|
||||
WorkflowPage.getters.archivedTag().should('not.exist');
|
||||
|
||||
// Activate the workflow again
|
||||
WorkflowPage.actions.activateWorkflow();
|
||||
WorkflowPage.getters.isWorkflowActivated();
|
||||
});
|
||||
|
||||
describe('duplicate workflow', () => {
|
||||
function duplicateWorkflow() {
|
||||
WorkflowPage.getters.workflowMenu().should('be.visible');
|
||||
|
||||
@@ -84,6 +84,8 @@ export class WorkflowPage extends BasePage {
|
||||
firstStepButton: () => cy.getByTestId('canvas-add-button'),
|
||||
isWorkflowSaved: () => this.getters.saveButton().should('match', 'span'), // In Element UI, disabled button turn into spans 🤷♂️
|
||||
isWorkflowActivated: () => this.getters.activatorSwitch().should('have.class', 'is-checked'),
|
||||
isWorkflowDeactivated: () =>
|
||||
this.getters.activatorSwitch().should('not.have.class', 'is-checked'),
|
||||
expressionModalInput: () => cy.getByTestId('expression-modal-input').find('[role=textbox]'),
|
||||
expressionModalOutput: () => cy.getByTestId('expression-modal-output'),
|
||||
|
||||
@@ -117,6 +119,8 @@ export class WorkflowPage extends BasePage {
|
||||
workflowMenuItemImportFromFile: () => cy.getByTestId('workflow-menu-item-import-from-file'),
|
||||
workflowMenuItemSettings: () => cy.getByTestId('workflow-menu-item-settings'),
|
||||
workflowMenuItemDelete: () => cy.getByTestId('workflow-menu-item-delete'),
|
||||
workflowMenuItemArchive: () => cy.getByTestId('workflow-menu-item-archive'),
|
||||
workflowMenuItemUnarchive: () => cy.getByTestId('workflow-menu-item-unarchive'),
|
||||
workflowMenuItemGitPush: () => cy.getByTestId('workflow-menu-item-push'),
|
||||
// Workflow settings dialog elements
|
||||
workflowSettingsModal: () => cy.getByTestId('workflow-settings-dialog'),
|
||||
@@ -136,6 +140,7 @@ export class WorkflowPage extends BasePage {
|
||||
workflowSettingsSaveButton: () =>
|
||||
cy.getByTestId('workflow-settings-save-button').find('button'),
|
||||
|
||||
archivedTag: () => cy.getByTestId('workflow-archived-tag'),
|
||||
shareButton: () => cy.getByTestId('workflow-share-button'),
|
||||
|
||||
duplicateWorkflowModal: () => cy.getByTestId('duplicate-modal'),
|
||||
@@ -214,6 +219,7 @@ export class WorkflowPage extends BasePage {
|
||||
}
|
||||
return parseFloat(element.css('top'));
|
||||
},
|
||||
confirmModal: () => cy.get('div[role=dialog][aria-modal=true]'),
|
||||
};
|
||||
|
||||
actions = {
|
||||
@@ -551,5 +557,9 @@ export class WorkflowPage extends BasePage {
|
||||
top: +$el[0].style.top.replace('px', ''),
|
||||
}));
|
||||
},
|
||||
acceptConfirmModal: () => {
|
||||
this.getters.confirmModal().should('be.visible');
|
||||
cy.get('button.btn--confirm').should('be.visible').click();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ export class WorkflowsPage extends BasePage {
|
||||
workflowCardActions: (workflowName: string) =>
|
||||
this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-actions'),
|
||||
workflowActionItem: (action: string) => cy.getByTestId(`action-${action}`).filter(':visible'),
|
||||
workflowArchiveButton: () =>
|
||||
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Archive'),
|
||||
workflowUnarchiveButton: () =>
|
||||
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Unarchive'),
|
||||
workflowDeleteButton: () =>
|
||||
cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'),
|
||||
workflowMoveButton: () =>
|
||||
@@ -47,6 +51,7 @@ export class WorkflowsPage extends BasePage {
|
||||
workflowStatusItem: (status: string) => cy.getByTestId('status').contains(status),
|
||||
workflowOwnershipDropdown: () => cy.getByTestId('user-select-trigger'),
|
||||
workflowOwner: (email: string) => cy.getByTestId('user-email').contains(email),
|
||||
workflowArchivedCheckbox: () => cy.getByTestId('show-archived-checkbox'),
|
||||
workflowResetFilters: () => cy.getByTestId('workflows-filter-reset'),
|
||||
workflowSortDropdown: () => cy.getByTestId('resources-list-sort'),
|
||||
workflowSortItem: (sort: string) =>
|
||||
|
||||
@@ -34,6 +34,16 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl
|
||||
@Column()
|
||||
active: boolean;
|
||||
|
||||
/**
|
||||
* Indicates whether the workflow has been soft-deleted (`true`) or not (`false`).
|
||||
*
|
||||
* Archived workflows can be restored (unarchived) or deleted permanently,
|
||||
* and they can still be executed as sub workflow executions, but they
|
||||
* cannot be activated or modified.
|
||||
*/
|
||||
@Column({ default: false })
|
||||
isArchived: boolean;
|
||||
|
||||
@JsonColumn()
|
||||
nodes: INode[];
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('ActiveExecutions', () => {
|
||||
id: '123',
|
||||
name: 'Test workflow 1',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
nodes: [],
|
||||
|
||||
@@ -108,7 +108,7 @@ export class Reset extends BaseCommand {
|
||||
const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials);
|
||||
|
||||
for (const { workflowId } of ownedSharedWorkflows) {
|
||||
await Container.get(WorkflowService).delete(owner, workflowId);
|
||||
await Container.get(WorkflowService).delete(owner, workflowId, true);
|
||||
}
|
||||
|
||||
for (const credential of ownedCredentials) {
|
||||
|
||||
@@ -238,7 +238,7 @@ export class UsersController {
|
||||
const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials);
|
||||
|
||||
for (const { workflowId } of ownedSharedWorkflows) {
|
||||
await this.workflowService.delete(userToDelete, workflowId);
|
||||
await this.workflowService.delete(userToDelete, workflowId, true);
|
||||
}
|
||||
|
||||
for (const credential of ownedCredentials) {
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-Crea
|
||||
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
|
||||
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
|
||||
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
||||
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
|
||||
import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn';
|
||||
|
||||
export const mysqlMigrations: Migration[] = [
|
||||
@@ -174,4 +175,5 @@ export const mysqlMigrations: Migration[] = [
|
||||
RenameAnalyticsToInsights1741167584277,
|
||||
AddScopesColumnToApiKeys1742918400000,
|
||||
AddWorkflowStatisticsRootCount1745587087521,
|
||||
AddWorkflowArchivedColumn1745934666076,
|
||||
];
|
||||
|
||||
@@ -85,6 +85,7 @@ import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-Crea
|
||||
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
|
||||
import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys';
|
||||
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
||||
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
|
||||
|
||||
export const postgresMigrations: Migration[] = [
|
||||
InitialMigration1587669153312,
|
||||
@@ -172,4 +173,5 @@ export const postgresMigrations: Migration[] = [
|
||||
RenameAnalyticsToInsights1741167584277,
|
||||
AddScopesColumnToApiKeys1742918400000,
|
||||
AddWorkflowStatisticsRootCount1745587087521,
|
||||
AddWorkflowArchivedColumn1745934666076,
|
||||
];
|
||||
|
||||
@@ -82,6 +82,7 @@ import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462-
|
||||
import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables';
|
||||
import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights';
|
||||
import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount';
|
||||
import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn';
|
||||
|
||||
const sqliteMigrations: Migration[] = [
|
||||
InitialMigration1588102412422,
|
||||
@@ -166,6 +167,7 @@ const sqliteMigrations: Migration[] = [
|
||||
RenameAnalyticsToInsights1741167584277,
|
||||
AddScopesColumnToApiKeys1742918400000,
|
||||
AddWorkflowStatisticsRootCount1745587087521,
|
||||
AddWorkflowArchivedColumn1745934666076,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
||||
@@ -394,6 +394,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||
): void {
|
||||
this.applyNameFilter(qb, filter);
|
||||
this.applyActiveFilter(qb, filter);
|
||||
this.applyIsArchivedFilter(qb, filter);
|
||||
this.applyTagsFilter(qb, filter);
|
||||
this.applyProjectFilter(qb, filter);
|
||||
this.applyParentFolderFilter(qb, filter);
|
||||
@@ -440,6 +441,15 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||
}
|
||||
}
|
||||
|
||||
private applyIsArchivedFilter(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
filter: ListQuery.Options['filter'],
|
||||
): void {
|
||||
if (typeof filter?.isArchived === 'boolean') {
|
||||
qb.andWhere('workflow.isArchived = :isArchived', { isArchived: filter.isArchived });
|
||||
}
|
||||
}
|
||||
|
||||
private applyTagsFilter(
|
||||
qb: SelectQueryBuilder<WorkflowEntity>,
|
||||
filter: ListQuery.Options['filter'],
|
||||
@@ -508,6 +518,7 @@ export class WorkflowRepository extends Repository<WorkflowEntity> {
|
||||
'workflow.id',
|
||||
'workflow.name',
|
||||
'workflow.active',
|
||||
'workflow.isArchived',
|
||||
'workflow.createdAt',
|
||||
'workflow.updatedAt',
|
||||
'workflow.versionId',
|
||||
|
||||
@@ -106,6 +106,7 @@ export class SourceControlExportService {
|
||||
versionId: e.versionId,
|
||||
owner: owners[e.id],
|
||||
parentFolderId: e.parentFolder?.id ?? null,
|
||||
isArchived: e.isArchived,
|
||||
};
|
||||
this.logger.debug(`Writing workflow ${e.id} to ${fileName}`);
|
||||
return await fsWriteFile(fileName, JSON.stringify(sanitizedWorkflow, null, 2));
|
||||
|
||||
@@ -310,7 +310,14 @@ export class SourceControlImportService {
|
||||
continue;
|
||||
}
|
||||
const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id);
|
||||
importedWorkflow.active = existingWorkflow?.active ?? false;
|
||||
|
||||
// Workflow's active status is not saved in the remote workflow files, and the field is missing despite
|
||||
// IWorkflowToImport having it typed as boolean. Imported workflows are always inactive if they are new,
|
||||
// and existing workflows use the existing workflow's active status unless they have been archived on the remote.
|
||||
// In that case, we deactivate the existing workflow on pull and turn it archived.
|
||||
importedWorkflow.active = existingWorkflow
|
||||
? existingWorkflow.active && !importedWorkflow.isArchived
|
||||
: false;
|
||||
|
||||
const parentFolderId = importedWorkflow.parentFolderId ?? '';
|
||||
|
||||
@@ -353,14 +360,17 @@ export class SourceControlImportService {
|
||||
// remove active pre-import workflow
|
||||
this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`);
|
||||
await workflowManager.remove(existingWorkflow.id);
|
||||
// try activating the imported workflow
|
||||
this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`);
|
||||
await workflowManager.add(existingWorkflow.id, 'activate');
|
||||
// update the versionId of the workflow to match the imported workflow
|
||||
|
||||
if (importedWorkflow.active) {
|
||||
// try activating the imported workflow
|
||||
this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`);
|
||||
await workflowManager.add(existingWorkflow.id, 'activate');
|
||||
}
|
||||
} catch (e) {
|
||||
const error = ensureError(e);
|
||||
this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, { error });
|
||||
} finally {
|
||||
// update the versionId of the workflow to match the imported workflow
|
||||
await this.workflowRepository.update(
|
||||
{ id: existingWorkflow.id },
|
||||
{ versionId: importedWorkflow.versionId },
|
||||
@@ -639,7 +649,7 @@ export class SourceControlImportService {
|
||||
|
||||
async deleteWorkflowsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) {
|
||||
for (const candidate of candidates) {
|
||||
await this.workflowService.delete(user, candidate.id);
|
||||
await this.workflowService.delete(user, candidate.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,4 +12,5 @@ export interface ExportableWorkflow {
|
||||
versionId?: string;
|
||||
owner: ResourceOwner;
|
||||
parentFolderId: string | null;
|
||||
isArchived: boolean;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ export const eventNamesAudit = [
|
||||
'n8n.audit.workflow.created',
|
||||
'n8n.audit.workflow.deleted',
|
||||
'n8n.audit.workflow.updated',
|
||||
'n8n.audit.workflow.archived',
|
||||
'n8n.audit.workflow.unarchived',
|
||||
] as const;
|
||||
|
||||
export type EventNamesWorkflowType = (typeof eventNamesWorkflow)[number];
|
||||
|
||||
@@ -51,6 +51,62 @@ describe('LogStreamingEventRelay', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should log on `workflow-archived` event', () => {
|
||||
const event: RelayEventMap['workflow-archived'] = {
|
||||
user: {
|
||||
id: '456',
|
||||
email: 'jane@n8n.io',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
role: 'user',
|
||||
},
|
||||
workflowId: 'wf789',
|
||||
publicApi: false,
|
||||
};
|
||||
|
||||
eventService.emit('workflow-archived', event);
|
||||
|
||||
expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({
|
||||
eventName: 'n8n.audit.workflow.archived',
|
||||
payload: {
|
||||
userId: '456',
|
||||
_email: 'jane@n8n.io',
|
||||
_firstName: 'Jane',
|
||||
_lastName: 'Smith',
|
||||
globalRole: 'user',
|
||||
workflowId: 'wf789',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should log on `workflow-unarchived` event', () => {
|
||||
const event: RelayEventMap['workflow-unarchived'] = {
|
||||
user: {
|
||||
id: '456',
|
||||
email: 'jane@n8n.io',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
role: 'user',
|
||||
},
|
||||
workflowId: 'wf789',
|
||||
publicApi: false,
|
||||
};
|
||||
|
||||
eventService.emit('workflow-unarchived', event);
|
||||
|
||||
expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({
|
||||
eventName: 'n8n.audit.workflow.unarchived',
|
||||
payload: {
|
||||
userId: '456',
|
||||
_email: 'jane@n8n.io',
|
||||
_firstName: 'Jane',
|
||||
_lastName: 'Smith',
|
||||
globalRole: 'user',
|
||||
workflowId: 'wf789',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should log on `workflow-deleted` event', () => {
|
||||
const event: RelayEventMap['workflow-deleted'] = {
|
||||
user: {
|
||||
|
||||
@@ -68,6 +68,18 @@ export type RelayEventMap = {
|
||||
publicApi: boolean;
|
||||
};
|
||||
|
||||
'workflow-archived': {
|
||||
user: UserLike;
|
||||
workflowId: string;
|
||||
publicApi: boolean;
|
||||
};
|
||||
|
||||
'workflow-unarchived': {
|
||||
user: UserLike;
|
||||
workflowId: string;
|
||||
publicApi: boolean;
|
||||
};
|
||||
|
||||
'workflow-saved': {
|
||||
user: UserLike;
|
||||
workflow: IWorkflowDb;
|
||||
|
||||
@@ -20,6 +20,8 @@ export class LogStreamingEventRelay extends EventRelay {
|
||||
this.setupListeners({
|
||||
'workflow-created': (event) => this.workflowCreated(event),
|
||||
'workflow-deleted': (event) => this.workflowDeleted(event),
|
||||
'workflow-archived': (event) => this.workflowArchived(event),
|
||||
'workflow-unarchived': (event) => this.workflowUnarchived(event),
|
||||
'workflow-saved': (event) => this.workflowSaved(event),
|
||||
'workflow-pre-execute': (event) => this.workflowPreExecute(event),
|
||||
'workflow-post-execute': (event) => this.workflowPostExecute(event),
|
||||
@@ -86,6 +88,22 @@ export class LogStreamingEventRelay extends EventRelay {
|
||||
});
|
||||
}
|
||||
|
||||
@Redactable()
|
||||
private workflowArchived({ user, workflowId }: RelayEventMap['workflow-archived']) {
|
||||
void this.eventBus.sendAuditEvent({
|
||||
eventName: 'n8n.audit.workflow.archived',
|
||||
payload: { ...user, workflowId },
|
||||
});
|
||||
}
|
||||
|
||||
@Redactable()
|
||||
private workflowUnarchived({ user, workflowId }: RelayEventMap['workflow-unarchived']) {
|
||||
void this.eventBus.sendAuditEvent({
|
||||
eventName: 'n8n.audit.workflow.unarchived',
|
||||
payload: { ...user, workflowId },
|
||||
});
|
||||
}
|
||||
|
||||
@Redactable()
|
||||
private workflowSaved({ user, workflow }: RelayEventMap['workflow-saved']) {
|
||||
void this.eventBus.sendAuditEvent({
|
||||
|
||||
@@ -61,6 +61,7 @@ describe('Execution Lifecycle Hooks', () => {
|
||||
id: workflowId,
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
isArchived: false,
|
||||
connections: {},
|
||||
nodes: [],
|
||||
settings: {},
|
||||
|
||||
@@ -38,6 +38,7 @@ export function prepareExecutionDataForDbUpdate(parameters: {
|
||||
'id',
|
||||
'name',
|
||||
'active',
|
||||
'isArchived',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'nodes',
|
||||
|
||||
@@ -68,6 +68,8 @@ type ExternalHooksMap = {
|
||||
'workflow.afterUpdate': [updatedWorkflow: IWorkflowBase];
|
||||
'workflow.delete': [workflowId: string];
|
||||
'workflow.afterDelete': [workflowId: string];
|
||||
'workflow.afterArchive': [workflowId: string];
|
||||
'workflow.afterUnarchive': [workflowId: string];
|
||||
|
||||
'workflow.preExecute': [workflow: Workflow, mode: WorkflowExecuteMode];
|
||||
'workflow.postExecute': [
|
||||
|
||||
@@ -14,6 +14,11 @@ export class WorkflowFilter extends BaseFilter {
|
||||
@Expose()
|
||||
active?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
isArchived?: boolean;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@IsOptional()
|
||||
|
||||
@@ -97,7 +97,7 @@ export = {
|
||||
async (req: WorkflowRequest.Get, res: express.Response): Promise<express.Response> => {
|
||||
const { id: workflowId } = req.params;
|
||||
|
||||
const workflow = await Container.get(WorkflowService).delete(req.user, workflowId);
|
||||
const workflow = await Container.get(WorkflowService).delete(req.user, workflowId, true);
|
||||
if (!workflow) {
|
||||
// user trying to access a workflow they do not own
|
||||
// or workflow does not exist
|
||||
|
||||
@@ -162,6 +162,7 @@ describe('WorkflowStatisticsService', () => {
|
||||
id: '1',
|
||||
name: '',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
nodes: [],
|
||||
@@ -191,6 +192,7 @@ describe('WorkflowStatisticsService', () => {
|
||||
id: '1',
|
||||
name: '',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
nodes: [],
|
||||
@@ -213,6 +215,7 @@ describe('WorkflowStatisticsService', () => {
|
||||
id: '1',
|
||||
name: '',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
nodes: [],
|
||||
|
||||
@@ -114,7 +114,7 @@ export class ProjectService {
|
||||
);
|
||||
} else {
|
||||
for (const sharedWorkflow of ownedSharedWorkflows) {
|
||||
await workflowService.delete(user, sharedWorkflow.workflowId);
|
||||
await workflowService.delete(user, sharedWorkflow.workflowId, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -234,6 +234,7 @@ describe('WorkflowExecutionService', () => {
|
||||
id: 'abc',
|
||||
name: 'test',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
pinData: {
|
||||
[pinnedTrigger.name]: [{ json: {} }],
|
||||
},
|
||||
@@ -301,6 +302,7 @@ describe('WorkflowExecutionService', () => {
|
||||
id: 'abc',
|
||||
name: 'test',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
pinData: {
|
||||
[pinnedTrigger.name]: [{ json: {} }],
|
||||
},
|
||||
|
||||
@@ -54,8 +54,6 @@ export declare namespace WorkflowRequest {
|
||||
listQueryOptions: ListQuery.Options;
|
||||
};
|
||||
|
||||
type Delete = Get;
|
||||
|
||||
type Update = AuthenticatedRequest<
|
||||
{ workflowId: string },
|
||||
{},
|
||||
|
||||
@@ -382,7 +382,7 @@ export class WorkflowService {
|
||||
* If the user does not have the permissions to delete the workflow this does
|
||||
* nothing and returns void.
|
||||
*/
|
||||
async delete(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
|
||||
async delete(user: User, workflowId: string, force = false): Promise<WorkflowEntity | undefined> {
|
||||
await this.externalHooks.run('workflow.delete', [workflowId]);
|
||||
|
||||
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
|
||||
@@ -393,6 +393,10 @@ export class WorkflowService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workflow.isArchived && !force) {
|
||||
throw new BadRequestError('Workflow must be archived before it can be deleted.');
|
||||
}
|
||||
|
||||
if (workflow.active) {
|
||||
// deactivate before deleting
|
||||
await this.activeWorkflowManager.remove(workflowId);
|
||||
@@ -414,6 +418,65 @@ export class WorkflowService {
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async archive(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
|
||||
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
|
||||
'workflow:delete',
|
||||
]);
|
||||
|
||||
if (!workflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (workflow.isArchived) {
|
||||
throw new BadRequestError('Workflow is already archived.');
|
||||
}
|
||||
|
||||
if (workflow.active) {
|
||||
await this.activeWorkflowManager.remove(workflowId);
|
||||
}
|
||||
|
||||
const versionId = uuid();
|
||||
await this.workflowRepository.update(workflowId, {
|
||||
isArchived: true,
|
||||
active: false,
|
||||
versionId,
|
||||
});
|
||||
|
||||
this.eventService.emit('workflow-archived', { user, workflowId, publicApi: false });
|
||||
await this.externalHooks.run('workflow.afterArchive', [workflowId]);
|
||||
|
||||
workflow.isArchived = true;
|
||||
workflow.active = false;
|
||||
workflow.versionId = versionId;
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async unarchive(user: User, workflowId: string): Promise<WorkflowEntity | undefined> {
|
||||
const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [
|
||||
'workflow:delete',
|
||||
]);
|
||||
|
||||
if (!workflow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!workflow.isArchived) {
|
||||
throw new BadRequestError('Workflow is not archived.');
|
||||
}
|
||||
|
||||
const versionId = uuid();
|
||||
await this.workflowRepository.update(workflowId, { isArchived: false, versionId });
|
||||
|
||||
this.eventService.emit('workflow-unarchived', { user, workflowId, publicApi: false });
|
||||
await this.externalHooks.run('workflow.afterUnarchive', [workflowId]);
|
||||
|
||||
workflow.isArchived = false;
|
||||
workflow.versionId = versionId;
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
async getWorkflowScopes(user: User, workflowId: string): Promise<Scope[]> {
|
||||
const userProjectRelations = await this.projectService.getProjectRelationsForUser(user);
|
||||
const shared = await this.sharedWorkflowRepository.find({
|
||||
|
||||
@@ -382,23 +382,63 @@ export class WorkflowsController {
|
||||
|
||||
@Delete('/:workflowId')
|
||||
@ProjectScope('workflow:delete')
|
||||
async delete(req: WorkflowRequest.Delete) {
|
||||
const { workflowId } = req.params;
|
||||
|
||||
async delete(req: AuthenticatedRequest, _res: Response, @Param('workflowId') workflowId: string) {
|
||||
const workflow = await this.workflowService.delete(req.user, workflowId);
|
||||
if (!workflow) {
|
||||
this.logger.warn('User attempted to delete a workflow without permissions', {
|
||||
workflowId,
|
||||
userId: req.user.id,
|
||||
});
|
||||
throw new BadRequestError(
|
||||
'Could not delete the workflow - you can only remove workflows owned by you',
|
||||
throw new ForbiddenError(
|
||||
'Could not delete the workflow - workflow was not found in your projects',
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Post('/:workflowId/archive')
|
||||
@ProjectScope('workflow:delete')
|
||||
async archive(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('workflowId') workflowId: string,
|
||||
) {
|
||||
const workflow = await this.workflowService.archive(req.user, workflowId);
|
||||
if (!workflow) {
|
||||
this.logger.warn('User attempted to archive a workflow without permissions', {
|
||||
workflowId,
|
||||
userId: req.user.id,
|
||||
});
|
||||
throw new ForbiddenError(
|
||||
'Could not archive the workflow - workflow was not found in your projects',
|
||||
);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
@Post('/:workflowId/unarchive')
|
||||
@ProjectScope('workflow:delete')
|
||||
async unarchive(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('workflowId') workflowId: string,
|
||||
) {
|
||||
const workflow = await this.workflowService.unarchive(req.user, workflowId);
|
||||
if (!workflow) {
|
||||
this.logger.warn('User attempted to unarchive a workflow without permissions', {
|
||||
workflowId,
|
||||
userId: req.user.id,
|
||||
});
|
||||
throw new ForbiddenError(
|
||||
'Could not unarchive the workflow - workflow was not found in your projects',
|
||||
);
|
||||
}
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
@Post('/:workflowId/run')
|
||||
@ProjectScope('workflow:execute')
|
||||
async runManually(
|
||||
|
||||
@@ -25,10 +25,11 @@ export async function createManyWorkflows(
|
||||
}
|
||||
|
||||
export function newWorkflow(attributes: Partial<IWorkflowDb> = {}): IWorkflowDb {
|
||||
const { active, name, nodes, connections, versionId, settings } = attributes;
|
||||
const { active, isArchived, name, nodes, connections, versionId, settings } = attributes;
|
||||
|
||||
const workflowEntity = Container.get(WorkflowRepository).create({
|
||||
active: active ?? false,
|
||||
isArchived: isArchived ?? false,
|
||||
name: name ?? 'test workflow',
|
||||
nodes: nodes ?? [
|
||||
{
|
||||
|
||||
@@ -2328,9 +2328,197 @@ describe('POST /workflows/:workflowId/run', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /workflows/:workflowId', () => {
|
||||
test('deletes a workflow owned by the user', async () => {
|
||||
describe('POST /workflows/:workflowId/archive', () => {
|
||||
test('should archive workflow', async () => {
|
||||
const workflow = await createWorkflow({}, owner);
|
||||
const response = await authOwnerAgent
|
||||
.post(`/workflows/${workflow.id}/archive`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
data: { isArchived, versionId },
|
||||
} = response.body;
|
||||
|
||||
expect(isArchived).toBe(true);
|
||||
expect(versionId).not.toBe(workflow.versionId);
|
||||
|
||||
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
|
||||
expect(updatedWorkflow).not.toBeNull();
|
||||
expect(updatedWorkflow!.isArchived).toBe(true);
|
||||
});
|
||||
|
||||
test('should deactivate active workflow on archive', async () => {
|
||||
const workflow = await createWorkflow({ active: true }, owner);
|
||||
const response = await authOwnerAgent
|
||||
.post(`/workflows/${workflow.id}/archive`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
data: { isArchived, versionId, active },
|
||||
} = response.body;
|
||||
|
||||
expect(isArchived).toBe(true);
|
||||
expect(active).toBe(false);
|
||||
expect(versionId).not.toBe(workflow.versionId);
|
||||
expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id);
|
||||
|
||||
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
|
||||
expect(updatedWorkflow).not.toBeNull();
|
||||
expect(updatedWorkflow!.isArchived).toBe(true);
|
||||
});
|
||||
|
||||
test('should not archive workflow that is already archived', async () => {
|
||||
const workflow = await createWorkflow({ isArchived: true }, owner);
|
||||
const response = await authOwnerAgent
|
||||
.post(`/workflows/${workflow.id}/archive`)
|
||||
.send()
|
||||
.expect(400);
|
||||
|
||||
expect(response.body.message).toBe('Workflow is already archived.');
|
||||
|
||||
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
|
||||
expect(updatedWorkflow).not.toBeNull();
|
||||
expect(updatedWorkflow!.isArchived).toBe(true);
|
||||
});
|
||||
|
||||
test('should not archive missing workflow', async () => {
|
||||
const response = await authOwnerAgent.post('/workflows/404/archive').send().expect(403);
|
||||
expect(response.body.message).toBe(
|
||||
'Could not archive the workflow - workflow was not found in your projects',
|
||||
);
|
||||
});
|
||||
|
||||
test('should not archive a workflow that is not owned by the user', async () => {
|
||||
const workflow = await createWorkflow({ isArchived: false }, member);
|
||||
|
||||
await testServer
|
||||
.authAgentFor(anotherMember)
|
||||
.post(`/workflows/${workflow.id}/archive`)
|
||||
.send()
|
||||
.expect(403);
|
||||
|
||||
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
|
||||
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
||||
workflowId: workflow.id,
|
||||
});
|
||||
|
||||
expect(workflowsInDb).not.toBeNull();
|
||||
expect(workflowsInDb!.isArchived).toBe(false);
|
||||
expect(sharedWorkflowsInDb).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("should allow the owner to archive workflows they don't own", async () => {
|
||||
const workflow = await createWorkflow({ isArchived: false }, member);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post(`/workflows/${workflow.id}/archive`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
data: { isArchived, versionId },
|
||||
} = response.body;
|
||||
|
||||
expect(isArchived).toBe(true);
|
||||
expect(versionId).not.toBe(workflow.versionId);
|
||||
|
||||
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
|
||||
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
||||
workflowId: workflow.id,
|
||||
});
|
||||
|
||||
expect(workflowsInDb).not.toBeNull();
|
||||
expect(workflowsInDb!.isArchived).toBe(true);
|
||||
expect(sharedWorkflowsInDb).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /workflows/:workflowId/unarchive', () => {
|
||||
test('should unarchive workflow', async () => {
|
||||
const workflow = await createWorkflow({ isArchived: true }, owner);
|
||||
const response = await authOwnerAgent
|
||||
.post(`/workflows/${workflow.id}/unarchive`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
data: { isArchived, versionId },
|
||||
} = response.body;
|
||||
|
||||
expect(isArchived).toBe(false);
|
||||
expect(versionId).not.toBe(workflow.versionId);
|
||||
|
||||
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
|
||||
expect(updatedWorkflow).not.toBeNull();
|
||||
expect(updatedWorkflow!.isArchived).toBe(false);
|
||||
});
|
||||
|
||||
test('should not unarchive workflow that is already not archived', async () => {
|
||||
const workflow = await createWorkflow({ isArchived: false }, owner);
|
||||
await authOwnerAgent.post(`/workflows/${workflow.id}/unarchive`).send().expect(400);
|
||||
|
||||
const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id);
|
||||
expect(updatedWorkflow).not.toBeNull();
|
||||
expect(updatedWorkflow!.isArchived).toBe(false);
|
||||
});
|
||||
|
||||
test('should not unarchive missing workflow', async () => {
|
||||
const response = await authOwnerAgent.post('/workflows/404/unarchive').send().expect(403);
|
||||
expect(response.body.message).toBe(
|
||||
'Could not unarchive the workflow - workflow was not found in your projects',
|
||||
);
|
||||
});
|
||||
|
||||
test('should not unarchive a workflow that is not owned by the user', async () => {
|
||||
const workflow = await createWorkflow({ isArchived: true }, member);
|
||||
|
||||
await testServer
|
||||
.authAgentFor(anotherMember)
|
||||
.post(`/workflows/${workflow.id}/unarchive`)
|
||||
.send()
|
||||
.expect(403);
|
||||
|
||||
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
|
||||
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
||||
workflowId: workflow.id,
|
||||
});
|
||||
|
||||
expect(workflowsInDb).not.toBeNull();
|
||||
expect(workflowsInDb!.isArchived).toBe(true);
|
||||
expect(sharedWorkflowsInDb).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("should allow the owner to unarchive workflows they don't own", async () => {
|
||||
const workflow = await createWorkflow({ isArchived: true }, member);
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post(`/workflows/${workflow.id}/unarchive`)
|
||||
.send()
|
||||
.expect(200);
|
||||
|
||||
const {
|
||||
data: { isArchived, versionId },
|
||||
} = response.body;
|
||||
|
||||
expect(isArchived).toBe(false);
|
||||
expect(versionId).not.toBe(workflow.versionId);
|
||||
|
||||
const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id);
|
||||
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
||||
workflowId: workflow.id,
|
||||
});
|
||||
|
||||
expect(workflowsInDb).not.toBeNull();
|
||||
expect(workflowsInDb!.isArchived).toBe(false);
|
||||
expect(sharedWorkflowsInDb).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /workflows/:workflowId', () => {
|
||||
test('deletes an archived workflow owned by the user', async () => {
|
||||
const workflow = await createWorkflow({ isArchived: true }, owner);
|
||||
|
||||
await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200);
|
||||
|
||||
@@ -2343,8 +2531,15 @@ describe('DELETE /workflows/:workflowId', () => {
|
||||
expect(sharedWorkflowsInDb).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('deletes a workflow owned by the user, even if the user is just a member', async () => {
|
||||
const workflow = await createWorkflow({}, member);
|
||||
test('should not delete missing workflow', async () => {
|
||||
const response = await authOwnerAgent.delete('/workflows/404').send().expect(403);
|
||||
expect(response.body.message).toBe(
|
||||
'Could not delete the workflow - workflow was not found in your projects',
|
||||
);
|
||||
});
|
||||
|
||||
test('deletes an archived workflow owned by the user, even if the user is just a member', async () => {
|
||||
const workflow = await createWorkflow({ isArchived: true }, member);
|
||||
|
||||
await testServer.authAgentFor(member).delete(`/workflows/${workflow.id}`).send().expect(200);
|
||||
|
||||
@@ -2357,8 +2552,23 @@ describe('DELETE /workflows/:workflowId', () => {
|
||||
expect(sharedWorkflowsInDb).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('does not delete a workflow that is not owned by the user', async () => {
|
||||
const workflow = await createWorkflow({}, member);
|
||||
test('does not delete a workflow that is not archived', async () => {
|
||||
const workflow = await createWorkflow({}, owner);
|
||||
|
||||
const response = await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(400);
|
||||
expect(response.body.message).toBe('Workflow must be archived before it can be deleted.');
|
||||
|
||||
const workflowInDb = await Container.get(WorkflowRepository).findById(workflow.id);
|
||||
const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({
|
||||
workflowId: workflow.id,
|
||||
});
|
||||
|
||||
expect(workflowInDb).not.toBeNull();
|
||||
expect(sharedWorkflowsInDb).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('does not delete an archived workflow that is not owned by the user', async () => {
|
||||
const workflow = await createWorkflow({ isArchived: true }, member);
|
||||
|
||||
await testServer
|
||||
.authAgentFor(anotherMember)
|
||||
@@ -2375,8 +2585,8 @@ describe('DELETE /workflows/:workflowId', () => {
|
||||
expect(sharedWorkflowsInDb).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("allows the owner to delete workflows they don't own", async () => {
|
||||
const workflow = await createWorkflow({}, member);
|
||||
test("allows the owner to delete archived workflows they don't own", async () => {
|
||||
const workflow = await createWorkflow({ isArchived: true }, member);
|
||||
|
||||
await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200);
|
||||
|
||||
|
||||
@@ -315,6 +315,7 @@ export interface IWorkflowDb {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
isArchived: boolean;
|
||||
createdAt: number | string;
|
||||
updatedAt: number | string;
|
||||
nodes: INodeUi[];
|
||||
|
||||
@@ -177,6 +177,7 @@ export function createTestWorkflow({
|
||||
nodes = [],
|
||||
connections = {},
|
||||
active = false,
|
||||
isArchived = false,
|
||||
settings = {
|
||||
timezone: 'DEFAULT',
|
||||
executionOrder: 'v1',
|
||||
@@ -192,6 +193,7 @@ export function createTestWorkflow({
|
||||
nodes,
|
||||
connections,
|
||||
active,
|
||||
isArchived,
|
||||
settings,
|
||||
versionId: '1',
|
||||
meta: {},
|
||||
|
||||
@@ -12,6 +12,9 @@ export const workflowFactory = Factory.extend<IWorkflowDb>({
|
||||
active() {
|
||||
return faker.datatype.boolean();
|
||||
},
|
||||
isArchived() {
|
||||
return faker.datatype.boolean();
|
||||
},
|
||||
nodes() {
|
||||
return [];
|
||||
},
|
||||
|
||||
@@ -31,7 +31,6 @@ const DEFAULT_FOLDER: FolderResource = {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
resourceType: 'folder',
|
||||
readOnly: false,
|
||||
workflowCount: 2,
|
||||
subFolderCount: 2,
|
||||
homeProject: {
|
||||
|
||||
@@ -70,6 +70,7 @@ const render = (props: Partial<Props> = {}, pinData?: INodeExecutionData[], runD
|
||||
id: '',
|
||||
name: '',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
nodes,
|
||||
|
||||
@@ -251,6 +251,7 @@ function hideGithubButton() {
|
||||
:active="workflow.active"
|
||||
:read-only="readOnly"
|
||||
:current-folder="parentFolderForBreadcrumbs"
|
||||
:is-archived="workflow.isArchived"
|
||||
/>
|
||||
<div v-if="showGitHubButton" :class="[$style['github-button'], 'hidden-sm-and-down']">
|
||||
<div :class="$style['github-button-container']">
|
||||
|
||||
@@ -1,19 +1,30 @@
|
||||
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { EnterpriseEditionFeature, STORES, WORKFLOW_SHARE_MODAL_KEY } from '@/constants';
|
||||
import { type MockedStore, mockedStore } from '@/__tests__/utils';
|
||||
import {
|
||||
EnterpriseEditionFeature,
|
||||
MODAL_CONFIRM,
|
||||
STORES,
|
||||
VIEWS,
|
||||
WORKFLOW_SHARE_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useRoute } from 'vue-router';
|
||||
import type { Mock } from 'vitest';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
...(await importOriginal<typeof import('vue-router')>()),
|
||||
useRoute: vi.fn().mockReturnValue({}),
|
||||
useRouter: vi.fn(() => ({
|
||||
useRouter: vi.fn().mockReturnValue({
|
||||
replace: vi.fn(),
|
||||
})),
|
||||
push: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/stores/pushConnection.store', () => ({
|
||||
@@ -22,6 +33,26 @@ vi.mock('@/stores/pushConnection.store', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/composables/useToast', () => {
|
||||
const showError = vi.fn();
|
||||
const showMessage = vi.fn();
|
||||
return {
|
||||
useToast: () => ({
|
||||
showError,
|
||||
showMessage,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useMessage', () => {
|
||||
const confirm = vi.fn(async () => MODAL_CONFIRM);
|
||||
return {
|
||||
useMessage: () => ({
|
||||
confirm,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const initialState = {
|
||||
[STORES.SETTINGS]: {
|
||||
settings: {
|
||||
@@ -59,17 +90,33 @@ const renderComponent = createComponentRenderer(WorkflowDetails, {
|
||||
});
|
||||
|
||||
let uiStore: ReturnType<typeof useUIStore>;
|
||||
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
|
||||
let message: ReturnType<typeof useMessage>;
|
||||
let toast: ReturnType<typeof useToast>;
|
||||
let router: ReturnType<typeof useRouter>;
|
||||
|
||||
const workflow = {
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
tags: ['1', '2'],
|
||||
active: false,
|
||||
isArchived: false,
|
||||
};
|
||||
|
||||
describe('WorkflowDetails', () => {
|
||||
beforeEach(() => {
|
||||
uiStore = useUIStore();
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
|
||||
message = useMessage();
|
||||
toast = useToast();
|
||||
router = useRouter();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders workflow name and tags', async () => {
|
||||
(useRoute as Mock).mockReturnValue({
|
||||
query: { parentFolderId: '1' },
|
||||
@@ -123,4 +170,229 @@ describe('WorkflowDetails', () => {
|
||||
data: { id: '1' },
|
||||
});
|
||||
});
|
||||
|
||||
describe('Workflow menu', () => {
|
||||
beforeEach(() => {
|
||||
(useRoute as Mock).mockReturnValue({
|
||||
meta: {
|
||||
nodeView: true,
|
||||
},
|
||||
query: { parentFolderId: '1' },
|
||||
});
|
||||
});
|
||||
|
||||
it("should have disabled 'Archive' option on new workflow", async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
id: 'new',
|
||||
readOnly: false,
|
||||
isArchived: false,
|
||||
scopes: ['workflow:delete'],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
|
||||
expect(getByTestId('workflow-menu-item-archive')).toBeInTheDocument();
|
||||
expect(getByTestId('workflow-menu-item-archive')).toHaveClass('disabled');
|
||||
});
|
||||
|
||||
it("should have 'Archive' option on non archived workflow", async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
readOnly: false,
|
||||
isArchived: false,
|
||||
scopes: ['workflow:delete'],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
|
||||
expect(getByTestId('workflow-menu-item-archive')).toBeInTheDocument();
|
||||
expect(getByTestId('workflow-menu-item-archive')).not.toHaveClass('disabled');
|
||||
});
|
||||
|
||||
it("should not have 'Archive' option on non archived readonly workflow", async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
readOnly: true,
|
||||
isArchived: false,
|
||||
scopes: ['workflow:delete'],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not have 'Archive' option on non archived workflow without permission", async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
readOnly: false,
|
||||
isArchived: false,
|
||||
scopes: ['workflow:update'],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should have 'Unarchive' and 'Delete' options on archived workflow", async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
isArchived: true,
|
||||
readOnly: false,
|
||||
scopes: ['workflow:delete'],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument();
|
||||
expect(getByTestId('workflow-menu-item-delete')).toBeInTheDocument();
|
||||
expect(getByTestId('workflow-menu-item-delete')).not.toHaveClass('disabled');
|
||||
expect(getByTestId('workflow-menu-item-unarchive')).toBeInTheDocument();
|
||||
expect(getByTestId('workflow-menu-item-unarchive')).not.toHaveClass('disabled');
|
||||
});
|
||||
|
||||
it("should not have 'Unarchive' or 'Delete' options on archived readonly workflow", async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
isArchived: true,
|
||||
readOnly: true,
|
||||
scopes: ['workflow:delete'],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not have 'Unarchive' or 'Delete' options on archived workflow without permission", async () => {
|
||||
const { getByTestId, queryByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
isArchived: true,
|
||||
readOnly: false,
|
||||
scopes: ['workflow:update'],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument();
|
||||
expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call onWorkflowMenuSelect on 'Archive' option click", async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
readOnly: false,
|
||||
isArchived: false,
|
||||
scopes: ['workflow:delete'],
|
||||
},
|
||||
});
|
||||
|
||||
workflowsStore.archiveWorkflow.mockResolvedValue(undefined);
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
await userEvent.click(getByTestId('workflow-menu-item-archive'));
|
||||
|
||||
expect(message.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(toast.showError).toHaveBeenCalledTimes(0);
|
||||
expect(toast.showMessage).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.archiveWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.archiveWorkflow).toHaveBeenCalledWith(workflow.id);
|
||||
expect(router.push).toHaveBeenCalledTimes(1);
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
name: VIEWS.WORKFLOWS,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call onWorkflowMenuSelect on 'Unarchive' option click", async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
readOnly: false,
|
||||
isArchived: true,
|
||||
scopes: ['workflow:delete'],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
await userEvent.click(getByTestId('workflow-menu-item-unarchive'));
|
||||
|
||||
expect(toast.showError).toHaveBeenCalledTimes(0);
|
||||
expect(toast.showMessage).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledWith(workflow.id);
|
||||
});
|
||||
|
||||
it("should call onWorkflowMenuSelect on 'Delete' option click", async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
readOnly: false,
|
||||
isArchived: true,
|
||||
scopes: ['workflow:delete'],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('workflow-menu'));
|
||||
await userEvent.click(getByTestId('workflow-menu-item-delete'));
|
||||
|
||||
expect(message.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(toast.showError).toHaveBeenCalledTimes(0);
|
||||
expect(toast.showMessage).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.deleteWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.deleteWorkflow).toHaveBeenCalledWith(workflow.id);
|
||||
expect(router.push).toHaveBeenCalledTimes(1);
|
||||
expect(router.push).toHaveBeenCalledWith({
|
||||
name: VIEWS.WORKFLOWS,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Archived badge', () => {
|
||||
it('should show badge on archived workflow', async () => {
|
||||
const { getByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
readOnly: false,
|
||||
isArchived: true,
|
||||
scopes: ['workflow:delete'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('workflow-archived-tag')).toBeVisible();
|
||||
});
|
||||
|
||||
it('should not show badge on non archived workflow', async () => {
|
||||
const { queryByTestId } = renderComponent({
|
||||
props: {
|
||||
...workflow,
|
||||
readOnly: false,
|
||||
isArchived: false,
|
||||
scopes: ['workflow:delete'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('workflow-archived-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ const props = defineProps<{
|
||||
scopes: IWorkflowDb['scopes'];
|
||||
active: IWorkflowDb['active'];
|
||||
currentFolder?: FolderShortInfo;
|
||||
isArchived: IWorkflowDb['isArchived'];
|
||||
}>();
|
||||
|
||||
const $style = useCssModule();
|
||||
@@ -144,14 +145,20 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
|
||||
label: locale.baseText('menuActions.download'),
|
||||
disabled: !onWorkflowPage.value,
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
if (!props.readOnly && !props.isArchived) {
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.RENAME,
|
||||
label: locale.baseText('generic.rename'),
|
||||
disabled: !onWorkflowPage.value || workflowPermissions.value.update !== true,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
|
||||
if (
|
||||
(workflowPermissions.value.delete === true && !props.readOnly && !props.isArchived) ||
|
||||
isNewWorkflow.value
|
||||
) {
|
||||
actions.unshift({
|
||||
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
|
||||
label: locale.baseText('menuActions.duplicate'),
|
||||
@@ -190,14 +197,29 @@ const workflowMenuItems = computed<ActionDropdownItem[]>(() => {
|
||||
disabled: !onWorkflowPage.value || isNewWorkflow.value,
|
||||
});
|
||||
|
||||
if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) {
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.DELETE,
|
||||
label: locale.baseText('menuActions.delete'),
|
||||
disabled: !onWorkflowPage.value || isNewWorkflow.value,
|
||||
customClass: $style.deleteItem,
|
||||
divided: true,
|
||||
});
|
||||
if ((workflowPermissions.value.delete === true && !props.readOnly) || isNewWorkflow.value) {
|
||||
if (props.isArchived) {
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.UNARCHIVE,
|
||||
label: locale.baseText('menuActions.unarchive'),
|
||||
disabled: !onWorkflowPage.value || isNewWorkflow.value,
|
||||
});
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.DELETE,
|
||||
label: locale.baseText('menuActions.delete'),
|
||||
disabled: !onWorkflowPage.value || isNewWorkflow.value,
|
||||
customClass: $style.deleteItem,
|
||||
divided: true,
|
||||
});
|
||||
} else {
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.ARCHIVE,
|
||||
label: locale.baseText('menuActions.archive'),
|
||||
disabled: !onWorkflowPage.value || isNewWorkflow.value,
|
||||
customClass: $style.deleteItem,
|
||||
divided: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return actions;
|
||||
@@ -512,6 +534,56 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
|
||||
uiStore.openModal(WORKFLOW_SETTINGS_MODAL_KEY);
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.ARCHIVE: {
|
||||
const archiveConfirmed = await message.confirm(
|
||||
locale.baseText('mainSidebar.confirmMessage.workflowArchive.message', {
|
||||
interpolate: { workflowName: props.name },
|
||||
}),
|
||||
locale.baseText('mainSidebar.confirmMessage.workflowArchive.headline'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: locale.baseText(
|
||||
'mainSidebar.confirmMessage.workflowArchive.confirmButtonText',
|
||||
),
|
||||
cancelButtonText: locale.baseText(
|
||||
'mainSidebar.confirmMessage.workflowArchive.cancelButtonText',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
if (archiveConfirmed !== MODAL_CONFIRM) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workflowsStore.archiveWorkflow(props.id);
|
||||
} catch (error) {
|
||||
toast.showError(error, locale.baseText('generic.archiveWorkflowError'));
|
||||
return;
|
||||
}
|
||||
|
||||
uiStore.stateIsDirty = false;
|
||||
toast.showMessage({
|
||||
title: locale.baseText('mainSidebar.showMessage.handleArchive.title', {
|
||||
interpolate: { workflowName: props.name },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
await router.push({ name: VIEWS.WORKFLOWS });
|
||||
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.UNARCHIVE: {
|
||||
await workflowsStore.unarchiveWorkflow(props.id);
|
||||
toast.showMessage({
|
||||
title: locale.baseText('mainSidebar.showMessage.handleUnarchive.title', {
|
||||
interpolate: { workflowName: props.name },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
break;
|
||||
}
|
||||
case WORKFLOW_MENU_ACTIONS.DELETE: {
|
||||
const deleteConfirmed = await message.confirm(
|
||||
locale.baseText('mainSidebar.confirmMessage.workflowDelete.message', {
|
||||
@@ -543,7 +615,9 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise<void
|
||||
// Reset tab title since workflow is deleted.
|
||||
documentTitle.reset();
|
||||
toast.showMessage({
|
||||
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title'),
|
||||
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title', {
|
||||
interpolate: { workflowName: props.name },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
@@ -628,7 +702,9 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
|
||||
:preview-value="shortenedName"
|
||||
:is-edit-enabled="isNameEditEnabled"
|
||||
:max-length="MAX_WORKFLOW_NAME_LENGTH"
|
||||
:disabled="readOnly || (!isNewWorkflow && !workflowPermissions.update)"
|
||||
:disabled="
|
||||
readOnly || isArchived || (!isNewWorkflow && !workflowPermissions.update)
|
||||
"
|
||||
placeholder="Enter workflow name"
|
||||
class="name"
|
||||
@toggle="onNameToggle"
|
||||
@@ -641,42 +717,62 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
|
||||
</template>
|
||||
</BreakpointsObserver>
|
||||
|
||||
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
|
||||
<WorkflowTagsDropdown
|
||||
v-if="isTagsEditEnabled && !readOnly && (isNewWorkflow || workflowPermissions.update)"
|
||||
ref="dropdown"
|
||||
v-model="appliedTagIds"
|
||||
:event-bus="tagsEventBus"
|
||||
:placeholder="i18n.baseText('workflowDetails.chooseOrCreateATag')"
|
||||
class="tags-edit"
|
||||
data-test-id="workflow-tags-dropdown"
|
||||
@blur="onTagsBlur"
|
||||
@esc="onTagsEditEsc"
|
||||
/>
|
||||
<div
|
||||
v-else-if="
|
||||
(tags ?? []).length === 0 && !readOnly && (isNewWorkflow || workflowPermissions.update)
|
||||
"
|
||||
>
|
||||
<span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable">
|
||||
+ {{ i18n.baseText('workflowDetails.addTag') }}
|
||||
</span>
|
||||
</div>
|
||||
<WorkflowTagsContainer
|
||||
v-else
|
||||
:key="id"
|
||||
:tag-ids="workflowTagIds"
|
||||
:clickable="true"
|
||||
:responsive="true"
|
||||
data-test-id="workflow-tags"
|
||||
@click="onTagsEditEnable"
|
||||
/>
|
||||
<span class="tags" data-test-id="workflow-tags-container">
|
||||
<template v-if="settingsStore.areTagsEnabled">
|
||||
<WorkflowTagsDropdown
|
||||
v-if="
|
||||
isTagsEditEnabled &&
|
||||
!(readOnly || isArchived) &&
|
||||
(isNewWorkflow || workflowPermissions.update)
|
||||
"
|
||||
ref="dropdown"
|
||||
v-model="appliedTagIds"
|
||||
:event-bus="tagsEventBus"
|
||||
:placeholder="i18n.baseText('workflowDetails.chooseOrCreateATag')"
|
||||
class="tags-edit"
|
||||
data-test-id="workflow-tags-dropdown"
|
||||
@blur="onTagsBlur"
|
||||
@esc="onTagsEditEsc"
|
||||
/>
|
||||
<div
|
||||
v-else-if="
|
||||
(tags ?? []).length === 0 &&
|
||||
!(readOnly || isArchived) &&
|
||||
(isNewWorkflow || workflowPermissions.update)
|
||||
"
|
||||
>
|
||||
<span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable">
|
||||
+ {{ i18n.baseText('workflowDetails.addTag') }}
|
||||
</span>
|
||||
</div>
|
||||
<WorkflowTagsContainer
|
||||
v-else
|
||||
:key="id"
|
||||
:tag-ids="workflowTagIds"
|
||||
:clickable="true"
|
||||
:responsive="true"
|
||||
data-test-id="workflow-tags"
|
||||
@click="onTagsEditEnable"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<span class="archived">
|
||||
<N8nBadge
|
||||
v-if="isArchived"
|
||||
class="ml-3xs"
|
||||
theme="tertiary"
|
||||
bold
|
||||
data-test-id="workflow-archived-tag"
|
||||
>
|
||||
{{ locale.baseText('workflows.item.archived') }}
|
||||
</N8nBadge>
|
||||
</span>
|
||||
</span>
|
||||
<span v-else class="tags"></span>
|
||||
|
||||
<PushConnectionTracker class="actions">
|
||||
<span :class="`activator ${$style.group}`">
|
||||
<WorkflowActivator
|
||||
:is-archived="isArchived"
|
||||
:workflow-active="active"
|
||||
:workflow-id="id"
|
||||
:workflow-permissions="workflowPermissions"
|
||||
@@ -726,10 +822,13 @@ const onBreadcrumbsItemSelected = (item: PathItem) => {
|
||||
type="primary"
|
||||
:saved="!uiStore.stateIsDirty && !isNewWorkflow"
|
||||
:disabled="
|
||||
isWorkflowSaving || readOnly || (!isNewWorkflow && !workflowPermissions.update)
|
||||
isWorkflowSaving ||
|
||||
readOnly ||
|
||||
isArchived ||
|
||||
(!isNewWorkflow && !workflowPermissions.update)
|
||||
"
|
||||
:is-saving="isWorkflowSaving"
|
||||
:with-shortcut="!readOnly && workflowPermissions.update"
|
||||
:with-shortcut="!readOnly && !isArchived && workflowPermissions.update"
|
||||
:shortcut-tooltip="i18n.baseText('saveWorkflowButton.hint')"
|
||||
data-test-id="workflow-save-button"
|
||||
@click="onSaveButtonClick"
|
||||
@@ -814,6 +913,14 @@ $--header-spacing: 20px;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.archived {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
margin-right: $--header-spacing;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -250,6 +250,7 @@ const { isSubNodeType } = useNodeType({
|
||||
node,
|
||||
});
|
||||
|
||||
const isArchivedWorkflow = computed(() => workflowsStore.workflow.isArchived);
|
||||
const isReadOnlyRoute = computed(() => route.meta.readOnlyCanvas === true);
|
||||
const isWaitNodeWaiting = computed(() => {
|
||||
return (
|
||||
@@ -549,7 +550,8 @@ const pinButtonDisabled = computed(
|
||||
(!rawInputData.value.length && !pinnedData.hasData.value) ||
|
||||
!!binaryData.value?.length ||
|
||||
isReadOnlyRoute.value ||
|
||||
readOnlyEnv.value,
|
||||
readOnlyEnv.value ||
|
||||
isArchivedWorkflow.value,
|
||||
);
|
||||
|
||||
const activeTaskMetadata = computed((): ITaskMetadata | null => {
|
||||
@@ -847,7 +849,13 @@ function showPinDataDiscoveryTooltip(value: IDataObject[]) {
|
||||
|
||||
const pinDataDiscoveryFlag = useStorage(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG).value;
|
||||
|
||||
if (value && value.length > 0 && !isReadOnlyRoute.value && !pinDataDiscoveryFlag) {
|
||||
if (
|
||||
value &&
|
||||
value.length > 0 &&
|
||||
!isReadOnlyRoute.value &&
|
||||
!isArchivedWorkflow.value &&
|
||||
!pinDataDiscoveryFlag
|
||||
) {
|
||||
pinDataDiscoveryComplete();
|
||||
|
||||
setTimeout(() => {
|
||||
@@ -1367,7 +1375,7 @@ defineExpose({ enterEditMode });
|
||||
data-test-id="ndv-pinned-data-callout"
|
||||
>
|
||||
{{ i18n.baseText('runData.pindata.thisDataIsPinned') }}
|
||||
<span v-if="!isReadOnlyRoute && !readOnlyEnv" class="ml-4xs">
|
||||
<span v-if="!isReadOnlyRoute && !isArchivedWorkflow && !readOnlyEnv" class="ml-4xs">
|
||||
<N8nLink
|
||||
theme="secondary"
|
||||
size="small"
|
||||
|
||||
@@ -31,6 +31,7 @@ const EMPTY_WORKFLOW = {
|
||||
versionId: '1',
|
||||
name: 'Email Summary Agent ',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
connections: {},
|
||||
nodes: [],
|
||||
usedCredentials: [],
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('WorkflowActivator', () => {
|
||||
it('renders correctly', () => {
|
||||
const renderOptions = {
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -50,6 +51,7 @@ describe('WorkflowActivator', () => {
|
||||
const { getByTestId, getByRole } = renderComponent(renderOptions);
|
||||
expect(getByTestId('workflow-activator-status')).toBeInTheDocument();
|
||||
expect(getByRole('switch')).toBeInTheDocument();
|
||||
expect(getByRole('switch')).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('display an inactive tooltip when there are no nodes available', async () => {
|
||||
@@ -57,6 +59,7 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
const { getByTestId, getByRole } = renderComponent({
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -80,6 +83,7 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
const { getByTestId, getByRole } = renderComponent({
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -143,6 +147,7 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
const { rerender } = renderComponent({
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -210,6 +215,7 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
const { rerender } = renderComponent({
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -251,6 +257,7 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
const { rerender } = renderComponent({
|
||||
props: {
|
||||
isArchived: false,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
@@ -261,4 +268,27 @@ describe('WorkflowActivator', () => {
|
||||
|
||||
expect(toast.showMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should be disabled on archived workflow', async () => {
|
||||
const renderOptions = {
|
||||
props: {
|
||||
isArchived: true,
|
||||
workflowActive: false,
|
||||
workflowId: '1',
|
||||
workflowPermissions: { update: true },
|
||||
},
|
||||
};
|
||||
|
||||
const { getByTestId, getByRole } = renderComponent(renderOptions);
|
||||
expect(getByTestId('workflow-activator-status')).toBeInTheDocument();
|
||||
expect(getByRole('switch')).toBeInTheDocument();
|
||||
expect(getByRole('switch')).toBeDisabled();
|
||||
|
||||
await userEvent.hover(getByRole('switch'));
|
||||
expect(getByRole('tooltip')).toBeInTheDocument();
|
||||
expect(getByRole('tooltip')).toHaveTextContent(
|
||||
'This workflow is archived so it cannot be activated',
|
||||
);
|
||||
expect(getByTestId('workflow-activator-status')).toHaveTextContent('Inactive');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const props = defineProps<{
|
||||
isArchived: boolean;
|
||||
workflowActive: boolean;
|
||||
workflowId: string;
|
||||
workflowPermissions: PermissionsRecord['workflow'];
|
||||
@@ -87,6 +88,10 @@ const isNewWorkflow = computed(
|
||||
);
|
||||
|
||||
const disabled = computed((): boolean => {
|
||||
if (props.isArchived) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isNewWorkflow.value || isCurrentWorkflow.value) {
|
||||
return !props.workflowActive && !containsTrigger.value;
|
||||
}
|
||||
@@ -221,9 +226,11 @@ watch(
|
||||
<div>
|
||||
{{
|
||||
i18n.baseText(
|
||||
containsOnlyExecuteWorkflowTrigger
|
||||
? 'workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode'
|
||||
: 'workflowActivator.thisWorkflowHasNoTriggerNodes',
|
||||
isArchived
|
||||
? 'workflowActivator.thisWorkflowIsArchived'
|
||||
: containsOnlyExecuteWorkflowTrigger
|
||||
? 'workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode'
|
||||
: 'workflowActivator.thisWorkflowHasNoTriggerNodes',
|
||||
)
|
||||
}}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import type { MockInstance } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { waitFor, within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import { VIEWS } from '@/constants';
|
||||
import { type MockedStore, mockedStore } from '@/__tests__/utils';
|
||||
import { MODAL_CONFIRM, VIEWS } from '@/constants';
|
||||
import WorkflowCard from '@/components/WorkflowCard.vue';
|
||||
import type { IWorkflowDb } from '@/Interface';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useProjectsStore } from '@/stores/projects.store';
|
||||
import { useMessage } from '@/composables/useMessage';
|
||||
import { useToast } from '@/composables/useToast';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
|
||||
vi.mock('vue-router', () => {
|
||||
const push = vi.fn();
|
||||
@@ -22,7 +26,29 @@ vi.mock('vue-router', () => {
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowCard);
|
||||
vi.mock('@/composables/useToast', () => {
|
||||
const showError = vi.fn();
|
||||
const showMessage = vi.fn();
|
||||
return {
|
||||
useToast: () => ({
|
||||
showError,
|
||||
showMessage,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/composables/useMessage', () => {
|
||||
const confirm = vi.fn(async () => MODAL_CONFIRM);
|
||||
return {
|
||||
useMessage: () => ({
|
||||
confirm,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const renderComponent = createComponentRenderer(WorkflowCard, {
|
||||
pinia: createTestingPinia({}),
|
||||
});
|
||||
|
||||
const createWorkflow = (overrides = {}): IWorkflowDb => ({
|
||||
id: '1',
|
||||
@@ -32,21 +58,26 @@ const createWorkflow = (overrides = {}): IWorkflowDb => ({
|
||||
nodes: [],
|
||||
connections: {},
|
||||
active: true,
|
||||
isArchived: false,
|
||||
versionId: '1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('WorkflowCard', () => {
|
||||
let pinia: ReturnType<typeof createPinia>;
|
||||
let windowOpenSpy: MockInstance;
|
||||
let router: ReturnType<typeof useRouter>;
|
||||
let projectsStore: ReturnType<typeof useProjectsStore>;
|
||||
let projectsStore: MockedStore<typeof useProjectsStore>;
|
||||
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
|
||||
let message: ReturnType<typeof useMessage>;
|
||||
let toast: ReturnType<typeof useToast>;
|
||||
|
||||
beforeEach(async () => {
|
||||
pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
router = useRouter();
|
||||
projectsStore = useProjectsStore();
|
||||
projectsStore = mockedStore(useProjectsStore);
|
||||
workflowsStore = mockedStore(useWorkflowsStore);
|
||||
message = useMessage();
|
||||
toast = useToast();
|
||||
|
||||
windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null);
|
||||
});
|
||||
|
||||
@@ -171,6 +202,110 @@ describe('WorkflowCard', () => {
|
||||
expect(actions).toHaveTextContent('Change owner');
|
||||
});
|
||||
|
||||
it("should have 'Archive' action on non archived workflows", async () => {
|
||||
const data = createWorkflow({
|
||||
isArchived: false,
|
||||
scopes: ['workflow:delete'],
|
||||
});
|
||||
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: { data },
|
||||
});
|
||||
const cardActions = getByTestId('workflow-card-actions');
|
||||
expect(cardActions).toBeInTheDocument();
|
||||
|
||||
const cardActionsOpener = within(cardActions).getByRole('button');
|
||||
expect(cardActionsOpener).toBeInTheDocument();
|
||||
|
||||
const controllingId = cardActionsOpener.getAttribute('aria-controls');
|
||||
await userEvent.click(cardActions);
|
||||
const actions = document.querySelector(`#${controllingId}`);
|
||||
if (!actions) {
|
||||
throw new Error('Actions menu not found');
|
||||
}
|
||||
expect(actions).toHaveTextContent('Archive');
|
||||
expect(actions).not.toHaveTextContent('Unarchive');
|
||||
expect(actions).not.toHaveTextContent('Delete');
|
||||
|
||||
await userEvent.click(getByTestId('action-archive'));
|
||||
|
||||
expect(message.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.archiveWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.archiveWorkflow).toHaveBeenCalledWith(data.id);
|
||||
expect(toast.showError).toHaveBeenCalledTimes(0);
|
||||
expect(toast.showMessage).toHaveBeenCalledTimes(1);
|
||||
expect(emitted()['workflow:archived']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should have 'Unarchive' action on archived workflows", async () => {
|
||||
const data = createWorkflow({
|
||||
isArchived: true,
|
||||
scopes: ['workflow:delete'],
|
||||
});
|
||||
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: { data },
|
||||
});
|
||||
const cardActions = getByTestId('workflow-card-actions');
|
||||
expect(cardActions).toBeInTheDocument();
|
||||
|
||||
const cardActionsOpener = within(cardActions).getByRole('button');
|
||||
expect(cardActionsOpener).toBeInTheDocument();
|
||||
|
||||
const controllingId = cardActionsOpener.getAttribute('aria-controls');
|
||||
await userEvent.click(cardActions);
|
||||
const actions = document.querySelector(`#${controllingId}`);
|
||||
if (!actions) {
|
||||
throw new Error('Actions menu not found');
|
||||
}
|
||||
expect(actions).not.toHaveTextContent('Archive');
|
||||
expect(actions).toHaveTextContent('Unarchive');
|
||||
expect(actions).toHaveTextContent('Delete');
|
||||
|
||||
await userEvent.click(getByTestId('action-unarchive'));
|
||||
|
||||
expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledWith(data.id);
|
||||
expect(toast.showError).toHaveBeenCalledTimes(0);
|
||||
expect(toast.showMessage).toHaveBeenCalledTimes(1);
|
||||
expect(emitted()['workflow:unarchived']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should show 'Delete' action on archived workflows", async () => {
|
||||
const data = createWorkflow({
|
||||
isArchived: true,
|
||||
scopes: ['workflow:delete'],
|
||||
});
|
||||
|
||||
const { getByTestId, emitted } = renderComponent({
|
||||
props: { data },
|
||||
});
|
||||
const cardActions = getByTestId('workflow-card-actions');
|
||||
expect(cardActions).toBeInTheDocument();
|
||||
|
||||
const cardActionsOpener = within(cardActions).getByRole('button');
|
||||
expect(cardActionsOpener).toBeInTheDocument();
|
||||
|
||||
const controllingId = cardActionsOpener.getAttribute('aria-controls');
|
||||
await userEvent.click(cardActions);
|
||||
const actions = document.querySelector(`#${controllingId}`);
|
||||
if (!actions) {
|
||||
throw new Error('Actions menu not found');
|
||||
}
|
||||
expect(actions).not.toHaveTextContent('Archive');
|
||||
expect(actions).toHaveTextContent('Unarchive');
|
||||
expect(actions).toHaveTextContent('Delete');
|
||||
|
||||
await userEvent.click(getByTestId('action-delete'));
|
||||
|
||||
expect(message.confirm).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.deleteWorkflow).toHaveBeenCalledTimes(1);
|
||||
expect(workflowsStore.deleteWorkflow).toHaveBeenCalledWith(data.id);
|
||||
expect(toast.showError).toHaveBeenCalledTimes(0);
|
||||
expect(toast.showMessage).toHaveBeenCalledTimes(1);
|
||||
expect(emitted()['workflow:deleted']).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should show Read only mode', async () => {
|
||||
const data = createWorkflow();
|
||||
const { getByRole } = renderComponent({ props: { data } });
|
||||
@@ -178,4 +313,18 @@ describe('WorkflowCard', () => {
|
||||
const heading = getByRole('heading');
|
||||
expect(heading).toHaveTextContent('Read only');
|
||||
});
|
||||
|
||||
it('should show Archived badge on archived workflows', async () => {
|
||||
const data = createWorkflow({ isArchived: true });
|
||||
const { getByTestId } = renderComponent({ props: { data } });
|
||||
|
||||
expect(getByTestId('workflow-archived-tag')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show Archived badge on non archived workflows', async () => {
|
||||
const data = createWorkflow({ isArchived: false });
|
||||
const { queryByTestId } = renderComponent({ props: { data } });
|
||||
|
||||
expect(queryByTestId('workflow-archived-tag')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ const WORKFLOW_LIST_ITEM_ACTIONS = {
|
||||
SHARE: 'share',
|
||||
DUPLICATE: 'duplicate',
|
||||
DELETE: 'delete',
|
||||
ARCHIVE: 'archive',
|
||||
UNARCHIVE: 'unarchive',
|
||||
MOVE: 'move',
|
||||
MOVE_TO_FOLDER: 'moveToFolder',
|
||||
};
|
||||
@@ -57,6 +59,8 @@ const emit = defineEmits<{
|
||||
'expand:tags': [];
|
||||
'click:tag': [tagId: string, e: PointerEvent];
|
||||
'workflow:deleted': [];
|
||||
'workflow:archived': [];
|
||||
'workflow:unarchived': [];
|
||||
'workflow:active-toggle': [value: { id: string; active: boolean }];
|
||||
'action:move-to-folder': [value: { id: string; name: string; parentFolderId?: string }];
|
||||
}>();
|
||||
@@ -129,7 +133,7 @@ const actions = computed(() => {
|
||||
},
|
||||
];
|
||||
|
||||
if (workflowPermissions.value.create && !props.readOnly) {
|
||||
if (workflowPermissions.value.create && !props.readOnly && !props.data.isArchived) {
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.duplicate'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
|
||||
@@ -151,10 +155,21 @@ const actions = computed(() => {
|
||||
}
|
||||
|
||||
if (workflowPermissions.value.delete && !props.readOnly) {
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.delete'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
|
||||
});
|
||||
if (!props.data.isArchived) {
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.archive'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.ARCHIVE,
|
||||
});
|
||||
} else {
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.delete'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
|
||||
});
|
||||
items.push({
|
||||
label: locale.baseText('workflows.item.unarchive'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.UNARCHIVE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
@@ -234,6 +249,12 @@ async function onAction(action: string) {
|
||||
case WORKFLOW_LIST_ITEM_ACTIONS.DELETE:
|
||||
await deleteWorkflow();
|
||||
break;
|
||||
case WORKFLOW_LIST_ITEM_ACTIONS.ARCHIVE:
|
||||
await archiveWorkflow();
|
||||
break;
|
||||
case WORKFLOW_LIST_ITEM_ACTIONS.UNARCHIVE:
|
||||
await unarchiveWorkflow();
|
||||
break;
|
||||
case WORKFLOW_LIST_ITEM_ACTIONS.MOVE:
|
||||
moveResource();
|
||||
break;
|
||||
@@ -277,12 +298,68 @@ async function deleteWorkflow() {
|
||||
|
||||
// Reset tab title since workflow is deleted.
|
||||
toast.showMessage({
|
||||
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title'),
|
||||
title: locale.baseText('mainSidebar.showMessage.handleSelect1.title', {
|
||||
interpolate: { workflowName: props.data.name },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
emit('workflow:deleted');
|
||||
}
|
||||
|
||||
async function archiveWorkflow() {
|
||||
const archiveConfirmed = await message.confirm(
|
||||
locale.baseText('mainSidebar.confirmMessage.workflowArchive.message', {
|
||||
interpolate: { workflowName: props.data.name },
|
||||
}),
|
||||
locale.baseText('mainSidebar.confirmMessage.workflowArchive.headline'),
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: locale.baseText(
|
||||
'mainSidebar.confirmMessage.workflowArchive.confirmButtonText',
|
||||
),
|
||||
cancelButtonText: locale.baseText(
|
||||
'mainSidebar.confirmMessage.workflowArchive.cancelButtonText',
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
if (archiveConfirmed !== MODAL_CONFIRM) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workflowsStore.archiveWorkflow(props.data.id);
|
||||
} catch (error) {
|
||||
toast.showError(error, locale.baseText('generic.archiveWorkflowError'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.showMessage({
|
||||
title: locale.baseText('mainSidebar.showMessage.handleArchive.title', {
|
||||
interpolate: { workflowName: props.data.name },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
emit('workflow:archived');
|
||||
}
|
||||
|
||||
async function unarchiveWorkflow() {
|
||||
try {
|
||||
await workflowsStore.unarchiveWorkflow(props.data.id);
|
||||
} catch (error) {
|
||||
toast.showError(error, locale.baseText('generic.unarchiveWorkflowError'));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.showMessage({
|
||||
title: locale.baseText('mainSidebar.showMessage.handleUnarchive.title', {
|
||||
interpolate: { workflowName: props.data.name },
|
||||
}),
|
||||
type: 'success',
|
||||
});
|
||||
emit('workflow:unarchived');
|
||||
}
|
||||
|
||||
const fetchHiddenBreadCrumbsItems = async () => {
|
||||
if (!props.data.homeProject?.id || !projectName.value || !props.data.parentFolder) {
|
||||
hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]);
|
||||
@@ -331,6 +408,15 @@ const onBreadcrumbItemClick = async (item: PathItem) => {
|
||||
<N8nBadge v-if="!workflowPermissions.update" class="ml-3xs" theme="tertiary" bold>
|
||||
{{ locale.baseText('workflows.item.readonly') }}
|
||||
</N8nBadge>
|
||||
<N8nBadge
|
||||
v-if="data.isArchived"
|
||||
class="ml-3xs"
|
||||
theme="tertiary"
|
||||
bold
|
||||
data-test-id="workflow-archived-tag"
|
||||
>
|
||||
{{ locale.baseText('workflows.item.archived') }}
|
||||
</N8nBadge>
|
||||
</n8n-text>
|
||||
</template>
|
||||
<div :class="$style.cardDescription">
|
||||
@@ -388,6 +474,7 @@ const onBreadcrumbItemClick = async (item: PathItem) => {
|
||||
</ProjectCardBadge>
|
||||
<WorkflowActivator
|
||||
class="mr-s"
|
||||
:is-archived="data.isArchived"
|
||||
:workflow-active="data.active"
|
||||
:workflow-id="data.id"
|
||||
:workflow-permissions="workflowPermissions"
|
||||
|
||||
@@ -60,6 +60,7 @@ describe('WorkflowSettingsVue', () => {
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
isArchived: false,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
createdAt: 1,
|
||||
@@ -71,6 +72,7 @@ describe('WorkflowSettingsVue', () => {
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
isArchived: false,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
createdAt: 1,
|
||||
@@ -273,6 +275,7 @@ describe('WorkflowSettingsVue', () => {
|
||||
id: '1',
|
||||
name: 'Test Workflow',
|
||||
active: true,
|
||||
isArchived: false,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
createdAt: 1,
|
||||
|
||||
@@ -22,7 +22,6 @@ type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders';
|
||||
|
||||
export type FolderResource = BaseFolderItem & {
|
||||
resourceType: 'folder';
|
||||
readOnly: boolean;
|
||||
};
|
||||
|
||||
export type WorkflowResource = BaseResource & {
|
||||
@@ -30,6 +29,7 @@ export type WorkflowResource = BaseResource & {
|
||||
updatedAt: string;
|
||||
createdAt: string;
|
||||
active: boolean;
|
||||
isArchived: boolean;
|
||||
homeProject?: ProjectSharingData;
|
||||
scopes?: Scope[];
|
||||
tags?: ITag[] | string[];
|
||||
|
||||
@@ -398,6 +398,7 @@ const testWorkflow: IWorkflowDb = {
|
||||
id: 'MokOcBHON6KkPq6Y',
|
||||
name: 'My Sub-Workflow 3',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: -1,
|
||||
updatedAt: -1,
|
||||
connections: {
|
||||
|
||||
@@ -57,7 +57,8 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) =
|
||||
() =>
|
||||
sourceControlStore.preferences.branchReadOnly ||
|
||||
uiStore.isReadOnlyView ||
|
||||
!workflowPermissions.value.update,
|
||||
!workflowPermissions.value.update ||
|
||||
workflowsStore.workflow.isArchived,
|
||||
);
|
||||
|
||||
const targetNodeIds = computed(() => {
|
||||
|
||||
@@ -1161,6 +1161,7 @@ export function useWorkflowHelpers(options: { router: ReturnType<typeof useRoute
|
||||
function initState(workflowData: IWorkflowDb) {
|
||||
workflowsStore.addWorkflow(workflowData);
|
||||
workflowsStore.setActive(workflowData.active || false);
|
||||
workflowsStore.setIsArchived(workflowData.isArchived);
|
||||
workflowsStore.setWorkflowId(workflowData.id);
|
||||
workflowsStore.setWorkflowName({
|
||||
newName: workflowData.name,
|
||||
|
||||
@@ -32,9 +32,8 @@ export function useWorkflowSaving({ router }: { router: ReturnType<typeof useRou
|
||||
cancel?: () => Promise<void>;
|
||||
} = {},
|
||||
) {
|
||||
if (!uiStore.stateIsDirty) {
|
||||
if (!uiStore.stateIsDirty || workflowsStore.workflow.isArchived) {
|
||||
next();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -614,6 +614,8 @@ export const enum WORKFLOW_MENU_ACTIONS {
|
||||
PUSH = 'push',
|
||||
SETTINGS = 'settings',
|
||||
DELETE = 'delete',
|
||||
ARCHIVE = 'archive',
|
||||
UNARCHIVE = 'unarchive',
|
||||
SWITCH_NODE_VIEW_VERSION = 'switch-node-view-version',
|
||||
RENAME = 'rename',
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@
|
||||
"generic.create": "Create",
|
||||
"generic.create.workflow": "Create Workflow",
|
||||
"generic.deleteWorkflowError": "Problem deleting workflow",
|
||||
"generic.archiveWorkflowError": "Problem archiving workflow",
|
||||
"generic.unarchiveWorkflowError": "Problem unarchiving workflow",
|
||||
"generic.filtersApplied": "Filters are currently applied.",
|
||||
"generic.field": "field",
|
||||
"generic.fields": "fields",
|
||||
@@ -1016,6 +1018,10 @@
|
||||
"logs.details.body.multipleInputs": "Multiple inputs. View them by {button}",
|
||||
"logs.details.body.multipleInputs.openingTheNode": "opening the node",
|
||||
"mainSidebar.aboutN8n": "About n8n",
|
||||
"mainSidebar.confirmMessage.workflowArchive.cancelButtonText": "",
|
||||
"mainSidebar.confirmMessage.workflowArchive.confirmButtonText": "Yes, archive",
|
||||
"mainSidebar.confirmMessage.workflowArchive.headline": "Archive Workflow?",
|
||||
"mainSidebar.confirmMessage.workflowArchive.message": "Are you sure that you want to archive '{workflowName}'?",
|
||||
"mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "",
|
||||
"mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete",
|
||||
"mainSidebar.confirmMessage.workflowDelete.headline": "Delete Workflow?",
|
||||
@@ -1040,9 +1046,11 @@
|
||||
"mainSidebar.showError.stopExecution.title": "Problem stopping execution",
|
||||
"mainSidebar.showMessage.handleFileImport.message": "The file does not contain valid JSON data",
|
||||
"mainSidebar.showMessage.handleFileImport.title": "Could not import file",
|
||||
"mainSidebar.showMessage.handleSelect1.title": "Workflow deleted",
|
||||
"mainSidebar.showMessage.handleSelect1.title": "Workflow '{workflowName}' deleted",
|
||||
"mainSidebar.showMessage.handleSelect2.title": "Workflow created",
|
||||
"mainSidebar.showMessage.handleSelect3.title": "Workflow created",
|
||||
"mainSidebar.showMessage.handleArchive.title": "Workflow '{workflowName}' archived",
|
||||
"mainSidebar.showMessage.handleUnarchive.title": "Workflow '{workflowName}' unarchived",
|
||||
"mainSidebar.showMessage.stopExecution.title": "Execution stopped",
|
||||
"mainSidebar.templates": "Templates",
|
||||
"mainSidebar.workflows": "Workflows",
|
||||
@@ -1056,6 +1064,8 @@
|
||||
"menuActions.importFromUrl": "Import from URL...",
|
||||
"menuActions.importFromFile": "Import from File...",
|
||||
"menuActions.delete": "Delete",
|
||||
"menuActions.archive": "Archive",
|
||||
"menuActions.unarchive": "Unarchive",
|
||||
"multipleParameter.addItem": "Add item",
|
||||
"multipleParameter.currentlyNoItemsExist": "Currently no items exist",
|
||||
"multipleParameter.deleteItem": "Delete item",
|
||||
@@ -2334,6 +2344,7 @@
|
||||
"workflowActivator.showMessage.displayActivationError.title": "Problem activating workflow",
|
||||
"workflowActivator.theWorkflowIsSetToBeActiveBut": "The workflow is activated but could not be started.<br />Click to display error message.",
|
||||
"workflowActivator.thisWorkflowHasNoTriggerNodes": "This workflow has no trigger nodes that require activation",
|
||||
"workflowActivator.thisWorkflowIsArchived": "This workflow is archived so it cannot be activated",
|
||||
"workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode": "'Execute Workflow Trigger' doesn't require activation as it is triggered by another workflow",
|
||||
"workflowDetails.share": "Share",
|
||||
"workflowDetails.active": "Active",
|
||||
@@ -2466,11 +2477,14 @@
|
||||
"workflows.item.share": "Share...",
|
||||
"workflows.item.duplicate": "Duplicate",
|
||||
"workflows.item.delete": "Delete",
|
||||
"workflows.item.archive": "Archive",
|
||||
"workflows.item.unarchive": "Unarchive",
|
||||
"workflows.item.move": "Move",
|
||||
"workflows.item.changeOwner": "Change owner",
|
||||
"workflows.item.updated": "Last updated",
|
||||
"workflows.item.created": "Created",
|
||||
"workflows.item.readonly": "Read only",
|
||||
"workflows.item.archived": "Archived",
|
||||
"workflows.search.placeholder": "Search",
|
||||
"workflows.filters": "Filters",
|
||||
"workflows.filters.tags": "Tags",
|
||||
@@ -2478,6 +2492,7 @@
|
||||
"workflows.filters.status.all": "All",
|
||||
"workflows.filters.status.active": "Active",
|
||||
"workflows.filters.status.deactivated": "Deactivated",
|
||||
"workflows.filters.showArchived": "Show archived workflows",
|
||||
"workflows.filters.ownedBy": "Owned by",
|
||||
"workflows.filters.sharedWith": "Shared with",
|
||||
"workflows.filters.apply": "Apply filters",
|
||||
|
||||
@@ -98,7 +98,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => {
|
||||
async function fetchTotalWorkflowsAndFoldersCount(projectId?: string): Promise<number> {
|
||||
const { count } = await workflowsApi.getWorkflowsAndFolders(
|
||||
rootStore.restApiContext,
|
||||
{ projectId },
|
||||
{ projectId, isArchived: false },
|
||||
{ skip: 0, take: 1 },
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -853,6 +853,84 @@ describe('useWorkflowsStore', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('archiveWorkflow', () => {
|
||||
it('should call the API to archive the workflow', async () => {
|
||||
const workflowId = '1';
|
||||
const versionId = '00000000-0000-0000-0000-000000000000';
|
||||
const updatedVersionId = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
workflowsStore.workflowsById = {
|
||||
'1': { active: true, isArchived: false, versionId } as IWorkflowDb,
|
||||
};
|
||||
workflowsStore.workflow.active = true;
|
||||
workflowsStore.workflow.isArchived = false;
|
||||
workflowsStore.workflow.id = workflowId;
|
||||
workflowsStore.workflow.versionId = versionId;
|
||||
|
||||
const makeRestApiRequestSpy = vi
|
||||
.spyOn(apiUtils, 'makeRestApiRequest')
|
||||
.mockImplementation(async () => ({
|
||||
versionId: updatedVersionId,
|
||||
}));
|
||||
|
||||
await workflowsStore.archiveWorkflow(workflowId);
|
||||
|
||||
expect(workflowsStore.workflowsById['1'].active).toBe(false);
|
||||
expect(workflowsStore.workflowsById['1'].isArchived).toBe(true);
|
||||
expect(workflowsStore.workflowsById['1'].versionId).toBe(updatedVersionId);
|
||||
expect(workflowsStore.workflow.active).toBe(false);
|
||||
expect(workflowsStore.workflow.isArchived).toBe(true);
|
||||
expect(workflowsStore.workflow.versionId).toBe(updatedVersionId);
|
||||
expect(makeRestApiRequestSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: '/rest',
|
||||
pushRef: expect.any(String),
|
||||
}),
|
||||
'POST',
|
||||
`/workflows/${workflowId}/archive`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unarchiveWorkflow', () => {
|
||||
it('should call the API to unarchive the workflow', async () => {
|
||||
const workflowId = '1';
|
||||
const versionId = '00000000-0000-0000-0000-000000000000';
|
||||
const updatedVersionId = '11111111-1111-1111-1111-111111111111';
|
||||
|
||||
workflowsStore.workflowsById = {
|
||||
'1': { active: false, isArchived: true, versionId } as IWorkflowDb,
|
||||
};
|
||||
workflowsStore.workflow.active = false;
|
||||
workflowsStore.workflow.isArchived = true;
|
||||
workflowsStore.workflow.id = workflowId;
|
||||
workflowsStore.workflow.versionId = versionId;
|
||||
|
||||
const makeRestApiRequestSpy = vi
|
||||
.spyOn(apiUtils, 'makeRestApiRequest')
|
||||
.mockImplementation(async () => ({
|
||||
versionId: updatedVersionId,
|
||||
}));
|
||||
|
||||
await workflowsStore.unarchiveWorkflow(workflowId);
|
||||
|
||||
expect(workflowsStore.workflowsById['1'].active).toBe(false);
|
||||
expect(workflowsStore.workflowsById['1'].isArchived).toBe(false);
|
||||
expect(workflowsStore.workflowsById['1'].versionId).toBe(updatedVersionId);
|
||||
expect(workflowsStore.workflow.active).toBe(false);
|
||||
expect(workflowsStore.workflow.isArchived).toBe(false);
|
||||
expect(workflowsStore.workflow.versionId).toBe(updatedVersionId);
|
||||
expect(makeRestApiRequestSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseUrl: '/rest',
|
||||
pushRef: expect.any(String),
|
||||
}),
|
||||
'POST',
|
||||
`/workflows/${workflowId}/unarchive`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function getMockEditFieldsNode() {
|
||||
@@ -886,6 +964,7 @@ function generateMockExecutionEvents() {
|
||||
nodes: [],
|
||||
connections: {},
|
||||
active: false,
|
||||
isArchived: false,
|
||||
versionId: '1',
|
||||
},
|
||||
finished: false,
|
||||
|
||||
@@ -99,6 +99,7 @@ import type { NodeExecuteBefore } from '@n8n/api-types/push/execution';
|
||||
const defaults: Omit<IWorkflowDb, 'id'> & { settings: NonNullable<IWorkflowDb['settings']> } = {
|
||||
name: '',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: -1,
|
||||
updatedAt: -1,
|
||||
connections: {},
|
||||
@@ -541,7 +542,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
page = 1,
|
||||
pageSize = DEFAULT_WORKFLOW_PAGE_SIZE,
|
||||
sortBy?: string,
|
||||
filters: { name?: string; tags?: string[]; active?: boolean; parentFolderId?: string } = {},
|
||||
filters: {
|
||||
name?: string;
|
||||
tags?: string[];
|
||||
active?: boolean;
|
||||
isArchived?: boolean;
|
||||
parentFolderId?: string;
|
||||
} = {},
|
||||
includeFolders: boolean = false,
|
||||
): Promise<WorkflowListResource[]> {
|
||||
const filter = { ...filters, projectId };
|
||||
@@ -734,6 +741,42 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
workflowsById.value = workflows;
|
||||
}
|
||||
|
||||
async function archiveWorkflow(id: string) {
|
||||
const updatedWorkflow = await makeRestApiRequest<IWorkflowDb>(
|
||||
rootStore.restApiContext,
|
||||
'POST',
|
||||
`/workflows/${id}/archive`,
|
||||
);
|
||||
if (workflowsById.value[id]) {
|
||||
workflowsById.value[id].isArchived = true;
|
||||
workflowsById.value[id].versionId = updatedWorkflow.versionId;
|
||||
}
|
||||
|
||||
setWorkflowInactive(id);
|
||||
|
||||
if (id === workflow.value.id) {
|
||||
setIsArchived(true);
|
||||
setWorkflowVersionId(updatedWorkflow.versionId);
|
||||
}
|
||||
}
|
||||
|
||||
async function unarchiveWorkflow(id: string) {
|
||||
const updatedWorkflow = await makeRestApiRequest<IWorkflowDb>(
|
||||
rootStore.restApiContext,
|
||||
'POST',
|
||||
`/workflows/${id}/unarchive`,
|
||||
);
|
||||
if (workflowsById.value[id]) {
|
||||
workflowsById.value[id].isArchived = false;
|
||||
workflowsById.value[id].versionId = updatedWorkflow.versionId;
|
||||
}
|
||||
|
||||
if (id === workflow.value.id) {
|
||||
setIsArchived(false);
|
||||
setWorkflowVersionId(updatedWorkflow.versionId);
|
||||
}
|
||||
}
|
||||
|
||||
function addWorkflow(workflow: IWorkflowDb) {
|
||||
workflowsById.value = {
|
||||
...workflowsById.value,
|
||||
@@ -781,6 +824,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
workflow.value.active = active;
|
||||
}
|
||||
|
||||
function setIsArchived(isArchived: boolean) {
|
||||
workflow.value.isArchived = isArchived;
|
||||
}
|
||||
|
||||
async function getDuplicateCurrentWorkflowName(currentWorkflowName: string): Promise<string> {
|
||||
if (
|
||||
currentWorkflowName &&
|
||||
@@ -1849,11 +1896,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => {
|
||||
replaceInvalidWorkflowCredentials,
|
||||
setWorkflows,
|
||||
deleteWorkflow,
|
||||
archiveWorkflow,
|
||||
unarchiveWorkflow,
|
||||
addWorkflow,
|
||||
setWorkflowActive,
|
||||
setWorkflowInactive,
|
||||
fetchActiveWorkflows,
|
||||
setActive,
|
||||
setIsArchived,
|
||||
getDuplicateCurrentWorkflowName,
|
||||
setWorkflowExecutionData,
|
||||
setWorkflowExecutionRunData,
|
||||
|
||||
@@ -258,7 +258,8 @@ const isCanvasReadOnly = computed(() => {
|
||||
return (
|
||||
isDemoRoute.value ||
|
||||
isReadOnlyEnvironment.value ||
|
||||
!(workflowPermissions.value.update ?? projectPermissions.value.workflow.update)
|
||||
!(workflowPermissions.value.update ?? projectPermissions.value.workflow.update) ||
|
||||
editableWorkflow.value.isArchived
|
||||
);
|
||||
});
|
||||
|
||||
@@ -759,8 +760,9 @@ function onPinNodes(ids: string[], source: PinDataSource) {
|
||||
|
||||
async function onSaveWorkflow() {
|
||||
const workflowIsSaved = !uiStore.stateIsDirty;
|
||||
const workflowIsArchived = workflowsStore.workflow.isArchived;
|
||||
|
||||
if (workflowIsSaved) {
|
||||
if (workflowIsSaved || workflowIsArchived) {
|
||||
return;
|
||||
}
|
||||
const saved = await workflowHelpers.saveCurrentWorkflow();
|
||||
|
||||
@@ -16,6 +16,7 @@ describe('TestDefinitionRootView', () => {
|
||||
id: 'different-id',
|
||||
name: 'Test Workflow',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
nodes: [],
|
||||
|
||||
@@ -156,6 +156,7 @@ describe('WorkflowsView', () => {
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
tags: [TEST_TAG.name],
|
||||
isArchived: false,
|
||||
}),
|
||||
expect.any(Boolean),
|
||||
);
|
||||
@@ -176,6 +177,7 @@ describe('WorkflowsView', () => {
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
name: 'one',
|
||||
isArchived: false,
|
||||
}),
|
||||
expect.any(Boolean),
|
||||
);
|
||||
@@ -196,6 +198,7 @@ describe('WorkflowsView', () => {
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
active: true,
|
||||
isArchived: false,
|
||||
}),
|
||||
expect.any(Boolean),
|
||||
);
|
||||
@@ -216,6 +219,27 @@ describe('WorkflowsView', () => {
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
active: false,
|
||||
isArchived: false,
|
||||
}),
|
||||
expect.any(Boolean),
|
||||
);
|
||||
});
|
||||
|
||||
it('should unset isArchived filter based on query parameters', async () => {
|
||||
await router.replace({ query: { showArchived: 'true' } });
|
||||
|
||||
workflowsStore.fetchWorkflowsPage.mockResolvedValue([]);
|
||||
|
||||
renderComponent({ pinia });
|
||||
await waitAllPromises();
|
||||
|
||||
expect(workflowsStore.fetchWorkflowsPage).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
expect.any(Number),
|
||||
expect.any(Number),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
isArchived: undefined,
|
||||
}),
|
||||
expect.any(Boolean),
|
||||
);
|
||||
@@ -299,6 +323,7 @@ describe('Folders', () => {
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
active: true,
|
||||
isArchived: false,
|
||||
versionId: '1',
|
||||
homeProject: {
|
||||
id: '1',
|
||||
|
||||
@@ -72,13 +72,14 @@ const FILTERS_DEBOUNCE_TIME = 100;
|
||||
|
||||
interface Filters extends BaseFilters {
|
||||
status: string | boolean;
|
||||
showArchived: boolean;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const StatusFilter = {
|
||||
ALL: '',
|
||||
ACTIVE: 'active',
|
||||
DEACTIVATED: 'deactivated',
|
||||
ALL: '',
|
||||
};
|
||||
|
||||
/** Maps sort values from the ResourcesListLayout component to values expected by workflows endpoint */
|
||||
@@ -121,6 +122,7 @@ const filters = ref<Filters>({
|
||||
search: '',
|
||||
homeProject: '',
|
||||
status: StatusFilter.ALL,
|
||||
showArchived: false,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
@@ -281,13 +283,14 @@ const workflowListResources = computed<Resource[]>(() => {
|
||||
workflowCount: resource.workflowCount,
|
||||
subFolderCount: resource.subFolderCount,
|
||||
parentFolder: resource.parentFolder,
|
||||
} as FolderResource;
|
||||
} satisfies FolderResource;
|
||||
} else {
|
||||
return {
|
||||
resourceType: 'workflow',
|
||||
id: resource.id,
|
||||
name: resource.name,
|
||||
active: resource.active ?? false,
|
||||
isArchived: resource.isArchived,
|
||||
updatedAt: resource.updatedAt.toString(),
|
||||
createdAt: resource.createdAt.toString(),
|
||||
homeProject: resource.homeProject,
|
||||
@@ -296,7 +299,7 @@ const workflowListResources = computed<Resource[]>(() => {
|
||||
readOnly: !getResourcePermissions(resource.scopes).workflow.update,
|
||||
tags: resource.tags,
|
||||
parentFolder: resource.parentFolder,
|
||||
} as WorkflowResource;
|
||||
} satisfies WorkflowResource;
|
||||
}
|
||||
});
|
||||
return resources;
|
||||
@@ -345,6 +348,7 @@ const hasFilters = computed(() => {
|
||||
return !!(
|
||||
filters.value.search ||
|
||||
filters.value.status !== StatusFilter.ALL ||
|
||||
filters.value.showArchived ||
|
||||
filters.value.tags.length
|
||||
);
|
||||
});
|
||||
@@ -375,6 +379,7 @@ watch(
|
||||
async (newVal) => {
|
||||
currentFolderId.value = newVal as string;
|
||||
filters.value.search = '';
|
||||
saveFiltersOnQueryString();
|
||||
await fetchWorkflows();
|
||||
},
|
||||
);
|
||||
@@ -384,7 +389,7 @@ sourceControlStore.$onAction(({ name, after }) => {
|
||||
after(async () => await initialize());
|
||||
});
|
||||
|
||||
const onWorkflowDeleted = async () => {
|
||||
const refreshWorkflows = async () => {
|
||||
await Promise.all([
|
||||
fetchWorkflows(),
|
||||
foldersStore.fetchTotalWorkflowsAndFoldersCount(route.params.projectId as string | undefined),
|
||||
@@ -483,11 +488,14 @@ const fetchWorkflows = async () => {
|
||||
const tags = filters.value.tags.length
|
||||
? filters.value.tags.map((tagId) => tagsStore.tagsById[tagId]?.name)
|
||||
: [];
|
||||
|
||||
const activeFilter =
|
||||
filters.value.status === StatusFilter.ALL
|
||||
? undefined
|
||||
: filters.value.status === StatusFilter.ACTIVE;
|
||||
|
||||
const archivedFilter = filters.value.showArchived ? undefined : false;
|
||||
|
||||
// Only fetch folders if showFolders is enabled and there are not tags or active filter applied
|
||||
const fetchFolders = showFolders.value && !tags.length && activeFilter === undefined;
|
||||
|
||||
@@ -500,6 +508,7 @@ const fetchWorkflows = async () => {
|
||||
{
|
||||
name: filters.value.search || undefined,
|
||||
active: activeFilter,
|
||||
isArchived: archivedFilter,
|
||||
tags: tags.length ? tags : undefined,
|
||||
parentFolderId:
|
||||
parentFolder ??
|
||||
@@ -609,6 +618,12 @@ const saveFiltersOnQueryString = () => {
|
||||
delete currentQuery.status;
|
||||
}
|
||||
|
||||
if (filters.value.showArchived) {
|
||||
currentQuery.showArchived = 'true';
|
||||
} else {
|
||||
delete currentQuery.showArchived;
|
||||
}
|
||||
|
||||
if (filters.value.tags.length) {
|
||||
currentQuery.tags = filters.value.tags.join(',');
|
||||
} else {
|
||||
@@ -628,7 +643,7 @@ const saveFiltersOnQueryString = () => {
|
||||
|
||||
const setFiltersFromQueryString = async () => {
|
||||
const newQuery: LocationQueryRaw = { ...route.query };
|
||||
const { tags, status, search, homeProject, sort } = route.query ?? {};
|
||||
const { tags, status, search, homeProject, sort, showArchived } = route.query ?? {};
|
||||
|
||||
// Helper to check if string value is not empty
|
||||
const isValidString = (value: unknown): value is string =>
|
||||
@@ -673,8 +688,7 @@ const setFiltersFromQueryString = async () => {
|
||||
}
|
||||
|
||||
// Handle status
|
||||
const validStatusValues = ['true', 'false'];
|
||||
if (isValidString(status) && validStatusValues.includes(status)) {
|
||||
if (isValidString(status)) {
|
||||
newQuery.status = status;
|
||||
filters.value.status = status === 'true' ? StatusFilter.ACTIVE : StatusFilter.DEACTIVATED;
|
||||
} else {
|
||||
@@ -690,6 +704,14 @@ const setFiltersFromQueryString = async () => {
|
||||
delete newQuery.sort;
|
||||
}
|
||||
|
||||
if (isValidString(showArchived)) {
|
||||
newQuery.showArchived = showArchived;
|
||||
filters.value.showArchived = showArchived === 'true';
|
||||
} else {
|
||||
delete newQuery.showArchived;
|
||||
filters.value.showArchived = false;
|
||||
}
|
||||
|
||||
void router.replace({ query: newQuery });
|
||||
};
|
||||
|
||||
@@ -1531,7 +1553,9 @@ const onNameSubmit = async ({
|
||||
:show-ownership-badge="showCardsBadge"
|
||||
data-target="workflow"
|
||||
@click:tag="onClickTag"
|
||||
@workflow:deleted="onWorkflowDeleted"
|
||||
@workflow:deleted="refreshWorkflows"
|
||||
@workflow:archived="refreshWorkflows"
|
||||
@workflow:unarchived="refreshWorkflows"
|
||||
@workflow:moved="fetchWorkflows"
|
||||
@workflow:duplicated="fetchWorkflows"
|
||||
@workflow:active-toggle="onWorkflowActiveToggle"
|
||||
@@ -1623,6 +1647,14 @@ const onNameSubmit = async ({
|
||||
</N8nOption>
|
||||
</N8nSelect>
|
||||
</div>
|
||||
<div class="mb-s">
|
||||
<N8nCheckbox
|
||||
:label="i18n.baseText('workflows.filters.showArchived')"
|
||||
:model-value="filters.showArchived || false"
|
||||
data-test-id="show-archived-checkbox"
|
||||
@update:model-value="setKeyValue('showArchived', $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #postamble>
|
||||
<div
|
||||
|
||||
@@ -2262,6 +2262,7 @@ export interface IWorkflowBase {
|
||||
id: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
isArchived: boolean;
|
||||
createdAt: Date;
|
||||
startedAt?: Date;
|
||||
updatedAt: Date;
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('generateNodesGraph', () => {
|
||||
id: 'NfV4GV9aQTifSLc2',
|
||||
name: 'My workflow 26',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
@@ -157,6 +158,7 @@ describe('generateNodesGraph', () => {
|
||||
id: 'NfV4GV9aQTifSLc2',
|
||||
name: 'My workflow 26',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
nodes: [],
|
||||
connections: {},
|
||||
settings: { executionOrder: 'v1' },
|
||||
@@ -198,6 +200,7 @@ describe('generateNodesGraph', () => {
|
||||
id: 'NfV4GV9aQTifSLc2',
|
||||
name: 'My workflow 26',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
@@ -263,6 +266,7 @@ describe('generateNodesGraph', () => {
|
||||
id: 'NfV4GV9aQTifSLc2',
|
||||
name: 'My workflow 26',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
@@ -339,6 +343,7 @@ describe('generateNodesGraph', () => {
|
||||
id: 'NfV4GV9aQTifSLc2',
|
||||
name: 'My workflow 26',
|
||||
active: false,
|
||||
isArchived: false,
|
||||
nodes: [
|
||||
{
|
||||
parameters: {},
|
||||
|
||||
Reference in New Issue
Block a user