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
@@ -2651,6 +2651,7 @@ type CommandMenuItem {
hotKeys: [String!]
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
pageLayoutId: UUID
applicationId: UUID
createdAt: DateTime!
updatedAt: DateTime!
@@ -4183,6 +4184,7 @@ input CreateCommandMenuItemInput {
conditionalAvailabilityExpression: String
availabilityObjectMetadataId: UUID
payload: JSON
pageLayoutId: UUID
}
input UpdateCommandMenuItemInput {
@@ -4196,6 +4198,7 @@ input UpdateCommandMenuItemInput {
availabilityObjectMetadataId: UUID
engineComponentKey: EngineComponentKey
hotKeys: [String!]
pageLayoutId: UUID
}
input CreateFrontComponentInput {
@@ -2368,6 +2368,7 @@ export interface CommandMenuItem {
hotKeys?: Scalars['String'][]
conditionalAvailabilityExpression?: Scalars['String']
availabilityObjectMetadataId?: Scalars['UUID']
pageLayoutId?: Scalars['UUID']
applicationId?: Scalars['UUID']
createdAt: Scalars['DateTime']
updatedAt: Scalars['DateTime']
@@ -5706,6 +5707,7 @@ export interface CommandMenuItemGenqlSelection{
hotKeys?: boolean | number
conditionalAvailabilityExpression?: boolean | number
availabilityObjectMetadataId?: boolean | number
pageLayoutId?: boolean | number
applicationId?: boolean | number
createdAt?: boolean | number
updatedAt?: boolean | number
@@ -6742,9 +6744,9 @@ update: UpdateLogicFunctionFromSourceInputUpdates}
export interface UpdateLogicFunctionFromSourceInputUpdates {name?: (Scalars['String'] | null),description?: (Scalars['String'] | null),timeoutSeconds?: (Scalars['Float'] | null),sourceHandlerCode?: (Scalars['String'] | null),toolInputSchema?: (Scalars['JSON'] | null),handlerName?: (Scalars['String'] | null),sourceHandlerPath?: (Scalars['String'] | null),isTool?: (Scalars['Boolean'] | null),cronTriggerSettings?: (Scalars['JSON'] | null),databaseEventTriggerSettings?: (Scalars['JSON'] | null),httpRouteTriggerSettings?: (Scalars['JSON'] | null)}
export interface CreateCommandMenuItemInput {workflowVersionId?: (Scalars['UUID'] | null),frontComponentId?: (Scalars['UUID'] | null),engineComponentKey: EngineComponentKey,label: Scalars['String'],icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),hotKeys?: (Scalars['String'][] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),payload?: (Scalars['JSON'] | null)}
export interface CreateCommandMenuItemInput {workflowVersionId?: (Scalars['UUID'] | null),frontComponentId?: (Scalars['UUID'] | null),engineComponentKey: EngineComponentKey,label: Scalars['String'],icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),hotKeys?: (Scalars['String'][] | null),conditionalAvailabilityExpression?: (Scalars['String'] | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),payload?: (Scalars['JSON'] | null),pageLayoutId?: (Scalars['UUID'] | null)}
export interface UpdateCommandMenuItemInput {id: Scalars['UUID'],label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),engineComponentKey?: (EngineComponentKey | null),hotKeys?: (Scalars['String'][] | null)}
export interface UpdateCommandMenuItemInput {id: Scalars['UUID'],label?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),shortLabel?: (Scalars['String'] | null),position?: (Scalars['Float'] | null),isPinned?: (Scalars['Boolean'] | null),availabilityType?: (CommandMenuItemAvailabilityType | null),availabilityObjectMetadataId?: (Scalars['UUID'] | null),engineComponentKey?: (EngineComponentKey | null),hotKeys?: (Scalars['String'][] | null),pageLayoutId?: (Scalars['UUID'] | null)}
export interface CreateFrontComponentInput {id?: (Scalars['UUID'] | null),name: Scalars['String'],description?: (Scalars['String'] | null),sourceComponentPath: Scalars['String'],builtComponentPath: Scalars['String'],componentName: Scalars['String'],builtComponentChecksum: Scalars['String']}
@@ -5334,6 +5334,9 @@ export default {
"availabilityObjectMetadataId": [
3
],
"pageLayoutId": [
3
],
"applicationId": [
3
],
@@ -10666,6 +10669,9 @@ export default {
"payload": [
15
],
"pageLayoutId": [
3
],
"__typename": [
1
]
@@ -10701,6 +10707,9 @@ export default {
"hotKeys": [
1
],
"pageLayoutId": [
3
],
"__typename": [
1
]
File diff suppressed because one or more lines are too long
@@ -32,6 +32,7 @@ import { useResetFocusStackToFocusItem } from '@/ui/utilities/focus/hooks/useRes
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useStore } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
import {
@@ -44,6 +45,7 @@ import { AppBasePath, AppPath, SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { AnalyticsType } from '~/generated-metadata/graphql';
import { usePageChangeEffectNavigateLocation } from '~/hooks/usePageChangeEffectNavigateLocation';
import { getPageLayoutIdForLocation } from '~/modules/app/utils/getPageLayoutIdForLocation';
import { useInitializeQueryParamState } from '~/modules/app/hooks/useInitializeQueryParamState';
import { isMatchingLocation } from '~/utils/isMatchingLocation';
import { getPageTitleFromPath } from '~/utils/title-utils';
@@ -146,10 +148,15 @@ export const PageChangeEffect = () => {
if (!previousLocation || previousLocation !== location.pathname) {
setPreviousLocation(location.pathname);
executeTasksOnAnyLocationChange();
} else {
return;
const newPageLayoutId = getPageLayoutIdForLocation({
location,
store,
});
store.set(currentPageLayoutIdState.atom, newPageLayoutId);
}
}, [location, previousLocation, executeTasksOnAnyLocationChange]);
}, [location, previousLocation, executeTasksOnAnyLocationChange, store]);
useEffect(() => {
initializeQueryParamState();
@@ -0,0 +1,59 @@
import { type getDefaultStore } from 'jotai';
import { type Location, matchPath } from 'react-router-dom';
import { AppPath, CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { objectMetadataItemFamilySelector } from '@/object-metadata/states/objectMetadataItemFamilySelector';
import { recordPageLayoutByObjectMetadataIdFamilySelector } from '@/page-layout/states/selectors/recordPageLayoutByObjectMetadataIdFamilySelector';
import { getDefaultRecordPageLayoutId } from '@/page-layout/utils/getDefaultRecordPageLayoutId';
const DASHBOARD_NAME_SINGULAR = CoreObjectNameSingular.Dashboard;
export const getPageLayoutIdForLocation = ({
location,
store,
}: {
location: Location;
store: ReturnType<typeof getDefaultStore>;
}): string | null => {
const recordShowMatch = matchPath(AppPath.RecordShowPage, location.pathname);
if (isDefined(recordShowMatch?.params.objectNameSingular)) {
const objectNameSingular = recordShowMatch.params.objectNameSingular;
if (objectNameSingular === DASHBOARD_NAME_SINGULAR) {
return null;
}
const objectMetadataItem = store.get(
objectMetadataItemFamilySelector.selectorFamily({
objectName: objectNameSingular,
objectNameType: 'singular',
}),
);
if (!isDefined(objectMetadataItem)) {
return null;
}
const recordPageLayout = store.get(
recordPageLayoutByObjectMetadataIdFamilySelector.selectorFamily({
objectMetadataId: objectMetadataItem.id,
}),
);
return isDefined(recordPageLayout)
? recordPageLayout.id
: getDefaultRecordPageLayoutId({
targetObjectNameSingular: objectNameSingular,
});
}
const pageLayoutMatch = matchPath(AppPath.PageLayoutPage, location.pathname);
if (isDefined(pageLayoutMatch?.params.pageLayoutId)) {
return pageLayoutMatch.params.pageLayoutId;
}
return null;
};
@@ -5,7 +5,9 @@ import { PinnedCommandMenuItemButtons } from '@/command-menu-item/display/compon
import { CommandMenuItemEditButton } from '@/command-menu-item/edit/components/CommandMenuItemEditButton';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useStore } from 'jotai';
import { useMemo } from 'react';
@@ -22,6 +24,7 @@ export const StandalonePageCommandMenu = () => {
const isMobile = useIsMobile();
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const currentWorkspace = useAtomStateValue(currentWorkspaceState);
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
const { objectMetadataItems } = useObjectMetadataItems();
const commandMenuContextApi = useMemo<CommandMenuContextApi>(() => {
@@ -83,6 +86,7 @@ export const StandalonePageCommandMenu = () => {
item.availabilityType !==
CommandMenuItemAvailabilityType.GLOBAL_OBJECT_CONTEXT,
)
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.filter((item) =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
@@ -92,7 +96,7 @@ export const StandalonePageCommandMenu = () => {
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItems, commandMenuContextApi]);
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
return (
<CommandMenuContext.Provider
@@ -4,7 +4,9 @@ import {
} from '@/command-menu-item/contexts/CommandMenuContext';
import { commandMenuItemsSelector } from '@/command-menu-item/states/commandMenuItemsSelector';
import { doesCommandMenuItemMatchObjectMetadataId } from '@/command-menu-item/utils/doesCommandMenuItemMatchObjectMetadataId';
import { doesCommandMenuItemMatchPageLayoutId } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageLayoutId';
import { doesCommandMenuItemMatchPageType } from '@/command-menu-item/utils/doesCommandMenuItemMatchPageType';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useMemo } from 'react';
import { type CommandMenuContextApi } from 'twenty-shared/types';
@@ -24,6 +26,7 @@ export const CommandMenuContextProviderContent = ({
commandMenuContextApi,
}: CommandMenuContextProviderContentProps) => {
const commandMenuItems = useAtomStateValue(commandMenuItemsSelector);
const currentPageLayoutId = useAtomStateValue(currentPageLayoutIdState);
const filteredCommandMenuItems = useMemo(() => {
const currentObjectMetadataItemId =
@@ -34,6 +37,7 @@ export const CommandMenuContextProviderContent = ({
doesCommandMenuItemMatchObjectMetadataId(currentObjectMetadataItemId),
)
.filter(doesCommandMenuItemMatchPageType(commandMenuContextApi.pageType))
.filter(doesCommandMenuItemMatchPageLayoutId(currentPageLayoutId))
.filter((item) =>
evaluateConditionalAvailabilityExpression(
item.conditionalAvailabilityExpression,
@@ -43,7 +47,7 @@ export const CommandMenuContextProviderContent = ({
.sort(
(firstItem, secondItem) => firstItem.position - secondItem.position,
);
}, [commandMenuItems, commandMenuContextApi]);
}, [commandMenuItems, commandMenuContextApi, currentPageLayoutId]);
return (
<CommandMenuContext.Provider
@@ -28,5 +28,6 @@ export const COMMAND_MENU_ITEM_FRAGMENT = gql`
conditionalAvailabilityExpression
availabilityType
availabilityObjectMetadataId
pageLayoutId
}
`;
@@ -0,0 +1,6 @@
import { isDefined } from 'twenty-shared/utils';
import { type CommandMenuItemFieldsFragment } from '~/generated-metadata/graphql';
export const doesCommandMenuItemMatchPageLayoutId =
(pageLayoutId: string | null) => (item: CommandMenuItemFieldsFragment) =>
!isDefined(item.pageLayoutId) || item.pageLayoutId === pageLayoutId;
@@ -95,7 +95,9 @@ export const useSetIsPageLayoutInEditMode = (pageLayoutIdFromProps: string) => {
store.set(contextStoreIsFullTabWidgetInEditModeState, value);
store.set(currentPageLayoutIdState.atom, value ? pageLayoutId : null);
if (value) {
store.set(currentPageLayoutIdState.atom, pageLayoutId);
}
},
[
isDashboardInEditModeState,
@@ -238,6 +238,8 @@ export class RefactorNavigationCommandsCommand extends ActiveOrSuspendedWorkspac
workflowVersionId: null,
availabilityObjectMetadataId: null,
availabilityObjectMetadataUniversalIdentifier: null,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
});
@@ -0,0 +1,33 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('1.23.0', 1776168404836)
export class AddPageLayoutIdToCommandMenuItemFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" ADD "pageLayoutId" uuid',
);
await queryRunner.query(
'CREATE INDEX "IDX_COMMAND_MENU_ITEM_PAGE_LAYOUT_ID_WORKSPACE_ID" ON "core"."commandMenuItem" ("pageLayoutId", "workspaceId") ',
);
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" ADD CONSTRAINT "FK_8577be6253969364b6725b807b4" FOREIGN KEY ("pageLayoutId") REFERENCES "core"."pageLayout"("id") ON DELETE CASCADE ON UPDATE NO ACTION',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" DROP CONSTRAINT "FK_8577be6253969364b6725b807b4"',
);
await queryRunner.query(
'DROP INDEX "core"."IDX_COMMAND_MENU_ITEM_PAGE_LAYOUT_ID_WORKSPACE_ID"',
);
await queryRunner.query(
'ALTER TABLE "core"."commandMenuItem" DROP COLUMN "pageLayoutId"',
);
}
}
@@ -13,6 +13,7 @@ import { AddCreditBalanceToBillingCustomerFastInstanceCommand } from 'src/databa
import { BackfillWorkspaceIdOnIndirectEntitiesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-instance-command-slow-1775758621018-backfill-workspace-id-on-indirect-entities';
import { DropWorkspaceVersionColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1785000000000-drop-workspace-version-column';
import { AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776090711153-add-global-object-context-to-command-menu-item-availability-type';
import { AddPageLayoutIdToCommandMenuItemFastInstanceCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-instance-command-fast-1776168404836-add-page-layout-id-to-command-menu-item';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -28,4 +29,5 @@ export const INSTANCE_COMMANDS = [
AddCreditBalanceToBillingCustomerFastInstanceCommand,
DropWorkspaceVersionColumnFastInstanceCommand,
AddGlobalObjectContextToCommandMenuItemAvailabilityTypeFastInstanceCommand,
AddPageLayoutIdToCommandMenuItemFastInstanceCommand,
];
@@ -44,6 +44,7 @@ export const fromCommandMenuItemManifestToUniversalFlatCommandMenuItem = ({
payload: null,
hotKeys: null,
workflowVersionId: null,
pageLayoutUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
};
@@ -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",
@@ -82,13 +82,15 @@ export const prefillFrontComponentCommandMenuItems = async ({
icon: definition.icon,
shortLabel: definition.label,
position: definition.position,
isPinned: false,
isPinned: definition.isPinned ?? false,
availabilityType: CommandMenuItemAvailabilityType.GLOBAL,
conditionalAvailabilityExpression: null,
availabilityObjectMetadataId: null,
availabilityObjectMetadataUniversalIdentifier: null,
payload: null,
hotKeys: null,
pageLayoutId: definition.pageLayoutId ?? null,
pageLayoutUniversalIdentifier: definition.pageLayoutId ?? null,
createdAt: now,
updatedAt: now,
};
@@ -1,5 +1,8 @@
import { v5 as uuidv5 } from 'uuid';
import { PAGE_LAYOUT_SEEDS } from 'src/engine/workspace-manager/dev-seeder/core/constants/page-layout-seeds.constant';
import { generateSeedId } from 'src/engine/workspace-manager/dev-seeder/core/utils/generate-seed-id.util';
const SEED_FRONT_COMPONENT_ID_NAMESPACE =
'e7a3b1c4-f5d6-4e8a-9b2c-3d4e5f6a7b8c';
@@ -20,6 +23,8 @@ export type SeedFrontComponentCommandMenuItemDefinition = {
label: string;
icon: string;
position: number;
isPinned?: boolean;
pageLayoutId?: string | null;
};
export const getSeedFrontComponentIds = (workspaceId: string) => ({
@@ -98,5 +103,20 @@ export const getSeedFrontComponentCommandMenuItemDefinitions = (
icon: 'IconBell',
position: 201,
},
{
universalIdentifier: uuidv5(
`${workspaceId}:seed-front-component-command:standalone-page-show-notification`,
SEED_FRONT_COMPONENT_ID_NAMESPACE,
),
frontComponentId: showNotificationId,
label: 'Show Notification',
icon: 'IconStar',
position: 202,
isPinned: true,
pageLayoutId: generateSeedId(
workspaceId,
PAGE_LAYOUT_SEEDS.DOCUMENTATION_STANDALONE_PAGE,
),
},
];
};
@@ -68,6 +68,8 @@ export const prefillWorkflowCommandMenuItems = async ({
availabilityObjectMetadataUniversalIdentifier: null,
payload: null,
hotKeys: null,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
};
@@ -74,6 +74,8 @@ export const createStandardCommandMenuItemFlatMetadata = ({
availabilityObjectMetadataId: resolvedObjectMetadataId,
availabilityObjectMetadataUniversalIdentifier:
resolvedObjectMetadataUniversalIdentifier,
pageLayoutId: null,
pageLayoutUniversalIdentifier: null,
createdAt: now,
updatedAt: now,
};
@@ -30,7 +30,7 @@ export class CreateCommandMenuItemActionHandlerService extends WorkspaceMigratio
flatApplication,
workspaceId,
}: WorkspaceMigrationActionRunnerArgs<UniversalCreateCommandMenuItemAction>): Promise<FlatCreateCommandMenuItemAction> {
const { availabilityObjectMetadataId, frontComponentId } =
const { availabilityObjectMetadataId, frontComponentId, pageLayoutId } =
resolveUniversalRelationIdentifiersToIds({
flatEntityMaps: allFlatEntityMaps,
metadataName: action.metadataName,
@@ -48,6 +48,7 @@ export class CreateCommandMenuItemActionHandlerService extends WorkspaceMigratio
...action.flatEntity,
availabilityObjectMetadataId,
frontComponentId,
pageLayoutId,
applicationId: flatApplication.id,
id: action.id ?? v4(),
workspaceId,