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:
+25
@@ -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',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="definePageLayoutTab" description="Add a tab to an existing page layout">
|
||||
|
||||
`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`).
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
+1
@@ -315,6 +315,7 @@ export const EXPECTED_MANIFEST: Manifest = {
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
roles: [
|
||||
{
|
||||
universalIdentifier: 'e1e2e3e4-e5e6-4000-8000-000000000040',
|
||||
|
||||
+23
@@ -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',
|
||||
|
||||
+1
@@ -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);
|
||||
}
|
||||
|
||||
+1
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
+44
@@ -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: [],
|
||||
});
|
||||
`;
|
||||
};
|
||||
@@ -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<T extends DefinableEntity = DefinableEntity> = (
|
||||
config: T,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<PageLayoutTabConfig> = (
|
||||
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 });
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type PageLayoutTabManifest } from 'twenty-shared/application';
|
||||
|
||||
export type PageLayoutTabConfig = Omit<
|
||||
PageLayoutTabManifest,
|
||||
'pageLayoutUniversalIdentifier'
|
||||
> & {
|
||||
pageLayoutUniversalIdentifier: string;
|
||||
};
|
||||
+1
@@ -78,6 +78,7 @@ export class ApplicationManifestMigrationService {
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
};
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
+37
@@ -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;
|
||||
};
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ const buildMinimalManifest = (
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
});
|
||||
|
||||
describe('resolveManifestAssetUrls', () => {
|
||||
|
||||
+1
@@ -68,6 +68,7 @@ const createValidManifest = (universalIdentifier: string) =>
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
});
|
||||
|
||||
const insertRegistrationWithSource = async (
|
||||
|
||||
+1
@@ -95,6 +95,7 @@ const buildManifestWithCrossEntityIdentifierConflict = (
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
});
|
||||
|
||||
describe('Install application should return structured validation errors', () => {
|
||||
|
||||
+208
@@ -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<Pick<Manifest, 'pageLayoutTabs'>>,
|
||||
) =>
|
||||
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);
|
||||
});
|
||||
+1
@@ -36,5 +36,6 @@ export const buildBaseManifest = ({
|
||||
views: [],
|
||||
navigationMenuItems: [],
|
||||
pageLayouts: [],
|
||||
pageLayoutTabs: [],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -9,4 +9,5 @@ export enum SyncableEntity {
|
||||
View = 'view',
|
||||
NavigationMenuItem = 'navigationMenuItem',
|
||||
PageLayout = 'pageLayout',
|
||||
PageLayoutTab = 'pageLayoutTab',
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ export type PageLayoutTabManifest = SyncableEntityOptions & {
|
||||
icon?: string;
|
||||
layoutMode?: PageLayoutTabLayoutMode;
|
||||
widgets?: PageLayoutWidgetManifest[];
|
||||
pageLayoutUniversalIdentifier?: string;
|
||||
};
|
||||
|
||||
export type PageLayoutManifest = SyncableEntityOptions & {
|
||||
|
||||
Reference in New Issue
Block a user