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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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 { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowEditStep = () => {
const showPageWorkflowSelectedNode = useRecoilValue(
showPageWorkflowSelectedNodeState,
);
const workflowId = useRecoilValue(workflowIdState);
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 { showPageWorkflowIdState } from '@/workflow/states/showPageWorkflowIdState';
import { Workflow } from '@/workflow/types/Workflow';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowSelectAction = () => {
const showPageWorkflowId = useRecoilValue(showPageWorkflowIdState);
const { record: workflow } = useFindOneRecord<Workflow>({
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: showPageWorkflowId,
recordGqlFields: {
id: true,
name: true,
versions: true,
publishedVersionId: true,
},
});
const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId);
if (!isDefined(workflow)) {
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 { useRightDrawerWorkflowSelectAction } from '@/workflow/hooks/useRightDrawerWorkflowSelectAction';
import { Workflow } from '@/workflow/types/Workflow';
import { ACTIONS } from '@/workflow/constants/Actions';
import { useCreateStep } from '@/workflow/hooks/useCreateStep';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
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`
display: flex;
flex-direction: column;
@ -24,33 +14,24 @@ const StyledActionListContainer = styled.div`
padding-inline: ${({ theme }) => theme.spacing(2)};
`;
export const TAB_LIST_COMPONENT_ID =
'workflow-select-action-page-right-tab-list';
export const RightDrawerWorkflowSelectActionContent = ({
workflow,
}: {
workflow: Workflow;
workflow: WorkflowWithCurrentVersion;
}) => {
const tabListId = `${TAB_LIST_COMPONENT_ID}`;
const { tabs, options, handleActionClick } =
useRightDrawerWorkflowSelectAction({ tabListId, workflow });
const { createStep } = useCreateStep({
workflow,
});
return (
<>
<StyledTabListContainer>
<TabList loading={false} tabListId={tabListId} tabs={tabs} />
</StyledTabListContainer>
<StyledActionListContainer>
{options.map((option) => (
{ACTIONS.map((action) => (
<MenuItem
key={option.id}
LeftIcon={option.icon}
text={option.name}
LeftIcon={action.icon}
text={action.label}
onClick={() => {
handleActionClick(option.id);
return createStep(action.type);
}}
/>
))}

View File

@ -1,7 +1,7 @@
import { WorkflowShowPageDiagramCreateStepNode } from '@/workflow/components/WorkflowShowPageDiagramCreateStepNode';
import { WorkflowShowPageDiagramEffect } from '@/workflow/components/WorkflowShowPageDiagramEffect';
import { WorkflowShowPageDiagramStepNode } from '@/workflow/components/WorkflowShowPageDiagramStepNode';
import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState';
import { WorkflowDiagramCanvasEffect } from '@/workflow/components/WorkflowDiagramCanvasEffect';
import { WorkflowDiagramCreateStepNode } from '@/workflow/components/WorkflowDiagramCreateStepNode';
import { WorkflowDiagramStepNode } from '@/workflow/components/WorkflowDiagramStepNode';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import {
WorkflowDiagram,
WorkflowDiagramEdge,
@ -21,7 +21,7 @@ import { useMemo } from 'react';
import { useSetRecoilState } from 'recoil';
import { GRAY_SCALE, isDefined } from 'twenty-ui';
export const WorkflowShowPageDiagram = ({
export const WorkflowDiagramCanvas = ({
diagram,
}: {
diagram: WorkflowDiagram;
@ -31,14 +31,12 @@ export const WorkflowShowPageDiagram = ({
[diagram],
);
const setShowPageWorkflowDiagram = useSetRecoilState(
showPageWorkflowDiagramState,
);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
const handleNodesChange = (
nodeChanges: Array<NodeChange<WorkflowDiagramNode>>,
) => {
setShowPageWorkflowDiagram((diagram) => {
setWorkflowDiagram((diagram) => {
if (isDefined(diagram) === false) {
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.',
@ -55,7 +53,7 @@ export const WorkflowShowPageDiagram = ({
const handleEdgesChange = (
edgeChanges: Array<EdgeChange<WorkflowDiagramEdge>>,
) => {
setShowPageWorkflowDiagram((diagram) => {
setWorkflowDiagram((diagram) => {
if (isDefined(diagram) === false) {
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.',
@ -72,8 +70,8 @@ export const WorkflowShowPageDiagram = ({
return (
<ReactFlow
nodeTypes={{
default: WorkflowShowPageDiagramStepNode,
'create-step': WorkflowShowPageDiagramCreateStepNode,
default: WorkflowDiagramStepNode,
'create-step': WorkflowDiagramCreateStepNode,
}}
fitView
nodes={nodes.map((node) => ({ ...node, draggable: false }))}
@ -81,7 +79,7 @@ export const WorkflowShowPageDiagram = ({
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
>
<WorkflowShowPageDiagramEffect />
<WorkflowDiagramCanvasEffect />
<Background color={GRAY_SCALE.gray25} size={2} />
</ReactFlow>

View File

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

View File

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

View File

@ -64,7 +64,7 @@ export const StyledTargetHandle = styled(Handle)`
visibility: hidden;
`;
export const WorkflowShowPageDiagramStepNode = ({
export const WorkflowDiagramStepNode = ({
data,
}: {
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 { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState';
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 { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { addCreateStepNodes } from '@/workflow/utils/addCreateStepNodes';
import { getWorkflowLastDiagramVersion } from '@/workflow/utils/getWorkflowLastDiagramVersion';
import { getWorkflowVersionDiagram } from '@/workflow/utils/getWorkflowVersionDiagram';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
@ -18,50 +14,29 @@ type WorkflowShowPageEffectProps = {
export const WorkflowShowPageEffect = ({
workflowId,
}: WorkflowShowPageEffectProps) => {
const {
record: workflow,
loading,
error,
} = useFindOneRecord<Workflow>({
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: workflowId,
recordGqlFields: {
id: true,
name: true,
versions: true,
publishedVersionId: true,
},
});
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const setShowPageWorkflowId = useSetRecoilState(showPageWorkflowIdState);
const setCurrentWorkflowData = useSetRecoilState(
showPageWorkflowDiagramState,
);
const setCurrentWorkflowLoading = useSetRecoilState(
showPageWorkflowLoadingState,
);
const setCurrentWorkflowError = useSetRecoilState(showPageWorkflowErrorState);
const setWorkflowId = useSetRecoilState(workflowIdState);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
useEffect(() => {
setShowPageWorkflowId(workflowId);
}, [setShowPageWorkflowId, workflowId]);
setWorkflowId(workflowId);
}, [setWorkflowId, workflowId]);
useEffect(() => {
const flowLastVersion = getWorkflowLastDiagramVersion(workflow);
const flowWithCreateStepNodes = addCreateStepNodes(flowLastVersion);
const currentVersion = workflowWithCurrentVersion?.currentVersion;
if (!isDefined(currentVersion)) {
setWorkflowDiagram(undefined);
setCurrentWorkflowData(
isDefined(workflow) ? flowWithCreateStepNodes : undefined,
);
}, [setCurrentWorkflowData, workflow]);
return;
}
useEffect(() => {
setCurrentWorkflowLoading(loading);
}, [loading, setCurrentWorkflowLoading]);
const lastWorkflowDiagram = getWorkflowVersionDiagram(currentVersion);
const workflowDiagramWithCreateStepNodes =
addCreateStepNodes(lastWorkflowDiagram);
useEffect(() => {
setCurrentWorkflowError(error);
}, [error, setCurrentWorkflowError]);
setWorkflowDiagram(workflowDiagramWithCreateStepNodes);
}, [setWorkflowDiagram, workflowWithCurrentVersion?.currentVersion]);
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 { createState } from 'twenty-ui';
export const showPageWorkflowDiagramState = createState<
WorkflowDiagram | undefined
>({
key: 'showPageWorkflowDiagramState',
export const workflowDiagramState = createState<WorkflowDiagram | undefined>({
key: 'workflowDiagramState',
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 WorkflowStepType = WorkflowStep['type'];
export type WorkflowTriggerType = 'DATABASE_EVENT';
type BaseTrigger = {
@ -46,14 +48,23 @@ export type WorkflowDatabaseEventTrigger = BaseTrigger & {
export type WorkflowTrigger = WorkflowDatabaseEventTrigger;
export type WorkflowStatus = 'DRAFT' | 'ACTIVE' | 'DEACTIVATED';
export type WorkflowVersionStatus =
| 'DRAFT'
| 'ACTIVE'
| 'DEACTIVATED'
| 'ARCHIVED';
export type WorkflowVersion = {
id: string;
name: string;
createdAt: string;
updatedAt: string;
workflowId: string;
trigger: WorkflowTrigger;
steps: Array<WorkflowStep>;
trigger: WorkflowTrigger | null;
steps: Array<WorkflowStep> | null;
status: WorkflowVersionStatus;
__typename: 'WorkflowVersion';
};
@ -62,5 +73,10 @@ export type Workflow = {
id: string;
name: string;
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({
data: {
label: trigger.settings.eventName,
label: 'Company is Created',
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', () => {
it('returns a deep copy of the provided steps array instead of mutating it', () => {
const workflowVersionInitial: WorkflowVersion = {
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
@ -15,7 +16,7 @@ describe('insertStep', () => {
},
updatedAt: '',
workflowId: '',
};
} satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = {
id: 'step-1',
name: '',
@ -40,8 +41,9 @@ describe('insertStep', () => {
});
it('adds the step when the steps array is empty', () => {
const workflowVersionInitial: WorkflowVersion = {
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
@ -52,7 +54,7 @@ describe('insertStep', () => {
},
updatedAt: '',
workflowId: '',
};
} satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = {
id: 'step-1',
name: '',
@ -78,8 +80,9 @@ describe('insertStep', () => {
});
it('adds the step at the end of a non-empty steps array', () => {
const workflowVersionInitial: WorkflowVersion = {
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
@ -117,7 +120,7 @@ describe('insertStep', () => {
},
updatedAt: '',
workflowId: '',
};
} satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = {
id: 'step-3',
name: '',
@ -147,8 +150,9 @@ describe('insertStep', () => {
});
it('adds the step in the middle of a non-empty steps array', () => {
const workflowVersionInitial: WorkflowVersion = {
const workflowVersionInitial = {
__typename: 'WorkflowVersion',
status: 'ACTIVE',
createdAt: '',
id: '1',
name: '',
@ -186,7 +190,7 @@ describe('insertStep', () => {
},
updatedAt: '',
workflowId: '',
};
} satisfies WorkflowVersion;
const stepToAdd: WorkflowStep = {
id: 'step-3',
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 {
WorkflowDiagram,
WorkflowDiagramEdge,
WorkflowDiagramNode,
} from '@/workflow/types/WorkflowDiagram';
import { splitWorkflowTriggerEventName } from '@/workflow/utils/splitWorkflowTriggerEventName';
import { MarkerType } from '@xyflow/react';
import { v4 } from 'uuid';
import { capitalize } from '~/utils/string/capitalize';
export const generateWorkflowDiagram = ({
trigger,
@ -58,12 +61,15 @@ export const generateWorkflowDiagram = ({
};
// Start with the trigger node
const triggerNodeId = 'trigger';
const triggerNodeId = TRIGGER_STEP_ID;
const triggerEvent = splitWorkflowTriggerEventName(
trigger.settings.eventName,
);
nodes.push({
id: triggerNodeId,
data: {
nodeType: 'trigger',
label: trigger.settings.eventName,
label: `${capitalize(triggerEvent.objectType)} is ${capitalize(triggerEvent.event)}`,
},
position: {
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';
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}.`);
};
import { findStepPositionOrThrow } from '@/workflow/utils/findStepPositionOrThrow';
export const insertStep = ({
steps: stepsInitial,
@ -43,11 +10,10 @@ export const insertStep = ({
parentStepId: string | undefined;
stepToAdd: WorkflowStep;
}): Array<WorkflowStep> => {
// Make a deep copy of the nested object to prevent unwanted side effects.
const steps = structuredClone(stepsInitial);
const parentStepPosition = findStepPositionOrThrow({
steps: steps,
steps,
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 { PageContainer } from '@/ui/layout/page/PageContainer';
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 { WorkflowShowPageHeader } from '@/workflow/components/WorkflowShowPageHeader';
import { showPageWorkflowDiagramState } from '@/workflow/states/showPageWorkflowDiagramState';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import styled from '@emotion/styled';
import '@xyflow/react/dist/style.css';
import { useParams } from 'react-router-dom';
@ -37,7 +37,7 @@ export const WorkflowShowPage = () => {
const workflowName = 'Test Workflow';
const showPageWorkflowDiagram = useRecoilValue(showPageWorkflowDiagramState);
const workflowDiagram = useRecoilValue(workflowDiagramState);
if (parameters.workflowId === undefined) {
return null;
@ -54,8 +54,8 @@ export const WorkflowShowPage = () => {
/>
<PageBody>
<StyledFlowContainer>
{showPageWorkflowDiagram === undefined ? null : (
<WorkflowShowPageDiagram diagram={showPageWorkflowDiagram} />
{workflowDiagram === undefined ? null : (
<WorkflowDiagramCanvas diagram={workflowDiagram} />
)}
</StyledFlowContainer>
</PageBody>

View File

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