fix(web): lock header rows while submitting, name their clear buttons (#1915)
Round-2 review follow-ups on #2038, all three from the suppressed set. The header rows stayed editable while a save was in flight, unlike the rest of the modal — a slow request let a user edit a row whose value had already been captured, and the modal then closed having persisted the earlier one. `KeyValueRows` takes a `disabled` prop and the modal passes `submitting`. Each row's clear buttons were still named the bare "Clear", so a screen reader saw six indistinguishable buttons across three rows. They now name the field they empty ("Clear header name, Cookie"). The App-level headers-into-settings merge — the seam that distinguishes an edit from a clone, and where the credential copy lived — moves into `utils/serverSettingsPatch.ts` with its own tests. App.tsx is outside the coverage gate, so inline it could not be covered at all. Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
82ce02b516
commit
ed99617c53
4 changed files with 202 additions and 33 deletions
|
|
@ -4,13 +4,14 @@ import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
|||
import { KeyValueRows } from "./KeyValueRows";
|
||||
|
||||
describe("KeyValueRows", () => {
|
||||
function setup(items: { key: string; value: string }[]) {
|
||||
function setup(items: { key: string; value: string }[], disabled?: boolean) {
|
||||
const onChange = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
renderWithMantine(
|
||||
<KeyValueRows
|
||||
items={items}
|
||||
entityLabel="header"
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
|
|
@ -57,21 +58,42 @@ describe("KeyValueRows", () => {
|
|||
expect(onChange).toHaveBeenLastCalledWith(0, "X", "12");
|
||||
});
|
||||
|
||||
it("clears a key or a value through its Clear button", async () => {
|
||||
it("clears a key or a value through its own named Clear button", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const { onChange } = setup([{ key: "X", value: "1" }]);
|
||||
|
||||
const clearButtons = screen.getAllByRole("button", { name: "Clear" });
|
||||
await user.click(clearButtons[0]!);
|
||||
// A bare "Clear" repeated per field would be indistinguishable across
|
||||
// rows, so each clear button names the field it empties.
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Clear header name, X" }),
|
||||
);
|
||||
expect(onChange).toHaveBeenLastCalledWith(0, "", "1");
|
||||
|
||||
await user.click(clearButtons[1]!);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Clear header value, X" }),
|
||||
);
|
||||
expect(onChange).toHaveBeenLastCalledWith(0, "X", "");
|
||||
});
|
||||
|
||||
it("omits the Clear button for an empty key or value", () => {
|
||||
setup([{ key: "", value: "" }]);
|
||||
expect(screen.queryAllByRole("button", { name: "Clear" })).toHaveLength(0);
|
||||
expect(screen.queryAllByRole("button", { name: /^Clear/ })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("locks every control when disabled", () => {
|
||||
setup([{ key: "X", value: "1" }], true);
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "header name, X" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "header value, X" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Clear header name, X" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove header, X" }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it("removes the clicked row", async () => {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,16 @@ export interface KeyValueRowsProps {
|
|||
* to build each control's `aria-label`: the section heading and the "Key" /
|
||||
* "Value" placeholders are not programmatically associated with the inputs,
|
||||
* so without it an assistive technology cannot tell one list's key box from
|
||||
* another's, and the remove button announces only "X".
|
||||
* another's, and the remove button announces only "X". The clear buttons are
|
||||
* named the same way — a bare "Clear" repeated six times across three rows
|
||||
* tells a screen-reader user nothing about which field it empties.
|
||||
*/
|
||||
entityLabel: string;
|
||||
/**
|
||||
* Lock every control in the list — used while a submit is in flight, so the
|
||||
* rows can't drift out of sync with the payload the caller already captured.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
onChange: (index: number, key: string, value: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
|
@ -42,6 +49,7 @@ const RemoveIcon = ActionIcon.withProps({
|
|||
export function KeyValueRows({
|
||||
items,
|
||||
entityLabel,
|
||||
disabled,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: KeyValueRowsProps) {
|
||||
|
|
@ -55,12 +63,15 @@ export function KeyValueRows({
|
|||
placeholder="Key"
|
||||
aria-label={`${entityLabel} name, ${rowLabel}`}
|
||||
value={item.key}
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
onChange(index, e.currentTarget.value, item.value)
|
||||
}
|
||||
rightSection={
|
||||
item.key ? (
|
||||
<ClearButton
|
||||
aria-label={`Clear ${entityLabel} name, ${rowLabel}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(index, "", item.value)}
|
||||
/>
|
||||
) : null
|
||||
|
|
@ -70,15 +81,21 @@ export function KeyValueRows({
|
|||
placeholder="Value"
|
||||
aria-label={`${entityLabel} value, ${rowLabel}`}
|
||||
value={item.value}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(index, item.key, e.currentTarget.value)}
|
||||
rightSection={
|
||||
item.value ? (
|
||||
<ClearButton onClick={() => onChange(index, item.key, "")} />
|
||||
<ClearButton
|
||||
aria-label={`Clear ${entityLabel} value, ${rowLabel}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(index, item.key, "")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<RemoveIcon
|
||||
aria-label={`Remove ${entityLabel}, ${rowLabel}`}
|
||||
disabled={disabled}
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
X
|
||||
|
|
|
|||
|
|
@ -504,6 +504,7 @@ export function ServerConfigModal({
|
|||
<KeyValueRows
|
||||
items={form.headers}
|
||||
entityLabel="header"
|
||||
disabled={submitting}
|
||||
onChange={changeHeader}
|
||||
onRemove={removeHeader}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -8,12 +8,15 @@ import {
|
|||
type ServerSettingsSection,
|
||||
} from "./ServerSettingsForm";
|
||||
|
||||
/** Find the "Clear" button living in the rightSection of `input`'s field. */
|
||||
/** Find the clear button living in the rightSection of `input`'s field. The
|
||||
* name is a prefix match because a KeyValueRows field names its clear button
|
||||
* for the row it belongs to ("Clear header name, Cookie"), while a standalone
|
||||
* field keeps the bare "Clear". */
|
||||
function clearButtonFor(input: HTMLElement): HTMLElement {
|
||||
const root =
|
||||
input.closest('[class*="mantine-TextInput-root"]') ??
|
||||
input.closest('[class*="Input-wrapper"]');
|
||||
return within(root as HTMLElement).getByRole("button", { name: "Clear" });
|
||||
return within(root as HTMLElement).getByRole("button", { name: /^Clear/ });
|
||||
}
|
||||
|
||||
const emptySettings: InspectorServerSettings = {
|
||||
|
|
@ -1391,12 +1394,12 @@ describe("ServerSettingsForm", () => {
|
|||
expect(
|
||||
within(
|
||||
keyInput.closest('[class*="Input-wrapper"]') as HTMLElement,
|
||||
).queryByRole("button", { name: "Clear" }),
|
||||
).queryByRole("button", { name: /^Clear/ }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(
|
||||
valueInput.closest('[class*="Input-wrapper"]') as HTMLElement,
|
||||
).queryByRole("button", { name: "Clear" }),
|
||||
).queryByRole("button", { name: /^Clear/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
|
|
@ -1419,12 +1422,12 @@ describe("ServerSettingsForm", () => {
|
|||
expect(
|
||||
within(
|
||||
uriInput.closest('[class*="Input-wrapper"]') as HTMLElement,
|
||||
).queryByRole("button", { name: "Clear" }),
|
||||
).queryByRole("button", { name: /^Clear/ }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(
|
||||
nameInput.closest('[class*="Input-wrapper"]') as HTMLElement,
|
||||
).queryByRole("button", { name: "Clear" }),
|
||||
).queryByRole("button", { name: /^Clear/ }),
|
||||
).toBeNull();
|
||||
// Typing into the URI threads the empty name through (`root.name ?? ""`).
|
||||
await user.type(uriInput, "f");
|
||||
|
|
|
|||
Loading…
Reference in a new issue