admin: live query detail becomes a modal, live/history and period pickers become tabs and radios, client delete confirms in a dialog
the streamed-query detail panel is now a rac modal dialog with focus containment and restore. the live/history switch is rac tabs driven by the url, the overview period picker is a rac radio group, and the clients delete flow uses the shared confirm dialog; an authority turn keeps the dialog open and withdraws only the destructive action.
This commit is contained in:
@@ -57,7 +57,17 @@ function json(payload: unknown): Response {
|
||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response> = () => json({})) {
|
||||
/**
|
||||
* The empty history page. Live mode asks for no query pages, but switching back
|
||||
* to History does, and a page-shaped response is the only honest answer there.
|
||||
*/
|
||||
const EMPTY_PAGE = { queries: [], next_before: null, coverage: { complete: true, available_since: 0 } };
|
||||
|
||||
function defaultHandler(url: string): Response {
|
||||
return json(url === "/api/queries" || url.startsWith("/api/queries?") ? EMPTY_PAGE : {});
|
||||
}
|
||||
|
||||
function stubFetch(handler: (url: string) => Response | Promise<Response> = defaultHandler) {
|
||||
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
||||
@@ -256,7 +266,40 @@ test("a recovered row links to its stored detail; a streamed one opens in place
|
||||
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a streamed row opens its own provenance, from the keyboard as well as the pointer", async () => {
|
||||
/** The open detail. Named by its heading, so the query proves the name is visible. */
|
||||
function detailDialog(): HTMLElement {
|
||||
return screen.getByRole("dialog", { name: "Streamed query" });
|
||||
}
|
||||
|
||||
/** React Aria's ModalOverlay, two levels out from the dialog it wraps. */
|
||||
function backdrop(): HTMLElement {
|
||||
return detailDialog().parentElement!.parentElement!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate a control the way a keyboard does. jsdom runs no default action for
|
||||
* Enter on a button, so the click a browser would then dispatch is issued here;
|
||||
* `detail: 0` is what marks it as keyboard-driven rather than pointer-driven,
|
||||
* and is the flag React Aria itself reads.
|
||||
*/
|
||||
function pressWithKeyboard(control: HTMLElement) {
|
||||
act(() => control.focus());
|
||||
fireEvent.keyDown(control, { key: "Enter" });
|
||||
fireEvent.click(control, { detail: 0 });
|
||||
fireEvent.keyUp(control, { key: "Enter" });
|
||||
}
|
||||
|
||||
/** Activate a control the way a mouse does, through the full pointer sequence. */
|
||||
function pressWithMouse(control: HTMLElement) {
|
||||
fireEvent.pointerDown(control, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.pointerUp(control, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.click(control, { detail: 1 });
|
||||
}
|
||||
|
||||
test.each([
|
||||
["the pointer", pressWithMouse],
|
||||
["the keyboard", pressWithKeyboard],
|
||||
])("a streamed row opens its provenance in a named dialog, from %s", async (_label, press) => {
|
||||
await openLive();
|
||||
act(() =>
|
||||
sources[0]!.emit(
|
||||
@@ -273,69 +316,108 @@ test("a streamed row opens its own provenance, from the keyboard as well as the
|
||||
// only way to lose that is to opt out of it, which nothing here may do.
|
||||
expect(trigger.tagName).toBe("BUTTON");
|
||||
expect(trigger.getAttribute("tabindex")).toBeNull();
|
||||
act(() => trigger.focus());
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
// The row opens a dialog, so it says so; what it no longer claims is to
|
||||
// expand a region that stays in the page.
|
||||
expect(trigger.getAttribute("aria-haspopup")).toBe("dialog");
|
||||
expect(trigger.getAttribute("aria-expanded")).toBeNull();
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
|
||||
fireEvent.click(trigger);
|
||||
const heading = screen.getByRole("heading", { level: 1, name: "streamed.example" });
|
||||
expect(heading).toBeTruthy();
|
||||
const panel = heading.closest("div")!.parentElement!;
|
||||
expect(within(panel).getByText("Blocked locally")).toBeTruthy();
|
||||
expect(panel.textContent).toContain("the query log may not have written it yet");
|
||||
press(trigger);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
|
||||
const dialog = detailDialog();
|
||||
expect(within(dialog).getByRole("heading", { level: 1, name: "streamed.example" })).toBeTruthy();
|
||||
expect(within(dialog).getByText("Blocked locally")).toBeTruthy();
|
||||
expect(dialog.textContent).toContain("the query log may not have written it yet");
|
||||
// React Aria may defer the move by a frame, depending on the modality it read
|
||||
// from the activation, so the wait is the assertion rather than a workaround.
|
||||
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||
expect(within(dialog).getByRole("button", { name: "Close" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the detail takes focus when a row opens it and hands it back when it closes", async () => {
|
||||
test("tabbing forward and backward stays inside the open dialog", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||
|
||||
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
const dialog = detailDialog();
|
||||
await waitFor(() => expect(dialog.contains(document.activeElement)).toBe(true));
|
||||
// The related links land asynchronously; tabbing before them would walk a
|
||||
// shorter dialog than the reader ever sees.
|
||||
await waitFor(() => expect(within(dialog).getAllByRole("link").length).toBeGreaterThan(1));
|
||||
|
||||
// A native button activates on Enter and Space; jsdom does not synthesize
|
||||
// the click those keys fire, so the click is the activation.
|
||||
act(() => trigger.focus());
|
||||
fireEvent.click(trigger);
|
||||
|
||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(trigger.getAttribute("aria-controls")).toBe(panel.id);
|
||||
// The panel is inserted above the table, behind the trigger in tab order, so
|
||||
// the only thing that keeps a forward tab inside it is focus moving in.
|
||||
expect(panel.compareDocumentPosition(trigger) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
expect(panel.contains(screen.getByRole("button", { name: "Close" }))).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(screen.queryByRole("group", { name: "Streamed query" })).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
||||
const visited = new Set<Element>();
|
||||
for (const shiftKey of [false, false, false, false, false, false, true, true, true, true]) {
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Tab", shiftKey });
|
||||
fireEvent.keyUp(document.activeElement!, { key: "Tab", shiftKey });
|
||||
expect(dialog.contains(document.activeElement)).toBe(true);
|
||||
visited.add(document.activeElement!);
|
||||
}
|
||||
// Containment that never moved focus would satisfy the check above without
|
||||
// trapping anything, so the walk has to have actually walked.
|
||||
expect(visited.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
test("opening a second row moves the expanded state and the focus with it", async () => {
|
||||
test.each([
|
||||
["the Close button", () => fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }))],
|
||||
["Escape", () => fireEvent.keyDown(detailDialog(), { key: "Escape" })],
|
||||
[
|
||||
"a click on the backdrop",
|
||||
() => {
|
||||
const overlay = backdrop();
|
||||
fireEvent.pointerDown(overlay, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.pointerUp(overlay, { pointerType: "mouse", button: 0 });
|
||||
fireEvent.click(overlay, { detail: 1 });
|
||||
},
|
||||
],
|
||||
])("%s closes the dialog and returns focus to the row that opened it", async (_label, dismiss) => {
|
||||
await openLive();
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "first.example"));
|
||||
sources[0]!.emit("query", frame(1001, "second.example"));
|
||||
});
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
const trigger = screen.getByRole("button", { name: "streamed.example" });
|
||||
act(() => trigger.focus());
|
||||
fireEvent.click(trigger);
|
||||
expect(detailDialog()).toBeTruthy();
|
||||
|
||||
const first = screen.getByRole("button", { name: "first.example" });
|
||||
const second = screen.getByRole("button", { name: "second.example" });
|
||||
fireEvent.click(first);
|
||||
fireEvent.click(second);
|
||||
dismiss();
|
||||
|
||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
||||
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy();
|
||||
expect(document.activeElement).toBe(panel);
|
||||
expect(first.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(second.getAttribute("aria-expanded")).toBe("true");
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "streamed.example" }));
|
||||
});
|
||||
|
||||
test("the stream runs on behind the open dialog, and its rows land in the table on close", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||
|
||||
const snapshot = detailDialog().textContent;
|
||||
act(() => sources[0]!.emit("query", frame(1001, "arrived-while-open.example")));
|
||||
|
||||
// The connection is untouched: no close, no second EventSource.
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]!.closed).toBe(false);
|
||||
// And the snapshot is a snapshot: nothing that arrives rewrites it.
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
|
||||
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "arrived-while-open.example" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "streamed.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the rows and toolbar behind the dialog are out of reach while it is open", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||
|
||||
// React Aria hides everything outside the modal from assistive technology
|
||||
// and from the pointer alike, so the row and the toolbar are unreachable by
|
||||
// role: nothing behind the dialog can be operated while it is open.
|
||||
expect(screen.queryByRole("button", { name: "streamed.example" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Freeze" })).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
expect(document.activeElement).toBe(second);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "Freeze" })).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -359,23 +441,57 @@ function renderLiveWithCapacity(capacity: number) {
|
||||
);
|
||||
}
|
||||
|
||||
test("an open streamed detail survives the row being evicted from the ring buffer", () => {
|
||||
renderLiveWithCapacity(5);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
|
||||
// One ringful more: the ring keeps the newest 5, so the selected row is gone
|
||||
// from the table. The detail is a snapshot, not a lookup into the ring.
|
||||
/** Push one ringful of filler through a 5-row ring, evicting whatever was there. */
|
||||
function evictWithFiller() {
|
||||
act(() => {
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("an open dialog's snapshot survives its row being evicted from the ring buffer", async () => {
|
||||
renderLiveWithCapacity(5);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "evicted.example" }));
|
||||
const snapshot = detailDialog().textContent;
|
||||
expect(within(detailDialog()).getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
|
||||
// One ringful more: the ring keeps the newest 5, so the selected row is gone.
|
||||
// The dialog holds the frame itself, not a lookup into the ring, so it neither
|
||||
// blanks out nor closes.
|
||||
evictWithFiller();
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
|
||||
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(screen.getAllByRole("row")).toHaveLength(6);
|
||||
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
|
||||
expect(screen.getByRole("heading", { level: 1, name: "evicted.example" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("closing after the source row is evicted anchors focus in the results region", async () => {
|
||||
renderLiveWithCapacity(5);
|
||||
act(() => sources[0]!.emit("open"));
|
||||
act(() => sources[0]!.emit("query", frame(1000, "evicted.example")));
|
||||
const trigger = screen.getByRole("button", { name: "evicted.example" });
|
||||
act(() => trigger.focus());
|
||||
fireEvent.click(trigger);
|
||||
|
||||
evictWithFiller();
|
||||
expect(trigger.isConnected).toBe(false);
|
||||
|
||||
fireEvent.click(within(detailDialog()).getByRole("button", { name: "Close" }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
|
||||
// Never the body, never whichever row happens to sit where the old one did,
|
||||
// and never the Freeze button: a stable anchor in the region the reader was
|
||||
// reading. React Aria's own deferred restore runs after this and, finding
|
||||
// focus already placed, leaves it alone.
|
||||
const region = screen.getByRole("region", { name: "Live queries" });
|
||||
expect(document.activeElement).toBe(region);
|
||||
await act(() => new Promise((resolve) => requestAnimationFrame(() => resolve(undefined))));
|
||||
expect(document.activeElement).toBe(region);
|
||||
});
|
||||
|
||||
test("the route renders the live ring at its production capacity", async () => {
|
||||
@@ -391,11 +507,15 @@ test("an open streamed detail survives Freeze and Resume", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
|
||||
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
|
||||
const snapshot = detailDialog().textContent;
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
||||
// Freeze is behind the dialog, so it is reached the way the code reaches it
|
||||
// rather than by role, which the modal deliberately hides.
|
||||
const freeze = () => screen.getByText("Freeze") as HTMLButtonElement;
|
||||
act(() => freeze().click());
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
act(() => (screen.getByText("Resume") as HTMLButtonElement).click());
|
||||
expect(detailDialog().textContent).toBe(snapshot);
|
||||
});
|
||||
|
||||
test("the filter row stays visible, keeps its values, and is out of the tab order", async () => {
|
||||
@@ -431,7 +551,7 @@ test("live mode asks for no query pages, whatever filters the url retained", asy
|
||||
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "History" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||
await screen.findByRole("button", { name: "Apply filters" });
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]!.closed).toBe(true);
|
||||
@@ -440,7 +560,7 @@ test("leaving live closes the stream, and coming back opens exactly one fresh on
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Live" }));
|
||||
fireEvent.click(screen.getByRole("tab", { name: "Live" }));
|
||||
await screen.findByRole("button", { name: "Freeze" });
|
||||
expect(sources).toHaveLength(2);
|
||||
expect(sources[1]!.closed).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user