From bd03073b6d179fb302e600e867ca85f76bbf821d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 19 Feb 2026 15:23:46 +0100 Subject: [PATCH] [FRONT COMPONENTS] Declare command menu items in front components (#18047) ## Description - Adds support for declaring command menu items directly within `defineFrontComponent` via an optional command config property - Introduces a new `CommandMenuItemManifest` type in twenty-shared and wires it through the manifest build pipeline ## Example Of usage ```tsx import { defineFrontComponent } from "twenty-sdk"; const TestAction = () => { return
Test Action
; }; export default defineFrontComponent({ universalIdentifier: "6c289461-0007-4a62-a99f-69e5c11a4ce7", name: "test-action", description: "Test Action", component: TestAction, command: { universalIdentifier: "c07df864-495f-46f3-9f5b-9d3ce2589e9b", label: "Run My Action", icon: "IconBolt", isPinned: false, }, }); ``` ## Video QA https://github.com/user-attachments/assets/f910fc6a-44a9-45d1-87c5-f0ce64bb3878 --- ...define-front-component-to-direct-export.ts | 2 +- .../build/manifest/manifest-build.ts | 3 +- .../src/sdk/define-front-component.ts | 10 +++++ .../src/sdk/front-component-config.ts | 7 +++- packages/twenty-sdk/src/sdk/index.ts | 1 + ...cation-manifest-metadata-names.constant.ts | 1 + ...est-all-universal-flat-entity-maps.util.ts | 20 +++++++++ ...o-universal-flat-command-menu-item.util.ts | 41 +++++++++++++++++++ .../application/frontComponentManifestType.ts | 17 ++++++++ .../twenty-shared/src/application/index.ts | 6 ++- 10 files changed, 104 insertions(+), 4 deletions(-) create mode 100644 packages/twenty-server/src/engine/core-modules/application/utils/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util.ts diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts index 441edc89bd..b197c6237d 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/common/front-component-build/utils/unwrap-define-front-component-to-direct-export.ts @@ -2,7 +2,7 @@ const DEFINE_FRONT_COMPONENT_IMPORT_PATTERN = /import\s*\{\s*defineFrontComponent\s*\}\s*from\s*['"][^'"]+['"];?\n?/g; const DEFINE_FRONT_COMPONENT_EXPORT_PATTERN = - /export\s+default\s+defineFrontComponent\s*\(\s*\{[^}]*component\s*:\s*(\w+)[^}]*\}\s*\)\s*;?/s; + /export\s+default\s+defineFrontComponent\s*\(\s*\{[\s\S]*?component\s*:\s*(\w+)[\s\S]*?\}\s*\)\s*;?/s; export const unwrapDefineFrontComponentToDirectExport = ( sourceCode: string, diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index 9fdaaffef0..c9d5e36431 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -5,6 +5,7 @@ import { TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING, } from '@/cli/utilities/build/manifest/manifest-extract-config'; import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file'; +import { injectDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/inject-default-fields-in-object-fields'; import { type ApplicationConfig, type FrontComponentConfig, @@ -32,7 +33,6 @@ import { } from 'twenty-shared/application'; import { getInputSchemaFromSourceCode } from 'twenty-shared/logic-function'; import { assertUnreachable } from 'twenty-shared/utils'; -import { injectDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/inject-default-fields-in-object-fields'; const loadSources = async (appPath: string): Promise => { return await glob(['**/*.ts', '**/*.tsx'], { @@ -216,6 +216,7 @@ export const buildManifest = async ( frontComponents.push(config); frontComponentsFilePaths.push(relativePath); + break; } case ManifestEntityKey.Views: { diff --git a/packages/twenty-sdk/src/sdk/define-front-component.ts b/packages/twenty-sdk/src/sdk/define-front-component.ts index 42901d5ef3..56b3395768 100644 --- a/packages/twenty-sdk/src/sdk/define-front-component.ts +++ b/packages/twenty-sdk/src/sdk/define-front-component.ts @@ -19,6 +19,16 @@ export const defineFrontComponent: DefineEntity = ( errors.push('Front component component must be a React component'); } + if (config.command) { + if (!config.command.universalIdentifier) { + errors.push('Command must have a universalIdentifier'); + } + + if (!config.command.label) { + errors.push('Command must have a label'); + } + } + return createValidationResult({ config, errors, diff --git a/packages/twenty-sdk/src/sdk/front-component-config.ts b/packages/twenty-sdk/src/sdk/front-component-config.ts index 58482b957a..efd79e8a56 100644 --- a/packages/twenty-sdk/src/sdk/front-component-config.ts +++ b/packages/twenty-sdk/src/sdk/front-component-config.ts @@ -1,7 +1,12 @@ -import { type FrontComponentManifest } from 'twenty-shared/application'; +import { + type FrontComponentCommandManifest, + type FrontComponentManifest, +} from 'twenty-shared/application'; export type FrontComponentType = React.ComponentType; +export type FrontComponentCommandConfig = FrontComponentCommandManifest; + export type FrontComponentConfig = Omit< FrontComponentManifest, | 'sourceComponentPath' diff --git a/packages/twenty-sdk/src/sdk/index.ts b/packages/twenty-sdk/src/sdk/index.ts index ac9ffe0059..2c1acadd1b 100644 --- a/packages/twenty-sdk/src/sdk/index.ts +++ b/packages/twenty-sdk/src/sdk/index.ts @@ -24,6 +24,7 @@ export { OnDeleteAction } from './fields/on-delete-action'; export { RelationType } from './fields/relation-type'; export { validateFields } from './fields/validate-fields'; export type { + FrontComponentCommandConfig, FrontComponentConfig, FrontComponentType, } from './front-component-config'; diff --git a/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts b/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts index 16602361fb..859b026f04 100644 --- a/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts +++ b/packages/twenty-server/src/engine/core-modules/application/constants/application-manifest-metadata-names.constant.ts @@ -16,6 +16,7 @@ export const APPLICATION_MANIFEST_METADATA_NAMES = [ 'pageLayout', 'pageLayoutTab', 'pageLayoutWidget', + 'commandMenuItem', ] as const satisfies AllMetadataName[]; export type ApplicationManifestMetadataName = diff --git a/packages/twenty-server/src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts index 62c61a7c7c..4f2fb11ea8 100644 --- a/packages/twenty-server/src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts @@ -1,6 +1,7 @@ import { type Manifest } from 'twenty-shared/application'; import { type ApplicationManifestMetadataName } from 'src/engine/core-modules/application/constants/application-manifest-metadata-names.constant'; +import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/utils/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util'; import { fromFieldManifestToUniversalFlatFieldMetadata } from 'src/engine/core-modules/application/utils/from-field-manifest-to-universal-flat-field-metadata.util'; import { fromFrontComponentManifestToUniversalFlatFrontComponent } from 'src/engine/core-modules/application/utils/from-front-component-manifest-to-universal-flat-front-component.util'; import { fromLogicFunctionManifestToUniversalFlatLogicFunction } from 'src/engine/core-modules/application/utils/from-logic-function-manifest-to-universal-flat-logic-function.util'; @@ -117,6 +118,25 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({ universalFlatEntityAndRelatedMapsToMutate: allUniversalFlatEntityMaps, }, ); + + if (frontComponentManifest.command) { + addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow( + { + metadataName: 'commandMenuItem', + universalFlatEntity: + fromCommandMenuItemManifestToUniversalFlatCommandMenuItem({ + commandMenuItemManifest: { + ...frontComponentManifest.command, + frontComponentUniversalIdentifier: + frontComponentManifest.universalIdentifier, + }, + applicationUniversalIdentifier, + now, + }), + universalFlatEntityAndRelatedMapsToMutate: allUniversalFlatEntityMaps, + }, + ); + } } for (const roleManifest of manifest.roles) { diff --git a/packages/twenty-server/src/engine/core-modules/application/utils/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util.ts b/packages/twenty-server/src/engine/core-modules/application/utils/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util.ts new file mode 100644 index 0000000000..464d148169 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/application/utils/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util.ts @@ -0,0 +1,41 @@ +import { type CommandMenuItemManifest } from 'twenty-shared/application'; + +import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity'; +import { type UniversalFlatCommandMenuItem } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-command-menu-item.type'; + +const AVAILABILITY_TYPE_MAP: Record< + NonNullable, + CommandMenuItemAvailabilityType +> = { + GLOBAL: CommandMenuItemAvailabilityType.GLOBAL, + SINGLE_RECORD: CommandMenuItemAvailabilityType.SINGLE_RECORD, + BULK_RECORDS: CommandMenuItemAvailabilityType.BULK_RECORDS, +}; + +export const fromCommandMenuItemManifestToUniversalFlatCommandMenuItem = ({ + commandMenuItemManifest, + applicationUniversalIdentifier, + now, +}: { + commandMenuItemManifest: CommandMenuItemManifest; + applicationUniversalIdentifier: string; + now: string; +}): UniversalFlatCommandMenuItem => { + return { + universalIdentifier: commandMenuItemManifest.universalIdentifier, + applicationUniversalIdentifier, + label: commandMenuItemManifest.label, + icon: commandMenuItemManifest.icon ?? null, + isPinned: commandMenuItemManifest.isPinned ?? false, + availabilityType: commandMenuItemManifest.availabilityType + ? AVAILABILITY_TYPE_MAP[commandMenuItemManifest.availabilityType] + : CommandMenuItemAvailabilityType.GLOBAL, + frontComponentUniversalIdentifier: + commandMenuItemManifest.frontComponentUniversalIdentifier, + availabilityObjectMetadataUniversalIdentifier: + commandMenuItemManifest.availabilityObjectUniversalIdentifier ?? null, + workflowVersionId: null, + createdAt: now, + updatedAt: now, + }; +}; diff --git a/packages/twenty-shared/src/application/frontComponentManifestType.ts b/packages/twenty-shared/src/application/frontComponentManifestType.ts index c076f81feb..f8ab8eedb1 100644 --- a/packages/twenty-shared/src/application/frontComponentManifestType.ts +++ b/packages/twenty-shared/src/application/frontComponentManifestType.ts @@ -1,3 +1,19 @@ +import { type SyncableEntityOptions } from '@/application/syncableEntityOptionsType'; + +export type CommandMenuItemManifest = SyncableEntityOptions & { + label: string; + icon?: string; + isPinned?: boolean; + availabilityType?: 'GLOBAL' | 'SINGLE_RECORD' | 'BULK_RECORDS'; + availabilityObjectUniversalIdentifier?: string; + frontComponentUniversalIdentifier: string; +}; + +export type FrontComponentCommandManifest = Omit< + CommandMenuItemManifest, + 'frontComponentUniversalIdentifier' +>; + export type FrontComponentManifest = { universalIdentifier: string; name?: string; @@ -6,4 +22,5 @@ export type FrontComponentManifest = { builtComponentPath: string; builtComponentChecksum: string; componentName: string; + command?: FrontComponentCommandManifest; }; diff --git a/packages/twenty-shared/src/application/index.ts b/packages/twenty-shared/src/application/index.ts index 20ea9e92a8..c9a4cd658d 100644 --- a/packages/twenty-shared/src/application/index.ts +++ b/packages/twenty-shared/src/application/index.ts @@ -25,7 +25,11 @@ export type { RelationFieldManifest, FieldManifest, } from './fieldManifestType'; -export type { FrontComponentManifest } from './frontComponentManifestType'; +export type { + CommandMenuItemManifest, + FrontComponentCommandManifest, + FrontComponentManifest, +} from './frontComponentManifestType'; export type { LogicFunctionManifest, CronTriggerSettings,