✨ feat(table): Supports drag and drop sorting
This commit is contained in:
parent
0befdb0864
commit
2f56ef5eec
@ -1,11 +1,13 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ProTable as _ProTable, ProTableProps } from '@repo/ui/pro-table';
|
import { ProTable as _ProTable, ProTableProps } from '@repo/ui/pro-table';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
export { type ProTableActions } from '@repo/ui/pro-table';
|
export { type ProTableActions } from '@repo/ui/pro-table';
|
||||||
export function ProTable<TData, TValue extends Record<string, unknown>>(
|
|
||||||
props: ProTableProps<TData, TValue>,
|
export function ProTable<
|
||||||
) {
|
TData extends Record<string, unknown>,
|
||||||
|
TValue extends Record<string, unknown>,
|
||||||
|
>(props: ProTableProps<TData, TValue>) {
|
||||||
const t = useTranslations('common.table');
|
const t = useTranslations('common.table');
|
||||||
return (
|
return (
|
||||||
<_ProTable
|
<_ProTable
|
||||||
|
|||||||
@ -15,6 +15,9 @@
|
|||||||
"lint": "eslint . --max-warnings 0"
|
"lint": "eslint . --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@monaco-editor/react": "^4.6.0",
|
"@monaco-editor/react": "^4.6.0",
|
||||||
"@radix-ui/react-icons": "^1.3.2",
|
"@radix-ui/react-icons": "^1.3.2",
|
||||||
"@shadcn/ui": "workspace:*",
|
"@shadcn/ui": "workspace:*",
|
||||||
|
|||||||
@ -64,7 +64,6 @@ export function ColumnHeader<TData, TValue>({
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
|
||||||
<EyeNoneIcon className='text-muted-foreground/70 mr-2 h-3.5 w-3.5' />
|
<EyeNoneIcon className='text-muted-foreground/70 mr-2 h-3.5 w-3.5' />
|
||||||
Hide
|
|
||||||
{text?.hide || 'Hide'}
|
{text?.hide || 'Hide'}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -1,5 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import {
|
||||||
|
closestCenter,
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@shadcn/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@shadcn/ui/alert';
|
||||||
import { Button } from '@shadcn/ui/button';
|
import { Button } from '@shadcn/ui/button';
|
||||||
import { Checkbox } from '@shadcn/ui/checkbox';
|
import { Checkbox } from '@shadcn/ui/checkbox';
|
||||||
@ -17,13 +32,15 @@ import {
|
|||||||
VisibilityState,
|
VisibilityState,
|
||||||
} from '@tanstack/react-table';
|
} from '@tanstack/react-table';
|
||||||
import { useSize } from 'ahooks';
|
import { useSize } from 'ahooks';
|
||||||
import { ListRestart, Loader, RefreshCcw } from 'lucide-react';
|
import { GripVertical, ListRestart, Loader, RefreshCcw } from 'lucide-react';
|
||||||
import React, { Fragment, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
import React, { Fragment, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
import Empty from '../empty';
|
import Empty from '../empty';
|
||||||
import { ColumnFilter, IParams } from './column-filter';
|
import { ColumnFilter, IParams } from './column-filter';
|
||||||
import { ColumnHeader } from './column-header';
|
import { ColumnHeader } from './column-header';
|
||||||
import { ColumnToggle } from './column-toggle';
|
import { ColumnToggle } from './column-toggle';
|
||||||
import { Pagination } from './pagination';
|
import { Pagination } from './pagination';
|
||||||
|
import { SortableRow } from './sortable-row';
|
||||||
|
|
||||||
export interface ProTableProps<TData, TValue> {
|
export interface ProTableProps<TData, TValue> {
|
||||||
columns: ColumnDef<TData, TValue>[];
|
columns: ColumnDef<TData, TValue>[];
|
||||||
request: (
|
request: (
|
||||||
@ -53,13 +70,18 @@ export interface ProTableProps<TData, TValue> {
|
|||||||
selectedRowsText: (total: number) => string;
|
selectedRowsText: (total: number) => string;
|
||||||
}>;
|
}>;
|
||||||
empty?: React.ReactNode;
|
empty?: React.ReactNode;
|
||||||
|
onSort?: (newOrder: TData[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProTableActions {
|
export interface ProTableActions {
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProTable<TData, TValue extends Record<string, unknown>>({
|
export function ProTable<
|
||||||
|
TData extends Record<string, unknown> & { id?: string },
|
||||||
|
TValue extends Record<string, unknown>,
|
||||||
|
>({
|
||||||
columns,
|
columns,
|
||||||
request,
|
request,
|
||||||
params,
|
params,
|
||||||
@ -68,6 +90,7 @@ export function ProTable<TData, TValue extends Record<string, unknown>>({
|
|||||||
action,
|
action,
|
||||||
texts,
|
texts,
|
||||||
empty,
|
empty,
|
||||||
|
onSort,
|
||||||
}: ProTableProps<TData, TValue>) {
|
}: ProTableProps<TData, TValue>) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
@ -84,6 +107,18 @@ export function ProTable<TData, TValue extends Record<string, unknown>>({
|
|||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
columns: [
|
columns: [
|
||||||
|
...(onSort
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'sortable',
|
||||||
|
header: (
|
||||||
|
<GripVertical className='h-4 w-4 cursor-move text-gray-500 hover:text-gray-700' />
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
...(actions?.batchRender ? [createSelectColumn<TData, TValue>()] : []),
|
...(actions?.batchRender ? [createSelectColumn<TData, TValue>()] : []),
|
||||||
...columns,
|
...columns,
|
||||||
...(actions?.render
|
...(actions?.render
|
||||||
@ -103,9 +138,10 @@ export function ProTable<TData, TValue extends Record<string, unknown>>({
|
|||||||
},
|
},
|
||||||
] as ColumnDef<TData, TValue>[])
|
] as ColumnDef<TData, TValue>[])
|
||||||
: []),
|
: []),
|
||||||
],
|
] as ColumnDef<TData, TValue>[],
|
||||||
onPaginationChange: setPagination,
|
onPaginationChange: setPagination,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
|
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
@ -123,6 +159,7 @@ export function ProTable<TData, TValue extends Record<string, unknown>>({
|
|||||||
manualPagination: true,
|
manualPagination: true,
|
||||||
manualFiltering: true,
|
manualFiltering: true,
|
||||||
rowCount: rowCount,
|
rowCount: rowCount,
|
||||||
|
manualSorting: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
@ -163,6 +200,32 @@ export function ProTable<TData, TValue extends Record<string, unknown>>({
|
|||||||
fetchData();
|
fetchData();
|
||||||
}, [pagination.pageIndex, pagination.pageSize, columnFilters]);
|
}, [pagination.pageIndex, pagination.pageSize, columnFilters]);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (active?.id !== over?.id) {
|
||||||
|
setData((items) => {
|
||||||
|
const oldIndex = items.findIndex((item) => {
|
||||||
|
return String(item.id) === active?.id;
|
||||||
|
});
|
||||||
|
const newIndex = items.findIndex((item) => {
|
||||||
|
return String(item.id) === over?.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const newOrder = arrayMove(items, oldIndex, newIndex);
|
||||||
|
if (onSort) onSort(newOrder);
|
||||||
|
return newOrder;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const selectedRows = table.getSelectedRowModel().flatRows.map((row) => row.original);
|
const selectedRows = table.getSelectedRowModel().flatRows.map((row) => row.original);
|
||||||
const selectedCount = selectedRows.length;
|
const selectedCount = selectedRows.length;
|
||||||
|
|
||||||
@ -230,15 +293,50 @@ export function ProTable<TData, TValue extends Record<string, unknown>>({
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{table.getRowModel()?.rows?.length ? (
|
{table.getRowModel()?.rows?.length ? (
|
||||||
table.getRowModel().rows.map((row) => (
|
onSort ? (
|
||||||
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
<DndContext
|
||||||
{row.getVisibleCells().map((cell) => (
|
sensors={sensors}
|
||||||
<TableCell key={cell.id} className={getTableCellClass(cell.column.id)}>
|
collisionDetection={closestCenter}
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
onDragEnd={handleDragEnd}
|
||||||
</TableCell>
|
>
|
||||||
))}
|
<SortableContext
|
||||||
</TableRow>
|
items={table.getRowModel()?.rows.map((row) => {
|
||||||
))
|
return String(row.original.id);
|
||||||
|
})}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<SortableRow
|
||||||
|
key={row.original.id ? String(row.original.id) : String(row.index)}
|
||||||
|
id={row.original.id ? String(row.original.id) : String(row.index)}
|
||||||
|
data-state={row.getIsSelected() && 'selected'}
|
||||||
|
isSortable
|
||||||
|
>
|
||||||
|
{row
|
||||||
|
.getVisibleCells()
|
||||||
|
.filter((cell) => {
|
||||||
|
return cell.column.id !== 'sortable';
|
||||||
|
})
|
||||||
|
.map((cell) => (
|
||||||
|
<TableCell key={cell.id} className={getTableCellClass(cell.column.id)}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</SortableRow>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
) : (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id} className={getTableCellClass(cell.column.id)}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={columns.length + 2} className='py-24'>
|
<TableCell colSpan={columns.length + 2} className='py-24'>
|
||||||
|
|||||||
39
packages/ui/src/pro-table/sortable-row.tsx
Normal file
39
packages/ui/src/pro-table/sortable-row.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { TableCell, TableRow } from '@shadcn/ui/table';
|
||||||
|
import { GripVertical } from 'lucide-react';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface SortableRowProps {
|
||||||
|
id: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
isSortable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableRow({ id, children, isSortable }: SortableRowProps) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition } = useSortable({
|
||||||
|
id,
|
||||||
|
disabled: !isSortable,
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString({
|
||||||
|
x: 0,
|
||||||
|
y: transform?.y || 0,
|
||||||
|
scaleX: transform?.scaleX || 1,
|
||||||
|
scaleY: transform?.scaleY || 1,
|
||||||
|
}),
|
||||||
|
transition,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow ref={setNodeRef} style={style}>
|
||||||
|
{isSortable ? (
|
||||||
|
<TableCell className='cursor-move' {...listeners} {...attributes}>
|
||||||
|
<GripVertical className='h-4 w-4 cursor-move text-gray-500 hover:text-gray-700' />
|
||||||
|
</TableCell>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
10820
pnpm-lock.yaml
generated
10820
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user