Skip to content
93 changes: 93 additions & 0 deletions apps/website/app/api/rows/dataset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
export interface ServerRow extends Record<string, unknown> {
id: string;
region: string;
rep: string;
amount: number;
}

/** Deliberately small and low-cardinality: three regions, four reps. */
export const SERVER_ROWS: ServerRow[] = [
{ id: "s1", region: "East", rep: "Ada", amount: 120 },
{ id: "s2", region: "East", rep: "Brin", amount: 340 },
{ id: "s3", region: "East", rep: "Cyd", amount: 55 },
{ id: "s4", region: "North", rep: "Ada", amount: 900 },
{ id: "s5", region: "North", rep: "Dara", amount: 210 },
{ id: "s6", region: "West", rep: "Brin", amount: 75 },
{ id: "s7", region: "West", rep: "Cyd", amount: 480 },
{ id: "s8", region: "West", rep: "Dara", amount: 260 },
];

/**
* The wire shape of a query, as it arrives over JSON.
*
* Structurally looser than `PretableQueryFor<TColumns>`: the arrays are
* readonly and the entries carry only what this handler reads, so the grid's
* own query — which also carries `nulls`, and `direction` on row groups —
* assigns to it without a cast. `operator` and `value` are widened because a
* request body is untrusted input, not a typed value.
*/
export interface ServerQuery {
filters: readonly { columnId: string; operator: string; value?: unknown }[];
sort: readonly { columnId: string; direction: "asc" | "desc" }[];
rowGroups: readonly { columnId: string }[];
}

/**
* The "server". Applies the query to its own rows and returns the result.
*
* Read this before trusting any screen assertion built on it: the fixture is in
* controlled mode, which means the grid does not apply a query *transition*
* itself — it reports intent and waits for the consumer to hand back the next
* `query`. It does still apply whatever `query` prop it is holding to whatever
* `rows` prop it is holding. So when the fixture feeds it both the new query
* and the server's rows, the engine sorts and filters the server's answer a
* second time, and the two applications agree.
*
* The consequence is sharp: if this function returned rows in a random order,
* the grid would still show them correctly sorted, and a test that only looks
* at the screen would still pass. That is why the fixture publishes the
* server's answer verbatim (`data-server-row-ids`) and the e2e asserts on it
* and on the outgoing request bodies rather than on the rendered order alone.
*/
export function applyServerQuery(
rows: readonly ServerRow[],
query: ServerQuery,
): ServerRow[] {
let out = [...rows];

for (const filter of query.filters) {
const { columnId, operator, value } = filter;
if (operator === "isAnyOf" && Array.isArray(value)) {
out = out.filter((row) => value.includes(row[columnId]));
} else if (operator === "contains" && typeof value === "string") {
out = out.filter((row) =>
String(row[columnId]).toLowerCase().includes(value.toLowerCase()),
);
} else if (operator === "gte" && typeof value === "number") {
out = out.filter((row) => Number(row[columnId]) >= value);
}
}

for (const entry of [...query.sort].reverse()) {
const { columnId, direction } = entry;
out.sort((left, right) => {
const a = left[columnId];
const b = right[columnId];
const cmp =
typeof a === "number" && typeof b === "number"
? a - b
: String(a).localeCompare(String(b));
return direction === "desc" ? -cmp : cmp;
});
}

// Grouping is expressed as ordering here: rows arrive already clustered by
// the group key, which is what a server that cannot send tree structure does.
for (const group of [...query.rowGroups].reverse()) {
out.sort((left, right) =>
String(left[group.columnId]).localeCompare(String(right[group.columnId])),
);
}

return out;
}
22 changes: 22 additions & 0 deletions apps/website/app/api/rows/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";

import { applyServerQuery, SERVER_ROWS, type ServerQuery } from "./dataset";

/**
* Rows for a query. POST rather than GET so the query travels as JSON instead
* of a hand-rolled encoding, and so responses are never cached — the test
* asserts one request per query change and a cache hit would swallow them.
*/
export async function POST(request: Request): Promise<NextResponse> {
const query = (await request.json()) as Partial<ServerQuery>;
const rows = applyServerQuery(SERVER_ROWS, {
filters: query.filters ?? [],
sort: query.sort ?? [],
rowGroups: query.rowGroups ?? [],
});

return NextResponse.json(
{ rows, total: rows.length },
{ headers: { "cache-control": "no-store" } },
);
}
94 changes: 94 additions & 0 deletions apps/website/app/fixtures/server-query/ServerQueryGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"use client";

import { PretableSurface, type PretableColumn } from "@pretable/react";
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentProps,
} from "react";

import type { ServerRow } from "../../api/rows/dataset";

const COLUMNS: PretableColumn<ServerRow>[] = [
{ id: "region", header: "Region", type: "enum", widthPx: 140 },
{ id: "rep", header: "Rep", widthPx: 140 },
{ id: "amount", header: "Amount", type: "number", widthPx: 140 },
];

