From 3548751be2311aa0a06aa6f887df2329d59a36ec Mon Sep 17 00:00:00 2001 From: Baptiste Devessier Date: Thu, 12 Sep 2024 17:01:10 +0200 Subject: [PATCH] Scaffold empty workflow (#6926) - Create a workflow version when the user visits an empty workflow. - If the trigger is not defined yet and the user selects either the standard object type or the event type first, we automatically select the first option of the other value. Indeed, every state update is automatically saved on the backend and we need both standard object and event types to save the event name. - Introduces a change in the backend. I removed the assertions that throw when a workflow version is not complete, that is, when it doesn't have a defined trigger, which is the case when scaffolding a new workflow with a first empty workflow version. - We should keep validating the workflow versions, at least when we publish them. That should be done in a second step. --- .../RightDrawerWorkflowEditStepContent.tsx | 13 +- .../modules/workflow/components/Workflow.tsx | 4 +- .../WorkflowDiagramBaseStepNode.tsx | 109 +++++++++++++++++ .../components/WorkflowDiagramCanvas.tsx | 2 + .../WorkflowDiagramEmptyTrigger.tsx | 30 +++++ .../components/WorkflowDiagramStepNode.tsx | 113 +++++++----------- .../components/WorkflowEditActionForm.tsx | 6 +- .../components/WorkflowEditTriggerForm.tsx | 94 +++++++++------ ...wShowPageEffect.tsx => WorkflowEffect.tsx} | 6 +- .../components/WorkflowShowPageHeader.tsx | 30 ----- .../hooks/useWorkflowWithCurrentVersion.tsx | 56 +++++---- .../getWorkflowVersionDiagram.test.ts | 30 ++++- .../workflow/utils/generateWorkflowDiagram.ts | 45 ++++--- .../utils/getWorkflowVersionDiagram.ts | 12 +- .../workspace-query-hook.interface.ts | 8 ++ .../storage/workspace-query-hook.storage.ts | 21 ++++ .../workspace-query-hook.service.ts | 33 ++++- .../workspace-query-runner.service.ts | 7 ++ .../workflow-create-many.post-query.hook.ts | 42 +++++++ .../workflow-create-one.post-query.hook.ts | 40 +++++++ .../query-hooks/workflow-query-hook.module.ts | 12 +- .../assert-workflow-version-is-draft.util.ts | 5 +- ...orkflow-version-trigger-is-defined.util.ts | 22 ++++ .../workflow-common.workspace-service.ts | 28 ++--- .../assert-version-can-be-activated.util.ts | 15 +-- .../workflow-trigger.workspace-service.ts | 34 ++---- 26 files changed, 548 insertions(+), 269 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx create mode 100644 packages/twenty-front/src/modules/workflow/components/WorkflowDiagramEmptyTrigger.tsx rename packages/twenty-front/src/modules/workflow/components/{WorkflowShowPageEffect.tsx => WorkflowEffect.tsx} (91%) delete mode 100644 packages/twenty-front/src/modules/workflow/components/WorkflowShowPageHeader.tsx create mode 100644 packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-create-many.post-query.hook.ts create mode 100644 packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-create-one.post-query.hook.ts create mode 100644 packages/twenty-server/src/modules/workflow/common/utils/assert-workflow-version-trigger-is-defined.util.ts diff --git a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStepContent.tsx b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStepContent.tsx index a5669f989878..9fb36225678c 100644 --- a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStepContent.tsx +++ b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowEditStepContent.tsx @@ -23,7 +23,10 @@ const getStepDefinitionOrThrow = ({ if (stepId === TRIGGER_STEP_ID) { if (!isDefined(currentVersion.trigger)) { - throw new Error('Expected to find the definition of the trigger'); + return { + type: 'trigger', + definition: undefined, + } as const; } return { @@ -33,7 +36,9 @@ const getStepDefinitionOrThrow = ({ } if (!isDefined(currentVersion.steps)) { - throw new Error('Expected to find an array of steps'); + throw new Error( + 'Malformed workflow version: missing steps information; be sure to create at least one step before trying to edit one', + ); } const selectedNodePosition = findStepPositionOrThrow({ @@ -74,7 +79,7 @@ export const RightDrawerWorkflowEditStepContent = ({ return ( ); } @@ -82,7 +87,7 @@ export const RightDrawerWorkflowEditStepContent = ({ return ( ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/Workflow.tsx b/packages/twenty-front/src/modules/workflow/components/Workflow.tsx index d0b3331e045c..7c7e5bc74b62 100644 --- a/packages/twenty-front/src/modules/workflow/components/Workflow.tsx +++ b/packages/twenty-front/src/modules/workflow/components/Workflow.tsx @@ -1,6 +1,6 @@ import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { WorkflowDiagramCanvas } from '@/workflow/components/WorkflowDiagramCanvas'; -import { WorkflowShowPageEffect } from '@/workflow/components/WorkflowShowPageEffect'; +import { WorkflowEffect } from '@/workflow/components/WorkflowEffect'; import { workflowDiagramState } from '@/workflow/states/workflowDiagramState'; import styled from '@emotion/styled'; import '@xyflow/react/dist/style.css'; @@ -36,7 +36,7 @@ export const Workflow = ({ return ( <> - + {workflowDiagram === undefined ? null : ( diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx new file mode 100644 index 000000000000..8c05d48baa09 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx @@ -0,0 +1,109 @@ +import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram'; +import styled from '@emotion/styled'; +import { Handle, Position } from '@xyflow/react'; +import React from 'react'; +import { capitalize } from '~/utils/string/capitalize'; + +type Variant = 'placeholder'; + +const StyledStepNodeContainer = styled.div` + display: flex; + flex-direction: column; + + padding-bottom: 12px; + padding-top: 6px; +`; + +const StyledStepNodeType = styled.div` + background-color: ${({ theme }) => theme.background.tertiary}; + border-radius: ${({ theme }) => theme.border.radius.sm} + ${({ theme }) => theme.border.radius.sm} 0 0; + + color: ${({ theme }) => theme.color.gray50}; + font-size: ${({ theme }) => theme.font.size.xs}; + font-weight: ${({ theme }) => theme.font.weight.semiBold}; + + padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; + position: absolute; + top: 0; + transform: translateY(-100%); + + .selectable.selected &, + .selectable:focus &, + .selectable:focus-visible & { + background-color: ${({ theme }) => theme.color.blue}; + color: ${({ theme }) => theme.font.color.inverted}; + } +`; + +const StyledStepNodeInnerContainer = styled.div<{ variant?: Variant }>` + background-color: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-style: ${({ variant }) => + variant === 'placeholder' ? 'dashed' : null}; + border-radius: ${({ theme }) => theme.border.radius.md}; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(2)}; + + position: relative; + box-shadow: ${({ variant, theme }) => + variant === 'placeholder' ? 'none' : theme.boxShadow.superHeavy}; + + .selectable.selected &, + .selectable:focus &, + .selectable:focus-visible & { + background-color: ${({ theme }) => theme.color.blue10}; + border-color: ${({ theme }) => theme.color.blue}; + } +`; + +const StyledStepNodeLabel = styled.div<{ variant?: Variant }>` + align-items: center; + display: flex; + font-size: ${({ theme }) => theme.font.size.md}; + font-weight: ${({ theme }) => theme.font.weight.medium}; + column-gap: ${({ theme }) => theme.spacing(2)}; + color: ${({ variant, theme }) => + variant === 'placeholder' ? theme.font.color.extraLight : null}; +`; + +const StyledSourceHandle = styled(Handle)` + background-color: ${({ theme }) => theme.color.gray50}; +`; + +export const StyledTargetHandle = styled(Handle)` + visibility: hidden; +`; + +export const WorkflowDiagramBaseStepNode = ({ + nodeType, + label, + variant, + Icon, +}: { + nodeType: WorkflowDiagramStepNodeData['nodeType']; + label: string; + variant?: Variant; + Icon?: React.ReactNode; +}) => { + return ( + + {nodeType !== 'trigger' ? ( + + ) : null} + + + {capitalize(nodeType)} + + + {Icon} + + {label} + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvas.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvas.tsx index 6d65014a8995..ef1123f4eed6 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvas.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvas.tsx @@ -1,5 +1,6 @@ import { WorkflowDiagramCanvasEffect } from '@/workflow/components/WorkflowDiagramCanvasEffect'; import { WorkflowDiagramCreateStepNode } from '@/workflow/components/WorkflowDiagramCreateStepNode'; +import { WorkflowDiagramEmptyTrigger } from '@/workflow/components/WorkflowDiagramEmptyTrigger'; import { WorkflowDiagramStepNode } from '@/workflow/components/WorkflowDiagramStepNode'; import { workflowDiagramState } from '@/workflow/states/workflowDiagramState'; import { @@ -72,6 +73,7 @@ export const WorkflowDiagramCanvas = ({ nodeTypes={{ default: WorkflowDiagramStepNode, 'create-step': WorkflowDiagramCreateStepNode, + 'empty-trigger': WorkflowDiagramEmptyTrigger, }} fitView nodes={nodes.map((node) => ({ ...node, draggable: false }))} diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramEmptyTrigger.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramEmptyTrigger.tsx new file mode 100644 index 000000000000..a355733219ab --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramEmptyTrigger.tsx @@ -0,0 +1,30 @@ +import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { IconPlaylistAdd } from 'twenty-ui'; + +const StyledStepNodeLabelIconContainer = styled.div` + align-items: center; + background: ${({ theme }) => theme.background.transparent.light}; + border-radius: ${({ theme }) => theme.spacing(1)}; + display: flex; + justify-content: center; + padding: ${({ theme }) => theme.spacing(1)}; +`; + +export const WorkflowDiagramEmptyTrigger = () => { + const theme = useTheme(); + + return ( + + + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNode.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNode.tsx index fe005cc595b4..0c0e6ce945ea 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNode.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNode.tsx @@ -1,67 +1,16 @@ +import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode'; import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram'; +import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { Handle, Position } from '@xyflow/react'; +import { IconCode, IconPlaylistAdd } from 'twenty-ui'; -const StyledStepNodeContainer = styled.div` +const StyledStepNodeLabelIconContainer = styled.div` + align-items: center; + background: ${({ theme }) => theme.background.transparent.light}; + border-radius: ${({ theme }) => theme.spacing(1)}; display: flex; - flex-direction: column; - - padding-bottom: 12px; - padding-top: 6px; -`; - -const StyledStepNodeType = styled.div` - background-color: ${({ theme }) => theme.background.tertiary}; - border-radius: ${({ theme }) => theme.border.radius.sm} - ${({ theme }) => theme.border.radius.sm} 0 0; - - color: ${({ theme }) => theme.color.gray50}; - font-size: ${({ theme }) => theme.font.size.xs}; - font-weight: ${({ theme }) => theme.font.weight.semiBold}; - - padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; - position: absolute; - top: 0; - transform: translateY(-100%); - - .selectable.selected &, - .selectable:focus &, - .selectable:focus-visible & { - background-color: ${({ theme }) => theme.color.blue}; - color: ${({ theme }) => theme.font.color.inverted}; - } -`; - -const StyledStepNodeInnerContainer = styled.div` - background-color: ${({ theme }) => theme.background.secondary}; - border: 1px solid ${({ theme }) => theme.border.color.medium}; - border-radius: ${({ theme }) => theme.border.radius.md}; - display: flex; - gap: ${({ theme }) => theme.spacing(2)}; - padding: ${({ theme }) => theme.spacing(2)}; - - position: relative; - box-shadow: ${({ theme }) => theme.boxShadow.superHeavy}; - - .selectable.selected &, - .selectable:focus &, - .selectable:focus-visible & { - background-color: ${({ theme }) => theme.color.blue10}; - border-color: ${({ theme }) => theme.color.blue}; - } -`; - -const StyledStepNodeLabel = styled.div` - font-size: ${({ theme }) => theme.font.size.md}; - font-weight: ${({ theme }) => theme.font.weight.medium}; -`; - -const StyledSourceHandle = styled(Handle)` - background-color: ${({ theme }) => theme.color.gray50}; -`; - -export const StyledTargetHandle = styled(Handle)` - visibility: hidden; + justify-content: center; + padding: ${({ theme }) => theme.spacing(1)}; `; export const WorkflowDiagramStepNode = ({ @@ -69,19 +18,37 @@ export const WorkflowDiagramStepNode = ({ }: { data: WorkflowDiagramStepNodeData; }) => { - return ( - - {data.nodeType !== 'trigger' ? ( - - ) : null} - - - {data.nodeType} + const theme = useTheme(); + + const renderStepIcon = () => { + switch (data.nodeType) { + case 'trigger': { + return ( + + + + ); + } + case 'action': { + return ( + + + + ); + } + } + + return null; + }; - {data.label} - - - - + return ( + ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionForm.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionForm.tsx index 3cddde9f02db..015952309d11 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionForm.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionForm.tsx @@ -45,10 +45,10 @@ const StyledTriggerSettings = styled.div` export const WorkflowEditActionForm = ({ action, - onUpdateAction, + onActionUpdate, }: { action: WorkflowAction; - onUpdateAction: (trigger: WorkflowAction) => void; + onActionUpdate: (trigger: WorkflowAction) => void; }) => { const theme = useTheme(); @@ -88,7 +88,7 @@ export const WorkflowEditActionForm = ({ value={action.settings.serverlessFunctionId} options={availableFunctions} onChange={(updatedFunction) => { - onUpdateAction({ + onActionUpdate({ ...action, settings: { ...action.settings, diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditTriggerForm.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditTriggerForm.tsx index e345d3e65c42..9a3960428162 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditTriggerForm.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditTriggerForm.tsx @@ -47,39 +47,35 @@ const StyledTriggerSettings = styled.div` export const WorkflowEditTriggerForm = ({ trigger, - onUpdateTrigger, + onTriggerUpdate, }: { - trigger: WorkflowTrigger; - onUpdateTrigger: (trigger: WorkflowTrigger) => void; + trigger: WorkflowTrigger | undefined; + onTriggerUpdate: (trigger: WorkflowTrigger) => void; }) => { const theme = useTheme(); const { activeObjectMetadataItems } = useFilteredObjectMetadataItems(); - const triggerEvent = splitWorkflowTriggerEventName( - trigger.settings.eventName, - ); + const triggerEvent = isDefined(trigger) + ? splitWorkflowTriggerEventName(trigger.settings.eventName) + : undefined; const availableMetadata: Array> = activeObjectMetadataItems.map((item) => ({ label: item.labelPlural, value: item.nameSingular, })); - const recordTypeMetadata = activeObjectMetadataItems.find( - (item) => item.nameSingular === triggerEvent.objectType, - ); - if (!isDefined(recordTypeMetadata)) { - throw new Error( - 'Expected to find the metadata configuration for the currently selected record type of the trigger.', - ); - } + const recordTypeMetadata = isDefined(triggerEvent) + ? activeObjectMetadataItems.find( + (item) => item.nameSingular === triggerEvent.objectType, + ) + : undefined; - const selectedEvent = OBJECT_EVENT_TRIGGERS.find( - (availableEvent) => availableEvent.value === triggerEvent.event, - ); - if (!isDefined(selectedEvent)) { - throw new Error('Expected to find the currently selected event type.'); - } + const selectedEvent = isDefined(triggerEvent) + ? OBJECT_EVENT_TRIGGERS.find( + (availableEvent) => availableEvent.value === triggerEvent.event, + ) + : undefined; return ( <> @@ -89,11 +85,15 @@ export const WorkflowEditTriggerForm = ({ - When a {recordTypeMetadata.labelSingular} is {selectedEvent.label} + {isDefined(recordTypeMetadata) && isDefined(selectedEvent) + ? `When a ${recordTypeMetadata.labelSingular} is ${selectedEvent.label}` + : '-'} - Trigger . Record is {selectedEvent.label} + {isDefined(selectedEvent) + ? `Trigger . Record is ${selectedEvent.label}` + : '-'} @@ -102,32 +102,50 @@ export const WorkflowEditTriggerForm = ({ dropdownId="workflow-edit-trigger-record-type" label="Record Type" fullWidth - value={triggerEvent.objectType} + value={triggerEvent?.objectType} options={availableMetadata} onChange={(updatedRecordType) => { - onUpdateTrigger({ - ...trigger, - settings: { - ...trigger.settings, - eventName: `${updatedRecordType}.${triggerEvent.event}`, - }, - }); + onTriggerUpdate( + isDefined(trigger) && isDefined(triggerEvent) + ? { + ...trigger, + settings: { + ...trigger.settings, + eventName: `${updatedRecordType}.${triggerEvent.event}`, + }, + } + : { + type: 'DATABASE_EVENT', + settings: { + eventName: `${updatedRecordType}.${OBJECT_EVENT_TRIGGERS[0].value}`, + }, + }, + ); }} />