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:
committed by
GitHub
parent
8e003aa6cf
commit
926dd545f4
+51
@@ -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();
|
||||
});
|
||||
});
|
||||
+39
@@ -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' });
|
||||
});
|
||||
});
|
||||
+78
@@ -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);
|
||||
});
|
||||
});
|
||||
+49
@@ -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');
|
||||
});
|
||||
});
|
||||
+202
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
+42
@@ -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);
|
||||
});
|
||||
});
|
||||
+51
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user