mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-06 15:22:12 +03:00
Clicking a provider card hitting back loses scroll position (#8349)
* clicking a provider card hitting back loses scroll position * clean up code * Rename some funcitons * minor UX updates * add null-guard in highlight() * Add providerCardHandle tests * add highlight tests. refactored code into separate utility functions * Minor change to skip a firstElementChild call - use a ref to access the Link inside ProviderCard.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { MouseEvent, ReactNode } from "react";
|
import type { MouseEvent, ReactNode } from "react";
|
||||||
import { useState } from "react";
|
import { forwardRef, useCallback, useImperativeHandle, useRef, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
@@ -74,6 +74,7 @@ interface ProviderCardProps {
|
|||||||
stats: ProviderStats;
|
stats: ProviderStats;
|
||||||
authType?: string;
|
authType?: string;
|
||||||
onToggle: (active: boolean) => void;
|
onToggle: (active: boolean) => void;
|
||||||
|
onCardClick?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DOT_COLORS: Record<string, string> = {
|
const DOT_COLORS: Record<string, string> = {
|
||||||
@@ -152,17 +153,51 @@ function getStatusDisplay(
|
|||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProviderCard({
|
export type ProviderCardHandle = {
|
||||||
providerId,
|
highlight: () => void;
|
||||||
provider,
|
getProviderId(): string;
|
||||||
stats,
|
scrollIntoView: (options?: ScrollIntoViewOptions) => void;
|
||||||
authType = "apikey",
|
};
|
||||||
onToggle,
|
|
||||||
}: ProviderCardProps) {
|
const ProviderCard = forwardRef<ProviderCardHandle, ProviderCardProps>(function ProviderCard(
|
||||||
|
{ providerId, provider, stats, authType = "apikey", onToggle, onCardClick },
|
||||||
|
ref
|
||||||
|
) {
|
||||||
const t = useTranslations("providers");
|
const t = useTranslations("providers");
|
||||||
const tc = useTranslations("common");
|
const tc = useTranslations("common");
|
||||||
const tp = useTranslations("miniPlayground");
|
const tp = useTranslations("miniPlayground");
|
||||||
const [testExpanded, setTestExpanded] = useState<boolean>(false);
|
const [testExpanded, setTestExpanded] = useState<boolean>(false);
|
||||||
|
const innerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const linkElementRef = useRef<HTMLAnchorElement>(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"
|
// 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
|
// OR when the provider has no explicit serviceKinds but is a regular LLM provider
|
||||||
@@ -260,9 +295,18 @@ export default function ProviderCard({
|
|||||||
onToggle(allDisabled);
|
onToggle(allDisabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCardClick = useCallback(() => {
|
||||||
|
onCardClick?.(providerId);
|
||||||
|
}, [onCardClick, providerId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div ref={innerRef} id={`provider-${providerId}`} className="flex flex-col h-full">
|
||||||
<Link href={`/dashboard/providers/${providerId}`} className="group flex-1 flex flex-col">
|
<Link
|
||||||
|
ref={linkElementRef}
|
||||||
|
href={`/dashboard/providers/${providerId}`}
|
||||||
|
className="group flex-1 flex flex-col focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary/60"
|
||||||
|
onClick={handleCardClick}
|
||||||
|
>
|
||||||
<Card
|
<Card
|
||||||
padding="xs"
|
padding="xs"
|
||||||
className={`h-full flex flex-col hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer ${
|
className={`h-full flex flex-col hover:bg-black/5 dark:hover:bg-white/5 transition-colors cursor-pointer ${
|
||||||
@@ -422,7 +466,7 @@ export default function ProviderCard({
|
|||||||
<Toggle
|
<Toggle
|
||||||
size="xs"
|
size="xs"
|
||||||
checked={!allDisabled}
|
checked={!allDisabled}
|
||||||
onChange={() => {}}
|
onChange={undefined}
|
||||||
title={allDisabled ? t("enableProvider") : t("disableProvider")}
|
title={allDisabled ? t("enableProvider") : t("disableProvider")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -461,4 +505,6 @@ export default function ProviderCard({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
|
export default ProviderCard;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
loadProviderPageData,
|
loadProviderPageData,
|
||||||
} from "./providerPageUtils";
|
} from "./providerPageUtils";
|
||||||
import type { ProviderEntry } from "./providerPageUtils";
|
import type { ProviderEntry } from "./providerPageUtils";
|
||||||
|
import { recordProviderNavigation, resolveHighlightedCard } from "./providerPageHighlightUtils";
|
||||||
import {
|
import {
|
||||||
readProviderDisplayModePreference,
|
readProviderDisplayModePreference,
|
||||||
shouldSyncProviderDisplayMode,
|
shouldSyncProviderDisplayMode,
|
||||||
@@ -50,6 +51,7 @@ import { CategoryDot } from "./components/CategoryDot";
|
|||||||
import { ImportProvidersFromFileModal } from "./components/ImportProvidersFromFileModal";
|
import { ImportProvidersFromFileModal } from "./components/ImportProvidersFromFileModal";
|
||||||
import NoAuthProvidersSection from "./components/NoAuthProvidersSection";
|
import NoAuthProvidersSection from "./components/NoAuthProvidersSection";
|
||||||
import ProviderCard from "./components/ProviderCard";
|
import ProviderCard from "./components/ProviderCard";
|
||||||
|
import type { ProviderCardHandle } from "./components/ProviderCard";
|
||||||
import ProviderCountBadge from "./components/ProviderCountBadge";
|
import ProviderCountBadge from "./components/ProviderCountBadge";
|
||||||
import ProviderSummaryCard from "./components/ProviderSummaryCard";
|
import ProviderSummaryCard from "./components/ProviderSummaryCard";
|
||||||
import {
|
import {
|
||||||
@@ -205,6 +207,22 @@ export default function ProvidersPage() {
|
|||||||
// #4240: media-category (serviceKind) filter — composes with activeCategory,
|
// #4240: media-category (serviceKind) filter — composes with activeCategory,
|
||||||
// search and configured-only. null = no serviceKind filter.
|
// search and configured-only. null = no serviceKind filter.
|
||||||
const [activeServiceKind, setActiveServiceKind] = useState<string | null>(null);
|
const [activeServiceKind, setActiveServiceKind] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// a highlighted card is one that we should scroll into view and highlight upon navigation
|
||||||
|
const [highlightedProviderId, setHighlightedProviderId] = useState<string | null>(() => {
|
||||||
|
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 notify = useNotificationStore();
|
||||||
const sectionCategoryAliases: Record<string, string> = {
|
const sectionCategoryAliases: Record<string, string> = {
|
||||||
cloud: "cloudagent",
|
cloud: "cloudagent",
|
||||||
@@ -918,6 +936,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(entry.providerId, entry.toggleAuthType, active)
|
handleToggleProvider(entry.providerId, entry.toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1004,6 +1025,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1078,6 +1102,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1136,6 +1163,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1184,6 +1214,9 @@ export default function ProvidersPage() {
|
|||||||
stats={stats}
|
stats={stats}
|
||||||
authType="web-cookie"
|
authType="web-cookie"
|
||||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1232,6 +1265,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1285,6 +1321,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1351,6 +1390,9 @@ export default function ProvidersPage() {
|
|||||||
stats={stats}
|
stats={stats}
|
||||||
authType="upstream-proxy"
|
authType="upstream-proxy"
|
||||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1383,6 +1425,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1416,6 +1461,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1449,6 +1497,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1499,6 +1550,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1546,6 +1600,9 @@ export default function ProvidersPage() {
|
|||||||
stats={stats}
|
stats={stats}
|
||||||
authType="local"
|
authType="local"
|
||||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1592,6 +1649,9 @@ export default function ProvidersPage() {
|
|||||||
stats={stats}
|
stats={stats}
|
||||||
authType="search"
|
authType="search"
|
||||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1624,6 +1684,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1657,6 +1720,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -1704,6 +1770,9 @@ export default function ProvidersPage() {
|
|||||||
stats={stats}
|
stats={stats}
|
||||||
authType="audio"
|
authType="audio"
|
||||||
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
onToggle={(active) => handleToggleProvider(providerId, toggleAuthType, active)}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1736,6 +1805,9 @@ export default function ProvidersPage() {
|
|||||||
onToggle={(active) =>
|
onToggle={(active) =>
|
||||||
handleToggleProvider(providerId, toggleAuthType, active)
|
handleToggleProvider(providerId, toggleAuthType, active)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onCardClick={handleCardClick}
|
||||||
|
ref={highlightedCardRef}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
134
tests/unit/ui/providerCardHandle.test.tsx
Normal file
134
tests/unit/ui/providerCardHandle.test.tsx
Normal file
@@ -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(
|
||||||
|
<ProviderCard
|
||||||
|
ref={(h) => {
|
||||||
|
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<string, string>).backgroundColor).toBe("rgba(59,130,246,0.22)");
|
||||||
|
expect((keyframes[2] as Record<string, string>).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(
|
||||||
|
<ProviderCard
|
||||||
|
ref={(n) => {
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
129
tests/unit/ui/providerPageHighlightLogic.test.tsx
Normal file
129
tests/unit/ui/providerPageHighlightLogic.test.tsx
Normal file
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user