feat(FN-4052): add mobile oauth manual-code scroll assist and modal flow te
Adds mobile OAuth manual-code fallback with scroll assistance in the OAuthManualCodeForm component, including full test coverage across ModelOnboardingModal and SettingsModal flows, plus documentation updates. Also introduces weighted test shard planning for CI. Fusion-Task-Id: FN-4052
This commit is contained in:
@@ -24,6 +24,7 @@
|
||||
min-height: 84px;
|
||||
resize: vertical;
|
||||
font-family: var(--font-mono);
|
||||
scroll-margin-block: var(--space-xl);
|
||||
}
|
||||
|
||||
.oauth-manual-code__input:focus-visible {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import "./OAuthManualCodeForm.css";
|
||||
|
||||
interface OAuthManualCodeFormProps {
|
||||
@@ -23,13 +24,89 @@ export function OAuthManualCodeForm({
|
||||
submitLabel = "Submit code",
|
||||
"data-testid": testId,
|
||||
}: OAuthManualCodeFormProps) {
|
||||
const formRef = useRef<HTMLDivElement | null>(null);
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
|
||||
const shouldUseMobileScrollAssist = useCallback(() => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const compactLayout = window.matchMedia("(max-width: 768px)").matches;
|
||||
const coarsePointer = window.matchMedia("(pointer: coarse)").matches;
|
||||
return compactLayout || coarsePointer;
|
||||
}, []);
|
||||
|
||||
const getScrollBehavior = useCallback((): ScrollBehavior => {
|
||||
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
|
||||
return "auto";
|
||||
}
|
||||
|
||||
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth";
|
||||
}, []);
|
||||
|
||||
const scrollInputIntoView = useCallback(() => {
|
||||
if (!shouldUseMobileScrollAssist()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = inputRef.current ?? formRef.current;
|
||||
if (!target || typeof target.scrollIntoView !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
const behavior = getScrollBehavior();
|
||||
requestAnimationFrame(() => {
|
||||
target.scrollIntoView({ block: "center", behavior, inline: "nearest" });
|
||||
// Mobile keyboards can shift viewport after focus; a short follow-up call
|
||||
// keeps the textarea visible when that deferred viewport resize completes.
|
||||
window.setTimeout(() => {
|
||||
target.scrollIntoView({ block: "center", behavior, inline: "nearest" });
|
||||
}, 120);
|
||||
});
|
||||
}, [getScrollBehavior, shouldUseMobileScrollAssist]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inputFocused || !shouldUseMobileScrollAssist()) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollInputIntoView();
|
||||
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleViewportShift = () => {
|
||||
if (document.activeElement === inputRef.current) {
|
||||
scrollInputIntoView();
|
||||
}
|
||||
};
|
||||
|
||||
viewport.addEventListener("resize", handleViewportShift);
|
||||
viewport.addEventListener("scroll", handleViewportShift);
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener("resize", handleViewportShift);
|
||||
viewport.removeEventListener("scroll", handleViewportShift);
|
||||
};
|
||||
}, [inputFocused, scrollInputIntoView, shouldUseMobileScrollAssist]);
|
||||
|
||||
return (
|
||||
<div className="oauth-manual-code" data-testid={testId}>
|
||||
<div ref={formRef} className="oauth-manual-code" data-testid={testId}>
|
||||
<p className="oauth-manual-code__prompt">{prompt}</p>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="form-input oauth-manual-code__input"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
onFocus={() => {
|
||||
setInputFocused(true);
|
||||
scrollInputIntoView();
|
||||
}}
|
||||
onBlur={() => setInputFocused(false)}
|
||||
placeholder={placeholder}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
|
||||
@@ -712,6 +712,60 @@ describe("ModelOnboardingModal", () => {
|
||||
expect(mockWindowOpen).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scrolls the onboarding manual-code input into view on mobile focus", async () => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(max-width: 768px)" || query === "(pointer: coarse)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
const mockWindowOpen = vi.fn();
|
||||
vi.spyOn(window, "open").mockImplementation(mockWindowOpen);
|
||||
mockLoginProvider.mockResolvedValueOnce({
|
||||
url: "https://claude.ai/oauth/authorize",
|
||||
manualCode: {
|
||||
prompt: "Paste the final redirect URL or authorization code",
|
||||
},
|
||||
});
|
||||
|
||||
render(<ModelOnboardingModal onComplete={vi.fn()} addToast={vi.fn()} projectId="proj_123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Login")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Login"));
|
||||
|
||||
const prompt = await screen.findByText("Paste the final redirect URL or authorization code");
|
||||
const card = prompt.closest(".onboarding-provider-card") as HTMLElement;
|
||||
const textarea = within(card).getByRole("textbox");
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(textarea, "scrollIntoView", {
|
||||
value: scrollIntoView,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockWindowOpen).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps OpenAI Codex manual-code UX available in onboarding", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [{ id: "openai-codex", name: "OpenAI Codex", authenticated: false, type: "oauth" }],
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { OAuthManualCodeForm } from "../OAuthManualCodeForm";
|
||||
|
||||
function mockMatchMedia({ mobile = false, coarse = false, reducedMotion = false }: { mobile?: boolean; coarse?: boolean; reducedMotion?: boolean }) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches:
|
||||
(query === "(max-width: 768px)" && mobile)
|
||||
|| (query === "(pointer: coarse)" && coarse)
|
||||
|| (query === "(prefers-reduced-motion: reduce)" && reducedMotion),
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe("OAuthManualCodeForm", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("scrolls the textarea into view on mobile focus and visual viewport resize", () => {
|
||||
mockMatchMedia({ mobile: true });
|
||||
|
||||
const listeners: Record<string, (() => void) | undefined> = {};
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: {
|
||||
addEventListener: vi.fn((event: string, callback: () => void) => {
|
||||
listeners[event] = callback;
|
||||
}),
|
||||
removeEventListener: vi.fn((event: string) => {
|
||||
delete listeners[event];
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<OAuthManualCodeForm
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
prompt="Paste code"
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(textarea, "scrollIntoView", {
|
||||
value: scrollIntoView,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "center",
|
||||
behavior: "smooth",
|
||||
inline: "nearest",
|
||||
});
|
||||
|
||||
Object.defineProperty(document, "activeElement", {
|
||||
configurable: true,
|
||||
get: () => textarea,
|
||||
});
|
||||
|
||||
listeners.resize?.();
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not trigger scroll assist on non-mobile layouts", () => {
|
||||
mockMatchMedia({ mobile: false, coarse: false });
|
||||
|
||||
render(
|
||||
<OAuthManualCodeForm
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
onSubmit={vi.fn()}
|
||||
prompt="Paste code"
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByRole("textbox");
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(textarea, "scrollIntoView", {
|
||||
value: scrollIntoView,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(scrollIntoView).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1142,6 +1142,58 @@ describe("SettingsModal", () => {
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("scrolls the manual-code input into view on mobile focus", async () => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(max-width: 768px)" || query === "(pointer: coarse)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
|
||||
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
|
||||
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
mockFetchAuthStatus.mockResolvedValueOnce({
|
||||
providers: [{ id: "anthropic", name: "Anthropic", authenticated: false, type: "oauth" }],
|
||||
});
|
||||
mockLoginProvider.mockResolvedValueOnce({
|
||||
url: "https://claude.ai/oauth/authorize",
|
||||
manualCode: {
|
||||
prompt: "Paste the final redirect URL or authorization code",
|
||||
},
|
||||
});
|
||||
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
const anthropicCard = screen.getByTestId("auth-provider-icon-anthropic").closest(".auth-provider-card") as HTMLElement;
|
||||
await userEvent.click(within(anthropicCard).getByRole("button", { name: "Login" }));
|
||||
|
||||
const textarea = await within(anthropicCard).findByRole("textbox");
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(textarea, "scrollIntoView", {
|
||||
value: scrollIntoView,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
fireEvent.focus(textarea);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(scrollIntoView).toHaveBeenCalled();
|
||||
});
|
||||
expect(openSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows cancel action for server-reported pending oauth login", async () => {
|
||||
mockFetchAuthStatus.mockResolvedValue({
|
||||
providers: [{ id: "github-copilot", name: "GitHub Copilot", authenticated: false, type: "oauth", loginInProgress: true }],
|
||||
|
||||
Reference in New Issue
Block a user