Create workflow version show page (#7466)

In this PR:

- Refactored components to clarify their behavior. For example, I
renamed the `Workflow` component to `WorkflowVisualizer`. This moved
forward the issue #7010.
- Create two variants of several workflow-related components: one
version for editing and another for viewing. For instance, there is
`WorkflowDiagramCanvasEditable.tsx` and
`WorkflowDiagramCanvasReadonly.tsx`
- Implement the show page for workflow versions. On this page, we
display a readonly workflow visualizer. Users can click on nodes and it
will expand the right drawer.
- I added buttons in the header of the RecordShowPage for workflow
versions: users can activate, deactivate or use the currently viewed
version as the next draft.

**There are many cache desynchronisation and I'll fix them really
soon.**

## Demo

(Turn sound on)


https://github.com/user-attachments/assets/97fafa48-8902-4dab-8b39-f40848bf041e
This commit is contained in:
Baptiste Devessier
2024-10-08 18:16:36 +02:00
committed by GitHub
parent d5bd320b8d
commit 1863636003
39 changed files with 856 additions and 310 deletions

View File

@ -15,7 +15,7 @@ import { assertWorkflowWithCurrentVersionIsDefined } from '../utils/assertWorkfl
export const RecordShowPageWorkflowHeader = ({
workflowId,
}: {
workflowId: string | undefined;
workflowId: string;
}) => {
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);

View File

@ -0,0 +1,127 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord';
import { Button } from '@/ui/input/button/components/Button';
import { useActivateWorkflowVersion } from '@/workflow/hooks/useActivateWorkflowVersion';
import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion';
import { useDeactivateWorkflowVersion } from '@/workflow/hooks/useDeactivateWorkflowVersion';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { Workflow, WorkflowVersion } from '@/workflow/types/Workflow';
import { IconPencil, IconPlayerStop, IconPower, isDefined } from 'twenty-ui';
export const RecordShowPageWorkflowVersionHeader = ({
workflowVersionId,
}: {
workflowVersionId: string;
}) => {
const workflowVersion = useWorkflowVersion(workflowVersionId);
const workflowVersionRelatedWorkflowQuery = useFindOneRecord<
Pick<Workflow, '__typename' | 'id' | 'lastPublishedVersionId'>
>({
objectNameSingular: CoreObjectNameSingular.Workflow,
objectRecordId: workflowVersion?.workflowId,
recordGqlFields: {
id: true,
lastPublishedVersionId: true,
},
skip: !isDefined(workflowVersion),
});
// TODO: In the future, use the workflow.status property to determine if there is a draft version
const {
records: draftWorkflowVersions,
loading: loadingDraftWorkflowVersions,
} = useFindManyRecords<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
filter: {
workflowId: {
eq: workflowVersion?.workflow.id,
},
status: {
eq: 'DRAFT',
},
},
skip: !isDefined(workflowVersion),
limit: 1,
});
const showUseAsDraftButton =
!loadingDraftWorkflowVersions &&
isDefined(workflowVersion) &&
!workflowVersionRelatedWorkflowQuery.loading &&
isDefined(workflowVersionRelatedWorkflowQuery.record) &&
workflowVersion.status !== 'DRAFT' &&
workflowVersion.id !==
workflowVersionRelatedWorkflowQuery.record.lastPublishedVersionId;
const hasAlreadyDraftVersion =
!loadingDraftWorkflowVersions && draftWorkflowVersions.length > 0;
const isWaitingForWorkflowVersion = !isDefined(workflowVersion);
const { activateWorkflowVersion } = useActivateWorkflowVersion();
const { deactivateWorkflowVersion } = useDeactivateWorkflowVersion();
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const { updateOneRecord: updateOneWorkflowVersion } =
useUpdateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
return (
<>
{showUseAsDraftButton ? (
<Button
title={`Use as Draft${hasAlreadyDraftVersion ? ' (override)' : ''}`}
variant="secondary"
Icon={IconPencil}
disabled={isWaitingForWorkflowVersion}
onClick={async () => {
if (hasAlreadyDraftVersion) {
await updateOneWorkflowVersion({
idToUpdate: draftWorkflowVersions[0].id,
updateOneRecordInput: {
trigger: workflowVersion.trigger,
steps: workflowVersion.steps,
},
});
} else {
await createNewWorkflowVersion({
workflowId: workflowVersion.workflow.id,
name: `v${workflowVersion.workflow.versions.length + 1}`,
status: 'DRAFT',
trigger: workflowVersion.trigger,
steps: workflowVersion.steps,
});
}
}}
/>
) : null}
{workflowVersion?.status === 'DRAFT' ||
workflowVersion?.status === 'DEACTIVATED' ? (
<Button
title="Activate"
variant="secondary"
Icon={IconPower}
disabled={isWaitingForWorkflowVersion}
onClick={() => {
return activateWorkflowVersion(workflowVersion.id);
}}
/>
) : workflowVersion?.status === 'ACTIVE' ? (
<Button
title="Deactivate"
variant="secondary"
Icon={IconPlayerStop}
disabled={isWaitingForWorkflowVersion}
onClick={() => {
return deactivateWorkflowVersion(workflowVersion.id);
}}
/>
) : null}
</>
);
};

