From 7babc049f55ec78e0199b42fa94fab40caf2c463 Mon Sep 17 00:00:00 2001 From: Priyanshu Bartwal Date: Fri, 10 Jul 2026 19:17:59 +0530 Subject: [PATCH] [Twenty-Front]: Record board column drag and drop functionality (#22323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #22321 - Used `@dnd-kit` library for core drag and drop logic. - Tried to keep as much similar to #21304 as possible. https://github.com/user-attachments/assets/bff100cf-d727-4281-a5fa-b010a373f189 Review in cubic --------- Co-authored-by: Charles Bochet Co-authored-by: Raphaƫl Bosi <71827178+bosiraphael@users.noreply.github.com> Co-authored-by: bosiraphael --- .../components/RecordBoardHeader.tsx | 30 +-- .../components/RecordBoardColumnDnd.tsx | 55 +++++ .../components/RecordBoardColumnHeader.tsx | 128 ++++++----- .../constants/RecordBoardColumnDroppableId.ts | 1 + .../dnd/hooks/useRecordBoardColumnDndKit.ts | 199 ++++++++++++++++++ .../RecordBoardColumnDndKitProvider.tsx | 37 ++++ .../components/RecordTableHeaderCell.tsx | 8 +- .../components/RecordTableHeaderDnd.tsx | 36 ++-- .../RecordTableHeaderFirstScrollableCell.tsx | 8 +- .../RecordTableHeaderSortableHandle.tsx | 28 --- .../context/RecordTableHeaderDndContext.tsx | 10 - .../dnd/hooks/useRecordTableHeaderDndKit.ts | 106 ++++------ .../RecordTableHeaderDndKitProvider.tsx | 10 +- .../useDisableDragSelectOnPointerDown.ts | 23 -- ...TableHeaderDropProcessingComponentState.ts | 9 - .../components/DragDropColumnDropTarget.tsx} | 25 +-- .../DragDropColumnDroppableSlot.tsx} | 24 ++- .../DragDropColumnSortableCell.tsx} | 39 ++-- .../DragDropColumnSortableHandle.tsx | 36 ++++ .../context/DragDropColumnDndContext.tsx | 10 + ...ragDropColumnSortableHandleRefContext.tsx} | 2 +- .../types/DragDropColumnData.ts} | 4 +- .../resolveDragDropColumnDrop.test.ts | 114 ++++++++++ .../utils/resolveDragDropColumnDrop.ts} | 29 ++- .../useDisableDragSelectOnPointerDown.ts | 14 ++ 25 files changed, 680 insertions(+), 305 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDnd.tsx create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/constants/RecordBoardColumnDroppableId.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableHandle.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderDndContext.tsx delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useDisableDragSelectOnPointerDown.ts delete mode 100644 packages/twenty-front/src/modules/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState.ts rename packages/twenty-front/src/modules/{object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDropTarget.tsx => ui/utilities/drag-and-drop/components/DragDropColumnDropTarget.tsx} (71%) rename packages/twenty-front/src/modules/{object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDroppableSlot.tsx => ui/utilities/drag-and-drop/components/DragDropColumnDroppableSlot.tsx} (72%) rename packages/twenty-front/src/modules/{object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableCell.tsx => ui/utilities/drag-and-drop/components/DragDropColumnSortableCell.tsx} (53%) create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropColumnSortableHandle.tsx create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/context/DragDropColumnDndContext.tsx rename packages/twenty-front/src/modules/{object-record/record-table/record-table-header/dnd/context/RecordTableHeaderSortableHandleRefContext.tsx => ui/utilities/drag-and-drop/context/DragDropColumnSortableHandleRefContext.tsx} (59%) rename packages/twenty-front/src/modules/{object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData.ts => ui/utilities/drag-and-drop/types/DragDropColumnData.ts} (51%) create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDragDropColumnDrop.test.ts rename packages/twenty-front/src/modules/{object-record/record-table/record-table-header/dnd/utils/resolveRecordTableHeaderDrop.ts => ui/utilities/drag-and-drop/utils/resolveDragDropColumnDrop.ts} (63%) create mode 100644 packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/useDisableDragSelectOnPointerDown.ts diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx index 29c46235d9..8234d7005f 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx @@ -1,12 +1,6 @@ -import { RecordBoardAddGroupColumn } from '@/object-record/record-board/components/RecordBoardAddGroupColumn'; -import { RecordBoardColumnHeaderWrapper } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper'; -import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext'; -import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; +import { RecordBoardColumnDnd } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDnd'; import { RecordIndexGroupAggregatesDataLoader } from '@/object-record/record-index/components/RecordIndexGroupAggregatesDataLoader'; -import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; -import { ViewType } from '@/views/types/ViewType'; import { styled } from '@linaria/react'; -import { themeCssVariables } from 'twenty-ui/theme-constants'; const StyledHeaderContainer = styled.div` display: flex; @@ -21,32 +15,12 @@ const StyledHeaderContainer = styled.div` position: sticky; top: 0; } - - & > *:not(:first-of-type) { - border-left: 1px solid ${themeCssVariables.border.color.light}; - } `; export const RecordBoardHeader = () => { - const visibleRecordGroupIds = useAtomComponentFamilySelectorValue( - visibleRecordGroupIdsComponentFamilySelector, - ViewType.KANBAN, - ); - return ( - {visibleRecordGroupIds.map((recordGroupId, index) => ( - - - - ))} - + ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDnd.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDnd.tsx new file mode 100644 index 0000000000..80b4079156 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDnd.tsx @@ -0,0 +1,55 @@ +import { Fragment } from 'react'; + +import { RecordBoardAddGroupColumn } from '@/object-record/record-board/components/RecordBoardAddGroupColumn'; +import { RecordBoardColumnHeaderWrapper } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper'; +import { RECORD_BOARD_COLUMN_DROPPABLE_ID } from '@/object-record/record-board/record-board-column/dnd/constants/RecordBoardColumnDroppableId'; +import { RecordBoardColumnDndKitProvider } from '@/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider'; +import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; +import { RecordGroupContext } from '@/object-record/record-group/states/context/RecordGroupContext'; +import { DragDropColumnDropTarget } from '@/ui/utilities/drag-and-drop/components/DragDropColumnDropTarget'; +import { DragDropColumnDroppableSlot } from '@/ui/utilities/drag-and-drop/components/DragDropColumnDroppableSlot'; +import { DragDropColumnSortableCell } from '@/ui/utilities/drag-and-drop/components/DragDropColumnSortableCell'; +import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; +import { ViewType } from '@/views/types/ViewType'; + +export const RecordBoardColumnDnd = () => { + const visibleRecordGroupIds = useAtomComponentFamilySelectorValue( + visibleRecordGroupIdsComponentFamilySelector, + ViewType.KANBAN, + ); + + return ( + + + + + {visibleRecordGroupIds.map((recordGroupId, index) => ( + + + + + + + + + + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx index dc0832f35b..1d0a575db6 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx @@ -6,7 +6,7 @@ import { useObjectPermissionsForObject } from '@/object-record/hooks/useObjectPe import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu'; import { RecordBoardColumnHeaderAggregateDropdown } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderAggregateDropdown'; - +import { DragDropColumnSortableHandle } from '@/ui/utilities/drag-and-drop/components/DragDropColumnSortableHandle'; import { RECORD_BOARD_COLUMN_WIDTH } from '@/object-record/record-board/constants/RecordBoardColumnWidth'; import { RECORD_BOARD_COLUMN_WIDTH_CSS_VARIABLE_NAME } from '@/object-record/record-board/constants/RecordBoardColumnWidthCssVariableName'; import { RecordBoardColumnResizeHandler } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnResizeHandler'; @@ -19,6 +19,7 @@ import { recordIndexAggregateDisplayValueForGroupValueComponentFamilyState } fro import { useCreateNewIndexRecord } from '@/object-record/record-table/hooks/useCreateNewIndexRecord'; import { canCreateRecordsForObjectMetadataItem } from '@/object-record/utils/canCreateRecordsForObjectMetadataItem'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useDisableDragSelectOnPointerDown } from '@/ui/utilities/drag-select/hooks/useDisableDragSelectOnPointerDown'; import { useToggleDropdown } from '@/ui/layout/dropdown/hooks/useToggleDropdown'; import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; @@ -75,6 +76,10 @@ const StyledColumn = styled.div` padding: ${themeCssVariables.spacing[2]}; position: relative; + + &[data-has-left-border='true'] { + border-left: 1px solid ${themeCssVariables.border.color.light}; + } `; const StyledTagContainer = styled.div` @@ -89,7 +94,15 @@ const StyledDropdownContainer = styled.div` `; export const RecordBoardColumnHeader = () => { - const { columnDefinition } = useContext(RecordBoardColumnContext); + const { columnDefinition, columnIndex } = useContext( + RecordBoardColumnContext, + ); + + const { + onPointerCancel: handlePointerCancel, + onPointerDown: handlePointerDown, + onPointerUp: handlePointerUp, + } = useDisableDragSelectOnPointerDown(); const [isHeaderHovered, setIsHeaderHovered] = useState(false); @@ -136,64 +149,69 @@ export const RecordBoardColumnHeader = () => { }; return ( - - setIsHeaderHovered(true)} - onMouseLeave={() => setIsHeaderHovered(false)} - > - - - - - - - } - dropdownComponents={} - /> - - - - - - {isHeaderHovered && ( - - { - toggleDropdown({ - dropdownComponentInstanceIdFromProps: dropdownId, - }); + 0 ? 'true' : undefined}> + + setIsHeaderHovered(true)} + onMouseLeave={() => setIsHeaderHovered(false)} + onPointerCancel={handlePointerCancel} + onPointerDown={handlePointerDown} + onPointerUp={handlePointerUp} + > + + + + + + + } + dropdownComponents={} /> - {canCreateRecords && !hasAnySoftDeleteFilterOnView && ( + + + + + + {isHeaderHovered && ( + { + toggleDropdown({ + dropdownComponentInstanceIdFromProps: dropdownId, + }); + }} /> - )} - - )} - - - + {canCreateRecords && !hasAnySoftDeleteFilterOnView && ( + + )} + + )} + + + + ); diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/constants/RecordBoardColumnDroppableId.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/constants/RecordBoardColumnDroppableId.ts new file mode 100644 index 0000000000..d726b339c3 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/constants/RecordBoardColumnDroppableId.ts @@ -0,0 +1 @@ +export const RECORD_BOARD_COLUMN_DROPPABLE_ID = 'record-board-header'; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts new file mode 100644 index 0000000000..af59de5579 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit.ts @@ -0,0 +1,199 @@ +import { type DragDropProvider } from '@dnd-kit/react'; +import { type ComponentProps, useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +import { RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID } from '@/object-record/record-group/constants/RecordGroupReorderConfirmationModalId'; +import { useReorderRecordGroups } from '@/object-record/record-group/hooks/useReorderRecordGroups'; +import { visibleRecordGroupIdsComponentFamilySelector } from '@/object-record/record-group/states/selectors/visibleRecordGroupIdsComponentFamilySelector'; +import { RecordGroupSort } from '@/object-record/record-group/types/RecordGroupSort'; +import { useRecordIndexIdFromCurrentContextStore } from '@/object-record/record-index/hooks/useRecordIndexIdFromCurrentContextStore'; +import { recordIndexKanbanColumnWidthComponentState } from '@/object-record/record-index/states/recordIndexKanbanColumnWidthComponentState'; +import { recordIndexRecordGroupIsDraggableSortComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexRecordGroupIsDraggableSortComponentSelector'; +import { recordIndexRecordGroupSortComponentState } from '@/object-record/record-index/states/recordIndexRecordGroupSortComponentState'; +import { useModal } from '@/ui/layout/modal/hooks/useModal'; +import { type DragDropColumnData } from '@/ui/utilities/drag-and-drop/types/DragDropColumnData'; +import { resolveDragDropColumnDrop } from '@/ui/utilities/drag-and-drop/utils/resolveDragDropColumnDrop'; +import { useDragSelect } from '@/ui/utilities/drag-select/hooks/useDragSelect'; +import { useScrollWrapperHTMLElement } from '@/ui/utilities/scroll/hooks/useScrollWrapperHTMLElement'; +import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue'; +import { useAtomComponentFamilySelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilySelectorValue'; +import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState'; +import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { ViewType } from '@/views/types/ViewType'; + +type DragStartPayload = Parameters< + NonNullable< + ComponentProps>['onDragStart'] + > +>[0]; +type DragMovePayload = Parameters< + NonNullable< + ComponentProps>['onDragMove'] + > +>[0]; +type DragEndPayload = Parameters< + NonNullable< + ComponentProps>['onDragEnd'] + > +>[0]; + +type PendingReorder = { + fromIndex: number; + toIndex: number; +}; + +export type RecordBoardColumnDndKitContextValues = { + activeDropTargetIndex: number | null; +}; + +export const useRecordBoardColumnDndKit = (): { + contextValues: RecordBoardColumnDndKitContextValues; + handlePendingReorderConfirmClick: () => void; + handlers: { + onDragStart: (event: DragStartPayload) => void; + onDragMove: (event: DragMovePayload) => void; + onDragEnd: (event: DragEndPayload) => void; + }; +} => { + const { openModal } = useModal(); + const { recordIndexId } = useRecordIndexIdFromCurrentContextStore(); + const { reorderRecordGroups } = useReorderRecordGroups({ + recordIndexId, + viewType: ViewType.KANBAN, + }); + const { setDragSelectionStartEnabled } = useDragSelect(); + const { getScrollWrapperElement } = useScrollWrapperHTMLElement(); + + const isRecordGroupDraggableSort = useAtomComponentSelectorValue( + recordIndexRecordGroupIsDraggableSortComponentSelector, + recordIndexId, + ); + + const visibleRecordGroupIds = useAtomComponentFamilySelectorValue( + visibleRecordGroupIdsComponentFamilySelector, + ViewType.KANBAN, + ); + const recordIndexKanbanColumnWidth = useAtomComponentStateValue( + recordIndexKanbanColumnWidthComponentState, + ); + + const [, setRecordIndexRecordGroupSort] = useAtomComponentState( + recordIndexRecordGroupSortComponentState, + ); + + const [activeDropTargetIndex, setActiveDropTargetIndex] = useState< + number | null + >(null); + + const [pendingReorder, setPendingReorder] = useState( + null, + ); + + const resolveDropFromPointerX = ({ + pointerX, + sourceIndex, + }: { + pointerX: number; + sourceIndex: number; + }) => { + const { scrollWrapperElement } = getScrollWrapperElement(); + if (!isDefined(scrollWrapperElement)) return null; + + const columnWidths = visibleRecordGroupIds.map( + () => recordIndexKanbanColumnWidth, + ); + + if (columnWidths.length === 0) { + return null; + } + + return resolveDragDropColumnDrop({ + pointerX, + sourceIndex, + scrollWrapperElement, + columnWidths, + }); + }; + + const handleDragStart = (_event: DragStartPayload) => { + setActiveDropTargetIndex(null); + }; + + const handleDragMove = (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; + }); + }; + + const handleDragEnd = (event: DragEndPayload) => { + const { operation } = event; + const source = operation.source; + + setActiveDropTargetIndex(null); + setDragSelectionStartEnabled(true); + + if (event.canceled || !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; + + if (!isRecordGroupDraggableSort) { + setPendingReorder({ + fromIndex: resolvedDrop.sourceIndex, + toIndex: resolvedDrop.destinationIndex, + }); + openModal(RECORD_GROUP_REORDER_CONFIRMATION_MODAL_ID); + return; + } + + reorderRecordGroups({ + fromIndex: resolvedDrop.sourceIndex, + toIndex: resolvedDrop.destinationIndex, + }); + }; + + const handlePendingReorderConfirmClick = () => { + if (!isDefined(pendingReorder)) return; + + setRecordIndexRecordGroupSort(RecordGroupSort.Manual); + reorderRecordGroups(pendingReorder); + setPendingReorder(null); + }; + + return { + contextValues: { + activeDropTargetIndex, + }, + handlePendingReorderConfirmClick, + handlers: { + onDragStart: handleDragStart, + onDragMove: handleDragMove, + onDragEnd: handleDragEnd, + }, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx new file mode 100644 index 0000000000..e20012a51c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/dnd/providers/RecordBoardColumnDndKitProvider.tsx @@ -0,0 +1,37 @@ +import { DragDropProvider } from '@dnd-kit/react'; +import type { ReactNode } from 'react'; + +import { useRecordBoardColumnDndKit } from '@/object-record/record-board/record-board-column/dnd/hooks/useRecordBoardColumnDndKit'; +import { RecordGroupReorderConfirmationModal } from '@/object-record/record-group/components/RecordGroupReorderConfirmationModal'; +import { DragDropColumnDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnDndContext'; +import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { type DragDropColumnData } from '@/ui/utilities/drag-and-drop/types/DragDropColumnData'; + +type RecordBoardColumnDndKitProviderProps = { + children: ReactNode; +}; + +export const RecordBoardColumnDndKitProvider = ({ + children, +}: RecordBoardColumnDndKitProviderProps) => { + const { contextValues, handlers, handlePendingReorderConfirmClick } = + useRecordBoardColumnDndKit(); + + 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/components/RecordTableHeaderCell.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/components/RecordTableHeaderCell.tsx index 086029e2ac..10b094168d 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,7 +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 { DragDropColumnSortableHandle } from '@/ui/utilities/drag-and-drop/components/DragDropColumnSortableHandle'; 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'; @@ -15,7 +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 { useDisableDragSelectOnPointerDown } from '@/ui/utilities/drag-select/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'; @@ -104,7 +104,7 @@ export const RecordTableHeaderCell = ({ position="left" /> )} - + {isRecordTableColumnHeadersReadOnly ? ( ) : ( @@ -113,7 +113,7 @@ export const RecordTableHeaderCell = ({ objectMetadataId={objectMetadataItem.id} /> )} - + {isRecordTableColumnResizable && ( { return ( - - - + + {isDefined(firstScrollableRecordField) && ( - { - + )} {recordFieldsWithoutFirstTwo.map((recordField, index) => ( - - - - + + { recordField={recordField} recordFieldIndex={index + 2} /> - + ))} - - - + {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 a8e81f33fb..9a01379229 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,7 +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 { DragDropColumnSortableHandle } from '@/ui/utilities/drag-and-drop/components/DragDropColumnSortableHandle'; 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'; @@ -17,12 +17,12 @@ 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 '@/ui/utilities/drag-select/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'; import { cx } from '@linaria/core'; import { isDefined } from 'twenty-shared/utils'; -import { useDisableDragSelectOnPointerDown } from '@/object-record/record-table/record-table-header/hooks/useDisableDragSelectOnPointerDown'; type RecordTableHeaderFirstScrollableCellProps = { firstScrollableRecordField: RecordField; @@ -99,7 +99,7 @@ export const RecordTableHeaderFirstScrollableCell = ({ {isRecordTableColumnResizable && ( )} - + {isRecordTableColumnHeadersReadOnly ? ( ) : ( @@ -108,7 +108,7 @@ export const RecordTableHeaderFirstScrollableCell = ({ objectMetadataId={objectMetadataItem.id} /> )} - + {isRecordTableColumnResizable && ( )} 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 deleted file mode 100644 index 4a39702589..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableHandle.tsx +++ /dev/null @@ -1,28 +0,0 @@ -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/context/RecordTableHeaderDndContext.tsx b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderDndContext.tsx deleted file mode 100644 index f566d8542c..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderDndContext.tsx +++ /dev/null @@ -1,10 +0,0 @@ -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/hooks/useRecordTableHeaderDndKit.ts b/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/hooks/useRecordTableHeaderDndKit.ts index 8e31c800a6..7e6852fc51 100644 --- 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 @@ -1,44 +1,35 @@ import { type DragDropProvider } from '@dnd-kit/react'; -import { useStore } from 'jotai'; -import { type ComponentProps, useCallback, useState } from 'react'; +import { type ComponentProps, 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 { type DragDropColumnData } from '@/ui/utilities/drag-and-drop/types/DragDropColumnData'; +import { resolveDragDropColumnDrop } from '@/ui/utilities/drag-and-drop/utils/resolveDragDropColumnDrop'; import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; type DragStartPayload = Parameters< NonNullable< - ComponentProps< - typeof DragDropProvider - >['onDragStart'] + ComponentProps>['onDragStart'] > >[0]; type DragMovePayload = Parameters< NonNullable< - ComponentProps< - typeof DragDropProvider - >['onDragMove'] + ComponentProps>['onDragMove'] > >[0]; type DragEndPayload = Parameters< NonNullable< - ComponentProps< - typeof DragDropProvider - >['onDragEnd'] + ComponentProps>['onDragEnd'] > >[0]; @@ -54,8 +45,6 @@ export const useRecordTableHeaderDndKit = (): { onDragEnd: (event: DragEndPayload) => void; }; } => { - const store = useStore(); - const { recordTableId, visibleRecordFields } = useRecordTableContextOrThrow(); const { labelIdentifierFieldMetadataItem } = useRecordIndexContextOrThrow(); const { reorderVisibleRecordFields } = @@ -64,10 +53,6 @@ export const useRecordTableHeaderDndKit = (): { const { setDragSelectionStartEnabled } = useDragSelect(); const { getScrollWrapperElement } = useScrollWrapperHTMLElement(); - const isRecordTableHeaderDropProcessingCallbackState = - useAtomComponentStateCallbackState( - isRecordTableHeaderDropProcessingComponentState, - ); const isRecordTableDragColumnHidden = useAtomComponentStateValue( isRecordTableDragColumnHiddenComponentState, ); @@ -100,57 +85,53 @@ export const useRecordTableHeaderDndKit = (): { : 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; + const resolveDropFromPointerX = ({ + 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, - ], - ); + return resolveDragDropColumnDrop({ + pointerX, + sourceIndex, + scrollWrapperElement, + columnWidths: recordFieldsWithoutLabelIdentifier.map( + (recordField) => recordField.size, + ), + leadingOffset: nonSortableColumnsWidth, + }); + }; const handleDragStart = (_event: DragStartPayload) => { - store.set(isRecordTableHeaderDropProcessingCallbackState, true); - setActiveDropTargetIndex(null); }; - const handleDragMove = useCallback( - (event: DragMovePayload) => { - const { operation } = event; - const sourceIndex = operation.source?.data.index; + const handleDragMove = (event: DragMovePayload) => { + const { operation } = event; + const sourceIndex = operation.source?.data.index; - if (!isDefined(sourceIndex)) { - setActiveDropTargetIndex(null); - return; - } + if (!isDefined(sourceIndex)) { + setActiveDropTargetIndex(null); + return; + } - const resolvedDrop = resolveDropFromPointerX({ - pointerX: operation.position.current.x, - sourceIndex, - }); + const resolvedDrop = resolveDropFromPointerX({ + pointerX: operation.position.current.x, + sourceIndex, + }); - setActiveDropTargetIndex((currentActiveDropTargetIndex) => { - const nextActiveDropTargetIndex = resolvedDrop?.dropTargetIndex ?? null; + setActiveDropTargetIndex((currentActiveDropTargetIndex) => { + const nextActiveDropTargetIndex = resolvedDrop?.dropTargetIndex ?? null; - return currentActiveDropTargetIndex === nextActiveDropTargetIndex - ? currentActiveDropTargetIndex - : nextActiveDropTargetIndex; - }); - }, - [resolveDropFromPointerX, setActiveDropTargetIndex], - ); + return currentActiveDropTargetIndex === nextActiveDropTargetIndex + ? currentActiveDropTargetIndex + : nextActiveDropTargetIndex; + }); + }; const handleDragEnd = (event: DragEndPayload) => { const { operation } = event; @@ -158,7 +139,6 @@ export const useRecordTableHeaderDndKit = (): { setActiveDropTargetIndex(null); setDragSelectionStartEnabled(true); - store.set(isRecordTableHeaderDropProcessingCallbackState, false); if (event.canceled) return; if (!isDefined(source)) return; 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 index ebcc1f139d..2ea9d55348 100644 --- 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 @@ -1,10 +1,10 @@ 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 { DragDropColumnDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnDndContext'; import { DND_KIT_SENSORS } from '@/ui/utilities/drag-and-drop/constants/DndKitSensors'; +import { type DragDropColumnData } from '@/ui/utilities/drag-and-drop/types/DragDropColumnData'; type RecordTableHeaderDndKitProviderProps = { children: ReactNode; @@ -16,8 +16,8 @@ export const RecordTableHeaderDndKitProvider = ({ const { contextValues, handlers } = useRecordTableHeaderDndKit(); return ( - - + + sensors={DND_KIT_SENSORS} onDragStart={handlers.onDragStart} onDragMove={handlers.onDragMove} @@ -25,6 +25,6 @@ export const RecordTableHeaderDndKitProvider = ({ > {children} - + ); }; 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 deleted file mode 100644 index a3b30bc54e..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/hooks/useDisableDragSelectOnPointerDown.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index cf7a2abb86..0000000000 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/states/isRecordTableHeaderDropProcessingComponentState.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDropTarget.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropColumnDropTarget.tsx similarity index 71% rename from packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDropTarget.tsx rename to packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropColumnDropTarget.tsx index cfc0db2900..56df635e5e 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDropTarget.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropColumnDropTarget.tsx @@ -2,16 +2,16 @@ 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'; +import { DragDropColumnDndContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnDndContext'; const StyledDropTarget = styled.div<{ $compact?: boolean; + $overlay?: boolean; }>` height: 100%; min-height: ${({ $compact }) => $compact ? '100%' : themeCssVariables.spacing[2]}; - position: relative; + position: ${({ $overlay }) => ($overlay ? 'absolute' : 'relative')}; transition: background-color 120ms ease-out; width: 100%; @@ -43,26 +43,27 @@ const StyledDropTarget = styled.div<{ } `; -type RecordTableHeaderDropTargetProps = { - index: number; +type DragDropColumnDropTargetProps = { children?: ReactNode; compact?: boolean; + index: number; + overlay?: boolean; }; -export const RecordTableHeaderDropTarget = ({ - index, +export const DragDropColumnDropTarget = ({ children, compact = false, -}: RecordTableHeaderDropTargetProps) => { - const { activeDropTargetIndex } = useContext(RecordTableHeaderDndContext); + index, + overlay = false, +}: DragDropColumnDropTargetProps) => { + const { activeDropTargetIndex } = useContext(DragDropColumnDndContext); - const isDragOver = isDefined(activeDropTargetIndex) - ? activeDropTargetIndex === index - : false; + const isDragOver = activeDropTargetIndex === index; 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/ui/utilities/drag-and-drop/components/DragDropColumnDroppableSlot.tsx similarity index 72% rename from packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDroppableSlot.tsx rename to packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropColumnDroppableSlot.tsx index a9d66285ea..358cdf1f4c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderDroppableSlot.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropColumnDroppableSlot.tsx @@ -1,9 +1,10 @@ -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'; +import { type DragDropColumnData } from '@/ui/utilities/drag-and-drop/types/DragDropColumnData'; + const StyledSlotWrapper = styled.div` align-self: stretch; flex: 0 0 2px; @@ -16,23 +17,24 @@ const StyledSlotWrapper = styled.div` const SLOT_COLLISION_PRIORITY = 1; -type RecordTableHeaderDroppableSlotProps = { +type DragDropColumnDroppableSlotProps = { + children?: ReactNode; + collisionPriority?: number; + disabled?: boolean; droppableId: string; index: number; - children?: ReactNode; - disabled?: boolean; - collisionPriority?: number; }; -export const RecordTableHeaderDroppableSlot = ({ +export const DragDropColumnDroppableSlot = ({ + children, + collisionPriority = SLOT_COLLISION_PRIORITY, + disabled = false, droppableId, index, - children, - disabled = false, - collisionPriority = SLOT_COLLISION_PRIORITY, -}: RecordTableHeaderDroppableSlotProps) => { +}: DragDropColumnDroppableSlotProps) => { const id = `${droppableId}::${index}`; - const data: RecordTableHeaderDndData = { droppableId, index }; + const data: DragDropColumnData = { droppableId, index }; + const { ref } = useDroppable({ id, disabled, 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/ui/utilities/drag-and-drop/components/DragDropColumnSortableCell.tsx similarity index 53% rename from packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableCell.tsx rename to packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropColumnSortableCell.tsx index 0b49f97270..b29e559b90 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/components/RecordTableHeaderSortableCell.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropColumnSortableCell.tsx @@ -4,43 +4,48 @@ 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'; +import { DragDropColumnSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnSortableHandleRefContext'; 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 SORTABLE_MODIFIERS = [RestrictToHorizontalAxis]; -const RECORD_TABLE_HEADER_SORTABLE_TRANSITION = { +const SORTABLE_TRANSITION = { duration: 180, easing: 'cubic-bezier(0.2, 0, 0, 1)', idle: true, }; -const StyledSortableRoot = styled.div` +const StyledSortableRoot = styled.div<{ $fill?: boolean }>` + display: ${({ $fill }) => ($fill ? 'flex' : 'block')}; + height: ${({ $fill }) => ($fill ? '100%' : 'auto')}; min-height: 0; + min-width: ${({ $fill }) => ($fill ? '0' : 'auto')}; outline: none; position: relative; will-change: transform; `; -type RecordTableHeaderSortableCellProps = { +type DragDropColumnSortableCellProps = { children: ReactNode; disabled?: boolean; + fill?: boolean; group: string; id: string; index: number; }; -export const RecordTableHeaderSortableCell = ({ +export const DragDropColumnSortableCell = ({ + children, + disabled = false, + fill = false, + group, id, index, - group, - disabled = false, - children, -}: RecordTableHeaderSortableCellProps) => { +}: DragDropColumnSortableCellProps) => { const { handleRef, ref } = useSortable({ id, index, @@ -48,18 +53,20 @@ export const RecordTableHeaderSortableCell = ({ collisionPriority: SORTABLE_COLLISION_PRIORITY, data: { droppableId: group, - index: index, + index, }, disabled, - transition: RECORD_TABLE_HEADER_SORTABLE_TRANSITION, + transition: SORTABLE_TRANSITION, plugins: PLUGINS_WITHOUT_OPTIMISTIC, - modifiers: RECORD_TABLE_HEADER_SORTABLE_MODIFIERS, + modifiers: SORTABLE_MODIFIERS, feedback: 'clone', }); return ( - - {children} - + + + {children} + + ); }; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropColumnSortableHandle.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropColumnSortableHandle.tsx new file mode 100644 index 0000000000..f279547a48 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/components/DragDropColumnSortableHandle.tsx @@ -0,0 +1,36 @@ +import { styled } from '@linaria/react'; +import { type ReactNode, useContext } from 'react'; +import { themeCssVariables } from 'twenty-ui/theme-constants'; + +import { DragDropColumnSortableHandleRefContext } from '@/ui/utilities/drag-and-drop/context/DragDropColumnSortableHandleRefContext'; + +const StyledSortableHandle = styled.div<{ $fill?: boolean }>` + display: ${({ $fill }) => ($fill ? 'flex' : 'block')}; + height: 100%; + min-width: 0; + outline: none; + width: ${({ $fill }) => ($fill ? '100%' : 'auto')}; + + &:focus-visible { + outline: 2px solid ${themeCssVariables.color.blue}; + outline-offset: -2px; + } +`; + +type DragDropColumnSortableHandleProps = { + children: ReactNode; + fill?: boolean; +}; + +export const DragDropColumnSortableHandle = ({ + children, + fill = false, +}: DragDropColumnSortableHandleProps) => { + const sortableHandleRef = useContext(DragDropColumnSortableHandleRefContext); + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/context/DragDropColumnDndContext.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/context/DragDropColumnDndContext.tsx new file mode 100644 index 0000000000..f4e3245081 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/context/DragDropColumnDndContext.tsx @@ -0,0 +1,10 @@ +import { createContext } from 'react'; + +type DragDropColumnDndContextValue = { + activeDropTargetIndex: number | null; +}; + +export const DragDropColumnDndContext = + 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/ui/utilities/drag-and-drop/context/DragDropColumnSortableHandleRefContext.tsx similarity index 59% rename from packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderSortableHandleRefContext.tsx rename to packages/twenty-front/src/modules/ui/utilities/drag-and-drop/context/DragDropColumnSortableHandleRefContext.tsx index 40a42fe0e9..bc6b382423 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/context/RecordTableHeaderSortableHandleRefContext.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/context/DragDropColumnSortableHandleRefContext.tsx @@ -1,5 +1,5 @@ import { createContext } from 'react'; -export const RecordTableHeaderSortableHandleRefContext = createContext< +export const DragDropColumnSortableHandleRefContext = createContext< ((element: Element | null) => void) | undefined >(undefined); 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/ui/utilities/drag-and-drop/types/DragDropColumnData.ts similarity index 51% rename from packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData.ts rename to packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropColumnData.ts index 27e43b235e..840f5270a5 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/types/RecordTableHeaderDndData.ts +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/types/DragDropColumnData.ts @@ -1,4 +1,4 @@ -export type RecordTableHeaderDndData = { - index: number; +export type DragDropColumnData = { droppableId: string; + index: number; }; diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDragDropColumnDrop.test.ts b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDragDropColumnDrop.test.ts new file mode 100644 index 0000000000..d6cd3aa1c4 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/__tests__/resolveDragDropColumnDrop.test.ts @@ -0,0 +1,114 @@ +import { resolveDragDropColumnDrop } from '@/ui/utilities/drag-and-drop/utils/resolveDragDropColumnDrop'; + +const createScrollWrapperElement = ({ + left = 0, + scrollLeft = 0, +}: { + left?: number; + scrollLeft?: number; +}) => + ({ + getBoundingClientRect: () => ({ left }) as DOMRect, + scrollLeft, + }) as unknown as HTMLElement; + +const COLUMN_WIDTHS = [100, 100, 100]; + +describe('resolveDragDropColumnDrop', () => { + it('should target the column under the pointer before its midpoint', () => { + const result = resolveDragDropColumnDrop({ + pointerX: 40, + sourceIndex: 2, + scrollWrapperElement: createScrollWrapperElement({}), + columnWidths: COLUMN_WIDTHS, + }); + + expect(result).toEqual({ + sourceIndex: 2, + dropTargetIndex: 0, + destinationIndex: 0, + }); + }); + + it('should target the next slot once the pointer passes a column midpoint', () => { + const result = resolveDragDropColumnDrop({ + pointerX: 120, + sourceIndex: 2, + scrollWrapperElement: createScrollWrapperElement({}), + columnWidths: COLUMN_WIDTHS, + }); + + expect(result.dropTargetIndex).toBe(1); + expect(result.destinationIndex).toBe(1); + }); + + it('should target the trailing slot when the pointer is past every column', () => { + const result = resolveDragDropColumnDrop({ + pointerX: 400, + sourceIndex: 0, + scrollWrapperElement: createScrollWrapperElement({}), + columnWidths: COLUMN_WIDTHS, + }); + + expect(result.dropTargetIndex).toBe(COLUMN_WIDTHS.length); + expect(result.destinationIndex).toBe(COLUMN_WIDTHS.length - 1); + }); + + it('should decrement destinationIndex when dropping to the right of the source', () => { + const result = resolveDragDropColumnDrop({ + pointerX: 160, + sourceIndex: 0, + scrollWrapperElement: createScrollWrapperElement({}), + columnWidths: COLUMN_WIDTHS, + }); + + expect(result.dropTargetIndex).toBe(2); + expect(result.destinationIndex).toBe(1); + }); + + it('should keep destinationIndex equal to dropTargetIndex when dropping to the left of the source', () => { + const result = resolveDragDropColumnDrop({ + pointerX: 120, + sourceIndex: 2, + scrollWrapperElement: createScrollWrapperElement({}), + columnWidths: COLUMN_WIDTHS, + }); + + expect(result.dropTargetIndex).toBe(1); + expect(result.destinationIndex).toBe(1); + }); + + it('should offset the pointer by the scroll position', () => { + const result = resolveDragDropColumnDrop({ + pointerX: 40, + sourceIndex: 2, + scrollWrapperElement: createScrollWrapperElement({ scrollLeft: 100 }), + columnWidths: COLUMN_WIDTHS, + }); + + expect(result.dropTargetIndex).toBe(1); + }); + + it('should offset the pointer by the container left', () => { + const result = resolveDragDropColumnDrop({ + pointerX: 90, + sourceIndex: 2, + scrollWrapperElement: createScrollWrapperElement({ left: 50 }), + columnWidths: COLUMN_WIDTHS, + }); + + expect(result.dropTargetIndex).toBe(0); + }); + + it('should subtract the leading offset before resolving the column', () => { + const result = resolveDragDropColumnDrop({ + pointerX: 140, + sourceIndex: 2, + scrollWrapperElement: createScrollWrapperElement({}), + columnWidths: COLUMN_WIDTHS, + leadingOffset: 100, + }); + + expect(result.dropTargetIndex).toBe(0); + }); +}); 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/ui/utilities/drag-and-drop/utils/resolveDragDropColumnDrop.ts similarity index 63% rename from packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/utils/resolveRecordTableHeaderDrop.ts rename to packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDragDropColumnDrop.ts index 2569581a80..d1c1f52ab2 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/record-table-header/dnd/utils/resolveRecordTableHeaderDrop.ts +++ b/packages/twenty-front/src/modules/ui/utilities/drag-and-drop/utils/resolveDragDropColumnDrop.ts @@ -1,14 +1,12 @@ -import { type RecordField } from '@/object-record/record-field/types/RecordField'; - -type ResolveRecordTableHeaderDropArgs = { +type ResolveDragDropColumnDropArgs = { pointerX: number; sourceIndex: number; scrollWrapperElement: HTMLElement; - nonSortableColumnsWidth: number; - recordFields: RecordField[]; + columnWidths: number[]; + leadingOffset?: number; }; -type ResolvedRecordTableHeaderDrop = { +type ResolvedDragDropColumnDrop = { sourceIndex: number; dropTargetIndex: number; destinationIndex: number; @@ -22,26 +20,25 @@ const getDestinationIndexFromDropTargetIndex = ({ dropTargetIndex: number; }) => (dropTargetIndex > sourceIndex ? dropTargetIndex - 1 : dropTargetIndex); -export const resolveRecordTableHeaderDrop = ({ +export const resolveDragDropColumnDrop = ({ pointerX, sourceIndex, scrollWrapperElement, - nonSortableColumnsWidth, - recordFields, -}: ResolveRecordTableHeaderDropArgs): ResolvedRecordTableHeaderDrop => { + columnWidths, + leadingOffset = 0, +}: ResolveDragDropColumnDropArgs): ResolvedDragDropColumnDrop => { const scrollContainerRect = scrollWrapperElement.getBoundingClientRect(); const contentX = pointerX - scrollContainerRect.left + scrollWrapperElement.scrollLeft - - nonSortableColumnsWidth; + leadingOffset; let left = 0; - for (const [index, field] of recordFields.entries()) { - const width = field.size; - const midpoint = left + width / 2; + for (const [index, columnWidth] of columnWidths.entries()) { + const midpoint = left + columnWidth / 2; if (contentX < midpoint) { return { @@ -54,10 +51,10 @@ export const resolveRecordTableHeaderDrop = ({ }; } - left += width; + left += columnWidth; } - const dropTargetIndex = recordFields.length; + const dropTargetIndex = columnWidths.length; return { sourceIndex, diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/useDisableDragSelectOnPointerDown.ts b/packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/useDisableDragSelectOnPointerDown.ts new file mode 100644 index 0000000000..7c6cf407c7 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/hooks/useDisableDragSelectOnPointerDown.ts @@ -0,0 +1,14 @@ +import { useDragSelect } from './useDragSelect'; + +export const useDisableDragSelectOnPointerDown = () => { + const { setDragSelectionStartEnabled } = useDragSelect(); + + const handlePointerDown = () => setDragSelectionStartEnabled(false); + const handlePointerEnd = () => setDragSelectionStartEnabled(true); + + return { + onPointerDown: handlePointerDown, + onPointerUp: handlePointerEnd, + onPointerCancel: handlePointerEnd, + }; +};