From df4d347896a76879cde2efd36a55daf5b7a5b50d Mon Sep 17 00:00:00 2001 From: Kyleasmth <45497561+Kyleasmth@users.noreply.github.com> Date: Wed, 18 Feb 2026 14:35:09 -0800 Subject: [PATCH 1/3] feat(ui): add tests for BibleVersionPicker displays a loading spinner and handled empty states --- .../components/bible-version-picker.test.tsx | 194 ++++++++++++++++++ .../src/components/bible-version-picker.tsx | 18 +- 2 files changed, 209 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/components/bible-version-picker.test.tsx 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..381e982a --- /dev/null +++ b/packages/ui/src/components/bible-version-picker.test.tsx @@ -0,0 +1,194 @@ +/** + * @vitest-environment jsdom + */ +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 { + // eslint-disable-next-line @typescript-eslint/no-empty-function + observe() {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + unobserve() {} + // eslint-disable-next-line @typescript-eslint/no-empty-function + disconnect() {} +} +globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; + +import { BibleVersionPicker } 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 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..d33aaef7 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 + )} ); @@ -524,7 +532,11 @@ function Content() { ) : null} {!filteredVersions.length && !filteredRecentVersions.length ? (
- No versions found + {versionsLoading ? ( + + ) : ( + 'No versions found' + )}
) : null} From 69cbbde2600f846f2be9dc86581f4fd1e0824c63 Mon Sep 17 00:00:00 2001 From: Kyleasmth <45497561+Kyleasmth@users.noreply.github.com> Date: Thu, 19 Feb 2026 09:41:56 -0800 Subject: [PATCH 2/3] Add a Storybook Loading story for visual QA --- .../bible-version-picker.stories.tsx | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) 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, From f3e4fbe1f03434cbf1795b3e3dc42c05b7d00b50 Mon Sep 17 00:00:00 2001 From: Kyleasmth <45497561+Kyleasmth@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:13:28 -0800 Subject: [PATCH 3/3] Add loading spinner test for BibleVersionPicker with recent versions, disable lint for the entire bible picker test file. --- .../components/bible-version-picker.test.tsx | 36 ++++++++++++++++--- .../src/components/bible-version-picker.tsx | 15 ++++---- 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/components/bible-version-picker.test.tsx b/packages/ui/src/components/bible-version-picker.test.tsx index 381e982a..51294de6 100644 --- a/packages/ui/src/components/bible-version-picker.test.tsx +++ b/packages/ui/src/components/bible-version-picker.test.tsx @@ -1,22 +1,20 @@ /** * @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 { - // eslint-disable-next-line @typescript-eslint/no-empty-function observe() {} - // eslint-disable-next-line @typescript-eslint/no-empty-function unobserve() {} - // eslint-disable-next-line @typescript-eslint/no-empty-function disconnect() {} } globalThis.ResizeObserver = ResizeObserverMock as unknown as typeof ResizeObserver; -import { BibleVersionPicker } from './bible-version-picker'; +import { BibleVersionPicker, RECENT_VERSIONS_KEY } from './bible-version-picker'; import { useVersions, useVersion, @@ -128,6 +126,36 @@ describe('BibleVersionPicker', () => { 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(); diff --git a/packages/ui/src/components/bible-version-picker.tsx b/packages/ui/src/components/bible-version-picker.tsx index d33aaef7..0363da84 100644 --- a/packages/ui/src/components/bible-version-picker.tsx +++ b/packages/ui/src/components/bible-version-picker.tsx @@ -493,7 +493,7 @@ function Content() { )} {/* All Versions */} - {filteredVersions && filteredVersions.length > 0 ? ( + {filteredVersions.length > 0 ? (

All Versions

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