- Implemented asset and resource list query state helpers to manage loading, error, and availability states. - Created EntityListStatus component for displaying status messages and retry options based on entity list state. - Added EntityTableSkeleton component for rendering loading placeholders in tables. - Defined EntityListState type and entityListState function for consistent state management across components. - Developed comprehensive UI tests for list states using Playwright, covering various scenarios including loading, error handling, and responsiveness.
211 lines
7.0 KiB
TypeScript
211 lines
7.0 KiB
TypeScript
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@repo/shadcn-ui/components/table";
|
|
import type { Table as TableModel } from "@tanstack/react-table";
|
|
import type { ReactNode } from "react";
|
|
import { Fragment, isValidElement } from "react";
|
|
|
|
import type { EntityListState } from "../lib/entity-list-state";
|
|
|
|
import type { EntityColumn } from "./entity-list";
|
|
import { EntityTableSkeleton } from "./entity-table-skeleton";
|
|
|
|
export function EntityTableContent<T extends { id: string }>({
|
|
table,
|
|
columns,
|
|
title,
|
|
loading,
|
|
unavailable,
|
|
message,
|
|
rowClassName,
|
|
onRowClick,
|
|
onSort,
|
|
state,
|
|
}: {
|
|
table: TableModel<T>;
|
|
columns: readonly EntityColumn<T>[];
|
|
title: string;
|
|
loading: boolean;
|
|
unavailable: boolean;
|
|
message: string;
|
|
rowClassName: ((item: T) => string) | undefined;
|
|
onRowClick: ((item: T) => void) | undefined;
|
|
onSort: () => void;
|
|
state?: EntityListState | undefined;
|
|
}) {
|
|
const visibleColumns = [
|
|
...table.getCenterVisibleLeafColumns(),
|
|
...table.getRightVisibleLeafColumns(),
|
|
];
|
|
const total = table.getFilteredRowModel().rows.length;
|
|
|
|
return (
|
|
<Table
|
|
aria-busy={state?.status === "loading" || state?.status === "refreshing" || undefined}
|
|
aria-label={title}
|
|
>
|
|
<TableHeader>
|
|
<TableRow>
|
|
{visibleColumns.map((column) => {
|
|
const direction = column.getIsSorted();
|
|
|
|
return (
|
|
<TableHead
|
|
aria-sort={
|
|
direction === "asc"
|
|
? "ascending"
|
|
: direction === "desc"
|
|
? "descending"
|
|
: undefined
|
|
}
|
|
className={entityColumnClass(column.id, column.getIsPinned() === "right", true)}
|
|
data-pinned={column.getIsPinned() || undefined}
|
|
key={column.id}
|
|
scope="col"
|
|
>
|
|
{column.getCanSort() ? (
|
|
<button
|
|
className="inline-flex items-center gap-2 rounded py-1 text-left focus-visible:outline-2 focus-visible:outline-ring"
|
|
onClick={() => {
|
|
column.toggleSorting();
|
|
onSort();
|
|
}}
|
|
type="button"
|
|
>
|
|
{String(column.columnDef.header)}
|
|
<span aria-hidden="true">
|
|
{direction === "asc" ? "↑" : direction === "desc" ? "↓" : "↕"}
|
|
</span>
|
|
</button>
|
|
) : (
|
|
<span>{String(column.columnDef.header)}</span>
|
|
)}
|
|
</TableHead>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{state?.status === "loading" ? (
|
|
<EntityTableSkeleton
|
|
columns={visibleColumns.map((column) => ({
|
|
id: column.id,
|
|
className: entityColumnClass(column.id, column.getIsPinned() === "right", false),
|
|
}))}
|
|
rows={table.getState().pagination.pageSize}
|
|
/>
|
|
) : loading || unavailable ? (
|
|
<TableRow>
|
|
<TableCell
|
|
aria-hidden={state?.status === "unavailable" || undefined}
|
|
className={state?.status === "unavailable" ? "h-64" : undefined}
|
|
colSpan={visibleColumns.length}
|
|
>
|
|
{state?.status !== "unavailable" && (
|
|
<span className="text-muted-foreground">{message}</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
table.getRowModel().rows.map((row) => (
|
|
<TableRow
|
|
className={`group/entity-row ${onRowClick ? "cursor-pointer" : ""} ${rowClassName?.(row.original) ?? ""}`}
|
|
key={row.id}
|
|
onClick={
|
|
onRowClick
|
|
? (event) => {
|
|
// Los controles de la fila conservan su interacción propia.
|
|
if (
|
|
event.defaultPrevented ||
|
|
!(event.target instanceof Element) ||
|
|
event.target.closest(
|
|
'a, button, input, select, textarea, label, [role="button"], [role="menuitem"], [role="checkbox"], [role="radio"]'
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
onRowClick(row.original);
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{visibleColumns.map((column) => {
|
|
const content = columns.find((item) => item.id === column.id)?.render(row.original);
|
|
|
|
return (
|
|
<TableCell
|
|
className={entityColumnClass(
|
|
column.id,
|
|
column.getIsPinned() === "right",
|
|
false
|
|
)}
|
|
data-pinned={column.getIsPinned() || undefined}
|
|
key={column.id}
|
|
>
|
|
{hasCellContent(content) ? content : "—"}
|
|
</TableCell>
|
|
);
|
|
})}
|
|
</TableRow>
|
|
))
|
|
)}
|
|
{!(loading || unavailable || total) && (
|
|
<TableRow>
|
|
<TableCell className="p-6 text-muted-foreground" colSpan={visibleColumns.length}>
|
|
{message}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
);
|
|
}
|
|
|
|
function entityColumnClass(columnId: string, pinned: boolean, header: boolean) {
|
|
if (columnId === "selection") {
|
|
const labelVisibility = header ? "[&>span]:sr-only" : "";
|
|
|
|
return `w-px min-w-0 py-3 pr-1 pl-4 text-center whitespace-nowrap [&:has([role=checkbox])]:pr-1 ${labelVisibility}`;
|
|
}
|
|
|
|
if (columnId === "icon") {
|
|
const labelVisibility = header ? "[&>span]:sr-only bg-background" : "";
|
|
|
|
return `w-px min-w-0 py-3 pr-1 pl-3 text-center whitespace-nowrap ${labelVisibility}`;
|
|
}
|
|
|
|
if (pinned) {
|
|
return header
|
|
? "sticky right-0 z-20 w-20 min-w-20 bg-card pr-4 text-right"
|
|
: "sticky right-0 z-10 h-10 w-20 min-w-20 bg-card px-3 py-3 pr-4 text-right whitespace-nowrap group-hover/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] group-has-aria-expanded/entity-row:bg-[color-mix(in_oklch,var(--muted)_50%,var(--card))] ";
|
|
}
|
|
|
|
const nameWidth = columnId === "name" ? "min-w-40" : "first:min-w-40";
|
|
|
|
return header
|
|
? `${nameWidth} bg-background font-medium`
|
|
: `h-14 ${nameWidth} max-w-lg whitespace-normal break-words px-3 py-3`;
|
|
}
|
|
|
|
function hasCellContent(content: ReactNode): boolean {
|
|
if (content === null || content === undefined || typeof content === "boolean") {
|
|
return false;
|
|
}
|
|
if (typeof content === "string") {
|
|
return content.trim().length > 0;
|
|
}
|
|
if (Array.isArray(content)) {
|
|
return content.some(hasCellContent);
|
|
}
|
|
if (isValidElement<{ children?: ReactNode }>(content) && content.type === Fragment) {
|
|
return hasCellContent(content.props.children);
|
|
}
|
|
|
|
return true;
|
|
}
|