diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 2a51cd34a..490f67c86 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1786,6 +1786,8 @@ export type Role = { export type RunWorkflowVersionInput = { /** Execution result in JSON format */ payload?: InputMaybe; + /** Workflow run ID */ + workflowRunId?: InputMaybe; /** Workflow version ID */ workflowVersionId: Scalars['String']; }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/run-workflow-actions/hooks/useRunWorkflowRecordActions.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/run-workflow-actions/hooks/useRunWorkflowRecordActions.tsx index 8b0ddcbd5..c3f06e294 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/run-workflow-actions/hooks/useRunWorkflowRecordActions.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/run-workflow-actions/hooks/useRunWorkflowRecordActions.tsx @@ -64,6 +64,7 @@ export const useRunWorkflowRecordActions = ({ } await runWorkflowVersion({ + workflowId: activeWorkflowVersion.workflowId, workflowVersionId: activeWorkflowVersion.id, payload: selectedRecord, }); diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/components/TestWorkflowSingleRecordAction.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/components/TestWorkflowSingleRecordAction.tsx index 31e059ee6..324a9aa8c 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/components/TestWorkflowSingleRecordAction.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/single-record/workflow-actions/components/TestWorkflowSingleRecordAction.tsx @@ -16,6 +16,7 @@ export const TestWorkflowSingleRecordAction = () => { runWorkflowVersion({ workflowVersionId: workflowWithCurrentVersion.currentVersion.id, + workflowId: workflowWithCurrentVersion.id, }); }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/run-workflow-actions/hooks/useRunWorkflowRecordAgnosticActions.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/run-workflow-actions/hooks/useRunWorkflowRecordAgnosticActions.tsx index cd9486417..f5debd10e 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/run-workflow-actions/hooks/useRunWorkflowRecordAgnosticActions.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-agnostic-actions/run-workflow-actions/hooks/useRunWorkflowRecordAgnosticActions.tsx @@ -52,6 +52,7 @@ export const useRunWorkflowRecordAgnosticActions = () => { onClick={() => { runWorkflowVersion({ workflowVersionId: activeWorkflowVersion.id, + workflowId: activeWorkflowVersion.workflowId, }); }} closeSidePanelOnCommandMenuListActionExecution={false} diff --git a/packages/twenty-front/src/modules/object-record/cache/hooks/useUpsertFindOneRecordQueryInCache.ts b/packages/twenty-front/src/modules/object-record/cache/hooks/useUpsertFindOneRecordQueryInCache.ts new file mode 100644 index 000000000..5a8d28b32 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/cache/hooks/useUpsertFindOneRecordQueryInCache.ts @@ -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; + 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, + }; +}; diff --git a/packages/twenty-front/src/modules/subscription/components/ListenUpdatesEffect.tsx b/packages/twenty-front/src/modules/subscription/components/ListenUpdatesEffect.tsx index 2d70e8c4e..c125541b5 100644 --- a/packages/twenty-front/src/modules/subscription/components/ListenUpdatesEffect.tsx +++ b/packages/twenty-front/src/modules/subscription/components/ListenUpdatesEffect.tsx @@ -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(recordId); + const cachedRecordNode = getRecordNodeFromRecord({ + 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, }); diff --git a/packages/twenty-front/src/modules/workflow/hooks/useRunWorkflowRunOpeningInCommandMenuSideEffects.ts b/packages/twenty-front/src/modules/workflow/hooks/useRunWorkflowRunOpeningInCommandMenuSideEffects.ts index c0284c9a4..0eecd38e7 100644 --- a/packages/twenty-front/src/modules/workflow/hooks/useRunWorkflowRunOpeningInCommandMenuSideEffects.ts +++ b/packages/twenty-front/src/modules/workflow/hooks/useRunWorkflowRunOpeningInCommandMenuSideEffects.ts @@ -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({ diff --git a/packages/twenty-front/src/modules/workflow/hooks/useRunWorkflowVersion.tsx b/packages/twenty-front/src/modules/workflow/hooks/useRunWorkflowVersion.tsx index 326a92378..f0310e9f1 100644 --- a/packages/twenty-front/src/modules/workflow/hooks/useRunWorkflowVersion.tsx +++ b/packages/twenty-front/src/modules/workflow/hooks/useRunWorkflowVersion.tsx @@ -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({ + 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({ - 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; }) => { - const { data } = await mutate({ - variables: { input: { workflowVersionId, payload } }, + const workflowRunId = v4(); + + const recordInput: Partial = { + 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 } }, }); }; diff --git a/packages/twenty-server/src/engine/core-modules/workflow/dtos/run-workflow-version-input.dto.ts b/packages/twenty-server/src/engine/core-modules/workflow/dtos/run-workflow-version-input.dto.ts index a8e78b353..276fc4325 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/dtos/run-workflow-version-input.dto.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/dtos/run-workflow-version-input.dto.ts @@ -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, diff --git a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts index 50256d110..d8b69a44d 100644 --- a/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/workflow/resolvers/workflow-trigger.resolver.ts @@ -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( @@ -72,6 +73,7 @@ export class WorkflowTriggerResolver { return await this.workflowTriggerWorkspaceService.runWorkflowVersion({ workflowVersionId, + workflowRunId: workflowRunId ?? undefined, payload: payload ?? {}, createdBy: buildCreatedByFromFullNameMetadata({ fullNameMetadata: { diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service.ts index dab4205e7..9e17de248 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/workflow-run/workflow-run.workspace-service.ts @@ -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( 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({ diff --git a/packages/twenty-server/src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service.ts index 1374e4da1..3e5cd991b 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-runner/workspace-services/workflow-runner.workspace-service.ts @@ -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, }); diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts index bc66d5a05..638d3929b 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/workspace-services/workflow-trigger.workspace-service.ts @@ -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,