feat(sdk): add defineCommandMenuItem (#20256)

## Summary

- Add `defineCommandMenuItem` and `definePageLayoutWidget` as standalone
SDK defines, mirroring the existing `definePageLayoutTab` pattern. Both
entities can still be declared nested inside their parent
(`defineFrontComponent.command` / `definePageLayout.tabs[].widgets[]`).
- Add `CommandMenuItem` and `PageLayoutWidget` to the `SyncableEntity`
enum and the dev-mode UI labels.
- Wire the SDK manifest-build to extract the two new defines into
top-level `commandMenuItems` / `pageLayoutWidgets` arrays on the
manifest, and the server aggregator to consume them through the existing
flat-entity converters.
- On the server, expose `Application.commandMenuItems` (relation + DTO +
service hydration in `findOneApplication`).
- On the front, list command menu items in the application content tab
and add a dedicated detail page with a settings tab, mirroring how
`frontComponents` are surfaced.
- Add `twenty add` templates and Vitest unit tests for both new defines.
- Document the standalone-vs-nested pattern in
`packages/twenty-sdk/README.md`.

### Why

Until now, command menu items could only be declared as the nested
`command:` field on `defineFrontComponent` — there was no way to
register a command menu item from a separate file or from another
package. The `SyncableEntity` enum had 12 values, while the server
already synced 18 (including `commandMenuItem` and `pageLayoutWidget`).
The same gap existed for `pageLayoutWidget`, which had no top-level
define despite being synced server-side. This PR closes both gaps and
aligns the SDK surface with what the server actually accepts.

The standalone defines coexist with the nested form — pick one per
entity, never both with the same `universalIdentifier` (the manifest
aggregator will throw on duplicates). The README now documents this.

## Test plan

- [x] `npx nx typecheck twenty-sdk` / `twenty-server` / `twenty-front`
- [x] `npx nx lint:diff-with-main twenty-front` / `twenty-server`
- [x] `npx nx lint twenty-sdk` / `twenty-shared`
- [x] New unit tests: `define-command-menu-item.spec.ts`,
`define-page-layout-widget.spec.ts`
- [x] Existing manifest extract config tests still pass
- [ ] Codegen `npx nx run twenty-front:graphql:generate
--configuration=metadata` should be re-run after merge — the generated
`graphql.ts` was patched manually to include `commandMenuItems` on
`Application` and the `FindOneApplication` document.
- [ ] Smoke test: scaffold an app with `twenty add` for both new entity
types, run `twenty dev`, confirm the dev UI shows them in the sync list
and the settings page surfaces command menu items in the content tab.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: martmull <martmull@hotmail.fr>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Félix Malfait
2026-05-05 16:16:04 +02:00
committed by GitHub
parent 4dd08097ce
commit 633553f729
55 changed files with 1644 additions and 1210 deletions
@@ -7,6 +7,7 @@ import {
} from 'twenty-shared/types';
export const EXPECTED_MANIFEST: Manifest = {
commandMenuItems: [],
application: {
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000001',
displayName: 'Root App',
@@ -12,6 +12,7 @@ import {
} from 'twenty-shared/types';
export const EXPECTED_MANIFEST: Manifest = {
commandMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [
{
@@ -33,6 +33,7 @@ export const normalizeManifestForComparison = <T extends Manifest>(
navigationMenuItems: sortById(manifest.navigationMenuItems),
pageLayouts: sortById(manifest.pageLayouts),
pageLayoutTabs: sortById(manifest.pageLayoutTabs ?? []),
commandMenuItems: sortById(manifest.commandMenuItems ?? []),
logicFunctions: sortById(
manifest.logicFunctions?.map((fn) => ({
...fn,
@@ -15,6 +15,7 @@ import { getFrontComponentBaseFile } from '@/cli/utilities/entity/entity-front-c
import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-function-template';
import { getNavigationMenuItemBaseFile } from '@/cli/utilities/entity/entity-navigation-menu-item-template';
import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template';
import { getCommandMenuItemBaseFile } from '@/cli/utilities/entity/entity-command-menu-item-template';
import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template';
import { getPageLayoutTabBaseFile } from '@/cli/utilities/entity/entity-page-layout-tab-template';
import { getRecordPageLayoutBaseFile } from '@/cli/utilities/entity/entity-record-page-layout-template';
@@ -217,6 +218,15 @@ export class EntityAddCommand {
return { name, file };
}
case SyncableEntity.CommandMenuItem: {
const name = await this.getEntityName(entity);
const file = getCommandMenuItemBaseFile({
name,
});
return { name, file };
}
default:
assertUnreachable(entity);
}
@@ -1,14 +1,9 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { numberOfSelectedRecords } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'comparison-operator',
component: MyComponent,
command: {
universalIdentifier: 'comparison-operator-cmd',
label: 'Comparison Operator',
conditionalAvailabilityExpression: numberOfSelectedRecords > 0,
},
export default defineCommandMenuItem({
universalIdentifier: 'comparison-operator-cmd',
label: 'Comparison Operator',
frontComponentUniversalIdentifier: 'comparison-operator',
conditionalAvailabilityExpression: numberOfSelectedRecords > 0,
});
@@ -1,4 +1,4 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import {
none,
numberOfSelectedRecords,
@@ -6,17 +6,12 @@ import {
selectedRecords,
} from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'complex-soft-delete',
component: MyComponent,
command: {
universalIdentifier: 'complex-soft-delete-cmd',
label: 'Complex Soft Delete',
conditionalAvailabilityExpression:
objectPermissions.canSoftDeleteObjectRecords &&
none(selectedRecords, 'isRemote') &&
numberOfSelectedRecords > 0,
},
export default defineCommandMenuItem({
universalIdentifier: 'complex-soft-delete-cmd',
label: 'Complex Soft Delete',
frontComponentUniversalIdentifier: 'complex-soft-delete',
conditionalAvailabilityExpression:
objectPermissions.canSoftDeleteObjectRecords &&
none(selectedRecords, 'isRemote') &&
numberOfSelectedRecords > 0,
});
@@ -1,17 +1,9 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { someDefined, selectedRecords } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'custom-function',
component: MyComponent,
command: {
universalIdentifier: 'custom-function-cmd',
label: 'Custom Function',
conditionalAvailabilityExpression: someDefined(
selectedRecords,
'deletedAt',
),
},
export default defineCommandMenuItem({
universalIdentifier: 'custom-function-cmd',
label: 'Custom Function',
frontComponentUniversalIdentifier: 'custom-function',
conditionalAvailabilityExpression: someDefined(selectedRecords, 'deletedAt'),
});
@@ -1,16 +1,11 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { featureFlags, objectPermissions } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'feature-flag-gated',
component: MyComponent,
command: {
universalIdentifier: 'feature-flag-gated-cmd',
label: 'Feature Flag Gated',
conditionalAvailabilityExpression:
featureFlags.IS_JUNCTION_RELATIONS_ENABLED &&
objectPermissions.canReadObjectRecords,
},
export default defineCommandMenuItem({
universalIdentifier: 'feature-flag-gated-cmd',
label: 'Feature Flag Gated',
frontComponentUniversalIdentifier: 'feature-flag-gated',
conditionalAvailabilityExpression:
featureFlags.IS_JUNCTION_RELATIONS_ENABLED &&
objectPermissions.canReadObjectRecords,
});
@@ -1,20 +1,15 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import {
favoriteRecordIds,
objectMetadataItem,
pageType,
} from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'parenthesized-expression',
component: MyComponent,
command: {
universalIdentifier: 'parenthesized-expression-cmd',
label: 'Parenthesized Expression',
conditionalAvailabilityExpression:
(pageType === 'RECORD_PAGE' || favoriteRecordIds.length > 0) &&
!objectMetadataItem.isRemote,
},
export default defineCommandMenuItem({
universalIdentifier: 'parenthesized-expression-cmd',
label: 'Parenthesized Expression',
frontComponentUniversalIdentifier: 'parenthesized-expression',
conditionalAvailabilityExpression:
(pageType === 'RECORD_PAGE' || favoriteRecordIds.length > 0) &&
!objectMetadataItem.isRemote,
});
@@ -1,15 +1,10 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { isInSidePanel, objectPermissions } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'permissions-check',
component: MyComponent,
command: {
universalIdentifier: 'permissions-check-cmd',
label: 'Permissions Check',
conditionalAvailabilityExpression:
objectPermissions.canUpdateObjectRecords && !isInSidePanel,
},
export default defineCommandMenuItem({
universalIdentifier: 'permissions-check-cmd',
label: 'Permissions Check',
frontComponentUniversalIdentifier: 'permissions-check',
conditionalAvailabilityExpression:
objectPermissions.canUpdateObjectRecords && !isInSidePanel,
});
@@ -1,14 +1,9 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { pageType } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'simple-boolean',
component: MyComponent,
command: {
universalIdentifier: 'simple-boolean-cmd',
label: 'Simple Boolean',
conditionalAvailabilityExpression: pageType === 'RECORD_PAGE',
},
export default defineCommandMenuItem({
universalIdentifier: 'simple-boolean-cmd',
label: 'Simple Boolean',
frontComponentUniversalIdentifier: 'simple-boolean',
conditionalAvailabilityExpression: pageType === 'RECORD_PAGE',
});
@@ -1,16 +1,11 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { everyEquals, pageType, selectedRecords } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'string-comparison',
component: MyComponent,
command: {
universalIdentifier: 'string-comparison-cmd',
label: 'String Comparison',
conditionalAvailabilityExpression:
pageType === 'RECORD_PAGE' &&
everyEquals(selectedRecords, 'company.name', 'apple'),
},
export default defineCommandMenuItem({
universalIdentifier: 'string-comparison-cmd',
label: 'String Comparison',
frontComponentUniversalIdentifier: 'string-comparison',
conditionalAvailabilityExpression:
pageType === 'RECORD_PAGE' &&
everyEquals(selectedRecords, 'company.name', 'apple'),
});
@@ -1,15 +1,10 @@
import { defineFrontComponent } from '@/sdk/define';
import { defineCommandMenuItem } from '@/sdk/define';
import { pageType, targetObjectWritePermissions } from '@/sdk/front-component';
const MyComponent = () => null;
export default defineFrontComponent({
universalIdentifier: 'target-permissions',
component: MyComponent,
command: {
universalIdentifier: 'target-permissions-cmd',
label: 'Target Permissions',
conditionalAvailabilityExpression:
pageType === 'RECORD_PAGE' && targetObjectWritePermissions.person,
},
export default defineCommandMenuItem({
universalIdentifier: 'target-permissions-cmd',
label: 'Target Permissions',
frontComponentUniversalIdentifier: 'target-permissions',
conditionalAvailabilityExpression:
pageType === 'RECORD_PAGE' && targetObjectWritePermissions.person,
});
@@ -33,6 +33,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"createValidationResult",
"defineAgent",
"defineApplication",
"defineCommandMenuItem",
"defineConnectionProvider",
"defineField",
"defineFrontComponent",
@@ -1,7 +1,7 @@
import {
type ApplicationManifest,
type Manifest,
type FieldManifest,
type Manifest,
} from 'twenty-shared/application';
import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { manifestValidate } from '@/cli/utilities/build/manifest/manifest-validate';
@@ -25,6 +25,7 @@ const validField: FieldManifest = {
};
const validManifest: Manifest = {
commandMenuItems: [],
application: validApplication,
objects: [],
frontComponents: [],
@@ -8,6 +8,7 @@ import {
import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest-extract-config-from-file';
import { getDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields';
import { type ApplicationConfig, type LogicFunctionConfig } from '@/sdk/define';
import { type CommandMenuItemConfig } from '@/sdk/define/command-menu-items/command-menu-item-config';
import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config';
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
import { type PageLayoutConfig } from '@/sdk/define/page-layouts/page-layout-config';
@@ -21,9 +22,9 @@ import {
type ApplicationManifest,
type AssetManifest,
ASSETS_DIR,
type CommandMenuItemManifest,
type ConnectionProviderManifest,
type FieldManifest,
type FrontComponentCommandManifest,
type FrontComponentManifest,
type LogicFunctionManifest,
type Manifest,
@@ -89,6 +90,7 @@ export const buildManifest = async (
const navigationMenuItems: NavigationMenuItemManifest[] = [];
const pageLayouts: PageLayoutManifest[] = [];
const pageLayoutTabs: PageLayoutTabManifest[] = [];
const commandMenuItems: CommandMenuItemManifest[] = [];
const postInstallLogicFunctions: PostInstallLogicFunctionApplicationManifest[] =
[];
const preInstallLogicFunctions: PreInstallLogicFunctionApplicationManifest[] =
@@ -107,6 +109,7 @@ export const buildManifest = async (
const navigationMenuItemsFilePaths: string[] = [];
const pageLayoutsFilePaths: string[] = [];
const pageLayoutTabsFilePaths: string[] = [];
const commandMenuItemsFilePaths: string[] = [];
for (const filePath of filePaths) {
const fileContent = await readFile(filePath, 'utf-8');
@@ -321,7 +324,7 @@ export const buildManifest = async (
errors.push(...extract.errors);
const { component, command, ...rest } = extract.config;
const { component, ...rest } = extract.config;
const relativeFilePath = relative(appPath, filePath);
@@ -332,8 +335,6 @@ export const buildManifest = async (
builtComponentPath: relativeFilePath.replace(/\.tsx?$/, '.mjs'),
builtComponentChecksum: '',
isHeadless: rest.isHeadless ?? false,
// transformed by conditionalAvailabilityTransformPlugin
command: command as FrontComponentCommandManifest,
};
frontComponents.push(config);
@@ -397,6 +398,19 @@ export const buildManifest = async (
pageLayoutTabsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.CommandMenuItems: {
const extract = await extractManifestFromFile<CommandMenuItemConfig>({
appPath,
filePath,
});
commandMenuItems.push(
extract.config as unknown as CommandMenuItemManifest,
);
errors.push(...extract.errors);
commandMenuItemsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PublicAssets: {
// Public assets are handled below
break;
@@ -475,6 +489,7 @@ export const buildManifest = async (
navigationMenuItems: navigationMenuItems.sort(byId),
pageLayouts: pageLayouts.sort(byId),
pageLayoutTabs: pageLayoutTabs.sort(byId),
commandMenuItems: commandMenuItems.sort(byId),
};
const entityFilePaths: EntityFilePaths = {
@@ -492,6 +507,7 @@ export const buildManifest = async (
navigationMenuItems: navigationMenuItemsFilePaths,
pageLayouts: pageLayoutsFilePaths,
pageLayoutTabs: pageLayoutTabsFilePaths,
commandMenuItems: commandMenuItemsFilePaths,
};
return { manifest, filePaths: entityFilePaths, errors };
@@ -16,6 +16,7 @@ export enum TargetFunction {
DefineNavigationMenuItem = 'defineNavigationMenuItem',
DefinePageLayout = 'definePageLayout',
DefinePageLayoutTab = 'definePageLayoutTab',
DefineCommandMenuItem = 'defineCommandMenuItem',
}
export enum ManifestEntityKey {
@@ -33,6 +34,7 @@ export enum ManifestEntityKey {
NavigationMenuItems = 'navigationMenuItems',
PageLayouts = 'pageLayouts',
PageLayoutTabs = 'pageLayoutTabs',
CommandMenuItems = 'commandMenuItems',
}
export type EntityFilePaths = Record<ManifestEntityKey, string[]>;
@@ -60,6 +62,7 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record<
ManifestEntityKey.NavigationMenuItems,
[TargetFunction.DefinePageLayout]: ManifestEntityKey.PageLayouts,
[TargetFunction.DefinePageLayoutTab]: ManifestEntityKey.PageLayoutTabs,
[TargetFunction.DefineCommandMenuItem]: ManifestEntityKey.CommandMenuItems,
};
const computeIsTargetFunctionCall = (node: ts.Node): string | undefined => {
@@ -76,6 +76,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record<string, SyncableEntity | undefined> = {
navigationMenuItems: SyncableEntity.NavigationMenuItem,
pageLayouts: SyncableEntity.PageLayout,
pageLayoutTabs: SyncableEntity.PageLayoutTab,
commandMenuItems: SyncableEntity.CommandMenuItem,
};
const MAX_EVENT_COUNT = 200;
@@ -97,6 +97,7 @@ export const ENTITY_LABELS: Record<SyncableEntity, string> = {
[SyncableEntity.Field]: 'Fields',
[SyncableEntity.LogicFunction]: 'Logic functions',
[SyncableEntity.FrontComponent]: 'Front components',
[SyncableEntity.CommandMenuItem]: 'Command menu items',
[SyncableEntity.Role]: 'Roles',
[SyncableEntity.Skill]: 'Skills',
[SyncableEntity.View]: 'Views',
@@ -0,0 +1,14 @@
import { v4 as uuidv4 } from 'uuid';
export const getCommandMenuItemBaseFile = ({ name }: { name: string }) => {
return `import { defineCommandMenuItem } from 'twenty-sdk/define';
export default defineCommandMenuItem({
universalIdentifier: '${uuidv4()}',
frontComponentUniversalIdentifier:
'replace-with-existing-front-component-uuid',
label: '${name}',
availabilityType: 'GLOBAL',
});
`;
};