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
This commit is contained in:
@@ -27,11 +27,40 @@
|
||||
background: var(--color-secondary-fg);
|
||||
}
|
||||
|
||||
.thContent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.thSortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.thSortable:hover {
|
||||
background: color-mix(in srgb, var(--color-secondary-fg) 80%, var(--color-secondary-border) 20%);
|
||||
}
|
||||
|
||||
.sortIcon {
|
||||
opacity: 0.4;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sortIconActive {
|
||||
opacity: 1;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .th {
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-secondary-fg);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .thSortable:hover {
|
||||
background: color-mix(in srgb, var(--color-secondary) 80%, var(--color-secondary-border) 20%);
|
||||
}
|
||||
|
||||
/* Rounded top corners on first/last header cells */
|
||||
.th:first-child { border-radius: var(--radius-lg) 0 0 0; }
|
||||
.th:last-child { border-radius: 0 var(--radius-lg) 0 0; }
|
||||
|
||||
+120
-24
@@ -1,13 +1,18 @@
|
||||
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> {
|
||||
@@ -17,7 +22,10 @@ export interface TableProps<T> {
|
||||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
className?: string;
|
||||
/** Enable built-in client-side pagination with this default page size */
|
||||
/**
|
||||
* 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[];
|
||||
@@ -25,6 +33,21 @@ export interface TableProps<T> {
|
||||
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>({
|
||||
@@ -38,34 +61,85 @@ export function Table<T>({
|
||||
pageSizeOptions,
|
||||
onRowClick,
|
||||
getRowProps,
|
||||
sortBy,
|
||||
sortDir,
|
||||
onSort,
|
||||
total,
|
||||
page: controlledPage,
|
||||
onPageChange,
|
||||
onPageSizeChange,
|
||||
}: TableProps<T>) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize ?? 0);
|
||||
const [internalPage, setInternalPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(defaultPageSize ?? 10);
|
||||
|
||||
const paginated = defaultPageSize !== undefined && !loading;
|
||||
const visibleData = paginated ? data.slice((page - 1) * pageSize, page * pageSize) : data;
|
||||
const serverMode = total !== undefined;
|
||||
|
||||
// Reset to page 1 when data changes and current page would be empty
|
||||
const lastPage = pageSize > 0 ? Math.ceil(data.length / pageSize) : 1;
|
||||
const safePage = Math.min(page, Math.max(1, lastPage));
|
||||
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) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={styles.th}
|
||||
style={{
|
||||
width: col.width,
|
||||
textAlign: col.align ?? 'left',
|
||||
}}
|
||||
>
|
||||
{col.header}
|
||||
</th>
|
||||
))}
|
||||
{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>
|
||||
@@ -122,17 +196,39 @@ export function Table<T>({
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
{paginated && data.length > 0 && (
|
||||
{serverMode && serverTotal > 0 && onPageChange && (
|
||||
<Pagination
|
||||
page={safePage}
|
||||
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={setPage}
|
||||
onPageChange={setInternalPage}
|
||||
onPageSizeChange={
|
||||
pageSizeOptions
|
||||
? (size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
setInternalPage(1);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -62,7 +62,7 @@ export { Modal } from './Modal/Modal';
|
||||
export type { ModalProps } from './Modal/Modal';
|
||||
|
||||
export { Table } from './Table/Table';
|
||||
export type { TableProps, TableColumn } from './Table/Table';
|
||||
export type { TableProps, TableColumn, SortDirection } from './Table/Table';
|
||||
|
||||
export { UuidBadge } from './UuidBadge/UuidBadge';
|
||||
export type { UuidBadgeProps } from './UuidBadge/UuidBadge';
|
||||
|
||||
Reference in New Issue
Block a user