refactor: add reusable select controls and enhance data table columns

This commit is contained in:
2026-06-17 20:32:50 +05:30
parent b325975282
commit 463ee0835f
20 changed files with 418 additions and 26 deletions

View File

@@ -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>
))
) : (