Make page layout tab layoutMode diffable and default standalone pages to vertical list (#23596)
Reported on Discord: an app declared a `STANDALONE_PAGE` layout with one `FRONT_COMPONENT` widget and got a small bordered card instead of a full-bleed page, and setting `layoutMode` afterwards changed nothing. Two bugs: - `pageLayoutTab.layoutMode` was `toCompare: false`, so it was written once at create and never diffed again. Changing it in a manifest and redeploying was a silent no-op, and `updatePageLayoutTab(layoutMode:)` was accepted by the API then dropped by the runner's update sanitizer. - A manifest tab that omits `layoutMode` defaulted to `GRID` regardless of page layout type, and a `GRID` tab always renders its widgets as cards on a 12-column grid. Standalone pages now default to `VERTICAL_LIST`, where a lone widget owns the tab. Also fixes the SDK scaffolder (`twenty add page-layout` emitted a tab with no `position`, which does not typecheck) and the docs claim that a single widget is always full-bleed. Worth knowing for review: this does not migrate workspaces holding legacy `CANVAS` tabs. The standard-app sync only runs against a fresh schema, so those rows stay `CANVAS` and keep rendering correctly through the derived presentation. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23596?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:
+1
-1
@@ -18,7 +18,7 @@ export default definePageLayout({
|
||||
title: 'Overview',
|
||||
position: 0,
|
||||
icon: 'IconApps',
|
||||
layoutMode: PageLayoutTabLayoutMode.CANVAS,
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
widgets: [
|
||||
{
|
||||
universalIdentifier: MAIN_PAGE_WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
|
||||
@@ -53,7 +53,8 @@ export default definePageLayout({
|
||||
|
||||
- `type` is one of `'RECORD_INDEX'`, `'RECORD_PAGE'`, `'DASHBOARD'` or `'STANDALONE_PAGE'`. Use `'RECORD_PAGE'` to customize the detail view of a specific object.
|
||||
- `objectUniversalIdentifier` specifies which object this layout applies to.
|
||||
- Each `tab` defines a section of the page with a `title`, `position`, and `layoutMode` (`VERTICAL_LIST` for record pages, `GRID` for dashboards). A tab holding a single widget renders it full-bleed automatically; with several widgets they stack as cards.
|
||||
- Each `tab` defines a section of the page with a `title`, `position`, and `layoutMode`: `VERTICAL_LIST` for record pages and standalone pages, `GRID` for dashboards. In a `VERTICAL_LIST` tab, a single widget renders full-bleed and owns the whole tab; with several widgets they stack as cards. A `GRID` tab always lays its widgets out as cards on a 12-column grid, whatever their number, so pick `VERTICAL_LIST` when you want one widget to fill the page.
|
||||
- Set `layoutMode` explicitly. Omitting it gives you `VERTICAL_LIST` on a `STANDALONE_PAGE` and `GRID` everywhere else, which is rarely what you want on a record page.
|
||||
- Each `widget` inside a tab can render a [front component](/developers/extend/apps/layout/front-components), a relation list, or other built-in widget types.
|
||||
- `position` on tabs controls their order. Use higher values (e.g., 50) to place custom tabs after built-in ones.
|
||||
|
||||
|
||||
+15
-3
@@ -9,17 +9,28 @@ describe('getPageLayoutBaseFile', () => {
|
||||
type: PageLayoutType.STANDALONE_PAGE,
|
||||
});
|
||||
|
||||
expect(result).toContain(
|
||||
"import { definePageLayout, PageLayoutType } from 'twenty-sdk/define';",
|
||||
);
|
||||
expect(result).toContain("} from 'twenty-sdk/define';");
|
||||
expect(result).toContain('definePageLayout,');
|
||||
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('position: 0');
|
||||
expect(result).toContain('widgets: []');
|
||||
expect(result).toContain('tabs: [');
|
||||
});
|
||||
|
||||
it('should render a standalone page tab in VERTICAL_LIST so a lone widget owns the page', () => {
|
||||
const result = getPageLayoutBaseFile({
|
||||
name: 'my-layout',
|
||||
type: PageLayoutType.STANDALONE_PAGE,
|
||||
});
|
||||
|
||||
expect(result).toContain(
|
||||
'layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST',
|
||||
);
|
||||
});
|
||||
|
||||
it('should render proper file with DASHBOARD type', () => {
|
||||
const result = getPageLayoutBaseFile({
|
||||
name: 'my-dashboard',
|
||||
@@ -27,6 +38,7 @@ describe('getPageLayoutBaseFile', () => {
|
||||
});
|
||||
|
||||
expect(result).toContain('type: PageLayoutType.DASHBOARD');
|
||||
expect(result).toContain('layoutMode: PageLayoutTabLayoutMode.GRID');
|
||||
});
|
||||
|
||||
it('should generate valid UUIDs for layout and tab', () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type PageLayoutType } from 'twenty-shared/types';
|
||||
import { PageLayoutTabLayoutMode, PageLayoutType } from 'twenty-shared/types';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export const getPageLayoutBaseFile = ({
|
||||
@@ -8,7 +8,16 @@ export const getPageLayoutBaseFile = ({
|
||||
name: string;
|
||||
type: PageLayoutType;
|
||||
}) => {
|
||||
return `import { definePageLayout, PageLayoutType } from 'twenty-sdk/define';
|
||||
const layoutMode =
|
||||
type === PageLayoutType.DASHBOARD
|
||||
? PageLayoutTabLayoutMode.GRID
|
||||
: PageLayoutTabLayoutMode.VERTICAL_LIST;
|
||||
|
||||
return `import {
|
||||
definePageLayout,
|
||||
PageLayoutTabLayoutMode,
|
||||
PageLayoutType,
|
||||
} from 'twenty-sdk/define';
|
||||
|
||||
export default definePageLayout({
|
||||
universalIdentifier: '${uuidv4()}',
|
||||
@@ -18,6 +27,8 @@ export default definePageLayout({
|
||||
{
|
||||
universalIdentifier: '${uuidv4()}',
|
||||
title: 'Overview',
|
||||
position: 0,
|
||||
layoutMode: PageLayoutTabLayoutMode.${layoutMode},
|
||||
widgets: [],
|
||||
},
|
||||
],
|
||||
|
||||
+52
-1
@@ -1,4 +1,4 @@
|
||||
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
|
||||
import { PageLayoutTabLayoutMode, PageLayoutType } from 'twenty-shared/types';
|
||||
|
||||
import { fromPageLayoutTabManifestToUniversalFlatPageLayoutTab } from 'src/engine/core-modules/application/application-manifest/converters/from-page-layout-tab-manifest-to-universal-flat-page-layout-tab.util';
|
||||
|
||||
@@ -15,6 +15,7 @@ describe('fromPageLayoutTabManifestToUniversalFlatPageLayoutTab', () => {
|
||||
position: 0,
|
||||
},
|
||||
pageLayoutUniversalIdentifier,
|
||||
pageLayoutType: undefined,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
});
|
||||
@@ -43,6 +44,7 @@ describe('fromPageLayoutTabManifestToUniversalFlatPageLayoutTab', () => {
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
},
|
||||
pageLayoutUniversalIdentifier,
|
||||
pageLayoutType: undefined,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
});
|
||||
@@ -52,4 +54,53 @@ describe('fromPageLayoutTabManifestToUniversalFlatPageLayoutTab', () => {
|
||||
expect(result.icon).toBe('IconLayout');
|
||||
expect(result.layoutMode).toBe(PageLayoutTabLayoutMode.VERTICAL_LIST);
|
||||
});
|
||||
|
||||
it('should default a standalone page tab to VERTICAL_LIST', () => {
|
||||
const result = fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({
|
||||
pageLayoutTabManifest: {
|
||||
universalIdentifier: 'tab-uuid-3',
|
||||
title: 'Overview',
|
||||
position: 0,
|
||||
},
|
||||
pageLayoutUniversalIdentifier,
|
||||
pageLayoutType: PageLayoutType.STANDALONE_PAGE,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.layoutMode).toBe(PageLayoutTabLayoutMode.VERTICAL_LIST);
|
||||
});
|
||||
|
||||
it('should default a dashboard tab to GRID', () => {
|
||||
const result = fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({
|
||||
pageLayoutTabManifest: {
|
||||
universalIdentifier: 'tab-uuid-4',
|
||||
title: 'Overview',
|
||||
position: 0,
|
||||
},
|
||||
pageLayoutUniversalIdentifier,
|
||||
pageLayoutType: PageLayoutType.DASHBOARD,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.layoutMode).toBe(PageLayoutTabLayoutMode.GRID);
|
||||
});
|
||||
|
||||
it('should keep an explicit layoutMode on a standalone page tab', () => {
|
||||
const result = fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({
|
||||
pageLayoutTabManifest: {
|
||||
universalIdentifier: 'tab-uuid-5',
|
||||
title: 'Overview',
|
||||
position: 0,
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
},
|
||||
pageLayoutUniversalIdentifier,
|
||||
pageLayoutType: PageLayoutType.STANDALONE_PAGE,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.layoutMode).toBe(PageLayoutTabLayoutMode.GRID);
|
||||
});
|
||||
});
|
||||
|
||||
+11
-3
@@ -1,16 +1,21 @@
|
||||
import { type PageLayoutTabManifest } from 'twenty-shared/application';
|
||||
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
|
||||
import {
|
||||
type PageLayoutManifest,
|
||||
type PageLayoutTabManifest,
|
||||
} from 'twenty-shared/application';
|
||||
import { PageLayoutTabLayoutMode, PageLayoutType } from 'twenty-shared/types';
|
||||
|
||||
import { type UniversalFlatPageLayoutTab } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-tab.type';
|
||||
|
||||
export const fromPageLayoutTabManifestToUniversalFlatPageLayoutTab = ({
|
||||
pageLayoutTabManifest,
|
||||
pageLayoutUniversalIdentifier,
|
||||
pageLayoutType,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
}: {
|
||||
pageLayoutTabManifest: PageLayoutTabManifest;
|
||||
pageLayoutUniversalIdentifier: string;
|
||||
pageLayoutType: PageLayoutManifest['type'] | undefined;
|
||||
applicationUniversalIdentifier: string;
|
||||
now: string;
|
||||
}): UniversalFlatPageLayoutTab => {
|
||||
@@ -22,7 +27,10 @@ export const fromPageLayoutTabManifestToUniversalFlatPageLayoutTab = ({
|
||||
pageLayoutUniversalIdentifier,
|
||||
icon: pageLayoutTabManifest.icon ?? null,
|
||||
layoutMode:
|
||||
pageLayoutTabManifest.layoutMode ?? PageLayoutTabLayoutMode.GRID,
|
||||
pageLayoutTabManifest.layoutMode ??
|
||||
(pageLayoutType === PageLayoutType.STANDALONE_PAGE
|
||||
? PageLayoutTabLayoutMode.VERTICAL_LIST
|
||||
: PageLayoutTabLayoutMode.GRID),
|
||||
isActive: true,
|
||||
isSystemSideEffect: false,
|
||||
widgetUniversalIdentifiers: [],
|
||||
|
||||
+8
@@ -526,6 +526,7 @@ export class ComputeApplicationManifestAllUniversalFlatEntityMapsService {
|
||||
pageLayoutTabManifest,
|
||||
pageLayoutUniversalIdentifier:
|
||||
pageLayoutManifest.universalIdentifier,
|
||||
pageLayoutType: pageLayoutManifest.type,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
}),
|
||||
@@ -560,12 +561,19 @@ export class ComputeApplicationManifestAllUniversalFlatEntityMapsService {
|
||||
);
|
||||
}
|
||||
|
||||
const referencedPageLayoutManifest = manifest.pageLayouts?.find(
|
||||
(pageLayoutManifest) =>
|
||||
pageLayoutManifest.universalIdentifier ===
|
||||
pageLayoutTabManifest.pageLayoutUniversalIdentifier,
|
||||
);
|
||||
|
||||
addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
|
||||
universalFlatEntity:
|
||||
fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({
|
||||
pageLayoutTabManifest,
|
||||
pageLayoutUniversalIdentifier:
|
||||
pageLayoutTabManifest.pageLayoutUniversalIdentifier,
|
||||
pageLayoutType: referencedPageLayoutManifest?.type,
|
||||
applicationUniversalIdentifier,
|
||||
now,
|
||||
}),
|
||||
|
||||
+1
@@ -213,6 +213,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
|
||||
"position",
|
||||
"deletedAt",
|
||||
"icon",
|
||||
"layoutMode",
|
||||
"isActive",
|
||||
"overrides",
|
||||
],
|
||||
|
||||
+1
-1
@@ -1116,7 +1116,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
|
||||
universalProperty: 'pageLayoutUniversalIdentifier',
|
||||
},
|
||||
layoutMode: {
|
||||
toCompare: false,
|
||||
toCompare: true,
|
||||
toStringify: false,
|
||||
universalProperty: undefined,
|
||||
},
|
||||
|
||||
+55
-1
@@ -6,6 +6,7 @@ import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-enti
|
||||
import { FlatPageLayoutWidgetTypeValidatorService } from 'src/engine/metadata-modules/flat-page-layout-widget/services/flat-page-layout-widget-type-validator.service';
|
||||
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
|
||||
import { PageLayoutTabExceptionCode } from 'src/engine/metadata-modules/page-layout-tab/exceptions/page-layout-tab.exception';
|
||||
import { PageLayoutWidgetExceptionCode } from 'src/engine/metadata-modules/page-layout-widget/exceptions/page-layout-widget.exception';
|
||||
import { type UniversalFlatPageLayoutTab } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-page-layout-tab.type';
|
||||
import { FlatPageLayoutWidgetValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-page-layout-widget-validator.service';
|
||||
|
||||
@@ -18,12 +19,21 @@ const WIDGET_UNIVERSAL_IDENTIFIER = '00000000-0000-0000-0000-000000000111';
|
||||
|
||||
const tab = (
|
||||
universalIdentifier = EXISTING_TAB_UNIVERSAL_IDENTIFIER,
|
||||
layoutMode = PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
): UniversalFlatPageLayoutTab =>
|
||||
({
|
||||
universalIdentifier,
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
layoutMode,
|
||||
}) as unknown as UniversalFlatPageLayoutTab;
|
||||
|
||||
const GRID_POSITION = {
|
||||
layoutMode: PageLayoutTabLayoutMode.GRID,
|
||||
row: 0,
|
||||
column: 0,
|
||||
rowSpan: 4,
|
||||
columnSpan: 4,
|
||||
};
|
||||
|
||||
const widget = (
|
||||
universalIdentifier = WIDGET_UNIVERSAL_IDENTIFIER,
|
||||
pageLayoutTabUniversalIdentifier = EXISTING_TAB_UNIVERSAL_IDENTIFIER,
|
||||
@@ -142,5 +152,49 @@ describe('FlatPageLayoutWidgetValidatorService', () => {
|
||||
result.flatEntityMinimalInformation.pageLayoutTabUniversalIdentifier,
|
||||
).toBe(DESTINATION_TAB_UNIVERSAL_IDENTIFIER);
|
||||
});
|
||||
|
||||
it('rejects a position whose layout mode does not match its tab', async () => {
|
||||
const result = await service.validateFlatPageLayoutWidgetUpdate(
|
||||
buildUpdateArgs({
|
||||
update: { position: GRID_POSITION },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.errors.map((error) => error.code)).toContain(
|
||||
PageLayoutWidgetExceptionCode.INVALID_PAGE_LAYOUT_WIDGET_DATA,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts a position whose layout mode matches a tab already flipped in the same build', async () => {
|
||||
const result = await service.validateFlatPageLayoutWidgetUpdate(
|
||||
buildUpdateArgs({
|
||||
update: { position: GRID_POSITION },
|
||||
tabs: [
|
||||
tab(
|
||||
EXISTING_TAB_UNIVERSAL_IDENTIFIER,
|
||||
PageLayoutTabLayoutMode.GRID,
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('accepts a null position against a tab of any layout mode', async () => {
|
||||
const result = await service.validateFlatPageLayoutWidgetUpdate(
|
||||
buildUpdateArgs({
|
||||
update: { position: null },
|
||||
tabs: [
|
||||
tab(
|
||||
EXISTING_TAB_UNIVERSAL_IDENTIFIER,
|
||||
PageLayoutTabLayoutMode.GRID,
|
||||
),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+15
@@ -1,10 +1,24 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`Page layout tab update should succeed should update page layout tab layout mode 1`] = `
|
||||
{
|
||||
"createdAt": Any<String>,
|
||||
"deletedAt": null,
|
||||
"id": Any<String>,
|
||||
"layoutMode": "VERTICAL_LIST",
|
||||
"pageLayoutId": Any<String>,
|
||||
"position": 0,
|
||||
"title": "Original Tab Title",
|
||||
"updatedAt": Any<String>,
|
||||
}
|
||||
`;
|
||||
|
||||
exports[`Page layout tab update should succeed should update page layout tab position 1`] = `
|
||||
{
|
||||
"createdAt": Any<String>,
|
||||
"deletedAt": null,
|
||||
"id": Any<String>,
|
||||
"layoutMode": "GRID",
|
||||
"pageLayoutId": Any<String>,
|
||||
"position": 10,
|
||||
"title": "Original Tab Title",
|
||||
@@ -17,6 +31,7 @@ exports[`Page layout tab update should succeed should update page layout tab tit
|
||||
"createdAt": Any<String>,
|
||||
"deletedAt": null,
|
||||
"id": Any<String>,
|
||||
"layoutMode": "GRID",
|
||||
"pageLayoutId": Any<String>,
|
||||
"position": 0,
|
||||
"title": "Updated Tab Title",
|
||||
|
||||
+10
@@ -8,11 +8,13 @@ import {
|
||||
type EachTestingContext,
|
||||
eachTestingContextFilter,
|
||||
} from 'twenty-shared/testing';
|
||||
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
|
||||
|
||||
type TestContext = {
|
||||
input: {
|
||||
title?: string;
|
||||
position?: number;
|
||||
layoutMode?: PageLayoutTabLayoutMode;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,6 +35,14 @@ const SUCCESSFUL_TEST_CASES: EachTestingContext<TestContext>[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'update page layout tab layout mode',
|
||||
context: {
|
||||
input: {
|
||||
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe('Page layout tab update should succeed', () => {
|
||||
|
||||
+2
@@ -11,6 +11,7 @@ const DEFAULT_PAGE_LAYOUT_TAB_GQL_FIELDS = `
|
||||
id
|
||||
title
|
||||
position
|
||||
layoutMode
|
||||
pageLayoutId
|
||||
createdAt
|
||||
updatedAt
|
||||
@@ -34,6 +35,7 @@ export const updateOnePageLayoutTabQueryFactory = ({
|
||||
title: input.title,
|
||||
position: input.position,
|
||||
icon: input.icon,
|
||||
layoutMode: input.layoutMode,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user