Files
liqa/client/src/ui/Table/Table.tsx
T
ars9 ad155e269c feat(table,runs,sessions): add server-side sorting and pagination
- 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
2026-04-15 12:18:47 +03:00

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>
);
}