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
@@ -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;
};