View File

@ -1,62 +1,11 @@
import { WorkflowEditActionFormSendEmail } from '@/workflow/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormServerlessFunction } from '@/workflow/components/WorkflowEditActionFormServerlessFunction';
import { WorkflowEditTriggerForm } from '@/workflow/components/WorkflowEditTriggerForm';
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { WorkflowStepDetail } from '@/workflow/components/WorkflowStepDetail';
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 { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { findStepPosition } from '@/workflow/utils/findStepPosition';
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)) {
return {
type: 'trigger',
definition: undefined,
} as const;
}
return {
type: 'trigger',
definition: currentVersion.trigger,
} as const;
}
if (!isDefined(currentVersion.steps)) {
throw new Error(
'Malformed workflow version: missing steps information; be sure to create at least one step before trying to edit one',
);
}
const selectedNodePosition = findStepPosition({
steps: currentVersion.steps,
stepId: stepId,
});
if (!isDefined(selectedNodePosition)) {
return undefined;
}
return {
type: 'action',
definition: selectedNodePosition.steps[selectedNodePosition.index],
} as const;
};
export const RightDrawerWorkflowEditStepContent = ({
workflow,
}: {
@ -75,47 +24,12 @@ export const RightDrawerWorkflowEditStepContent = ({
stepId: workflowSelectedNode,
});
const stepDefinition = getStepDefinitionOrThrow({
stepId: workflowSelectedNode,
workflow,
});
if (!isDefined(stepDefinition)) {
return null;
}
switch (stepDefinition.type) {
case 'trigger': {
return (
<WorkflowEditTriggerForm
trigger={stepDefinition.definition}
onTriggerUpdate={updateTrigger}
/>
);
}
case 'action': {
switch (stepDefinition.definition.type) {
case 'CODE': {
return (
<WorkflowEditActionFormServerlessFunction
action={stepDefinition.definition}
onActionUpdate={updateStep}
/>
);
}
case 'SEND_EMAIL': {
return (
<WorkflowEditActionFormSendEmail
action={stepDefinition.definition}
onActionUpdate={updateStep}
/>
);
}
}
}
}
return assertUnreachable(
stepDefinition,
`Unsupported step: ${JSON.stringify(stepDefinition)}`,
return (
<WorkflowStepDetail
stepId={workflowSelectedNode}
workflowVersion={workflow.currentVersion}
onActionUpdate={updateStep}
onTriggerUpdate={updateTrigger}
/>
);
};

View File

@ -0,0 +1,22 @@
import { RightDrawerWorkflowViewStepContent } from '@/workflow/components/RightDrawerWorkflowViewStepContent';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowViewStep = () => {
const workflowVersionId = useRecoilValue(workflowVersionIdState);
if (!isDefined(workflowVersionId)) {
throw new Error('Expected a workflow version id');
}
const workflowVersion = useWorkflowVersion(workflowVersionId);
if (!isDefined(workflowVersion)) {
return null;
}
return (
<RightDrawerWorkflowViewStepContent workflowVersion={workflowVersion} />
);
};

View File

@ -0,0 +1,26 @@
import { WorkflowStepDetail } from '@/workflow/components/WorkflowStepDetail';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const RightDrawerWorkflowViewStepContent = ({
workflowVersion,
}: {
workflowVersion: WorkflowVersion;
}) => {
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
throw new Error(
'Expected a node to be selected. Selecting a node is mandatory to edit it.',
);
}
return (
<WorkflowStepDetail
stepId={workflowSelectedNode}
workflowVersion={workflowVersion}
readonly
/>
);
};

View File

@ -1,58 +0,0 @@
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { WorkflowDiagramCanvas } from '@/workflow/components/WorkflowDiagramCanvas';
import { WorkflowEffect } from '@/workflow/components/WorkflowEffect';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import styled from '@emotion/styled';
import '@xyflow/react/dist/style.css';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
const StyledFlowContainer = styled.div`
height: 100%;
width: 100%;
position: relative;
/* Below we reset the default styling of Reactflow */
.react-flow__node-input,
.react-flow__node-default,
.react-flow__node-output,
.react-flow__node-group {
padding: 0;
}
--xy-node-border-radius: none;
--xy-node-border: none;
--xy-node-background-color: none;
--xy-node-boxshadow-hover: none;
--xy-node-boxshadow-selected: none;
`;
export const Workflow = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const workflowId = targetableObject.id;
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const workflowDiagram = useRecoilValue(workflowDiagramState);
return (
<>
<WorkflowEffect
workflowId={workflowId}
workflowWithCurrentVersion={workflowWithCurrentVersion}
/>
<StyledFlowContainer>
{isDefined(workflowDiagram) && isDefined(workflowWithCurrentVersion) ? (
<WorkflowDiagramCanvas
diagram={workflowDiagram}
workflowWithCurrentVersion={workflowWithCurrentVersion}
/>
) : null}
</StyledFlowContainer>
</>
);
};

