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
@@ -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;
};