diff --git a/packages/ui/src/components/bible-version-picker.stories.tsx b/packages/ui/src/components/bible-version-picker.stories.tsx index 9bad1bd8..c45875ec 100644 --- a/packages/ui/src/components/bible-version-picker.stories.tsx +++ b/packages/ui/src/components/bible-version-picker.stories.tsx @@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { BibleVersionPicker, type RootProps } from './bible-version-picker'; import { useState } from 'react'; import { screen, userEvent, within, expect, waitFor } from 'storybook/test'; +import { http, HttpResponse, delay } from 'msw'; import { BookOpenIcon } from './icons/book-open'; import { Button } from './ui/button'; import { RECENT_VERSIONS_KEY } from './bible-version-picker'; @@ -73,6 +74,30 @@ export const Default: Story = { }, }; +export const Loading: Story = { + args: { + versionId: 111, + }, + parameters: { + msw: { + handlers: [ + http.get('*/v1/bibles', async () => { + await delay('infinite'); + return new HttpResponse(null); + }), + http.get('*/v1/languages', async () => { + await delay('infinite'); + return new HttpResponse(null); + }), + http.get('*/v1/bibles/:id', async () => { + await delay('infinite'); + return new HttpResponse(null); + }), + ], + }, + }, +}; + export const LightBackground: Story = { args: { versionId: 111, diff --git a/packages/ui/src/components/bible-version-picker.test.tsx b/packages/ui/src/components/bible-version-picker.test.tsx new file mode 100644 index 00000000..51294de6 --- /dev/null +++ b/packages/ui/src/components/bible-version-picker.test.tsx @@ -0,0 +1,222 @@ +/** + * @vitest-environment jsdom + */ +/* eslint-disable @typescript-eslint/no-empty-function */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +// ResizeObserver is used by VersionAbbreviationIcon and @floating-ui/dom (Radix Popover) +class ResizeObserverMock { + observe() {} + unobserve() {} + disconnect() {} +} +globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + +import { BibleVersionPicker, RECENT_VERSIONS_KEY } from './bible-version-picker'; +import { + useVersions, + useVersion, + useLanguages, + useLanguage, + useFilteredVersions, + useTheme, +} from '@youversion/platform-react-hooks'; +import type { BibleVersion } from '@youversion/platform-core'; + +vi.mock('@youversion/platform-react-hooks'); + +const mockVersions: BibleVersion[] = [ + { + id: 111, + title: 'New International Version', + abbreviation: 'NIV', + localized_title: 'New International Version', + localized_abbreviation: 'NIV', + language_tag: 'en', + books: ['GEN', 'EXO'], + youversion_deep_link: 'https://bible.com/versions/111', + }, + { + id: 206, + title: 'New Living Translation', + abbreviation: 'NLT', + localized_title: 'New Living Translation', + localized_abbreviation: 'NLT', + language_tag: 'en', + books: ['GEN', 'EXO'], + youversion_deep_link: 'https://bible.com/versions/206', + }, +]; + +function setupDefaultMocks({ + versionsLoading = false, + filteredVersions = mockVersions, +}: { + versionsLoading?: boolean; + filteredVersions?: BibleVersion[]; +} = {}) { + vi.mocked(useVersions).mockReturnValue({ + versions: versionsLoading ? null : { data: mockVersions, next_page_token: null }, + loading: versionsLoading, + error: null, + refetch: vi.fn(), + }); + + vi.mocked(useVersion).mockReturnValue({ + version: mockVersions[0]!, + loading: false, + error: null, + refetch: vi.fn(), + }); + + vi.mocked(useLanguages).mockReturnValue({ + languages: { data: [], next_page_token: null }, + loading: false, + error: null, + refetch: vi.fn(), + }); + + vi.mocked(useLanguage).mockReturnValue({ + language: { id: 'en', display_names: { en: 'English' }, language: 'English' }, + loading: false, + error: null, + refetch: vi.fn(), + } as ReturnType); + + vi.mocked(useFilteredVersions).mockReturnValue(filteredVersions); + + vi.mocked(useTheme).mockReturnValue('light'); +} + +function renderPicker() { + return render( + + + + + + , + ); +} + +async function openPicker() { + const trigger = screen.getByRole('button', { name: /open/i }); + await userEvent.click(trigger); +} + +describe('BibleVersionPicker', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + describe('loading state', () => { + it('should show spinner in version list when versions are loading', async () => { + setupDefaultMocks({ versionsLoading: true, filteredVersions: [] }); + renderPicker(); + await openPicker(); + + await waitFor(() => { + const dialog = screen.getByRole('dialog'); + const spinner = dialog.querySelector('svg.yv\\:animate-spin'); + expect(spinner).not.toBeNull(); + }); + + expect(screen.queryByText('No versions found')).toBeNull(); + }); + + it('should show spinner in version list when loading even with recent versions', async () => { + const recentVersions = [ + { + id: 111, + title: 'New International Version', + localized_abbreviation: 'NIV', + abbreviation: 'NIV', + }, + ]; + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => { + if (key === RECENT_VERSIONS_KEY) return JSON.stringify(recentVersions); + return null; + }); + + setupDefaultMocks({ versionsLoading: true, filteredVersions: [] }); + renderPicker(); + await openPicker(); + + await waitFor(() => { + const dialog = screen.getByRole('dialog'); + const spinners = dialog.querySelectorAll('svg.yv\\:animate-spin'); + // Badge spinner + version list spinner + expect(spinners.length).toBeGreaterThanOrEqual(2); + }); + + expect(screen.queryByText('No versions found')).toBeNull(); + + getItemSpy.mockRestore(); + }); + + it('should show spinner in badge when versions are loading', async () => { + setupDefaultMocks({ versionsLoading: true, filteredVersions: [] }); + renderPicker(); + await openPicker(); + + await waitFor(() => { + const languageButton = screen.getByRole('button', { name: /select language/i }); + const badge = languageButton.querySelector('[data-slot="badge"]'); + expect(badge).not.toBeNull(); + + const spinner = badge!.querySelector('svg'); + expect(spinner).not.toBeNull(); + + expect(badge!.textContent).not.toContain('0'); + }); + }); + }); + + describe('empty state', () => { + it('should show "No versions found" when not loading and results are empty', async () => { + setupDefaultMocks({ versionsLoading: false, filteredVersions: [] }); + renderPicker(); + await openPicker(); + + await waitFor(() => { + expect(screen.getByText('No versions found')).toBeInTheDocument(); + }); + + const dialog = screen.getByRole('dialog'); + const spinners = dialog.querySelectorAll('svg.yv\\:animate-spin'); + expect(spinners).toHaveLength(0); + }); + }); + + describe('loaded state', () => { + it('should show version count in badge when loaded', async () => { + setupDefaultMocks({ versionsLoading: false, filteredVersions: mockVersions }); + renderPicker(); + await openPicker(); + + await waitFor(() => { + const languageButton = screen.getByRole('button', { name: /select language/i }); + const badge = languageButton.querySelector('[data-slot="badge"]'); + expect(badge).not.toBeNull(); + expect(badge!.textContent).toBe('2'); + }); + }); + + it('should render version items when loaded', async () => { + setupDefaultMocks({ versionsLoading: false, filteredVersions: mockVersions }); + renderPicker(); + await openPicker(); + + await waitFor(() => { + expect( + screen.getByRole('listitem', { name: /new international version/i }), + ).toBeInTheDocument(); + expect( + screen.getByRole('listitem', { name: /new living translation/i }), + ).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index 7f9ddde8..0363da84 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -21,6 +21,7 @@ import { import { cn } from '@/lib/utils'; import { ArrowLeftIcon } from './icons/arrow-left'; import { GlobeIcon } from './icons/globe'; +import { LoaderIcon } from './icons/loader'; import { SearchIcon } from './icons/search'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; @@ -150,6 +151,7 @@ type BibleVersionPickerContextType = { addRecentVersion: (version: RecentVersion) => void; isPopoverOpen: boolean; setIsPopoverOpen: (open: boolean) => void; + versionsLoading: boolean; }; const BibleVersionPickerContext = createContext(null); @@ -219,7 +221,7 @@ function Root({ page_size: '*', }); - const { versions } = useVersions(selectedLanguageId); + const { versions, loading: versionsLoading } = useVersions(selectedLanguageId); const { versions: versionsLanguageInfo } = useVersions('*', undefined, { fields: ['id', 'language_tag'], page_size: '*', @@ -310,6 +312,7 @@ function Root({ addRecentVersion, isPopoverOpen, setIsPopoverOpen, + versionsLoading, }; return ( @@ -369,6 +372,7 @@ function Content() { suggestedLanguages, languages, setIsPopoverOpen, + versionsLoading, } = useBibleVersionPickerContext(); const providerTheme = useTheme(); const theme = background || providerTheme; @@ -420,7 +424,11 @@ function Content() { variant="secondary" className="yv:h-5 yv:min-w-5 yv:rounded-full yv:px-1 yv:font-mono yv:tabular-nums" > - {filteredVersions.length + filteredRecentVersions.length} + {versionsLoading ? ( + + ) : ( + filteredVersions.length + filteredRecentVersions.length + )} ); @@ -485,7 +493,7 @@ function Content() { )} {/* All Versions */} - {filteredVersions && filteredVersions.length > 0 ? ( + {filteredVersions.length > 0 ? (

All Versions

{filteredVersions.map((version: BibleVersion) => ( @@ -521,8 +529,11 @@ function Content() { ))}
- ) : null} - {!filteredVersions.length && !filteredRecentVersions.length ? ( + ) : versionsLoading ? ( +
+ +
+ ) : !filteredRecentVersions.length ? (
No versions found