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
@@ -79,6 +79,7 @@ export class ApplicationManifestMigrationService {
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
commandMenuItems: [],
};
const now = new Date().toISOString();
@@ -158,23 +158,6 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
universalFlatEntityMapsToMutate:
allUniversalFlatEntityMaps.flatFrontComponentMaps,
});
if (frontComponentManifest.command) {
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity:
fromCommandMenuItemManifestToUniversalFlatCommandMenuItem({
commandMenuItemManifest: {
...frontComponentManifest.command,
frontComponentUniversalIdentifier:
frontComponentManifest.universalIdentifier,
},
applicationUniversalIdentifier,
now,
}),
universalFlatEntityMapsToMutate:
allUniversalFlatEntityMaps.flatCommandMenuItemMaps,
});
}
}
for (const connectionProviderManifest of manifest.connectionProviders ?? []) {
@@ -448,5 +431,24 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
}
}
for (const commandMenuItemManifest of manifest.commandMenuItems ?? []) {
if (!isDefined(commandMenuItemManifest.frontComponentUniversalIdentifier)) {
throw new Error(
`Top-level commandMenuItem "${commandMenuItemManifest.universalIdentifier}" is missing required frontComponentUniversalIdentifier`,
);
}
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
universalFlatEntity:
fromCommandMenuItemManifestToUniversalFlatCommandMenuItem({
commandMenuItemManifest,
applicationUniversalIdentifier,
now,
}),
universalFlatEntityMapsToMutate:
allUniversalFlatEntityMaps.flatCommandMenuItemMaps,
});
}
return allUniversalFlatEntityMaps;
};
@@ -26,6 +26,7 @@ const buildMinimalManifest = (
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
commandMenuItems: [],
});
describe('resolveManifestAssetUrls', () => {
@@ -20,6 +20,7 @@ import { ApplicationRegistrationSourceType } from 'src/engine/core-modules/appli
import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity';
import { ApplicationVariableEntity } from 'src/engine/core-modules/application/application-variable/application-variable.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -142,6 +143,15 @@ export class ApplicationEntity extends WorkspaceRelatedEntity {
)
frontComponents: Relation<FrontComponentEntity[]>;
@OneToMany(
() => CommandMenuItemEntity,
(commandMenuItem) => commandMenuItem.application,
{
onDelete: 'CASCADE',
},
)
commandMenuItems: Relation<CommandMenuItemEntity[]>;
@OneToMany(
() => ApplicationVariableEntity,
(applicationVariable) => applicationVariable.application,
@@ -9,6 +9,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
@@ -23,6 +24,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache
LogicFunctionEntity,
AgentEntity,
FrontComponentEntity,
CommandMenuItemEntity,
ObjectMetadataEntity,
ApplicationVariableEntity,
]),
@@ -17,6 +17,7 @@ import { type FlatApplication } from 'src/engine/core-modules/application/types/
import { FileStorageService } from 'src/engine/core-modules/file-storage/file-storage.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { ALL_FLAT_ENTITY_MAPS_PROPERTIES } from 'src/engine/metadata-modules/flat-entity/constant/all-flat-entity-maps-properties.constant';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { LogicFunctionEntity } from 'src/engine/metadata-modules/logic-function/logic-function.entity';
@@ -40,6 +41,8 @@ export class ApplicationService {
private readonly agentRepository: Repository<AgentEntity>,
@InjectRepository(FrontComponentEntity)
private readonly frontComponentRepository: Repository<FrontComponentEntity>,
@InjectRepository(CommandMenuItemEntity)
private readonly commandMenuItemRepository: Repository<CommandMenuItemEntity>,
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(ApplicationVariableEntity)
@@ -187,6 +190,7 @@ export class ApplicationService {
logicFunctions,
agents,
frontComponents,
commandMenuItems,
objects,
applicationVariables,
] = await Promise.all([
@@ -199,6 +203,9 @@ export class ApplicationService {
this.frontComponentRepository.find({
where: { applicationId: application.id, workspaceId },
}),
this.commandMenuItemRepository.find({
where: { applicationId: application.id, workspaceId },
}),
this.objectMetadataRepository.find({
where: { applicationId: application.id, workspaceId },
}),
@@ -210,6 +217,7 @@ export class ApplicationService {
application.logicFunctions = logicFunctions;
application.agents = agents;
application.frontComponents = frontComponents;
application.commandMenuItems = commandMenuItems;
application.objects = objects;
application.applicationVariables = applicationVariables;
@@ -4,6 +4,7 @@ export const APPLICATION_ENTITY_RELATION_PROPERTIES = [
'workspace',
'agents',
'frontComponents',
'commandMenuItems',
'logicFunctions',
'objects',
'applicationVariables',
@@ -13,6 +13,7 @@ import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/
import { ApplicationRegistrationSummaryDTO } from 'src/engine/core-modules/application/application-registration/dtos/application-registration-summary.dto';
import { ApplicationVariableEntityDTO } from 'src/engine/core-modules/application/application-variable/dtos/application-variable.dto';
import { AgentDTO } from 'src/engine/metadata-modules/ai/ai-agent/dtos/agent.dto';
import { CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto';
import { FrontComponentDTO } from 'src/engine/metadata-modules/front-component/dtos/front-component.dto';
import { LogicFunctionDTO } from 'src/engine/metadata-modules/logic-function/dtos/logic-function.dto';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
@@ -100,6 +101,9 @@ export class ApplicationDTO {
@Field(() => [FrontComponentDTO])
frontComponents?: FrontComponentDTO[];
@Field(() => [CommandMenuItemDTO])
commandMenuItems?: CommandMenuItemDTO[];
@Field(() => [LogicFunctionDTO])
logicFunctions?: LogicFunctionDTO[];