Update workflow nodes configuration (#6861)

- Improve the design of the right drawer
- Allow to update the trigger of the workflow: the object and the event
listened to
- Allow to update the selected serverless function that a code action
should execute
- Change how we determine which workflow version to display in the
visualizer. We fetch the selected workflow's data, including whether it
has a draft or a published version. If the workflow has a draft version,
it gets displayed; otherwise, we display the last published version.
- I used the type `WorkflowWithCurrentVersion` to forward the currently
edited workflow with its _current_ version embedded across the app.
- I created single-responsibility hooks like
`useFindWorkflowWithCurrentVersion`, `useFindShowPageWorkflow`,
`useUpdateWorkflowVersionTrigger` or `useUpdateWorkflowVersionStep`.
- I updated the types for workflow related objects, like `Workflow` and
`WorkflowVersion`. See
`packages/twenty-front/src/modules/workflow/types/Workflow.ts`.
- This introduced the possibility to have `null` values for triggers and
steps. I made the according changes in the codebase and in the tests.
- I created a utility function to extract both parts of object-event
format (`company.created`):
`packages/twenty-front/src/modules/workflow/utils/splitWorkflowTriggerEventName.ts`
This commit is contained in:
Baptiste Devessier
2024-09-04 17:39:28 +02:00
committed by GitHub
parent c55dfbde6e
commit a2b1062db6
46 changed files with 1056 additions and 498 deletions

View File

@ -1,10 +1,16 @@
import { showPageWorkflowSelectedNodeState } from '@/workflow/states/showPageWorkflowSelectedNodeState'; import { RightDrawerWorkflowEditStepContent } from '@/workflow/components/RightDrawerWorkflowEditStepContent';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowEditStep = () => { export const RightDrawerWorkflowEditStep = () => {
const showPageWorkflowSelectedNode = useRecoilValue( const workflowId = useRecoilValue(workflowIdState);
showPageWorkflowSelectedNodeState, const workflow = useWorkflowWithCurrentVersion(workflowId);
);
return <p>{showPageWorkflowSelectedNode}</p>; if (!isDefined(workflow)) {
return null;
}
return <RightDrawerWorkflowEditStepContent workflow={workflow} />;
}; };

View File

@ -0,0 +1,88 @@
import { WorkflowEditActionForm } from '@/workflow/components/WorkflowEditActionForm';
import { WorkflowEditTriggerForm } from '@/workflow/components/WorkflowEditTriggerForm';
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { useUpdateWorkflowVersionStep } from '@/workflow/hooks/useUpdateWorkflowVersionStep';
import { useUpdateWorkflowVersionTrigger } from '@/workflow/hooks/useUpdateWorkflowVersionTrigger';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
const getStepDefinitionOrThrow = ({
stepId,
workflow,
}: {
stepId: string;
workflow: WorkflowWithCurrentVersion;
}) => {
const currentVersion = workflow.currentVersion;
if (!isDefined(currentVersion)) {
throw new Error('Expected to find a current version');
}
if (stepId === TRIGGER_STEP_ID) {
if (!isDefined(currentVersion.trigger)) {
throw new Error('Expected to find the definition of the trigger');
}
return {
type: 'trigger',
definition: currentVersion.trigger,
} as const;
}
if (!isDefined(currentVersion.steps)) {
throw new Error('Expected to find an array of steps');
}
const selectedNodePosition = findStepPositionOrThrow({
steps: currentVersion.steps,
stepId: stepId,
});
return {
type: 'action',
definition: selectedNodePosition.steps[selectedNodePosition.index],
} as const;
};
export const RightDrawerWorkflowEditStepContent = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
throw new Error(
'Expected a node to be selected. Selecting a node is mandatory to edit it.',
);
}
const { updateTrigger } = useUpdateWorkflowVersionTrigger({ workflow });
const { updateStep } = useUpdateWorkflowVersionStep({
workflow,
stepId: workflowSelectedNode,
});
const stepDefinition = getStepDefinitionOrThrow({
stepId: workflowSelectedNode,
workflow,
});
if (stepDefinition.type === 'trigger') {
return (
<WorkflowEditTriggerForm
trigger={stepDefinition.definition}
onUpdateTrigger={updateTrigger}
/>
);
}
return (
<WorkflowEditActionForm
action={stepDefinition.definition}
onUpdateAction={updateStep}
/>
);
};

View File

