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;
|
||||
}
|
||||
|
||||
function boundError(label: string, reason: "unparseable" | "nonexistent"): string {
|
||||
return reason === "unparseable"
|
||||
? `${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.`;
|
||||
/** The message plus the bound it belongs to, so that input can point at it. */
|
||||
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} names a local time that does not exist — the clock jumps over it for daylight saving.`,
|
||||
};
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -115,18 +129,22 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
|
||||
const [blocked, setBlocked] = useState(blockedOption(applied.blocked));
|
||||
const [since, setSince] = useState<DatetimeField>(() => datetimeField(applied.since));
|
||||
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) {
|
||||
event.preventDefault();
|
||||
const sinceValue = resolveDatetimeField(since);
|
||||
if (!sinceValue.ok) {
|
||||
setError(boundError("Since", sinceValue.reason));
|
||||
setError(boundError("since", sinceValue.reason));
|
||||
return;
|
||||
}
|
||||
const untilValue = resolveDatetimeField(until);
|
||||
if (!untilValue.ok) {
|
||||
setError(boundError("Until", untilValue.reason));
|
||||
setError(boundError("until", untilValue.reason));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
@@ -186,6 +204,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={since.text}
|
||||
aria-invalid={invalid("since")}
|
||||
aria-describedby={invalid("since") && ERROR_ID}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setSince(editDatetimeField(since, event.target.value))}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
@@ -197,6 +217,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
|
||||
type="datetime-local"
|
||||
step={1}
|
||||
value={until.text}
|
||||
aria-invalid={invalid("until")}
|
||||
aria-describedby={invalid("until") && ERROR_ID}
|
||||
disabled={isDisabled}
|
||||
onChange={(event) => setUntil(editDatetimeField(until, event.target.value))}
|
||||
{...stylex.props(shared.smallInput, styles.input, shared.focusRing)}
|
||||
@@ -221,8 +243,8 @@ export default function ActivityFilters({ applied, isDisabled, onApply, onClear
|
||||
</div>
|
||||
</form>
|
||||
{error !== null && (
|
||||
<p role="alert" {...stylex.props(styles.error)}>
|
||||
{error}
|
||||
<p id={ERROR_ID} role="alert" {...stylex.props(styles.error)}>
|
||||
{error.message}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -4,7 +4,15 @@ import * as stylex from "@stylexjs/stylex";
|
||||
import { clientPrefixesPutMutation } from "@/lib/queries";
|
||||
import type { ClientPrefix, Group } from "@/lib/types";
|
||||
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 AuthorityGate from "@/features/configuration/AuthorityGate";
|
||||
import Select from "@/ui/Select";
|
||||
@@ -16,6 +24,9 @@ interface Props {
|
||||
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({
|
||||
section: {
|
||||
marginTop: "2.5rem",
|
||||
@@ -162,12 +173,24 @@ function AssignmentsTable({ prefixes }: { prefixes: ClientPrefix[] }) {
|
||||
function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation(clientPrefixesPutMutation(queryClient));
|
||||
const [state, dispatch] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||
const [validation, setValidation] = useState<string | null>(null);
|
||||
const [state, apply] = useReducer(prefixEditorReducer, prefixes, initPrefixEditor);
|
||||
const [validation, setValidation] = useState<PrefixProblem | null>(null);
|
||||
const dirty = isDirty(state);
|
||||
const fallbackGroupId = defaultGroupId(groups);
|
||||
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 problem = firstProblem(state.rows);
|
||||
setValidation(problem);
|
||||
@@ -188,6 +211,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
<input
|
||||
type="text"
|
||||
aria-label={`Range ${index + 1}`}
|
||||
aria-invalid={invalid(index, "prefix")}
|
||||
aria-describedby={invalid(index, "prefix") && VALIDATION_ID}
|
||||
placeholder="192.168.1.0/24"
|
||||
value={row.prefix}
|
||||
onChange={(event) =>
|
||||
@@ -208,6 +233,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
aria-label={`Priority for range ${index + 1}`}
|
||||
aria-invalid={invalid(index, "priority")}
|
||||
aria-describedby={invalid(index, "priority") && VALIDATION_ID}
|
||||
placeholder="100"
|
||||
value={row.priority}
|
||||
onChange={(event) =>
|
||||
@@ -227,8 +254,8 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
</ul>
|
||||
)}
|
||||
{validation !== null && (
|
||||
<p role="alert" {...stylex.props(styles.validation)}>
|
||||
{validation}
|
||||
<p id={VALIDATION_ID} role="alert" {...stylex.props(styles.validation)}>
|
||||
{validation.message}
|
||||
</p>
|
||||
)}
|
||||
<InlineError error={mutation.error} />
|
||||
@@ -251,10 +278,7 @@ function AssignmentsEditor({ prefixes, groups }: Props) {
|
||||
{dirty && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setValidation(null);
|
||||
dispatch({ type: "reset", prefixes });
|
||||
}}
|
||||
onClick={() => dispatch({ type: "reset", prefixes })}
|
||||
{...stylex.props(shared.button, shared.focusRing)}
|
||||
>
|
||||
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", () => {
|
||||
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(
|
||||
firstProblem([
|
||||
{ prefix: "10.0.0.0/8", group_id: 1, priority: "100" },
|
||||
{ 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 {
|
||||
for (const [i, row] of rows.entries()) {
|
||||
if (row.prefix.trim() === "") return `Row ${i + 1}: prefix is required.`;
|
||||
/** Which input the message is about, so the editor can point that input at it. */
|
||||
export interface PrefixProblem {
|
||||
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();
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import { styles as config } from "./styles";
|
||||
|
||||
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({
|
||||
form: {
|
||||
marginTop: "1rem",
|
||||
@@ -175,12 +178,15 @@ function FieldRow({
|
||||
}
|
||||
if (def.kind === "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 (
|
||||
<div {...stylex.props(styles.field)}>
|
||||
<FieldLabel id={id} text={def.key} restart={restart} />
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
aria-invalid={Number.isNaN(numeric) || undefined}
|
||||
value={Number.isNaN(numeric) ? "" : numeric}
|
||||
onChange={(e) => onChange(e.target.valueAsNumber)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -286,6 +292,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
id="web.password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={passwordsMismatch || undefined}
|
||||
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -299,6 +307,8 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
id="web.password_confirm"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
aria-invalid={passwordsMismatch || undefined}
|
||||
aria-describedby={passwordsMismatch ? MISMATCH_ID : undefined}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
{...stylex.props(styles.fieldInput, shared.focusRing)}
|
||||
@@ -311,7 +321,7 @@ export default function SettingsForm({ envelope }: { envelope: SettingsEnvelope
|
||||
</p>
|
||||
)}
|
||||
{passwordsMismatch && (
|
||||
<p {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||
<p id={MISMATCH_ID} {...stylex.props(styles.spanRow, styles.mismatchNotice)}>
|
||||
Passwords do not match.
|
||||
</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 } });
|
||||
});
|
||||
|
||||
/** 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 () => {
|
||||
await openSystem();
|
||||
|
||||
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);
|
||||
// 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 () => {
|
||||
|
||||
Reference in New Issue
Block a user