From 6528038e9ecb5f745e9bc81883c66bb0e92a81ce Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Wed, 13 Aug 2025 17:51:11 +0200 Subject: [PATCH] Refactor inline-cell-input hover on field-list (#13906) This is first step toward separating the path between display mode and input mode for inline-cells like we have done for tableCell. The idea is to have 1 floating or anchored field input shared between all cells. This should be done for field-list, board and task/note row + for hover and edit mode. This PR is only about field-list and hover --- .../components/ActivityRichTextEditor.tsx | 10 +- ...RecordFieldListCellAnchorPortalContext.tsx | 56 +++++++++ .../RecordFieldListCellAnchoredPortal.tsx | 118 ++++++++++++++++++ .../RecordFieldListCellHoveredPortal.tsx | 39 ++++++ ...ecordFieldListCellHoveredPortalContent.tsx | 65 ++++++++++ .../RecordFieldListCellFieldInput.tsx | 110 ++++++++++++++++ .../useFieldListFieldMetadataFromPosition.ts | 48 +++++++ .../hooks/useFieldListFieldMetadataItems.ts | 91 ++++++++++++++ ...RecordFieldListComponentInstanceContext.ts | 4 + ...dListCellEditModePositionComponentState.ts | 9 ++ ...ordFieldListHoverPositionComponentState.ts | 10 ++ .../record-field/ui/contexts/FieldContext.ts | 3 + .../components/RecordInlineCellContainer.tsx | 7 +- .../RecordInlineCellHoveredPortal.tsx | 11 ++ .../RecordInlineCellHoveredPortalContent.tsx | 66 ++++++++++ .../record-show/components/FieldsCard.tsx | 110 +++++++--------- .../components/RecordShowContainer.tsx | 1 - .../record-show/components/SummaryCard.tsx | 14 ++- .../hooks/useRecordShowContainerData.ts | 9 -- .../pages/object-record/RecordShowPage.tsx | 2 +- 20 files changed, 694 insertions(+), 89 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchorPortalContext.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchoredPortal.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortal.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortalContent.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldListCellFieldInput.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/states/recordFieldListCellEditModePositionComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-field-list/states/recordFieldListHoverPositionComponentState.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellHoveredPortal.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellHoveredPortalContent.tsx diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx index bc707ff323..ea6d02ccf4 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityRichTextEditor.tsx @@ -24,13 +24,13 @@ import { getActivityAttachmentIdsToDelete } from '@/activities/utils/getActivity import { getActivityAttachmentPathsToRestore } from '@/activities/utils/getActivityAttachmentPathsToRestore'; import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId'; import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient'; +import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords'; import { useRestoreManyRecords } from '@/object-record/hooks/useRestoreManyRecords'; import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; import { useIsRecordFieldReadOnly } from '@/object-record/record-field/ui/hooks/read-only/useIsRecordFieldReadOnly'; import { isInlineCellInEditModeFamilyState } from '@/object-record/record-inline-cell/states/isInlineCellInEditModeFamilyState'; -import { useRecordShowContainerData } from '@/object-record/record-show/hooks/useRecordShowContainerData'; import { RecordTitleCellContainerType } from '@/object-record/record-title-cell/types/RecordTitleCellContainerType'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; import { BlockEditor } from '@/ui/input/editor/components/BlockEditor'; @@ -363,10 +363,10 @@ export const ActivityRichTextEditor = ({ dependencies: [handleAllKeys], }); - const { labelIdentifierFieldMetadataItem } = useRecordShowContainerData({ - objectNameSingular: activityObjectNameSingular, - objectRecordId: activityId, - }); + const { labelIdentifierFieldMetadataItem } = + useLabelIdentifierFieldMetadataItem({ + objectNameSingular: activityObjectNameSingular, + }); const recordTitleCellId = getRecordFieldInputInstanceId({ recordId: activityId, diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchorPortalContext.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchorPortalContext.tsx new file mode 100644 index 0000000000..7f105c7049 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchorPortalContext.tsx @@ -0,0 +1,56 @@ +import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay'; +import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { useGetButtonIcon } from '@/object-record/record-field/ui/hooks/useGetButtonIcon'; +import { useIsFieldInputOnly } from '@/object-record/record-field/ui/hooks/useIsFieldInputOnly'; +import { + RecordInlineCellContext, + type RecordInlineCellContextProps, +} from '@/object-record/record-inline-cell/components/RecordInlineCellContext'; +import { useContext, type ReactNode } from 'react'; +import { useIcons } from 'twenty-ui/display'; + +type RecordFieldListCellAnchorPortalContextProps = { + children: ReactNode; +}; + +export const RecordFieldListCellAnchorPortalContext = ({ + children, +}: RecordFieldListCellAnchorPortalContextProps) => { + const { + isRecordFieldReadOnly, + fieldDefinition, + isDisplayModeFixHeight, + onOpenEditMode, + onCloseEditMode, + isCentered, + } = useContext(FieldContext); + const buttonIcon = useGetButtonIcon(); + const { getIcon } = useIcons(); + const isFieldInputOnly = useIsFieldInputOnly(); + + const RecordInlineCellContextValue: RecordInlineCellContextProps = { + readonly: isRecordFieldReadOnly, + buttonIcon: buttonIcon, + IconLabel: fieldDefinition.iconName + ? getIcon(fieldDefinition.iconName) + : undefined, + label: fieldDefinition.label, + labelWidth: fieldDefinition.labelWidth, + showLabel: fieldDefinition.showLabel, + isCentered, + editModeContent: , + displayModeContent: , + isDisplayModeFixHeight: isDisplayModeFixHeight, + editModeContentOnly: isFieldInputOnly, + loading: false, + onOpenEditMode, + onCloseEditMode, + }; + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchoredPortal.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchoredPortal.tsx new file mode 100644 index 0000000000..1de8a86861 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchoredPortal.tsx @@ -0,0 +1,118 @@ +import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { RecordFieldListCellAnchorPortalContext } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchorPortalContext'; +import { + FieldContext, + type RecordUpdateHook, + type RecordUpdateHookParams, +} from '@/object-record/record-field/ui/contexts/FieldContext'; +import { useIsRecordFieldReadOnly } from '@/object-record/record-field/ui/hooks/read-only/useIsRecordFieldReadOnly'; +import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; +import { RecordInlineCellHoveredPortal } from '@/object-record/record-inline-cell/components/RecordInlineCellHoveredPortal'; +import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { createPortal } from 'react-dom'; +import { isDefined } from 'twenty-shared/utils'; + +type RecordFieldListCellAnchoredPortalProps = { + position: number; + fieldMetadataItem: Pick< + FieldMetadataItem, + 'id' | 'name' | 'type' | 'createdAt' | 'updatedAt' | 'label' + >; + children: React.ReactNode; +}; + +export const RecordFieldListCellAnchoredPortal = ({ + position, + fieldMetadataItem, + children, +}: RecordFieldListCellAnchoredPortalProps) => { + const anchorElement = document.body.querySelector( + `#record-field-list-cell-${position}`, + ); + + const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow(); + + const targetedRecordsRuleFromContextStore = useRecoilComponentValue( + contextStoreTargetedRecordsRuleComponentState, + ); + + let recordId; + + if (targetedRecordsRuleFromContextStore.mode === 'selection') { + recordId = targetedRecordsRuleFromContextStore.selectedRecordIds[0]; + } + + const INPUT_ID_PREFIX = 'fields-card'; + + const isRecordFieldReadOnly = useIsRecordFieldReadOnly({ + fieldMetadataId: fieldMetadataItem.id, + objectMetadataId: objectMetadataItem.id, + recordId: recordId ?? '', + }); + + const { updateOneRecord } = useUpdateOneRecord({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const useUpdateOneObjectRecordMutation: RecordUpdateHook = () => { + const updateEntity = ({ variables }: RecordUpdateHookParams) => { + updateOneRecord?.({ + idToUpdate: variables.where.id as string, + updateOneRecordInput: variables.updateOneRecordInput, + }); + }; + + return [updateEntity, { loading: false }]; + }; + + if (!isDefined(anchorElement) || !isDefined(recordId)) { + return null; + } + + return ( + + <> + {createPortal( + + + + {children} + + + , + anchorElement, + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortal.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortal.tsx new file mode 100644 index 0000000000..06884a0c87 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortal.tsx @@ -0,0 +1,39 @@ +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { RecordFieldListCellAnchoredPortal } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListCellAnchoredPortal'; +import { RecordFieldListCellHoveredPortalContent } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortalContent'; +import { useFieldListFieldMetadataFromPosition } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition'; +import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState'; +import { isDefined } from 'twenty-shared/utils'; + +type RecordFieldListCellHoveredPortalProps = { + objectMetadataItem: ObjectMetadataItem; +}; + +export const RecordFieldListCellHoveredPortal = ({ + objectMetadataItem, +}: RecordFieldListCellHoveredPortalProps) => { + const hoverPosition = useRecoilComponentValue( + recordFieldListHoverPositionComponentState, + ); + + const { hoveredFieldMetadataItem } = useFieldListFieldMetadataFromPosition({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + if (!isDefined(hoverPosition) || !isDefined(hoveredFieldMetadataItem)) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortalContent.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortalContent.tsx new file mode 100644 index 0000000000..021c4c0c38 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortalContent.tsx @@ -0,0 +1,65 @@ +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; + +import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useFieldListFieldMetadataFromPosition } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition'; +import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState'; +import { FieldDisplay } from '@/object-record/record-field/ui/components/FieldDisplay'; +import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput'; +import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; +import { useRecordInlineCellContext } from '@/object-record/record-inline-cell/components/RecordInlineCellContext'; +import { RecordInlineCellDisplayMode } from '@/object-record/record-inline-cell/components/RecordInlineCellDisplayMode'; +import { RecordInlineCellHoveredPortalContent } from '@/object-record/record-inline-cell/components/RecordInlineCellHoveredPortalContent'; +import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; +import { useContext } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +type RecordFieldListCellHoveredPortalContentProps = { + objectMetadataItem: ObjectMetadataItem; +}; + +export const RecordFieldListCellHoveredPortalContent = ({ + objectMetadataItem, +}: RecordFieldListCellHoveredPortalContentProps) => { + const hoverPosition = useRecoilComponentValue( + recordFieldListHoverPositionComponentState, + ); + + const { editModeContentOnly, isCentered } = useRecordInlineCellContext(); + + const { hoveredFieldMetadataItem } = useFieldListFieldMetadataFromPosition({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const { isRecordFieldReadOnly } = useContext(FieldContext); + const { openInlineCell } = useInlineCell(); + + const shouldContainerBeClickable = + !isRecordFieldReadOnly && !editModeContentOnly; + + const setRecordFieldListHoverPosition = useSetRecoilComponentState( + recordFieldListHoverPositionComponentState, + 'fields-card', + ); + + const handleMouseLeave = () => { + setRecordFieldListHoverPosition(null); + }; + + if (!isDefined(hoverPosition) || !isDefined(hoveredFieldMetadataItem)) { + return null; + } + + return ( + + + {editModeContentOnly ? : } + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldListCellFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldListCellFieldInput.tsx new file mode 100644 index 0000000000..4023e4b0ac --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/components/RecordFieldListCellFieldInput.tsx @@ -0,0 +1,110 @@ +import { FieldInput } from '@/object-record/record-field/ui/components/FieldInput'; + +import { usePersistFieldFromFieldInputContext } from '@/object-record/record-field/ui/hooks/usePersistFieldFromFieldInputContext'; +import { RecordFieldComponentInstanceContext } from '@/object-record/record-field/ui/states/contexts/RecordFieldComponentInstanceContext'; + +import { + FieldInputEventContext, + type FieldInputClickOutsideEvent, + type FieldInputEvent, +} from '@/object-record/record-field/ui/contexts/FieldInputEventContext'; +import { useRecordTableBodyContextOrThrow } from '@/object-record/record-table/contexts/RecordTableBodyContext'; +import { currentFocusIdSelector } from '@/ui/utilities/focus/states/currentFocusIdSelector'; +import { useAvailableComponentInstanceId } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceId'; +import { useRecoilCallback } from 'recoil'; + +export const RecordTableCellFieldInput = () => { + const { onMoveFocus, onCloseTableCell } = useRecordTableBodyContextOrThrow(); + + const instanceId = useAvailableComponentInstanceId( + RecordFieldComponentInstanceContext, + ); + + const { persistFieldFromFieldInputContext } = + usePersistFieldFromFieldInputContext(); + + const handleEnter: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + onCloseTableCell(); + onMoveFocus('down'); + }; + + const handleSubmit: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + onCloseTableCell(); + }; + + const handleCancel = () => { + onCloseTableCell(); + }; + + const handleClickOutside: FieldInputClickOutsideEvent = useRecoilCallback( + ({ snapshot }) => + ({ newValue, event, skipPersist }) => { + const currentFocusId = snapshot + .getLoadable(currentFocusIdSelector) + .getValue(); + + if (currentFocusId !== instanceId) { + return; + } + event?.preventDefault(); + event?.stopImmediatePropagation(); + + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + onCloseTableCell(); + }, + [onCloseTableCell, instanceId, persistFieldFromFieldInputContext], + ); + + const handleEscape: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + onCloseTableCell(); + }; + + const handleTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + onCloseTableCell(); + onMoveFocus('right'); + }; + + const handleShiftTab: FieldInputEvent = ({ newValue, skipPersist }) => { + if (skipPersist !== true) { + persistFieldFromFieldInputContext(newValue); + } + + onCloseTableCell(); + onMoveFocus('left'); + }; + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition.ts b/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition.ts new file mode 100644 index 0000000000..7b10e934ea --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataFromPosition.ts @@ -0,0 +1,48 @@ +import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems'; +import { recordFieldListCellEditModePositionComponentState } from '@/object-record/record-field-list/states/recordFieldListCellEditModePositionComponentState'; +import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState'; +import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; +import { isDefined } from 'twenty-shared/utils'; + +type UseFieldListFieldMetadataFromPositionProps = { + objectNameSingular: string; +}; + +export const useFieldListFieldMetadataFromPosition = ({ + objectNameSingular, +}: UseFieldListFieldMetadataFromPositionProps) => { + const hoverPosition = useRecoilComponentValue( + recordFieldListHoverPositionComponentState, + ); + + const editModePosition = useRecoilComponentValue( + recordFieldListCellEditModePositionComponentState, + ); + + const { + inlineFieldMetadataItems, + inlineRelationFieldMetadataItems, + boxedRelationFieldMetadataItems, + } = useFieldListFieldMetadataItems({ + objectNameSingular, + }); + + const fieldMetadataItems = [ + ...inlineRelationFieldMetadataItems, + ...inlineFieldMetadataItems, + ...boxedRelationFieldMetadataItems, + ]; + + const hoveredFieldMetadataItem = isDefined(hoverPosition) + ? fieldMetadataItems.at(hoverPosition) + : undefined; + + const editedFieldMetadataItem = isDefined(editModePosition) + ? fieldMetadataItems.at(editModePosition) + : undefined; + + return { + hoveredFieldMetadataItem, + editedFieldMetadataItem, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts b/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts new file mode 100644 index 0000000000..a5a8cc9f7f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/hooks/useFieldListFieldMetadataItems.ts @@ -0,0 +1,91 @@ +import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; +import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported'; +import groupBy from 'lodash.groupby'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +type UseFieldListFieldMetadataItemsProps = { + objectNameSingular: string; +}; + +export const useFieldListFieldMetadataItems = ({ + objectNameSingular, +}: UseFieldListFieldMetadataItemsProps) => { + const { labelIdentifierFieldMetadataItem } = + useLabelIdentifierFieldMetadataItem({ + objectNameSingular, + }); + + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { objectMetadataItems } = useObjectMetadataItems(); + + const availableFieldMetadataItems = objectMetadataItem.readableFields + .filter( + (fieldMetadataItem) => + isFieldCellSupported(fieldMetadataItem, objectMetadataItems) && + fieldMetadataItem.id !== labelIdentifierFieldMetadataItem?.id, + ) + .sort((fieldMetadataItemA, fieldMetadataItemB) => + fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name), + ); + + const { inlineFieldMetadataItems, relationFieldMetadataItems } = groupBy( + availableFieldMetadataItems + .filter( + (fieldMetadataItem) => + fieldMetadataItem.name !== 'createdAt' && + fieldMetadataItem.name !== 'deletedAt', + ) + .filter( + (fieldMetadataItem) => + fieldMetadataItem.type !== FieldMetadataType.RICH_TEXT_V2, + ), + (fieldMetadataItem) => + fieldMetadataItem.type === FieldMetadataType.RELATION + ? 'relationFieldMetadataItems' + : 'inlineFieldMetadataItems', + ); + + const inlineRelationFieldMetadataItems = ( + relationFieldMetadataItems ?? [] + ).filter( + (fieldMetadataItem) => + (objectNameSingular === CoreObjectNameSingular.Note && + fieldMetadataItem.name === 'noteTargets') || + (objectNameSingular === CoreObjectNameSingular.Task && + fieldMetadataItem.name === 'taskTargets'), + ); + + const boxedRelationFieldMetadataItems = ( + relationFieldMetadataItems ?? [] + ).filter( + (fieldMetadataItem) => + !( + (objectNameSingular === CoreObjectNameSingular.Note && + fieldMetadataItem.name === 'noteTargets') || + (objectNameSingular === CoreObjectNameSingular.Task && + fieldMetadataItem.name === 'taskTargets') + ) && + isDefined(fieldMetadataItem.relation?.targetObjectMetadata.id) && + getObjectPermissionsForObject( + objectPermissionsByObjectMetadataId, + fieldMetadataItem.relation?.targetObjectMetadata.id, + ).canReadObjectRecords, + ); + + return { + inlineFieldMetadataItems, + inlineRelationFieldMetadataItems, + boxedRelationFieldMetadataItems, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext.ts b/packages/twenty-front/src/modules/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext.ts new file mode 100644 index 0000000000..f2eceb8863 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext.ts @@ -0,0 +1,4 @@ +import { createComponentInstanceContext } from '@/ui/utilities/state/component-state/utils/createComponentInstanceContext'; + +export const RecordFieldListComponentInstanceContext = + createComponentInstanceContext(); diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/states/recordFieldListCellEditModePositionComponentState.ts b/packages/twenty-front/src/modules/object-record/record-field-list/states/recordFieldListCellEditModePositionComponentState.ts new file mode 100644 index 0000000000..d784a2eaa7 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/states/recordFieldListCellEditModePositionComponentState.ts @@ -0,0 +1,9 @@ +import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordFieldListCellEditModePositionComponentState = + createComponentState({ + key: 'recordFieldListCellEditModePositionComponentState', + defaultValue: null, + componentInstanceContext: RecordFieldListComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-field-list/states/recordFieldListHoverPositionComponentState.ts b/packages/twenty-front/src/modules/object-record/record-field-list/states/recordFieldListHoverPositionComponentState.ts new file mode 100644 index 0000000000..f949c84804 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field-list/states/recordFieldListHoverPositionComponentState.ts @@ -0,0 +1,10 @@ +import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const recordFieldListHoverPositionComponentState = createComponentState< + number | null +>({ + key: 'recordFieldListHoverPositionComponentState', + defaultValue: null, + componentInstanceContext: RecordFieldListComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-field/ui/contexts/FieldContext.ts b/packages/twenty-front/src/modules/object-record/record-field/ui/contexts/FieldContext.ts index 6fe7e7fdf1..c760cfb9c8 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/ui/contexts/FieldContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/ui/contexts/FieldContext.ts @@ -38,8 +38,11 @@ export type GenericFieldContextType = { onRecordChipClick?: (event: MouseEvent) => void; onOpenEditMode?: () => void; onCloseEditMode?: () => void; + onMouseEnter?: () => void; + onMouseLeave?: () => void; triggerEvent?: TriggerEventType; isForbidden?: boolean; + anchorId?: string; }; export const FieldContext = createContext( diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx index 7144f1c2a2..394c3a4261 100644 --- a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellContainer.tsx @@ -99,7 +99,8 @@ export const RecordInlineCellContainer = () => { const { isInlineCellInEditMode, openInlineCell } = useInlineCell(); - const { recordId, fieldDefinition } = useContext(FieldContext); + const { recordId, fieldDefinition, onMouseEnter, onMouseLeave, anchorId } = + useContext(FieldContext); const shouldContainerBeClickable = !readonly && !editModeContentOnly && !isInlineCellInEditMode; @@ -114,12 +115,14 @@ export const RecordInlineCellContainer = () => { if (!readonly) { setIsFocused(true); } + onMouseEnter?.(); }; const handleContainerMouseLeave = () => { if (!readonly) { setIsFocused(false); } + onMouseLeave?.(); }; const theme = useTheme(); @@ -164,7 +167,7 @@ export const RecordInlineCellContainer = () => { {isInlineCellInEditMode && ( )} - + diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellHoveredPortal.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellHoveredPortal.tsx new file mode 100644 index 0000000000..b59f667996 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellHoveredPortal.tsx @@ -0,0 +1,11 @@ +import styled from '@emotion/styled'; + +const StyledRecordTableCellHoveredPortal = styled.div` + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; +`; + +export const RecordInlineCellHoveredPortal = StyledRecordTableCellHoveredPortal; diff --git a/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellHoveredPortalContent.tsx b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellHoveredPortalContent.tsx new file mode 100644 index 0000000000..f166cf2491 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-inline-cell/components/RecordInlineCellHoveredPortalContent.tsx @@ -0,0 +1,66 @@ +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; + +const StyledRecordTableCellHoveredPortalContent = styled.div<{ + readonly?: boolean; + isCentered?: boolean; +}>` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; + width: 100%; + + ${({ isCentered }) => + isCentered === true && + ` + justify-content: center; + `}; + + ${({ readonly }) => + !readonly && + css` + cursor: pointer; + `}; +`; + +const StyledInlineCellBaseContainer = styled.div<{ readonly: boolean }>` + box-sizing: border-box; + width: 100%; + display: flex; + height: fit-content; + gap: ${({ theme }) => theme.spacing(1)}; + user-select: none; + align-items: center; + cursor: ${({ readonly }) => (readonly ? 'default' : 'pointer')}; +`; + +type RecordInlineCellHoveredPortalContentProps = { + children: React.ReactNode; + readonly: boolean; + isCentered?: boolean; + onClick?: () => void; + onMouseLeave?: () => void; +}; + +export const RecordInlineCellHoveredPortalContent = ({ + children, + isCentered, + readonly, + onClick, + onMouseLeave, +}: RecordInlineCellHoveredPortalContentProps) => { + return ( + + + {children} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx index d17c86ed28..49f0cdf45d 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/FieldsCard.tsx @@ -1,12 +1,12 @@ -import groupBy from 'lodash.groupby'; - import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { type CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition'; -import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { RecordFieldListCellHoveredPortal } from '@/object-record/record-field-list/anchored-portal/components/RecordFieldListCellHoveredPortal'; +import { useFieldListFieldMetadataItems } from '@/object-record/record-field-list/hooks/useFieldListFieldMetadataItems'; +import { RecordFieldListComponentInstanceContext } from '@/object-record/record-field-list/states/contexts/RecordFieldListComponentInstanceContext'; +import { recordFieldListHoverPositionComponentState } from '@/object-record/record-field-list/states/recordFieldListHoverPositionComponentState'; import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; import { useIsRecordReadOnly } from '@/object-record/record-field/ui/hooks/read-only/useIsRecordReadOnly'; import { isRecordFieldReadOnly } from '@/object-record/record-field/ui/hooks/read-only/utils/isRecordFieldReadOnly'; @@ -19,11 +19,9 @@ import { useRecordShowContainerData } from '@/object-record/record-show/hooks/us import { RecordDetailDuplicatesSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailDuplicatesSection'; import { RecordDetailRelationSection } from '@/object-record/record-show/record-detail-section/components/RecordDetailRelationSection'; import { getRecordFieldInputInstanceId } from '@/object-record/utils/getRecordFieldInputId'; -import { isFieldCellSupported } from '@/object-record/utils/isFieldCellSupported'; import { getObjectPermissionsFromMapByObjectMetadataId } from '@/settings/roles/role-permissions/objects-permissions/utils/getObjectPermissionsFromMapByObjectMetadataId'; import { useIsInRightDrawerOrThrow } from '@/ui/layout/right-drawer/contexts/RightDrawerContext'; -import { isDefined } from 'twenty-shared/utils'; -import { FieldMetadataType } from '~/generated-metadata/graphql'; +import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; type FieldsCardProps = { objectNameSingular: string; @@ -38,16 +36,14 @@ export const FieldsCard = ({ objectRecordId, showDuplicatesSection = true, }: FieldsCardProps) => { - const { recordLoading, labelIdentifierFieldMetadataItem, isPrefetchLoading } = - useRecordShowContainerData({ - objectNameSingular, - objectRecordId, - }); + const { recordLoading, isPrefetchLoading } = useRecordShowContainerData({ + objectRecordId, + }); const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); - const { objectMetadataItems } = useObjectMetadataItems(); + const { objectPermissionsByObjectMetadataId } = useObjectPermissions(); const { useUpdateOneObjectRecordMutation } = useRecordShowContainerActions({ @@ -57,63 +53,34 @@ export const FieldsCard = ({ const { isInRightDrawer } = useIsInRightDrawerOrThrow(); - const availableFieldMetadataItems = objectMetadataItem.readableFields - .filter( - (fieldMetadataItem) => - isFieldCellSupported(fieldMetadataItem, objectMetadataItems) && - fieldMetadataItem.id !== labelIdentifierFieldMetadataItem?.id, - ) - .sort((fieldMetadataItemA, fieldMetadataItemB) => - fieldMetadataItemA.name.localeCompare(fieldMetadataItemB.name), - ); - - const { inlineFieldMetadataItems, relationFieldMetadataItems } = groupBy( - availableFieldMetadataItems - .filter( - (fieldMetadataItem) => - fieldMetadataItem.name !== 'createdAt' && - fieldMetadataItem.name !== 'deletedAt', - ) - .filter( - (fieldMetadataItem) => - fieldMetadataItem.type !== FieldMetadataType.RICH_TEXT_V2, - ), - (fieldMetadataItem) => - fieldMetadataItem.type === FieldMetadataType.RELATION - ? 'relationFieldMetadataItems' - : 'inlineFieldMetadataItems', - ); - - const inlineRelationFieldMetadataItems = relationFieldMetadataItems?.filter( - (fieldMetadataItem) => - (objectNameSingular === CoreObjectNameSingular.Note && - fieldMetadataItem.name === 'noteTargets') || - (objectNameSingular === CoreObjectNameSingular.Task && - fieldMetadataItem.name === 'taskTargets'), - ); - - const boxedRelationFieldMetadataItems = relationFieldMetadataItems?.filter( - (fieldMetadataItem) => - !( - (objectNameSingular === CoreObjectNameSingular.Note && - fieldMetadataItem.name === 'noteTargets') || - (objectNameSingular === CoreObjectNameSingular.Task && - fieldMetadataItem.name === 'taskTargets') - ) && - isDefined(fieldMetadataItem.relation?.targetObjectMetadata.id) && - getObjectPermissionsForObject( - objectPermissionsByObjectMetadataId, - fieldMetadataItem.relation?.targetObjectMetadata.id, - ).canReadObjectRecords, - ); - const isRecordReadOnly = useIsRecordReadOnly({ recordId: objectRecordId, objectMetadataId: objectMetadataItem.id, }); + const setRecordFieldListHoverPosition = useSetRecoilComponentState( + recordFieldListHoverPositionComponentState, + 'fields-card', + ); + + const handleMouseEnter = (index: number) => { + setRecordFieldListHoverPosition(index); + }; + + const { + inlineFieldMetadataItems, + inlineRelationFieldMetadataItems, + boxedRelationFieldMetadataItems, + } = useFieldListFieldMetadataItems({ + objectNameSingular, + }); + return ( - <> + {isPrefetchLoading ? ( @@ -200,6 +167,13 @@ export const FieldsCard = ({ fieldType: fieldMetadataItem.type, isCustom: fieldMetadataItem.isCustom ?? false, }), + onMouseEnter: () => + handleMouseEnter( + index + (inlineRelationFieldMetadataItems?.length ?? 0), + ), + anchorId: `record-field-list-cell-${ + index + (inlineRelationFieldMetadataItems?.length ?? 0) + }`, }} > ))} - + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx index 3400287bf4..04819d4421 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx @@ -36,7 +36,6 @@ export const RecordShowContainer = ({ }); const { isPrefetchLoading, recordLoading } = useRecordShowContainerData({ - objectNameSingular, objectRecordId, }); diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx index b0b9fca309..48dd0b709b 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-show/components/SummaryCard.tsx @@ -1,4 +1,5 @@ import { useGetStandardObjectIcon } from '@/object-metadata/hooks/useGetStandardObjectIcon'; +import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { FieldContext } from '@/object-record/record-field/ui/contexts/FieldContext'; @@ -27,11 +28,9 @@ export const SummaryCard = ({ objectRecordId, isInRightDrawer, }: SummaryCardProps) => { - const { recordLoading, labelIdentifierFieldMetadataItem, isPrefetchLoading } = - useRecordShowContainerData({ - objectNameSingular, - objectRecordId, - }); + const { recordLoading, isPrefetchLoading } = useRecordShowContainerData({ + objectRecordId, + }); const recordCreatedAt = useRecoilValue( recordStoreFamilySelector({ @@ -60,6 +59,11 @@ export const SummaryCard = ({ objectNameSingular, }); + const { labelIdentifierFieldMetadataItem } = + useLabelIdentifierFieldMetadataItem({ + objectNameSingular, + }); + const isTitleReadOnly = useIsRecordFieldReadOnly({ recordId: objectRecordId, fieldMetadataId: labelIdentifierFieldMetadataItem?.id ?? '', diff --git a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerData.ts b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerData.ts index c1f951db8d..cc6694010c 100644 --- a/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerData.ts +++ b/packages/twenty-front/src/modules/object-record/record-show/hooks/useRecordShowContainerData.ts @@ -1,22 +1,14 @@ -import { useLabelIdentifierFieldMetadataItem } from '@/object-metadata/hooks/useLabelIdentifierFieldMetadataItem'; import { recordLoadingFamilyState } from '@/object-record/record-store/states/recordLoadingFamilyState'; import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; import { useRecoilState } from 'recoil'; type UseRecordShowContainerDataProps = { - objectNameSingular: string; objectRecordId: string; }; export const useRecordShowContainerData = ({ - objectNameSingular, objectRecordId, }: UseRecordShowContainerDataProps) => { - const { labelIdentifierFieldMetadataItem } = - useLabelIdentifierFieldMetadataItem({ - objectNameSingular, - }); - const [recordLoading] = useRecoilState( recordLoadingFamilyState(objectRecordId), ); @@ -25,7 +17,6 @@ export const useRecordShowContainerData = ({ return { recordLoading, - labelIdentifierFieldMetadataItem, isPrefetchLoading, }; }; diff --git a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx index 328c7c1884..d7ac9c91f0 100644 --- a/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx +++ b/packages/twenty-front/src/pages/object-record/RecordShowPage.tsx @@ -9,7 +9,6 @@ import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/reco import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext'; import { RecordShowContainer } from '@/object-record/record-show/components/RecordShowContainer'; import { RecordShowEffect } from '@/object-record/record-show/components/RecordShowEffect'; -import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage'; import { computeRecordShowComponentInstanceId } from '@/object-record/record-show/utils/computeRecordShowComponentInstanceId'; import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext'; import { PageHeaderToggleCommandMenuButton } from '@/ui/layout/page-header/components/PageHeaderToggleCommandMenuButton'; @@ -17,6 +16,7 @@ import { PageBody } from '@/ui/layout/page/components/PageBody'; import { PageContainer } from '@/ui/layout/page/components/PageContainer'; import { RecordShowPageHeader } from '~/pages/object-record/RecordShowPageHeader'; import { RecordShowPageTitle } from '~/pages/object-record/RecordShowPageTitle'; +import { useRecordShowPage } from '@/object-record/record-show/hooks/useRecordShowPage'; export const RecordShowPage = () => { const parameters = useParams<{