mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 13:22:11 +03:00
#3501 Phase 1b: extract EditCompatibleNodeModal (cycle-safe via leaf constants module). Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
1d147b61da
commit
ee53ced36b
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void | unknown>;
|
||||
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<void>;
|
||||
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
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title={
|
||||
isCcCompatible
|
||||
? t("ccCompatibleDetailsTitle")
|
||||
: t("editCompatibleTitle", { type: isAnthropic ? t("anthropic") : t("openai") })
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isCcCompatible && (
|
||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
|
||||
warning
|
||||
</span>
|
||||
<p>{t("ccCompatibleValidationHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label={t("nameLabel")}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={
|
||||
isCcCompatible
|
||||
? t("ccCompatibleNamePlaceholder")
|
||||
: t("compatibleProdPlaceholder", {
|
||||
type: isAnthropic ? t("anthropic") : t("openai"),
|
||||
})
|
||||
}
|
||||
hint={isCcCompatible ? t("ccCompatibleNameHint") : t("nameHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("prefixLabel")}
|
||||
value={formData.prefix}
|
||||
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
||||
placeholder={
|
||||
isCcCompatible
|
||||
? t("ccCompatiblePrefixPlaceholder")
|
||||
: isAnthropic
|
||||
? t("anthropicPrefixPlaceholder")
|
||||
: t("openaiPrefixPlaceholder")
|
||||
}
|
||||
hint={isCcCompatible ? t("ccCompatiblePrefixHint") : t("prefixHint")}
|
||||
/>
|
||||
{!isAnthropic && (
|
||||
<Select
|
||||
label={t("apiTypeLabel")}
|
||||
options={apiTypeOptions}
|
||||
value={formData.apiType}
|
||||
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label={t("baseUrlLabel")}
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => 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"),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
aria-expanded={showAdvanced}
|
||||
aria-controls="advanced-settings"
|
||||
>
|
||||
<span
|
||||
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
{t("advancedSettings")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div id="advanced-settings" className="flex flex-col gap-3 pl-2 border-l-2 border-border">
|
||||
<Input
|
||||
label={t("chatPathLabel")}
|
||||
value={formData.chatPath}
|
||||
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
|
||||
placeholder={
|
||||
isCcCompatible
|
||||
? CC_COMPATIBLE_DEFAULT_CHAT_PATH
|
||||
: isAnthropic
|
||||
? "/messages"
|
||||
: t("chatPathPlaceholder")
|
||||
}
|
||||
hint={isCcCompatible ? t("ccCompatibleChatPathHint") : t("chatPathHint")}
|
||||
/>
|
||||
{!isCcCompatible && (
|
||||
<Input
|
||||
label={t("modelsPathLabel")}
|
||||
value={formData.modelsPath}
|
||||
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
|
||||
placeholder={t("modelsPathPlaceholder")}
|
||||
hint={t("modelsPathHint")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label={t("apiKeyForCheck")}
|
||||
type="password"
|
||||
value={checkKey}
|
||||
onChange={(e) => setCheckKey(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={!checkKey || validating || !formData.baseUrl.trim()}
|
||||
variant="secondary"
|
||||
>
|
||||
{validating ? t("checking") : t("check")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? t("valid") : t("invalid")}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
fullWidth
|
||||
disabled={
|
||||
!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || saving
|
||||
}
|
||||
>
|
||||
{saving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ──── ImportGeminiAuthModal ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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<void>;
|
||||
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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
title={
|
||||
isCcCompatible
|
||||
? t("ccCompatibleDetailsTitle")
|
||||
: t("editCompatibleTitle", { type: isAnthropic ? t("anthropic") : t("openai") })
|
||||
}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{isCcCompatible && (
|
||||
<div className="rounded-lg border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-sm text-text-muted">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[18px] text-amber-500">
|
||||
warning
|
||||
</span>
|
||||
<p>{t("ccCompatibleValidationHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Input
|
||||
label={t("nameLabel")}
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={
|
||||
isCcCompatible
|
||||
? t("ccCompatibleNamePlaceholder")
|
||||
: t("compatibleProdPlaceholder", {
|
||||
type: isAnthropic ? t("anthropic") : t("openai"),
|
||||
})
|
||||
}
|
||||
hint={isCcCompatible ? t("ccCompatibleNameHint") : t("nameHint")}
|
||||
/>
|
||||
<Input
|
||||
label={t("prefixLabel")}
|
||||
value={formData.prefix}
|
||||
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
||||
placeholder={
|
||||
isCcCompatible
|
||||
? t("ccCompatiblePrefixPlaceholder")
|
||||
: isAnthropic
|
||||
? t("anthropicPrefixPlaceholder")
|
||||
: t("openaiPrefixPlaceholder")
|
||||
}
|
||||
hint={isCcCompatible ? t("ccCompatiblePrefixHint") : t("prefixHint")}
|
||||
/>
|
||||
{!isAnthropic && (
|
||||
<Select
|
||||
label={t("apiTypeLabel")}
|
||||
options={apiTypeOptions}
|
||||
value={formData.apiType}
|
||||
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label={t("baseUrlLabel")}
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => 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"),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm text-text-muted hover:text-text-primary flex items-center gap-1"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
aria-expanded={showAdvanced}
|
||||
aria-controls="advanced-settings"
|
||||
>
|
||||
<span
|
||||
className={`transition-transform ${showAdvanced ? "rotate-90" : ""}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
▶
|
||||
</span>
|
||||
{t("advancedSettings")}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div id="advanced-settings" className="flex flex-col gap-3 pl-2 border-l-2 border-border">
|
||||
<Input
|
||||
label={t("chatPathLabel")}
|
||||
value={formData.chatPath}
|
||||
onChange={(e) => setFormData({ ...formData, chatPath: e.target.value })}
|
||||
placeholder={
|
||||
isCcCompatible
|
||||
? CC_COMPATIBLE_DEFAULT_CHAT_PATH
|
||||
: isAnthropic
|
||||
? "/messages"
|
||||
: t("chatPathPlaceholder")
|
||||
}
|
||||
hint={isCcCompatible ? t("ccCompatibleChatPathHint") : t("chatPathHint")}
|
||||
/>
|
||||
{!isCcCompatible && (
|
||||
<Input
|
||||
label={t("modelsPathLabel")}
|
||||
value={formData.modelsPath}
|
||||
onChange={(e) => setFormData({ ...formData, modelsPath: e.target.value })}
|
||||
placeholder={t("modelsPathPlaceholder")}
|
||||
hint={t("modelsPathHint")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label={t("apiKeyForCheck")}
|
||||
type="password"
|
||||
value={checkKey}
|
||||
onChange={(e) => setCheckKey(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button
|
||||
onClick={handleValidate}
|
||||
disabled={!checkKey || validating || !formData.baseUrl.trim()}
|
||||
variant="secondary"
|
||||
>
|
||||
{validating ? t("checking") : t("check")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? t("valid") : t("invalid")}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
fullWidth
|
||||
disabled={
|
||||
!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || saving
|
||||
}
|
||||
>
|
||||
{saving ? t("saving") : t("save")}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
<EditCompatibleNodeModal
|
||||
isOpen
|
||||
node={{ id: "n1", name: "Node", baseUrl: "https://x" }}
|
||||
onSave={async () => {}}
|
||||
onClose={() => {}}
|
||||
isCcCompatible
|
||||
/>
|
||||
);
|
||||
expect(c.querySelector("*")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing harmful when closed", () => {
|
||||
expect(() =>
|
||||
renderModal(
|
||||
<EditCompatibleNodeModal isOpen={false} node={null} onSave={async () => {}} onClose={() => {}} />
|
||||
)
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user