Link command menu items to specific page layout (#19706)

- Add a `pageLayoutId` foreign key to `CommandMenuItem`, allowing
command menu items to be scoped to a specific page layout instead of
being globally available
- Filter command menu items by the current page layout on the frontend.
Items with a `pageLayoutId` only appear when viewing that layout, while
items without one remain globally visible
- Create an effect to track the current page layout ID
- Include a seed example: a "Show Notification" command pinned to the
Star history standalone page layout

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Raphaël Bosi
2026-04-16 14:07:36 +02:00
committed by GitHub
parent 7af82fb6a4
commit 2b5b8a8b13
39 changed files with 342 additions and 51 deletions
@@ -97,11 +97,19 @@ export class CommandMenuItemService {
input: CreateCommandMenuItemInput,
workspaceId: string,
): Promise<CommandMenuItemDTO> {
const { flatObjectMetadataMaps, flatFrontComponentMaps } =
const {
flatObjectMetadataMaps,
flatFrontComponentMaps,
flatPageLayoutMaps,
} =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFrontComponentMaps'],
flatMapsKeys: [
'flatObjectMetadataMaps',
'flatFrontComponentMaps',
'flatPageLayoutMaps',
],
},
);
@@ -117,6 +125,7 @@ export class CommandMenuItemService {
flatApplication: workspaceCustomFlatApplication,
flatObjectMetadataMaps,
flatFrontComponentMaps,
flatPageLayoutMaps,
});
const validateAndBuildResult =
@@ -171,19 +180,24 @@ export class CommandMenuItemService {
const {
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
} =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatCommandMenuItemMaps', 'flatObjectMetadataMaps'],
},
);
flatPageLayoutMaps: existingFlatPageLayoutMaps,
} = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatCommandMenuItemMaps',
'flatObjectMetadataMaps',
'flatPageLayoutMaps',
],
},
);
const flatCommandMenuItemToUpdate =
fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow({
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
updateCommandMenuItemInput: input,
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatPageLayoutMaps: existingFlatPageLayoutMaps,
});
const validateAndBuildResult =
@@ -91,6 +91,11 @@ export class CommandMenuItemDTO {
@Field(() => UUIDScalarType, { nullable: true })
availabilityObjectMetadataId?: string;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
pageLayoutId?: string;
@HideField()
workspaceId: string;
@@ -83,4 +83,9 @@ export class CreateCommandMenuItemInput {
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
payload?: CommandMenuItemPayload;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
pageLayoutId?: string;
}
@@ -65,4 +65,9 @@ export class UpdateCommandMenuItemInput {
@IsOptional()
@Field(() => [String], { nullable: true })
hotKeys?: string[];
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
pageLayoutId?: string;
}
@@ -16,6 +16,7 @@ import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/com
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'commandMenuItem', schema: 'core' })
@@ -30,6 +31,10 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
@Index('IDX_COMMAND_MENU_ITEM_AVAILABILITY_OBJECT_METADATA_ID', [
'availabilityObjectMetadataId',
])
@Index('IDX_COMMAND_MENU_ITEM_PAGE_LAYOUT_ID_WORKSPACE_ID', [
'pageLayoutId',
'workspaceId',
])
@Check(
'CHK_CMD_MENU_ITEM_ENGINE_KEY_COHERENCE',
`("engineComponentKey" = 'TRIGGER_WORKFLOW_VERSION' AND "workflowVersionId" IS NOT NULL AND "frontComponentId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'FRONT_COMPONENT_RENDERER' AND "frontComponentId" IS NOT NULL AND "workflowVersionId" IS NULL AND "payload" IS NULL) OR ("engineComponentKey" = 'NAVIGATION' AND "payload" IS NOT NULL AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL) OR ("engineComponentKey" NOT IN ('TRIGGER_WORKFLOW_VERSION', 'FRONT_COMPONENT_RENDERER', 'NAVIGATION') AND "workflowVersionId" IS NULL AND "frontComponentId" IS NULL AND "payload" IS NULL)`,
@@ -99,6 +104,16 @@ export class CommandMenuItemEntity
@JoinColumn({ name: 'availabilityObjectMetadataId' })
availabilityObjectMetadata: Relation<ObjectMetadataEntity> | null;
@Column({ nullable: true, type: 'uuid' })
pageLayoutId: string | null;
@ManyToOne(() => PageLayoutEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'pageLayoutId' })
pageLayout: Relation<PageLayoutEntity> | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -10,4 +10,5 @@ export const FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES = [
'availabilityType',
'availabilityObjectMetadataId',
'engineComponentKey',
'pageLayoutId',
] as const satisfies MetadataEntityPropertyName<'commandMenuItem'>[];
@@ -7,6 +7,7 @@ import { WorkspaceFlatCommandMenuItemMapCacheService } from 'src/engine/metadata
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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
@Module({
imports: [
@@ -15,6 +16,7 @@ import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadat
ApplicationEntity,
ObjectMetadataEntity,
FrontComponentEntity,
PageLayoutEntity,
]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
@@ -12,6 +12,7 @@ import { fromCommandMenuItemEntityToFlatCommandMenuItem } from 'src/engine/metad
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/utils/create-id-to-universal-identifier-map.util';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
@@ -28,33 +29,45 @@ export class WorkspaceFlatCommandMenuItemMapCacheService extends WorkspaceCacheP
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FrontComponentEntity)
private readonly frontComponentRepository: Repository<FrontComponentEntity>,
@InjectRepository(PageLayoutEntity)
private readonly pageLayoutRepository: Repository<PageLayoutEntity>,
) {
super();
}
async computeForCache(workspaceId: string): Promise<FlatCommandMenuItemMaps> {
const [commandMenuItems, applications, objectMetadatas, frontComponents] =
await Promise.all([
this.commandMenuItemRepository.find({
where: { workspaceId },
withDeleted: true,
}),
this.applicationRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.objectMetadataRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.frontComponentRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
]);
const [
commandMenuItems,
applications,
objectMetadatas,
frontComponents,
pageLayouts,
] = await Promise.all([
this.commandMenuItemRepository.find({
where: { workspaceId },
withDeleted: true,
}),
this.applicationRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.objectMetadataRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.frontComponentRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
this.pageLayoutRepository.find({
where: { workspaceId },
select: ['id', 'universalIdentifier'],
withDeleted: true,
}),
]);
const applicationIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(applications);
@@ -62,6 +75,8 @@ export class WorkspaceFlatCommandMenuItemMapCacheService extends WorkspaceCacheP
createIdToUniversalIdentifierMap(objectMetadatas);
const frontComponentIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(frontComponents);
const pageLayoutIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(pageLayouts);
const flatCommandMenuItemMaps = createEmptyFlatEntityMaps();
@@ -72,6 +87,7 @@ export class WorkspaceFlatCommandMenuItemMapCacheService extends WorkspaceCacheP
applicationIdToUniversalIdentifierMap,
objectMetadataIdToUniversalIdentifierMap,
frontComponentIdToUniversalIdentifierMap,
pageLayoutIdToUniversalIdentifierMap,
});
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
@@ -65,6 +65,8 @@ export const buildNavigationFlatCommandMenuItem = ({
workflowVersionId: null,
availabilityObjectMetadataId: null,
availabilityObjectMetadataUniversalIdentifier: null,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
};
@@ -12,6 +12,7 @@ export const fromCommandMenuItemEntityToFlatCommandMenuItem = ({
applicationIdToUniversalIdentifierMap,
objectMetadataIdToUniversalIdentifierMap,
frontComponentIdToUniversalIdentifierMap,
pageLayoutIdToUniversalIdentifierMap,
}: FromEntityToFlatEntityArgs<'commandMenuItem'>): FlatCommandMenuItem => {
const applicationUniversalIdentifier =
applicationIdToUniversalIdentifierMap.get(
@@ -57,6 +58,22 @@ export const fromCommandMenuItemEntityToFlatCommandMenuItem = ({
}
}
let pageLayoutUniversalIdentifier: string | null = null;
if (isDefined(commandMenuItemEntity.pageLayoutId)) {
pageLayoutUniversalIdentifier =
pageLayoutIdToUniversalIdentifierMap.get(
commandMenuItemEntity.pageLayoutId,
) ?? null;
if (!isDefined(pageLayoutUniversalIdentifier)) {
throw new FlatEntityMapsException(
`PageLayout with id ${commandMenuItemEntity.pageLayoutId} not found for commandMenuItem ${commandMenuItemEntity.id}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
}
return {
id: commandMenuItemEntity.id,
workflowVersionId: commandMenuItemEntity.workflowVersionId,
@@ -82,5 +99,7 @@ export const fromCommandMenuItemEntityToFlatCommandMenuItem = ({
commandMenuItemEntity.conditionalAvailabilityExpression,
availabilityObjectMetadataUniversalIdentifier,
frontComponentUniversalIdentifier,
pageLayoutId: commandMenuItemEntity.pageLayoutId,
pageLayoutUniversalIdentifier,
};
};
@@ -14,13 +14,14 @@ export const fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate = ({
flatApplication,
flatObjectMetadataMaps,
flatFrontComponentMaps,
flatPageLayoutMaps,
}: {
createCommandMenuItemInput: CreateCommandMenuItemInput;
workspaceId: string;
flatApplication: FlatApplication;
} & Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps' | 'flatFrontComponentMaps'
'flatObjectMetadataMaps' | 'flatFrontComponentMaps' | 'flatPageLayoutMaps'
>): FlatCommandMenuItem => {
const id = uuidv4();
const now = new Date().toISOString();
@@ -28,14 +29,20 @@ export const fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate = ({
const {
availabilityObjectMetadataUniversalIdentifier,
frontComponentUniversalIdentifier,
pageLayoutUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'commandMenuItem',
foreignKeyValues: {
availabilityObjectMetadataId:
createCommandMenuItemInput.availabilityObjectMetadataId,
frontComponentId: createCommandMenuItemInput.frontComponentId,
pageLayoutId: createCommandMenuItemInput.pageLayoutId,
},
flatEntityMaps: {
flatObjectMetadataMaps,
flatFrontComponentMaps,
flatPageLayoutMaps,
},
flatEntityMaps: { flatObjectMetadataMaps, flatFrontComponentMaps },
});
return {
@@ -64,6 +71,8 @@ export const fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate = ({
conditionalAvailabilityExpression:
createCommandMenuItemInput.conditionalAvailabilityExpression ?? null,
availabilityObjectMetadataUniversalIdentifier,
pageLayoutId: createCommandMenuItemInput.pageLayoutId ?? null,
pageLayoutUniversalIdentifier,
workspaceId,
applicationId: flatApplication.id,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
@@ -20,6 +20,7 @@ export const fromFlatCommandMenuItemToCommandMenuItemDto = (
flatCommandMenuItem.conditionalAvailabilityExpression ?? undefined,
availabilityObjectMetadataId:
flatCommandMenuItem.availabilityObjectMetadataId ?? undefined,
pageLayoutId: flatCommandMenuItem.pageLayoutId ?? undefined,
workspaceId: flatCommandMenuItem.workspaceId,
applicationId: flatCommandMenuItem.applicationId ?? undefined,
createdAt: new Date(flatCommandMenuItem.createdAt),
@@ -18,12 +18,13 @@ export const fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow
flatCommandMenuItemMaps,
updateCommandMenuItemInput,
flatObjectMetadataMaps,
flatPageLayoutMaps,
}: {
flatCommandMenuItemMaps: FlatCommandMenuItemMaps;
updateCommandMenuItemInput: UpdateCommandMenuItemInput;
} & Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps'
'flatObjectMetadataMaps' | 'flatPageLayoutMaps'
>): FlatCommandMenuItem => {
const existingFlatCommandMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: updateCommandMenuItemInput.id,
@@ -63,5 +64,19 @@ export const fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow
availabilityObjectMetadataUniversalIdentifier;
}
if (updates.pageLayoutId !== undefined) {
const { pageLayoutUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'commandMenuItem',
foreignKeyValues: {
pageLayoutId: flatCommandMenuItemToUpdate.pageLayoutId,
},
flatEntityMaps: { flatPageLayoutMaps },
});
flatCommandMenuItemToUpdate.pageLayoutUniversalIdentifier =
pageLayoutUniversalIdentifier;
}
return flatCommandMenuItemToUpdate;
};
@@ -1114,6 +1114,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
pageLayoutId: {
toCompare: false,
toStringify: false,
universalProperty: 'pageLayoutUniversalIdentifier',
},
},
navigationMenuItem: {
type: {
@@ -50,6 +50,9 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
frontComponent: {
foreignKey: 'frontComponentId',
},
pageLayout: {
foreignKey: 'pageLayoutId',
},
},
navigationMenuItem: {
workspace: null,
@@ -86,6 +86,13 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
isNullable: true,
universalForeignKey: 'frontComponentUniversalIdentifier',
},
pageLayout: {
metadataName: 'pageLayout',
foreignKey: 'pageLayoutId',
inverseOneToManyProperty: null,
isNullable: true,
universalForeignKey: 'pageLayoutUniversalIdentifier',
},
},
navigationMenuItem: {
workspace: null,
@@ -69,6 +69,7 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
commandMenuItem: {
objectMetadata: true,
frontComponent: true,
pageLayout: true,
},
navigationMenuItem: {
objectMetadata: true,
@@ -6,6 +6,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
[
"objectMetadata",
"frontComponent",
"pageLayout",
]
`;
@@ -4,10 +4,10 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
[
"rowLevelPermissionPredicate",
"navigationMenuItem",
"commandMenuItem",
"fieldPermission",
"viewField",
"viewFilter",
"commandMenuItem",
"objectPermission",
"pageLayoutWidget",
"viewSort",