admin: associate inline field errors with their inputs
network assignment rows, activity filters, and the settings password pair now mark the offending input with aria-invalid and point it at the error text with aria-describedby. the prefix validator returns the row and field it is about, and any row mutation clears a message that named a position. settings numeric fields carry aria-invalid on an unparseable value.
This commit is contained in:
@@ -2,6 +2,20 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
|
||||
import { ClientName, type ClientNames } from "./clientNames";
|
||||
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
||||
|
||||
/** The text of the elements an input points at with `aria-describedby`. */
|
||||
function describedText(input: HTMLElement): string {
|
||||
const ids = input.getAttribute("aria-describedby");
|
||||
if (ids === null) throw new Error("input has no aria-describedby");
|
||||
return ids
|
||||
.split(/\s+/)
|
||||
.map((id) => {
|
||||
const node = document.getElementById(id);
|
||||
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||
return node.textContent ?? "";
|
||||
})
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function clientRow(ip: string): HTMLElement {
|
||||
const row = screen.getByText(ip).closest("tr");
|
||||
if (row === null) throw new Error(`no client row for ${ip}`);
|
||||
@@ -315,7 +329,9 @@ describe.each([
|
||||
fireEvent.click(confirm);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1")),
|
||||
fetchMock.mock.calls.filter(
|
||||
([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1"),
|
||||
),
|
||||
).toHaveLength(1),
|
||||
);
|
||||
});
|
||||
@@ -357,3 +373,66 @@ test("a pending config status holds the same line as a failed one (R3-4)", async
|
||||
const observed = clientRow("192.168.1.11");
|
||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
test("a save-time problem marks the input it is about and describes it", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(range)).toBe("Row 1: prefix is required.");
|
||||
|
||||
// Only the offending input is marked; the priority beside it is untouched.
|
||||
const priority = screen.getByLabelText("Priority for range 1");
|
||||
expect(priority.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(priority.getAttribute("aria-describedby")).toBeNull();
|
||||
|
||||
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
|
||||
fireEvent.change(screen.getByLabelText("Priority for range 1"), { target: { value: "abc" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(describedText(screen.getByLabelText("Priority for range 1"))).toBe(
|
||||
"Row 1: priority must be a whole number.",
|
||||
);
|
||||
});
|
||||
|
||||
test("removing a row drops the message rather than moving it to another input", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
// Row 1 is the offending one, so removing it is what would slide the stale
|
||||
// index onto row 2 — an input that validated cleanly.
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add range" }));
|
||||
fireEvent.change(screen.getByLabelText("Range 2"), { target: { value: "10.0.0.0/8" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(describedText(range)).toBe("Row 1: prefix is required.");
|
||||
|
||||
const section = assignmentsSection();
|
||||
fireEvent.click(within(section).getAllByRole("button", { name: "Remove" })[0] as HTMLButtonElement);
|
||||
|
||||
const survivor = screen.getByLabelText("Range 1") as HTMLInputElement;
|
||||
expect(survivor.value).toBe("10.0.0.0/8");
|
||||
expect(survivor.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(survivor.getAttribute("aria-describedby")).toBeNull();
|
||||
expect(within(section).queryByRole("alert")).toBeNull();
|
||||
expect(screen.queryByLabelText("Range 2")).toBeNull();
|
||||
});
|
||||
|
||||
test("editing a row clears the message it was about", async () => {
|
||||
await renderClientsPage();
|
||||
|
||||
const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
|
||||
fireEvent.change(range, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
|
||||
expect(range.getAttribute("aria-invalid")).toBe("true");
|
||||
|
||||
fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
|
||||
expect(range.getAttribute("aria-invalid")).toBeNull();
|
||||
expect(within(assignmentsSection()).queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user