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 = { export type RunWorkflowVersionInput = {
/** Execution result in JSON format */ /** Execution result in JSON format */
payload?: InputMaybe<Scalars['JSON']>; payload?: InputMaybe<Scalars['JSON']>;
/** Workflow run ID */
workflowRunId?: InputMaybe<Scalars['String']>;
/** Workflow version ID */ /** Workflow version ID */
workflowVersionId: Scalars['String']; workflowVersionId: Scalars['String'];
}; };

View File

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

View File

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

View File

@ -52,6 +52,7 @@ export const useRunWorkflowRecordAgnosticActions = () => {
onClick={() => { onClick={() => {
runWorkflowVersion({ runWorkflowVersion({
workflowVersionId: activeWorkflowVersion.id, workflowVersionId: activeWorkflowVersion.id,
workflowId: activeWorkflowVersion.workflowId,
}); });
}} }}
closeSidePanelOnCommandMenuListActionExecution={false} 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 { useOnDbEvent } from '@/subscription/hooks/useOnDbEvent';
import { useApolloClient } from '@apollo/client'; 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'; import { DatabaseEventAction } from '~/generated/graphql';
type ListenRecordUpdatesEffectProps = { type ListenRecordUpdatesEffectProps = {
@ -16,28 +28,70 @@ export const ListenRecordUpdatesEffect = ({
}: ListenRecordUpdatesEffectProps) => { }: ListenRecordUpdatesEffectProps) => {
const apolloClient = useApolloClient(); 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({ useOnDbEvent({
input: { recordId, action: DatabaseEventAction.UPDATED }, input: { recordId, action: DatabaseEventAction.UPDATED },
onData: (data) => { onData: (data) => {
const updatedRecord = data.onDbEvent.record; const updatedRecord = data.onDbEvent.record;
const fieldsUpdater = listenedFields.reduce((acc, listenedField) => { const cachedRecord = getRecordFromCache<ObjectRecord>(recordId);
if (!isDefined(updatedRecord[listenedField])) { const cachedRecordNode = getRecordNodeFromRecord<ObjectRecord>({
return acc; record: cachedRecord,
} objectMetadataItem,
return { objectMetadataItems,
...acc, computeReferences: false,
[listenedField]: () => updatedRecord[listenedField],
};
}, {});
apolloClient.cache.modify({
id: apolloClient.cache.identify({
__typename: capitalize(objectNameSingular),
id: recordId,
}),
fields: fieldsUpdater,
}); });
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, skip: listenedFields.length === 0,
}); });

View File

@ -49,9 +49,7 @@ export const useRunWorkflowRunOpeningInCommandMenuSideEffects = () => {
if ( if (
!(isDefined(workflowRunRecord) && isDefined(workflowRunRecord.output)) !(isDefined(workflowRunRecord) && isDefined(workflowRunRecord.output))
) { ) {
throw new Error( return;
`No workflow run record found for record ID ${recordId}`,
);
} }
const { stepToOpenByDefault } = generateWorkflowRunDiagram({ 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 { 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 { 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 { RUN_WORKFLOW_VERSION } from '@/workflow/graphql/mutations/runWorkflowVersion';
import { WorkflowRun } from '@/workflow/types/Workflow'; import { WorkflowRun } from '@/workflow/types/Workflow';
import { useApolloClient, useMutation } from '@apollo/client'; import { useApolloClient, useMutation } from '@apollo/client';
import { useRecoilCallback, useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { import {
RunWorkflowVersionMutation, RunWorkflowVersionMutation,
RunWorkflowVersionMutationVariables, RunWorkflowVersionMutationVariables,
@ -11,6 +26,18 @@ import {
export const useRunWorkflowVersion = () => { export const useRunWorkflowVersion = () => {
const apolloClient = useApolloClient(); 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< const [mutate] = useMutation<
RunWorkflowVersionMutation, RunWorkflowVersionMutation,
RunWorkflowVersionMutationVariables RunWorkflowVersionMutationVariables
@ -18,34 +45,100 @@ export const useRunWorkflowVersion = () => {
client: apolloClient, client: apolloClient,
}); });
const { findOneRecord: findOneWorkflowRun } = const computedRecordGqlFields = generateDepthOneRecordGqlFields({
useLazyFindOneRecord<WorkflowRun>({ objectMetadataItem,
objectNameSingular: CoreObjectNameSingular.WorkflowRun, });
const { upsertFindOneRecordQueryInCache } =
useUpsertFindOneRecordQueryInCache({
objectMetadataItem,
recordGqlFields: computedRecordGqlFields,
}); });
const { openRecordInCommandMenu } = useOpenRecordInCommandMenu(); const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
const setRecordInStore = useRecoilCallback(
({ set }) =>
(workflowRun: WorkflowRun) => {
set(recordStoreFamilyState(workflowRun.id), workflowRun);
},
[],
);
const runWorkflowVersion = async ({ const runWorkflowVersion = async ({
workflowId,
workflowVersionId, workflowVersionId,
payload, payload,
}: { }: {
workflowId: string;
workflowVersionId: string; workflowVersionId: string;
payload?: Record<string, any>; payload?: Record<string, any>;
}) => { }) => {
const { data } = await mutate({ const workflowRunId = v4();
variables: { input: { workflowVersionId, payload } },
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, objectRecordId: workflowRunId,
onCompleted: (workflowRun) => { });
openRecordInCommandMenu({
objectNameSingular: CoreObjectNameSingular.WorkflowRun, triggerCreateRecordsOptimisticEffect({
recordId: workflowRun.id, 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; workflowVersionId: string;
@Field(() => String, {
description: 'Workflow run ID',
nullable: true,
})
workflowRunId?: string | null;
@Field(() => graphqlTypeJson, { @Field(() => graphqlTypeJson, {
description: 'Execution result in JSON format', description: 'Execution result in JSON format',
nullable: true, nullable: true,

View File

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

View File

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

View File

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

View File

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