fix(ui): show filter-select label and shape loading skeletons per column

The workflow-runs Status filter leaked the internal "__all__" sentinel as its
displayed value because Base UI's Select.Value renders the raw value when no
items map or children function is given. Drop the sentinel and use Base UI's
native null handling: a null "All statuses" item plus a placeholder, with an
items map so a real selection renders its capitalized label rather than the
raw status string

The shared DataTable rendered every loading-skeleton cell as one identical
half-width bar, which read as a rigid grid instead of the table beneath it.
Vary the skeleton width per column and add a per-column skeleton shape hint
(text or twoLine) on ColumnMeta so identity columns like the workflow "Run"
cell get a two-line skeleton that matches their real content
This commit is contained in:
Yuneng Jiang 2026-07-10 18:05:49 -07:00
parent a88a9bfcb2
commit ae55170343
No known key found for this signature in database
5 changed files with 59 additions and 17 deletions

View file

@ -67,7 +67,13 @@ const STATUS_DOT: Record<RunStatus, string> = {
};
const RUN_STATUS_OPTIONS: RunStatus[] = ["pending", "running", "paused", "completed", "failed"];
const FILTER_ALL = "__all__";
const STATUS_LABELS: Record<RunStatus, string> = {
pending: "Pending",
running: "Running",
paused: "Paused",
completed: "Completed",
failed: "Failed",
};
const EVENT_COLOR: Record<string, { bar: string; border: string; text: string }> = {
"step.started": { bar: "#f0fdf4", border: "#86efac", text: "#16a34a" },
@ -562,7 +568,7 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
id: "run",
accessorFn: (row) => `${runTitle(row)} ${row.run_id}`,
header: "Run",
meta: { title: "Run" },
meta: { title: "Run", skeleton: "twoLine" },
cell: ({ row }) => {
const run = row.original;
return (
@ -674,17 +680,18 @@ const WorkflowRuns: React.FC<WorkflowRunsProps> = ({ accessToken }) => {
<>
<DataTableFilterField label="Status">
<Select
value={(get("status") as string) || FILTER_ALL}
onValueChange={(value) => set("status", value === FILTER_ALL ? "" : value)}
items={STATUS_LABELS}
value={(get("status") as string) || null}
onValueChange={(value: string | null) => set("status", value ?? "")}
>
<SelectTrigger className="w-full">
<SelectValue />
<SelectValue placeholder="All statuses" />
</SelectTrigger>
<SelectContent>
<SelectItem value={FILTER_ALL}>All statuses</SelectItem>
<SelectItem value={null}>All statuses</SelectItem>
{RUN_STATUS_OPTIONS.map((status) => (
<SelectItem key={status} value={status} className="capitalize">
{status}
<SelectItem key={status} value={status}>
{STATUS_LABELS[status]}
</SelectItem>
))}
</SelectContent>

View file

@ -286,6 +286,23 @@ describe("DataTable loading", () => {
expect(screen.queryAllByTestId("skeleton-row")).toHaveLength(0);
expect(names()).toEqual(["Charlie", "Alice", "Bob"]);
});
it("varies skeleton shape and width per column instead of one fixed bar", () => {
const columns: ColumnDef<Person, unknown>[] = [
{ accessorKey: "name", header: "Name", meta: { skeleton: "twoLine" }, cell: () => null },
{ accessorKey: "email", header: "Email", cell: () => null },
];
render(<DataTable data={CHARLIE_ALICE_BOB} columns={columns} isLoading />);
const firstRow = screen.getAllByTestId("skeleton-row").at(0);
expect(firstRow).toBeDefined();
const bars = Array.from(firstRow?.querySelectorAll('[data-slot="skeleton"]') ?? []);
// twoLine column contributes a main + sub bar (2); the text column contributes 1
expect(bars).toHaveLength(3);
// per-column widths differ instead of every cell sharing one fixed width
expect(new Set(bars.map((bar) => bar.className)).size).toBeGreaterThan(1);
});
});
describe("DataTable column visibility", () => {

View file

@ -333,26 +333,42 @@ function DefaultEmptyState() {
);
}
function SkeletonRows({
const SKELETON_WIDTHS = ["w-[58%]", "w-[44%]", "w-[70%]", "w-[50%]", "w-[64%]", "w-[48%]"] as const;
function SkeletonCell<TData>({ column, index }: { column: Column<TData, unknown> | undefined; index: number }) {
const meta = column?.columnDef.meta;
const width = SKELETON_WIDTHS[index % SKELETON_WIDTHS.length];
if (meta?.skeleton === "twoLine") {
return (
<div className="flex flex-col gap-2">
<Skeleton className={cn("h-3.5", width)} />
<Skeleton className="h-2.5 w-2/5 opacity-65" />
</div>
);
}
return <Skeleton className={cn("h-3.5", width, meta?.numeric ? "ml-auto" : "")} />;
}
function SkeletonRows<TData>({
rowCount,
columnCount,
columns,
size,
message,
}: {
rowCount: number;
columnCount: number;
columns: readonly Column<TData, unknown>[];
size: DataTableSize;
message?: string;
}) {
const rowKeys = Array.from({ length: Math.max(rowCount, 1) }, (_, index) => index);
const columnKeys = Array.from({ length: Math.max(columnCount, 1) }, (_, index) => index);
const cells = columns.length > 0 ? columns : [undefined];
return (
<Fragment>
{rowKeys.map((rowKey) => (
<TableRow key={`skeleton-${rowKey}`} className="hover:bg-transparent" data-testid="skeleton-row">
{columnKeys.map((columnKey) => (
<TableCell key={columnKey} className={size === "compact" ? "px-2 py-1" : ""}>
<Skeleton className="h-4 w-3/5" />
{cells.map((column, columnKey) => (
<TableCell key={column?.id ?? columnKey} className={size === "compact" ? "px-2 py-1" : ""}>
<SkeletonCell column={column} index={columnKey} />
{rowKey === 0 && columnKey === 0 && message !== undefined ? (
<span className="sr-only">{message}</span>
) : null}
@ -521,7 +537,7 @@ export function DataTable<TData extends RowData, TValue>(props: DataTableProps<T
return (
<SkeletonRows
rowCount={skeletonRowCount}
columnCount={visibleColumnCount}
columns={table.getVisibleLeafColumns()}
size={size}
message={loadingMessage}
/>

View file

@ -1,6 +1,6 @@
import type { RowData } from "@tanstack/react-table";
import type { ColumnPinnedSide } from "./types";
import type { ColumnPinnedSide, DataTableSkeletonShape } from "./types";
declare module "@tanstack/react-table" {
interface ColumnMeta<TData extends RowData, TValue> {
@ -9,5 +9,6 @@ declare module "@tanstack/react-table" {
headerClassName?: string;
title?: string;
pinned?: ColumnPinnedSide;
skeleton?: DataTableSkeletonShape;
}
}

View file

@ -18,6 +18,7 @@ export type FilterMode = "none" | "client" | "server";
export type ColumnResizeMode = "onEnd" | "onChange";
export type DataTableSize = "compact" | "default";
export type ColumnPinnedSide = "left" | "right";
export type DataTableSkeletonShape = "text" | "twoLine";
export interface DataTableProps<TData extends RowData, TValue> {
data: TData[];