Repository navigation
feat(ftl-checker): F6 results loading + F7 Continue to exemptions (SEL-585) #3687
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,125 @@ | ||
| /** | ||
| * SEL-585 F6 + F7: skipping or finishing exemptions must not leave the | ||
| * exemption questions as the only interactive UI. The checker switches to | ||
| * the results step with an aria-busy loading skeleton (no invented numbers), | ||
| * and the categories step button reads `Continue to exemptions`. | ||
| */ | ||
| import type { ReactNode } from 'react'; | ||
| import { act, render, screen, waitFor } from '@testing-library/react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; | ||
| import { FTLCheckerClient } from '@/app/tools/ftl-checker/components/FTLCheckerClient'; | ||
|
|
||
| const { capture, trackToolRunStarted, trackToolRunCompleted } = vi.hoisted(() => ({ | ||
| capture: vi.fn(), | ||
| trackToolRunStarted: vi.fn(), | ||
| trackToolRunCompleted: vi.fn(), | ||
| })); | ||
|
|
||
| vi.mock('@/lib/posthog-provider', () => ({ | ||
| usePostHog: () => ({ capture }), | ||
| })); | ||
|
|
||
| vi.mock('@/lib/conversion-events', async (importOriginal) => ({ | ||
| ...(await importOriginal<typeof import('@/lib/conversion-events')>()), | ||
| trackToolRunStarted, | ||
| trackToolRunCompleted, | ||
| })); | ||
|
|
||
| vi.mock('@/app/actions/submit-assessment', () => ({ | ||
| submitAssessment: vi.fn().mockResolvedValue({ success: true }), | ||
| })); | ||
|
|
||
| vi.mock('@/lib/auth-context', () => ({ | ||
| useAuth: () => ({ user: null, isAuthenticated: false, isLoading: false }), | ||
| })); | ||
|
|
||
| vi.mock('framer-motion', () => ({ | ||
| AnimatePresence: ({ children }: { children?: ReactNode }) => <>{children}</>, | ||
| motion: new Proxy({}, { | ||
| get: () => ({ children, className }: { children?: ReactNode; className?: string }) => ( | ||
| <div className={className}>{children}</div> | ||
| ), | ||
| }), | ||
| })); | ||
|
|
||
| function eventsNamed(name: string) { | ||
| return capture.mock.calls.filter(([event]) => event === name); | ||
| } | ||
|
|
||
| async function goToExemptions(user: ReturnType<typeof userEvent.setup>) { | ||
| render(<FTLCheckerClient />); | ||
| await user.click(screen.getByText('Shell Eggs').closest('button') as HTMLElement); | ||
| await user.click(screen.getByRole('button', { name: 'Continue to exemptions' })); | ||
| await screen.findByRole('button', { name: 'Skip Exemptions' }); | ||
| } | ||
|
|
||
| describe('SEL-585 F6 + F7 exemptions transition and button label', () => { | ||
| beforeEach(() => { | ||
| capture.mockReset(); | ||
| trackToolRunStarted.mockReset(); | ||
| trackToolRunCompleted.mockReset(); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
|
|
||
| it('F7: categories step button reads Continue to exemptions', () => { | ||
| render(<FTLCheckerClient />); | ||
|
|
||
| expect(screen.getByRole('button', { name: 'Continue to exemptions' })).toBeInTheDocument(); | ||
| expect(screen.queryByRole('button', { name: /Check Listed Groupings/i })).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| it('F6: Skip Exemptions shows an aria-busy results skeleton with no invented numbers', async () => { | ||
| let rafCallback: ((time: number) => void) | null = null; | ||
| vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { | ||
| rafCallback = callback; | ||
| return 1; | ||
| }); | ||
| const user = userEvent.setup(); | ||
| await goToExemptions(user); | ||
|
|
||
| await user.click(screen.getByRole('button', { name: 'Skip Exemptions' })); | ||
|
|
||
| const skeleton = screen.getByTestId('results-loading'); | ||
| expect(skeleton).toHaveAttribute('aria-busy', 'true'); | ||
| expect(skeleton.textContent).not.toMatch(/\d/); | ||
| expect(screen.queryByRole('button', { name: 'Skip Exemptions' })).not.toBeInTheDocument(); | ||
|
|
||
| act(() => { | ||
| rafCallback?.(16); | ||
| }); | ||
| await waitFor(() => | ||
| expect(screen.queryByTestId('results-loading')).not.toBeInTheDocument(), | ||
| ); | ||
| }); | ||
|
|
||
| it('F6: See Results never leaves the exemption questions interactive with an enabled Skip', async () => { | ||
| const user = userEvent.setup(); | ||
| await goToExemptions(user); | ||
|
|
||
| await user.click(screen.getByRole('button', { name: 'See Results' })); | ||
|
|
||
| const skipAfter = screen.queryByRole('button', { name: 'Skip Exemptions' }); | ||
| const loadingAfter = screen.queryByTestId('results-loading'); | ||
| const leftInteractive = | ||
| skipAfter !== null && | ||
| (skipAfter as HTMLButtonElement).disabled === false && | ||
| loadingAfter === null; | ||
| expect(leftInteractive).toBe(false); | ||
|
|
||
| await waitFor(() => | ||
| expect(eventsNamed('tool_completed')).toEqual([['tool_completed', { tool_id: 'ftl-checker', status: 'success' }]]), | ||
| ); | ||
| }); | ||
|
|
||
| it('F1 intact: continuing from categories still captures tool_started', async () => { | ||
| const user = userEvent.setup(); | ||
| await goToExemptions(user); | ||
|
|
||
| expect(eventsNamed('tool_started')).toEqual([['tool_started', { tool_id: 'ftl-checker' }]]); | ||
| expect(trackToolRunStarted).toHaveBeenCalledWith('ftl-checker'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -454,6 +454,7 @@ export function FTLCheckerClient() { | |
| const [exemptionAnswers, setExemptionAnswers] = useState<Record<string, boolean | null>>({}); | ||
| const [showExemptionHelp, setShowExemptionHelp] = useState<string | null>(null); | ||
| const [showResults, setShowResults] = useState(false); | ||
| const [isResultsLoading, setIsResultsLoading] = useState(false); | ||
| const [email, setEmail] = useState(''); | ||
| const [emailSubmitted, setEmailSubmitted] = useState(false); | ||
| const [isDownloading, setIsDownloading] = useState(false); | ||
|
|
@@ -493,6 +494,14 @@ export function FTLCheckerClient() { | |
| } | ||
| }, [currentStep, posthog]); | ||
|
|
||
| // F6: clear the results transition flag after the results step has painted. | ||
| useEffect(() => { | ||
| if (currentStep === 'results' && isResultsLoading) { | ||
| const frame = requestAnimationFrame(() => setIsResultsLoading(false)); | ||
| return () => cancelAnimationFrame(frame); | ||
| } | ||
| }, [currentStep, isResultsLoading]); | ||
|
|
||
| // Generate shareable URL | ||
| const getShareableUrl = () => { | ||
| const base = typeof window !== 'undefined' ? window.location.origin : ''; | ||
|
|
@@ -562,14 +571,15 @@ export function FTLCheckerClient() { | |
| if (hasCoveredCategories) { | ||
| setCurrentStep('exemptions'); | ||
| } else { | ||
| setIsResultsLoading(true); | ||
| setCurrentStep('results'); | ||
| setShowResults(true); | ||
| } | ||
| } | ||
| }; | ||
|
|
||
| const handleSkipExemptions = () => { setCurrentStep('results'); setShowResults(true); }; | ||
| const handleFinishExemptions = () => { setCurrentStep('results'); setShowResults(true); }; | ||
| const handleSkipExemptions = () => { setIsResultsLoading(true); setCurrentStep('results'); setShowResults(true); }; | ||
| const handleFinishExemptions = () => { setIsResultsLoading(true); setCurrentStep('results'); setShowResults(true); }; | ||
| const handleBackToCategories = () => { setCurrentStep('categories'); setExemptionAnswers({}); }; | ||
| const handleStartOver = () => { runStarted.current = false; runCompleted.current = false; setSelectedCategories([]); setExemptionAnswers({}); setCurrentStep('categories'); setShowResults(false); setEmailSubmitted(false); }; | ||
|
|
||
|
|
@@ -862,7 +872,7 @@ export function FTLCheckerClient() { | |
| disabled={selectedCategories.length === 0} | ||
| trailingIcon={<ArrowRight size={16} strokeWidth={1.75} aria-hidden="true" />} | ||
| > | ||
| Check Listed Groupings | ||
| Continue to exemptions | ||
| </Button> | ||
| </div> | ||
| </Card> | ||
|
|
@@ -917,6 +927,7 @@ export function FTLCheckerClient() { | |
| variant="secondary" | ||
| aria-pressed={answer === true} | ||
| className={cn(answer === true && 'bg-re-surface-inset font-semibold text-re-text-primary')} | ||
| disabled={isResultsLoading} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| onClick={() => handleExemptionAnswer(q.id, true)} | ||
| > | ||
| Yes | ||
|
|
@@ -926,6 +937,7 @@ export function FTLCheckerClient() { | |
| variant="secondary" | ||
| aria-pressed={answer === false} | ||
| className={cn(answer === false && 'bg-re-surface-inset font-semibold text-re-text-primary')} | ||
| disabled={isResultsLoading} | ||
| onClick={() => handleExemptionAnswer(q.id, false)} | ||
| > | ||
| No | ||
|
|
@@ -960,9 +972,10 @@ export function FTLCheckerClient() { | |
| <div className="mt-6 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center"> | ||
| <Button variant="secondary" onClick={handleBackToCategories}>← Back to Categories</Button> | ||
| <div className="flex flex-col gap-3 sm:flex-row"> | ||
| <Button variant="secondary" onClick={handleSkipExemptions}>Skip Exemptions</Button> | ||
| <Button variant="secondary" disabled={isResultsLoading} onClick={handleSkipExemptions}>Skip Exemptions</Button> | ||
| <Button | ||
| size="lg" | ||
| disabled={isResultsLoading} | ||
| onClick={handleFinishExemptions} | ||
| trailingIcon={<ArrowRight size={16} strokeWidth={1.75} aria-hidden="true" />} | ||
| > | ||
|
|
@@ -975,7 +988,19 @@ export function FTLCheckerClient() { | |
| )} | ||
|
|
||
| {/* Step 3: Results */} | ||
| {currentStep === 'results' && ( | ||
| {currentStep === 'results' && isResultsLoading && ( | ||
| <motion.div key="results-loading" initial={{ opacity: 0 }} animate={{ opacity: 1 }}> | ||
| <Card padding="spacious" className="mb-6 text-center" role="status" aria-busy="true" aria-label="Loading results" data-testid="results-loading"> | ||
| <p className="text-ui text-re-text-secondary">Loading results</p> | ||
| <div className="mx-auto mt-4 flex max-w-md flex-col gap-2" aria-hidden="true"> | ||
| <div className="h-4 rounded bg-re-surface-inset" /> | ||
| <div className="h-4 w-5/6 rounded bg-re-surface-inset" /> | ||
| <div className="h-4 w-2/3 rounded bg-re-surface-inset" /> | ||
| </div> | ||
| </Card> | ||
| </motion.div> | ||
| )} | ||
| {currentStep === 'results' && !isResultsLoading && ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Results skeleton skipped during transitionMedium Severity
Additional Locations (2)Reviewed by Cursor Bugbot for commit 2320699. Configure here. |
||
| <motion.div key="results" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }}> | ||
| <LeadGate | ||
| source="ftl-checker" | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: regengine/RegEngine
Length of output: 4889
Script executed:
Repository: regengine/RegEngine
Length of output: 16913
Web query:
Framer Motion 14.1.0 default transition duration for motion div exit opacity x without transition propResult:
Script executed:
Repository: regengine/RegEngine
Length of output: 2230
Clear loading after the results placeholder mounts.
AnimatePresenceusesmode="wait", so theresults-loadingchild waits for theexemptionschild to finish exiting. The effect clearsisResultsLoadingon the next animation frame, which can remove the placeholder before it mounts. No local or repository-ownedMotionConfigoverrides the default, and the opacity exit uses Motion's default 0.3-second tween.Move the loading-state reset into a component that mounts inside the placeholder, or trigger it after that placeholder mounts. Keep coverage with the real
AnimatePresenceimplementation.