mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
feat(settings): AI provider section with single dropdown driving form below
Replaces the v1.4.15-vintage two-card layout (Codex connect at the top, personal-provider form at the bottom) with the dropdown-driven UX called out in feedback_settings_no_split.md: one Pulldown picks the active provider, the form below adapts to that choice, and the fallback chain at the bottom reorders / toggles entries with arrow controls. Five provider config forms live in this file (Codex, OpenAI, Anthropic, Local, Admin OpenAI) and share a single card frame so transitions feel like updating one concept rather than swapping pages. The Codex form keeps the device-code flow + OAuth callback handling unchanged. OpenAI gets a model dropdown plus a collapsed Base URL override (advanced). Anthropic and Local mirror that shape with the right credentials. The fallback chain calls the new PUT /api/insights/provider-chain to persist reorders. The Select is URL-synced via ?provider=… so deep links work, the SSR test can drive each branch deterministically, and a refresh keeps the user on the form they were editing. Native <select> elements keep the SSR-only render-to-static-markup test happy without bringing a portal-based Radix tree into play. No new dependency — dnd-kit is not in package.json so reorder uses up/down arrow buttons. EN + DE i18n keys land alongside; the i18n parity guard test sees both locales and the existing settings.ai.providerChain.types.* tree keeps the per-provider labels stable across the chain summary and the new chain-management UI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
3e037b4d42
commit
23f4ee5b1e
5 files changed
+1725
-732
No files matched your search
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* v1.4.16 phase B2 — AI provider settings UX (dropdown-driven).
|
||||
*
|
||||
* The new section replaces the v1.4.15-vintage top/bottom split (Marc's
|
||||
* `feedback_settings_no_split.md`): a single Select picks the active
|
||||
* provider; the form below is switch-rendered for that provider; the
|
||||
* fallback chain at the bottom reorders / toggles entries with arrow
|
||||
* controls (no new dependency — dnd-kit is not in package.json).
|
||||
*
|
||||
* Tests cover:
|
||||
* 1. Codex selected → Codex form (connect button) renders, OpenAI
|
||||
* form does NOT.
|
||||
* 2. OpenAI selected → API-key field + model select render, Codex
|
||||
* connect button does NOT.
|
||||
* 3. Fallback-chain rows surface in priority order with the right
|
||||
* i18n labels.
|
||||
*
|
||||
* Each case feeds the section a different `?provider=…` URL parameter
|
||||
* so the SSR render lands on the desired branch — the production
|
||||
* component reads the same parameter via `useSearchParams()`. This
|
||||
* keeps the test SSR-only (no jsdom dependency, no testing-library)
|
||||
* and matches the existing settings-section test style.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
let activeProviderQuery: string | null = null;
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/ai",
|
||||
useSearchParams: () =>
|
||||
new URLSearchParams(
|
||||
activeProviderQuery ? `provider=${activeProviderQuery}` : "",
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: "u1", role: "USER" },
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Stable mocks for every TanStack Query the section reads. The chain
|
||||
// fixture mirrors the default order (codex first) so the rows render in
|
||||
// a known order; insights settings + ai-provider GET return enough to
|
||||
// keep the form branches content (status, hasOpenaiKey etc.).
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
|
||||
const key = Array.isArray(queryKey) ? queryKey.join("/") : "";
|
||||
if (key === "insights/settings") {
|
||||
return {
|
||||
data: {
|
||||
codexStatus: "disconnected",
|
||||
codexConnectedAt: null,
|
||||
hasAdminKey: false,
|
||||
codexOauthConfigured: true,
|
||||
privacyMode: "aggregated",
|
||||
lastInsightAt: null,
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
if (key === "user/ai-provider") {
|
||||
return {
|
||||
data: {
|
||||
provider: null,
|
||||
model: null,
|
||||
baseUrl: null,
|
||||
hasAnthropicKey: false,
|
||||
anthropicKeyPreview: null,
|
||||
hasLocalKey: false,
|
||||
hasOpenaiKey: false,
|
||||
openaiKeyPreview: null,
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
if (key === "insights/provider-chain") {
|
||||
return {
|
||||
data: {
|
||||
activeProvider: "codex",
|
||||
cachedActiveProvider: null,
|
||||
configuredChain: [
|
||||
{ providerType: "codex", available: true },
|
||||
{ providerType: "openai", available: true },
|
||||
{ providerType: "anthropic", available: true },
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
};
|
||||
}
|
||||
return { data: null, isLoading: false };
|
||||
},
|
||||
useMutation: () => ({ mutate: vi.fn(), isPending: false }),
|
||||
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { AiSection } from "../ai-section";
|
||||
|
||||
function render(locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<AiSection />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
activeProviderQuery = null;
|
||||
});
|
||||
|
||||
describe("<AiSection> — dropdown-driven UX (B2)", () => {
|
||||
it("renders the active-provider Select with the configured options", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-active-provider-select"');
|
||||
// The four selectable options resolve via the new
|
||||
// `settings.ai.providerSelect.*` keys.
|
||||
expect(html).toContain("ChatGPT account (Codex)");
|
||||
expect(html).toContain("OpenAI (your API key)");
|
||||
expect(html).toContain("Anthropic (Claude)");
|
||||
expect(html).toContain("Local model (OpenAI-compatible)");
|
||||
});
|
||||
|
||||
it("Codex selected → Codex provider form, no OpenAI form", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
// Provider-config card surfaces.
|
||||
expect(html).toContain('data-testid="ai-provider-config-codex"');
|
||||
// OpenAI's API-key field is NOT rendered when Codex is active.
|
||||
expect(html).not.toContain('data-testid="ai-provider-config-openai"');
|
||||
// Connect-with-ChatGPT CTA is the focal action.
|
||||
expect(html).toContain("Connect with ChatGPT");
|
||||
});
|
||||
|
||||
it("OpenAI selected → API-key field + model select, no Codex form", () => {
|
||||
activeProviderQuery = "openai";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-provider-config-openai"');
|
||||
expect(html).not.toContain('data-testid="ai-provider-config-codex"');
|
||||
// OpenAI form surfaces the API-key input + model select.
|
||||
expect(html).toContain('data-testid="ai-openai-api-key"');
|
||||
expect(html).toContain('data-testid="ai-openai-model"');
|
||||
});
|
||||
|
||||
it("Anthropic selected → Anthropic API-key form", () => {
|
||||
activeProviderQuery = "anthropic";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-provider-config-anthropic"');
|
||||
expect(html).not.toContain('data-testid="ai-provider-config-openai"');
|
||||
});
|
||||
|
||||
it("Local selected → Local base-URL + key form", () => {
|
||||
activeProviderQuery = "local";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-provider-config-local"');
|
||||
expect(html).not.toContain('data-testid="ai-provider-config-codex"');
|
||||
});
|
||||
|
||||
it("renders the fallback-chain card with rows in priority order", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-fallback-chain"');
|
||||
// Each row carries a stable `data-chain-row="<providerType>"`
|
||||
// marker so the e2e suite can target without leaning on i18n
|
||||
// labels (which differ per locale).
|
||||
expect(html).toContain('data-chain-row="codex"');
|
||||
expect(html).toContain('data-chain-row="openai"');
|
||||
expect(html).toContain('data-chain-row="anthropic"');
|
||||
});
|
||||
|
||||
it("exposes a Test active provider button", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="ai-test-active-provider"');
|
||||
expect(html).toContain("Test active provider");
|
||||
});
|
||||
|
||||
it("renders the German labels when locale=de", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render("de");
|
||||
expect(html).toContain("Mit ChatGPT verbinden");
|
||||
expect(html).toContain("ChatGPT-Account (Codex)");
|
||||
// Heading uses the locale's full string ("Aktiver KI-Provider"); a
|
||||
// shorter substring would also match the "Aktiver Provider" line
|
||||
// in the chain summary.
|
||||
expect(html).toContain("Aktiver KI-Provider");
|
||||
});
|
||||
});
|
||||
@@ -3,10 +3,13 @@ import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
// Stub out next/navigation. The settings sections that read it (Account)
|
||||
// only call `useRouter().push()` from inside an effect after mount, so a
|
||||
// no-op stub is enough for SSR smoke rendering.
|
||||
// no-op stub is enough for SSR smoke rendering. v1.4.16 phase B2:
|
||||
// `<AiSection>` reads `?provider=…` via `useSearchParams()` to pick the
|
||||
// dropdown branch, so the mock has to expose that hook too.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/account",
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}));
|
||||
|
||||
// Stub TanStack Query — every section reads from it but we only need the
|
||||
|
||||
+1413
-727
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user