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 { useAtom } from "jotai";
import { IconDatabase } from "@tabler/icons-react"; import { IconDatabase } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; 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 { generateJitteredKeyBetween } from "fractional-indexing-jittered";
import { useBaseQuery } from "@/features/base/queries/base-query"; import { useBaseQuery } from "@/features/base/queries/base-query";
import { useBaseSocket } from "@/features/base/hooks/use-base-socket"; import { useBaseSocket } from "@/features/base/hooks/use-base-socket";
@@ -207,14 +207,11 @@ export function BaseTable({ pageId, embedded }: BaseTableProps) {
}, [pageId, createViewMutation, t]); }, [pageId, createViewMutation, t]);
const handleColumnReorder = useCallback( const handleColumnReorder = useCallback(
(activeId: string, overId: string) => { (columnId: string, finishIndex: number) => {
const currentOrder = table.getState().columnOrder; const order = table.getState().columnOrder;
const oldIndex = currentOrder.indexOf(activeId); const startIndex = order.indexOf(columnId);
const newIndex = currentOrder.indexOf(overId); if (startIndex === -1 || startIndex === finishIndex) return;
if (oldIndex === -1 || newIndex === -1) return; table.setColumnOrder(reorder({ list: order, startIndex, finishIndex }));
const newOrder = arrayMove(currentOrder, oldIndex, newIndex);
table.setColumnOrder(newOrder);
persistViewConfig(); persistViewConfig();
}, },
[table, persistViewConfig], [table, persistViewConfig],
@@ -7,20 +7,6 @@ import {
windowScroll, windowScroll,
} from "@tanstack/react-virtual"; } from "@tanstack/react-virtual";
import { useAtom } from "jotai"; 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 { IBaseRow, IBaseProperty, EditingCell } from "@/features/base/types/base.types";
import { editingCellAtomFamily, activePropertyMenuAtomFamily, propertyMenuDirtyAtomFamily, propertyMenuCloseRequestAtomFamily } from "@/features/base/atoms/base-atoms"; import { editingCellAtomFamily, activePropertyMenuAtomFamily, propertyMenuDirtyAtomFamily, propertyMenuCloseRequestAtomFamily } from "@/features/base/atoms/base-atoms";
import { useColumnResize } from "@/features/base/hooks/use-column-resize"; import { useColumnResize } from "@/features/base/hooks/use-column-resize";
@@ -54,7 +40,7 @@ type GridContainerProps = {
onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void; onCellUpdate: (rowId: string, propertyId: string, value: unknown) => void;
onAddRow?: () => void; onAddRow?: () => void;
pageId: string; pageId: string;
onColumnReorder?: (columnId: string, overColumnId: string) => void; onColumnReorder?: (columnId: string, finishIndex: number) => void;
onResizeEnd?: () => void; onResizeEnd?: () => void;
onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void; onRowReorder?: (rowId: string, targetRowId: string, position: "above" | "below") => void;
hasNextPage?: boolean; hasNextPage?: boolean;
@@ -314,63 +300,34 @@ export function GridContainer({
}); });
}, []); }, []);
const sensors = useSensors( const getColumnOrder = useCallback(
useSensor(PointerSensor, { () => table.getState().columnOrder,
activationConstraint: { distance: 8 }, [table],
}),
useSensor(KeyboardSensor),
); );
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 ( return (
<DndContext <div role="grid">
sensors={sensors} <div className={classes.stickyBand}>
collisionDetection={closestCenter} {stickyBandPrelude}
onDragEnd={handleDragEnd} <div
modifiers={modifiers} className={classes.headerGrid}
> ref={headerRef}
<div role="grid"> style={{ gridTemplateColumns }}
<div className={classes.stickyBand}> role="row"
{stickyBandPrelude} >
<div <GridHeader
className={classes.headerGrid} table={table}
ref={headerRef} pageId={pageId}
style={{ gridTemplateColumns }} columnOrder={table.getState().columnOrder}
role="row" columnVisibility={table.getState().columnVisibility}
> properties={properties}
<SortableContext loadedRowIds={rowIds}
items={sortableColumnIds} onPropertyCreated={handlePropertyCreated}
strategy={horizontalListSortingStrategy} getColumnOrder={getColumnOrder}
> onColumnReorder={onColumnReorder}
<GridHeader />
table={table}
pageId={pageId}
columnOrder={table.getState().columnOrder}
columnVisibility={table.getState().columnVisibility}
properties={properties}
loadedRowIds={rowIds}
onPropertyCreated={handlePropertyCreated}
/>
</SortableContext>
</div>
</div> </div>
</div>
<div <div
className={classes.bodyGrid} className={classes.bodyGrid}
ref={bodyRef} ref={bodyRef}
@@ -415,7 +372,6 @@ export function GridContainer({
<AddRowButton onClick={handleAddRow} /> <AddRowButton onClick={handleAddRow} />
{pageId && <SelectionActionBar pageId={pageId} />} {pageId && <SelectionActionBar pageId={pageId} />}
</div> </div>
</div> </div>
</DndContext>
); );
} }
@@ -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 { Header, flexRender } from "@tanstack/react-table";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Popover } from "@mantine/core"; import { Popover } from "@mantine/core";
import { useAtom } from "jotai"; 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 { 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 { import {
IconLetterT, IconLetterT,
IconHash, IconHash,
@@ -24,9 +40,12 @@ import {
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { PropertyMenuContent } from "@/features/base/components/property/property-menu"; import { PropertyMenuContent } from "@/features/base/components/property/property-menu";
import { RowNumberHeaderCell } from "./row-number-header-cell"; import { RowNumberHeaderCell } from "./row-number-header-cell";
import { BaseDropEdgeIndicator } from "./base-drop-edge-indicator";
import { useRowSelection } from "@/features/base/hooks/use-row-selection"; import { useRowSelection } from "@/features/base/hooks/use-row-selection";
import classes from "@/features/base/styles/grid.module.css"; import classes from "@/features/base/styles/grid.module.css";
const COLUMN_DRAG_TYPE = "base-column";
const typeIcons: Record<string, typeof IconLetterT> = { const typeIcons: Record<string, typeof IconLetterT> = {
text: IconLetterT, text: IconLetterT,
number: IconHash, number: IconHash,
@@ -49,6 +68,8 @@ type GridHeaderCellProps = {
property: IBaseProperty | undefined; property: IBaseProperty | undefined;
loadedRowIds: string[]; loadedRowIds: string[];
pageId: string; pageId: string;
getColumnOrder: () => string[];
onColumnReorder?: (columnId: string, finishIndex: number) => void;
}; };
export const GridHeaderCell = memo(function GridHeaderCell({ export const GridHeaderCell = memo(function GridHeaderCell({
@@ -56,6 +77,8 @@ export const GridHeaderCell = memo(function GridHeaderCell({
property, property,
loadedRowIds, loadedRowIds,
pageId, pageId,
getColumnOrder,
onColumnReorder,
}: GridHeaderCellProps) { }: GridHeaderCellProps) {
const isRowNumber = header.column.id === "__row_number"; const isRowNumber = header.column.id === "__row_number";
const isPinned = header.column.getIsPinned(); 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 [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 [, 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) => { const handleDirtyChange = useCallback((dirty: boolean) => {
setPropertyMenuDirty(dirty); setPropertyMenuDirty(dirty);
}, [setPropertyMenuDirty]); }, [setPropertyMenuDirty]);
const isSortableDisabled = isRowNumber || isPinned === "left"; const isSortableDisabled = isRowNumber || isPinned === "left";
const { useEffect(() => {
attributes, const el = cellRef.current;
listeners, if (!el || isSortableDisabled) return;
setNodeRef, return combine(
transform, draggable({
transition, element: el,
isDragging, getInitialData: () => ({
} = useSortable({ type: COLUMN_DRAG_TYPE,
id: header.column.id, columnId: header.column.id,
disabled: isSortableDisabled, }),
}); onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
const combinedRef = useCallback( }),
(node: HTMLDivElement | null) => { dropTargetForElements({
setNodeRef(node); element: el,
(cellRef as React.MutableRefObject<HTMLDivElement | null>).current = node; canDrop: ({ source }) =>
}, source.data.type === COLUMN_DRAG_TYPE &&
[setNodeRef], 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(() => { const handleHeaderClick = useCallback(() => {
setEditingCell(null); setEditingCell(null);
@@ -108,11 +162,6 @@ export const GridHeaderCell = memo(function GridHeaderCell({
setActivePropertyMenu(null); setActivePropertyMenu(null);
}, [setActivePropertyMenu]); }, [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(() => { useEffect(() => {
if (!menuOpened) return; if (!menuOpened) return;
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@@ -129,33 +178,19 @@ export const GridHeaderCell = memo(function GridHeaderCell({
const TypeIcon = property ? typeIcons[property.type] : undefined; 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 ( return (
<div <div
ref={combinedRef} ref={cellRef}
className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`} className={`${classes.headerCell} ${isPinned ? classes.headerCellPinned : ""} ${hasSelection ? classes.hasSelection : ""}`}
style={{ style={{
...(isPinned ...(isPinned
? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties) ? ({ "--pin-offset": `${pinOffset}px` } as React.CSSProperties)
: {}), : {}),
...(isRowNumber ? {} : { cursor: "pointer" }), ...(isRowNumber ? {} : { cursor: "pointer" }),
...sortableStyle, opacity: isDragging ? 0.4 : 1,
}} }}
onClick={handleHeaderClick} onClick={handleHeaderClick}
{...(isSortableDisabled ? {} : attributes)} data-dragging={isDragging || undefined}
{...(isSortableDisabled ? {} : listeners)}
> >
{isRowNumber ? ( {isRowNumber ? (
<RowNumberHeaderCell loadedRowIds={loadedRowIds} pageId={pageId} /> <RowNumberHeaderCell loadedRowIds={loadedRowIds} pageId={pageId} />
@@ -186,6 +221,7 @@ export const GridHeaderCell = memo(function GridHeaderCell({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
)} )}
{closestEdge && <BaseDropEdgeIndicator edge={closestEdge} />}
{property && !isRowNumber && ( {property && !isRowNumber && (
<Popover <Popover
opened={menuOpened} opened={menuOpened}
@@ -8,13 +8,13 @@ import classes from "@/features/base/styles/grid.module.css";
type GridHeaderProps = { type GridHeaderProps = {
table: Table<IBaseRow>; table: Table<IBaseRow>;
pageId: string; 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; columnOrder: ColumnOrderState;
columnVisibility: VisibilityState; columnVisibility: VisibilityState;
properties: IBaseProperty[]; properties: IBaseProperty[];
loadedRowIds: string[]; loadedRowIds: string[];
onPropertyCreated?: () => void; onPropertyCreated?: () => void;
getColumnOrder: () => string[];
onColumnReorder?: (columnId: string, finishIndex: number) => void;
}; };
export const GridHeader = memo(function GridHeader({ export const GridHeader = memo(function GridHeader({
@@ -27,6 +27,8 @@ export const GridHeader = memo(function GridHeader({
properties, properties,
loadedRowIds, loadedRowIds,
onPropertyCreated, onPropertyCreated,
getColumnOrder,
onColumnReorder,
}: GridHeaderProps) { }: GridHeaderProps) {
const headerGroups = table.getHeaderGroups(); const headerGroups = table.getHeaderGroups();
const propertyById = useMemo(() => { const propertyById = useMemo(() => {
@@ -44,6 +46,8 @@ export const GridHeader = memo(function GridHeader({
property={propertyById.get(header.column.id)} property={propertyById.get(header.column.id)}
loadedRowIds={loadedRowIds} loadedRowIds={loadedRowIds}
pageId={pageId} pageId={pageId}
getColumnOrder={getColumnOrder}
onColumnReorder={onColumnReorder}
/> />
))} ))}
<CreatePropertyPopover <CreatePropertyPopover