Use optimistic rendering when executing a workflow with manual trigger (#12695)

This PR adds optimistic rendering at two places:

- In the `runWorkflowVersion`, to create a workflow run entry as fast as
possible in the cache and render it immediately in the side panel.
- In the `ListenUpdatesEffect`, to be sure the cache is properly set; we
also need to set the record in the record store that's used in the
fields card.


## Before


https://github.com/user-attachments/assets/8b360ea9-c292-4e05-82a0-d2f12176bb6f

## After


https://github.com/user-attachments/assets/2d11023c-2ceb-4fa3-a951-187b9a0b5743

### With a slowed-down network


https://github.com/user-attachments/assets/7d2a592a-1ea7-455b-856f-bf3d9d905061

## Follow up

I will create next a PR to ensure the viewport is always set when we
know the dimensions of the nodes.
This commit is contained in:
Baptiste Devessier
2025-06-19 14:09:47 +02:00
committed by GitHub
parent a6b8830b91
commit dae282ca0f
13 changed files with 261 additions and 46 deletions

View File

@ -1786,6 +1786,8 @@ export type Role = {
export type RunWorkflowVersionInput = {
/** Execution result in JSON format */
payload?: InputMaybe<Scalars['JSON']>;
/** Workflow run ID */
workflowRunId?: InputMaybe<Scalars['String']>;
/** Workflow version ID */
workflowVersionId: Scalars['String'];
};

View File

@ -64,6 +64,7 @@ export const useRunWorkflowRecordActions = ({
}
await runWorkflowVersion({
workflowId: activeWorkflowVersion.workflowId,
workflowVersionId: activeWorkflowVersion.id,
payload: selectedRecord,
});

View File

@ -16,6 +16,7 @@ export const TestWorkflowSingleRecordAction = () => {
runWorkflowVersion({
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
workflowId: workflowWithCurrentVersion.id,
});
};

View File

@ -52,6 +52,7 @@ export const useRunWorkflowRecordAgnosticActions = () => {
onClick={() => {
runWorkflowVersion({
workflowVersionId: activeWorkflowVersion.id,
workflowId: activeWorkflowVersion.workflowId,
});
}}
closeSidePanelOnCommandMenuListActionExecution={false}

View File

@ -0,0 +1,45 @@
import { useApolloClient } from '@apollo/client';
import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { useFindOneRecordQuery } from '@/object-record/hooks/useFindOneRecordQuery';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
export const useUpsertFindOneRecordQueryInCache = ({
objectMetadataItem,
recordGqlFields,
withSoftDeleted = false,
}: {
objectMetadataItem: ObjectMetadataItem;
recordGqlFields: Record<string, any>;
withSoftDeleted?: boolean;
}) => {
const apolloClient = useApolloClient();
const { findOneRecordQuery } = useFindOneRecordQuery({
objectNameSingular: objectMetadataItem.nameSingular,
recordGqlFields,
withSoftDeleted,
});
const upsertFindOneRecordQueryInCache = <
T extends ObjectRecord = ObjectRecord,
>({
objectRecordId,
objectRecordToOverwrite,
}: {
objectRecordId: string;
objectRecordToOverwrite: T;
}) => {
apolloClient.writeQuery({
query: findOneRecordQuery,
variables: { objectRecordId },
data: {
[objectMetadataItem.nameSingular]: objectRecordToOverwrite,
},
});
};
return {
upsertFindOneRecordQueryInCache,
};
};

View File

@ -1,6 +1,18 @@
import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { updateRecordFromCache } from '@/object-record/cache/utils/updateRecordFromCache';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useOnDbEvent } from '@/subscription/hooks/useOnDbEvent';
import { useApolloClient } from '@apollo/client';
import { capitalize, isDefined } from 'twenty-shared/utils';
import { useRecoilCallback } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { DatabaseEventAction } from '~/generated/graphql';
type ListenRecordUpdatesEffectProps = {
@ -16,28 +28,70 @@ export const ListenRecordUpdatesEffect = ({
}: ListenRecordUpdatesEffectProps) => {
const apolloClient = useApolloClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const getRecordFromCache = useGetRecordFromCache({
objectNameSingular,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const computedRecordGqlFields = generateDepthOneRecordGqlFields({
objectMetadataItem,
});
const setRecordInStore = useRecoilCallback(
({ set }) =>
(record: ObjectRecord) => {
set(recordStoreFamilyState(record.id), record);
},
[],
);
useOnDbEvent({
input: { recordId, action: DatabaseEventAction.UPDATED },
onData: (data) => {
const updatedRecord = data.onDbEvent.record;
const fieldsUpdater = listenedFields.reduce((acc, listenedField) => {
if (!isDefined(updatedRecord[listenedField])) {
return acc;
}
return {
...acc,
[listenedField]: () => updatedRecord[listenedField],
};
}, {});
apolloClient.cache.modify({
id: apolloClient.cache.identify({
__typename: capitalize(objectNameSingular),
id: recordId,
}),
fields: fieldsUpdater,
const cachedRecord = getRecordFromCache<ObjectRecord>(recordId);
const cachedRecordNode = getRecordNodeFromRecord<ObjectRecord>({
record: cachedRecord,
objectMetadataItem,
objectMetadataItems,
computeReferences: false,
});
const shouldHandleOptimisticCache =
isDefined(cachedRecordNode) && isDefined(cachedRecord);
if (shouldHandleOptimisticCache) {
const computedOptimisticRecord: ObjectRecord = {
...cachedRecord,
...updatedRecord,
id: recordId,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
};
updateRecordFromCache({
objectMetadataItems,
objectMetadataItem,
cache: apolloClient.cache,
record: computedOptimisticRecord,
recordGqlFields: computedRecordGqlFields,
objectPermissionsByObjectMetadataId,
});
triggerUpdateRecordOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem,
currentRecord: cachedRecordNode,
updatedRecord: updatedRecord,
objectMetadataItems,
});
setRecordInStore(computedOptimisticRecord);
}
},
skip: listenedFields.length === 0,
});

View File

@ -49,9 +49,7 @@ export const useRunWorkflowRunOpeningInCommandMenuSideEffects = () => {
if (
!(isDefined(workflowRunRecord) && isDefined(workflowRunRecord.output))
) {
throw new Error(
`No workflow run record found for record ID ${recordId}`,
);
return;
}
const { stepToOpenByDefault } = generateWorkflowRunDiagram({

View File

@ -1,9 +1,24 @@
import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { useLazyFindOneRecord } from '@/object-record/hooks/useLazyFindOneRecord';
import { useCreateOneRecordInCache } from '@/object-record/cache/hooks/useCreateOneRecordInCache';
import { useUpsertFindOneRecordQueryInCache } from '@/object-record/cache/hooks/useUpsertFindOneRecordQueryInCache';
import { getObjectTypename } from '@/object-record/cache/utils/getObjectTypename';
import { getRecordNodeFromRecord } from '@/object-record/cache/utils/getRecordNodeFromRecord';
import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
import { computeOptimisticCreateRecordBaseRecordInput } from '@/object-record/utils/computeOptimisticCreateRecordBaseRecordInput';
import { computeOptimisticRecordFromInput } from '@/object-record/utils/computeOptimisticRecordFromInput';
import { RUN_WORKFLOW_VERSION } from '@/workflow/graphql/mutations/runWorkflowVersion';
import { WorkflowRun } from '@/workflow/types/Workflow';
import { useApolloClient, useMutation } from '@apollo/client';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import {
RunWorkflowVersionMutation,
RunWorkflowVersionMutationVariables,
@ -11,6 +26,18 @@ import {
export const useRunWorkflowVersion = () => {
const apolloClient = useApolloClient();
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
});
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const createOneRecordInCache = useCreateOneRecordInCache<WorkflowRun>({
objectMetadataItem,
});
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { objectMetadataItems } = useObjectMetadataItems();
const [mutate] = useMutation<
RunWorkflowVersionMutation,
RunWorkflowVersionMutationVariables
@ -18,34 +45,100 @@ export const useRunWorkflowVersion = () => {
client: apolloClient,
});
const { findOneRecord: findOneWorkflowRun } =
useLazyFindOneRecord<WorkflowRun>({
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
const computedRecordGqlFields = generateDepthOneRecordGqlFields({
objectMetadataItem,
});
const { upsertFindOneRecordQueryInCache } =
useUpsertFindOneRecordQueryInCache({
objectMetadataItem,
recordGqlFields: computedRecordGqlFields,
});
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
const setRecordInStore = useRecoilCallback(
({ set }) =>
(workflowRun: WorkflowRun) => {
set(recordStoreFamilyState(workflowRun.id), workflowRun);
},
[],
);
const runWorkflowVersion = async ({
workflowId,
workflowVersionId,
payload,
}: {
workflowId: string;
workflowVersionId: string;
payload?: Record<string, any>;
}) => {
const { data } = await mutate({
variables: { input: { workflowVersionId, payload } },
const workflowRunId = v4();
const recordInput: Partial<WorkflowRun> = {
name: '#0',
status: 'NOT_STARTED',
output: null,
context: null,
workflowVersionId,
workflowId,
createdAt: new Date().toISOString(),
};
const optimisticRecordInput = computeOptimisticRecordFromInput({
cache: apolloClient.cache,
currentWorkspaceMember: currentWorkspaceMember,
objectMetadataItem,
objectMetadataItems,
recordInput: {
...computeOptimisticCreateRecordBaseRecordInput(objectMetadataItem),
...recordInput,
id: workflowRunId,
},
objectPermissionsByObjectMetadataId,
});
const workflowRunId = data?.runWorkflowVersion?.workflowRunId;
const recordCreatedInCache = createOneRecordInCache({
...optimisticRecordInput,
id: workflowRunId,
__typename: getObjectTypename(objectMetadataItem.nameSingular),
});
await findOneWorkflowRun({
const recordNodeCreatedInCache = getRecordNodeFromRecord({
objectMetadataItem,
objectMetadataItems,
record: recordCreatedInCache,
computeReferences: false,
});
if (!isDefined(recordNodeCreatedInCache)) {
throw new Error('The record should have been created in cache');
}
upsertFindOneRecordQueryInCache({
objectRecordToOverwrite: recordCreatedInCache,
objectRecordId: workflowRunId,
onCompleted: (workflowRun) => {
openRecordInCommandMenu({
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
recordId: workflowRun.id,
});
},
});
triggerCreateRecordsOptimisticEffect({
cache: apolloClient.cache,
objectMetadataItem,
recordsToCreate: [recordNodeCreatedInCache],
objectMetadataItems,
shouldMatchRootQueryFilter: true,
objectPermissionsByObjectMetadataId,
});
setRecordInStore(recordCreatedInCache);
openRecordInCommandMenu({
objectNameSingular: CoreObjectNameSingular.WorkflowRun,
recordId: workflowRunId,
});
await mutate({
variables: { input: { workflowVersionId, workflowRunId, payload } },
});
};

View File

@ -10,6 +10,12 @@ export class RunWorkflowVersionInput {
})
workflowVersionId: string;
@Field(() => String, {
description: 'Workflow run ID',
nullable: true,
})
workflowRunId?: string | null;
@Field(() => graphqlTypeJson, {
description: 'Execution result in JSON format',
nullable: true,

View File

@ -56,7 +56,8 @@ export class WorkflowTriggerResolver {
async runWorkflowVersion(
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Args('input') { workflowVersionId, payload }: RunWorkflowVersionInput,
@Args('input')
{ workflowVersionId, workflowRunId, payload }: RunWorkflowVersionInput,
) {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
@ -72,6 +73,7 @@ export class WorkflowTriggerResolver {
return await this.workflowTriggerWorkspaceService.runWorkflowVersion({
workflowVersionId,
workflowRunId: workflowRunId ?? undefined,
payload: payload ?? {},
createdBy: buildCreatedByFromFullNameMetadata({
fullNameMetadata: {

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { v4 } from 'uuid';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { objectRecordChangedValues } from 'src/engine/core-modules/event-emitter/utils/object-record-changed-values';
@ -39,9 +40,11 @@ export class WorkflowRunWorkspaceService {
async createWorkflowRun({
workflowVersionId,
createdBy,
workflowRunId,
}: {
workflowVersionId: string;
createdBy: ActorMetadata;
workflowRunId?: string;
}) {
const workspaceId =
this.scopedWorkspaceContextFactory.create()?.workspaceId;
@ -54,7 +57,7 @@ export class WorkflowRunWorkspaceService {
}
const workflowRunRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkflowRunWorkspaceEntity>(
workspaceId,
'workflowRun',
{ shouldBypassPermissionChecks: true },
@ -101,16 +104,19 @@ export class WorkflowRunWorkspaceService {
workspaceId,
});
return (
await workflowRunRepository.save({
name: `#${workflowRunCount + 1} - ${workflow.name}`,
workflowVersionId,
createdBy,
workflowId: workflow.id,
status: WorkflowRunStatus.NOT_STARTED,
position,
})
).id;
const workflowRun = workflowRunRepository.create({
id: workflowRunId ?? v4(),
name: `#${workflowRunCount + 1} - ${workflow.name}`,
workflowVersionId,
createdBy,
workflowId: workflow.id,
status: WorkflowRunStatus.NOT_STARTED,
position,
});
await workflowRunRepository.insert(workflowRun);
return workflowRun.id;
}
async startWorkflowRun({

View File

@ -26,11 +26,13 @@ export class WorkflowRunnerWorkspaceService {
workflowVersionId,
payload,
source,
workflowRunId: initialWorkflowRunId,
}: {
workspaceId: string;
workflowVersionId: string;
payload: object;
source: ActorMetadata;
workflowRunId?: string;
}) {
const canFeatureBeUsed =
await this.billingUsageService.canFeatureBeUsed(workspaceId);
@ -43,6 +45,7 @@ export class WorkflowRunnerWorkspaceService {
const workflowRunId =
await this.workflowRunWorkspaceService.createWorkflowRun({
workflowVersionId,
workflowRunId: initialWorkflowRunId,
createdBy: source,
});

View File

@ -63,10 +63,12 @@ export class WorkflowTriggerWorkspaceService {
workflowVersionId,
payload,
createdBy,
workflowRunId,
}: {
workflowVersionId: string;
payload: object;
createdBy: ActorMetadata;
workflowRunId?: string;
}) {
await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({
workflowVersionId,
@ -75,6 +77,7 @@ export class WorkflowTriggerWorkspaceService {
return this.workflowRunnerWorkspaceService.run({
workspaceId: this.getWorkspaceId(),
workflowRunId,
workflowVersionId,
payload,
source: createdBy,