From 6447b7f935e3349888926dfc486c1c3f7a859476 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Thu, 30 Jul 2026 16:00:10 +0200 Subject: [PATCH] feat(workflow): make the flow atom authoritative for version content, read core behind a flag (#23499) ## What The frontend half of the workflow-version read switch, plus the small server query it consumes. Two ideas: 1. **One hook owns where content comes from.** `useWorkflowVersionContent(workflowVersionId)` returns `{ trigger, steps }` from the workspace record when `IS_WORKFLOW_VERSION_IN_CORE_ENABLED` is off (default), and from the new `workflowVersionContent` core query when on. Switching the source later (core-only, after the column drop) is a change inside this one hook. 2. **`flowComponentState` becomes authoritative for the builder.** The canvas, diagram and step output schemas derive from the jotai atom; the atom is seeded once per version through the hook above; mutations keep it up to date. ## Why the seeding change is required Today `WorkflowDiagramEffect` re-seeds the atom from the Apollo record on **every** `currentVersion` identity change. That has two consequences: - Three of the five step/edge hooks (`delete step`, `create edge`, `delete edge`) never write the atom themselves; they only write the record and the re-seed papers over it. - The model breaks the moment content comes from a source mutations do not write (i.e. core): the stale fetch would be re-applied over every optimistic edit, and your just-added step would vanish from the canvas. So the atom is now seeded **once per version**, and `useUpdateWorkflowVersionCache` applies the mutation's `stepsDiff`/`triggerDiff` to the atom directly. All five step/edge hooks get that through their existing call, which closes the three-hook gap in one move. The step-update, trigger and tidy-up hooks write the atom too. The record-cache writes are all kept while `trigger`/`steps` still live on the record (dropped later with the columns). ## The dead wire, now the refresh path `shouldWorkflowRefetchRequestFamilyState` was set by `WorkflowSSESubscribeEffect` (reconnect, other-tab create) and **consumed by nothing**. It is now the external-refresh path: when set, the builder refetches content and reseeds. Known trade-off: while connected, another tab's edits no longer live-patch the canvas through record cache updates (they arrive on reconnect, version switch or reload). Given concurrent editing of one draft has no conflict handling anyway, that seemed acceptable; easy to extend the SSE effect to set the flag on update events if we want live propagation back. ## Untouched by design - **Run visualizer**: feeds the same atom from the immutable `workflowRun.state.flow` snapshot; that duality (version content or run snapshot) is exactly why the atom stays separate from the record store. - **Version visualizer** (read-only): reseeds on content change, safe because nothing writes its instance optimistically. - Peripheral readers of `currentVersion.trigger/steps` (test-workflow command, headless command enrichment, if-else body, etc.) still read the record. Correct while dual-writing continues; they move to the content hook before workspace content writes stop (tracked in the migration plan). ## Verification - `nx typecheck` green on both packages; `oxfmt` + `oxlint --type-aware` green on all 16 changed files - Front unit tests: 134 suites / 993 tests green (the two hook tests gained the visualizer instance context their hooks now require) - New server integration test for `workflowVersionContent` - **Live click-through pending**: step create/delete/duplicate, edge create/delete, trigger edit, tidy-up, draft create/discard, activation, version viewer, run viewer, with the flag off and on. The failure mode this PR guards against (an edit vanishing from the canvas) does not show up in typecheck or unit tests. Review in cubic --- .../twenty-front/src/generated/graphql.ts | 23 +++- .../TidyUpWorkflowSingleRecordCommand.tsx | 11 +- .../components/SidePanelWorkflowStepInfo.tsx | 3 +- .../state/jotai/hooks/useAtomFamilyState.ts | 13 +++ .../components/WorkflowDiagramEffect.tsx | 108 ++++++++++++++---- .../WorkflowVersionVisualizerEffect.tsx | 41 +++++-- .../useUpdateWorkflowVersionStep.test.ts | 21 +++- ... => useApplyWorkflowVersionStepChanges.ts} | 27 ++++- .../hooks/useCreateWorkflowVersionEdge.ts | 7 +- .../hooks/useCreateWorkflowVersionStep.ts | 19 +-- .../hooks/useDeleteWorkflowVersionEdge.ts | 7 +- .../hooks/useDeleteWorkflowVersionStep.ts | 7 +- .../hooks/useDuplicateWorkflowVersionStep.ts | 19 +-- .../hooks/useUpdateWorkflowVersionStep.ts | 19 ++- .../useUpdateWorkflowVersionTrigger.test.ts | 17 ++- .../hooks/useUpdateWorkflowVersionTrigger.ts | 12 ++ .../queries/getWorkflowVersionContent.ts | 11 ++ .../hooks/useTidyUpWorkflowVersion.ts | 38 +++++- .../hooks/useWorkflowVersionContent.ts | 71 ++++++++++++ .../dtos/workflow-version-content.dto.ts | 19 +++ .../resolvers/workflow-version.resolver.ts | 25 +++- ...rkflow-version-content.integration-spec.ts | 97 ++++++++++++++++ 22 files changed, 528 insertions(+), 87 deletions(-) create mode 100644 packages/twenty-front/src/modules/ui/utilities/state/jotai/hooks/useAtomFamilyState.ts rename packages/twenty-front/src/modules/workflow/workflow-steps/hooks/{useUpdateWorkflowVersionCache.ts => useApplyWorkflowVersionStepChanges.ts} (75%) create mode 100644 packages/twenty-front/src/modules/workflow/workflow-version/graphql/queries/getWorkflowVersionContent.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-version/hooks/useWorkflowVersionContent.ts create mode 100644 packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-version-content.dto.ts create mode 100644 packages/twenty-server/test/integration/graphql/suites/workflow/workflow-version-content.integration-spec.ts diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index b07d44b765..bc95e7d596 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -367,6 +367,7 @@ export type Query = { isMaintenanceModeBannerDismissed: Scalars['Boolean']['output']; search: SearchResultConnection; workflowStepConnectedAccountHandle?: Maybe; + workflowVersionContent: WorkflowVersionContent; }; @@ -442,6 +443,11 @@ export type QueryWorkflowStepConnectedAccountHandleArgs = { connectedAccountId: Scalars['UUID']['input']; }; + +export type QueryWorkflowVersionContentArgs = { + workflowVersionId: Scalars['UUID']['input']; +}; + export type RunWorkflowVersion = { __typename?: 'RunWorkflowVersion'; workflowRunId: Scalars['UUID']['output']; @@ -705,6 +711,13 @@ export type WorkflowStepPositionUpdateInput = { position: WorkflowStepPositionInput; }; +export type WorkflowVersionContent = { + __typename?: 'WorkflowVersionContent'; + steps?: Maybe; + trigger?: Maybe; + workflowVersionId: Scalars['UUID']['output']; +}; + export type WorkflowVersionDto = { __typename?: 'WorkflowVersionDTO'; createdAt: Scalars['String']['output']; @@ -916,6 +929,13 @@ export type UpdateWorkflowVersionPositionsMutationVariables = Exact<{ export type UpdateWorkflowVersionPositionsMutation = { __typename?: 'Mutation', updateWorkflowVersionPositions: boolean }; +export type GetWorkflowVersionContentQueryVariables = Exact<{ + workflowVersionId: Scalars['UUID']['input']; +}>; + + +export type GetWorkflowVersionContentQuery = { __typename?: 'Query', workflowVersionContent: { __typename?: 'WorkflowVersionContent', workflowVersionId: any, trigger?: any | null, steps?: any | null } }; + export const TimelineCalendarEventParticipantFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEventParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}}]} as unknown as DocumentNode; export const TimelineCalendarEventCallRecordingFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventCallRecordingFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEventCallRecording"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}}]}}]} as unknown as DocumentNode; export const TimelineCalendarEventFragmentFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEvent"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"location"}},{"kind":"Field","name":{"kind":"Name","value":"startsAt"}},{"kind":"Field","name":{"kind":"Name","value":"endsAt"}},{"kind":"Field","name":{"kind":"Name","value":"isFullDay"}},{"kind":"Field","name":{"kind":"Name","value":"visibility"}},{"kind":"Field","name":{"kind":"Name","value":"participants"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineCalendarEventParticipantFragment"}}]}},{"kind":"Field","name":{"kind":"Name","value":"callRecordings"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"TimelineCalendarEventCallRecordingFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventParticipantFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEventParticipant"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"personId"}},{"kind":"Field","name":{"kind":"Name","value":"workspaceMemberId"}},{"kind":"Field","name":{"kind":"Name","value":"firstName"}},{"kind":"Field","name":{"kind":"Name","value":"lastName"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"TimelineCalendarEventCallRecordingFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"TimelineCalendarEventCallRecording"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"applicationId"}}]}}]} as unknown as DocumentNode; @@ -946,4 +966,5 @@ export const UpdateWorkflowVersionTriggerDocument = {"kind":"Document","definiti export const WorkflowStepConnectedAccountHandleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"WorkflowStepConnectedAccountHandle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workflowStepConnectedAccountHandle"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"connectedAccountId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"connectedAccountId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"handle"}},{"kind":"Field","name":{"kind":"Name","value":"provider"}}]}}]}}]} as unknown as DocumentNode; export const SubmitFormStepDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"SubmitFormStep"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"SubmitFormStepInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"submitFormStep"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; export const TestHttpRequestDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"TestHttpRequest"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"TestHttpRequestInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"testHttpRequest"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"message"}},{"kind":"Field","name":{"kind":"Name","value":"result"}},{"kind":"Field","name":{"kind":"Name","value":"error"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"statusText"}},{"kind":"Field","name":{"kind":"Name","value":"headers"}}]}}]}}]} as unknown as DocumentNode; -export const UpdateWorkflowVersionPositionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkflowVersionPositions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkflowVersionPositionsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkflowVersionPositions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const UpdateWorkflowVersionPositionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWorkflowVersionPositions"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWorkflowVersionPositionsInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWorkflowVersionPositions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; +export const GetWorkflowVersionContentDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWorkflowVersionContent"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"workflowVersionId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workflowVersionContent"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"workflowVersionId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"workflowVersionId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"workflowVersionId"}},{"kind":"Field","name":{"kind":"Name","value":"trigger"}},{"kind":"Field","name":{"kind":"Name","value":"steps"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand.tsx b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand.tsx index cd4abf430b..0acad20391 100644 --- a/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand.tsx +++ b/packages/twenty-front/src/modules/command-menu-item/engine-command/record/single-record/workflow/components/TidyUpWorkflowSingleRecordCommand.tsx @@ -12,10 +12,10 @@ export const TidyUpWorkflowSingleRecordCommand = () => { const { selectedRecords } = useHeadlessCommandContextApi(); const recordId = selectedRecords[0]?.id; - const { tidyUpWorkflowVersion } = useTidyUpWorkflowVersion(); const instanceId = getWorkflowVisualizerComponentInstanceId({ recordId: recordId ?? '', }); + const { tidyUpWorkflowVersion } = useTidyUpWorkflowVersion(instanceId); const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersionOrThrow(instanceId); @@ -35,7 +35,14 @@ export const TidyUpWorkflowSingleRecordCommand = () => { const workflowVersionId = await getUpdatableWorkflowVersion(); - await tidyUpWorkflowVersion(workflowVersionId, workflowDiagram); + const tidiedUpDiagram = await tidyUpWorkflowVersion( + workflowVersionId, + workflowDiagram, + ); + + if (isDefined(tidiedUpDiagram)) { + store.set(workflowDiagramAtom, tidiedUpDiagram); + } }; return ; diff --git a/packages/twenty-front/src/modules/side-panel/components/SidePanelWorkflowStepInfo.tsx b/packages/twenty-front/src/modules/side-panel/components/SidePanelWorkflowStepInfo.tsx index 137a278511..685b1a0c19 100644 --- a/packages/twenty-front/src/modules/side-panel/components/SidePanelWorkflowStepInfo.tsx +++ b/packages/twenty-front/src/modules/side-panel/components/SidePanelWorkflowStepInfo.tsx @@ -59,7 +59,8 @@ export const SidePanelWorkflowStepInfo = ({ const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersionOrThrow(instanceId); - const { updateWorkflowVersionStep } = useUpdateWorkflowVersionStep(); + const { updateWorkflowVersionStep } = + useUpdateWorkflowVersionStep(instanceId); const { updateTrigger } = useUpdateWorkflowVersionTrigger(instanceId); const { diff --git a/packages/twenty-front/src/modules/ui/utilities/state/jotai/hooks/useAtomFamilyState.ts b/packages/twenty-front/src/modules/ui/utilities/state/jotai/hooks/useAtomFamilyState.ts new file mode 100644 index 0000000000..82079e3ee4 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/state/jotai/hooks/useAtomFamilyState.ts @@ -0,0 +1,13 @@ +import { useAtom } from 'jotai'; + +import { type FamilyState } from '@/ui/utilities/state/jotai/types/FamilyState'; + +export const useAtomFamilyState = ( + familyState: FamilyState, + familyKey: FamilyKey, +): [ + ValueType, + (value: ValueType | ((prev: ValueType) => ValueType)) => void, +] => { + return useAtom(familyState.atomFamily(familyKey)); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx index 90454cee61..7ffe7a8590 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEffect.tsx @@ -1,15 +1,21 @@ import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { useAtomFamilyState } from '@/ui/utilities/state/jotai/hooks/useAtomFamilyState'; import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; import { flowComponentState } from '@/workflow/states/flowComponentState'; +import { shouldWorkflowRefetchRequestFamilyState } from '@/workflow/states/shouldWorkflowRefetchRequestFamilyState'; import { workflowLastCreatedStepIdComponentState } from '@/workflow/states/workflowLastCreatedStepIdComponentState'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; -import { type WorkflowVersion } from '@/workflow/types/Workflow'; +import { + type WorkflowAction, + type WorkflowTrigger, +} from '@/workflow/types/Workflow'; import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState'; +import { useWorkflowVersionContent } from '@/workflow/workflow-version/hooks/useWorkflowVersionContent'; import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema'; -import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram'; +import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram'; import { mergeWorkflowDiagrams } from '@/workflow/workflow-diagram/utils/mergeWorkflowDiagrams'; import { useStore } from 'jotai'; import { useCallback, useEffect, useState } from 'react'; @@ -28,6 +34,7 @@ export const WorkflowDiagramEffect = () => { workflowDiagramComponentState, ); const setFlow = useSetAtomComponentState(flowComponentState); + const flow = useAtomComponentStateValue(flowComponentState); const { populateStepsOutputSchema } = useStepsOutputSchema(); const workflowLastCreatedStepId = useAtomComponentStateCallbackState( @@ -37,14 +44,33 @@ export const WorkflowDiagramEffect = () => { const store = useStore(); const currentVersion = workflowWithCurrentVersion?.currentVersion; - const [previousVersionId, setPreviousVersionId] = useState(); + const { content, refetchContent } = useWorkflowVersionContent( + currentVersion?.id, + ); + + const [shouldWorkflowRefetchRequest, setShouldWorkflowRefetchRequest] = + useAtomFamilyState( + shouldWorkflowRefetchRequestFamilyState, + workflowVisualizerWorkflowId ?? '', + ); + + const [seededVersionId, setSeededVersionId] = useState(); + const [previousDiagramVersionId, setPreviousDiagramVersionId] = + useState(); const computeAndMergeNewWorkflowDiagram = useCallback( - (version: WorkflowVersion, preservePositions: boolean) => { + ( + flowToRender: { + trigger: WorkflowTrigger | null; + steps: WorkflowAction[] | null; + }, + preservePositions: boolean, + ) => { const previousWorkflowDiagram = store.get(workflowDiagram); - const nextWorkflowDiagram = getWorkflowVersionDiagram({ - workflowVersion: version, + const nextWorkflowDiagram = generateWorkflowDiagram({ + trigger: flowToRender.trigger ?? undefined, + steps: flowToRender.steps ?? [], workflowContext: 'workflow', }); @@ -79,33 +105,75 @@ export const WorkflowDiagramEffect = () => { ); useEffect(() => { - if (!isDefined(currentVersion)) { + if (!shouldWorkflowRefetchRequest) { return; } - const isSameVersion = previousVersionId === currentVersion.id; - const isTransitionToDraft = currentVersion.status === 'DRAFT'; - const shouldPreservePositions = isSameVersion || isTransitionToDraft; + setShouldWorkflowRefetchRequest(false); - setPreviousVersionId(currentVersion.id); + void refetchContent() + .then(() => { + setSeededVersionId(undefined); + }) + .catch(() => {}); + }, [ + shouldWorkflowRefetchRequest, + setShouldWorkflowRefetchRequest, + refetchContent, + ]); + + useEffect(() => { + if (!isDefined(currentVersion) || !isDefined(content)) { + return; + } + + if (content.workflowVersionId !== currentVersion.id) { + return; + } + + if (seededVersionId === currentVersion.id) { + return; + } + + setSeededVersionId(currentVersion.id); setFlow({ workflowVersionId: currentVersion.id, - trigger: currentVersion.trigger, - steps: currentVersion.steps, + trigger: content.trigger, + steps: content.steps, }); - - computeAndMergeNewWorkflowDiagram(currentVersion, shouldPreservePositions); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [computeAndMergeNewWorkflowDiagram, setFlow, currentVersion]); + }, [content, currentVersion, seededVersionId, setFlow]); useEffect(() => { - if (!isDefined(currentVersion)) { + if (!isDefined(flow)) { return; } - populateStepsOutputSchema(currentVersion); - }, [currentVersion, populateStepsOutputSchema]); + const isSameVersion = previousDiagramVersionId === flow.workflowVersionId; + const isTransitionToDraft = currentVersion?.status === 'DRAFT'; + const shouldPreservePositions = isSameVersion || isTransitionToDraft; + + setPreviousDiagramVersionId(flow.workflowVersionId); + + computeAndMergeNewWorkflowDiagram(flow, shouldPreservePositions); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [computeAndMergeNewWorkflowDiagram, flow]); + + useEffect(() => { + if (!isDefined(currentVersion) || !isDefined(flow)) { + return; + } + + if (flow.workflowVersionId !== currentVersion.id) { + return; + } + + populateStepsOutputSchema({ + ...currentVersion, + trigger: flow.trigger, + steps: flow.steps, + }); + }, [currentVersion, flow, populateStepsOutputSchema]); return null; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect.tsx index 142f6834c6..fbb9d3718e 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowVersionVisualizerEffect.tsx @@ -4,7 +4,8 @@ import { flowComponentState } from '@/workflow/states/flowComponentState'; import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { workflowVisualizerWorkflowVersionIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowVersionIdComponentState'; import { workflowDiagramComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramComponentState'; -import { getWorkflowVersionDiagram } from '@/workflow/workflow-diagram/utils/getWorkflowVersionDiagram'; +import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram'; +import { useWorkflowVersionContent } from '@/workflow/workflow-version/hooks/useWorkflowVersionContent'; import { useStepsOutputSchema } from '@/workflow/workflow-variables/hooks/useStepsOutputSchema'; import { useEffect } from 'react'; import { isDefined } from 'twenty-shared/utils'; @@ -15,6 +16,7 @@ export const WorkflowVersionVisualizerEffect = ({ workflowVersionId: string; }) => { const workflowVersion = useWorkflowVersion(workflowVersionId); + const { content } = useWorkflowVersionContent(workflowVersionId); const setFlow = useSetAtomComponentState(flowComponentState); const setWorkflowDiagram = useSetAtomComponentState( @@ -30,7 +32,11 @@ export const WorkflowVersionVisualizerEffect = ({ const { populateStepsOutputSchema } = useStepsOutputSchema(); useEffect(() => { - if (!isDefined(workflowVersion)) { + if ( + !isDefined(workflowVersion) || + !isDefined(content) || + content.workflowVersionId !== workflowVersion.id + ) { setFlow(undefined); return; @@ -38,13 +44,14 @@ export const WorkflowVersionVisualizerEffect = ({ setFlow({ workflowVersionId: workflowVersion.id, - trigger: workflowVersion.trigger, - steps: workflowVersion.steps, + trigger: content.trigger, + steps: content.steps, }); setWorkflowVisualizerWorkflowId(workflowVersion.workflowId); setWorkflowVisualizerWorkflowVersionId(workflowVersion.id); }, [ + content, setFlow, setWorkflowVisualizerWorkflowId, setWorkflowVisualizerWorkflowVersionId, @@ -52,27 +59,39 @@ export const WorkflowVersionVisualizerEffect = ({ ]); useEffect(() => { - if (!isDefined(workflowVersion)) { + if ( + !isDefined(content) || + content.workflowVersionId !== workflowVersionId + ) { setWorkflowDiagram(undefined); return; } - const nextWorkflowDiagram = getWorkflowVersionDiagram({ - workflowVersion, + const nextWorkflowDiagram = generateWorkflowDiagram({ + trigger: content.trigger ?? undefined, + steps: content.steps ?? [], workflowContext: 'workflow-version', }); setWorkflowDiagram(nextWorkflowDiagram); - }, [setWorkflowDiagram, workflowVersion]); + }, [content, setWorkflowDiagram, workflowVersionId]); useEffect(() => { - if (!isDefined(workflowVersion)) { + if ( + !isDefined(workflowVersion) || + !isDefined(content) || + content.workflowVersionId !== workflowVersion.id + ) { return; } - populateStepsOutputSchema(workflowVersion); - }, [populateStepsOutputSchema, workflowVersion]); + populateStepsOutputSchema({ + ...workflowVersion, + trigger: content.trigger, + steps: content.steps, + }); + }, [content, populateStepsOutputSchema, workflowVersion]); return null; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useUpdateWorkflowVersionStep.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useUpdateWorkflowVersionStep.test.ts index 8dae10b4f9..0747e5d19f 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useUpdateWorkflowVersionStep.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useUpdateWorkflowVersionStep.test.ts @@ -1,3 +1,5 @@ +import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; +import { createElement, type ReactNode } from 'react'; import { useUpdateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep'; import { act, renderHook } from '@testing-library/react'; @@ -44,6 +46,13 @@ jest.mock('@apollo/client/react', () => ({ useMutation: () => [mockMutate], })); +const Wrapper = ({ children }: { children: ReactNode }) => + createElement( + WorkflowVisualizerComponentInstanceContext.Provider, + { value: { instanceId: 'workflow-visualizer-test' } }, + children, + ); + describe('useUpdateWorkflowVersionStep', () => { beforeEach(() => { jest.clearAllMocks(); @@ -65,7 +74,9 @@ describe('useUpdateWorkflowVersionStep', () => { steps: [{ id: 'step-1', name: 'Create Record', type: 'CREATE_RECORD' }], }); - const { result } = renderHook(() => useUpdateWorkflowVersionStep()); + const { result } = renderHook(() => useUpdateWorkflowVersionStep(), { + wrapper: Wrapper, + }); await act(async () => { await result.current.updateWorkflowVersionStep({ @@ -83,7 +94,9 @@ describe('useUpdateWorkflowVersionStep', () => { it('should not mark step for recomputation when mutation returns no data', async () => { mockMutate.mockResolvedValue({ data: null }); - const { result } = renderHook(() => useUpdateWorkflowVersionStep()); + const { result } = renderHook(() => useUpdateWorkflowVersionStep(), { + wrapper: Wrapper, + }); await act(async () => { await result.current.updateWorkflowVersionStep({ @@ -108,7 +121,9 @@ describe('useUpdateWorkflowVersionStep', () => { mockGetRecordFromCache.mockReturnValue(undefined); - const { result } = renderHook(() => useUpdateWorkflowVersionStep()); + const { result } = renderHook(() => useUpdateWorkflowVersionStep(), { + wrapper: Wrapper, + }); await act(async () => { await result.current.updateWorkflowVersionStep({ diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useApplyWorkflowVersionStepChanges.ts similarity index 75% rename from packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache.ts rename to packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useApplyWorkflowVersionStepChanges.ts index df26b17f68..6e3636bc9a 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useApplyWorkflowVersionStepChanges.ts @@ -5,11 +5,13 @@ import { CoreObjectNameSingular } from 'twenty-shared/types'; import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; +import { flowComponentState } from '@/workflow/states/flowComponentState'; import { type WorkflowVersion } from '@/workflow/types/Workflow'; import { applyDiff, isDefined } from 'twenty-shared/utils'; import { type WorkflowVersionStepChanges } from '~/generated/graphql'; -export const useUpdateWorkflowVersionCache = () => { +export const useApplyWorkflowVersionStepChanges = (instanceId?: string) => { const apolloCoreClient = useApolloCoreClient(); const { objectMetadataItems } = useObjectMetadataItems(); @@ -24,7 +26,9 @@ export const useUpdateWorkflowVersionCache = () => { objectNameSingular: CoreObjectNameSingular.WorkflowVersion, }); - const updateWorkflowVersionCache = ({ + const setFlow = useSetAtomComponentState(flowComponentState, instanceId); + + const applyWorkflowVersionStepChanges = ({ workflowVersionStepChanges, workflowVersionId, }: { @@ -35,14 +39,27 @@ export const useUpdateWorkflowVersionCache = () => { return; } + const { triggerDiff, stepsDiff } = workflowVersionStepChanges; + + setFlow((currentFlow) => { + if (!isDefined(currentFlow)) { + return currentFlow; + } + + return { + workflowVersionId, + trigger: applyDiff({ trigger: currentFlow.trigger }, triggerDiff) + .trigger, + steps: applyDiff({ steps: currentFlow.steps }, stepsDiff).steps, + }; + }); + const cachedRecord = getRecordFromCache(workflowVersionId); if (!isDefined(cachedRecord)) { return; } - const { triggerDiff, stepsDiff } = workflowVersionStepChanges; - const newCachedRecord = { ...cachedRecord, steps: applyDiff({ steps: cachedRecord.steps }, stepsDiff).steps, @@ -67,5 +84,5 @@ export const useUpdateWorkflowVersionCache = () => { return newCachedRecord; }; - return { updateWorkflowVersionCache }; + return { applyWorkflowVersionStepChanges }; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionEdge.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionEdge.ts index 656499430a..0ff125cca6 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionEdge.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionEdge.ts @@ -7,12 +7,13 @@ import { type CreateWorkflowVersionEdgeInput, } from '~/generated/graphql'; import { CREATE_WORKFLOW_VERSION_EDGE } from '@/workflow/graphql/mutations/createWorkflowVersionEdge'; -import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache'; +import { useApplyWorkflowVersionStepChanges } from '@/workflow/workflow-steps/hooks/useApplyWorkflowVersionStepChanges'; export const useCreateWorkflowVersionEdge = () => { const apolloCoreClient = useApolloCoreClient(); - const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache(); + const { applyWorkflowVersionStepChanges } = + useApplyWorkflowVersionStepChanges(); const { enqueueErrorSnackBar } = useSnackBar(); const [mutate] = useMutation< @@ -32,7 +33,7 @@ export const useCreateWorkflowVersionEdge = () => { const workflowVersionStepChanges = result?.data?.createWorkflowVersionEdge; - updateWorkflowVersionCache({ + applyWorkflowVersionStepChanges({ workflowVersionStepChanges, workflowVersionId: input.workflowVersionId, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts index 134302452f..a21d2359c1 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep.ts @@ -7,17 +7,14 @@ import { type CreateWorkflowVersionStepMutation, type CreateWorkflowVersionStepMutationVariables, } from '~/generated/graphql'; -import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache'; -import { flowComponentState } from '@/workflow/states/flowComponentState'; -import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; -import { isDefined } from 'twenty-shared/utils'; +import { useApplyWorkflowVersionStepChanges } from '@/workflow/workflow-steps/hooks/useApplyWorkflowVersionStepChanges'; export const useCreateWorkflowVersionStep = () => { const apolloCoreClient = useApolloCoreClient(); - const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache(); + const { applyWorkflowVersionStepChanges } = + useApplyWorkflowVersionStepChanges(); - const setFlow = useSetAtomComponentState(flowComponentState); const { enqueueErrorSnackBar } = useSnackBar(); const [mutate] = useMutation< @@ -39,19 +36,11 @@ export const useCreateWorkflowVersionStep = () => { const workflowVersionStepChanges = result?.data?.createWorkflowVersionStep; - const updatedWorkflowVersion = updateWorkflowVersionCache({ + applyWorkflowVersionStepChanges({ workflowVersionStepChanges, workflowVersionId: input.workflowVersionId, }); - if (isDefined(updatedWorkflowVersion)) { - setFlow({ - workflowVersionId: updatedWorkflowVersion.id, - trigger: updatedWorkflowVersion.trigger, - steps: updatedWorkflowVersion.steps, - }); - } - return result; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionEdge.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionEdge.ts index 6e2d88069b..9316716441 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionEdge.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionEdge.ts @@ -6,12 +6,13 @@ import { type DeleteWorkflowVersionEdgeMutationVariables, } from '~/generated/graphql'; import { DELETE_WORKFLOW_VERSION_EDGE } from '@/workflow/graphql/mutations/deleteWorkflowVersionEdge'; -import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache'; +import { useApplyWorkflowVersionStepChanges } from '@/workflow/workflow-steps/hooks/useApplyWorkflowVersionStepChanges'; export const useDeleteWorkflowVersionEdge = () => { const apolloCoreClient = useApolloCoreClient(); - const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache(); + const { applyWorkflowVersionStepChanges } = + useApplyWorkflowVersionStepChanges(); const [mutate] = useMutation< DeleteWorkflowVersionEdgeMutation, @@ -25,7 +26,7 @@ export const useDeleteWorkflowVersionEdge = () => { const workflowVersionStepChanges = result?.data?.deleteWorkflowVersionEdge; - updateWorkflowVersionCache({ + applyWorkflowVersionStepChanges({ workflowVersionStepChanges, workflowVersionId: input.workflowVersionId, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep.ts index fa2dd27209..73b2bcf317 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDeleteWorkflowVersionStep.ts @@ -2,7 +2,7 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient import { CoreObjectNameSingular } from 'twenty-shared/types'; import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQuery'; import { DELETE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/deleteWorkflowVersionStep'; -import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache'; +import { useApplyWorkflowVersionStepChanges } from '@/workflow/workflow-steps/hooks/useApplyWorkflowVersionStepChanges'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useMutation } from '@apollo/client/react'; import { @@ -14,7 +14,8 @@ import { export const useDeleteWorkflowVersionStep = () => { const apolloCoreClient = useApolloCoreClient(); - const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache(); + const { applyWorkflowVersionStepChanges } = + useApplyWorkflowVersionStepChanges(); const { enqueueErrorSnackBar } = useSnackBar(); const { findOneRecordQuery: findOneWorkflowVersionQuery } = @@ -48,7 +49,7 @@ export const useDeleteWorkflowVersionStep = () => { const workflowVersionStepChanges = result?.data?.deleteWorkflowVersionStep; - updateWorkflowVersionCache({ + applyWorkflowVersionStepChanges({ workflowVersionStepChanges, workflowVersionId: input.workflowVersionId, }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep.ts index efa64f4e67..73a0539421 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useDuplicateWorkflowVersionStep.ts @@ -1,11 +1,8 @@ import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; -import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; import { DUPLICATE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/duplicateWorkflowVersionStep'; -import { flowComponentState } from '@/workflow/states/flowComponentState'; -import { useUpdateWorkflowVersionCache } from '@/workflow/workflow-steps/hooks/useUpdateWorkflowVersionCache'; +import { useApplyWorkflowVersionStepChanges } from '@/workflow/workflow-steps/hooks/useApplyWorkflowVersionStepChanges'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useMutation } from '@apollo/client/react'; -import { isDefined } from 'twenty-shared/utils'; import { type DuplicateWorkflowVersionStepInput, type DuplicateWorkflowVersionStepMutation, @@ -15,9 +12,9 @@ import { export const useDuplicateWorkflowVersionStep = () => { const apolloCoreClient = useApolloCoreClient(); - const { updateWorkflowVersionCache } = useUpdateWorkflowVersionCache(); + const { applyWorkflowVersionStepChanges } = + useApplyWorkflowVersionStepChanges(); - const setFlow = useSetAtomComponentState(flowComponentState); const { enqueueErrorSnackBar } = useSnackBar(); const [mutate] = useMutation< @@ -40,19 +37,11 @@ export const useDuplicateWorkflowVersionStep = () => { const workflowVersionStepChanges = result?.data?.duplicateWorkflowVersionStep; - const updatedWorkflowVersion = updateWorkflowVersionCache({ + applyWorkflowVersionStepChanges({ workflowVersionStepChanges, workflowVersionId: input.workflowVersionId, }); - if (isDefined(updatedWorkflowVersion)) { - setFlow({ - workflowVersionId: updatedWorkflowVersion.id, - trigger: updatedWorkflowVersion.trigger, - steps: updatedWorkflowVersion.steps, - }); - } - return result; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep.ts index 6adeaf3716..0b338cb6a4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useUpdateWorkflowVersionStep.ts @@ -5,6 +5,8 @@ import { CoreObjectNameSingular } from 'twenty-shared/types'; import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; +import { flowComponentState } from '@/workflow/states/flowComponentState'; import { UPDATE_WORKFLOW_VERSION_STEP } from '@/workflow/graphql/mutations/updateWorkflowVersionStep'; import { type WorkflowVersion, @@ -20,12 +22,13 @@ import { type UpdateWorkflowVersionStepMutationVariables, } from '~/generated/graphql'; -export const useUpdateWorkflowVersionStep = () => { +export const useUpdateWorkflowVersionStep = (instanceId?: string) => { const apolloCoreClient = useApolloCoreClient(); const { objectMetadataItems } = useObjectMetadataItems(); const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); const { enqueueErrorSnackBar } = useSnackBar(); const { markStepForRecomputation } = useStepsOutputSchema(); + const setFlow = useSetAtomComponentState(flowComponentState, instanceId); const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular: CoreObjectNameSingular.WorkflowVersion, @@ -59,6 +62,20 @@ export const useUpdateWorkflowVersionStep = () => { workflowVersionId: input.workflowVersionId, }); + setFlow((currentFlow) => { + if (!isDefined(currentFlow)) { + return currentFlow; + } + + return { + ...currentFlow, + workflowVersionId: input.workflowVersionId, + steps: (currentFlow.steps ?? []).map((step) => + step.id === updatedStep.id ? updatedStep : step, + ), + }; + }); + const cachedRecord = getRecordFromCache( input.workflowVersionId, ); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/__tests__/useUpdateWorkflowVersionTrigger.test.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/__tests__/useUpdateWorkflowVersionTrigger.test.ts index 03d055d86f..85d367b2bf 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/__tests__/useUpdateWorkflowVersionTrigger.test.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/__tests__/useUpdateWorkflowVersionTrigger.test.ts @@ -1,3 +1,5 @@ +import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; +import { createElement, type ReactNode } from 'react'; import { type WorkflowTrigger } from '@/workflow/types/Workflow'; import { useUpdateWorkflowVersionTrigger } from '@/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger'; import { act, renderHook } from '@testing-library/react'; @@ -53,6 +55,13 @@ jest.mock('@apollo/client/react', () => ({ useMutation: () => [mockMutate], })); +const Wrapper = ({ children }: { children: ReactNode }) => + createElement( + WorkflowVisualizerComponentInstanceContext.Provider, + { value: { instanceId: 'workflow-visualizer-test' } }, + children, + ); + describe('useUpdateWorkflowVersionTrigger', () => { const trigger: WorkflowTrigger = { name: 'Company created', @@ -75,7 +84,9 @@ describe('useUpdateWorkflowVersionTrigger', () => { it('updates the trigger via the dedicated mutation and marks it for recomputation', async () => { mockGetUpdatableWorkflowVersion.mockResolvedValue('version-id'); - const { result } = renderHook(() => useUpdateWorkflowVersionTrigger()); + const { result } = renderHook(() => useUpdateWorkflowVersionTrigger(), { + wrapper: Wrapper, + }); await act(async () => { await result.current.updateTrigger(trigger); @@ -114,7 +125,9 @@ describe('useUpdateWorkflowVersionTrigger', () => { nextStepIds: [], } as unknown as WorkflowTrigger; - const { result } = renderHook(() => useUpdateWorkflowVersionTrigger()); + const { result } = renderHook(() => useUpdateWorkflowVersionTrigger(), { + wrapper: Wrapper, + }); await act(async () => { await result.current.updateTrigger(testTrigger); diff --git a/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger.ts b/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger.ts index be3ae856ed..fb35a88dc6 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-trigger/hooks/useUpdateWorkflowVersionTrigger.ts @@ -4,6 +4,8 @@ import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadat import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; +import { flowComponentState } from '@/workflow/states/flowComponentState'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { UPDATE_WORKFLOW_VERSION_TRIGGER } from '@/workflow/graphql/mutations/updateWorkflowVersionTrigger'; import { useGetUpdatableWorkflowVersionOrThrow } from '@/workflow/hooks/useGetUpdatableWorkflowVersionOrThrow'; @@ -39,6 +41,8 @@ export const useUpdateWorkflowVersionTrigger = (instanceId?: string) => { objectNameSingular: CoreObjectNameSingular.WorkflowVersion, }); + const setFlow = useSetAtomComponentState(flowComponentState, instanceId); + const [mutate] = useMutation< UpdateWorkflowVersionTriggerMutation, UpdateWorkflowVersionTriggerMutationVariables @@ -70,6 +74,14 @@ export const useUpdateWorkflowVersionTrigger = (instanceId?: string) => { workflowVersionId, }); + setFlow((currentFlow) => { + if (!isDefined(currentFlow)) { + return currentFlow; + } + + return { ...currentFlow, workflowVersionId, trigger: updatedTrigger }; + }); + const cachedRecord = getRecordFromCache(workflowVersionId); if (!isDefined(cachedRecord)) { return; diff --git a/packages/twenty-front/src/modules/workflow/workflow-version/graphql/queries/getWorkflowVersionContent.ts b/packages/twenty-front/src/modules/workflow/workflow-version/graphql/queries/getWorkflowVersionContent.ts new file mode 100644 index 0000000000..952f258f7c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-version/graphql/queries/getWorkflowVersionContent.ts @@ -0,0 +1,11 @@ +import { gql } from '@apollo/client'; + +export const GET_WORKFLOW_VERSION_CONTENT = gql` + query GetWorkflowVersionContent($workflowVersionId: UUID!) { + workflowVersionContent(workflowVersionId: $workflowVersionId) { + workflowVersionId + trigger + steps + } + } +`; diff --git a/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useTidyUpWorkflowVersion.ts b/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useTidyUpWorkflowVersion.ts index 9973734c5f..45a191ac50 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useTidyUpWorkflowVersion.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useTidyUpWorkflowVersion.ts @@ -14,10 +14,12 @@ import { CoreObjectNameSingular } from 'twenty-shared/types'; import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState'; +import { flowComponentState } from '@/workflow/states/flowComponentState'; import { getOrganizedDiagram } from '@/workflow/workflow-diagram/utils/getOrganizedDiagram'; import { UPDATE_WORKFLOW_VERSION_POSITIONS } from '@/workflow/workflow-version/graphql/mutations/updateWorkflowVersionPositions'; -export const useTidyUpWorkflowVersion = () => { +export const useTidyUpWorkflowVersion = (instanceId?: string) => { const apolloCoreClient = useApolloCoreClient(); const { objectMetadataItems } = useObjectMetadataItems(); @@ -30,6 +32,8 @@ export const useTidyUpWorkflowVersion = () => { objectNameSingular: CoreObjectNameSingular.WorkflowVersion, }); + const setFlow = useSetAtomComponentState(flowComponentState, instanceId); + const [mutate] = useMutation< UpdateWorkflowVersionPositionsMutation, UpdateWorkflowVersionPositionsMutationVariables @@ -41,6 +45,38 @@ export const useTidyUpWorkflowVersion = () => { ) => { await mutate({ variables: { input: { workflowVersionId, positions } } }); + setFlow((currentFlow) => { + if (!isDefined(currentFlow)) { + return currentFlow; + } + + const triggerPositionInFlow = positions.find( + (position) => position.id === 'trigger', + ); + + return { + ...currentFlow, + workflowVersionId, + trigger: + isDefined(triggerPositionInFlow) && isDefined(currentFlow.trigger) + ? { + ...currentFlow.trigger, + position: triggerPositionInFlow.position, + } + : currentFlow.trigger, + steps: + currentFlow.steps?.map((step) => { + const stepPosition = positions.find( + (position) => position.id === step.id, + ); + + return isDefined(stepPosition) + ? { ...step, position: stepPosition.position } + : step; + }) ?? null, + }; + }); + const cachedRecord = getRecordFromCache(workflowVersionId); if (!isDefined(cachedRecord)) { diff --git a/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useWorkflowVersionContent.ts b/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useWorkflowVersionContent.ts new file mode 100644 index 0000000000..050c676f9b --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-version/hooks/useWorkflowVersionContent.ts @@ -0,0 +1,71 @@ +import { useQuery } from '@apollo/client/react'; +import { CoreObjectNameSingular } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; +import { type WorkflowVersion } from '@/workflow/types/Workflow'; +import { GET_WORKFLOW_VERSION_CONTENT } from '@/workflow/workflow-version/graphql/queries/getWorkflowVersionContent'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { FeatureFlagKey } from '~/generated-metadata/graphql'; + +export type WorkflowVersionContent = { + workflowVersionId: string; + trigger: WorkflowVersion['trigger']; + steps: WorkflowVersion['steps']; +}; + +export const useWorkflowVersionContent = (workflowVersionId?: string) => { + const apolloCoreClient = useApolloCoreClient(); + const isWorkflowVersionInCoreEnabled = useIsFeatureEnabled( + FeatureFlagKey.IS_WORKFLOW_VERSION_IN_CORE_ENABLED, + ); + + const { + data: coreData, + loading: coreLoading, + refetch: refetchCore, + } = useQuery<{ + workflowVersionContent: WorkflowVersionContent; + }>(GET_WORKFLOW_VERSION_CONTENT, { + client: apolloCoreClient, + variables: { workflowVersionId }, + skip: !isWorkflowVersionInCoreEnabled || !isDefined(workflowVersionId), + }); + + const { + record: workflowVersionRecord, + loading: recordLoading, + refetch: refetchRecord, + } = useFindOneRecord({ + objectNameSingular: CoreObjectNameSingular.WorkflowVersion, + objectRecordId: workflowVersionId, + recordGqlFields: { + id: true, + trigger: true, + steps: true, + }, + skip: isWorkflowVersionInCoreEnabled || !isDefined(workflowVersionId), + }); + + const content: WorkflowVersionContent | undefined = + isWorkflowVersionInCoreEnabled + ? coreData?.workflowVersionContent + : isDefined(workflowVersionRecord) + ? { + workflowVersionId: workflowVersionRecord.id, + trigger: workflowVersionRecord.trigger, + steps: workflowVersionRecord.steps, + } + : undefined; + + const refetchContent = isWorkflowVersionInCoreEnabled + ? refetchCore + : refetchRecord; + + return { + content, + loading: isWorkflowVersionInCoreEnabled ? coreLoading : recordLoading, + refetchContent, + }; +}; diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-version-content.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-version-content.dto.ts new file mode 100644 index 0000000000..9cd2b32ca9 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/workflow-version-content.dto.ts @@ -0,0 +1,19 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +import GraphQLJSON from 'graphql-type-json'; + +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; +import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; +import { WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; + +@ObjectType('WorkflowVersionContent') +export class WorkflowVersionContentDTO { + @Field(() => UUIDScalarType) + workflowVersionId: string; + + @Field(() => GraphQLJSON, { nullable: true }) + trigger: WorkflowTrigger | null; + + @Field(() => GraphQLJSON, { nullable: true }) + steps: WorkflowAction[] | null; +} diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts index 256faa835e..1df26c4b75 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-version.resolver.ts @@ -1,14 +1,16 @@ import { UseFilters, UseGuards, UsePipes } from '@nestjs/common'; -import { Args, Mutation } from '@nestjs/graphql'; +import { Args, Mutation, Query } from '@nestjs/graphql'; import { PermissionFlagType } from 'twenty-shared/constants'; import { CoreResolver } from 'src/engine/api/graphql/graphql-config/decorators/core-resolver.decorator'; +import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { PreventNestToAutoLogGraphqlErrorsFilter } from 'src/engine/core-modules/graphql/filters/prevent-nest-to-auto-log-graphql-errors.filter'; import { ResolverValidationPipe } from 'src/engine/core-modules/graphql/pipes/resolver-validation.pipe'; import { CreateDraftFromWorkflowVersionInput } from 'src/engine/core-modules/workflow/dtos/create-draft-from-workflow-version.input'; import { DuplicateWorkflowInput } from 'src/engine/core-modules/workflow/dtos/duplicate-workflow.input'; import { UpdateWorkflowVersionPositionsInput } from 'src/engine/core-modules/workflow/dtos/update-workflow-version-positions.input'; +import { WorkflowVersionContentDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version-content.dto'; import { WorkflowVersionDTO } from 'src/engine/core-modules/workflow/dtos/workflow-version.dto'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; @@ -16,6 +18,7 @@ import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.g import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service'; @CoreResolver() @@ -32,8 +35,28 @@ import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-b export class WorkflowVersionResolver { constructor( private readonly workflowVersionWorkspaceService: WorkflowVersionWorkspaceService, + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, ) {} + @Query(() => WorkflowVersionContentDTO) + async workflowVersionContent( + @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, + @Args('workflowVersionId', { type: () => UUIDScalarType }) + workflowVersionId: string, + ): Promise { + const workflowVersion = + await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({ + workspaceId, + workflowVersionId, + }); + + return { + workflowVersionId, + trigger: workflowVersion.trigger, + steps: workflowVersion.steps, + }; + } + @Mutation(() => WorkflowVersionDTO) async createDraftFromWorkflowVersion( @AuthWorkspace() { id: workspaceId }: WorkspaceEntity, diff --git a/packages/twenty-server/test/integration/graphql/suites/workflow/workflow-version-content.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/workflow/workflow-version-content.integration-spec.ts new file mode 100644 index 0000000000..3f0b5e4f8f --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/workflow/workflow-version-content.integration-spec.ts @@ -0,0 +1,97 @@ +import request from 'supertest'; +import { updateWorkflowVersionTrigger } from 'test/integration/graphql/suites/workflow/utils/update-workflow-version-trigger.util'; + +const client = request(`http://localhost:${APP_PORT}`); + +const graphql = (query: string, variables?: object) => + client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ query, variables }); + +describe('workflowVersionContent (e2e)', () => { + let workflowId: string; + let workflowVersionId: string; + + beforeAll(async () => { + const createResponse = await graphql(` + mutation { + createWorkflow(data: { name: "Version Content Query" }) { + id + } + } + `); + + expect(createResponse.body.errors).toBeUndefined(); + workflowId = createResponse.body.data.createWorkflow.id; + + const getResponse = await graphql( + ` + query GetWorkflow($id: UUID!) { + workflow(filter: { id: { eq: $id } }) { + versions { + edges { + node { + id + } + } + } + } + } + `, + { id: workflowId }, + ); + + workflowVersionId = + getResponse.body.data.workflow.versions.edges[0].node.id; + + await updateWorkflowVersionTrigger({ + workflowVersionId, + trigger: { + name: 'Content Query Trigger', + type: 'MANUAL', + settings: { outputSchema: {} }, + nextStepIds: [], + position: { x: 0, y: 0 }, + }, + }); + }); + + afterAll(async () => { + if (workflowId) { + await graphql( + ` + mutation DestroyWorkflow($id: ID!) { + destroyWorkflow(id: $id) { + id + } + } + `, + { id: workflowId }, + ); + } + }); + + it('returns the version trigger and steps', async () => { + const contentResponse = await graphql( + ` + query GetWorkflowVersionContent($workflowVersionId: UUID!) { + workflowVersionContent(workflowVersionId: $workflowVersionId) { + workflowVersionId + trigger + steps + } + } + `, + { workflowVersionId }, + ); + + expect(contentResponse.body.errors).toBeUndefined(); + + const content = contentResponse.body.data.workflowVersionContent; + + expect(content.workflowVersionId).toBe(workflowVersionId); + expect(content.trigger.name).toBe('Content Query Trigger'); + expect(content.trigger.type).toBe('MANUAL'); + }); +});