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
File diff suppressed because one or more lines are too long
@@ -1,6 +1,12 @@
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
import { fieldsWidgetEditorModePersistedComponentState } from '@/page-layout/states/fieldsWidgetEditorModePersistedComponentState';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetGroupsPersistedComponentState } from '@/page-layout/states/fieldsWidgetGroupsPersistedComponentState';
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
import { fieldsWidgetUngroupedFieldsPersistedComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsPersistedComponentState';
import { hasInitializedFieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/hasInitializedFieldsWidgetGroupsDraftComponentState';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { pageLayoutCurrentLayoutsComponentState } from '@/page-layout/states/pageLayoutCurrentLayoutsComponentState';
@@ -9,9 +15,9 @@ import { pageLayoutIsInitializedComponentState } from '@/page-layout/states/page
import { pageLayoutPersistedComponentState } from '@/page-layout/states/pageLayoutPersistedComponentState';
import { convertPageLayoutToTabLayouts } from '@/page-layout/utils/convertPageLayoutToTabLayouts';
import { useCloseAnyOpenDropdown } from '@/ui/layout/dropdown/hooks/useCloseAnyOpenDropdown';
import { useStore } from 'jotai';
import { useCallback } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useStore } from 'jotai';
export const useExecuteTasksOnAnyLocationChange = () => {
const store = useStore();
@@ -71,6 +77,48 @@ export const useExecuteTasksOnAnyLocationChange = () => {
{},
);
store.set(
fieldsWidgetGroupsDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
{},
);
store.set(
fieldsWidgetGroupsPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
{},
);
store.set(
fieldsWidgetUngroupedFieldsDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
{},
);
store.set(
fieldsWidgetUngroupedFieldsPersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
{},
);
store.set(
fieldsWidgetEditorModeDraftComponentState.atomFamily({
instanceId: pageLayoutId,
}),
{},
);
store.set(
fieldsWidgetEditorModePersistedComponentState.atomFamily({
instanceId: pageLayoutId,
}),
{},
);
store.set(currentPageLayoutIdState.atom, null);
}
@@ -167,6 +167,7 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
... on FieldsConfiguration {
configurationType
viewId
shouldAllowUserToSeeHiddenFields
newFieldDefaultConfiguration {
isVisible
viewFieldGroupId
@@ -1,10 +1,14 @@
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreIsPageInEditModeComponentState } from '@/context-store/states/contextStoreIsPageInEditModeComponentState';
import { PageLayoutComponentInstanceContext } from '@/page-layout/states/contexts/PageLayoutComponentInstanceContext';
import { currentPageLayoutIdState } from '@/page-layout/states/currentPageLayoutIdState';
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
import { hasInitializedFieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/hasInitializedFieldsWidgetGroupsDraftComponentState';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { SIDE_PANEL_COMPONENT_INSTANCE_ID } from '@/side-panel/constants/SidePanelComponentInstanceId';
import { isSidePanelOpenedState } from '@/side-panel/states/isSidePanelOpenedState';
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useStore } from 'jotai';
@@ -27,10 +31,39 @@ export const useSetIsPageLayoutInEditMode = (pageLayoutIdFromProps: string) => {
MAIN_CONTEXT_STORE_INSTANCE_ID,
);
const fieldsWidgetGroupsDraftState = useAtomComponentStateCallbackState(
fieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const fieldsWidgetUngroupedFieldsDraftState =
useAtomComponentStateCallbackState(
fieldsWidgetUngroupedFieldsDraftComponentState,
pageLayoutId,
);
const fieldsWidgetEditorModeDraftState = useAtomComponentStateCallbackState(
fieldsWidgetEditorModeDraftComponentState,
pageLayoutId,
);
const hasInitializedFieldsWidgetGroupsDraftState =
useAtomComponentStateCallbackState(
hasInitializedFieldsWidgetGroupsDraftComponentState,
pageLayoutId,
);
const store = useStore();
const setIsPageLayoutInEditMode = useCallback(
(value: boolean) => {
if (value) {
store.set(fieldsWidgetGroupsDraftState, {});
store.set(fieldsWidgetUngroupedFieldsDraftState, {});
store.set(fieldsWidgetEditorModeDraftState, {});
store.set(hasInitializedFieldsWidgetGroupsDraftState, {});
}
store.set(isPageLayoutInEditModeState, value);
store.set(contextStoreIsFullTabWidgetInEditModeState, value);
@@ -51,6 +84,10 @@ export const useSetIsPageLayoutInEditMode = (pageLayoutIdFromProps: string) => {
[
isPageLayoutInEditModeState,
contextStoreIsFullTabWidgetInEditModeState,
fieldsWidgetGroupsDraftState,
fieldsWidgetUngroupedFieldsDraftState,
fieldsWidgetEditorModeDraftState,
hasInitializedFieldsWidgetGroupsDraftState,
pageLayoutId,
store,
],
@@ -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');
});
});
@@ -7,6 +7,7 @@ import { FieldsWidgetCellHoveredPortal } from '@/page-layout/widgets/fields/comp
import { FieldsWidgetFieldList } from '@/page-layout/widgets/fields/components/FieldsWidgetFieldList';
import { FieldsWidgetGroupContainer } from '@/page-layout/widgets/fields/components/FieldsWidgetGroupContainer';
import { useFieldsWidgetGroupsForDisplay } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroupsForDisplay';
import { useFieldsWidgetHiddenFieldsForDisplay } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetHiddenFieldsForDisplay';
import { useLayoutRenderingContext } from '@/ui/layout/contexts/LayoutRenderingContext';
import { useTargetRecord } from '@/ui/layout/contexts/useTargetRecord';
import { SidePanelProvider } from '@/ui/layout/side-panel/contexts/SidePanelContext';
@@ -40,13 +41,16 @@ const StyledPropertyBox = styled.div`
padding-top: ${themeCssVariables.spacing[3]};
`;
const StyledInlineFieldsPropertyBox = styled.div`
const StyledInlineFieldsPropertyBox = styled.div<{
hasMoreSection: boolean;
}>`
align-self: stretch;
border-radius: ${themeCssVariables.border.radius.sm};
display: flex;
flex-direction: column;
gap: ${themeCssVariables.spacing[2]};
padding-bottom: 0;
padding-bottom: ${({ hasMoreSection }) =>
hasMoreSection ? themeCssVariables.spacing[3] : '0'};
padding-top: 0;
`;
@@ -72,9 +76,31 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
objectNameSingular: targetRecord.targetObjectNameSingular,
});
const flattenedFieldMetadataItems = groups.flatMap((group) =>
group.fields.map((field) => field.fieldMetadataItem),
);
const { hiddenFields } = useFieldsWidgetHiddenFieldsForDisplay({
widgetId: widget.id,
viewId: fieldsConfiguration.viewId ?? null,
objectNameSingular: targetRecord.targetObjectNameSingular,
});
const shouldShowHiddenFields =
fieldsConfiguration.shouldAllowUserToSeeHiddenFields === true &&
hiddenFields.length > 0;
const visibleFields = groups.flatMap((group) => group.fields);
const hiddenFieldsWithOffsetGlobalIndex = shouldShowHiddenFields
? hiddenFields.map((field) => ({
...field,
globalIndex: field.globalIndex + visibleFields.length,
}))
: [];
const flattenedFieldMetadataItems = [
...visibleFields.map((field) => field.fieldMetadataItem),
...hiddenFieldsWithOffsetGlobalIndex.map(
(field) => field.fieldMetadataItem,
),
];
const hasFieldsToDisplay = groups.length > 0;
@@ -110,7 +136,9 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
}}
>
{displayMode === 'inline' ? (
<StyledInlineFieldsPropertyBox>
<StyledInlineFieldsPropertyBox
hasMoreSection={shouldShowHiddenFields}
>
<FieldsWidgetFieldList
fields={groups.flatMap((group) => group.fields)}
instanceId={instanceId}
@@ -129,6 +157,20 @@ export const FieldsWidget = ({ widget }: FieldsWidgetProps) => {
))
)}
{shouldShowHiddenFields && (
<FieldsWidgetGroupContainer
title={t`More (${hiddenFieldsWithOffsetGlobalIndex.length})`}
defaultExpanded={false}
>
<StyledPropertyBox>
<FieldsWidgetFieldList
fields={hiddenFieldsWithOffsetGlobalIndex}
instanceId={instanceId}
/>
</StyledPropertyBox>
</FieldsWidgetGroupContainer>
)}
<FieldsWidgetCellHoveredPortal
objectMetadataItem={objectMetadataItem}
recordId={targetRecord.id}
@@ -2,7 +2,7 @@ import { styled } from '@linaria/react';
import { useContext, useState } from 'react';
import { IconChevronDown } from 'twenty-ui/display';
import { AnimatedExpandableContainer, Section } from 'twenty-ui/layout';
import { themeCssVariables, ThemeContext } from 'twenty-ui/theme-constants';
import { ThemeContext, themeCssVariables } from 'twenty-ui/theme-constants';
const StyledHeader = styled.header`
align-items: center;
@@ -29,14 +29,16 @@ const StyledChevronWrapper = styled.div<{ isExpanded: boolean }>`
type FieldsWidgetGroupContainerProps = {
children: React.ReactNode;
title: string;
defaultExpanded?: boolean;
};
export const FieldsWidgetGroupContainer = ({
children,
title,
defaultExpanded = true,
}: FieldsWidgetGroupContainerProps) => {
const { theme } = useContext(ThemeContext);
const [isExpanded, setIsExpanded] = useState(true);
const [isExpanded, setIsExpanded] = useState(defaultExpanded);
const handleToggleGroup = () =>
setIsExpanded((previousIsExpanded) => !previousIsExpanded);
@@ -0,0 +1,92 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import {
type FieldsWidgetGroup,
type FieldsWidgetGroupField,
} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { getHiddenFieldsFromGroups } from '@/page-layout/widgets/fields/utils/getHiddenFieldsFromGroups';
import { useViewById } from '@/views/hooks/useViewById';
import { useMemo } from 'react';
import { isDefined, isNonEmptyArray } from 'twenty-shared/utils';
type UseFieldsWidgetHiddenFieldsParams = {
viewId: string | null;
objectNameSingular: string;
};
export const useFieldsWidgetHiddenFields = ({
viewId,
objectNameSingular,
}: UseFieldsWidgetHiddenFieldsParams) => {
const { view } = useViewById(viewId);
const { objectMetadataItem } = useObjectMetadataItem({
objectNameSingular,
});
const hiddenFields = useMemo<FieldsWidgetGroupField[]>(() => {
if (!isDefined(objectMetadataItem)) {
return [];
}
if (isDefined(view) && isNonEmptyArray(view.viewFieldGroups)) {
const groups: FieldsWidgetGroup[] = view.viewFieldGroups.map((group) => {
const fields: FieldsWidgetGroupField[] = (group.viewFields ?? [])
.map((viewField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(f) => f.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
return {
fieldMetadataItem,
position: viewField.position,
isVisible: viewField.isVisible,
globalIndex: 0,
};
})
.filter(isDefined);
return {
id: group.id,
name: group.name,
position: group.position,
isVisible: group.isVisible,
fields,
};
});
return getHiddenFieldsFromGroups(groups);
}
if (isDefined(view) && view.viewFields.length > 0) {
let globalIndex = 0;
return [...view.viewFields]
.sort((a, b) => a.position - b.position)
.filter((viewField) => !viewField.isVisible)
.map((viewField) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(f) => f.id === viewField.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return null;
}
return {
fieldMetadataItem,
position: viewField.position,
isVisible: false,
globalIndex: globalIndex++,
};
})
.filter(isDefined);
}
return [];
}, [objectMetadataItem, view]);
return { hiddenFields };
};
@@ -0,0 +1,87 @@
import { fieldsWidgetEditorModeDraftComponentState } from '@/page-layout/states/fieldsWidgetEditorModeDraftComponentState';
import { fieldsWidgetGroupsDraftComponentState } from '@/page-layout/states/fieldsWidgetGroupsDraftComponentState';
import { fieldsWidgetUngroupedFieldsDraftComponentState } from '@/page-layout/states/fieldsWidgetUngroupedFieldsDraftComponentState';
import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPageLayoutInEditModeComponentState';
import { useFieldsWidgetHiddenFields } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetHiddenFields';
import { type FieldsWidgetGroupField } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { getHiddenFieldsFromGroups } from '@/page-layout/widgets/fields/utils/getHiddenFieldsFromGroups';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UseFieldsWidgetHiddenFieldsForDisplayParams = {
widgetId: string;
viewId: string | null;
objectNameSingular: string;
};
export const useFieldsWidgetHiddenFieldsForDisplay = ({
widgetId,
viewId,
objectNameSingular,
}: UseFieldsWidgetHiddenFieldsForDisplayParams) => {
const isPageLayoutInEditMode = useAtomComponentStateValue(
isPageLayoutInEditModeComponentState,
);
const fieldsWidgetGroupsDraft = useAtomComponentStateValue(
fieldsWidgetGroupsDraftComponentState,
);
const fieldsWidgetUngroupedFieldsDraft = useAtomComponentStateValue(
fieldsWidgetUngroupedFieldsDraftComponentState,
);
const fieldsWidgetEditorModeDraft = useAtomComponentStateValue(
fieldsWidgetEditorModeDraftComponentState,
);
const { hiddenFields: viewHiddenFields } = useFieldsWidgetHiddenFields({
viewId,
objectNameSingular,
});
const draftGroupsForWidget = fieldsWidgetGroupsDraft[widgetId];
const draftEditorMode = fieldsWidgetEditorModeDraft[widgetId];
const ungroupedFieldsForWidget = fieldsWidgetUngroupedFieldsDraft[widgetId];
const hasDraftGroups =
isDefined(draftGroupsForWidget) && draftGroupsForWidget.length > 0;
const hasDraftUngroupedFields =
isDefined(ungroupedFieldsForWidget) && ungroupedFieldsForWidget.length > 0;
const hiddenFields = useMemo<FieldsWidgetGroupField[]>(() => {
if (
isPageLayoutInEditMode &&
draftEditorMode === 'ungrouped' &&
hasDraftUngroupedFields
) {
let globalIndex = 0;
return ungroupedFieldsForWidget
.filter((field) => !field.isVisible)
.sort((a, b) => a.position - b.position)
.map((field) => ({
...field,
globalIndex: globalIndex++,
}));
}
if (isPageLayoutInEditMode && hasDraftGroups) {
return getHiddenFieldsFromGroups(draftGroupsForWidget);
}
return viewHiddenFields;
}, [
isPageLayoutInEditMode,
draftEditorMode,
hasDraftGroups,
hasDraftUngroupedFields,
draftGroupsForWidget,
ungroupedFieldsForWidget,
viewHiddenFields,
]);
return { hiddenFields };
};
@@ -0,0 +1,191 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type FieldsWidgetGroup } from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
import { getHiddenFieldsFromGroups } from '@/page-layout/widgets/fields/utils/getHiddenFieldsFromGroups';
const mockFieldMetadataItem = {
id: 'field-meta-1',
name: 'name',
label: 'Name',
type: 'TEXT',
} as FieldMetadataItem;
const makeGroup = (
overrides: Partial<FieldsWidgetGroup> & { id: string },
): FieldsWidgetGroup => ({
name: 'Group',
position: 0,
isVisible: true,
fields: [],
...overrides,
});
const makeField = (
overrides: Partial<FieldsWidgetGroup['fields'][number]> = {},
): FieldsWidgetGroup['fields'][number] => ({
fieldMetadataItem: mockFieldMetadataItem,
position: 0,
isVisible: true,
globalIndex: 0,
viewFieldId: 'vf1',
...overrides,
});
describe('getHiddenFieldsFromGroups', () => {
it('should return empty array for empty input', () => {
const result = getHiddenFieldsFromGroups([]);
expect(result).toEqual([]);
});
it('should return empty array when all fields are visible in visible groups', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
isVisible: true,
fields: [
makeField({ isVisible: true, viewFieldId: 'vf1' }),
makeField({ isVisible: true, viewFieldId: 'vf2' }),
],
}),
];
const result = getHiddenFieldsFromGroups(groups);
expect(result).toEqual([]);
});
it('should collect hidden fields from visible groups', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
isVisible: true,
fields: [
makeField({ isVisible: true, viewFieldId: 'vf1' }),
makeField({ isVisible: false, viewFieldId: 'vf2' }),
makeField({ isVisible: true, viewFieldId: 'vf3' }),
],
}),
];
const result = getHiddenFieldsFromGroups(groups);
expect(result).toHaveLength(1);
expect(result[0].viewFieldId).toBe('vf2');
expect(result[0].isVisible).toBe(false);
});
it('should collect all fields from hidden groups', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
isVisible: false,
fields: [
makeField({ isVisible: true, viewFieldId: 'vf1' }),
makeField({ isVisible: true, viewFieldId: 'vf2' }),
],
}),
];
const result = getHiddenFieldsFromGroups(groups);
expect(result).toHaveLength(2);
expect(result[0].isVisible).toBe(false);
expect(result[1].isVisible).toBe(false);
});
it('should sort groups by position', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g2',
position: 1,
isVisible: false,
fields: [makeField({ viewFieldId: 'vf2' })],
}),
makeGroup({
id: 'g1',
position: 0,
isVisible: false,
fields: [makeField({ viewFieldId: 'vf1' })],
}),
];
const result = getHiddenFieldsFromGroups(groups);
expect(result[0].viewFieldId).toBe('vf1');
expect(result[1].viewFieldId).toBe('vf2');
});
it('should sort fields by position within each group', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
isVisible: false,
fields: [
makeField({ position: 2, viewFieldId: 'vf2' }),
makeField({ position: 0, viewFieldId: 'vf0' }),
makeField({ position: 1, viewFieldId: 'vf1' }),
],
}),
];
const result = getHiddenFieldsFromGroups(groups);
expect(result.map((f) => f.viewFieldId)).toEqual(['vf0', 'vf1', 'vf2']);
});
it('should compute correct globalIndex across groups', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
position: 0,
isVisible: false,
fields: [
makeField({ position: 0, viewFieldId: 'vf1' }),
makeField({ position: 1, viewFieldId: 'vf2' }),
],
}),
makeGroup({
id: 'g2',
position: 1,
isVisible: false,
fields: [makeField({ position: 0, viewFieldId: 'vf3' })],
}),
];
const result = getHiddenFieldsFromGroups(groups);
expect(result[0].globalIndex).toBe(0);
expect(result[1].globalIndex).toBe(1);
expect(result[2].globalIndex).toBe(2);
});
it('should not count visible fields in globalIndex', () => {
const groups: FieldsWidgetGroup[] = [
makeGroup({
id: 'g1',
position: 0,
isVisible: true,
fields: [
makeField({ position: 0, isVisible: true, viewFieldId: 'vf1' }),
makeField({ position: 1, isVisible: false, viewFieldId: 'vf2' }),
],
}),
makeGroup({
id: 'g2',
position: 1,
isVisible: true,
fields: [
makeField({ position: 0, isVisible: false, viewFieldId: 'vf3' }),
],
}),
];
const result = getHiddenFieldsFromGroups(groups);
expect(result).toHaveLength(2);
expect(result[0].globalIndex).toBe(0);
expect(result[0].viewFieldId).toBe('vf2');
expect(result[1].globalIndex).toBe(1);
expect(result[1].viewFieldId).toBe('vf3');
});
});
@@ -0,0 +1,33 @@
import {
type FieldsWidgetGroup,
type FieldsWidgetGroupField,
} from '@/page-layout/widgets/fields/types/FieldsWidgetGroup';
export const getHiddenFieldsFromGroups = (
groups: FieldsWidgetGroup[],
): FieldsWidgetGroupField[] => {
const sortedGroups = [...groups].sort((a, b) => a.position - b.position);
let globalIndex = 0;
const result: FieldsWidgetGroupField[] = [];
for (const group of sortedGroups) {
const sortedFields = [...group.fields].sort(
(a, b) => a.position - b.position,
);
for (const field of sortedFields) {
if (field.isVisible && group.isVisible) {
continue;
}
result.push({
...field,
isVisible: false,
globalIndex: globalIndex++,
});
}
}
return result;
};
@@ -1,17 +1,19 @@
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { CommandMenuItem } from '@/command-menu/components/CommandMenuItem';
import { CommandMenuItemToggle } from '@/command-menu/components/CommandMenuItemToggle';
import { useFieldsWidgetGroups } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroups';
import { SidePanelGroup } from '@/side-panel/components/SidePanelGroup';
import { SidePanelList } from '@/side-panel/components/SidePanelList';
import { WidgetSettingsFooter } from '@/side-panel/pages/page-layout/components/WidgetSettingsFooter';
import { useNavigatePageLayoutSidePanel } from '@/side-panel/pages/page-layout/hooks/useNavigatePageLayoutSidePanel';
import { usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { useFieldsWidgetGroups } from '@/page-layout/widgets/fields/hooks/useFieldsWidgetGroups';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { styled } from '@linaria/react';
import { useLingui } from '@lingui/react/macro';
import { SidePanelPages } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconLayoutSidebarRight } from 'twenty-ui/display';
import { IconChevronDown, IconLayoutSidebarRight } from 'twenty-ui/display';
import { type FieldsConfiguration } from '~/generated-metadata/graphql';
const StyledContainer = styled.div`
@@ -33,6 +35,9 @@ export const SidePanelPageLayoutFieldsSettings = () => {
const { pageLayoutId, objectNameSingular } =
usePageLayoutIdForRecordPageLayoutFromContextStoreTargetedRecord();
const { updateCurrentWidgetConfig } =
useUpdateCurrentWidgetConfig(pageLayoutId);
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
const fieldsConfiguration = widgetInEditMode?.configuration as
@@ -59,6 +64,18 @@ export const SidePanelPageLayoutFieldsSettings = () => {
});
};
const isShouldAllowUserToSeeHiddenFieldsToggled =
fieldsConfiguration?.shouldAllowUserToSeeHiddenFields === true;
const handleToggleShouldAllowUserToSeeHiddenFields = () => {
updateCurrentWidgetConfig({
configToUpdate: {
shouldAllowUserToSeeHiddenFields:
!isShouldAllowUserToSeeHiddenFieldsToggled,
},
});
};
const selectableItemIds = [
'layout',
'display-more-fields-button',
@@ -75,7 +92,7 @@ export const SidePanelPageLayoutFieldsSettings = () => {
<StyledContainer>
<StyledSidePanelContainer>
<SidePanelList commandGroups={[]} selectableItemIds={selectableItemIds}>
<SidePanelGroup heading={t`Customize`}>
<SidePanelGroup heading={t`Data and display`}>
<SelectableListItem
itemId="layout"
onEnter={handleNavigateToLayout}
@@ -90,6 +107,18 @@ export const SidePanelPageLayoutFieldsSettings = () => {
contextualTextPosition="right"
/>
</SelectableListItem>
<SelectableListItem
itemId="display-more-fields-button"
onEnter={handleToggleShouldAllowUserToSeeHiddenFields}
>
<CommandMenuItemToggle
LeftIcon={IconChevronDown}
text={t`Display "More fields" button`}
id="display-more-fields-button"
toggled={isShouldAllowUserToSeeHiddenFieldsToggled}
onToggleChange={handleToggleShouldAllowUserToSeeHiddenFields}
/>
</SelectableListItem>
</SidePanelGroup>
</SidePanelList>
</StyledSidePanelContainer>
@@ -1169,6 +1169,7 @@ type FieldsConfiguration {
configurationType: WidgetConfigurationType!
viewId: String
newFieldDefaultConfiguration: NewFieldDefaultConfiguration
shouldAllowUserToSeeHiddenFields: Boolean
}
type FilesConfiguration {
@@ -888,6 +888,7 @@ export interface FieldsConfiguration {
configurationType: WidgetConfigurationType
viewId?: Scalars['String']
newFieldDefaultConfiguration?: NewFieldDefaultConfiguration
shouldAllowUserToSeeHiddenFields?: Scalars['Boolean']
__typename: 'FieldsConfiguration'
}
@@ -3752,6 +3753,7 @@ export interface FieldsConfigurationGenqlSelection{
configurationType?: boolean | number
viewId?: boolean | number
newFieldDefaultConfiguration?: NewFieldDefaultConfigurationGenqlSelection
shouldAllowUserToSeeHiddenFields?: boolean | number
__typename?: boolean | number
__scalar?: boolean | number
}
@@ -2418,6 +2418,9 @@ export default {
"newFieldDefaultConfiguration": [
73
],
"shouldAllowUserToSeeHiddenFields": [
6
],
"__typename": [
1
]
@@ -42,4 +42,9 @@ export class FieldsConfigurationDTO implements FieldsConfiguration {
@ValidateNested()
@Type(() => NewFieldDefaultConfigurationDTO)
newFieldDefaultConfiguration: NewFieldDefaultConfigurationDTO | null;
@Field(() => Boolean, { nullable: true })
@IsOptional()
@IsBoolean()
shouldAllowUserToSeeHiddenFields?: boolean;
}
@@ -104,6 +104,7 @@ export type FieldsConfiguration = {
configurationType: 'FIELDS';
viewId?: string | null;
newFieldDefaultConfiguration?: NewFieldDefaultConfiguration | null;
shouldAllowUserToSeeHiddenFields?: boolean;
};
export type FieldRichTextConfiguration = {