Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
039eeeda96
|
||
|
|
c65d92d8f8
|
||
|
|
207252acee
|
||
|
|
f1de80477a
|
||
|
|
3c674966be
|
||
|
|
59d6be98f8
|
||
|
|
317d5dd4f8
|
||
|
|
79578e1d2a
|
||
|
|
d5613ee718
|
@@ -14,7 +14,7 @@ import { readdirSync, statSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const BUDGET_BYTES = 800_000;
|
||||
const BUDGET_BYTES = 850_000;
|
||||
|
||||
const distDir = join(dirname(dirname(fileURLToPath(import.meta.url))), "dist", "assets");
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
|
||||
import { AuthProvider } from "@/auth/store";
|
||||
@@ -313,16 +313,17 @@ test("a row retention has pruned explains the 404 and keeps the way back to the
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The related-actions region of a query detail. Scoped on purpose: the sidebar
|
||||
* carries a Pause of its own, and this is the one that answers "this query was
|
||||
* blocked and should not have been".
|
||||
*/
|
||||
/** The related-actions region of a query detail. */
|
||||
function related(): HTMLElement {
|
||||
return screen.getByRole("region", { name: "Related" });
|
||||
}
|
||||
|
||||
test("a blocked query's Related offers Pause; an allowed one has nothing to pause about", async () => {
|
||||
/**
|
||||
* Related is links only. Pause is a resolver-wide control and lives in the
|
||||
* sidebar alone, so a blocked query — the case that used to carry one here —
|
||||
* offers no button of any kind.
|
||||
*/
|
||||
test("Related carries its four links and no control, blocked query or not", async () => {
|
||||
responses["/api/queries/50"] = detail(50, {
|
||||
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
|
||||
route: { kind: "blocked", upstream: "" },
|
||||
@@ -330,25 +331,12 @@ test("a blocked query's Related offers Pause; an allowed one has nothing to paus
|
||||
renderDetail(50);
|
||||
|
||||
await screen.findByRole("heading", { name: "example.com" });
|
||||
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
|
||||
|
||||
cleanup();
|
||||
responses["/api/queries/51"] = detail(51, { policy: { action: "allow", reason: "no_match", matched: "" } });
|
||||
renderDetail(51);
|
||||
|
||||
await screen.findByRole("heading", { name: "example.com" });
|
||||
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the Pause action stays away while protection is unavailable", async () => {
|
||||
responses["/api/health"] = health({ protection: { state: "unavailable", until: null } });
|
||||
responses["/api/queries/52"] = detail(52, {
|
||||
policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
|
||||
route: { kind: "blocked", upstream: "" },
|
||||
});
|
||||
renderDetail(52);
|
||||
|
||||
await screen.findByRole("heading", { name: "example.com" });
|
||||
await waitFor(() => expect(screen.getByText("Diagnostics around this query")).toBeTruthy());
|
||||
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
|
||||
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
|
||||
expect(within(related()).getAllByRole("link").map((link) => link.textContent)).toEqual([
|
||||
"Test this domain against current policy",
|
||||
"All activity for this domain",
|
||||
"All activity from this client",
|
||||
"Diagnostics around this query",
|
||||
]);
|
||||
expect(within(related()).queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -73,9 +73,7 @@ export default function ActivityDetailPage() {
|
||||
domain={domain}
|
||||
client={client}
|
||||
ts={time}
|
||||
origin={origin}
|
||||
blocked={detail.policy.action === "block"}
|
||||
/>
|
||||
origin={origin} />
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* The filter toolbar on its own: the controls that decide what reaches the URL,
|
||||
* driven through a harness that plays the part the page plays.
|
||||
*
|
||||
* The timezone is fixed because the only invalid bound reachable in jsdom is a
|
||||
* wall-clock time inside a spring-forward gap: jsdom applies the
|
||||
* `datetime-local` value sanitization algorithm, so text that is merely
|
||||
* incomplete never reaches the component at all — the input hands it back as
|
||||
* the empty string, which is a bound the operator cleared.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { afterAll, expect, test, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||
import { unixToDatetimeLocal } from "./datetime";
|
||||
|
||||
vi.stubEnv("TZ", "Europe/Paris");
|
||||
afterAll(() => vi.unstubAllEnvs());
|
||||
|
||||
/** 02:30 does not exist on this date in Paris; the clock jumps 02:00 to 03:00. */
|
||||
const GAP_WALL_TIME = "2026-03-29T02:30:00";
|
||||
|
||||
/** The text of the elements an input points at with `aria-describedby`. */
|
||||
function describedText(input: HTMLElement): string {
|
||||
const ids = input.getAttribute("aria-describedby");
|
||||
if (ids === null) throw new Error("input has no aria-describedby");
|
||||
return ids
|
||||
.split(/\s+/)
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||
return node.textContent ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's half of the contract: the applied state is held outside the form.
|
||||
*
|
||||
* `deferred` holds the patches back instead of applying them, so a test can
|
||||
* land them in an order the network and the router can genuinely produce —
|
||||
* an early debounce arriving after a later one, over a draft that has moved on.
|
||||
*/
|
||||
function renderFilters(initial: AppliedFilters = NO_FILTERS, deferred = false) {
|
||||
const patches: Array<Partial<AppliedFilters>> = [];
|
||||
const state: { current: AppliedFilters } = { current: initial };
|
||||
const setter: { current: ((next: AppliedFilters) => void) | null } = { current: null };
|
||||
|
||||
function Harness() {
|
||||
const [applied, setApplied] = useState(initial);
|
||||
state.current = applied;
|
||||
setter.current = setApplied;
|
||||
const onApply = useCallback((patch: Partial<AppliedFilters>) => {
|
||||
patches.push(patch);
|
||||
if (!deferred) setApplied((prev) => ({ ...prev, ...patch }));
|
||||
}, []);
|
||||
const onClear = useCallback(() => {
|
||||
patches.push({});
|
||||
if (!deferred) setApplied(NO_FILTERS);
|
||||
}, []);
|
||||
return <ActivityFilters applied={applied} onApply={onApply} onClear={onClear} />;
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
/** The URL moving under the form: a commit landing, or the back button. */
|
||||
function land(next: AppliedFilters) {
|
||||
act(() => setter.current!(next));
|
||||
}
|
||||
return { patches, state, land };
|
||||
}
|
||||
|
||||
function openTimeMenu() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
||||
}
|
||||
|
||||
test("the segments commit to the applied state on the click, with no button in between", () => {
|
||||
const { patches, state } = renderFilters();
|
||||
expect(
|
||||
screen.getAllByRole("radio").map((radio) => radio.closest("label")?.textContent),
|
||||
).toEqual(["Any", "Blocked", "Allowed"]);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
expect(patches).toEqual([{ blocked: true }]);
|
||||
expect(state.current.blocked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Allowed" }));
|
||||
expect(state.current.blocked).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Any" }));
|
||||
expect(state.current.blocked).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a time preset writes an absolute second, and the trigger names the preset", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const { patches } = renderFilters();
|
||||
|
||||
openTimeMenu();
|
||||
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
|
||||
"Any time",
|
||||
"Past hour",
|
||||
"Past 24 hours",
|
||||
"Past 7 days",
|
||||
"Custom…",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" }));
|
||||
|
||||
// Both bounds, concrete: an open upper bound would keep taking in queries
|
||||
// logged after the reader stopped looking, so the same link tomorrow would
|
||||
// name a different day.
|
||||
expect(patches).toEqual([{ since: now / 1000 - 86_400, until: now / 1000 }]);
|
||||
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
|
||||
|
||||
// A clock that has moved on does not move the label: the URL still holds the
|
||||
// second the click resolved to.
|
||||
vi.spyOn(Date, "now").mockReturnValue(now + 60_000);
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("bounds nobody picked here read as Custom, and Any time clears both", () => {
|
||||
const { patches } = renderFilters({ ...NO_FILTERS, since: 1_700_000_000, until: 1_700_000_600 });
|
||||
expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();
|
||||
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Any time" }));
|
||||
|
||||
expect(patches).toEqual([{ since: undefined, until: undefined }]);
|
||||
expect(screen.getByRole("button", { name: "Time: Any time" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the custom range applies only through Set range, and Enter is Set range", () => {
|
||||
const { patches } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
fireEvent.change(since, { target: { value: "2026-03-29T04:30:00" } });
|
||||
// Typing a bound is not applying it: the other half may still be half-typed.
|
||||
expect(patches).toEqual([]);
|
||||
|
||||
const applied = { since: Math.floor(new Date("2026-03-29T04:30:00").getTime() / 1000), until: undefined };
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(patches).toEqual([applied]);
|
||||
|
||||
// Enter inside a bound is that bound's action, not the toolbar's submit: the
|
||||
// outer form flushes the text filters and would apply neither half of this.
|
||||
fireEvent.change(since, { target: { value: "2026-03-29T05:30:00" } });
|
||||
fireEvent.keyDown(since, { key: "Enter" });
|
||||
expect(patches).toHaveLength(2);
|
||||
expect(patches[1]).toEqual({
|
||||
since: Math.floor(new Date("2026-03-29T05:30:00").getTime() / 1000),
|
||||
until: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test("a rejected bound marks its own input and describes it", () => {
|
||||
const { patches } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(since, { target: { value: GAP_WALL_TIME } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
|
||||
expect(since.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(since)).toContain("daylight saving");
|
||||
// Refused means refused, and only the offending bound carries the mark.
|
||||
expect(patches).toEqual([]);
|
||||
expect(until.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(until.getAttribute("aria-describedby")).toBeNull();
|
||||
});
|
||||
|
||||
test("an upper bound below the lower one is refused, against the bound that is wrong", () => {
|
||||
const { patches } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||
fireEvent.change(since, { target: { value: "2026-05-02T10:00:00" } });
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
|
||||
expect(until.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(until)).toContain("selects nothing");
|
||||
expect(since.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(patches).toEqual([]);
|
||||
|
||||
// The window is half-open, so two equal bounds are as empty as an inverted
|
||||
// pair and are refused the same way.
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T10:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(until.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(patches).toEqual([]);
|
||||
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T11:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(until.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(patches).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("Clear keeps its place while there is nothing to clear, and stays out of the tab order", () => {
|
||||
const { state } = renderFilters();
|
||||
const clear = screen.getByText("Clear");
|
||||
expect(clear.getAttribute("tabindex")).toBe("-1");
|
||||
expect(clear.getAttribute("aria-hidden")).toBe("true");
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
|
||||
expect(clear.getAttribute("tabindex")).toBeNull();
|
||||
expect(clear.getAttribute("aria-hidden")).toBeNull();
|
||||
|
||||
fireEvent.click(clear);
|
||||
expect(state.current).toEqual(NO_FILTERS);
|
||||
expect(clear.getAttribute("tabindex")).toBe("-1");
|
||||
});
|
||||
|
||||
test("Clear empties the text drafts along with the applied filters", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { state } = renderFilters();
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
fireEvent.change(domain, { target: { value: "ads" } });
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(state.current.domain).toBe("ads");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear" }));
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
|
||||
expect(domain.value).toBe("");
|
||||
expect(state.current).toEqual(NO_FILTERS);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("the domain field is search-shaped, unspellchecked, and labelled without a visible label", () => {
|
||||
renderFilters();
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
expect(domain.type).toBe("search");
|
||||
expect(domain.getAttribute("spellcheck")).toBe("false");
|
||||
expect(domain.getAttribute("autocomplete")).toBe("off");
|
||||
expect(domain.getAttribute("placeholder")).toBe("Filter domains…");
|
||||
// The magnifier is decoration over the field, never a second thing to read.
|
||||
expect(document.querySelector("svg[aria-hidden='true']")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a bound change does not reset the domain draft that is still being typed", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const { state } = renderFilters();
|
||||
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
fireEvent.change(domain, { target: { value: "ads" } });
|
||||
|
||||
// A preset commits at once, while the typed word is still waiting out its
|
||||
// debounce. The URL moves, but not the part of it this field is derived from.
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||
|
||||
expect(state.current.since).toBe(now / 1000 - 3600);
|
||||
expect(domain.value).toBe("ads");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("a debounced commit landing late does not roll the field back over newer typing", () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { patches, land } = renderFilters(NO_FILTERS, true);
|
||||
const domain = screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
|
||||
// Two commits in flight, and the reader is a keystroke ahead of both.
|
||||
fireEvent.change(domain, { target: { value: "ad" } });
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
fireEvent.change(domain, { target: { value: "ads" } });
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(patches).toEqual([
|
||||
{ domain: "ad", client: undefined },
|
||||
{ domain: "ads", client: undefined },
|
||||
]);
|
||||
fireEvent.change(domain, { target: { value: "adsx" } });
|
||||
|
||||
// The older one lands first. It is this toolbar's own echo, two keystrokes
|
||||
// stale, and seeding the field from it would delete what was typed since.
|
||||
land({ ...NO_FILTERS, domain: "ad" });
|
||||
expect(domain.value).toBe("adsx");
|
||||
|
||||
land({ ...NO_FILTERS, domain: "ads" });
|
||||
expect(domain.value).toBe("adsx");
|
||||
|
||||
// An address that was never sent from here is someone else's — a pasted
|
||||
// link, or the back button — and that one does move the field.
|
||||
land({ ...NO_FILTERS, domain: "elsewhere" });
|
||||
expect(domain.value).toBe("elsewhere");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("a window moved from outside re-opens the custom row over the bounds it arrived with", () => {
|
||||
const now = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
const { land } = renderFilters();
|
||||
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||
// A preset supersedes the custom row, so it is shut and the label is the preset.
|
||||
expect(screen.queryByLabelText("Since")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
|
||||
|
||||
// The back button, or a pasted link: a range this form did not choose.
|
||||
const since = 1_700_000_000;
|
||||
land({ ...NO_FILTERS, since, until: since + 600 });
|
||||
|
||||
expect(screen.getByRole("button", { name: "Time: Custom" })).toBeTruthy();
|
||||
// The label says Custom, so the fields it names have to be on screen holding
|
||||
// that range — a Custom window with nothing to read is the label lying.
|
||||
// jsdom's value sanitizer spells the milliseconds out, so the seeded text is
|
||||
// the prefix rather than the whole of what the input holds.
|
||||
expect((screen.getByLabelText("Since") as HTMLInputElement).value).toContain(
|
||||
unixToDatetimeLocal(since),
|
||||
);
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).value).toContain(
|
||||
unixToDatetimeLocal(since + 600),
|
||||
);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("a window moved from outside clears an error left over from the old one", () => {
|
||||
const { land } = renderFilters();
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
|
||||
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||
fireEvent.change(screen.getByLabelText("Since"), { target: { value: "2026-05-02T10:00:00" } });
|
||||
fireEvent.change(until, { target: { value: "2026-05-02T09:00:00" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
expect(until.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
const since = 1_700_000_000;
|
||||
land({ ...NO_FILTERS, since, until: since + 600 });
|
||||
|
||||
// The bounds the message was about are gone, so the message is too.
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).getAttribute("aria-invalid")).toBeNull();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
test("the client field names the exact match it performs", () => {
|
||||
renderFilters();
|
||||
const client = screen.getByLabelText("Client IP (exact match)") as HTMLInputElement;
|
||||
expect(client.getAttribute("placeholder")).toBe("Client IP…");
|
||||
});
|
||||
|
||||
test("an earlier range commit landing late does not unseat the pick that is current", () => {
|
||||
const first = 1_800_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(first);
|
||||
const { patches, land } = renderFilters(NO_FILTERS, true);
|
||||
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||
|
||||
// A second pick before the first has come back through the URL.
|
||||
const second = first + 60_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(second);
|
||||
openTimeMenu();
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past 24 hours" }));
|
||||
|
||||
expect(patches).toEqual([
|
||||
{ since: first / 1000 - 3600, until: first / 1000 },
|
||||
{ since: second / 1000 - 86_400, until: second / 1000 },
|
||||
]);
|
||||
|
||||
// The older window lands first. It is this toolbar's own echo, so it must not
|
||||
// read as a range someone else chose: doing that would clear the preset and
|
||||
// throw the custom row open over a window the reader has already moved past.
|
||||
land({ ...NO_FILTERS, since: first / 1000 - 3600, until: first / 1000 });
|
||||
expect(screen.queryByLabelText("Since")).toBeNull();
|
||||
|
||||
land({ ...NO_FILTERS, since: second / 1000 - 86_400, until: second / 1000 });
|
||||
expect(screen.getByRole("button", { name: "Time: Past 24 hours" })).toBeTruthy();
|
||||
expect(screen.queryByLabelText("Since")).toBeNull();
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
@@ -1,73 +1,198 @@
|
||||
/**
|
||||
* The filter row over the Activity table.
|
||||
* The filter toolbar over the Activity history table.
|
||||
*
|
||||
* The applied state is the URL, never this form: what the reader sees is what
|
||||
* the link they can paste to a housemate will show. So this holds a draft only,
|
||||
* and the page remounts it whenever the applied search changes — a back button
|
||||
* or a pasted URL has to move the form with it, and a form that seeded itself
|
||||
* once would keep showing the previous investigation's filters.
|
||||
* the link they can paste to a housemate will show. So this holds a draft, and
|
||||
* every control writes through to the URL — the segments and the time presets
|
||||
* at once, the two text fields after a pause so a five-letter domain is one
|
||||
* navigation rather than five.
|
||||
*
|
||||
* In live mode the row stays visible and disabled rather than disappearing: the
|
||||
* filters are retained in the URL and apply again the moment history comes
|
||||
* back, and hiding them would read as having lost them. The stream itself is
|
||||
* unfiltered — the server sends every query — so a row that looked usable here
|
||||
* A time preset writes both bounds as absolute seconds, resolved once at the
|
||||
* click. Neither half may be left open: a bookmark has to describe the same
|
||||
* investigation tomorrow, and a window that slid overnight — or one that stayed
|
||||
* open at the top and swallowed everything logged since — would answer a
|
||||
* different question under the same link.
|
||||
*
|
||||
* A URL can move under this form at any time, and the draft only follows the
|
||||
* part of it that actually moved. A field is re-seeded when its own URL value
|
||||
* changed and the new value is not one this toolbar just wrote: the debounce
|
||||
* means the URL is always a little behind the keyboard, and a landing commit
|
||||
* must not roll the input back over the letters typed since.
|
||||
*
|
||||
* The custom range is the one control that does not live-apply. Two half-typed
|
||||
* timestamps are a normal intermediate state of typing one of them, and a lower
|
||||
* bound at or above an upper bound selects nothing at all, so the pair is
|
||||
* validated and applied together or not at all.
|
||||
*
|
||||
* Live mode does not render this at all — the page mounts it inside the History
|
||||
* panel. The stream is unfiltered, and a row of controls that looked usable
|
||||
* would promise filtering that is not happening.
|
||||
*/
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useCallback, useEffect, useState, type FormEvent, type KeyboardEvent } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import Select from "@/ui/Select";
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover, Radio, RadioGroup } from "react-aria-components";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
||||
import type { ActivitySearch } from "./search";
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: "any", label: "All" },
|
||||
{ value: "blocked", label: "Blocked only" },
|
||||
{ value: "allowed", label: "Allowed only" },
|
||||
];
|
||||
/** Long enough that a typed word is one navigation, short enough to feel live. */
|
||||
const DEBOUNCE_MS = 350;
|
||||
|
||||
const RESULTS = [
|
||||
{ value: "any", label: "Any" },
|
||||
{ value: "blocked", label: "Blocked" },
|
||||
{ value: "allowed", label: "Allowed" },
|
||||
] as const;
|
||||
|
||||
const PRESETS = [
|
||||
{ label: "Any time", seconds: null },
|
||||
{ label: "Past hour", seconds: 3600 },
|
||||
{ label: "Past 24 hours", seconds: 86_400 },
|
||||
{ label: "Past 7 days", seconds: 604_800 },
|
||||
] as const;
|
||||
|
||||
const CUSTOM_ITEM = "Custom…";
|
||||
|
||||
/** The pointer-target floor `ui/Checkbox` and the dialog Close button already set. */
|
||||
const HIT_TARGET = 44;
|
||||
|
||||
const styles = stylex.create({
|
||||
/** One column on a phone, two from `sm`, five from `lg`. */
|
||||
grid: {
|
||||
toolbar: {
|
||||
marginTop: "1rem",
|
||||
display: "grid",
|
||||
gap: "0.75rem",
|
||||
gridTemplateColumns: {
|
||||
default: "repeat(1, minmax(0, 1fr))",
|
||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
||||
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
|
||||
},
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
label: {
|
||||
display: "block",
|
||||
/** The domain field is the one that grows; everything else keeps its size. */
|
||||
searchWrap: {
|
||||
position: "relative",
|
||||
flexGrow: 1,
|
||||
flexShrink: 1,
|
||||
flexBasis: "14rem",
|
||||
display: "flex",
|
||||
},
|
||||
searchIcon: {
|
||||
position: "absolute",
|
||||
insetInlineStart: "0.5rem",
|
||||
top: "50%",
|
||||
transform: "translateY(-50%)",
|
||||
color: colors.textMuted,
|
||||
pointerEvents: "none",
|
||||
},
|
||||
/** Every control in the row is a pointer target before it is anything else. */
|
||||
field: {
|
||||
minHeight: HIT_TARGET,
|
||||
},
|
||||
searchInput: {
|
||||
width: "100%",
|
||||
paddingInlineStart: "1.875rem",
|
||||
},
|
||||
clientInput: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
flexBasis: "10rem",
|
||||
},
|
||||
/** A button is text-sized by default; this is the hit area around the text. */
|
||||
hitTarget: {
|
||||
minHeight: HIT_TARGET,
|
||||
minWidth: HIT_TARGET,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
resultGroup: {
|
||||
display: "flex",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
/** The segment styling of the Overview period picker, item for item. */
|
||||
segment: {
|
||||
cursor: "pointer",
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.625rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
minHeight: HIT_TARGET,
|
||||
minWidth: HIT_TARGET,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
/** A Radio is a `label`, so RAC drives the ring rather than `:focus-visible`. */
|
||||
segmentFocusVisible: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
/** The pressed fill is heavier than `surfaceHover`, so a hover cannot mimic it. */
|
||||
segmentSelected: {
|
||||
backgroundColor: {
|
||||
default: "oklch(92% 0.004 286.32)",
|
||||
"@media (prefers-color-scheme: dark)": "oklch(37% 0.013 285.805)",
|
||||
},
|
||||
color: colors.text,
|
||||
fontWeight: 500,
|
||||
},
|
||||
segmentIdle: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
popover: {
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
color: colors.text,
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menu: {
|
||||
outlineStyle: "none",
|
||||
paddingBlock: "0.25rem",
|
||||
},
|
||||
menuItem: {
|
||||
cursor: "pointer",
|
||||
paddingInline: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
whiteSpace: "nowrap",
|
||||
minHeight: HIT_TARGET,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
},
|
||||
/** Inset because an item flush against the popover edge clips an outset ring. */
|
||||
menuItemFocused: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||
},
|
||||
/**
|
||||
* Clear keeps its box when there is nothing to clear. It appears the moment a
|
||||
* filter is set, and a control that appeared by widening the row would move
|
||||
* every other control out from under the pointer that was reaching for it.
|
||||
*/
|
||||
clearHidden: {
|
||||
visibility: "hidden",
|
||||
},
|
||||
customRow: {
|
||||
marginTop: "0.5rem",
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
customField: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
input: {
|
||||
marginTop: "0.25rem",
|
||||
width: "100%",
|
||||
// A disabled native input keeps its value legible but reads as inert,
|
||||
// matching what RAC does to the Select trigger beside it.
|
||||
cursor: { default: null, ":disabled": "not-allowed" },
|
||||
opacity: { default: null, ":disabled": 0.55 },
|
||||
},
|
||||
buttonRow: {
|
||||
display: "flex",
|
||||
alignItems: "flex-end",
|
||||
gap: "0.5rem",
|
||||
gridColumn: {
|
||||
default: null,
|
||||
"@media (min-width: 640px)": "span 2 / span 2",
|
||||
"@media (min-width: 1024px)": "span 5 / span 5",
|
||||
},
|
||||
},
|
||||
toolbarButton: {
|
||||
fontWeight: 500,
|
||||
},
|
||||
error: {
|
||||
marginTop: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.dangerText,
|
||||
@@ -86,6 +211,22 @@ export const NO_FILTERS: AppliedFilters = {
|
||||
until: undefined,
|
||||
};
|
||||
|
||||
/** The half-open window the API reads: `ts >= since` and `ts < until`. */
|
||||
interface Bounds {
|
||||
since: number | undefined;
|
||||
until: number | undefined;
|
||||
}
|
||||
|
||||
/** The two text filters as the URL spells them, with absent written as empty. */
|
||||
interface TextPair {
|
||||
domain: string;
|
||||
client: string;
|
||||
}
|
||||
|
||||
function sameBounds(a: Bounds, b: Bounds): boolean {
|
||||
return a.since === b.since && a.until === b.until;
|
||||
}
|
||||
|
||||
function blockedOption(blocked: boolean | undefined): string {
|
||||
if (blocked === undefined) return "any";
|
||||
return blocked ? "blocked" : "allowed";
|
||||
@@ -96,135 +237,404 @@ function optionBlocked(value: string): boolean | undefined {
|
||||
return value === "allowed" ? false : undefined;
|
||||
}
|
||||
|
||||
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
|
||||
return reason === "unparseable"
|
||||
? `${label} is not a complete date and time.`
|
||||
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`;
|
||||
/** A filter value the URL can carry: trimmed, and empty means absent. */
|
||||
function textFilter(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
/** The message plus the bound it belongs to, so that input can point at it. */
|
||||
interface BoundError {
|
||||
field: "since" | "until";
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** The toolbar is rendered once per page, so the message can hold a fixed id. */
|
||||
const ERROR_ID = "activity-filter-error";
|
||||
|
||||
function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError {
|
||||
const label = field === "since" ? "Since" : "Until";
|
||||
return {
|
||||
field,
|
||||
message:
|
||||
reason === "unparseable"
|
||||
? `${label} is not a complete date and time.`
|
||||
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`,
|
||||
};
|
||||
}
|
||||
|
||||
/** The preset a label is claimed for, kept only while the URL still holds it. */
|
||||
interface ChosenPreset extends Bounds {
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What this toolbar knows about the URL, and what it is still waiting to see
|
||||
* come back from it.
|
||||
*
|
||||
* `url` and `bounds` are the last values looked at, so a change can be told
|
||||
* apart field by field. `pendingText` and `pendingBounds` are every commit made
|
||||
* here that the URL has not echoed yet. Both are queues rather than single
|
||||
* slots: two keystrokes either side of the debounce put two text commits in
|
||||
* flight, and two menu picks in quick succession do the same to the range. In
|
||||
* both cases the older echo landing second must not be mistaken for someone
|
||||
* else's edit — that is what would clear the preset out from under the pick
|
||||
* that is actually current.
|
||||
*/
|
||||
interface Sync {
|
||||
url: TextPair;
|
||||
bounds: Bounds;
|
||||
pendingText: TextPair[];
|
||||
pendingBounds: Bounds[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
applied: AppliedFilters;
|
||||
isDisabled: boolean;
|
||||
onApply: (filters: AppliedFilters) => void;
|
||||
/** Merges a patch into the applied search. `replace` keeps typing out of history. */
|
||||
onApply: (patch: Partial<AppliedFilters>, replace?: boolean) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) {
|
||||
const [domain, setDomain] = useState(applied.domain ?? "");
|
||||
const [client, setClient] = useState(applied.client ?? "");
|
||||
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
|
||||
export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
||||
const urlText: TextPair = { domain: applied.domain ?? "", client: applied.client ?? "" };
|
||||
const urlBounds: Bounds = { since: applied.since, until: applied.until };
|
||||
|
||||
const [domain, setDomain] = useState(urlText.domain);
|
||||
const [client, setClient] = useState(urlText.client);
|
||||
const [preset, setPreset] = useState<ChosenPreset | null>(null);
|
||||
const [showCustom, setShowCustom] = useState(applied.since !== undefined || applied.until !== undefined);
|
||||
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
|
||||
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<BoundError | null>(null);
|
||||
const [sync, setSync] = useState<Sync>({
|
||||
url: urlText,
|
||||
bounds: urlBounds,
|
||||
pendingText: [],
|
||||
pendingBounds: [],
|
||||
});
|
||||
|
||||
function submit(event: FormEvent) {
|
||||
const textMoved = sync.url.domain !== urlText.domain || sync.url.client !== urlText.client;
|
||||
const boundsMoved = !sameBounds(sync.bounds, urlBounds);
|
||||
if (textMoved || boundsMoved) {
|
||||
let pendingText = sync.pendingText;
|
||||
let pendingBounds = sync.pendingBounds;
|
||||
|
||||
if (textMoved) {
|
||||
// The newest commit the URL matches, and everything before it, has now
|
||||
// been accounted for; an older one landing later is not new news.
|
||||
const echo = pendingText.findLastIndex(
|
||||
(sent) => sent.domain === urlText.domain && sent.client === urlText.client,
|
||||
);
|
||||
if (echo >= 0) {
|
||||
pendingText = pendingText.slice(echo + 1);
|
||||
} else {
|
||||
pendingText = [];
|
||||
if (sync.url.domain !== urlText.domain) setDomain(urlText.domain);
|
||||
if (sync.url.client !== urlText.client) setClient(urlText.client);
|
||||
}
|
||||
}
|
||||
|
||||
if (boundsMoved) {
|
||||
const echo = pendingBounds.findLastIndex((sent) => sameBounds(sent, urlBounds));
|
||||
if (echo >= 0) {
|
||||
pendingBounds = pendingBounds.slice(echo + 1);
|
||||
} else {
|
||||
// Someone else moved the window — the back button, or a pasted link.
|
||||
// Whatever this form was showing about the old one is now wrong: the
|
||||
// preset it was named after, an error against bounds that are gone,
|
||||
// and a custom row that is open or shut for the wrong range.
|
||||
pendingBounds = [];
|
||||
setPreset(null);
|
||||
setError(null);
|
||||
setShowCustom(urlBounds.since !== undefined || urlBounds.until !== undefined);
|
||||
setSince(datetimeField(urlBounds.since));
|
||||
setUntil(datetimeField(urlBounds.until));
|
||||
}
|
||||
}
|
||||
|
||||
setSync({ url: urlText, bounds: urlBounds, pendingText, pendingBounds });
|
||||
}
|
||||
|
||||
const dirty = textFilter(domain) !== applied.domain || textFilter(client) !== applied.client;
|
||||
|
||||
const commitText = useCallback(
|
||||
(replace: boolean) => {
|
||||
const next = { domain: textFilter(domain), client: textFilter(client) };
|
||||
setSync((prev) => ({
|
||||
...prev,
|
||||
pendingText: [...prev.pendingText, { domain: next.domain ?? "", client: next.client ?? "" }],
|
||||
}));
|
||||
onApply(next, replace);
|
||||
},
|
||||
[domain, client, onApply],
|
||||
);
|
||||
|
||||
function commitBounds(bounds: Bounds) {
|
||||
setSync((prev) => ({ ...prev, pendingBounds: [...prev.pendingBounds, bounds] }));
|
||||
onApply(bounds);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const id = setTimeout(() => commitText(true), DEBOUNCE_MS);
|
||||
return () => clearTimeout(id);
|
||||
}, [dirty, commitText]);
|
||||
|
||||
function flush(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (dirty) commitText(true);
|
||||
}
|
||||
|
||||
function selectPreset(label: string) {
|
||||
if (label === CUSTOM_ITEM) {
|
||||
setShowCustom(true);
|
||||
return;
|
||||
}
|
||||
const option = PRESETS.find((candidate) => candidate.label === label);
|
||||
if (option === undefined) return;
|
||||
setShowCustom(false);
|
||||
setError(null);
|
||||
if (option.seconds === null) {
|
||||
setPreset(null);
|
||||
setSince(datetimeField(undefined));
|
||||
setUntil(datetimeField(undefined));
|
||||
commitBounds({ since: undefined, until: undefined });
|
||||
return;
|
||||
}
|
||||
// Both bounds, resolved once, here. An open upper bound would keep taking
|
||||
// in queries logged after the reader stopped looking, so "the past hour"
|
||||
// would name a different hour every time the link was opened.
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const bounds: Bounds = { since: now - option.seconds, until: now };
|
||||
setPreset({ label, ...bounds });
|
||||
setSince(datetimeField(bounds.since));
|
||||
setUntil(datetimeField(bounds.until));
|
||||
commitBounds(bounds);
|
||||
}
|
||||
|
||||
function setRange() {
|
||||
const sinceValue = resolveDatetimeField(since);
|
||||
if (!sinceValue.ok) {
|
||||
setError(boundError("Since", sinceValue.reason));
|
||||
setError(boundError("since", sinceValue.reason));
|
||||
return;
|
||||
}
|
||||
const untilValue = resolveDatetimeField(until);
|
||||
if (!untilValue.ok) {
|
||||
setError(boundError("Until", untilValue.reason));
|
||||
setError(boundError("until", untilValue.reason));
|
||||
return;
|
||||
}
|
||||
// The window is half-open — `ts >= since` and `ts < until` — so two equal
|
||||
// bounds are as empty as an inverted pair, and neither is worth applying.
|
||||
if (
|
||||
sinceValue.value !== undefined &&
|
||||
untilValue.value !== undefined &&
|
||||
untilValue.value <= sinceValue.value
|
||||
) {
|
||||
setError({
|
||||
field: "until",
|
||||
message: "Until must be after Since, or the range selects nothing.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
onApply({
|
||||
domain: domain.trim() === "" ? undefined : domain.trim(),
|
||||
client: client.trim() === "" ? undefined : client.trim(),
|
||||
blocked: optionBlocked(blocked),
|
||||
since: sinceValue.value,
|
||||
until: untilValue.value,
|
||||
});
|
||||
setPreset(null);
|
||||
commitBounds({ since: sinceValue.value, until: untilValue.value });
|
||||
}
|
||||
|
||||
function clear() {
|
||||
setDomain("");
|
||||
setClient("");
|
||||
setBlocked("any");
|
||||
setPreset(null);
|
||||
setShowCustom(false);
|
||||
setSince(datetimeField(undefined));
|
||||
setUntil(datetimeField(undefined));
|
||||
setError(null);
|
||||
setSync((prev) => ({
|
||||
...prev,
|
||||
pendingText: [],
|
||||
pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
|
||||
}));
|
||||
onClear();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<form onSubmit={submit} {...stylex.props(styles.grid)}>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Domain contains
|
||||
<input
|
||||
type="text"
|
||||
value={domain}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Client (exact)
|
||||
<input
|
||||
type="text"
|
||||
value={client}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<Select
|
||||
variant="compactField"
|
||||
label="Result"
|
||||
value={blocked}
|
||||
isDisabled={isDisabled}
|
||||
onChange={setBlocked}
|
||||
options={STATUS_OPTIONS}
|
||||
const active =
|
||||
domain.trim() !== "" ||
|
||||
client.trim() !== "" ||
|
||||
applied.blocked !== undefined ||
|
||||
applied.since !== undefined ||
|
||||
applied.until !== undefined;
|
||||
|
||||
// The label the URL earns on its own, overridden only while a preset click is
|
||||
// still the whole of what the URL says. Bounds nobody here chose read as
|
||||
// "Custom": that is what a pasted link or an edited range is.
|
||||
let timeLabel = "Any time";
|
||||
if (applied.since !== undefined || applied.until !== undefined) {
|
||||
timeLabel = preset !== null && sameBounds(preset, urlBounds) ? preset.label : "Custom";
|
||||
}
|
||||
|
||||
/** True for the one bound the current message is about; nothing else is marked. */
|
||||
const invalid = (field: BoundError["field"]): true | undefined =>
|
||||
error !== null && error.field === field ? true : undefined;
|
||||
|
||||
function boundInput(field: BoundError["field"]) {
|
||||
const state = field === "since" ? since : until;
|
||||
const set = field === "since" ? setSince : setUntil;
|
||||
return (
|
||||
<label {...stylex.props(styles.customField)}>
|
||||
{field === "since" ? "Since" : "Until"}
|
||||
<input
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={state.text}
|
||||
aria-invalid={invalid(field)}
|
||||
aria-describedby={invalid(field) && ERROR_ID}
|
||||
onChange={(event) => set(editDatetimeField(state, event.target.value))}
|
||||
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
// Enter here means this range, not the toolbar's text filters:
|
||||
// the two bounds only ever apply together, and the outer form's
|
||||
// submit would apply neither of them.
|
||||
if (event.key !== "Enter") return;
|
||||
event.preventDefault();
|
||||
setRange();
|
||||
}}
|
||||
onBlur={() => {
|
||||
const resolved = resolveDatetimeField(state);
|
||||
if (!resolved.ok) setError(boundError(field, resolved.reason));
|
||||
else if (invalid(field)) setError(null);
|
||||
}}
|
||||
{...stylex.props(shared.smallInput, styles.field, shared.focusRing)}
|
||||
/>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Since
|
||||
<input
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={since.text}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<label {...stylex.props(styles.label)}>
|
||||
Until
|
||||
<input
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={until.text}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
/>
|
||||
</label>
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isDisabled}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
{invalid(field) && (
|
||||
<span id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
|
||||
{error?.message}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={flush}>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
<div {...stylex.props(styles.searchWrap)}>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 16 16"
|
||||
width="14"
|
||||
height="14"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
{...stylex.props(styles.searchIcon)}
|
||||
>
|
||||
Apply filters
|
||||
</button>
|
||||
<circle cx="7" cy="7" r="4.5" />
|
||||
<path d="M10.5 10.5 14 14" strokeLinecap="round" />
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
aria-label="Filter domains"
|
||||
placeholder="Filter domains…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={domain}
|
||||
onChange={(event) => setDomain(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.field, styles.searchInput, shared.focusRing)}
|
||||
/>
|
||||
</div>
|
||||
{/*
|
||||
* The client filter matches one address exactly — the server has no
|
||||
* substring match for it — so the name says so rather than leaving the
|
||||
* reader to discover it by typing half an address and getting nothing.
|
||||
*/}
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Client IP (exact match)"
|
||||
placeholder="Client IP…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
value={client}
|
||||
onChange={(event) => setClient(event.target.value)}
|
||||
{...stylex.props(shared.smallInput, styles.field, styles.clientInput, shared.focusRing)}
|
||||
/>
|
||||
<RadioGroup
|
||||
aria-label="Result"
|
||||
orientation="horizontal"
|
||||
value={blockedOption(applied.blocked)}
|
||||
onChange={(next) => onApply({ blocked: optionBlocked(next) })}
|
||||
className={() => stylex.props(styles.resultGroup).className ?? ""}
|
||||
>
|
||||
{RESULTS.map((option) => (
|
||||
<Radio
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
className={({ isSelected, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.segment,
|
||||
isSelected ? styles.segmentSelected : styles.segmentIdle,
|
||||
isFocusVisible && styles.segmentFocusVisible,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</Radio>
|
||||
))}
|
||||
</RadioGroup>
|
||||
<MenuTrigger>
|
||||
<Button
|
||||
className={() =>
|
||||
stylex.props(shared.button, styles.hitTarget, shared.focusRing).className ?? ""
|
||||
}
|
||||
>
|
||||
Time: {timeLabel}
|
||||
</Button>
|
||||
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||
<Menu {...stylex.props(styles.menu)}>
|
||||
{[...PRESETS.map((option) => option.label), CUSTOM_ITEM].map((label) => (
|
||||
<MenuItem
|
||||
key={label}
|
||||
onAction={() => selectPreset(label)}
|
||||
className={({ isFocused }) =>
|
||||
stylex.props(
|
||||
styles.menuItem,
|
||||
shared.insetFocusRing,
|
||||
isFocused && styles.menuItemFocused,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
tabIndex={active ? undefined : -1}
|
||||
aria-hidden={active ? undefined : true}
|
||||
{...stylex.props(
|
||||
shared.button,
|
||||
styles.hitTarget,
|
||||
shared.focusRing,
|
||||
!active && styles.clearHidden,
|
||||
)}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{showCustom && (
|
||||
<div {...stylex.props(styles.customRow)}>
|
||||
{boundInput("since")}
|
||||
{boundInput("until")}
|
||||
<button
|
||||
type="button"
|
||||
onClick={clear}
|
||||
disabled={isDisabled}
|
||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
||||
onClick={setRange}
|
||||
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing)}
|
||||
>
|
||||
Clear
|
||||
Set range
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{error !== null && (
|
||||
<p role="alert" {...stylex.props(styles.error)}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { createAppRouter } from "@/routes";
|
||||
import { health } from "@/lib/healthFixture";
|
||||
import type { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
|
||||
import { queryRow } from "@/features/provenance/provenanceFixture";
|
||||
import { FakeEventSource } from "./fakeEventSource";
|
||||
|
||||
function client(id: number, ip: string, name: string, learnedName: string): Client {
|
||||
return {
|
||||
@@ -135,6 +136,22 @@ function queryCalls(): string[] {
|
||||
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
|
||||
}
|
||||
|
||||
/** The toolbar's search field, which is how a domain filter is entered now. */
|
||||
function domainInput(): HTMLInputElement {
|
||||
return screen.getByLabelText("Filter domains") as HTMLInputElement;
|
||||
}
|
||||
|
||||
/** Enter in a text field: the debounce's escape hatch, and the fast path here. */
|
||||
function submitFilters() {
|
||||
fireEvent.submit(domainInput().closest("form")!);
|
||||
}
|
||||
|
||||
/** The custom range lives behind the Time menu; the two bounds only exist there. */
|
||||
function openCustomRange() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||
}
|
||||
|
||||
test("renders the first page with the seven columns filled in", async () => {
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
@@ -162,7 +179,7 @@ test("renders the first page with the seven columns filled in", async () => {
|
||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
test("resolves each row's client to its display name, reading the IP out with it", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
@@ -182,19 +199,22 @@ test("resolves each row's client to its display name, keeping the IP as the tool
|
||||
|
||||
// A hand-typed name wins outright; the learned name never surfaces for it.
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
// The address reads out with the name it replaced, rather than sitting in a
|
||||
// title only a mouse can reach.
|
||||
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||
expect(named.getAttribute("title")).toBeNull();
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
// A learned name reads muted and nothing more here: the "learned" tag would
|
||||
// repeat on every row of the table, so the Clients page carries it instead.
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
// A known client with neither name, and a client the loaded list has never
|
||||
// seen, both fall back to the bare address with no tooltip standing in.
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
|
||||
// seen, both fall back to the bare address with nothing standing in for it.
|
||||
expect(screen.getByText("192.0.2.12").textContent).toBe("192.0.2.12");
|
||||
expect(screen.getByText("192.0.2.99").textContent).toBe("192.0.2.99");
|
||||
});
|
||||
|
||||
test("load more appends the next page and stops at the end of the log", async () => {
|
||||
@@ -216,8 +236,8 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await screen.findByText("older.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
@@ -242,8 +262,8 @@ test("a load-more that resolves after a filter change is discarded", async () =>
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
|
||||
releaseLoadMore();
|
||||
@@ -281,8 +301,8 @@ test("load more is disabled while a filter change shows placeholder data, then u
|
||||
renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
|
||||
const staleButton = await screen.findByRole("button", { name: "Load more" });
|
||||
expect(staleButton).toHaveProperty("disabled", true);
|
||||
@@ -416,7 +436,7 @@ test("a ?domain= link seeds the filter form and fetches that domain on arrival",
|
||||
renderPage("/activity?domain=ads");
|
||||
|
||||
await screen.findByText("ads.example");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
|
||||
expect(domainInput()).toHaveProperty("value", "ads");
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -441,7 +461,7 @@ test("a rejected search parameter is dropped rather than guessed at", async () =
|
||||
await screen.findByText("first.example");
|
||||
// Nothing survived validation, so the request is the unfiltered one.
|
||||
expect(queryCalls()).toEqual(["/api/queries"]);
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
test("the form draft follows the url back and forward, seconds included", async () => {
|
||||
@@ -454,25 +474,33 @@ test("the form draft follows the url back and forward, seconds included", async
|
||||
const seeded = 1_700_000_017;
|
||||
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
|
||||
|
||||
const domainInput = await screen.findByLabelText("Domain contains");
|
||||
expect(domainInput).toHaveProperty("value", "first");
|
||||
await screen.findByLabelText("Filter domains");
|
||||
expect(domainInput()).toHaveProperty("value", "first");
|
||||
// A seeded custom range opens its row, so the link's bounds are visible.
|
||||
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
|
||||
expect(sinceInput.value).toContain(":37");
|
||||
|
||||
fireEvent.change(domainInput, { target: { value: "second" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.change(domainInput(), { target: { value: "second" } });
|
||||
submitFilters();
|
||||
await waitFor(() => expect(history.location.search).toContain("domain=second"));
|
||||
// The untouched Since bound applied as the exact second it was seeded with.
|
||||
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
|
||||
|
||||
// A pasted link, then the buttons over it: the draft is derived from the URL,
|
||||
// so whichever way the browser moves it the field has to move with it.
|
||||
act(() => history.push(`/activity?mode=history&domain=third&since=${seeded}`));
|
||||
await waitFor(() => {
|
||||
expect(domainInput()).toHaveProperty("value", "third");
|
||||
});
|
||||
|
||||
act(() => history.back());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
|
||||
expect(domainInput()).toHaveProperty("value", "second");
|
||||
});
|
||||
|
||||
act(() => history.forward());
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
|
||||
expect(domainInput()).toHaveProperty("value", "third");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -508,8 +536,9 @@ test("a wall-clock time the daylight-saving jump skips is refused, not silently
|
||||
const callsBefore = queryCalls().length;
|
||||
const searchBefore = history.location.search;
|
||||
|
||||
openCustomRange();
|
||||
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||
|
||||
if (inGap) {
|
||||
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
|
||||
@@ -544,6 +573,40 @@ test("the policy simulation is reachable from the header, with no rows to click
|
||||
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(/the History filters apply to history only/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Clear empties the url as well as the form", async () => {
|
||||
const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
|
||||
await screen.findByText("ads.example");
|
||||
@@ -553,5 +616,130 @@ test("Clear empties the url as well as the form", async () => {
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
});
|
||||
expect(history.location.search).not.toContain("blocked");
|
||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
fireEvent.change(domainInput(), { target: { value: "a" } });
|
||||
fireEvent.change(domainInput(), { target: { value: "ad" } });
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
// Mid-word the URL has not moved: three keystrokes are one investigation,
|
||||
// not three, and each one would otherwise be a request and a history entry.
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
// Replaced, not pushed: Back leaves the page, it does not retype the word.
|
||||
expect(history.length).toBe(1);
|
||||
|
||||
fireEvent.change(domainInput(), { target: { value: "first" } });
|
||||
submitFilters();
|
||||
await waitFor(() => expect(history.location.search).toContain("domain=first"));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("the field being typed in keeps the focus when the debounce commits", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("first.example");
|
||||
|
||||
const input = domainInput();
|
||||
input.focus();
|
||||
fireEvent.change(input, { target: { value: "ads" } });
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(400);
|
||||
});
|
||||
await waitFor(() => expect(history.location.search).toContain("domain=ads"));
|
||||
|
||||
// The same node, still focused, still holding the caret: a toolbar that
|
||||
// remounted on the URL it just wrote would drop the next keystroke.
|
||||
expect(domainInput()).toBe(input);
|
||||
expect(document.activeElement).toBe(input);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test("a result segment commits on the click, with no wait and no button", async () => {
|
||||
const { history } = renderPage("/activity?domain=ads");
|
||||
await screen.findByText("ads.example");
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: "Blocked" }));
|
||||
|
||||
await waitFor(() => expect(history.location.search).toContain("blocked=true"));
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
});
|
||||
|
||||
test("a time preset writes the second it resolved to, not a rolling window", async () => {
|
||||
const now = 1_700_000_000_000;
|
||||
vi.spyOn(Date, "now").mockReturnValue(now);
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
const { history } = renderPage();
|
||||
await screen.findByText("No queries logged yet.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Time: Any time" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Past hour" }));
|
||||
|
||||
// Both bounds are concrete, so the link names a closed hour rather than one
|
||||
// that keeps growing at the top as the log does.
|
||||
const since = now / 1000 - 3600;
|
||||
const until = now / 1000;
|
||||
await waitFor(() => expect(history.location.search).toContain(`since=${since}`));
|
||||
expect(history.location.search).toContain(`until=${until}`);
|
||||
expect(screen.getByRole("button", { name: "Time: Past hour" })).toBeTruthy();
|
||||
expect(queryCalls()).toContain(`/api/queries?since=${since}&until=${until}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test("Live shows no toolbar at all", async () => {
|
||||
vi.stubGlobal(
|
||||
"EventSource",
|
||||
class {
|
||||
constructor(url: string) {
|
||||
return new FakeEventSource(url) as unknown as EventSource;
|
||||
}
|
||||
},
|
||||
);
|
||||
renderPage("/activity?mode=live&domain=ads");
|
||||
await screen.findByText(/the History filters apply to history only/);
|
||||
|
||||
// The stream is unfiltered, so a control here would promise filtering that is
|
||||
// not happening; the filters are still in the URL, waiting for History.
|
||||
expect(screen.queryByLabelText("Filter domains")).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
|
||||
});
|
||||
|
||||
test("the coverage watermark reads under the results, never over them", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
|
||||
return json({
|
||||
queries: [row(20, "kept.example")],
|
||||
next_before: null,
|
||||
coverage: { complete: false, available_since: 1_700_000_000 },
|
||||
} satisfies QueriesPage);
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText("kept.example");
|
||||
|
||||
const watermark = screen.getByText(/Query history is available from/);
|
||||
const count = screen.getByText(/Showing 1 query/);
|
||||
// After the count in document order, which is what "footer" means here.
|
||||
expect(count.compareDocumentPosition(watermark) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -8,8 +8,10 @@
|
||||
* recipient should be looking at.
|
||||
*/
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||
@@ -53,6 +55,13 @@ const styles = stylex.create({
|
||||
fontWeight: 500,
|
||||
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: {
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: { default: colors.textSecondary, ":hover": colors.text },
|
||||
@@ -76,13 +85,33 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
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() {
|
||||
const search = useSearch({ from: "/shell/activity" });
|
||||
const navigate = useNavigate({ from: "/activity" });
|
||||
const live = search.mode === "live";
|
||||
|
||||
// The functional form, not a replacement object: the filters are retained
|
||||
// across a mode switch on purpose, and spelling out a new search here would
|
||||
// drop every one of them on the way to Live and back.
|
||||
@@ -91,62 +120,75 @@ export default function ActivityPage() {
|
||||
void navigate({ search: (prev) => ({ ...prev, mode }) });
|
||||
}
|
||||
|
||||
function apply(filters: AppliedFilters) {
|
||||
void navigate({ search: { mode: search.mode, ...filters } });
|
||||
}
|
||||
// A patch, merged into whatever the URL already says: a segment click must not
|
||||
// spell out the four filters it is not about. `replace` is the debounced text
|
||||
// commits, so the back button steps between investigations, not keystrokes.
|
||||
const apply = useCallback(
|
||||
(patch: Partial<AppliedFilters>, replace = false) => {
|
||||
void navigate({ search: (prev) => ({ ...prev, ...patch }), replace });
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
void navigate({ search: (prev) => ({ mode: prev.mode, ...NO_FILTERS }) });
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div {...stylex.props(styles.header)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
||||
<div role="group" aria-label="Activity mode" {...stylex.props(styles.switch)}>
|
||||
{MODES.map((option) => {
|
||||
const selected = option.mode === search.mode;
|
||||
return (
|
||||
<button
|
||||
{/*
|
||||
* 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)}>
|
||||
<h1 {...stylex.props(styles.heading)}>Activity</h1>
|
||||
<TabList
|
||||
aria-label="Activity mode"
|
||||
className={() => stylex.props(styles.switch).className ?? ""}
|
||||
>
|
||||
{MODES.map((option) => (
|
||||
<Tab
|
||||
key={option.mode}
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={() => selectMode(option.mode)}
|
||||
{...stylex.props(
|
||||
styles.modeButton,
|
||||
selected ? styles.modeSelected : styles.modeIdle,
|
||||
shared.focusRing,
|
||||
)}
|
||||
id={option.mode}
|
||||
className={({ isSelected, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.modeButton,
|
||||
isSelected ? styles.modeSelected : styles.modeIdle,
|
||||
isFocusVisible && styles.modeFocusVisible,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
||||
Current policy simulation
|
||||
</Link>
|
||||
</div>
|
||||
<Link to="/activity/test" {...stylex.props(styles.simulationLink, shared.focusRing)}>
|
||||
Current policy simulation
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
* Remounted whenever the applied search changes, which is what makes
|
||||
* the back button work: the draft is derived state, and the browser
|
||||
* moving the URL under it has to move the form with it.
|
||||
*/}
|
||||
<ActivityFilters
|
||||
key={`${search.domain ?? ""}|${search.client ?? ""}|${String(search.blocked)}|${String(search.since)}|${String(search.until)}`}
|
||||
applied={search}
|
||||
isDisabled={live}
|
||||
onApply={apply}
|
||||
onClear={() => apply(NO_FILTERS)}
|
||||
/>
|
||||
|
||||
{live ? (
|
||||
<>
|
||||
{/*
|
||||
* The toolbar belongs to History alone. It is not remounted on a search
|
||||
* change: it resyncs its draft from the URL instead, because a remount
|
||||
* mid-debounce would take the focus out of the input being typed in.
|
||||
*/}
|
||||
<TabPanel id="history" className={panelClass}>
|
||||
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
|
||||
<HistoryActivity search={search} />
|
||||
</TabPanel>
|
||||
<TabPanel id="live" className={panelClass}>
|
||||
<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; the History filters apply to history only.
|
||||
</p>
|
||||
<LiveActivity origin={search} />
|
||||
</>
|
||||
) : (
|
||||
<HistoryActivity search={search} />
|
||||
)}
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,8 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
refetching: {
|
||||
/** The gap a line standing on its own needs from the block above it. */
|
||||
spacedTop: {
|
||||
marginTop: "0.75rem",
|
||||
},
|
||||
moreButton: {
|
||||
@@ -81,7 +82,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
|
||||
const pages = base.data?.pages ?? [];
|
||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
||||
const coverage = pages[0]?.coverage;
|
||||
// Only from the settled response. Placeholder pages belong to the previous
|
||||
// filter, and a watermark is a claim about the window being displayed.
|
||||
const coverage = base.isPlaceholderData ? undefined : pages[0]?.coverage;
|
||||
const filterActive = Object.keys(filter).length > 0;
|
||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
||||
// data stands in for a filter change; derive the cursor from what is on
|
||||
@@ -112,16 +115,27 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*
|
||||
* A quiet line, never a skeleton: the rows on screen stay put while a
|
||||
* filter change is in flight, so a keystroke must not blank the table
|
||||
* it is narrowing.
|
||||
*/}
|
||||
{base.isFetching && (
|
||||
<p {...stylex.props(styles.note, styles.refetching)} role="status">
|
||||
Loading…
|
||||
<p {...stylex.props(styles.note, styles.spacedTop)} role="status">
|
||||
Updating…
|
||||
</p>
|
||||
)}
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
||||
{rows.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
<>
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||
</p>
|
||||
{coverage !== undefined && (
|
||||
<div {...stylex.props(styles.spacedTop)}>
|
||||
<CoverageNotice coverage={coverage} variant="note" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
@@ -158,6 +172,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||
{hasMore ? "" : " — end of log"}
|
||||
</p>
|
||||
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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));
|
||||
@@ -168,7 +178,7 @@ test("streams rows, flags blocked ones, and freezes the display", async () => {
|
||||
expect(screen.getByText("later.example")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
|
||||
test("resolves each row's client to its display name, reading the IP out with it", async () => {
|
||||
await openLive();
|
||||
act(() => {
|
||||
sources[0]!.emit("query", frame(1000, "named.example", { request: { client: "192.0.2.10" } }));
|
||||
@@ -178,11 +188,14 @@ test("resolves each row's client to its display name, keeping the IP as the tool
|
||||
});
|
||||
|
||||
const named = await screen.findByText("Kitchen Pi");
|
||||
expect(named.getAttribute("title")).toBe("192.0.2.10");
|
||||
// The address reads out with the name it replaced, rather than sitting in a
|
||||
// title only a mouse can reach.
|
||||
expect(named.textContent).toBe("Kitchen Pi (192.0.2.10)");
|
||||
expect(named.getAttribute("title")).toBeNull();
|
||||
expect(screen.queryByText("pi.lan")).toBeNull();
|
||||
|
||||
const learned = screen.getByText("laptop.lan");
|
||||
expect(learned.getAttribute("title")).toBe("192.0.2.11");
|
||||
expect(learned.textContent).toBe("laptop.lan (192.0.2.11)");
|
||||
expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();
|
||||
|
||||
expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
|
||||
@@ -256,7 +269,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 +319,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 +444,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,34 +510,29 @@ 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 () => {
|
||||
test("live renders no filter toolbar, not even a disabled one", async () => {
|
||||
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
|
||||
|
||||
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
|
||||
expect(domain.value).toBe("ads");
|
||||
expect(domain.disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
|
||||
|
||||
const form = domain.closest("form")!;
|
||||
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
|
||||
expect(controls.length).toBeGreaterThan(0);
|
||||
for (const control of controls) {
|
||||
// A disabled form control is skipped by the browser's tab order, and RAC
|
||||
// pins its own trigger out of it as well. Nothing in the row may
|
||||
// reintroduce itself with a reachable tabindex.
|
||||
expect(control.hasAttribute("disabled")).toBe(true);
|
||||
const tabindex = control.getAttribute("tabindex");
|
||||
expect(tabindex === null || tabindex === "-1").toBe(true);
|
||||
}
|
||||
// The stream is unfiltered — the server sends every query — so a row of
|
||||
// controls here would promise filtering that is not happening. The filters
|
||||
// are not lost: they are in the URL, and History applies them on the way back.
|
||||
expect(screen.queryByLabelText("Filter domains")).toBeNull();
|
||||
expect(screen.queryByLabelText("Client IP (exact match)")).toBeNull();
|
||||
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
|
||||
expect(screen.queryByText("Clear")).toBeNull();
|
||||
expect(screen.getByText(/the History filters apply to history only/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("live mode asks for no query pages, whatever filters the url retained", async () => {
|
||||
@@ -431,31 +545,30 @@ 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" }));
|
||||
await screen.findByRole("button", { name: "Apply filters" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||
await screen.findByLabelText("Filter domains");
|
||||
expect(sources).toHaveLength(1);
|
||||
expect(sources[0]!.closed).toBe(true);
|
||||
// The filters came along, which is the point of switching rather than
|
||||
// navigating: the reader keeps the question they were asking.
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
|
||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
|
||||
expect((screen.getByLabelText("Filter domains") as HTMLInputElement).value).toBe("ads");
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
/**
|
||||
* The related-actions region of a query detail. Scoped on purpose: the sidebar
|
||||
* carries a Pause of its own, and this is the one that answers "this query was
|
||||
* blocked and should not have been".
|
||||
*/
|
||||
/** The related-actions region of a query detail. */
|
||||
function related(): HTMLElement {
|
||||
return screen.getByRole("region", { name: "Related" });
|
||||
}
|
||||
|
||||
test("a streamed blocked row carries the same Pause action as the persisted detail", async () => {
|
||||
/**
|
||||
* The streamed detail carries the same Related as the persisted one: four links
|
||||
* and no control. Pause is resolver-wide and lives in the sidebar alone.
|
||||
*/
|
||||
test("a streamed blocked row's Related carries links only", async () => {
|
||||
await openLive();
|
||||
act(() =>
|
||||
sources[0]!.emit(
|
||||
@@ -468,14 +581,7 @@ test("a streamed blocked row carries the same Pause action as the persisted deta
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "streamed.example" }));
|
||||
|
||||
await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());
|
||||
});
|
||||
|
||||
test("a streamed row that was allowed offers nothing to pause", async () => {
|
||||
await openLive();
|
||||
act(() => sources[0]!.emit("query", frame(1001, "allowed.example", { policy: { action: "allow" } })));
|
||||
fireEvent.click(screen.getByRole("button", { name: "allowed.example" }));
|
||||
|
||||
await screen.findByRole("heading", { level: 1, name: "allowed.example" });
|
||||
expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
|
||||
await waitFor(() => expect(within(related()).getByText("Diagnostics around this query")).toBeTruthy());
|
||||
expect(within(related()).getAllByRole("link")).toHaveLength(4);
|
||||
expect(within(related()).queryByRole("button")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Activity in live mode: the SSE stream, its bounded ring buffer, and the
|
||||
* in-place detail a streamed row opens.
|
||||
* Activity in live mode: the SSE stream, its bounded ring buffer, and the modal
|
||||
* detail a streamed row opens.
|
||||
*
|
||||
* 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
|
||||
@@ -17,6 +17,7 @@ import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useClientNames } from "@/features/clients/clientNames";
|
||||
import { summarizeEvent } from "@/features/provenance/querySummary";
|
||||
import Dialog from "@/ui/Dialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { ActivityCells, ActivityTableHead, activityDomainLink } from "./cells";
|
||||
@@ -88,6 +89,7 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
dismiss: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
@@ -161,29 +163,6 @@ const styles = stylex.create({
|
||||
textDecorationLine: "underline",
|
||||
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: {
|
||||
marginTop: "0.75rem",
|
||||
fontSize: "0.875rem",
|
||||
@@ -192,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> = {
|
||||
connecting: "Connecting…",
|
||||
open: "Live",
|
||||
@@ -225,40 +200,15 @@ function StatusPill({ status }: { status: StreamStatus }) {
|
||||
*
|
||||
* 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
|
||||
* panel would blank out for no reason they could see. The snapshot is the whole
|
||||
* fact — a streamed frame carries its own provenance — so it survives eviction,
|
||||
* a merge and a Freeze/Resume, and closes only when the reader closes it or
|
||||
* leaves live mode.
|
||||
* dialog would blank out or swap under their eyes for no reason they could see.
|
||||
* The snapshot is the whole fact — a streamed frame carries its own provenance
|
||||
* — so it survives eviction, a merge and a Freeze/Resume, and closes only when
|
||||
* 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 }) {
|
||||
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 (
|
||||
<div
|
||||
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>
|
||||
<Dialog title="Streamed query" size="detail" isOpen onClose={onClose}>
|
||||
<ProvenanceDetail
|
||||
provenance={row.event}
|
||||
persistedId={null}
|
||||
@@ -268,11 +218,10 @@ function LiveDetail({ row, origin, onClose }: { row: StreamedRow; origin: Activi
|
||||
client={summary.client_ip}
|
||||
ts={summary.ts}
|
||||
origin={origin}
|
||||
blocked={row.event.policy.action === "block"}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,21 +241,36 @@ export default function LiveActivity({
|
||||
const clientNames = useClientNames();
|
||||
const [selected, setSelected] = useState<StreamedRow | 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) {
|
||||
trigger.current = from;
|
||||
restoring.current = true;
|
||||
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 (
|
||||
<>
|
||||
<div {...stylex.props(styles.toolbar)}>
|
||||
@@ -362,71 +326,81 @@ export default function LiveActivity({
|
||||
</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.status !== "capped" && (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
{live.rows.map((row) => {
|
||||
const summary = summaryOf(row);
|
||||
return (
|
||||
<tr
|
||||
key={row.key}
|
||||
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
|
||||
>
|
||||
<ActivityCells
|
||||
row={summary}
|
||||
clientNames={clientNames}
|
||||
renderDomain={(_id, children) =>
|
||||
row.kind === "streamed" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
onClick={(event) => open(row, event.currentTarget)}
|
||||
{...stylex.props(
|
||||
styles.domainButton,
|
||||
activityDomainLink,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/activity/queries/$id"
|
||||
params={{ id: String(row.row.id) }}
|
||||
search={origin}
|
||||
{...stylex.props(activityDomainLink, shared.focusRing)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p {...stylex.props(styles.footnote)}>
|
||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first,
|
||||
last {capacity} kept).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{live.rows.length === 0 ? (
|
||||
live.status !== "capped" && (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
{live.status === "open" ? "Waiting for queries…" : "No queries received yet."}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div {...stylex.props(styles.tableWrap)}>
|
||||
<table {...stylex.props(styles.table)}>
|
||||
<ActivityTableHead />
|
||||
<tbody>
|
||||
{live.rows.map((row) => {
|
||||
const summary = summaryOf(row);
|
||||
return (
|
||||
<tr
|
||||
key={row.key}
|
||||
{...stylex.props(styles.row, summary.blocked && styles.rowBlocked)}
|
||||
>
|
||||
<ActivityCells
|
||||
row={summary}
|
||||
clientNames={clientNames}
|
||||
renderDomain={(_id, children) =>
|
||||
row.kind === "streamed" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={selected?.key === row.key}
|
||||
aria-controls={
|
||||
selected?.key === row.key ? DETAIL_PANEL_ID : undefined
|
||||
}
|
||||
onClick={(event) => open(row, event.currentTarget)}
|
||||
{...stylex.props(
|
||||
styles.domainButton,
|
||||
activityDomainLink,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
) : (
|
||||
<Link
|
||||
to="/activity/queries/$id"
|
||||
params={{ id: String(row.row.id) }}
|
||||
search={origin}
|
||||
{...stylex.props(activityDomainLink, shared.focusRing)}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p {...stylex.props(styles.footnote)}>
|
||||
Showing {live.rows.length} {live.rows.length === 1 ? "query" : "queries"} (newest first, last{" "}
|
||||
{capacity} kept).
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{selected !== null && <LiveDetail row={selected} origin={origin} onClose={() => setSelected(null)} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import PauseControl from "@/features/pause/PauseControl";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { provenanceRelatedLink } from "./ProvenanceDetail";
|
||||
import { diagnosticsBounds, relatedBounds } from "./relatedLinks";
|
||||
@@ -24,15 +23,9 @@ interface Props {
|
||||
ts: number;
|
||||
/** The Activity search the reader came from; its bounds win over the defaults. */
|
||||
origin: Pick<ActivitySearch, "since" | "until">;
|
||||
/**
|
||||
* This query was blocked. Pausing is a valid answer to a block the reader
|
||||
* disagrees with, and to nothing else here — so the control appears for a
|
||||
* block and not beside an allowed query it could not have caused.
|
||||
*/
|
||||
blocked: boolean;
|
||||
}
|
||||
|
||||
export default function RelatedActions({ domain, client, ts, origin, blocked }: Props) {
|
||||
export default function RelatedActions({ domain, client, ts, origin }: Props) {
|
||||
const bounds = relatedBounds(ts, origin);
|
||||
const window = diagnosticsBounds(ts);
|
||||
return (
|
||||
@@ -61,7 +54,6 @@ export default function RelatedActions({ domain, client, ts, origin, blocked }:
|
||||
>
|
||||
Diagnostics around this query
|
||||
</Link>
|
||||
{blocked && <PauseControl />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,16 +18,10 @@ interface Props {
|
||||
}
|
||||
|
||||
const styles = stylex.create({
|
||||
heading: {
|
||||
fontSize: "1.125rem",
|
||||
lineHeight: "1.75rem",
|
||||
fontWeight: 600,
|
||||
},
|
||||
form: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "1rem",
|
||||
marginTop: "1rem",
|
||||
},
|
||||
fieldLabel: {
|
||||
display: "block",
|
||||
@@ -67,8 +61,7 @@ export default function ClientEditDialog({ client, groups, onClose }: Props) {
|
||||
const readOnly = useReadOnlyConfig();
|
||||
|
||||
return (
|
||||
<Dialog label={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||
<h2 {...stylex.props(styles.heading)}>Edit {client.ip}</h2>
|
||||
<Dialog title={`Edit client ${client.ip}`} isOpen onClose={onClose}>
|
||||
<form
|
||||
{...stylex.props(styles.form)}
|
||||
onSubmit={(event) => {
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { ClientName, type ClientNames } from "./clientNames";
|
||||
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
||||
import { BASE, CLIENTS, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
||||
|
||||
/** The text of the elements an input points at with `aria-describedby`. */
|
||||
function describedText(input: HTMLElement): string {
|
||||
const ids = input.getAttribute("aria-describedby");
|
||||
if (ids === null) throw new Error("input has no aria-describedby");
|
||||
return ids
|
||||
.split(/\s+/)
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||
return node.textContent ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function clientRow(ip: string): HTMLElement {
|
||||
const row = screen.getByText(ip).closest("tr");
|
||||
@@ -73,6 +87,39 @@ test("the address links to the client's detail page", async () => {
|
||||
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 () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });
|
||||
|
||||
@@ -140,9 +187,15 @@ test("an unknown group id filters to nothing and offers a way out", async () =>
|
||||
expect(router.state.location.search).toEqual({});
|
||||
});
|
||||
|
||||
// Both statuses lock the declared delete, but only file authority proves the
|
||||
// file declares the row; the anchors keep the two sentences apart.
|
||||
const DECLARED_NOTE = /^This client is declared in the configuration file/;
|
||||
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
|
||||
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
|
||||
await screen.findAllByLabelText(/Managed by \/etc\/nxdns\/config\.zon/);
|
||||
// The settled sentence, not the tag: "Locked" is already on screen while
|
||||
// authority is pending, so waiting on it would not wait for this status.
|
||||
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
|
||||
@@ -150,6 +203,34 @@ test("file mode drops every edit affordance and keeps the observed delete live (
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
|
||||
// Why the locked Delete will not answer, in visible text and exactly once:
|
||||
// per row it would repeat down the whole page, and on the button it was a
|
||||
// title that a keyboard and a touch screen never reached.
|
||||
expect(screen.getAllByText(DECLARED_NOTE, { selector: "p" })).toHaveLength(1);
|
||||
expect(within(declared).queryByText(DECLARED_NOTE, { selector: "p" })).toBeNull();
|
||||
// The description stays on the button itself too, for a reader on that control.
|
||||
const locked = within(declared).getByRole("button", { name: "Delete" });
|
||||
expect(document.getElementById(locked.getAttribute("aria-describedby") ?? "")?.textContent).toMatch(DECLARED_NOTE);
|
||||
});
|
||||
|
||||
test("an all-observed page still says why Edit is gone, with no delete note to carry it", async () => {
|
||||
// Every row observed, so no Delete is locked. The edit lock is still real, and
|
||||
// "Locked" appearing with nothing to explain it is the failure this guards.
|
||||
const observedOnly = { clients: [CLIENTS.clients[1]] };
|
||||
await renderClientsPage({
|
||||
...BASE,
|
||||
"GET /api/clients": observedOnly,
|
||||
"GET /api/config/status": MANAGED_FILE,
|
||||
});
|
||||
// The settled sentence is both the anchor and the assertion: it is the whole
|
||||
// explanation for the missing Edit action.
|
||||
await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
|
||||
expect(await screen.findAllByText("Locked")).not.toHaveLength(0);
|
||||
|
||||
// The delete sentence belongs only to a row that has one.
|
||||
expect(screen.queryByText(DECLARED_NOTE)).toBeNull();
|
||||
expect((screen.getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
|
||||
@@ -170,7 +251,7 @@ test("file mode renders network assignments with no mutation control at all (R2-
|
||||
|
||||
test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
|
||||
await screen.findAllByLabelText(/Configuration status unavailable/);
|
||||
await screen.findAllByText(/^Configuration status unavailable/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
||||
@@ -186,10 +267,6 @@ test("a failed config status exposes no configuration mutation, and still delete
|
||||
// confirmation is already open. `undefined` is the failed status: the fetch stub
|
||||
// answers 404 for a key it does not hold.
|
||||
//
|
||||
// Both statuses lock the declared delete, but only file authority proves the
|
||||
// file declares the row; the anchors keep the two sentences apart.
|
||||
const DECLARED_NOTE = /^This client is declared in the configuration file/;
|
||||
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
|
||||
describe.each([
|
||||
["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
|
||||
["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
|
||||
@@ -212,13 +289,13 @@ describe.each([
|
||||
expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
|
||||
expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
|
||||
expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
|
||||
expect(within(dialog).getByLabelText(/^Locked\./)).toBeTruthy();
|
||||
expect(within(dialog).getByText("Locked")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
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 { queryClient, fetchMock } = await renderClientsPage(map);
|
||||
// The Edit affordance appearing is the proof authority resolved to
|
||||
@@ -227,23 +304,68 @@ describe.each([
|
||||
|
||||
const declared = clientRow("192.168.1.10");
|
||||
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);
|
||||
|
||||
const confirming = clientRow("192.168.1.10");
|
||||
expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
|
||||
expect(within(confirming).getByText(lockNote)).toBeTruthy();
|
||||
expect(within(confirming).queryByText(otherNote)).toBeNull();
|
||||
expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();
|
||||
// The dialog stays put. Closing it would throw focus at the Delete button
|
||||
// the same turn disabled, and the reason would survive only as a title
|
||||
// attribute; here the reason is the dialog's own message.
|
||||
await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
|
||||
expect(within(dialog).getByText(lockNote)).toBeTruthy();
|
||||
expect(within(dialog).queryByText(otherNote)).toBeNull();
|
||||
expect(within(dialog).getByText("Locked")).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
|
||||
);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
|
||||
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 () => {
|
||||
const map = { ...BASE, "DELETE /api/clients/2": {} };
|
||||
const { queryClient, fetchMock } = await renderClientsPage(map);
|
||||
@@ -253,10 +375,11 @@ describe.each([
|
||||
|
||||
const observed = clientRow("192.168.1.11");
|
||||
fireEvent.click(within(observed).getByRole("button", { name: "Delete" }));
|
||||
const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
|
||||
|
||||
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(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(
|
||||
@@ -271,7 +394,7 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
|
||||
// The status request never settles, so authority stays pending for the whole
|
||||
// test: nothing configuration owns may be offered on that guess.
|
||||
await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
|
||||
await screen.findAllByLabelText(/Checking which configuration source/);
|
||||
await screen.findAllByText(/^Checking which configuration source/);
|
||||
|
||||
expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
|
||||
expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
|
||||
@@ -280,3 +403,66 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("a save-time problem marks the input it is about and describes it", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(range)).toBe("Row 1: prefix is required.");
|
||||
|
||||
// Only the offending input is marked; the priority beside it is untouched.
|
||||
const priority = screen.getByLabelText("Priority for range 1");
|
||||
expect(priority.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(priority.getAttribute("aria-describedby")).toBeNull();
|
||||
|
||||
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
|
||||
fireEvent.change(screen.getByLabelText("Priority for range 1"), { target: { value: "abc" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(describedText(screen.getByLabelText("Priority for range 1"))).toBe(
|
||||
"Row 1: priority must be a whole number.",
|
||||
);
|
||||
});
|
||||
|
||||
test("removing a row drops the message rather than moving it to another input", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
// Row 1 is the offending one, so removing it is what would slide the stale
|
||||
// index onto row 2 — an input that validated cleanly.
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
|
||||
fireEvent.change(screen.getByLabelText("Range 2"), { target: { value: "10.0.0.0/8" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(range)).toBe("Row 1: prefix is required.");
|
||||
|
||||
const section = assignmentsSection();
|
||||
fireEvent.click(within(section).getAllByRole("button", { name: "Remove" })[0] as HTMLButtonElement);
|
||||
|
||||
const survivor = screen.getByLabelText("Range 1") as HTMLInputElement;
|
||||
expect(survivor.value).toBe("10.0.0.0/8");
|
||||
expect(survivor.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(survivor.getAttribute("aria-describedby")).toBeNull();
|
||||
expect(within(section).queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByLabelText("Range 2")).toBeNull();
|
||||
});
|
||||
|
||||
test("editing a row clears the message it was about", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
|
||||
expect(range.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(within(assignmentsSection()).queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,8 @@ import ClientEditDialog from "./ClientEditDialog";
|
||||
import NetworkAssignments from "./NetworkAssignments";
|
||||
import { ClientDisplayName } from "./clientIdentity";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import ConfigLockIndicator from "@/features/configuration/ConfigLockIndicator";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import ConfigLockIndicator, { lockReason } from "@/features/configuration/ConfigLockIndicator";
|
||||
import { useAuthority, useReadOnlyConfig, type Authority } from "@/features/configuration/authority";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -24,6 +25,9 @@ import { colors } from "@/ui/tokens.stylex";
|
||||
* alone cannot say which operator surface set the row — so the sentence names
|
||||
* the doubt rather than asserting a declaration, the way `provenanceOf` does.
|
||||
*/
|
||||
/** The one note every locked Delete on this page describes itself with. */
|
||||
const DELETE_LOCK_NOTE_ID = "clients-delete-locked-note";
|
||||
|
||||
function declaredDeleteNote(authority: Authority): string {
|
||||
if (authority.state === "resolved") {
|
||||
return "This client is declared in the configuration file; remove it there and restart.";
|
||||
@@ -31,6 +35,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.";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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({
|
||||
heading: {
|
||||
fontSize: "1.5rem",
|
||||
@@ -41,6 +55,15 @@ const styles = stylex.create({
|
||||
marginTop: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
lockNote: {
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
filterBar: {
|
||||
marginTop: "1rem",
|
||||
display: "flex",
|
||||
@@ -79,23 +102,11 @@ const styles = stylex.create({
|
||||
color: colors.primaryOnSurface,
|
||||
textDecorationLine: "none",
|
||||
},
|
||||
confirmGroup: {
|
||||
display: "inline-flex",
|
||||
flexWrap: "wrap",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
actionGroup: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
note: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.danger,
|
||||
},
|
||||
@@ -112,7 +123,7 @@ export default function ClientsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const deleteMutation = useMutation(clientDeleteMutation(queryClient));
|
||||
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 authority = useAuthority();
|
||||
|
||||
@@ -122,6 +133,17 @@ export default function ClientsPage() {
|
||||
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);
|
||||
|
||||
// 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;
|
||||
|
||||
// The reason a locked Delete will not answer, printed once above the table.
|
||||
// Per row it would repeat down the whole page; on the button it was a `title`
|
||||
// that a keyboard and a touch screen never reached.
|
||||
const deletesLocked = readOnly && rows.some((client) => client.hand_edited);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h1 {...stylex.props(styles.heading)}>Clients</h1>
|
||||
@@ -137,6 +159,15 @@ export default function ClientsPage() {
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
{/* The whole explanation for this page's locks, printed once. Per row it
|
||||
would repeat down the table; on the controls it was a `title` that a
|
||||
keyboard and a touch screen never reached. */}
|
||||
{readOnly && (
|
||||
<div {...stylex.props(styles.lockNote)}>
|
||||
<p>{lockReason(authority)}.</p>
|
||||
{deletesLocked && <p id={DELETE_LOCK_NOTE_ID}>{declaredDeleteNote(authority)}</p>}
|
||||
</div>
|
||||
)}
|
||||
{clients.length === 0 ? (
|
||||
<p {...stylex.props(styles.empty)}>
|
||||
No clients yet. Rows appear automatically as devices on the network make DNS queries — there is
|
||||
@@ -178,77 +209,37 @@ export default function ClientsPage() {
|
||||
<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, 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>
|
||||
)}
|
||||
<span {...stylex.props(styles.actionGroup)}>
|
||||
{/* Naming a client writes configuration, so the affordance is
|
||||
absent — not disabled — wherever the write cannot land. */}
|
||||
{readOnly ? (
|
||||
<ConfigLockIndicator compact />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(null)}
|
||||
onClick={() => setEditing(client)}
|
||||
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||
>
|
||||
Cancel
|
||||
Edit
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span {...stylex.props(styles.actionGroup)}>
|
||||
{/* Naming a client writes configuration, so the affordance is
|
||||
absent — not disabled — wherever the write cannot land. */}
|
||||
{readOnly ? (
|
||||
<ConfigLockIndicator />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing(client)}
|
||||
{...stylex.props(shared.smallButton, shared.focusRing)}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPendingDelete(client)}
|
||||
disabled={readOnly && client.hand_edited}
|
||||
aria-describedby={
|
||||
readOnly && client.hand_edited ? DELETE_LOCK_NOTE_ID : undefined
|
||||
}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmingId(client.id)}
|
||||
disabled={readOnly && client.hand_edited}
|
||||
title={
|
||||
readOnly && client.hand_edited
|
||||
? declaredDeleteNote(authority)
|
||||
: undefined
|
||||
}
|
||||
{...stylex.props(
|
||||
shared.smallButton,
|
||||
styles.dangerText,
|
||||
styles.dimWhenDisabled,
|
||||
shared.focusRing,
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@@ -256,6 +247,24 @@ export default function ClientsPage() {
|
||||
</table>
|
||||
</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} />
|
||||
{editing !== null && <ClientEditDialog client={editing} groups={groups} onClose={() => setEditing(null)} />}
|
||||
<NetworkAssignments prefixes={prefixes} groups={groups} />
|
||||
|
||||
@@ -4,7 +4,15 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||
import type { ClientPrefix, Group } from "@/lib/types";
|
||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
||||
import {
|
||||
firstProblem,
|
||||
initPrefixEditor,
|
||||
isDirty,
|
||||
prefixEditorReducer,
|
||||
toInputs,
|
||||
type PrefixEditorAction,
|
||||
type PrefixProblem,
|
||||
} from "./prefixEditor";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import AuthorityGate from "@/features/configuration/AuthorityGate";
|
||||
import Select from "@/ui/Select";
|
||||
@@ -16,6 +24,9 @@ interface Props {
|
||||
groups: Group[];
|
||||
}
|
||||
|
||||
/** The editor is rendered once per page, so the message can hold a fixed id. */
|
||||
const VALIDATION_ID = "network-assignments-validation";
|
||||
|
||||
const styles = stylex.create({
|
||||
section: {
|
||||
marginTop: "2.5rem",
|
||||
@@ -65,6 +76,7 @@ const styles = stylex.create({
|
||||
width: "5rem",
|
||||
},
|
||||
removeButton: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -161,12 +173,24 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
|
||||
function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||
const [validation, setValidation] = useState<PrefixProblem | null>(null);
|
||||
const dirty = isDirty(state);
|
||||
const fallbackGroupId = defaultGroupId(groups);
|
||||
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
||||
|
||||
// A problem names a row by position, and every action here can move, add or
|
||||
// delete a position. The message describes the rows Save read, so it dies
|
||||
// with them rather than drifting onto whatever row inherits the index.
|
||||
const dispatch = (action: PrefixEditorAction) => {
|
||||
setValidation(null);
|
||||
apply(action);
|
||||
};
|
||||
|
||||
/** True for the one input the current message is about; nothing else is marked. */
|
||||
const invalid = (index: number, field: PrefixProblem["field"]): true | undefined =>
|
||||
validation !== null && validation.index === index && validation.field === field ? true : undefined;
|
||||
|
||||
const save = () => {
|
||||
const problem = firstProblem(state.rows);
|
||||
setValidation(problem);
|
||||
@@ -187,6 +211,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Range ${index + 1}`}
|
||||
aria-invalid={invalid(index, "prefix")}
|
||||
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
|
||||
placeholder="192.168.1.0/24"
|
||||
value={row.prefix}
|
||||
onChange={(event) =>
|
||||
@@ -207,6 +233,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={`Priority for range ${index + 1}`}
|
||||
aria-invalid={invalid(index, "priority")}
|
||||
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
|
||||
placeholder="100"
|
||||
value={row.priority}
|
||||
onChange={(event) =>
|
||||
@@ -226,8 +254,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
</ul>
|
||||
)}
|
||||
{validation !== null && (
|
||||
<p role="alert" {...stylex.props(styles.validation)}>
|
||||
{validation}
|
||||
<p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
|
||||
{validation.message}
|
||||
</p>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
@@ -250,10 +278,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValidation(null);
|
||||
dispatch({ type: "reset", prefixes });
|
||||
}}
|
||||
onClick={() => dispatch({ type: "reset", prefixes })}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
Discard changes
|
||||
|
||||
@@ -16,6 +16,14 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { clientsQuery } from "@/lib/queries";
|
||||
import type { Client } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
|
||||
const styles = stylex.create({
|
||||
/** Secondary to the name it qualifies, and never the only thing in the cell. */
|
||||
address: {
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
export type ClientNames = ReadonlyMap<string, Pick<Client, "name" | "learned_name">>;
|
||||
|
||||
@@ -53,11 +61,13 @@ export function clientLabel(ip: string, names: ClientNames): { text: string; lea
|
||||
export function ClientName({ ip, names }: { ip: string; names: ClientNames }) {
|
||||
const label = clientLabel(ip, names);
|
||||
if (label === null) return <span {...stylex.props(shared.mono)}>{ip}</span>;
|
||||
// The name replaces the address on screen, so the address stays reachable
|
||||
// as the tooltip rather than disappearing from the row entirely.
|
||||
// The name replaces the address, so the address follows it as real text that
|
||||
// anyone can read and copy. A `title` carried it before, which reaches
|
||||
// neither a keyboard nor a touch screen.
|
||||
return (
|
||||
<span title={ip} {...stylex.props(label.learned && shared.learnedName)}>
|
||||
<span {...stylex.props(label.learned && shared.learnedName)}>
|
||||
{label.text}
|
||||
<span {...stylex.props(styles.address)}> ({ip})</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,11 +76,15 @@ test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
|
||||
|
||||
test("firstProblem flags empty prefixes and non-integer priorities", () => {
|
||||
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
|
||||
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
|
||||
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toEqual({
|
||||
index: 0,
|
||||
field: "prefix",
|
||||
message: "Row 1: prefix is required.",
|
||||
});
|
||||
expect(
|
||||
firstProblem([
|
||||
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
|
||||
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
|
||||
]),
|
||||
).toBe("Row 2: priority must be a whole number.");
|
||||
).toEqual({ index: 1, field: "priority", message: "Row 2: priority must be a whole number." });
|
||||
});
|
||||
|
||||
@@ -55,11 +55,20 @@ export function isDirty(state: PrefixEditorState): boolean {
|
||||
});
|
||||
}
|
||||
|
||||
export function firstProblem(rows: PrefixRow[]): string | null {
|
||||
for (const [i, row] of rows.entries()) {
|
||||
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
|
||||
/** Which input the message is about, so the editor can point that input at it. */
|
||||
export interface PrefixProblem {
|
||||
index: number;
|
||||
field: "prefix" | "priority";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
|
||||
for (const [index, row] of rows.entries()) {
|
||||
if (row.prefix.trim() === "")
|
||||
return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
|
||||
const priority = row.priority.trim();
|
||||
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
|
||||
if (priority !== "" && !/^\d+$/.test(priority))
|
||||
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -61,17 +61,20 @@ test("the lock is silent when the database owns the configuration", async () =>
|
||||
test("under file authority the lock names the file, in words a reader hears", async () => {
|
||||
renderIndicator(MANAGED_FILE);
|
||||
|
||||
// The reason is visible text beside the tag, not a title and not a label: a
|
||||
// tooltip reaches neither a keyboard nor a touch screen, and screen-reader-only
|
||||
// text is the same failure pointed the other way.
|
||||
const lock = await screen.findByText("Locked");
|
||||
await waitFor(() =>
|
||||
expect(lock.getAttribute("aria-label")).toBe(
|
||||
`Locked. Managed by ${CONFIG_PATH}; edit the file and restart nxdns.`,
|
||||
),
|
||||
expect(screen.getByText(`Managed by ${CONFIG_PATH}; edit the file and restart nxdns`)).toBeTruthy(),
|
||||
);
|
||||
expect(lock.getAttribute("title")).toBeNull();
|
||||
expect(lock.getAttribute("aria-label")).toBeNull();
|
||||
});
|
||||
|
||||
test("an unanswered status still locks, and says that is why", async () => {
|
||||
renderIndicator("failed");
|
||||
|
||||
const lock = await screen.findByText("Locked");
|
||||
await waitFor(() => expect(lock.getAttribute("aria-label")).toContain("Configuration status unavailable"));
|
||||
await screen.findByText("Locked");
|
||||
await waitFor(() => expect(screen.getByText(/^Configuration status unavailable/)).toBeTruthy());
|
||||
});
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import { useAuthority } from "./authority";
|
||||
import { useAuthority, type Authority } from "./authority";
|
||||
|
||||
const styles = stylex.create({
|
||||
row: {
|
||||
display: "inline-flex",
|
||||
alignItems: "baseline",
|
||||
flexWrap: "wrap",
|
||||
gap: "0.375rem",
|
||||
},
|
||||
/** The sentence is secondary to the word, and wraps rather than stretching a row. */
|
||||
reason: {
|
||||
fontSize: "0.75rem",
|
||||
lineHeight: "1rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
tag: {
|
||||
marginLeft: "0.5rem",
|
||||
borderWidth: 1,
|
||||
@@ -26,20 +38,34 @@ const styles = stylex.create({
|
||||
* The word is real text, not colour or an icon, so a screen reader announces
|
||||
* the reason the control will not answer.
|
||||
*/
|
||||
export default function ConfigLockIndicator() {
|
||||
/**
|
||||
* Why a configuration control will not answer. Exported because a page that
|
||||
* shows the compact tag has to print this sentence itself, once, somewhere the
|
||||
* tag can point at.
|
||||
*/
|
||||
export function lockReason(authority: Authority): string {
|
||||
if (authority.state === "pending") return "Checking which configuration source this server obeys";
|
||||
if (authority.state === "failed") return "Configuration status unavailable, so edits are held back";
|
||||
return `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
|
||||
}
|
||||
|
||||
export default function ConfigLockIndicator({ compact = false }: { compact?: boolean }) {
|
||||
const authority = useAuthority();
|
||||
if (authority.state === "resolved" && authority.status.authority === "database") return null;
|
||||
|
||||
const reason =
|
||||
authority.state === "pending"
|
||||
? "Checking which configuration source this server obeys"
|
||||
: authority.state === "failed"
|
||||
? "Configuration status unavailable, so edits are held back"
|
||||
: `Managed by ${authority.status.path ?? "the configuration file"}; edit the file and restart nxdns`;
|
||||
const reason = lockReason(authority);
|
||||
|
||||
// A compact caller has no room for the sentence and must print it once
|
||||
// nearby instead: the Clients table would otherwise repeat it down every row.
|
||||
if (compact) return <span {...stylex.props(styles.tag)}>Locked</span>;
|
||||
|
||||
// Everywhere else the reason is visible text rather than a `title` or an
|
||||
// `aria-label`. A tooltip reaches neither a keyboard nor a touch screen, and
|
||||
// screen-reader-only text is the same failure pointed the other way.
|
||||
return (
|
||||
<span title={reason} aria-label={`Locked. ${reason}.`} {...stylex.props(styles.tag)}>
|
||||
Locked
|
||||
<span {...stylex.props(styles.row)}>
|
||||
<span {...stylex.props(styles.tag)}>Locked</span>
|
||||
<span {...stylex.props(styles.reason)}>{reason}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { groupSourcesPutMutation, groupSourcesQuery } from "@/lib/queries";
|
||||
import type { Blocklist } from "@/lib/types";
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
import { sameSet } from "./sourceSet";
|
||||
import Checkbox, { CheckboxGroup } from "@/ui/Checkbox";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -28,13 +29,6 @@ const styles = stylex.create({
|
||||
flexDirection: "column",
|
||||
gap: "0.25rem",
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
buttonRow: {
|
||||
marginTop: "0.75rem",
|
||||
display: "flex",
|
||||
@@ -66,21 +60,22 @@ export default function GroupSourcesEditor({ groupId, blocklists }: Props) {
|
||||
|
||||
return (
|
||||
<div {...stylex.props(styles.root)}>
|
||||
<ul {...stylex.props(styles.list)}>
|
||||
{blocklists.map((blocklist) => (
|
||||
<li key={blocklist.id}>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={current.includes(blocklist.id)}
|
||||
onChange={() => setSelected(toggleSource(current, blocklist.id))}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
{blocklist.name}
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* The section's own "Assigned sources" heading is the visible label; a
|
||||
Label here would put the same words on screen twice. Ids cross the
|
||||
React Aria boundary as strings, the same convention as Select. */}
|
||||
<CheckboxGroup
|
||||
aria-label="Assigned sources"
|
||||
value={current.map(String)}
|
||||
onChange={(values) => setSelected(values.map(Number).sort((a, b) => a - b))}
|
||||
>
|
||||
<ul {...stylex.props(styles.list)}>
|
||||
{blocklists.map((blocklist) => (
|
||||
<li key={blocklist.id}>
|
||||
<Checkbox value={String(blocklist.id)}>{blocklist.name}</Checkbox>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CheckboxGroup>
|
||||
<InlineError error={mutation.error} />
|
||||
<div {...stylex.props(styles.buttonRow)}>
|
||||
<button
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { Blocklist, ConfigStatus, Group, Rule, RuleAction, RuleKind } from
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import DefinitionList from "@/ui/DefinitionList";
|
||||
import Select from "@/ui/Select";
|
||||
import Switch from "@/ui/Switch";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import AuthorityGate from "./AuthorityGate";
|
||||
@@ -68,13 +69,6 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
},
|
||||
checkboxLabel: {
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
spacer: {
|
||||
marginLeft: "auto",
|
||||
},
|
||||
@@ -355,21 +349,15 @@ function GroupDetailEditable({ group }: { group: Group }) {
|
||||
)}
|
||||
|
||||
<div {...stylex.props(styles.controlRow)}>
|
||||
<label {...stylex.props(styles.checkboxLabel)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={group.safe_search}
|
||||
disabled={update.isPending}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
onChange={(event) =>
|
||||
update.mutate({
|
||||
id: group.id,
|
||||
input: { name: group.name, safe_search: event.target.checked },
|
||||
})
|
||||
}
|
||||
/>
|
||||
<Switch
|
||||
isSelected={group.safe_search}
|
||||
isDisabled={update.isPending}
|
||||
onChange={(safeSearch) =>
|
||||
update.mutate({ id: group.id, input: { name: group.name, safe_search: safeSearch } })
|
||||
}
|
||||
>
|
||||
Safe search
|
||||
</label>
|
||||
</Switch>
|
||||
<span {...stylex.props(styles.spacer)}>
|
||||
{!renaming && (
|
||||
<button
|
||||
|
||||
@@ -46,7 +46,7 @@ test("another group can be renamed and deleted, and carries its safe-search stat
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
|
||||
expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
expect((screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
|
||||
const dialog = await screen.findByRole("alertdialog");
|
||||
@@ -60,7 +60,7 @@ test("toggling safe search resends the whole group row", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Safe search" }));
|
||||
fireEvent.click(screen.getByRole("switch", { name: "Safe search" }));
|
||||
|
||||
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
||||
expect(writes("PUT")[0]).toMatchObject({
|
||||
@@ -89,6 +89,31 @@ test("the source assignment saves the full set via PUT", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("the source checkboxes form one named group, and each carries its own state", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
|
||||
// The section heading names the group; the boxes belong to it rather than
|
||||
// sitting loose beside the group's other checkboxes.
|
||||
const group = await screen.findByRole("group", { name: "Assigned sources" });
|
||||
const ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
|
||||
expect(ads.checked).toBe(false);
|
||||
// Safe search is the group's own field, not one of its sources, and it is a
|
||||
// switch rather than a checkbox because it applies the moment it moves.
|
||||
expect(within(group).queryByRole("switch")).toBeNull();
|
||||
expect(screen.getByRole("switch", { name: "Safe search" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(ads);
|
||||
await waitFor(() => expect(ads.checked).toBe(true));
|
||||
|
||||
// Discard returns the group to the server's set rather than clearing it.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Discard" }));
|
||||
await waitFor(() =>
|
||||
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(false),
|
||||
);
|
||||
expect(writes("PUT")).toEqual([]);
|
||||
});
|
||||
|
||||
test("only the selected group's rules are listed", async () => {
|
||||
await openProtection(2);
|
||||
await screen.findByRole("heading", { name: "kids", level: 2 });
|
||||
@@ -212,11 +237,33 @@ test("the Sources tab lists the catalogue with both skipped columns and their no
|
||||
expect(
|
||||
screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
|
||||
).toBeTruthy();
|
||||
expect((screen.getByLabelText("Ads enabled") as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByLabelText("Trackers enabled") as HTMLInputElement).checked).toBe(false);
|
||||
// Switches, not checkboxes: the row applies the moment it moves, and the role
|
||||
// is what tells a screen reader so.
|
||||
expect((screen.getByRole("switch", { name: "Ads enabled" }) as HTMLInputElement).checked).toBe(true);
|
||||
expect((screen.getByRole("switch", { name: "Trackers enabled" }) as HTMLInputElement).checked).toBe(false);
|
||||
expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a source switch resends the whole row immediately, with no Save step", async () => {
|
||||
calls = stubApi(DATABASE);
|
||||
await renderPage("/configuration/protection?tab=sources", "Protection");
|
||||
|
||||
fireEvent.click(await screen.findByRole("switch", { name: "Trackers enabled" }));
|
||||
|
||||
// No Save button stands between the switch and the write: one click, one PUT.
|
||||
await waitFor(() => expect(writes("PUT")).toHaveLength(1));
|
||||
expect(writes("PUT")[0]).toMatchObject({
|
||||
url: "/api/blocklists/2",
|
||||
// The whole row goes back, not a patch of the one field that moved.
|
||||
body: {
|
||||
url: "https://example.com/trackers.txt",
|
||||
name: "Trackers",
|
||||
enabled: true,
|
||||
is_suggested: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("Update now says it started, and says nothing once it succeeds", async () => {
|
||||
let release: ((response: Response) => void) | null = null;
|
||||
calls = stubApi(DATABASE, {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Blocklist, BlocklistInput } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import Switch from "@/ui/Switch";
|
||||
import AuthorityGate from "./AuthorityGate";
|
||||
import BlocklistForm from "./BlocklistForm";
|
||||
import FileModeNote from "./FileModeNote";
|
||||
@@ -249,13 +250,11 @@ function SourcesEditor({ blocklists }: { blocklists: Blocklist[] }) {
|
||||
</span>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Switch
|
||||
aria-label={`${b.name} enabled`}
|
||||
checked={b.enabled}
|
||||
disabled={toggle.isPending}
|
||||
isSelected={b.enabled}
|
||||
isDisabled={toggle.isPending}
|
||||
onChange={() => toggleEnabled(b)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{b.domain_count}</td>
|
||||
|
||||
@@ -13,6 +13,9 @@ import { styles as config } from "./styles";
|
||||
|
||||
const DARK = "@media (prefers-color-scheme: dark)";
|
||||
|
||||
/** The form is rendered once per page, so the message can hold a fixed id. */
|
||||
const MISMATCH_ID = "web.password_mismatch";
|
||||
|
||||
const styles = stylex.create({
|
||||
form: {
|
||||
marginTop: "1rem",
|
||||
@@ -100,6 +103,7 @@ const styles = stylex.create({
|
||||
gap: "0.75rem",
|
||||
},
|
||||
save: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "1rem",
|
||||
@@ -174,12 +178,15 @@ function FieldRow({
|
||||
}
|
||||
if (def.kind === "number") {
|
||||
const numeric = value as number;
|
||||
// An empty or unparseable number reads back as NaN. The form already refuses
|
||||
// to submit on it; this is what says so to a screen reader.
|
||||
return (
|
||||
<div {...stylex.props(styles.field)}>
|
||||
<FieldLabel id={id} text={def.key} restart={restart} />
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
aria-invalid={Number.isNaN(numeric) || undefined}
|
||||
value={Number.isNaN(numeric) ? "" : numeric}
|
||||
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -285,6 +292,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
id="web.password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={passwordsMismatch || undefined}
|
||||
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -298,6 +307,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
id="web.password_confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={passwordsMismatch || undefined}
|
||||
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -310,7 +321,7 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
</p>
|
||||
)}
|
||||
{passwordsMismatch && (
|
||||
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||
<p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||
Passwords do not match.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -176,12 +176,52 @@ test("enum and boolean fields diff as their own types", async () => {
|
||||
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
|
||||
});
|
||||
|
||||
/** The text of the elements an input points at with `aria-describedby`. */
|
||||
function describedText(input: HTMLElement): string {
|
||||
const ids = input.getAttribute("aria-describedby");
|
||||
if (ids === null) throw new Error("input has no aria-describedby");
|
||||
return ids
|
||||
.split(/\s+/)
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||
return node.textContent ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
test("clearing a number field disables Save instead of sending NaN", async () => {
|
||||
await openSystem();
|
||||
|
||||
const cache = screen.getByRole("group", { name: "Cache" });
|
||||
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
|
||||
const size = within(cache).getByLabelText("size");
|
||||
fireEvent.change(size, { target: { value: "" } });
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
// The refusal is on the field itself, not only on the Save button.
|
||||
expect(size.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
fireEvent.change(size, { target: { value: "512" } });
|
||||
expect(size.getAttribute("aria-invalid")).toBeNull();
|
||||
});
|
||||
|
||||
test("the mismatch message is attached to both password inputs", async () => {
|
||||
await openSystem();
|
||||
|
||||
const web = screen.getByRole("group", { name: "Web" });
|
||||
const passwordInput = within(web).getByLabelText("password");
|
||||
const confirmInput = within(web).getByLabelText("confirm password");
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
|
||||
for (const input of [passwordInput, confirmInput]) {
|
||||
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(input)).toBe("Passwords do not match.");
|
||||
}
|
||||
|
||||
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
|
||||
for (const input of [passwordInput, confirmInput]) {
|
||||
expect(input.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(input.getAttribute("aria-describedby")).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Upstream, UpstreamInput } from "@/lib/types";
|
||||
import ConfirmDialog from "@/ui/ConfirmDialog";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
import Switch from "@/ui/Switch";
|
||||
import AuthorityGate from "./AuthorityGate";
|
||||
import FileModeNote from "./FileModeNote";
|
||||
import QueryPanel from "./QueryPanel";
|
||||
@@ -165,13 +166,11 @@ function UpstreamsEditor({ upstreams }: { upstreams: Upstream[] }) {
|
||||
</td>
|
||||
<td {...stylex.props(shared.td, shared.tabularNums)}>{u.priority}</td>
|
||||
<td {...stylex.props(shared.td)}>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Switch
|
||||
aria-label={`${u.url} enabled`}
|
||||
checked={u.enabled}
|
||||
disabled={toggle.isPending}
|
||||
isSelected={u.enabled}
|
||||
isDisabled={toggle.isPending}
|
||||
onChange={() => toggleEnabled(u)}
|
||||
{...stylex.props(shared.focusRing)}
|
||||
/>
|
||||
</td>
|
||||
<td {...stylex.props(shared.td)}>{u.tls_name === "" ? "—" : u.tls_name}</td>
|
||||
|
||||
@@ -24,7 +24,7 @@ afterEach(() => {
|
||||
function mutationControls(): Element[] {
|
||||
return [
|
||||
...contentArea().querySelectorAll(
|
||||
'input, textarea, select, [role="combobox"], [role="checkbox"], [contenteditable]',
|
||||
'input, textarea, select, [role="combobox"], [role="checkbox"], [role="switch"], [contenteditable]',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,18 +1,4 @@
|
||||
import { sameSet, toggleSource } from "./sourceSet";
|
||||
|
||||
test("toggleSource adds a missing id keeping ascending order", () => {
|
||||
expect(toggleSource([1, 3], 2)).toEqual([1, 2, 3]);
|
||||
expect(toggleSource([], 5)).toEqual([5]);
|
||||
});
|
||||
|
||||
test("toggleSource removes a present id", () => {
|
||||
expect(toggleSource([1, 2, 3], 2)).toEqual([1, 3]);
|
||||
expect(toggleSource([5], 5)).toEqual([]);
|
||||
});
|
||||
|
||||
test("toggleSource twice is a no-op set-wise", () => {
|
||||
expect(toggleSource(toggleSource([1, 2], 3), 3)).toEqual([1, 2]);
|
||||
});
|
||||
import { sameSet } from "./sourceSet";
|
||||
|
||||
test("sameSet compares regardless of order", () => {
|
||||
expect(sameSet([1, 2, 3], [3, 1, 2])).toBe(true);
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
export function toggleSource(ids: number[], id: number): number[] {
|
||||
if (ids.includes(id)) return ids.filter((existing) => existing !== id);
|
||||
return [...ids, id].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function sameSet(a: number[], b: number[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort((x, y) => x - y);
|
||||
|
||||
@@ -69,8 +69,6 @@ const styles = stylex.create({
|
||||
interface Series {
|
||||
key: string;
|
||||
label: string;
|
||||
/** Kept beside the label so a renamed client is still identifiable by address. */
|
||||
address: string | null;
|
||||
color: string;
|
||||
buckets: number[];
|
||||
}
|
||||
@@ -89,15 +87,11 @@ function seriesOf(data: ClientChartData, names: ClientNames): Series[] {
|
||||
// the same precedence and the same lookup the query tables use. The colour
|
||||
// keys on the address regardless, so naming a client never repaints it.
|
||||
label: clientLabel(client.client, names)?.text ?? client.client,
|
||||
address: client.client,
|
||||
color: seriesColor(clientKey(client.client)),
|
||||
buckets: client.buckets,
|
||||
}));
|
||||
if (data.other.every((count) => count === 0)) return named;
|
||||
return [
|
||||
...named,
|
||||
{ key: OTHER_KEY, label: "Other", address: null, color: seriesColor(OTHER_KEY), buckets: data.other },
|
||||
];
|
||||
return [...named, { key: OTHER_KEY, label: "Other", color: seriesColor(OTHER_KEY), buckets: data.other }];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,7 +213,7 @@ export default function ClientChart({ data }: { data: ClientChartData }) {
|
||||
)}
|
||||
<ul {...stylex.props(styles.legend)}>
|
||||
{series.map((one) => (
|
||||
<li key={one.key} title={one.address ?? undefined} {...stylex.props(styles.legendItem)}>
|
||||
<li key={one.key} {...stylex.props(styles.legendItem)}>
|
||||
<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(one.color))} />
|
||||
{one.label}
|
||||
</li>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { Radio, RadioGroup } from "react-aria-components";
|
||||
import type { Period } from "@/lib/types";
|
||||
import { styles as shared } from "@/ui/styles";
|
||||
import { colors } from "@/ui/tokens.stylex";
|
||||
@@ -38,6 +39,7 @@ const styles = stylex.create({
|
||||
gap: "0.25rem",
|
||||
},
|
||||
period: {
|
||||
cursor: "pointer",
|
||||
borderStyle: "none",
|
||||
borderRadius: "0.25rem",
|
||||
paddingInline: "0.625rem",
|
||||
@@ -45,6 +47,13 @@ const styles = stylex.create({
|
||||
fontSize: "0.875rem",
|
||||
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. */
|
||||
periodSelected: {
|
||||
backgroundColor: {
|
||||
@@ -67,23 +76,29 @@ const styles = stylex.create({
|
||||
|
||||
export function PeriodPicker({ period, onChange }: { period: Period; onChange: (period: Period) => void }) {
|
||||
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) => (
|
||||
<button
|
||||
<Radio
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={option === period}
|
||||
onClick={() => onChange(option)}
|
||||
{...stylex.props(
|
||||
styles.period,
|
||||
option === period ? styles.periodSelected : styles.periodIdle,
|
||||
shared.focusRing,
|
||||
)}
|
||||
value={option}
|
||||
className={({ isSelected, isFocusVisible }) =>
|
||||
stylex.props(
|
||||
styles.period,
|
||||
isSelected ? styles.periodSelected : styles.periodIdle,
|
||||
isFocusVisible && styles.periodFocusVisible,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{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();
|
||||
|
||||
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.
|
||||
const loading = await screen.findByText("Loading…");
|
||||
expect(loading.getAttribute("role")).toBe("status");
|
||||
@@ -214,6 +214,16 @@ test("a registered client is named in the chart, an unregistered one keeps its a
|
||||
await waitFor(() => expect(within(chart).getAllByText("kitchen-pi")).toHaveLength(2));
|
||||
expect(within(chart).queryByText("192.0.2.30")).toBeNull();
|
||||
expect(within(chart).getAllByText("192.0.2.31")).toHaveLength(2);
|
||||
|
||||
// Nor on hover: a pointer-only tooltip would say what the design just chose
|
||||
// not to, and only to a reader holding a mouse.
|
||||
const legendItem = within(chart)
|
||||
.getAllByText("kitchen-pi")
|
||||
.map((node) => node.closest("li"))
|
||||
.find((node) => node !== null);
|
||||
expect(legendItem).toBeTruthy();
|
||||
expect(legendItem?.getAttribute("title")).toBeNull();
|
||||
expect(within(chart).getByRole("list").querySelectorAll("[title]")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("a client named only by reverse DNS is named by it too", async () => {
|
||||
@@ -335,14 +345,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 () => {
|
||||
renderApp("/overview?period=1h");
|
||||
await screen.findByText("12");
|
||||
expect(screen.getByRole("button", { name: "1h" }).getAttribute("aria-pressed")).toBe("true");
|
||||
expect(screen.getByRole("button", { name: "24h" }).getAttribute("aria-pressed")).toBe("false");
|
||||
// One radio group named Period, holding the four periods and exactly one
|
||||
// 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 () => {
|
||||
const router = renderApp("/overview?period=90d");
|
||||
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({});
|
||||
});
|
||||
|
||||
@@ -350,7 +369,7 @@ test("the picker rescopes every panel and writes the period into the url", async
|
||||
const router = renderApp();
|
||||
await screen.findByText("1,000");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "1h" }));
|
||||
fireEvent.click(screen.getByRole("radio", { name: "1h" }));
|
||||
|
||||
await screen.findByText("12");
|
||||
await waitFor(() => expect(router.state.location.search).toEqual({ period: "1h" }));
|
||||
@@ -369,7 +388,7 @@ test("a failed request is one error for the whole page, stated once and retryabl
|
||||
expect(screen.getAllByRole("button", { name: "Retry" })).toHaveLength(1);
|
||||
// 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("button", { name: "1h" })).toBeTruthy();
|
||||
expect(screen.getByRole("radio", { name: "1h" })).toBeTruthy();
|
||||
expect(screen.queryByText("Something went wrong")).toBeNull();
|
||||
|
||||
failing = false;
|
||||
|
||||
@@ -91,11 +91,14 @@ test("unpaused: duration menu pauses with the picked duration_seconds", async ()
|
||||
|
||||
fireEvent.click(trigger);
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("true");
|
||||
for (const label of ["60 seconds", "5 minutes", "30 minutes", "Indefinitely"]) {
|
||||
expect(screen.getByRole("button", { name: label })).toBeTruthy();
|
||||
}
|
||||
expect(screen.getAllByRole("menuitem").map((item) => item.textContent)).toEqual([
|
||||
"60 seconds",
|
||||
"5 minutes",
|
||||
"30 minutes",
|
||||
"Indefinitely",
|
||||
]);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true, duration_seconds: 300 }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
@@ -105,7 +108,7 @@ test("indefinite pause sends no duration_seconds", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Indefinitely" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "Indefinitely" }));
|
||||
await waitFor(() => expect(postBodies).toEqual([{ paused: true }]));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
@@ -151,11 +154,15 @@ test("an active resolver states nothing: the button already says Pause", async (
|
||||
test("escape closes the duration menu", async () => {
|
||||
renderControl();
|
||||
|
||||
const trigger = await findPauseTrigger();
|
||||
fireEvent.click(trigger);
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
fireEvent.keyDown(trigger, { key: "Escape" });
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
// Opening the menu moves focus into it, so Escape is pressed where the reader
|
||||
// is, not on the trigger they left.
|
||||
fireEvent.keyDown(screen.getByRole("menu"), { key: "Escape" });
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull());
|
||||
expect(screen.getByRole("button", { name: "Pause" }).getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||
@@ -168,7 +175,7 @@ test("failed pause with 429 shows a ticking retry countdown", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("Rate limited. Try again in 30s.");
|
||||
@@ -187,7 +194,7 @@ test("failed pause with 503 shows the degraded message", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "60 seconds" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "60 seconds" }));
|
||||
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert.textContent).toBe("The server is starting or degraded. Try again shortly.");
|
||||
@@ -202,12 +209,12 @@ test("a successful pause clears the previous mutation error", async () => {
|
||||
renderControl();
|
||||
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
await screen.findByRole("alert");
|
||||
|
||||
postFailure = null;
|
||||
fireEvent.click(screen.getByRole("button", { name: "Pause" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "5 minutes" }));
|
||||
fireEvent.click(screen.getByRole("menuitem", { name: "5 minutes" }));
|
||||
|
||||
await screen.findByRole("button", { name: "Resume" });
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
@@ -240,7 +247,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
protection = { state: "unavailable", until: null };
|
||||
await vi.advanceTimersByTimeAsync(11_000);
|
||||
@@ -253,7 +260,7 @@ test("a menu left open when the control withdraws does not come back open", asyn
|
||||
// reader opened, and nobody opened this one.
|
||||
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -261,7 +268,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
renderControl();
|
||||
fireEvent.click(await findPauseTrigger());
|
||||
expect(screen.getByRole("button", { name: "Indefinitely" })).toBeTruthy();
|
||||
expect(screen.getByRole("menuitem", { name: "Indefinitely" })).toBeTruthy();
|
||||
|
||||
// Filtering is paused from somewhere else, and this browser learns it from
|
||||
// the poll. The Resume rendering has no menu.
|
||||
@@ -274,7 +281,7 @@ test("a menu open when someone else pauses does not reopen when that pause ends"
|
||||
|
||||
const trigger = await vi.waitFor(() => screen.getByRole("button", { name: "Pause" }));
|
||||
expect(trigger.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(screen.queryByRole("button", { name: "Indefinitely" })).toBeNull();
|
||||
expect(screen.queryByRole("menuitem", { name: "Indefinitely" })).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/**
|
||||
* The Pause/Resume control, in the two places a pause is a valid answer to what
|
||||
* the reader is looking at: the foot of the sidebar, where it belongs to the
|
||||
* resolver rather than to any page, and beside the detail of a query that was
|
||||
* blocked.
|
||||
* The Pause/Resume control, at the foot of the sidebar. A pause stops filtering
|
||||
* for the whole resolver, so it belongs to the shell rather than to any page,
|
||||
* and it has no second placement.
|
||||
*
|
||||
* It reads `Health.protection` rather than `/api/pause` so it cannot contradict
|
||||
* the Diagnostics health strip, and it renders nothing at all while protection
|
||||
@@ -20,9 +19,10 @@
|
||||
* line: the button says Pause, which is the whole message.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover } from "react-aria-components";
|
||||
import { formatClock } from "@/lib/format";
|
||||
import { pauseMutation } from "@/lib/queries";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
@@ -48,6 +48,8 @@ const styles = stylex.create({
|
||||
":disabled": "oklch(70.5% 0.015 286.067)",
|
||||
"@media (prefers-color-scheme: dark)": { default: null, ":disabled": "oklch(44.2% 0.017 285.786)" },
|
||||
},
|
||||
/** The sidebar foot is the only placement, and there Log out sets the width. */
|
||||
width: "100%",
|
||||
},
|
||||
row: {
|
||||
display: "flex",
|
||||
@@ -61,43 +63,44 @@ const styles = stylex.create({
|
||||
lineHeight: "1rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
anchor: {
|
||||
position: "relative",
|
||||
},
|
||||
menu: {
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: "100%",
|
||||
zIndex: 10,
|
||||
marginTop: "0.25rem",
|
||||
display: "flex",
|
||||
width: "9rem",
|
||||
flexDirection: "column",
|
||||
/** `--trigger-width` is RAC's: the menu is as wide as the button that opened it. */
|
||||
popover: {
|
||||
width: "var(--trigger-width)",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
paddingBlock: "0.25rem",
|
||||
color: colors.text,
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
},
|
||||
menu: {
|
||||
outlineStyle: "none",
|
||||
paddingBlock: "0.25rem",
|
||||
},
|
||||
menuItem: {
|
||||
borderStyle: "none",
|
||||
backgroundColor: { default: "transparent", ":hover": colors.surfaceHover },
|
||||
color: "inherit",
|
||||
cursor: "pointer",
|
||||
paddingInline: "0.75rem",
|
||||
paddingBlock: "0.375rem",
|
||||
textAlign: "left",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/**
|
||||
* RAC focuses the item's own node, so the shared ring applies; it is inset
|
||||
* because an item flush against the popover edge clips an outset one, and
|
||||
* recoloured because the focus token is the blue this row just painted.
|
||||
*/
|
||||
menuItemFocused: {
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
outlineColor: { default: null, ":focus-visible": colors.primaryText },
|
||||
},
|
||||
});
|
||||
|
||||
export default function PauseControl() {
|
||||
const queryClient = useQueryClient();
|
||||
const protection = useProtection();
|
||||
const mutation = useMutation(pauseMutation(queryClient));
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const paused = protection.state === "paused";
|
||||
const { reset } = mutation;
|
||||
useEffect(() => reset(), [paused, reset]);
|
||||
@@ -108,15 +111,6 @@ export default function PauseControl() {
|
||||
// the wrong action, which is the contradiction this control exists to end.
|
||||
const actionable = protection.state === "active" || protection.state === "paused";
|
||||
|
||||
// Leaving `active` unmounts the menu but not the state that opened it, and
|
||||
// the menu belongs to the active rendering alone — a pause someone else
|
||||
// started, seen through the poll, takes it away exactly as a withdrawal does.
|
||||
// Closing on the way out rather than on the way back means the trigger can
|
||||
// only ever come back shut, however long it was gone.
|
||||
useEffect(() => {
|
||||
if (protection.state !== "active") setMenuOpen(false);
|
||||
}, [protection.state]);
|
||||
|
||||
if (!actionable) return null;
|
||||
|
||||
if (paused) {
|
||||
@@ -140,41 +134,40 @@ export default function PauseControl() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
{...stylex.props(styles.anchor)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setMenuOpen(false);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={menuOpen}
|
||||
aria-controls="pause-menu"
|
||||
onClick={() => setMenuOpen((open) => !open)}
|
||||
disabled={mutation.isPending}
|
||||
{...stylex.props(shared.button, styles.trigger, shared.focusRing)}
|
||||
>
|
||||
Pause
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div id="pause-menu" {...stylex.props(styles.menu)}>
|
||||
{DURATIONS.map(({ label, seconds }) => (
|
||||
<button
|
||||
key={label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
mutation.mutate(
|
||||
seconds === null ? { paused: true } : { paused: true, duration_seconds: seconds },
|
||||
);
|
||||
}}
|
||||
{...stylex.props(styles.menuItem, shared.insetFocusRing)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div {...stylex.props(styles.row)}>
|
||||
<MenuTrigger>
|
||||
<Button
|
||||
isDisabled={mutation.isPending}
|
||||
className={() => stylex.props(shared.button, styles.trigger, shared.focusRing).className ?? ""}
|
||||
>
|
||||
Pause
|
||||
</Button>
|
||||
<Popover className={() => stylex.props(styles.popover).className ?? ""}>
|
||||
<Menu {...stylex.props(styles.menu)}>
|
||||
{DURATIONS.map(({ label, seconds }) => (
|
||||
<MenuItem
|
||||
key={label}
|
||||
onAction={() =>
|
||||
mutation.mutate(
|
||||
seconds === null
|
||||
? { paused: true }
|
||||
: { paused: true, duration_seconds: seconds },
|
||||
)
|
||||
}
|
||||
className={({ isFocused }) =>
|
||||
stylex.props(
|
||||
styles.menuItem,
|
||||
shared.insetFocusRing,
|
||||
isFocused && styles.menuItemFocused,
|
||||
).className ?? ""
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
<InlineError error={mutation.error} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,12 @@ const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
/** The same sentence with no box, for a results footer that already has one. */
|
||||
note: {
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
color: colors.textMuted,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -27,11 +33,21 @@ const styles = stylex.create({
|
||||
* watermark and nothing more: the same incompleteness covers a log retention
|
||||
* has pruned and one that simply has not been running long enough, and the
|
||||
* response does not say which.
|
||||
*
|
||||
* `note` is the same sentence without the panel, for a place that already sits
|
||||
* under the data it qualifies — a results footer states the reach of the count
|
||||
* beside it, where a full-width banner would announce a limit as news.
|
||||
*/
|
||||
export default function CoverageNotice({ coverage }: { coverage: Coverage }) {
|
||||
export default function CoverageNotice({
|
||||
coverage,
|
||||
variant = "banner",
|
||||
}: {
|
||||
coverage: Coverage;
|
||||
variant?: "banner" | "note";
|
||||
}) {
|
||||
if (coverage.complete) return null;
|
||||
return (
|
||||
<p role="status" {...stylex.props(styles.notice)}>
|
||||
<p role="status" {...stylex.props(variant === "note" ? styles.note : styles.notice)}>
|
||||
Query history is available from {formatTime(coverage.available_since)}.
|
||||
</p>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ const styles = stylex.create({
|
||||
color: colors.danger,
|
||||
},
|
||||
retry: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
|
||||
@@ -214,12 +214,45 @@ test("mount probe reveals the logout button and a failed logout surfaces inline"
|
||||
|
||||
renderShell();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
|
||||
// Both renderings are mounted; the viewport paints the sidebar one on WIDE
|
||||
// and the header one below it.
|
||||
await waitFor(() => expect(screen.getAllByRole("button", { name: "Log out" })).toHaveLength(2));
|
||||
const aside = document.querySelector("aside") as HTMLElement;
|
||||
fireEvent.click(within(aside).getByRole("button", { name: "Log out" }));
|
||||
|
||||
await screen.findByText("Rate limited. Try again in 7s.");
|
||||
await within(aside).findByText("Rate limited. Try again in 7s.");
|
||||
expect(screen.getByRole("heading", { name: "Overview" })).toBeTruthy();
|
||||
});
|
||||
|
||||
test("Log out sits in the sidebar on wide, and only in the header below it", async () => {
|
||||
stubFetch((url) =>
|
||||
url === "/api/auth/login"
|
||||
? new Response(JSON.stringify({ error: "password required" }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
: null,
|
||||
);
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
|
||||
const aside = document.querySelector("aside") as HTMLElement;
|
||||
const header = document.querySelector("header") as HTMLElement;
|
||||
await waitFor(() => expect(within(aside).getByRole("button", { name: "Log out" })).toBeTruthy());
|
||||
expect(within(header).getByRole("button", { name: "Log out" })).toBeTruthy();
|
||||
// The header is the narrow rendering now: the Menu button is its only nav.
|
||||
expect(within(header).getByRole("button", { name: "Menu" })).toBeTruthy();
|
||||
|
||||
// In the sidebar the control precedes the version label rather than crowding it.
|
||||
const version = within(aside).getByText(/^nxdns v/);
|
||||
const logout = within(aside).getByRole("button", { name: "Log out" });
|
||||
expect(logout.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
// The drawer keeps no third copy: the header already carries the narrow one.
|
||||
fireEvent.click(within(header).getByRole("button", { name: "Menu" }));
|
||||
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||
expect(within(drawer).queryByRole("button", { name: "Log out" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the header carries no protection display at all any more", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
@@ -257,6 +290,40 @@ test("the mobile drawer carries the same control, not a header one it lost", asy
|
||||
expect(pause.compareDocumentPosition(version) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the Menu button is a disclosure trigger, and it names the panel it opens", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
const menu = screen.getByRole("button", { name: "Menu" });
|
||||
|
||||
// The trigger states the drawer's state, and points at the drawer itself.
|
||||
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(menu.getAttribute("aria-controls")).toBe("mobile-nav");
|
||||
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||
|
||||
fireEvent.click(menu);
|
||||
expect(menu.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(drawer.getAttribute("hidden")).toBeNull();
|
||||
expect(within(drawer).getByRole("navigation", { name: "Main" })).toBeTruthy();
|
||||
|
||||
fireEvent.click(menu);
|
||||
expect(menu.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("following a drawer link closes the drawer behind it", async () => {
|
||||
renderShell();
|
||||
await screen.findByRole("heading", { name: "Overview" });
|
||||
const menu = screen.getByRole("button", { name: "Menu" });
|
||||
fireEvent.click(menu);
|
||||
|
||||
const drawer = document.getElementById("mobile-nav") as HTMLElement;
|
||||
fireEvent.click(within(drawer).getByRole("link", { name: "Clients" }));
|
||||
|
||||
await waitFor(() => expect(menu.getAttribute("aria-expanded")).toBe("false"));
|
||||
expect(drawer.getAttribute("hidden")).not.toBeNull();
|
||||
});
|
||||
|
||||
test("a paused resolver says so in both renderings, not only on Diagnostics", async () => {
|
||||
// The trace a pause leaves on every page. With the header indicator and the
|
||||
// Overview status row both gone, a reader who is not on Diagnostics has only
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useId, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link, Outlet, useNavigate } from "@tanstack/react-router";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Button, Disclosure, DisclosurePanel } from "react-aria-components";
|
||||
import { useAuth } from "@/auth/store";
|
||||
import InlineError from "@/lib/InlineError";
|
||||
import { healthQuery, versionQuery } from "@/lib/queries";
|
||||
@@ -110,6 +111,17 @@ const styles = stylex.create({
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
},
|
||||
/**
|
||||
* In a 14rem column the button and its error stack, so the button spans the
|
||||
* sidebar and matches the Pause trigger below it.
|
||||
*/
|
||||
sidebarLogout: {
|
||||
flexDirection: "column",
|
||||
alignItems: "stretch",
|
||||
gap: "0.25rem",
|
||||
paddingInline: "1rem",
|
||||
paddingTop: "0.75rem",
|
||||
},
|
||||
shell: {
|
||||
minHeight: "100dvh",
|
||||
height: { default: null, [WIDE]: "100dvh" },
|
||||
@@ -147,8 +159,9 @@ const styles = stylex.create({
|
||||
minWidth: { default: null, [WIDE]: 0 },
|
||||
flexDirection: "column",
|
||||
},
|
||||
/** Narrow only: on WIDE the sidebar carries everything this row held. */
|
||||
header: {
|
||||
display: "flex",
|
||||
display: { default: "flex", [WIDE]: "none" },
|
||||
alignItems: "center",
|
||||
gap: "0.75rem",
|
||||
borderBottomWidth: 1,
|
||||
@@ -284,13 +297,13 @@ function VersionFooter() {
|
||||
);
|
||||
}
|
||||
|
||||
function LogoutButton() {
|
||||
function LogoutButton({ style }: { style?: stylex.StyleXStyles }) {
|
||||
const { authRequired, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
if (authRequired !== true) return null;
|
||||
return (
|
||||
<div {...stylex.props(styles.logoutRow)}>
|
||||
<div {...stylex.props(styles.logoutRow, style)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -318,37 +331,49 @@ export default function AppShell() {
|
||||
<nav aria-label="Main" {...stylex.props(styles.sidebarNav)}>
|
||||
<NavLinks />
|
||||
</nav>
|
||||
<LogoutButton style={styles.sidebarLogout} />
|
||||
<SidebarFooter />
|
||||
</aside>
|
||||
<div {...stylex.props(styles.column)}>
|
||||
{/* The column itself is the disclosure: the trigger sits in the header
|
||||
and the panel opens below the notices, so any wrapper around only
|
||||
the two would have to cut across the column's own flex children. */}
|
||||
<Disclosure
|
||||
isExpanded={drawerOpen}
|
||||
onExpandedChange={setDrawerOpen}
|
||||
{...stylex.props(styles.column)}
|
||||
>
|
||||
<header {...stylex.props(styles.header)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={drawerOpen}
|
||||
aria-controls="mobile-nav"
|
||||
onClick={() => setDrawerOpen((open) => !open)}
|
||||
{...stylex.props(shared.button, styles.narrowOnly, shared.focusRing)}
|
||||
<Button
|
||||
slot="trigger"
|
||||
className={() =>
|
||||
stylex.props(shared.button, styles.narrowOnly, shared.focusRing).className ?? ""
|
||||
}
|
||||
>
|
||||
Menu
|
||||
</button>
|
||||
</Button>
|
||||
<span {...stylex.props(styles.narrowBrand)}>nxdns</span>
|
||||
<div {...stylex.props(styles.headerRight)}>
|
||||
<LogoutButton />
|
||||
</div>
|
||||
</header>
|
||||
<ConfigStatusNotices />
|
||||
{drawerOpen && (
|
||||
<div id="mobile-nav" {...stylex.props(styles.drawer)}>
|
||||
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
|
||||
<NavLinks onNavigate={() => setDrawerOpen(false)} />
|
||||
</nav>
|
||||
<SidebarFooter />
|
||||
</div>
|
||||
)}
|
||||
<DisclosurePanel id="mobile-nav" {...stylex.props(styles.drawer)}>
|
||||
{/* React Aria only hides a collapsed panel; the shell drops it
|
||||
instead, so a closed drawer keeps no second health poll and
|
||||
no second Pause control alive behind the header. */}
|
||||
{drawerOpen && (
|
||||
<>
|
||||
<nav aria-label="Main" {...stylex.props(styles.drawerNav)}>
|
||||
<NavLinks onNavigate={() => setDrawerOpen(false)} />
|
||||
</nav>
|
||||
<SidebarFooter />
|
||||
</>
|
||||
)}
|
||||
</DisclosurePanel>
|
||||
<main id="main-content" data-scroll-restoration-id="main" {...stylex.props(styles.main)}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</Disclosure>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import Checkbox, { CheckboxGroup } from "./Checkbox";
|
||||
|
||||
/**
|
||||
* The drawn box, which is the label's one child that does not hold the hidden
|
||||
* input. StyleX compiles to class names and jsdom loads no stylesheet, so the
|
||||
* class list is the only place the composed ring is observable.
|
||||
*/
|
||||
function indicator(input: HTMLElement): HTMLElement {
|
||||
const label = input.closest("label") as HTMLElement;
|
||||
const spans = [...label.querySelectorAll("span")];
|
||||
const box = spans.find((span) => !span.contains(input));
|
||||
if (box === undefined) throw new Error("the checkbox drew no indicator");
|
||||
return box;
|
||||
}
|
||||
|
||||
test("a standalone checkbox reports its state and reads its label as its name", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Checkbox isSelected onChange={onChange}>
|
||||
Safe search
|
||||
</Checkbox>,
|
||||
);
|
||||
|
||||
const box = screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement;
|
||||
expect(box.checked).toBe(true);
|
||||
|
||||
fireEvent.click(box);
|
||||
// The caller owns the state, so the box reports the value it would move to
|
||||
// rather than moving there itself.
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("a disabled checkbox keeps its state on screen but takes no input", () => {
|
||||
render(
|
||||
<Checkbox isSelected isDisabled onChange={vi.fn()}>
|
||||
Safe search
|
||||
</Checkbox>,
|
||||
);
|
||||
|
||||
const box = screen.getByRole("checkbox", { name: "Safe search" }) as HTMLInputElement;
|
||||
expect(box.checked).toBe(true);
|
||||
// The disabled input is the whole guard: a browser fires no click on one, and
|
||||
// it is out of the tab order. Clicking it here would prove nothing either way,
|
||||
// because `fireEvent` dispatches straight at the node and skips that check.
|
||||
expect(box.disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("keyboard focus composes the ring onto the drawn box", () => {
|
||||
render(
|
||||
<Checkbox isSelected={false} onChange={vi.fn()}>
|
||||
Ads
|
||||
</Checkbox>,
|
||||
);
|
||||
|
||||
const input = screen.getByRole("checkbox", { name: "Ads" });
|
||||
const box = indicator(input);
|
||||
const idle = box.className.split(" ");
|
||||
expect(input.closest("label")?.getAttribute("data-focus-visible")).toBeNull();
|
||||
|
||||
// React Aria only calls focus visible after the modality is keyboard, which
|
||||
// is why a bare focus() is not enough to raise the ring.
|
||||
act(() => {
|
||||
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||
input.focus();
|
||||
});
|
||||
|
||||
expect(input.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
|
||||
const ringed = box.className.split(" ");
|
||||
// The box gained classes it did not have: the ring is composed onto it, not
|
||||
// merely reported by React Aria on the root.
|
||||
expect(ringed.length).toBeGreaterThan(idle.length);
|
||||
expect(idle.every((name) => ringed.includes(name))).toBe(true);
|
||||
});
|
||||
|
||||
test("inside a group the group owns the selection, and the group carries the name", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<CheckboxGroup aria-label="Assigned sources" value={["1"]} onChange={onChange}>
|
||||
<Checkbox value="1">Ads</Checkbox>
|
||||
<Checkbox value="2">Trackers</Checkbox>
|
||||
</CheckboxGroup>,
|
||||
);
|
||||
|
||||
const group = screen.getByRole("group", { name: "Assigned sources" });
|
||||
expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(true);
|
||||
expect((within(group).getByRole("checkbox", { name: "Trackers" }) as HTMLInputElement).checked).toBe(false);
|
||||
|
||||
// The group reports the whole set, not the box that moved.
|
||||
fireEvent.click(within(group).getByRole("checkbox", { name: "Trackers" }));
|
||||
expect(onChange).toHaveBeenCalledWith(["1", "2"]);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* The checkbox, wrapping React Aria's.
|
||||
*
|
||||
* React Aria hides the real input and leaves the mark to the call site, so the
|
||||
* box below is what the reader sees. It is drawn to the native control's size
|
||||
* so a row that held a native checkbox keeps its height and its baseline.
|
||||
*
|
||||
* One component covers both uses: pass `value` for a box inside a
|
||||
* `CheckboxGroup`, which owns the selection, or `isSelected`/`onChange` for a
|
||||
* standalone box that owns its own.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Checkbox as AriaCheckbox } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
|
||||
/**
|
||||
* Re-exported rather than wrapped: the group adds no styling of its own, and
|
||||
* routing it through here keeps React Aria's checkbox parts to one import site,
|
||||
* the same rule `Select` follows.
|
||||
*/
|
||||
export { CheckboxGroup } from "react-aria-components";
|
||||
|
||||
interface Common {
|
||||
/** The visible label, which is also the accessible name. */
|
||||
children: ReactNode;
|
||||
/** Visible but inert; React Aria also drops it from the tab order. */
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
/** Inside a `CheckboxGroup`, which holds the selection for every box in it. */
|
||||
interface Grouped extends Common {
|
||||
value: string;
|
||||
isSelected?: never;
|
||||
onChange?: never;
|
||||
}
|
||||
|
||||
/** On its own, where the caller holds the state and is told to move it. */
|
||||
interface Standalone extends Common {
|
||||
value?: never;
|
||||
isSelected: boolean;
|
||||
onChange: (isSelected: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two modes are exclusive: a grouped box that also carried `isSelected`
|
||||
* would have two sources of truth, and a standalone one without `onChange`
|
||||
* could never move. The union is what makes both unrepresentable.
|
||||
*/
|
||||
type Props = Grouped | Standalone;
|
||||
|
||||
const styles = stylex.create({
|
||||
/**
|
||||
* The whole label is the hit area, so it carries the 44px pointer-target
|
||||
* floor the dialog's Close button already sets, on both axes. The text is
|
||||
* 20px tall, and a one-word source name is narrower than 44px again.
|
||||
*/
|
||||
label: {
|
||||
minHeight: 44,
|
||||
minWidth: 44,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
box: {
|
||||
flexShrink: 0,
|
||||
boxSizing: "border-box",
|
||||
width: "0.875rem",
|
||||
height: "0.875rem",
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "0.1875rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.borderStrong,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
},
|
||||
boxSelected: {
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.primary,
|
||||
color: colors.primaryText,
|
||||
},
|
||||
/**
|
||||
* The shared ring keys off `:focus-visible`, which lands on the hidden input
|
||||
* rather than on this box, so the ring follows the state React Aria reports.
|
||||
*/
|
||||
boxFocused: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
/** The box dims, not the words: the label still has to be readable. */
|
||||
boxDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
|
||||
/** `currentColor` so the mark follows the selected box's foreground token. */
|
||||
function CheckMark() {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 12 12" width="10" height="10" fill="none">
|
||||
<path
|
||||
d="M2.5 6.5 5 9l4.5-5.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Checkbox({ children, ...state }: Props) {
|
||||
return (
|
||||
<AriaCheckbox {...state} className={() => stylex.props(styles.label).className ?? ""}>
|
||||
{(renderProps) => (
|
||||
<>
|
||||
<span
|
||||
{...stylex.props(
|
||||
styles.box,
|
||||
renderProps.isSelected && styles.boxSelected,
|
||||
renderProps.isFocusVisible && styles.boxFocused,
|
||||
renderProps.isDisabled && styles.boxDisabled,
|
||||
)}
|
||||
>
|
||||
{renderProps.isSelected && <CheckMark />}
|
||||
</span>
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</AriaCheckbox>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
* and a stray outside click is not one. Escape still cancels.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Dialog as AriaDialog, Heading, Modal, ModalOverlay } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
@@ -19,6 +20,14 @@ interface Props {
|
||||
/** The full sentence the operator reads before confirming; names the entity. */
|
||||
message: 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;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -67,6 +76,7 @@ const styles = stylex.create({
|
||||
marginTop: "1.5rem",
|
||||
},
|
||||
dangerButton: {
|
||||
cursor: { default: "pointer", ":disabled": "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.danger,
|
||||
@@ -79,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 (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
@@ -98,13 +108,17 @@ export default function ConfirmDialog({ isOpen, title, message, confirmLabel, on
|
||||
<button type="button" onClick={onCancel} {...stylex.props(shared.button, shared.focusRing)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
{...stylex.props(styles.dangerButton, shared.focusRing)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
{lock === undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
{...stylex.props(styles.dangerButton, shared.focusRing)}
|
||||
>
|
||||
{confirmLabel}
|
||||
</button>
|
||||
) : (
|
||||
lock
|
||||
)}
|
||||
</div>
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
|
||||
+83
-10
@@ -4,18 +4,25 @@
|
||||
* 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
|
||||
* 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 * 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 { styles as shared } from "./styles";
|
||||
|
||||
interface Props {
|
||||
/** The dialog's accessible name. */
|
||||
label: string;
|
||||
/** The dialog's heading, and with it the dialog's accessible name. */
|
||||
title: string;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
/** `detail` is the wider panel a record needs; `form` fits a column of fields. */
|
||||
size?: "form" | "detail";
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -30,25 +37,74 @@ const styles = stylex.create({
|
||||
padding: "1rem",
|
||||
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: {
|
||||
width: "100%",
|
||||
maxWidth: "28rem",
|
||||
maxHeight: "85vh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
borderRadius: "0.5rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
borderColor: colors.border,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
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)",
|
||||
},
|
||||
/** 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: {
|
||||
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 (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
@@ -58,9 +114,26 @@ export default function Dialog({ label, isOpen, onClose, children }: Props) {
|
||||
isDismissable
|
||||
className={() => stylex.props(styles.overlay).className ?? ""}
|
||||
>
|
||||
<Modal className={() => stylex.props(styles.panel).className ?? ""}>
|
||||
<AriaDialog aria-label={label} {...stylex.props(styles.body)}>
|
||||
{children}
|
||||
<Modal
|
||||
className={() =>
|
||||
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>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import Switch from "./Switch";
|
||||
|
||||
/**
|
||||
* The drawn track, which is the label's one span that does not hold the hidden
|
||||
* input. StyleX compiles to class names and jsdom loads no stylesheet, so the
|
||||
* class list is the only place the composed state is observable.
|
||||
*/
|
||||
function track(input: HTMLElement): HTMLElement {
|
||||
const label = input.closest("label") as HTMLElement;
|
||||
const spans = [...label.querySelectorAll("span")];
|
||||
const drawn = spans.find((span) => !span.contains(input));
|
||||
if (drawn === undefined) throw new Error("the switch drew no track");
|
||||
return drawn;
|
||||
}
|
||||
|
||||
test("a switch reports the switch role, not a checkbox one", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Switch isSelected onChange={onChange}>
|
||||
Safe search
|
||||
</Switch>,
|
||||
);
|
||||
|
||||
// The role is the whole point: it tells a screen reader the setting moves now
|
||||
// rather than on some later Save.
|
||||
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
|
||||
expect(control.checked).toBe(true);
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
|
||||
fireEvent.click(control);
|
||||
// The caller owns the state, so the switch reports the value it would move to.
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("a bare switch takes its name from aria-label", () => {
|
||||
render(<Switch aria-label="udp://1.1.1.1:53 enabled" isSelected={false} onChange={vi.fn()} />);
|
||||
|
||||
const control = screen.getByRole("switch", { name: "udp://1.1.1.1:53 enabled" }) as HTMLInputElement;
|
||||
expect(control.checked).toBe(false);
|
||||
});
|
||||
|
||||
test("a disabled switch keeps its state on screen but takes no input", () => {
|
||||
render(
|
||||
<Switch isSelected isDisabled onChange={vi.fn()}>
|
||||
Safe search
|
||||
</Switch>,
|
||||
);
|
||||
|
||||
const control = screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement;
|
||||
expect(control.checked).toBe(true);
|
||||
// The disabled input is the whole guard: a browser fires no click on one, and
|
||||
// it is out of the tab order. Clicking it here would prove nothing either way,
|
||||
// because `fireEvent` dispatches straight at the node and skips that check.
|
||||
expect(control.disabled).toBe(true);
|
||||
});
|
||||
|
||||
test("the track is drawn from the selected state, not left to the browser", () => {
|
||||
const { rerender } = render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
|
||||
|
||||
const control = screen.getByRole("switch");
|
||||
const off = track(control).className.split(" ");
|
||||
|
||||
rerender(<Switch aria-label="Safe search" isSelected onChange={vi.fn()} />);
|
||||
const on = track(control).className.split(" ");
|
||||
|
||||
// The two states compose to different class sets, so the thumb and the fill
|
||||
// actually move rather than the input alone changing.
|
||||
expect(on).not.toEqual(off);
|
||||
});
|
||||
|
||||
test("keyboard focus composes the ring onto the drawn track", () => {
|
||||
render(<Switch aria-label="Safe search" isSelected={false} onChange={vi.fn()} />);
|
||||
|
||||
const control = screen.getByRole("switch");
|
||||
const drawn = track(control);
|
||||
const idle = drawn.className.split(" ");
|
||||
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBeNull();
|
||||
|
||||
// React Aria only calls focus visible after the modality is keyboard, which
|
||||
// is why a bare focus() is not enough to raise the ring.
|
||||
act(() => {
|
||||
fireEvent.keyDown(document.body, { key: "Tab" });
|
||||
control.focus();
|
||||
});
|
||||
|
||||
expect(control.closest("label")?.getAttribute("data-focus-visible")).toBe("true");
|
||||
const ringed = drawn.className.split(" ");
|
||||
expect(ringed.length).toBeGreaterThan(idle.length);
|
||||
expect(idle.every((name) => ringed.includes(name))).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* The on/off switch, wrapping React Aria's.
|
||||
*
|
||||
* A switch, not a checkbox: every call site flips a setting that takes effect
|
||||
* the moment it moves, with no Save between. A checkbox states what a form will
|
||||
* submit, which is a promise these controls do not make.
|
||||
*
|
||||
* React Aria hides the real input and leaves the track to the call site, so the
|
||||
* track and thumb below are what the reader sees. The caller always holds the
|
||||
* state — none of these controls owns the value it shows, because the server's
|
||||
* answer is the value.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import * as stylex from "@stylexjs/stylex";
|
||||
import { Switch as AriaSwitch } from "react-aria-components";
|
||||
import { colors } from "./tokens.stylex";
|
||||
|
||||
interface Common {
|
||||
isSelected: boolean;
|
||||
onChange: (isSelected: boolean) => void;
|
||||
/** Visible but inert; React Aria also drops it from the tab order. */
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
/** With visible words beside the track, which name it. */
|
||||
interface Labelled extends Common {
|
||||
children: ReactNode;
|
||||
"aria-label"?: never;
|
||||
}
|
||||
|
||||
/** Bare, in a table cell whose column heading cannot name a single row. */
|
||||
interface Named extends Common {
|
||||
children?: never;
|
||||
"aria-label": string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A switch has to be named, and exactly one of the two ways: visible words that
|
||||
* `aria-label` would then override and hide from the reader who can see them,
|
||||
* or no words and a label only the screen reader gets.
|
||||
*/
|
||||
type Props = Labelled | Named;
|
||||
|
||||
const styles = stylex.create({
|
||||
/**
|
||||
* The whole label is the hit area, so it carries the 44px pointer-target
|
||||
* floor on both axes, the same floor `Checkbox` and the dialog's Close
|
||||
* button already set. The track itself is far under it.
|
||||
*/
|
||||
label: {
|
||||
minWidth: 44,
|
||||
minHeight: 44,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
fontSize: "0.875rem",
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
/** Bare switches sit in a table cell, where the row sets the rhythm. */
|
||||
track: {
|
||||
flexShrink: 0,
|
||||
boxSizing: "border-box",
|
||||
width: 28,
|
||||
height: 16,
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-start",
|
||||
borderRadius: 8,
|
||||
padding: 2,
|
||||
backgroundColor: colors.borderStrong,
|
||||
},
|
||||
/**
|
||||
* The thumb moves by the box's own alignment rather than by a transform, so
|
||||
* there is no transition to withhold from a reader who asked for less motion.
|
||||
*/
|
||||
trackSelected: {
|
||||
justifyContent: "flex-end",
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
/** As on `Checkbox`: the ring follows the state React Aria reports, because
|
||||
* `:focus-visible` lands on the hidden input rather than on this track. */
|
||||
trackFocused: {
|
||||
outlineWidth: 2,
|
||||
outlineStyle: "solid",
|
||||
outlineColor: colors.focus,
|
||||
outlineOffset: 2,
|
||||
},
|
||||
/** The track dims, not the words: the label still has to be readable. */
|
||||
trackDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
thumb: {
|
||||
width: 12,
|
||||
height: 12,
|
||||
borderRadius: 6,
|
||||
backgroundColor: colors.surfaceRaised,
|
||||
},
|
||||
});
|
||||
|
||||
export default function Switch({ children, ...state }: Props) {
|
||||
return (
|
||||
<AriaSwitch {...state} className={() => stylex.props(styles.label).className ?? ""}>
|
||||
{(renderProps) => (
|
||||
<>
|
||||
<span
|
||||
{...stylex.props(
|
||||
styles.track,
|
||||
renderProps.isSelected && styles.trackSelected,
|
||||
renderProps.isFocusVisible && styles.trackFocused,
|
||||
renderProps.isDisabled && styles.trackDisabled,
|
||||
)}
|
||||
>
|
||||
<span {...stylex.props(styles.thumb)} />
|
||||
</span>
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</AriaSwitch>
|
||||
);
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export const styles = stylex.create({
|
||||
},
|
||||
|
||||
button: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -70,6 +71,7 @@ export const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
smallButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -80,6 +82,7 @@ export const styles = stylex.create({
|
||||
lineHeight: "1.25rem",
|
||||
},
|
||||
largeButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
borderStyle: "solid",
|
||||
@@ -90,6 +93,7 @@ export const styles = stylex.create({
|
||||
},
|
||||
|
||||
primaryButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.primary,
|
||||
@@ -102,6 +106,7 @@ export const styles = stylex.create({
|
||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||
},
|
||||
largePrimaryButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: colors.primary,
|
||||
@@ -113,6 +118,7 @@ export const styles = stylex.create({
|
||||
},
|
||||
|
||||
rowButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderRadius: "0.25rem",
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
@@ -123,6 +129,7 @@ export const styles = stylex.create({
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
linkButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
@@ -132,6 +139,7 @@ export const styles = stylex.create({
|
||||
color: colors.primaryOnSurface,
|
||||
},
|
||||
dangerLinkButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
borderStyle: "none",
|
||||
backgroundColor: "transparent",
|
||||
padding: 0,
|
||||
@@ -142,6 +150,7 @@ export const styles = stylex.create({
|
||||
opacity: { default: 1, [DISABLED]: 0.5 },
|
||||
},
|
||||
retryButton: {
|
||||
cursor: { default: "pointer", [DISABLED]: "not-allowed" },
|
||||
marginTop: "0.75rem",
|
||||
borderRadius: "0.25rem",
|
||||
borderWidth: 1,
|
||||
|
||||
Reference in New Issue
Block a user