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:
+4
@@ -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,
|
||||
|
||||
+34
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+34
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+74
@@ -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>;
|
||||
|
||||
+12
@@ -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;
|
||||
+2
-4
@@ -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(
|
||||
|
||||
+224
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user