From eeb84e97c9cc65b2cdad5f7445be51ebcab286b3 Mon Sep 17 00:00:00 2001 From: Philipinho <16838612+Philipinho@users.noreply.github.com> Date: Sun, 24 May 2026 02:24:54 +0100 Subject: [PATCH] refactor(base): migrate column reorder from dnd-kit to pragmatic-drag-and-drop --- .../features/base/components/base-table.tsx | 15 +-- .../base/components/grid/grid-container.tsx | 96 ++++--------- .../base/components/grid/grid-header-cell.tsx | 126 +++++++++++------- .../base/components/grid/grid-header.tsx | 8 +- 4 files changed, 119 insertions(+), 126 deletions(-) diff --git a/apps/client/src/features/base/components/base-table.tsx b/apps/client/src/features/base/components/base-table.tsx index b7db5bd53..d0674e715 100644 --- a/apps/client/src/features/base/components/base-table.tsx +++ b/apps/client/src/features/base/components/base-table.tsx @@ -4,7 +4,7 @@ import { notifications } from "@mantine/notifications"; import { useAtom } from "jotai"; import { IconDatabase } from "@tabler/icons-react"; import { useTranslation } from "react-i18next"; -import { arrayMove } from "@dnd-kit/sortable"; +import { reorder } from "@atlaskit/pragmatic-drag-and-drop/reorder"; import { generateJitteredKeyBetween } from "fractional-indexing-jittered"; import { useBaseQuery } from "@/features/base/queries/base-query"; import { useBaseSocket } from "@/features/base/hooks/use-base-socket"; @@ -207,14 +207,11 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) { }, [pageId, createViewMutation, t]); const handleColumnReorder = useCallback( - (activeId: string, overId: string) => { - const currentOrder = table.getState().columnOrder; - const oldIndex = currentOrder.indexOf(activeId); - const newIndex = currentOrder.indexOf(overId); - if (oldIndex === -1 || newIndex === -1) return; - - const newOrder = arrayMove(currentOrder, oldIndex, newIndex); - table.setColumnOrder(newOrder); + (columnId: string, finishIndex: number) => { + const order = table.getState().columnOrder; + const startIndex = order.indexOf(columnId); + if (startIndex === -1 || startIndex === finishIndex) return; + table.setColumnOrder(reorder({ list: order, startIndex, finishIndex })); persistViewConfig(); }, [table, persistViewConfig], diff --git a/apps/client/src/features/base/components/grid/grid-container.tsx b/apps/client/src/features/base/components/grid/grid-container.tsx index 8617239ab..a674eaf5b 100644 --- a/apps/client/src/features/base/components/grid/grid-container.tsx +++ b/apps/client/src/features/base/components/grid/grid-container.tsx @@ -7,20 +7,6 @@ import { windowScroll, } from "@tanstack/react-virtual"; import { useAtom } from "jotai"; -import { - DndContext, - closestCenter, - KeyboardSensor, - PointerSensor, - useSensor, - useSensors, - DragEndEvent, -} from "@dnd-kit/core"; -import { - SortableContext, - horizontalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"; import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types"; import { editingCellAtomFamily, activePropertyMenuAtomFamily, propertyMenuDirtyAtomFamily, propertyMenuCloseRequestAtomFamily } from "@/features/base/atoms/base-atoms"; import { useColumnResize } from "@/features/base/hooks/use-column-resize"; @@ -54,7 +40,7 @@ type GridContainerProps = { onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void; onAddRow?: () => void; pageId: string; - onColumnReorder?: (columnId: string, overColumnId: string) => void; + onColumnReorder?: (columnId: string, finishIndex: number) => void; onResizeEnd?: () => void; onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void; hasNextPage?: boolean; @@ -314,63 +300,34 @@ export function GridContainer({ }); }, []); - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { distance: 8 }, - }), - useSensor(KeyboardSensor), + const getColumnOrder = useCallback( + () => table.getState().columnOrder, + [table], ); - const sortableColumnIds = useMemo(() => { - return table - .getVisibleLeafColumns() - .filter((col) => col.id !== "__row_number") - .map((col) => col.id); - }, [table, table.getState().columnOrder]); - - const handleDragEnd = useCallback( - (event: DragEndEvent) => { - const { active, over } = event; - if (!over || active.id === over.id) return; - onColumnReorder?.(active.id as string, over.id as string); - }, - [onColumnReorder], - ); - - const modifiers = useMemo(() => [restrictToHorizontalAxis], []); - return ( - -
-
- {stickyBandPrelude} -
- - - -
+
+
+ {stickyBandPrelude} +
+
+
{pageId && }
-
- +
); } diff --git a/apps/client/src/features/base/components/grid/grid-header-cell.tsx b/apps/client/src/features/base/components/grid/grid-header-cell.tsx index d800b1cf5..445987c2c 100644 --- a/apps/client/src/features/base/components/grid/grid-header-cell.tsx +++ b/apps/client/src/features/base/components/grid/grid-header-cell.tsx @@ -1,11 +1,27 @@ -import { memo, useCallback, useEffect, useRef } from "react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; import { Header, flexRender } from "@tanstack/react-table"; -import { useSortable } from "@dnd-kit/sortable"; -import { CSS } from "@dnd-kit/utilities"; import { Popover } from "@mantine/core"; import { useAtom } from "jotai"; +import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; +import { + draggable, + dropTargetForElements, +} from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { + attachClosestEdge, + extractClosestEdge, + type Edge, +} from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge"; +import { getReorderDestinationIndex } from "@atlaskit/pragmatic-drag-and-drop-hitbox/util/get-reorder-destination-index"; +import { triggerPostMoveFlash } from "@atlaskit/pragmatic-drag-and-drop-flourish/trigger-post-move-flash"; +import * as liveRegion from "@atlaskit/pragmatic-drag-and-drop-live-region"; import { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types"; -import { activePropertyMenuAtomFamily, propertyMenuDirtyAtomFamily, propertyMenuCloseRequestAtomFamily, editingCellAtomFamily } from "@/features/base/atoms/base-atoms"; +import { + activePropertyMenuAtomFamily, + propertyMenuDirtyAtomFamily, + propertyMenuCloseRequestAtomFamily, + editingCellAtomFamily, +} from "@/features/base/atoms/base-atoms"; import { IconLetterT, IconHash, @@ -24,9 +40,12 @@ import { } from "@tabler/icons-react"; import { PropertyMenuContent } from "@/features/base/components/property/property-menu"; import { RowNumberHeaderCell } from "./row-number-header-cell"; +import { BaseDropEdgeIndicator } from "./base-drop-edge-indicator"; import { useRowSelection } from "@/features/base/hooks/use-row-selection"; import classes from "@/features/base/styles/grid.module.css"; +const COLUMN_DRAG_TYPE = "base-column"; + const typeIcons: Record = { text: IconLetterT, number: IconHash, @@ -49,6 +68,8 @@ type GridHeaderCellProps = { property: IBaseProperty | undefined; loadedRowIds: string[]; pageId: string; + getColumnOrder: () => string[]; + onColumnReorder?: (columnId: string, finishIndex: number) => void; }; export const GridHeaderCell = memo(function GridHeaderCell({ @@ -56,6 +77,8 @@ export const GridHeaderCell = memo(function GridHeaderCell({ property, loadedRowIds, pageId, + getColumnOrder, + onColumnReorder, }: GridHeaderCellProps) { const isRowNumber = header.column.id === "__row_number"; const isPinned = header.column.getIsPinned(); @@ -70,31 +93,62 @@ export const GridHeaderCell = memo(function GridHeaderCell({ const [closeRequest, setCloseRequest] = useAtom(propertyMenuCloseRequestAtomFamily(pageId)) as unknown as [number, (val: number) => void]; const [, setEditingCell] = useAtom(editingCellAtomFamily(pageId)) as unknown as [EditingCell, (val: EditingCell) => void]; + const [isDragging, setIsDragging] = useState(false); + const [closestEdge, setClosestEdge] = useState(null); + const handleDirtyChange = useCallback((dirty: boolean) => { setPropertyMenuDirty(dirty); }, [setPropertyMenuDirty]); const isSortableDisabled = isRowNumber || isPinned === "left"; - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ - id: header.column.id, - disabled: isSortableDisabled, - }); - - const combinedRef = useCallback( - (node: HTMLDivElement | null) => { - setNodeRef(node); - (cellRef as React.MutableRefObject).current = node; - }, - [setNodeRef], - ); + useEffect(() => { + const el = cellRef.current; + if (!el || isSortableDisabled) return; + return combine( + draggable({ + element: el, + getInitialData: () => ({ + type: COLUMN_DRAG_TYPE, + columnId: header.column.id, + }), + onDragStart: () => setIsDragging(true), + onDrop: () => setIsDragging(false), + }), + dropTargetForElements({ + element: el, + canDrop: ({ source }) => + source.data.type === COLUMN_DRAG_TYPE && + source.data.columnId !== header.column.id, + getData: ({ input, element }) => + attachClosestEdge( + { columnId: header.column.id }, + { input, element, allowedEdges: ["left", "right"] }, + ), + onDrag: ({ self }) => setClosestEdge(extractClosestEdge(self.data)), + onDragLeave: () => setClosestEdge(null), + onDrop: ({ source, self }) => { + setClosestEdge(null); + const edge = extractClosestEdge(self.data); + if (!edge) return; + const order = getColumnOrder(); + const startIndex = order.indexOf(source.data.columnId as string); + const indexOfTarget = order.indexOf(header.column.id); + if (startIndex === -1 || indexOfTarget === -1) return; + const finishIndex = getReorderDestinationIndex({ + startIndex, + indexOfTarget, + closestEdgeOfTarget: edge, + axis: "horizontal", + }); + if (finishIndex === startIndex) return; + onColumnReorder?.(source.data.columnId as string, finishIndex); + triggerPostMoveFlash(el); + liveRegion.announce(`Moved column to position ${finishIndex + 1}`); + }, + }), + ); + }, [header.column.id, isSortableDisabled, onColumnReorder, getColumnOrder]); const handleHeaderClick = useCallback(() => { setEditingCell(null); @@ -108,11 +162,6 @@ export const GridHeaderCell = memo(function GridHeaderCell({ setActivePropertyMenu(null); }, [setActivePropertyMenu]); - // Mantine's built-in `closeOnEscape` only fires when focus is inside the - // dropdown, but opening the property menu (clicking the header) leaves - // focus on the header itself. Mirror the click-outside path: when dirty, - // bump `propertyMenuCloseRequestAtomFamily` so property-menu shows its - // "Unsaved changes" confirmation panel; otherwise close directly. useEffect(() => { if (!menuOpened) return; const handler = (e: KeyboardEvent) => { @@ -129,33 +178,19 @@ export const GridHeaderCell = memo(function GridHeaderCell({ const TypeIcon = property ? typeIcons[property.type] : undefined; - const sortableStyle = transform - ? { - transform: CSS.Transform.toString({ - ...transform, - scaleX: 1, - scaleY: 1, - }), - transition, - opacity: isDragging ? 0.5 : 1, - zIndex: isDragging ? 10 : undefined, - } - : {}; - return (
{isRowNumber ? ( @@ -186,6 +221,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({ onClick={(e) => e.stopPropagation()} /> )} + {closestEdge && } {property && !isRowNumber && ( ; pageId: string; - // Passed explicitly to break memo when columns change - // (table ref is stable from useReactTable, so memo won't fire without these) columnOrder: ColumnOrderState; columnVisibility: VisibilityState; properties: IBaseProperty[]; loadedRowIds: string[]; onPropertyCreated?: () => void; + getColumnOrder: () => string[]; + onColumnReorder?: (columnId: string, finishIndex: number) => void; }; export const GridHeader = memo(function GridHeader({ @@ -27,6 +27,8 @@ export const GridHeader = memo(function GridHeader({ properties, loadedRowIds, onPropertyCreated, + getColumnOrder, + onColumnReorder, }: GridHeaderProps) { const headerGroups = table.getHeaderGroups(); const propertyById = useMemo(() => { @@ -44,6 +46,8 @@ export const GridHeader = memo(function GridHeader({ property={propertyById.get(header.column.id)} loadedRowIds={loadedRowIds} pageId={pageId} + getColumnOrder={getColumnOrder} + onColumnReorder={onColumnReorder} /> ))}