diff --git a/cypress/e2e/1-workflows.cy.ts b/cypress/e2e/1-workflows.cy.ts index 0b5a3069ab..af6077b703 100644 --- a/cypress/e2e/1-workflows.cy.ts +++ b/cypress/e2e/1-workflows.cy.ts @@ -62,13 +62,13 @@ describe('Workflows', () => { cy.contains('No workflows found').should('be.visible'); }); - it('should delete all the workflows', () => { + it('should archive all the workflows', () => { WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 1); for (let i = 0; i < multipleWorkflowsCount + 1; i++) { cy.getByTestId('workflow-card-actions').first().click(); - WorkflowsPage.getters.workflowDeleteButton().click(); - cy.get('button').contains('delete').click(); + WorkflowsPage.getters.workflowArchiveButton().click(); + cy.get('button').contains('archive').click(); successToast().should('be.visible'); } @@ -141,4 +141,40 @@ describe('Workflows', () => { WorkflowsPage.getters.workflowActionItem('share').click(); workflowSharingModal.getters.modal().should('be.visible'); }); + + it('should delete archived workflows', () => { + cy.visit(WorkflowsPage.url); + + // Toggle "Show archived workflows" filter + WorkflowsPage.getters.workflowFilterButton().click(); + WorkflowsPage.getters.workflowArchivedCheckbox().click(); + + WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 3); + + cy.reload(); + + WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 3); + + // Archive -> Unarchive -> Archive -> Delete on the first workflow + cy.getByTestId('workflow-card-actions').first().click(); + WorkflowsPage.getters.workflowArchiveButton().click(); + cy.get('button').contains('archive').click(); + successToast().should('be.visible'); + + cy.getByTestId('workflow-card-actions').first().click(); + WorkflowsPage.getters.workflowUnarchiveButton().click(); + successToast().should('be.visible'); + + cy.getByTestId('workflow-card-actions').first().click(); + WorkflowsPage.getters.workflowArchiveButton().click(); + cy.get('button').contains('archive').click(); + successToast().should('be.visible'); + + cy.getByTestId('workflow-card-actions').first().click(); + WorkflowsPage.getters.workflowDeleteButton().click(); + cy.get('button').contains('delete').click(); + successToast().should('be.visible'); + + WorkflowsPage.getters.workflowCards().should('have.length', multipleWorkflowsCount + 2); + }); }); diff --git a/cypress/e2e/7-workflow-actions.cy.ts b/cypress/e2e/7-workflow-actions.cy.ts index 079030359a..df1aadc054 100644 --- a/cypress/e2e/7-workflow-actions.cy.ts +++ b/cypress/e2e/7-workflow-actions.cy.ts @@ -257,23 +257,103 @@ describe('Workflow Actions', () => { }).as('loadWorkflows'); }); - it('should not be able to delete unsaved workflow', () => { + it('should not be able to archive or delete unsaved workflow', () => { WorkflowPage.getters.workflowMenu().should('be.visible'); WorkflowPage.getters.workflowMenu().click(); - WorkflowPage.getters.workflowMenuItemDelete().closest('li').should('have.class', 'is-disabled'); + WorkflowPage.getters.workflowMenuItemDelete().should('not.exist'); + WorkflowPage.getters + .workflowMenuItemArchive() + .closest('li') + .should('have.class', 'is-disabled'); }); - it('should delete workflow', () => { + it('should archive workflow and then delete it', () => { WorkflowPage.actions.saveWorkflowOnButtonClick(); + WorkflowPage.getters.archivedTag().should('not.exist'); + + // Archive the workflow + WorkflowPage.getters.workflowMenu().should('be.visible'); + WorkflowPage.getters.workflowMenu().click(); + WorkflowPage.getters.workflowMenuItemArchive().click(); + WorkflowPage.actions.acceptConfirmModal(); + + successToast().should('exist'); + cy.url().should('include', WorkflowPages.url); + + // Return back to the workflow + cy.go('back'); + + WorkflowPage.getters.archivedTag().should('be.visible'); + WorkflowPage.getters.nodeCreatorPlusButton().should('not.exist'); + + // Delete the workflow WorkflowPage.getters.workflowMenu().should('be.visible'); WorkflowPage.getters.workflowMenu().click(); WorkflowPage.getters.workflowMenuItemDelete().click(); - cy.get('div[role=dialog][aria-modal=true]').should('be.visible'); - cy.get('button.btn--confirm').should('be.visible').click(); + WorkflowPage.actions.acceptConfirmModal(); successToast().should('exist'); cy.url().should('include', WorkflowPages.url); }); + it('should archive workflow and then unarchive it', () => { + WorkflowPage.actions.saveWorkflowOnButtonClick(); + WorkflowPage.getters.archivedTag().should('not.exist'); + + // Archive the workflow + WorkflowPage.getters.workflowMenu().should('be.visible'); + WorkflowPage.getters.workflowMenu().click(); + WorkflowPage.getters.workflowMenuItemArchive().click(); + WorkflowPage.actions.acceptConfirmModal(); + successToast().should('exist'); + cy.url().should('include', WorkflowPages.url); + + // Return back to the workflow + cy.go('back'); + + WorkflowPage.getters.archivedTag().should('be.visible'); + WorkflowPage.getters.nodeCreatorPlusButton().should('not.exist'); + + // Unarchive the workflow + WorkflowPage.getters.workflowMenu().should('be.visible'); + WorkflowPage.getters.workflowMenu().click(); + WorkflowPage.getters.workflowMenuItemUnarchive().click(); + successToast().should('exist'); + WorkflowPage.getters.archivedTag().should('not.exist'); + WorkflowPage.getters.nodeCreatorPlusButton().should('be.visible'); + }); + + it('should deactivate active workflow on archive', () => { + WorkflowPage.actions.addNodeToCanvas(SCHEDULE_TRIGGER_NODE_NAME); + WorkflowPage.actions.saveWorkflowOnButtonClick(); + WorkflowPage.actions.activateWorkflow(); + WorkflowPage.getters.isWorkflowActivated(); + + // Archive the workflow + WorkflowPage.getters.workflowMenu().click(); + WorkflowPage.getters.workflowMenuItemArchive().click(); + WorkflowPage.actions.acceptConfirmModal(); + successToast().should('exist'); + cy.url().should('include', WorkflowPages.url); + + // Return back to the workflow + cy.go('back'); + + WorkflowPage.getters.archivedTag().should('be.visible'); + WorkflowPage.getters.isWorkflowDeactivated(); + WorkflowPage.getters.activatorSwitch().find('input').first().should('be.disabled'); + + // Unarchive the workflow + WorkflowPage.getters.workflowMenu().should('be.visible'); + WorkflowPage.getters.workflowMenu().click(); + WorkflowPage.getters.workflowMenuItemUnarchive().click(); + successToast().should('exist'); + WorkflowPage.getters.archivedTag().should('not.exist'); + + // Activate the workflow again + WorkflowPage.actions.activateWorkflow(); + WorkflowPage.getters.isWorkflowActivated(); + }); + describe('duplicate workflow', () => { function duplicateWorkflow() { WorkflowPage.getters.workflowMenu().should('be.visible'); diff --git a/cypress/pages/workflow.ts b/cypress/pages/workflow.ts index d34109f1b2..2fdd7844ea 100644 --- a/cypress/pages/workflow.ts +++ b/cypress/pages/workflow.ts @@ -84,6 +84,8 @@ export class WorkflowPage extends BasePage { firstStepButton: () => cy.getByTestId('canvas-add-button'), isWorkflowSaved: () => this.getters.saveButton().should('match', 'span'), // In Element UI, disabled button turn into spans 🤷‍♂️ isWorkflowActivated: () => this.getters.activatorSwitch().should('have.class', 'is-checked'), + isWorkflowDeactivated: () => + this.getters.activatorSwitch().should('not.have.class', 'is-checked'), expressionModalInput: () => cy.getByTestId('expression-modal-input').find('[role=textbox]'), expressionModalOutput: () => cy.getByTestId('expression-modal-output'), @@ -117,6 +119,8 @@ export class WorkflowPage extends BasePage { workflowMenuItemImportFromFile: () => cy.getByTestId('workflow-menu-item-import-from-file'), workflowMenuItemSettings: () => cy.getByTestId('workflow-menu-item-settings'), workflowMenuItemDelete: () => cy.getByTestId('workflow-menu-item-delete'), + workflowMenuItemArchive: () => cy.getByTestId('workflow-menu-item-archive'), + workflowMenuItemUnarchive: () => cy.getByTestId('workflow-menu-item-unarchive'), workflowMenuItemGitPush: () => cy.getByTestId('workflow-menu-item-push'), // Workflow settings dialog elements workflowSettingsModal: () => cy.getByTestId('workflow-settings-dialog'), @@ -136,6 +140,7 @@ export class WorkflowPage extends BasePage { workflowSettingsSaveButton: () => cy.getByTestId('workflow-settings-save-button').find('button'), + archivedTag: () => cy.getByTestId('workflow-archived-tag'), shareButton: () => cy.getByTestId('workflow-share-button'), duplicateWorkflowModal: () => cy.getByTestId('duplicate-modal'), @@ -214,6 +219,7 @@ export class WorkflowPage extends BasePage { } return parseFloat(element.css('top')); }, + confirmModal: () => cy.get('div[role=dialog][aria-modal=true]'), }; actions = { @@ -551,5 +557,9 @@ export class WorkflowPage extends BasePage { top: +$el[0].style.top.replace('px', ''), })); }, + acceptConfirmModal: () => { + this.getters.confirmModal().should('be.visible'); + cy.get('button.btn--confirm').should('be.visible').click(); + }, }; } diff --git a/cypress/pages/workflows.ts b/cypress/pages/workflows.ts index a57d9d22ea..ab88e65646 100644 --- a/cypress/pages/workflows.ts +++ b/cypress/pages/workflows.ts @@ -36,6 +36,10 @@ export class WorkflowsPage extends BasePage { workflowCardActions: (workflowName: string) => this.getters.workflowCard(workflowName).findChildByTestId('workflow-card-actions'), workflowActionItem: (action: string) => cy.getByTestId(`action-${action}`).filter(':visible'), + workflowArchiveButton: () => + cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Archive'), + workflowUnarchiveButton: () => + cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Unarchive'), workflowDeleteButton: () => cy.getByTestId('action-toggle-dropdown').filter(':visible').contains('Delete'), workflowMoveButton: () => @@ -47,6 +51,7 @@ export class WorkflowsPage extends BasePage { workflowStatusItem: (status: string) => cy.getByTestId('status').contains(status), workflowOwnershipDropdown: () => cy.getByTestId('user-select-trigger'), workflowOwner: (email: string) => cy.getByTestId('user-email').contains(email), + workflowArchivedCheckbox: () => cy.getByTestId('show-archived-checkbox'), workflowResetFilters: () => cy.getByTestId('workflows-filter-reset'), workflowSortDropdown: () => cy.getByTestId('resources-list-sort'), workflowSortItem: (sort: string) => diff --git a/packages/@n8n/db/src/entities/workflow-entity.ts b/packages/@n8n/db/src/entities/workflow-entity.ts index e673011df4..deaea679d6 100644 --- a/packages/@n8n/db/src/entities/workflow-entity.ts +++ b/packages/@n8n/db/src/entities/workflow-entity.ts @@ -34,6 +34,16 @@ export class WorkflowEntity extends WithTimestampsAndStringId implements IWorkfl @Column() active: boolean; + /** + * Indicates whether the workflow has been soft-deleted (`true`) or not (`false`). + * + * Archived workflows can be restored (unarchived) or deleted permanently, + * and they can still be executed as sub workflow executions, but they + * cannot be activated or modified. + */ + @Column({ default: false }) + isArchived: boolean; + @JsonColumn() nodes: INode[]; diff --git a/packages/cli/src/__tests__/active-executions.test.ts b/packages/cli/src/__tests__/active-executions.test.ts index 028e9204d4..fce5d2817d 100644 --- a/packages/cli/src/__tests__/active-executions.test.ts +++ b/packages/cli/src/__tests__/active-executions.test.ts @@ -53,6 +53,7 @@ describe('ActiveExecutions', () => { id: '123', name: 'Test workflow 1', active: false, + isArchived: false, createdAt: new Date(), updatedAt: new Date(), nodes: [], diff --git a/packages/cli/src/commands/ldap/reset.ts b/packages/cli/src/commands/ldap/reset.ts index e59fe0bae0..00284b17e3 100644 --- a/packages/cli/src/commands/ldap/reset.ts +++ b/packages/cli/src/commands/ldap/reset.ts @@ -108,7 +108,7 @@ export class Reset extends BaseCommand { const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials); for (const { workflowId } of ownedSharedWorkflows) { - await Container.get(WorkflowService).delete(owner, workflowId); + await Container.get(WorkflowService).delete(owner, workflowId, true); } for (const credential of ownedCredentials) { diff --git a/packages/cli/src/controllers/users.controller.ts b/packages/cli/src/controllers/users.controller.ts index 15cf9a8b73..6741d327aa 100644 --- a/packages/cli/src/controllers/users.controller.ts +++ b/packages/cli/src/controllers/users.controller.ts @@ -238,7 +238,7 @@ export class UsersController { const ownedCredentials = ownedSharedCredentials.map(({ credentials }) => credentials); for (const { workflowId } of ownedSharedWorkflows) { - await this.workflowService.delete(userToDelete, workflowId); + await this.workflowService.delete(userToDelete, workflowId, true); } for (const credential of ownedCredentials) { diff --git a/packages/cli/src/databases/migrations/common/1745934666076-AddWorkflowArchivedColumn.ts b/packages/cli/src/databases/migrations/common/1745934666076-AddWorkflowArchivedColumn.ts new file mode 100644 index 0000000000..2ab9e2e600 --- /dev/null +++ b/packages/cli/src/databases/migrations/common/1745934666076-AddWorkflowArchivedColumn.ts @@ -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}`); + } +} diff --git a/packages/cli/src/databases/migrations/mysqldb/index.ts b/packages/cli/src/databases/migrations/mysqldb/index.ts index 586c6a9b8c..31f1de4b13 100644 --- a/packages/cli/src/databases/migrations/mysqldb/index.ts +++ b/packages/cli/src/databases/migrations/mysqldb/index.ts @@ -85,6 +85,7 @@ import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-Crea import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys'; import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount'; +import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn'; import { UpdateParentFolderIdColumn1740445074052 } from '../mysqldb/1740445074052-UpdateParentFolderIdColumn'; export const mysqlMigrations: Migration[] = [ @@ -174,4 +175,5 @@ export const mysqlMigrations: Migration[] = [ RenameAnalyticsToInsights1741167584277, AddScopesColumnToApiKeys1742918400000, AddWorkflowStatisticsRootCount1745587087521, + AddWorkflowArchivedColumn1745934666076, ]; diff --git a/packages/cli/src/databases/migrations/postgresdb/index.ts b/packages/cli/src/databases/migrations/postgresdb/index.ts index f9e1476a24..a40be8a7b3 100644 --- a/packages/cli/src/databases/migrations/postgresdb/index.ts +++ b/packages/cli/src/databases/migrations/postgresdb/index.ts @@ -85,6 +85,7 @@ import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-Crea import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; import { AddScopesColumnToApiKeys1742918400000 } from '../common/1742918400000-AddScopesColumnToApiKeys'; import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount'; +import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn'; export const postgresMigrations: Migration[] = [ InitialMigration1587669153312, @@ -172,4 +173,5 @@ export const postgresMigrations: Migration[] = [ RenameAnalyticsToInsights1741167584277, AddScopesColumnToApiKeys1742918400000, AddWorkflowStatisticsRootCount1745587087521, + AddWorkflowArchivedColumn1745934666076, ]; diff --git a/packages/cli/src/databases/migrations/sqlite/index.ts b/packages/cli/src/databases/migrations/sqlite/index.ts index aef3e8a08c..d29cb456be 100644 --- a/packages/cli/src/databases/migrations/sqlite/index.ts +++ b/packages/cli/src/databases/migrations/sqlite/index.ts @@ -82,6 +82,7 @@ import { AddErrorColumnsToTestRuns1737715421462 } from '../common/1737715421462- import { CreateAnalyticsTables1739549398681 } from '../common/1739549398681-CreateAnalyticsTables'; import { RenameAnalyticsToInsights1741167584277 } from '../common/1741167584277-RenameAnalyticsToInsights'; import { AddWorkflowStatisticsRootCount1745587087521 } from '../common/1745587087521-AddWorkflowStatisticsRootCount'; +import { AddWorkflowArchivedColumn1745934666076 } from '../common/1745934666076-AddWorkflowArchivedColumn'; const sqliteMigrations: Migration[] = [ InitialMigration1588102412422, @@ -166,6 +167,7 @@ const sqliteMigrations: Migration[] = [ RenameAnalyticsToInsights1741167584277, AddScopesColumnToApiKeys1742918400000, AddWorkflowStatisticsRootCount1745587087521, + AddWorkflowArchivedColumn1745934666076, ]; export { sqliteMigrations }; diff --git a/packages/cli/src/databases/repositories/workflow.repository.ts b/packages/cli/src/databases/repositories/workflow.repository.ts index ce6fc75e66..3f6c8e8621 100644 --- a/packages/cli/src/databases/repositories/workflow.repository.ts +++ b/packages/cli/src/databases/repositories/workflow.repository.ts @@ -394,6 +394,7 @@ export class WorkflowRepository extends Repository { ): void { this.applyNameFilter(qb, filter); this.applyActiveFilter(qb, filter); + this.applyIsArchivedFilter(qb, filter); this.applyTagsFilter(qb, filter); this.applyProjectFilter(qb, filter); this.applyParentFolderFilter(qb, filter); @@ -440,6 +441,15 @@ export class WorkflowRepository extends Repository { } } + private applyIsArchivedFilter( + qb: SelectQueryBuilder, + filter: ListQuery.Options['filter'], + ): void { + if (typeof filter?.isArchived === 'boolean') { + qb.andWhere('workflow.isArchived = :isArchived', { isArchived: filter.isArchived }); + } + } + private applyTagsFilter( qb: SelectQueryBuilder, filter: ListQuery.Options['filter'], @@ -508,6 +518,7 @@ export class WorkflowRepository extends Repository { 'workflow.id', 'workflow.name', 'workflow.active', + 'workflow.isArchived', 'workflow.createdAt', 'workflow.updatedAt', 'workflow.versionId', diff --git a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts index 9011e356b3..9426a166f3 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts @@ -106,6 +106,7 @@ export class SourceControlExportService { versionId: e.versionId, owner: owners[e.id], parentFolderId: e.parentFolder?.id ?? null, + isArchived: e.isArchived, }; this.logger.debug(`Writing workflow ${e.id} to ${fileName}`); return await fsWriteFile(fileName, JSON.stringify(sanitizedWorkflow, null, 2)); diff --git a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts index 4785420d4d..9c0df9b450 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-import.service.ee.ts @@ -310,7 +310,14 @@ export class SourceControlImportService { continue; } const existingWorkflow = existingWorkflows.find((e) => e.id === importedWorkflow.id); - importedWorkflow.active = existingWorkflow?.active ?? false; + + // Workflow's active status is not saved in the remote workflow files, and the field is missing despite + // IWorkflowToImport having it typed as boolean. Imported workflows are always inactive if they are new, + // and existing workflows use the existing workflow's active status unless they have been archived on the remote. + // In that case, we deactivate the existing workflow on pull and turn it archived. + importedWorkflow.active = existingWorkflow + ? existingWorkflow.active && !importedWorkflow.isArchived + : false; const parentFolderId = importedWorkflow.parentFolderId ?? ''; @@ -353,14 +360,17 @@ export class SourceControlImportService { // remove active pre-import workflow this.logger.debug(`Deactivating workflow id ${existingWorkflow.id}`); await workflowManager.remove(existingWorkflow.id); - // try activating the imported workflow - this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`); - await workflowManager.add(existingWorkflow.id, 'activate'); - // update the versionId of the workflow to match the imported workflow + + if (importedWorkflow.active) { + // try activating the imported workflow + this.logger.debug(`Reactivating workflow id ${existingWorkflow.id}`); + await workflowManager.add(existingWorkflow.id, 'activate'); + } } catch (e) { const error = ensureError(e); this.logger.error(`Failed to activate workflow ${existingWorkflow.id}`, { error }); } finally { + // update the versionId of the workflow to match the imported workflow await this.workflowRepository.update( { id: existingWorkflow.id }, { versionId: importedWorkflow.versionId }, @@ -639,7 +649,7 @@ export class SourceControlImportService { async deleteWorkflowsNotInWorkfolder(user: User, candidates: SourceControlledFile[]) { for (const candidate of candidates) { - await this.workflowService.delete(user, candidate.id); + await this.workflowService.delete(user, candidate.id, true); } } diff --git a/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts b/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts index fd53a30d15..7e123bfdf9 100644 --- a/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts +++ b/packages/cli/src/environments.ee/source-control/types/exportable-workflow.ts @@ -12,4 +12,5 @@ export interface ExportableWorkflow { versionId?: string; owner: ResourceOwner; parentFolderId: string | null; + isArchived: boolean; } diff --git a/packages/cli/src/eventbus/event-message-classes/index.ts b/packages/cli/src/eventbus/event-message-classes/index.ts index d7444cf3ab..fe985af4e0 100644 --- a/packages/cli/src/eventbus/event-message-classes/index.ts +++ b/packages/cli/src/eventbus/event-message-classes/index.ts @@ -59,6 +59,8 @@ export const eventNamesAudit = [ 'n8n.audit.workflow.created', 'n8n.audit.workflow.deleted', 'n8n.audit.workflow.updated', + 'n8n.audit.workflow.archived', + 'n8n.audit.workflow.unarchived', ] as const; export type EventNamesWorkflowType = (typeof eventNamesWorkflow)[number]; diff --git a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts index 55eaeb8965..aa8a979309 100644 --- a/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts +++ b/packages/cli/src/events/__tests__/log-streaming-event-relay.test.ts @@ -51,6 +51,62 @@ describe('LogStreamingEventRelay', () => { }); }); + it('should log on `workflow-archived` event', () => { + const event: RelayEventMap['workflow-archived'] = { + user: { + id: '456', + email: 'jane@n8n.io', + firstName: 'Jane', + lastName: 'Smith', + role: 'user', + }, + workflowId: 'wf789', + publicApi: false, + }; + + eventService.emit('workflow-archived', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.workflow.archived', + payload: { + userId: '456', + _email: 'jane@n8n.io', + _firstName: 'Jane', + _lastName: 'Smith', + globalRole: 'user', + workflowId: 'wf789', + }, + }); + }); + + it('should log on `workflow-unarchived` event', () => { + const event: RelayEventMap['workflow-unarchived'] = { + user: { + id: '456', + email: 'jane@n8n.io', + firstName: 'Jane', + lastName: 'Smith', + role: 'user', + }, + workflowId: 'wf789', + publicApi: false, + }; + + eventService.emit('workflow-unarchived', event); + + expect(eventBus.sendAuditEvent).toHaveBeenCalledWith({ + eventName: 'n8n.audit.workflow.unarchived', + payload: { + userId: '456', + _email: 'jane@n8n.io', + _firstName: 'Jane', + _lastName: 'Smith', + globalRole: 'user', + workflowId: 'wf789', + }, + }); + }); + it('should log on `workflow-deleted` event', () => { const event: RelayEventMap['workflow-deleted'] = { user: { diff --git a/packages/cli/src/events/maps/relay.event-map.ts b/packages/cli/src/events/maps/relay.event-map.ts index 78c7b7d0da..accf34c2e1 100644 --- a/packages/cli/src/events/maps/relay.event-map.ts +++ b/packages/cli/src/events/maps/relay.event-map.ts @@ -68,6 +68,18 @@ export type RelayEventMap = { publicApi: boolean; }; + 'workflow-archived': { + user: UserLike; + workflowId: string; + publicApi: boolean; + }; + + 'workflow-unarchived': { + user: UserLike; + workflowId: string; + publicApi: boolean; + }; + 'workflow-saved': { user: UserLike; workflow: IWorkflowDb; diff --git a/packages/cli/src/events/relays/log-streaming.event-relay.ts b/packages/cli/src/events/relays/log-streaming.event-relay.ts index 4110185ddd..6286fe8f9b 100644 --- a/packages/cli/src/events/relays/log-streaming.event-relay.ts +++ b/packages/cli/src/events/relays/log-streaming.event-relay.ts @@ -20,6 +20,8 @@ export class LogStreamingEventRelay extends EventRelay { this.setupListeners({ 'workflow-created': (event) => this.workflowCreated(event), 'workflow-deleted': (event) => this.workflowDeleted(event), + 'workflow-archived': (event) => this.workflowArchived(event), + 'workflow-unarchived': (event) => this.workflowUnarchived(event), 'workflow-saved': (event) => this.workflowSaved(event), 'workflow-pre-execute': (event) => this.workflowPreExecute(event), 'workflow-post-execute': (event) => this.workflowPostExecute(event), @@ -86,6 +88,22 @@ export class LogStreamingEventRelay extends EventRelay { }); } + @Redactable() + private workflowArchived({ user, workflowId }: RelayEventMap['workflow-archived']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.workflow.archived', + payload: { ...user, workflowId }, + }); + } + + @Redactable() + private workflowUnarchived({ user, workflowId }: RelayEventMap['workflow-unarchived']) { + void this.eventBus.sendAuditEvent({ + eventName: 'n8n.audit.workflow.unarchived', + payload: { ...user, workflowId }, + }); + } + @Redactable() private workflowSaved({ user, workflow }: RelayEventMap['workflow-saved']) { void this.eventBus.sendAuditEvent({ diff --git a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts index cfb2e47ca4..7231aec35d 100644 --- a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -61,6 +61,7 @@ describe('Execution Lifecycle Hooks', () => { id: workflowId, name: 'Test Workflow', active: true, + isArchived: false, connections: {}, nodes: [], settings: {}, diff --git a/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts b/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts index a3a314f9e5..85467893cc 100644 --- a/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts +++ b/packages/cli/src/execution-lifecycle/shared/shared-hook-functions.ts @@ -38,6 +38,7 @@ export function prepareExecutionDataForDbUpdate(parameters: { 'id', 'name', 'active', + 'isArchived', 'createdAt', 'updatedAt', 'nodes', diff --git a/packages/cli/src/external-hooks.ts b/packages/cli/src/external-hooks.ts index d8e4fa4168..6cae7572fb 100644 --- a/packages/cli/src/external-hooks.ts +++ b/packages/cli/src/external-hooks.ts @@ -68,6 +68,8 @@ type ExternalHooksMap = { 'workflow.afterUpdate': [updatedWorkflow: IWorkflowBase]; 'workflow.delete': [workflowId: string]; 'workflow.afterDelete': [workflowId: string]; + 'workflow.afterArchive': [workflowId: string]; + 'workflow.afterUnarchive': [workflowId: string]; 'workflow.preExecute': [workflow: Workflow, mode: WorkflowExecuteMode]; 'workflow.postExecute': [ diff --git a/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts b/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts index ca9d27a6dc..851069b3a8 100644 --- a/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts +++ b/packages/cli/src/middlewares/list-query/dtos/workflow.filter.dto.ts @@ -14,6 +14,11 @@ export class WorkflowFilter extends BaseFilter { @Expose() active?: boolean; + @IsBoolean() + @IsOptional() + @Expose() + isArchived?: boolean; + @IsArray() @IsString({ each: true }) @IsOptional() diff --git a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts index 8790925719..d7a67cef53 100644 --- a/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts +++ b/packages/cli/src/public-api/v1/handlers/workflows/workflows.handler.ts @@ -97,7 +97,7 @@ export = { async (req: WorkflowRequest.Get, res: express.Response): Promise => { const { id: workflowId } = req.params; - const workflow = await Container.get(WorkflowService).delete(req.user, workflowId); + const workflow = await Container.get(WorkflowService).delete(req.user, workflowId, true); if (!workflow) { // user trying to access a workflow they do not own // or workflow does not exist diff --git a/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts b/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts index a95803d8af..cc4b12866d 100644 --- a/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts +++ b/packages/cli/src/services/__tests__/workflow-statistics.service.test.ts @@ -162,6 +162,7 @@ describe('WorkflowStatisticsService', () => { id: '1', name: '', active: false, + isArchived: false, createdAt: new Date(), updatedAt: new Date(), nodes: [], @@ -191,6 +192,7 @@ describe('WorkflowStatisticsService', () => { id: '1', name: '', active: false, + isArchived: false, createdAt: new Date(), updatedAt: new Date(), nodes: [], @@ -213,6 +215,7 @@ describe('WorkflowStatisticsService', () => { id: '1', name: '', active: false, + isArchived: false, createdAt: new Date(), updatedAt: new Date(), nodes: [], diff --git a/packages/cli/src/services/project.service.ee.ts b/packages/cli/src/services/project.service.ee.ts index 02c428ccc7..82caa40e52 100644 --- a/packages/cli/src/services/project.service.ee.ts +++ b/packages/cli/src/services/project.service.ee.ts @@ -114,7 +114,7 @@ export class ProjectService { ); } else { for (const sharedWorkflow of ownedSharedWorkflows) { - await workflowService.delete(user, sharedWorkflow.workflowId); + await workflowService.delete(user, sharedWorkflow.workflowId, true); } } diff --git a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts index dd78293055..551ab455e9 100644 --- a/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts +++ b/packages/cli/src/workflows/__tests__/workflow-execution.service.test.ts @@ -234,6 +234,7 @@ describe('WorkflowExecutionService', () => { id: 'abc', name: 'test', active: false, + isArchived: false, pinData: { [pinnedTrigger.name]: [{ json: {} }], }, @@ -301,6 +302,7 @@ describe('WorkflowExecutionService', () => { id: 'abc', name: 'test', active: false, + isArchived: false, pinData: { [pinnedTrigger.name]: [{ json: {} }], }, diff --git a/packages/cli/src/workflows/workflow.request.ts b/packages/cli/src/workflows/workflow.request.ts index a9cba76bf5..334c109ab8 100644 --- a/packages/cli/src/workflows/workflow.request.ts +++ b/packages/cli/src/workflows/workflow.request.ts @@ -54,8 +54,6 @@ export declare namespace WorkflowRequest { listQueryOptions: ListQuery.Options; }; - type Delete = Get; - type Update = AuthenticatedRequest< { workflowId: string }, {}, diff --git a/packages/cli/src/workflows/workflow.service.ts b/packages/cli/src/workflows/workflow.service.ts index d7bd1b99dd..f248225a1e 100644 --- a/packages/cli/src/workflows/workflow.service.ts +++ b/packages/cli/src/workflows/workflow.service.ts @@ -382,7 +382,7 @@ export class WorkflowService { * If the user does not have the permissions to delete the workflow this does * nothing and returns void. */ - async delete(user: User, workflowId: string): Promise { + async delete(user: User, workflowId: string, force = false): Promise { await this.externalHooks.run('workflow.delete', [workflowId]); const workflow = await this.workflowFinderService.findWorkflowForUser(workflowId, user, [ @@ -393,6 +393,10 @@ export class WorkflowService { return; } + if (!workflow.isArchived && !force) { + throw new BadRequestError('Workflow must be archived before it can be deleted.'); + } + if (workflow.active) { // deactivate before deleting await this.activeWorkflowManager.remove(workflowId); @@ -414,6 +418,65 @@ export class WorkflowService { return workflow; } + async archive(user: User, workflowId: string): Promise { + 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 { + 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 { const userProjectRelations = await this.projectService.getProjectRelationsForUser(user); const shared = await this.sharedWorkflowRepository.find({ diff --git a/packages/cli/src/workflows/workflows.controller.ts b/packages/cli/src/workflows/workflows.controller.ts index 7f3d167bc5..17d039e1af 100644 --- a/packages/cli/src/workflows/workflows.controller.ts +++ b/packages/cli/src/workflows/workflows.controller.ts @@ -382,23 +382,63 @@ export class WorkflowsController { @Delete('/:workflowId') @ProjectScope('workflow:delete') - async delete(req: WorkflowRequest.Delete) { - const { workflowId } = req.params; - + async delete(req: AuthenticatedRequest, _res: Response, @Param('workflowId') workflowId: string) { const workflow = await this.workflowService.delete(req.user, workflowId); if (!workflow) { this.logger.warn('User attempted to delete a workflow without permissions', { workflowId, userId: req.user.id, }); - throw new BadRequestError( - 'Could not delete the workflow - you can only remove workflows owned by you', + throw new ForbiddenError( + 'Could not delete the workflow - workflow was not found in your projects', ); } return true; } + @Post('/:workflowId/archive') + @ProjectScope('workflow:delete') + async archive( + req: AuthenticatedRequest, + _res: Response, + @Param('workflowId') workflowId: string, + ) { + const workflow = await this.workflowService.archive(req.user, workflowId); + if (!workflow) { + this.logger.warn('User attempted to archive a workflow without permissions', { + workflowId, + userId: req.user.id, + }); + throw new ForbiddenError( + 'Could not archive the workflow - workflow was not found in your projects', + ); + } + + return workflow; + } + + @Post('/:workflowId/unarchive') + @ProjectScope('workflow:delete') + async unarchive( + req: AuthenticatedRequest, + _res: Response, + @Param('workflowId') workflowId: string, + ) { + const workflow = await this.workflowService.unarchive(req.user, workflowId); + if (!workflow) { + this.logger.warn('User attempted to unarchive a workflow without permissions', { + workflowId, + userId: req.user.id, + }); + throw new ForbiddenError( + 'Could not unarchive the workflow - workflow was not found in your projects', + ); + } + + return workflow; + } + @Post('/:workflowId/run') @ProjectScope('workflow:execute') async runManually( diff --git a/packages/cli/test/integration/shared/db/workflows.ts b/packages/cli/test/integration/shared/db/workflows.ts index b1e19d02d4..e113ecf3fb 100644 --- a/packages/cli/test/integration/shared/db/workflows.ts +++ b/packages/cli/test/integration/shared/db/workflows.ts @@ -25,10 +25,11 @@ export async function createManyWorkflows( } export function newWorkflow(attributes: Partial = {}): IWorkflowDb { - const { active, name, nodes, connections, versionId, settings } = attributes; + const { active, isArchived, name, nodes, connections, versionId, settings } = attributes; const workflowEntity = Container.get(WorkflowRepository).create({ active: active ?? false, + isArchived: isArchived ?? false, name: name ?? 'test workflow', nodes: nodes ?? [ { diff --git a/packages/cli/test/integration/workflows/workflows.controller.test.ts b/packages/cli/test/integration/workflows/workflows.controller.test.ts index c491581b39..a8961b44cb 100644 --- a/packages/cli/test/integration/workflows/workflows.controller.test.ts +++ b/packages/cli/test/integration/workflows/workflows.controller.test.ts @@ -2328,9 +2328,197 @@ describe('POST /workflows/:workflowId/run', () => { }); }); -describe('DELETE /workflows/:workflowId', () => { - test('deletes a workflow owned by the user', async () => { +describe('POST /workflows/:workflowId/archive', () => { + test('should archive workflow', async () => { const workflow = await createWorkflow({}, owner); + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/archive`) + .send() + .expect(200); + + const { + data: { isArchived, versionId }, + } = response.body; + + expect(isArchived).toBe(true); + expect(versionId).not.toBe(workflow.versionId); + + const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id); + expect(updatedWorkflow).not.toBeNull(); + expect(updatedWorkflow!.isArchived).toBe(true); + }); + + test('should deactivate active workflow on archive', async () => { + const workflow = await createWorkflow({ active: true }, owner); + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/archive`) + .send() + .expect(200); + + const { + data: { isArchived, versionId, active }, + } = response.body; + + expect(isArchived).toBe(true); + expect(active).toBe(false); + expect(versionId).not.toBe(workflow.versionId); + expect(activeWorkflowManagerLike.remove).toBeCalledWith(workflow.id); + + const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id); + expect(updatedWorkflow).not.toBeNull(); + expect(updatedWorkflow!.isArchived).toBe(true); + }); + + test('should not archive workflow that is already archived', async () => { + const workflow = await createWorkflow({ isArchived: true }, owner); + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/archive`) + .send() + .expect(400); + + expect(response.body.message).toBe('Workflow is already archived.'); + + const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id); + expect(updatedWorkflow).not.toBeNull(); + expect(updatedWorkflow!.isArchived).toBe(true); + }); + + test('should not archive missing workflow', async () => { + const response = await authOwnerAgent.post('/workflows/404/archive').send().expect(403); + expect(response.body.message).toBe( + 'Could not archive the workflow - workflow was not found in your projects', + ); + }); + + test('should not archive a workflow that is not owned by the user', async () => { + const workflow = await createWorkflow({ isArchived: false }, member); + + await testServer + .authAgentFor(anotherMember) + .post(`/workflows/${workflow.id}/archive`) + .send() + .expect(403); + + const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowsInDb).not.toBeNull(); + expect(workflowsInDb!.isArchived).toBe(false); + expect(sharedWorkflowsInDb).toHaveLength(1); + }); + + test("should allow the owner to archive workflows they don't own", async () => { + const workflow = await createWorkflow({ isArchived: false }, member); + + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/archive`) + .send() + .expect(200); + + const { + data: { isArchived, versionId }, + } = response.body; + + expect(isArchived).toBe(true); + expect(versionId).not.toBe(workflow.versionId); + + const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowsInDb).not.toBeNull(); + expect(workflowsInDb!.isArchived).toBe(true); + expect(sharedWorkflowsInDb).toHaveLength(1); + }); +}); + +describe('POST /workflows/:workflowId/unarchive', () => { + test('should unarchive workflow', async () => { + const workflow = await createWorkflow({ isArchived: true }, owner); + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/unarchive`) + .send() + .expect(200); + + const { + data: { isArchived, versionId }, + } = response.body; + + expect(isArchived).toBe(false); + expect(versionId).not.toBe(workflow.versionId); + + const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id); + expect(updatedWorkflow).not.toBeNull(); + expect(updatedWorkflow!.isArchived).toBe(false); + }); + + test('should not unarchive workflow that is already not archived', async () => { + const workflow = await createWorkflow({ isArchived: false }, owner); + await authOwnerAgent.post(`/workflows/${workflow.id}/unarchive`).send().expect(400); + + const updatedWorkflow = await Container.get(WorkflowRepository).findById(workflow.id); + expect(updatedWorkflow).not.toBeNull(); + expect(updatedWorkflow!.isArchived).toBe(false); + }); + + test('should not unarchive missing workflow', async () => { + const response = await authOwnerAgent.post('/workflows/404/unarchive').send().expect(403); + expect(response.body.message).toBe( + 'Could not unarchive the workflow - workflow was not found in your projects', + ); + }); + + test('should not unarchive a workflow that is not owned by the user', async () => { + const workflow = await createWorkflow({ isArchived: true }, member); + + await testServer + .authAgentFor(anotherMember) + .post(`/workflows/${workflow.id}/unarchive`) + .send() + .expect(403); + + const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowsInDb).not.toBeNull(); + expect(workflowsInDb!.isArchived).toBe(true); + expect(sharedWorkflowsInDb).toHaveLength(1); + }); + + test("should allow the owner to unarchive workflows they don't own", async () => { + const workflow = await createWorkflow({ isArchived: true }, member); + + const response = await authOwnerAgent + .post(`/workflows/${workflow.id}/unarchive`) + .send() + .expect(200); + + const { + data: { isArchived, versionId }, + } = response.body; + + expect(isArchived).toBe(false); + expect(versionId).not.toBe(workflow.versionId); + + const workflowsInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowsInDb).not.toBeNull(); + expect(workflowsInDb!.isArchived).toBe(false); + expect(sharedWorkflowsInDb).toHaveLength(1); + }); +}); + +describe('DELETE /workflows/:workflowId', () => { + test('deletes an archived workflow owned by the user', async () => { + const workflow = await createWorkflow({ isArchived: true }, owner); await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200); @@ -2343,8 +2531,15 @@ describe('DELETE /workflows/:workflowId', () => { expect(sharedWorkflowsInDb).toHaveLength(0); }); - test('deletes a workflow owned by the user, even if the user is just a member', async () => { - const workflow = await createWorkflow({}, member); + test('should not delete missing workflow', async () => { + const response = await authOwnerAgent.delete('/workflows/404').send().expect(403); + expect(response.body.message).toBe( + 'Could not delete the workflow - workflow was not found in your projects', + ); + }); + + test('deletes an archived workflow owned by the user, even if the user is just a member', async () => { + const workflow = await createWorkflow({ isArchived: true }, member); await testServer.authAgentFor(member).delete(`/workflows/${workflow.id}`).send().expect(200); @@ -2357,8 +2552,23 @@ describe('DELETE /workflows/:workflowId', () => { expect(sharedWorkflowsInDb).toHaveLength(0); }); - test('does not delete a workflow that is not owned by the user', async () => { - const workflow = await createWorkflow({}, member); + test('does not delete a workflow that is not archived', async () => { + const workflow = await createWorkflow({}, owner); + + const response = await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(400); + expect(response.body.message).toBe('Workflow must be archived before it can be deleted.'); + + const workflowInDb = await Container.get(WorkflowRepository).findById(workflow.id); + const sharedWorkflowsInDb = await Container.get(SharedWorkflowRepository).findBy({ + workflowId: workflow.id, + }); + + expect(workflowInDb).not.toBeNull(); + expect(sharedWorkflowsInDb).toHaveLength(1); + }); + + test('does not delete an archived workflow that is not owned by the user', async () => { + const workflow = await createWorkflow({ isArchived: true }, member); await testServer .authAgentFor(anotherMember) @@ -2375,8 +2585,8 @@ describe('DELETE /workflows/:workflowId', () => { expect(sharedWorkflowsInDb).toHaveLength(1); }); - test("allows the owner to delete workflows they don't own", async () => { - const workflow = await createWorkflow({}, member); + test("allows the owner to delete archived workflows they don't own", async () => { + const workflow = await createWorkflow({ isArchived: true }, member); await authOwnerAgent.delete(`/workflows/${workflow.id}`).send().expect(200); diff --git a/packages/frontend/editor-ui/src/Interface.ts b/packages/frontend/editor-ui/src/Interface.ts index 502496a775..0cab3469ed 100644 --- a/packages/frontend/editor-ui/src/Interface.ts +++ b/packages/frontend/editor-ui/src/Interface.ts @@ -315,6 +315,7 @@ export interface IWorkflowDb { id: string; name: string; active: boolean; + isArchived: boolean; createdAt: number | string; updatedAt: number | string; nodes: INodeUi[]; diff --git a/packages/frontend/editor-ui/src/__tests__/mocks.ts b/packages/frontend/editor-ui/src/__tests__/mocks.ts index 66788d588a..65b7f44fcb 100644 --- a/packages/frontend/editor-ui/src/__tests__/mocks.ts +++ b/packages/frontend/editor-ui/src/__tests__/mocks.ts @@ -177,6 +177,7 @@ export function createTestWorkflow({ nodes = [], connections = {}, active = false, + isArchived = false, settings = { timezone: 'DEFAULT', executionOrder: 'v1', @@ -192,6 +193,7 @@ export function createTestWorkflow({ nodes, connections, active, + isArchived, settings, versionId: '1', meta: {}, diff --git a/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts b/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts index c15217029e..555b03e06c 100644 --- a/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts +++ b/packages/frontend/editor-ui/src/__tests__/server/factories/workflow.ts @@ -12,6 +12,9 @@ export const workflowFactory = Factory.extend({ active() { return faker.datatype.boolean(); }, + isArchived() { + return faker.datatype.boolean(); + }, nodes() { return []; }, diff --git a/packages/frontend/editor-ui/src/components/Folders/FolderCard.test.ts b/packages/frontend/editor-ui/src/components/Folders/FolderCard.test.ts index c0d4ddd750..d784873fb0 100644 --- a/packages/frontend/editor-ui/src/components/Folders/FolderCard.test.ts +++ b/packages/frontend/editor-ui/src/components/Folders/FolderCard.test.ts @@ -31,7 +31,6 @@ const DEFAULT_FOLDER: FolderResource = { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), resourceType: 'folder', - readOnly: false, workflowCount: 2, subFolderCount: 2, homeProject: { diff --git a/packages/frontend/editor-ui/src/components/InputPanel.test.ts b/packages/frontend/editor-ui/src/components/InputPanel.test.ts index fad9f143b6..77865e30f5 100644 --- a/packages/frontend/editor-ui/src/components/InputPanel.test.ts +++ b/packages/frontend/editor-ui/src/components/InputPanel.test.ts @@ -70,6 +70,7 @@ const render = (props: Partial = {}, pinData?: INodeExecutionData[], runD id: '', name: '', active: false, + isArchived: false, createdAt: '', updatedAt: '', nodes, diff --git a/packages/frontend/editor-ui/src/components/MainHeader/MainHeader.vue b/packages/frontend/editor-ui/src/components/MainHeader/MainHeader.vue index 079108e089..15a343fecd 100644 --- a/packages/frontend/editor-ui/src/components/MainHeader/MainHeader.vue +++ b/packages/frontend/editor-ui/src/components/MainHeader/MainHeader.vue @@ -251,6 +251,7 @@ function hideGithubButton() { :active="workflow.active" :read-only="readOnly" :current-folder="parentFolderForBreadcrumbs" + :is-archived="workflow.isArchived" />
diff --git a/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.test.ts b/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.test.ts index 2c9a57cd36..fc7563dfa2 100644 --- a/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.test.ts +++ b/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.test.ts @@ -1,19 +1,30 @@ import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue'; import { createComponentRenderer } from '@/__tests__/render'; -import { EnterpriseEditionFeature, STORES, WORKFLOW_SHARE_MODAL_KEY } from '@/constants'; +import { type MockedStore, mockedStore } from '@/__tests__/utils'; +import { + EnterpriseEditionFeature, + MODAL_CONFIRM, + STORES, + VIEWS, + WORKFLOW_SHARE_MODAL_KEY, +} from '@/constants'; import { createTestingPinia } from '@pinia/testing'; import userEvent from '@testing-library/user-event'; import { useUIStore } from '@/stores/ui.store'; -import { useRoute } from 'vue-router'; import type { Mock } from 'vitest'; +import { useRoute, useRouter } from 'vue-router'; +import { useMessage } from '@/composables/useMessage'; +import { useToast } from '@/composables/useToast'; +import { useWorkflowsStore } from '@/stores/workflows.store'; vi.mock('vue-router', async (importOriginal) => ({ // eslint-disable-next-line @typescript-eslint/consistent-type-imports ...(await importOriginal()), useRoute: vi.fn().mockReturnValue({}), - useRouter: vi.fn(() => ({ + useRouter: vi.fn().mockReturnValue({ replace: vi.fn(), - })), + push: vi.fn().mockResolvedValue(undefined), + }), })); vi.mock('@/stores/pushConnection.store', () => ({ @@ -22,6 +33,26 @@ vi.mock('@/stores/pushConnection.store', () => ({ }), })); +vi.mock('@/composables/useToast', () => { + const showError = vi.fn(); + const showMessage = vi.fn(); + return { + useToast: () => ({ + showError, + showMessage, + }), + }; +}); + +vi.mock('@/composables/useMessage', () => { + const confirm = vi.fn(async () => MODAL_CONFIRM); + return { + useMessage: () => ({ + confirm, + }), + }; +}); + const initialState = { [STORES.SETTINGS]: { settings: { @@ -59,17 +90,33 @@ const renderComponent = createComponentRenderer(WorkflowDetails, { }); let uiStore: ReturnType; +let workflowsStore: MockedStore; +let message: ReturnType; +let toast: ReturnType; +let router: ReturnType; + const workflow = { id: '1', name: 'Test Workflow', tags: ['1', '2'], active: false, + isArchived: false, }; describe('WorkflowDetails', () => { beforeEach(() => { uiStore = useUIStore(); + workflowsStore = mockedStore(useWorkflowsStore); + + message = useMessage(); + toast = useToast(); + router = useRouter(); }); + + afterEach(() => { + vi.clearAllMocks(); + }); + it('renders workflow name and tags', async () => { (useRoute as Mock).mockReturnValue({ query: { parentFolderId: '1' }, @@ -123,4 +170,229 @@ describe('WorkflowDetails', () => { data: { id: '1' }, }); }); + + describe('Workflow menu', () => { + beforeEach(() => { + (useRoute as Mock).mockReturnValue({ + meta: { + nodeView: true, + }, + query: { parentFolderId: '1' }, + }); + }); + + it("should have disabled 'Archive' option on new workflow", async () => { + const { getByTestId, queryByTestId } = renderComponent({ + props: { + ...workflow, + id: 'new', + readOnly: false, + isArchived: false, + scopes: ['workflow:delete'], + }, + }); + + await userEvent.click(getByTestId('workflow-menu')); + expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument(); + expect(getByTestId('workflow-menu-item-archive')).toBeInTheDocument(); + expect(getByTestId('workflow-menu-item-archive')).toHaveClass('disabled'); + }); + + it("should have 'Archive' option on non archived workflow", async () => { + const { getByTestId, queryByTestId } = renderComponent({ + props: { + ...workflow, + readOnly: false, + isArchived: false, + scopes: ['workflow:delete'], + }, + }); + + await userEvent.click(getByTestId('workflow-menu')); + expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument(); + expect(getByTestId('workflow-menu-item-archive')).toBeInTheDocument(); + expect(getByTestId('workflow-menu-item-archive')).not.toHaveClass('disabled'); + }); + + it("should not have 'Archive' option on non archived readonly workflow", async () => { + const { getByTestId, queryByTestId } = renderComponent({ + props: { + ...workflow, + readOnly: true, + isArchived: false, + scopes: ['workflow:delete'], + }, + }); + + await userEvent.click(getByTestId('workflow-menu')); + expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument(); + }); + + it("should not have 'Archive' option on non archived workflow without permission", async () => { + const { getByTestId, queryByTestId } = renderComponent({ + props: { + ...workflow, + readOnly: false, + isArchived: false, + scopes: ['workflow:update'], + }, + }); + + await userEvent.click(getByTestId('workflow-menu')); + expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument(); + }); + + it("should have 'Unarchive' and 'Delete' options on archived workflow", async () => { + const { getByTestId, queryByTestId } = renderComponent({ + props: { + ...workflow, + isArchived: true, + readOnly: false, + scopes: ['workflow:delete'], + }, + }); + + await userEvent.click(getByTestId('workflow-menu')); + expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument(); + expect(getByTestId('workflow-menu-item-delete')).toBeInTheDocument(); + expect(getByTestId('workflow-menu-item-delete')).not.toHaveClass('disabled'); + expect(getByTestId('workflow-menu-item-unarchive')).toBeInTheDocument(); + expect(getByTestId('workflow-menu-item-unarchive')).not.toHaveClass('disabled'); + }); + + it("should not have 'Unarchive' or 'Delete' options on archived readonly workflow", async () => { + const { getByTestId, queryByTestId } = renderComponent({ + props: { + ...workflow, + isArchived: true, + readOnly: true, + scopes: ['workflow:delete'], + }, + }); + + await userEvent.click(getByTestId('workflow-menu')); + expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument(); + }); + + it("should not have 'Unarchive' or 'Delete' options on archived workflow without permission", async () => { + const { getByTestId, queryByTestId } = renderComponent({ + props: { + ...workflow, + isArchived: true, + readOnly: false, + scopes: ['workflow:update'], + }, + }); + + await userEvent.click(getByTestId('workflow-menu')); + expect(queryByTestId('workflow-menu-item-archive')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-menu-item-delete')).not.toBeInTheDocument(); + expect(queryByTestId('workflow-menu-item-unarchive')).not.toBeInTheDocument(); + }); + + it("should call onWorkflowMenuSelect on 'Archive' option click", async () => { + const { getByTestId } = renderComponent({ + props: { + ...workflow, + readOnly: false, + isArchived: false, + scopes: ['workflow:delete'], + }, + }); + + workflowsStore.archiveWorkflow.mockResolvedValue(undefined); + + await userEvent.click(getByTestId('workflow-menu')); + await userEvent.click(getByTestId('workflow-menu-item-archive')); + + expect(message.confirm).toHaveBeenCalledTimes(1); + expect(toast.showError).toHaveBeenCalledTimes(0); + expect(toast.showMessage).toHaveBeenCalledTimes(1); + expect(workflowsStore.archiveWorkflow).toHaveBeenCalledTimes(1); + expect(workflowsStore.archiveWorkflow).toHaveBeenCalledWith(workflow.id); + expect(router.push).toHaveBeenCalledTimes(1); + expect(router.push).toHaveBeenCalledWith({ + name: VIEWS.WORKFLOWS, + }); + }); + + it("should call onWorkflowMenuSelect on 'Unarchive' option click", async () => { + const { getByTestId } = renderComponent({ + props: { + ...workflow, + readOnly: false, + isArchived: true, + scopes: ['workflow:delete'], + }, + }); + + await userEvent.click(getByTestId('workflow-menu')); + await userEvent.click(getByTestId('workflow-menu-item-unarchive')); + + expect(toast.showError).toHaveBeenCalledTimes(0); + expect(toast.showMessage).toHaveBeenCalledTimes(1); + expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledTimes(1); + expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledWith(workflow.id); + }); + + it("should call onWorkflowMenuSelect on 'Delete' option click", async () => { + const { getByTestId } = renderComponent({ + props: { + ...workflow, + readOnly: false, + isArchived: true, + scopes: ['workflow:delete'], + }, + }); + + await userEvent.click(getByTestId('workflow-menu')); + await userEvent.click(getByTestId('workflow-menu-item-delete')); + + expect(message.confirm).toHaveBeenCalledTimes(1); + expect(toast.showError).toHaveBeenCalledTimes(0); + expect(toast.showMessage).toHaveBeenCalledTimes(1); + expect(workflowsStore.deleteWorkflow).toHaveBeenCalledTimes(1); + expect(workflowsStore.deleteWorkflow).toHaveBeenCalledWith(workflow.id); + expect(router.push).toHaveBeenCalledTimes(1); + expect(router.push).toHaveBeenCalledWith({ + name: VIEWS.WORKFLOWS, + }); + }); + }); + + describe('Archived badge', () => { + it('should show badge on archived workflow', async () => { + const { getByTestId } = renderComponent({ + props: { + ...workflow, + readOnly: false, + isArchived: true, + scopes: ['workflow:delete'], + }, + }); + + expect(getByTestId('workflow-archived-tag')).toBeVisible(); + }); + + it('should not show badge on non archived workflow', async () => { + const { queryByTestId } = renderComponent({ + props: { + ...workflow, + readOnly: false, + isArchived: false, + scopes: ['workflow:delete'], + }, + }); + + expect(queryByTestId('workflow-archived-tag')).not.toBeInTheDocument(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.vue b/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.vue index 134c7cb9ce..1eff2ff642 100644 --- a/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.vue +++ b/packages/frontend/editor-ui/src/components/MainHeader/WorkflowDetails.vue @@ -69,6 +69,7 @@ const props = defineProps<{ scopes: IWorkflowDb['scopes']; active: IWorkflowDb['active']; currentFolder?: FolderShortInfo; + isArchived: IWorkflowDb['isArchived']; }>(); const $style = useCssModule(); @@ -144,14 +145,20 @@ const workflowMenuItems = computed(() => { label: locale.baseText('menuActions.download'), disabled: !onWorkflowPage.value, }, - { + ]; + + if (!props.readOnly && !props.isArchived) { + actions.push({ id: WORKFLOW_MENU_ACTIONS.RENAME, label: locale.baseText('generic.rename'), disabled: !onWorkflowPage.value || workflowPermissions.value.update !== true, - }, - ]; + }); + } - if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) { + if ( + (workflowPermissions.value.delete === true && !props.readOnly && !props.isArchived) || + isNewWorkflow.value + ) { actions.unshift({ id: WORKFLOW_MENU_ACTIONS.DUPLICATE, label: locale.baseText('menuActions.duplicate'), @@ -190,14 +197,29 @@ const workflowMenuItems = computed(() => { disabled: !onWorkflowPage.value || isNewWorkflow.value, }); - if ((workflowPermissions.value.delete && !props.readOnly) || isNewWorkflow.value) { - actions.push({ - id: WORKFLOW_MENU_ACTIONS.DELETE, - label: locale.baseText('menuActions.delete'), - disabled: !onWorkflowPage.value || isNewWorkflow.value, - customClass: $style.deleteItem, - divided: true, - }); + if ((workflowPermissions.value.delete === true && !props.readOnly) || isNewWorkflow.value) { + if (props.isArchived) { + actions.push({ + id: WORKFLOW_MENU_ACTIONS.UNARCHIVE, + label: locale.baseText('menuActions.unarchive'), + disabled: !onWorkflowPage.value || isNewWorkflow.value, + }); + actions.push({ + id: WORKFLOW_MENU_ACTIONS.DELETE, + label: locale.baseText('menuActions.delete'), + disabled: !onWorkflowPage.value || isNewWorkflow.value, + customClass: $style.deleteItem, + divided: true, + }); + } else { + actions.push({ + id: WORKFLOW_MENU_ACTIONS.ARCHIVE, + label: locale.baseText('menuActions.archive'), + disabled: !onWorkflowPage.value || isNewWorkflow.value, + customClass: $style.deleteItem, + divided: true, + }); + } } return actions; @@ -512,6 +534,56 @@ async function onWorkflowMenuSelect(action: WORKFLOW_MENU_ACTIONS): Promise { :preview-value="shortenedName" :is-edit-enabled="isNameEditEnabled" :max-length="MAX_WORKFLOW_NAME_LENGTH" - :disabled="readOnly || (!isNewWorkflow && !workflowPermissions.update)" + :disabled=" + readOnly || isArchived || (!isNewWorkflow && !workflowPermissions.update) + " placeholder="Enter workflow name" class="name" @toggle="onNameToggle" @@ -641,42 +717,62 @@ const onBreadcrumbsItemSelected = (item: PathItem) => { - - -
- - + {{ i18n.baseText('workflowDetails.addTag') }} - -
- + + + + + + {{ locale.baseText('workflows.item.archived') }} + + - { type="primary" :saved="!uiStore.stateIsDirty && !isNewWorkflow" :disabled=" - isWorkflowSaving || readOnly || (!isNewWorkflow && !workflowPermissions.update) + isWorkflowSaving || + readOnly || + isArchived || + (!isNewWorkflow && !workflowPermissions.update) " :is-saving="isWorkflowSaving" - :with-shortcut="!readOnly && workflowPermissions.update" + :with-shortcut="!readOnly && !isArchived && workflowPermissions.update" :shortcut-tooltip="i18n.baseText('saveWorkflowButton.hint')" data-test-id="workflow-save-button" @click="onSaveButtonClick" @@ -814,6 +913,14 @@ $--header-spacing: 20px; max-width: 460px; } +.archived { + display: flex; + align-items: center; + width: 100%; + flex: 1; + margin-right: $--header-spacing; +} + .actions { display: flex; align-items: center; diff --git a/packages/frontend/editor-ui/src/components/RunData.vue b/packages/frontend/editor-ui/src/components/RunData.vue index f58ac1ee0b..7a5da624cc 100644 --- a/packages/frontend/editor-ui/src/components/RunData.vue +++ b/packages/frontend/editor-ui/src/components/RunData.vue @@ -250,6 +250,7 @@ const { isSubNodeType } = useNodeType({ node, }); +const isArchivedWorkflow = computed(() => workflowsStore.workflow.isArchived); const isReadOnlyRoute = computed(() => route.meta.readOnlyCanvas === true); const isWaitNodeWaiting = computed(() => { return ( @@ -549,7 +550,8 @@ const pinButtonDisabled = computed( (!rawInputData.value.length && !pinnedData.hasData.value) || !!binaryData.value?.length || isReadOnlyRoute.value || - readOnlyEnv.value, + readOnlyEnv.value || + isArchivedWorkflow.value, ); const activeTaskMetadata = computed((): ITaskMetadata | null => { @@ -847,7 +849,13 @@ function showPinDataDiscoveryTooltip(value: IDataObject[]) { const pinDataDiscoveryFlag = useStorage(LOCAL_STORAGE_PIN_DATA_DISCOVERY_NDV_FLAG).value; - if (value && value.length > 0 && !isReadOnlyRoute.value && !pinDataDiscoveryFlag) { + if ( + value && + value.length > 0 && + !isReadOnlyRoute.value && + !isArchivedWorkflow.value && + !pinDataDiscoveryFlag + ) { pinDataDiscoveryComplete(); setTimeout(() => { @@ -1367,7 +1375,7 @@ defineExpose({ enterEditMode }); data-test-id="ndv-pinned-data-callout" > {{ i18n.baseText('runData.pindata.thisDataIsPinned') }} - + { it('renders correctly', () => { const renderOptions = { props: { + isArchived: false, workflowActive: false, workflowId: '1', workflowPermissions: { update: true }, @@ -50,6 +51,7 @@ describe('WorkflowActivator', () => { const { getByTestId, getByRole } = renderComponent(renderOptions); expect(getByTestId('workflow-activator-status')).toBeInTheDocument(); expect(getByRole('switch')).toBeInTheDocument(); + expect(getByRole('switch')).not.toBeDisabled(); }); it('display an inactive tooltip when there are no nodes available', async () => { @@ -57,6 +59,7 @@ describe('WorkflowActivator', () => { const { getByTestId, getByRole } = renderComponent({ props: { + isArchived: false, workflowActive: false, workflowId: '1', workflowPermissions: { update: true }, @@ -80,6 +83,7 @@ describe('WorkflowActivator', () => { const { getByTestId, getByRole } = renderComponent({ props: { + isArchived: false, workflowActive: false, workflowId: '1', workflowPermissions: { update: true }, @@ -143,6 +147,7 @@ describe('WorkflowActivator', () => { const { rerender } = renderComponent({ props: { + isArchived: false, workflowActive: false, workflowId: '1', workflowPermissions: { update: true }, @@ -210,6 +215,7 @@ describe('WorkflowActivator', () => { const { rerender } = renderComponent({ props: { + isArchived: false, workflowActive: false, workflowId: '1', workflowPermissions: { update: true }, @@ -251,6 +257,7 @@ describe('WorkflowActivator', () => { const { rerender } = renderComponent({ props: { + isArchived: false, workflowActive: false, workflowId: '1', workflowPermissions: { update: true }, @@ -261,4 +268,27 @@ describe('WorkflowActivator', () => { expect(toast.showMessage).not.toHaveBeenCalled(); }); + + it('Should be disabled on archived workflow', async () => { + const renderOptions = { + props: { + isArchived: true, + workflowActive: false, + workflowId: '1', + workflowPermissions: { update: true }, + }, + }; + + const { getByTestId, getByRole } = renderComponent(renderOptions); + expect(getByTestId('workflow-activator-status')).toBeInTheDocument(); + expect(getByRole('switch')).toBeInTheDocument(); + expect(getByRole('switch')).toBeDisabled(); + + await userEvent.hover(getByRole('switch')); + expect(getByRole('tooltip')).toBeInTheDocument(); + expect(getByRole('tooltip')).toHaveTextContent( + 'This workflow is archived so it cannot be activated', + ); + expect(getByTestId('workflow-activator-status')).toHaveTextContent('Inactive'); + }); }); diff --git a/packages/frontend/editor-ui/src/components/WorkflowActivator.vue b/packages/frontend/editor-ui/src/components/WorkflowActivator.vue index 14a6f6f232..f6c4d5ffc1 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowActivator.vue +++ b/packages/frontend/editor-ui/src/components/WorkflowActivator.vue @@ -22,6 +22,7 @@ import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers'; import { useRouter } from 'vue-router'; const props = defineProps<{ + isArchived: boolean; workflowActive: boolean; workflowId: string; workflowPermissions: PermissionsRecord['workflow']; @@ -87,6 +88,10 @@ const isNewWorkflow = computed( ); const disabled = computed((): boolean => { + if (props.isArchived) { + return true; + } + if (isNewWorkflow.value || isCurrentWorkflow.value) { return !props.workflowActive && !containsTrigger.value; } @@ -221,9 +226,11 @@ watch(
{{ i18n.baseText( - containsOnlyExecuteWorkflowTrigger - ? 'workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode' - : 'workflowActivator.thisWorkflowHasNoTriggerNodes', + isArchived + ? 'workflowActivator.thisWorkflowIsArchived' + : containsOnlyExecuteWorkflowTrigger + ? 'workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode' + : 'workflowActivator.thisWorkflowHasNoTriggerNodes', ) }}
diff --git a/packages/frontend/editor-ui/src/components/WorkflowCard.test.ts b/packages/frontend/editor-ui/src/components/WorkflowCard.test.ts index 9ed6735a8d..27940e4dc6 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowCard.test.ts +++ b/packages/frontend/editor-ui/src/components/WorkflowCard.test.ts @@ -1,13 +1,17 @@ import type { MockInstance } from 'vitest'; -import { setActivePinia, createPinia } from 'pinia'; import { waitFor, within } from '@testing-library/vue'; import userEvent from '@testing-library/user-event'; import { createComponentRenderer } from '@/__tests__/render'; -import { VIEWS } from '@/constants'; +import { type MockedStore, mockedStore } from '@/__tests__/utils'; +import { MODAL_CONFIRM, VIEWS } from '@/constants'; import WorkflowCard from '@/components/WorkflowCard.vue'; import type { IWorkflowDb } from '@/Interface'; import { useRouter } from 'vue-router'; import { useProjectsStore } from '@/stores/projects.store'; +import { useMessage } from '@/composables/useMessage'; +import { useToast } from '@/composables/useToast'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { createTestingPinia } from '@pinia/testing'; vi.mock('vue-router', () => { const push = vi.fn(); @@ -22,7 +26,29 @@ vi.mock('vue-router', () => { }; }); -const renderComponent = createComponentRenderer(WorkflowCard); +vi.mock('@/composables/useToast', () => { + const showError = vi.fn(); + const showMessage = vi.fn(); + return { + useToast: () => ({ + showError, + showMessage, + }), + }; +}); + +vi.mock('@/composables/useMessage', () => { + const confirm = vi.fn(async () => MODAL_CONFIRM); + return { + useMessage: () => ({ + confirm, + }), + }; +}); + +const renderComponent = createComponentRenderer(WorkflowCard, { + pinia: createTestingPinia({}), +}); const createWorkflow = (overrides = {}): IWorkflowDb => ({ id: '1', @@ -32,21 +58,26 @@ const createWorkflow = (overrides = {}): IWorkflowDb => ({ nodes: [], connections: {}, active: true, + isArchived: false, versionId: '1', ...overrides, }); describe('WorkflowCard', () => { - let pinia: ReturnType; let windowOpenSpy: MockInstance; let router: ReturnType; - let projectsStore: ReturnType; + let projectsStore: MockedStore; + let workflowsStore: MockedStore; + let message: ReturnType; + let toast: ReturnType; beforeEach(async () => { - pinia = createPinia(); - setActivePinia(pinia); router = useRouter(); - projectsStore = useProjectsStore(); + projectsStore = mockedStore(useProjectsStore); + workflowsStore = mockedStore(useWorkflowsStore); + message = useMessage(); + toast = useToast(); + windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null); }); @@ -171,6 +202,110 @@ describe('WorkflowCard', () => { expect(actions).toHaveTextContent('Change owner'); }); + it("should have 'Archive' action on non archived workflows", async () => { + const data = createWorkflow({ + isArchived: false, + scopes: ['workflow:delete'], + }); + + const { getByTestId, emitted } = renderComponent({ + props: { data }, + }); + const cardActions = getByTestId('workflow-card-actions'); + expect(cardActions).toBeInTheDocument(); + + const cardActionsOpener = within(cardActions).getByRole('button'); + expect(cardActionsOpener).toBeInTheDocument(); + + const controllingId = cardActionsOpener.getAttribute('aria-controls'); + await userEvent.click(cardActions); + const actions = document.querySelector(`#${controllingId}`); + if (!actions) { + throw new Error('Actions menu not found'); + } + expect(actions).toHaveTextContent('Archive'); + expect(actions).not.toHaveTextContent('Unarchive'); + expect(actions).not.toHaveTextContent('Delete'); + + await userEvent.click(getByTestId('action-archive')); + + expect(message.confirm).toHaveBeenCalledTimes(1); + expect(workflowsStore.archiveWorkflow).toHaveBeenCalledTimes(1); + expect(workflowsStore.archiveWorkflow).toHaveBeenCalledWith(data.id); + expect(toast.showError).toHaveBeenCalledTimes(0); + expect(toast.showMessage).toHaveBeenCalledTimes(1); + expect(emitted()['workflow:archived']).toHaveLength(1); + }); + + it("should have 'Unarchive' action on archived workflows", async () => { + const data = createWorkflow({ + isArchived: true, + scopes: ['workflow:delete'], + }); + + const { getByTestId, emitted } = renderComponent({ + props: { data }, + }); + const cardActions = getByTestId('workflow-card-actions'); + expect(cardActions).toBeInTheDocument(); + + const cardActionsOpener = within(cardActions).getByRole('button'); + expect(cardActionsOpener).toBeInTheDocument(); + + const controllingId = cardActionsOpener.getAttribute('aria-controls'); + await userEvent.click(cardActions); + const actions = document.querySelector(`#${controllingId}`); + if (!actions) { + throw new Error('Actions menu not found'); + } + expect(actions).not.toHaveTextContent('Archive'); + expect(actions).toHaveTextContent('Unarchive'); + expect(actions).toHaveTextContent('Delete'); + + await userEvent.click(getByTestId('action-unarchive')); + + expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledTimes(1); + expect(workflowsStore.unarchiveWorkflow).toHaveBeenCalledWith(data.id); + expect(toast.showError).toHaveBeenCalledTimes(0); + expect(toast.showMessage).toHaveBeenCalledTimes(1); + expect(emitted()['workflow:unarchived']).toHaveLength(1); + }); + + it("should show 'Delete' action on archived workflows", async () => { + const data = createWorkflow({ + isArchived: true, + scopes: ['workflow:delete'], + }); + + const { getByTestId, emitted } = renderComponent({ + props: { data }, + }); + const cardActions = getByTestId('workflow-card-actions'); + expect(cardActions).toBeInTheDocument(); + + const cardActionsOpener = within(cardActions).getByRole('button'); + expect(cardActionsOpener).toBeInTheDocument(); + + const controllingId = cardActionsOpener.getAttribute('aria-controls'); + await userEvent.click(cardActions); + const actions = document.querySelector(`#${controllingId}`); + if (!actions) { + throw new Error('Actions menu not found'); + } + expect(actions).not.toHaveTextContent('Archive'); + expect(actions).toHaveTextContent('Unarchive'); + expect(actions).toHaveTextContent('Delete'); + + await userEvent.click(getByTestId('action-delete')); + + expect(message.confirm).toHaveBeenCalledTimes(1); + expect(workflowsStore.deleteWorkflow).toHaveBeenCalledTimes(1); + expect(workflowsStore.deleteWorkflow).toHaveBeenCalledWith(data.id); + expect(toast.showError).toHaveBeenCalledTimes(0); + expect(toast.showMessage).toHaveBeenCalledTimes(1); + expect(emitted()['workflow:deleted']).toHaveLength(1); + }); + it('should show Read only mode', async () => { const data = createWorkflow(); const { getByRole } = renderComponent({ props: { data } }); @@ -178,4 +313,18 @@ describe('WorkflowCard', () => { const heading = getByRole('heading'); expect(heading).toHaveTextContent('Read only'); }); + + it('should show Archived badge on archived workflows', async () => { + const data = createWorkflow({ isArchived: true }); + const { getByTestId } = renderComponent({ props: { data } }); + + expect(getByTestId('workflow-archived-tag')).toBeInTheDocument(); + }); + + it('should not show Archived badge on non archived workflows', async () => { + const data = createWorkflow({ isArchived: false }); + const { queryByTestId } = renderComponent({ props: { data } }); + + expect(queryByTestId('workflow-archived-tag')).not.toBeInTheDocument(); + }); }); diff --git a/packages/frontend/editor-ui/src/components/WorkflowCard.vue b/packages/frontend/editor-ui/src/components/WorkflowCard.vue index 38e5da8b70..e189f39590 100644 --- a/packages/frontend/editor-ui/src/components/WorkflowCard.vue +++ b/packages/frontend/editor-ui/src/components/WorkflowCard.vue @@ -35,6 +35,8 @@ const WORKFLOW_LIST_ITEM_ACTIONS = { SHARE: 'share', DUPLICATE: 'duplicate', DELETE: 'delete', + ARCHIVE: 'archive', + UNARCHIVE: 'unarchive', MOVE: 'move', MOVE_TO_FOLDER: 'moveToFolder', }; @@ -57,6 +59,8 @@ const emit = defineEmits<{ 'expand:tags': []; 'click:tag': [tagId: string, e: PointerEvent]; 'workflow:deleted': []; + 'workflow:archived': []; + 'workflow:unarchived': []; 'workflow:active-toggle': [value: { id: string; active: boolean }]; 'action:move-to-folder': [value: { id: string; name: string; parentFolderId?: string }]; }>(); @@ -129,7 +133,7 @@ const actions = computed(() => { }, ]; - if (workflowPermissions.value.create && !props.readOnly) { + if (workflowPermissions.value.create && !props.readOnly && !props.data.isArchived) { items.push({ label: locale.baseText('workflows.item.duplicate'), value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE, @@ -151,10 +155,21 @@ const actions = computed(() => { } if (workflowPermissions.value.delete && !props.readOnly) { - items.push({ - label: locale.baseText('workflows.item.delete'), - value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE, - }); + if (!props.data.isArchived) { + items.push({ + label: locale.baseText('workflows.item.archive'), + value: WORKFLOW_LIST_ITEM_ACTIONS.ARCHIVE, + }); + } else { + items.push({ + label: locale.baseText('workflows.item.delete'), + value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE, + }); + items.push({ + label: locale.baseText('workflows.item.unarchive'), + value: WORKFLOW_LIST_ITEM_ACTIONS.UNARCHIVE, + }); + } } return items; @@ -234,6 +249,12 @@ async function onAction(action: string) { case WORKFLOW_LIST_ITEM_ACTIONS.DELETE: await deleteWorkflow(); break; + case WORKFLOW_LIST_ITEM_ACTIONS.ARCHIVE: + await archiveWorkflow(); + break; + case WORKFLOW_LIST_ITEM_ACTIONS.UNARCHIVE: + await unarchiveWorkflow(); + break; case WORKFLOW_LIST_ITEM_ACTIONS.MOVE: moveResource(); break; @@ -277,12 +298,68 @@ async function deleteWorkflow() { // Reset tab title since workflow is deleted. toast.showMessage({ - title: locale.baseText('mainSidebar.showMessage.handleSelect1.title'), + title: locale.baseText('mainSidebar.showMessage.handleSelect1.title', { + interpolate: { workflowName: props.data.name }, + }), type: 'success', }); emit('workflow:deleted'); } +async function archiveWorkflow() { + const archiveConfirmed = await message.confirm( + locale.baseText('mainSidebar.confirmMessage.workflowArchive.message', { + interpolate: { workflowName: props.data.name }, + }), + locale.baseText('mainSidebar.confirmMessage.workflowArchive.headline'), + { + type: 'warning', + confirmButtonText: locale.baseText( + 'mainSidebar.confirmMessage.workflowArchive.confirmButtonText', + ), + cancelButtonText: locale.baseText( + 'mainSidebar.confirmMessage.workflowArchive.cancelButtonText', + ), + }, + ); + + if (archiveConfirmed !== MODAL_CONFIRM) { + return; + } + + try { + await workflowsStore.archiveWorkflow(props.data.id); + } catch (error) { + toast.showError(error, locale.baseText('generic.archiveWorkflowError')); + return; + } + + toast.showMessage({ + title: locale.baseText('mainSidebar.showMessage.handleArchive.title', { + interpolate: { workflowName: props.data.name }, + }), + type: 'success', + }); + emit('workflow:archived'); +} + +async function unarchiveWorkflow() { + try { + await workflowsStore.unarchiveWorkflow(props.data.id); + } catch (error) { + toast.showError(error, locale.baseText('generic.unarchiveWorkflowError')); + return; + } + + toast.showMessage({ + title: locale.baseText('mainSidebar.showMessage.handleUnarchive.title', { + interpolate: { workflowName: props.data.name }, + }), + type: 'success', + }); + emit('workflow:unarchived'); +} + const fetchHiddenBreadCrumbsItems = async () => { if (!props.data.homeProject?.id || !projectName.value || !props.data.parentFolder) { hiddenBreadcrumbsItemsAsync.value = Promise.resolve([]); @@ -331,6 +408,15 @@ const onBreadcrumbItemClick = async (item: PathItem) => { {{ locale.baseText('workflows.item.readonly') }} + + {{ locale.baseText('workflows.item.archived') }} +
@@ -388,6 +474,7 @@ const onBreadcrumbItemClick = async (item: PathItem) => { { id: '1', name: 'Test Workflow', active: true, + isArchived: false, nodes: [], connections: {}, createdAt: 1, @@ -71,6 +72,7 @@ describe('WorkflowSettingsVue', () => { id: '1', name: 'Test Workflow', active: true, + isArchived: false, nodes: [], connections: {}, createdAt: 1, @@ -273,6 +275,7 @@ describe('WorkflowSettingsVue', () => { id: '1', name: 'Test Workflow', active: true, + isArchived: false, nodes: [], connections: {}, createdAt: 1, diff --git a/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue b/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue index 9f876d8794..180e35c9d6 100644 --- a/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue +++ b/packages/frontend/editor-ui/src/components/layouts/ResourcesListLayout.vue @@ -22,7 +22,6 @@ type ResourceKeyType = 'credentials' | 'workflows' | 'variables' | 'folders'; export type FolderResource = BaseFolderItem & { resourceType: 'folder'; - readOnly: boolean; }; export type WorkflowResource = BaseResource & { @@ -30,6 +29,7 @@ export type WorkflowResource = BaseResource & { updatedAt: string; createdAt: string; active: boolean; + isArchived: boolean; homeProject?: ProjectSharingData; scopes?: Scope[]; tags?: ITag[] | string[]; diff --git a/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.test.ts b/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.test.ts index 157355d2ab..17e298b15a 100644 --- a/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.test.ts +++ b/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.test.ts @@ -398,6 +398,7 @@ const testWorkflow: IWorkflowDb = { id: 'MokOcBHON6KkPq6Y', name: 'My Sub-Workflow 3', active: false, + isArchived: false, createdAt: -1, updatedAt: -1, connections: { diff --git a/packages/frontend/editor-ui/src/composables/useContextMenu.ts b/packages/frontend/editor-ui/src/composables/useContextMenu.ts index 8bbd7ccec7..850813cb65 100644 --- a/packages/frontend/editor-ui/src/composables/useContextMenu.ts +++ b/packages/frontend/editor-ui/src/composables/useContextMenu.ts @@ -57,7 +57,8 @@ export const useContextMenu = (onAction: ContextMenuActionCallback = () => {}) = () => sourceControlStore.preferences.branchReadOnly || uiStore.isReadOnlyView || - !workflowPermissions.value.update, + !workflowPermissions.value.update || + workflowsStore.workflow.isArchived, ); const targetNodeIds = computed(() => { diff --git a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts index 460458fd40..e4de532599 100644 --- a/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts +++ b/packages/frontend/editor-ui/src/composables/useWorkflowHelpers.ts @@ -1161,6 +1161,7 @@ export function useWorkflowHelpers(options: { router: ReturnType Promise; } = {}, ) { - if (!uiStore.stateIsDirty) { + if (!uiStore.stateIsDirty || workflowsStore.workflow.isArchived) { next(); - return; } diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts index 48d8c9f28d..0785e8fa6e 100644 --- a/packages/frontend/editor-ui/src/constants.ts +++ b/packages/frontend/editor-ui/src/constants.ts @@ -614,6 +614,8 @@ export const enum WORKFLOW_MENU_ACTIONS { PUSH = 'push', SETTINGS = 'settings', DELETE = 'delete', + ARCHIVE = 'archive', + UNARCHIVE = 'unarchive', SWITCH_NODE_VIEW_VERSION = 'switch-node-view-version', RENAME = 'rename', } diff --git a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json index e527e51b78..0a4bea141a 100644 --- a/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json +++ b/packages/frontend/editor-ui/src/plugins/i18n/locales/en.json @@ -38,6 +38,8 @@ "generic.create": "Create", "generic.create.workflow": "Create Workflow", "generic.deleteWorkflowError": "Problem deleting workflow", + "generic.archiveWorkflowError": "Problem archiving workflow", + "generic.unarchiveWorkflowError": "Problem unarchiving workflow", "generic.filtersApplied": "Filters are currently applied.", "generic.field": "field", "generic.fields": "fields", @@ -1016,6 +1018,10 @@ "logs.details.body.multipleInputs": "Multiple inputs. View them by {button}", "logs.details.body.multipleInputs.openingTheNode": "opening the node", "mainSidebar.aboutN8n": "About n8n", + "mainSidebar.confirmMessage.workflowArchive.cancelButtonText": "", + "mainSidebar.confirmMessage.workflowArchive.confirmButtonText": "Yes, archive", + "mainSidebar.confirmMessage.workflowArchive.headline": "Archive Workflow?", + "mainSidebar.confirmMessage.workflowArchive.message": "Are you sure that you want to archive '{workflowName}'?", "mainSidebar.confirmMessage.workflowDelete.cancelButtonText": "", "mainSidebar.confirmMessage.workflowDelete.confirmButtonText": "Yes, delete", "mainSidebar.confirmMessage.workflowDelete.headline": "Delete Workflow?", @@ -1040,9 +1046,11 @@ "mainSidebar.showError.stopExecution.title": "Problem stopping execution", "mainSidebar.showMessage.handleFileImport.message": "The file does not contain valid JSON data", "mainSidebar.showMessage.handleFileImport.title": "Could not import file", - "mainSidebar.showMessage.handleSelect1.title": "Workflow deleted", + "mainSidebar.showMessage.handleSelect1.title": "Workflow '{workflowName}' deleted", "mainSidebar.showMessage.handleSelect2.title": "Workflow created", "mainSidebar.showMessage.handleSelect3.title": "Workflow created", + "mainSidebar.showMessage.handleArchive.title": "Workflow '{workflowName}' archived", + "mainSidebar.showMessage.handleUnarchive.title": "Workflow '{workflowName}' unarchived", "mainSidebar.showMessage.stopExecution.title": "Execution stopped", "mainSidebar.templates": "Templates", "mainSidebar.workflows": "Workflows", @@ -1056,6 +1064,8 @@ "menuActions.importFromUrl": "Import from URL...", "menuActions.importFromFile": "Import from File...", "menuActions.delete": "Delete", + "menuActions.archive": "Archive", + "menuActions.unarchive": "Unarchive", "multipleParameter.addItem": "Add item", "multipleParameter.currentlyNoItemsExist": "Currently no items exist", "multipleParameter.deleteItem": "Delete item", @@ -2334,6 +2344,7 @@ "workflowActivator.showMessage.displayActivationError.title": "Problem activating workflow", "workflowActivator.theWorkflowIsSetToBeActiveBut": "The workflow is activated but could not be started.
Click to display error message.", "workflowActivator.thisWorkflowHasNoTriggerNodes": "This workflow has no trigger nodes that require activation", + "workflowActivator.thisWorkflowIsArchived": "This workflow is archived so it cannot be activated", "workflowActivator.thisWorkflowHasOnlyOneExecuteWorkflowTriggerNode": "'Execute Workflow Trigger' doesn't require activation as it is triggered by another workflow", "workflowDetails.share": "Share", "workflowDetails.active": "Active", @@ -2466,11 +2477,14 @@ "workflows.item.share": "Share...", "workflows.item.duplicate": "Duplicate", "workflows.item.delete": "Delete", + "workflows.item.archive": "Archive", + "workflows.item.unarchive": "Unarchive", "workflows.item.move": "Move", "workflows.item.changeOwner": "Change owner", "workflows.item.updated": "Last updated", "workflows.item.created": "Created", "workflows.item.readonly": "Read only", + "workflows.item.archived": "Archived", "workflows.search.placeholder": "Search", "workflows.filters": "Filters", "workflows.filters.tags": "Tags", @@ -2478,6 +2492,7 @@ "workflows.filters.status.all": "All", "workflows.filters.status.active": "Active", "workflows.filters.status.deactivated": "Deactivated", + "workflows.filters.showArchived": "Show archived workflows", "workflows.filters.ownedBy": "Owned by", "workflows.filters.sharedWith": "Shared with", "workflows.filters.apply": "Apply filters", diff --git a/packages/frontend/editor-ui/src/stores/folders.store.ts b/packages/frontend/editor-ui/src/stores/folders.store.ts index 0d90704115..c1251a29ac 100644 --- a/packages/frontend/editor-ui/src/stores/folders.store.ts +++ b/packages/frontend/editor-ui/src/stores/folders.store.ts @@ -98,7 +98,7 @@ export const useFoldersStore = defineStore(STORES.FOLDERS, () => { async function fetchTotalWorkflowsAndFoldersCount(projectId?: string): Promise { const { count } = await workflowsApi.getWorkflowsAndFolders( rootStore.restApiContext, - { projectId }, + { projectId, isArchived: false }, { skip: 0, take: 1 }, true, ); diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts index 04ac8e7f90..4b839fe8e2 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts @@ -853,6 +853,84 @@ describe('useWorkflowsStore', () => { }, ); }); + + describe('archiveWorkflow', () => { + it('should call the API to archive the workflow', async () => { + const workflowId = '1'; + const versionId = '00000000-0000-0000-0000-000000000000'; + const updatedVersionId = '11111111-1111-1111-1111-111111111111'; + + workflowsStore.workflowsById = { + '1': { active: true, isArchived: false, versionId } as IWorkflowDb, + }; + workflowsStore.workflow.active = true; + workflowsStore.workflow.isArchived = false; + workflowsStore.workflow.id = workflowId; + workflowsStore.workflow.versionId = versionId; + + const makeRestApiRequestSpy = vi + .spyOn(apiUtils, 'makeRestApiRequest') + .mockImplementation(async () => ({ + versionId: updatedVersionId, + })); + + await workflowsStore.archiveWorkflow(workflowId); + + expect(workflowsStore.workflowsById['1'].active).toBe(false); + expect(workflowsStore.workflowsById['1'].isArchived).toBe(true); + expect(workflowsStore.workflowsById['1'].versionId).toBe(updatedVersionId); + expect(workflowsStore.workflow.active).toBe(false); + expect(workflowsStore.workflow.isArchived).toBe(true); + expect(workflowsStore.workflow.versionId).toBe(updatedVersionId); + expect(makeRestApiRequestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: '/rest', + pushRef: expect.any(String), + }), + 'POST', + `/workflows/${workflowId}/archive`, + ); + }); + }); + + describe('unarchiveWorkflow', () => { + it('should call the API to unarchive the workflow', async () => { + const workflowId = '1'; + const versionId = '00000000-0000-0000-0000-000000000000'; + const updatedVersionId = '11111111-1111-1111-1111-111111111111'; + + workflowsStore.workflowsById = { + '1': { active: false, isArchived: true, versionId } as IWorkflowDb, + }; + workflowsStore.workflow.active = false; + workflowsStore.workflow.isArchived = true; + workflowsStore.workflow.id = workflowId; + workflowsStore.workflow.versionId = versionId; + + const makeRestApiRequestSpy = vi + .spyOn(apiUtils, 'makeRestApiRequest') + .mockImplementation(async () => ({ + versionId: updatedVersionId, + })); + + await workflowsStore.unarchiveWorkflow(workflowId); + + expect(workflowsStore.workflowsById['1'].active).toBe(false); + expect(workflowsStore.workflowsById['1'].isArchived).toBe(false); + expect(workflowsStore.workflowsById['1'].versionId).toBe(updatedVersionId); + expect(workflowsStore.workflow.active).toBe(false); + expect(workflowsStore.workflow.isArchived).toBe(false); + expect(workflowsStore.workflow.versionId).toBe(updatedVersionId); + expect(makeRestApiRequestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: '/rest', + pushRef: expect.any(String), + }), + 'POST', + `/workflows/${workflowId}/unarchive`, + ); + }); + }); }); function getMockEditFieldsNode() { @@ -886,6 +964,7 @@ function generateMockExecutionEvents() { nodes: [], connections: {}, active: false, + isArchived: false, versionId: '1', }, finished: false, diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.ts b/packages/frontend/editor-ui/src/stores/workflows.store.ts index cf56ce7e03..9827f14f2e 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.ts @@ -99,6 +99,7 @@ import type { NodeExecuteBefore } from '@n8n/api-types/push/execution'; const defaults: Omit & { settings: NonNullable } = { name: '', active: false, + isArchived: false, createdAt: -1, updatedAt: -1, connections: {}, @@ -541,7 +542,13 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { page = 1, pageSize = DEFAULT_WORKFLOW_PAGE_SIZE, sortBy?: string, - filters: { name?: string; tags?: string[]; active?: boolean; parentFolderId?: string } = {}, + filters: { + name?: string; + tags?: string[]; + active?: boolean; + isArchived?: boolean; + parentFolderId?: string; + } = {}, includeFolders: boolean = false, ): Promise { const filter = { ...filters, projectId }; @@ -734,6 +741,42 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { workflowsById.value = workflows; } + async function archiveWorkflow(id: string) { + const updatedWorkflow = await makeRestApiRequest( + 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( + rootStore.restApiContext, + 'POST', + `/workflows/${id}/unarchive`, + ); + if (workflowsById.value[id]) { + workflowsById.value[id].isArchived = false; + workflowsById.value[id].versionId = updatedWorkflow.versionId; + } + + if (id === workflow.value.id) { + setIsArchived(false); + setWorkflowVersionId(updatedWorkflow.versionId); + } + } + function addWorkflow(workflow: IWorkflowDb) { workflowsById.value = { ...workflowsById.value, @@ -781,6 +824,10 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { workflow.value.active = active; } + function setIsArchived(isArchived: boolean) { + workflow.value.isArchived = isArchived; + } + async function getDuplicateCurrentWorkflowName(currentWorkflowName: string): Promise { if ( currentWorkflowName && @@ -1849,11 +1896,14 @@ export const useWorkflowsStore = defineStore(STORES.WORKFLOWS, () => { replaceInvalidWorkflowCredentials, setWorkflows, deleteWorkflow, + archiveWorkflow, + unarchiveWorkflow, addWorkflow, setWorkflowActive, setWorkflowInactive, fetchActiveWorkflows, setActive, + setIsArchived, getDuplicateCurrentWorkflowName, setWorkflowExecutionData, setWorkflowExecutionRunData, diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue index 8190bbf925..c2789abf44 100644 --- a/packages/frontend/editor-ui/src/views/NodeView.vue +++ b/packages/frontend/editor-ui/src/views/NodeView.vue @@ -258,7 +258,8 @@ const isCanvasReadOnly = computed(() => { return ( isDemoRoute.value || isReadOnlyEnvironment.value || - !(workflowPermissions.value.update ?? projectPermissions.value.workflow.update) + !(workflowPermissions.value.update ?? projectPermissions.value.workflow.update) || + editableWorkflow.value.isArchived ); }); @@ -759,8 +760,9 @@ function onPinNodes(ids: string[], source: PinDataSource) { async function onSaveWorkflow() { const workflowIsSaved = !uiStore.stateIsDirty; + const workflowIsArchived = workflowsStore.workflow.isArchived; - if (workflowIsSaved) { + if (workflowIsSaved || workflowIsArchived) { return; } const saved = await workflowHelpers.saveCurrentWorkflow(); diff --git a/packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionRootView.test.ts b/packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionRootView.test.ts index 6a0fe570b3..8e8738ec78 100644 --- a/packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionRootView.test.ts +++ b/packages/frontend/editor-ui/src/views/TestDefinition/tests/TestDefinitionRootView.test.ts @@ -16,6 +16,7 @@ describe('TestDefinitionRootView', () => { id: 'different-id', name: 'Test Workflow', active: false, + isArchived: false, createdAt: Date.now(), updatedAt: Date.now(), nodes: [], diff --git a/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts b/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts index 48c15e92e8..0269865c3c 100644 --- a/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts +++ b/packages/frontend/editor-ui/src/views/WorkflowsView.test.ts @@ -156,6 +156,7 @@ describe('WorkflowsView', () => { expect.any(String), expect.objectContaining({ tags: [TEST_TAG.name], + isArchived: false, }), expect.any(Boolean), ); @@ -176,6 +177,7 @@ describe('WorkflowsView', () => { expect.any(String), expect.objectContaining({ name: 'one', + isArchived: false, }), expect.any(Boolean), ); @@ -196,6 +198,7 @@ describe('WorkflowsView', () => { expect.any(String), expect.objectContaining({ active: true, + isArchived: false, }), expect.any(Boolean), ); @@ -216,6 +219,27 @@ describe('WorkflowsView', () => { expect.any(String), expect.objectContaining({ active: false, + isArchived: false, + }), + expect.any(Boolean), + ); + }); + + it('should unset isArchived filter based on query parameters', async () => { + await router.replace({ query: { showArchived: 'true' } }); + + workflowsStore.fetchWorkflowsPage.mockResolvedValue([]); + + renderComponent({ pinia }); + await waitAllPromises(); + + expect(workflowsStore.fetchWorkflowsPage).toHaveBeenCalledWith( + expect.any(String), + expect.any(Number), + expect.any(Number), + expect.any(String), + expect.objectContaining({ + isArchived: undefined, }), expect.any(Boolean), ); @@ -299,6 +323,7 @@ describe('Folders', () => { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), active: true, + isArchived: false, versionId: '1', homeProject: { id: '1', diff --git a/packages/frontend/editor-ui/src/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/views/WorkflowsView.vue index da8f7e664c..841c1c35ee 100644 --- a/packages/frontend/editor-ui/src/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/views/WorkflowsView.vue @@ -72,13 +72,14 @@ const FILTERS_DEBOUNCE_TIME = 100; interface Filters extends BaseFilters { status: string | boolean; + showArchived: boolean; tags: string[]; } const StatusFilter = { + ALL: '', ACTIVE: 'active', DEACTIVATED: 'deactivated', - ALL: '', }; /** Maps sort values from the ResourcesListLayout component to values expected by workflows endpoint */ @@ -121,6 +122,7 @@ const filters = ref({ search: '', homeProject: '', status: StatusFilter.ALL, + showArchived: false, tags: [], }); @@ -281,13 +283,14 @@ const workflowListResources = computed(() => { workflowCount: resource.workflowCount, subFolderCount: resource.subFolderCount, parentFolder: resource.parentFolder, - } as FolderResource; + } satisfies FolderResource; } else { return { resourceType: 'workflow', id: resource.id, name: resource.name, active: resource.active ?? false, + isArchived: resource.isArchived, updatedAt: resource.updatedAt.toString(), createdAt: resource.createdAt.toString(), homeProject: resource.homeProject, @@ -296,7 +299,7 @@ const workflowListResources = computed(() => { readOnly: !getResourcePermissions(resource.scopes).workflow.update, tags: resource.tags, parentFolder: resource.parentFolder, - } as WorkflowResource; + } satisfies WorkflowResource; } }); return resources; @@ -345,6 +348,7 @@ const hasFilters = computed(() => { return !!( filters.value.search || filters.value.status !== StatusFilter.ALL || + filters.value.showArchived || filters.value.tags.length ); }); @@ -375,6 +379,7 @@ watch( async (newVal) => { currentFolderId.value = newVal as string; filters.value.search = ''; + saveFiltersOnQueryString(); await fetchWorkflows(); }, ); @@ -384,7 +389,7 @@ sourceControlStore.$onAction(({ name, after }) => { after(async () => await initialize()); }); -const onWorkflowDeleted = async () => { +const refreshWorkflows = async () => { await Promise.all([ fetchWorkflows(), foldersStore.fetchTotalWorkflowsAndFoldersCount(route.params.projectId as string | undefined), @@ -483,11 +488,14 @@ const fetchWorkflows = async () => { const tags = filters.value.tags.length ? filters.value.tags.map((tagId) => tagsStore.tagsById[tagId]?.name) : []; + const activeFilter = filters.value.status === StatusFilter.ALL ? undefined : filters.value.status === StatusFilter.ACTIVE; + const archivedFilter = filters.value.showArchived ? undefined : false; + // Only fetch folders if showFolders is enabled and there are not tags or active filter applied const fetchFolders = showFolders.value && !tags.length && activeFilter === undefined; @@ -500,6 +508,7 @@ const fetchWorkflows = async () => { { name: filters.value.search || undefined, active: activeFilter, + isArchived: archivedFilter, tags: tags.length ? tags : undefined, parentFolderId: parentFolder ?? @@ -609,6 +618,12 @@ const saveFiltersOnQueryString = () => { delete currentQuery.status; } + if (filters.value.showArchived) { + currentQuery.showArchived = 'true'; + } else { + delete currentQuery.showArchived; + } + if (filters.value.tags.length) { currentQuery.tags = filters.value.tags.join(','); } else { @@ -628,7 +643,7 @@ const saveFiltersOnQueryString = () => { const setFiltersFromQueryString = async () => { const newQuery: LocationQueryRaw = { ...route.query }; - const { tags, status, search, homeProject, sort } = route.query ?? {}; + const { tags, status, search, homeProject, sort, showArchived } = route.query ?? {}; // Helper to check if string value is not empty const isValidString = (value: unknown): value is string => @@ -673,8 +688,7 @@ const setFiltersFromQueryString = async () => { } // Handle status - const validStatusValues = ['true', 'false']; - if (isValidString(status) && validStatusValues.includes(status)) { + if (isValidString(status)) { newQuery.status = status; filters.value.status = status === 'true' ? StatusFilter.ACTIVE : StatusFilter.DEACTIVATED; } else { @@ -690,6 +704,14 @@ const setFiltersFromQueryString = async () => { delete newQuery.sort; } + if (isValidString(showArchived)) { + newQuery.showArchived = showArchived; + filters.value.showArchived = showArchived === 'true'; + } else { + delete newQuery.showArchived; + filters.value.showArchived = false; + } + void router.replace({ query: newQuery }); }; @@ -1531,7 +1553,9 @@ const onNameSubmit = async ({ :show-ownership-badge="showCardsBadge" data-target="workflow" @click:tag="onClickTag" - @workflow:deleted="onWorkflowDeleted" + @workflow:deleted="refreshWorkflows" + @workflow:archived="refreshWorkflows" + @workflow:unarchived="refreshWorkflows" @workflow:moved="fetchWorkflows" @workflow:duplicated="fetchWorkflows" @workflow:active-toggle="onWorkflowActiveToggle" @@ -1623,6 +1647,14 @@ const onNameSubmit = async ({
+
+ +