Store the current flow definition in a state to not depend on a specific workflow version (#10352)

This PR introduces a new Recoil state to store the flow.

A few parts of the application need to know the definition of the
current flow. Previously, we stored the workflow version's ID and
fetched its definition with the `useWorkflowVersion` hook. However, we
must use another strategy to visualize workflow runs. Indeed, we now
store the definition of the workflow in the workflow run output when it
is executed. This is useful for draft versions, which can change between
the moment they were executed and the moment they are visualized.
This commit is contained in:
Baptiste Devessier
2025-02-20 17:12:03 +01:00
committed by GitHub
parent d96865abc3
commit 05d00e6604
14 changed files with 180 additions and 92 deletions

View File

@ -0,0 +1,60 @@
import { expect } from '@playwright/test';
import { test } from '../lib/fixtures/blank-workflow';
test('The workflow run visualizer shows the executed draft version without the last draft changes', async ({
workflowVisualizer,
page,
}) => {
await workflowVisualizer.createInitialTrigger('manual');
const manualTriggerAvailabilitySelect = page.getByRole('button', {
name: 'When record(s) are selected',
});
await manualTriggerAvailabilitySelect.click();
const alwaysAvailableOption = page.getByText(
'When no record(s) are selected',
);
await alwaysAvailableOption.click();
await workflowVisualizer.closeSidePanel();
const { createdStepId: firstStepId } =
await workflowVisualizer.createStep('create-record');
await workflowVisualizer.closeSidePanel();
const launchTestButton = page.getByRole('button', { name: 'Test' });
await launchTestButton.click();
const goToExecutionPageLink = page.getByRole('link', {
name: 'View execution details',
});
const executionPageUrl = await goToExecutionPageLink.getAttribute('href');
expect(executionPageUrl).not.toBeNull();
await workflowVisualizer.deleteStep(firstStepId);
await page.goto(executionPageUrl!);
const workflowRunName = page.getByText('Execution of v1');
await expect(workflowRunName).toBeVisible();
const flowTab = page.getByText('Flow', { exact: true });
await flowTab.click();
const executedFirstStepNode = workflowVisualizer.getStepNode(firstStepId);
await expect(executedFirstStepNode).toBeVisible();
await executedFirstStepNode.click();
await expect(
workflowVisualizer.commandMenu.getByRole('textbox').first(),
).toHaveValue('Create Record');
});

View File

@ -16,10 +16,7 @@ export const WorkflowRunVisualizerContent = ({
return ( return (
<> <>
<WorkflowRunVisualizerEffect <WorkflowRunVisualizerEffect workflowRun={workflowRun} />
workflowRun={workflowRun}
workflowVersionId={workflowVersion.id}
/>
<WorkflowDiagramCanvasReadonly versionStatus={workflowVersion.status} /> <WorkflowDiagramCanvasReadonly versionStatus={workflowVersion.status} />
</> </>

View File

@ -0,0 +1,12 @@
import { flowState } from '@/workflow/states/flowState';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
export const useFlowOrThrow = () => {
const flow = useRecoilValue(flowState);
if (!isDefined(flow)) {
throw new Error('Expected the flow to be defined');
}
return flow;
};

View File

@ -0,0 +1,13 @@
import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
import { createState } from '@ui/utilities/state/utils/createState';
export const flowState = createState<
| {
trigger: WorkflowTrigger | null;
steps: WorkflowAction[] | null;
}
| undefined
>({
key: 'flowState',
defaultValue: undefined,
});

View File

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

View File

@ -1,17 +1,19 @@
import { WorkflowVersion } from '@/workflow/types/Workflow'; import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
import { findStepPosition } from '@/workflow/utils/findStepPosition'; import { findStepPosition } from '@/workflow/utils/findStepPosition';
import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId'; import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
export const getStepDefinitionOrThrow = ({ export const getStepDefinitionOrThrow = ({
stepId, stepId,
workflowVersion, trigger,
steps,
}: { }: {
stepId: string; stepId: string;
workflowVersion: WorkflowVersion; trigger: WorkflowTrigger | null;
steps: Array<WorkflowAction> | null;
}) => { }) => {
if (stepId === TRIGGER_STEP_ID) { if (stepId === TRIGGER_STEP_ID) {
if (!isDefined(workflowVersion.trigger)) { if (!isDefined(trigger)) {
return { return {
type: 'trigger', type: 'trigger',
definition: undefined, definition: undefined,
@ -20,18 +22,18 @@ export const getStepDefinitionOrThrow = ({
return { return {
type: 'trigger', type: 'trigger',
definition: workflowVersion.trigger, definition: trigger,
} as const; } as const;
} }
if (!isDefined(workflowVersion.steps)) { if (!isDefined(steps)) {
throw new Error( throw new Error(
'Malformed workflow version: missing steps information; be sure to create at least one step before trying to edit one', 'Malformed workflow version: missing steps information; be sure to create at least one step before trying to edit one',
); );
} }
const selectedNodePosition = findStepPosition({ const selectedNodePosition = findStepPosition({
steps: workflowVersion.steps, steps,
stepId: stepId, stepId: stepId,
}); });
if (!isDefined(selectedNodePosition)) { if (!isDefined(selectedNodePosition)) {

View File

@ -1,4 +1,5 @@
import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue'; import { getSnapshotValue } from '@/ui/utilities/state/utils/getSnapshotValue';
import { flowState } from '@/workflow/states/flowState';
import { workflowLastCreatedStepIdState } from '@/workflow/states/workflowLastCreatedStepIdState'; import { workflowLastCreatedStepIdState } from '@/workflow/states/workflowLastCreatedStepIdState';
import { import {
WorkflowVersion, WorkflowVersion,
@ -20,6 +21,7 @@ export const WorkflowDiagramEffect = ({
workflowWithCurrentVersion: WorkflowWithCurrentVersion | undefined; workflowWithCurrentVersion: WorkflowWithCurrentVersion | undefined;
}) => { }) => {
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState); const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
const setFlow = useSetRecoilState(flowState);
const computeAndMergeNewWorkflowDiagram = useRecoilCallback( const computeAndMergeNewWorkflowDiagram = useRecoilCallback(
({ snapshot, set }) => { ({ snapshot, set }) => {
@ -67,14 +69,21 @@ export const WorkflowDiagramEffect = ({
useEffect(() => { useEffect(() => {
const currentVersion = workflowWithCurrentVersion?.currentVersion; const currentVersion = workflowWithCurrentVersion?.currentVersion;
if (!isDefined(currentVersion)) { if (!isDefined(currentVersion)) {
setFlow(undefined);
setWorkflowDiagram(undefined); setWorkflowDiagram(undefined);
return; return;
} }
setFlow({
trigger: currentVersion.trigger,
steps: currentVersion.steps,
});
computeAndMergeNewWorkflowDiagram(currentVersion); computeAndMergeNewWorkflowDiagram(currentVersion);
}, [ }, [
computeAndMergeNewWorkflowDiagram, computeAndMergeNewWorkflowDiagram,
setFlow,
setWorkflowDiagram, setWorkflowDiagram,
workflowWithCurrentVersion?.currentVersion, workflowWithCurrentVersion?.currentVersion,
]); ]);

View File

@ -1,4 +1,4 @@
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState'; import { flowState } from '@/workflow/states/flowState';
import { WorkflowRun } from '@/workflow/types/Workflow'; import { WorkflowRun } from '@/workflow/types/Workflow';
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState'; import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram'; import { generateWorkflowRunDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowRunDiagram';
@ -7,18 +7,25 @@ import { useSetRecoilState } from 'recoil';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
export const WorkflowRunVisualizerEffect = ({ export const WorkflowRunVisualizerEffect = ({
workflowVersionId,
workflowRun, workflowRun,
}: { }: {
workflowVersionId: string;
workflowRun: WorkflowRun; workflowRun: WorkflowRun;
}) => { }) => {
const setWorkflowVersionId = useSetRecoilState(workflowVersionIdState); const setFlow = useSetRecoilState(flowState);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState); const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
useEffect(() => { useEffect(() => {
setWorkflowVersionId(workflowVersionId); if (!isDefined(workflowRun.output)) {
}, [setWorkflowVersionId, workflowVersionId]); setFlow(undefined);
return;
}
setFlow({
trigger: workflowRun.output.flow.trigger,
steps: workflowRun.output.flow.steps,
});
}, [setFlow, workflowRun.output]);
useEffect(() => { useEffect(() => {
if (!isDefined(workflowRun.output)) { if (!isDefined(workflowRun.output)) {

View File

@ -1,5 +1,5 @@
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion'; import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion';
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState'; import { flowState } from '@/workflow/states/flowState';
import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState'; import { workflowDiagramState } from '@/workflow/workflow-diagram/states/workflowDiagramState';
import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram'; import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram';
import { markLeafNodes } from '@/workflow/workflow-diagram/utils/markLeafNodes'; import { markLeafNodes } from '@/workflow/workflow-diagram/utils/markLeafNodes';
@ -14,12 +14,21 @@ export const WorkflowVersionVisualizerEffect = ({
}) => { }) => {
const workflowVersion = useWorkflowVersion(workflowVersionId); const workflowVersion = useWorkflowVersion(workflowVersionId);
const setWorkflowVersionId = useSetRecoilState(workflowVersionIdState); const setFlow = useSetRecoilState(flowState);
const setWorkflowDiagram = useSetRecoilState(workflowDiagramState); const setWorkflowDiagram = useSetRecoilState(workflowDiagramState);
useEffect(() => { useEffect(() => {
setWorkflowVersionId(workflowVersionId); if (!isDefined(workflowVersion)) {
}, [setWorkflowVersionId, workflowVersionId]); setFlow(undefined);
return;
}
setFlow({
trigger: workflowVersion.trigger,
steps: workflowVersion.steps,
});
}, [setFlow, workflowVersion]);
useEffect(() => { useEffect(() => {
if (!isDefined(workflowVersion)) { if (!isDefined(workflowVersion)) {

View File

@ -1,3 +1,4 @@
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState'; import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail'; import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
@ -11,6 +12,8 @@ export const RightDrawerWorkflowEditStepContent = ({
}: { }: {
workflow: WorkflowWithCurrentVersion; workflow: WorkflowWithCurrentVersion;
}) => { }) => {
const flow = useFlowOrThrow();
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState); const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) { if (!isDefined(workflowSelectedNode)) {
throw new Error( throw new Error(
@ -26,7 +29,8 @@ export const RightDrawerWorkflowEditStepContent = ({
return ( return (
<WorkflowStepDetail <WorkflowStepDetail
stepId={workflowSelectedNode} stepId={workflowSelectedNode}
workflowVersion={workflow.currentVersion} trigger={flow.trigger}
steps={flow.steps}
onActionUpdate={updateStep} onActionUpdate={updateStep}
onTriggerUpdate={updateTrigger} onTriggerUpdate={updateTrigger}
/> />

View File

@ -1,22 +1,25 @@
import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion'; import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { workflowVersionIdState } from '@/workflow/states/workflowVersionIdState'; import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { RightDrawerWorkflowViewStepContent } from '@/workflow/workflow-steps/components/RightDrawerWorkflowViewStepContent'; import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared'; import { isDefined } from 'twenty-shared';
export const RightDrawerWorkflowViewStep = () => { export const RightDrawerWorkflowViewStep = () => {
const workflowVersionId = useRecoilValue(workflowVersionIdState); const flow = useFlowOrThrow();
if (!isDefined(workflowVersionId)) {
throw new Error('Expected a workflow version id');
}
const workflowVersion = useWorkflowVersion(workflowVersionId); const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
if (!isDefined(workflowSelectedNode)) {
if (!isDefined(workflowVersion)) { throw new Error(
return null; 'Expected a node to be selected. Selecting a node is mandatory to view its details.',
);
} }
return ( return (
<RightDrawerWorkflowViewStepContent workflowVersion={workflowVersion} /> <WorkflowStepDetail
stepId={workflowSelectedNode}
trigger={flow.trigger}
steps={flow.steps}
readonly
/>
); );
}; };

View File

@ -1,26 +0,0 @@
import { WorkflowVersion } from '@/workflow/types/Workflow';
import { workflowSelectedNodeState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeState';
import { WorkflowStepDetail } from '@/workflow/workflow-steps/components/WorkflowStepDetail';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared';
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,8 +1,4 @@
import { import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow';
WorkflowAction,
WorkflowTrigger,
WorkflowVersion,
} from '@/workflow/types/Workflow';
import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow'; import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
import { WorkflowEditActionFormCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormCreateRecord'; import { WorkflowEditActionFormCreateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionFormCreateRecord';
@ -25,30 +21,34 @@ const WorkflowEditActionFormServerlessFunction = lazy(() =>
})), })),
); );
type WorkflowStepDetailProps = type WorkflowStepDetailProps = {
stepId: string;
trigger: WorkflowTrigger | null;
steps: Array<WorkflowAction> | null;
} & (
| { | {
stepId: string;
workflowVersion: WorkflowVersion;
readonly: true; readonly: true;
onTriggerUpdate?: undefined; onTriggerUpdate?: undefined;
onActionUpdate?: undefined; onActionUpdate?: undefined;
} }
| { | {
stepId: string; stepId: string;
workflowVersion: WorkflowVersion;
readonly?: false; readonly?: false;
onTriggerUpdate: (trigger: WorkflowTrigger) => void; onTriggerUpdate: (trigger: WorkflowTrigger) => void;
onActionUpdate: (action: WorkflowAction) => void; onActionUpdate: (action: WorkflowAction) => void;
}; }
);
export const WorkflowStepDetail = ({ export const WorkflowStepDetail = ({
stepId, stepId,
workflowVersion, trigger,
steps,
...props ...props
}: WorkflowStepDetailProps) => { }: WorkflowStepDetailProps) => {
const stepDefinition = getStepDefinitionOrThrow({ const stepDefinition = getStepDefinitionOrThrow({
stepId, stepId,
workflowVersion, trigger,
steps,
}); });
if (!isDefined(stepDefinition) || !isDefined(stepDefinition.definition)) { if (!isDefined(stepDefinition) || !isDefined(stepDefinition.definition)) {
return null; return null;

View File

@ -1,3 +1,4 @@
import { useFlowOrThrow } from '@/workflow/hooks/useFlowOrThrow';
import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion';
import { workflowIdState } from '@/workflow/states/workflowIdState'; import { workflowIdState } from '@/workflow/states/workflowIdState';
import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow'; import { getStepDefinitionOrThrow } from '@/workflow/utils/getStepDefinitionOrThrow';
@ -24,27 +25,32 @@ export const useAvailableVariablesInWorkflowStep = ({
const workflowId = useRecoilValue(workflowIdState); const workflowId = useRecoilValue(workflowIdState);
const workflow = useWorkflowWithCurrentVersion(workflowId); const workflow = useWorkflowWithCurrentVersion(workflowId);
const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState); const workflowSelectedNode = useRecoilValue(workflowSelectedNodeState);
const flow = useFlowOrThrow();
if (!isDefined(workflowSelectedNode) || !isDefined(workflow)) { if (!isDefined(workflowSelectedNode) || !isDefined(workflow)) {
return []; return [];
} }
const trigger = flow.trigger;
const steps = flow.steps;
const stepDefinition = getStepDefinitionOrThrow({ const stepDefinition = getStepDefinitionOrThrow({
stepId: workflowSelectedNode, stepId: workflowSelectedNode,
workflowVersion: workflow.currentVersion, trigger,
steps,
}); });
if ( if (
!isDefined(stepDefinition) || !isDefined(stepDefinition) ||
stepDefinition.type === 'trigger' || stepDefinition.type === 'trigger' ||
!isDefined(workflow.currentVersion.steps) !isDefined(steps)
) { ) {
return []; return [];
} }
const previousSteps = []; const previousSteps = [];
for (const step of workflow.currentVersion.steps) { for (const step of steps) {
if (step.id === workflowSelectedNode) { if (step.id === workflowSelectedNode) {
break; break;
} }
@ -54,34 +60,32 @@ export const useAvailableVariablesInWorkflowStep = ({
const result = []; const result = [];
const filteredTriggerOutputSchema = filterOutputSchema( const filteredTriggerOutputSchema = filterOutputSchema(
workflow.currentVersion.trigger?.settings?.outputSchema as trigger?.settings?.outputSchema as OutputSchema | undefined,
| OutputSchema
| undefined,
objectNameSingularToSelect, objectNameSingularToSelect,
); );
if ( if (
isDefined(workflow.currentVersion.trigger) && isDefined(trigger) &&
isDefined(filteredTriggerOutputSchema) && isDefined(filteredTriggerOutputSchema) &&
!isEmptyObject(filteredTriggerOutputSchema) !isEmptyObject(filteredTriggerOutputSchema)
) { ) {
const triggerIconKey = const triggerIconKey =
workflow.currentVersion.trigger.type === 'DATABASE_EVENT' trigger.type === 'DATABASE_EVENT'
? getTriggerIcon({ ? getTriggerIcon({
type: workflow.currentVersion.trigger.type, type: trigger.type,
eventName: splitWorkflowTriggerEventName( eventName: splitWorkflowTriggerEventName(
workflow.currentVersion.trigger.settings?.eventName, trigger.settings?.eventName,
).event, ).event,
}) })
: getTriggerIcon({ : getTriggerIcon({
type: workflow.currentVersion.trigger.type, type: trigger.type,
}); });
result.push({ result.push({
id: 'trigger', id: 'trigger',
name: isDefined(workflow.currentVersion.trigger.name) name: isDefined(trigger.name)
? workflow.currentVersion.trigger.name ? trigger.name
: getTriggerStepName(workflow.currentVersion.trigger), : getTriggerStepName(trigger),
icon: triggerIconKey, icon: triggerIconKey,
outputSchema: filteredTriggerOutputSchema, outputSchema: filteredTriggerOutputSchema,
}); });