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:
@@ -136,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");
|
||||
@@ -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" }));
|
||||
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");
|
||||
@@ -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.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();
|
||||
@@ -285,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);
|
||||
@@ -420,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();
|
||||
});
|
||||
|
||||
@@ -445,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 () => {
|
||||
@@ -458,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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -512,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");
|
||||
@@ -579,7 +604,7 @@ test("the mode switch is a tab list whose selection is the url, and it keeps the
|
||||
selected: true,
|
||||
}),
|
||||
).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 () => {
|
||||
@@ -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("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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user