diff --git a/packages/twenty-apps/fixtures/rich-app/src/page-layout-tabs/extra-tab.page-layout-tab.ts b/packages/twenty-apps/fixtures/rich-app/src/page-layout-tabs/extra-tab.page-layout-tab.ts
new file mode 100644
index 0000000000..fe59adcdf2
--- /dev/null
+++ b/packages/twenty-apps/fixtures/rich-app/src/page-layout-tabs/extra-tab.page-layout-tab.ts
@@ -0,0 +1,25 @@
+import {
+ definePageLayoutTab,
+ PageLayoutTabLayoutMode,
+} from 'twenty-sdk/define';
+
+export default definePageLayoutTab({
+ 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',
+ },
+ },
+ ],
+});
diff --git a/packages/twenty-docs/developers/extend/apps/layout.mdx b/packages/twenty-docs/developers/extend/apps/layout.mdx
index 5ebf1a5819..90e1098e86 100644
--- a/packages/twenty-docs/developers/extend/apps/layout.mdx
+++ b/packages/twenty-docs/developers/extend/apps/layout.mdx
@@ -13,6 +13,7 @@ Layout entities control how your app surfaces inside Twenty's UI — what lives
| **View** | A saved list configuration for an object — visible fields, order, filters, groups | `defineView` |
| **Navigation Menu Item** | An entry in the left sidebar that links to a view or an external URL | `defineNavigationMenuItem` |
| **Page Layout** | The tabs and widgets that make up a record's detail page | `definePageLayout` |
+| **Page Layout Tab** | A standalone tab attached to an existing page layout (standard or your own app's) | `definePageLayoutTab` |
Views, navigation items, and page layouts reference each other by `universalIdentifier`:
@@ -127,5 +128,51 @@ Key points:
- Each `widget` inside a tab can render a front component, 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.
+
+
+
+`definePageLayoutTab` lets your app attach a single tab — with optional widgets — to an **existing** page layout. The most common use case is adding a custom tab (for example, an analytics or AI summary tab) to one of Twenty's built-in record pages, or to a page layout your own app already ships.
+
+The targeted page layout must be either a **standard** Twenty page layout or one defined by **your own app**; cross-app references to page layouts owned by another installed app are not supported today.
+
+```ts src/page-layouts/example-extra-tab.ts
+import {
+ definePageLayoutTab,
+ PageLayoutTabLayoutMode,
+} from 'twenty-sdk/define';
+import { HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from '../front-components/hello-world';
+
+const COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
+ '20202020-ab01-4001-8001-c0aba11c0100';
+
+export default definePageLayoutTab({
+ universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000001',
+ pageLayoutUniversalIdentifier:
+ COMPANY_RECORD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER,
+ title: 'Hello World',
+ position: 1000,
+ icon: 'IconWorld',
+ layoutMode: PageLayoutTabLayoutMode.CANVAS,
+ widgets: [
+ {
+ universalIdentifier: 'b1b2b3b4-b5b6-4000-8000-000000000002',
+ title: 'Hello World',
+ type: 'FRONT_COMPONENT',
+ configuration: {
+ configurationType: 'FRONT_COMPONENT',
+ frontComponentUniversalIdentifier:
+ HELLO_WORLD_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
+ },
+ },
+ ],
+});
+```
+
+Key points:
+- `pageLayoutUniversalIdentifier` is **required** when using `definePageLayoutTab` and must point to a page layout that already exists at install time (standard or your app's). When the parent page layout is missing, installation fails with a clear validation error.
+- `widgets` are scoped to this tab only — they reference front components, views, etc. exactly like widgets defined inline in `definePageLayout`.
+- `position` controls ordering against existing tabs on the targeted layout. Pick a value that places your tab where you want it relative to built-in tabs.
+- Use this instead of `definePageLayout` when you only want to **add** to an existing layout. Use `definePageLayout` when you own the entire layout (typically a `RECORD_PAGE` for an object you ship in your app, or a `STANDALONE_PAGE`).
+
diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts
index 1b2be3f06a..623e026d5a 100644
--- a/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts
+++ b/packages/twenty-sdk/src/cli/__tests__/apps/minimal-app/__integration__/app-dev/expected-manifest.ts
@@ -315,6 +315,7 @@ export const EXPECTED_MANIFEST: Manifest = {
views: [],
navigationMenuItems: [],
pageLayouts: [],
+ pageLayoutTabs: [],
roles: [
{
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000040',
diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts
index a857cf4edb..b1ce1e42c0 100644
--- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts
+++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/expected-manifest.ts
@@ -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',
diff --git a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts
index 0ee9ee56fe..3d4b4f6c25 100644
--- a/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts
+++ b/packages/twenty-sdk/src/cli/__tests__/apps/rich-app/__integration__/app-dev/tests/manifest.tests.ts
@@ -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),
diff --git a/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts b/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts
index e2bf4b98ea..8037caeed8 100644
--- a/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts
+++ b/packages/twenty-sdk/src/cli/__tests__/integration/utils/normalize-manifest.util.ts
@@ -31,6 +31,7 @@ export const normalizeManifestForComparison = (
views: sortById(manifest.views),
navigationMenuItems: sortById(manifest.navigationMenuItems),
pageLayouts: sortById(manifest.pageLayouts),
+ pageLayoutTabs: sortById(manifest.pageLayoutTabs ?? []),
logicFunctions: sortById(
manifest.logicFunctions?.map((fn) => ({
...fn,
diff --git a/packages/twenty-sdk/src/cli/commands/add.ts b/packages/twenty-sdk/src/cli/commands/add.ts
index bf47c64fe0..874a56b78b 100644
--- a/packages/twenty-sdk/src/cli/commands/add.ts
+++ b/packages/twenty-sdk/src/cli/commands/add.ts
@@ -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);
}
diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts
index c85c594758..06fef5962a 100644
--- a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts
+++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts
@@ -37,6 +37,7 @@ const validManifest: Manifest = {
views: [],
navigationMenuItems: [],
pageLayouts: [],
+ pageLayoutTabs: [],
};
describe('manifestValidate', () => {
diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts
index 05540aa484..bd8e4ab413 100644
--- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts
+++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts
@@ -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({
+ 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 };
diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts
index 0d3c152b65..66c034eb58 100644
--- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts
+++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-extract-config.ts
@@ -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;
@@ -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 => {
diff --git a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts
index 93801f5f46..670a074bf6 100644
--- a/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts
+++ b/packages/twenty-sdk/src/cli/utilities/dev/orchestrator/dev-mode-orchestrator-state.ts
@@ -74,6 +74,7 @@ const ENTITY_TYPE_TO_SYNCABLE: Record = {
views: SyncableEntity.View,
navigationMenuItems: SyncableEntity.NavigationMenuItem,
pageLayouts: SyncableEntity.PageLayout,
+ pageLayoutTabs: SyncableEntity.PageLayoutTab,
};
const MAX_EVENT_COUNT = 200;
diff --git a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts
index 81db345070..db9a57db1f 100644
--- a/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts
+++ b/packages/twenty-sdk/src/cli/utilities/dev/ui/dev-ui-constants.ts
@@ -102,6 +102,7 @@ export const ENTITY_LABELS: Record = {
[SyncableEntity.View]: 'Views',
[SyncableEntity.NavigationMenuItem]: 'Navigation menu items',
[SyncableEntity.PageLayout]: 'Page layouts',
+ [SyncableEntity.PageLayoutTab]: 'Page layout tabs',
[SyncableEntity.Agent]: 'Agents',
};
diff --git a/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-page-layout-tab-base-file.spec.ts b/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-page-layout-tab-base-file.spec.ts
new file mode 100644
index 0000000000..f8de4e5094
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-page-layout-tab-base-file.spec.ts
@@ -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);
+ });
+});
diff --git a/packages/twenty-sdk/src/cli/utilities/entity/entity-page-layout-tab-template.ts b/packages/twenty-sdk/src/cli/utilities/entity/entity-page-layout-tab-template.ts
new file mode 100644
index 0000000000..24362b54e1
--- /dev/null
+++ b/packages/twenty-sdk/src/cli/utilities/entity/entity-page-layout-tab-template.ts
@@ -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: [],
+});
+`;
+};
diff --git a/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts b/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts
index a2e22abed8..0216e5c576 100644
--- a/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts
+++ b/packages/twenty-sdk/src/sdk/define/common/types/define-entity.type.ts
@@ -3,6 +3,7 @@ import { type FrontComponentConfig } from '@/sdk/define/front-component/front-co
import { type LogicFunctionConfig } from '@/sdk/define/logic-functions/logic-function-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 { type PostInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/post-install-logic-function-config';
import { type PreInstallLogicFunctionConfig } from '@/sdk/define/logic-functions/pre-install-logic-function-config';
@@ -33,7 +34,8 @@ export type DefinableEntity =
| SkillManifest
| ViewConfig
| NavigationMenuItemManifest
- | PageLayoutConfig;
+ | PageLayoutConfig
+ | PageLayoutTabConfig;
export type DefineEntity = (
config: T,
diff --git a/packages/twenty-sdk/src/sdk/define/index.ts b/packages/twenty-sdk/src/sdk/define/index.ts
index 0c7d6801af..e53b54d8c3 100644
--- a/packages/twenty-sdk/src/sdk/define/index.ts
+++ b/packages/twenty-sdk/src/sdk/define/index.ts
@@ -70,7 +70,14 @@ export {
} from '@/sdk/define/objects/standard-object-ids';
export { definePageLayout } from '@/sdk/define/page-layouts/define-page-layout';
+export { definePageLayoutTab } from '@/sdk/define/page-layouts/define-page-layout-tab';
export type { PageLayoutConfig } from '@/sdk/define/page-layouts/page-layout-config';
+export type { PageLayoutTabConfig } from '@/sdk/define/page-layouts/page-layout-tab-config';
+export type {
+ PageLayoutManifest,
+ PageLayoutTabManifest,
+ PageLayoutWidgetManifest,
+} from 'twenty-shared/application';
export { defineRole } from '@/sdk/define/roles/define-role';
export { PermissionFlag } from '@/sdk/define/roles/permission-flag-type';
diff --git a/packages/twenty-sdk/src/sdk/define/page-layouts/define-page-layout-tab.ts b/packages/twenty-sdk/src/sdk/define/page-layouts/define-page-layout-tab.ts
new file mode 100644
index 0000000000..592919846c
--- /dev/null
+++ b/packages/twenty-sdk/src/sdk/define/page-layouts/define-page-layout-tab.ts
@@ -0,0 +1,39 @@
+import { type DefineEntity } from '@/sdk/define/common/types/define-entity.type';
+import { createValidationResult } from '@/sdk/define/common/utils/create-validation-result';
+import { type PageLayoutTabConfig } from '@/sdk/define/page-layouts/page-layout-tab-config';
+
+export const definePageLayoutTab: DefineEntity = (
+ config,
+) => {
+ const errors: string[] = [];
+
+ if (!config.universalIdentifier) {
+ errors.push('PageLayoutTab must have a universalIdentifier');
+ }
+
+ if (!config.title) {
+ errors.push('PageLayoutTab must have a title');
+ }
+
+ if (!config.pageLayoutUniversalIdentifier) {
+ errors.push(
+ 'PageLayoutTab must have a pageLayoutUniversalIdentifier when defined standalone (use the parent page layout universalIdentifier)',
+ );
+ }
+
+ if (config.widgets) {
+ for (const widget of config.widgets) {
+ if (!widget.universalIdentifier) {
+ errors.push('PageLayoutWidget must have a universalIdentifier');
+ }
+ if (!widget.title) {
+ errors.push('PageLayoutWidget must have a title');
+ }
+ if (!widget.type) {
+ errors.push('PageLayoutWidget must have a type');
+ }
+ }
+ }
+
+ return createValidationResult({ config, errors });
+};
diff --git a/packages/twenty-sdk/src/sdk/define/page-layouts/page-layout-tab-config.ts b/packages/twenty-sdk/src/sdk/define/page-layouts/page-layout-tab-config.ts
new file mode 100644
index 0000000000..7756f6ddd6
--- /dev/null
+++ b/packages/twenty-sdk/src/sdk/define/page-layouts/page-layout-tab-config.ts
@@ -0,0 +1,8 @@
+import { type PageLayoutTabManifest } from 'twenty-shared/application';
+
+export type PageLayoutTabConfig = Omit<
+ PageLayoutTabManifest,
+ 'pageLayoutUniversalIdentifier'
+> & {
+ pageLayoutUniversalIdentifier: string;
+};
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts
index 512c962ad1..deabbc7a4e 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/application-manifest-migration.service.ts
@@ -78,6 +78,7 @@ export class ApplicationManifestMigrationService {
views: [],
navigationMenuItems: [],
pageLayouts: [],
+ pageLayoutTabs: [],
};
const now = new Date().toISOString();
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts
index c6c0b26a57..ddd1016eb2 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts
@@ -397,5 +397,42 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({
}
}
+ for (const pageLayoutTabManifest of manifest.pageLayoutTabs ?? []) {
+ if (!isDefined(pageLayoutTabManifest.pageLayoutUniversalIdentifier)) {
+ throw new Error(
+ `Top-level pageLayoutTab "${pageLayoutTabManifest.universalIdentifier}" is missing required pageLayoutUniversalIdentifier`,
+ );
+ }
+
+ addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
+ universalFlatEntity:
+ fromPageLayoutTabManifestToUniversalFlatPageLayoutTab({
+ pageLayoutTabManifest,
+ pageLayoutUniversalIdentifier:
+ pageLayoutTabManifest.pageLayoutUniversalIdentifier,
+ applicationUniversalIdentifier,
+ now,
+ }),
+ universalFlatEntityMapsToMutate:
+ allUniversalFlatEntityMaps.flatPageLayoutTabMaps,
+ });
+
+ for (const pageLayoutWidgetManifest of pageLayoutTabManifest.widgets ??
+ []) {
+ addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({
+ universalFlatEntity:
+ fromPageLayoutWidgetManifestToUniversalFlatPageLayoutWidget({
+ pageLayoutWidgetManifest,
+ pageLayoutTabUniversalIdentifier:
+ pageLayoutTabManifest.universalIdentifier,
+ applicationUniversalIdentifier,
+ now,
+ }),
+ universalFlatEntityMapsToMutate:
+ allUniversalFlatEntityMaps.flatPageLayoutWidgetMaps,
+ });
+ }
+ }
+
return allUniversalFlatEntityMaps;
};
diff --git a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/resolve-manifest-asset-urls.util.spec.ts b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/resolve-manifest-asset-urls.util.spec.ts
index 1e0c9206d6..bb9dfd08e0 100644
--- a/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/resolve-manifest-asset-urls.util.spec.ts
+++ b/packages/twenty-server/src/engine/core-modules/application/application-marketplace/utils/__tests__/resolve-manifest-asset-urls.util.spec.ts
@@ -25,6 +25,7 @@ const buildMinimalManifest = (
views: [],
navigationMenuItems: [],
pageLayouts: [],
+ pageLayoutTabs: [],
});
describe('resolveManifestAssetUrls', () => {
diff --git a/packages/twenty-server/test/integration/metadata/suites/application/app-distribution.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/app-distribution.integration-spec.ts
index 842a58d225..09f8c7122a 100644
--- a/packages/twenty-server/test/integration/metadata/suites/application/app-distribution.integration-spec.ts
+++ b/packages/twenty-server/test/integration/metadata/suites/application/app-distribution.integration-spec.ts
@@ -68,6 +68,7 @@ const createValidManifest = (universalIdentifier: string) =>
views: [],
navigationMenuItems: [],
pageLayouts: [],
+ pageLayoutTabs: [],
});
const insertRegistrationWithSource = async (
diff --git a/packages/twenty-server/test/integration/metadata/suites/application/failing-install-application-validation-errors.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/failing-install-application-validation-errors.integration-spec.ts
index 02d8a30b8c..abd2229338 100644
--- a/packages/twenty-server/test/integration/metadata/suites/application/failing-install-application-validation-errors.integration-spec.ts
+++ b/packages/twenty-server/test/integration/metadata/suites/application/failing-install-application-validation-errors.integration-spec.ts
@@ -95,6 +95,7 @@ const buildManifestWithCrossEntityIdentifierConflict = (
views: [],
navigationMenuItems: [],
pageLayouts: [],
+ pageLayoutTabs: [],
});
describe('Install application should return structured validation errors', () => {
diff --git a/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-page-layout-tab.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-page-layout-tab.integration-spec.ts
new file mode 100644
index 0000000000..776a247f56
--- /dev/null
+++ b/packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-page-layout-tab.integration-spec.ts
@@ -0,0 +1,208 @@
+import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util';
+import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util';
+import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util';
+import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util';
+import { findPageLayoutTabs } from 'test/integration/metadata/suites/page-layout-tab/utils/find-page-layout-tabs.util';
+import { type Manifest } from 'twenty-shared/application';
+import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
+import { v4 as uuidv4 } from 'uuid';
+
+const TEST_APP_ID = uuidv4();
+const TEST_ROLE_ID = uuidv4();
+const TEST_TAB_ID = uuidv4();
+
+const STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID =
+ '20202020-a102-4002-8002-ae0a1ea11002';
+
+const PAGE_LAYOUT_TAB_GQL_FIELDS = `
+ id
+ title
+ position
+ pageLayoutId
+ applicationId
+`;
+
+let testApplicationId: string;
+let standardPersonPageLayoutId: string;
+
+const buildManifest = (
+ overrides?: Partial>,
+) =>
+ buildBaseManifest({
+ appId: TEST_APP_ID,
+ roleId: TEST_ROLE_ID,
+ overrides,
+ });
+
+const findStandardPersonPageLayoutTabs = async () => {
+ const { data } = await findPageLayoutTabs({
+ gqlFields: PAGE_LAYOUT_TAB_GQL_FIELDS,
+ expectToFail: false,
+ input: { pageLayoutId: standardPersonPageLayoutId },
+ });
+
+ return data.getPageLayoutTabs.filter(
+ (tab) => tab.applicationId === testApplicationId,
+ );
+};
+
+describe('Manifest update - page layout tabs (standalone)', () => {
+ beforeEach(async () => {
+ await setupApplicationForSync({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ name: 'Test Application',
+ description: 'App for testing standalone page layout tab manifest updates',
+ sourcePath: 'test-manifest-update-page-layout-tab',
+ });
+
+ const applicationRow = await globalThis.testDataSource.query(
+ `SELECT id FROM core."application" WHERE "universalIdentifier" = $1`,
+ [TEST_APP_ID],
+ );
+
+ testApplicationId = applicationRow[0].id;
+
+ const pageLayoutRow = await globalThis.testDataSource.query(
+ `SELECT id FROM core."pageLayout" WHERE "universalIdentifier" = $1`,
+ [STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID],
+ );
+
+ standardPersonPageLayoutId = pageLayoutRow[0].id;
+ }, 60000);
+
+ afterEach(async () => {
+ await cleanupApplicationAndAppRegistration({
+ applicationUniversalIdentifier: TEST_APP_ID,
+ });
+ });
+
+ it('should attach a standalone tab to a standard page layout', async () => {
+ await syncApplication({
+ manifest: buildManifest({
+ pageLayoutTabs: [
+ {
+ universalIdentifier: TEST_TAB_ID,
+ pageLayoutUniversalIdentifier:
+ STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
+ title: 'Insights',
+ position: 1000,
+ icon: 'IconChartBar',
+ layoutMode: PageLayoutTabLayoutMode.CANVAS,
+ },
+ ],
+ }),
+ expectToFail: false,
+ });
+
+ const tabs = await findStandardPersonPageLayoutTabs();
+
+ expect(tabs).toHaveLength(1);
+ expect(tabs[0]).toMatchObject({
+ title: 'Insights',
+ position: 1000,
+ pageLayoutId: standardPersonPageLayoutId,
+ applicationId: testApplicationId,
+ });
+ }, 60000);
+
+ it('should rename and reposition a standalone tab on second sync', async () => {
+ await syncApplication({
+ manifest: buildManifest({
+ pageLayoutTabs: [
+ {
+ universalIdentifier: TEST_TAB_ID,
+ pageLayoutUniversalIdentifier:
+ STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
+ title: 'Insights',
+ position: 1000,
+ layoutMode: PageLayoutTabLayoutMode.CANVAS,
+ },
+ ],
+ }),
+ expectToFail: false,
+ });
+
+ const tabsAfterFirstSync = await findStandardPersonPageLayoutTabs();
+
+ expect(tabsAfterFirstSync).toHaveLength(1);
+ expect(tabsAfterFirstSync[0]).toMatchObject({
+ title: 'Insights',
+ position: 1000,
+ });
+
+ await syncApplication({
+ manifest: buildManifest({
+ pageLayoutTabs: [
+ {
+ universalIdentifier: TEST_TAB_ID,
+ pageLayoutUniversalIdentifier:
+ STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
+ title: 'Renamed Insights',
+ position: 1500,
+ layoutMode: PageLayoutTabLayoutMode.CANVAS,
+ },
+ ],
+ }),
+ expectToFail: false,
+ });
+
+ const tabsAfterSecondSync = await findStandardPersonPageLayoutTabs();
+
+ expect(tabsAfterSecondSync).toHaveLength(1);
+ expect(tabsAfterSecondSync[0]).toMatchObject({
+ title: 'Renamed Insights',
+ position: 1500,
+ });
+ }, 60000);
+
+ it('should delete a standalone tab when removed from manifest on second sync', async () => {
+ await syncApplication({
+ manifest: buildManifest({
+ pageLayoutTabs: [
+ {
+ universalIdentifier: TEST_TAB_ID,
+ pageLayoutUniversalIdentifier:
+ STANDARD_PERSON_PAGE_LAYOUT_UNIVERSAL_ID,
+ title: 'Insights',
+ position: 1000,
+ layoutMode: PageLayoutTabLayoutMode.CANVAS,
+ },
+ ],
+ }),
+ expectToFail: false,
+ });
+
+ const tabsAfterFirstSync = await findStandardPersonPageLayoutTabs();
+
+ expect(tabsAfterFirstSync).toHaveLength(1);
+
+ await syncApplication({
+ manifest: buildManifest({ pageLayoutTabs: [] }),
+ expectToFail: false,
+ });
+
+ const tabsAfterSecondSync = await findStandardPersonPageLayoutTabs();
+
+ expect(tabsAfterSecondSync).toHaveLength(0);
+ }, 60000);
+
+ it('should fail to sync when standalone tab references a non-existent page layout', async () => {
+ const { errors } = await syncApplication({
+ manifest: buildManifest({
+ pageLayoutTabs: [
+ {
+ universalIdentifier: TEST_TAB_ID,
+ pageLayoutUniversalIdentifier: uuidv4(),
+ title: 'Insights',
+ position: 1000,
+ layoutMode: PageLayoutTabLayoutMode.CANVAS,
+ },
+ ],
+ }),
+ expectToFail: true,
+ });
+
+ expect(errors).toBeDefined();
+ expect(errors?.length).toBeGreaterThan(0);
+ }, 60000);
+});
diff --git a/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts b/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts
index eb1b8a2ff9..99e22c1ebb 100644
--- a/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts
+++ b/packages/twenty-server/test/integration/metadata/suites/application/utils/build-base-manifest.util.ts
@@ -36,5 +36,6 @@ export const buildBaseManifest = ({
views: [],
navigationMenuItems: [],
pageLayouts: [],
+ pageLayoutTabs: [],
...overrides,
});
diff --git a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts
index eccf35947f..2bd3300ed1 100644
--- a/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts
+++ b/packages/twenty-shared/src/application/enums/syncable-entities.enum.ts
@@ -9,4 +9,5 @@ export enum SyncableEntity {
View = 'view',
NavigationMenuItem = 'navigationMenuItem',
PageLayout = 'pageLayout',
+ PageLayoutTab = 'pageLayoutTab',
}
diff --git a/packages/twenty-shared/src/application/manifestType.ts b/packages/twenty-shared/src/application/manifestType.ts
index 946fe74af1..2f98805720 100644
--- a/packages/twenty-shared/src/application/manifestType.ts
+++ b/packages/twenty-shared/src/application/manifestType.ts
@@ -6,7 +6,10 @@ import { type FrontComponentManifest } from './frontComponentManifestType';
import { type LogicFunctionManifest } from './logicFunctionManifestType';
import { type NavigationMenuItemManifest } from './navigationMenuItemManifestType';
import { type ObjectManifest } from './objectManifestType';
-import { type PageLayoutManifest } from './pageLayoutManifestType';
+import {
+ type PageLayoutManifest,
+ type PageLayoutTabManifest,
+} from './pageLayoutManifestType';
import { type RoleManifest } from './roleManifestType';
import { type SkillManifest } from './skillManifestType';
import { type ViewManifest } from './viewManifestType';
@@ -24,4 +27,5 @@ export type Manifest = {
views: ViewManifest[];
navigationMenuItems: NavigationMenuItemManifest[];
pageLayouts: PageLayoutManifest[];
+ pageLayoutTabs: PageLayoutTabManifest[];
};
diff --git a/packages/twenty-shared/src/application/pageLayoutManifestType.ts b/packages/twenty-shared/src/application/pageLayoutManifestType.ts
index 82d087611e..bcd271bb2e 100644
--- a/packages/twenty-shared/src/application/pageLayoutManifestType.ts
+++ b/packages/twenty-shared/src/application/pageLayoutManifestType.ts
@@ -21,6 +21,7 @@ export type PageLayoutTabManifest = SyncableEntityOptions & {
icon?: string;
layoutMode?: PageLayoutTabLayoutMode;
widgets?: PageLayoutWidgetManifest[];
+ pageLayoutUniversalIdentifier?: string;
};
export type PageLayoutManifest = SyncableEntityOptions & {