Create fake hidden fields group (#18525)

## Demo


https://github.com/user-attachments/assets/43f31c43-fe37-4553-ad42-fc97a948d6ea

## Ungrouped fields

<img width="3456" height="2160" alt="CleanShot 2026-03-10 at 13 52
57@2x"
src="https://github.com/user-attachments/assets/13d1db63-59ac-4e2b-8950-fccf430176c4"
/>
This commit is contained in:
Baptiste Devessier
2026-03-10 16:26:35 +01:00
committed by GitHub
parent 8e003aa6cf
commit 926dd545f4
23 changed files with 1110 additions and 22 deletions
@@ -0,0 +1,51 @@
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { assertPageLayoutTabHasDefinedLayoutModeOrThrow } from '@/page-layout/utils/assertPageLayoutTabHasDefinedLayoutModeOrThrow';
import { PageLayoutTabLayoutMode } from '~/generated-metadata/graphql';
describe('assertPageLayoutTabHasDefinedLayoutModeOrThrow', () => {
it('should throw when tab is undefined', () => {
expect(() => {
assertPageLayoutTabHasDefinedLayoutModeOrThrow(undefined);
}).toThrow('Tab layout mode is not defined');
});
it('should throw when tab has null layoutMode', () => {
const tab = {
layoutMode: null,
} as unknown as PageLayoutTab;
expect(() => {
assertPageLayoutTabHasDefinedLayoutModeOrThrow(tab);
}).toThrow('Tab layout mode is not defined');
});
it('should throw when tab has undefined layoutMode', () => {
const tab = {
layoutMode: undefined,
} as unknown as PageLayoutTab;
expect(() => {
assertPageLayoutTabHasDefinedLayoutModeOrThrow(tab);
}).toThrow('Tab layout mode is not defined');
});
it('should not throw when tab has a valid layoutMode', () => {
const tab = {
layoutMode: PageLayoutTabLayoutMode.GRID,
} as unknown as PageLayoutTab;
expect(() => {
assertPageLayoutTabHasDefinedLayoutModeOrThrow(tab);
}).not.toThrow();
});
it('should not throw for VERTICAL_LIST layoutMode', () => {
const tab = {
layoutMode: PageLayoutTabLayoutMode.VERTICAL_LIST,
} as unknown as PageLayoutTab;
expect(() => {
assertPageLayoutTabHasDefinedLayoutModeOrThrow(tab);
}).not.toThrow();
});
});
@@ -0,0 +1,39 @@
import { buildWidgetVisibilityContext } from '@/page-layout/utils/buildWidgetVisibilityContext';
describe('buildWidgetVisibilityContext', () => {
it('should return MOBILE device when isMobile is true', () => {
const result = buildWidgetVisibilityContext({
isMobile: true,
isInSidePanel: false,
});
expect(result).toEqual({ device: 'MOBILE' });
});
it('should return MOBILE device when isInSidePanel is true', () => {
const result = buildWidgetVisibilityContext({
isMobile: false,
isInSidePanel: true,
});
expect(result).toEqual({ device: 'MOBILE' });
});
it('should return MOBILE device when both isMobile and isInSidePanel are true', () => {
const result = buildWidgetVisibilityContext({
isMobile: true,
isInSidePanel: true,
});
expect(result).toEqual({ device: 'MOBILE' });
});
it('should return DESKTOP device when both are false', () => {
const result = buildWidgetVisibilityContext({
isMobile: false,
isInSidePanel: false,
});
expect(result).toEqual({ device: 'DESKTOP' });
});
});
@@ -0,0 +1,78 @@
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultCompanyRecordPageLayoutId';
import { DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultPersonRecordPageLayoutId';
import { DEFAULT_RECORD_PAGE_LAYOUT_ID } from '@/page-layout/constants/DefaultRecordPageLayoutId';
import { getRecordPageLayoutId } from '@/page-layout/utils/getRecordPageLayoutId';
import { CoreObjectNameSingular } from 'twenty-shared/types';
const createMockRecord = (
overrides: Partial<ObjectRecord> = {},
): ObjectRecord => ({
id: 'record-1',
__typename: 'ObjectRecord',
...overrides,
});
describe('getRecordPageLayoutId', () => {
it('should return null when record is null', () => {
const result = getRecordPageLayoutId({
record: null,
targetObjectNameSingular: CoreObjectNameSingular.Company,
});
expect(result).toBeNull();
});
it('should return null when record is undefined', () => {
const result = getRecordPageLayoutId({
record: undefined,
targetObjectNameSingular: CoreObjectNameSingular.Company,
});
expect(result).toBeNull();
});
it('should return record.pageLayoutId when it is defined', () => {
const record = createMockRecord({ pageLayoutId: 'custom-layout-123' });
const result = getRecordPageLayoutId({
record,
targetObjectNameSingular: CoreObjectNameSingular.Company,
});
expect(result).toBe('custom-layout-123');
});
it('should return Company default layout for Company object', () => {
const record = createMockRecord();
const result = getRecordPageLayoutId({
record,
targetObjectNameSingular: CoreObjectNameSingular.Company,
});
expect(result).toBe(DEFAULT_COMPANY_RECORD_PAGE_LAYOUT_ID);
});
it('should return Person default layout for Person object', () => {
const record = createMockRecord();
const result = getRecordPageLayoutId({
record,
targetObjectNameSingular: CoreObjectNameSingular.Person,
});
expect(result).toBe(DEFAULT_PERSON_RECORD_PAGE_LAYOUT_ID);
});
it('should return generic default layout for unknown object type', () => {
const record = createMockRecord();
const result = getRecordPageLayoutId({
record,
targetObjectNameSingular: 'customObject',
});
expect(result).toBe(DEFAULT_RECORD_PAGE_LAYOUT_ID);
});
});
@@ -0,0 +1,49 @@
import { getTabListInstanceIdFromPageLayoutAndRecord } from '@/page-layout/utils/getTabListInstanceIdFromPageLayoutAndRecord';
import { PageLayoutType } from '~/generated-metadata/graphql';
describe('getTabListInstanceIdFromPageLayoutAndRecord', () => {
it('should include record ID for RECORD_PAGE layout with targetRecordIdentifier', () => {
const result = getTabListInstanceIdFromPageLayoutAndRecord({
pageLayoutId: 'layout-1',
layoutType: PageLayoutType.RECORD_PAGE,
targetRecordIdentifier: {
id: 'record-42',
targetObjectNameSingular: 'company',
},
});
expect(result).toBe('layout-1-tab-list-record-42');
});
it('should omit record ID for DASHBOARD layout', () => {
const result = getTabListInstanceIdFromPageLayoutAndRecord({
pageLayoutId: 'layout-1',
layoutType: PageLayoutType.DASHBOARD,
targetRecordIdentifier: {
id: 'record-42',
targetObjectNameSingular: 'company',
},
});
expect(result).toBe('layout-1-tab-list');
});
it('should omit record ID when targetRecordIdentifier is undefined', () => {
const result = getTabListInstanceIdFromPageLayoutAndRecord({
pageLayoutId: 'layout-1',
layoutType: PageLayoutType.RECORD_PAGE,
targetRecordIdentifier: undefined,
});
expect(result).toBe('layout-1-tab-list');
});
it('should use the base instance ID from getTabListInstanceIdFromPageLayoutId', () => {
const result = getTabListInstanceIdFromPageLayoutAndRecord({
pageLayoutId: 'my-custom-layout',
layoutType: PageLayoutType.DASHBOARD,
});
expect(result).toBe('my-custom-layout-tab-list');
});
});
@@ -0,0 +1,202 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { type PageLayoutTab } from '@/page-layout/types/PageLayoutTab';
import { type PageLayoutWidget } from '@/page-layout/types/PageLayoutWidget';
import { injectRelationWidgetsIntoLayout } from '@/page-layout/utils/injectRelationWidgetsIntoLayout';
import {
WidgetConfigurationType,
WidgetType,
} from '~/generated-metadata/graphql';
const createMockWidget = (
id: string,
type: WidgetType = WidgetType.FIELDS,
): PageLayoutWidget =>
({
__typename: 'PageLayoutWidget',
id,
pageLayoutTabId: 'tab-1',
title: `Widget ${id}`,
type,
objectMetadataId: null,
gridPosition: {
__typename: 'GridPosition',
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
position: {
__typename: 'PageLayoutWidgetGridPosition',
layoutMode: 'GRID',
row: 0,
column: 0,
rowSpan: 1,
columnSpan: 12,
},
configuration: {
__typename: 'FieldsConfiguration',
configurationType: WidgetConfigurationType.FIELDS,
viewId: null,
},
isOverridden: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
}) as PageLayoutWidget;
const createMockTab = (
id: string,
widgets: PageLayoutWidget[],
): PageLayoutTab =>
({
__typename: 'PageLayoutTab',
applicationId: '',
id,
pageLayoutId: 'page-layout-1',
title: `Tab ${id}`,
position: 0,
widgets,
isOverridden: false,
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-01T00:00:00.000Z',
deletedAt: null,
}) as PageLayoutTab;
const createMockLayout = (tabs: PageLayoutTab[]): PageLayout =>
({
__typename: 'PageLayout',
id: 'layout-1',
tabs,
}) as PageLayout;
const createMockFieldMetadataItem = (
id: string,
label: string,
): FieldMetadataItem =>
({
id,
label,
name: label.toLowerCase(),
type: 'RELATION',
}) as FieldMetadataItem;
describe('injectRelationWidgetsIntoLayout', () => {
it('should return layout unchanged when relation fields array is empty', () => {
const layout = createMockLayout([
createMockTab('tab-1', [createMockWidget('w1')]),
]);
const result = injectRelationWidgetsIntoLayout(layout, []);
expect(result).toBe(layout);
});
it('should return layout unchanged when there are no tabs', () => {
const layout = createMockLayout([]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
expect(result).toBe(layout);
});
it('should append relation widgets when no FIELDS widget exists', () => {
const otherWidget = createMockWidget('w1', WidgetType.TIMELINE);
const layout = createMockLayout([createMockTab('tab-1', [otherWidget])]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
expect(result.tabs[0].widgets).toHaveLength(2);
expect(result.tabs[0].widgets[0].id).toBe('w1');
expect(result.tabs[0].widgets[1].id).toContain('dynamic-relation-widget-');
expect(result.tabs[0].widgets[1].id).toContain('f1');
});
it('should inject relation widgets after the first FIELDS widget', () => {
const fieldsWidget = createMockWidget('fields-1', WidgetType.FIELDS);
const timelineWidget = createMockWidget('timeline-1', WidgetType.TIMELINE);
const layout = createMockLayout([
createMockTab('tab-1', [fieldsWidget, timelineWidget]),
]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
expect(result.tabs[0].widgets).toHaveLength(3);
expect(result.tabs[0].widgets[0].id).toBe('fields-1');
expect(result.tabs[0].widgets[1].id).toContain('f1');
expect(result.tabs[0].widgets[2].id).toBe('timeline-1');
});
it('should reposition NOTES widget after relation widgets', () => {
const fieldsWidget = createMockWidget('fields-1', WidgetType.FIELDS);
const notesWidget = createMockWidget('notes-1', WidgetType.NOTES);
const timelineWidget = createMockWidget('timeline-1', WidgetType.TIMELINE);
const layout = createMockLayout([
createMockTab('tab-1', [fieldsWidget, notesWidget, timelineWidget]),
]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
const widgetIds = result.tabs[0].widgets.map((w) => w.id);
expect(widgetIds[0]).toBe('fields-1');
expect(widgetIds[1]).toContain('f1');
expect(widgetIds[2]).toBe('notes-1');
expect(widgetIds[3]).toBe('timeline-1');
});
it('should only modify the first tab', () => {
const layout = createMockLayout([
createMockTab('tab-1', [createMockWidget('fields-1', WidgetType.FIELDS)]),
createMockTab('tab-2', [createMockWidget('fields-2', WidgetType.FIELDS)]),
]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
expect(result.tabs[0].widgets).toHaveLength(2);
expect(result.tabs[1].widgets).toHaveLength(1);
expect(result.tabs[1].widgets[0].id).toBe('fields-2');
});
it('should inject multiple relation widgets in order', () => {
const fieldsWidget = createMockWidget('fields-1', WidgetType.FIELDS);
const layout = createMockLayout([createMockTab('tab-1', [fieldsWidget])]);
const fields = [
createMockFieldMetadataItem('f1', 'Company'),
createMockFieldMetadataItem('f2', 'Person'),
];
const result = injectRelationWidgetsIntoLayout(layout, fields);
expect(result.tabs[0].widgets).toHaveLength(3);
expect(result.tabs[0].widgets[0].id).toBe('fields-1');
expect(result.tabs[0].widgets[1].id).toContain('f1');
expect(result.tabs[0].widgets[2].id).toContain('f2');
});
it('should set correct properties on injected relation widgets', () => {
const layout = createMockLayout([
createMockTab('tab-1', [createMockWidget('fields-1', WidgetType.FIELDS)]),
]);
const fields = [createMockFieldMetadataItem('f1', 'Company')];
const result = injectRelationWidgetsIntoLayout(layout, fields);
const injectedWidget = result.tabs[0].widgets[1];
expect(injectedWidget.type).toBe(WidgetType.FIELD);
expect(injectedWidget.title).toBe('Company');
expect(injectedWidget.pageLayoutTabId).toBe('tab-1');
expect(injectedWidget.configuration).toEqual(
expect.objectContaining({
fieldMetadataId: 'f1',
layout: 'CARD',
}),
);
});
});
@@ -0,0 +1,42 @@
import { type PageLayout } from '@/page-layout/types/PageLayout';
import { isPageLayoutEmpty } from '@/page-layout/utils/isPageLayoutEmpty';
const createMockLayout = (tabWidgetCounts: number[]): PageLayout =>
({
__typename: 'PageLayout',
id: 'layout-1',
tabs: tabWidgetCounts.map((widgetCount, index) => ({
__typename: 'PageLayoutTab',
id: `tab-${index}`,
widgets: Array.from({ length: widgetCount }, (_, i) => ({
__typename: 'PageLayoutWidget',
id: `widget-${index}-${i}`,
})),
})),
}) as PageLayout;
describe('isPageLayoutEmpty', () => {
it('should return true when layout has one tab with no widgets', () => {
const layout = createMockLayout([0]);
expect(isPageLayoutEmpty(layout)).toBe(true);
});
it('should return false when layout has one tab with widgets', () => {
const layout = createMockLayout([2]);
expect(isPageLayoutEmpty(layout)).toBe(false);
});
it('should return false when layout has multiple tabs even with no widgets', () => {
const layout = createMockLayout([0, 0]);
expect(isPageLayoutEmpty(layout)).toBe(false);
});
it('should return false when layout has multiple tabs with widgets', () => {
const layout = createMockLayout([1, 3]);
expect(isPageLayoutEmpty(layout)).toBe(false);
});
});
@@ -0,0 +1,51 @@
import { removeTabLayouts } from '@/page-layout/utils/removeTabLayouts';
describe('removeTabLayouts', () => {
it('should remove the specified tab and return the rest', () => {
const allTabLayouts = {
'tab-1': { lg: [], md: [] },
'tab-2': { lg: [], md: [] },
'tab-3': { lg: [], md: [] },
};
const result = removeTabLayouts(allTabLayouts, 'tab-2');
expect(result).toEqual({
'tab-1': { lg: [], md: [] },
'tab-3': { lg: [], md: [] },
});
expect(result).not.toHaveProperty('tab-2');
});
it('should return the same object reference when tab does not exist', () => {
const allTabLayouts = {
'tab-1': { lg: [], md: [] },
};
const result = removeTabLayouts(allTabLayouts, 'non-existent');
expect(result).toBe(allTabLayouts);
});
it('should return empty object when removing the only tab', () => {
const allTabLayouts = {
'tab-1': { lg: [], md: [] },
};
const result = removeTabLayouts(allTabLayouts, 'tab-1');
expect(result).toEqual({});
});
it('should not mutate the original object', () => {
const allTabLayouts = {
'tab-1': { lg: [], md: [] },
'tab-2': { lg: [], md: [] },
};
removeTabLayouts(allTabLayouts, 'tab-1');
expect(allTabLayouts).toHaveProperty('tab-1');
expect(allTabLayouts).toHaveProperty('tab-2');
});
});