From 29e48e16ba5260be89e352af6d485fcf635d4684 Mon Sep 17 00:00:00 2001
From: Etienne <45695613+etiennejouan@users.noreply.github.com>
Date: Thu, 2 Jul 2026 15:40:04 +0200
Subject: [PATCH] [Breaking change] fix: make pageLayout type field required
(#22450)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../twenty-sdk/src/cli/commands/dev/add.ts | 13 ++++++++
...stub-twenty-sdk-define.plugin.spec.ts.snap | 1 +
.../get-page-layout-base-file.spec.ts | 30 +++++++++++++++----
.../entity/entity-page-layout-template.ts | 12 ++++++--
.../entity-record-page-layout-template.ts | 4 +--
packages/twenty-sdk/src/sdk/define/index.ts | 1 +
.../define/page-layouts/define-page-layout.ts | 4 +++
...to-universal-flat-page-layout.util.spec.ts | 24 +++++++++++++--
...fest-to-universal-flat-page-layout.util.ts | 5 ++--
.../src/application/pageLayoutManifestType.ts | 2 +-
packages/twenty-shared/src/types/index.ts | 1 +
.../src/types/page-layout/PageLayoutType.ts | 6 ++++
12 files changed, 87 insertions(+), 16 deletions(-)
create mode 100644 packages/twenty-shared/src/types/page-layout/PageLayoutType.ts
diff --git a/packages/twenty-sdk/src/cli/commands/dev/add.ts b/packages/twenty-sdk/src/cli/commands/dev/add.ts
index f4da9982a0..e7010fe00c 100644
--- a/packages/twenty-sdk/src/cli/commands/dev/add.ts
+++ b/packages/twenty-sdk/src/cli/commands/dev/add.ts
@@ -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 };
}
diff --git a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap
index 4d13aff039..7963f2352d 100644
--- a/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap
+++ b/packages/twenty-sdk/src/cli/utilities/build/common/plugins/__tests__/__snapshots__/stub-twenty-sdk-define.plugin.spec.ts.snap
@@ -13,6 +13,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
"ObjectRecordGroupByDateGranularity",
"OnDeleteAction",
"PageLayoutTabLayoutMode",
+ "PageLayoutType",
"RelationType",
"RowLevelPermissionPredicateGroupLogicalOperator",
"RowLevelPermissionPredicateOperand",
diff --git a/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-page-layout-base-file.spec.ts b/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-page-layout-base-file.spec.ts
index c06269f1fd..067988f9ae 100644
--- a/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-page-layout-base-file.spec.ts
+++ b/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-page-layout-base-file.spec.ts
@@ -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})'/;
diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-page-layout-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-page-layout-template.ts
index 8651315191..62b214ddc8 100644
--- a/packages/twenty-sdk/src/cli/utilities/entity/entity-page-layout-template.ts
+++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-page-layout-template.ts
@@ -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()}',
diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-record-page-layout-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-record-page-layout-template.ts
index 7837b555be..9658963f85 100644
--- a/packages/twenty-sdk/src/cli/utilities/entity/entity-record-page-layout-template.ts
+++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-record-page-layout-template.ts
@@ -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: [
{
diff --git a/packages/twenty-sdk/src/sdk/define/index.ts b/packages/twenty-sdk/src/sdk/define/index.ts
index df7457a2ce..8fec66ad08 100644
--- a/packages/twenty-sdk/src/sdk/define/index.ts
+++ b/packages/twenty-sdk/src/sdk/define/index.ts
@@ -175,6 +175,7 @@ export {
NumberDataType,
ObjectRecordGroupByDateGranularity,
PageLayoutTabLayoutMode,
+ PageLayoutType,
ViewCalendarLayout,
ViewFilterGroupLogicalOperator,
ViewFilterOperand,
diff --git a/packages/twenty-sdk/src/sdk/define/page-layouts/define-page-layout.ts b/packages/twenty-sdk/src/sdk/define/page-layouts/define-page-layout.ts
index ca99f01669..9732b3fc07 100644
--- a/packages/twenty-sdk/src/sdk/define/page-layouts/define-page-layout.ts
+++ b/packages/twenty-sdk/src/sdk/define/page-layouts/define-page-layout.ts
@@ -13,6 +13,10 @@ export const definePageLayout: DefineEntity = (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) {
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/__tests__/from-page-layout-manifest-to-universal-flat-page-layout.util.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/__tests__/from-page-layout-manifest-to-universal-flat-page-layout.util.spec.ts
index dfac4a02a4..c94b3cecec 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/__tests__/from-page-layout-manifest-to-universal-flat-page-layout.util.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/__tests__/from-page-layout-manifest-to-universal-flat-page-layout.util.spec.ts
@@ -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',
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-page-layout-manifest-to-universal-flat-page-layout.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-page-layout-manifest-to-universal-flat-page-layout.util.ts
index d3bfaa7f6b..869f42954a 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-page-layout-manifest-to-universal-flat-page-layout.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-page-layout-manifest-to-universal-flat-page-layout.util.ts
@@ -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:
diff --git a/packages/twenty-shared/src/application/pageLayoutManifestType.ts b/packages/twenty-shared/src/application/pageLayoutManifestType.ts
index bcd271bb2e..5597b5bf0f 100644
--- a/packages/twenty-shared/src/application/pageLayoutManifestType.ts
+++ b/packages/twenty-shared/src/application/pageLayoutManifestType.ts
@@ -26,7 +26,7 @@ export type PageLayoutTabManifest = SyncableEntityOptions & {
export type PageLayoutManifest = SyncableEntityOptions & {
name: string;
- type?: string;
+ type: string;
objectUniversalIdentifier?: string;
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier?: string;
tabs?: PageLayoutTabManifest[];
diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts
index e855a4c4c1..994260fec6 100644
--- a/packages/twenty-shared/src/types/index.ts
+++ b/packages/twenty-shared/src/types/index.ts
@@ -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';
diff --git a/packages/twenty-shared/src/types/page-layout/PageLayoutType.ts b/packages/twenty-shared/src/types/page-layout/PageLayoutType.ts
new file mode 100644
index 0000000000..b527fec2bf
--- /dev/null
+++ b/packages/twenty-shared/src/types/page-layout/PageLayoutType.ts
@@ -0,0 +1,6 @@
+export enum PageLayoutType {
+ RECORD_INDEX = 'RECORD_INDEX',
+ RECORD_PAGE = 'RECORD_PAGE',
+ DASHBOARD = 'DASHBOARD',
+ STANDALONE_PAGE = 'STANDALONE_PAGE',
+}