refactor(mcp): complete form refactoring for unified MCP management
Complete the v3.7.0 MCP refactoring by updating the form layer to match
the unified architecture already implemented in data/service/API layers.
**Breaking Changes:**
- Remove confusing `appId` parameter from McpFormModal
- Replace with `defaultFormat` (json/toml) and `defaultEnabledApps` (array)
**Form Enhancements:**
- Add app enablement checkboxes (Claude/Codex/Gemini) directly in the form
- Smart defaults: new servers default to Claude enabled, editing preserves state
- Support "draft" mode: servers can be created without enabling any apps
**Architecture Improvements:**
- Eliminate semantic confusion: format selection separate from app targeting
- One-step workflow: configure and enable apps in single form submission
- Consistent with unified backend: `apps: { claude, codex, gemini }`
**Testing:**
- Update test mocks to use `useUpsertMcpServer` hook
- Add test case for creating servers with no apps enabled
- Fix parameter references from `appId` to `defaultFormat`
**i18n:**
- Add `mcp.form.enabledApps` translation (zh/en)
- Add `mcp.form.noAppsWarning` translation (zh/en)
This completes the MCP management refactoring, ensuring all layers
(data, service, API, UI) follow the same unified architecture pattern.
This commit is contained in:
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
|
import { Save, Plus, AlertCircle, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -30,26 +31,28 @@ import { useMcpValidation } from "./useMcpValidation";
|
|||||||
import { useUpsertMcpServer } from "@/hooks/useMcp";
|
import { useUpsertMcpServer } from "@/hooks/useMcp";
|
||||||
|
|
||||||
interface McpFormModalProps {
|
interface McpFormModalProps {
|
||||||
appId: AppId;
|
|
||||||
editingId?: string;
|
editingId?: string;
|
||||||
initialData?: McpServer;
|
initialData?: McpServer;
|
||||||
onSave: () => Promise<void>; // v3.7.0: 简化为仅用于关闭表单的回调
|
onSave: () => Promise<void>; // v3.7.0: 简化为仅用于关闭表单的回调
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
existingIds?: string[];
|
existingIds?: string[];
|
||||||
|
defaultFormat?: "json" | "toml"; // 默认配置格式(可选,默认为 JSON)
|
||||||
|
defaultEnabledApps?: AppId[]; // 默认启用到哪些应用(可选,默认为 Claude)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MCP 表单模态框组件(简化版)
|
* MCP 表单模态框组件(v3.7.0 完整重构版)
|
||||||
* Claude: 使用 JSON 格式
|
* - 支持 JSON 和 TOML 两种格式
|
||||||
* Codex: 使用 TOML 格式
|
* - 统一管理,通过复选框选择启用到哪些应用
|
||||||
*/
|
*/
|
||||||
const McpFormModal: React.FC<McpFormModalProps> = ({
|
const McpFormModal: React.FC<McpFormModalProps> = ({
|
||||||
appId,
|
|
||||||
editingId,
|
editingId,
|
||||||
initialData,
|
initialData,
|
||||||
onSave,
|
onSave,
|
||||||
onClose,
|
onClose,
|
||||||
existingIds = [],
|
existingIds = [],
|
||||||
|
defaultFormat = "json",
|
||||||
|
defaultEnabledApps = ["claude"],
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
const { formatTomlError, validateTomlConfig, validateJsonConfig } =
|
||||||
@@ -68,6 +71,23 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
|
const [formDocs, setFormDocs] = useState(initialData?.docs || "");
|
||||||
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
|
const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || "");
|
||||||
|
|
||||||
|
// 启用状态:编辑模式使用现有值,新增模式使用默认值
|
||||||
|
const [enabledApps, setEnabledApps] = useState<{
|
||||||
|
claude: boolean;
|
||||||
|
codex: boolean;
|
||||||
|
gemini: boolean;
|
||||||
|
}>(() => {
|
||||||
|
if (initialData?.apps) {
|
||||||
|
return { ...initialData.apps };
|
||||||
|
}
|
||||||
|
// 新增模式:根据 defaultEnabledApps 设置初始值
|
||||||
|
return {
|
||||||
|
claude: defaultEnabledApps.includes("claude"),
|
||||||
|
codex: defaultEnabledApps.includes("codex"),
|
||||||
|
gemini: defaultEnabledApps.includes("gemini"),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// 编辑模式下禁止修改 ID
|
// 编辑模式下禁止修改 ID
|
||||||
const isEditing = !!editingId;
|
const isEditing = !!editingId;
|
||||||
|
|
||||||
@@ -84,11 +104,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
isEditing ? hasAdditionalInfo : false,
|
isEditing ? hasAdditionalInfo : false,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 根据 appId 决定初始格式
|
// 配置格式:优先使用 defaultFormat,编辑模式下可从现有数据推断
|
||||||
|
const useTomlFormat = useMemo(() => {
|
||||||
|
if (initialData?.server) {
|
||||||
|
// 编辑模式:尝试从现有数据推断格式(这里简化处理,默认 JSON)
|
||||||
|
return defaultFormat === "toml";
|
||||||
|
}
|
||||||
|
return defaultFormat === "toml";
|
||||||
|
}, [defaultFormat, initialData]);
|
||||||
|
|
||||||
|
// 根据格式决定初始配置
|
||||||
const [formConfig, setFormConfig] = useState(() => {
|
const [formConfig, setFormConfig] = useState(() => {
|
||||||
const spec = initialData?.server;
|
const spec = initialData?.server;
|
||||||
if (!spec) return "";
|
if (!spec) return "";
|
||||||
if (appId === "codex") {
|
if (useTomlFormat) {
|
||||||
return mcpServerToToml(spec);
|
return mcpServerToToml(spec);
|
||||||
}
|
}
|
||||||
return JSON.stringify(spec, null, 2);
|
return JSON.stringify(spec, null, 2);
|
||||||
@@ -99,8 +128,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
const [isWizardOpen, setIsWizardOpen] = useState(false);
|
||||||
const [idError, setIdError] = useState("");
|
const [idError, setIdError] = useState("");
|
||||||
|
|
||||||
// 判断是否使用 TOML 格式
|
// 判断是否使用 TOML 格式(向后兼容,后续可扩展为格式切换按钮)
|
||||||
const useToml = appId === "codex";
|
const useToml = useTomlFormat;
|
||||||
|
|
||||||
const wizardInitialSpec = useMemo(() => {
|
const wizardInitialSpec = useMemo(() => {
|
||||||
const fallback = initialData?.server;
|
const fallback = initialData?.server;
|
||||||
@@ -333,12 +362,8 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
id: trimmedId,
|
id: trimmedId,
|
||||||
name: finalName,
|
name: finalName,
|
||||||
server: serverSpec,
|
server: serverSpec,
|
||||||
// 确保 apps 字段始终存在(v3.7.0 新架构必需)
|
// 使用表单中的启用状态(v3.7.0 完整重构)
|
||||||
apps: initialData?.apps || {
|
apps: enabledApps,
|
||||||
claude: false,
|
|
||||||
codex: false,
|
|
||||||
gemini: false,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const descriptionTrimmed = formDescription.trim();
|
const descriptionTrimmed = formDescription.trim();
|
||||||
@@ -387,11 +412,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getFormTitle = () => {
|
const getFormTitle = () => {
|
||||||
if (appId === "claude") {
|
return isEditing ? t("mcp.editServer") : t("mcp.addServer");
|
||||||
return isEditing ? t("mcp.editClaudeServer") : t("mcp.addClaudeServer");
|
|
||||||
} else {
|
|
||||||
return isEditing ? t("mcp.editCodexServer") : t("mcp.addCodexServer");
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -477,6 +498,62 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 启用到哪些应用(v3.7.0 新增) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
{t("mcp.form.enabledApps")}
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enable-claude"
|
||||||
|
checked={enabledApps.claude}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
setEnabledApps({ ...enabledApps, claude: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="enable-claude"
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.claude")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enable-codex"
|
||||||
|
checked={enabledApps.codex}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
setEnabledApps({ ...enabledApps, codex: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="enable-codex"
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.codex")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="enable-gemini"
|
||||||
|
checked={enabledApps.gemini}
|
||||||
|
onCheckedChange={(checked: boolean) =>
|
||||||
|
setEnabledApps({ ...enabledApps, gemini: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="enable-gemini"
|
||||||
|
className="text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none"
|
||||||
|
>
|
||||||
|
{t("mcp.unifiedPanel.apps.gemini")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 可折叠的附加信息按钮 */}
|
{/* 可折叠的附加信息按钮 */}
|
||||||
<div>
|
<div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -191,12 +191,13 @@ const UnifiedMcpPanel: React.FC<UnifiedMcpPanelProps> = ({
|
|||||||
{/* Form Modal */}
|
{/* Form Modal */}
|
||||||
{isFormOpen && (
|
{isFormOpen && (
|
||||||
<McpFormModal
|
<McpFormModal
|
||||||
appId="claude" // Default to claude for unified panel
|
|
||||||
editingId={editingId || undefined}
|
editingId={editingId || undefined}
|
||||||
initialData={
|
initialData={
|
||||||
editingId && serversMap ? serversMap[editingId] : undefined
|
editingId && serversMap ? serversMap[editingId] : undefined
|
||||||
}
|
}
|
||||||
existingIds={serversMap ? Object.keys(serversMap) : []}
|
existingIds={serversMap ? Object.keys(serversMap) : []}
|
||||||
|
defaultFormat="json"
|
||||||
|
defaultEnabledApps={["claude"]} // 默认启用 Claude
|
||||||
onSave={async () => {
|
onSave={async () => {
|
||||||
setIsFormOpen(false);
|
setIsFormOpen(false);
|
||||||
setEditingId(null);
|
setEditingId(null);
|
||||||
|
|||||||
@@ -478,6 +478,8 @@
|
|||||||
"titlePlaceholder": "my-mcp-server",
|
"titlePlaceholder": "my-mcp-server",
|
||||||
"name": "Display Name",
|
"name": "Display Name",
|
||||||
"namePlaceholder": "e.g. @modelcontextprotocol/server-time",
|
"namePlaceholder": "e.g. @modelcontextprotocol/server-time",
|
||||||
|
"enabledApps": "Enable to Apps",
|
||||||
|
"noAppsWarning": "At least one app must be selected",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"descriptionPlaceholder": "Optional description",
|
"descriptionPlaceholder": "Optional description",
|
||||||
"tags": "Tags (comma separated)",
|
"tags": "Tags (comma separated)",
|
||||||
|
|||||||
@@ -478,6 +478,8 @@
|
|||||||
"titlePlaceholder": "my-mcp-server",
|
"titlePlaceholder": "my-mcp-server",
|
||||||
"name": "显示名称",
|
"name": "显示名称",
|
||||||
"namePlaceholder": "例如 @modelcontextprotocol/server-time",
|
"namePlaceholder": "例如 @modelcontextprotocol/server-time",
|
||||||
|
"enabledApps": "启用到应用",
|
||||||
|
"noAppsWarning": "至少选择一个应用",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"descriptionPlaceholder": "可选的描述信息",
|
"descriptionPlaceholder": "可选的描述信息",
|
||||||
"tags": "标签(逗号分隔)",
|
"tags": "标签(逗号分隔)",
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ import McpFormModal from "@/components/mcp/McpFormModal";
|
|||||||
|
|
||||||
const toastErrorMock = vi.hoisted(() => vi.fn());
|
const toastErrorMock = vi.hoisted(() => vi.fn());
|
||||||
const toastSuccessMock = vi.hoisted(() => vi.fn());
|
const toastSuccessMock = vi.hoisted(() => vi.fn());
|
||||||
const getConfigMock = vi.hoisted(() => vi.fn().mockResolvedValue({ servers: {} }));
|
const upsertMock = vi.hoisted(() => {
|
||||||
|
const fn = vi.fn();
|
||||||
|
fn.mockResolvedValue(undefined);
|
||||||
|
return fn;
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
vi.mock("sonner", () => ({
|
||||||
toast: {
|
toast: {
|
||||||
@@ -65,6 +69,18 @@ vi.mock("@/components/ui/textarea", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/ui/checkbox", () => ({
|
||||||
|
Checkbox: ({ id, checked, onCheckedChange, ...rest }: any) => (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id={id}
|
||||||
|
checked={checked ?? false}
|
||||||
|
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/ui/dialog", () => ({
|
vi.mock("@/components/ui/dialog", () => ({
|
||||||
Dialog: ({ children }: any) => <div>{children}</div>,
|
Dialog: ({ children }: any) => <div>{children}</div>,
|
||||||
DialogContent: ({ children }: any) => <div>{children}</div>,
|
DialogContent: ({ children }: any) => <div>{children}</div>,
|
||||||
@@ -91,40 +107,44 @@ vi.mock("@/components/mcp/McpWizardModal", () => ({
|
|||||||
) : null,
|
) : null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/lib/api", async () => {
|
vi.mock("@/hooks/useMcp", async () => {
|
||||||
const actual = await vi.importActual<typeof import("@/lib/api")>("@/lib/api");
|
const actual = await vi.importActual<typeof import("@/hooks/useMcp")>(
|
||||||
|
"@/hooks/useMcp",
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
mcpApi: {
|
useUpsertMcpServer: () => ({
|
||||||
...actual.mcpApi,
|
mutateAsync: (...args: unknown[]) => upsertMock(...args),
|
||||||
getConfig: (...args: unknown[]) => getConfigMock(...args),
|
}),
|
||||||
},
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("McpFormModal", () => {
|
describe("McpFormModal", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
toastErrorMock.mockReset();
|
toastErrorMock.mockClear();
|
||||||
toastSuccessMock.mockReset();
|
toastSuccessMock.mockClear();
|
||||||
getConfigMock.mockReset();
|
upsertMock.mockClear();
|
||||||
getConfigMock.mockResolvedValue({ servers: {} });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>) => {
|
const renderForm = (
|
||||||
const { onSave: overrideOnSave, onClose: overrideOnClose, ...rest } = props ?? {};
|
props?: Partial<React.ComponentProps<typeof McpFormModal>>,
|
||||||
const onSave = overrideOnSave ?? vi.fn().mockResolvedValue(undefined);
|
) => {
|
||||||
const onClose = overrideOnClose ?? vi.fn();
|
const { onSave: overrideOnSave, onClose: overrideOnClose, ...rest } =
|
||||||
render(
|
props ?? {};
|
||||||
<McpFormModal
|
const onSave = overrideOnSave ?? vi.fn().mockResolvedValue(undefined);
|
||||||
appId="claude"
|
const onClose = overrideOnClose ?? vi.fn();
|
||||||
onSave={onSave}
|
render(
|
||||||
onClose={onClose}
|
<McpFormModal
|
||||||
existingIds={[]}
|
onSave={onSave}
|
||||||
|
onClose={onClose}
|
||||||
|
existingIds={[]}
|
||||||
|
defaultFormat="json"
|
||||||
|
defaultEnabledApps={["claude"]}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
return { onSave, onClose };
|
return { onSave, onClose };
|
||||||
};
|
};
|
||||||
|
|
||||||
it("应用预设后填充 ID 与配置内容", async () => {
|
it("应用预设后填充 ID 与配置内容", async () => {
|
||||||
renderForm();
|
renderForm();
|
||||||
@@ -145,30 +165,7 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
|
|||||||
expect(configTextarea.value).toBe('{\n "type": "stdio",\n "command": "preset-cmd"\n}');
|
expect(configTextarea.value).toBe('{\n "type": "stdio",\n "command": "preset-cmd"\n}');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("在同步另一侧存在冲突时展示警告", async () => {
|
it("提交时清洗字段并调用 upsert 与 onSave", async () => {
|
||||||
getConfigMock.mockResolvedValue({ servers: { conflict: {} } });
|
|
||||||
renderForm();
|
|
||||||
|
|
||||||
const idInput = screen.getByPlaceholderText(
|
|
||||||
"mcp.form.titlePlaceholder",
|
|
||||||
) as HTMLInputElement;
|
|
||||||
fireEvent.change(idInput, { target: { value: "conflict" } });
|
|
||||||
|
|
||||||
await waitFor(() => expect(getConfigMock).toHaveBeenCalled());
|
|
||||||
|
|
||||||
const checkbox = screen.getByLabelText(
|
|
||||||
'mcp.form.syncOtherSide:{"target":"apps.codex"}',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
fireEvent.click(checkbox);
|
|
||||||
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(
|
|
||||||
screen.getByText('mcp.form.willOverwriteWarning:{"target":"apps.codex"}'),
|
|
||||||
).toBeInTheDocument(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("提交时清洗字段并调用 onSave", async () => {
|
|
||||||
const { onSave } = renderForm();
|
const { onSave } = renderForm();
|
||||||
|
|
||||||
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
||||||
@@ -197,17 +194,11 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
|
|||||||
target: { value: '{"type":"stdio","command":"run"}' },
|
target: { value: '{"type":"stdio","command":"run"}' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const syncCheckbox = screen.getByLabelText(
|
|
||||||
'mcp.form.syncOtherSide:{"target":"apps.codex"}',
|
|
||||||
) as HTMLInputElement;
|
|
||||||
fireEvent.click(syncCheckbox);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("common.add"));
|
fireEvent.click(screen.getByText("common.add"));
|
||||||
|
|
||||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
||||||
const [id, payload, options] = (onSave as any).mock.calls[0];
|
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
||||||
expect(id).toBe("my-server");
|
expect(entry).toMatchObject({
|
||||||
expect(payload).toMatchObject({
|
|
||||||
id: "my-server",
|
id: "my-server",
|
||||||
name: "Friendly",
|
name: "Friendly",
|
||||||
description: "Description",
|
description: "Description",
|
||||||
@@ -218,8 +209,14 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
|
|||||||
type: "stdio",
|
type: "stdio",
|
||||||
command: "run",
|
command: "run",
|
||||||
},
|
},
|
||||||
|
apps: {
|
||||||
|
claude: true,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(options).toEqual({ syncOtherSide: true });
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSave).toHaveBeenCalledWith();
|
||||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -236,7 +233,7 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
|
|||||||
fireEvent.click(screen.getByText("common.add"));
|
fireEvent.click(screen.getByText("common.add"));
|
||||||
|
|
||||||
await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());
|
await waitFor(() => expect(toastErrorMock).toHaveBeenCalled());
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(upsertMock).not.toHaveBeenCalled();
|
||||||
const [message] = toastErrorMock.mock.calls.at(-1) ?? [];
|
const [message] = toastErrorMock.mock.calls.at(-1) ?? [];
|
||||||
expect(message).toBe("mcp.error.jsonInvalid");
|
expect(message).toBe("mcp.error.jsonInvalid");
|
||||||
});
|
});
|
||||||
@@ -262,7 +259,7 @@ const renderForm = (props?: Partial<React.ComponentProps<typeof McpFormModal>>)
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("TOML 模式下自动提取 ID 并成功保存", async () => {
|
it("TOML 模式下自动提取 ID 并成功保存", async () => {
|
||||||
const { onSave } = renderForm({ appId: "codex" });
|
const { onSave } = renderForm({ defaultFormat: "toml" });
|
||||||
|
|
||||||
const configTextarea = screen.getByPlaceholderText(
|
const configTextarea = screen.getByPlaceholderText(
|
||||||
"mcp.form.tomlPlaceholder",
|
"mcp.form.tomlPlaceholder",
|
||||||
@@ -282,15 +279,17 @@ command = "run"
|
|||||||
|
|
||||||
fireEvent.click(screen.getByText("common.add"));
|
fireEvent.click(screen.getByText("common.add"));
|
||||||
|
|
||||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
||||||
const [id, payload] = (onSave as any).mock.calls[0];
|
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
||||||
expect(id).toBe("demo");
|
expect(entry.id).toBe("demo");
|
||||||
expect(payload.server).toEqual({ type: "stdio", command: "run" });
|
expect(entry.server).toEqual({ type: "stdio", command: "run" });
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSave).toHaveBeenCalledWith();
|
||||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("TOML 模式下缺少命令时展示错误提示并阻止提交", async () => {
|
it("TOML 模式下缺少命令时展示错误提示并阻止提交", async () => {
|
||||||
const { onSave } = renderForm({ appId: "codex" });
|
const { onSave } = renderForm({ defaultFormat: "toml" });
|
||||||
|
|
||||||
const configTextarea = screen.getByPlaceholderText(
|
const configTextarea = screen.getByPlaceholderText(
|
||||||
"mcp.form.tomlPlaceholder",
|
"mcp.form.tomlPlaceholder",
|
||||||
@@ -304,11 +303,11 @@ type = "stdio"
|
|||||||
fireEvent.click(screen.getByText("common.add"));
|
fireEvent.click(screen.getByText("common.add"));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(toastErrorMock).toHaveBeenCalledWith("mcp.error.idRequired", {
|
expect(toastErrorMock).toHaveBeenCalledWith("mcp.error.tomlInvalid", {
|
||||||
duration: 3000,
|
duration: 3000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(onSave).not.toHaveBeenCalled();
|
expect(upsertMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("编辑模式下保持 ID 并更新配置", async () => {
|
it("编辑模式下保持 ID 并更新配置", async () => {
|
||||||
@@ -321,7 +320,6 @@ type = "stdio"
|
|||||||
} as McpServer;
|
} as McpServer;
|
||||||
|
|
||||||
const { onSave } = renderForm({
|
const { onSave } = renderForm({
|
||||||
appId: "claude",
|
|
||||||
editingId: "existing",
|
editingId: "existing",
|
||||||
initialData,
|
initialData,
|
||||||
});
|
});
|
||||||
@@ -343,12 +341,48 @@ type = "stdio"
|
|||||||
|
|
||||||
fireEvent.click(screen.getByText("common.save"));
|
fireEvent.click(screen.getByText("common.save"));
|
||||||
|
|
||||||
await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
||||||
const [id, entry, options] = (onSave as any).mock.calls[0];
|
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
||||||
expect(id).toBe("existing");
|
expect(entry.id).toBe("existing");
|
||||||
expect(entry.server.command).toBe("updated");
|
expect(entry.server.command).toBe("updated");
|
||||||
expect(entry.enabled).toBe(true);
|
expect(entry.enabled).toBe(true);
|
||||||
expect(options).toEqual({ syncOtherSide: false });
|
expect(entry.apps).toEqual({
|
||||||
|
claude: true,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
});
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onSave).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("允许未选择任何应用保存配置,并保持 apps 全 false", async () => {
|
||||||
|
const { onSave } = renderForm();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), {
|
||||||
|
target: { value: "no-apps" },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), {
|
||||||
|
target: { value: '{"type":"stdio","command":"run"}' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const claudeCheckbox = screen.getByLabelText(
|
||||||
|
"mcp.unifiedPanel.apps.claude",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
expect(claudeCheckbox.checked).toBe(true);
|
||||||
|
fireEvent.click(claudeCheckbox);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("common.add"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
||||||
|
const [entry] = upsertMock.mock.calls.at(-1) ?? [];
|
||||||
|
expect(entry.id).toBe("no-apps");
|
||||||
|
expect(entry.apps).toEqual({
|
||||||
|
claude: false,
|
||||||
|
codex: false,
|
||||||
|
gemini: false,
|
||||||
|
});
|
||||||
|
expect(onSave).toHaveBeenCalledTimes(1);
|
||||||
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("保存失败时展示翻译后的错误并恢复按钮", async () => {
|
it("保存失败时展示翻译后的错误并恢复按钮", async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user