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:
+1
@@ -79,6 +79,7 @@ export class ApplicationManifestMigrationService {
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
commandMenuItems: [],
|
||||
};
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
+19
-17
@@ -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;
|
||||
};
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
|
||||
+1
@@ -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[];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user