219 lines
7.2 KiB
TypeScript
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>
|
|
);
|
|
}
|