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
4 changed files with 121 additions and 36 deletions

View file

@ -159,7 +159,7 @@ export const EditSseWithHeaders: Story = {
const body = within(canvasElement.ownerDocument.body);
const dialog = within(await findDialog(body, "Edit server"));
const value = (await dialog.findByRole("textbox", {
name: "header value, Cookie",
name: "header value, Cookie, row 1",
})) as HTMLInputElement;
await expect(value.value).toBe("branch=feature-x");

View file

@ -509,7 +509,7 @@ describe("ServerConfigModal", () => {
"Cookie",
);
await user.type(
screen.getByRole("textbox", { name: /header value, Cookie/ }),
screen.getByRole("textbox", { name: /header value, Cookie, row 1/ }),
"branch=feature-x",
);
await user.click(screen.getByRole("button", { name: /^Add$/ }));
@ -538,7 +538,7 @@ describe("ServerConfigModal", () => {
);
const valueInput = screen.getByRole("textbox", {
name: /header value, X-Env/,
name: /header value, X-Env, row 1/,
});
expect(valueInput).toHaveValue("dev");
await user.type(valueInput, "-2");
@ -573,11 +573,11 @@ describe("ServerConfigModal", () => {
await user.click(screen.getByRole("button", { name: "+ Add Header" }));
// Editing one row of several must leave its siblings untouched.
await user.type(
screen.getByRole("textbox", { name: "header value, Keep" }),
screen.getByRole("textbox", { name: "header value, Keep, row 1" }),
"9",
);
await user.click(
screen.getByRole("button", { name: "Remove header, Drop" }),
screen.getByRole("button", { name: "Remove header, Drop, row 2" }),
);
await user.click(screen.getByRole("button", { name: /^Save$/ }));

View file

@ -10,7 +10,7 @@ import {
/** 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
* for the row it belongs to ("Clear header name, Cookie, row 1"), while a standalone
* field keeps the bare "Clear". */
function clearButtonFor(input: HTMLElement): HTMLElement {
const root =
@ -333,7 +333,9 @@ describe("ServerSettingsForm", () => {
/>,
);
await user.click(
screen.getByRole("button", { name: "Remove header, Authorization" }),
screen.getByRole("button", {
name: "Remove header, Authorization, row 1",
}),
);
expect(onRemoveHeader).toHaveBeenCalledWith(0);
});
@ -356,7 +358,9 @@ describe("ServerSettingsForm", () => {
expect(onMetadataChange).toHaveBeenCalled();
await user.click(
screen.getByRole("button", { name: "Remove metadata entry, userId" }),
screen.getByRole("button", {
name: "Remove metadata entry, userId, row 1",
}),
);
expect(onRemoveMetadata).toHaveBeenCalledWith(0);
});
@ -685,7 +689,7 @@ describe("ServerSettingsForm", () => {
// accessible name identifies which row it belongs to.
await user.click(
screen.getByRole("button", {
name: "Remove environment variable, API_KEY",
name: "Remove environment variable, API_KEY, row 1",
}),
);
expect(onRemoveEnv).toHaveBeenCalledWith(0);

View file

@ -277,7 +277,9 @@ describe("ServerSettingsModal", () => {
);
await user.click(screen.getByRole("button", { name: "Custom Headers" }));
await user.click(
screen.getByRole("button", { name: "Remove header, Authorization" }),
screen.getByRole("button", {
name: "Remove header, Authorization, row 1",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...initialSettings,
@ -346,7 +348,9 @@ describe("ServerSettingsModal", () => {
// Both the header and metadata rows have a remove button; each is named
// for the row it belongs to, so no positional guess is needed.
await user.click(
screen.getByRole("button", { name: "Remove metadata entry, userId" }),
screen.getByRole("button", {
name: "Remove metadata entry, userId, row 1",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...initialSettings,
@ -767,7 +771,9 @@ describe("ServerSettingsModal", () => {
screen.getByRole("button", { name: "Environment Variables" }),
);
await user.click(
screen.getByRole("button", { name: "Remove environment variable, A" }),
screen.getByRole("button", {
name: "Remove environment variable, A, row 1",
}),
);
expect(onSettingsChange).toHaveBeenCalledWith({
...emptySettings,