[Breaking change] fix: make pageLayout type field required (#22450)

fixes https://github.com/twentyhq/twenty/issues/22251


**Summary**
- Fixes #22251 — NavigationMenuItem with type PAGE_LAYOUT returns 404
"Off track" for custom standalone pages
- Makes type a required field in PageLayoutManifest instead of relying
on a fallback default to RECORD_PAGE
- Adds PageLayoutType enum to twenty-shared and exports it from the SDK
for app developers
- Adds build-time validation in definePageLayout to reject manifests
missing type
- Updates the CLI add command to prompt users to select a page layout
type interactively

**Root cause**
When definePageLayout was called without type, the manifest converter
defaulted to RECORD_PAGE. The frontend route guard at /page/:id then
rejected it (only STANDALONE_PAGE is allowed), producing a 404.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22450?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-07-02 15:40:04 +02:00
committed by GitHub
parent 7cf8b58f1b
commit 29e48e16ba
12 changed files with 87 additions and 16 deletions
@@ -5,6 +5,7 @@ import { join, relative } from 'path';
import { SyncableEntity } from 'twenty-shared/application';
import {
FieldMetadataType,
PageLayoutType,
RelationOnDeleteAction,
RelationType,
ViewType,
@@ -217,8 +218,20 @@ export class EntityAddCommand {
case SyncableEntity.PageLayout: {
const name = await this.getEntityName(entity);
const { type } = await inquirer.prompt<{
type: PageLayoutType;
}>([
{
type: 'select',
name: 'type',
message: 'Select the page layout type:',
choices: Object.values(PageLayoutType),
},
]);
const file = getPageLayoutBaseFile({
name,
type,
});
return { name, file };
}
@@ -13,6 +13,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"ObjectRecordGroupByDateGranularity",
"OnDeleteAction",
"PageLayoutTabLayoutMode",
"PageLayoutType",
"RelationType",
"RowLevelPermissionPredicateGroupLogicalOperator",
"RowLevelPermissionPredicateOperand",
@@ -1,37 +1,51 @@
import { PageLayoutType } from 'twenty-shared/types';
import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template';
describe('getPageLayoutBaseFile', () => {
it('should render proper file using definePageLayout', () => {
it('should render proper file using definePageLayout with STANDALONE_PAGE type', () => {
const result = getPageLayoutBaseFile({
name: 'my-layout',
type: PageLayoutType.STANDALONE_PAGE,
});
expect(result).toContain(
"import { definePageLayout } from 'twenty-sdk/define';",
"import { definePageLayout, PageLayoutType } from 'twenty-sdk/define';",
);
expect(result).toContain('export default definePageLayout({');
expect(result).toContain("name: 'my-layout'");
expect(result).toContain('type: PageLayoutType.STANDALONE_PAGE');
expect(result).toContain("title: 'Overview'");
expect(result).toContain('widgets: []');
expect(result).toContain('tabs: [');
});
it('should render proper file with DASHBOARD type', () => {
const result = getPageLayoutBaseFile({
name: 'my-dashboard',
type: PageLayoutType.DASHBOARD,
});
expect(result).toContain('type: PageLayoutType.DASHBOARD');
});
it('should generate valid UUIDs for layout and tab', () => {
const result = getPageLayoutBaseFile({
name: 'test-layout',
type: PageLayoutType.STANDALONE_PAGE,
});
const uuidRegex =
/universalIdentifier: '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}'/g;
const matches = result.match(uuidRegex);
// Should have two UUIDs: one for the layout and one for the tab
expect(matches).toHaveLength(2);
});
it('should generate unique UUIDs for layout and tab', () => {
const result = getPageLayoutBaseFile({
name: 'unique-layout',
type: PageLayoutType.STANDALONE_PAGE,
});
const uuidRegex =
@@ -47,8 +61,14 @@ describe('getPageLayoutBaseFile', () => {
});
it('should generate unique UUIDs across calls', () => {
const result1 = getPageLayoutBaseFile({ name: 'layout-1' });
const result2 = getPageLayoutBaseFile({ name: 'layout-2' });
const result1 = getPageLayoutBaseFile({
name: 'layout-1',
type: PageLayoutType.STANDALONE_PAGE,
});
const result2 = getPageLayoutBaseFile({
name: 'layout-2',
type: PageLayoutType.STANDALONE_PAGE,
});
const uuidRegex =
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
@@ -1,11 +1,19 @@
import { type PageLayoutType } from 'twenty-shared/types';
import { v4 as uuidv4 } from 'uuid';
export const getPageLayoutBaseFile = ({ name }: { name: string }) => {
return `import { definePageLayout } from 'twenty-sdk/define';
export const getPageLayoutBaseFile = ({
name,
type,
}: {
name: string;
type: PageLayoutType;
}) => {
return `import { definePageLayout, PageLayoutType } from 'twenty-sdk/define';
export default definePageLayout({
universalIdentifier: '${uuidv4()}',
name: '${name}',
type: PageLayoutType.${type},
tabs: [
{
universalIdentifier: '${uuidv4()}',
@@ -9,12 +9,12 @@ export const getRecordPageLayoutBaseFile = ({
objectUniversalIdentifier: string;
fieldsWidgetViewUniversalIdentifier: string;
}) => {
return `import { definePageLayout, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
return `import { definePageLayout, PageLayoutTabLayoutMode, PageLayoutType } from 'twenty-sdk/define';
export default definePageLayout({
universalIdentifier: '${uuidv4()}',
name: 'Default ${objectLabelSingular} Layout',
type: 'RECORD_PAGE',
type: PageLayoutType.RECORD_PAGE,
objectUniversalIdentifier: '${objectUniversalIdentifier}',
tabs: [
{
@@ -175,6 +175,7 @@ export {
NumberDataType,
ObjectRecordGroupByDateGranularity,
PageLayoutTabLayoutMode,
PageLayoutType,
ViewCalendarLayout,
ViewFilterGroupLogicalOperator,
ViewFilterOperand,
@@ -13,6 +13,10 @@ export const definePageLayout: DefineEntity<PageLayoutConfig> = (config) => {
errors.push('PageLayout must have a name');
}
if (!config.type) {
errors.push('PageLayout must have a type');
}
if (config.tabs) {
for (const tab of config.tabs) {
if (!tab.universalIdentifier) {
@@ -5,11 +5,12 @@ describe('fromPageLayoutManifestToUniversalFlatPageLayout', () => {
const now = '2026-01-01T00:00:00.000Z';
const applicationUniversalIdentifier = 'app-uuid-1';
it('should convert a minimal page layout manifest', () => {
it('should convert a standalone page layout manifest', () => {
const result = fromPageLayoutManifestToUniversalFlatPageLayout({
pageLayoutManifest: {
universalIdentifier: 'pl-uuid-1',
name: 'My Page Layout',
type: PageLayoutType.STANDALONE_PAGE,
},
applicationUniversalIdentifier,
now,
@@ -20,7 +21,7 @@ describe('fromPageLayoutManifestToUniversalFlatPageLayout', () => {
applicationUniversalIdentifier,
);
expect(result.name).toBe('My Page Layout');
expect(result.type).toBe(PageLayoutType.RECORD_PAGE);
expect(result.type).toBe(PageLayoutType.STANDALONE_PAGE);
expect(result.objectMetadataUniversalIdentifier).toBeNull();
expect(
result.defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier,
@@ -28,10 +29,27 @@ describe('fromPageLayoutManifestToUniversalFlatPageLayout', () => {
expect(result.tabUniversalIdentifiers).toEqual([]);
});
it('should convert a fully specified page layout manifest', () => {
it('should convert a record page layout manifest', () => {
const result = fromPageLayoutManifestToUniversalFlatPageLayout({
pageLayoutManifest: {
universalIdentifier: 'pl-uuid-2',
name: 'Record Layout',
type: PageLayoutType.RECORD_PAGE,
objectUniversalIdentifier: 'obj-uuid-1',
},
applicationUniversalIdentifier,
now,
});
expect(result.name).toBe('Record Layout');
expect(result.type).toBe(PageLayoutType.RECORD_PAGE);
expect(result.objectMetadataUniversalIdentifier).toBe('obj-uuid-1');
});
it('should convert a fully specified page layout manifest', () => {
const result = fromPageLayoutManifestToUniversalFlatPageLayout({
pageLayoutManifest: {
universalIdentifier: 'pl-uuid-3',
name: 'Dashboard Layout',
type: PageLayoutType.DASHBOARD,
objectUniversalIdentifier: 'obj-uuid-1',
@@ -1,6 +1,6 @@
import { type PageLayoutManifest } from 'twenty-shared/application';
import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { type PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum';
import { type UniversalFlatPageLayout } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout.type';
export const fromPageLayoutManifestToUniversalFlatPageLayout = ({
@@ -16,8 +16,7 @@ export const fromPageLayoutManifestToUniversalFlatPageLayout = ({
universalIdentifier: pageLayoutManifest.universalIdentifier,
applicationUniversalIdentifier,
name: pageLayoutManifest.name,
type:
(pageLayoutManifest.type as PageLayoutType) ?? PageLayoutType.RECORD_PAGE,
type: pageLayoutManifest.type as PageLayoutType,
objectMetadataUniversalIdentifier:
pageLayoutManifest.objectUniversalIdentifier ?? null,
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier:
@@ -26,7 +26,7 @@ export type PageLayoutTabManifest = SyncableEntityOptions & {
export type PageLayoutManifest = SyncableEntityOptions & {
name: string;
type?: string;
type: string;
objectUniversalIdentifier?: string;
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier?: string;
tabs?: PageLayoutTabManifest[];
@@ -215,6 +215,7 @@ export type {
} from './page-layout/page-layout-widget-position.type';
export type { PageLayoutWidgetUniversalConfiguration } from './page-layout/page-layout-widget-universal-configuration.type';
export { PageLayoutTabLayoutMode } from './page-layout/PageLayoutTabLayoutMode';
export { PageLayoutType } from './page-layout/PageLayoutType';
export type { PageLayoutWidgetConditionalDisplay } from './page-layout/PageLayoutWidgetConditionalDisplay';
export type { RatioAggregateConfig } from './page-layout/ratio-aggregate-config.type';
export type { PartialFieldMetadataItem } from './PartialFieldMetadataItem';
@@ -0,0 +1,6 @@
export enum PageLayoutType {
RECORD_INDEX = 'RECORD_INDEX',
RECORD_PAGE = 'RECORD_PAGE',
DASHBOARD = 'DASHBOARD',
STANDALONE_PAGE = 'STANDALONE_PAGE',
}