Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/app/components/tasks-table-columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
import * as React from "react";
import { toast } from "sonner";
import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header";
import { DataTableDragHandle } from "@/components/data-table/data-table-drag-handle";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
Expand All @@ -31,7 +32,6 @@ import { type Task, tasks } from "@/db/schema";
import { formatDate } from "@/lib/format";
import { getErrorMessage } from "@/lib/handle-error";
import type { DataTableRowAction } from "@/types/data-table";

import { updateTask } from "../lib/actions";
import { getPriorityIcon, getStatusIcon } from "../lib/utils";

Expand All @@ -51,6 +51,14 @@ export function getTasksTableColumns({
setRowAction,
}: GetTasksTableColumnsProps): ColumnDef<Task>[] {
return [
{
id: "drag",
header: () => null,
cell: () => <DataTableDragHandle label="Drag to reorder" />,
size: 36,
enableSorting: false,
enableHiding: false,
},
{
id: "select",
header: ({ table }) => (
Expand Down
25 changes: 24 additions & 1 deletion src/app/components/tasks-table.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import * as React from "react";
import { toast } from "sonner";
import { DataTable } from "@/components/data-table/data-table";
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar";
import { DataTableFilterList } from "@/components/data-table/data-table-filter-list";
Expand Down Expand Up @@ -44,6 +45,10 @@ export function TasksTable({ promises, queryKeys }: TasksTableProps) {
estimatedHoursRange,
] = React.use(promises);

const [orderOverride, setOrderOverride] = React.useState<Task[] | null>(null);

const tableData = orderOverride ?? data;

const [rowAction, setRowAction] =
React.useState<DataTableRowAction<Task> | null>(null);

Expand All @@ -59,7 +64,7 @@ export function TasksTable({ promises, queryKeys }: TasksTableProps) {
);

const { table, shallow, debounceMs, throttleMs } = useDataTable({
data,
data: tableData,
columns,
pageCount,
enableAdvancedFilter,
Expand All @@ -73,11 +78,29 @@ export function TasksTable({ promises, queryKeys }: TasksTableProps) {
clearOnDefault: true,
});

const onRowReorder = React.useCallback(
(orderedRowIds: string[]) => {
setOrderOverride((prev) => {
const source = prev ?? data;
const rowMap = new Map(source.map((row) => [String(row.id), row]));
const next = orderedRowIds
.map((id) => rowMap.get(id))
.filter((row): row is Task => row !== undefined);
if (next.length !== source.length) return prev;
return next;
});
toast.success("Rows reordered successfully");
},
[data],
Comment on lines +83 to +94
);

return (
<>
<DataTable
table={table}
actionBar={<TasksTableActionBar table={table} />}
enableRowDragAndDrop={true}
onRowReorder={onRowReorder}
>
{enableAdvancedFilter ? (
<DataTableAdvancedToolbar table={table}>
Expand Down
87 changes: 87 additions & 0 deletions src/components/data-table/data-table-drag-handle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"use client";

import type {
DraggableAttributes,
DraggableSyntheticListeners,
} from "@dnd-kit/core";
import { GripVertical } from "lucide-react";
import * as React from "react";

import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

export type DataTableDragHandleProps = Omit<
React.ComponentProps<typeof Button>,
"onClick"
> & {
label?: string;
};

/**
* Internal context provided by `<DataTable>` to each sortable row.
*
* Keeps the drag handle decoupled from the row implementation: the consumer
* just renders `<DataTableDragHandle />` inside any cell and the listeners
* are wired automatically.
*/
interface RowDragHandleContextValue {
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
isDragging: boolean;
/** Disabled when the row is not part of the sortable context. */
enabled: boolean;
}

const RowDragHandleContext =
React.createContext<RowDragHandleContextValue | null>(null);

/**
* Renders a drag handle bound to the surrounding sortable row.
*
* Falls back to a static visual placeholder when used outside a DnD-enabled
* `<DataTable>` (e.g. during SSR / first paint). This keeps the cell layout
* stable across hydration — `@dnd-kit` only registers listeners on the client.
*/
function DataTableDragHandle({
className,
label = "Drag to reorder",
children,
...props
}: DataTableDragHandleProps) {
const ctx = React.useContext(RowDragHandleContext);

if (!ctx?.enabled) {
return (
<span
aria-hidden
className={cn(
"inline-flex size-7 items-center justify-center text-muted-foreground/40",
className,
)}
>
{children ?? <GripVertical className="size-4" />}
</span>
);
}

return (
<Button
type="button"
variant="ghost"
size="icon"
aria-label={label}
data-dragging={ctx.isDragging || undefined}
className={cn(
"size-7 cursor-grab text-muted-foreground hover:bg-muted/60 active:cursor-grabbing data-dragging:cursor-grabbing",
className,
)}
{...ctx.attributes}
{...ctx.listeners}
{...props}
>
{children ?? <GripVertical className="size-4" />}
</Button>
);
}

export { DataTableDragHandle, RowDragHandleContext };
54 changes: 54 additions & 0 deletions src/components/data-table/data-table-sortable-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { flexRender, type Row } from "@tanstack/react-table";
import { useMemo } from "react";
import { getColumnPinningStyle } from "@/lib/data-table";
import { TableCell, TableRow } from "../ui/table";
import { RowDragHandleContext } from "./data-table-drag-handle";

export function SortableRow<TData>({ row }: { row: Row<TData> }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: row.id,
});

const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.6 : undefined,
position: "relative",
zIndex: isDragging ? 1 : undefined,
};

const dragCtx = useMemo(
() => ({ attributes, listeners, isDragging, enabled: true }),
[attributes, listeners, isDragging],
);

return (
<RowDragHandleContext.Provider value={dragCtx}>
<TableRow
ref={setNodeRef}
data-state={row.getIsSelected() && "selected"}
data-dragging={isDragging || undefined}
style={style}
className="data-dragging:bg-muted/40"
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{ ...getColumnPinningStyle({ column: cell.column }) }}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
</RowDragHandleContext.Provider>
);
}
Loading
Loading