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:
Raphaël Bosi
2026-07-31 15:12:19 +02:00
committed by GitHub
parent 50adf4fa8c
commit 0f06fccdee
13 changed files with 186 additions and 13 deletions
@@ -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);
});
});
@@ -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: [],
@@ -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,
}),
@@ -213,6 +213,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"position",
"deletedAt",
"icon",
"layoutMode",
"isActive",
"overrides",
],
@@ -1116,7 +1116,7 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
universalProperty: 'pageLayoutUniversalIdentifier',
},
layoutMode: {
toCompare: false,
toCompare: true,
toStringify: false,
universalProperty: undefined,
},
@@ -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([]);
});
});
});