diff --git a/packages/twenty-e2e-testing/lib/fixtures/blank-workflow.ts b/packages/twenty-e2e-testing/lib/fixtures/blank-workflow.ts index c6cdf62cf..b6f96a20a 100644 --- a/packages/twenty-e2e-testing/lib/fixtures/blank-workflow.ts +++ b/packages/twenty-e2e-testing/lib/fixtures/blank-workflow.ts @@ -1,8 +1,9 @@ -import { test as base, expect, Page } from '@playwright/test'; +import { test as base, expect, Locator, Page } from '@playwright/test'; import { randomUUID } from 'node:crypto'; import { createWorkflow } from '../requests/create-workflow'; import { deleteWorkflow } from '../requests/delete-workflow'; import { destroyWorkflow } from '../requests/destroy-workflow'; +import { WorkflowActionType, WorkflowTriggerType } from '../types/workflows'; export class WorkflowVisualizerPage { #page: Page; @@ -10,9 +11,65 @@ export class WorkflowVisualizerPage { workflowId: string; workflowName: string; + readonly addStepButton: Locator; + readonly workflowStatus: Locator; + readonly activateWorkflowButton: Locator; + readonly deactivateWorkflowButton: Locator; + readonly addTriggerButton: Locator; + readonly commandMenu: Locator; + readonly workflowNameButton: Locator; + readonly triggerNode: Locator; + readonly background: Locator; + + #actionNames: Record = { + 'create-record': 'Create Record', + 'update-record': 'Update Record', + 'delete-record': 'Delete Record', + code: 'Code', + 'send-email': 'Send Email', + }; + + #createdActionNames: Record = { + 'create-record': 'Create Record', + 'update-record': 'Update Record', + 'delete-record': 'Delete Record', + code: 'Code - Serverless Function', + 'send-email': 'Send Email', + }; + + #triggerNames: Record = { + 'record-created': 'Record is Created', + 'record-updated': 'Record is Updated', + 'record-deleted': 'Record is Deleted', + manual: 'Launch manually', + }; + + #createdTriggerNames: Record = { + 'record-created': 'Record is Created', + 'record-updated': 'Record is Updated', + 'record-deleted': 'Record is Deleted', + manual: 'Manual Trigger', + }; + constructor({ page, workflowName }: { page: Page; workflowName: string }) { this.#page = page; this.workflowName = workflowName; + + this.addStepButton = page.getByLabel('Add a step'); + this.workflowStatus = page.getByTestId('workflow-visualizer-status'); + this.activateWorkflowButton = page.getByLabel('Activate Workflow', { + exact: true, + }); + this.deactivateWorkflowButton = page.getByLabel('Deactivate Workflow', { + exact: true, + }); + this.addTriggerButton = page.getByText('Add a Trigger'); + this.commandMenu = page.getByTestId('command-menu'); + this.workflowNameButton = page.getByRole('button', { + name: this.workflowName, + }); + this.triggerNode = this.#page.getByTestId('rf__node-trigger'); + this.background = page.locator('.react-flow__pane'); } async createOneWorkflow() { @@ -32,14 +89,137 @@ export class WorkflowVisualizerPage { this.workflowId = id; } + async waitForWorkflowVisualizerLoad() { + await expect(this.workflowNameButton).toBeVisible(); + } + async goToWorkflowVisualizerPage() { - await this.#page.goto(`/object/workflow/${this.workflowId}`); + await Promise.all([ + this.#page.goto(`/object/workflow/${this.workflowId}`), - const workflowName = this.#page.getByRole('button', { - name: this.workflowName, - }); + this.waitForWorkflowVisualizerLoad(), + ]); + } - await expect(workflowName).toBeVisible(); + async createInitialTrigger(trigger: WorkflowTriggerType) { + await this.addTriggerButton.click(); + + const triggerName = this.#triggerNames[trigger]; + const createdTriggerName = this.#createdTriggerNames[trigger]; + + const triggerOption = this.#page.getByText(triggerName); + await triggerOption.click(); + + await expect(this.triggerNode).toHaveClass(/selected/); + await expect(this.triggerNode).toContainText(createdTriggerName); + } + + async createStep(action: WorkflowActionType) { + await this.addStepButton.click(); + + const actionName = this.#actionNames[action]; + const createdActionName = this.#createdActionNames[action]; + + const actionToCreateOption = this.commandMenu.getByText(actionName); + + const [createWorkflowStepResponse] = await Promise.all([ + this.#page.waitForResponse((response) => { + if (!response.url().endsWith('/graphql')) { + return false; + } + + const requestBody = response.request().postDataJSON(); + + return requestBody.operationName === 'CreateWorkflowVersionStep'; + }), + + actionToCreateOption.click(), + ]); + const createWorkflowStepResponseBody = + await createWorkflowStepResponse.json(); + const createdStepId = + createWorkflowStepResponseBody.data.createWorkflowVersionStep.id; + + await expect( + this.#page.getByTestId('command-menu').getByRole('textbox').first(), + ).toHaveValue(createdActionName); + + const createdActionNode = this.#page + .locator('.react-flow__node.selected') + .getByText(createdActionName); + + await expect(createdActionNode).toBeVisible(); + + const selectedNodes = this.#page.locator('.react-flow__node.selected'); + + await expect(selectedNodes).toHaveCount(1); + + return { + createdStepId, + }; + } + + getStepNode(stepId: string) { + return this.#page.getByTestId(`rf__node-${stepId}`); + } + + getDeleteNodeButton(nodeLocator: Locator) { + return nodeLocator.getByRole('button'); + } + + getAllStepNodes() { + return this.#page + .getByTestId(/^rf__node-.+$/) + .and(this.#page.getByTestId(/^((?!rf__node-trigger).)*$/)) + .and( + this.#page.getByTestId(/^((?!rf__node-branch-\d+__create-step).)*$/), + ); + } + + async deleteStep(stepId: string) { + const stepNode = this.getStepNode(stepId); + + await stepNode.click(); + + await Promise.all([ + expect(stepNode).not.toBeVisible(), + this.#page.waitForResponse((response) => { + if (!response.url().endsWith('/graphql')) { + return false; + } + + const requestBody = response.request().postDataJSON(); + + return ( + requestBody.operationName === 'DeleteWorkflowVersionStep' && + requestBody.variables.input.stepId === stepId + ); + }), + + this.getDeleteNodeButton(stepNode).click(), + ]); + } + + async deleteTrigger() { + await this.triggerNode.click(); + + await Promise.all([ + expect(this.triggerNode).toContainText('Add a Trigger'), + this.#page.waitForResponse((response) => { + if (!response.url().endsWith('/graphql')) { + return false; + } + + const requestBody = response.request().postDataJSON(); + + return ( + requestBody.operationName === 'UpdateOneWorkflowVersion' && + requestBody.variables.input.trigger === null + ); + }), + + this.getDeleteNodeButton(this.triggerNode).click(), + ]); } } diff --git a/packages/twenty-e2e-testing/lib/types/workflows.ts b/packages/twenty-e2e-testing/lib/types/workflows.ts new file mode 100644 index 000000000..6b1f4bec8 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/types/workflows.ts @@ -0,0 +1,12 @@ +export type WorkflowTriggerType = + | 'record-created' + | 'record-updated' + | 'record-deleted' + | 'manual'; + +export type WorkflowActionType = + | 'create-record' + | 'update-record' + | 'delete-record' + | 'code' + | 'send-email'; diff --git a/packages/twenty-e2e-testing/tests/workflow-visualizer.spec.ts b/packages/twenty-e2e-testing/tests/workflow-visualizer.spec.ts index 8c9b9f62e..e411cad0c 100644 --- a/packages/twenty-e2e-testing/tests/workflow-visualizer.spec.ts +++ b/packages/twenty-e2e-testing/tests/workflow-visualizer.spec.ts @@ -1,35 +1,186 @@ import { expect } from '@playwright/test'; import { test } from '../lib/fixtures/blank-workflow'; -test('Create simple workflow', async ({ workflowVisualizer, page }) => { - const addTriggerButton = page.getByText('Add a Trigger'); - await addTriggerButton.click(); +test('Create workflow with every possible step', async ({ + workflowVisualizer, + page, +}) => { + await workflowVisualizer.createInitialTrigger('record-created'); - const triggerOption = page.getByText('Database Event'); - await triggerOption.click(); + await workflowVisualizer.createStep('create-record'); + await workflowVisualizer.createStep('update-record'); + await workflowVisualizer.createStep('delete-record'); + await workflowVisualizer.createStep('code'); + await workflowVisualizer.createStep('send-email'); - await expect( - page.getByTestId('command-menu').getByRole('textbox'), - ).toHaveValue('When a Company is Created'); + await workflowVisualizer.background.click(); - const triggerNode = page.getByTestId('rf__node-trigger'); - await expect(triggerNode).toHaveClass(/selected/); - await expect(triggerNode).toHaveText(/Company is Created/); + const draftWorkflowStatus = + workflowVisualizer.workflowStatus.getByText('Draft'); - const addStepButton = page.getByLabel('Add a step'); - await addStepButton.click(); + await expect(draftWorkflowStatus).toBeVisible(); - const createRecordOption = page.getByText('Create Record'); + await workflowVisualizer.activateWorkflowButton.click(); - await createRecordOption.click(); + const activeWorkflowStatus = + workflowVisualizer.workflowStatus.getByText('Active'); - await expect( - page.getByTestId('command-menu').getByRole('textbox').first(), - ).toHaveValue('Create Record'); - - const createRecordNode = page - .locator('.react-flow__node.selected') - .getByText('Create Record'); - await expect(createRecordNode).toBeVisible(); - await expect(triggerNode).not.toHaveClass(/selected/); + await expect(draftWorkflowStatus).not.toBeVisible(); + await expect(activeWorkflowStatus).toBeVisible(); + await expect(workflowVisualizer.activateWorkflowButton).not.toBeVisible(); + await expect(workflowVisualizer.deactivateWorkflowButton).toBeVisible(); +}); + +test('Delete steps from draft version', async ({ + workflowVisualizer, + page, +}) => { + await workflowVisualizer.createInitialTrigger('record-created'); + + const { createdStepId: firstStepId } = + await workflowVisualizer.createStep('create-record'); + const { createdStepId: secondStepId } = + await workflowVisualizer.createStep('update-record'); + const { createdStepId: thirdStepId } = + await workflowVisualizer.createStep('delete-record'); + const { createdStepId: fourthStepId } = + await workflowVisualizer.createStep('code'); + const { createdStepId: fifthStepId } = + await workflowVisualizer.createStep('send-email'); + + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Create Record', + 'Update Record', + 'Delete Record', + 'Code - Serverless Function', + 'Send Email', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(5); + + await workflowVisualizer.deleteStep(firstStepId); + + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Update Record', + 'Delete Record', + 'Code - Serverless Function', + 'Send Email', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(4); + + await workflowVisualizer.deleteStep(fifthStepId); + + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Update Record', + 'Delete Record', + 'Code - Serverless Function', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(3); + + await workflowVisualizer.deleteStep(secondStepId); + + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Delete Record', + 'Code - Serverless Function', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(2); + + await workflowVisualizer.deleteStep(fourthStepId); + + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Delete Record', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1); + + await workflowVisualizer.deleteStep(thirdStepId); + + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(0); + + await Promise.all([ + page.reload(), + + expect(workflowVisualizer.triggerNode).toBeVisible(), + ]); + + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(0); +}); + +test('Add a step to an active version', async ({ + workflowVisualizer, + page, +}) => { + await workflowVisualizer.createInitialTrigger('record-created'); + + await workflowVisualizer.createStep('create-record'); + + await expect(workflowVisualizer.workflowStatus).toHaveText('Draft'); + + await workflowVisualizer.background.click(); + + await Promise.all([ + expect(workflowVisualizer.workflowStatus).toHaveText('Active'), + + workflowVisualizer.activateWorkflowButton.click(), + ]); + + await expect(workflowVisualizer.activateWorkflowButton).not.toBeVisible(); + + const assertEndState = async () => { + await expect(workflowVisualizer.workflowStatus).toHaveText('Active'); + await expect(workflowVisualizer.triggerNode).toContainText( + 'Record is Created', + ); + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Create Record', + ]); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1); + }; + + await assertEndState(); + + await page.reload(); + + await assertEndState(); +}); + +test('Replace the trigger of an active version', async ({ + workflowVisualizer, + page, +}) => { + await workflowVisualizer.createInitialTrigger('record-created'); + + await workflowVisualizer.createStep('create-record'); + + await workflowVisualizer.background.click(); + + await Promise.all([ + expect(workflowVisualizer.workflowStatus).toHaveText('Active'), + + workflowVisualizer.activateWorkflowButton.click(), + ]); + + await Promise.all([ + expect(workflowVisualizer.workflowStatus).toHaveText('Draft'), + + workflowVisualizer.deleteTrigger(), + ]); + + await workflowVisualizer.createInitialTrigger('record-deleted'); + + await workflowVisualizer.background.click(); + + await Promise.all([ + expect(workflowVisualizer.workflowStatus).toHaveText('Active'), + + workflowVisualizer.activateWorkflowButton.click(), + ]); + + await page.reload(); + + await expect(workflowVisualizer.triggerNode).toContainText( + 'Record is Deleted', + ); + await expect(workflowVisualizer.getAllStepNodes()).toHaveCount(1); + await expect(workflowVisualizer.getAllStepNodes()).toContainText([ + 'Create Record', + ]); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx index e48d5b300..838b537bc 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramCanvasBase.tsx @@ -233,7 +233,7 @@ export const WorkflowDiagramCanvasBase = ({ {children} - +