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:
@@ -12,6 +12,7 @@ import { createAppRouter } from "@/routes";
|
|||||||
import { health } from "@/lib/healthFixture";
|
import { health } from "@/lib/healthFixture";
|
||||||
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||||
import { queryRow } from "@/features/provenance/provenanceFixture";
|
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||||
|
import { FakeEventSource } from "./fakeEventSource";
|
||||||
|
|
||||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||||
return {
|
return {
|
||||||
@@ -544,6 +545,40 @@ test("the policy simulation is reachable from the header, with no rows to click
|
|||||||
expect(history.location.pathname).toBe("/activity/test");
|
expect(history.location.pathname).toBe("/activity/test");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the mode switch is a tab list whose selection is the url, and it keeps the filters", async () => {
|
||||||
|
// Live opens a stream as soon as its panel mounts, so the switch cannot be
|
||||||
|
// exercised without one; the fake stands in for the browser's EventSource.
|
||||||
|
vi.stubGlobal(
|
||||||
|
"EventSource",
|
||||||
|
class {
|
||||||
|
constructor(url: string) {
|
||||||
|
return new FakeEventSource(url) as unknown as EventSource;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||||
|
await screen.findByText("ads.example");
|
||||||
|
|
||||||
|
const tabs = within(screen.getByRole("tablist", { name: "Activity mode" }));
|
||||||
|
expect(tabs.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["History", "Live"]);
|
||||||
|
expect(tabs.getByRole("tab", { name: "History", selected: true })).toBeTruthy();
|
||||||
|
expect(tabs.getByRole("tab", { name: "Live", selected: false })).toBeTruthy();
|
||||||
|
|
||||||
|
fireEvent.click(tabs.getByRole("tab", { name: "Live" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(history.location.search).toContain("mode=live"));
|
||||||
|
// The investigation survives the switch: both filters are still in the URL.
|
||||||
|
expect(history.location.search).toContain("domain=ads");
|
||||||
|
expect(history.location.search).toContain("blocked=true");
|
||||||
|
expect(
|
||||||
|
within(screen.getByRole("tablist", { name: "Activity mode" })).getByRole("tab", {
|
||||||
|
name: "Live",
|
||||||
|
selected: true,
|
||||||
|
}),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(await screen.findByText(/these filters apply to history only/)).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
test("Clear empties the url as well as the form", async () => {
|
test("Clear empties the url as well as the form", async () => {
|
||||||
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||||
await screen.findByText("ads.example");
|
await screen.findByText("ads.example");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
|
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||||
@@ -53,6 +54,13 @@ const styles = stylex.create({
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
},
|
},
|
||||||
|
/** A Tab is a `div` with a roving tabindex, so RAC drives the ring, not `:focus-visible`. */
|
||||||
|
modeFocusVisible: {
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineColor: colors.focus,
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
modeIdle: {
|
modeIdle: {
|
||||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||||
color: { default: colors.textSecondary, ":hover": colors.text },
|
color: { default: colors.textSecondary, ":hover": colors.text },
|
||||||
@@ -76,8 +84,30 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
|
panel: {
|
||||||
|
outlineStyle: "none",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* A panel with nothing tabbable in it — an empty or loading history — is given
|
||||||
|
* a tabindex by RAC so the reader can still reach its content, so it has to be
|
||||||
|
* able to show that it holds the focus.
|
||||||
|
*/
|
||||||
|
panelFocusVisible: {
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineColor: colors.focus,
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
|
/** Explicit, so RAC's default `react-aria-Tabs` class does not land instead. */
|
||||||
|
tabsRoot: {
|
||||||
|
display: "block",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function panelClass({ isFocusVisible }: { isFocusVisible: boolean }): string {
|
||||||
|
return stylex.props(styles.panel, isFocusVisible && styles.panelFocusVisible).className ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const search = useSearch({ from: "/shell/activity" });
|
const search = useSearch({ from: "/shell/activity" });
|
||||||
const navigate = useNavigate({ from: "/activity" });
|
const navigate = useNavigate({ from: "/activity" });
|
||||||
@@ -97,28 +127,38 @@ export default function ActivityPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
|
{/*
|
||||||
|
* The selected tab is the URL's `mode` and nothing else. RAC would hold
|
||||||
|
* the selection itself, but a second copy of it would fight the back
|
||||||
|
* button, so the search parameter stays the only state there is.
|
||||||
|
*/}
|
||||||
|
<Tabs
|
||||||
|
selectedKey={search.mode}
|
||||||
|
onSelectionChange={(key) => selectMode(key as ActivityMode)}
|
||||||
|
className={() => stylex.props(styles.tabsRoot).className ?? ""}
|
||||||
|
>
|
||||||
<div {...stylex.props(styles.header)}>
|
<div {...stylex.props(styles.header)}>
|
||||||
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
||||||
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
|
<TabList
|
||||||
{MODES.map((option) => {
|
aria-label="Activity mode"
|
||||||
const selected = option.mode === search.mode;
|
className={() => stylex.props(styles.switch).className ?? ""}
|
||||||
return (
|
>
|
||||||
<button
|
{MODES.map((option) => (
|
||||||
|
<Tab
|
||||||
key={option.mode}
|
key={option.mode}
|
||||||
type="button"
|
id={option.mode}
|
||||||
aria-pressed={selected}
|
className={({ isSelected, isFocusVisible }) =>
|
||||||
onClick={() => selectMode(option.mode)}
|
stylex.props(
|
||||||
{...stylex.props(
|
|
||||||
styles.modeButton,
|
styles.modeButton,
|
||||||
selected ? styles.modeSelected : styles.modeIdle,
|
isSelected ? styles.modeSelected : styles.modeIdle,
|
||||||
shared.focusRing,
|
isFocusVisible && styles.modeFocusVisible,
|
||||||
)}
|
).className ?? ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</Tab>
|
||||||
);
|
))}
|
||||||
})}
|
</TabList>
|
||||||
</div>
|
|
||||||
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
||||||
Current policy simulation
|
Current policy simulation
|
||||||
</Link>
|
</Link>
|
||||||
@@ -137,16 +177,16 @@ export default function ActivityPage() {
|
|||||||
onClear={() => apply(NO_FILTERS)}
|
onClear={() => apply(NO_FILTERS)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{live ? (
|
<TabPanel id="history" className={panelClass}>
|
||||||
<>
|
<HistoryActivity search={search} />
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel id="live" className={panelClass}>
|
||||||
<p {...stylex.props(styles.liveNote)}>
|
<p {...stylex.props(styles.liveNote)}>
|
||||||
The stream carries every query the server answers; these filters apply to history only.
|
The stream carries every query the server answers; these filters apply to history only.
|
||||||
</p>
|
</p>
|
||||||
<LiveActivity origin={search} />
|
<LiveActivity origin={search} />
|
||||||
</>
|
</TabPanel>
|
||||||
) : (
|
</Tabs>
|
||||||
<HistoryActivity search={search} />
|
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,17 @@ function json(payload: unknown): Response {
|
|||||||
return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } });
|
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) => {
|
fetchMock = vi.fn((input: RequestInfo | URL) => {
|
||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url === "/api/version") return Promise.resolve(json(VERSION));
|
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();
|
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();
|
await openLive();
|
||||||
act(() =>
|
act(() =>
|
||||||
sources[0]!.emit(
|
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.
|
// only way to lose that is to opt out of it, which nothing here may do.
|
||||||
expect(trigger.tagName).toBe("BUTTON");
|
expect(trigger.tagName).toBe("BUTTON");
|
||||||
expect(trigger.getAttribute("tabindex")).toBeNull();
|
expect(trigger.getAttribute("tabindex")).toBeNull();
|
||||||
act(() => trigger.focus());
|
// The row opens a dialog, so it says so; what it no longer claims is to
|
||||||
expect(document.activeElement).toBe(trigger);
|
// 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);
|
press(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");
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
const dialog = detailDialog();
|
||||||
expect(screen.queryByRole("heading", { level: 1, name: "streamed.example" })).toBeNull();
|
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();
|
await openLive();
|
||||||
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||||
|
|
||||||
|
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));
|
||||||
|
|
||||||
|
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.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, "streamed.example")));
|
||||||
const trigger = 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();
|
|
||||||
|
|
||||||
// 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());
|
act(() => trigger.focus());
|
||||||
fireEvent.click(trigger);
|
fireEvent.click(trigger);
|
||||||
|
expect(detailDialog()).toBeTruthy();
|
||||||
|
|
||||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
dismiss();
|
||||||
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" }));
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
expect(screen.queryByRole("group", { name: "Streamed query" })).toBeNull();
|
expect(document.activeElement).toBe(screen.getByRole("button", { name: "streamed.example" }));
|
||||||
expect(document.activeElement).toBe(trigger);
|
|
||||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
|
||||||
expect(trigger.getAttribute("aria-controls")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("opening a second row moves the expanded state and the focus with it", async () => {
|
test("the stream runs on behind the open dialog, and its rows land in the table on close", async () => {
|
||||||
await openLive();
|
await openLive();
|
||||||
act(() => {
|
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||||
sources[0]!.emit("query", frame(1000, "first.example"));
|
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||||
sources[0]!.emit("query", frame(1001, "second.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();
|
||||||
});
|
});
|
||||||
|
|
||||||
const first = screen.getByRole("button", { name: "first.example" });
|
test("the rows and toolbar behind the dialog are out of reach while it is open", async () => {
|
||||||
const second = screen.getByRole("button", { name: "second.example" });
|
await openLive();
|
||||||
fireEvent.click(first);
|
act(() => sources[0]!.emit("query", frame(1000, "streamed.example")));
|
||||||
fireEvent.click(second);
|
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||||
|
|
||||||
const panel = screen.getByRole("group", { name: "Streamed query" });
|
// React Aria hides everything outside the modal from assistive technology
|
||||||
expect(within(panel).getByRole("heading", { level: 1, name: "second.example" })).toBeTruthy();
|
// and from the pointer alike, so the row and the toolbar are unreachable by
|
||||||
expect(document.activeElement).toBe(panel);
|
// role: nothing behind the dialog can be operated while it is open.
|
||||||
expect(first.getAttribute("aria-expanded")).toBe("false");
|
expect(screen.queryByRole("button", { name: "streamed.example" })).toBeNull();
|
||||||
expect(second.getAttribute("aria-expanded")).toBe("true");
|
expect(screen.queryByRole("button", { name: "Freeze" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
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", () => {
|
/** Push one ringful of filler through a 5-row ring, evicting whatever was there. */
|
||||||
renderLiveWithCapacity(5);
|
function evictWithFiller() {
|
||||||
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.
|
|
||||||
act(() => {
|
act(() => {
|
||||||
for (let index = 0; index < 5; index += 1) {
|
for (let index = 0; index < 5; index += 1) {
|
||||||
sources[0]!.emit("query", frame(2000 + index, `filler${index}.example`));
|
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.getAllByRole("row")).toHaveLength(6);
|
||||||
expect(screen.queryByRole("button", { name: "evicted.example" })).toBeNull();
|
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 () => {
|
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();
|
await openLive();
|
||||||
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
|
act(() => sources[0]!.emit("query", frame(1000, "held.example")));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
|
fireEvent.click(screen.getByRole("button", { name: "held.example" }));
|
||||||
|
const snapshot = detailDialog().textContent;
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Freeze" }));
|
// Freeze is behind the dialog, so it is reached the way the code reaches it
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
// rather than by role, which the modal deliberately hides.
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Resume" }));
|
const freeze = () => screen.getByText("Freeze") as HTMLButtonElement;
|
||||||
expect(screen.getByRole("heading", { level: 1, name: "held.example" })).toBeTruthy();
|
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 () => {
|
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 () => {
|
test("leaving live closes the stream, and coming back opens exactly one fresh one", async () => {
|
||||||
await openLive("/activity?mode=live&domain=ads");
|
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" });
|
await screen.findByRole("button", { name: "Apply filters" });
|
||||||
expect(sources).toHaveLength(1);
|
expect(sources).toHaveLength(1);
|
||||||
expect(sources[0]!.closed).toBe(true);
|
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).value).toBe("ads");
|
||||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
|
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" });
|
await screen.findByRole("button", { name: "Freeze" });
|
||||||
expect(sources).toHaveLength(2);
|
expect(sources).toHaveLength(2);
|
||||||
expect(sources[1]!.closed).toBe(false);
|
expect(sources[1]!.closed).toBe(false);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
|
* Activity in live mode: the SSE stream, its bounded ring buffer, and the modal
|
||||||
* in-place detail a streamed row opens.
|
* detail a streamed row opens.
|
||||||
*
|
*
|
||||||
* This subtree is mounted only while the URL says `mode=live`, which is what
|
* This subtree is mounted only while the URL says `mode=live`, which is what
|
||||||
* closes the EventSource on the way back to history: the connection is a
|
* closes the EventSource on the way back to history: the connection is a
|
||||||
@@ -17,6 +17,7 @@ import { Link } from "@tanstack/react-router";
|
|||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { useClientNames } from "@/features/clients/clientNames";
|
import { useClientNames } from "@/features/clients/clientNames";
|
||||||
import { summarizeEvent } from "@/features/provenance/querySummary";
|
import { summarizeEvent } from "@/features/provenance/querySummary";
|
||||||
|
import Dialog from "@/ui/Dialog";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||||
@@ -162,29 +163,6 @@ const styles = stylex.create({
|
|||||||
textDecorationLine: "underline",
|
textDecorationLine: "underline",
|
||||||
textDecorationStyle: "dotted",
|
textDecorationStyle: "dotted",
|
||||||
},
|
},
|
||||||
detailPanel: {
|
|
||||||
marginTop: "1rem",
|
|
||||||
borderRadius: "0.25rem",
|
|
||||||
borderWidth: 1,
|
|
||||||
borderStyle: "solid",
|
|
||||||
borderColor: colors.borderStrong,
|
|
||||||
backgroundColor: colors.surface,
|
|
||||||
padding: "1rem",
|
|
||||||
},
|
|
||||||
detailBar: {
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "baseline",
|
|
||||||
justifyContent: "space-between",
|
|
||||||
gap: "0.75rem",
|
|
||||||
},
|
|
||||||
detailLabel: {
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
lineHeight: "1rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: "0.05em",
|
|
||||||
textTransform: "uppercase",
|
|
||||||
color: colors.textMuted,
|
|
||||||
},
|
|
||||||
footnote: {
|
footnote: {
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
@@ -193,10 +171,6 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/** One panel at a time, so the trigger that opened it can name it in `aria-controls`. */
|
|
||||||
const DETAIL_PANEL_ID = "live-query-detail";
|
|
||||||
const DETAIL_LABEL_ID = "live-query-detail-label";
|
|
||||||
|
|
||||||
const PILL_LABELS: Record<StreamStatus, string> = {
|
const PILL_LABELS: Record<StreamStatus, string> = {
|
||||||
connecting: "Connecting…",
|
connecting: "Connecting…",
|
||||||
open: "Live",
|
open: "Live",
|
||||||
@@ -226,40 +200,15 @@ function StatusPill({ status }: { status: StreamStatus }) {
|
|||||||
*
|
*
|
||||||
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
|
* The buffer is a 500-row ring that a gap merge also rewrites: a reference by
|
||||||
* key would go stale under the reader while they were still reading it, and the
|
* key would go stale under the reader while they were still reading it, and the
|
||||||
* panel would blank out for no reason they could see. The snapshot is the whole
|
* dialog would blank out or swap under their eyes for no reason they could see.
|
||||||
* fact — a streamed frame carries its own provenance — so it survives eviction,
|
* The snapshot is the whole fact — a streamed frame carries its own provenance
|
||||||
* a merge and a Freeze/Resume, and closes only when the reader closes it or
|
* — so it survives eviction, a merge and a Freeze/Resume, and closes only when
|
||||||
* leaves live mode.
|
* the reader closes it or leaves live mode. The stream behind it never stops.
|
||||||
*/
|
*/
|
||||||
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
|
function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: ActivitySearch; onClose: () => void }) {
|
||||||
const summary = summarizeEvent(row.event);
|
const summary = summarizeEvent(row.event);
|
||||||
const panel = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// The panel opens above the table, behind the trigger in tab order, so a
|
|
||||||
// forward tab from the row would walk past it. Focus moves in on open —
|
|
||||||
// keyed on the row, so choosing a second row moves it again — and the
|
|
||||||
// closer puts it back on the trigger.
|
|
||||||
useEffect(() => {
|
|
||||||
panel.current?.focus();
|
|
||||||
}, [row.key]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Dialog title="Streamed query" size="detail" isOpen onClose={onClose}>
|
||||||
ref={panel}
|
|
||||||
id={DETAIL_PANEL_ID}
|
|
||||||
tabIndex={-1}
|
|
||||||
role="group"
|
|
||||||
aria-labelledby={DETAIL_LABEL_ID}
|
|
||||||
{...stylex.props(styles.detailPanel)}
|
|
||||||
>
|
|
||||||
<div {...stylex.props(styles.detailBar)}>
|
|
||||||
<span id={DETAIL_LABEL_ID} {...stylex.props(styles.detailLabel)}>
|
|
||||||
Streamed query
|
|
||||||
</span>
|
|
||||||
<button type="button" onClick={onClose} {...stylex.props(shared.button, shared.focusRing)}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<ProvenanceDetail
|
<ProvenanceDetail
|
||||||
provenance={row.event}
|
provenance={row.event}
|
||||||
persistedId={null}
|
persistedId={null}
|
||||||
@@ -268,10 +217,11 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
|
|||||||
domain={summary.domain}
|
domain={summary.domain}
|
||||||
client={summary.client_ip}
|
client={summary.client_ip}
|
||||||
ts={summary.ts}
|
ts={summary.ts}
|
||||||
origin={origin} />
|
origin={origin}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,21 +241,36 @@ export default function LiveActivity({
|
|||||||
const clientNames = useClientNames();
|
const clientNames = useClientNames();
|
||||||
const [selected, setSelected] = useState<StreamedRow | null>(null);
|
const [selected, setSelected] = useState<StreamedRow | null>(null);
|
||||||
const trigger = useRef<HTMLButtonElement | null>(null);
|
const trigger = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const results = useRef<HTMLDivElement>(null);
|
||||||
|
const restoring = useRef(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where focus lands when the dialog closes.
|
||||||
|
*
|
||||||
|
* React Aria restores focus itself, but in a `requestAnimationFrame` and
|
||||||
|
* only while focus is still on the body — and its target is the row button,
|
||||||
|
* which the ring may have evicted while the reader was reading. So this runs
|
||||||
|
* in the effect that follows the focus scope's teardown and puts focus on a
|
||||||
|
* connected element first: the row if it is still there, the results region
|
||||||
|
* if it is not. React Aria's deferred pass then finds focus already placed
|
||||||
|
* and does nothing, so the two never fight over it. If a navigation unmounts
|
||||||
|
* this component the effect never runs, which is the right answer — there is
|
||||||
|
* no longer a table to return to.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (selected !== null || !restoring.current) return;
|
||||||
|
restoring.current = false;
|
||||||
|
const from = trigger.current;
|
||||||
|
trigger.current = null;
|
||||||
|
(from?.isConnected === true ? from : results.current)?.focus();
|
||||||
|
}, [selected]);
|
||||||
|
|
||||||
function open(row: StreamedRow, from: HTMLButtonElement) {
|
function open(row: StreamedRow, from: HTMLButtonElement) {
|
||||||
trigger.current = from;
|
trigger.current = from;
|
||||||
|
restoring.current = true;
|
||||||
setSelected(row);
|
setSelected(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The row that opened the panel takes focus back, unless the ring has
|
|
||||||
// already evicted it: a detached button cannot be focused, and the browser
|
|
||||||
// falls back to the document, which is the best available answer.
|
|
||||||
function close() {
|
|
||||||
setSelected(null);
|
|
||||||
trigger.current?.focus();
|
|
||||||
trigger.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div {...stylex.props(styles.toolbar)}>
|
<div {...stylex.props(styles.toolbar)}>
|
||||||
@@ -361,8 +326,18 @@ export default function LiveActivity({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={close} />}
|
{/*
|
||||||
|
* The region is the anchor focus falls back to when the row that
|
||||||
|
* opened the dialog is gone, so it is rendered unconditionally: an
|
||||||
|
* anchor that disappears with the last row is no anchor at all.
|
||||||
|
*/}
|
||||||
|
<div
|
||||||
|
ref={results}
|
||||||
|
tabIndex={-1}
|
||||||
|
role="region"
|
||||||
|
aria-label="Live queries"
|
||||||
|
{...stylex.props(shared.focusRing)}
|
||||||
|
>
|
||||||
{live.rows.length === 0 ? (
|
{live.rows.length === 0 ? (
|
||||||
live.status !== "capped" && (
|
live.status !== "capped" && (
|
||||||
<p {...stylex.props(styles.empty)}>
|
<p {...stylex.props(styles.empty)}>
|
||||||
@@ -389,10 +364,7 @@ export default function LiveActivity({
|
|||||||
row.kind === "streamed" ? (
|
row.kind === "streamed" ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={selected?.key === row.key}
|
aria-haspopup="dialog"
|
||||||
aria-controls={
|
|
||||||
selected?.key === row.key ? DETAIL_PANEL_ID : undefined
|
|
||||||
}
|
|
||||||
onClick={(event) => open(row, event.currentTarget)}
|
onClick={(event) => open(row, event.currentTarget)}
|
||||||
{...stylex.props(
|
{...stylex.props(
|
||||||
styles.domainButton,
|
styles.domainButton,
|
||||||
@@ -421,11 +393,14 @@ export default function LiveActivity({
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<p {...stylex.props(styles.footnote)}>
|
<p {...stylex.props(styles.footnote)}>
|
||||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first,
|
||||||
{capacity} kept).
|
last {capacity} kept).
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={() => setSelected(null)} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,16 +18,10 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
heading: {
|
|
||||||
fontSize: "1.125rem",
|
|
||||||
lineHeight: "1.75rem",
|
|
||||||
fontWeight: 600,
|
|
||||||
},
|
|
||||||
form: {
|
form: {
|
||||||
display: "flex",
|
display: "flex",
|
||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
gap: "1rem",
|
gap: "1rem",
|
||||||
marginTop: "1rem",
|
|
||||||
},
|
},
|
||||||
fieldLabel: {
|
fieldLabel: {
|
||||||
display: "block",
|
display: "block",
|
||||||
@@ -67,8 +61,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
|||||||
const readOnly = useReadOnlyConfig();
|
const readOnly = useReadOnlyConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
<Dialog title={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||||
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
|
|
||||||
<form
|
<form
|
||||||
{...stylex.props(styles.form)}
|
{...stylex.props(styles.form)}
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
|
|||||||
@@ -73,6 +73,39 @@ test("the address links to the client's detail page", async () => {
|
|||||||
expect(link.getAttribute("href")).toBe("/clients/1");
|
expect(link.getAttribute("href")).toBe("/clients/1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("delete asks first, naming the row, and the confirmation carries out the delete", async () => {
|
||||||
|
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
|
||||||
|
|
||||||
|
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
|
||||||
|
|
||||||
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
// The operator has to be able to tell from the dialog alone which row this is.
|
||||||
|
expect(within(dialog).getByText(/kids-tablet\.lan \(192\.168\.1\.11\)/)).toBeTruthy();
|
||||||
|
expect(within(dialog).getByText(/re-materialize on their next DNS query/)).toBeTruthy();
|
||||||
|
// Asking is not deleting.
|
||||||
|
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||||
|
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2")),
|
||||||
|
).toHaveLength(1),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cancelling the confirmation keeps the client", async () => {
|
||||||
|
const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });
|
||||||
|
|
||||||
|
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
|
expect(screen.getByText("192.168.1.11")).toBeTruthy();
|
||||||
|
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test("shows the DNS-activity empty state when there are no clients", async () => {
|
test("shows the DNS-activity empty state when there are no clients", async () => {
|
||||||
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
||||||
|
|
||||||
@@ -218,7 +251,7 @@ describe.each([
|
|||||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||||
});
|
});
|
||||||
|
|
||||||
test("locks an open declared-client delete confirmation and leaves cancel working", async () => {
|
test("locks an open declared-client delete confirmation in place and leaves cancel working", async () => {
|
||||||
const map = { ...BASE };
|
const map = { ...BASE };
|
||||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||||
// The Edit affordance appearing is the proof authority resolved to
|
// The Edit affordance appearing is the proof authority resolved to
|
||||||
@@ -227,23 +260,66 @@ describe.each([
|
|||||||
|
|
||||||
const declared = clientRow("192.168.1.10");
|
const declared = clientRow("192.168.1.10");
|
||||||
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
|
fireEvent.click(within(declared).getByRole("button", { name: "Delete" }));
|
||||||
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy();
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
|
||||||
await setConfigStatus(map, queryClient, status);
|
await setConfigStatus(map, queryClient, status);
|
||||||
|
|
||||||
const confirming = clientRow("192.168.1.10");
|
// The dialog stays put. Closing it would throw focus at the Delete button
|
||||||
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
|
// the same turn disabled, and the reason would survive only as a title
|
||||||
expect(within(confirming).getByText(lockNote)).toBeTruthy();
|
// attribute; here the reason is the dialog's own message.
|
||||||
expect(within(confirming).queryByText(otherNote)).toBeNull();
|
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
|
||||||
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();
|
expect(within(dialog).getByText(lockNote)).toBeTruthy();
|
||||||
|
expect(within(dialog).queryByText(otherNote)).toBeNull();
|
||||||
|
expect(within(dialog).getByLabelText(/^Locked\./)).toBeTruthy();
|
||||||
|
|
||||||
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
await waitFor(() =>
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
|
|
||||||
);
|
|
||||||
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a cancelled confirmation stays closed when authority comes back", async () => {
|
||||||
|
const map = { ...BASE };
|
||||||
|
const { queryClient } = await renderClientsPage(map);
|
||||||
|
await unlockedEdit(0);
|
||||||
|
|
||||||
|
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||||
|
|
||||||
|
// A poll that fails and then recovers must not raise a destructive
|
||||||
|
// question the operator already answered.
|
||||||
|
await setConfigStatus(map, queryClient, status);
|
||||||
|
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getAllByRole("button", { name: "Edit" }).length).toBeGreaterThan(0));
|
||||||
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the confirm action comes back when authority does, without a second prompt", async () => {
|
||||||
|
const map = { ...BASE, "DELETE /api/clients/1": {} };
|
||||||
|
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||||
|
await unlockedEdit(0);
|
||||||
|
|
||||||
|
fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
|
||||||
|
await setConfigStatus(map, queryClient, status);
|
||||||
|
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
|
||||||
|
|
||||||
|
await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);
|
||||||
|
|
||||||
|
// The question was never withdrawn, so the answer returns to the same
|
||||||
|
// dialog rather than asking the operator to start again.
|
||||||
|
const confirm = await within(dialog).findByRole("button", { name: "Delete" });
|
||||||
|
fireEvent.click(confirm);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1")),
|
||||||
|
).toHaveLength(1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
|
test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
|
||||||
const map = { ...BASE, "DELETE /api/clients/2": {} };
|
const map = { ...BASE, "DELETE /api/clients/2": {} };
|
||||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||||
@@ -253,10 +329,11 @@ describe.each([
|
|||||||
|
|
||||||
const observed = clientRow("192.168.1.11");
|
const observed = clientRow("192.168.1.11");
|
||||||
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
|
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
|
||||||
|
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||||
|
|
||||||
await setConfigStatus(map, queryClient, status);
|
await setConfigStatus(map, queryClient, status);
|
||||||
|
|
||||||
fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm delete" }));
|
fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
expect(
|
||||||
fetchMock.mock.calls.filter(
|
fetchMock.mock.calls.filter(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import ClientEditDialog from "./ClientEditDialog";
|
|||||||
import NetworkAssignments from "./NetworkAssignments";
|
import NetworkAssignments from "./NetworkAssignments";
|
||||||
import { ClientDisplayName } from "./clientIdentity";
|
import { ClientDisplayName } from "./clientIdentity";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
|
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||||
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
|
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
|
||||||
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
|
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
@@ -31,6 +32,16 @@ function declaredDeleteNote(authority: Authority): string {
|
|||||||
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
|
return "nxdns cannot say whether this client is declared in the configuration file until it reports its configuration status, so deleting it stays locked.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How the confirmation names the row. The address is always there and always
|
||||||
|
* unique, so it carries the sentence; a name the operator recognizes leads when
|
||||||
|
* the row has one.
|
||||||
|
*/
|
||||||
|
function clientLabel(client: Client): string {
|
||||||
|
const name = client.name !== "" ? client.name : client.learned_name;
|
||||||
|
return name === "" ? client.ip : `${name} (${client.ip})`;
|
||||||
|
}
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
heading: {
|
heading: {
|
||||||
fontSize: "1.5rem",
|
fontSize: "1.5rem",
|
||||||
@@ -79,23 +90,11 @@ const styles = stylex.create({
|
|||||||
color: colors.primaryOnSurface,
|
color: colors.primaryOnSurface,
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
},
|
},
|
||||||
confirmGroup: {
|
|
||||||
display: "inline-flex",
|
|
||||||
flexWrap: "wrap",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "flex-end",
|
|
||||||
gap: "0.5rem",
|
|
||||||
},
|
|
||||||
actionGroup: {
|
actionGroup: {
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "0.5rem",
|
gap: "0.5rem",
|
||||||
},
|
},
|
||||||
note: {
|
|
||||||
fontSize: "0.75rem",
|
|
||||||
lineHeight: "1rem",
|
|
||||||
color: colors.textMuted,
|
|
||||||
},
|
|
||||||
dangerText: {
|
dangerText: {
|
||||||
color: colors.danger,
|
color: colors.danger,
|
||||||
},
|
},
|
||||||
@@ -112,7 +111,7 @@ export default function ClientsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||||
const [editing, setEditing] = useState<Client | null>(null);
|
const [editing, setEditing] = useState<Client | null>(null);
|
||||||
const [confirmingId, setConfirmingId] = useState<number | null>(null);
|
const [pendingDelete, setPendingDelete] = useState<Client | null>(null);
|
||||||
const readOnly = useReadOnlyConfig();
|
const readOnly = useReadOnlyConfig();
|
||||||
const authority = useAuthority();
|
const authority = useAuthority();
|
||||||
|
|
||||||
@@ -122,6 +121,12 @@ export default function ClientsPage() {
|
|||||||
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
|
const filterGroup: Group | undefined = group === undefined ? undefined : groups.find((row) => row.id === group);
|
||||||
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
|
const rows = group === undefined ? clients : clients.filter((client) => client.group_id === group);
|
||||||
|
|
||||||
|
// Authority is polled, so it can turn while the confirmation is open. The
|
||||||
|
// dialog reads it on every render rather than trusting the state that opened
|
||||||
|
// it, and withdraws the answer that would now fail instead of withdrawing the
|
||||||
|
// question: the operator is told why, and only they close the dialog.
|
||||||
|
const deleteLocked = pendingDelete !== null && readOnly && pendingDelete.hand_edited;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section>
|
<section>
|
||||||
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
||||||
@@ -178,43 +183,6 @@ export default function ClientsPage() {
|
|||||||
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
<td {...stylex.props(styles.cell)}>{formatTime(client.first_seen)}</td>
|
||||||
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
<td {...stylex.props(styles.cell)}>{formatTime(client.last_seen)}</td>
|
||||||
<td {...stylex.props(styles.cell, styles.right)}>
|
<td {...stylex.props(styles.cell, styles.right)}>
|
||||||
{confirmingId === client.id ? (
|
|
||||||
<span {...stylex.props(styles.confirmGroup)}>
|
|
||||||
{/* Authority is polled, so it can turn while a confirmation
|
|
||||||
sits open. The confirm path reads it on every render
|
|
||||||
rather than trusting the state that opened it. */}
|
|
||||||
<span {...stylex.props(styles.note)}>
|
|
||||||
{readOnly && client.hand_edited
|
|
||||||
? declaredDeleteNote(authority)
|
|
||||||
: "Deleted clients re-materialize on their next DNS query."}
|
|
||||||
</span>
|
|
||||||
{readOnly && client.hand_edited ? (
|
|
||||||
<ConfigLockIndicator />
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setConfirmingId(null);
|
|
||||||
deleteMutation.mutate(client.id);
|
|
||||||
}}
|
|
||||||
{...stylex.props(
|
|
||||||
shared.smallButton,
|
|
||||||
styles.dangerText,
|
|
||||||
shared.focusRing,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Confirm delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setConfirmingId(null)}
|
|
||||||
{...stylex.props(shared.smallButton, shared.focusRing)}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span {...stylex.props(styles.actionGroup)}>
|
<span {...stylex.props(styles.actionGroup)}>
|
||||||
{/* Naming a client writes configuration, so the affordance is
|
{/* Naming a client writes configuration, so the affordance is
|
||||||
absent — not disabled — wherever the write cannot land. */}
|
absent — not disabled — wherever the write cannot land. */}
|
||||||
@@ -231,7 +199,7 @@ export default function ClientsPage() {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmingId(client.id)}
|
onClick={() => setPendingDelete(client)}
|
||||||
disabled={readOnly && client.hand_edited}
|
disabled={readOnly && client.hand_edited}
|
||||||
title={
|
title={
|
||||||
readOnly && client.hand_edited
|
readOnly && client.hand_edited
|
||||||
@@ -248,7 +216,6 @@ export default function ClientsPage() {
|
|||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -256,6 +223,24 @@ export default function ClientsPage() {
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={pendingDelete !== null}
|
||||||
|
title="Delete client"
|
||||||
|
message={
|
||||||
|
pendingDelete === null
|
||||||
|
? ""
|
||||||
|
: deleteLocked
|
||||||
|
? declaredDeleteNote(authority)
|
||||||
|
: `Delete ${clientLabel(pendingDelete)}? Deleted clients re-materialize on their next DNS query.`
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
lock={deleteLocked ? <ConfigLockIndicator /> : undefined}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (pendingDelete !== null) deleteMutation.mutate(pendingDelete.id);
|
||||||
|
setPendingDelete(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setPendingDelete(null)}
|
||||||
|
/>
|
||||||
<InlineError error={deleteMutation.error} />
|
<InlineError error={deleteMutation.error} />
|
||||||
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
||||||
<NetworkAssignments prefixes={prefixes} groups={groups} />
|
<NetworkAssignments prefixes={prefixes} groups={groups} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
|
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
|
import { Radio, RadioGroup } from "react-aria-components";
|
||||||
import type { Period } from "@/lib/types";
|
import type { Period } from "@/lib/types";
|
||||||
import { styles as shared } from "@/ui/styles";
|
import { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
@@ -38,7 +39,7 @@ const styles = stylex.create({
|
|||||||
gap: "0.25rem",
|
gap: "0.25rem",
|
||||||
},
|
},
|
||||||
period: {
|
period: {
|
||||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
cursor: "pointer",
|
||||||
borderStyle: "none",
|
borderStyle: "none",
|
||||||
borderRadius: "0.25rem",
|
borderRadius: "0.25rem",
|
||||||
paddingInline: "0.625rem",
|
paddingInline: "0.625rem",
|
||||||
@@ -46,6 +47,13 @@ const styles = stylex.create({
|
|||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
},
|
},
|
||||||
|
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
|
||||||
|
periodFocusVisible: {
|
||||||
|
outlineWidth: 2,
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineColor: colors.focus,
|
||||||
|
outlineOffset: 2,
|
||||||
|
},
|
||||||
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
||||||
periodSelected: {
|
periodSelected: {
|
||||||
backgroundColor: {
|
backgroundColor: {
|
||||||
@@ -68,23 +76,29 @@ const styles = stylex.create({
|
|||||||
|
|
||||||
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
||||||
return (
|
return (
|
||||||
<div role="group" aria-label="Period" {...stylex.props(styles.periodGroup)}>
|
<RadioGroup
|
||||||
|
aria-label="Period"
|
||||||
|
orientation="horizontal"
|
||||||
|
value={period}
|
||||||
|
onChange={(next) => onChange(next as Period)}
|
||||||
|
className={() => stylex.props(styles.periodGroup).className ?? ""}
|
||||||
|
>
|
||||||
{PERIODS.map((option) => (
|
{PERIODS.map((option) => (
|
||||||
<button
|
<Radio
|
||||||
key={option}
|
key={option}
|
||||||
type="button"
|
value={option}
|
||||||
aria-pressed={option === period}
|
className={({ isSelected, isFocusVisible }) =>
|
||||||
onClick={() => onChange(option)}
|
stylex.props(
|
||||||
{...stylex.props(
|
|
||||||
styles.period,
|
styles.period,
|
||||||
option === period ? styles.periodSelected : styles.periodIdle,
|
isSelected ? styles.periodSelected : styles.periodIdle,
|
||||||
shared.focusRing,
|
isFocusVisible && styles.periodFocusVisible,
|
||||||
)}
|
).className ?? ""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{option}
|
{option}
|
||||||
</button>
|
</Radio>
|
||||||
))}
|
))}
|
||||||
</div>
|
</RadioGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ test("a request in flight leaves the heading and the picker usable behind one lo
|
|||||||
renderApp();
|
renderApp();
|
||||||
|
|
||||||
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
await screen.findByRole("heading", { name: "Overview", level: 1 });
|
||||||
expect(screen.getByRole("button", { name: "1h" })).toBeTruthy();
|
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
|
||||||
// One loading state for the whole page, not one per panel.
|
// One loading state for the whole page, not one per panel.
|
||||||
const loading = await screen.findByText("Loading…");
|
const loading = await screen.findByText("Loading…");
|
||||||
expect(loading.getAttribute("role")).toBe("status");
|
expect(loading.getAttribute("role")).toBe("status");
|
||||||
@@ -335,14 +335,23 @@ test("an empty window says so in every panel instead of drawing nothing", async
|
|||||||
test("a deep link opens on the period it names", async () => {
|
test("a deep link opens on the period it names", async () => {
|
||||||
renderApp("/overview?period=1h");
|
renderApp("/overview?period=1h");
|
||||||
await screen.findByText("12");
|
await screen.findByText("12");
|
||||||
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
|
// One radio group named Period, holding the four periods and exactly one
|
||||||
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
|
// selection: the segmented picker is a single choice, not four toggles.
|
||||||
|
const picker = within(screen.getByRole("radiogroup", { name: "Period" }));
|
||||||
|
expect(picker.getAllByRole("radio").map((radio) => radio.getAttribute("value"))).toEqual([
|
||||||
|
"1h",
|
||||||
|
"24h",
|
||||||
|
"7d",
|
||||||
|
"30d",
|
||||||
|
]);
|
||||||
|
expect(picker.getByRole("radio", { name: "1h", checked: true })).toBeTruthy();
|
||||||
|
expect(picker.getByRole("radio", { name: "24h", checked: false })).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
|
test("a period the API does not have falls back to the default without carrying it in the url", async () => {
|
||||||
const router = renderApp("/overview?period=90d");
|
const router = renderApp("/overview?period=90d");
|
||||||
await screen.findByText("1,000");
|
await screen.findByText("1,000");
|
||||||
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("true");
|
expect(screen.getByRole("radio", { name: "24h", checked: true })).toBeTruthy();
|
||||||
expect(router.state.location.search).toEqual({});
|
expect(router.state.location.search).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -350,7 +359,7 @@ test("the picker rescopes every panel and writes the period into the url", async
|
|||||||
const router = renderApp();
|
const router = renderApp();
|
||||||
await screen.findByText("1,000");
|
await screen.findByText("1,000");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
fireEvent.click(screen.getByRole("radio", { name: "1h" }));
|
||||||
|
|
||||||
await screen.findByText("12");
|
await screen.findByText("12");
|
||||||
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
|
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
|
||||||
@@ -369,7 +378,7 @@ test("a failed request is one error for the whole page, stated once and retryabl
|
|||||||
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
|
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
|
||||||
// The heading and the picker survive it, so the reader can rescope or retry.
|
// The heading and the picker survive it, so the reader can rescope or retry.
|
||||||
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
|
expect(screen.getByRole("heading", { name: "Overview", level: 1 })).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: "1h" })).toBeTruthy();
|
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
|
||||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||||
|
|
||||||
failing = false;
|
failing = false;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* and a stray outside click is not one. Escape still cancels.
|
* and a stray outside click is not one. Escape still cancels.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||||
import { colors } from "./tokens.stylex";
|
import { colors } from "./tokens.stylex";
|
||||||
@@ -19,6 +20,14 @@ interface Props {
|
|||||||
/** The full sentence the operator reads before confirming; names the entity. */
|
/** The full sentence the operator reads before confirming; names the entity. */
|
||||||
message: string;
|
message: string;
|
||||||
confirmLabel: string;
|
confirmLabel: string;
|
||||||
|
/**
|
||||||
|
* Stands in for the confirm action when the write can no longer land, the way
|
||||||
|
* an open edit dialog drops its Save. Closing the dialog instead would throw
|
||||||
|
* focus at a control that is now disabled and say nothing about why, so the
|
||||||
|
* question stays on screen and only the answer that would fail is withdrawn.
|
||||||
|
* Cancel is always live.
|
||||||
|
*/
|
||||||
|
lock?: ReactNode;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
@@ -80,7 +89,7 @@ const styles = stylex.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, onConfirm, onCancel }: Props) {
|
export default function ConfirmDialog({ isOpen, title, message, confirmLabel, lock, onConfirm, onCancel }: Props) {
|
||||||
return (
|
return (
|
||||||
<ModalOverlay
|
<ModalOverlay
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
@@ -99,6 +108,7 @@ export default function ConfirmDialog({ isOpen, title, message, confirmLabel, on
|
|||||||
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
|
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
{lock === undefined ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
@@ -106,6 +116,9 @@ export default function ConfirmDialog({ isOpen, title, message, confirmLabel, on
|
|||||||
>
|
>
|
||||||
{confirmLabel}
|
{confirmLabel}
|
||||||
</button>
|
</button>
|
||||||
|
) : (
|
||||||
|
lock
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</AriaDialog>
|
</AriaDialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
+83
-10
@@ -4,18 +4,25 @@
|
|||||||
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
|
* React Aria owns the focus trap, the Escape handler and the `aria-modal`
|
||||||
* wiring that the hand-rolled overlay only approximated. State is controlled by
|
* wiring that the hand-rolled overlay only approximated. State is controlled by
|
||||||
* the caller because the trigger is a table row button, not a `DialogTrigger`.
|
* the caller because the trigger is a table row button, not a `DialogTrigger`.
|
||||||
|
*
|
||||||
|
* The title is both the visible heading and the accessible name: `Heading
|
||||||
|
* slot="title"` is what React Aria points `aria-labelledby` at, so a caller
|
||||||
|
* cannot name the dialog one thing and show another.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { Dialog as AriaDialog, Modal, ModalOverlay } from "react-aria-components";
|
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||||
import { colors } from "./tokens.stylex";
|
import { colors } from "./tokens.stylex";
|
||||||
|
import { styles as shared } from "./styles";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
/** The dialog's accessible name. */
|
/** The dialog's heading, and with it the dialog's accessible name. */
|
||||||
label: string;
|
title: string;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** `detail` is the wider panel a record needs; `form` fits a column of fields. */
|
||||||
|
size?: "form" | "detail";
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,25 +37,74 @@ const styles = stylex.create({
|
|||||||
padding: "1rem",
|
padding: "1rem",
|
||||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* A column so the header stays put and the body scrolls under it. The height
|
||||||
|
* cap is what keeps a long record inside the viewport instead of running off
|
||||||
|
* the bottom of a short one.
|
||||||
|
*/
|
||||||
panel: {
|
panel: {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: "28rem",
|
maxHeight: "85vh",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
borderRadius: "0.5rem",
|
borderRadius: "0.5rem",
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: "solid",
|
borderStyle: "solid",
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
backgroundColor: colors.surfaceRaised,
|
backgroundColor: colors.surfaceRaised,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
padding: "1.5rem",
|
|
||||||
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)",
|
||||||
},
|
},
|
||||||
/** The panel already draws the boundary; the dialog's own ring would double it. */
|
panelForm: {
|
||||||
|
maxWidth: "28rem",
|
||||||
|
},
|
||||||
|
panelDetail: {
|
||||||
|
maxWidth: "52rem",
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* The panel already draws the boundary; the dialog's own ring would double
|
||||||
|
* it. `minHeight: 0` is what lets the body shrink far enough to scroll.
|
||||||
|
*/
|
||||||
body: {
|
body: {
|
||||||
outlineStyle: "none",
|
outlineStyle: "none",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexShrink: 0,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
gap: "1rem",
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomStyle: "solid",
|
||||||
|
borderBottomColor: colors.border,
|
||||||
|
paddingInline: "1.5rem",
|
||||||
|
paddingBlock: "0.75rem",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: "1.125rem",
|
||||||
|
lineHeight: "1.75rem",
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
/** 44px on both axes: the pointer-target floor, which the word alone misses. */
|
||||||
|
close: {
|
||||||
|
minWidth: 44,
|
||||||
|
minHeight: 44,
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
minHeight: 0,
|
||||||
|
overflowY: "auto",
|
||||||
|
paddingInline: "1.5rem",
|
||||||
|
paddingBlock: "1.5rem",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
export default function Dialog({ title, isOpen, onClose, size = "form", children }: Props) {
|
||||||
return (
|
return (
|
||||||
<ModalOverlay
|
<ModalOverlay
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
@@ -58,9 +114,26 @@ export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
|||||||
isDismissable
|
isDismissable
|
||||||
className={() => stylex.props(styles.overlay).className ?? ""}
|
className={() => stylex.props(styles.overlay).className ?? ""}
|
||||||
>
|
>
|
||||||
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
|
<Modal
|
||||||
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
|
className={() =>
|
||||||
{children}
|
stylex.props(styles.panel, size === "detail" ? styles.panelDetail : styles.panelForm).className ??
|
||||||
|
""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<AriaDialog {...stylex.props(styles.body)}>
|
||||||
|
<div {...stylex.props(styles.header)}>
|
||||||
|
<Heading slot="title" level={2} {...stylex.props(styles.title)}>
|
||||||
|
{title}
|
||||||
|
</Heading>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
{...stylex.props(shared.button, styles.close, shared.focusRing)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div {...stylex.props(styles.content)}>{children}</div>
|
||||||
</AriaDialog>
|
</AriaDialog>
|
||||||
</Modal>
|
</Modal>
|
||||||
</ModalOverlay>
|
</ModalOverlay>
|
||||||
|
|||||||
Reference in New Issue
Block a user