type SurfaceQuery = NonNullable<
ComponentProps<typeof PretableSurface<ServerRow>>["query"]
>;

const EMPTY_QUERY: SurfaceQuery = { filters: [], sort: [], rowGroups: [] };

/**
* Test fixture for `apps/website/e2e/server-query.spec.ts`, and the reference
* for wiring a real backend.
*
* Controlled mode against a real endpoint: the grid reports the query the user
* asked for, this component fetches rows for it, and the grid renders what
* comes back. Nothing here sorts or filters — that is the whole point.
*
* The two data attributes are the test's only honest evidence, and they exist
* because the screen is not evidence. Controlled mode stops the grid applying
* a query *transition* itself, but it still applies the `query` prop it holds
* to the `rows` prop it holds — and this component hands it both. So the
* engine re-sorts and re-filters the server's answer, and a rendered order
* assertion would pass even if `/api/rows` returned rows in a random order.
*
* - `data-fetch-count` proves a round-trip happened at all (freeze the fetch
* and it stops moving, while the screen keeps working).
* - `data-server-row-ids` is the server's answer verbatim, in the order it
* arrived, before the engine touches it. It is the one thing on the page
* that changes if the server stops applying the query.
*
* `groupPanel` is enabled because the column menu — the only way to add a row
* group by pointer — is rendered only when it is (`showColumnMenu` in
* packages/react/src/pretable-surface.tsx).
*
* Deliberately not part of the product surface, and not linked from the site.
*/
export function ServerQueryGrid() {
const columns = useMemo(() => COLUMNS, []);
const [query, setQuery] = useState<SurfaceQuery>(EMPTY_QUERY);
const [rows, setRows] = useState<ServerRow[]>([]);
const [fetchCount, setFetchCount] = useState(0);
const generation = useRef(0);

useEffect(() => {
const mine = ++generation.current;
void (async () => {
const response = await fetch("/api/rows", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(query),
});
const payload = (await response.json()) as { rows: ServerRow[] };
// A slower earlier request must not overwrite a newer answer.
if (mine !== generation.current) return;
setRows(payload.rows);
setFetchCount((count) => count + 1);
})();
}, [query]);

return (
<div
data-testid="server-query-fixture"
data-fetch-count={fetchCount}
data-server-row-ids={rows.map((row) => row.id).join(",")}
>
<PretableSurface<ServerRow>
ariaLabel="Server query grid"
columns={columns}
getRowId={(row) => row.id}
groupPanel={{ enabled: true }}
onQueryChange={setQuery}
query={query}
rows={rows}
viewportHeight={320}
/>
</div>
);
}
16 changes: 16 additions & 0 deletions apps/website/app/fixtures/server-query/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { ServerQueryGrid } from "./ServerQueryGrid";

/**
* Kept to a heading and the grid so the header row sits above the fold. The
* filter popover closes on any scroll (`overlay/useHeaderPopover.ts`), and
* Playwright auto-scrolls a target into view before clicking it, so a funnel
* below the fold opens and shuts in the same frame.
*/
export default function ServerQueryFixturePage() {
return (
<main style={{ padding: 24 }}>
<h1 style={{ marginBottom: 12 }}>Server query fixture</h1>
<ServerQueryGrid />
</main>
);
}
144 changes: 144 additions & 0 deletions apps/website/e2e/controlled-query.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { expect, test } from "@playwright/test";

import {
openFilterMenu,
waitForGridReady,
waitForStablePosition,
} from "./helpers";

