Collapse ServerSettingsForm into accordion and add subtle ListToggle variant

Wraps every section of ServerSettingsForm (Connection Mode, Custom
Headers, Request Metadata, Timeouts, OAuth Settings) in a controlled
Mantine Accordion. The accordion is driven by new required
`expandedSections` / `onExpandedSectionsChange` props so the parent
owns expand/collapse state — consistent with the dumb-component
principle.

Adds a `variant?: "default" | "subtle"` prop to ListToggle. In subtle
mode it renders as an ActionIcon with `variant="subtle"`, `color="gray"`,
and a 16px icon so it visually matches Mantine's CloseButton in modal
headers (transparent until hover).

Adds a WithSettingsModal story on UnconnectedView that presents the
form inside a Mantine Modal with a custom three-column header: subtle
ListToggle on the left (expands/collapses all sections at once),
"Server Settings" Title centered, CloseButton on the right.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-04-05 09:36:14 -04:00
commit 8483b98c37
5 changed files with 264 additions and 96 deletions

View file

@ -23,3 +23,19 @@ export const Collapsed: Story = {
onToggle: fn(),
},
};
export const SubtleExpanded: Story = {
args: {
compact: false,
variant: "subtle",
onToggle: fn(),
},
};
export const SubtleCollapsed: Story = {
args: {
compact: true,
variant: "subtle",
onToggle: fn(),
},
};

View file