@ -1,24 +1,12 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { RightDrawerWorkflowSelectActionContent } from '@/workflow/components/RightDrawerWorkflowSelectActionContent'; import { RightDrawerWorkflowSelectActionContent } from '@/workflow/components/RightDrawerWorkflowSelectActionContent';
import { showPageWorkflowIdState } from '@/workflow/states/showPageWorkflowIdState'; import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { Workflow } from '@/workflow/types/Workflow'; import { workflowIdState } from '@/workflow/states/workflowIdState';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowSelectAction = () => { export const RightDrawerWorkflowSelectAction = () => {
const showPageWorkflowId = useRecoilValue(showPageWorkflowIdState); const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
const { record: workflow } = useFindOneRecord<Workflow>({
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: showPageWorkflowId,
recordGqlFields: {
id: true,
name: true,
versions: true,
publishedVersionId: true,
},
});
if (!isDefined(workflow)) { if (!isDefined(workflow)) {
return null; return null;

View File

@ -1,19 +1,9 @@
import { TabList } from '@/ui/layout/tab/components/TabList';
import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem';
import { useRightDrawerWorkflowSelectAction } from '@/workflow/hooks/useRightDrawerWorkflowSelectAction'; import { ACTIONS } from '@/workflow/constants/Actions';
import { Workflow } from '@/workflow/types/Workflow'; import { useCreateStep } from '@/workflow/hooks/useCreateStep';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
// FIXME: copy-pasted
const StyledTabListContainer = styled.div`
align-items: center;
border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`};
box-sizing: border-box;
display: flex;
gap: ${({ theme }) => theme.spacing(2)};
height: 40px;
`;
const StyledActionListContainer = styled.div` const StyledActionListContainer = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -24,33 +14,24 @@ const StyledActionListContainer = styled.div`
padding-inline: ${({ theme }) => theme.spacing(2)}; padding-inline: ${({ theme }) => theme.spacing(2)};
`; `;
export const TAB_LIST_COMPONENT_ID =
'workflow-select-action-page-right-tab-list';
export const RightDrawerWorkflowSelectActionContent = ({ export const RightDrawerWorkflowSelectActionContent = ({
workflow, workflow,
}: { }: {
workflow: Workflow; workflow: WorkflowWithCurrentVersion;
}) => { }) => {
const tabListId = `${TAB_LIST_COMPONENT_ID}`; const { createStep } = useCreateStep({
workflow,
const { tabs, options, handleActionClick } = });
useRightDrawerWorkflowSelectAction({ tabListId, workflow });
return ( return (
<> <>
<StyledTabListContainer>
<TabList loading={false} tabListId={tabListId} tabs={tabs} />
</StyledTabListContainer>
<StyledActionListContainer> <StyledActionListContainer>
{options.map((option) => ( {ACTIONS.map((action) => (
<MenuItem <MenuItem
key={option.id} LeftIcon={action.icon}
LeftIcon={option.icon} text={action.label}
text={option.name}
onClick={() => { onClick={() => {
handleActionClick(option.id); return createStep(action.type);
}} }}
/> />
))} ))}

View File

@ -1,7 +1,7 @@
import { WorkflowShowPageDiagramCreateStepNode } from '@/workflow/components/WorkflowShowPageDiagramCreateStepNode'; import { WorkflowDiagramCanvasEffect } from '@/workflow/components/WorkflowDiagramCanvasEffect';
import { WorkflowShowPageDiagramEffect } from '@/workflow/components/WorkflowShowPageDiagramEffect'; import { WorkflowDiagramCreateStepNode } from '@/workflow/components/WorkflowDiagramCreateStepNode';
import { WorkflowShowPageDiagramStepNode } from '@/workflow/components/WorkflowShowPageDiagramStepNode'; import { WorkflowDiagramStepNode } from '@/workflow/components/WorkflowDiagramStepNode';
import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { import {
WorkflowDiagram, WorkflowDiagram,
WorkflowDiagramEdge, WorkflowDiagramEdge,
@ -21,7 +21,7 @@ import { useMemo } from 'react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { GRAY_SCALE, isDefined } from 'twenty-ui'; import { GRAY_SCALE, isDefined } from 'twenty-ui';
export const WorkflowShowPageDiagram = ({ export const WorkflowDiagramCanvas = ({
diagram, diagram,
}: { }: {
diagram: WorkflowDiagram; diagram: WorkflowDiagram;
@ -31,14 +31,12 @@ export const WorkflowShowPageDiagram = ({
[diagram], [diagram],
); );
const setShowPageWorkflowDiagram = useSetRecoilState( const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
showPageWorkflowDiagramState,
);
const handleNodesChange = ( const handleNodesChange = (
nodeChanges: Array<NodeChange<WorkflowDiagramNode>>, nodeChanges: Array<NodeChange<WorkflowDiagramNode>>,
) => { ) => {
setShowPageWorkflowDiagram((diagram) => { setWorkflowDiagram((diagram) => {
if (isDefined(diagram) === false) { if (isDefined(diagram) === false) {
throw new Error( throw new Error(
'It must be impossible for the nodes to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.', 'It must be impossible for the nodes to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.',
@ -55,7 +53,7 @@ export const WorkflowShowPageDiagram = ({
const handleEdgesChange = ( const handleEdgesChange = (
edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>, edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>,
) => { ) => {
setShowPageWorkflowDiagram((diagram) => { setWorkflowDiagram((diagram) => {
if (isDefined(diagram) === false) { if (isDefined(diagram) === false) {
throw new Error( throw new Error(
'It must be impossible for the edges to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.', 'It must be impossible for the edges to be updated if the diagram is not defined yet. Be sure the diagram is rendered only when defined.',
@ -72,8 +70,8 @@ export const WorkflowShowPageDiagram = ({
return ( return (
<ReactFlow <ReactFlow
nodeTypes={{ nodeTypes={{
default: WorkflowShowPageDiagramStepNode, default: WorkflowDiagramStepNode,
'create-step': WorkflowShowPageDiagramCreateStepNode, 'create-step': WorkflowDiagramCreateStepNode,
}} }}
fitView fitView
nodes={nodes.map((node) => ({ ...node, draggable: false }))} nodes={nodes.map((node) => ({ ...node, draggable: false }))}
@ -81,7 +79,7 @@ export const WorkflowShowPageDiagram = ({
onNodesChange={handleNodesChange} onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange} onEdgesChange={handleEdgesChange}
> >
<WorkflowShowPageDiagramEffect /> <WorkflowDiagramCanvasEffect />
<Background color={GRAY_SCALE.gray25} size={2} /> <Background color={GRAY_SCALE.gray25} size={2} />
</ReactFlow> </ReactFlow>

View File

@ -1,8 +1,8 @@
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useStartNodeCreation } from '@/workflow/hooks/useStartNodeCreation'; import { useStartNodeCreation } from '@/workflow/hooks/useStartNodeCreation';
import { showPageWorkflowDiagramTriggerNodeSelectionState } from '@/workflow/states/showPageWorkflowDiagramTriggerNodeSelectionState'; import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState';
import { showPageWorkflowSelectedNodeState } from '@/workflow/states/showPageWorkflowSelectedNodeState'; import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { import {
WorkflowDiagramEdge, WorkflowDiagramEdge,
WorkflowDiagramNode, WorkflowDiagramNode,
@ -16,18 +16,16 @@ import { useCallback, useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
export const WorkflowShowPageDiagramEffect = () => { export const WorkflowDiagramCanvasEffect = () => {
const reactflow = useReactFlow<WorkflowDiagramNode, WorkflowDiagramEdge>(); const reactflow = useReactFlow<WorkflowDiagramNode, WorkflowDiagramEdge>();
const { startNodeCreation } = useStartNodeCreation(); const { startNodeCreation } = useStartNodeCreation();
const { openRightDrawer, closeRightDrawer } = useRightDrawer(); const { openRightDrawer, closeRightDrawer } = useRightDrawer();
const setShowPageWorkflowSelectedNode = useSetRecoilState( const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
showPageWorkflowSelectedNodeState,
);
const showPageWorkflowDiagramTriggerNodeSelection = useRecoilValue( const workflowDiagramTriggerNodeSelection = useRecoilValue(
showPageWorkflowDiagramTriggerNodeSelectionState, workflowDiagramTriggerNodeSelectionState,
); );
const handleSelectionChange = useCallback( const handleSelectionChange = useCallback(
@ -52,13 +50,13 @@ export const WorkflowShowPageDiagramEffect = () => {
return; return;
} }
setShowPageWorkflowSelectedNode(selectedNode.id); setWorkflowSelectedNode(selectedNode.id);
openRightDrawer(RightDrawerPages.WorkflowStepEdit); openRightDrawer(RightDrawerPages.WorkflowStepEdit);
}, },
[ [
closeRightDrawer, closeRightDrawer,
openRightDrawer, openRightDrawer,
setShowPageWorkflowSelectedNode, setWorkflowSelectedNode,
startNodeCreation, startNodeCreation,
], ],
); );
@ -68,14 +66,14 @@ export const WorkflowShowPageDiagramEffect = () => {
}); });
useEffect(() => { useEffect(() => {
if (!isDefined(showPageWorkflowDiagramTriggerNodeSelection)) { if (!isDefined(workflowDiagramTriggerNodeSelection)) {
return; return;
} }
reactflow.updateNode(showPageWorkflowDiagramTriggerNodeSelection, { reactflow.updateNode(workflowDiagramTriggerNodeSelection, {
selected: true, selected: true,
}); });
}, [reactflow, showPageWorkflowDiagramTriggerNodeSelection]); }, [reactflow, workflowDiagramTriggerNodeSelection]);
return null; return null;
}; };

View File

@ -7,7 +7,7 @@ export const StyledTargetHandle = styled(Handle)`
visibility: hidden; visibility: hidden;
`; `;
export const WorkflowShowPageDiagramCreateStepNode = () => { export const WorkflowDiagramCreateStepNode = () => {
return ( return (
<> <>
<StyledTargetHandle type="target" position={Position.Top} /> <StyledTargetHandle type="target" position={Position.Top} />

View File

@ -64,7 +64,7 @@ export const StyledTargetHandle = styled(Handle)`
visibility: hidden; visibility: hidden;
`; `;
export const WorkflowShowPageDiagramStepNode = ({ export const WorkflowDiagramStepNode = ({
data, data,
}: { }: {
data: WorkflowDiagramStepNodeData; data: WorkflowDiagramStepNodeData;

View File

@ -0,0 +1,103 @@
import { useGetManyServerlessFunctions } from '@/settings/serverless-functions/hooks/useGetManyServerlessFunctions';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { WorkflowAction } from '@/workflow/types/Workflow';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconCode, isDefined } from 'twenty-ui';
const StyledTriggerHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(6)};
`;
const StyledTriggerHeaderTitle = styled.p`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
margin: ${({ theme }) => theme.spacing(3)} 0;
`;
const StyledTriggerHeaderType = styled.p`
color: ${({ theme }) => theme.font.color.tertiary};
margin: 0;
`;
const StyledTriggerHeaderIconContainer = styled.div`
align-self: flex-start;
display: flex;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.xs};
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledTriggerSettings = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(4)};
`;
export const WorkflowEditActionForm = ({
action,
onUpdateAction,
}: {
action: WorkflowAction;
onUpdateAction: (trigger: WorkflowAction) => void;
}) => {
const theme = useTheme();
const { serverlessFunctions } = useGetManyServerlessFunctions();
const availableFunctions: Array<SelectOption<string>> = [
{ label: 'None', value: '' },
...serverlessFunctions
.filter((serverlessFunction) =>
isDefined(serverlessFunction.latestVersion),
)
.map((serverlessFunction) => ({
label: serverlessFunction.name,
value: serverlessFunction.id,
})),
];
return (
<>
<StyledTriggerHeader>
<StyledTriggerHeaderIconContainer>
<IconCode color={theme.color.orange} />
</StyledTriggerHeaderIconContainer>
<StyledTriggerHeaderTitle>
Code - Serverless Function
</StyledTriggerHeaderTitle>
<StyledTriggerHeaderType>Code</StyledTriggerHeaderType>
</StyledTriggerHeader>
<StyledTriggerSettings>
<Select
dropdownId="workflow-edit-action-function"
label="Function"
fullWidth
value={action.settings.serverlessFunctionId}
options={availableFunctions}
onChange={(updatedFunction) => {
onUpdateAction({
...action,
settings: {
...action.settings,
serverlessFunctionId: updatedFunction,
},
});
}}
/>
</StyledTriggerSettings>
</>
);
};

View File

@ -0,0 +1,136 @@
import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems';
import { Select, SelectOption } from '@/ui/input/components/Select';
import { OBJECT_EVENT_TRIGGERS } from '@/workflow/constants/ObjectEventTriggers';
import { WorkflowTrigger } from '@/workflow/types/Workflow';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { IconPlaylistAdd, isDefined } from 'twenty-ui';
const StyledTriggerHeader = styled.div`
background-color: ${({ theme }) => theme.background.secondary};
border-bottom: 1px solid ${({ theme }) => theme.border.color.medium};
display: flex;
flex-direction: column;
padding: ${({ theme }) => theme.spacing(6)};
`;
const StyledTriggerHeaderTitle = styled.p`
color: ${({ theme }) => theme.font.color.primary};
font-weight: ${({ theme }) => theme.font.weight.semiBold};
font-size: ${({ theme }) => theme.font.size.xl};
margin: ${({ theme }) => theme.spacing(3)} 0;
`;
const StyledTriggerHeaderType = styled.p`
color: ${({ theme }) => theme.font.color.tertiary};
margin: 0;
`;
const StyledTriggerHeaderIconContainer = styled.div`
align-self: flex-start;
display: flex;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.background.transparent.light};
border-radius: ${({ theme }) => theme.border.radius.xs};
padding: ${({ theme }) => theme.spacing(1)};
`;
const StyledTriggerSettings = styled.div`
padding: ${({ theme }) => theme.spacing(6)};
display: flex;
flex-direction: column;
row-gap: ${({ theme }) => theme.spacing(4)};
`;
export const WorkflowEditTriggerForm = ({
trigger,
onUpdateTrigger,
}: {
trigger: WorkflowTrigger;
onUpdateTrigger: (trigger: WorkflowTrigger) => void;
}) => {
const theme = useTheme();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,
);
const availableMetadata: Array<SelectOption<string>> =
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 selectedEvent = OBJECT_EVENT_TRIGGERS.find(
(availableEvent) => availableEvent.value === triggerEvent.event,
);
if (!isDefined(selectedEvent)) {
throw new Error('Expected to find the currently selected event type.');
}
return (
<>
<StyledTriggerHeader>
<StyledTriggerHeaderIconContainer>
<IconPlaylistAdd color={theme.font.color.tertiary} />
</StyledTriggerHeaderIconContainer>
<StyledTriggerHeaderTitle>
When a {recordTypeMetadata.labelSingular} is {selectedEvent.label}
</StyledTriggerHeaderTitle>
<StyledTriggerHeaderType>
Trigger . Record is {selectedEvent.label}
</StyledTriggerHeaderType>
</StyledTriggerHeader>
<StyledTriggerSettings>
<Select
dropdownId="workflow-edit-trigger-record-type"
label="Record Type"
fullWidth
value={triggerEvent.objectType}
options={availableMetadata}
onChange={(updatedRecordType) => {
onUpdateTrigger({
...trigger,
settings: {
...trigger.settings,
eventName: `${updatedRecordType}.${triggerEvent.event}`,
},
});
}}
/>
<Select
dropdownId="workflow-edit-trigger-event-type"
label="Event type"
fullWidth
value={triggerEvent.event}
options={OBJECT_EVENT_TRIGGERS}
onChange={(updatedEvent) => {
onUpdateTrigger({
...trigger,
settings: {
...trigger.settings,
eventName: `${triggerEvent.objectType}.${updatedEvent}`,
},
});
}}
/>
</StyledTriggerSettings>
</>
);
};

View File

@ -1,12 +1,8 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; import { workflowIdState } from '@/workflow/states/workflowIdState';
import { showPageWorkflowErrorState } from '@/workflow/states/showPageWorkflowErrorState';
import { showPageWorkflowIdState } from '@/workflow/states/showPageWorkflowIdState';
import { showPageWorkflowLoadingState } from '@/workflow/states/showPageWorkflowLoadingState';
import { Workflow } from '@/workflow/types/Workflow';
import { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes'; import { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes';
import { getWorkflowLastDiagramVersion } from '@/workflow/utils/getWorkflowLastDiagramVersion'; import { getWorkflowVersionDiagram } from '@/workflow/utils/getWorkflowVersionDiagram';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil'; import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui'; import { isDefined } from 'twenty-ui';
@ -18,50 +14,29 @@ type WorkflowShowPageEffectProps = {
export const WorkflowShowPageEffect = ({ export const WorkflowShowPageEffect = ({
workflowId, workflowId,
}: WorkflowShowPageEffectProps) => { }: WorkflowShowPageEffectProps) => {
const { const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
record: workflow,
loading,
error,
} = useFindOneRecord<Workflow>({
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: workflowId,
recordGqlFields: {
id: true,
name: true,
versions: true,
publishedVersionId: true,
},
});
const setShowPageWorkflowId = useSetRecoilState(showPageWorkflowIdState); const setWorkflowId = useSetRecoilState(workflowIdState);
const setCurrentWorkflowData = useSetRecoilState( const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
showPageWorkflowDiagramState,
);
const setCurrentWorkflowLoading = useSetRecoilState(
showPageWorkflowLoadingState,
);
const setCurrentWorkflowError = useSetRecoilState(showPageWorkflowErrorState);
useEffect(() => { useEffect(() => {
setShowPageWorkflowId(workflowId); setWorkflowId(workflowId);
}, [setShowPageWorkflowId, workflowId]); }, [setWorkflowId, workflowId]);
useEffect(() => { useEffect(() => {
const flowLastVersion = getWorkflowLastDiagramVersion(workflow); const currentVersion = workflowWithCurrentVersion?.currentVersion;
const flowWithCreateStepNodes = addCreateStepNodes(flowLastVersion); if (!isDefined(currentVersion)) {
setWorkflowDiagram(undefined);
setCurrentWorkflowData( return;
isDefined(workflow) ? flowWithCreateStepNodes : undefined, }
);
}, [setCurrentWorkflowData, workflow]);
useEffect(() => { const lastWorkflowDiagram = getWorkflowVersionDiagram(currentVersion);
setCurrentWorkflowLoading(loading); const workflowDiagramWithCreateStepNodes =
}, [loading, setCurrentWorkflowLoading]); addCreateStepNodes(lastWorkflowDiagram);
useEffect(() => { setWorkflowDiagram(workflowDiagramWithCreateStepNodes);
setCurrentWorkflowError(error); }, [setWorkflowDiagram, workflowWithCurrentVersion?.currentVersion]);
}, [error, setCurrentWorkflowError]);
return null; return null;
}; };

View File

@ -0,0 +1,14 @@
import { WorkflowStepType } from '@/workflow/types/Workflow';
import { IconComponent, IconSettingsAutomation } from 'twenty-ui';
export const ACTIONS: Array<{
label: string;
type: WorkflowStepType;
icon: IconComponent;
}> = [
{
label: 'Serverless Function',
type: 'CODE_ACTION',
icon: IconSettingsAutomation,
},
];

View File

@ -0,0 +1,16 @@
import { SelectOption } from '@/ui/input/components/Select';
export const OBJECT_EVENT_TRIGGERS: Array<SelectOption<string>> = [
{
label: 'Created',
value: 'created',
},
{
label: 'Updated',
value: 'updated',
},
{
label: 'Deleted',
value: 'deleted',
},
];

View File

@ -0,0 +1 @@
export const TRIGGER_STEP_ID = 'trigger';

View File

@ -1,47 +0,0 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import {
Workflow,
WorkflowStep,
WorkflowVersion,
} from '@/workflow/types/Workflow';
import { getWorkflowLastVersion } from '@/workflow/utils/getWorkflowLastVersion';
import { insertStep } from '@/workflow/utils/insertStep';
import { isDefined } from 'twenty-ui';
export const useCreateNode = ({ workflow }: { workflow: Workflow }) => {
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const createNode = ({
parentNodeId,
nodeToAdd,
}: {
parentNodeId: string;
nodeToAdd: WorkflowStep;
}) => {
const lastVersion = getWorkflowLastVersion(workflow);
if (!isDefined(lastVersion)) {
throw new Error(
"Can't add a node when no version exists yet. Create a first workflow version before trying to add a node.",
);
}
return updateOneWorkflowVersion({
idToUpdate: lastVersion.id,
updateOneRecordInput: {
steps: insertStep({
steps: lastVersion.steps,
parentStepId: parentNodeId,
stepToAdd: nodeToAdd,
}),
},
});
};
return {
createNode,
};
};

View File

@ -0,0 +1,83 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState';
import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState';
import {
WorkflowStep,
WorkflowStepType,
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { getStepDefaultDefinition } from '@/workflow/utils/getStepDefaultDefinition';
import { insertStep } from '@/workflow/utils/insertStep';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const useCreateStep = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const workflowCreateStepFromParentStepId = useRecoilValue(
workflowCreateStepFromParentStepIdState,
);
const setWorkflowDiagramTriggerNodeSelection = useSetRecoilState(
workflowDiagramTriggerNodeSelectionState,
);
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const insertNodeAndSave = ({
parentNodeId,
nodeToAdd,
}: {
parentNodeId: string;
nodeToAdd: WorkflowStep;
}) => {
const currentVersion = workflow.currentVersion;
if (!isDefined(currentVersion)) {
throw new Error("Can't add a node when there is no current version.");
}
return updateOneWorkflowVersion({
idToUpdate: currentVersion.id,
updateOneRecordInput: {
steps: insertStep({
steps: currentVersion.steps ?? [],
parentStepId: parentNodeId,
stepToAdd: nodeToAdd,
}),
},
});
};
const createStep = async (newStepType: WorkflowStepType) => {
if (!isDefined(workflowCreateStepFromParentStepId)) {
throw new Error('Select a step to create a new step from first.');
}
const newStep = getStepDefaultDefinition(newStepType);
await insertNodeAndSave({
parentNodeId: workflowCreateStepFromParentStepId,
nodeToAdd: newStep,
});
/**
* After the step has been created, select it.
* As the `insertNodeAndSave` function mutates the cached workflow before resolving,
* we are sure that the new node will have been created at this stage.
*
* Selecting the node will cause a right drawer to open in order to edit the step.
*/
setWorkflowDiagramTriggerNodeSelection(newStep.id);
};
return {
createStep,
};
};

View File

@ -1,117 +0,0 @@
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useCreateNode } from '@/workflow/hooks/useCreateNode';
import { showPageWorkflowDiagramTriggerNodeSelectionState } from '@/workflow/states/showPageWorkflowDiagramTriggerNodeSelectionState';
import { workflowCreateStepFromParentStepIdState } from '@/workflow/states/workflowCreateStepFromParentStepIdState';
import { Workflow } from '@/workflow/types/Workflow';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
IconPlaystationSquare,
IconPlug,
IconPlus,
IconSearch,
IconSettingsAutomation,
} from 'twenty-ui';
import { v4 } from 'uuid';
export const useRightDrawerWorkflowSelectAction = ({
tabListId,
workflow,
}: {
tabListId: string;
workflow: Workflow;
}) => {
const workflowCreateStepFromParentStepId = useRecoilValue(
workflowCreateStepFromParentStepIdState,
);
const setShowPageWorkflowDiagramTriggerNodeSelection = useSetRecoilState(
showPageWorkflowDiagramTriggerNodeSelectionState,
);
const { createNode } = useCreateNode({ workflow });
const allOptions: Array<{
id: string;
name: string;
type: 'standard' | 'custom';
icon: any;
}> = [
{
id: 'create-record',
name: 'Create Record',
type: 'standard',
icon: IconPlus,
},
{
id: 'find-records',
name: 'Find Records',
type: 'standard',
icon: IconSearch,
},
];
const tabs = [
{
id: 'all',
title: 'All',
Icon: IconSettingsAutomation,
},
{
id: 'standard',
title: 'Standard',
Icon: IconPlaystationSquare,
},
{
id: 'custom',
title: 'Custom',
Icon: IconPlug,
},
];
const { activeTabIdState } = useTabList(tabListId);
const activeTabId = useRecoilValue(activeTabIdState);
const options = allOptions.filter(
(option) => activeTabId === 'all' || option.type === activeTabId,
);
const handleActionClick = async (actionId: string) => {
if (workflowCreateStepFromParentStepId === undefined) {
throw new Error('Select a step to create a new step from first.');
}
const newNodeId = v4();
/**
* FIXME: For now, the data of the node to create are mostly static.
*/
await createNode({
parentNodeId: workflowCreateStepFromParentStepId,
nodeToAdd: {
id: newNodeId,
name: actionId,
type: 'CODE_ACTION',
valid: true,
settings: {
serverlessFunctionId: '111',
errorHandlingOptions: {
continueOnFailure: {
value: true,
},
retryOnFailure: {
value: true,
},
},
},
},
});
setShowPageWorkflowDiagramTriggerNodeSelection(newNodeId);
};
return {
tabs,
options,
handleActionClick,
};
};

View File

@ -0,0 +1,43 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import {
WorkflowStep,
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { replaceStep } from '@/workflow/utils/replaceStep';
import { isDefined } from 'twenty-ui';
export const useUpdateWorkflowVersionStep = ({
workflow,
stepId,
}: {
workflow: WorkflowWithCurrentVersion;
stepId: string;
}) => {
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const updateStep = async (updatedStep: WorkflowStep) => {
if (!isDefined(workflow.currentVersion)) {
throw new Error('Can not update an undefined workflow version.');
}
await updateOneWorkflowVersion({
idToUpdate: workflow.currentVersion.id,
updateOneRecordInput: {
steps: replaceStep({
steps: workflow.currentVersion.steps ?? [],
stepId,
stepToReplace: updatedStep,
}),
},
});
};
return {
updateStep,
};
};

View File

@ -0,0 +1,36 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import {
WorkflowTrigger,
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { isDefined } from 'twenty-ui';
export const useUpdateWorkflowVersionTrigger = ({
workflow,
}: {
workflow: WorkflowWithCurrentVersion;
}) => {
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const updateTrigger = async (updatedTrigger: WorkflowTrigger) => {
if (!isDefined(workflow.currentVersion)) {
throw new Error('Can not update an undefined workflow version.');
}
await updateOneWorkflowVersion({
idToUpdate: workflow.currentVersion.id,
updateOneRecordInput: {
trigger: updatedTrigger,
},
});
};
return {
updateTrigger,
};
};

View File

@ -0,0 +1,55 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import {
Workflow,
WorkflowVersion,
WorkflowWithCurrentVersion,
} from '@/workflow/types/Workflow';
import { isDefined } from 'twenty-ui';
export const useWorkflowWithCurrentVersion = (
workflowId: string | undefined,
): WorkflowWithCurrentVersion | undefined => {
const { record: workflow } = useFindOneRecord<Workflow>({
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: workflowId,
recordGqlFields: {
id: true,
name: true,
statuses: true,
},
skip: !isDefined(workflowId),
});
const { records: mostRecentWorkflowVersions } =
useFindManyRecords<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
filter: {
workflowId: {
eq: workflowId,
},
},
orderBy: [
{
createdAt: 'DescNullsLast',
},
],
limit: 1,
skip: !isDefined(workflowId),
});
if (!isDefined(workflow)) {
return undefined;
}
const currentVersion = mostRecentWorkflowVersions?.[0];
if (!isDefined(currentVersion)) {
return undefined;
}
return {
...workflow,
currentVersion,
};
};

View File

@ -1,8 +0,0 @@
import { createState } from 'twenty-ui';
export const showPageWorkflowDiagramTriggerNodeSelectionState = createState<
string | undefined
>({
key: 'showPageWorkflowDiagramTriggerNodeSelectionState',
defaultValue: undefined,
});

View File

@ -1,7 +0,0 @@
import { ApolloError } from '@apollo/client';
import { createState } from 'twenty-ui';
export const showPageWorkflowErrorState = createState<ApolloError | undefined>({
key: 'showPageWorkflowErrorState',
defaultValue: undefined,
});

View File

@ -1,6 +0,0 @@
import { createState } from 'twenty-ui';
export const showPageWorkflowIdState = createState<string | undefined>({
key: 'showPageWorkflowIdState',
defaultValue: undefined,
});

View File

@ -1,6 +0,0 @@
import { createState } from 'twenty-ui';
export const showPageWorkflowLoadingState = createState<boolean>({
key: 'showPageWorkflowLoadingState',
defaultValue: true,
});

View File

@ -1,8 +0,0 @@
import { createState } from 'twenty-ui';
export const showPageWorkflowSelectedNodeState = createState<
string | undefined
>({
key: 'showPageWorkflowSelectedNodeState',
defaultValue: undefined,
});

View File

@ -1,9 +1,7 @@
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
import { createState } from 'twenty-ui'; import { createState } from 'twenty-ui';
export const showPageWorkflowDiagramState = createState< export const workflowDiagramState = createState<WorkflowDiagram | undefined>({
WorkflowDiagram | undefined key: 'workflowDiagramState',
>({
key: 'showPageWorkflowDiagramState',
defaultValue: undefined, defaultValue: undefined,
}); });

View File

@ -0,0 +1,8 @@
import { createState } from 'twenty-ui';
export const workflowDiagramTriggerNodeSelectionState = createState<
string | undefined
>({
key: 'workflowDiagramTriggerNodeSelectionState',
defaultValue: undefined,
});

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const workflowIdState = createState<string | undefined>({
key: 'workflowIdState',
defaultValue: undefined,
});

View File

@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const workflowSelectedNodeState = createState<string | undefined>({
key: 'workflowSelectedNodeState',
defaultValue: undefined,
});

View File

@ -30,6 +30,8 @@ export type WorkflowAction = WorkflowCodeAction;
export type WorkflowStep = WorkflowAction; export type WorkflowStep = WorkflowAction;
export type WorkflowStepType = WorkflowStep['type'];
export type WorkflowTriggerType = 'DATABASE_EVENT'; export type WorkflowTriggerType = 'DATABASE_EVENT';
type BaseTrigger = { type BaseTrigger = {
@ -46,14 +48,23 @@ export type WorkflowDatabaseEventTrigger = BaseTrigger & {
export type WorkflowTrigger = WorkflowDatabaseEventTrigger; export type WorkflowTrigger = WorkflowDatabaseEventTrigger;
export type WorkflowStatus = 'DRAFT' | 'ACTIVE' | 'DEACTIVATED';
export type WorkflowVersionStatus =
| 'DRAFT'
| 'ACTIVE'
| 'DEACTIVATED'
| 'ARCHIVED';
export type WorkflowVersion = { export type WorkflowVersion = {
id: string; id: string;
name: string; name: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
workflowId: string; workflowId: string;
trigger: WorkflowTrigger; trigger: WorkflowTrigger | null;
steps: Array<WorkflowStep>; steps: Array<WorkflowStep> | null;
status: WorkflowVersionStatus;
__typename: 'WorkflowVersion'; __typename: 'WorkflowVersion';
}; };
@ -62,5 +73,10 @@ export type Workflow = {
id: string; id: string;
name: string; name: string;
versions: Array<WorkflowVersion>; versions: Array<WorkflowVersion>;
publishedVersionId: string; lastPublishedVersionId: string;
statuses: Array<WorkflowStatus> | null;
};
export type WorkflowWithCurrentVersion = Workflow & {
currentVersion: WorkflowVersion | undefined;
}; };

View File

@ -18,7 +18,7 @@ describe('generateWorkflowDiagram', () => {
expect(result.nodes[0]).toMatchObject({ expect(result.nodes[0]).toMatchObject({
data: { data: {
label: trigger.settings.eventName, label: 'Company is Created',
nodeType: 'trigger', nodeType: 'trigger',
}, },
}); });

View File

@ -1,79 +0,0 @@
import { Workflow } from '@/workflow/types/Workflow';
import { getWorkflowLastDiagramVersion } from '../getWorkflowLastDiagramVersion';
describe('getWorkflowLastDiagramVersion', () => {
it('returns an empty diagram if the provided workflow is undefined', () => {
const result = getWorkflowLastDiagramVersion(undefined);
expect(result).toEqual({ nodes: [], edges: [] });
});
it('returns an empty diagram if the provided workflow has no versions', () => {
const result = getWorkflowLastDiagramVersion({
__typename: 'Workflow',
id: 'aa',
name: 'aa',
publishedVersionId: '',
versions: [],
});
expect(result).toEqual({ nodes: [], edges: [] });
});
it('returns the diagram for the last version', () => {
const workflow: Workflow = {
__typename: 'Workflow',
id: 'aa',
name: 'aa',
publishedVersionId: '',
versions: [
{
__typename: 'WorkflowVersion',
createdAt: '',
id: '1',
name: '',
steps: [],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
},
{
__typename: 'WorkflowVersion',
createdAt: '',
id: '1',
name: '',
steps: [
{
id: 'step-1',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
},
],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
},
],
};
const result = getWorkflowLastDiagramVersion(workflow);
// Corresponds to the trigger + 1 step
expect(result.nodes).toHaveLength(2);
expect(result.edges).toHaveLength(1);
});
});

View File

@ -0,0 +1,79 @@
import { getWorkflowVersionDiagram } from '../getWorkflowVersionDiagram';
describe('getWorkflowVersionDiagram', () => {
it('returns an empty diagram if the provided workflow version', () => {
const result = getWorkflowVersionDiagram(undefined);
expect(result).toEqual({ nodes: [], edges: [] });
});
it('returns an empty diagram if the provided workflow version has no trigger', () => {
const result = getWorkflowVersionDiagram({
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
steps: [],
trigger: null,
updatedAt: '',
workflowId: '',
});
expect(result).toEqual({ nodes: [], edges: [] });
});
it('returns an empty diagram if the provided workflow version has no steps', () => {
const result = getWorkflowVersionDiagram({
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
steps: null,
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
});
expect(result).toEqual({ nodes: [], edges: [] });
});
it('returns the diagram for the last version', () => {
const result = getWorkflowVersionDiagram({
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
steps: [
{
id: 'step-1',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
},
],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
});
// Corresponds to the trigger + 1 step
expect(result.nodes).toHaveLength(2);
expect(result.edges).toHaveLength(1);
});
});

View File

@ -3,8 +3,9 @@ import { insertStep } from '../insertStep';
describe('insertStep', () => { describe('insertStep', () => {
it('returns a deep copy of the provided steps array instead of mutating it', () => { it('returns a deep copy of the provided steps array instead of mutating it', () => {
const workflowVersionInitial: WorkflowVersion = { const workflowVersionInitial = {
__typename: 'WorkflowVersion', __typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '', createdAt: '',
id: '1', id: '1',
name: '', name: '',
@ -15,7 +16,7 @@ describe('insertStep', () => {
}, },
updatedAt: '', updatedAt: '',
workflowId: '', workflowId: '',
}; } satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = { const stepToAdd: WorkflowStep = {
id: 'step-1', id: 'step-1',
name: '', name: '',
@ -40,8 +41,9 @@ describe('insertStep', () => {
}); });
it('adds the step when the steps array is empty', () => { it('adds the step when the steps array is empty', () => {
const workflowVersionInitial: WorkflowVersion = { const workflowVersionInitial = {
__typename: 'WorkflowVersion', __typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '', createdAt: '',
id: '1', id: '1',
name: '', name: '',
@ -52,7 +54,7 @@ describe('insertStep', () => {
}, },
updatedAt: '', updatedAt: '',
workflowId: '', workflowId: '',
}; } satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = { const stepToAdd: WorkflowStep = {
id: 'step-1', id: 'step-1',
name: '', name: '',
@ -78,8 +80,9 @@ describe('insertStep', () => {
}); });
it('adds the step at the end of a non-empty steps array', () => { it('adds the step at the end of a non-empty steps array', () => {
const workflowVersionInitial: WorkflowVersion = { const workflowVersionInitial = {
__typename: 'WorkflowVersion', __typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '', createdAt: '',
id: '1', id: '1',
name: '', name: '',
@ -117,7 +120,7 @@ describe('insertStep', () => {
}, },
updatedAt: '', updatedAt: '',
workflowId: '', workflowId: '',
}; } satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = { const stepToAdd: WorkflowStep = {
id: 'step-3', id: 'step-3',
name: '', name: '',
@ -147,8 +150,9 @@ describe('insertStep', () => {
}); });
it('adds the step in the middle of a non-empty steps array', () => { it('adds the step in the middle of a non-empty steps array', () => {
const workflowVersionInitial: WorkflowVersion = { const workflowVersionInitial = {
__typename: 'WorkflowVersion', __typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '', createdAt: '',
id: '1', id: '1',
name: '', name: '',
@ -186,7 +190,7 @@ describe('insertStep', () => {
}, },
updatedAt: '', updatedAt: '',
workflowId: '', workflowId: '',
}; } satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = { const stepToAdd: WorkflowStep = {
id: 'step-3', id: 'step-3',
name: '', name: '',

View File

@ -0,0 +1,127 @@
import { WorkflowStep, WorkflowVersion } from '@/workflow/types/Workflow';
import { replaceStep } from '../replaceStep';
describe('replaceStep', () => {
it('returns a deep copy of the provided steps array instead of mutating it', () => {
const stepToBeReplaced = {
id: 'step-1',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'first',
},
type: 'CODE_ACTION',
valid: true,
} satisfies WorkflowStep;
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
steps: [stepToBeReplaced],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
} satisfies WorkflowVersion;
const stepsUpdated = replaceStep({
steps: workflowVersionInitial.steps,
stepToReplace: {
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'second',
},
},
stepId: stepToBeReplaced.id,
});
expect(workflowVersionInitial.steps).not.toBe(stepsUpdated);
});
it('replaces a step in a non-empty steps array', () => {
const stepToBeReplaced: WorkflowStep = {
id: 'step-2',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
};
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
steps: [
{
id: 'step-1',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
},
stepToBeReplaced,
{
id: 'step-3',
name: '',
settings: {
errorHandlingOptions: {
retryOnFailure: { value: true },
continueOnFailure: { value: false },
},
serverlessFunctionId: 'a5434be2-c10b-465c-acec-46492782a997',
},
type: 'CODE_ACTION',
valid: true,
},
],
trigger: {
settings: { eventName: 'company.created' },
type: 'DATABASE_EVENT',
},
updatedAt: '',
workflowId: '',
} satisfies WorkflowVersion;
const updatedStepName = "that's another name";
const stepsUpdated = replaceStep({
stepId: stepToBeReplaced.id,
steps: workflowVersionInitial.steps,
stepToReplace: {
name: updatedStepName,
},
});
const expectedUpdatedSteps: Array<WorkflowStep> = [
workflowVersionInitial.steps[0],
{
...stepToBeReplaced,
name: updatedStepName,
},
workflowVersionInitial.steps[2],
];
expect(stepsUpdated).toEqual(expectedUpdatedSteps);
});
});

View File

@ -0,0 +1,41 @@
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { WorkflowStep } from '@/workflow/types/Workflow';
import { isDefined } from 'twenty-ui';
/**
* This function returns the reference of the array where the step should be positioned
* and at which index.
*/
export const findStepPositionOrThrow = ({
steps,
stepId,
}: {
steps: Array<WorkflowStep>;
stepId: string | undefined;
}): { steps: Array<WorkflowStep>; index: number } => {
if (!isDefined(stepId) || stepId === TRIGGER_STEP_ID) {
return {
steps,
index: 0,
};
}
for (const [index, step] of steps.entries()) {
if (step.id === stepId) {
return {
steps,
index,
};
}
// TODO: When condition will have been implemented, put recursivity here.
// if (step.type === "CONDITION") {
// return findNodePosition({
// workflowSteps: step.conditions,
// stepId,
// })
// }
}
throw new Error(`Couldn't locate the step. Unreachable step id: ${stepId}.`);
};

View File

@ -1,11 +1,14 @@
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow'; import { WorkflowStep, WorkflowTrigger } from '@/workflow/types/Workflow';
import { import {
WorkflowDiagram, WorkflowDiagram,
WorkflowDiagramEdge, WorkflowDiagramEdge,
WorkflowDiagramNode, WorkflowDiagramNode,
} from '@/workflow/types/WorkflowDiagram'; } from '@/workflow/types/WorkflowDiagram';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { MarkerType } from '@xyflow/react'; import { MarkerType } from '@xyflow/react';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { capitalize } from '~/utils/string/capitalize';
export const generateWorkflowDiagram = ({ export const generateWorkflowDiagram = ({
trigger, trigger,
@ -58,12 +61,15 @@ export const generateWorkflowDiagram = ({
}; };
// Start with the trigger node // Start with the trigger node
const triggerNodeId = 'trigger'; const triggerNodeId = TRIGGER_STEP_ID;
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,
);
nodes.push({ nodes.push({
id: triggerNodeId, id: triggerNodeId,
data: { data: {
nodeType: 'trigger', nodeType: 'trigger',
label: trigger.settings.eventName, label: `${capitalize(triggerEvent.objectType)} is ${capitalize(triggerEvent.event)}`,
}, },
position: { position: {
x: 0, x: 0,

View File

@ -0,0 +1,33 @@
import { WorkflowStep, WorkflowStepType } from '@/workflow/types/Workflow';
import { v4 } from 'uuid';
export const getStepDefaultDefinition = (
type: WorkflowStepType,
): WorkflowStep => {
const newStepId = v4();
switch (type) {
case 'CODE_ACTION': {
return {
id: newStepId,
name: 'Code',
type: 'CODE_ACTION',
valid: false,
settings: {
serverlessFunctionId: '',
errorHandlingOptions: {
continueOnFailure: {
value: false,
},
retryOnFailure: {
value: false,
},
},
},
};
}
default: {
throw new Error(`Unknown type: ${type}`);
}
}
};

View File

@ -1,28 +0,0 @@
import { Workflow } from '@/workflow/types/Workflow';
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
import { generateWorkflowDiagram } from '@/workflow/utils/generateWorkflowDiagram';
import { getWorkflowLastVersion } from '@/workflow/utils/getWorkflowLastVersion';
import { isDefined } from 'twenty-ui';
const EMPTY_DIAGRAM: WorkflowDiagram = {
nodes: [],
edges: [],
};
export const getWorkflowLastDiagramVersion = (
workflow: Workflow | undefined,
): WorkflowDiagram => {
if (!isDefined(workflow)) {
return EMPTY_DIAGRAM;
}
const lastVersion = getWorkflowLastVersion(workflow);
if (!isDefined(lastVersion) || !isDefined(lastVersion.trigger)) {
return EMPTY_DIAGRAM;
}
return generateWorkflowDiagram({
trigger: lastVersion.trigger,
steps: lastVersion.steps,
});
};

View File

@ -1,10 +0,0 @@
import { Workflow, WorkflowVersion } from '@/workflow/types/Workflow';
export const getWorkflowLastVersion = (
workflow: Workflow,
): WorkflowVersion | undefined => {
return workflow.versions
.slice()
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1))
.at(-1);
};

View File

@ -0,0 +1,28 @@
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
import { generateWorkflowDiagram } from '@/workflow/utils/generateWorkflowDiagram';
import { isDefined } from 'twenty-ui';
const EMPTY_DIAGRAM: WorkflowDiagram = {
nodes: [],
edges: [],
};
export const getWorkflowVersionDiagram = (
workflowVersion: WorkflowVersion | undefined,
): WorkflowDiagram => {
if (
!(
isDefined(workflowVersion) &&
isDefined(workflowVersion.trigger) &&
isDefined(workflowVersion.steps)
)
) {
return EMPTY_DIAGRAM;
}
return generateWorkflowDiagram({
trigger: workflowVersion.trigger,
steps: workflowVersion.steps,
});
};

View File

@ -1,38 +1,5 @@
import { WorkflowStep } from '@/workflow/types/Workflow'; import { WorkflowStep } from '@/workflow/types/Workflow';
import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow';
const findStepPositionOrThrow = ({
steps,
stepId,
}: {
steps: Array<WorkflowStep>;
stepId: string | undefined;
}): { steps: Array<WorkflowStep>; index: number } => {
if (stepId === undefined) {
return {
steps,
index: 0,
};
}
for (const [index, step] of steps.entries()) {
if (step.id === stepId) {
return {
steps,
index,
};
}
// TODO: When condition will have been implemented, put recursivity here.
// if (step.type === "CONDITION") {
// return findNodePosition({
// workflowSteps: step.conditions,
// stepId,
// })
// }
}
throw new Error(`Couldn't locate the step. Unreachable step id: ${stepId}.`);
};
export const insertStep = ({ export const insertStep = ({
steps: stepsInitial, steps: stepsInitial,
@ -43,11 +10,10 @@ export const insertStep = ({
parentStepId: string | undefined; parentStepId: string | undefined;
stepToAdd: WorkflowStep; stepToAdd: WorkflowStep;
}): Array<WorkflowStep> => { }): Array<WorkflowStep> => {
// Make a deep copy of the nested object to prevent unwanted side effects.
const steps = structuredClone(stepsInitial); const steps = structuredClone(stepsInitial);
const parentStepPosition = findStepPositionOrThrow({ const parentStepPosition = findStepPositionOrThrow({
steps: steps, steps,
stepId: parentStepId, stepId: parentStepId,
}); });

View File

@ -0,0 +1,26 @@
import { WorkflowStep } from '@/workflow/types/Workflow';
import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow';
export const replaceStep = ({
steps: stepsInitial,
stepId,
stepToReplace,
}: {
steps: Array<WorkflowStep>;
stepId: string;
stepToReplace: Partial<Omit<WorkflowStep, 'id'>>;
}) => {
const steps = structuredClone(stepsInitial);
const parentStepPosition = findStepPositionOrThrow({
steps,
stepId,
});
parentStepPosition.steps[parentStepPosition.index] = {
...parentStepPosition.steps[parentStepPosition.index],
...stepToReplace,
};
return steps;
};

View File

@ -0,0 +1,8 @@
export const splitWorkflowTriggerEventName = (eventName: string) => {
const [objectType, event] = eventName.split('.');
return {
objectType,
event,
};
};

View File

@ -1,10 +1,10 @@
import { PageBody } from '@/ui/layout/page/PageBody'; import { PageBody } from '@/ui/layout/page/PageBody';
import { PageContainer } from '@/ui/layout/page/PageContainer'; import { PageContainer } from '@/ui/layout/page/PageContainer';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle'; import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { WorkflowShowPageDiagram } from '@/workflow/components/WorkflowShowPageDiagram'; import { WorkflowDiagramCanvas } from '@/workflow/components/WorkflowDiagramCanvas';
import { WorkflowShowPageEffect } from '@/workflow/components/WorkflowShowPageEffect'; import { WorkflowShowPageEffect } from '@/workflow/components/WorkflowShowPageEffect';
import { WorkflowShowPageHeader } from '@/workflow/components/WorkflowShowPageHeader'; import { WorkflowShowPageHeader } from '@/workflow/components/WorkflowShowPageHeader';
import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState'; import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import styled from '@emotion/styled'; import styled from '@emotion/styled';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
@ -37,7 +37,7 @@ export const WorkflowShowPage = () => {
const workflowName = 'Test Workflow'; const workflowName = 'Test Workflow';
const showPageWorkflowDiagram = useRecoilValue(showPageWorkflowDiagramState); const workflowDiagram = useRecoilValue(workflowDiagramState);
if (parameters.workflowId === undefined) { if (parameters.workflowId === undefined) {
return null; return null;
@ -54,8 +54,8 @@ export const WorkflowShowPage = () => {
/> />
<PageBody> <PageBody>
<StyledFlowContainer> <StyledFlowContainer>
{showPageWorkflowDiagram === undefined ? null : ( {workflowDiagram === undefined ? null : (
<WorkflowShowPageDiagram diagram={showPageWorkflowDiagram} /> <WorkflowDiagramCanvas diagram={workflowDiagram} />
)} )}
</StyledFlowContainer> </StyledFlowContainer>
</PageBody> </PageBody>

View File

@ -178,6 +178,7 @@ export {
IconWand, IconWand,
IconWorld, IconWorld,
IconX, IconX,
IconPlaylistAdd,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
export type { TablerIconsProps } from '@tabler/icons-react'; export type { TablerIconsProps } from '@tabler/icons-react';