refactor: add reusable select controls and enhance data table columns
This commit is contained in:
@@ -2,7 +2,10 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnPinningState,
|
||||
ColumnSizingState,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
@@ -14,11 +17,23 @@ import { Table, TableBody, TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import type { PermissionInput } from '@/hooks/usePermissions';
|
||||
import { PermissionGuard } from '@/guards';
|
||||
|
||||
import TopHeader from './Header';
|
||||
import TableHeader from './TableHeader';
|
||||
import { TableFooter } from './Footer';
|
||||
import { TableActionButton } from './TableActionButton';
|
||||
import { ColumnViewOptions } from './ColumnViewOptions';
|
||||
import { getPinnedColumnStyles } from './columnStyles';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function shouldTruncateCell(column: { id: string; columnDef: { meta?: unknown } }) {
|
||||
if (column.id === 'actions') return false;
|
||||
const meta = column.columnDef.meta as { disableTruncate?: boolean } | undefined;
|
||||
return !meta?.disableTruncate;
|
||||
}
|
||||
|
||||
export type ColumnAlign = 'left' | 'right';
|
||||
|
||||
export interface DataTableAction<TData> {
|
||||
label: string | ((item: TData) => string);
|
||||
@@ -37,6 +52,9 @@ export interface DataTableProps<TData, TValue> {
|
||||
addButtonText?: string;
|
||||
isLoading?: boolean;
|
||||
actions?: DataTableAction<TData>[];
|
||||
/** Sticky-pin the actions column (use with actionsAlign). */
|
||||
actionsSticky?: boolean;
|
||||
actionsAlign?: ColumnAlign;
|
||||
toolbar?: React.ReactNode;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
@@ -49,9 +67,32 @@ export interface DataTableProps<TData, TValue> {
|
||||
};
|
||||
sorting?: SortingState;
|
||||
onSortingChange?: (sorting: SortingState) => void;
|
||||
enableColumnControls?: boolean;
|
||||
/** Minimum width (px) for every column unless overridden in the column def. */
|
||||
minColumnSize?: number;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
export function DataTable<TData, TValue>(props: DataTableProps<TData, TValue>) {
|
||||
const { actions, ...rest } = props;
|
||||
|
||||
if (!actions?.length) {
|
||||
return <DataTableContent {...props} />;
|
||||
}
|
||||
|
||||
const actionPermissions = actions.map((action) => action.permission);
|
||||
|
||||
return (
|
||||
<PermissionGuard
|
||||
permissions={actionPermissions}
|
||||
match="any"
|
||||
fallback={<DataTableContent {...rest} actions={undefined} />}
|
||||
>
|
||||
<DataTableContent {...props} />
|
||||
</PermissionGuard>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTableContent<TData, TValue>({
|
||||
columns: initialColumns,
|
||||
data,
|
||||
title,
|
||||
@@ -59,14 +100,21 @@ export function DataTable<TData, TValue>({
|
||||
addButtonText,
|
||||
isLoading = false,
|
||||
actions,
|
||||
actionsSticky = false,
|
||||
actionsAlign = 'right',
|
||||
toolbar,
|
||||
emptyTitle = 'No results found.',
|
||||
emptyDescription = 'Try adjusting your filters or search terms.',
|
||||
pagination,
|
||||
sorting: controlledSorting,
|
||||
onSortingChange,
|
||||
enableColumnControls = true,
|
||||
minColumnSize = 120,
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>({});
|
||||
const [internalSorting, setInternalSorting] = React.useState<SortingState>(
|
||||
[],
|
||||
);
|
||||
@@ -91,11 +139,16 @@ export function DataTable<TData, TValue>({
|
||||
if (actions?.length) {
|
||||
cols.push({
|
||||
id: 'actions',
|
||||
size: 110,
|
||||
minSize: 90,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
enablePinning: false,
|
||||
header: () => <div className="text-right">Actions</div>,
|
||||
cell: ({ row }) => {
|
||||
const item = row.original;
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<div className="flex justify-start gap-2">
|
||||
{actions.map((action) => {
|
||||
const label =
|
||||
typeof action.label === 'function'
|
||||
@@ -126,14 +179,31 @@ export function DataTable<TData, TValue>({
|
||||
return cols;
|
||||
}, [actions, initialColumns]);
|
||||
|
||||
const columnPinning = React.useMemo<ColumnPinningState>(
|
||||
() =>
|
||||
actionsSticky && actions?.length
|
||||
? { [actionsAlign]: ['actions'] }
|
||||
: {},
|
||||
[actions, actionsAlign, actionsSticky],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onColumnSizingChange: setColumnSizing,
|
||||
onSortingChange: handleSortingChange,
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
enableColumnResizing: true,
|
||||
enableColumnPinning: true,
|
||||
columnResizeMode: 'onChange',
|
||||
defaultColumn: {
|
||||
minSize: minColumnSize,
|
||||
size: minColumnSize,
|
||||
},
|
||||
manualPagination: !!pagination,
|
||||
manualSorting: isManualSorting,
|
||||
pageCount: pagination?.totalItems
|
||||
@@ -141,6 +211,9 @@ export function DataTable<TData, TValue>({
|
||||
: -1,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
columnPinning,
|
||||
columnSizing,
|
||||
sorting,
|
||||
pagination: pagination
|
||||
? {
|
||||
@@ -177,12 +250,16 @@ export function DataTable<TData, TValue>({
|
||||
onAddNew={onAddNew}
|
||||
addButtonText={addButtonText}
|
||||
toolbar={toolbar}
|
||||
tableTools={
|
||||
enableColumnControls ? <ColumnViewOptions table={table} /> : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Table
|
||||
containerClassName="max-h-[calc(100vh-350px)] overflow-y-auto"
|
||||
className="border-separate border-spacing-0"
|
||||
className="table-fixed border-separate border-spacing-0"
|
||||
style={{ width: '100%', minWidth: table.getTotalSize() }}
|
||||
>
|
||||
<TableHeader table={table} />
|
||||
<TableBody>
|
||||
@@ -202,14 +279,30 @@ export function DataTable<TData, TValue>({
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
const truncate = shouldTruncateCell(cell.column);
|
||||
const content = flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
);
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={getPinnedColumnStyles(cell.column)}
|
||||
className={cn(
|
||||
'max-w-0 overflow-hidden',
|
||||
cell.column.getIsPinned() && 'bg-card',
|
||||
)}
|
||||
>
|
||||
{truncate ? (
|
||||
<div className="truncate">{content}</div>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user