This commit is contained in:
2026-02-16 19:34:35 +00:00
commit 9ab0940ca4
784 changed files with 41710 additions and 0 deletions
@@ -0,0 +1,198 @@
import type { SortKey } from 'hooks/useDataControl';
import { useState, useEffect } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { ArrowUpDown } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useDataControl, type UseDataControlReturn } from 'hooks/useDataControl';
import { PaginationBar } from './PaginationBar';
import { cn } from 'helpers/cn';
export const DataTable = <T,>(props: DataTableProps<T>) => {
const [columns, setColumns] = useState<Column<T>[]>([]);
const { dataController, uniqueKey, noDataCTA, noDataMessage } = props;
const theUniqueKey = uniqueKey || 'id';
const { rawData, data, pageSize, sortBy, sortedBy, setCustomSort } = dataController;
useEffect(() => {
if (props.columns) {
setColumns(props.columns);
} else if (!!rawData && rawData.length > 0) {
const cols: Column<T>[] = Object.keys(rawData[0] as object).map((key) => ({
field: key as keyof T,
label: toLabel(key),
sortKey: key as SortKey<T>,
}));
setColumns(cols);
} else {
setColumns([]);
}
}, [props.columns, rawData]);
useEffect(() => {
if (typeof props.pageSize === 'number') {
dataController.setPageSize(props.pageSize);
}
}, [props.pageSize]);
useEffect(() => {
if (!!props.customSort) {
setCustomSort(() => props.customSort!);
}
}, [props.customSort]);
useEffect(() => {
if (props.searchKeys) {
const propsSearchKeys = JSON.stringify(props.searchKeys);
const controllerSearchKeys = JSON.stringify(dataController.searchKeys);
if (propsSearchKeys !== controllerSearchKeys) {
dataController.setSearchKeys(props.searchKeys);
}
} else if (columns.length > 0 && !dataController.searchKeys) {
const colFields = columns.map((col) => col.field).filter((f): f is keyof T => !!f);
dataController.setSearchKeys(colFields);
}
}, [props.searchKeys, columns.length]);
if (!rawData) return null;
if (rawData.length === 0) {
return (
<div className="py-12 text-center">
<div className="text-muted-foreground">{noDataMessage || 'No Data Found.'}</div>
{noDataCTA && <>{noDataCTA}</>}
</div>
);
}
return (
<div className="bg-card overflow-hidden rounded-xl border">
<div>
<Table>
<TableHeader className="bg-muted/50">
<TableRow>
{columns.map((column: Column<T>, idx: number) => {
const { label = '', sortKey, field, condition = true } = column;
const { tooltip, headerClassName, labelMobile } = column;
const renderColumn = typeof condition === 'function' ? condition() : condition;
return (
renderColumn && (
<TableHead className={cn('whitespace-nowrap max-[499px]:px-2', headerClassName)} key={idx}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className={cn(
'flex items-center',
sortKey && 'cursor-pointer hover:text-black',
sortedBy?.startsWith(`${String(sortKey)}-`) && 'text-black',
)}
onClick={() => sortBy(sortKey as Extract<keyof T, string> | undefined)}
>
<span className="hidden min-[500px]:inline">
{String(label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
</span>
<span className="min-[500px]:hidden">
{String(labelMobile || label || field || '')} {tooltip ? <sup>ⓘ</sup> : ''}
</span>
{sortKey && <ArrowUpDown className={cn('ml-2 h-4 w-4')} />}
</div>
</TooltipTrigger>
{tooltip && (
<TooltipContent>
<p className="whitespace-pre-line">{tooltip}</p>
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
</TableHead>
)
);
})}
</TableRow>
</TableHeader>
<TableBody>
{data?.map((item: T, idx: number) => {
return (
<TableRow key={String(item[theUniqueKey as keyof T] || idx)} className="group/tablerow">
{columns.map((column, idx) => {
const value = column.field !== undefined ? item[column.field] : undefined;
const { format, condition = true, cellClassName } = column;
const renderColumn = typeof condition === 'function' ? condition() : condition;
return (
renderColumn && (
<TableCell className={cn('whitespace-nowrap max-[499px]:px-2', cellClassName)} key={idx}>
<>
{format
? column.field !== undefined
? format({
value: item[column.field],
item,
data,
idx,
})
: format({
value: undefined as unknown as T[keyof T],
item,
data,
idx,
})
: value}
</>
</TableCell>
)
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{!!pageSize && pageSize < rawData?.length && (
<PaginationBar dataManager={dataController as UseDataControlReturn<T>} />
)}
</div>
);
};
export { useDataControl };
export type { UseDataControlReturn };
type DataTableProps<T> = {
dataController: UseDataControlReturn<T>;
columns?: Column<T>[];
uniqueKey?: keyof T;
searchKeys?: (keyof T)[];
pageSize?: number;
noDataCTA?: React.ReactNode;
customSort?: (a: T, b: T, originalSort: (a: T, b: T) => number) => number;
noDataMessage?: string;
};
export type DataTableFormatArgs<T> = {
value: T[keyof T];
item: T;
data: T[];
idx: number;
};
export type Column<T> = {
field?: keyof T;
label?: string;
labelMobile?: string;
headerClassName?: string;
cellClassName?: string;
format?: (args: DataTableFormatArgs<T>) => React.ReactNode;
condition?: boolean | (() => boolean);
sortKey?: SortKey<T>;
tooltip?: string;
};
function toLabel(key: string): string {
return key
.replace(/([a-z0-9])([A-Z])/g, '$1 $2') // insert space before capitals
.replace(/_/g, ' ') // replace underscores with spaces
.replace(/\s+/g, ' ') // collapse multiple spaces
.trim()
.replace(/\w\S*/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
}