From a445f4a6fafef322d8736d4389ec96d37d327f4b Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Thu, 23 Apr 2026 14:00:10 +0200 Subject: [PATCH] feat(sdk): add definePageLayoutTab for extending existing page layouts (#20004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- .../extra-tab.page-layout-tab.ts | 25 +++ .../developers/extend/apps/layout.mdx | 47 ++++ .../app-dev/expected-manifest.ts | 1 + .../app-dev/expected-manifest.ts | 23 ++ .../app-dev/tests/manifest.tests.ts | 1 + .../utils/normalize-manifest.util.ts | 1 + packages/twenty-sdk/src/cli/commands/add.ts | 10 + .../__tests__/manifest-validate.spec.ts | 1 + .../build/manifest/manifest-build.ts | 21 ++ .../build/manifest/manifest-extract-config.ts | 3 + .../dev-mode-orchestrator-state.ts | 1 + .../cli/utilities/dev/ui/dev-ui-constants.ts | 1 + .../get-page-layout-tab-base-file.spec.ts | 44 ++++ .../entity/entity-page-layout-tab-template.ts | 16 ++ .../define/common/types/define-entity.type.ts | 4 +- packages/twenty-sdk/src/sdk/define/index.ts | 7 + .../page-layouts/define-page-layout-tab.ts | 39 ++++ .../page-layouts/page-layout-tab-config.ts | 8 + .../application-manifest-migration.service.ts | 1 + ...est-all-universal-flat-entity-maps.util.ts | 37 ++++ .../resolve-manifest-asset-urls.util.spec.ts | 1 + .../app-distribution.integration-spec.ts | 1 + ...tion-validation-errors.integration-spec.ts | 1 + ...update-page-layout-tab.integration-spec.ts | 208 ++++++++++++++++++ .../utils/build-base-manifest.util.ts | 1 + .../enums/syncable-entities.enum.ts | 1 + .../src/application/manifestType.ts | 6 +- .../src/application/pageLayoutManifestType.ts | 1 + 28 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-apps/fixtures/rich-app/src/page-layout-tabs/extra-tab.page-layout-tab.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/entity/__tests__/get-page-layout-tab-base-file.spec.ts create mode 100644 packages/twenty-sdk/src/cli/utilities/entity/entity-page-layout-tab-template.ts create mode 100644 packages/twenty-sdk/src/sdk/define/page-layouts/define-page-layout-tab.ts create mode 100644 packages/twenty-sdk/src/sdk/define/page-layouts/page-layout-tab-config.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/successful-manifest-update-page-layout-tab.integration-spec.ts 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 & {