diff --git a/CHANGELOG.md b/CHANGELOG.md index eedf360813..9e98eeab64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - **Provider-detail god-component decomposition — Phase 0** ([#3501]): introduced `ProviderDetailPageClient.tsx` and reduced `providers/[id]/page.tsx` to a thin 9-line route wrapper (was 12,882 LOC), following the repo's `*PageClient` convention. Added the first-ever smoke render test for the page (Hard Rule #8) as the safety net every later extraction phase is diffed against. Behavior unchanged; the `check-file-size` ratchet now tracks the extracted client. Foundation for Phases 1–6 (strangler-fig). Thanks @oyi77 for the parallel modularization effort in #3627. - **Provider-detail god-component decomposition — Phase 1a** ([#3501]): extracted the three self-contained auth-import modal clusters (Codex/Claude/Gemini `Import*AuthModal` + `Apply*AuthModal` + their co-located helpers, ~2,160 LOC) into `providers/[id]/components/modals/`. `ProviderDetailPageClient.tsx` drops 12,882 → 10,719 LOC. Behavior unchanged (smoke test green; clusters had clean `{ onClose, onSuccess }` / inline-prop interfaces). Co-authored with @oyi77. +- **Provider-detail god-component decomposition — Phase 1b** ([#3501]): extracted `EditCompatibleNodeModal` (+ its node/props types) into `providers/[id]/components/modals/`, and moved the shared `CC_COMPATIBLE_DEFAULT_CHAT_PATH` constant into a leaf `providerDetailConstants.ts` so the page client and the modal can both import it without a circular dependency. Also removed two dangling section comments left by Phase 1a. `ProviderDetailPageClient.tsx`: 10,719 → 10,435 LOC. Behavior unchanged (smoke test + a new standalone modal render test green; `check:cycles` clean). Co-authored with @oyi77. --- diff --git a/file-size-baseline.json b/file-size-baseline.json index 02310f7666..dbf3e74a00 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -48,7 +48,7 @@ "src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": 2570, "src/app/(dashboard)/dashboard/health/page.tsx": 1091, "src/app/(dashboard)/dashboard/playground/components/tabs/ApiTab.tsx": 847, - "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 10720, + "src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": 10435, "src/app/(dashboard)/dashboard/providers/components/onboarding/ProviderOnboardingWizard.tsx": 906, "src/app/(dashboard)/dashboard/providers/page.tsx": 1925, "src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": 1127, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx index 315ec228f1..e44a1d5b73 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx @@ -99,6 +99,8 @@ import { ImportCodexAuthModal, ApplyCodexAuthModal } from "./components/modals/I import { ImportClaudeAuthModal, ApplyClaudeAuthModal } from "./components/modals/ImportClaudeAuthModal"; import { ImportGeminiAuthModal, ApplyGeminiAuthModal } from "./components/modals/ImportGeminiAuthModal"; +import EditCompatibleNodeModal from "./components/modals/EditCompatibleNodeModal"; +import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "./providerDetailConstants"; type CompatByProtocolMap = Partial< Record< ModelCompatProtocolKey, @@ -905,27 +907,6 @@ interface EditConnectionModalProps { onSave: (data: unknown) => Promise; onClose: () => void; } - -interface EditCompatibleNodeModalNode { - id?: string; - name?: string; - prefix?: string; - apiType?: string; - baseUrl?: string; - chatPath?: string; - modelsPath?: string; -} - -interface EditCompatibleNodeModalProps { - isOpen: boolean; - node: EditCompatibleNodeModalNode | null; - onSave: (data: unknown) => Promise; - onClose: () => void; - isAnthropic?: boolean; - isCcCompatible?: boolean; -} - -const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true"; const CODEX_REASONING_STRENGTH_OPTIONS = [ { value: "none", label: "None" }, { value: "low", label: "Low" }, @@ -9347,8 +9328,6 @@ function AddApiKeyModal({ ); } -// ──── ImportCodexAuthModal ──────────────────────────────────────────────────── - function normalizeAndValidateHttpBaseUrl(rawValue, fallbackUrl) { const value = (typeof rawValue === "string" ? rawValue.trim() : "") || fallbackUrl; try { @@ -10453,267 +10432,3 @@ function EditConnectionModal({ isOpen, connection, onSave, onClose }: EditConnec ); } - -function EditCompatibleNodeModal({ - isOpen, - node, - onSave, - onClose, - isAnthropic, - isCcCompatible, -}: EditCompatibleNodeModalProps) { - const t = useTranslations("providers"); - const [formData, setFormData] = useState({ - name: "", - prefix: "", - apiType: "chat", - baseUrl: "https://api.openai.com/v1", - chatPath: "", - modelsPath: "", - }); - const [saving, setSaving] = useState(false); - const [checkKey, setCheckKey] = useState(""); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState(null); - const [showAdvanced, setShowAdvanced] = useState(false); - - useEffect(() => { - if (node) { - setFormData({ - name: node.name || "", - prefix: node.prefix || "", - apiType: node.apiType || "chat", - baseUrl: - node.baseUrl || - (isCcCompatible - ? "https://api.anthropic.com" - : isAnthropic - ? "https://api.anthropic.com/v1" - : "https://api.openai.com/v1"), - chatPath: node.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""), - modelsPath: isCcCompatible ? "" : node.modelsPath || "", - }); - setShowAdvanced( - !!( - node.chatPath || - (!isCcCompatible && node.modelsPath) || - (isCcCompatible && !node.chatPath) - ) - ); - } - }, [node, isAnthropic, isCcCompatible]); - - const apiTypeOptions = [ - { value: "chat", label: t("chatCompletions") }, - { value: "responses", label: t("responsesApi") }, - { value: "embeddings", label: t("embeddings") }, - { value: "audio-transcriptions", label: t("audioTranscriptions") }, - { value: "audio-speech", label: t("audioSpeech") }, - { value: "images-generations", label: t("imagesGenerations") }, - ]; - - const handleSubmit = async () => { - if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; - setSaving(true); - try { - const payload: any = { - name: formData.name, - prefix: formData.prefix, - baseUrl: formData.baseUrl, - chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""), - modelsPath: isCcCompatible ? "" : formData.modelsPath, - }; - if (!isAnthropic) { - payload.apiType = formData.apiType; - } - await onSave(payload); - } finally { - setSaving(false); - } - }; - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/provider-nodes/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - baseUrl: formData.baseUrl, - apiKey: checkKey, - type: isAnthropic ? "anthropic-compatible" : "openai-compatible", - compatMode: isCcCompatible ? "cc" : undefined, - chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""), - modelsPath: isCcCompatible ? "" : formData.modelsPath, - }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - if (!node) return null; - - return ( - -
- {isCcCompatible && ( -
-
- - warning - -

{t("ccCompatibleValidationHint")}

-
-
- )} - setFormData({ ...formData, name: e.target.value })} - placeholder={ - isCcCompatible - ? t("ccCompatibleNamePlaceholder") - : t("compatibleProdPlaceholder", { - type: isAnthropic ? t("anthropic") : t("openai"), - }) - } - hint={isCcCompatible ? t("ccCompatibleNameHint") : t("nameHint")} - /> - setFormData({ ...formData, prefix: e.target.value })} - placeholder={ - isCcCompatible - ? t("ccCompatiblePrefixPlaceholder") - : isAnthropic - ? t("anthropicPrefixPlaceholder") - : t("openaiPrefixPlaceholder") - } - hint={isCcCompatible ? t("ccCompatiblePrefixHint") : t("prefixHint")} - /> - {!isAnthropic && ( - setFormData({ ...formData, baseUrl: e.target.value })} - placeholder={ - isCcCompatible - ? t("ccCompatibleBaseUrlPlaceholder") - : isAnthropic - ? t("anthropicBaseUrlPlaceholder") - : t("openaiBaseUrlPlaceholder") - } - hint={ - isCcCompatible - ? t("ccCompatibleBaseUrlHint") - : t("compatibleBaseUrlHint", { - type: isAnthropic ? t("anthropic") : t("openai"), - }) - } - /> - - {showAdvanced && ( -
- setFormData({ ...formData, chatPath: e.target.value })} - placeholder={ - isCcCompatible - ? CC_COMPATIBLE_DEFAULT_CHAT_PATH - : isAnthropic - ? "/messages" - : t("chatPathPlaceholder") - } - hint={isCcCompatible ? t("ccCompatibleChatPathHint") : t("chatPathHint")} - /> - {!isCcCompatible && ( - setFormData({ ...formData, modelsPath: e.target.value })} - placeholder={t("modelsPathPlaceholder")} - hint={t("modelsPathHint")} - /> - )} -
- )} -
- setCheckKey(e.target.value)} - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} - - )} -
- - -
-
-
- ); -} - -// ──── ImportGeminiAuthModal ──────────────────────────────────────────────────── - diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx new file mode 100644 index 0000000000..306a0caccd --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditCompatibleNodeModal.tsx @@ -0,0 +1,284 @@ +"use client"; +import { useState, useEffect } from "react"; +import { useTranslations } from "next-intl"; +import { Button, Badge, Input, Modal, Select } from "@/shared/components"; +import { CC_COMPATIBLE_DEFAULT_CHAT_PATH } from "../../providerDetailConstants"; +interface EditCompatibleNodeModalNode { + id?: string; + name?: string; + prefix?: string; + apiType?: string; + baseUrl?: string; + chatPath?: string; + modelsPath?: string; +} + +interface EditCompatibleNodeModalProps { + isOpen: boolean; + node: EditCompatibleNodeModalNode | null; + onSave: (data: unknown) => Promise; + onClose: () => void; + isAnthropic?: boolean; + isCcCompatible?: boolean; +} + +export default function EditCompatibleNodeModal({ + isOpen, + node, + onSave, + onClose, + isAnthropic, + isCcCompatible, +}: EditCompatibleNodeModalProps) { + const t = useTranslations("providers"); + const [formData, setFormData] = useState({ + name: "", + prefix: "", + apiType: "chat", + baseUrl: "https://api.openai.com/v1", + chatPath: "", + modelsPath: "", + }); + const [saving, setSaving] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(false); + + useEffect(() => { + if (node) { + setFormData({ + name: node.name || "", + prefix: node.prefix || "", + apiType: node.apiType || "chat", + baseUrl: + node.baseUrl || + (isCcCompatible + ? "https://api.anthropic.com" + : isAnthropic + ? "https://api.anthropic.com/v1" + : "https://api.openai.com/v1"), + chatPath: node.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""), + modelsPath: isCcCompatible ? "" : node.modelsPath || "", + }); + setShowAdvanced( + !!( + node.chatPath || + (!isCcCompatible && node.modelsPath) || + (isCcCompatible && !node.chatPath) + ) + ); + } + }, [node, isAnthropic, isCcCompatible]); + + const apiTypeOptions = [ + { value: "chat", label: t("chatCompletions") }, + { value: "responses", label: t("responsesApi") }, + { value: "embeddings", label: t("embeddings") }, + { value: "audio-transcriptions", label: t("audioTranscriptions") }, + { value: "audio-speech", label: t("audioSpeech") }, + { value: "images-generations", label: t("imagesGenerations") }, + ]; + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + setSaving(true); + try { + const payload: any = { + name: formData.name, + prefix: formData.prefix, + baseUrl: formData.baseUrl, + chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""), + modelsPath: isCcCompatible ? "" : formData.modelsPath, + }; + if (!isAnthropic) { + payload.apiType = formData.apiType; + } + await onSave(payload); + } finally { + setSaving(false); + } + }; + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/provider-nodes/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + baseUrl: formData.baseUrl, + apiKey: checkKey, + type: isAnthropic ? "anthropic-compatible" : "openai-compatible", + compatMode: isCcCompatible ? "cc" : undefined, + chatPath: formData.chatPath || (isCcCompatible ? CC_COMPATIBLE_DEFAULT_CHAT_PATH : ""), + modelsPath: isCcCompatible ? "" : formData.modelsPath, + }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + if (!node) return null; + + return ( + +
+ {isCcCompatible && ( +
+
+ + warning + +

{t("ccCompatibleValidationHint")}

+
+
+ )} + setFormData({ ...formData, name: e.target.value })} + placeholder={ + isCcCompatible + ? t("ccCompatibleNamePlaceholder") + : t("compatibleProdPlaceholder", { + type: isAnthropic ? t("anthropic") : t("openai"), + }) + } + hint={isCcCompatible ? t("ccCompatibleNameHint") : t("nameHint")} + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder={ + isCcCompatible + ? t("ccCompatiblePrefixPlaceholder") + : isAnthropic + ? t("anthropicPrefixPlaceholder") + : t("openaiPrefixPlaceholder") + } + hint={isCcCompatible ? t("ccCompatiblePrefixHint") : t("prefixHint")} + /> + {!isAnthropic && ( + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder={ + isCcCompatible + ? t("ccCompatibleBaseUrlPlaceholder") + : isAnthropic + ? t("anthropicBaseUrlPlaceholder") + : t("openaiBaseUrlPlaceholder") + } + hint={ + isCcCompatible + ? t("ccCompatibleBaseUrlHint") + : t("compatibleBaseUrlHint", { + type: isAnthropic ? t("anthropic") : t("openai"), + }) + } + /> + + {showAdvanced && ( +
+ setFormData({ ...formData, chatPath: e.target.value })} + placeholder={ + isCcCompatible + ? CC_COMPATIBLE_DEFAULT_CHAT_PATH + : isAnthropic + ? "/messages" + : t("chatPathPlaceholder") + } + hint={isCcCompatible ? t("ccCompatibleChatPathHint") : t("chatPathHint")} + /> + {!isCcCompatible && ( + setFormData({ ...formData, modelsPath: e.target.value })} + placeholder={t("modelsPathPlaceholder")} + hint={t("modelsPathHint")} + /> + )} +
+ )} +
+ setCheckKey(e.target.value)} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? t("valid") : t("invalid")} + + )} +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/editCompatibleNodeModal.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/editCompatibleNodeModal.test.tsx new file mode 100644 index 0000000000..678820ddff --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/__tests__/editCompatibleNodeModal.test.tsx @@ -0,0 +1,62 @@ +// @vitest-environment jsdom +// +// Phase 1b regression test for Issue #3501. EditCompatibleNodeModal was extracted +// out of the god-component into a standalone file (its node/props types co-moved, +// the shared CC_COMPATIBLE_DEFAULT_CHAT_PATH constant pulled into a leaf module to +// keep the import graph acyclic). Proves it mounts in isolation (Hard Rule #8). +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import EditCompatibleNodeModal from "../EditCompatibleNodeModal"; + +vi.mock("next-intl", () => ({ + useTranslations: (ns?: string) => (k: string) => (ns ? `${ns}.${k}` : k), +})); + +const cleanups: Array<() => void> = []; + +function renderModal(node: React.ReactElement) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => root.render(node)); + cleanups.push(() => { + act(() => root.unmount()); + container.remove(); + }); + return container; +} + +describe("EditCompatibleNodeModal (Phase 1b extraction)", () => { + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + while (cleanups.length) cleanups.pop()?.(); + document.body.innerHTML = ""; + }); + + it("mounts standalone when open", () => { + const c = renderModal( + {}} + onClose={() => {}} + isCcCompatible + /> + ); + expect(c.querySelector("*")).not.toBeNull(); + }); + + it("renders nothing harmful when closed", () => { + expect(() => + renderModal( + {}} onClose={() => {}} /> + ) + ).not.toThrow(); + }); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerDetailConstants.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerDetailConstants.ts new file mode 100644 index 0000000000..0c7cbb5327 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerDetailConstants.ts @@ -0,0 +1,6 @@ +// Shared constants for the provider-detail page and its extracted modals +// (Issue #3501 strangler-fig decomposition). Kept in a leaf module so both the +// page client and the colocated modals can import without a circular dependency. + +/** Default chat path for Claude-Code-compatible (Anthropic-shaped) custom nodes. */ +export const CC_COMPATIBLE_DEFAULT_CHAT_PATH = "/v1/messages?beta=true";