Add record page layout tabs editing (#18702)

- Fix duplication of tabs in dashboards that didn't open the duplicated
tab in the side panel: replaced the logic that used Math.round with an
algorithm that switches the positions of the elements. We will keep
relying on integers, but it will work well in all cases.
- Make dnd work with record page layouts, where there is a pinned tab
- Make tab movements work with pinned tab, too
- Allow user to set a tab as the pinned tab
- Make backend changes to be able to save tabs with `layoutMode`



https://github.com/user-attachments/assets/ce1130fa-71df-49ba-ba2f-6f971e15dd49

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Baptiste Devessier
2026-03-18 15:48:23 +01:00
committed by GitHub
parent f2fa958f20
commit fe4512f80c
77 changed files with 1153 additions and 662 deletions
@@ -4,4 +4,5 @@ export const FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES = [
'title',
'position',
'icon',
'layoutMode',
] as const satisfies MetadataEntityPropertyName<'pageLayoutTab'>[];
@@ -56,7 +56,8 @@ export const fromCreatePageLayoutTabInputToFlatPageLayoutTabToCreate = ({
widgetIds: [],
widgetUniversalIdentifiers: [],
icon: null,
layoutMode: PageLayoutTabLayoutMode.GRID,
layoutMode:
createPageLayoutTabInput.layoutMode ?? PageLayoutTabLayoutMode.GRID,
overrides: null,
};
};
@@ -1,12 +1,14 @@
import { Field, Float, InputType } from '@nestjs/graphql';
import {
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@@ -26,4 +28,12 @@ export class CreatePageLayoutTabInput {
@IsUUID()
@IsNotEmpty()
pageLayoutId: string;
@Field(() => PageLayoutTabLayoutMode, {
nullable: true,
defaultValue: PageLayoutTabLayoutMode.GRID,
})
@IsEnum(PageLayoutTabLayoutMode)
@IsOptional()
layoutMode?: PageLayoutTabLayoutMode;
}
@@ -3,6 +3,7 @@ import { Field, Float, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsArray,
IsEnum,
IsNotEmpty,
IsNumber,
IsOptional,
@@ -10,6 +11,7 @@ import {
IsUUID,
ValidateNested,
} from 'class-validator';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { UpdatePageLayoutWidgetWithIdInput } from 'src/engine/metadata-modules/page-layout-widget/dtos/inputs/update-page-layout-widget-with-id.input';
@@ -35,6 +37,14 @@ export class UpdatePageLayoutTabWithWidgetsInput {
@IsOptional()
icon?: string | null;
@Field(() => PageLayoutTabLayoutMode, {
nullable: true,
defaultValue: PageLayoutTabLayoutMode.GRID,
})
@IsEnum(PageLayoutTabLayoutMode)
@IsOptional()
layoutMode?: PageLayoutTabLayoutMode;
@Field(() => [UpdatePageLayoutWidgetWithIdInput])
@IsArray()
@ValidateNested({ each: true })
@@ -1,6 +1,7 @@
import { Field, Float, InputType } from '@nestjs/graphql';
import { IsNumber, IsOptional, IsString } from 'class-validator';
import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
import { PageLayoutTabLayoutMode } from 'twenty-shared/types';
@InputType()
export class UpdatePageLayoutTabInput {
@@ -18,4 +19,9 @@ export class UpdatePageLayoutTabInput {
@IsString()
@IsOptional()
icon?: string | null;
@Field(() => PageLayoutTabLayoutMode, { nullable: true })
@IsEnum(PageLayoutTabLayoutMode)
@IsOptional()
layoutMode?: PageLayoutTabLayoutMode;
}
@@ -294,7 +294,7 @@ export class PageLayoutUpdateService {
widgetIds: [],
widgetUniversalIdentifiers: [],
icon: null,
layoutMode: PageLayoutTabLayoutMode.GRID,
layoutMode: tabInput.layoutMode ?? PageLayoutTabLayoutMode.GRID,
overrides: null,
};
},
@@ -311,6 +311,7 @@ export class PageLayoutUpdateService {
...existingTab,
title: tabInput.title,
position: tabInput.position,
layoutMode: tabInput.layoutMode ?? existingTab.layoutMode,
updatedAt: now.toISOString(),
};
},
@@ -327,6 +328,7 @@ export class PageLayoutUpdateService {
...existingTab,
title: tabInput.title,
position: tabInput.position,
layoutMode: tabInput.layoutMode ?? existingTab.layoutMode,
deletedAt: null,
updatedAt: now.toISOString(),
};