View File

@ -1,14 +1,11 @@
import { WorkflowDiagramCanvasEffect } from '@/workflow/components/WorkflowDiagramCanvasEffect';
import { WorkflowDiagramCreateStepNode } from '@/workflow/components/WorkflowDiagramCreateStepNode';
import { WorkflowDiagramEmptyTrigger } from '@/workflow/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramStepNode } from '@/workflow/components/WorkflowDiagramStepNode';
import { WorkflowVersionStatusTag } from '@/workflow/components/WorkflowVersionStatusTag';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { WorkflowVersionStatus } from '@/workflow/types/Workflow';
import {
WorkflowDiagram,
WorkflowDiagramEdge,
WorkflowDiagramNode,
WorkflowDiagramNodeType,
} from '@/workflow/types/WorkflowDiagram';
import { getOrganizedDiagram } from '@/workflow/utils/getOrganizedDiagram';
import styled from '@emotion/styled';
@ -19,13 +16,34 @@ import {
EdgeChange,
FitViewOptions,
NodeChange,
NodeProps,
ReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import { useMemo } from 'react';
import React, { useMemo } from 'react';
import { useSetRecoilState } from 'recoil';
import { GRAY_SCALE, isDefined } from 'twenty-ui';
const StyledResetReactflowStyles = styled.div`
height: 100%;
width: 100%;
position: relative;
/* Below we reset the default styling of Reactflow */
.react-flow__node-input,
.react-flow__node-default,
.react-flow__node-output,
.react-flow__node-group {
padding: 0;
}
--xy-node-border-radius: none;
--xy-node-border: none;
--xy-node-background-color: none;
--xy-node-boxshadow-hover: none;
--xy-node-boxshadow-selected: none;
`;
const StyledStatusTagContainer = styled.div`
left: 0;
top: 0;
@ -38,12 +56,26 @@ const defaultFitViewOptions: FitViewOptions = {
maxZoom: 1.3,
};
export const WorkflowDiagramCanvas = ({
export const WorkflowDiagramCanvasBase = ({
diagram,
workflowWithCurrentVersion,
status,
nodeTypes,
children,
}: {
diagram: WorkflowDiagram;
workflowWithCurrentVersion: WorkflowWithCurrentVersion;
status: WorkflowVersionStatus;
nodeTypes: Partial<
Record<
WorkflowDiagramNodeType,
React.ComponentType<
NodeProps & {
data: any;
type: any;
}
>
>
>;
children?: React.ReactNode;
}) => {
const { nodes, edges } = useMemo(
() => getOrganizedDiagram(diagram),
@ -87,33 +119,26 @@ export const WorkflowDiagramCanvas = ({
};
return (
<>
<StyledResetReactflowStyles>
<ReactFlow
key={workflowWithCurrentVersion.currentVersion.id}
onInit={({ fitView }) => {
fitView(defaultFitViewOptions);
}}
nodeTypes={{
default: WorkflowDiagramStepNode,
'create-step': WorkflowDiagramCreateStepNode,
'empty-trigger': WorkflowDiagramEmptyTrigger,
}}
nodeTypes={nodeTypes}
fitView
nodes={nodes.map((node) => ({ ...node, draggable: false }))}
edges={edges}
onNodesChange={handleNodesChange}
onEdgesChange={handleEdgesChange}
>
<WorkflowDiagramCanvasEffect />
<Background color={GRAY_SCALE.gray25} size={2} />
</ReactFlow>
<StyledStatusTagContainer>
<WorkflowVersionStatusTag
versionStatus={workflowWithCurrentVersion.currentVersion.status}
/>
</StyledStatusTagContainer>
</>
{children}
<StyledStatusTagContainer>
<WorkflowVersionStatusTag versionStatus={status} />
</StyledStatusTagContainer>
</ReactFlow>
</StyledResetReactflowStyles>
);
};

View File

@ -0,0 +1,30 @@
import { WorkflowDiagramCanvasBase } from '@/workflow/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramCanvasEditableEffect } from '@/workflow/components/WorkflowDiagramCanvasEditableEffect';
import { WorkflowDiagramCreateStepNode } from '@/workflow/components/WorkflowDiagramCreateStepNode';
import { WorkflowDiagramEmptyTrigger } from '@/workflow/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramStepNodeEditable } from '@/workflow/components/WorkflowDiagramStepNodeEditable';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
export const WorkflowDiagramCanvasEditable = ({
diagram,
workflowWithCurrentVersion,
}: {
diagram: WorkflowDiagram;
workflowWithCurrentVersion: WorkflowWithCurrentVersion;
}) => {
return (
<WorkflowDiagramCanvasBase
key={workflowWithCurrentVersion.currentVersion.id}
diagram={diagram}
status={workflowWithCurrentVersion.currentVersion.status}
nodeTypes={{
default: WorkflowDiagramStepNodeEditable,
'create-step': WorkflowDiagramCreateStepNode,
'empty-trigger': WorkflowDiagramEmptyTrigger,
}}
>
<WorkflowDiagramCanvasEditableEffect />
</WorkflowDiagramCanvasBase>
);
};

View File

@ -1,33 +1,20 @@
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 { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState';
import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import {
WorkflowDiagramEdge,
WorkflowDiagramNode,
} from '@/workflow/types/WorkflowDiagram';
import {
OnSelectionChangeParams,
useOnSelectionChange,
useReactFlow,
} from '@xyflow/react';
import { useCallback, useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { WorkflowDiagramNode } from '@/workflow/types/WorkflowDiagram';
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const WorkflowDiagramCanvasEffect = () => {
const reactflow = useReactFlow<WorkflowDiagramNode, WorkflowDiagramEdge>();
export const WorkflowDiagramCanvasEditableEffect = () => {
const { startNodeCreation } = useStartNodeCreation();
const { openRightDrawer, closeRightDrawer } = useRightDrawer();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const workflowDiagramTriggerNodeSelection = useRecoilValue(
workflowDiagramTriggerNodeSelectionState,
);
const handleSelectionChange = useCallback(
({ nodes }: OnSelectionChangeParams) => {
const selectedNode = nodes[0] as WorkflowDiagramNode;
@ -65,15 +52,7 @@ export const WorkflowDiagramCanvasEffect = () => {
onChange: handleSelectionChange,
});
useEffect(() => {
if (!isDefined(workflowDiagramTriggerNodeSelection)) {
return;
}
reactflow.updateNode(workflowDiagramTriggerNodeSelection, {
selected: true,
});
}, [reactflow, workflowDiagramTriggerNodeSelection]);
useTriggerNodeSelection();
return null;
};

View File

@ -0,0 +1,28 @@
import { WorkflowDiagramCanvasBase } from '@/workflow/components/WorkflowDiagramCanvasBase';
import { WorkflowDiagramCanvasReadonlyEffect } from '@/workflow/components/WorkflowDiagramCanvasReadonlyEffect';
import { WorkflowDiagramEmptyTrigger } from '@/workflow/components/WorkflowDiagramEmptyTrigger';
import { WorkflowDiagramStepNodeReadonly } from '@/workflow/components/WorkflowDiagramStepNodeReadonly';
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram';
export const WorkflowDiagramCanvasReadonly = ({
diagram,
workflowVersion,
}: {
diagram: WorkflowDiagram;
workflowVersion: WorkflowVersion;
}) => {
return (
<WorkflowDiagramCanvasBase
key={workflowVersion.id}
diagram={diagram}
status={workflowVersion.status}
nodeTypes={{
default: WorkflowDiagramStepNodeReadonly,
'empty-trigger': WorkflowDiagramEmptyTrigger,
}}
>
<WorkflowDiagramCanvasReadonlyEffect />
</WorkflowDiagramCanvasBase>
);
};

View File

@ -0,0 +1,39 @@
import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer';
import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages';
import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection';
import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState';
import { WorkflowDiagramNode } from '@/workflow/types/WorkflowDiagram';
import { OnSelectionChangeParams, useOnSelectionChange } from '@xyflow/react';
import { useCallback } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const WorkflowDiagramCanvasReadonlyEffect = () => {
const { openRightDrawer, closeRightDrawer } = useRightDrawer();
const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState);
const handleSelectionChange = useCallback(
({ nodes }: OnSelectionChangeParams) => {
const selectedNode = nodes[0] as WorkflowDiagramNode;
const isClosingStep = isDefined(selectedNode) === false;
if (isClosingStep) {
closeRightDrawer();
return;
}
setWorkflowSelectedNode(selectedNode.id);
openRightDrawer(RightDrawerPages.WorkflowStepView);
},
[closeRightDrawer, openRightDrawer, setWorkflowSelectedNode],
);
useOnSelectionChange({
onChange: handleSelectionChange,
});
useTriggerNodeSelection();
return null;
};

View File

@ -1,6 +1,5 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import {
WorkflowVersion,
WorkflowWithCurrentVersion,
@ -13,22 +12,13 @@ import { useEffect } from 'react';
import { useRecoilCallback, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
type WorkflowEffectProps = {
workflowId: string;
workflowWithCurrentVersion: WorkflowWithCurrentVersion | undefined;
};
export const WorkflowEffect = ({
workflowId,
export const WorkflowDiagramEffect = ({
workflowWithCurrentVersion,
}: WorkflowEffectProps) => {
const setWorkflowId = useSetRecoilState(workflowIdState);
}: {
workflowWithCurrentVersion: WorkflowWithCurrentVersion | undefined;
}) => {
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
useEffect(() => {
setWorkflowId(workflowId);
}, [setWorkflowId, workflowId]);
const computeAndMergeNewWorkflowDiagram = useRecoilCallback(
({ snapshot, set }) => {
return (currentVersion: WorkflowVersion) => {

View File

@ -1,15 +1,9 @@
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
import { WorkflowDiagramBaseStepNode } from '@/workflow/components/WorkflowDiagramBaseStepNode';
import { useDeleteOneStep } from '@/workflow/hooks/useDeleteOneStep';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { useRecoilValue } from 'recoil';
import { IconCode, IconMail, IconPlaylistAdd, IconTrash } from 'twenty-ui';
import { IconCode, IconMail, IconPlaylistAdd } from 'twenty-ui';
const StyledStepNodeLabelIconContainer = styled.div`
align-items: center;
@ -20,27 +14,15 @@ const StyledStepNodeLabelIconContainer = styled.div`
padding: ${({ theme }) => theme.spacing(1)};
`;
export const WorkflowDiagramStepNode = ({
id,
export const WorkflowDiagramStepNodeBase = ({
data,
selected,
RightFloatingElement,
}: {
id: string;
data: WorkflowDiagramStepNodeData;
selected?: boolean;
RightFloatingElement?: React.ReactNode;
}) => {
const theme = useTheme();
const workflowId = useRecoilValue(workflowIdState);
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
assertWorkflowWithCurrentVersionIsDefined(workflowWithCurrentVersion);
const { deleteOneStep } = useDeleteOneStep({
workflow: workflowWithCurrentVersion,
stepId: id,
});
const renderStepIcon = () => {
switch (data.nodeType) {
case 'trigger': {
@ -87,16 +69,7 @@ export const WorkflowDiagramStepNode = ({
nodeType={data.nodeType}
label={data.label}
Icon={renderStepIcon()}
RightFloatingElement={
selected ? (
<FloatingIconButton
Icon={IconTrash}
onClick={() => {
return deleteOneStep();
}}
/>
) : undefined
}
RightFloatingElement={RightFloatingElement}
/>
);
};

View File

@ -0,0 +1,45 @@
import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton';
import { WorkflowDiagramStepNodeBase } from '@/workflow/components/WorkflowDiagramStepNodeBase';
import { useDeleteOneStep } from '@/workflow/hooks/useDeleteOneStep';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined';
import { useRecoilValue } from 'recoil';
import { IconTrash } from 'twenty-ui';
export const WorkflowDiagramStepNodeEditable = ({
id,
data,
selected,
}: {
id: string;
data: WorkflowDiagramStepNodeData;
selected?: boolean;
}) => {
const workflowId = useRecoilValue(workflowIdState);
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
assertWorkflowWithCurrentVersionIsDefined(workflowWithCurrentVersion);
const { deleteOneStep } = useDeleteOneStep({
workflow: workflowWithCurrentVersion,
stepId: id,
});
return (
<WorkflowDiagramStepNodeBase
data={data}
RightFloatingElement={
selected ? (
<FloatingIconButton
Icon={IconTrash}
onClick={() => {
return deleteOneStep();
}}
/>
) : undefined
}
/>
);
};

View File

@ -0,0 +1,10 @@
import { WorkflowDiagramStepNodeBase } from '@/workflow/components/WorkflowDiagramStepNodeBase';
import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram';
export const WorkflowDiagramStepNodeReadonly = ({
data,
}: {
data: WorkflowDiagramStepNodeData;
}) => {
return <WorkflowDiagramStepNodeBase data={data} />;
};

View File

@ -16,18 +16,25 @@ const StyledTriggerSettings = styled.div`
row-gap: ${({ theme }) => theme.spacing(4)};
`;
type WorkflowEditActionFormSendEmailProps =
| {
action: WorkflowSendEmailStep;
readonly: true;
}
| {
action: WorkflowSendEmailStep;
readonly?: false;
onActionUpdate: (action: WorkflowSendEmailStep) => void;
};
type SendEmailFormData = {
subject: string;
body: string;
};
export const WorkflowEditActionFormSendEmail = ({
action,
onActionUpdate,
}: {
action: WorkflowSendEmailStep;
onActionUpdate: (action: WorkflowSendEmailStep) => void;
}) => {
export const WorkflowEditActionFormSendEmail = (
props: WorkflowEditActionFormSendEmailProps,
) => {
const theme = useTheme();
const form = useForm<SendEmailFormData>({
@ -35,18 +42,23 @@ export const WorkflowEditActionFormSendEmail = ({
subject: '',
body: '',
},
disabled: props.readonly,
});
useEffect(() => {
form.setValue('subject', action.settings.subject ?? '');
form.setValue('body', action.settings.template ?? '');
}, [action.settings.subject, action.settings.template, form]);
form.setValue('subject', props.action.settings.subject ?? '');
form.setValue('body', props.action.settings.template ?? '');
}, [props.action.settings.subject, props.action.settings.template, form]);
const saveAction = useDebouncedCallback((formData: SendEmailFormData) => {
onActionUpdate({
...action,
if (props.readonly === true) {
return;
}
props.onActionUpdate({
...props.action,
settings: {
...action.settings,
...props.action.settings,
title: formData.subject,
subject: formData.subject,
template: formData.body,
@ -77,6 +89,7 @@ export const WorkflowEditActionFormSendEmail = ({
label="Subject"
placeholder="Thank you for building such an awesome CRM!"
value={field.value}
disabled={field.disabled}
onChange={(email) => {
field.onChange(email);
@ -95,6 +108,7 @@ export const WorkflowEditActionFormSendEmail = ({
placeholder="Thank you so much!"
value={field.value}
minRows={4}
disabled={field.disabled}
onChange={(email) => {
field.onChange(email);

View File

@ -13,13 +13,20 @@ const StyledTriggerSettings = styled.div`
row-gap: ${({ theme }) => theme.spacing(4)};
`;
export const WorkflowEditActionFormServerlessFunction = ({
action,
onActionUpdate,
}: {
action: WorkflowCodeStep;
onActionUpdate: (trigger: WorkflowCodeStep) => void;
}) => {
type WorkflowEditActionFormServerlessFunctionProps =
| {
action: WorkflowCodeStep;
readonly: true;
}
| {
action: WorkflowCodeStep;
readonly?: false;
onActionUpdate: (action: WorkflowCodeStep) => void;
};
export const WorkflowEditActionFormServerlessFunction = (
props: WorkflowEditActionFormServerlessFunctionProps,
) => {
const theme = useTheme();
const { serverlessFunctions } = useGetManyServerlessFunctions();
@ -47,13 +54,18 @@ export const WorkflowEditActionFormServerlessFunction = ({
dropdownId="workflow-edit-action-function"
label="Function"
fullWidth
value={action.settings.serverlessFunctionId}
value={props.action.settings.serverlessFunctionId}
options={availableFunctions}
disabled={props.readonly}
onChange={(updatedFunction) => {
onActionUpdate({
...action,
if (props.readonly === true) {
return;
}
props.onActionUpdate({
...props.action,
settings: {
...action.settings,
...props.action.settings,
serverlessFunctionId: updatedFunction,
},
});

View File

@ -45,13 +45,23 @@ const StyledTriggerSettings = styled.div`
row-gap: ${({ theme }) => theme.spacing(4)};
`;
type WorkflowEditTriggerFormProps =
| {
trigger: WorkflowTrigger | undefined;
readonly: true;
onTriggerUpdate?: undefined;
}
| {
trigger: WorkflowTrigger | undefined;
readonly?: false;
onTriggerUpdate: (trigger: WorkflowTrigger) => void;
};
export const WorkflowEditTriggerForm = ({
trigger,
readonly,
onTriggerUpdate,
}: {
trigger: WorkflowTrigger | undefined;
onTriggerUpdate: (trigger: WorkflowTrigger) => void;
}) => {
}: WorkflowEditTriggerFormProps) => {
const theme = useTheme();
const { activeObjectMetadataItems } = useFilteredObjectMetadataItems();
@ -102,9 +112,14 @@ export const WorkflowEditTriggerForm = ({
dropdownId="workflow-edit-trigger-record-type"
label="Record Type"
fullWidth
disabled={readonly}
value={triggerEvent?.objectType}
options={availableMetadata}
onChange={(updatedRecordType) => {
if (readonly === true) {
return;
}
onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {
@ -129,7 +144,12 @@ export const WorkflowEditTriggerForm = ({
fullWidth
value={triggerEvent?.event}
options={OBJECT_EVENT_TRIGGERS}
disabled={readonly}
onChange={(updatedEvent) => {
if (readonly === true) {
return;
}
onTriggerUpdate(
isDefined(trigger) && isDefined(triggerEvent)
? {

View File

@ -0,0 +1,80 @@
import { WorkflowEditActionFormSendEmail } from '@/workflow/components/WorkflowEditActionFormSendEmail';
import { WorkflowEditActionFormServerlessFunction } from '@/workflow/components/WorkflowEditActionFormServerlessFunction';
import { WorkflowEditTriggerForm } from '@/workflow/components/WorkflowEditTriggerForm';
import {
WorkflowAction,
WorkflowTrigger,
WorkflowVersion,
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { isDefined } from 'twenty-ui';
type WorkflowStepDetailProps =
| {
stepId: string;
workflowVersion: WorkflowVersion;
readonly: true;
onTriggerUpdate?: undefined;
onActionUpdate?: undefined;
}
| {
stepId: string;
workflowVersion: WorkflowVersion;
readonly?: false;
onTriggerUpdate: (trigger: WorkflowTrigger) => void;
onActionUpdate: (action: WorkflowAction) => void;
};
export const WorkflowStepDetail = ({
stepId,
workflowVersion,
...props
}: WorkflowStepDetailProps) => {
const stepDefinition = getStepDefinitionOrThrow({
stepId,
workflowVersion,
});
if (!isDefined(stepDefinition)) {
return null;
}
switch (stepDefinition.type) {
case 'trigger': {
return (
<WorkflowEditTriggerForm
trigger={stepDefinition.definition}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
}
case 'action': {
switch (stepDefinition.definition.type) {
case 'CODE': {
return (
<WorkflowEditActionFormServerlessFunction
action={stepDefinition.definition}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
}
case 'SEND_EMAIL': {
return (
<WorkflowEditActionFormSendEmail
action={stepDefinition.definition}
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
/>
);
}
}
}
}
return assertUnreachable(
stepDefinition,
`Expected the step to have an handler; ${JSON.stringify(stepDefinition)}`,
);
};

View File

@ -0,0 +1,23 @@
import { WorkflowDiagramCanvasReadonly } from '@/workflow/components/WorkflowDiagramCanvasReadonly';
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import '@xyflow/react/dist/style.css';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const WorkflowVersionVisualizer = ({
workflowVersionId,
}: {
workflowVersionId: string;
}) => {
const workflowVersion = useWorkflowVersion(workflowVersionId);
const workflowDiagram = useRecoilValue(workflowDiagramState);
return isDefined(workflowDiagram) && isDefined(workflowVersion) ? (
<WorkflowDiagramCanvasReadonly
diagram={workflowDiagram}
workflowVersion={workflowVersion}
/>
) : null;
};

View File

@ -0,0 +1,36 @@
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState';
import { getWorkflowVersionDiagram } from '@/workflow/utils/getWorkflowVersionDiagram';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const WorkflowVersionVisualizerEffect = ({
workflowVersionId,
}: {
workflowVersionId: string;
}) => {
const workflowVersion = useWorkflowVersion(workflowVersionId);
const setWorkflowVersionId = useSetRecoilState(workflowVersionIdState);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
useEffect(() => {
setWorkflowVersionId(workflowVersionId);
}, [setWorkflowVersionId, workflowVersionId]);
useEffect(() => {
if (!isDefined(workflowVersion)) {
setWorkflowDiagram(undefined);
return;
}
const nextWorkflowDiagram = getWorkflowVersionDiagram(workflowVersion);
setWorkflowDiagram(nextWorkflowDiagram);
}, [setWorkflowDiagram, workflowVersion]);
return null;
};

View File

@ -0,0 +1,34 @@
import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity';
import { WorkflowDiagramCanvasEditable } from '@/workflow/components/WorkflowDiagramCanvasEditable';
import { WorkflowDiagramEffect } from '@/workflow/components/WorkflowDiagramEffect';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowDiagramState } from '@/workflow/states/workflowDiagramState';
import '@xyflow/react/dist/style.css';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-ui';
export const WorkflowVisualizer = ({
targetableObject,
}: {
targetableObject: ActivityTargetableObject;
}) => {
const workflowId = targetableObject.id;
const workflowWithCurrentVersion = useWorkflowWithCurrentVersion(workflowId);
const workflowDiagram = useRecoilValue(workflowDiagramState);
return (
<>
<WorkflowDiagramEffect
workflowWithCurrentVersion={workflowWithCurrentVersion}
/>
{isDefined(workflowDiagram) && isDefined(workflowWithCurrentVersion) ? (
<WorkflowDiagramCanvasEditable
diagram={workflowDiagram}
workflowWithCurrentVersion={workflowWithCurrentVersion}
/>
) : null}
</>
);
};

View File

@ -0,0 +1,17 @@
import { workflowIdState } from '@/workflow/states/workflowIdState';
import { useEffect } from 'react';
import { useSetRecoilState } from 'recoil';
export const WorkflowVisualizerEffect = ({
workflowId,
}: {
workflowId: string;
}) => {
const setWorkflowId = useSetRecoilState(workflowIdState);
useEffect(() => {
setWorkflowId(workflowId);
}, [setWorkflowId, workflowId]);
return null;
};