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:
cliffhall 2026-08-16 21:40:07 -04:00
commit ed99617c53
2 changed files with 202 additions and 33 deletions

View file

@ -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 () => {

View file

@ -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