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:
committed by
GitHub
parent
a6b8830b91
commit
dae282ca0f
@ -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'];
|
||||
};
|
||||
|
||||
@ -64,6 +64,7 @@ export const useRunWorkflowRecordActions = ({
|
||||
}
|
||||
|
||||
await runWorkflowVersion({
|
||||
workflowId: activeWorkflowVersion.workflowId,
|
||||
workflowVersionId: activeWorkflowVersion.id,
|
||||
payload: selectedRecord,
|
||||
});
|
||||
|
||||
@ -16,6 +16,7 @@ export const TestWorkflowSingleRecordAction = () => {
|
||||
|
||||
runWorkflowVersion({
|
||||
workflowVersionId: workflowWithCurrentVersion.currentVersion.id,
|
||||
workflowId: workflowWithCurrentVersion.id,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -52,6 +52,7 @@ export const useRunWorkflowRecordAgnosticActions = () => {
|
||||
onClick={() => {
|
||||
runWorkflowVersion({
|
||||
workflowVersionId: activeWorkflowVersion.id,
|
||||
workflowId: activeWorkflowVersion.workflowId,
|
||||
});
|
||||
}}
|
||||
closeSidePanelOnCommandMenuListActionExecution={false}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 } },
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user