refactor(base): migrate column reorder from dnd-kit to pragmatic-drag-and-drop

This commit is contained in:
Philipinho
2026-05-24 02:24:54 +01:00
parent 9c124f8851
commit eeb84e97c9
4 changed files with 119 additions and 126 deletions
@@ -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],
@@ -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 (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
modifiers={modifiers}
>
<div role="grid">
<div className={classes.stickyBand}>
{stickyBandPrelude}
<div
className={classes.headerGrid}
ref={headerRef}
style={{ gridTemplateColumns }}
role="row"
>
<SortableContext
items={sortableColumnIds}
strategy={horizontalListSortingStrategy}
>
<GridHeader
table={table}
pageId={pageId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
/>
</SortableContext>
</div>
<div role="grid">
<div className={classes.stickyBand}>
{stickyBandPrelude}
<div
className={classes.headerGrid}
ref={headerRef}
style={{ gridTemplateColumns }}
role="row"
>
<GridHeader
table={table}
pageId={pageId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
getColumnOrder={getColumnOrder}
onColumnReorder={onColumnReorder}
/>
</div>
</div>
<div
className={classes.bodyGrid}
ref={bodyRef}
@@ -415,7 +372,6 @@ export function GridContainer({
<AddRowButton onClick={handleAddRow} />
{pageId && <SelectionActionBar pageId={pageId} />}
</div>
</div>
</DndContext>
</div>
);
}
@@ -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<string, typeof IconLetterT> = {
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<Edge | null>(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<HTMLDivElement | null>).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 (
<div
ref={combinedRef}
ref={cellRef}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{
...(isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: {}),
...(isRowNumber ? {} : { cursor: "pointer" }),
...sortableStyle,
opacity: isDragging ? 0.4 : 1,
}}
onClick={handleHeaderClick}
{...(isSortableDisabled ? {} : attributes)}
{...(isSortableDisabled ? {} : listeners)}
data-dragging={isDragging || undefined}
>
{isRowNumber ? (
<RowNumberHeaderCell loadedRowIds={loadedRowIds} pageId={pageId} />
@@ -186,6 +221,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({
onClick={(e) => e.stopPropagation()}
/>
)}
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
{property && !isRowNumber && (
<Popover
opened={menuOpened}
@@ -8,13 +8,13 @@ import classes from "@/features/base/styles/grid.module.css";
type GridHeaderProps = {
table: Table<IBaseRow>;
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}
/>
))}
<CreatePropertyPopover