Add ServerSettingsModal group, revert form to controlled component

Revert ServerSettingsForm to a controlled component with
expandedSections/onExpandedSectionsChange props. The form is a
dumb component — accordion state belongs to its container.

Create ServerSettingsModal group that manages:
- Accordion expand/collapse state with ListToggle expand-all button
- Add/remove/edit header pairs
- Add/remove/edit metadata pairs
- Connection mode, timeout, and OAuth changes
- All changes flow through a single onSettingsChange callback

Update ServerSettingsForm stories with useState render functions
so accordion sections are interactive.

Update UnconnectedView WithSettingsModal story to use the new
ServerSettingsModal component.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-04-21 17:13:57 -04:00
commit a6fc900271
5 changed files with 307 additions and 100 deletions

View file

@ -1,7 +1,11 @@
import { useState } from "react";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js"; import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { ServerSettingsForm } from "./ServerSettingsForm"; import {
ServerSettingsForm,
type ServerSettingsSection,
} from "./ServerSettingsForm";
const defaultSettings: InspectorServerSettings = { const defaultSettings: InspectorServerSettings = {
connectionMode: "proxy", connectionMode: "proxy",
@ -11,72 +15,97 @@ const defaultSettings: InspectorServerSettings = {
requestTimeout: 60000, requestTimeout: 60000,
}; };
const defaultHandlers = {
onConnectionModeChange: fn(),
onAddHeader: fn(),
onRemoveHeader: fn(),
onHeaderChange: fn(),
onAddMetadata: fn(),
onRemoveMetadata: fn(),
onMetadataChange: fn(),
onTimeoutChange: fn(),
onOAuthChange: fn(),
};
function InteractiveForm({
settings,
initialSections = ["connectionMode"],
}: {
settings: InspectorServerSettings;
initialSections?: ServerSettingsSection[];
}) {
const [expandedSections, setExpandedSections] =
useState<ServerSettingsSection[]>(initialSections);
return (
<ServerSettingsForm
settings={settings}
expandedSections={expandedSections}
onExpandedSectionsChange={setExpandedSections}
{...defaultHandlers}
/>
);
}
const meta: Meta<typeof ServerSettingsForm> = { const meta: Meta<typeof ServerSettingsForm> = {
title: "Groups/ServerSettingsForm", title: "Groups/ServerSettingsForm",
component: ServerSettingsForm, component: ServerSettingsForm,
args: {
onConnectionModeChange: fn(),
onAddHeader: fn(),
onRemoveHeader: fn(),
onHeaderChange: fn(),
onAddMetadata: fn(),
onRemoveMetadata: fn(),
onMetadataChange: fn(),
onTimeoutChange: fn(),
onOAuthChange: fn(),
},
}; };
export default meta; export default meta;
type Story = StoryObj<typeof ServerSettingsForm>; type Story = StoryObj<typeof ServerSettingsForm>;
export const DefaultSettings: Story = { export const DefaultSettings: Story = {
args: { render: () => <InteractiveForm settings={defaultSettings} />,
settings: defaultSettings,
},
}; };
export const WithHeaders: Story = { export const WithHeaders: Story = {
args: { render: () => (
settings: { <InteractiveForm
...defaultSettings, settings={{
headers: [ ...defaultSettings,
{ key: "Authorization", value: "Bearer token-abc-123" }, headers: [
{ key: "X-Custom-Header", value: "custom-value" }, { key: "Authorization", value: "Bearer token-abc-123" },
], { key: "X-Custom-Header", value: "custom-value" },
}, ],
}, }}
/>
),
}; };
export const WithOAuth: Story = { export const WithOAuth: Story = {
args: { render: () => (
settings: { <InteractiveForm
...defaultSettings, settings={{
connectionMode: "direct", ...defaultSettings,
oauthClientId: "my-client-id", connectionMode: "direct",
oauthClientSecret: "super-secret-value", oauthClientId: "my-client-id",
oauthScopes: "read write admin", oauthClientSecret: "super-secret-value",
}, oauthScopes: "read write admin",
}, }}
/>
),
}; };
export const AllConfigured: Story = { export const AllConfigured: Story = {
args: { render: () => (
settings: { <InteractiveForm
connectionMode: "proxy", settings={{
headers: [ connectionMode: "proxy",
{ key: "Authorization", value: "Bearer token-abc-123" }, headers: [
{ key: "X-Request-Id", value: "req-456" }, { key: "Authorization", value: "Bearer token-abc-123" },
], { key: "X-Request-Id", value: "req-456" },
metadata: [ ],
{ key: "userId", value: "user-789" }, metadata: [
{ key: "sessionId", value: "session-012" }, { key: "userId", value: "user-789" },
], { key: "sessionId", value: "session-012" },
connectionTimeout: 15000, ],
requestTimeout: 45000, connectionTimeout: 15000,
oauthClientId: "my-client-id", requestTimeout: 45000,
oauthClientSecret: "super-secret-value", oauthClientId: "my-client-id",
oauthScopes: "read write", oauthClientSecret: "super-secret-value",
}, oauthScopes: "read write",
}, }}
/>
),
}; };

View file

@ -1,4 +1,3 @@
import { useState } from "react";
import { import {
Accordion, Accordion,
ActionIcon, ActionIcon,
@ -21,7 +20,8 @@ export type ServerSettingsSection =
export interface ServerSettingsFormProps { export interface ServerSettingsFormProps {
settings: InspectorServerSettings; settings: InspectorServerSettings;
defaultExpandedSections?: ServerSettingsSection[]; expandedSections: ServerSettingsSection[];
onExpandedSectionsChange: (sections: ServerSettingsSection[]) => void;
onConnectionModeChange: (mode: "proxy" | "direct") => void; onConnectionModeChange: (mode: "proxy" | "direct") => void;
onAddHeader: () => void; onAddHeader: () => void;
onRemoveHeader: (index: number) => void; onRemoveHeader: (index: number) => void;
@ -95,7 +95,8 @@ function KeyValueRows({
export function ServerSettingsForm({ export function ServerSettingsForm({
settings, settings,
defaultExpandedSections = ["connectionMode"], expandedSections,
onExpandedSectionsChange,
onConnectionModeChange, onConnectionModeChange,
onAddHeader, onAddHeader,
onRemoveHeader, onRemoveHeader,
@ -106,10 +107,6 @@ export function ServerSettingsForm({
onTimeoutChange, onTimeoutChange,
onOAuthChange, onOAuthChange,
}: ServerSettingsFormProps) { }: ServerSettingsFormProps) {
const [expandedSections, setExpandedSections] = useState<
ServerSettingsSection[]
>(defaultExpandedSections);
const handleTimeoutChange = (field: string) => (value: number | string) => { const handleTimeoutChange = (field: string) => (value: number | string) => {
const numValue = const numValue =
typeof value === "string" ? parseInt(value, 10) || 0 : value; typeof value === "string" ? parseInt(value, 10) || 0 : value;
@ -121,7 +118,7 @@ export function ServerSettingsForm({
multiple multiple
value={expandedSections} value={expandedSections}
onChange={(value) => onChange={(value) =>
setExpandedSections(value as ServerSettingsSection[]) onExpandedSectionsChange(value as ServerSettingsSection[])
} }
variant="separated" variant="separated"
> >

View file

@ -0,0 +1,62 @@
import { useState } from "react";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ServerSettingsModal } from "./ServerSettingsModal";
const initialSettings: InspectorServerSettings = {
connectionMode: "proxy",
headers: [
{ key: "Authorization", value: "Bearer token-abc-123" },
{ key: "X-Request-Id", value: "req-456" },
],
metadata: [{ key: "userId", value: "user-789" }],
connectionTimeout: 30000,
requestTimeout: 60000,
oauthClientId: "my-client-id",
oauthClientSecret: "super-secret-value",
oauthScopes: "read write",
};
function InteractiveModal({
startSettings,
}: {
startSettings: InspectorServerSettings;
}) {
const [settings, setSettings] =
useState<InspectorServerSettings>(startSettings);
return (
<ServerSettingsModal
opened
settings={settings}
onClose={() => {}}
onSettingsChange={setSettings}
/>
);
}
const meta: Meta<typeof ServerSettingsModal> = {
title: "Groups/ServerSettingsModal",
component: ServerSettingsModal,
};
export default meta;
type Story = StoryObj<typeof ServerSettingsModal>;
export const FullyConfigured: Story = {
render: () => <InteractiveModal startSettings={initialSettings} />,
};
export const EmptySettings: Story = {
render: () => (
<InteractiveModal
startSettings={{
connectionMode: "proxy",
headers: [],
metadata: [],
connectionTimeout: 30000,
requestTimeout: 60000,
}}
/>
),
};

View file

@ -0,0 +1,139 @@
import { useState } from "react";
import { CloseButton, Group, Modal, Title } from "@mantine/core";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import {
ServerSettingsForm,
type ServerSettingsSection,
} from "../ServerSettingsForm/ServerSettingsForm";
const ALL_SECTIONS: ServerSettingsSection[] = [
"connectionMode",
"headers",
"metadata",
"timeouts",
"oauth",
];
export interface ServerSettingsModalProps {
opened: boolean;
settings: InspectorServerSettings;
onClose: () => void;
onSettingsChange: (settings: InspectorServerSettings) => void;
}
export function ServerSettingsModal({
opened,
settings,
onClose,
onSettingsChange,
}: ServerSettingsModalProps) {
const [expandedSections, setExpandedSections] = useState<
ServerSettingsSection[]
>(["connectionMode"]);
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
function handleToggleAll() {
setExpandedSections(allExpanded ? [] : ALL_SECTIONS);
}
function handleConnectionModeChange(mode: "proxy" | "direct") {
onSettingsChange({ ...settings, connectionMode: mode });
}
function handleAddHeader() {
onSettingsChange({
...settings,
headers: [...settings.headers, { key: "", value: "" }],
});
}
function handleRemoveHeader(index: number) {
onSettingsChange({
...settings,
headers: settings.headers.filter((_, i) => i !== index),
});
}
function handleHeaderChange(index: number, key: string, value: string) {
const headers = settings.headers.map((h, i) =>
i === index ? { key, value } : h,
);
onSettingsChange({ ...settings, headers });
}
function handleAddMetadata() {
onSettingsChange({
...settings,
metadata: [...settings.metadata, { key: "", value: "" }],
});
}
function handleRemoveMetadata(index: number) {
onSettingsChange({
...settings,
metadata: settings.metadata.filter((_, i) => i !== index),
});
}
function handleMetadataChange(index: number, key: string, value: string) {
const metadata = settings.metadata.map((m, i) =>
i === index ? { key, value } : m,
);
onSettingsChange({ ...settings, metadata });
}
function handleTimeoutChange(field: string, value: number) {
onSettingsChange({ ...settings, [field]: value });
}
function handleOAuthChange(field: string, value: string) {
const oauthFieldMap: Record<string, string> = {
clientId: "oauthClientId",
clientSecret: "oauthClientSecret",
scopes: "oauthScopes",
};
const settingsKey = oauthFieldMap[field] ?? field;
onSettingsChange({ ...settings, [settingsKey]: value });
}
return (
<Modal
opened={opened}
onClose={onClose}
withCloseButton={false}
title={
<Group justify="space-between" wrap="nowrap" w="100%">
<ListToggle
compact={allExpanded}
variant="subtle"
onToggle={handleToggleAll}
/>
<Title order={4} ta="center" style={{ flex: 1 }}>
Server Settings
</Title>
<CloseButton onClick={onClose} />
</Group>
}
size="lg"
centered
styles={{ title: { flex: 1 } }}
>
<ServerSettingsForm
settings={settings}
expandedSections={expandedSections}
onExpandedSectionsChange={setExpandedSections}
onConnectionModeChange={handleConnectionModeChange}
onAddHeader={handleAddHeader}
onRemoveHeader={handleRemoveHeader}
onHeaderChange={handleHeaderChange}
onAddMetadata={handleAddMetadata}
onRemoveMetadata={handleRemoveMetadata}
onMetadataChange={handleMetadataChange}
onTimeoutChange={handleTimeoutChange}
onOAuthChange={handleOAuthChange}
/>
</Modal>
);
}

View file

@ -1,9 +1,10 @@
import { useState } from "react";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { CloseButton, Group, Modal, Title } from "@mantine/core";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { UnconnectedView } from "./UnconnectedView.js"; import { UnconnectedView } from "./UnconnectedView.js";
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen"; import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
import { ServerSettingsForm } from "../../groups/ServerSettingsForm/ServerSettingsForm"; import { ServerSettingsModal } from "../../groups/ServerSettingsModal/ServerSettingsModal";
import type { ServerCardProps } from "../../groups/ServerCard/ServerCard"; import type { ServerCardProps } from "../../groups/ServerCard/ServerCard";
const meta: Meta<typeof UnconnectedView> = { const meta: Meta<typeof UnconnectedView> = {
@ -112,6 +113,20 @@ export const WithServers: Story = {
}; };
function SettingsModalStory() { function SettingsModalStory() {
const [settings, setSettings] = useState<InspectorServerSettings>({
connectionMode: "proxy",
headers: [
{ key: "Authorization", value: "Bearer token-abc-123" },
{ key: "X-Request-Id", value: "req-456" },
],
metadata: [{ key: "userId", value: "user-789" }],
connectionTimeout: 30000,
requestTimeout: 60000,
oauthClientId: "my-client-id",
oauthClientSecret: "super-secret-value",
oauthScopes: "read write",
});
return ( return (
<UnconnectedView onToggleTheme={fn()}> <UnconnectedView onToggleTheme={fn()}>
<ServerListScreen <ServerListScreen
@ -126,47 +141,12 @@ function SettingsModalStory() {
onImportConfig={fn()} onImportConfig={fn()}
onImportServerJson={fn()} onImportServerJson={fn()}
/> />
<Modal <ServerSettingsModal
opened opened
settings={settings}
onClose={fn()} onClose={fn()}
withCloseButton={false} onSettingsChange={setSettings}
title={ />
<Group justify="space-between" wrap="nowrap" w="100%">
<Title order={4} ta="center" style={{ flex: 1 }}>
Server Settings
</Title>
<CloseButton onClick={fn()} />
</Group>
}
size="lg"
centered
styles={{ title: { flex: 1 } }}
>
<ServerSettingsForm
settings={{
connectionMode: "proxy",
headers: [
{ key: "Authorization", value: "Bearer token-abc-123" },
{ key: "X-Request-Id", value: "req-456" },
],
metadata: [{ key: "userId", value: "user-789" }],
connectionTimeout: 30000,
requestTimeout: 60000,
oauthClientId: "my-client-id",
oauthClientSecret: "super-secret-value",
oauthScopes: "read write",
}}
onConnectionModeChange={fn()}
onAddHeader={fn()}
onRemoveHeader={fn()}
onHeaderChange={fn()}
onAddMetadata={fn()}
onRemoveMetadata={fn()}
onMetadataChange={fn()}
onTimeoutChange={fn()}
onOAuthChange={fn()}
/>
</Modal>
</UnconnectedView> </UnconnectedView>
); );
} }