Update pageLayout* data models for backend recordPageLayout refactor (#17446)

<img width="814" height="356" alt="Screenshot 2026-01-26 at 16 09 27"
src="https://github.com/user-attachments/assets/91d95a1d-cc33-4bf0-a63e-4d1815030983"
/>
This commit is contained in:
Weiko
2026-01-29 14:39:46 +01:00
committed by GitHub
parent 09de762285
commit 7d69000ab7
31 changed files with 194 additions and 9 deletions
@@ -3355,6 +3355,7 @@ export type PageInfo = {
export type PageLayout = {
__typename?: 'PageLayout';
createdAt: Scalars['DateTime'];
defaultTabToFocusOnMobileAndSidePanelId?: Maybe<Scalars['UUID']>;
deletedAt?: Maybe<Scalars['DateTime']>;
id: Scalars['UUID'];
name: Scalars['String'];
@@ -3368,7 +3369,9 @@ export type PageLayoutTab = {
__typename?: 'PageLayoutTab';
createdAt: Scalars['DateTime'];
deletedAt?: Maybe<Scalars['DateTime']>;
icon?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
layoutMode?: Maybe<PageLayoutTabLayoutMode>;
pageLayoutId: Scalars['UUID'];
position: Scalars['Float'];
title: Scalars['String'];
@@ -3376,6 +3379,12 @@ export type PageLayoutTab = {
widgets?: Maybe<Array<PageLayoutWidget>>;
};
export enum PageLayoutTabLayoutMode {
CANVAS = 'CANVAS',
GRID = 'GRID',
VERTICAL_LIST = 'VERTICAL_LIST'
}
export enum PageLayoutType {
DASHBOARD = 'DASHBOARD',
RECORD_INDEX = 'RECORD_INDEX',
@@ -3384,6 +3393,7 @@ export enum PageLayoutType {
export type PageLayoutWidget = {
__typename?: 'PageLayoutWidget';
conditionalDisplay?: Maybe<Scalars['JSON']>;
configuration: WidgetConfiguration;
createdAt: Scalars['DateTime'];
deletedAt?: Maybe<Scalars['DateTime']>;
@@ -3237,6 +3237,7 @@ export type PageInfo = {
export type PageLayout = {
__typename?: 'PageLayout';
createdAt: Scalars['DateTime'];
defaultTabToFocusOnMobileAndSidePanelId?: Maybe<Scalars['UUID']>;
deletedAt?: Maybe<Scalars['DateTime']>;
id: Scalars['UUID'];
name: Scalars['String'];
@@ -3250,7 +3251,9 @@ export type PageLayoutTab = {
__typename?: 'PageLayoutTab';
createdAt: Scalars['DateTime'];
deletedAt?: Maybe<Scalars['DateTime']>;
icon?: Maybe<Scalars['String']>;
id: Scalars['UUID'];
layoutMode?: Maybe<PageLayoutTabLayoutMode>;
pageLayoutId: Scalars['UUID'];
position: Scalars['Float'];
title: Scalars['String'];
@@ -3258,6 +3261,12 @@ export type PageLayoutTab = {
widgets?: Maybe<Array<PageLayoutWidget>>;
};
export enum PageLayoutTabLayoutMode {
CANVAS = 'CANVAS',
GRID = 'GRID',
VERTICAL_LIST = 'VERTICAL_LIST'
}
export enum PageLayoutType {
DASHBOARD = 'DASHBOARD',
RECORD_INDEX = 'RECORD_INDEX',
@@ -3266,6 +3275,7 @@ export enum PageLayoutType {
export type PageLayoutWidget = {
__typename?: 'PageLayoutWidget';
conditionalDisplay?: Maybe<Scalars['JSON']>;
configuration: WidgetConfiguration;
createdAt: Scalars['DateTime'];
deletedAt?: Maybe<Scalars['DateTime']>;
@@ -2,11 +2,14 @@ import { type PageLayoutTabLayoutMode } from '@/page-layout/types/PageLayoutTabL
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { type PageLayoutTab as PageLayoutTabGenerated } from '~/generated/graphql';
export type PageLayoutTab = Omit<PageLayoutTabGenerated, 'widgets'> & {
export type PageLayoutTab = Omit<
PageLayoutTabGenerated,
'widgets' | 'layoutMode'
> & {
widgets: PageLayoutWidget[];
/**
* Only available behind IS_RECORD_PAGE_LAYOUT_ENABLED for now.
*/
layoutMode?: PageLayoutTabLayoutMode;
icon?: string;
icon?: string | null;
};
@@ -9,11 +9,14 @@ export const transformPageLayout = (
...pageLayout,
tabs: (pageLayout.tabs ?? [])
.toSorted((a, b) => a.position - b.position)
.map(
(tab): PageLayoutTab => ({
...tab,
.map((tab): PageLayoutTab => {
// TODO: remove this once the frontend consumes the new type
const { layoutMode: _layoutMode, ...tabWithoutLayoutMode } = tab;
return {
...tabWithoutLayoutMode,
widgets: tab.widgets ?? [],
}),
),
};
}),
};
};
@@ -0,0 +1,47 @@
import { type MigrationInterface, type QueryRunner } from 'typeorm';
export class UpdatePageLayoutForRecordPageLayout1769679579382
implements MigrationInterface
{
name = 'UpdatePageLayoutForRecordPageLayout1769679579382';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."pageLayout" ADD "defaultTabToFocusOnMobileAndSidePanelId" uuid`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutTab" ADD "icon" character varying`,
);
await queryRunner.query(
`CREATE TYPE "core"."pageLayoutTab_layoutmode_enum" AS ENUM('GRID', 'VERTICAL_LIST', 'CANVAS')`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutTab" ADD "layoutMode" "core"."pageLayoutTab_layoutmode_enum" NOT NULL DEFAULT 'GRID'`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ADD "conditionalDisplay" jsonb`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayout" ADD CONSTRAINT "FK_747fbc25827bdcb9e35cc68a990" FOREIGN KEY ("defaultTabToFocusOnMobileAndSidePanelId") REFERENCES "core"."pageLayoutTab"("id") ON DELETE SET NULL ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."pageLayout" DROP CONSTRAINT "FK_747fbc25827bdcb9e35cc68a990"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" DROP COLUMN "conditionalDisplay"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutTab" DROP COLUMN "layoutMode"`,
);
await queryRunner.query(`DROP TYPE "core"."pageLayoutTab_layoutmode_enum"`);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutTab" DROP COLUMN "icon"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayout" DROP COLUMN "defaultTabToFocusOnMobileAndSidePanelId"`,
);
}
}
@@ -306,6 +306,11 @@ export const ALL_METADATA_RELATIONS = {
foreignKey: 'objectMetadataId',
},
application: null,
defaultTabToFocusOnMobileAndSidePanel: {
metadataName: 'pageLayoutTab',
flatEntityForeignKeyAggregator: null,
foreignKey: 'defaultTabToFocusOnMobileAndSidePanelId',
},
},
oneToMany: {
tabs: { metadataName: 'pageLayoutTab' },
@@ -62,6 +62,7 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
},
pageLayout: {
objectMetadata: true,
pageLayoutTab: true,
},
pageLayoutTab: {
pageLayout: true,
@@ -9,13 +9,13 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
"viewField",
"commandMenuItem",
"index",
"pageLayout",
"roleTarget",
"rowLevelPermissionPredicateGroup",
"viewGroup",
"agent",
"frontComponent",
"logicFunction",
"pageLayout",
"pageLayoutTab",
"skill",
"view",
@@ -1,3 +1,4 @@
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils';
import { v4 } from 'uuid';
@@ -36,5 +37,7 @@ export const fromCreatePageLayoutTabInputToFlatPageLayoutTabToCreate = ({
universalIdentifier: pageLayoutTabId,
applicationId: workspaceCustomApplicationId,
widgetIds: [],
icon: null,
layoutMode: PageLayoutTabLayoutMode.GRID,
};
};
@@ -46,6 +46,8 @@ export const transformPageLayoutTabEntityToFlatPageLayoutTab = ({
universalIdentifier: pageLayoutTabEntity.universalIdentifier,
applicationId: pageLayoutTabEntity.applicationId,
widgetIds: pageLayoutTabEntity.widgets.map((widget) => widget.id),
icon: pageLayoutTabEntity.icon,
layoutMode: pageLayoutTabEntity.layoutMode,
__universal: {
universalIdentifier: pageLayoutTabEntity.universalIdentifier,
applicationUniversalIdentifier,
@@ -44,5 +44,6 @@ export const fromCreatePageLayoutWidgetInputToFlatPageLayoutWidgetToCreate = ({
gridPosition: createPageLayoutWidgetInput.gridPosition,
configuration: createPageLayoutWidgetInput.configuration,
applicationId: workspaceCustomApplicationId,
conditionalDisplay: null,
};
};
@@ -87,6 +87,8 @@ export const fromPageLayoutWidgetEntityToFlatPageLayoutWidget = ({
objectMetadataUniversalIdentifier,
gridPosition: pageLayoutWidgetEntityWithoutRelations.gridPosition,
configuration: configurationWithUniversalIdentifiers,
conditionalDisplay:
pageLayoutWidgetEntityWithoutRelations.conditionalDisplay,
},
};
};
@@ -70,6 +70,8 @@ export class WorkspaceFlatPageLayoutMapCacheService extends WorkspaceCacheProvid
createIdToUniversalIdentifierMap(applications);
const objectMetadataIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(objectMetadatas);
const pageLayoutTabIdToUniversalIdentifierMap =
createIdToUniversalIdentifierMap(pageLayoutTabs);
const flatPageLayoutMaps = createEmptyFlatEntityMaps();
@@ -81,6 +83,7 @@ export class WorkspaceFlatPageLayoutMapCacheService extends WorkspaceCacheProvid
},
applicationIdToUniversalIdentifierMap,
objectMetadataIdToUniversalIdentifierMap,
pageLayoutTabIdToUniversalIdentifierMap,
});
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
@@ -37,5 +37,6 @@ export const fromCreatePageLayoutInputToFlatPageLayoutToCreate = ({
universalIdentifier: pageLayoutId,
applicationId: workspaceCustomApplicationId,
tabIds: [],
defaultTabToFocusOnMobileAndSidePanelId: null,
};
};
@@ -11,6 +11,7 @@ export const transformPageLayoutEntityToFlatPageLayout = ({
entity: pageLayoutEntity,
applicationIdToUniversalIdentifierMap,
objectMetadataIdToUniversalIdentifierMap,
pageLayoutTabIdToUniversalIdentifierMap,
}: FromEntityToFlatEntityArgs<'pageLayout'>): FlatPageLayout => {
const applicationUniversalIdentifier =
applicationIdToUniversalIdentifierMap.get(pageLayoutEntity.applicationId);
@@ -38,6 +39,23 @@ export const transformPageLayoutEntityToFlatPageLayout = ({
}
}
let defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier: string | null =
null;
if (isDefined(pageLayoutEntity.defaultTabToFocusOnMobileAndSidePanelId)) {
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier =
pageLayoutTabIdToUniversalIdentifierMap.get(
pageLayoutEntity.defaultTabToFocusOnMobileAndSidePanelId,
) ?? null;
if (!isDefined(defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier)) {
throw new FlatEntityMapsException(
`PageLayoutTab with id ${pageLayoutEntity.defaultTabToFocusOnMobileAndSidePanelId} not found for pageLayout ${pageLayoutEntity.id}`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
}
return {
createdAt: pageLayoutEntity.createdAt.toISOString(),
deletedAt: pageLayoutEntity.deletedAt?.toISOString() ?? null,
@@ -50,6 +68,8 @@ export const transformPageLayoutEntityToFlatPageLayout = ({
universalIdentifier: pageLayoutEntity.universalIdentifier,
applicationId: pageLayoutEntity.applicationId,
tabIds: pageLayoutEntity.tabs.map((tab) => tab.id),
defaultTabToFocusOnMobileAndSidePanelId:
pageLayoutEntity.defaultTabToFocusOnMobileAndSidePanelId,
__universal: {
universalIdentifier: pageLayoutEntity.universalIdentifier,
applicationUniversalIdentifier,
@@ -57,6 +77,7 @@ export const transformPageLayoutEntityToFlatPageLayout = ({
tabUniversalIdentifiers: pageLayoutEntity.tabs.map(
(tab) => tab.universalIdentifier,
),
defaultTabToFocusOnMobileAndSidePanelUniversalIdentifier,
},
};
};
@@ -1,10 +1,15 @@
import { Field, Float, ObjectType } from '@nestjs/graphql';
import { Field, Float, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PageLayoutWidgetDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/page-layout-widget.dto';
registerEnumType(PageLayoutTabLayoutMode, {
name: 'PageLayoutTabLayoutMode',
});
@ObjectType('PageLayoutTab')
export class PageLayoutTabDTO {
@IDField(() => UUIDScalarType)
@@ -22,6 +27,15 @@ export class PageLayoutTabDTO {
@Field(() => [PageLayoutWidgetDTO], { nullable: true })
widgets?: PageLayoutWidgetDTO[] | null;
@Field(() => String, { nullable: true })
icon?: string | null;
@Field(() => PageLayoutTabLayoutMode, {
nullable: true, // Not nullable in the database, but we need to make it nullable here until the frontend consumes the new type
defaultValue: PageLayoutTabLayoutMode.GRID,
})
layoutMode: PageLayoutTabLayoutMode;
@Field()
createdAt: Date;
@@ -1,5 +1,6 @@
import { ObjectType } from '@nestjs/graphql';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import {
Column,
CreateDateColumn,
@@ -52,6 +53,17 @@ export class PageLayoutTabEntity
})
widgets: Relation<PageLayoutWidgetEntity[]>;
@Column({ nullable: true, type: 'varchar' })
icon: string | null;
@Column({
type: 'enum',
enum: Object.values(PageLayoutTabLayoutMode),
nullable: false,
default: PageLayoutTabLayoutMode.GRID,
})
layoutMode: PageLayoutTabLayoutMode;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -1,6 +1,8 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import GraphQLJSON from 'graphql-type-json';
import { PageLayoutWidgetConditionalDisplay } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { WidgetConfiguration } from 'src/engine/metadata-modules/page-layout-widget/dtos/widget-configuration.interface';
@@ -47,6 +49,9 @@ export class PageLayoutWidgetDTO {
@Field(() => WidgetConfiguration, { nullable: false })
configuration: AllPageLayoutWidgetConfiguration;
@Field(() => GraphQLJSON, { nullable: true })
conditionalDisplay?: PageLayoutWidgetConditionalDisplay | null;
@Field()
createdAt: Date;
@@ -1,5 +1,6 @@
import { ObjectType } from '@nestjs/graphql';
import { PageLayoutWidgetConditionalDisplay } from 'twenty-shared/types';
import {
Column,
CreateDateColumn,
@@ -70,6 +71,9 @@ export class PageLayoutWidgetEntity<
@JoinColumn({ name: 'objectMetadataId' })
objectMetadata: Relation<ObjectMetadataEntity> | null;
@Column({ type: 'jsonb', nullable: true })
conditionalDisplay: JsonbProperty<PageLayoutWidgetConditionalDisplay | null>;
@Column({ type: 'jsonb', nullable: false })
gridPosition: JsonbProperty<GridPosition>;
@@ -1,6 +1,7 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { IDField } from '@ptc-org/nestjs-query-graphql';
import { SerializedRelation } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { PageLayoutTabDTO } from 'src/engine/metadata-modules/page-layout-tab/dtos/page-layout-tab.dto';
@@ -28,6 +29,9 @@ export class PageLayoutDTO {
@Field(() => [PageLayoutTabDTO], { nullable: true })
tabs?: PageLayoutTabDTO[] | null;
@Field(() => UUIDScalarType, { nullable: true })
defaultTabToFocusOnMobileAndSidePanelId?: SerializedRelation;
@Field()
createdAt: Date;
@@ -59,6 +59,16 @@ export class PageLayoutEntity
})
tabs: Relation<PageLayoutTabEntity[]>;
@Column({ nullable: true, type: 'uuid' })
defaultTabToFocusOnMobileAndSidePanelId: string | null;
@ManyToOne(() => PageLayoutTabEntity, {
onDelete: 'SET NULL',
nullable: true,
})
@JoinColumn({ name: 'defaultTabToFocusOnMobileAndSidePanelId' })
defaultTabToFocusOnMobileAndSidePanel: Relation<PageLayoutTabEntity> | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { computeDiffBetweenObjects, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
@@ -236,6 +237,8 @@ export class PageLayoutUpdateService {
universalIdentifier: tabId,
applicationId: workspaceCustomApplicationId,
widgetIds: [],
icon: null,
layoutMode: PageLayoutTabLayoutMode.GRID,
};
},
);
@@ -392,6 +395,7 @@ export class PageLayoutUpdateService {
deletedAt: null,
universalIdentifier: widgetId,
applicationId: workspaceCustomApplicationId,
conditionalDisplay: null,
};
},
);
@@ -9,6 +9,7 @@ export const fromFlatPageLayoutToPageLayoutDto = (
updatedAt,
deletedAt,
tabIds: _tabIds,
defaultTabToFocusOnMobileAndSidePanelId,
...rest
} = flatPageLayout;
@@ -17,5 +18,7 @@ export const fromFlatPageLayoutToPageLayoutDto = (
createdAt: new Date(createdAt),
updatedAt: new Date(updatedAt),
deletedAt: deletedAt ? new Date(deletedAt) : null,
defaultTabToFocusOnMobileAndSidePanelId:
defaultTabToFocusOnMobileAndSidePanelId ?? undefined,
};
};
@@ -1,4 +1,5 @@
import { isDefined } from 'class-validator';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
import { STANDARD_PAGE_LAYOUTS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout.constant';
@@ -64,5 +65,7 @@ export const createStandardPageLayoutTabFlatMetadata = <
createdAt: now,
updatedAt: now,
deletedAt: null,
icon: null,
layoutMode: PageLayoutTabLayoutMode.GRID,
};
};
@@ -87,5 +87,6 @@ export const createStandardPageLayoutWidgetFlatMetadata = <
createdAt: now,
updatedAt: now,
deletedAt: null,
conditionalDisplay: null,
};
};
@@ -41,5 +41,6 @@ export const createStandardPageLayoutFlatMetadata = ({
createdAt: now,
updatedAt: now,
deletedAt: null,
defaultTabToFocusOnMobileAndSidePanelId: null,
};
};
@@ -798,6 +798,8 @@ export class WorkspaceMigrationBuildOrchestratorService {
dependencyOptimisticFlatEntityMaps: {
flatObjectMetadataMaps:
optimisticAllFlatEntityMaps.flatObjectMetadataMaps,
flatPageLayoutTabMaps:
optimisticAllFlatEntityMaps.flatPageLayoutTabMaps,
},
workspaceId,
},
@@ -42,6 +42,7 @@ export const ALL_JSONB_PROPERTIES_BY_METADATA_NAME = {
pageLayoutWidget: {
gridPosition: 'gridPosition',
configuration: 'configuration',
conditionalDisplay: 'conditionalDisplay',
},
commandMenuItem: {},
navigationMenuItem: {},
@@ -129,6 +129,8 @@ export { ObjectRecordGroupByDateGranularity } from './ObjectRecordGroupByDateGra
export type { ObjectsPermissions } from './ObjectsPermissions';
export type { ObjectsPermissionsByRoleId } from './ObjectsPermissionsByRoleId';
export type { OrderBy } from './OrderBy';
export { PageLayoutTabLayoutMode } from './page-layout/PageLayoutTabLayoutMode';
export type { PageLayoutWidgetConditionalDisplay } from './page-layout/PageLayoutWidgetConditionalDisplay';
export type { PartialFieldMetadataItem } from './PartialFieldMetadataItem';
export type { PartialFieldMetadataItemOption } from './PartialFieldMetadataOption';
export type { QueryCursorDirection } from './QueryCursorDirection';
@@ -0,0 +1,5 @@
export enum PageLayoutTabLayoutMode {
GRID = 'GRID',
VERTICAL_LIST = 'VERTICAL_LIST',
CANVAS = 'CANVAS',
}
@@ -0,0 +1,2 @@
// TODO: Loose typing for now. Add proper typing once product is clear.
export type PageLayoutWidgetConditionalDisplay = object;