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:
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* The filter row's error association.
|
||||||
|
*
|
||||||
|
* The timezone is fixed because the only invalid bound reachable in jsdom is a
|
||||||
|
* wall-clock time inside a spring-forward gap: jsdom applies the
|
||||||
|
* `datetime-local` value sanitization algorithm, so text that is merely
|
||||||
|
* incomplete never reaches the component at all — the input hands it back as
|
||||||
|
* the empty string, which is a bound the operator cleared.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterAll, expect, test, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import ActivityFilters, { NO_FILTERS } from "./ActivityFilters";
|
||||||
|
|
||||||
|
vi.stubEnv("TZ", "Europe/Paris");
|
||||||
|
afterAll(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
|
/** 02:30 does not exist on this date in Paris; the clock jumps 02:00 to 03:00. */
|
||||||
|
const GAP_WALL_TIME = "2026-03-29T02:30:00";
|
||||||
|
|
||||||
|
/** The text of the elements an input points at with `aria-describedby`. */
|
||||||
|
function describedText(input: HTMLElement): string {
|
||||||
|
const ids = input.getAttribute("aria-describedby");
|
||||||
|
if (ids === null) throw new Error("input has no aria-describedby");
|
||||||
|
return ids
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((id) => {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||||
|
return node.textContent ?? "";
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFilters() {
|
||||||
|
const applied: { current: unknown } = { current: null };
|
||||||
|
render(
|
||||||
|
<ActivityFilters
|
||||||
|
applied={NO_FILTERS}
|
||||||
|
isDisabled={false}
|
||||||
|
onApply={(filters) => (applied.current = filters)}
|
||||||
|
onClear={() => (applied.current = null)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
return applied;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a rejected bound marks its own input and describes it", () => {
|
||||||
|
const applied = renderFilters();
|
||||||
|
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||||
|
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(since, { target: { value: GAP_WALL_TIME } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||||
|
|
||||||
|
expect(since.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
expect(describedText(since)).toContain("daylight saving");
|
||||||
|
// Refused means refused, and only the offending bound carries the mark.
|
||||||
|
expect(applied.current).toBeNull();
|
||||||
|
expect(until.getAttribute("aria-invalid")).toBeNull();
|
||||||
|
expect(until.getAttribute("aria-describedby")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the mark moves to the other bound, and clears once both parse", () => {
|
||||||
|
renderFilters();
|
||||||
|
const since = screen.getByLabelText("Since") as HTMLInputElement;
|
||||||
|
const until = screen.getByLabelText("Until") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(until, { target: { value: GAP_WALL_TIME } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||||
|
|
||||||
|
expect(since.getAttribute("aria-invalid")).toBeNull();
|
||||||
|
expect(describedText(until)).toContain("daylight saving");
|
||||||
|
|
||||||
|
fireEvent.change(until, { target: { value: "2026-03-29T04:30:00" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
|
||||||
|
|
||||||
|
expect(until.getAttribute("aria-invalid")).toBeNull();
|
||||||
|
expect(until.getAttribute("aria-describedby")).toBeNull();
|
||||||
|
});
|
||||||
@@ -96,10 +96,24 @@ function optionBlocked(value: string): boolean | undefined {
|
|||||||
return value === "allowed" ? false : undefined;
|
return value === "allowed" ? false : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
|
/** The message plus the bound it belongs to, so that input can point at it. */
|
||||||
return reason === "unparseable"
|
interface BoundError {
|
||||||
|
field: "since" | "until";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The editor is rendered once per page, so the message can hold a fixed id. */
|
||||||
|
const ERROR_ID = "activity-filter-error";
|
||||||
|
|
||||||
|
function boundError(field: "since" | "until", reason: "unparseable" | "nonexistent"): BoundError {
|
||||||
|
const label = field === "since" ? "Since" : "Until";
|
||||||
|
return {
|
||||||
|
field,
|
||||||
|
message:
|
||||||
|
reason === "unparseable"
|
||||||
? `${label} is not a complete date and time.`
|
? `${label} is not a complete date and time.`
|
||||||
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`;
|
: `${label} names a local time that does not exist — the clock jumps over it for daylight saving.`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -115,18 +129,22 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
|
|||||||
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
|
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
|
||||||
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<string | null>(null);
|
const [error, setError] = useState<BoundError | null>(null);
|
||||||
|
|
||||||
|
/** 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 submit(event: FormEvent) {
|
function submit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const sinceValue = resolveDatetimeField(since);
|
const sinceValue = resolveDatetimeField(since);
|
||||||
if (!sinceValue.ok) {
|
if (!sinceValue.ok) {
|
||||||
setError(boundError("Since", sinceValue.reason));
|
setError(boundError("since", sinceValue.reason));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const untilValue = resolveDatetimeField(until);
|
const untilValue = resolveDatetimeField(until);
|
||||||
if (!untilValue.ok) {
|
if (!untilValue.ok) {
|
||||||
setError(boundError("Until", untilValue.reason));
|
setError(boundError("until", untilValue.reason));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -186,6 +204,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
step={1}
|
step={1}
|
||||||
value={since.text}
|
value={since.text}
|
||||||
|
aria-invalid={invalid("since")}
|
||||||
|
aria-describedby={invalid("since") && ERROR_ID}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
|
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
|
||||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||||
@@ -197,6 +217,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
|
|||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
step={1}
|
step={1}
|
||||||
value={until.text}
|
value={until.text}
|
||||||
|
aria-invalid={invalid("until")}
|
||||||
|
aria-describedby={invalid("until") && ERROR_ID}
|
||||||
disabled={isDisabled}
|
disabled={isDisabled}
|
||||||
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
|
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
|
||||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||||
@@ -221,8 +243,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{error !== null && (
|
{error !== null && (
|
||||||
<p role="alert" {...stylex.props(styles.error)}>
|
<p id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
|
||||||
{error}
|
{error.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ import { fireEvent, render, screen, waitFor, within } from "@testing-library/rea
|
|||||||
import { ClientName, type ClientNames } from "./clientNames";
|
import { ClientName, type ClientNames } from "./clientNames";
|
||||||
import { BASE, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";
|
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 {
|
function clientRow(ip: string): HTMLElement {
|
||||||
const row = screen.getByText(ip).closest("tr");
|
const row = screen.getByText(ip).closest("tr");
|
||||||
if (row === null) throw new Error(`no client row for ${ip}`);
|
if (row === null) throw new Error(`no client row for ${ip}`);
|
||||||
@@ -315,7 +329,9 @@ describe.each([
|
|||||||
fireEvent.click(confirm);
|
fireEvent.click(confirm);
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(
|
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),
|
).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");
|
const observed = clientRow("192.168.1.11");
|
||||||
expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,15 @@ import * as stylex from "@stylexjs/stylex";
|
|||||||
import { clientPrefixesPutMutation } from "@/lib/queries";
|
import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||||
import type { ClientPrefix, Group } from "@/lib/types";
|
import type { ClientPrefix, Group } from "@/lib/types";
|
||||||
import { defaultGroupId } from "@/lib/defaultGroup";
|
import { defaultGroupId } from "@/lib/defaultGroup";
|
||||||
import { firstProblem, initPrefixEditor, isDirty, prefixEditorReducer, toInputs } from "./prefixEditor";
|
import {
|
||||||
|
firstProblem,
|
||||||
|
initPrefixEditor,
|
||||||
|
isDirty,
|
||||||
|
prefixEditorReducer,
|
||||||
|
toInputs,
|
||||||
|
type PrefixEditorAction,
|
||||||
|
type PrefixProblem,
|
||||||
|
} from "./prefixEditor";
|
||||||
import InlineError from "@/lib/InlineError";
|
import InlineError from "@/lib/InlineError";
|
||||||
import AuthorityGate from "@/features/configuration/AuthorityGate";
|
import AuthorityGate from "@/features/configuration/AuthorityGate";
|
||||||
import Select from "@/ui/Select";
|
import Select from "@/ui/Select";
|
||||||
@@ -16,6 +24,9 @@ interface Props {
|
|||||||
groups: Group[];
|
groups: Group[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The editor is rendered once per page, so the message can hold a fixed id. */
|
||||||
|
const VALIDATION_ID = "network-assignments-validation";
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
section: {
|
section: {
|
||||||
marginTop: "2.5rem",
|
marginTop: "2.5rem",
|
||||||
@@ -162,12 +173,24 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
|
|||||||
function AssignmentsEditor({ prefixes, groups }: Props) {
|
function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||||
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||||
const [validation, setValidation] = useState<string | null>(null);
|
const [validation, setValidation] = useState<PrefixProblem | null>(null);
|
||||||
const dirty = isDirty(state);
|
const dirty = isDirty(state);
|
||||||
const fallbackGroupId = defaultGroupId(groups);
|
const fallbackGroupId = defaultGroupId(groups);
|
||||||
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
const groupOptions = groups.map((group) => ({ value: String(group.id), label: group.name }));
|
||||||
|
|
||||||
|
// A problem names a row by position, and every action here can move, add or
|
||||||
|
// delete a position. The message describes the rows Save read, so it dies
|
||||||
|
// with them rather than drifting onto whatever row inherits the index.
|
||||||
|
const dispatch = (action: PrefixEditorAction) => {
|
||||||
|
setValidation(null);
|
||||||
|
apply(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True for the one input the current message is about; nothing else is marked. */
|
||||||
|
const invalid = (index: number, field: PrefixProblem["field"]): true | undefined =>
|
||||||
|
validation !== null && validation.index === index && validation.field === field ? true : undefined;
|
||||||
|
|
||||||
const save = () => {
|
const save = () => {
|
||||||
const problem = firstProblem(state.rows);
|
const problem = firstProblem(state.rows);
|
||||||
setValidation(problem);
|
setValidation(problem);
|
||||||
@@ -188,6 +211,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
aria-label={`Range ${index + 1}`}
|
aria-label={`Range ${index + 1}`}
|
||||||
|
aria-invalid={invalid(index, "prefix")}
|
||||||
|
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
|
||||||
placeholder="192.168.1.0/24"
|
placeholder="192.168.1.0/24"
|
||||||
value={row.prefix}
|
value={row.prefix}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -208,6 +233,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
|||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
aria-label={`Priority for range ${index + 1}`}
|
aria-label={`Priority for range ${index + 1}`}
|
||||||
|
aria-invalid={invalid(index, "priority")}
|
||||||
|
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
|
||||||
placeholder="100"
|
placeholder="100"
|
||||||
value={row.priority}
|
value={row.priority}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
@@ -227,8 +254,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
{validation !== null && (
|
{validation !== null && (
|
||||||
<p role="alert" {...stylex.props(styles.validation)}>
|
<p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
|
||||||
{validation}
|
{validation.message}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<InlineError error={mutation.error} />
|
<InlineError error={mutation.error} />
|
||||||
@@ -251,10 +278,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
|||||||
{dirty && (
|
{dirty && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => dispatch({ type: "reset", prefixes })}
|
||||||
setValidation(null);
|
|
||||||
dispatch({ type: "reset", prefixes });
|
|
||||||
}}
|
|
||||||
{...stylex.props(shared.button, shared.focusRing)}
|
{...stylex.props(shared.button, shared.focusRing)}
|
||||||
>
|
>
|
||||||
Discard changes
|
Discard changes
|
||||||
|
|||||||
@@ -76,11 +76,15 @@ test("toInputs trims prefixes, parses priorities and omits empty ones", () => {
|
|||||||
|
|
||||||
test("firstProblem flags empty prefixes and non-integer priorities", () => {
|
test("firstProblem flags empty prefixes and non-integer priorities", () => {
|
||||||
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
|
expect(firstProblem([{ prefix: "10.0.0.0/8", group_id: 1, priority: "" }])).toBeNull();
|
||||||
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toBe("Row 1: prefix is required.");
|
expect(firstProblem([{ prefix: " ", group_id: 1, priority: "" }])).toEqual({
|
||||||
|
index: 0,
|
||||||
|
field: "prefix",
|
||||||
|
message: "Row 1: prefix is required.",
|
||||||
|
});
|
||||||
expect(
|
expect(
|
||||||
firstProblem([
|
firstProblem([
|
||||||
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
|
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
|
||||||
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
|
{ prefix: "10.1.0.0/16", group_id: 1, priority: "abc" },
|
||||||
]),
|
]),
|
||||||
).toBe("Row 2: priority must be a whole number.");
|
).toEqual({ index: 1, field: "priority", message: "Row 2: priority must be a whole number." });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,11 +55,20 @@ export function isDirty(state: PrefixEditorState): boolean {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function firstProblem(rows: PrefixRow[]): string | null {
|
/** Which input the message is about, so the editor can point that input at it. */
|
||||||
for (const [i, row] of rows.entries()) {
|
export interface PrefixProblem {
|
||||||
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
|
index: number;
|
||||||
|
field: "prefix" | "priority";
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function firstProblem(rows: PrefixRow[]): PrefixProblem | null {
|
||||||
|
for (const [index, row] of rows.entries()) {
|
||||||
|
if (row.prefix.trim() === "")
|
||||||
|
return { index, field: "prefix", message: `Row ${index + 1}: prefix is required.` };
|
||||||
const priority = row.priority.trim();
|
const priority = row.priority.trim();
|
||||||
if (priority !== "" && !/^\d+$/.test(priority)) return `Row ${i + 1}: priority must be a whole number.`;
|
if (priority !== "" && !/^\d+$/.test(priority))
|
||||||
|
return { index, field: "priority", message: `Row ${index + 1}: priority must be a whole number.` };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import { styles as config } from "./styles";
|
|||||||
|
|
||||||
const DARK = "@media (prefers-color-scheme: dark)";
|
const DARK = "@media (prefers-color-scheme: dark)";
|
||||||
|
|
||||||
|
/** The form is rendered once per page, so the message can hold a fixed id. */
|
||||||
|
const MISMATCH_ID = "web.password_mismatch";
|
||||||
|
|
||||||
const styles = stylex.create({
|
const styles = stylex.create({
|
||||||
form: {
|
form: {
|
||||||
marginTop: "1rem",
|
marginTop: "1rem",
|
||||||
@@ -175,12 +178,15 @@ function FieldRow({
|
|||||||
}
|
}
|
||||||
if (def.kind === "number") {
|
if (def.kind === "number") {
|
||||||
const numeric = value as number;
|
const numeric = value as number;
|
||||||
|
// An empty or unparseable number reads back as NaN. The form already refuses
|
||||||
|
// to submit on it; this is what says so to a screen reader.
|
||||||
return (
|
return (
|
||||||
<div {...stylex.props(styles.field)}>
|
<div {...stylex.props(styles.field)}>
|
||||||
<FieldLabel id={id} text={def.key} restart={restart} />
|
<FieldLabel id={id} text={def.key} restart={restart} />
|
||||||
<input
|
<input
|
||||||
id={id}
|
id={id}
|
||||||
type="number"
|
type="number"
|
||||||
|
aria-invalid={Number.isNaN(numeric) || undefined}
|
||||||
value={Number.isNaN(numeric) ? "" : numeric}
|
value={Number.isNaN(numeric) ? "" : numeric}
|
||||||
onChange={(e) => onChange(e.target.valueAsNumber)}
|
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||||
@@ -286,6 +292,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
|||||||
id="web.password"
|
id="web.password"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
aria-invalid={passwordsMismatch || undefined}
|
||||||
|
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||||
@@ -299,6 +307,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
|||||||
id="web.password_confirm"
|
id="web.password_confirm"
|
||||||
type="password"
|
type="password"
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
|
aria-invalid={passwordsMismatch || undefined}
|
||||||
|
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||||
value={confirm}
|
value={confirm}
|
||||||
onChange={(e) => setConfirm(e.target.value)}
|
onChange={(e) => setConfirm(e.target.value)}
|
||||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||||
@@ -311,7 +321,7 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{passwordsMismatch && (
|
{passwordsMismatch && (
|
||||||
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
<p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||||
Passwords do not match.
|
Passwords do not match.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -176,12 +176,52 @@ test("enum and boolean fields diff as their own types", async () => {
|
|||||||
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
|
expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** The text of the elements an input points at with `aria-describedby`. */
|
||||||
|
function describedText(input: HTMLElement): string {
|
||||||
|
const ids = input.getAttribute("aria-describedby");
|
||||||
|
if (ids === null) throw new Error("input has no aria-describedby");
|
||||||
|
return ids
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((id) => {
|
||||||
|
const node = document.getElementById(id);
|
||||||
|
if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
|
||||||
|
return node.textContent ?? "";
|
||||||
|
})
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
test("clearing a number field disables Save instead of sending NaN", async () => {
|
test("clearing a number field disables Save instead of sending NaN", async () => {
|
||||||
await openSystem();
|
await openSystem();
|
||||||
|
|
||||||
const cache = screen.getByRole("group", { name: "Cache" });
|
const cache = screen.getByRole("group", { name: "Cache" });
|
||||||
fireEvent.change(within(cache).getByLabelText("size"), { target: { value: "" } });
|
const size = within(cache).getByLabelText("size");
|
||||||
|
fireEvent.change(size, { target: { value: "" } });
|
||||||
expect(saveButton().disabled).toBe(true);
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
// The refusal is on the field itself, not only on the Save button.
|
||||||
|
expect(size.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
|
||||||
|
fireEvent.change(size, { target: { value: "512" } });
|
||||||
|
expect(size.getAttribute("aria-invalid")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the mismatch message is attached to both password inputs", async () => {
|
||||||
|
await openSystem();
|
||||||
|
|
||||||
|
const web = screen.getByRole("group", { name: "Web" });
|
||||||
|
const passwordInput = within(web).getByLabelText("password");
|
||||||
|
const confirmInput = within(web).getByLabelText("confirm password");
|
||||||
|
|
||||||
|
fireEvent.change(passwordInput, { target: { value: "hunter2" } });
|
||||||
|
for (const input of [passwordInput, confirmInput]) {
|
||||||
|
expect(input.getAttribute("aria-invalid")).toBe("true");
|
||||||
|
expect(describedText(input)).toBe("Passwords do not match.");
|
||||||
|
}
|
||||||
|
|
||||||
|
fireEvent.change(confirmInput, { target: { value: "hunter2" } });
|
||||||
|
for (const input of [passwordInput, confirmInput]) {
|
||||||
|
expect(input.getAttribute("aria-invalid")).toBeNull();
|
||||||
|
expect(input.getAttribute("aria-describedby")).toBeNull();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
|
test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user