Reputation: 1578
I have a react Table.js component, which has the ability to select rows, and drag and drop reorder single rows:
In some cases, my user may have hundreds of files, and wants to reorder a group of 20 rows, the current process of reordering each row manually is very tedious, I want to add the ability for a user to drag and drop multiple rows at once, re-ordering all of them.
I have found a good UX post with images on how this can be done, and I want to try and implement it in my React Table.js component:
So if a user selects multiple rows:
They may drag and drop to reorder all the current selected rows with a special UI popup on the left of the table:
Currently in my codesandbox.io project, the user can select multiple files, and always only reorder one row at a time:
I tried an attempt at implementing this here: https://codesandbox.io/p/sandbox/drag-and-drop-reorder-multiple-rows-at-once-forked-56pl7c?workspaceId=ws_F2QxELJDBKDDSAxdPEPbQt
But there is no ghost row UI indication of how many rows I am reordering, which makes the process not intuitive or clean:
//Table.js
// Table.js
import React, { useState } from "react";
import {
ColumnDef,
getCoreRowModel,
useReactTable,
flexRender,
getSortedRowModel,
getFilteredRowModel,
getPaginationRowModel,
} from "@tanstack/react-table";
import { DndContext, closestCenter } from "@dnd-kit/core";
import {
useSortable,
SortableContext,
verticalListSortingStrategy,
arrayMove,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import styles from "./Table.module.css";
// Indeterminate Checkbox Component
function IndeterminateCheckbox({ indeterminate, className = "", ...rest }) {
const ref = React.useRef(null);
React.useEffect(() => {
if (typeof indeterminate === "boolean") {
ref.current.indeterminate = indeterminate;
}
}, [indeterminate]);
return (
<input
type="checkbox"
ref={ref}
className={`${styles.checkbox} ${className}`}
{...rest}
/>
);
}
// Drag handle for rows
function DragHandle({ row }) {
const { attributes, listeners } = useSortable({ id: row.original.id });
return (
<button
{...attributes}
{...listeners}
className={styles.dragHandle}
title="Drag to reorder"
>
🟰
</button>
);
}
// Row Component
function Row({ row }) {
const { setNodeRef, transform, transition } = useSortable({
id: row.original.id,
});
const isSelected = row.getIsSelected();
const style = {
transform: CSS.Transform.toString(transform),
transition,
backgroundColor: isSelected ? "#e0f7fa" : "inherit", // Highlight selected rows
};
return (
<tr ref={setNodeRef} style={style} className={styles.row}>
{row.getVisibleCells().map((cell, index) => (
<td key={cell.id} className={styles.cell}>
{index === 1 ? <DragHandle row={row} /> : null}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
);
}
// Table Component
function Table({ data, setData, columns, rowSelection, setRowSelection }) {
const [globalFilter, setGlobalFilter] = useState("");
const [sorting, setSorting] = useState([]);
const tableColumns = React.useMemo(() => [
{
id: "select",
header: ({ table }) => (
<IndeterminateCheckbox
{...{
checked: table.getIsAllRowsSelected(),
indeterminate: table.getIsSomeRowsSelected(),
onChange: table.getToggleAllRowsSelectedHandler(),
}}
/>
),
cell: ({ row }) => (
<div className="px-1">
<IndeterminateCheckbox
{...{
checked: row.getIsSelected(),
disabled: !row.getCanSelect(),
indeterminate: row.getIsSomeSelected(),
onChange: row.getToggleSelectedHandler(),
}}
/>
</div>
),
},
{ accessorKey: "draggable", header: "Drag" },
{ accessorKey: "fileName", header: "File Name" },
{ accessorKey: "duration", header: "Duration" },
]);
const table = useReactTable({
data,
columns: tableColumns,
getRowId: (row) => row.id,
state: { rowSelection, globalFilter, sorting },
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
});
const handleDragEnd = (event) => {
const { active, over } = event;
if (active && over && active.id !== over.id) {
// Get the IDs of all selected rows
const selectedRowIds = Object.keys(rowSelection).filter(
(id) => rowSelection[id]
);
if (selectedRowIds.length > 0) {
// Find the indices of the selected rows
const selectedRows = data.filter((item) =>
selectedRowIds.includes(item.id)
);
const otherRows = data.filter(
(item) => !selectedRowIds.includes(item.id)
);
// Determine the target index in `otherRows` where the first selected row is dropped
const targetIndex = otherRows.findIndex((item) => item.id === over.id);
// Rebuild the data array with the selected rows inserted at the target index
const newData = [
...otherRows.slice(0, targetIndex),
...selectedRows,
...otherRows.slice(targetIndex),
];
setData(newData);
} else {
// Single row drag and drop
const oldIndex = data.findIndex((item) => item.id === active.id);
const newIndex = data.findIndex((item) => item.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newData = arrayMove([...data], oldIndex, newIndex);
setData(newData);
}
}
}
};
return (
<div>
<input
type="text"
value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)}
placeholder="Search..."
className={styles.search}
/>
<DndContext collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext
items={data.map((row) => row.id)}
strategy={verticalListSortingStrategy}
>
<table className={styles.table}>
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className={styles.headerRow}>
{headerGroup.headers.map((header) => (
<th
key={header.id}
className={styles.headerCell}
onClick={
header.column.getCanSort()
? () => header.column.toggleSorting()
: undefined
}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{header.column.getIsSorted() === "asc" ? " 🔼" : ""}
{header.column.getIsSorted() === "desc" ? " 🔽" : ""}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<Row key={row.original.id} row={row} />
))}
</tbody>
</table>
</SortableContext>
<div className={styles.pagination}>
<button
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</button>
<span>
Page {table.getState().pagination.pageIndex + 1} of{" "}
{table.getPageCount()}
</span>
<button
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</button>
</div>
</DndContext>
<div className={styles.footer}>
<span>
{Object.keys(rowSelection).length} of {data.length} rows selected
</span>
</div>
</div>
);
}
export default Table;
Upvotes: 1
Views: 258
Reputation: 653
You can use DragOverlay to display a preview of your component while draging. Here is the codesandbox
<DragOverlay>
{isDragging && selectedRows.length > 0
? selectedRows.map((row) => <Row key={row.original.id} row={row} />)
: null}
</DragOverlay>
Upvotes: 1