From 6e7d8ef96c0ef33fb790cda7bdbf7c7f9ee7feeb Mon Sep 17 00:00:00 2001 From: Priyanshu Bartwal Date: Tue, 23 Jun 2026 12:07:04 +0530 Subject: [PATCH] [Twenty-front]: Record table Header drag and drop functionality (#21304) Closes #21303 and https://github.com/twentyhq/core-team-issues/issues/151 https://github.com/user-attachments/assets/45cee1be-464f-467e-a1c0-cf5354ff87db --------- Co-authored-by: Charles Bochet --- .../NavigationMenuItemDndKitProvider.tsx | 19 +- .../components/RecordTableHeader.tsx | 31 +-- .../components/RecordTableHeaderCell.tsx | 29 ++- .../components/RecordTableHeaderDnd.tsx | 94 +++++++++ .../RecordTableHeaderFirstScrollableCell.tsx | 48 +++-- .../RecordTableHeaderDropTarget.tsx | 71 +++++++ .../RecordTableHeaderDroppableSlot.tsx | 45 ++++ .../RecordTableHeaderSortableCell.tsx | 65 ++++++ .../RecordTableHeaderSortableHandle.tsx | 28 +++ .../constants/RecordTableHeaderDroppableId.ts | 1 + .../context/RecordTableHeaderDndContext.tsx | 10 + ...ordTableHeaderSortableHandleRefContext.tsx | 5 + .../dnd/hooks/useRecordTableHeaderDndKit.ts | 197 ++++++++++++++++++ .../RecordTableHeaderDndKitProvider.tsx | 30 +++ .../dnd/types/RecordTableHeaderDndData.ts | 4 + .../dnd/utils/resolveRecordTableHeaderDrop.ts | 70 +++++++ .../useDisableDragSelectOnPointerDown.ts | 23 ++ ...TableHeaderDropProcessingComponentState.ts | 9 + .../drag-and-drop/constants/DndKitSensors.ts | 12 ++ 19 files changed, 720 insertions(+), 71 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderDnd.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDropTarget.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDroppableSlot.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableCell.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableHandle.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/constants/RecordTableHeaderDroppableId.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderDndContext.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderSortableHandleRefContext.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/utils/resolveRecordTableHeaderDrop.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useDisableDragSelectOnPointerDown.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState.ts create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitSensors.ts diff --git a/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx b/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx index 7ce6886e34..72e60197b9 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx +++ b/packages/twenty-front/src/modules/navigation-menu-item/display/dnd/providers/NavigationMenuItemDndKitProvider.tsx @@ -1,9 +1,4 @@ -import { PointerActivationConstraints } from '@dnd-kit/dom'; -import { - DragDropProvider, - KeyboardSensor, - PointerSensor, -} from '@dnd-kit/react'; +import { DragDropProvider } from '@dnd-kit/react'; import type { ReactNode } from 'react'; import type { NavigationSections } from '@/navigation-menu-item/common/constants/NavigationSections.constants'; @@ -12,15 +7,7 @@ import { NavigationDropTargetContext } from '@/navigation-menu-item/common/conte import { NavigationMenuItemDragContext } from '@/navigation-menu-item/common/contexts/NavigationMenuItemDragContext'; import type { DraggableData } from '@/navigation-menu-item/common/types/navigationMenuItemDndKitDraggableData'; import { useNavigationMenuItemDndKit } from '@/navigation-menu-item/display/dnd/hooks/useNavigationMenuItemDndKit'; - -const NAVIGATION_MENU_ITEM_DND_SENSORS = [ - PointerSensor.configure({ - activationConstraints: [ - new PointerActivationConstraints.Distance({ value: 8 }), - ], - }), - KeyboardSensor, -]; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; type NavigationMenuItemDndKitProviderProps = { section: NavigationSections; @@ -38,7 +25,7 @@ export const NavigationMenuItemDndKitProvider = ({ - sensors={NAVIGATION_MENU_ITEM_DND_SENSORS} + sensors={DND_KIT_SENSORS} onDragStart={handlers.onDragStart} onDragOver={handlers.onDragOver} onDragEnd={handlers.onDragEnd} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx index 9a551191f6..98e8f6b0d1 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeader.tsx @@ -1,16 +1,10 @@ import { TABLE_Z_INDEX } from '@/object-record/record-table/constants/TableZIndex'; -import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; -import { RecordTableHeaderAddColumnButton } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderAddColumnButton'; -import { RecordTableHeaderCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCell'; import { RecordTableHeaderCheckboxColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCheckboxColumn'; +import { RecordTableHeaderDnd } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderDnd'; import { RecordTableHeaderDragDropColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderDragDropColumn'; -import { RecordTableHeaderEmptyLastColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderEmptyLastColumn'; import { RecordTableHeaderFirstCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderFirstCell'; -import { RecordTableHeaderFirstScrollableCell } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell'; -import { RecordTableHeaderLastEmptyColumn } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderLastEmptyColumn'; import { useResizeTableHeader } from '@/object-record/record-table/record-table-header/hooks/useResizeTableHeader'; import { isRecordTableCheckboxColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableCheckboxColumnHiddenComponentState'; -import { isRecordTableColumnHeadersReadOnlyComponentState } from '@/object-record/record-table/states/isRecordTableColumnHeadersReadOnlyComponentState'; import { isRecordTableDragColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableDragColumnHiddenComponentState'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { styled } from '@linaria/react'; @@ -24,12 +18,6 @@ const StyledHeaderContainer = styled.div` `; export const RecordTableHeader = () => { - const { visibleRecordFields } = useRecordTableContextOrThrow(); - - const isRecordTableColumnHeadersReadOnly = useAtomComponentStateValue( - isRecordTableColumnHeadersReadOnlyComponentState, - ); - const isRecordTableDragColumnHidden = useAtomComponentStateValue( isRecordTableDragColumnHiddenComponentState, ); @@ -38,8 +26,6 @@ export const RecordTableHeader = () => { isRecordTableCheckboxColumnHiddenComponentState, ); - const recordFieldsWithoutFirstTwo = visibleRecordFields.slice(2); - useResizeTableHeader(); return ( @@ -49,20 +35,7 @@ export const RecordTableHeader = () => { )} - - {recordFieldsWithoutFirstTwo.map((recordField, index) => ( - - ))} - {isRecordTableColumnHeadersReadOnly ? ( - - ) : ( - - )} - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx index 346235c832..086029e2ac 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx @@ -6,6 +6,7 @@ import { RecordTableColumnHead } from '@/object-record/record-table/record-table import { RecordTableColumnHeadWithDropdown } from '@/object-record/record-table/record-table-header/components/RecordTableColumnHeadWithDropdown'; import { RecordTableHeaderCellContainer } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCellContainer'; import { RecordTableHeaderResizeHandler } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderResizeHandler'; +import { RecordTableHeaderSortableHandle } from '@/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableHandle'; import { isRecordTableColumnHeadersReadOnlyComponentState } from '@/object-record/record-table/states/isRecordTableColumnHeadersReadOnlyComponentState'; import { isRecordTableColumnResizableComponentState } from '@/object-record/record-table/states/isRecordTableColumnResizableComponentState'; import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; @@ -14,6 +15,7 @@ import { isRecordTableRowFocusedComponentFamilyState } from '@/object-record/rec import { isRecordTableScrolledVerticallyComponentState } from '@/object-record/record-table/states/isRecordTableScrolledVerticallyComponentState'; import { resizedFieldMetadataIdComponentState } from '@/object-record/record-table/states/resizedFieldMetadataIdComponentState'; import { getRecordTableColumnFieldWidthClassName } from '@/object-record/record-table/utils/getRecordTableColumnFieldWidthClassName'; +import { useDisableDragSelectOnPointerDown } from '@/object-record/record-table/record-table-header/hooks/useDisableDragSelectOnPointerDown'; import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; @@ -31,6 +33,12 @@ export const RecordTableHeaderCell = ({ }: RecordTableHeaderCellProps) => { const { objectMetadataItem } = useRecordTableContextOrThrow(); + const { + onPointerCancel: handlePointerCancel, + onPointerDown: handlePointerDown, + onPointerUp: handlePointerUp, + } = useDisableDragSelectOnPointerDown(); + const isRecordTableColumnHeadersReadOnly = useAtomComponentStateValue( isRecordTableColumnHeadersReadOnlyComponentState, ); @@ -83,6 +91,9 @@ export const RecordTableHeaderCell = ({ getRecordTableColumnFieldWidthClassName(recordFieldIndex), )} key={recordField.fieldMetadataItemId} + onPointerCancel={handlePointerCancel} + onPointerDown={handlePointerDown} + onPointerUp={handlePointerUp} shouldDisplayBorderBottom={shouldDisplayBorderBottom} isResizing={isResizingAnyColumn} isReadOnly={isRecordTableColumnHeadersReadOnly} @@ -93,14 +104,16 @@ export const RecordTableHeaderCell = ({ position="left" /> )} - {isRecordTableColumnHeadersReadOnly ? ( - - ) : ( - - )} + + {isRecordTableColumnHeadersReadOnly ? ( + + ) : ( + + )} + {isRecordTableColumnResizable && ( { + const { visibleRecordFields } = useRecordTableContextOrThrow(); + + const isRecordTableColumnHeadersReadOnly = useAtomComponentStateValue( + isRecordTableColumnHeadersReadOnlyComponentState, + ); + + const firstScrollableRecordField = visibleRecordFields[1]; + + const recordFieldsWithoutFirstTwo = visibleRecordFields.slice(2); + + return ( + + + + + + {isDefined(firstScrollableRecordField) && ( + + + + )} + + {recordFieldsWithoutFirstTwo.map((recordField, index) => ( + + + + + + + + + ))} + + + + {isRecordTableColumnHeadersReadOnly ? ( + + ) : ( + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx index 49a8923725..a8e81f33fb 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderFirstScrollableCell.tsx @@ -8,6 +8,7 @@ import { RecordTableHeaderResizeHandler } from '@/object-record/record-table/rec import { RecordTableHeaderCellContainer } from '@/object-record/record-table/record-table-header/components/RecordTableHeaderCellContainer'; import { hasRecordGroupsComponentSelector } from '@/object-record/record-group/states/selectors/hasRecordGroupsComponentSelector'; +import { RecordTableHeaderSortableHandle } from '@/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableHandle'; import { isRecordTableColumnHeadersReadOnlyComponentState } from '@/object-record/record-table/states/isRecordTableColumnHeadersReadOnlyComponentState'; import { isRecordTableColumnResizableComponentState } from '@/object-record/record-table/states/isRecordTableColumnResizableComponentState'; import { isRecordTableRowActiveComponentFamilyState } from '@/object-record/record-table/states/isRecordTableRowActiveComponentFamilyState'; @@ -21,10 +22,22 @@ import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/ import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { cx } from '@linaria/core'; import { isDefined } from 'twenty-shared/utils'; +import { useDisableDragSelectOnPointerDown } from '@/object-record/record-table/record-table-header/hooks/useDisableDragSelectOnPointerDown'; -export const RecordTableHeaderFirstScrollableCell = () => { - const { objectMetadataItem, visibleRecordFields } = - useRecordTableContextOrThrow(); +type RecordTableHeaderFirstScrollableCellProps = { + firstScrollableRecordField: RecordField; +}; + +export const RecordTableHeaderFirstScrollableCell = ({ + firstScrollableRecordField, +}: RecordTableHeaderFirstScrollableCellProps) => { + const { objectMetadataItem } = useRecordTableContextOrThrow(); + + const { + onPointerCancel: handlePointerCancel, + onPointerDown: handlePointerDown, + onPointerUp: handlePointerUp, + } = useDisableDragSelectOnPointerDown(); const isRecordTableColumnHeadersReadOnly = useAtomComponentStateValue( isRecordTableColumnHeadersReadOnlyComponentState, @@ -44,8 +57,6 @@ export const RecordTableHeaderFirstScrollableCell = () => { 0, ); - const recordField = visibleRecordFields[1] as RecordField | undefined; - const isRecordTableRowFocusActive = useAtomComponentStateValue( isRecordTableRowFocusActiveComponentState, ); @@ -73,14 +84,13 @@ export const RecordTableHeaderFirstScrollableCell = () => { const isResizingAnyColumn = isDefined(resizedFieldMetadataId); - if (!recordField) { - return <>; - } - return ( { {isRecordTableColumnResizable && ( )} - {isRecordTableColumnHeadersReadOnly ? ( - - ) : ( - - )} + + {isRecordTableColumnHeadersReadOnly ? ( + + ) : ( + + )} + {isRecordTableColumnResizable && ( )} diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDropTarget.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDropTarget.tsx new file mode 100644 index 0000000000..cfc0db2900 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDropTarget.tsx @@ -0,0 +1,71 @@ +import { styled } from '@linaria/react'; +import { type ReactNode, useContext } from 'react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { RecordTableHeaderDndContext } from '@/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderDndContext'; +import { isDefined } from 'twenty-shared/utils'; + +const StyledDropTarget = styled.div<{ + $compact?: boolean; +}>` + height: 100%; + min-height: ${({ $compact }) => + $compact ? '100%' : themeCssVariables.spacing[2]}; + position: relative; + transition: background-color 120ms ease-out; + width: 100%; + + &::before { + background-color: ${themeCssVariables.color.blue}; + border-radius: 0 ${themeCssVariables.border.radius.sm} + ${themeCssVariables.border.radius.sm} 0; + content: ''; + height: 100%; + left: 0; + opacity: 0; + position: absolute; + top: 0; + transform: scaleY(0.7); + transform-origin: center; + transition: + opacity 120ms ease-out, + transform 120ms ease-out; + width: 2px; + } + + &[data-drag-over='true'] { + background-color: ${themeCssVariables.background.transparent.blue}; + } + + &[data-drag-over='true']::before { + opacity: 1; + transform: scaleY(1); + } +`; + +type RecordTableHeaderDropTargetProps = { + index: number; + children?: ReactNode; + compact?: boolean; +}; + +export const RecordTableHeaderDropTarget = ({ + index, + children, + compact = false, +}: RecordTableHeaderDropTargetProps) => { + const { activeDropTargetIndex } = useContext(RecordTableHeaderDndContext); + + const isDragOver = isDefined(activeDropTargetIndex) + ? activeDropTargetIndex === index + : false; + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDroppableSlot.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDroppableSlot.tsx new file mode 100644 index 0000000000..a9d66285ea --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDroppableSlot.tsx @@ -0,0 +1,45 @@ +import { type RecordTableHeaderDndData } from '@/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData'; +import { pointerIntersection } from '@dnd-kit/collision'; +import { useDroppable } from '@dnd-kit/react'; +import { styled } from '@linaria/react'; +import { type ReactNode } from 'react'; + +const StyledSlotWrapper = styled.div` + align-self: stretch; + flex: 0 0 2px; + margin-left: -1px; + margin-right: -1px; + min-height: 0; + position: relative; + z-index: 100; +`; + +const SLOT_COLLISION_PRIORITY = 1; + +type RecordTableHeaderDroppableSlotProps = { + droppableId: string; + index: number; + children?: ReactNode; + disabled?: boolean; + collisionPriority?: number; +}; + +export const RecordTableHeaderDroppableSlot = ({ + droppableId, + index, + children, + disabled = false, + collisionPriority = SLOT_COLLISION_PRIORITY, +}: RecordTableHeaderDroppableSlotProps) => { + const id = `${droppableId}::${index}`; + const data: RecordTableHeaderDndData = { droppableId, index }; + const { ref } = useDroppable({ + id, + disabled, + collisionPriority, + collisionDetector: pointerIntersection, + data, + }); + + return {children}; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableCell.tsx new file mode 100644 index 0000000000..0b49f97270 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableCell.tsx @@ -0,0 +1,65 @@ +import { RestrictToHorizontalAxis } from '@dnd-kit/abstract/modifiers'; +import { SortableKeyboardPlugin } from '@dnd-kit/dom/sortable'; +import { useSortable } from '@dnd-kit/react/sortable'; +import { styled } from '@linaria/react'; +import { type ReactNode } from 'react'; + +import { RecordTableHeaderSortableHandleRefContext } from '@/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderSortableHandleRefContext'; + +const SORTABLE_COLLISION_PRIORITY = 3; + +const PLUGINS_WITHOUT_OPTIMISTIC = [SortableKeyboardPlugin]; + +// Columns can only be reordered horizontally, so keep the drag preview locked to the header row. +const RECORD_TABLE_HEADER_SORTABLE_MODIFIERS = [RestrictToHorizontalAxis]; + +const RECORD_TABLE_HEADER_SORTABLE_TRANSITION = { + duration: 180, + easing: 'cubic-bezier(0.2, 0, 0, 1)', + idle: true, +}; + +const StyledSortableRoot = styled.div` + min-height: 0; + outline: none; + position: relative; + will-change: transform; +`; + +type RecordTableHeaderSortableCellProps = { + children: ReactNode; + disabled?: boolean; + group: string; + id: string; + index: number; +}; + +export const RecordTableHeaderSortableCell = ({ + id, + index, + group, + disabled = false, + children, +}: RecordTableHeaderSortableCellProps) => { + const { handleRef, ref } = useSortable({ + id, + index, + group, + collisionPriority: SORTABLE_COLLISION_PRIORITY, + data: { + droppableId: group, + index: index, + }, + disabled, + transition: RECORD_TABLE_HEADER_SORTABLE_TRANSITION, + plugins: PLUGINS_WITHOUT_OPTIMISTIC, + modifiers: RECORD_TABLE_HEADER_SORTABLE_MODIFIERS, + feedback: 'clone', + }); + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableHandle.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableHandle.tsx new file mode 100644 index 0000000000..4a39702589 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableHandle.tsx @@ -0,0 +1,28 @@ +import { styled } from '@linaria/react'; +import { type ReactNode, useContext } from 'react'; + +import { RecordTableHeaderSortableHandleRefContext } from '@/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderSortableHandleRefContext'; + +const StyledSortableHandle = styled.div` + height: 100%; + min-width: 0; + outline: none; +`; + +type RecordTableHeaderSortableHandleProps = { + children: ReactNode; +}; + +export const RecordTableHeaderSortableHandle = ({ + children, +}: RecordTableHeaderSortableHandleProps) => { + const sortableHandleRef = useContext( + RecordTableHeaderSortableHandleRefContext, + ); + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/constants/RecordTableHeaderDroppableId.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/constants/RecordTableHeaderDroppableId.ts new file mode 100644 index 0000000000..cc44143003 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/constants/RecordTableHeaderDroppableId.ts @@ -0,0 +1 @@ +export const RECORD_TABLE_HEADER_DROPPABLE_ID = 'record-table-header-droppable'; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderDndContext.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderDndContext.tsx new file mode 100644 index 0000000000..f566d8542c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderDndContext.tsx @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +type RecordTableHeaderDndContextValue = { + activeDropTargetIndex: number | null; +}; + +export const RecordTableHeaderDndContext = + createContext({ + activeDropTargetIndex: null, + }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderSortableHandleRefContext.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderSortableHandleRefContext.tsx new file mode 100644 index 0000000000..40a42fe0e9 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderSortableHandleRefContext.tsx @@ -0,0 +1,5 @@ +import { createContext } from 'react'; + +export const RecordTableHeaderSortableHandleRefContext = createContext< + ((element: Element | null) => void) | undefined +>(undefined); diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts new file mode 100644 index 0000000000..8e31c800a6 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts @@ -0,0 +1,197 @@ +import { type DragDropProvider } from '@dnd-kit/react'; +import { useStore } from 'jotai'; +import { type ComponentProps, useCallback, useState } from 'react'; +import { filterOutByProperty, isDefined } from 'twenty-shared/utils'; + +import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext'; +import { RECORD_TABLE_COLUMN_CHECKBOX_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnCheckboxWidth'; +import { RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH } from '@/object-record/record-table/constants/RecordTableColumnDragAndDropWidth'; +import { useReorderVisibleRecordFields } from '@/object-record/record-field/hooks/useReorderVisibleRecordFields'; +import { resolveRecordTableHeaderDrop } from '@/object-record/record-table/record-table-header/dnd/utils/resolveRecordTableHeaderDrop'; +import { useSaveCurrentViewFields } from '@/views/hooks/useSaveCurrentViewFields'; +import { mapRecordFieldToViewField } from '@/views/utils/mapRecordFieldToViewField'; +import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState'; +import { isRecordTableHeaderDropProcessingComponentState } from '@/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState'; +import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect'; +import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; +import { type RecordTableHeaderDndData } from '@/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData'; +import { isRecordTableCheckboxColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableCheckboxColumnHiddenComponentState'; +import { isRecordTableDragColumnHiddenComponentState } from '@/object-record/record-table/states/isRecordTableDragColumnHiddenComponentState'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; + +type DragStartPayload = Parameters< + NonNullable< + ComponentProps< + typeof DragDropProvider + >['onDragStart'] + > +>[0]; +type DragMovePayload = Parameters< + NonNullable< + ComponentProps< + typeof DragDropProvider + >['onDragMove'] + > +>[0]; +type DragEndPayload = Parameters< + NonNullable< + ComponentProps< + typeof DragDropProvider + >['onDragEnd'] + > +>[0]; + +export type RecordTableHeaderDndKitContextValues = { + activeDropTargetIndex: number | null; +}; + +export const useRecordTableHeaderDndKit = (): { + contextValues: RecordTableHeaderDndKitContextValues; + handlers: { + onDragStart: (event: DragStartPayload) => void; + onDragMove: (event: DragMovePayload) => void; + onDragEnd: (event: DragEndPayload) => void; + }; +} => { + const store = useStore(); + + const { recordTableId, visibleRecordFields } = useRecordTableContextOrThrow(); + const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow(); + const { reorderVisibleRecordFields } = + useReorderVisibleRecordFields(recordTableId); + const { saveViewFields } = useSaveCurrentViewFields(); + const { setDragSelectionStartEnabled } = useDragSelect(); + const { getScrollWrapperElement } = useScrollWrapperHTMLElement(); + + const isRecordTableHeaderDropProcessingCallbackState = + useAtomComponentStateCallbackState( + isRecordTableHeaderDropProcessingComponentState, + ); + const isRecordTableDragColumnHidden = useAtomComponentStateValue( + isRecordTableDragColumnHiddenComponentState, + ); + const isRecordTableCheckboxColumnHidden = useAtomComponentStateValue( + isRecordTableCheckboxColumnHiddenComponentState, + ); + + const [activeDropTargetIndex, setActiveDropTargetIndex] = useState< + number | null + >(null); + + const recordFieldsWithoutLabelIdentifier = visibleRecordFields.filter( + filterOutByProperty( + 'fieldMetadataItemId', + labelIdentifierFieldMetadataItem?.id, + ), + ); + + const labelIdentifierRecordField = visibleRecordFields.find( + (recordField) => + recordField.fieldMetadataItemId === labelIdentifierFieldMetadataItem?.id, + ); + + const nonSortableColumnsWidth = + (isRecordTableDragColumnHidden + ? 0 + : RECORD_TABLE_COLUMN_DRAG_AND_DROP_WIDTH) + + (isRecordTableCheckboxColumnHidden + ? 0 + : RECORD_TABLE_COLUMN_CHECKBOX_WIDTH) + + (labelIdentifierRecordField?.size ?? 0); + + const resolveDropFromPointerX = useCallback( + ({ pointerX, sourceIndex }: { pointerX: number; sourceIndex: number }) => { + const { scrollWrapperElement } = getScrollWrapperElement(); + if (!isDefined(scrollWrapperElement)) return null; + + return resolveRecordTableHeaderDrop({ + pointerX, + sourceIndex, + scrollWrapperElement, + nonSortableColumnsWidth, + recordFields: recordFieldsWithoutLabelIdentifier, + }); + }, + [ + getScrollWrapperElement, + nonSortableColumnsWidth, + recordFieldsWithoutLabelIdentifier, + ], + ); + + const handleDragStart = (_event: DragStartPayload) => { + store.set(isRecordTableHeaderDropProcessingCallbackState, true); + + setActiveDropTargetIndex(null); + }; + + const handleDragMove = useCallback( + (event: DragMovePayload) => { + const { operation } = event; + const sourceIndex = operation.source?.data.index; + + if (!isDefined(sourceIndex)) { + setActiveDropTargetIndex(null); + return; + } + + const resolvedDrop = resolveDropFromPointerX({ + pointerX: operation.position.current.x, + sourceIndex, + }); + + setActiveDropTargetIndex((currentActiveDropTargetIndex) => { + const nextActiveDropTargetIndex = resolvedDrop?.dropTargetIndex ?? null; + + return currentActiveDropTargetIndex === nextActiveDropTargetIndex + ? currentActiveDropTargetIndex + : nextActiveDropTargetIndex; + }); + }, + [resolveDropFromPointerX, setActiveDropTargetIndex], + ); + + const handleDragEnd = (event: DragEndPayload) => { + const { operation } = event; + const source = operation.source; + + setActiveDropTargetIndex(null); + setDragSelectionStartEnabled(true); + store.set(isRecordTableHeaderDropProcessingCallbackState, false); + + if (event.canceled) return; + if (!isDefined(source)) return; + + const sourceIndex = source.data.index; + const resolvedDrop = resolveDropFromPointerX({ + pointerX: operation.position.current.x, + sourceIndex, + }); + + if (!isDefined(resolvedDrop)) return; + if (resolvedDrop.sourceIndex === resolvedDrop.destinationIndex) return; + + // Sortable indices exclude the pinned label-identifier column at visibleRecordFields[0], + // so shift by one to address the full visible field list. + const updatedRecordField = reorderVisibleRecordFields({ + fromIndex: resolvedDrop.sourceIndex + 1, + toIndex: resolvedDrop.destinationIndex + 1, + }); + + saveViewFields([mapRecordFieldToViewField(updatedRecordField)]); + }; + + const contextValues: RecordTableHeaderDndKitContextValues = { + activeDropTargetIndex, + }; + + return { + contextValues, + handlers: { + onDragStart: handleDragStart, + onDragMove: handleDragMove, + onDragEnd: handleDragEnd, + }, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx new file mode 100644 index 0000000000..ebcc1f139d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/providers/RecordTableHeaderDndKitProvider.tsx @@ -0,0 +1,30 @@ +import { DragDropProvider } from '@dnd-kit/react'; +import type { ReactNode } from 'react'; + +import { type RecordTableHeaderDndData } from '@/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData'; +import { RecordTableHeaderDndContext } from '@/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderDndContext'; +import { useRecordTableHeaderDndKit } from '@/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; + +type RecordTableHeaderDndKitProviderProps = { + children: ReactNode; +}; + +export const RecordTableHeaderDndKitProvider = ({ + children, +}: RecordTableHeaderDndKitProviderProps) => { + const { contextValues, handlers } = useRecordTableHeaderDndKit(); + + return ( + + + sensors={DND_KIT_SENSORS} + onDragStart={handlers.onDragStart} + onDragMove={handlers.onDragMove} + onDragEnd={handlers.onDragEnd} + > + {children} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData.ts new file mode 100644 index 0000000000..27e43b235e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData.ts @@ -0,0 +1,4 @@ +export type RecordTableHeaderDndData = { + index: number; + droppableId: string; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/utils/resolveRecordTableHeaderDrop.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/utils/resolveRecordTableHeaderDrop.ts new file mode 100644 index 0000000000..2569581a80 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/utils/resolveRecordTableHeaderDrop.ts @@ -0,0 +1,70 @@ +import { type RecordField } from '@/object-record/record-field/types/RecordField'; + +type ResolveRecordTableHeaderDropArgs = { + pointerX: number; + sourceIndex: number; + scrollWrapperElement: HTMLElement; + nonSortableColumnsWidth: number; + recordFields: RecordField[]; +}; + +type ResolvedRecordTableHeaderDrop = { + sourceIndex: number; + dropTargetIndex: number; + destinationIndex: number; +}; + +const getDestinationIndexFromDropTargetIndex = ({ + sourceIndex, + dropTargetIndex, +}: { + sourceIndex: number; + dropTargetIndex: number; +}) => (dropTargetIndex > sourceIndex ? dropTargetIndex - 1 : dropTargetIndex); + +export const resolveRecordTableHeaderDrop = ({ + pointerX, + sourceIndex, + scrollWrapperElement, + nonSortableColumnsWidth, + recordFields, +}: ResolveRecordTableHeaderDropArgs): ResolvedRecordTableHeaderDrop => { + const scrollContainerRect = scrollWrapperElement.getBoundingClientRect(); + + const contentX = + pointerX - + scrollContainerRect.left + + scrollWrapperElement.scrollLeft - + nonSortableColumnsWidth; + + let left = 0; + + for (const [index, field] of recordFields.entries()) { + const width = field.size; + const midpoint = left + width / 2; + + if (contentX < midpoint) { + return { + sourceIndex, + dropTargetIndex: index, + destinationIndex: getDestinationIndexFromDropTargetIndex({ + sourceIndex, + dropTargetIndex: index, + }), + }; + } + + left += width; + } + + const dropTargetIndex = recordFields.length; + + return { + sourceIndex, + dropTargetIndex, + destinationIndex: getDestinationIndexFromDropTargetIndex({ + sourceIndex, + dropTargetIndex, + }), + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useDisableDragSelectOnPointerDown.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useDisableDragSelectOnPointerDown.ts new file mode 100644 index 0000000000..a3b30bc54e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useDisableDragSelectOnPointerDown.ts @@ -0,0 +1,23 @@ +import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect'; +import { useCallback } from 'react'; + +// Prevents the record table cell drag-select from starting when interacting with a column header. +// Pointer capture is intentionally avoided: capturing on pointerdown retargets the pointerup/click +// to the cell and stops the column header dropdown from opening. dnd-kit handles drag pointer tracking. +export const useDisableDragSelectOnPointerDown = () => { + const { setDragSelectionStartEnabled } = useDragSelect(); + + const handlePointerDown = useCallback(() => { + setDragSelectionStartEnabled(false); + }, [setDragSelectionStartEnabled]); + + const handlePointerEnd = useCallback(() => { + setDragSelectionStartEnabled(true); + }, [setDragSelectionStartEnabled]); + + return { + onPointerDown: handlePointerDown, + onPointerUp: handlePointerEnd, + onPointerCancel: handlePointerEnd, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState.ts new file mode 100644 index 0000000000..cf7a2abb86 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState.ts @@ -0,0 +1,9 @@ +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; + +export const isRecordTableHeaderDropProcessingComponentState = + createAtomComponentState({ + key: 'isRecordTableHeaderDropProcessingComponentState', + defaultValue: false, + componentInstanceContext: RecordTableComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitSensors.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitSensors.ts new file mode 100644 index 0000000000..aec2c38b8e --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/constants/DndKitSensors.ts @@ -0,0 +1,12 @@ +import { PointerActivationConstraints } from '@dnd-kit/dom'; +import { KeyboardSensor, PointerSensor } from '@dnd-kit/react'; + +// Pointer drags only start past 8px so clicks on draggable items still register. +export const DND_KIT_SENSORS = [ + PointerSensor.configure({ + activationConstraints: [ + new PointerActivationConstraints.Distance({ value: 8 }), + ], + }), + KeyboardSensor, +];