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
+48
-6
@@ -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}
|
||||
|
||||
+4
-2
@@ -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);
|
||||
|
||||
+92
@@ -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 };
|
||||
};
|
||||
+87
@@ -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 };
|
||||
};
|
||||
+191
@@ -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');
|
||||
});
|
||||
});
|
||||
+33
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user