diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-query-hook.module.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-query-hook.module.ts index 0bcff6d0b2..ce3c39d062 100644 --- a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-query-hook.module.ts +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-query-hook.module.ts @@ -26,7 +26,9 @@ import { WorkflowRunDeleteManyPreQueryHook } from 'src/modules/workflow/common/q import { WorkflowRunDeleteOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-run-delete-one.pre-query.hook'; import { WorkflowRunUpdateManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-run-update-many.pre-query.hook'; import { WorkflowRunUpdateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-run-update-one.pre-query.hook'; +import { WorkflowUpdateManyPostQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-update-many.post-query.hook'; import { WorkflowUpdateManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-update-many.pre-query.hook'; +import { WorkflowUpdateOnePostQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-update-one.post-query.hook'; import { WorkflowUpdateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-update-one.pre-query.hook'; import { WorkflowVersionCreateManyPreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-create-many.pre-query.hook'; import { WorkflowVersionCreateOnePreQueryHook } from 'src/modules/workflow/common/query-hooks/workflow-version-create-one.pre-query.hook'; @@ -53,6 +55,8 @@ import { WorkflowVersionValidationWorkspaceService } from 'src/modules/workflow/ WorkflowCreateManyPreQueryHook, WorkflowUpdateOnePreQueryHook, WorkflowUpdateManyPreQueryHook, + WorkflowUpdateOnePostQueryHook, + WorkflowUpdateManyPostQueryHook, WorkflowRunCreateOnePreQueryHook, WorkflowRunCreateManyPreQueryHook, WorkflowRunUpdateOnePreQueryHook, diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-update-many.post-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-update-many.post-query.hook.ts new file mode 100644 index 0000000000..d892dd3f40 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-update-many.post-query.hook.ts @@ -0,0 +1,34 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type WorkspacePostQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface'; + +import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; +import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type'; +import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; + +@WorkspaceQueryHook({ + key: `workflow.updateMany`, + type: WorkspaceQueryHookType.POST_HOOK, +}) +export class WorkflowUpdateManyPostQueryHook implements WorkspacePostQueryHookInstance { + constructor( + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, + ) {} + + async execute( + authContext: WorkspaceAuthContext, + _objectName: string, + payload: WorkflowWorkspaceEntity[], + ): Promise { + const workflowIds = payload + .map((workflow) => workflow.id) + .filter(isDefined); + + await this.workflowCommonWorkspaceService.syncCommandMenuItemLabelForWorkflows( + workflowIds, + authContext, + ); + } +} diff --git a/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-update-one.post-query.hook.ts b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-update-one.post-query.hook.ts new file mode 100644 index 0000000000..81c14cd943 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/common/query-hooks/workflow-update-one.post-query.hook.ts @@ -0,0 +1,34 @@ +import { isDefined } from 'twenty-shared/utils'; + +import { type WorkspacePostQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface'; + +import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; +import { WorkspaceQueryHookType } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type'; +import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; +import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; + +@WorkspaceQueryHook({ + key: `workflow.updateOne`, + type: WorkspaceQueryHookType.POST_HOOK, +}) +export class WorkflowUpdateOnePostQueryHook implements WorkspacePostQueryHookInstance { + constructor( + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, + ) {} + + async execute( + authContext: WorkspaceAuthContext, + _objectName: string, + payload: WorkflowWorkspaceEntity[], + ): Promise { + const workflowIds = payload + .map((workflow) => workflow.id) + .filter(isDefined); + + await this.workflowCommonWorkspaceService.syncCommandMenuItemLabelForWorkflows( + workflowIds, + authContext, + ); + } +} diff --git a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts index 50fd249879..05d57a2dfd 100644 --- a/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/common/workspace-services/workflow-common.workspace-service.ts @@ -1,7 +1,9 @@ import { Injectable, Logger } from '@nestjs/common'; import { isDefined, isValidUuid } from 'twenty-shared/utils'; +import { In } from 'typeorm'; +import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type'; import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; @@ -36,6 +38,7 @@ import { WorkflowTriggerException, WorkflowTriggerExceptionCode, } from 'src/modules/workflow/workflow-trigger/exceptions/workflow-trigger.exception'; +import { getWorkflowCommandMenuItemLabel } from 'src/modules/workflow/workflow-trigger/utils/get-workflow-command-menu-item-label.util'; export type ObjectMetadataInfo = { flatObjectMetadata: FlatObjectMetadata; @@ -104,6 +107,77 @@ export class WorkflowCommonWorkspaceService { return { ...workflowVersion, trigger: workflowVersion.trigger }; } + async syncCommandMenuItemLabelForWorkflows( + workflowIds: string[], + authContext: WorkspaceAuthContext, + ): Promise { + const workspaceId = authContext.workspace?.id; + + if (!isDefined(workspaceId) || workflowIds.length === 0) { + return; + } + + const workflows = + await this.globalWorkspaceOrmManager.executeInWorkspaceContext( + async () => { + const workflowRepository = + await this.globalWorkspaceOrmManager.getRepository( + workspaceId, + 'workflow', + { shouldBypassPermissionChecks: true }, + ); + + return workflowRepository.find({ + where: { id: In(workflowIds) }, + }); + }, + authContext, + ); + + await Promise.all( + workflows.map((workflow) => + this.syncCommandMenuItemLabelForWorkflow(workflow, workspaceId), + ), + ); + } + + private async syncCommandMenuItemLabelForWorkflow( + workflow: WorkflowWorkspaceEntity, + workspaceId: string, + ): Promise { + if (!isDefined(workflow.lastPublishedVersionId)) { + return; + } + + const existingCommandMenuItem = + await this.commandMenuItemService.findByWorkflowVersionId( + workflow.lastPublishedVersionId, + workspaceId, + ); + + if (!isDefined(existingCommandMenuItem)) { + return; + } + + const label = getWorkflowCommandMenuItemLabel(workflow); + + if ( + existingCommandMenuItem.label === label && + existingCommandMenuItem.shortLabel === label + ) { + return; + } + + await this.commandMenuItemService.update( + { + id: existingCommandMenuItem.id, + label, + shortLabel: label, + }, + workspaceId, + ); + } + async getFlatEntityMaps(workspaceId: string): Promise<{ flatObjectMetadataMaps: FlatEntityMaps; flatFieldMetadataMaps: FlatEntityMaps; diff --git a/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/get-workflow-command-menu-item-label.util.ts b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/get-workflow-command-menu-item-label.util.ts new file mode 100644 index 0000000000..aa88ea0522 --- /dev/null +++ b/packages/twenty-server/src/modules/workflow/workflow-trigger/utils/get-workflow-command-menu-item-label.util.ts @@ -0,0 +1,12 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { type WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; + +export const DEFAULT_WORKFLOW_COMMAND_MENU_ITEM_LABEL = 'Untitled Workflow'; + +export const getWorkflowCommandMenuItemLabel = ( + workflow: Pick, +): string => + isNonEmptyString(workflow.name) + ? workflow.name + : DEFAULT_WORKFLOW_COMMAND_MENU_ITEM_LABEL; 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 f91b806ce3..6203079f1d 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 @@ -1,6 +1,5 @@ import { msg } from '@lingui/core/macro'; import { Injectable, Logger } from '@nestjs/common'; -import { isNonEmptyString } from '@sniptt/guards'; import { type ActorMetadata } from 'twenty-shared/types'; import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator'; @@ -40,6 +39,7 @@ import { } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; import { assertVersionCanBeActivated } from 'src/modules/workflow/workflow-trigger/utils/assert-version-can-be-activated.util'; import { computeCronPatternFromSchedule } from 'src/modules/workflow/workflow-trigger/utils/compute-cron-pattern-from-schedule'; +import { getWorkflowCommandMenuItemLabel } from 'src/modules/workflow/workflow-trigger/utils/get-workflow-command-menu-item-label.util'; import { assertNever } from 'src/utils/assert'; @Injectable() @@ -406,9 +406,7 @@ export class WorkflowTriggerWorkspaceService { const { availabilityType, availabilityObjectMetadataId } = await this.resolveManualTriggerAvailability(trigger, workspaceId); - const label = isNonEmptyString(workflow.name) - ? workflow.name - : 'Manual Trigger'; + const label = getWorkflowCommandMenuItemLabel(workflow); const existingCommandMenuItem = await this.commandMenuItemService.findByWorkflowVersionId( diff --git a/packages/twenty-server/test/integration/graphql/suites/workflow/workflow-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/workflow/workflow-resolver.integration-spec.ts index 19bfc8c52b..dd49d54aa1 100644 --- a/packages/twenty-server/test/integration/graphql/suites/workflow/workflow-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/workflow/workflow-resolver.integration-spec.ts @@ -1,4 +1,7 @@ import request from 'supertest'; +import { findCommandMenuItems } from 'test/integration/metadata/suites/command-menu-item/utils/find-command-menu-items.util'; + +import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto'; const client = request(`http://localhost:${APP_PORT}`); @@ -213,3 +216,224 @@ describe('workflowResolver', () => { expect(workflow.versions.edges[0].node.deletedAt).toBeNull(); }); }); + +const COMMAND_MENU_ITEM_GQL_FIELDS = ` + id + workflowVersionId + engineComponentKey + label + shortLabel +`; + +const findCommandMenuItemForWorkflowVersion = async ( + workflowVersionId: string, +): Promise => { + const { data } = await findCommandMenuItems({ + input: undefined, + gqlFields: COMMAND_MENU_ITEM_GQL_FIELDS, + }); + + return data?.commandMenuItems.find( + (item) => item.workflowVersionId === workflowVersionId, + ); +}; + +describe('workflowResolver command menu item label', () => { + const initialWorkflowName = 'Command Menu Label Sync Test'; + let createdWorkflowId: string | null = null; + let createdWorkflowVersionId: string | null = null; + + const renameWorkflow = async (name: string) => { + const response = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation UpdateWorkflow($id: ID!, $name: String) { + updateWorkflow(id: $id, data: { name: $name }) { + id + name + } + } + `, + variables: { id: createdWorkflowId, name }, + }); + + expect(response.body.errors).toBeUndefined(); + }; + + beforeAll(async () => { + const createWorkflowResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation CreateWorkflow($name: String!) { + createWorkflow(data: { name: $name }) { + id + } + } + `, + variables: { name: initialWorkflowName }, + }); + + expect(createWorkflowResponse.body.errors).toBeUndefined(); + createdWorkflowId = createWorkflowResponse.body.data.createWorkflow.id; + + const getWorkflowResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + query GetWorkflow($id: UUID!) { + workflow(filter: { id: { eq: $id } }) { + id + versions { + edges { + node { + id + } + } + } + } + } + `, + variables: { id: createdWorkflowId }, + }); + + createdWorkflowVersionId = + getWorkflowResponse.body.data.workflow.versions.edges[0].node.id; + + await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation UpdateWorkflowVersion($id: UUID!, $data: WorkflowVersionUpdateInput!) { + updateWorkflowVersion(id: $id, data: $data) { + id + } + } + `, + variables: { + id: createdWorkflowVersionId, + data: { + trigger: { + name: 'Manual Trigger', + type: 'MANUAL', + settings: { outputSchema: {} }, + nextStepIds: [], + position: { x: 0, y: 0 }, + }, + }, + }, + }); + + const createStepResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation CreateWorkflowVersionStep($input: CreateWorkflowVersionStepInput!) { + createWorkflowVersionStep(input: $input) { + stepsDiff + } + } + `, + variables: { + input: { + workflowVersionId: createdWorkflowVersionId, + stepType: 'FIND_RECORDS', + parentStepId: 'trigger', + position: { x: 200, y: 0 }, + }, + }, + }); + + expect(createStepResponse.body.errors).toBeUndefined(); + + const activateResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation ActivateWorkflowVersion($workflowVersionId: UUID!) { + activateWorkflowVersion(workflowVersionId: $workflowVersionId) + } + `, + variables: { workflowVersionId: createdWorkflowVersionId }, + }); + + expect(activateResponse.body.errors).toBeUndefined(); + expect(activateResponse.body.data.activateWorkflowVersion).toBe(true); + }); + + afterAll(async () => { + if (createdWorkflowVersionId) { + await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation DeactivateWorkflowVersion($workflowVersionId: UUID!) { + deactivateWorkflowVersion(workflowVersionId: $workflowVersionId) + } + `, + variables: { workflowVersionId: createdWorkflowVersionId }, + }); + } + + if (createdWorkflowId) { + await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send({ + query: ` + mutation DestroyWorkflow($id: ID!) { + destroyWorkflow(id: $id) { + id + } + } + `, + variables: { id: createdWorkflowId }, + }); + } + }); + + it('labels the command menu item with the workflow name on activation', async () => { + const commandMenuItem = await findCommandMenuItemForWorkflowVersion( + createdWorkflowVersionId as string, + ); + + expect(commandMenuItem).toBeDefined(); + expect(commandMenuItem?.engineComponentKey).toBe( + 'TRIGGER_WORKFLOW_VERSION', + ); + expect(commandMenuItem?.label).toBe(initialWorkflowName); + expect(commandMenuItem?.shortLabel).toBe(initialWorkflowName); + }); + + it('updates the command menu item label when the workflow is renamed', async () => { + const renamedWorkflowName = 'Renamed Command Menu Workflow'; + + await renameWorkflow(renamedWorkflowName); + + const commandMenuItem = await findCommandMenuItemForWorkflowVersion( + createdWorkflowVersionId as string, + ); + + expect(commandMenuItem?.label).toBe(renamedWorkflowName); + expect(commandMenuItem?.shortLabel).toBe(renamedWorkflowName); + }); + + it('falls back to "Untitled Workflow" when the workflow name is cleared', async () => { + await renameWorkflow(''); + + const commandMenuItem = await findCommandMenuItemForWorkflowVersion( + createdWorkflowVersionId as string, + ); + + expect(commandMenuItem?.label).toBe('Untitled Workflow'); + expect(commandMenuItem?.shortLabel).toBe('Untitled Workflow'); + }); +});