feat(web): custom headers on the manual server-config form (#1915)
Adding a server by hand offered no way to set custom HTTP headers, so the
only route to a cookie or a routing header was importing a config file.
The manual "Add server" form now carries a Custom Headers key/value editor
for the sse / streamable-http transports, pre-populated when editing or
cloning and submitted alongside the config.
Headers are not part of `MCPServerConfig` — they live on the entry's
`settings` — so they travel as `onSubmit`'s third argument rather than
folded into the config, and `addServer` / `updateServer` gained an optional
`settings` parameter to carry them to the backend (`POST /api/servers`
already accepted one). `updateServer`'s omission semantics are unchanged:
with no settings passed, the route still preserves the node on disk.
`KeyValueRows` moves out of ServerSettingsForm into a shared element so the
two editors cannot drift, and each row's controls gain a row-scoped
`aria-label` ("header value, Cookie", "Remove header, Cookie") — previously
every remove button announced only "X", indistinguishable across rows.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
6bc355df63
commit
9aab945964
3 changed files with 732 additions and 136 deletions
|
|
@ -0,0 +1,60 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { KeyValueRows } from "./KeyValueRows";
|
||||
|
||||
const meta: Meta<typeof KeyValueRows> = {
|
||||
title: "Elements/KeyValueRows",
|
||||
component: KeyValueRows,
|
||||
args: {
|
||||
entityLabel: "header",
|
||||
onChange: fn(),
|
||||
onRemove: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof KeyValueRows>;
|
||||
|
||||
// Each row's controls carry a row-scoped accessible name, so a screen reader
|
||||
// can tell one row's key box from another's.
|
||||
export const Populated: Story = {
|
||||
args: {
|
||||
items: [
|
||||
{ key: "Cookie", value: "branch=feature-x" },
|
||||
{ key: "X-Env", value: "dev" },
|
||||
],
|
||||
},
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const value = await canvas.findByRole("textbox", {
|
||||
name: "header value, Cookie",
|
||||
});
|
||||
await expect(value).toHaveValue("branch=feature-x");
|
||||
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Remove header, X-Env" }),
|
||||
);
|
||||
await expect(args.onRemove).toHaveBeenCalledWith(1);
|
||||
},
|
||||
};
|
||||
|
||||
// A row whose key is still blank falls back to a positional name rather than
|
||||
// announcing nothing.
|
||||
export const BlankKeyRow: Story = {
|
||||
args: { items: [{ key: "", value: "" }] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(
|
||||
await canvas.findByRole("textbox", { name: "header name, row 1" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// An empty list renders nothing — callers draw their own empty-state hint.
|
||||
export const Empty: Story = {
|
||||
args: { items: [] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryAllByRole("textbox")).toHaveLength(0);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { KeyValueRows } from "./KeyValueRows";
|
||||
|
||||
describe("KeyValueRows", () => {
|
||||
function setup(items: { key: string; value: string }[]) {
|
||||
const onChange = vi.fn();
|
||||
const onRemove = vi.fn();
|
||||
renderWithMantine(
|
||||
<KeyValueRows
|
||||
items={items}
|
||||
entityLabel="header"
|
||||
onChange={onChange}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
);
|
||||
return { onChange, onRemove };
|
||||
}
|
||||
|
||||
it("renders nothing for an empty list", () => {
|
||||
setup([]);
|
||||
expect(screen.queryAllByRole("textbox")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("names each control by entity and row so rows are distinguishable", () => {
|
||||
setup([
|
||||
{ key: "Cookie", value: "a=1" },
|
||||
{ key: "", value: "" },
|
||||
]);
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "header name, Cookie" }),
|
||||
).toHaveValue("Cookie");
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "header value, Cookie" }),
|
||||
).toHaveValue("a=1");
|
||||
// A key that is blank (or only whitespace) falls back to its position.
|
||||
expect(
|
||||
screen.getByRole("textbox", { name: "header name, row 2" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports key and value edits with the row index", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const { onChange } = setup([{ key: "X", value: "1" }]);
|
||||
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "header name, X" }),
|
||||
"Y",
|
||||
);
|
||||
expect(onChange).toHaveBeenLastCalledWith(0, "XY", "1");
|
||||
|
||||
await user.type(
|
||||
screen.getByRole("textbox", { name: "header value, X" }),
|
||||
"2",
|
||||
);
|
||||
expect(onChange).toHaveBeenLastCalledWith(0, "X", "12");
|
||||
});
|
||||
|
||||
it("clears a key or a value through its 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]!);
|
||||
expect(onChange).toHaveBeenLastCalledWith(0, "", "1");
|
||||
|
||||
await user.click(clearButtons[1]!);
|
||||
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);
|
||||
});
|
||||
|
||||
it("removes the clicked row", async () => {
|
||||
const user = userEvent.setup({ delay: null });
|
||||
const { onRemove } = setup([
|
||||
{ key: "A", value: "1" },
|
||||
{ key: "B", value: "2" },
|
||||
]);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Remove header, B" }));
|
||||
expect(onRemove).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { ActionIcon, Group, TextInput } from "@mantine/core";
|
||||
import { ClearButton } from "../ClearButton/ClearButton";
|
||||
|
||||
export interface KeyValuePair {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface KeyValueRowsProps {
|
||||
items: KeyValuePair[];
|
||||
/**
|
||||
* Singular noun for one row ("header", "environment variable", …). Used only
|
||||
* 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".
|
||||
*/
|
||||
entityLabel: string;
|
||||
onChange: (index: number, key: string, value: string) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
// Optional (non-required) clearable field — keeps the ClearButton clickable.
|
||||
const ClearableTextInput = TextInput.withProps({
|
||||
rightSectionPointerEvents: "auto",
|
||||
});
|
||||
|
||||
const RemoveIcon = ActionIcon.withProps({
|
||||
color: "red",
|
||||
variant: "subtle",
|
||||
});
|
||||
|
||||
/**
|
||||
* Controlled editor for a list of `{ key, value }` pairs — the shape the
|
||||
* Inspector persists for custom headers, request metadata, and stdio
|
||||
* environment variables. Owns no state: every keystroke is reported through
|
||||
* `onChange(index, key, value)` and the caller re-renders with the new list.
|
||||
*
|
||||
* Shared by ServerSettingsForm (headers / metadata / env) and ServerConfigModal
|
||||
* (headers on the manual add form, #1915) so the two cannot drift.
|
||||
*/
|
||||
export function KeyValueRows({
|
||||
items,
|
||||
entityLabel,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: KeyValueRowsProps) {
|
||||
return (
|
||||
<>
|
||||
{items.map((item, index) => {
|
||||
const rowLabel = item.key.trim() || `row ${index + 1}`;
|
||||
return (
|
||||
<Group key={index} grow>
|
||||
<ClearableTextInput
|
||||
placeholder="Key"
|
||||
aria-label={`${entityLabel} name, ${rowLabel}`}
|
||||
value={item.key}
|
||||
onChange={(e) =>
|
||||
onChange(index, e.currentTarget.value, item.value)
|
||||
}
|
||||
rightSection={
|
||||
item.key ? (
|
||||
<ClearButton
|
||||
onClick={() => onChange(index, "", item.value)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<ClearableTextInput
|
||||
placeholder="Value"
|
||||
aria-label={`${entityLabel} value, ${rowLabel}`}
|
||||
value={item.value}
|
||||
onChange={(e) => onChange(index, item.key, e.currentTarget.value)}
|
||||
rightSection={
|
||||
item.value ? (
|
||||
<ClearButton onClick={() => onChange(index, item.key, "")} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<RemoveIcon
|
||||
aria-label={`Remove ${entityLabel}, ${rowLabel}`}
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
X
|
||||
</RemoveIcon>
|
||||
</Group>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue