mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-05 15:45:46 +00:00
Compare commits
9 Commits
main
...
table-fixe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a1260d84a | ||
|
|
6e68cb5077 | ||
|
|
594d6eca2f | ||
|
|
73498486cb | ||
|
|
951c7cb7c6 | ||
|
|
1297ab58cc | ||
|
|
caa67717e0 | ||
|
|
cb67b2f2a5 | ||
|
|
85312ad3b8 |
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
"use no memo";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
import useDataTable, {
|
||||
toOnyxSortDirection,
|
||||
@@ -24,6 +24,7 @@ import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibi
|
||||
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
|
||||
import type { WidthConfig } from "@/refresh-components/table/hooks/useColumnWidths";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
DataTableProps,
|
||||
DataTableFooterConfig,
|
||||
@@ -34,8 +35,6 @@ import type {
|
||||
} from "@/refresh-components/table/types";
|
||||
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
|
||||
|
||||
const noopGetRowId = () => "";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal: resolve size-dependent widths and build TanStack columns
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -121,15 +120,19 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
const {
|
||||
data,
|
||||
columns,
|
||||
getRowId,
|
||||
pageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
draggable,
|
||||
footer,
|
||||
size = "regular",
|
||||
onSelectionChange,
|
||||
onRowClick,
|
||||
searchTerm,
|
||||
height,
|
||||
headerBackground,
|
||||
serverSide,
|
||||
} = props;
|
||||
|
||||
const effectivePageSize = pageSize ?? (footer ? 10 : data.length);
|
||||
@@ -148,15 +151,30 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
pageSize: resolvedPageSize,
|
||||
selectionState,
|
||||
selectedCount,
|
||||
selectedRowIds,
|
||||
clearSelection,
|
||||
toggleAllPageRowsSelected,
|
||||
isAllPageRowsSelected,
|
||||
isViewingSelected,
|
||||
enterViewMode,
|
||||
exitViewMode,
|
||||
} = useDataTable({
|
||||
data,
|
||||
columns: tanstackColumns,
|
||||
pageSize: effectivePageSize,
|
||||
initialSorting,
|
||||
initialColumnVisibility,
|
||||
getRowId,
|
||||
onSelectionChange,
|
||||
searchTerm,
|
||||
serverSide: serverSide
|
||||
? {
|
||||
totalItems: serverSide.totalItems,
|
||||
onSortingChange: serverSide.onSortingChange,
|
||||
onPaginationChange: serverSide.onPaginationChange,
|
||||
onSearchTermChange: serverSide.onSearchTermChange,
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// 3. Call useColumnWidths
|
||||
@@ -165,15 +183,34 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
...widthConfig,
|
||||
});
|
||||
|
||||
// 4. Call useDraggableRows (conditional)
|
||||
// 4. Call useDraggableRows (conditional — disabled in server-side mode)
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production" && serverSide && draggable) {
|
||||
console.warn(
|
||||
"DataTable: `draggable` is ignored when `serverSide` is enabled. " +
|
||||
"Drag-and-drop reordering is not supported with server-side pagination."
|
||||
);
|
||||
}
|
||||
}, [!!serverSide, !!draggable]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const footerShowView =
|
||||
footer?.mode === "selection" ? footer.showView : undefined;
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV !== "production" && serverSide && footerShowView) {
|
||||
console.warn(
|
||||
"DataTable: `showView` is ignored when `serverSide` is enabled. " +
|
||||
"View mode requires client-side filtering."
|
||||
);
|
||||
}
|
||||
}, [!!serverSide, !!footerShowView]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const effectiveDraggable = serverSide ? undefined : draggable;
|
||||
const draggableReturn = useDraggableRows({
|
||||
data,
|
||||
getRowId: draggable?.getRowId ?? noopGetRowId,
|
||||
enabled: !!draggable && table.getState().sorting.length === 0,
|
||||
onReorder: draggable?.onReorder,
|
||||
getRowId,
|
||||
enabled: !!effectiveDraggable && table.getState().sorting.length === 0,
|
||||
onReorder: effectiveDraggable?.onReorder,
|
||||
});
|
||||
|
||||
const hasDraggable = !!draggable;
|
||||
const hasDraggable = !!effectiveDraggable;
|
||||
const rowVariant = hasDraggable ? "table" : "list";
|
||||
|
||||
const isSelectable =
|
||||
@@ -183,11 +220,71 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
// Render
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderContent() {
|
||||
const isServerLoading = !!serverSide?.isLoading;
|
||||
|
||||
function renderFooter(footerConfig: DataTableFooterConfig) {
|
||||
if (footerConfig.mode === "selection") {
|
||||
return (
|
||||
<Footer
|
||||
mode="selection"
|
||||
multiSelect={footerConfig.multiSelect !== false}
|
||||
selectionState={selectionState}
|
||||
selectedCount={selectedCount}
|
||||
onClear={
|
||||
footerConfig.onClear ??
|
||||
(() => {
|
||||
if (isViewingSelected) exitViewMode();
|
||||
clearSelection();
|
||||
})
|
||||
}
|
||||
onView={
|
||||
footerConfig.showView
|
||||
? isViewingSelected
|
||||
? exitViewMode
|
||||
: enterViewMode
|
||||
: undefined
|
||||
}
|
||||
pageSize={resolvedPageSize}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary mode
|
||||
const rangeStart =
|
||||
totalItems === 0
|
||||
? 0
|
||||
: !isFinite(resolvedPageSize)
|
||||
? 1
|
||||
: (currentPage - 1) * resolvedPageSize + 1;
|
||||
const rangeEnd = !isFinite(resolvedPageSize)
|
||||
? totalItems
|
||||
: Math.min(currentPage * resolvedPageSize, totalItems);
|
||||
|
||||
return (
|
||||
<Footer
|
||||
mode="summary"
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableSizeProvider size={size}>
|
||||
<div>
|
||||
<div
|
||||
className="overflow-x-auto"
|
||||
className={cn(
|
||||
"overflow-x-auto transition-opacity duration-150",
|
||||
isServerLoading && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
...(height != null
|
||||
@@ -315,19 +412,24 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
? (activeId) => {
|
||||
const row = table
|
||||
.getRowModel()
|
||||
.rows.find(
|
||||
(r) => draggable!.getRowId(r.original) === activeId
|
||||
);
|
||||
.rows.find((r) => getRowId(r.original) === activeId);
|
||||
if (!row) return null;
|
||||
return <DragOverlayRow row={row} variant={rowVariant} />;
|
||||
return (
|
||||
<DragOverlayRow
|
||||
row={row}
|
||||
variant={rowVariant}
|
||||
columnWidths={columnWidths}
|
||||
columnKindMap={columnKindMap}
|
||||
qualifierColumn={qualifierColumn}
|
||||
isSelectable={isSelectable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
const rowId = hasDraggable
|
||||
? draggable!.getRowId(row.original)
|
||||
: undefined;
|
||||
const rowId = hasDraggable ? getRowId(row.original) : undefined;
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
@@ -336,6 +438,12 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
sortableId={rowId}
|
||||
selected={row.getIsSelected()}
|
||||
onClick={() => {
|
||||
if (
|
||||
hasDraggable &&
|
||||
draggableReturn.wasDraggingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (onRowClick) {
|
||||
onRowClick(row.original);
|
||||
} else if (isSelectable) {
|
||||
@@ -377,7 +485,11 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
// Actions cell
|
||||
if (cellColDef?.kind === "actions") {
|
||||
return (
|
||||
<ActionsContainer key={cell.id} type="cell">
|
||||
<ActionsContainer
|
||||
key={cell.id}
|
||||
type="cell"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
@@ -405,51 +517,6 @@ export default function DataTable<TData>(props: DataTableProps<TData>) {
|
||||
|
||||
{footer && renderFooter(footer)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFooter(footerConfig: DataTableFooterConfig) {
|
||||
if (footerConfig.mode === "selection") {
|
||||
return (
|
||||
<Footer
|
||||
mode="selection"
|
||||
multiSelect={footerConfig.multiSelect !== false}
|
||||
selectionState={selectionState}
|
||||
selectedCount={selectedCount}
|
||||
onClear={footerConfig.onClear ?? clearSelection}
|
||||
onView={footerConfig.onView}
|
||||
pageSize={resolvedPageSize}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Summary mode
|
||||
const rangeStart =
|
||||
totalItems === 0
|
||||
? 0
|
||||
: !isFinite(resolvedPageSize)
|
||||
? 1
|
||||
: (currentPage - 1) * resolvedPageSize + 1;
|
||||
const rangeEnd = !isFinite(resolvedPageSize)
|
||||
? totalItems
|
||||
: Math.min(currentPage * resolvedPageSize, totalItems);
|
||||
|
||||
return (
|
||||
<Footer
|
||||
mode="summary"
|
||||
rangeStart={rangeStart}
|
||||
rangeEnd={rangeEnd}
|
||||
totalItems={totalItems}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setPage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <TableSizeProvider size={size}>{renderContent()}</TableSizeProvider>;
|
||||
</TableSizeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,28 +2,87 @@ import { memo } from "react";
|
||||
import { type Row, flexRender } from "@tanstack/react-table";
|
||||
import TableRow from "@/refresh-components/table/TableRow";
|
||||
import TableCell from "@/refresh-components/table/TableCell";
|
||||
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
|
||||
import TableQualifier from "@/refresh-components/table/TableQualifier";
|
||||
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
|
||||
import type {
|
||||
OnyxColumnDef,
|
||||
OnyxQualifierColumn,
|
||||
} from "@/refresh-components/table/types";
|
||||
|
||||
interface DragOverlayRowProps<TData> {
|
||||
row: Row<TData>;
|
||||
variant?: "table" | "list";
|
||||
columnWidths?: Record<string, number>;
|
||||
columnKindMap?: Map<string, OnyxColumnDef<TData>>;
|
||||
qualifierColumn?: OnyxQualifierColumn<TData> | null;
|
||||
isSelectable?: boolean;
|
||||
}
|
||||
|
||||
function DragOverlayRowInner<TData>({
|
||||
row,
|
||||
variant,
|
||||
columnWidths,
|
||||
columnKindMap,
|
||||
qualifierColumn,
|
||||
isSelectable = false,
|
||||
}: DragOverlayRowProps<TData>) {
|
||||
const tableWidth = columnWidths
|
||||
? Object.values(columnWidths).reduce((sum, w) => sum + w, 0)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<table
|
||||
className="min-w-full border-collapse"
|
||||
style={{ tableLayout: "fixed" }}
|
||||
className="border-collapse"
|
||||
style={{
|
||||
tableLayout: "fixed",
|
||||
...(tableWidth != null ? { width: tableWidth } : { minWidth: "100%" }),
|
||||
}}
|
||||
>
|
||||
{columnWidths && (
|
||||
<colgroup>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<col
|
||||
key={cell.column.id}
|
||||
style={{ width: columnWidths[cell.column.id] }}
|
||||
/>
|
||||
))}
|
||||
</colgroup>
|
||||
)}
|
||||
<tbody>
|
||||
<TableRow variant={variant} selected={row.getIsSelected()}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} width={cell.column.getSize()}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const colDef = columnKindMap?.get(cell.column.id);
|
||||
|
||||
if (colDef?.kind === "qualifier" && qualifierColumn) {
|
||||
return (
|
||||
<QualifierContainer key={cell.id} type="cell">
|
||||
<TableQualifier
|
||||
content={qualifierColumn.content}
|
||||
initials={qualifierColumn.getInitials?.(row.original)}
|
||||
icon={qualifierColumn.getIcon?.(row.original)}
|
||||
imageSrc={qualifierColumn.getImageSrc?.(row.original)}
|
||||
selectable={isSelectable}
|
||||
selected={isSelectable && row.getIsSelected()}
|
||||
/>
|
||||
</QualifierContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (colDef?.kind === "actions") {
|
||||
return (
|
||||
<ActionsContainer key={cell.id} type="cell">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</ActionsContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -117,6 +117,7 @@ Fixed-width column rendered at the trailing edge. Houses column visibility and s
|
||||
| `showColumnVisibility` | `boolean` | `true` | Show the column visibility popover |
|
||||
| `showSorting` | `boolean` | `true` | Show the sorting popover |
|
||||
| `sortingFooterText` | `string` | - | Footer text inside the sorting popover |
|
||||
| `cell` | `(row: TData) => ReactNode` | - | Row-level cell renderer for action buttons |
|
||||
|
||||
Width is fixed: 88px at `"regular"`, 20px at `"small"`.
|
||||
|
||||
@@ -126,6 +127,19 @@ tc.actions({
|
||||
})
|
||||
```
|
||||
|
||||
Row-level actions — the `cell` callback receives the row data and renders content in each body row. Clicks inside the cell automatically call `stopPropagation`, so they won't trigger row selection.
|
||||
|
||||
```tsx
|
||||
tc.actions({
|
||||
cell: (row) => (
|
||||
<div className="flex gap-x-1">
|
||||
<IconButton icon={SvgPencil} onClick={() => openEdit(row.id)} />
|
||||
<IconButton icon={SvgTrash} onClick={() => confirmDelete(row.id)} />
|
||||
</div>
|
||||
),
|
||||
})
|
||||
```
|
||||
|
||||
## DataTable Props
|
||||
|
||||
`DataTableProps<TData>`:
|
||||
@@ -143,6 +157,8 @@ tc.actions({
|
||||
| `onRowClick` | `(row: TData) => void` | toggles selection | Called on row click, replaces default selection toggle |
|
||||
| `height` | `number \| string` | - | Max height for scrollable body (header stays pinned). `300` or `"50vh"` |
|
||||
| `headerBackground` | `string` | - | CSS color for the sticky header (prevents content showing through) |
|
||||
| `searchTerm` | `string` | - | Search term for client-side global text filtering (case-insensitive match across all accessor columns) |
|
||||
| `serverSide` | `ServerSideConfig` | - | Enable server-side mode for manual pagination, sorting, and filtering ([see below](#server-side-mode)) |
|
||||
|
||||
## Footer Config
|
||||
|
||||
@@ -193,6 +209,110 @@ Enable drag-and-drop row reordering. DnD is automatically disabled when column s
|
||||
| `getRowId` | `(row: TData) => string` | Extract a unique string ID from each row |
|
||||
| `onReorder` | `(ids: string[], changedOrders: Record<string, number>) => void \| Promise<void>` | Called after a successful reorder |
|
||||
|
||||
## Server-Side Mode
|
||||
|
||||
Pass the `serverSide` prop to switch from client-side to server-side pagination, sorting, and filtering. In this mode `data` should contain **only the current page slice** — TanStack operates with `manualPagination`, `manualSorting`, and `manualFiltering` enabled. Drag-and-drop is automatically disabled.
|
||||
|
||||
### `ServerSideConfig`
|
||||
|
||||
| Prop | Type | Description |
|
||||
|---|---|---|
|
||||
| `totalItems` | `number` | Total row count from the server, used to compute page count |
|
||||
| `isLoading` | `boolean` | Shows a loading overlay (opacity + pointer-events-none) while data is being fetched |
|
||||
| `onSortingChange` | `(sorting: SortingState) => void` | Fired when the user clicks a column header |
|
||||
| `onPaginationChange` | `(pageIndex: number, pageSize: number) => void` | Fired on page navigation and on automatic resets from sort/search changes |
|
||||
| `onSearchTermChange` | `(searchTerm: string) => void` | Fired when the `searchTerm` prop changes |
|
||||
|
||||
### Callback contract
|
||||
|
||||
The callbacks fire in a predictable order:
|
||||
|
||||
- **Sort change** — `onSortingChange` fires first, then the page resets to 0 and `onPaginationChange(0, pageSize)` fires.
|
||||
- **Page navigation** — only `onPaginationChange` fires.
|
||||
- **Search change** — `onSearchTermChange` fires, and the page resets to 0. `onPaginationChange` only fires if the page was actually on a non-zero page. When already on page 0, `searchTerm` drives the re-fetch independently (e.g. via your SWR key) — no `onPaginationChange` is needed.
|
||||
|
||||
Your data-fetching layer should include `searchTerm` in its fetch dependencies (e.g. SWR key) so that search changes trigger re-fetches regardless of pagination state.
|
||||
|
||||
### Full example
|
||||
|
||||
```tsx
|
||||
import { useState } from "react";
|
||||
import useSWR from "swr";
|
||||
import type { SortingState } from "@tanstack/react-table";
|
||||
import DataTable from "@/refresh-components/table/DataTable";
|
||||
import { createTableColumns } from "@/refresh-components/table/columns";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
const tc = createTableColumns<User>();
|
||||
const columns = [
|
||||
tc.qualifier(),
|
||||
tc.column("name", { header: "Name", weight: 40, minWidth: 120 }),
|
||||
tc.column("email", { header: "Email", weight: 60, minWidth: 150 }),
|
||||
tc.actions(),
|
||||
];
|
||||
|
||||
function UsersTable() {
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [pageIndex, setPageIndex] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
const { data: response, isLoading } = useSWR(
|
||||
["/api/users", sorting, pageIndex, pageSize, searchTerm],
|
||||
([url, sorting, pageIndex, pageSize, searchTerm]) =>
|
||||
fetch(
|
||||
`${url}?` +
|
||||
new URLSearchParams({
|
||||
page: String(pageIndex),
|
||||
size: String(pageSize),
|
||||
search: searchTerm,
|
||||
...(sorting[0] && {
|
||||
sortBy: sorting[0].id,
|
||||
sortDir: sorting[0].desc ? "desc" : "asc",
|
||||
}),
|
||||
})
|
||||
).then((r) => r.json())
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<InputTypeIn
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder="Search users..."
|
||||
/>
|
||||
<DataTable
|
||||
data={response?.items ?? []}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
searchTerm={searchTerm}
|
||||
pageSize={pageSize}
|
||||
footer={{ mode: "summary" }}
|
||||
serverSide={{
|
||||
totalItems: response?.total ?? 0,
|
||||
isLoading,
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: (idx, size) => {
|
||||
setPageIndex(idx);
|
||||
setPageSize(size);
|
||||
},
|
||||
onSearchTermChange: () => {
|
||||
// search state is already managed above via searchTerm prop;
|
||||
// this callback is useful for analytics or debouncing
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Sizing
|
||||
|
||||
The `size` prop (`"regular"` or `"small"`) affects:
|
||||
@@ -293,6 +413,31 @@ tc.qualifier({
|
||||
</div>
|
||||
```
|
||||
|
||||
### Server-side pagination
|
||||
|
||||
Minimal wiring for server-side mode — manage sorting/pagination state externally and pass the current page slice as `data`.
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
data={currentPageRows}
|
||||
columns={columns}
|
||||
getRowId={(row) => row.id}
|
||||
searchTerm={searchTerm}
|
||||
pageSize={pageSize}
|
||||
footer={{ mode: "summary" }}
|
||||
serverSide={{
|
||||
totalItems: totalCount,
|
||||
isLoading,
|
||||
onSortingChange: setSorting,
|
||||
onPaginationChange: (idx, size) => {
|
||||
setPageIndex(idx);
|
||||
setPageSize(size);
|
||||
},
|
||||
onSearchTermChange: (term) => setSearchTerm(term),
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
### Custom row click handler
|
||||
|
||||
```tsx
|
||||
|
||||
@@ -83,13 +83,15 @@ interface DisplayColumnConfig<TData> {
|
||||
// Actions column config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ActionsConfig {
|
||||
interface ActionsConfig<TData = any> {
|
||||
/** Show column visibility popover. @default true */
|
||||
showColumnVisibility?: boolean;
|
||||
/** Show sorting popover. @default true */
|
||||
showSorting?: boolean;
|
||||
/** Footer text for the sorting popover. */
|
||||
sortingFooterText?: string;
|
||||
/** Optional cell renderer for row-level action buttons. */
|
||||
cell?: (row: TData) => ReactNode;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -110,7 +112,7 @@ interface TableColumnsBuilder<TData> {
|
||||
displayColumn(config: DisplayColumnConfig<TData>): OnyxDisplayColumn<TData>;
|
||||
|
||||
/** Create an actions column (visibility/sorting popovers). */
|
||||
actions(config?: ActionsConfig): OnyxActionsColumn<TData>;
|
||||
actions(config?: ActionsConfig<TData>): OnyxActionsColumn<TData>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -226,7 +228,7 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
|
||||
};
|
||||
},
|
||||
|
||||
actions(config?: ActionsConfig): OnyxActionsColumn<TData> {
|
||||
actions(config?: ActionsConfig<TData>): OnyxActionsColumn<TData> {
|
||||
const def: ColumnDef<TData, any> = {
|
||||
id: "__actions",
|
||||
enableHiding: false,
|
||||
@@ -234,7 +236,9 @@ export function createTableColumns<TData>(): TableColumnsBuilder<TData> {
|
||||
enableResizing: false,
|
||||
// Header rendering is handled by DataTable based on the actions config
|
||||
header: () => null,
|
||||
cell: () => null,
|
||||
cell: config?.cell
|
||||
? (info: CellContext<TData, any>) => config.cell!(info.row.original)
|
||||
: () => null,
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"use client";
|
||||
"use no memo";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getFilteredRowModel,
|
||||
type Table,
|
||||
type ColumnDef,
|
||||
type RowData,
|
||||
@@ -44,6 +45,15 @@ export function toOnyxSortDirection(
|
||||
return "none";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global filter value (combines view-mode + text search)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface GlobalFilterValue {
|
||||
selectedIds: Set<string> | null;
|
||||
searchTerm: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook options & return types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -58,12 +68,16 @@ type ManagedKeys =
|
||||
| "onColumnSizingChange"
|
||||
| "onColumnVisibilityChange"
|
||||
| "onPaginationChange"
|
||||
| "onGlobalFilterChange"
|
||||
| "getCoreRowModel"
|
||||
| "getSortedRowModel"
|
||||
| "getPaginationRowModel"
|
||||
| "getFilteredRowModel"
|
||||
| "globalFilterFn"
|
||||
| "columnResizeMode"
|
||||
| "enableRowSelection"
|
||||
| "enableColumnResizing";
|
||||
| "enableColumnResizing"
|
||||
| "getRowId";
|
||||
|
||||
/**
|
||||
* Options accepted by {@link useDataTable}.
|
||||
@@ -81,12 +95,26 @@ interface UseDataTableOptions<TData extends RowData> {
|
||||
enableRowSelection?: boolean;
|
||||
/** Whether columns can be resized. @default true */
|
||||
enableColumnResizing?: boolean;
|
||||
/** Stable row identity function. TanStack tracks selection by ID instead of array index. */
|
||||
getRowId: TableOptions<TData>["getRowId"];
|
||||
/** Resize strategy. @default "onChange" */
|
||||
columnResizeMode?: ColumnResizeMode;
|
||||
/** Initial sorting state. @default [] */
|
||||
initialSorting?: SortingState;
|
||||
/** Initial column visibility state. @default {} */
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
/** Called whenever the set of selected row IDs changes. */
|
||||
onSelectionChange?: (selectedIds: string[]) => void;
|
||||
/** Search term for global text filtering. Rows are filtered to those containing
|
||||
* the term in any accessor column value (case-insensitive). */
|
||||
searchTerm?: string;
|
||||
/** Server-side configuration. When provided, enables manual pagination/sorting/filtering. */
|
||||
serverSide?: {
|
||||
totalItems: number;
|
||||
onSortingChange: (sorting: SortingState) => void;
|
||||
onPaginationChange: (pageIndex: number, pageSize: number) => void;
|
||||
onSearchTermChange: (searchTerm: string) => void;
|
||||
};
|
||||
/** Escape-hatch: extra options spread into `useReactTable`. Managed keys are excluded. */
|
||||
tableOptions?: Partial<Omit<TableOptions<TData>, ManagedKeys>>;
|
||||
}
|
||||
@@ -119,10 +147,20 @@ interface UseDataTableReturn<TData extends RowData> {
|
||||
selectedCount: number;
|
||||
/** Whether every row on the current page is selected. */
|
||||
isAllPageRowsSelected: boolean;
|
||||
/** IDs of currently selected rows (derived from `getRowId`). */
|
||||
selectedRowIds: string[];
|
||||
/** Deselect all rows. */
|
||||
clearSelection: () => void;
|
||||
/** Select or deselect all rows on the current page. */
|
||||
toggleAllPageRowsSelected: (selected: boolean) => void;
|
||||
|
||||
// View-mode (filter to selected rows)
|
||||
/** Whether the table is currently filtered to show only selected rows. */
|
||||
isViewingSelected: boolean;
|
||||
/** Enter view mode — freeze the current selection as a filter. */
|
||||
enterViewMode: () => void;
|
||||
/** Exit view mode — remove the selection filter. */
|
||||
exitViewMode: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -153,9 +191,15 @@ export default function useDataTable<TData extends RowData>(
|
||||
columnResizeMode = "onChange",
|
||||
initialSorting = [],
|
||||
initialColumnVisibility = {},
|
||||
getRowId,
|
||||
onSelectionChange,
|
||||
searchTerm,
|
||||
serverSide,
|
||||
tableOptions,
|
||||
} = options;
|
||||
|
||||
const isServerSide = !!serverSide;
|
||||
|
||||
// ---- internal state -----------------------------------------------------
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting);
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
@@ -167,6 +211,11 @@ export default function useDataTable<TData extends RowData>(
|
||||
pageIndex: 0,
|
||||
pageSize: pageSizeOption,
|
||||
});
|
||||
/** Combined global filter: view-mode (selected IDs) + text search. */
|
||||
const [globalFilter, setGlobalFilter] = useState<GlobalFilterValue>({
|
||||
selectedIds: null,
|
||||
searchTerm: "",
|
||||
});
|
||||
|
||||
// ---- sync pageSize prop to internal state --------------------------------
|
||||
useEffect(() => {
|
||||
@@ -177,30 +226,130 @@ export default function useDataTable<TData extends RowData>(
|
||||
}));
|
||||
}, [pageSizeOption]);
|
||||
|
||||
// ---- sync external searchTerm prop into combined filter state ------------
|
||||
// (client-side only — server-side uses separate callbacks instead)
|
||||
const preSearchPageRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isServerSide) return;
|
||||
const term = searchTerm ?? "";
|
||||
const wasSearching = !!globalFilter.searchTerm;
|
||||
|
||||
if (!wasSearching && term) {
|
||||
// Entering search — save current page, reset to 0
|
||||
preSearchPageRef.current = pagination.pageIndex;
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
} else if (wasSearching && !term) {
|
||||
// Clearing search — restore saved page
|
||||
setPagination((p) => ({ ...p, pageIndex: preSearchPageRef.current }));
|
||||
}
|
||||
|
||||
setGlobalFilter((prev) => ({ ...prev, searchTerm: term }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Intentionally
|
||||
// omits `globalFilter` and `pagination.pageIndex`: we only read snapshot
|
||||
// values to detect the search enter/clear transition, not to react to
|
||||
// every filter or page change.
|
||||
}, [searchTerm, isServerSide]);
|
||||
|
||||
// ---- server-side: 3 separate callbacks -----------------------------------
|
||||
// Single ref for the whole serverSide config — prevents effects from
|
||||
// re-firing when the consumer passes an inline object each render.
|
||||
const serverSideRef = useRef(serverSide);
|
||||
serverSideRef.current = serverSide;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isServerSide) return;
|
||||
serverSideRef.current!.onSortingChange(sorting);
|
||||
}, [sorting, isServerSide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isServerSide) return;
|
||||
serverSideRef.current!.onPaginationChange(
|
||||
pagination.pageIndex,
|
||||
pagination.pageSize
|
||||
);
|
||||
}, [pagination.pageIndex, pagination.pageSize, isServerSide]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isServerSide) return;
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
serverSideRef.current!.onSearchTermChange(searchTerm ?? "");
|
||||
}, [searchTerm, isServerSide]);
|
||||
|
||||
// ---- TanStack table instance --------------------------------------------
|
||||
const table = useReactTable({
|
||||
const serverPageCount = isServerSide
|
||||
? Math.ceil((serverSide!.totalItems || 0) / pagination.pageSize)
|
||||
: undefined;
|
||||
|
||||
const tableOpts: TableOptions<TData> = {
|
||||
data,
|
||||
columns,
|
||||
getRowId,
|
||||
state: {
|
||||
sorting,
|
||||
rowSelection,
|
||||
columnSizing,
|
||||
columnVisibility,
|
||||
pagination,
|
||||
...(isServerSide ? {} : { globalFilter }),
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onSortingChange: isServerSide
|
||||
? (updater) => {
|
||||
setSorting(updater);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}
|
||||
: setSorting,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
// We manage page resets explicitly (search enter/clear, view mode,
|
||||
// pageSize change) so disable TanStack's auto-reset which would
|
||||
// clobber our restored page index when the filter changes.
|
||||
autoResetPageIndex: false,
|
||||
columnResizeMode,
|
||||
enableRowSelection,
|
||||
enableColumnResizing,
|
||||
...tableOptions,
|
||||
});
|
||||
};
|
||||
|
||||
if (isServerSide) {
|
||||
tableOpts.manualPagination = true;
|
||||
tableOpts.manualSorting = true;
|
||||
tableOpts.manualFiltering = true;
|
||||
tableOpts.pageCount = serverPageCount;
|
||||
} else {
|
||||
tableOpts.onGlobalFilterChange = setGlobalFilter;
|
||||
tableOpts.getSortedRowModel = getSortedRowModel();
|
||||
tableOpts.getPaginationRowModel = getPaginationRowModel();
|
||||
tableOpts.getFilteredRowModel = getFilteredRowModel();
|
||||
tableOpts.globalFilterFn = (
|
||||
row,
|
||||
_columnId,
|
||||
filterValue: GlobalFilterValue
|
||||
) => {
|
||||
// View-mode filter (selected IDs)
|
||||
if (
|
||||
filterValue.selectedIds != null &&
|
||||
!filterValue.selectedIds.has(row.id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// Text search filter
|
||||
if (filterValue.searchTerm) {
|
||||
const term = filterValue.searchTerm.toLowerCase();
|
||||
return row.getAllCells().some((cell) => {
|
||||
const value = cell.getValue();
|
||||
if (value == null) return false;
|
||||
return String(value).toLowerCase().includes(term);
|
||||
});
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
const table = useReactTable(tableOpts);
|
||||
|
||||
// ---- derived values -----------------------------------------------------
|
||||
const isAllPageRowsSelected = table.getIsAllPageRowsSelected();
|
||||
@@ -212,12 +361,36 @@ export default function useDataTable<TData extends RowData>(
|
||||
? "partial"
|
||||
: "none";
|
||||
|
||||
const selectedCount = Object.keys(rowSelection).length;
|
||||
const selectedRowIds = useMemo(
|
||||
() => Object.keys(rowSelection),
|
||||
[rowSelection]
|
||||
);
|
||||
const selectedCount = selectedRowIds.length;
|
||||
const totalPages = Math.max(1, table.getPageCount());
|
||||
const currentPage = pagination.pageIndex + 1;
|
||||
const totalItems = data.length;
|
||||
const hasActiveFilter =
|
||||
!isServerSide &&
|
||||
(globalFilter.selectedIds != null || !!globalFilter.searchTerm);
|
||||
const totalItems = isServerSide
|
||||
? serverSide!.totalItems
|
||||
: hasActiveFilter
|
||||
? table.getPrePaginationRowModel().rows.length
|
||||
: data.length;
|
||||
const isPaginated = isFinite(pagination.pageSize);
|
||||
|
||||
// ---- selection change callback ------------------------------------------
|
||||
const isFirstRenderRef = useRef(true);
|
||||
const onSelectionChangeRef = useRef(onSelectionChange);
|
||||
onSelectionChangeRef.current = onSelectionChange;
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRenderRef.current) {
|
||||
isFirstRenderRef.current = false;
|
||||
return;
|
||||
}
|
||||
onSelectionChangeRef.current?.(selectedRowIds);
|
||||
}, [selectedRowIds]);
|
||||
|
||||
// ---- actions ------------------------------------------------------------
|
||||
const setPage = (page: number) => {
|
||||
const clamped = Math.max(1, Math.min(page, totalPages));
|
||||
@@ -232,6 +405,26 @@ export default function useDataTable<TData extends RowData>(
|
||||
table.toggleAllPageRowsSelected(selected);
|
||||
};
|
||||
|
||||
// ---- view mode (filter to selected rows) --------------------------------
|
||||
const isViewingSelected = globalFilter.selectedIds != null;
|
||||
|
||||
const enterViewMode = () => {
|
||||
if (isServerSide) return;
|
||||
if (selectedRowIds.length > 0) {
|
||||
setGlobalFilter((prev) => ({
|
||||
...prev,
|
||||
selectedIds: new Set(selectedRowIds),
|
||||
}));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
}
|
||||
};
|
||||
|
||||
const exitViewMode = () => {
|
||||
if (isServerSide) return;
|
||||
setGlobalFilter((prev) => ({ ...prev, selectedIds: null }));
|
||||
setPagination((prev) => ({ ...prev, pageIndex: 0 }));
|
||||
};
|
||||
|
||||
return {
|
||||
table,
|
||||
currentPage,
|
||||
@@ -242,8 +435,12 @@ export default function useDataTable<TData extends RowData>(
|
||||
isPaginated,
|
||||
selectionState,
|
||||
selectedCount,
|
||||
selectedRowIds,
|
||||
isAllPageRowsSelected,
|
||||
clearSelection,
|
||||
toggleAllPageRowsSelected,
|
||||
isViewingSelected,
|
||||
enterViewMode,
|
||||
exitViewMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import {
|
||||
useSensors,
|
||||
useSensor,
|
||||
@@ -49,6 +49,8 @@ interface DraggableRowsReturn {
|
||||
isDragging: boolean;
|
||||
/** Whether DnD is enabled. */
|
||||
isEnabled: boolean;
|
||||
/** Ref that is `true` briefly after a drag ends, used to suppress the trailing click. */
|
||||
wasDraggingRef: React.RefObject<boolean>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -75,6 +77,7 @@ export default function useDraggableRows<TData>(
|
||||
const { data, getRowId, enabled = true, onReorder } = options;
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const wasDraggingRef = useRef(false);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
@@ -108,6 +111,11 @@ export default function useDraggableRows<TData>(
|
||||
const handleDragEnd = useCallback(
|
||||
(event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
// Suppress the trailing click event that the browser fires after pointerup.
|
||||
wasDraggingRef.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
wasDraggingRef.current = false;
|
||||
});
|
||||
if (event.activatorEvent instanceof PointerEvent) {
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
@@ -152,5 +160,6 @@ export default function useDraggableRows<TData>(
|
||||
activeId,
|
||||
isDragging: activeId !== null,
|
||||
isEnabled: enabled,
|
||||
wasDraggingRef,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -99,13 +99,29 @@ export type OnyxColumnDef<TData> =
|
||||
| OnyxDisplayColumn<TData>
|
||||
| OnyxActionsColumn<TData>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Server-side pagination / sorting / search
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Server-side configuration for DataTable. */
|
||||
export interface ServerSideConfig {
|
||||
/** Total row count from the server. Used to compute page count. */
|
||||
totalItems: number;
|
||||
/** Whether data is currently being fetched. Shows loading state. */
|
||||
isLoading?: boolean;
|
||||
/** Fired when sorting state changes. */
|
||||
onSortingChange: (sorting: SortingState) => void;
|
||||
/** Fired when pagination changes (including page resets from sort/search). */
|
||||
onPaginationChange: (pageIndex: number, pageSize: number) => void;
|
||||
/** Fired when searchTerm changes. */
|
||||
onSearchTermChange: (searchTerm: string) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DataTable props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface DataTableDraggableConfig<TData> {
|
||||
/** Extract a unique string ID from each row. */
|
||||
getRowId: (row: TData) => string;
|
||||
export interface DataTableDraggableConfig {
|
||||
/** Called after a successful reorder with the new ID order and changed positions. */
|
||||
onReorder: (
|
||||
ids: string[],
|
||||
@@ -117,8 +133,8 @@ export interface DataTableFooterSelection {
|
||||
mode: "selection";
|
||||
/** Whether the table supports selecting multiple rows. @default true */
|
||||
multiSelect?: boolean;
|
||||
/** Handler for the "View" button. */
|
||||
onView?: () => void;
|
||||
/** When true, shows a "View" button that filters the table to only selected rows. @default false */
|
||||
showView?: boolean;
|
||||
/** Handler for the "Clear" button. When omitted, the default clearSelection is used. */
|
||||
onClear?: () => void;
|
||||
}
|
||||
@@ -136,6 +152,8 @@ export interface DataTableProps<TData> {
|
||||
data: TData[];
|
||||
/** Column definitions created via `createTableColumns()`. */
|
||||
columns: OnyxColumnDef<TData>[];
|
||||
/** Extract a unique string ID from each row. Used for stable row identity. */
|
||||
getRowId: (row: TData) => string;
|
||||
/** Rows per page. Set `Infinity` to disable pagination. @default 10 */
|
||||
pageSize?: number;
|
||||
/** Initial sorting state. */
|
||||
@@ -143,13 +161,18 @@ export interface DataTableProps<TData> {
|
||||
/** Initial column visibility state. */
|
||||
initialColumnVisibility?: VisibilityState;
|
||||
/** Enable drag-and-drop row reordering. */
|
||||
draggable?: DataTableDraggableConfig<TData>;
|
||||
draggable?: DataTableDraggableConfig;
|
||||
/** Footer configuration. */
|
||||
footer?: DataTableFooterConfig;
|
||||
/** Table size variant. @default "regular" */
|
||||
size?: TableSize;
|
||||
/** Called whenever the set of selected row IDs changes. Receives IDs produced by `getRowId`. */
|
||||
onSelectionChange?: (selectedIds: string[]) => void;
|
||||
/** Called when a row is clicked (replaces the default selection toggle). */
|
||||
onRowClick?: (row: TData) => void;
|
||||
/** Search term for global text filtering. When provided, rows are filtered
|
||||
* to those containing the term in any accessor column value (case-insensitive). */
|
||||
searchTerm?: string;
|
||||
/**
|
||||
* Max height of the scrollable table area. When set, the table body scrolls
|
||||
* vertically while the header stays pinned at the top.
|
||||
@@ -159,4 +182,12 @@ export interface DataTableProps<TData> {
|
||||
/** Background color for the sticky header row, preventing rows from showing
|
||||
* through when scrolling. Accepts any CSS color value. */
|
||||
headerBackground?: string;
|
||||
/**
|
||||
* Enable server-side mode. When provided:
|
||||
* - TanStack uses manualPagination/manualSorting/manualFiltering
|
||||
* - `data` should contain only the current page's rows
|
||||
* - Dragging is automatically disabled
|
||||
* - Fires separate callbacks for sorting, pagination, and search changes
|
||||
*/
|
||||
serverSide?: ServerSideConfig;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user