feat(sdk): add definePageLayoutTab for extending existing page layouts (#20004)

## Summary

Introduces `definePageLayoutTab` so apps can attach a single tab (with
optional widgets) to an **existing** `pageLayout` referenced by
`pageLayoutUniversalIdentifier`. The parent layout can be standard, from
the same app, or from another app — mirroring how `defineField`
references an object via `objectUniversalIdentifier`.

This complements `definePageLayout`: use `definePageLayout` when you own
the entire layout, use `definePageLayoutTab` when you only want to add
to one.

```ts
import { definePageLayoutTab, PageLayoutTabLayoutMode } from 'twenty-sdk/define';

export default definePageLayoutTab({
  universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
  pageLayoutUniversalIdentifier: 'STANDARD-OR-OTHER-APP-PAGE-LAYOUT-UUID',
  title: 'Hello World',
  position: 1000,
  icon: 'IconWorld',
  layoutMode: PageLayoutTabLayoutMode.CANVAS,
  widgets: [/* ... */],
});
```

## Changes

- **twenty-shared**: new top-level `pageLayoutTabs:
PageLayoutTabManifest[]` on `Manifest`, optional
`pageLayoutUniversalIdentifier` on `PageLayoutTabManifest`, new
`SyncableEntity.PageLayoutTab`.
- **twenty-sdk**:
  - new `definePageLayoutTab` + `PageLayoutTabConfig` exports;
- manifest extraction wiring (`TargetFunction.DefinePageLayoutTab`,
`ManifestEntityKey.PageLayoutTabs`);
  - dev-mode label/state for the new entity;
- CLI scaffold (`getPageLayoutTabBaseFile`) + unit tests for `npx
twenty-cli add`.
- **twenty-server**: convert top-level `pageLayoutTabs` (and their
widgets) into universal flat entities in
`computeApplicationManifestAllUniversalFlatEntityMaps`. Cross-app FK
validation on `pageLayoutUniversalIdentifier` is already handled by the
existing `FlatPageLayoutTab` validator.
- **docs**: new `definePageLayoutTab` accordion in `apps/layout.mdx`
with usage example and guidance vs `definePageLayout`.
- **CI / rich-app fixture**: `extra-tab.page-layout-tab.ts` exercises
the new flow with a front-component widget; `expected-manifest.ts` and
`manifest.tests.ts` updated.
This commit is contained in:
Charles Bochet
2026-04-23 14:00:10 +02:00
committed by GitHub
parent 20f7ba82d7
commit a445f4a6fa
28 changed files with 509 additions and 2 deletions
@@ -315,6 +315,7 @@ export const EXPECTED_MANIFEST: Manifest = {
views: [],
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
roles: [
{
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000040',
@@ -4,6 +4,7 @@ import { PermissionFlagType } from 'twenty-shared/constants';
import {
FieldMetadataType,
NavigationMenuItemType,
PageLayoutTabLayoutMode,
RelationOnDeleteAction,
RelationType,
ViewCalendarLayout,
@@ -12,6 +13,28 @@ import {
export const EXPECTED_MANIFEST: Manifest = {
pageLayouts: [],
pageLayoutTabs: [
{
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000010',
pageLayoutUniversalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000020',
title: 'Extra Tab',
position: 1000,
icon: 'IconLayout',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [
{
universalIdentifier: 'b0b1b2b3-b4b5-4000-8000-000000000011',
title: 'Extra Widget',
type: 'FRONT_COMPONENT',
configuration: {
configurationType: 'FRONT_COMPONENT',
frontComponentUniversalIdentifier:
'370ae182-743f-4ecb-b625-7ac48e21f0e5',
},
},
],
},
],
publicAssets: [
{
checksum: '99496069dcc2a1488e1cae9f826d2707',
@@ -26,6 +26,7 @@ export const defineManifestTests = (appPath: string): void => {
expect(manifest.fields).toHaveLength(23);
expect(manifest.views).toHaveLength(5);
expect(manifest.navigationMenuItems).toHaveLength(3);
expect(manifest.pageLayoutTabs).toHaveLength(1);
expect(normalizeManifestForComparison(manifest)).toEqual(
normalizeManifestForComparison(EXPECTED_MANIFEST),
@@ -31,6 +31,7 @@ export const normalizeManifestForComparison = <T extends Manifest>(
views: sortById(manifest.views),
navigationMenuItems: sortById(manifest.navigationMenuItems),
pageLayouts: sortById(manifest.pageLayouts),
pageLayoutTabs: sortById(manifest.pageLayoutTabs ?? []),
logicFunctions: sortById(
manifest.logicFunctions?.map((fn) => ({
...fn,
@@ -15,6 +15,7 @@ import { getLogicFunctionBaseFile } from '@/cli/utilities/entity/entity-logic-fu
import { getNavigationMenuItemBaseFile } from '@/cli/utilities/entity/entity-navigation-menu-item-template';
import { getObjectBaseFile } from '@/cli/utilities/entity/entity-object-template';
import { getPageLayoutBaseFile } from '@/cli/utilities/entity/entity-page-layout-template';
import { getPageLayoutTabBaseFile } from '@/cli/utilities/entity/entity-page-layout-tab-template';
import { getRecordPageLayoutBaseFile } from '@/cli/utilities/entity/entity-record-page-layout-template';
import { getRoleBaseFile } from '@/cli/utilities/entity/entity-role-template';
import { getAgentBaseFile } from '@/cli/utilities/entity/entity-agent-template';
@@ -189,6 +190,15 @@ export class EntityAddCommand {
return { name, file };
}
case SyncableEntity.PageLayoutTab: {
const name = await this.getEntityName(entity);
const file = getPageLayoutTabBaseFile({
name,
});
return { name, file };
}
default:
assertUnreachable(entity);
}
@@ -37,6 +37,7 @@ const validManifest: Manifest = {
views: [],
navigationMenuItems: [],
pageLayouts: [],
pageLayoutTabs: [],
};
describe('manifestValidate', () => {
@@ -11,6 +11,7 @@ import { type ApplicationConfig, type LogicFunctionConfig } from '@/sdk/define';
import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config';
import { type ObjectConfig } from '@/sdk/define/objects/object-config';
import { type PageLayoutConfig } from '@/sdk/define/page-layouts/page-layout-config';
import { type PageLayoutTabConfig } from '@/sdk/define/page-layouts/page-layout-tab-config';
import { type ViewConfig } from '@/sdk/define/views/view-config';
import { readFile } from 'node:fs/promises';
import { basename, extname, relative } from 'path';
@@ -28,6 +29,7 @@ import {
type NavigationMenuItemManifest,
type ObjectManifest,
type PageLayoutManifest,
type PageLayoutTabManifest,
type RoleManifest,
type SkillManifest,
type ViewManifest,
@@ -81,6 +83,7 @@ export const buildManifest = async (
const views: ViewManifest[] = [];
const navigationMenuItems: NavigationMenuItemManifest[] = [];
const pageLayouts: PageLayoutManifest[] = [];
const pageLayoutTabs: PageLayoutTabManifest[] = [];
const postInstallLogicFunctions: PostInstallLogicFunctionApplicationManifest[] =
[];
const preInstallLogicFunctions: PreInstallLogicFunctionApplicationManifest[] =
@@ -97,6 +100,7 @@ export const buildManifest = async (
const viewsFilePaths: string[] = [];
const navigationMenuItemsFilePaths: string[] = [];
const pageLayoutsFilePaths: string[] = [];
const pageLayoutTabsFilePaths: string[] = [];
for (const filePath of filePaths) {
const fileContent = await readFile(filePath, 'utf-8');
@@ -331,6 +335,21 @@ export const buildManifest = async (
pageLayoutsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PageLayoutTabs: {
const extract = await extractManifestFromFile<PageLayoutTabConfig>({
appPath,
filePath,
});
const pageLayoutTabManifest: PageLayoutTabManifest = {
...extract.config,
};
pageLayoutTabs.push(pageLayoutTabManifest);
errors.push(...extract.errors);
pageLayoutTabsFilePaths.push(relativePath);
break;
}
case ManifestEntityKey.PublicAssets: {
// Public assets are handled below
break;
@@ -407,6 +426,7 @@ export const buildManifest = async (
views: views.sort(byId),
navigationMenuItems: navigationMenuItems.sort(byId),
pageLayouts: pageLayouts.sort(byId),
pageLayoutTabs: pageLayoutTabs.sort(byId),
};
const entityFilePaths: EntityFilePaths = {
@@ -422,6 +442,7 @@ export const buildManifest = async (
views: viewsFilePaths,
navigationMenuItems: navigationMenuItemsFilePaths,
pageLayouts: pageLayoutsFilePaths,
pageLayoutTabs: pageLayoutTabsFilePaths,
};
return { manifest, filePaths: entityFilePaths, errors };
@@ -14,6 +14,7 @@ export enum TargetFunction {
DefineView = 'defineView',
DefineNavigationMenuItem = 'defineNavigationMenuItem',
DefinePageLayout = 'definePageLayout',
DefinePageLayoutTab = 'definePageLayoutTab',
}
export enum ManifestEntityKey {
@@ -29,6 +30,7 @@ export enum ManifestEntityKey {
Views = 'views',
NavigationMenuItems = 'navigationMenuItems',
PageLayouts = 'pageLayouts',
PageLayoutTabs = 'pageLayoutTabs',
}
export type EntityFilePaths = Record<ManifestEntityKey, string[]>;
@@ -53,6 +55,7 @@ export const TARGET_FUNCTION_TO_ENTITY_KEY_MAPPING: Record<
[TargetFunction.DefineNavigationMenuItem]:
ManifestEntityKey.NavigationMenuItems,
[TargetFunction.DefinePageLayout]: ManifestEntityKey.PageLayouts,
[TargetFunction.DefinePageLayoutTab]: ManifestEntityKey.PageLayoutTabs,
};
const computeIsTargetFunctionCall = (node: ts.Node): string | undefined => {
@@ -74,6 +74,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record<string, SyncableEntity | undefined> = {
views: SyncableEntity.View,
navigationMenuItems: SyncableEntity.NavigationMenuItem,
pageLayouts: SyncableEntity.PageLayout,
pageLayoutTabs: SyncableEntity.PageLayoutTab,
};
const MAX_EVENT_COUNT = 200;
@@ -102,6 +102,7 @@ export const ENTITY_LABELS: Record<SyncableEntity, string> = {
[SyncableEntity.View]: 'Views',
[SyncableEntity.NavigationMenuItem]: 'Navigation menu items',
[SyncableEntity.PageLayout]: 'Page layouts',
[SyncableEntity.PageLayoutTab]: 'Page layout tabs',
[SyncableEntity.Agent]: 'Agents',
};
@@ -0,0 +1,44 @@
import { getPageLayoutTabBaseFile } from '@/cli/utilities/entity/entity-page-layout-tab-template';
describe('getPageLayoutTabBaseFile', () => {
it('should render proper file using definePageLayoutTab', () => {
const result = getPageLayoutTabBaseFile({
name: 'My Custom Tab',
});
expect(result).toContain(
"import { definePageLayoutTab, PageLayoutTabLayoutMode } from 'twenty-sdk/define';",
);
expect(result).toContain('export default definePageLayoutTab({');
expect(result).toContain("title: 'My Custom Tab'");
expect(result).toContain('pageLayoutUniversalIdentifier:');
expect(result).toContain('widgets: []');
expect(result).toContain('layoutMode: PageLayoutTabLayoutMode.CANVAS');
});
it('should generate a valid UUID for the tab', () => {
const result = getPageLayoutTabBaseFile({
name: 'tab',
});
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);
expect(matches).toHaveLength(1);
});
it('should generate unique UUIDs across calls', () => {
const result1 = getPageLayoutTabBaseFile({ name: 'tab-1' });
const result2 = getPageLayoutTabBaseFile({ name: 'tab-2' });
const uuidRegex =
/universalIdentifier: '([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'/;
const uuid1 = result1.match(uuidRegex)?.[1];
const uuid2 = result2.match(uuidRegex)?.[1];
expect(uuid1).toBeDefined();
expect(uuid2).toBeDefined();
expect(uuid1).not.toBe(uuid2);
});
});
@@ -0,0 +1,16 @@
import { v4 as uuidv4 } from 'uuid';
export const getPageLayoutTabBaseFile = ({ name }: { name: string }) => {
return `import { definePageLayoutTab, PageLayoutTabLayoutMode } from 'twenty-sdk/define';
export default definePageLayoutTab({
universalIdentifier: '${uuidv4()}',
pageLayoutUniversalIdentifier: 'replace-with-existing-page-layout-uuid',
title: '${name}',
position: 1000,
icon: 'IconLayout',
layoutMode: PageLayoutTabLayoutMode.CANVAS,
widgets: [],
});
`;
};