/**
* The controlled-query surfaces on the docs pages: `query` + `onQueryChange`,
* where the consumer owns the query and the grid reports intent rather than
* applying it itself. This is the shape a server integration uses, and a manual
* smoke on 2026-08-14 could not verify either flow here — the filter check
* opened a non-checklist funnel and toggled nothing, and the collapse check
* counted rendered rows, which virtualization makes meaningless.
*/
test("checklist funnel filters a controlled grid, and the page sees the query", async ({
page,
}) => {
await page.goto("/docs/grid/filtering", { waitUntil: "domcontentloaded" });
await waitForGridReady(page);

// The example mounts with `status isAnyOf ["open"]` already applied and
// echoes it beneath the grid, so this establishes the starting state: the
// query is the consumer's (ColumnFiltersGrid.tsx's `useState`), not the
// grid's, and it is rendered. It says nothing yet about `onQueryChange` —
// at mount the echo only reflects that initial value. What proves the
// round-trip is the row count and the distinct statuses asserted after the
// toggle below, since in controlled mode the grid applies nothing and those
// rows can only have changed by way of the consumer's new query.
//
// Re-asserting the echo after the toggle would NOT strengthen this: it
// prints `${columnId} ${operator}`, and adding `shipped` to the same
// `isAnyOf` filter leaves the string "status isAnyOf" byte-identical. That
// assertion would pass whether or not the filter changed.
//
// Scoped to the Preview pane because `ExampleShell` keeps the Code pane
// mounted too (see its layout comment), and the source it renders is
// `ColumnFiltersGrid.tsx` — which contains the literal "Active filters:"
// that draws the echo. An unscoped match resolves to both.
const preview = page.getByRole("tabpanel", { name: "Preview" });
const echo = preview.getByText(/Active filters:/);
await expect(echo).toContainText("status");
const openRows = await page.locator("[data-pretable-row]").count();
expect(openRows).toBeGreaterThan(0);

// Scroll the header into place and let the page stop moving BEFORE opening
// anything. Playwright auto-scrolls a target into view before acting on it,
// and the docs routes scroll smoothly (`scroll-behavior: smooth`,
// app/globals.css), so that scroll is still animating when the next action
// fires. The popover closes on any scroll — deliberately, so it never floats
// away from its anchor (`overlay/useHeaderPopover.ts`) — so a click issued
// mid-glide opens the menu and the following animation frame shuts it again.
// Measured in WebKit: dialog present at t+1260ms, gone at t+1288ms, three
// runs out of three. Hovering here is what triggers the scroll;
// `openFilterMenu`'s own hover is then a no-op and its click lands on a still
// page. `smoke.spec.ts` never needed this because the hero grid sits at the
// top of the page and is already in view.
await page.locator("[data-pretable-header-row]").first().hover();
await waitForStablePosition(
page.locator(
'[data-pretable-filter-funnel][data-pretable-column-id="status"]',
),
);

// `status` is an enum column that declares no `options`, so this checklist is
// built from the rows' distinct values. All three must be offered.
const dialog = await openFilterMenu(page, "Status");
for (const value of ["open", "shipped", "cancelled"]) {
await expect(dialog.getByRole("checkbox", { name: value })).toBeVisible();
}

// Add `shipped` to the selection: strictly more rows, and both values present.
await dialog.getByRole("checkbox", { name: "shipped" }).check();
await page.keyboard.press("Escape");

await expect
.poll(() => page.locator("[data-pretable-row]").count())
.toBeGreaterThan(openRows);

const statuses = await page.$$eval(
'[data-pretable-row] [data-pretable-column-id="status"]',
(cells) => [...new Set(cells.map((cell) => cell.textContent?.trim()))],
);
expect([...statuses].sort()).toEqual(["open", "shipped"]);
});

test("collapsing a group hides its children, and expanding brings them back", async ({
page,
}) => {
await page.goto("/docs/grid/grouping", { waitUntil: "domcontentloaded" });
await waitForGridReady(page);

const firstGroup = page.locator("[data-pretable-group-row]").first();
await expect(firstGroup).toBeVisible();
await expect(firstGroup).toHaveAttribute("aria-expanded", "true");

// A NAMED child, not a row count. The grid virtualizes: collapsing a group
// pulls rows in from below, so `[data-pretable-row]` barely moves and a
// count-based assertion reports "collapse does nothing" — which is exactly
// what the 2026-08-14 manual smoke concluded, wrongly.
//
// `[data-pretable-row]` alone is already child-rows-only: group rows carry
// `data-pretable-group-row` and `data-pretable-row-id` but never
// `data-pretable-row` (packages/react/src/group-row.tsx vs the data-row
// branch in pretable-surface.tsx), so there is nothing here to exclude.
const childIds = await page.$$eval("[data-pretable-row]", (rows) =>
rows
.map((row) => row.getAttribute("data-pretable-row-id"))
.filter((id): id is string => id !== null),
);
expect(childIds.length).toBeGreaterThan(0);
// Scoped to `[data-pretable-row]` because group rows DO share the row-id
// attribute — the ids happen not to collide here (`p1`… vs `__group__:…`),
// but the locator should not depend on that.
const child = page.locator(
`[data-pretable-row][data-pretable-row-id="${childIds[0]}"]`,
);
await expect(child).toHaveCount(1);

// Settle before pressing, for the reason spelled out in the filtering test
// above: Playwright auto-scrolls the twisty into view, the docs routes scroll
// smoothly, and the target is 18px wide, so a press issued mid-glide can miss
// it. Nothing amplifies the miss here the way the filter popover's
// close-on-scroll does, which is what makes it worth guarding — a click that
// misses a twisty is silent, and the failure surfaces two assertions later as
// "collapse did nothing".
//
// Prophylactic, not a diagnosed fix: one WebKit run of this test failed once
// in ~37, on a machine at load average 11, and neither 20 repeat-each runs
// nor 10 further full-file runs reproduced it — its message was never
// captured (`reporter: "list"`, no HTML report). This is the most plausible
// mechanism, not a confirmed one.
const collapse = firstGroup.getByRole("button", { name: /^Collapse / });
await collapse.hover();
await waitForStablePosition(collapse);
await collapse.click();
await expect(firstGroup).toHaveAttribute("aria-expanded", "false");
await expect(child).toHaveCount(0);

await firstGroup.getByRole("button", { name: /^Expand / }).click();
await expect(firstGroup).toHaveAttribute("aria-expanded", "true");
await expect(child).toHaveCount(1);
});
Loading