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,