feat(core): Change workflow deletions to soft deletes (#14894)

Adds soft‑deletion support for workflows through a new boolean column `isArchived`.

When a workflow is archived we now set `isArchived` flag to true and the workflows
stays in the database and is omitted from the default workflow listing query.

Archived workflows can be viewed in read-only mode, but they cannot be activated.

Archived workflows are still available by ID and can be invoked as sub-executions,
so existing Execute Workflow nodes continue to work. Execution engine doesn't
care about isArchived flag.

Users can restore workflows via Unarchive action at the UI.
This commit is contained in:
Jaakko Husso
2025-05-06 17:48:24 +03:00
committed by GitHub
parent 32b72011e6
commit 3a13139f78
64 changed files with 1616 additions and 124 deletions

View File

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

View File

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

View File

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

View File

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