diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx index fc8c0423b8..c1382ccf58 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderCard.tsx @@ -1,7 +1,7 @@ "use client"; import type { MouseEvent, ReactNode } from "react"; -import { useState } from "react"; +import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react"; import Image from "next/image"; import Link from "next/link"; import { useTranslations } from "next-intl"; @@ -74,6 +74,7 @@ interface ProviderCardProps { stats: ProviderStats; authType?: string; onToggle: (active: boolean) => void; + onCardClick?: (id: string) => void; } const DOT_COLORS: Record = { @@ -152,17 +153,51 @@ function getStatusDisplay( return parts; } -export default function ProviderCard({ - providerId, - provider, - stats, - authType = "apikey", - onToggle, -}: ProviderCardProps) { +export type ProviderCardHandle = { + highlight: () => void; + getProviderId(): string; + scrollIntoView: (options?: ScrollIntoViewOptions) => void; +}; + +const ProviderCard = forwardRef(function ProviderCard( + { providerId, provider, stats, authType = "apikey", onToggle, onCardClick }, + ref +) { const t = useTranslations("providers"); const tc = useTranslations("common"); const tp = useTranslations("miniPlayground"); const [testExpanded, setTestExpanded] = useState(false); + const innerRef = useRef(null); + const linkElementRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + getProviderId() { + return providerId; + }, + highlight() { + const el = innerRef.current; + if (!el) return; + linkElementRef.current?.focus(); + const surface = linkElementRef.current?.firstElementChild; + surface?.animate( + [ + { backgroundColor: "rgba(59,130,246,0.22)" }, + { backgroundColor: "rgba(59,130,246,0.08)" }, + { backgroundColor: "transparent" }, + ], + { duration: 3000, easing: "ease-in-out" } + ); + }, + scrollIntoView() { + const el = innerRef.current; + if (!el) return; + el.scrollIntoView({ behavior: "auto", block: "center" }); + }, + }), + [providerId, innerRef, linkElementRef] + ); // Show the Test button for LLM providers (when serviceKinds includes "llm" // OR when the provider has no explicit serviceKinds but is a regular LLM provider @@ -260,9 +295,18 @@ export default function ProviderCard({ onToggle(allDisabled); }; + const handleCardClick = useCallback(() => { + onCardClick?.(providerId); + }, [onCardClick, providerId]); + return ( -
- +
+ {}} + onChange={undefined} title={allDisabled ? t("enableProvider") : t("disableProvider")} />
@@ -461,4 +505,6 @@ export default function ProviderCard({ )}
); -} +}); + +export default ProviderCard; diff --git a/src/app/(dashboard)/dashboard/providers/page.tsx b/src/app/(dashboard)/dashboard/providers/page.tsx index 678356b384..52280aef21 100644 --- a/src/app/(dashboard)/dashboard/providers/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/page.tsx @@ -34,6 +34,7 @@ import { loadProviderPageData, } from "./providerPageUtils"; import type { ProviderEntry } from "./providerPageUtils"; +import { recordProviderNavigation, resolveHighlightedCard } from "./providerPageHighlightUtils"; import { readProviderDisplayModePreference, shouldSyncProviderDisplayMode, @@ -50,6 +51,7 @@ import { CategoryDot } from "./components/CategoryDot"; import { ImportProvidersFromFileModal } from "./components/ImportProvidersFromFileModal"; import NoAuthProvidersSection from "./components/NoAuthProvidersSection"; import ProviderCard from "./components/ProviderCard"; +import type { ProviderCardHandle } from "./components/ProviderCard"; import ProviderCountBadge from "./components/ProviderCountBadge"; import ProviderSummaryCard from "./components/ProviderSummaryCard"; import { @@ -205,6 +207,22 @@ export default function ProvidersPage() { // #4240: media-category (serviceKind) filter — composes with activeCategory, // search and configured-only. null = no serviceKind filter. const [activeServiceKind, setActiveServiceKind] = useState(null); + + // a highlighted card is one that we should scroll into view and highlight upon navigation + const [highlightedProviderId, setHighlightedProviderId] = useState(() => { + return history.state?.providerId ?? null; + }); + + const handleCardClick = useCallback((id: string) => { + recordProviderNavigation(id); + }, []); + + const highlightedCardRef = useCallback( + (handle: ProviderCardHandle | null) => { + resolveHighlightedCard(handle, highlightedProviderId, () => setHighlightedProviderId(null)); + }, + [highlightedProviderId, setHighlightedProviderId] + ); const notify = useNotificationStore(); const sectionCategoryAliases: Record = { cloud: "cloudagent", @@ -918,6 +936,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(entry.providerId, entry.toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1004,6 +1025,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1078,6 +1102,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1136,6 +1163,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1184,6 +1214,9 @@ export default function ProvidersPage() { stats={stats} authType="web-cookie" onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)} + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1232,6 +1265,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1285,6 +1321,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1351,6 +1390,9 @@ export default function ProvidersPage() { stats={stats} authType="upstream-proxy" onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)} + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1383,6 +1425,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1416,6 +1461,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1449,6 +1497,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1499,6 +1550,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1546,6 +1600,9 @@ export default function ProvidersPage() { stats={stats} authType="local" onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)} + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1592,6 +1649,9 @@ export default function ProvidersPage() { stats={stats} authType="search" onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)} + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1624,6 +1684,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1657,6 +1720,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} @@ -1704,6 +1770,9 @@ export default function ProvidersPage() { stats={stats} authType="audio" onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)} + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ))} @@ -1736,6 +1805,9 @@ export default function ProvidersPage() { onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active) } + + onCardClick={handleCardClick} + ref={highlightedCardRef} /> ) )} diff --git a/src/app/(dashboard)/dashboard/providers/providerPageHighlightUtils.ts b/src/app/(dashboard)/dashboard/providers/providerPageHighlightUtils.ts new file mode 100644 index 0000000000..d5d7976340 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/providerPageHighlightUtils.ts @@ -0,0 +1,27 @@ +import type { ProviderCardHandle } from "./components/ProviderCard"; + +/** + * Called before navigating to a provider detail page. Persists the provider + * id in history.state so the list page can scroll to it on back-navigation. + */ +export function recordProviderNavigation(id: string) { + window.history.replaceState({ providerId: id }, ""); +} + +/** + * Ref callback for ProviderCard. When the rendered card's provider id + * matches the highlighted id, scrolls it into view and triggers the + * highlight animation. Always clears the highlighted id afterward so + * subsequent re-renders don't re-scroll. + */ +export function resolveHighlightedCard( + handle: ProviderCardHandle | null, + highlightedProviderId: string | null, + onAfterHighlight: () => void +) { + if (handle?.getProviderId() === highlightedProviderId) { + handle.scrollIntoView({ behavior: "auto", block: "center" }); + handle.highlight(); + } + onAfterHighlight(); +} diff --git a/tests/unit/ui/providerCardHandle.test.tsx b/tests/unit/ui/providerCardHandle.test.tsx new file mode 100644 index 0000000000..ce8f4b7fd7 --- /dev/null +++ b/tests/unit/ui/providerCardHandle.test.tsx @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +/** + * ProviderCardHandle imperative API — highlight(), scrollIntoView(), getProviderId(). + * + * NOTE on placement: see providerCardKimiPartnerAccent.test.tsx for rationale + * on keeping tests in tests/unit/ui/ (both vitest configs discover it here). + */ +import React from "react"; +import { createRoot } from "react-dom/client"; +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import ProviderCard, { + type ProviderCardHandle, +} from "@/app/(dashboard)/dashboard/providers/components/ProviderCard"; + +vi.mock("next-intl", () => ({ useTranslations: () => (k: string) => k })); +vi.mock("@/shared/components/ProviderTestSlideOver", () => ({ default: () => null })); +vi.mock("@/shared/components/ProviderIcon", () => ({ default: () => null })); + +// jsdom does not implement scrollIntoView or animate +if (typeof Element.prototype.scrollIntoView === "undefined") { + Object.defineProperty(Element.prototype, "scrollIntoView", { + value: vi.fn(), + writable: true, + configurable: true, + }); +} +if (typeof Element.prototype.animate === "undefined") { + Object.defineProperty(Element.prototype, "animate", { + value: () => ({ cancel: () => {}, finished: Promise.resolve() }), + writable: true, + configurable: true, + }); +} + +describe("ProviderCardHandle imperative API", () => { + let container: HTMLDivElement | null = null; + let handle: ProviderCardHandle | null = null; + + afterEach(() => { + handle = null; + if (container) { + document.body.removeChild(container); + container = null; + } + }); + + const PROVIDER_ID = "openai"; + const PROVIDER_NAME = "OpenAI"; + + function renderAndCapture() { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + act(() => { + root.render( + { + handle = h; + }} + providerId={PROVIDER_ID} + provider={{ id: PROVIDER_ID, name: PROVIDER_NAME }} + stats={{ total: 1, connected: 1, error: 0, warning: 0 }} + authType="apikey" + onToggle={() => {}} + /> + ); + }); + } + + it("getProviderId returns the providerId passed as a prop", () => { + renderAndCapture(); + expect(handle).not.toBeNull(); + expect(handle!.getProviderId()).toBe(PROVIDER_ID); + }); + + it("scrollIntoView calls Element.scrollIntoView on the wrapper div", () => { + renderAndCapture(); + const spy = vi.spyOn(Element.prototype, "scrollIntoView"); + act(() => { + handle!.scrollIntoView(); + }); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith({ behavior: "auto", block: "center" }); + spy.mockRestore(); + }); + + it("highlight calls animate on the Card surface", () => { + renderAndCapture(); + const spy = vi.spyOn(Element.prototype, "animate"); + act(() => { + handle!.highlight(); + }); + expect(spy).toHaveBeenCalledTimes(1); + const keyframes = spy.mock.calls[0][0] as Keyframe[]; + expect(keyframes).toHaveLength(3); + expect((keyframes[0] as Record).backgroundColor).toBe("rgba(59,130,246,0.22)"); + expect((keyframes[2] as Record).backgroundColor).toBe("transparent"); + spy.mockRestore(); + }); + + it("highlight focuses the link element", () => { + renderAndCapture(); + const focusSpy = vi.fn(); + const link = container!.querySelector("a"); + if (link) link.focus = focusSpy; + act(() => { + handle!.highlight(); + }); + expect(focusSpy).toHaveBeenCalledTimes(1); + }); + + it("getProviderId returns the correct id for a different provider", () => { + container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + let h: ProviderCardHandle | null = null; + act(() => { + root.render( + { + h = n; + }} + providerId="kimi-coding" + provider={{ id: "kimi-coding", name: "Kimi Code CLI" }} + stats={{ total: 0, connected: 0, error: 0, warning: 0 }} + authType="oauth" + onToggle={() => {}} + /> + ); + }); + expect(h!.getProviderId()).toBe("kimi-coding"); + }); +}); diff --git a/tests/unit/ui/providerPageHighlightLogic.test.tsx b/tests/unit/ui/providerPageHighlightLogic.test.tsx new file mode 100644 index 0000000000..7052474a84 --- /dev/null +++ b/tests/unit/ui/providerPageHighlightLogic.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +/** + * Tests for the page-level highlighted-card matching/clearing logic + * extracted into providerPageHighlightUtils.ts. + * + * These import the real production functions — not copied code. + */ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ProviderCardHandle } from "@/app/(dashboard)/dashboard/providers/components/ProviderCard"; +import { + recordProviderNavigation, + resolveHighlightedCard, +} from "@/app/(dashboard)/dashboard/providers/providerPageHighlightUtils"; + +function createMockHandle( + id: string, + callbacks?: { onScroll?(): void; onHighlight?(): void } +): ProviderCardHandle { + return { + getProviderId() { + return id; + }, + scrollIntoView() { + callbacks?.onScroll?.(); + }, + highlight() { + callbacks?.onHighlight?.(); + }, + }; +} + +describe("resolveHighlightedCard", () => { + it("calls scrollIntoView + highlight when handle matches highlighted id", () => { + const onScroll = vi.fn(); + const onHighlight = vi.fn(); + const onAfterHighlight = vi.fn(); + const handle = createMockHandle("openai", { onScroll, onHighlight }); + + resolveHighlightedCard(handle, "openai", onAfterHighlight); + + expect(onScroll).toHaveBeenCalledTimes(1); + expect(onHighlight).toHaveBeenCalledTimes(1); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("does NOT call scrollIntoView or highlight when ids do not match", () => { + const onScroll = vi.fn(); + const onHighlight = vi.fn(); + const onAfterHighlight = vi.fn(); + const handle = createMockHandle("openai", { onScroll, onHighlight }); + + resolveHighlightedCard(handle, "anthropic", onAfterHighlight); + + expect(onScroll).not.toHaveBeenCalled(); + expect(onHighlight).not.toHaveBeenCalled(); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("calls onAfterHighlight even when handle is null", () => { + const onAfterHighlight = vi.fn(); + resolveHighlightedCard(null, "openai", onAfterHighlight); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("calls onAfterHighlight even when ids do not match", () => { + const onAfterHighlight = vi.fn(); + const handle = createMockHandle("kimi-coding"); + resolveHighlightedCard(handle, "openai", onAfterHighlight); + expect(onAfterHighlight).toHaveBeenCalledTimes(1); + }); + + it("only the matching handle triggers scroll + highlight among multiple", () => { + const calls: string[] = []; + const onAfterHighlight = vi.fn(); + const handles = [ + createMockHandle("openai", { + onScroll: () => calls.push("openai-scroll"), + onHighlight: () => calls.push("openai-highlight"), + }), + createMockHandle("anthropic", { + onScroll: () => calls.push("anthropic-scroll"), + onHighlight: () => calls.push("anthropic-highlight"), + }), + createMockHandle("cursor", { + onScroll: () => calls.push("cursor-scroll"), + onHighlight: () => calls.push("cursor-highlight"), + }), + ]; + + resolveHighlightedCard(handles[0], "cursor", onAfterHighlight); + resolveHighlightedCard(handles[1], "cursor", onAfterHighlight); + resolveHighlightedCard(handles[2], "cursor", onAfterHighlight); + + expect(calls).toEqual(["cursor-scroll", "cursor-highlight"]); + expect(onAfterHighlight).toHaveBeenCalledTimes(3); + }); +}); + +describe("recordProviderNavigation", () => { + const originalReplaceState = window.history.replaceState; + + afterEach(() => { + window.history.replaceState = originalReplaceState; + }); + + it("calls history.replaceState with the provider id", () => { + const replaceSpy = vi.fn(); + window.history.replaceState = replaceSpy; + + recordProviderNavigation("openai"); + + expect(replaceSpy).toHaveBeenCalledTimes(1); + expect(replaceSpy).toHaveBeenCalledWith({ providerId: "openai" }, ""); + }); + + it("sets different provider ids on successive calls", () => { + const replaceSpy = vi.fn(); + window.history.replaceState = replaceSpy; + + recordProviderNavigation("openai"); + recordProviderNavigation("anthropic"); + recordProviderNavigation("kimi-coding"); + + expect(replaceSpy).toHaveBeenCalledTimes(3); + expect(replaceSpy.mock.calls[0][0]).toEqual({ providerId: "openai" }); + expect(replaceSpy.mock.calls[1][0]).toEqual({ providerId: "anthropic" }); + expect(replaceSpy.mock.calls[2][0]).toEqual({ providerId: "kimi-coding" }); + }); +});