* feat(web): add Mantine clear buttons to text inputs app-wide (#1333) Add a clear (×) affordance to every user-editable text input across the web client so a populated field can be reset in one click. - Autocomplete: enable `clearable` by default via the theme (`theme/Autocomplete.ts`) — covers the prompt-argument and resource-template variable autocomplete branches. - TextInput / Textarea: Mantine v8 does not expose a public `clearable` prop on these primitives (only the combobox-based components do), so add the clear button manually via `rightSection` + `CloseButton` (aria-label="Clear") shown only when the field has a value, wired to reset the field's controlled value to "". Applied to sidebar search inputs (prompts, tools, apps, tasks, history, resources, network, logs), prompt-argument and resource-template variable inputs, the sampling response/model fields, server config & settings forms, roots table, schema form, experimental features panel, and the server.json import panel. Read-only/display Textareas (OutputValidationModal, UrlElicitationErrorModal) are intentionally left untouched. Tests cover the clear behavior on representative inputs from each category (sidebar search, prompt argument, resource-template variable, sampling response, plus the config/settings forms). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): disable ServerConfigModal clear buttons while submitting Address review #2 on #1448: the clear CloseButton sat in `rightSection` with `rightSectionPointerEvents="auto"`, so it stayed clickable even while the field was `disabled={submitting}`, letting a user wipe a field mid-submit. Forward `disabled={submitting}` to each clear button so it locks together with its input. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(web): remove redundant Auto-respond from SamplingRequestPanel The sampling modal pre-loads its draft with the default stub result, and "Send Response" sends that draft — so on an untouched form "Auto-respond" did exactly the same thing as "Send Response", just with a fresh copy of the same defaults. Drop the redundant button and its `onAutoRespond` prop; "Send Response" now covers both the unedited (stub) and edited cases. The InlineSamplingRequest variant keeps its Auto-respond button — there it is the quick path versus "Edit & Send" (which opens this editable modal), not a duplicate of an adjacent send action. Updated PendingClientRequestModal wiring, stories, and tests accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
62d442b073
commit
0890c7509a
2 changed files with 805 additions and 28 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { within } from "@testing-library/react";
|
||||
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import {
|
||||
|
|
@ -7,6 +8,14 @@ import {
|
|||
type ServerSettingsSection,
|
||||
} from "./ServerSettingsForm";
|
||||
|
||||
/** Find the "Clear" button living in the rightSection of `input`'s field. */
|
||||
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" });
|
||||
}
|
||||
|
||||
const emptySettings: InspectorServerSettings = {
|
||||
headers: [],
|
||||
metadata: [],
|
||||
|
|
@ -438,6 +447,149 @@ describe("ServerSettingsForm", () => {
|
|||
expect(screen.getByLabelText(/Connection Timeout/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears a header value via its Clear button (onHeaderChange with empty value)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onHeaderChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onHeaderChange={onHeaderChange}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["headers"]}
|
||||
/>,
|
||||
);
|
||||
// populatedSettings has one header { key: "Authorization", value: "Bearer abc" }
|
||||
const valueInput = screen.getByDisplayValue("Bearer abc");
|
||||
await user.click(clearButtonFor(valueInput));
|
||||
expect(onHeaderChange).toHaveBeenCalledWith(0, "Authorization", "");
|
||||
});
|
||||
|
||||
it("clears a header key via its Clear button (onHeaderChange with empty key)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onHeaderChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onHeaderChange={onHeaderChange}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["headers"]}
|
||||
/>,
|
||||
);
|
||||
const keyInput = screen.getByDisplayValue("Authorization");
|
||||
await user.click(clearButtonFor(keyInput));
|
||||
expect(onHeaderChange).toHaveBeenCalledWith(0, "", "Bearer abc");
|
||||
});
|
||||
|
||||
it("clears a root uri via its Clear button (onRootChange with empty uri)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRootChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onRootChange={onRootChange}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["roots"]}
|
||||
/>,
|
||||
);
|
||||
// populatedSettings has one root { uri: "file:///project", name: "Project" }
|
||||
const uriInput = screen.getByDisplayValue("file:///project");
|
||||
await user.click(clearButtonFor(uriInput));
|
||||
expect(onRootChange).toHaveBeenCalledWith(0, "", "Project");
|
||||
});
|
||||
|
||||
it("clears the OAuth Client ID via its Clear button (onOAuthChange with empty clientId)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOAuthChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onOAuthChange={onOAuthChange}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
const clientIdInput = screen.getByLabelText("Client ID");
|
||||
await user.click(clearButtonFor(clientIdInput));
|
||||
expect(onOAuthChange).toHaveBeenCalledTimes(1);
|
||||
const arg = onOAuthChange.mock.calls[0][0];
|
||||
expect(arg.clientId).toBe("");
|
||||
expect(arg.clientSecret).toBe("secret");
|
||||
expect(arg.scopes).toBe("read");
|
||||
});
|
||||
|
||||
it("clears the OAuth Scopes via its Clear button (onOAuthChange with empty scopes)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOAuthChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onOAuthChange={onOAuthChange}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
const scopesInput = screen.getByLabelText("Scopes");
|
||||
await user.click(clearButtonFor(scopesInput));
|
||||
expect(onOAuthChange).toHaveBeenCalledTimes(1);
|
||||
const arg = onOAuthChange.mock.calls[0][0];
|
||||
expect(arg.scopes).toBe("");
|
||||
expect(arg.clientId).toBe("cid");
|
||||
});
|
||||
|
||||
it("clears a root name via its Clear button (onRootChange with empty name)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRootChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onRootChange={onRootChange}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["roots"]}
|
||||
/>,
|
||||
);
|
||||
// populatedSettings has one root { uri: "file:///project", name: "Project" }
|
||||
const nameInput = screen.getByDisplayValue("Project");
|
||||
await user.click(clearButtonFor(nameInput));
|
||||
expect(onRootChange).toHaveBeenCalledWith(0, "file:///project", "");
|
||||
});
|
||||
|
||||
it("clears the OAuth Client Secret via its Clear button (onOAuthChange with empty clientSecret)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onOAuthChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onOAuthChange={onOAuthChange}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["oauth"]}
|
||||
/>,
|
||||
);
|
||||
const secretInput = screen.getByLabelText("Client Secret");
|
||||
await user.click(clearButtonFor(secretInput));
|
||||
expect(onOAuthChange).toHaveBeenCalledTimes(1);
|
||||
const arg = onOAuthChange.mock.calls[0][0];
|
||||
expect(arg.clientSecret).toBe("");
|
||||
expect(arg.clientId).toBe("cid");
|
||||
expect(arg.scopes).toBe("read");
|
||||
});
|
||||
|
||||
it("clears a metadata value via its Clear button (onMetadataChange with empty value)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onMetadataChange = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerSettingsForm
|
||||
{...baseHandlers}
|
||||
onMetadataChange={onMetadataChange}
|
||||
settings={populatedSettings}
|
||||
expandedSections={["metadata"]}
|
||||
/>,
|
||||
);
|
||||
// populatedSettings has one metadata { key: "userId", value: "u-1" }
|
||||
const valueInput = screen.getByDisplayValue("u-1");
|
||||
await user.click(clearButtonFor(valueInput));
|
||||
expect(onMetadataChange).toHaveBeenCalledWith(0, "userId", "");
|
||||
});
|
||||
|
||||
it("invokes onTimeoutChange with 0 when a non-numeric string is provided", () => {
|
||||
const onTimeoutChange = vi.fn();
|
||||
// Render with a non-finite default in the NumberInput; then directly invoke
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
ActionIcon,
|
||||
Button,
|
||||
Checkbox,
|
||||
CloseButton,
|
||||
Group,
|
||||
NumberInput,
|
||||
Stack,
|
||||
|
|
@ -86,11 +87,29 @@ function KeyValueRows({
|
|||
placeholder="Key"
|
||||
value={item.key}
|
||||
onChange={(e) => onChange(index, e.currentTarget.value, item.value)}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
item.key ? (
|
||||
<CloseButton
|
||||
aria-label="Clear"
|
||||
onClick={() => onChange(index, "", item.value)}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Value"
|
||||
value={item.value}
|
||||
onChange={(e) => onChange(index, item.key, e.currentTarget.value)}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
item.value ? (
|
||||
<CloseButton
|
||||
aria-label="Clear"
|
||||
onClick={() => onChange(index, item.key, "")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<RemoveIcon onClick={() => onRemove(index)}>X</RemoveIcon>
|
||||
</Group>
|
||||
|
|
@ -122,11 +141,29 @@ function RootRows({
|
|||
onChange={(e) =>
|
||||
onChange(index, e.currentTarget.value, root.name ?? "")
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
root.uri ? (
|
||||
<CloseButton
|
||||
aria-label="Clear"
|
||||
onClick={() => onChange(index, "", root.name ?? "")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
placeholder="Name (optional)"
|
||||
value={root.name ?? ""}
|
||||
onChange={(e) => onChange(index, root.uri, e.currentTarget.value)}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
root.name ? (
|
||||
<CloseButton
|
||||
aria-label="Clear"
|
||||
onClick={() => onChange(index, root.uri, "")}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<RemoveIcon onClick={() => onRemove(index)}>X</RemoveIcon>
|
||||
</Group>
|
||||
|
|
@ -307,6 +344,17 @@ export function ServerSettingsForm({
|
|||
clientId: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.oauthClientId ? (
|
||||
<CloseButton
|
||||
aria-label="Clear"
|
||||
onClick={() =>
|
||||
onOAuthChange({ ...currentOAuth(), clientId: "" })
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Client Secret"
|
||||
|
|
@ -318,6 +366,17 @@ export function ServerSettingsForm({
|
|||
clientSecret: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.oauthClientSecret ? (
|
||||
<CloseButton
|
||||
aria-label="Clear"
|
||||
onClick={() =>
|
||||
onOAuthChange({ ...currentOAuth(), clientSecret: "" })
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<TextInput
|
||||
label="Scopes"
|
||||
|
|
@ -328,6 +387,17 @@ export function ServerSettingsForm({
|
|||
scopes: e.currentTarget.value,
|
||||
})
|
||||
}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.oauthScopes ? (
|
||||
<CloseButton
|
||||
aria-label="Clear"
|
||||
onClick={() =>
|
||||
onOAuthChange({ ...currentOAuth(), scopes: "" })
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Accordion.Panel>
|
||||
|
|
|
|||
Loading…
Reference in a new issue