[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: [
{