admin: ui polish pass, thirty findings from the emil audit
selected states stop changing font weight, buttons gain a pressed scale and scoped 120ms transitions with a reduced-motion override, every loading and empty state reserves its height, charts measure before first paint, hit targets rise to the 44px enhanced target where layout permits, long domains clamp to two lines on an unpadded inner span, chips and name cells truncate, tabular figures on counts and time columns, a z-index layer scale replaces magic numbers and fixes the dialog-over-confirm tie, history's empty state gains a clear-filters action, page headings balance, font smoothing and color-scheme land on the html reset.
This commit is contained in:
@@ -146,6 +146,26 @@ function submitFilters() {
|
||||
fireEvent.submit(domainInput().closest("form")!);
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer's count line, matched on the whole sentence.
|
||||
*
|
||||
* The number sits in a span of its own so it can carry tabular digits, so the
|
||||
* line is several text nodes and the default string matcher — which reads one
|
||||
* node at a time — cannot see it whole.
|
||||
*/
|
||||
function countMatcher(pattern: RegExp) {
|
||||
return (_: string, element: Element | null): boolean =>
|
||||
element?.tagName === "P" && pattern.test(element.textContent ?? "");
|
||||
}
|
||||
|
||||
function countLine(pattern: RegExp): HTMLElement {
|
||||
return screen.getByText(countMatcher(pattern));
|
||||
}
|
||||
|
||||
function findCountLine(pattern: RegExp): Promise<HTMLElement> {
|
||||
return screen.findByText(countMatcher(pattern));
|
||||
}
|
||||
|
||||
/** The custom range lives behind the Time menu; the two bounds only exist there. */
|
||||
function openCustomRange() {
|
||||
fireEvent.click(screen.getByRole("button", { name: /^Time: / }));
|
||||
@@ -176,7 +196,7 @@ test("renders the first page with the seven columns filled in", async () => {
|
||||
// Route cell says how: this is the pair the old Status column could not show.
|
||||
expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
|
||||
expect(within(blocked).getByText("—")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
|
||||
expect(countLine(/Showing 2 queries/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("resolves each row's client to its display name, reading the IP out with it", async () => {
|
||||
@@ -225,7 +245,7 @@ test("load more appends the next page and stops at the end of the log", async ()
|
||||
await screen.findByText("older.example");
|
||||
|
||||
expect(screen.getByText("first.example")).toBeTruthy();
|
||||
expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 3 queries — end of log/)).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -239,7 +259,7 @@ test("applying a filter puts it in the url, refetches, and resets the accumulate
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
await findCountLine(/Showing 1 query /);
|
||||
expect(history.location.search).toContain("domain=ads");
|
||||
expect(screen.getByText("ads.example")).toBeTruthy();
|
||||
expect(screen.queryByText("first.example")).toBeNull();
|
||||
@@ -264,7 +284,7 @@ test("a load-more that resolves after a filter change is discarded", async () =>
|
||||
|
||||
fireEvent.change(domainInput(), { target: { value: "ads" } });
|
||||
submitFilters();
|
||||
await screen.findByText(/Showing 1 query /);
|
||||
await findCountLine(/Showing 1 query /);
|
||||
|
||||
releaseLoadMore();
|
||||
await act(async () => {
|
||||
@@ -272,7 +292,7 @@ test("a load-more that resolves after a filter change is discarded", async () =>
|
||||
});
|
||||
|
||||
expect(screen.queryByText("older.example")).toBeNull();
|
||||
expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
|
||||
expect(countLine(/Showing 1 query /)).toBeTruthy();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
@@ -320,7 +340,7 @@ test("load more is disabled while a filter change shows placeholder data, then u
|
||||
await screen.findByText("ads.older.example");
|
||||
|
||||
expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
|
||||
expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 2 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
|
||||
@@ -371,7 +391,7 @@ test("a background refetch after new rows arrive leaves no gap between the loade
|
||||
await screen.findByText("n22.example");
|
||||
const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
|
||||
expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
|
||||
expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
expect(countLine(/Showing 6 queries — end of log/)).toBeTruthy();
|
||||
});
|
||||
|
||||
test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
|
||||
@@ -619,6 +639,38 @@ test("Clear empties the url as well as the form", async () => {
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
});
|
||||
|
||||
test("the empty result offers the clear it names, and it clears the same filters the toolbar does", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
if (url === "/api/queries") return json(PAGES["/api/queries"]);
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
const { history } = renderPage("/activity?mode=history&domain=nothing&blocked=true");
|
||||
await screen.findByText("No queries match the current filters.");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear filters" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(history.location.search).not.toContain("domain");
|
||||
});
|
||||
expect(history.location.search).not.toContain("blocked");
|
||||
expect(domainInput()).toHaveProperty("value", "");
|
||||
// The unfiltered log is back, so the button did the toolbar's Clear and not a
|
||||
// reset of its own that only emptied the form.
|
||||
expect(await screen.findByText("first.example")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("an empty log offers no clear, because there is no filter to blame for it", async () => {
|
||||
stubFetch((url) => {
|
||||
if (url === "/api/clients") return json({ clients: CLIENTS });
|
||||
return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
|
||||
});
|
||||
renderPage();
|
||||
|
||||
await screen.findByText("No queries logged yet.");
|
||||
expect(screen.queryByRole("button", { name: "Clear filters" })).toBeNull();
|
||||
});
|
||||
|
||||
test("the domain field debounces into the url, and Enter flushes it at once", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
@@ -739,7 +791,7 @@ test("the coverage watermark reads under the results, never over them", async ()
|
||||
await screen.findByText("kept.example");
|
||||
|
||||
const watermark = screen.getByText(/Query history is available from/);
|
||||
const count = screen.getByText(/Showing 1 query/);
|
||||
const count = countLine(/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