Introduce standalone page (#19675)

Add support for standalone pages: a new `PageLayout` type
(`STANDALONE_PAGE`) that can be rendered independently at
`/page/:pageLayoutId`, not tied to any record or object context.

- New `STANDALONE_PAGE` page layout type
- New `PAGE_LAYOUT` navigation menu item type: adds a `pageLayoutId`
foreign key to `NavigationMenuItemEntity`, allowing sidebar items to
link directly to standalone pages
- New `GLOBAL_OBJECT_CONTEXT` command menu availability type: separates
object-context-dependent commands (Create Record, Import, Export, See
Deleted, Create View, Hide Deleted) from truly global ones, so
standalone pages only show relevant commands
- Frontend routing & rendering: adds a `/page/:pageLayoutId` route with
its own page component, header, and command menu
- Widget rendering refactor
- Instance commands: two fast 1.22 migrations: `pageLayoutId` column +
`STANDALONE_PAGE` enum, and `GLOBAL_OBJECT_CONTEXT` availability type
enum
- Workspace command: backfills existing command menu items from `GLOBAL`
to `GLOBAL_OBJECT_CONTEXT` where appropriate
- Dev seeds: adds a sample "Star History" standalone page with an iframe
widget for local development
This commit is contained in:
Raphaël Bosi
2026-04-14 17:52:45 +02:00
committed by GitHub
parent 43249d80e8
commit a88d1f4442
122 changed files with 2250 additions and 836 deletions
@@ -16,6 +16,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
flatNavigationMenuItemMaps,
flatObjectMetadataMaps,
flatViewMaps,
flatPageLayoutMaps,
}: {
createNavigationMenuItemInput: CreateNavigationMenuItemInput;
workspaceId: string;
@@ -23,7 +24,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
} & Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps' | 'flatViewMaps'
'flatObjectMetadataMaps' | 'flatViewMaps' | 'flatPageLayoutMaps'
>): FlatNavigationMenuItem => {
const id = createNavigationMenuItemInput.id ?? uuidv4();
const now = new Date().toISOString();
@@ -52,6 +53,7 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
targetObjectMetadataUniversalIdentifier,
viewUniversalIdentifier,
folderUniversalIdentifier,
pageLayoutUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'navigationMenuItem',
foreignKeyValues: {
@@ -59,11 +61,13 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
createNavigationMenuItemInput.targetObjectMetadataId,
viewId: createNavigationMenuItemInput.viewId,
folderId: createNavigationMenuItemInput.folderId,
pageLayoutId: createNavigationMenuItemInput.pageLayoutId,
},
flatEntityMaps: {
flatObjectMetadataMaps,
flatViewMaps,
flatNavigationMenuItemMaps,
flatPageLayoutMaps,
},
});
@@ -80,6 +84,8 @@ export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
viewUniversalIdentifier,
folderId: createNavigationMenuItemInput.folderId ?? null,
folderUniversalIdentifier,
pageLayoutId: createNavigationMenuItemInput.pageLayoutId ?? null,
pageLayoutUniversalIdentifier,
name: createNavigationMenuItemInput.name ?? null,
link: createNavigationMenuItemInput.link ?? null,
icon: createNavigationMenuItemInput.icon ?? null,
@@ -16,6 +16,7 @@ export const fromFlatNavigationMenuItemToNavigationMenuItemDto = (
link: flatNavigationMenuItem.link ?? undefined,
icon: flatNavigationMenuItem.icon ?? undefined,
color: flatNavigationMenuItem.color ?? undefined,
pageLayoutId: flatNavigationMenuItem.pageLayoutId ?? undefined,
position: flatNavigationMenuItem.position,
workspaceId: flatNavigationMenuItem.workspaceId,
applicationId: flatNavigationMenuItem.applicationId ?? undefined,
@@ -13,6 +13,7 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({
objectMetadataIdToUniversalIdentifierMap,
navigationMenuItemIdToUniversalIdentifierMap,
viewIdToUniversalIdentifierMap,
pageLayoutIdToUniversalIdentifierMap,
}: FromEntityToFlatEntityArgs<'navigationMenuItem'>): FlatNavigationMenuItem => {
const applicationUniversalIdentifier =
applicationIdToUniversalIdentifierMap.get(
@@ -73,6 +74,22 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({
}
}
let pageLayoutUniversalIdentifier: string | null = null;
if (isDefined(navigationMenuItemEntity.pageLayoutId)) {
pageLayoutUniversalIdentifier =
pageLayoutIdToUniversalIdentifierMap.get(
navigationMenuItemEntity.pageLayoutId,
) ?? null;
if (!isDefined(pageLayoutUniversalIdentifier)) {
throw new FlatEntityMapsException(
`PageLayout with id ${navigationMenuItemEntity.pageLayoutId} not found for navigationMenuItem ${navigationMenuItemEntity.id}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
}
return {
id: navigationMenuItemEntity.id,
type: navigationMenuItemEntity.type,
@@ -85,6 +102,7 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({
link: navigationMenuItemEntity.link,
icon: navigationMenuItemEntity.icon,
color: navigationMenuItemEntity.color,
pageLayoutId: navigationMenuItemEntity.pageLayoutId,
position: navigationMenuItemEntity.position,
workspaceId: navigationMenuItemEntity.workspaceId,
universalIdentifier: navigationMenuItemEntity.universalIdentifier,
@@ -95,5 +113,6 @@ export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = ({
targetObjectMetadataUniversalIdentifier,
folderUniversalIdentifier,
viewUniversalIdentifier,
pageLayoutUniversalIdentifier,
};
};
@@ -1,5 +1,6 @@
import { isDefined } from 'twenty-shared/utils';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util';
import { FLAT_NAVIGATION_MENU_ITEM_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant';
@@ -15,13 +16,17 @@ import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-
export const fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow =
({
flatNavigationMenuItemMaps,
flatPageLayoutMaps,
updateNavigationMenuItemInput,
}: {
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
updateNavigationMenuItemInput: UpdateNavigationMenuItemInput & {
id: string;
};
}): FlatNavigationMenuItem => {
} & Pick<
AllFlatEntityMaps,
'flatPageLayoutMaps'
>): FlatNavigationMenuItem => {
const existingFlatNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: updateNavigationMenuItemInput.id,
flatEntityMaps: flatNavigationMenuItemMaps,
@@ -59,5 +64,19 @@ export const fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOr
folderUniversalIdentifier;
}
if (updates.pageLayoutId !== undefined) {
const { pageLayoutUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'navigationMenuItem',
foreignKeyValues: {
pageLayoutId: flatNavigationMenuItemToUpdate.pageLayoutId,
},
flatEntityMaps: { flatPageLayoutMaps },
});
flatNavigationMenuItemToUpdate.pageLayoutUniversalIdentifier =
pageLayoutUniversalIdentifier;
}
return flatNavigationMenuItemToUpdate;
};