chore: use dndkit for table editor columns sorting (#44617)

## Problem

We currently have 3 different libraries for drag & drop, two of which
are not actively maintained anymore.

## Solution

Migrate all usage of the two unmaintained libraries to DndKit.
This PR focuses on using DndKit instead of `react-beautiful-dnd` for
table editor columns sorting

## Screencast


https://github.com/user-attachments/assets/7c0bd298-4115-4c41-9dac-644c546d2c80

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Improvements**
* Redesigned column drag handle with clearer grab affordance, hover and
disabled states.
* More reliable column reordering — drag-and-drop behaves consistently,
including edge cases when moving columns.
* Smoother drag interactions and improved visual feedback while moving
columns.

* **Chores**
* Replaced legacy drag-and-drop implementation with a streamlined,
modern reorder system.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Gildas Garcia authored and GitHub committed 2026-04-07 14:50:28 +02:00
1 parent f4ba8dd403
commit 8923d52511
4 files changed
+116 -183

No files matched your search

@@ -1,6 +1,7 @@
import { Link, Menu, Plus, Settings, X } from 'lucide-react'
import { useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { GripVertical, Link, Plus, Settings, X } from 'lucide-react'
import { useState } from 'react'
import { DraggableProvidedDragHandleProps } from 'react-beautiful-dnd'
import {
Badge,
Button,
@@ -56,7 +57,6 @@ interface ColumnProps {
isNewRecord: boolean
hasForeignKeys: boolean
hasImportContent: boolean
dragHandleProps?: DraggableProvidedDragHandleProps | null
onUpdateColumn: (changes: Partial<ColumnField>) => void
onRemoveColumn: () => void
onEditForeignKey: (relation?: ForeignKey) => void
@@ -69,7 +69,6 @@ export const Column = ({
isNewRecord = false,
hasForeignKeys = false,
hasImportContent = false,
dragHandleProps,
onUpdateColumn,
onRemoveColumn,
onEditForeignKey,
@@ -108,12 +107,28 @@ export const Column = ({
.map((r) => getRelationStatus(r))
.some((x) => x !== undefined)
const { attributes, listeners, setNodeRef, setActivatorNodeRef, transform, transition } =
useSortable({
id: column.id,
})
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div className="flex w-full items-center">
<div className="flex w-full items-center" ref={setNodeRef} style={style}>
<div className={`w-[5%] ${!isNewRecord ? 'hidden' : ''}`}>
<div className="cursor-drag" {...dragHandleProps}>
<Menu strokeWidth={1} size={16} />
</div>
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="opacity-50 hover:opacity-100 disabled:hover:opacity-50 transition cursor-grab text-foreground"
type="button"
>
<GripVertical size={16} strokeWidth={1.5} />
</button>
</div>
<div className="w-[25%]">
<div className="flex w-[95%] items-center justify-between">
@@ -1,15 +1,22 @@
import {
closestCenter,
DndContext,
DragEndEvent,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core'
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable'
import { useParams } from 'common'
import { isEmpty, noop, partition } from 'lodash'
import { Edit, ExternalLink, HelpCircle, Key, Trash } from 'lucide-react'
import { useState } from 'react'
import {
DragDropContext,
Draggable,
Droppable,
type DraggableProvided,
type DroppableProvided,
type DropResult,
} from 'react-beautiful-dnd'
import {
Alert_Shadcn_,
AlertDescription_Shadcn_,
@@ -115,29 +122,38 @@ export const ColumnManagement = ({
onColumnsUpdated(updatedColumns)
}
const onSortColumns = (result: DropResult, type: 'pks' | 'others') => {
const onSortColumns = (result: DragEndEvent, type: 'pks' | 'others') => {
// Dropped outside of the list
if (!result.destination) {
if (result.over == null) {
return
}
if (type === 'pks') {
const updatedPrimaryKeyColumns = primaryKeyColumns.slice()
const [removed] = updatedPrimaryKeyColumns.splice(result.source.index, 1)
updatedPrimaryKeyColumns.splice(result.destination.index, 0, removed)
const activeIndex = primaryKeyColumns.findIndex((item) => item.id === result.active.id)
const overIndex = primaryKeyColumns.findIndex((item) => item.id === result.over!.id)
if (activeIndex === -1 || overIndex === -1) return
const updatedPrimaryKeyColumns = arrayMove(primaryKeyColumns, activeIndex, overIndex)
const updatedColumns = updatedPrimaryKeyColumns.concat(otherColumns)
return onColumnsUpdated(updatedColumns)
}
if (type === 'others') {
const updatedOtherColumns = otherColumns.slice()
const [removed] = updatedOtherColumns.splice(result.source.index, 1)
updatedOtherColumns.splice(result.destination.index, 0, removed)
const activeIndex = otherColumns.findIndex((item) => item.id === result.active.id)
const overIndex = otherColumns.findIndex((item) => item.id === result.over!.id)
if (activeIndex === -1 || overIndex === -1) return
const updatedOtherColumns = arrayMove(otherColumns, activeIndex, overIndex)
const updatedColumns = primaryKeyColumns.concat(updatedOtherColumns)
return onColumnsUpdated(updatedColumns)
}
}
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
)
return (
<>
<div className="w-full space-y-4 table-editor-columns">
@@ -259,89 +275,71 @@ export const ColumnManagement = ({
</div>
{primaryKeyColumns.length > 0 && (
<DragDropContext onDragEnd={(result) => onSortColumns(result, 'pks')}>
<Droppable droppableId="pk_columns_droppable">
{(droppableProvided: DroppableProvided) => (
<div
ref={droppableProvided.innerRef}
className={`space-y-2 rounded-md bg-surface-200 px-3 py-2 ${
isNewRecord ? '' : '-mx-3'
}`}
>
{primaryKeyColumns.map((column: ColumnField, index: number) => (
<Draggable key={column.id} draggableId={column.id} index={index}>
{(draggableProvided: DraggableProvided) => (
<div
ref={draggableProvided.innerRef}
{...draggableProvided.draggableProps}
>
<Column
column={column}
relations={relations.filter((relation) => {
return relation.columns.some((x) => x.source === column.name)
})}
enumTypes={enumTypes}
hasForeignKeys={checkIfHaveForeignKeys(column)}
isNewRecord={isNewRecord}
hasImportContent={hasImportContent}
dragHandleProps={draggableProvided.dragHandleProps}
onUpdateColumn={(changes) => onUpdateColumn(column, changes)}
onRemoveColumn={() => onRemoveColumn(column)}
onEditForeignKey={(fk) => {
setOpen(true)
setSelectedColumn(column)
if (fk) setSelectedFk(fk)
}}
/>
</div>
)}
</Draggable>
))}
{droppableProvided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
<DragDropContext onDragEnd={(result) => onSortColumns(result, 'others')}>
<Droppable droppableId="other_columns_droppable">
{(droppableProvided: DroppableProvided) => (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(result) => onSortColumns(result, 'pks')}
>
<SortableContext items={primaryKeyColumns} strategy={verticalListSortingStrategy}>
<div
ref={droppableProvided.innerRef}
className={`space-y-2 py-2 ${isNewRecord ? 'px-3 ' : ''}`}
className={`space-y-2 rounded-md bg-surface-200 px-3 py-2 ${
isNewRecord ? '' : '-mx-3'
}`}
>
{otherColumns.map((column: ColumnField, index: number) => (
<Draggable key={column.id} draggableId={column.id} index={index}>
{(draggableProvided: DraggableProvided) => (
<div ref={draggableProvided.innerRef} {...draggableProvided.draggableProps}>
<Column
column={column}
relations={relations.filter((relation) => {
return relation.columns.some((x) => x.source === column.name)
})}
enumTypes={enumTypes}
isNewRecord={isNewRecord}
hasForeignKeys={checkIfHaveForeignKeys(column)}
hasImportContent={hasImportContent}
dragHandleProps={draggableProvided.dragHandleProps}
onUpdateColumn={(changes) => onUpdateColumn(column, changes)}
onRemoveColumn={() => onRemoveColumn(column)}
onEditForeignKey={(fk) => {
setOpen(true)
setSelectedColumn(column)
if (fk) setSelectedFk(fk)
}}
/>
</div>
)}
</Draggable>
{primaryKeyColumns.map((column: ColumnField, index: number) => (
<Column
key={column.id}
column={column}
relations={relations.filter((relation) => {
return relation.columns.some((x) => x.source === column.name)
})}
enumTypes={enumTypes}
hasForeignKeys={checkIfHaveForeignKeys(column)}
isNewRecord={isNewRecord}
hasImportContent={hasImportContent}
onUpdateColumn={(changes) => onUpdateColumn(column, changes)}
onRemoveColumn={() => onRemoveColumn(column)}
onEditForeignKey={(fk) => {
setOpen(true)
setSelectedColumn(column)
if (fk) setSelectedFk(fk)
}}
/>
))}
{droppableProvided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
</SortableContext>
</DndContext>
)}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={(result) => onSortColumns(result, 'others')}
>
<SortableContext items={otherColumns} strategy={verticalListSortingStrategy}>
<div className={`space-y-2 py-2 ${isNewRecord ? 'px-3 ' : ''}`}>
{otherColumns.map((column: ColumnField, index: number) => (
<Column
key={column.id}
column={column}
relations={relations.filter((relation) => {
return relation.columns.some((x) => x.source === column.name)
})}
enumTypes={enumTypes}
isNewRecord={isNewRecord}
hasForeignKeys={checkIfHaveForeignKeys(column)}
hasImportContent={hasImportContent}
onUpdateColumn={(changes) => onUpdateColumn(column, changes)}
onRemoveColumn={() => onRemoveColumn(column)}
onEditForeignKey={(fk) => {
setOpen(true)
setSelectedColumn(column)
if (fk) setSelectedFk(fk)
}}
/>
))}
</div>
</SortableContext>
</DndContext>
</div>
{!hasImportContent && (
-1
View File
@@ -115,7 +115,6 @@
"pg-minify": "^1.6.3",
"randombytes": "^2.1.0",
"react": "catalog:",
"react-beautiful-dnd": "^13.1.0",
"react-data-grid": "7.0.0-beta.47",
"react-day-picker": "^9.11.1",
"react-dnd": "^16.0.1",
-79
View File
@@ -1096,9 +1096,6 @@ importers:
react:
specifier: 'catalog:'
version: 18.3.1
react-beautiful-dnd:
specifier: ^13.1.0
version: 13.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-data-grid:
specifier: 7.0.0-beta.47
version: 7.0.0-beta.47(patch_hash=803f02d6cede565990ab9675326b93195b98bcc6477a4811715405892c257488)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -9413,9 +9410,6 @@ packages:
'@types/react-grid-layout@1.3.3':
resolution: {integrity: sha512-VLgUoEN0PaAL3GeRsmYbHuoN0tpDNoRhlkdzHK4Ll8shHW6e16YpNCZWb7P9vrlZdzFtY75cM/hPr8p0ukZQCw==}
'@types/react-redux@7.1.27':
resolution: {integrity: sha512-xj7d9z32p1K/eBmO+OEy+qfaWXtcPlN8f1Xk3Ne0p/ZRQ867RI5bQ/bpBtxbqU1AHNhKJSgGvld/P2myU2uYkg==}
'@types/react-simple-maps@3.0.4':
resolution: {integrity: sha512-U9qnX0wVhxldrTpsase44fIoLpyO1OT/hgNMRoJTixj1qjpMRdSRIfih93mR3D/Tss/8CmM7dPwKMjtaGkDpmw==}
@@ -11000,9 +10994,6 @@ packages:
css-background-parser@0.1.0:
resolution: {integrity: sha512-2EZLisiZQ+7m4wwur/qiYJRniHX4K5Tc9w93MT3AS0WS1u5kaZ4FKXlOTBhOjc+CgEgPiGY+fX1yWD8UwpEqUA==}
css-box-model@1.2.1:
resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==}
css-box-shadow@1.0.0-3:
resolution: {integrity: sha512-9jaqR6e7Ohds+aWwmhe6wILJ99xYQbfmK9QQB9CcMjDbTxPZjwEmUQpU91OG05Xgm8BahT5fW+svbsQGjS/zPg==}
@@ -16178,9 +16169,6 @@ packages:
radix3@1.1.2:
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
raf-schd@4.0.3:
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
railroad-diagrams@1.0.0:
resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==}
@@ -16231,13 +16219,6 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-beautiful-dnd@13.1.1:
resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==}
deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672'
peerDependencies:
react: ^16.8.5 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0
react-compiler-runtime@19.1.0-rc.1:
resolution: {integrity: sha512-wCt6g+cRh8g32QT18/9blfQHywGjYu+4FlEc3CW1mx3pPxYzZZl1y+VtqxRgnKKBCFLIGUYxog4j4rs5YS86hw==}
peerDependencies:
@@ -16386,18 +16367,6 @@ packages:
react: ^16.8.0 || ^17 || ^18
react-dom: ^16.8.0 || ^17 || ^18
react-redux@7.2.9:
resolution: {integrity: sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==}
peerDependencies:
react: ^16.8.3 || ^17 || ^18
react-dom: '*'
react-native: '*'
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
react-refresh@0.14.2:
resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==}
engines: {node: '>=0.10.0'}
@@ -18415,11 +18384,6 @@ packages:
peerDependencies:
react: '>=16.8.0'
use-memo-one@1.1.3:
resolution: {integrity: sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
use-sidecar@1.1.3:
resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==}
engines: {node: '>=10'}
@@ -27235,13 +27199,6 @@ snapshots:
dependencies:
'@types/react': 18.3.3
'@types/react-redux@7.1.27':
dependencies:
'@types/hoist-non-react-statics': 3.3.2
'@types/react': 18.3.3
hoist-non-react-statics: 3.3.2
redux: 4.2.1
'@types/react-simple-maps@3.0.4':
dependencies:
'@types/d3-geo': 2.0.7
@@ -29176,10 +29133,6 @@ snapshots:
css-background-parser@0.1.0: {}
css-box-model@1.2.1:
dependencies:
tiny-invariant: 1.3.3
css-box-shadow@1.0.0-3: {}
css-color-keywords@1.0.0: {}
@@ -35473,8 +35426,6 @@ snapshots:
radix3@1.1.2: {}
raf-schd@4.0.3: {}
railroad-diagrams@1.0.0: {}
randexp@0.4.6:
@@ -35536,20 +35487,6 @@ snapshots:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-beautiful-dnd@13.1.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.26.10
css-box-model: 1.2.1
memoize-one: 5.2.1
raf-schd: 4.0.3
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
react-redux: 7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
redux: 4.2.1
use-memo-one: 1.1.3(react@18.3.1)
transitivePeerDependencies:
- react-native
react-compiler-runtime@19.1.0-rc.1(react@18.3.1):
dependencies:
react: 18.3.1
@@ -35735,18 +35672,6 @@ snapshots:
react-fast-compare: 3.2.2
warning: 4.0.3
react-redux@7.2.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.26.10
'@types/react-redux': 7.1.27
hoist-non-react-statics: 3.3.2
loose-envify: 1.4.0
prop-types: 15.8.1
react: 18.3.1
react-is: 17.0.2
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
react-refresh@0.14.2: {}
react-remove-scroll-bar@2.3.8(@types/react@18.3.3)(react@18.3.1):
@@ -38214,10 +38139,6 @@ snapshots:
dependencies:
react: 18.3.1
use-memo-one@1.1.3(react@18.3.1):
dependencies:
react: 18.3.1
use-sidecar@1.1.3(@types/react@18.3.3)(react@18.3.1):
dependencies:
detect-node-es: 1.1.0