@ -1,14 +1,27 @@
import { Button } from "@mantine/core";
import { ActionIcon, Button } from "@mantine/core";
import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri";
export interface ListToggleProps {
compact: boolean;
onToggle: () => void;
variant?: "default" | "subtle";
}
export function ListToggle({ compact, onToggle }: ListToggleProps) {
export function ListToggle({
compact,
onToggle,
variant = "default",
}: ListToggleProps) {
const Icon = compact ? RiExpandVerticalLine : RiCollapseVerticalLine;
if (variant === "subtle") {
return (
<ActionIcon variant="subtle" color="gray" size="md" onClick={onToggle}>
<Icon size={16} />
</ActionIcon>
);
}
return (
<Button size="sm" onClick={onToggle}>
<Icon size={20} />

View file

@ -6,6 +6,8 @@ const meta: Meta<typeof ServerSettingsForm> = {
title: "Groups/ServerSettingsForm",
component: ServerSettingsForm,
args: {
expandedSections: ["connectionMode"],
onExpandedSectionsChange: fn(),
onConnectionModeChange: fn(),
onAddHeader: fn(),
onRemoveHeader: fn(),

View file

@ -1,14 +1,13 @@
import {
Accordion,
ActionIcon,
Button,
Divider,
Group,
NumberInput,
Select,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
export interface KeyValuePair {
@ -16,6 +15,13 @@ export interface KeyValuePair {
value: string;
}
export type ServerSettingsSection =
| "connectionMode"
| "headers"
| "metadata"
| "timeouts"
| "oauth";
export interface ServerSettingsFormProps {
connectionMode: "proxy" | "direct";
headers: KeyValuePair[];
@ -25,6 +31,8 @@ export interface ServerSettingsFormProps {
oauthClientId?: string;
oauthClientSecret?: string;
oauthScopes?: string;
expandedSections: ServerSettingsSection[];
onExpandedSectionsChange: (sections: ServerSettingsSection[]) => void;
onConnectionModeChange: (mode: string) => void;
onAddHeader: () => void;
onRemoveHeader: (index: number) => void;
@ -105,6 +113,8 @@ export function ServerSettingsForm({
oauthClientId,
oauthClientSecret,
oauthScopes,
expandedSections,
onExpandedSectionsChange,
onConnectionModeChange,
onAddHeader,
onRemoveHeader,
@ -122,97 +132,127 @@ export function ServerSettingsForm({
};
return (
<Stack gap="md">
<Title order={5}>Connection Mode</Title>
<Select
data={CONNECTION_MODE_OPTIONS}
value={connectionMode}
onChange={(value) => {
if (value) onConnectionModeChange(value);
}}
description={
connectionMode === "proxy"
? "Route through inspector proxy (required for STDIO)"
: undefined
}
/>
<Accordion
multiple
value={expandedSections}
onChange={(value) =>
onExpandedSectionsChange(value as ServerSettingsSection[])
}
variant="separated"
>
<Accordion.Item value="connectionMode">
<Accordion.Control>Connection Mode</Accordion.Control>
<Accordion.Panel>
<Select
data={CONNECTION_MODE_OPTIONS}
value={connectionMode}
onChange={(value) => {
if (value) onConnectionModeChange(value);
}}
description={
connectionMode === "proxy"
? "Route through inspector proxy (required for STDIO)"
: undefined
}
/>
</Accordion.Panel>
</Accordion.Item>
<Divider />
<Accordion.Item value="headers">
<Accordion.Control>Custom Headers</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Group justify="space-between">
<HintText>
Headers sent with every HTTP request to this server
</HintText>
<AddButton onClick={onAddHeader}>+ Add Header</AddButton>
</Group>
{headers.length === 0 ? (
<EmptyHint>No custom headers configured</EmptyHint>
) : (
<KeyValueRows
items={headers}
onChange={onHeaderChange}
onRemove={onRemoveHeader}
/>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Group justify="space-between">
<Title order={5}>Custom Headers</Title>
<AddButton onClick={onAddHeader}>+ Add Header</AddButton>
</Group>
<HintText>Headers sent with every HTTP request to this server</HintText>
{headers.length === 0 ? (
<EmptyHint>No custom headers configured</EmptyHint>
) : (
<KeyValueRows
items={headers}
onChange={onHeaderChange}
onRemove={onRemoveHeader}
/>
)}
<Accordion.Item value="metadata">
<Accordion.Control>Request Metadata</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<Group justify="space-between">
<HintText>
Metadata sent with every MCP request (included in _meta field)
</HintText>
<AddButton onClick={onAddMetadata}>+ Add Metadata</AddButton>
</Group>
{metadata.length === 0 ? (
<EmptyHint>No request metadata configured</EmptyHint>
) : (
<KeyValueRows
items={metadata}
onChange={onMetadataChange}
onRemove={onRemoveMetadata}
/>
)}
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Divider />
<Accordion.Item value="timeouts">
<Accordion.Control>Timeouts</Accordion.Control>
<Accordion.Panel>
<Group>
<NumberInput
label="Connection Timeout"
suffix=" ms"
value={connectionTimeout}
onChange={handleTimeoutChange("connectionTimeout")}
/>
<NumberInput
label="Request Timeout"
suffix=" ms"
value={requestTimeout}
onChange={handleTimeoutChange("requestTimeout")}
/>
</Group>
</Accordion.Panel>
</Accordion.Item>
<Group justify="space-between">
<Title order={5}>Request Metadata</Title>
<AddButton onClick={onAddMetadata}>+ Add Metadata</AddButton>
</Group>
<HintText>
Metadata sent with every MCP request (included in _meta field)
</HintText>
{metadata.length === 0 ? (
<EmptyHint>No request metadata configured</EmptyHint>
) : (
<KeyValueRows
items={metadata}
onChange={onMetadataChange}
onRemove={onRemoveMetadata}
/>
)}
<Divider />
<Title order={5}>Timeouts</Title>
<Group>
<NumberInput
label="Connection Timeout"
suffix=" ms"
value={connectionTimeout}
onChange={handleTimeoutChange("connectionTimeout")}
/>
<NumberInput
label="Request Timeout"
suffix=" ms"
value={requestTimeout}
onChange={handleTimeoutChange("requestTimeout")}
/>
</Group>
<Divider />
<Title order={5}>OAuth Settings</Title>
<HintText>
Pre-configure OAuth credentials for servers requiring authentication
</HintText>
<TextInput
label="Client ID"
value={oauthClientId ?? ""}
onChange={(e) => onOAuthChange("clientId", e.currentTarget.value)}
/>
<TextInput
label="Client Secret"
value={oauthClientSecret ?? ""}
type="password"
onChange={(e) => onOAuthChange("clientSecret", e.currentTarget.value)}
/>
<TextInput
label="Scopes"
value={oauthScopes ?? ""}
onChange={(e) => onOAuthChange("scopes", e.currentTarget.value)}
/>
</Stack>
<Accordion.Item value="oauth">
<Accordion.Control>OAuth Settings</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">
<HintText>
Pre-configure OAuth credentials for servers requiring
authentication
</HintText>
<TextInput
label="Client ID"
value={oauthClientId ?? ""}
onChange={(e) => onOAuthChange("clientId", e.currentTarget.value)}
/>
<TextInput
label="Client Secret"
value={oauthClientSecret ?? ""}
type="password"
onChange={(e) =>
onOAuthChange("clientSecret", e.currentTarget.value)
}
/>
<TextInput
label="Scopes"
value={oauthScopes ?? ""}
onChange={(e) => onOAuthChange("scopes", e.currentTarget.value)}
/>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}

View file

@ -1,7 +1,22 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Box, CloseButton, Group, Modal, Title } from "@mantine/core";
import { fn } from "storybook/test";
import { UnconnectedView } from "./UnconnectedView.js";
import { ServerListScreen } from "../../screens/ServerListScreen/ServerListScreen";
import {
ServerSettingsForm,
type ServerSettingsSection,
} from "../../groups/ServerSettingsForm/ServerSettingsForm";
const ALL_SETTINGS_SECTIONS: ServerSettingsSection[] = [
"connectionMode",
"headers",
"metadata",
"timeouts",
"oauth",
];
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import type { ServerCardProps } from "../../groups/ServerCard/ServerCard";
const meta: Meta<typeof UnconnectedView> = {
@ -93,6 +108,88 @@ export const WithServers: Story = {
},
};
function SettingsModalStory() {
const [expandedSections, setExpandedSections] = useState<
ServerSettingsSection[]
>(["connectionMode"]);
const allExpanded = expandedSections.length === ALL_SETTINGS_SECTIONS.length;
return (
<UnconnectedView onToggleTheme={fn()}>
<ServerListScreen
servers={[
{
name: "everything-server",
version: "1.0.0",
transport: "stdio",
connectionMode: "Via Proxy",
command: "npx -y @modelcontextprotocol/server-everything",
status: "disconnected",
canTestClientFeatures: false,
...makeServerCallbacks(),
},
]}
onAddManually={fn()}
onImportConfig={fn()}
onImportServerJson={fn()}
/>
<Modal
opened
onClose={fn()}
withCloseButton={false}
title={
<Group justify="space-between" wrap="nowrap" w="100%">
<ListToggle
compact={allExpanded}
variant="subtle"
onToggle={() =>
setExpandedSections(allExpanded ? [] : ALL_SETTINGS_SECTIONS)
}
/>
<Title order={4} ta="center" style={{ flex: 1 }}>
Server Settings
</Title>
<Box>
<CloseButton onClick={fn()} />
</Box>
</Group>
}
size="lg"
centered
styles={{ title: { flex: 1 } }}
>
<ServerSettingsForm
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"
expandedSections={expandedSections}
onExpandedSectionsChange={setExpandedSections}
onConnectionModeChange={fn()}
onAddHeader={fn()}
onRemoveHeader={fn()}
onHeaderChange={fn()}
onAddMetadata={fn()}
onRemoveMetadata={fn()}
onMetadataChange={fn()}
onTimeoutChange={fn()}
onOAuthChange={fn()}
/>
</Modal>
</UnconnectedView>
);
}
export const WithSettingsModal: Story = {
render: () => <SettingsModalStory />,
};
export const ManyServers: Story = {
args: {
children: (