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

@ -11,6 +11,7 @@ import { RightDrawerTopBar } from '@/ui/layout/right-drawer/components/RightDraw
import { ComponentByRightDrawerPage } from '@/ui/layout/right-drawer/types/ComponentByRightDrawerPage';
import { RightDrawerWorkflowEditStep } from '@/workflow/components/RightDrawerWorkflowEditStep';
import { RightDrawerWorkflowSelectAction } from '@/workflow/components/RightDrawerWorkflowSelectAction';
import { RightDrawerWorkflowViewStep } from '@/workflow/components/RightDrawerWorkflowViewStep';
import { isDefined } from 'twenty-ui';
import { rightDrawerPageState } from '../states/rightDrawerPageState';
import { RightDrawerPages } from '../types/RightDrawerPages';
@ -41,6 +42,7 @@ const RIGHT_DRAWER_PAGES_CONFIG: ComponentByRightDrawerPage = {
<RightDrawerWorkflowSelectAction />
),
[RightDrawerPages.WorkflowStepEdit]: <RightDrawerWorkflowEditStep />,
[RightDrawerPages.WorkflowStepView]: <RightDrawerWorkflowViewStep />,
};
export const RightDrawerRouter = () => {

View File

@ -7,4 +7,5 @@ export const RIGHT_DRAWER_PAGE_ICONS = {
[RightDrawerPages.Copilot]: 'IconSparkles',
[RightDrawerPages.WorkflowStepEdit]: 'IconSparkles',
[RightDrawerPages.WorkflowStepSelectAction]: 'IconSparkles',
[RightDrawerPages.WorkflowStepView]: 'IconSparkles',
};

View File

@ -7,4 +7,5 @@ export const RIGHT_DRAWER_PAGE_TITLES = {
[RightDrawerPages.Copilot]: 'Copilot',
[RightDrawerPages.WorkflowStepEdit]: 'Workflow',
[RightDrawerPages.WorkflowStepSelectAction]: 'Workflow',
[RightDrawerPages.WorkflowStepView]: 'Workflow',
};

View File

@ -4,5 +4,6 @@ export enum RightDrawerPages {
ViewRecord = 'view-record',
Copilot = 'copilot',
WorkflowStepSelectAction = 'workflow-step-select-action',
WorkflowStepView = 'workflow-step-view',
WorkflowStepEdit = 'workflow-step-edit',
}

View File

@ -15,7 +15,10 @@ import { ShowPageActivityContainer } from '@/ui/layout/show-page/components/Show
import { TabList } from '@/ui/layout/tab/components/TabList';
import { useTabList } from '@/ui/layout/tab/hooks/useTabList';
import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
import { Workflow } from '@/workflow/components/Workflow';
import { WorkflowVersionVisualizer } from '@/workflow/components/WorkflowVersionVisualizer';
import { WorkflowVersionVisualizerEffect } from '@/workflow/components/WorkflowVersionVisualizerEffect';
import { WorkflowVisualizer } from '@/workflow/components/WorkflowVisualizer';
import { WorkflowVisualizerEffect } from '@/workflow/components/WorkflowVisualizerEffect';
import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled';
import styled from '@emotion/styled';
import { useState } from 'react';
@ -130,6 +133,10 @@ export const ShowPageRightContainer = ({
isWorkflowEnabled &&
targetableObject.targetObjectNameSingular ===
CoreObjectNameSingular.Workflow;
const isWorkflowVersion =
isWorkflowEnabled &&
targetableObject.targetObjectNameSingular ===
CoreObjectNameSingular.WorkflowVersion;
const shouldDisplayCalendarTab = isCompanyOrPerson;
const shouldDisplayEmailsTab = emails && isCompanyOrPerson;
@ -162,7 +169,7 @@ export const ShowPageRightContainer = ({
id: 'timeline',
title: 'Timeline',
Icon: IconTimelineEvent,
hide: !timeline || isInRightDrawer || isWorkflow,
hide: !timeline || isInRightDrawer || isWorkflow || isWorkflowVersion,
},
{
id: 'tasks',
@ -174,7 +181,8 @@ export const ShowPageRightContainer = ({
CoreObjectNameSingular.Note ||
targetableObject.targetObjectNameSingular ===
CoreObjectNameSingular.Task ||
isWorkflow,
isWorkflow ||
isWorkflowVersion,
},
{
id: 'notes',
@ -186,13 +194,14 @@ export const ShowPageRightContainer = ({
CoreObjectNameSingular.Note ||
targetableObject.targetObjectNameSingular ===
CoreObjectNameSingular.Task ||
isWorkflow,
isWorkflow ||
isWorkflowVersion,
},
{
id: 'files',
title: 'Files',
Icon: IconPaperclip,
hide: !notes || isWorkflow,
hide: !notes || isWorkflow || isWorkflowVersion,
},
{
id: 'emails',
@ -212,6 +221,12 @@ export const ShowPageRightContainer = ({
Icon: IconSettings,
hide: !isWorkflow,
},
{
id: 'workflowVersion',
title: 'Workflow Version',
Icon: IconSettings,
hide: !isWorkflowVersion,
},
];
const renderActiveTabContent = () => {
switch (activeTabId) {
@ -251,7 +266,25 @@ export const ShowPageRightContainer = ({
case 'calendar':
return <Calendar targetableObject={targetableObject} />;
case 'workflow':
return <Workflow targetableObject={targetableObject} />;
return (
<>
<WorkflowVisualizerEffect workflowId={targetableObject.id} />
<WorkflowVisualizer targetableObject={targetableObject} />
</>
);
case 'workflowVersion':
return (
<>
<WorkflowVersionVisualizerEffect
workflowVersionId={targetableObject.id}
/>
<WorkflowVersionVisualizer
workflowVersionId={targetableObject.id}
/>
</>
);
default:
return <></>;
}

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;
};

View File

@ -2,11 +2,7 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { WorkflowVersion } from '@/workflow/types/Workflow';
export const useCreateNewWorkflowVersion = ({
workflowId,
}: {
workflowId: string;
}) => {
export const useCreateNewWorkflowVersion = () => {
const { createOneRecord: createOneWorkflowVersion } =
useCreateOneRecord<WorkflowVersion>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
@ -15,13 +11,10 @@ export const useCreateNewWorkflowVersion = ({
const createNewWorkflowVersion = (
workflowVersionData: Pick<
WorkflowVersion,
'name' | 'status' | 'trigger' | 'steps'
'workflowId' | 'name' | 'status' | 'trigger' | 'steps'
>,
) => {
return createOneWorkflowVersion({
workflowId,
...workflowVersionData,
});
return createOneWorkflowVersion(workflowVersionData);
};
return {

View File

@ -32,9 +32,7 @@ export const useCreateStep = ({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion({
workflowId: workflow.id,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const insertNodeAndSave = async ({
parentNodeId,
@ -66,6 +64,7 @@ export const useCreateStep = ({
}
await createNewWorkflowVersion({
workflowId: workflow.id,
name: `v${workflow.versions.length + 1}`,
status: 'DRAFT',
trigger: workflow.currentVersion.trigger,

View File

@ -20,9 +20,7 @@ export const useDeleteOneStep = ({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion({
workflowId: workflow.id,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const deleteOneStep = async () => {
if (workflow.currentVersion.status !== 'DRAFT') {
@ -30,6 +28,7 @@ export const useDeleteOneStep = ({
if (stepId === TRIGGER_STEP_ID) {
await createNewWorkflowVersion({
workflowId: workflow.id,
name: newVersionName,
status: 'DRAFT',
trigger: null,
@ -37,6 +36,7 @@ export const useDeleteOneStep = ({
});
} else {
await createNewWorkflowVersion({
workflowId: workflow.id,
name: newVersionName,
status: 'DRAFT',
trigger: workflow.currentVersion.trigger,

View File

@ -0,0 +1,36 @@
import { workflowDiagramTriggerNodeSelectionState } from '@/workflow/states/workflowDiagramTriggerNodeSelectionState';
import {
WorkflowDiagramEdge,
WorkflowDiagramNode,
} from '@/workflow/types/WorkflowDiagram';
import { useReactFlow } from '@xyflow/react';
import { useEffect } from 'react';
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-ui';
export const useTriggerNodeSelection = () => {
const reactflow = useReactFlow<WorkflowDiagramNode, WorkflowDiagramEdge>();
const workflowDiagramTriggerNodeSelection = useRecoilValue(
workflowDiagramTriggerNodeSelectionState,
);
const setWorkflowDiagramTriggerNodeSelection = useSetRecoilState(
workflowDiagramTriggerNodeSelectionState,
);
useEffect(() => {
if (!isDefined(workflowDiagramTriggerNodeSelection)) {
return;
}
reactflow.updateNode(workflowDiagramTriggerNodeSelection, {
selected: true,
});
setWorkflowDiagramTriggerNodeSelection(undefined);
}, [
reactflow,
setWorkflowDiagramTriggerNodeSelection,
workflowDiagramTriggerNodeSelection,
]);
};

View File

@ -21,9 +21,7 @@ export const useUpdateWorkflowVersionStep = ({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion({
workflowId: workflow.id,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const updateStep = async <T extends WorkflowStep>(updatedStep: T) => {
if (!isDefined(workflow.currentVersion)) {
@ -48,6 +46,7 @@ export const useUpdateWorkflowVersionStep = ({
}
await createNewWorkflowVersion({
workflowId: workflow.id,
name: `v${workflow.versions.length + 1}`,
status: 'DRAFT',
trigger: workflow.currentVersion.trigger,

View File

@ -18,9 +18,7 @@ export const useUpdateWorkflowVersionTrigger = ({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion({
workflowId: workflow.id,
});
const { createNewWorkflowVersion } = useCreateNewWorkflowVersion();
const updateTrigger = async (updatedTrigger: WorkflowTrigger) => {
if (!isDefined(workflow.currentVersion)) {
@ -39,6 +37,7 @@ export const useUpdateWorkflowVersionTrigger = ({
}
await createNewWorkflowVersion({
workflowId: workflow.id,
name: `v${workflow.versions.length + 1}`,
status: 'DRAFT',
trigger: updatedTrigger,

View File

@ -0,0 +1,36 @@
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { Workflow, WorkflowVersion } from '@/workflow/types/Workflow';
export const useWorkflowVersion = (workflowVersionId: string) => {
const { record: workflowVersion } = useFindOneRecord<
WorkflowVersion & {
workflow: Omit<Workflow, 'versions'> & {
versions: Array<{ __typename: string }>;
};
}
>({
objectNameSingular: CoreObjectNameSingular.WorkflowVersion,
objectRecordId: workflowVersionId,
recordGqlFields: {
id: true,
name: true,
createdAt: true,
updatedAt: true,
workflowId: true,
trigger: true,
steps: true,
status: true,
workflow: {
id: true,
name: true,
statuses: true,
versions: {
totalCount: true,
},
},
},
});
return workflowVersion;
};

View File

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

View File

@ -28,3 +28,8 @@ export type WorkflowDiagramCreateStepNodeData = {
export type WorkflowDiagramNodeData =
| WorkflowDiagramStepNodeData
| WorkflowDiagramCreateStepNodeData;
export type WorkflowDiagramNodeType =
| 'default'
| 'empty-trigger'
| 'create-step';

View File

@ -0,0 +1,45 @@
import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId';
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { findStepPosition } from '@/workflow/utils/findStepPosition';
import { isDefined } from 'twenty-ui';
export const getStepDefinitionOrThrow = ({
stepId,
workflowVersion,
}: {
stepId: string;
workflowVersion: WorkflowVersion;
}) => {
if (stepId === TRIGGER_STEP_ID) {
if (!isDefined(workflowVersion.trigger)) {
return {
type: 'trigger',
definition: undefined,
} as const;
}
return {
type: 'trigger',
definition: workflowVersion.trigger,
} as const;
}
if (!isDefined(workflowVersion.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: workflowVersion.steps,
stepId: stepId,
});
if (!isDefined(selectedNodePosition)) {
return undefined;
}
return {
type: 'action',
definition: selectedNodePosition.steps[selectedNodePosition.index],
} as const;
};

View File

@ -10,6 +10,7 @@ import { PageBody } from '@/ui/layout/page/PageBody';
import { PageContainer } from '@/ui/layout/page/PageContainer';
import { PageTitle } from '@/ui/utilities/page-title/PageTitle';
import { RecordShowPageWorkflowHeader } from '@/workflow/components/RecordShowPageWorkflowHeader';
import { RecordShowPageWorkflowVersionHeader } from '@/workflow/components/RecordShowPageWorkflowVersionHeader';
import { RecordShowPageBaseHeader } from '~/pages/object-record/RecordShowPageBaseHeader';
import { RecordShowPageHeader } from '~/pages/object-record/RecordShowPageHeader';
@ -47,8 +48,11 @@ export const RecordShowPage = () => {
>
<>
{objectNameSingular === CoreObjectNameSingular.Workflow ? (
<RecordShowPageWorkflowHeader
workflowId={parameters.objectRecordId}
<RecordShowPageWorkflowHeader workflowId={objectRecordId} />
) : objectNameSingular ===
CoreObjectNameSingular.WorkflowVersion ? (
<RecordShowPageWorkflowVersionHeader
workflowVersionId={objectRecordId}
/>
) : (
<RecordShowPageBaseHeader