feat(FN-2399): merge fusion/fn-2399
This commit is contained in:
@@ -498,6 +498,7 @@ export function ModelOnboardingModal({
|
||||
const [apiKeyErrors, setApiKeyErrors] = useState<Record<string, string>>({});
|
||||
const [apiKeySuccess, setApiKeySuccess] = useState<Record<string, string | null>>({});
|
||||
const apiKeySuccessTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});
|
||||
const onboardingContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [loginOutcomes, setLoginOutcomes] = useState<Record<string, LoginOutcome>>({});
|
||||
const [isGithubSkipped, setIsGithubSkipped] = useState<boolean>(() => {
|
||||
@@ -982,6 +983,30 @@ export function ModelOnboardingModal({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const scrollOnboardingContentToTop = useCallback(() => {
|
||||
const content = onboardingContentRef.current;
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const prefersReducedMotion =
|
||||
typeof window !== "undefined"
|
||||
&& typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
try {
|
||||
if (typeof content.scrollTo === "function") {
|
||||
content.scrollTo({ top: 0, behavior: prefersReducedMotion ? "auto" : "smooth" });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to direct scrollTop assignment for environments
|
||||
// without ScrollToOptions support.
|
||||
}
|
||||
|
||||
content.scrollTop = 0;
|
||||
}, []);
|
||||
|
||||
// API key save handler
|
||||
const handleSaveApiKey = useCallback(
|
||||
async (providerId: string, keyValue?: string) => {
|
||||
@@ -1019,6 +1044,7 @@ export function ModelOnboardingModal({
|
||||
try {
|
||||
await saveApiKey(providerId, key);
|
||||
await loadAuthStatus();
|
||||
scrollOnboardingContentToTop();
|
||||
|
||||
setApiKeyInputs((prev) => {
|
||||
const next = { ...prev };
|
||||
@@ -1077,7 +1103,7 @@ export function ModelOnboardingModal({
|
||||
setAuthActionInProgress(null);
|
||||
}
|
||||
},
|
||||
[apiKeyInputs, addToast, loadAuthStatus],
|
||||
[apiKeyInputs, addToast, loadAuthStatus, scrollOnboardingContentToTop],
|
||||
);
|
||||
|
||||
// API key clear handler
|
||||
@@ -1507,7 +1533,7 @@ export function ModelOnboardingModal({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="model-onboarding-content">
|
||||
<div className="model-onboarding-content" ref={onboardingContentRef}>
|
||||
{step === "ai-setup" && (
|
||||
<div className="model-onboarding-ai-setup">
|
||||
<p className="model-onboarding-description">
|
||||
|
||||
@@ -512,6 +512,81 @@ describe("ModelOnboardingModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("scrolls onboarding content to top after successful API key save", async () => {
|
||||
mockFetchAuthStatus
|
||||
.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
|
||||
{ id: "openai", name: "OpenAI", authenticated: false, type: "api_key" },
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
providers: [
|
||||
{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" },
|
||||
{ id: "openai", name: "OpenAI", authenticated: true, type: "api_key" },
|
||||
],
|
||||
});
|
||||
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeTruthy();
|
||||
});
|
||||
|
||||
const content = document.querySelector(".model-onboarding-content") as HTMLDivElement;
|
||||
expect(content).toBeTruthy();
|
||||
const scrollToMock = vi.fn();
|
||||
Object.defineProperty(content, "scrollTo", {
|
||||
value: scrollToMock,
|
||||
writable: true,
|
||||
});
|
||||
content.scrollTop = 240;
|
||||
|
||||
fireEvent.change(screen.getByTestId("onboarding-apikey-input-openai"), {
|
||||
target: { value: "sk-scroll-success" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSaveApiKey).toHaveBeenCalledWith("openai", "sk-scroll-success");
|
||||
expect(scrollToMock).toHaveBeenCalledWith({ top: 0, behavior: "smooth" });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("✓ API key saved")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not scroll onboarding content to top when API key save fails", async () => {
|
||||
mockSaveApiKey.mockRejectedValueOnce(new Error("save failed"));
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("onboarding-apikey-input-openai")).toBeTruthy();
|
||||
});
|
||||
|
||||
const content = document.querySelector(".model-onboarding-content") as HTMLDivElement;
|
||||
expect(content).toBeTruthy();
|
||||
const scrollToMock = vi.fn();
|
||||
Object.defineProperty(content, "scrollTo", {
|
||||
value: scrollToMock,
|
||||
writable: true,
|
||||
});
|
||||
content.scrollTop = 180;
|
||||
|
||||
fireEvent.change(screen.getByTestId("onboarding-apikey-input-openai"), {
|
||||
target: { value: "sk-scroll-fail" },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("onboarding-apikey-save-openai"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("save failed")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(scrollToMock).not.toHaveBeenCalled();
|
||||
expect(content.scrollTop).toBe(180);
|
||||
});
|
||||
|
||||
it("submits API key when Enter is pressed", async () => {
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user