[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:
@@ -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 };
|
||||
}
|
||||
|
||||
+1
@@ -13,6 +13,7 @@ exports[`stub-twenty-sdk-define plugin > matches the recorded export partition 1
|
||||
"ObjectRecordGroupByDateGranularity",
|
||||
"OnDeleteAction",
|
||||
"PageLayoutTabLayoutMode",
|
||||
"PageLayoutType",
|
||||
"RelationType",
|
||||
"RowLevelPermissionPredicateGroupLogicalOperator",
|
||||
"RowLevelPermissionPredicateOperand",
|
||||
|
||||
+25
-5
@@ -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) {
|
||||
|
||||
+21
-3
@@ -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',
|
||||
|
||||
+2
-3
@@ -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',
|
||||
}
|
||||
Reference in New Issue
Block a user