fix(server): keep workflow command menu item label in sync with workflow name (#21490)

## Summary

Fixes #20766 — manual-trigger workflows showed `Manual Trigger` in the
command menu instead of the workflow's name.

Root cause (confirmed against a live instance): the command menu item's
`label` is written **only at activation** in
`createOrUpdateCommandMenuItem`, from `workflow.name`, with a hardcoded
`'Manual Trigger'` fallback. So:
- a workflow activated while unnamed gets the misleading `Manual
Trigger` label, and
- renaming the workflow afterwards never updates the label
(`workflow.updateOne` had no label-related hook).

Changes:
- Add `getWorkflowCommandMenuItemLabel` helper and use it in activation;
the empty-name fallback is now `Untitled Workflow` (consistent with the
rest of the UI) instead of `Manual Trigger`.
- Add `WorkflowCommandMenuSyncWorkspaceService` that updates the active
version's command menu item label/shortLabel from the workflow name
(idempotent, no-op for non-manual / inactive workflows).
- Add `workflow.updateOne` and `workflow.updateMany` post-query hooks
that call the sync service, registered in `WorkflowQueryHookModule`.

Out of scope (separate follow-up): the activation create path can
produce duplicate command items for one `workflowVersionId`; recommend
making it idempotent / adding a unique constraint.

## Test plan

- [x] `oxlint --type-aware` + `oxfmt` clean on changed files
- [x] Editor TS diagnostics clean (full `nx typecheck` was starved by
local dev servers)
- [ ] New integration test
`workflow-command-menu-label.integration-spec.ts`:
  - labels the command menu item with the workflow name on activation
  - updates the label when the workflow is renamed
  - falls back to `Untitled Workflow` when the name is cleared

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21490?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Thomas Trompette
2026-06-12 16:54:32 +02:00
committed by GitHub
parent a8a8bbb2ed
commit b36c0c51c3
7 changed files with 384 additions and 4 deletions
@@ -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,
@@ -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<void> {
const workflowIds = payload
.map((workflow) => workflow.id)
.filter(isDefined);
await this.workflowCommonWorkspaceService.syncCommandMenuItemLabelForWorkflows(
workflowIds,
authContext,
);
}
}
@@ -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<void> {
const workflowIds = payload
.map((workflow) => workflow.id)
.filter(isDefined);
await this.workflowCommonWorkspaceService.syncCommandMenuItemLabelForWorkflows(
workflowIds,
authContext,
);
}
}
@@ -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<void> {
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<WorkflowWorkspaceEntity>(
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<void> {
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<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
@@ -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<WorkflowWorkspaceEntity, 'name'>,
): string =>
isNonEmptyString(workflow.name)
? workflow.name
: DEFAULT_WORKFLOW_COMMAND_MENU_ITEM_LABEL;
@@ -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(
@@ -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<CommandMenuItemDTO | undefined> => {
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');
});
});