Pipeline/frontend/src/components/tables/DataTable.tsx

219 lines
7.2 KiB
TypeScript

import type { ReactNode } from "react";
import Link from "next/link";
import { type Row, type Table, flexRender } from "@tanstack/react-table";
import {
TableEmptyStateRow,
TableLoadingRow,
} from "@/components/ui/table-state";
import { Button, buttonVariants } from "@/components/ui/button";
export type DataTableEmptyState = {
icon: ReactNode;
title: string;
description: string;
actionHref?: string;
actionLabel?: string;
};
export type DataTableRowAction<TData> = {
key: string;
label: string;
href?: (row: TData) => string | null;
onClick?: (row: TData) => void;
className?: string;
};
export type DataTableRowActions<TData> = {
header?: ReactNode;
actions?: DataTableRowAction<TData>[];
getEditHref?: (row: TData) => string | null;
onDelete?: (row: TData) => void;
cellClassName?: string;
};
type DataTableProps<TData> = {
table: Table<TData>;
isLoading?: boolean;
loadingLabel?: string;
emptyMessage?: string;
emptyState?: DataTableEmptyState;
rowActions?: DataTableRowActions<TData>;
stickyHeader?: boolean;
tableClassName?: string;
headerClassName?: string;
headerCellClassName?: string;
bodyClassName?: string;
rowClassName?: string | ((row: Row<TData>) => string);
cellClassName?: string;
};
export function DataTable<TData>({
table,
isLoading = false,
loadingLabel = "Loading…",
emptyMessage = "No rows found.",
emptyState,
rowActions,
stickyHeader = false,
tableClassName = "w-full text-left text-sm",
headerClassName,
headerCellClassName = "px-3 py-2 md:px-6 md:py-3",
bodyClassName = "divide-y divide-[color:var(--border)]",
rowClassName = "transition-colors hover:bg-[color:var(--surface-muted)]",
cellClassName = "px-3 py-3 align-middle md:px-6",
}: DataTableProps<TData>) {
const resolvedRowActions = rowActions
? (rowActions.actions ??
[
rowActions.getEditHref
? ({
key: "edit",
label: "Edit",
href: rowActions.getEditHref,
} as DataTableRowAction<TData>)
: null,
rowActions.onDelete
? ({
key: "delete",
label: "Delete",
onClick: rowActions.onDelete,
} as DataTableRowAction<TData>)
: null,
].filter((value): value is DataTableRowAction<TData> => value !== null))
: [];
const hasRowActions = resolvedRowActions.length > 0;
const colSpan =
(table.getVisibleLeafColumns().length || 1) + (hasRowActions ? 1 : 0);
return (
<div className="overflow-x-auto">
<table className={tableClassName}>
<thead
className={
headerClassName ??
`${stickyHeader ? "sticky top-0 z-10 " : ""}bg-[color:var(--surface-muted)] text-xs font-semibold uppercase tracking-wider text-[color:var(--text-muted)]`
}
>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id} className={headerCellClassName}>
{header.isPlaceholder ? null : header.column.getCanSort() ? (
<button
type="button"
onClick={header.column.getToggleSortingHandler()}
className="inline-flex items-center gap-1 text-left"
>
<span>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</span>
{header.column.getIsSorted() === "asc" ? (
"↑"
) : header.column.getIsSorted() === "desc" ? (
"↓"
) : (
<span className="text-[color:var(--text-quiet)]">
</span>
)}
</button>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</th>
))}
{hasRowActions ? (
<th className={headerCellClassName}>
{rowActions?.header ?? ""}
</th>
) : null}
</tr>
))}
</thead>
<tbody className={bodyClassName}>
{isLoading ? (
<TableLoadingRow colSpan={colSpan} label={loadingLabel} />
) : table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<tr
key={row.id}
className={
typeof rowClassName === "function"
? rowClassName(row)
: rowClassName
}
>
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className={cellClassName}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
{hasRowActions ? (
<td className={rowActions?.cellClassName ?? cellClassName}>
<div className="flex justify-end gap-2">
{resolvedRowActions.map((action) => {
const href = action.href?.(row.original) ?? null;
if (href) {
return (
<Link
key={action.key}
href={href}
className={
action.className ??
buttonVariants({ variant: "ghost", size: "sm" })
}
>
{action.label}
</Link>
);
}
if (action.onClick) {
return (
<Button
key={action.key}
variant="ghost"
size="sm"
className={action.className}
onClick={() => action.onClick?.(row.original)}
>
{action.label}
</Button>
);
}
return null;
})}
</div>
</td>
) : null}
</tr>
))
) : emptyState ? (
<TableEmptyStateRow
colSpan={colSpan}
icon={emptyState.icon}
title={emptyState.title}
description={emptyState.description}
actionHref={emptyState.actionHref}
actionLabel={emptyState.actionLabel}
/>
) : (
<tr>
<td colSpan={colSpan} className="px-6 py-8 text-sm text-muted">
{emptyMessage}
</td>
</tr>
)}
</tbody>
</table>
</div>
);
}