Prepare field option dropdown for RecordField usage (#13935)

This PR prepares and simplify option dropdown for using record fields.

I had a problem while trying to use currentRecordFields directly with
ordering so I stayed with the actual column definition system.

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Lucas Bordeau
2025-08-18 10:34:12 +02:00
committed by GitHub
parent 2894d6f7c2
commit c342ece2ee
8 changed files with 262 additions and 245 deletions
@@ -27,7 +27,7 @@ Having a list that is draggable will be useful, not only in dropdown.
Create a folder @/ui/draggable-list with a DraggableList component
This component should take as prop: itemsComponents, onDragEnd((previousIndex, nextIndex) => {})
Use this component in ViewFieldsVisibilityDropdownSection (move the logic from ViewFieldsVisibilityDropdownSection to DraggableList) by passing a list of DraggableMenuItems
Use this component in ObjectOptionsDropdownHiddenFieldsContent (move the logic from ObjectOptionsDropdownHiddenFieldsContent to DraggableList) by passing a list of DraggableMenuItems
Add a storybook test on this list (we don't know how to actually test the draggable behavior, but we can at least make sure the component renders correctly a list of items)
```
@@ -1,55 +1,17 @@
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { useObjectOptionsForTable } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForTable';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection';
import { ViewType } from '@/views/types/ViewType';
import { ViewFieldsVisibleDropdownSection } from '@/views/components/ViewFieldsVisibleDropdownSection';
import { useLingui } from '@lingui/react/macro';
import { IconChevronLeft, IconEyeOff } from 'twenty-ui/display';
import { MenuItemNavigate } from 'twenty-ui/navigation';
export const ObjectOptionsDropdownFieldsContent = () => {
const { t } = useLingui();
const {
viewType,
recordIndexId,
objectMetadataItem,
onContentChange,
resetContent,
} = useObjectOptionsDropdown();
const {
handleColumnVisibilityChange,
handleReorderColumns,
visibleTableColumns,
} = useObjectOptionsForTable(recordIndexId, objectMetadataItem.id);
const {
visibleBoardFields,
handleReorderBoardFields,
handleBoardFieldVisibilityChange,
} = useObjectOptionsForBoard({
objectNameSingular: objectMetadataItem.nameSingular,
recordBoardId: recordIndexId,
viewBarId: recordIndexId,
});
const visibleRecordFields =
viewType === ViewType.Kanban ? visibleBoardFields : visibleTableColumns;
const handleReorderFields =
viewType === ViewType.Kanban
? handleReorderBoardFields
: handleReorderColumns;
const handleChangeFieldVisibility =
viewType === ViewType.Kanban
? handleBoardFieldVisibilityChange
: handleColumnVisibilityChange;
const { onContentChange, resetContent } = useObjectOptionsDropdown();
return (
<DropdownContent>
@@ -63,15 +25,7 @@ export const ObjectOptionsDropdownFieldsContent = () => {
>
{t`Fields`}
</DropdownMenuHeader>
<ViewFieldsVisibilityDropdownSection
title={t`Visible`}
fields={visibleRecordFields}
isDraggable
onDragEnd={handleReorderFields}
onVisibilityChange={handleChangeFieldVisibility}
showSubheader={false}
showDragGrip={true}
/>
<ViewFieldsVisibleDropdownSection />
<DropdownMenuSeparator />
<DropdownMenuItemsContainer scrollable={false}>
<MenuItemNavigate
@@ -4,8 +4,6 @@ import { useSetRecoilState } from 'recoil';
import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
import { useObjectOptionsDropdown } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsDropdown';
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { useObjectOptionsForTable } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForTable';
import { SettingsPath } from '@/types/SettingsPath';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
@@ -13,8 +11,7 @@ import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState';
import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection';
import { ViewType } from '@/views/types/ViewType';
import { ViewFieldsHiddenDropdownSection } from '@/views/components/ViewFieldsHiddenDropdownSection';
import { useLingui } from '@lingui/react/macro';
import { IconChevronLeft, IconSettings } from 'twenty-ui/display';
import { MenuItem, UndecoratedLink } from 'twenty-ui/navigation';
@@ -22,13 +19,8 @@ import { getSettingsPath } from '~/utils/navigation/getSettingsPath';
export const ObjectOptionsDropdownHiddenFieldsContent = () => {
const { t } = useLingui();
const {
viewType,
recordIndexId,
objectMetadataItem,
onContentChange,
closeDropdown,
} = useObjectOptionsDropdown();
const { objectMetadataItem, onContentChange, closeDropdown } =
useObjectOptionsDropdown();
const { objectNamePlural } = useObjectNamePluralFromSingular({
objectNameSingular: objectMetadataItem.nameSingular,
@@ -38,24 +30,6 @@ export const ObjectOptionsDropdownHiddenFieldsContent = () => {
objectNamePlural,
});
const { handleColumnVisibilityChange, hiddenTableColumns } =
useObjectOptionsForTable(recordIndexId, objectMetadataItem.id);
const { hiddenBoardFields, handleBoardFieldVisibilityChange } =
useObjectOptionsForBoard({
objectNameSingular: objectMetadataItem.nameSingular,
recordBoardId: recordIndexId,
viewBarId: recordIndexId,
});
const hiddenRecordFields =
viewType === ViewType.Kanban ? hiddenBoardFields : hiddenTableColumns;
const handleChangeFieldVisibility =
viewType === ViewType.Kanban
? handleBoardFieldVisibilityChange
: handleColumnVisibilityChange;
const location = useLocation();
const setNavigationMemorizedUrl = useSetRecoilState(
navigationMemorizedUrlState,
@@ -73,16 +47,7 @@ export const ObjectOptionsDropdownHiddenFieldsContent = () => {
>
{t`Hidden Fields`}
</DropdownMenuHeader>
{hiddenRecordFields.length > 0 && (
<ViewFieldsVisibilityDropdownSection
title={t`Hidden`}
fields={hiddenRecordFields}
isDraggable={false}
onVisibilityChange={handleChangeFieldVisibility}
showSubheader={false}
showDragGrip={false}
/>
)}
<ViewFieldsHiddenDropdownSection />
<DropdownMenuSeparator />
<UndecoratedLink
to={settingsUrl}
@@ -119,9 +119,9 @@ export const useObjectOptionsForBoard = ({
// Let's refactor this as we introduce the new viewBar
const handleBoardFieldVisibilityChange = useCallback(
async (
updatedFieldDefinition: Omit<
updatedFieldDefinition: Pick<
ColumnDefinition<FieldMetadata>,
'size' | 'position'
'fieldMetadataId' | 'isVisible'
>,
) => {
const isNewViewField = !(
@@ -49,7 +49,10 @@ export const useTableColumns = ({
const handleColumnVisibilityChange = useCallback(
async (
viewField: Omit<ColumnDefinition<FieldMetadata>, 'size' | 'position'>,
viewField: Pick<
ColumnDefinition<FieldMetadata>,
'fieldMetadataId' | 'isVisible'
>,
) => {
const shouldShowColumn = !visibleTableColumns.some(
(visibleColumn) =>
@@ -0,0 +1,112 @@
import { type DropResult, type ResponderProvided } from '@hello-pangea/dnd';
import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFieldMetadataItems';
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { useObjectOptionsForTable } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForTable';
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
import { currentRecordFieldsComponentState } from '@/object-record/record-field/states/currentRecordFieldsComponentState';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { ViewType } from '@/views/types/ViewType';
import { useContext } from 'react';
import { IconEye, useIcons } from 'twenty-ui/display';
import { MenuItemDraggable } from 'twenty-ui/navigation';
export const ViewFieldsHiddenDropdownSection = () => {
const { viewType, objectMetadataItem, recordIndexId } = useContext(
ObjectOptionsDropdownContext,
);
const { handleReorderColumns, handleColumnVisibilityChange } =
useObjectOptionsForTable(recordIndexId, objectMetadataItem.id);
const { handleReorderBoardFields, handleBoardFieldVisibilityChange } =
useObjectOptionsForBoard({
objectNameSingular: objectMetadataItem.nameSingular,
recordBoardId: recordIndexId,
viewBarId: recordIndexId,
});
const handleReorderFields =
viewType === ViewType.Kanban
? handleReorderBoardFields
: handleReorderColumns;
const handleChangeFieldVisibility =
viewType === ViewType.Kanban
? handleBoardFieldVisibilityChange
: handleColumnVisibilityChange;
const handleDragEnd = (result: DropResult, provided: ResponderProvided) => {
handleReorderFields(result, provided);
};
const currentRecordFields = useRecoilComponentValue(
currentRecordFieldsComponentState,
);
const visibleRecordFields = currentRecordFields.filter(
(recordFieldToFilter) => recordFieldToFilter.isVisible === true,
);
const { activeFieldMetadataItems } = useActiveFieldMetadataItems({
objectMetadataItem,
});
const availableFieldMetadataItemsToShow = activeFieldMetadataItems.filter(
(fieldMetadataItemToFilter) =>
!visibleRecordFields
.map((recordField) => recordField.fieldMetadataItemId)
.includes(fieldMetadataItemToFilter.id),
);
const { getIcon } = useIcons();
return (
<>
<DropdownMenuItemsContainer>
{availableFieldMetadataItemsToShow.length > 0 && (
<DraggableList
onDragEnd={handleDragEnd}
draggableItems={
<>
{availableFieldMetadataItemsToShow.map(
(fieldMetadataItem, index) => {
const fieldIndex = index;
return (
<DraggableItem
key={fieldMetadataItem.id}
draggableId={fieldMetadataItem.id}
index={fieldIndex + 1}
itemComponent={
<MenuItemDraggable
key={fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
iconButtons={[
{
Icon: IconEye,
onClick: () =>
handleChangeFieldVisibility({
fieldMetadataId: fieldMetadataItem.id,
isVisible: false,
}),
},
]}
text={fieldMetadataItem.label}
/>
}
/>
);
},
)}
</>
}
/>
)}
</DropdownMenuItemsContainer>
</>
);
};
@@ -1,153 +0,0 @@
import {
type DropResult,
type OnDragEndResponder,
type ResponderProvided,
} from '@hello-pangea/dnd';
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { type FieldMetadata } from '@/object-record/record-field/ui/types/FieldMetadata';
import { type ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader';
import { isDefined } from 'twenty-shared/utils';
import {
AppTooltip,
IconEye,
IconEyeOff,
IconInfoCircle,
useIcons,
} from 'twenty-ui/display';
import { MenuItemDraggable } from 'twenty-ui/navigation';
import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy';
type ViewFieldsVisibilityDropdownSectionProps = {
fields: Omit<ColumnDefinition<FieldMetadata>, 'size'>[];
isDraggable: boolean;
onDragEnd?: OnDragEndResponder;
onVisibilityChange: (
field: Omit<ColumnDefinition<FieldMetadata>, 'size' | 'position'>,
) => void;
title: string;
showSubheader: boolean;
showDragGrip: boolean;
};
export const ViewFieldsVisibilityDropdownSection = ({
fields,
isDraggable,
onDragEnd,
onVisibilityChange,
title,
showSubheader = true,
showDragGrip,
}: ViewFieldsVisibilityDropdownSectionProps) => {
const handleOnDrag = (result: DropResult, provided: ResponderProvided) => {
onDragEnd?.(result, provided);
};
const [openToolTipIndex, setOpenToolTipIndex] = useState<number>();
const handleInfoButtonClick = (index: number) => {
setOpenToolTipIndex(index === openToolTipIndex ? undefined : index);
};
const { getIcon } = useIcons();
const getIconButtons = (
index: number,
field: Omit<ColumnDefinition<FieldMetadata>, 'size' | 'position'>,
) => {
const iconButtons = [
field.infoTooltipContent
? {
Icon: IconInfoCircle,
onClick: () => handleInfoButtonClick(index),
isActive: openToolTipIndex === index,
}
: null,
field.isLabelIdentifier
? null
: {
Icon: field.isVisible ? IconEyeOff : IconEye,
onClick: () => onVisibilityChange(field),
},
].filter(isDefined);
return iconButtons.length ? iconButtons : undefined;
};
const { nonDraggableItems = [], draggableItems = [] } = isDraggable
? groupArrayItemsBy(fields, ({ isLabelIdentifier }) =>
isLabelIdentifier ? 'nonDraggableItems' : 'draggableItems',
)
: { nonDraggableItems: fields, draggableItems: [] };
return (
<>
{showSubheader && (
<StyledDropdownMenuSubheader>{title}</StyledDropdownMenuSubheader>
)}
<DropdownMenuItemsContainer>
{nonDraggableItems.map((field, fieldIndex) => (
<MenuItemDraggable
key={field.fieldMetadataId}
LeftIcon={getIcon(field.iconName)}
iconButtons={getIconButtons(fieldIndex, field)}
isTooltipOpen={openToolTipIndex === fieldIndex}
text={field.label}
className={`${title}-fixed-item-tooltip-anchor-${fieldIndex}`}
accent={showDragGrip ? 'placeholder' : 'default'}
showGrip={showDragGrip}
isDragDisabled
/>
))}
{!!draggableItems.length && (
<DraggableList
onDragEnd={handleOnDrag}
draggableItems={
<>
{draggableItems.map((field, index) => {
const fieldIndex = index + nonDraggableItems.length;
return (
<DraggableItem
key={field.fieldMetadataId}
draggableId={field.fieldMetadataId}
index={fieldIndex + 1}
itemComponent={
<MenuItemDraggable
key={field.fieldMetadataId}
LeftIcon={getIcon(field.iconName)}
iconButtons={getIconButtons(fieldIndex, field)}
isTooltipOpen={openToolTipIndex === fieldIndex}
text={field.label}
className={`${title}-draggable-item-tooltip-anchor-${fieldIndex}`}
showGrip
/>
}
/>
);
})}
</>
}
/>
)}
</DropdownMenuItemsContainer>
{isDefined(openToolTipIndex) &&
createPortal(
<AppTooltip
anchorSelect={`.${title}-${
isDraggable ? 'draggable' : 'fixed'
}-item-tooltip-anchor-${openToolTipIndex}`}
place="left"
content={fields[openToolTipIndex].infoTooltipContent}
hidden={false}
/>,
document.body,
)}
</>
);
};
@@ -0,0 +1,136 @@
import { type DropResult, type ResponderProvided } from '@hello-pangea/dnd';
import { useGetFieldMetadataItemById } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { useObjectOptionsForBoard } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForBoard';
import { useObjectOptionsForTable } from '@/object-record/object-options-dropdown/hooks/useObjectOptionsForTable';
import { ObjectOptionsDropdownContext } from '@/object-record/object-options-dropdown/states/contexts/ObjectOptionsDropdownContext';
import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { ViewType } from '@/views/types/ViewType';
import { useContext } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { IconEyeOff, useIcons } from 'twenty-ui/display';
import { MenuItemDraggable } from 'twenty-ui/navigation';
import { sortByProperty } from '~/utils/array/sortByProperty';
export const ViewFieldsVisibleDropdownSection = () => {
const { viewType, objectMetadataItem, recordIndexId } = useContext(
ObjectOptionsDropdownContext,
);
const {
handleReorderColumns,
handleColumnVisibilityChange,
visibleTableColumns,
} = useObjectOptionsForTable(recordIndexId, objectMetadataItem.id);
const {
handleReorderBoardFields,
handleBoardFieldVisibilityChange,
visibleBoardFields,
} = useObjectOptionsForBoard({
objectNameSingular: objectMetadataItem.nameSingular,
recordBoardId: recordIndexId,
viewBarId: recordIndexId,
});
const visibleRecordFields =
viewType === ViewType.Kanban ? visibleBoardFields : visibleTableColumns;
const { getFieldMetadataItemById } = useGetFieldMetadataItemById();
const handleReorderFields =
viewType === ViewType.Kanban
? handleReorderBoardFields
: handleReorderColumns;
const handleChangeFieldVisibility =
viewType === ViewType.Kanban
? handleBoardFieldVisibilityChange
: handleColumnVisibilityChange;
const handleDragEnd = (result: DropResult, provided: ResponderProvided) => {
handleReorderFields(result, provided);
};
const { getIcon } = useIcons();
const fieldMetadataItemLabelIdentifier =
getLabelIdentifierFieldMetadataItem(objectMetadataItem);
const nonDraggableRecordField = visibleRecordFields.find(
(recordFieldToFilter) =>
recordFieldToFilter.fieldMetadataId ===
fieldMetadataItemLabelIdentifier?.id,
);
const draggableRecordFields = visibleRecordFields
.filter(
(recordFieldToFilter) =>
recordFieldToFilter.fieldMetadataId !==
nonDraggableRecordField?.fieldMetadataId,
)
.sort(sortByProperty('position'));
return (
<>
<DropdownMenuItemsContainer>
{fieldMetadataItemLabelIdentifier && (
<MenuItemDraggable
LeftIcon={getIcon(fieldMetadataItemLabelIdentifier.icon)}
text={fieldMetadataItemLabelIdentifier.label}
accent={'placeholder'}
showGrip={true}
isDragDisabled
/>
)}
{draggableRecordFields.length > 0 && (
<DraggableList
onDragEnd={handleDragEnd}
draggableItems={
<>
{draggableRecordFields.map((recordField, index) => {
const fieldIndex =
index +
(isDefined(fieldMetadataItemLabelIdentifier) ? 1 : 0);
const fieldMetadataItem = getFieldMetadataItemById(
recordField.fieldMetadataId,
);
return (
<DraggableItem
key={recordField.fieldMetadataId}
draggableId={recordField.fieldMetadataId}
index={fieldIndex + 1}
itemComponent={
<MenuItemDraggable
key={recordField.fieldMetadataId}
LeftIcon={getIcon(fieldMetadataItem.icon)}
iconButtons={[
{
Icon: IconEyeOff,
onClick: () =>
handleChangeFieldVisibility({
fieldMetadataId: recordField.fieldMetadataId,
isVisible: recordField.isVisible,
}),
},
]}
text={fieldMetadataItem.label}
showGrip
/>
}
/>
);
})}
</>
}
/>
)}
</DropdownMenuItemsContainer>
</>
);
};