fix(web): only write settings on a real header edit; label rows by position (#1915)

Round-3 review follow-ups on #2038.

`buildHeaderSettingsPatch` treated the mere presence of stored headers as a
change, so saving an id or URL edit re-sent the modal's settings snapshot —
taken when it opened — and could overwrite a metadata or OAuth change made
in the settings form since. It now compares the submitted headers against
the stored ones and omits `settings` when they match, which is what makes
the PUT route preserve the node. Order counts as a difference: it is what
the form round-trips and what the user sees.

Row labels carried the key alone, so two rows sharing a key — mid-edit, or a
duplicate a server persisted — gave both rows' controls identical accessible
names, the thing the labelling exists to prevent. The row number is now
always part of the label rather than a blank-key fallback.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-16 21:57:09 -04:00
commit d60a5c713d
3 changed files with 121 additions and 36 deletions

View file

@ -27,18 +27,18 @@ export const Populated: Story = {
play: async ({ args, canvasElement }) => {
const canvas = within(canvasElement);
const value = await canvas.findByRole("textbox", {
name: "header value, Cookie",
name: "header value, Cookie, row 1",
});
await expect(value).toHaveValue("branch=feature-x");
await userEvent.click(
canvas.getByRole("button", { name: "Remove header, X-Env" }),
canvas.getByRole("button", { name: "Remove header, X-Env, row 2" }),
);
await expect(args.onRemove).toHaveBeenCalledWith(1);
},
};
// A row whose key is still blank falls back to a positional name rather than
// A row whose key is still blank is named by position alone rather than
// announcing nothing.
export const BlankKeyRow: Story = {
args: { items: [{ key: "", value: "" }] },

View file

@ -30,12 +30,12 @@ describe("KeyValueRows", () => {
{ key: "", value: "" },
]);
expect(
screen.getByRole("textbox", { name: "header name, Cookie" }),
screen.getByRole("textbox", { name: "header name, Cookie, row 1" }),
).toHaveValue("Cookie");
expect(
screen.getByRole("textbox", { name: "header value, Cookie" }),
screen.getByRole("textbox", { name: "header value, Cookie, row 1" }),
).toHaveValue("a=1");
// A key that is blank (or only whitespace) falls back to its position.
// A blank (or whitespace-only) key leaves the position alone as the name.
expect(
screen.getByRole("textbox", { name: "header name, row 2" }),
).toBeInTheDocument();
@ -46,13 +46,13 @@ describe("KeyValueRows", () => {
const { onChange } = setup([{ key: "X", value: "1" }]);
await user.type(
screen.getByRole("textbox", { name: "header name, X" }),
screen.getByRole("textbox", { name: "header name, X, row 1" }),
"Y",
);
expect(onChange).toHaveBeenLastCalledWith(0, "XY", "1");
await user.type(
screen.getByRole("textbox", { name: "header value, X" }),
screen.getByRole("textbox", { name: "header value, X, row 1" }),
"2",
);
expect(onChange).toHaveBeenLastCalledWith(0, "X", "12");
@ -65,12 +65,12 @@ describe("KeyValueRows", () => {
// 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" }),
screen.getByRole("button", { name: "Clear header name, X, row 1" }),
);
expect(onChange).toHaveBeenLastCalledWith(0, "", "1");
await user.click(
screen.getByRole("button", { name: "Clear header value, X" }),
screen.getByRole("button", { name: "Clear header value, X, row 1" }),
);
expect(onChange).toHaveBeenLastCalledWith(0, "X", "");
});
@ -83,19 +83,34 @@ describe("KeyValueRows", () => {
it("locks every control when disabled", () => {
setup([{ key: "X", value: "1" }], true);
expect(
screen.getByRole("textbox", { name: "header name, X" }),
screen.getByRole("textbox", { name: "header name, X, row 1" }),
).toBeDisabled();
expect(
screen.getByRole("textbox", { name: "header value, X" }),
screen.getByRole("textbox", { name: "header value, X, row 1" }),
).toBeDisabled();
expect(
screen.getByRole("button", { name: "Clear header name, X" }),
screen.getByRole("button", { name: "Clear header name, X, row 1" }),
).toBeDisabled();
expect(
screen.getByRole("button", { name: "Remove header, X" }),
screen.getByRole("button", { name: "Remove header, X, row 1" }),
).toBeDisabled();
});
it("distinguishes two rows that share a key", () => {
// Duplicate keys happen mid-edit, and a server can persist duplicate
// metadata, so the key alone cannot identify a row.
setup([
{ key: "Set-Cookie", value: "a=1" },
{ key: "Set-Cookie", value: "b=2" },
]);
expect(
screen.getByRole("textbox", { name: "header value, Set-Cookie, row 1" }),
).toHaveValue("a=1");
expect(
screen.getByRole("textbox", { name: "header value, Set-Cookie, row 2" }),
).toHaveValue("b=2");
});
it("removes the clicked row", async () => {
const user = userEvent.setup({ delay: null });
const { onRemove } = setup([
@ -103,7 +118,9 @@ describe("KeyValueRows", () => {
{ key: "B", value: "2" },
]);
await user.click(screen.getByRole("button", { name: "Remove header, B" }));
await user.click(
screen.getByRole("button", { name: "Remove header, B, row 2" }),
);
expect(onRemove).toHaveBeenCalledWith(1);
});
});

View file

@ -56,7 +56,13 @@ export function KeyValueRows({
return (
<>
{items.map((item, index) => {
const rowLabel = item.key.trim() || `row ${index + 1}`;
// The row number is always part of the label, not just a fallback for
// a blank key: two rows can carry the SAME key (mid-edit, or a
// duplicate a server legitimately persisted), and a key-only label
// would give both rows' controls identical accessible names — the very
// thing this labelling exists to prevent.
const key = item.key.trim();
const rowLabel = key ? `${key}, row ${index + 1}` : `row ${index + 1}`;
return (
<Group key={index} grow>
<ClearableTextInput