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