The DataTable's pagination carries the only interpolated strings in this area
('Showing page {{current}} of {{total}}', '{{size}} / page'). No plural forms:
nothing here is a count-varying noun, so inventing one would only add a key
nobody uses.
DataTable.test.tsx now renders inside an I18nProvider with the real en-GB
catalog, so it still asserts the English a user sees rather than a dot-path.
Left/right utilities became logical (ms-/me-/ps-/pe-/start-) per D3.
201 lines
7.5 KiB
TypeScript
201 lines
7.5 KiB
TypeScript
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';
|
|
import { useTranslation } from '@/components/locales';
|
|
|
|
export const DataTable = <T,>(props: DataTableProps<T>) => {
|
|
const { t } = useTranslation();
|
|
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 || t('dataTable.empty')}</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('ms-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());
|
|
}
|