Martin
Martin

Reputation: 1578

How to Implement Multi-Row Drag-and-Drop Reordering in a React TanStack Table Component?

I have a react Table.js component, which has the ability to select rows, and drag and drop reorder single rows:

https://codesandbox.io/p/sandbox/drag-and-drop-reorder-multiple-rows-at-once-forked-56pl7c?workspaceId=ws_F2QxELJDBKDDSAxdPEPbQt

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:

https://ux.stackexchange.com/questions/135393/the-best-way-to-select-multiple-rows-in-a-table-and-be-able-to-reorder-the-selec

So if a user selects multiple rows: enter image description here

They may drag and drop to reorder all the current selected rows with a special UI popup on the left of the table: enter image description here

Currently in my codesandbox.io project, the user can select multiple files, and always only reorder one row at a time: enter image description here

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

Answers (1)

quyentho
quyentho

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

Related Questions