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. Review in cubic --- .../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', +}