[COMMAND MENU ITEMS] Add engine component key (#18554)

## PR Description

In the process of migrating all the existing commands to the backend, we
stumbled across a couple of problems that made us reconsider the full
migration. This PR introduces a way for command menu items to bypass
front components and to directly reference a frontend component from
twenty front.

It:
- Introduces a `engineFrontComponentKey` field on `CommandMenuItem` as
an alternative to `frontComponentId` and `workflowVersionId`, allowing
command menu items to reference frontend components by key directly
rather than requiring a FrontComponent entity
- Updates the DB constraint to allow exactly one of `workflowVersionId`,
`frontComponentId`, or `engineFrontComponentKey`

### All standard command menu items from the frontend which use
`standardFrontComponentKey`

These are all commands that execute a GraphQL query or a mutation.
Two mains concerned have been raised that made us go with this
(temporary) architecture instead:
- If those commands are part of the standard application, they can only
alter objects from that application and not custom objects.
- We would need to implement a way to trigger optimistic rendering from
the front components, which might take some time to implement.

List:
- Create new record
- Delete (single record)
- Delete records (multiple)
- Restore record
- Restore records (multiple)
- Permanently destroy record
- Permanently destroy records (multiple)
- Add to favorites
- Remove from favorites
- Merge records
- Duplicate Dashboard
- Save Dashboard
- Save Page Layout
- Activate Workflow
- Deactivate Workflow
- Discard Draft (workflow)
- Test Workflow
- Tidy up workflow
- Duplicate Workflow
- Stop (workflow run)
- Use as draft (workflow version)

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Raphaël Bosi
2026-03-12 13:14:45 +01:00
committed by GitHub
parent 78473a606a
commit e8f8189167
55 changed files with 857 additions and 1152 deletions
@@ -1,10 +1,4 @@
import {
Field,
Float,
HideField,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import { Field, Float, HideField, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
@@ -18,13 +12,10 @@ import {
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
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 { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto';
registerEnumType(CommandMenuItemAvailabilityType, {
name: 'CommandMenuItemAvailabilityType',
});
@ObjectType('CommandMenuItem')
export class CommandMenuItemDTO {
@IsUUID()
@@ -45,6 +36,11 @@ export class CommandMenuItemDTO {
@Field(() => FrontComponentDTO, { nullable: true })
frontComponent?: FrontComponentDTO | null;
@IsEnum(EngineComponentKey)
@IsOptional()
@Field(() => EngineComponentKey, { nullable: true })
engineComponentKey?: EngineComponentKey;
@IsString()
@IsNotEmpty()
@Field()
@@ -11,7 +11,8 @@ import {
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
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';
@InputType()
export class CreateCommandMenuItemInput {
@@ -25,6 +26,11 @@ export class CreateCommandMenuItemInput {
@Field(() => UUIDScalarType, { nullable: true })
frontComponentId?: string;
@IsEnum(EngineComponentKey)
@IsOptional()
@Field(() => EngineComponentKey, { nullable: true })
engineComponentKey?: EngineComponentKey;
@IsString()
@IsNotEmpty()
@Field()
@@ -11,7 +11,8 @@ import {
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
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';
@InputType()
export class UpdateCommandMenuItemInput {
@@ -54,4 +55,9 @@ export class UpdateCommandMenuItemInput {
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
availabilityObjectMetadataId?: string;
@IsEnum(EngineComponentKey)
@IsOptional()
@Field(() => EngineComponentKey, { nullable: true })
engineComponentKey?: EngineComponentKey;
}
@@ -11,16 +11,12 @@ import {
UpdateDateColumn,
} from 'typeorm';
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';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
export enum CommandMenuItemAvailabilityType {
GLOBAL = 'GLOBAL',
RECORD_SELECTION = 'RECORD_SELECTION',
FALLBACK = 'FALLBACK',
}
@Entity({ name: 'commandMenuItem', schema: 'core' })
@Index('IDX_COMMAND_MENU_ITEM_WORKFLOW_VERSION_ID_WORKSPACE_ID', [
'workflowVersionId',
@@ -34,8 +30,8 @@ export enum CommandMenuItemAvailabilityType {
'availabilityObjectMetadataId',
])
@Check(
'CHK_command_menu_item_workflow_or_front_component',
'("workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL) OR ("workflowVersionId" IS NULL AND "frontComponentId" IS NOT NULL)',
'CHK_CMD_MENU_ITEM_WF_OR_FC_OR_ENGINE_KEY',
'("workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL AND "engineComponentKey" IS NULL) OR ("workflowVersionId" IS NULL AND "frontComponentId" IS NOT NULL AND "engineComponentKey" IS NULL) OR ("workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "engineComponentKey" IS NOT NULL)',
)
export class CommandMenuItemEntity
extends SyncableEntity
@@ -57,6 +53,13 @@ export class CommandMenuItemEntity
@JoinColumn({ name: 'frontComponentId' })
frontComponent: Relation<FrontComponentEntity> | null;
@Column({
type: 'enum',
enum: Object.values(EngineComponentKey),
nullable: true,
})
engineComponentKey: EngineComponentKey | null;
@Column({ nullable: false })
label: string;
@@ -74,7 +77,7 @@ export class CommandMenuItemEntity
@Column({
type: 'enum',
enum: CommandMenuItemAvailabilityType,
enum: Object.values(CommandMenuItemAvailabilityType),
nullable: false,
default: CommandMenuItemAvailabilityType.GLOBAL,
})
@@ -0,0 +1,11 @@
import { registerEnumType } from '@nestjs/graphql';
export enum CommandMenuItemAvailabilityType {
GLOBAL = 'GLOBAL',
RECORD_SELECTION = 'RECORD_SELECTION',
FALLBACK = 'FALLBACK',
}
registerEnumType(CommandMenuItemAvailabilityType, {
name: 'CommandMenuItemAvailabilityType',
});
@@ -0,0 +1,29 @@
import { registerEnumType } from '@nestjs/graphql';
export enum EngineComponentKey {
CREATE_NEW_RECORD = 'CREATE_NEW_RECORD',
DELETE_SINGLE_RECORD = 'DELETE_SINGLE_RECORD',
DELETE_MULTIPLE_RECORDS = 'DELETE_MULTIPLE_RECORDS',
RESTORE_SINGLE_RECORD = 'RESTORE_SINGLE_RECORD',
RESTORE_MULTIPLE_RECORDS = 'RESTORE_MULTIPLE_RECORDS',
DESTROY_SINGLE_RECORD = 'DESTROY_SINGLE_RECORD',
DESTROY_MULTIPLE_RECORDS = 'DESTROY_MULTIPLE_RECORDS',
ADD_TO_FAVORITES = 'ADD_TO_FAVORITES',
REMOVE_FROM_FAVORITES = 'REMOVE_FROM_FAVORITES',
MERGE_MULTIPLE_RECORDS = 'MERGE_MULTIPLE_RECORDS',
DUPLICATE_DASHBOARD = 'DUPLICATE_DASHBOARD',
DUPLICATE_WORKFLOW = 'DUPLICATE_WORKFLOW',
ACTIVATE_WORKFLOW = 'ACTIVATE_WORKFLOW',
DEACTIVATE_WORKFLOW = 'DEACTIVATE_WORKFLOW',
DISCARD_DRAFT_WORKFLOW = 'DISCARD_DRAFT_WORKFLOW',
TEST_WORKFLOW = 'TEST_WORKFLOW',
STOP_WORKFLOW_RUN = 'STOP_WORKFLOW_RUN',
USE_AS_DRAFT_WORKFLOW_VERSION = 'USE_AS_DRAFT_WORKFLOW_VERSION',
SAVE_RECORD_PAGE_LAYOUT = 'SAVE_RECORD_PAGE_LAYOUT',
SAVE_DASHBOARD_LAYOUT = 'SAVE_DASHBOARD_LAYOUT',
TIDY_UP_WORKFLOW = 'TIDY_UP_WORKFLOW',
}
registerEnumType(EngineComponentKey, {
name: 'EngineComponentKey',
});