[COMMAND MENU ITEMS] Create union type for command menu item payload (#19432)

Replace generic JSON scalar with a typed GraphQL union
CommandMenuItemPayload (PathNavigationPayload |
ObjectMetadataNavigationPayload) for the CommandMenuItem.payload field
This commit is contained in:
Raphaël Bosi
2026-04-08 14:47:34 +02:00
committed by GitHub
parent 265d859c6e
commit d07c27a907
28 changed files with 673 additions and 305 deletions
@@ -0,0 +1,29 @@
import { createUnionType } from '@nestjs/graphql';
import { ObjectMetadataCommandMenuItemPayloadDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/object-metadata-command-menu-item-payload.dto';
import { PathCommandMenuItemPayloadDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/path-command-menu-item-payload.dto';
import { type ObjectMetadataCommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/dtos/types/object-metadata-command-menu-item-payload.type';
import { type PathCommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/dtos/types/path-command-menu-item-payload.type';
export type CommandMenuItemPayload =
| PathCommandMenuItemPayload
| ObjectMetadataCommandMenuItemPayload;
export const CommandMenuItemPayloadUnion = createUnionType({
name: 'CommandMenuItemPayload',
types: () => [
PathCommandMenuItemPayloadDTO,
ObjectMetadataCommandMenuItemPayloadDTO,
],
resolveType(payload: CommandMenuItemPayload) {
if ('path' in payload) {
return PathCommandMenuItemPayloadDTO;
}
if ('objectMetadataItemId' in payload) {
return ObjectMetadataCommandMenuItemPayloadDTO;
}
return undefined;
},
});
@@ -10,10 +10,13 @@ import {
IsString,
IsUUID,
} from 'class-validator';
import { GraphQLJSON } from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import {
type CommandMenuItemPayload,
CommandMenuItemPayloadUnion,
} from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item-payload.union';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto';
@@ -70,8 +73,8 @@ export class CommandMenuItemDTO {
availabilityType: CommandMenuItemAvailabilityType;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
payload?: Record<string, unknown>;
@Field(() => CommandMenuItemPayloadUnion, { nullable: true })
payload?: CommandMenuItemPayload;
@IsString({ each: true })
@IsOptional()
@@ -13,6 +13,7 @@ import {
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { type CommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item-payload.union';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
@@ -81,5 +82,5 @@ export class CreateCommandMenuItemInput {
@IsObject()
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
payload?: Record<string, unknown>;
payload?: CommandMenuItemPayload;
}
@@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsNotEmpty, IsUUID } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('ObjectMetadataCommandMenuItemPayload')
export class ObjectMetadataCommandMenuItemPayloadDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
objectMetadataItemId: string;
}
@@ -0,0 +1,11 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@ObjectType('PathCommandMenuItemPayload')
export class PathCommandMenuItemPayloadDTO {
@IsString()
@IsNotEmpty()
@Field()
path: string;
}
@@ -0,0 +1,3 @@
export type ObjectMetadataCommandMenuItemPayload = {
objectMetadataItemId: string;
};
@@ -0,0 +1,3 @@
export type PathCommandMenuItemPayload = {
path: string;
};
@@ -11,6 +11,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { type CommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item-payload.union';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
@@ -80,7 +81,7 @@ export class CommandMenuItemEntity
availabilityType: CommandMenuItemAvailabilityType;
@Column({ type: 'jsonb', nullable: true })
payload: Record<string, unknown> | null;
payload: CommandMenuItemPayload | null;
@Column({ type: 'text', array: true, nullable: true })
hotKeys: string[] | null;
@@ -6,6 +6,7 @@ import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { CommandMenuItemExceptionCode } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception';
import { type CommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item-payload.union';
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
@@ -49,6 +50,7 @@ export class FlatCommandMenuItemValidatorService {
workflowVersionId: flatCommandMenuItem.workflowVersionId,
frontComponentUniversalIdentifier:
flatCommandMenuItem.frontComponentUniversalIdentifier,
payload: flatCommandMenuItem.payload,
validationResult,
});
@@ -133,11 +135,17 @@ export class FlatCommandMenuItemValidatorService {
flatEntityUpdate.engineComponentKey ??
fromFlatCommandMenuItem.engineComponentKey;
const payload =
flatEntityUpdate.payload !== undefined
? flatEntityUpdate.payload
: fromFlatCommandMenuItem.payload;
this.validateEngineComponentKeyCoherence({
engineComponentKey,
workflowVersionId: fromFlatCommandMenuItem.workflowVersionId,
frontComponentUniversalIdentifier:
fromFlatCommandMenuItem.frontComponentUniversalIdentifier,
payload,
validationResult,
});
@@ -148,11 +156,13 @@ export class FlatCommandMenuItemValidatorService {
engineComponentKey,
workflowVersionId,
frontComponentUniversalIdentifier,
payload,
validationResult,
}: {
engineComponentKey: EngineComponentKey | null;
workflowVersionId: string | null;
frontComponentUniversalIdentifier: string | null;
payload: CommandMenuItemPayload | null;
validationResult: FailedFlatEntityValidation<
'commandMenuItem',
'create' | 'update'
@@ -201,6 +211,27 @@ export class FlatCommandMenuItemValidatorService {
break;
}
case EngineComponentKey.NAVIGATION: {
this.validateNavigationPayload({ payload, validationResult });
if (isNonEmptyString(workflowVersionId)) {
validationResult.errors.push({
code: CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT,
message: t`workflowVersionId must not be set for engine component key ${engineComponentKey}`,
userFriendlyMessage: msg`Workflow version must not be set for this item type`,
});
}
if (isNonEmptyString(frontComponentUniversalIdentifier)) {
validationResult.errors.push({
code: CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT,
message: t`frontComponentId must not be set for engine component key ${engineComponentKey}`,
userFriendlyMessage: msg`Front component must not be set for this item type`,
});
}
break;
}
default: {
if (isNonEmptyString(workflowVersionId)) {
validationResult.errors.push({
@@ -222,4 +253,38 @@ export class FlatCommandMenuItemValidatorService {
}
}
}
private validateNavigationPayload({
payload,
validationResult,
}: {
payload: CommandMenuItemPayload | null;
validationResult: FailedFlatEntityValidation<
'commandMenuItem',
'create' | 'update'
>;
}): void {
if (!isDefined(payload)) {
validationResult.errors.push({
code: CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT,
message: t`payload is required when engineComponentKey is NAVIGATION`,
userFriendlyMessage: msg`Payload is required for navigation items`,
});
return;
}
const hasPath = 'path' in payload && isNonEmptyString(payload.path);
const hasObjectMetadataItemId =
'objectMetadataItemId' in payload &&
isNonEmptyString(payload.objectMetadataItemId);
if (!hasPath && !hasObjectMetadataItemId) {
validationResult.errors.push({
code: CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT,
message: t`payload must contain either a "path" or "objectMetadataItemId" property`,
userFriendlyMessage: msg`Payload must contain either a path or an object metadata item identifier`,
});
}
}
}