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
@@ -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} />;
}; };
@@ -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}
/>
);
};
@@ -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;
@@ -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);
}} }}
/> />
))} ))}
@@ -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>
@@ -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;
}; };
@@ -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} />
@@ -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;
@@ -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>
</>
);
};
@@ -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>
</>
);
};
@@ -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;
}; };
@@ -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,
},
];
@@ -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',
},
];
@@ -0,0 +1 @@
export const TRIGGER_STEP_ID = 'trigger';
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -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,
};
};
@@ -1,8 +0,0 @@
import { createState } from 'twenty-ui';
export const showPageWorkflowDiagramTriggerNodeSelectionState = createState<
string | undefined
>({
key: 'showPageWorkflowDiagramTriggerNodeSelectionState',
defaultValue: undefined,
});
@@ -1,7 +0,0 @@
import { ApolloError } from '@apollo/client';
import { createState } from 'twenty-ui';
export const showPageWorkflowErrorState = createState<ApolloError | undefined>({
key: 'showPageWorkflowErrorState',
defaultValue: undefined,
});
@@ -1,6 +0,0 @@
import { createState } from 'twenty-ui';
export const showPageWorkflowIdState = createState<string | undefined>({
key: 'showPageWorkflowIdState',
defaultValue: undefined,
});
@@ -1,6 +0,0 @@
import { createState } from 'twenty-ui';
export const showPageWorkflowLoadingState = createState<boolean>({
key: 'showPageWorkflowLoadingState',
defaultValue: true,
});
@@ -1,8 +0,0 @@
import { createState } from 'twenty-ui';
export const showPageWorkflowSelectedNodeState = createState<
string | undefined
>({
key: 'showPageWorkflowSelectedNodeState',
defaultValue: undefined,
});
@@ -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,
}); });
@@ -0,0 +1,8 @@
import { createState } from 'twenty-ui';
export const workflowDiagramTriggerNodeSelectionState = createState<
string | undefined
>({
key: 'workflowDiagramTriggerNodeSelectionState',
defaultValue: undefined,
});
@@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const workflowIdState = createState<string | undefined>({
key: 'workflowIdState',
defaultValue: undefined,
});
@@ -0,0 +1,6 @@
import { createState } from 'twenty-ui';
export const workflowSelectedNodeState = createState<string | undefined>({
key: 'workflowSelectedNodeState',
defaultValue: undefined,
});
@@ -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;
}; };
@@ -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',
}, },
}); });
@@ -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);
});
});
@@ -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);
});
});
@@ -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: '',
@@ -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);
});
});
@@ -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}.`);
};
@@ -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,
@@ -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}`);
}
}
};
@@ -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,
});
};
@@ -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);
};
@@ -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,
});
};
@@ -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,
}); });
@@ -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;
};
@@ -0,0 +1,8 @@
export const splitWorkflowTriggerEventName = (eventName: string) => {
const [objectType, event] = eventName.split('.');
return {
objectType,
event,
};
};
@@ -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>
@@ -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';