- add sortable column support to Table with asc/desc/unsorted icons - active sort icon uses --color-primary; no header background change - Table supports server-side mode via total/page/onPageChange props - wire server-side sort+pagination in ScenariosPage, AllRunsPage, RunsPage, SessionsPage - remove steps count column from run tables - fix server: RunsQueryDto now extends PaginationQueryDto with orderBy/orderDir - fix findAllRuns to use QueryBuilder; sort by scenario.name via JOIN - add per-resource typed query DTOs with @IsIn allowlist on orderBy - prevents SQL injection and returns 400 for unknown orderBy values
241 lines
7.6 KiB
TypeScript
241 lines
7.6 KiB
TypeScript
import { useState, type HTMLAttributes, type MouseEvent, type ReactNode } from 'react';
|
|
import { ArrowUp, ArrowDown, ArrowUpDown } from 'lucide-react';
|
|
import { Pagination } from '../Pagination/Pagination';
|
|
import styles from './Table.module.css';
|
|
|
|
export type SortDirection = 'asc' | 'desc';
|
|
|
|
export interface TableColumn<T> {
|
|
key: string;
|
|
header: ReactNode;
|
|
render: (row: T) => ReactNode;
|
|
width?: string | number;
|
|
align?: 'left' | 'center' | 'right';
|
|
/** If true, a sort icon is shown and clicking the header triggers onSort */
|
|
sortable?: boolean;
|
|
}
|
|
|
|
export interface TableProps<T> {
|
|
columns: TableColumn<T>[];
|
|
data: T[];
|
|
rowKey: (row: T) => string | number;
|
|
loading?: boolean;
|
|
emptyMessage?: string;
|
|
className?: string;
|
|
/**
|
|
* Client-side pagination: pass a default page size to enable.
|
|
* For server-side pagination use `total` + `page` + `onPageChange` instead.
|
|
*/
|
|
pageSize?: number;
|
|
/** Offer a page-size selector when pagination is enabled */
|
|
pageSizeOptions?: number[];
|
|
/** Called when a row is clicked */
|
|
onRowClick?: (row: T) => void;
|
|
/** Optional per-row props, useful for drag-and-drop and row-level attributes */
|
|
getRowProps?: (row: T) => HTMLAttributes<HTMLTableRowElement>;
|
|
/** Currently sorted column key (controlled) */
|
|
sortBy?: string;
|
|
/** Current sort direction (controlled) */
|
|
sortDir?: SortDirection;
|
|
/** Called when the user clicks a sortable column header */
|
|
onSort?: (key: string, dir: SortDirection) => void;
|
|
// ── Server-side pagination (controlled) ──────────────────────────────────
|
|
/** Total record count from server. Presence switches to server-side pagination mode. */
|
|
total?: number;
|
|
/** Controlled current page (1-based). Required when `total` is set. */
|
|
page?: number;
|
|
/** Called when the user navigates to a new page. Required when `total` is set. */
|
|
onPageChange?: (page: number) => void;
|
|
/** Called when the user changes page size. Optional for server-side pagination. */
|
|
onPageSizeChange?: (size: number) => void;
|
|
}
|
|
|
|
export function Table<T>({
|
|
columns,
|
|
data,
|
|
rowKey,
|
|
loading = false,
|
|
emptyMessage = 'No data.',
|
|
className,
|
|
pageSize: defaultPageSize,
|
|
pageSizeOptions,
|
|
onRowClick,
|
|
getRowProps,
|
|
sortBy,
|
|
sortDir,
|
|
onSort,
|
|
total,
|
|
page: controlledPage,
|
|
onPageChange,
|
|
onPageSizeChange,
|
|
}: TableProps<T>) {
|
|
const [internalPage, setInternalPage] = useState(1);
|
|
const [pageSize, setPageSize] = useState(defaultPageSize ?? 10);
|
|
|
|
const serverMode = total !== undefined;
|
|
|
|
const handleSortClick = (key: string) => {
|
|
if (!onSort) return;
|
|
const nextDir: SortDirection =
|
|
sortBy === key && sortDir === 'asc' ? 'desc' : 'asc';
|
|
onSort(key, nextDir);
|
|
};
|
|
|
|
// Client-side pagination
|
|
const clientPaginated = !serverMode && defaultPageSize !== undefined && !loading;
|
|
const visibleData = clientPaginated
|
|
? data.slice((internalPage - 1) * pageSize, internalPage * pageSize)
|
|
: data;
|
|
|
|
const lastClientPage = pageSize > 0 ? Math.ceil(data.length / pageSize) : 1;
|
|
const safeInternalPage = Math.min(internalPage, Math.max(1, lastClientPage));
|
|
|
|
// Server-side pagination display values
|
|
const serverPage = controlledPage ?? 1;
|
|
const serverTotal = total ?? 0;
|
|
|
|
return (
|
|
<div className={`${styles.wrapper} ${className ?? ''}`}>
|
|
<table className={styles.table}>
|
|
<thead>
|
|
<tr>
|
|
{columns.map((col) => {
|
|
const isSorted = col.sortable && sortBy === col.key;
|
|
const SortIcon = isSorted
|
|
? sortDir === 'asc'
|
|
? ArrowUp
|
|
: ArrowDown
|
|
: ArrowUpDown;
|
|
|
|
return (
|
|
<th
|
|
key={col.key}
|
|
className={[
|
|
styles.th,
|
|
col.sortable ? styles.thSortable : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
style={{
|
|
width: col.width,
|
|
textAlign: col.align ?? 'left',
|
|
}}
|
|
onClick={col.sortable ? () => handleSortClick(col.key) : undefined}
|
|
>
|
|
<span className={styles.thContent}>
|
|
{col.header}
|
|
{col.sortable && (
|
|
<SortIcon
|
|
size={12}
|
|
className={[
|
|
styles.sortIcon,
|
|
isSorted ? styles.sortIconActive : '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')}
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
</span>
|
|
</th>
|
|
);
|
|
})}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr>
|
|
<td colSpan={columns.length} className={styles.empty}>
|
|
Loading…
|
|
</td>
|
|
</tr>
|
|
) : visibleData.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={columns.length} className={styles.empty}>
|
|
{emptyMessage}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
visibleData.map((row) => {
|
|
const rowProps = getRowProps?.(row);
|
|
const mergedClassName = [
|
|
styles.tr,
|
|
onRowClick ? styles.clickable : '',
|
|
rowProps?.className ?? '',
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
|
|
const handleClick = (event: MouseEvent<HTMLTableRowElement>) => {
|
|
rowProps?.onClick?.(event);
|
|
if (!event.defaultPrevented) {
|
|
onRowClick?.(row);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<tr
|
|
key={rowKey(row)}
|
|
{...rowProps}
|
|
className={mergedClassName}
|
|
onClick={onRowClick || rowProps?.onClick ? handleClick : undefined}
|
|
>
|
|
{columns.map((col) => (
|
|
<td
|
|
key={col.key}
|
|
className={styles.td}
|
|
style={{ textAlign: col.align ?? 'left' }}
|
|
onClick={col.key === 'actions' ? (e) => e.stopPropagation() : undefined}
|
|
>
|
|
{col.render(row)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
{serverMode && serverTotal > 0 && onPageChange && (
|
|
<Pagination
|
|
page={serverPage}
|
|
pageSize={pageSize}
|
|
total={serverTotal}
|
|
onPageChange={onPageChange}
|
|
onPageSizeChange={
|
|
onPageSizeChange
|
|
? (size) => {
|
|
setPageSize(size);
|
|
onPageSizeChange(size);
|
|
}
|
|
: pageSizeOptions
|
|
? (size) => {
|
|
setPageSize(size);
|
|
onPageChange(1);
|
|
}
|
|
: undefined
|
|
}
|
|
pageSizeOptions={pageSizeOptions}
|
|
/>
|
|
)}
|
|
{clientPaginated && data.length > 0 && (
|
|
<Pagination
|
|
page={safeInternalPage}
|
|
pageSize={pageSize}
|
|
total={data.length}
|
|
onPageChange={setInternalPage}
|
|
onPageSizeChange={
|
|
pageSizeOptions
|
|
? (size) => {
|
|
setPageSize(size);
|
|
setInternalPage(1);
|
|
}
|
|
: undefined
|
|
}
|
|
pageSizeOptions={pageSizeOptions}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|