admin: activity history filters become a live-apply toolbar
the five-field form with its apply button is gone. one row holds a search-shaped domain field and a client ip field that debounce into the url with enter flushing at once, result segments and a time menu that commit instantly, and a clear that appears only when a filter is active without reflowing the row. presets freeze both absolute bounds at click time so a bookmark describes the same investigation later, custom ranges apply atomically through set range with inline validation, and echo queues keep in-flight commits from clobbering newer typing or newer picks. live mode renders no toolbar, the availability banner became a footer note, previous rows stay visible during refetch, and every control carries a 44px hit target.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* The filter row's error association.
|
* 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
|
* 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
|
* wall-clock time inside a spring-forward gap: jsdom applies the
|
||||||
@@ -8,9 +9,11 @@
|
|||||||
* the empty string, which is a bound the operator cleared.
|
* the empty string, which is a bound the operator cleared.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
import { afterAll, expect, test, vi } from "vitest";
|
import { afterAll, expect, test, vi } from "vitest";
|
||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import ActivityFilters, { NO_FILTERS } from "./ActivityFilters";
|
import ActivityFilters, { NO_FILTERS, type AppliedFilters } from "./ActivityFilters";
|
||||||
|
import { unixToDatetimeLocal } from "./datetime";
|
||||||
|
|
||||||
vi.stubEnv("TZ", "Europe/Paris");
|
vi.stubEnv("TZ", "Europe/Paris");
|
||||||
afterAll(() => vi.unstubAllEnvs());
|
afterAll(() => vi.unstubAllEnvs());
|
||||||
@@ -32,49 +35,359 @@ function describedText(input: HTMLElement): string {
|
|||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFilters() {
|
/**
|
||||||
const applied: { current: unknown } = { current: null };
|
* The page's half of the contract: the applied state is held outside the form.
|
||||||
render(
|
*
|
||||||
<ActivityFilters
|
* `deferred` holds the patches back instead of applying them, so a test can
|
||||||
applied={NO_FILTERS}
|
* land them in an order the network and the router can genuinely produce —
|
||||||
isDisabled={false}
|
* an early debounce arriving after a later one, over a draft that has moved on.
|
||||||
onApply={(filters) => (applied.current = filters)}
|
*/
|
||||||
onClear={() => (applied.current = null)}
|
function renderFilters(initial: AppliedFilters = NO_FILTERS, deferred = false) {
|
||||||
/>,
|
const patches: Array<Partial<AppliedFilters>> = [];
|
||||||
);
|
const state: { current: AppliedFilters } = { current: initial };
|
||||||
return applied;
|
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", () => {
|
test("a rejected bound marks its own input and describes it", () => {
|
||||||
const applied = renderFilters();
|
const { patches } = renderFilters();
|
||||||
|
openTimeMenu();
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||||
|
|
||||||
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||||
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||||
|
|
||||||
fireEvent.change(since, { target: { value: GAP_WALL_TIME } });
|
fireEvent.change(since, { target: { value: GAP_WALL_TIME } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
fireEvent.click(screen.getByRole("button", { name: "Set range" }));
|
||||||
|
|
||||||
expect(since.getAttribute("aria-invalid")).toBe("true");
|
expect(since.getAttribute("aria-invalid")).toBe("true");
|
||||||
expect(describedText(since)).toContain("daylight saving");
|
expect(describedText(since)).toContain("daylight saving");
|
||||||
// Refused means refused, and only the offending bound carries the mark.
|
// Refused means refused, and only the offending bound carries the mark.
|
||||||
expect(applied.current).toBeNull();
|
expect(patches).toEqual([]);
|
||||||
expect(until.getAttribute("aria-invalid")).toBeNull();
|
expect(until.getAttribute("aria-invalid")).toBeNull();
|
||||||
expect(until.getAttribute("aria-describedby")).toBeNull();
|
expect(until.getAttribute("aria-describedby")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the mark moves to the other bound, and clears once both parse", () => {
|
test("an upper bound below the lower one is refused, against the bound that is wrong", () => {
|
||||||
renderFilters();
|
const { patches } = renderFilters();
|
||||||
|
openTimeMenu();
|
||||||
|
fireEvent.click(screen.getByRole("menuitem", { name: "Custom…" }));
|
||||||
|
|
||||||
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||||
const until = screen.getByLabelText("Until") 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" }));
|
||||||
|
|
||||||
fireEvent.change(until, { target: { value: GAP_WALL_TIME } });
|
expect(until.getAttribute("aria-invalid")).toBe("true");
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
expect(describedText(until)).toContain("selects nothing");
|
||||||
|
|
||||||
expect(since.getAttribute("aria-invalid")).toBeNull();
|
expect(since.getAttribute("aria-invalid")).toBeNull();
|
||||||
expect(describedText(until)).toContain("daylight saving");
|
expect(patches).toEqual([]);
|
||||||
|
|
||||||
fireEvent.change(until, { target: { value: "2026-03-29T04:30:00" } });
|
// The window is half-open, so two equal bounds are as empty as an inverted
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
// 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(until.getAttribute("aria-invalid")).toBeNull();
|
||||||
expect(until.getAttribute("aria-describedby")).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 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,
|
* the link they can paste to a housemate will show. So this holds a draft, and
|
||||||
* and the page remounts it whenever the applied search changes — a back button
|
* every control writes through to the URL — the segments and the time presets
|
||||||
* or a pasted URL has to move the form with it, and a form that seeded itself
|
* at once, the two text fields after a pause so a five-letter domain is one
|
||||||
* once would keep showing the previous investigation's filters.
|
* navigation rather than five.
|
||||||
*
|
*
|
||||||
* In live mode the row stays visible and disabled rather than disappearing: the
|
* A time preset writes both bounds as absolute seconds, resolved once at the
|
||||||
* filters are retained in the URL and apply again the moment history comes
|
* click. Neither half may be left open: a bookmark has to describe the same
|
||||||
* back, and hiding them would read as having lost them. The stream itself is
|
* investigation tomorrow, and a window that slid overnight — or one that stayed
|
||||||
* unfiltered — the server sends every query — so a row that looked usable here
|
* 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.
|
* 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 * 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 { styles as shared } from "@/ui/styles";
|
||||||
import { colors } from "@/ui/tokens.stylex";
|
import { colors } from "@/ui/tokens.stylex";
|
||||||
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
import { datetimeField, editDatetimeField, resolveDatetimeField, type DatetimeField } from "./datetime";
|
||||||
import type { ActivitySearch } from "./search";
|
import type { ActivitySearch } from "./search";
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
/** Long enough that a typed word is one navigation, short enough to feel live. */
|
||||||
{ value: "any", label: "All" },
|
const DEBOUNCE_MS = 350;
|
||||||
{ value: "blocked", label: "Blocked only" },
|
|
||||||
{ value: "allowed", label: "Allowed only" },
|
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({
|
const styles = stylex.create({
|
||||||
/** One column on a phone, two from `sm`, five from `lg`. */
|
toolbar: {
|
||||||
grid: {
|
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
display: "grid",
|
display: "flex",
|
||||||
gap: "0.75rem",
|
flexWrap: "wrap",
|
||||||
gridTemplateColumns: {
|
alignItems: "center",
|
||||||
default: "repeat(1, minmax(0, 1fr))",
|
gap: "0.5rem",
|
||||||
"@media (min-width: 640px)": "repeat(2, minmax(0, 1fr))",
|
|
||||||
"@media (min-width: 1024px)": "repeat(5, minmax(0, 1fr))",
|
|
||||||
},
|
},
|
||||||
|
/** The domain field is the one that grows; everything else keeps its size. */
|
||||||
|
searchWrap: {
|
||||||
|
position: "relative",
|
||||||
|
flexGrow: 1,
|
||||||
|
flexShrink: 1,
|
||||||
|
flexBasis: "14rem",
|
||||||
|
display: "flex",
|
||||||
},
|
},
|
||||||
label: {
|
searchIcon: {
|
||||||
display: "block",
|
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",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
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: {
|
error: {
|
||||||
marginTop: "0.5rem",
|
|
||||||
fontSize: "0.875rem",
|
fontSize: "0.875rem",
|
||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.dangerText,
|
color: colors.dangerText,
|
||||||
@@ -86,6 +211,22 @@ export const NO_FILTERS: AppliedFilters = {
|
|||||||
until: undefined,
|
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 {
|
function blockedOption(blocked: boolean | undefined): string {
|
||||||
if (blocked === undefined) return "any";
|
if (blocked === undefined) return "any";
|
||||||
return blocked ? "blocked" : "allowed";
|
return blocked ? "blocked" : "allowed";
|
||||||
@@ -96,13 +237,19 @@ function optionBlocked(value: string): boolean | undefined {
|
|||||||
return value === "allowed" ? false : undefined;
|
return value === "allowed" ? false : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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. */
|
/** The message plus the bound it belongs to, so that input can point at it. */
|
||||||
interface BoundError {
|
interface BoundError {
|
||||||
field: "since" | "until";
|
field: "since" | "until";
|
||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The editor is rendered once per page, so the message can hold a fixed id. */
|
/** The toolbar is rendered once per page, so the message can hold a fixed id. */
|
||||||
const ERROR_ID = "activity-filter-error";
|
const ERROR_ID = "activity-filter-error";
|
||||||
|
|
||||||
function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError {
|
function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError {
|
||||||
@@ -116,27 +263,156 @@ function boundError(field: "since" | "until", reason: "unparseable" | "nonexiste
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
interface Props {
|
||||||
applied: AppliedFilters;
|
applied: AppliedFilters;
|
||||||
isDisabled: boolean;
|
/** Merges a patch into the applied search. `replace` keeps typing out of history. */
|
||||||
onApply: (filters: AppliedFilters) => void;
|
onApply: (patch: Partial<AppliedFilters>, replace?: boolean) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityFilters({ applied, isDisabled, onApply, onClear }: Props) {
|
export default function ActivityFilters({ applied, onApply, onClear }: Props) {
|
||||||
const [domain, setDomain] = useState(applied.domain ?? "");
|
const urlText: TextPair = { domain: applied.domain ?? "", client: applied.client ?? "" };
|
||||||
const [client, setClient] = useState(applied.client ?? "");
|
const urlBounds: Bounds = { since: applied.since, until: applied.until };
|
||||||
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
|
|
||||||
|
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 [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
|
||||||
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
|
const [until, setUntil] = useState<DatetimeField>(() => datetimeField(applied.until));
|
||||||
const [error, setError] = useState<BoundError | null>(null);
|
const [error, setError] = useState<BoundError | null>(null);
|
||||||
|
const [sync, setSync] = useState<Sync>({
|
||||||
|
url: urlText,
|
||||||
|
bounds: urlBounds,
|
||||||
|
pendingText: [],
|
||||||
|
pendingBounds: [],
|
||||||
|
});
|
||||||
|
|
||||||
/** True for the one bound the current message is about; nothing else is marked. */
|
const textMoved = sync.url.domain !== urlText.domain || sync.url.client !== urlText.client;
|
||||||
const invalid = (field: BoundError["field"]): true | undefined =>
|
const boundsMoved = !sameBounds(sync.bounds, urlBounds);
|
||||||
error !== null && error.field === field ? true : undefined;
|
if (textMoved || boundsMoved) {
|
||||||
|
let pendingText = sync.pendingText;
|
||||||
|
let pendingBounds = sync.pendingBounds;
|
||||||
|
|
||||||
function submit(event: FormEvent) {
|
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();
|
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);
|
const sinceValue = resolveDatetimeField(since);
|
||||||
if (!sinceValue.ok) {
|
if (!sinceValue.ok) {
|
||||||
setError(boundError("since", sinceValue.reason));
|
setError(boundError("since", sinceValue.reason));
|
||||||
@@ -147,106 +423,218 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
|
|||||||
setError(boundError("until", untilValue.reason));
|
setError(boundError("until", untilValue.reason));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(null);
|
// The window is half-open — `ts >= since` and `ts < until` — so two equal
|
||||||
onApply({
|
// bounds are as empty as an inverted pair, and neither is worth applying.
|
||||||
domain: domain.trim() === "" ? undefined : domain.trim(),
|
if (
|
||||||
client: client.trim() === "" ? undefined : client.trim(),
|
sinceValue.value !== undefined &&
|
||||||
blocked: optionBlocked(blocked),
|
untilValue.value !== undefined &&
|
||||||
since: sinceValue.value,
|
untilValue.value <= sinceValue.value
|
||||||
until: untilValue.value,
|
) {
|
||||||
|
setError({
|
||||||
|
field: "until",
|
||||||
|
message: "Until must be after Since, or the range selects nothing.",
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
setPreset(null);
|
||||||
|
commitBounds({ since: sinceValue.value, until: untilValue.value });
|
||||||
}
|
}
|
||||||
|
|
||||||
function clear() {
|
function clear() {
|
||||||
setDomain("");
|
setDomain("");
|
||||||
setClient("");
|
setClient("");
|
||||||
setBlocked("any");
|
setPreset(null);
|
||||||
|
setShowCustom(false);
|
||||||
setSince(datetimeField(undefined));
|
setSince(datetimeField(undefined));
|
||||||
setUntil(datetimeField(undefined));
|
setUntil(datetimeField(undefined));
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSync((prev) => ({
|
||||||
|
...prev,
|
||||||
|
pendingText: [],
|
||||||
|
pendingBounds: [...prev.pendingBounds, { since: undefined, until: undefined }],
|
||||||
|
}));
|
||||||
onClear();
|
onClear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<label {...stylex.props(styles.customField)}>
|
||||||
<form onSubmit={submit} {...stylex.props(styles.grid)}>
|
{field === "since" ? "Since" : "Until"}
|
||||||
<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}
|
|
||||||
/>
|
|
||||||
<label {...stylex.props(styles.label)}>
|
|
||||||
Since
|
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
step={1}
|
step={1}
|
||||||
value={since.text}
|
value={state.text}
|
||||||
aria-invalid={invalid("since")}
|
aria-invalid={invalid(field)}
|
||||||
aria-describedby={invalid("since") && ERROR_ID}
|
aria-describedby={invalid(field) && ERROR_ID}
|
||||||
disabled={isDisabled}
|
onChange={(event) => set(editDatetimeField(state, event.target.value))}
|
||||||
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
|
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
// 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)}
|
||||||
/>
|
/>
|
||||||
|
{invalid(field) && (
|
||||||
|
<span id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
|
||||||
|
{error?.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label {...stylex.props(styles.label)}>
|
);
|
||||||
Until
|
}
|
||||||
<input
|
|
||||||
type="datetime-local"
|
return (
|
||||||
step={1}
|
<form onSubmit={flush}>
|
||||||
value={until.text}
|
<div {...stylex.props(styles.toolbar)}>
|
||||||
aria-invalid={invalid("until")}
|
<div {...stylex.props(styles.searchWrap)}>
|
||||||
aria-describedby={invalid("until") && ERROR_ID}
|
<svg
|
||||||
disabled={isDisabled}
|
aria-hidden="true"
|
||||||
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
|
viewBox="0 0 16 16"
|
||||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
width="14"
|
||||||
/>
|
height="14"
|
||||||
</label>
|
fill="none"
|
||||||
<div {...stylex.props(styles.buttonRow)}>
|
stroke="currentColor"
|
||||||
<button
|
strokeWidth="1.5"
|
||||||
type="submit"
|
{...stylex.props(styles.searchIcon)}
|
||||||
disabled={isDisabled}
|
|
||||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
|
||||||
>
|
>
|
||||||
Apply filters
|
<circle cx="7" cy="7" r="4.5" />
|
||||||
</button>
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clear}
|
onClick={clear}
|
||||||
disabled={isDisabled}
|
tabIndex={active ? undefined : -1}
|
||||||
{...stylex.props(shared.button, styles.toolbarButton, shared.focusRing)}
|
aria-hidden={active ? undefined : true}
|
||||||
|
{...stylex.props(
|
||||||
|
shared.button,
|
||||||
|
styles.hitTarget,
|
||||||
|
shared.focusRing,
|
||||||
|
!active && styles.clearHidden,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
{showCustom && (
|
||||||
{error !== null && (
|
<div {...stylex.props(styles.customRow)}>
|
||||||
<p id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
|
{boundInput("since")}
|
||||||
{error.message}
|
{boundInput("until")}
|
||||||
</p>
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={setRange}
|
||||||
|
{...stylex.props(shared.button, styles.hitTarget, shared.focusRing)}
|
||||||
|
>
|
||||||
|
Set range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,6 +136,22 @@ function queryCalls(): string[] {
|
|||||||
.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
|
.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 () => {
|
test("renders the first page with the seven columns filled in", async () => {
|
||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText("first.example");
|
await screen.findByText("first.example");
|
||||||
@@ -220,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" }));
|
fireEvent.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
await screen.findByText("older.example");
|
await screen.findByText("older.example");
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
submitFilters();
|
||||||
|
|
||||||
await screen.findByText(/Showing 1 query /);
|
await screen.findByText(/Showing 1 query /);
|
||||||
expect(history.location.search).toContain("domain=ads");
|
expect(history.location.search).toContain("domain=ads");
|
||||||
@@ -246,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.click(screen.getByRole("button", { name: "Load more" }));
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
submitFilters();
|
||||||
await screen.findByText(/Showing 1 query /);
|
await screen.findByText(/Showing 1 query /);
|
||||||
|
|
||||||
releaseLoadMore();
|
releaseLoadMore();
|
||||||
@@ -285,8 +301,8 @@ test("load more is disabled while a filter change shows placeholder data, then u
|
|||||||
renderPage();
|
renderPage();
|
||||||
await screen.findByText("first.example");
|
await screen.findByText("first.example");
|
||||||
|
|
||||||
fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
|
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
submitFilters();
|
||||||
|
|
||||||
const staleButton = await screen.findByRole("button", { name: "Load more" });
|
const staleButton = await screen.findByRole("button", { name: "Load more" });
|
||||||
expect(staleButton).toHaveProperty("disabled", true);
|
expect(staleButton).toHaveProperty("disabled", true);
|
||||||
@@ -420,7 +436,7 @@ test("a ?domain= link seeds the filter form and fetches that domain on arrival",
|
|||||||
renderPage("/activity?domain=ads");
|
renderPage("/activity?domain=ads");
|
||||||
|
|
||||||
await screen.findByText("ads.example");
|
await screen.findByText("ads.example");
|
||||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
|
expect(domainInput()).toHaveProperty("value", "ads");
|
||||||
expect(screen.queryByText("first.example")).toBeNull();
|
expect(screen.queryByText("first.example")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -445,7 +461,7 @@ test("a rejected search parameter is dropped rather than guessed at", async () =
|
|||||||
await screen.findByText("first.example");
|
await screen.findByText("first.example");
|
||||||
// Nothing survived validation, so the request is the unfiltered one.
|
// Nothing survived validation, so the request is the unfiltered one.
|
||||||
expect(queryCalls()).toEqual(["/api/queries"]);
|
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 () => {
|
test("the form draft follows the url back and forward, seconds included", async () => {
|
||||||
@@ -458,25 +474,33 @@ test("the form draft follows the url back and forward, seconds included", async
|
|||||||
const seeded = 1_700_000_017;
|
const seeded = 1_700_000_017;
|
||||||
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
|
const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);
|
||||||
|
|
||||||
const domainInput = await screen.findByLabelText("Domain contains");
|
await screen.findByLabelText("Filter domains");
|
||||||
expect(domainInput).toHaveProperty("value", "first");
|
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;
|
const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
|
||||||
expect(sinceInput.value).toContain(":37");
|
expect(sinceInput.value).toContain(":37");
|
||||||
|
|
||||||
fireEvent.change(domainInput, { target: { value: "second" } });
|
fireEvent.change(domainInput(), { target: { value: "second" } });
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
submitFilters();
|
||||||
await waitFor(() => expect(history.location.search).toContain("domain=second"));
|
await waitFor(() => expect(history.location.search).toContain("domain=second"));
|
||||||
// The untouched Since bound applied as the exact second it was seeded with.
|
// The untouched Since bound applied as the exact second it was seeded with.
|
||||||
expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);
|
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());
|
act(() => history.back());
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
|
expect(domainInput()).toHaveProperty("value", "second");
|
||||||
});
|
});
|
||||||
|
|
||||||
act(() => history.forward());
|
act(() => history.forward());
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
|
expect(domainInput()).toHaveProperty("value", "third");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -512,8 +536,9 @@ test("a wall-clock time the daylight-saving jump skips is refused, not silently
|
|||||||
const callsBefore = queryCalls().length;
|
const callsBefore = queryCalls().length;
|
||||||
const searchBefore = history.location.search;
|
const searchBefore = history.location.search;
|
||||||
|
|
||||||
|
openCustomRange();
|
||||||
fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
|
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) {
|
if (inGap) {
|
||||||
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
|
expect(screen.getByRole("alert").textContent).toContain("daylight saving");
|
||||||
@@ -579,7 +604,7 @@ test("the mode switch is a tab list whose selection is the url, and it keeps the
|
|||||||
selected: true,
|
selected: true,
|
||||||
}),
|
}),
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(await screen.findByText(/these filters apply to history only/)).toBeTruthy();
|
expect(await screen.findByText(/the History filters apply to history only/)).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Clear empties the url as well as the form", async () => {
|
test("Clear empties the url as well as the form", async () => {
|
||||||
@@ -591,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("domain");
|
||||||
});
|
});
|
||||||
expect(history.location.search).not.toContain("blocked");
|
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,6 +8,7 @@
|
|||||||
* recipient should be looking at.
|
* recipient should be looking at.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useCallback } from "react";
|
||||||
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
import { Link, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import * as stylex from "@stylexjs/stylex";
|
import * as stylex from "@stylexjs/stylex";
|
||||||
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
|
import { Tab, TabList, TabPanel, Tabs } from "react-aria-components";
|
||||||
@@ -111,8 +112,6 @@ function panelClass({ isFocusVisible }: { isFocusVisible: boolean }): string {
|
|||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const search = useSearch({ from: "/shell/activity" });
|
const search = useSearch({ from: "/shell/activity" });
|
||||||
const navigate = useNavigate({ from: "/activity" });
|
const navigate = useNavigate({ from: "/activity" });
|
||||||
const live = search.mode === "live";
|
|
||||||
|
|
||||||
// The functional form, not a replacement object: the filters are retained
|
// 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
|
// 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.
|
// drop every one of them on the way to Live and back.
|
||||||
@@ -121,9 +120,19 @@ export default function ActivityPage() {
|
|||||||
void navigate({ search: (prev) => ({ ...prev, mode }) });
|
void navigate({ search: (prev) => ({ ...prev, mode }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
function apply(filters: AppliedFilters) {
|
// A patch, merged into whatever the URL already says: a segment click must not
|
||||||
void navigate({ search: { mode: search.mode, ...filters } });
|
// 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 (
|
return (
|
||||||
<section>
|
<section>
|
||||||
@@ -165,24 +174,17 @@ export default function ActivityPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
* Remounted whenever the applied search changes, which is what makes
|
* The toolbar belongs to History alone. It is not remounted on a search
|
||||||
* the back button work: the draft is derived state, and the browser
|
* change: it resyncs its draft from the URL instead, because a remount
|
||||||
* moving the URL under it has to move the form with it.
|
* mid-debounce would take the focus out of the input being typed in.
|
||||||
*/}
|
*/}
|
||||||
<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)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<TabPanel id="history" className={panelClass}>
|
<TabPanel id="history" className={panelClass}>
|
||||||
|
<ActivityFilters applied={search} onApply={apply} onClear={clear} />
|
||||||
<HistoryActivity search={search} />
|
<HistoryActivity search={search} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
<TabPanel id="live" className={panelClass}>
|
<TabPanel id="live" className={panelClass}>
|
||||||
<p {...stylex.props(styles.liveNote)}>
|
<p {...stylex.props(styles.liveNote)}>
|
||||||
The stream carries every query the server answers; these filters apply to history only.
|
The stream carries every query the server answers; the History filters apply to history only.
|
||||||
</p>
|
</p>
|
||||||
<LiveActivity origin={search} />
|
<LiveActivity origin={search} />
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.textMuted,
|
color: colors.textMuted,
|
||||||
},
|
},
|
||||||
refetching: {
|
/** The gap a line standing on its own needs from the block above it. */
|
||||||
|
spacedTop: {
|
||||||
marginTop: "0.75rem",
|
marginTop: "0.75rem",
|
||||||
},
|
},
|
||||||
moreButton: {
|
moreButton: {
|
||||||
@@ -81,7 +82,9 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
|||||||
|
|
||||||
const pages = base.data?.pages ?? [];
|
const pages = base.data?.pages ?? [];
|
||||||
const rows: QueryRow[] = pages.flatMap((page) => page.queries);
|
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;
|
const filterActive = Object.keys(filter).length > 0;
|
||||||
// `base.hasNextPage` reads the query state, which is empty while placeholder
|
// `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
|
// 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 (
|
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 && (
|
{base.isFetching && (
|
||||||
<p {...stylex.props(styles.note, styles.refetching)} role="status">
|
<p {...stylex.props(styles.note, styles.spacedTop)} role="status">
|
||||||
Loading…
|
Updating…
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{coverage !== undefined && <CoverageNotice coverage={coverage} />}
|
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
|
<>
|
||||||
<p {...stylex.props(styles.empty)}>
|
<p {...stylex.props(styles.empty)}>
|
||||||
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
{filterActive ? "No queries match the current filters." : "No queries logged yet."}
|
||||||
</p>
|
</p>
|
||||||
|
{coverage !== undefined && (
|
||||||
|
<div {...stylex.props(styles.spacedTop)}>
|
||||||
|
<CoverageNotice coverage={coverage} variant="note" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div {...stylex.props(styles.tableWrap)}>
|
<div {...stylex.props(styles.tableWrap)}>
|
||||||
@@ -158,6 +172,7 @@ export default function HistoryActivity({ search }: { search: ActivitySearch })
|
|||||||
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
Showing {rows.length} {rows.length === 1 ? "query" : "queries"}
|
||||||
{hasMore ? "" : " — end of log"}
|
{hasMore ? "" : " — end of log"}
|
||||||
</p>
|
</p>
|
||||||
|
{coverage !== undefined && <CoverageNotice coverage={coverage} variant="note" />}
|
||||||
{hasMore && (
|
{hasMore && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -521,27 +521,18 @@ test("an open streamed detail survives Freeze and Resume", async () => {
|
|||||||
expect(detailDialog().textContent).toBe(snapshot);
|
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");
|
await openLive("/activity?mode=live&domain=ads&client=192.0.2.10&blocked=true");
|
||||||
|
|
||||||
const domain = screen.getByLabelText("Domain contains") as HTMLInputElement;
|
// The stream is unfiltered — the server sends every query — so a row of
|
||||||
expect(domain.value).toBe("ads");
|
// controls here would promise filtering that is not happening. The filters
|
||||||
expect(domain.disabled).toBe(true);
|
// are not lost: they are in the URL, and History applies them on the way back.
|
||||||
expect((screen.getByLabelText("Client (exact)") as HTMLInputElement).disabled).toBe(true);
|
expect(screen.queryByLabelText("Filter domains")).toBeNull();
|
||||||
expect((screen.getByLabelText("Since") as HTMLInputElement).disabled).toBe(true);
|
expect(screen.queryByLabelText("Client IP (exact match)")).toBeNull();
|
||||||
expect((screen.getByLabelText("Until") as HTMLInputElement).disabled).toBe(true);
|
expect(screen.queryByRole("radio", { name: "Blocked" })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: /^Time: / })).toBeNull();
|
||||||
const form = domain.closest("form")!;
|
expect(screen.queryByText("Clear")).toBeNull();
|
||||||
const controls = [...form.querySelectorAll("input, button, select, textarea, a[href], [tabindex]")];
|
expect(screen.getByText(/the History filters apply to history only/)).toBeTruthy();
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("live mode asks for no query pages, whatever filters the url retained", async () => {
|
test("live mode asks for no query pages, whatever filters the url retained", async () => {
|
||||||
@@ -555,13 +546,12 @@ test("leaving live closes the stream, and coming back opens exactly one fresh on
|
|||||||
await openLive("/activity?mode=live&domain=ads");
|
await openLive("/activity?mode=live&domain=ads");
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
fireEvent.click(screen.getByRole("tab", { name: "History" }));
|
||||||
await screen.findByRole("button", { name: "Apply filters" });
|
await screen.findByLabelText("Filter domains");
|
||||||
expect(sources).toHaveLength(1);
|
expect(sources).toHaveLength(1);
|
||||||
expect(sources[0]!.closed).toBe(true);
|
expect(sources[0]!.closed).toBe(true);
|
||||||
// The filters came along, which is the point of switching rather than
|
// The filters came along, which is the point of switching rather than
|
||||||
// navigating: the reader keeps the question they were asking.
|
// navigating: the reader keeps the question they were asking.
|
||||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).value).toBe("ads");
|
expect((screen.getByLabelText("Filter domains") as HTMLInputElement).value).toBe("ads");
|
||||||
expect((screen.getByLabelText("Domain contains") as HTMLInputElement).disabled).toBe(false);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("tab", { name: "Live" }));
|
fireEvent.click(screen.getByRole("tab", { name: "Live" }));
|
||||||
await screen.findByRole("button", { name: "Freeze" });
|
await screen.findByRole("button", { name: "Freeze" });
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ const styles = stylex.create({
|
|||||||
lineHeight: "1.25rem",
|
lineHeight: "1.25rem",
|
||||||
color: colors.textSecondary,
|
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
|
* 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
|
* has pruned and one that simply has not been running long enough, and the
|
||||||
* response does not say which.
|
* 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;
|
if (coverage.complete) return null;
|
||||||
return (
|
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)}.
|
Query history is available from {formatTime(coverage.available_since)}.
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user