Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/red-ducks-accept.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@youversion/platform-react-hooks': minor
'@youversion/platform-react-ui': minor
'@youversion/platform-core': minor
---

Add recently used versions to the Bible Version Picker

- Display up to 3 recently selected Bible versions at the top of the picker
- Persist recent version selections in localStorage
- Recent versions are searchable and excluded from the main "All Versions" list
58 changes: 58 additions & 0 deletions packages/hooks/src/useFilteredVersions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,64 @@ describe('useFilteredVersions', () => {
});
});

describe('recent versions exclusion', () => {
it('should not exclude any versions when recentVersions is undefined', () => {
const { result } = renderHook(() => useFilteredVersions(mockVersions, '', '*', undefined));

expect(result.current).toEqual(mockVersions);
expect(result.current).toHaveLength(5);
});

it('should exclude recent versions from the results', () => {
const recentVersions = [
{ id: 1, title: 'King James Version', localized_abbreviation: 'KJV' },
];

const { result } = renderHook(() =>
useFilteredVersions(mockVersions, '', '*', recentVersions),
);

expect(result.current).toHaveLength(4);
expect(result.current.find((v) => v.id === 1)).toBeUndefined();
});

it('should exclude multiple recent versions from the results', () => {
const recentVersions = [
{ id: 1, title: 'King James Version', localized_abbreviation: 'KJV' },
{ id: 2, title: 'New International Version', localized_abbreviation: 'NIV' },
];

const { result } = renderHook(() =>
useFilteredVersions(mockVersions, '', '*', recentVersions),
);

expect(result.current).toHaveLength(3);
expect(result.current.find((v) => v.id === 1)).toBeUndefined();
expect(result.current.find((v) => v.id === 2)).toBeUndefined();
});

it('should not exclude any versions when recentVersions is empty array', () => {
const { result } = renderHook(() => useFilteredVersions(mockVersions, '', '*', []));

expect(result.current).toEqual(mockVersions);
expect(result.current).toHaveLength(5);
});

it('should exclude recent versions even when they match search term', () => {
const recentVersions = [
{ id: 2, title: 'New International Version', localized_abbreviation: 'NIV' },
];

const { result } = renderHook(() =>
useFilteredVersions(mockVersions, 'Version', '*', recentVersions),
);

// "Version" matches KJV and NIV, but NIV is excluded as a recent version
expect(result.current).toHaveLength(1);
expect(result.current[0]?.title).toBe('King James Version');
});
});

describe('memoization', () => {
it('should return the same reference when inputs do not change', () => {
const { result, rerender } = renderHook(
Expand Down
9 changes: 8 additions & 1 deletion packages/hooks/src/useFilteredVersions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export function useFilteredVersions(
versions: BibleVersion[],
searchTerm: string,
selectedLanguage: string,
recentVersions?: Pick<BibleVersion, 'id' | 'title' | 'localized_abbreviation'>[],
): BibleVersion[] {
return useMemo(() => {
let result = [...versions];
Expand All @@ -33,6 +34,12 @@ export function useFilteredVersions(
);
}

// Recently Used Filter
if (recentVersions) {
const recentVersionIds = recentVersions.map((version) => version.id);
result = result.filter((version) => !recentVersionIds.includes(version.id));
}

return result;
}, [versions, searchTerm, selectedLanguage]);
}, [versions, recentVersions, searchTerm, selectedLanguage]);
}
161 changes: 160 additions & 1 deletion packages/ui/src/components/bible-version-picker.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
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 } from 'storybook/test';
import { screen, userEvent, within, expect, waitFor } from 'storybook/test';
import { BookOpen } from 'lucide-react';
import { Button } from './ui/button';
import { RECENT_VERSIONS_KEY } from './bible-version-picker';

type StoredRecentVersion = {
id: number;
title: string;
localized_abbreviation: string;
};

function getStoredRecentVersions(): StoredRecentVersion[] {
try {
return JSON.parse(localStorage.getItem(RECENT_VERSIONS_KEY) || '[]') as StoredRecentVersion[];
} catch {
return [];
}
}

const withLayout = (Story: React.ComponentType) => (
<div className="yv:h-screen yv:flex yv:justify-center yv:items-end yv:p-12">
Expand All @@ -28,6 +43,9 @@ const meta = {
layout: 'fullscreen',
},
decorators: [withLayout],
beforeEach: () => {
localStorage.removeItem(RECENT_VERSIONS_KEY);
},
argTypes: {
versionId: {
control: 'number',
Expand Down Expand Up @@ -174,3 +192,144 @@ export const RealAPI: Story = {
},
},
};

export const RecentVersionsSelection: Story = {
args: {
versionId: 111,
background: 'light',
},
tags: ['integration'],
play: async () => {
// Open popover
const trigger = await screen.findByRole('button', { name: /NIV/i }, { timeout: 10_000 });
await userEvent.click(trigger);

// Verify initially there's no recent versions section
const dialog = await screen.findByRole('dialog');
await expect(dialog).toBeInTheDocument();
await expect(screen.queryByText('Recently Used Versions')).not.toBeInTheDocument();

// Select a different version (Amplified Bible)
const ampOption = await screen.findByRole('listitem', { name: /Amplified Bible/i });
await userEvent.click(ampOption);

// Reopen the popover and verify the recent versions section now appears
const updatedTrigger = await screen.findByRole('button', { name: /AMP/i });
await expect(updatedTrigger).toBeInTheDocument();
await userEvent.click(updatedTrigger);

await expect(await screen.findByText('Recently Used Versions')).toBeInTheDocument();
const recentVersionList = await screen.findByTestId('recent-version-list');
await expect(within(recentVersionList).getByText(/Amplified Bible/i)).toBeInTheDocument();

// Verify localStorage was updated
let storedVersions = getStoredRecentVersions();
await expect(storedVersions.length).toBe(1);
await expect(storedVersions[0]?.title).toContain('Amplified');

// Now select NIV from the main list
const nivOption = await screen.findByRole('listitem', {
name: /New International Version 2011/i,
});
await userEvent.click(nivOption);

// Reopen and select AMP from recent versions
const nivTrigger = await screen.findByRole('button', { name: /NIV/i });
await expect(nivTrigger).toBeInTheDocument();
await userEvent.click(nivTrigger);

const recentList = await screen.findByTestId('recent-version-list');
const ampRecentOption = within(recentList).getByRole('listitem', {
name: /Amplified Bible/i,
});
await userEvent.click(ampRecentOption);

// Verify AMP is selected and moved to top of recent versions
const ampTrigger = await screen.findByRole('button', { name: /AMP/i });
await expect(ampTrigger).toBeInTheDocument();

storedVersions = getStoredRecentVersions();
await expect(storedVersions[0]?.localized_abbreviation).toBe('AMP');
},
};

export const RecentVersionsSearchFilter: Story = {
args: {
versionId: 111,
background: 'light',
},
tags: ['integration'],
beforeEach: () => {
// Pre-populate localStorage with recent versions before component mounts
localStorage.setItem(
RECENT_VERSIONS_KEY,
JSON.stringify([
{ id: 1588, title: 'Amplified Bible', localized_abbreviation: 'AMP' },
{ id: 12, title: 'American Standard Version', localized_abbreviation: 'ASV' },
]),
);
},
play: async () => {
// Open popover
const trigger = await screen.findByRole('button', { name: /NIV/i }, { timeout: 10_000 });
await userEvent.click(trigger);

// Verify recent versions are displayed
await expect(await screen.findByText('Recently Used Versions')).toBeInTheDocument();
const recentVersionList = await screen.findByTestId('recent-version-list');
await expect(within(recentVersionList).getByText(/Amplified Bible/i)).toBeInTheDocument();
await expect(
within(recentVersionList).getByText(/American Standard Version/i),
).toBeInTheDocument();

// Search for "ASV"
const searchInput = screen.getByPlaceholderText('Search');
await userEvent.type(searchInput, 'ASV', { delay: 50 });

// Verify only ASV appears in recent versions after filtering
await waitFor(async () => {
await expect(screen.queryByText(/Amplified Bible/i)).not.toBeInTheDocument();
});
await expect(
within(await screen.findByTestId('recent-version-list')).getByText(
/American Standard Version/i,
),
).toBeInTheDocument();
},
};

export const RecentVersionsMaxLimit: Story = {
args: {
versionId: 111,
background: 'light',
},
tags: ['integration'],
beforeEach: () => {
// Pre-populate localStorage with 3 recent versions (max limit) before component mounts
localStorage.setItem(
RECENT_VERSIONS_KEY,
JSON.stringify([
{ id: 1588, title: 'Amplified Bible', localized_abbreviation: 'AMP' },
{ id: 100, title: 'New American Standard Bible 1995', localized_abbreviation: 'NASB1995' },
{ id: 12, title: 'American Standard Version', localized_abbreviation: 'ASV' },
]),
);
},
play: async () => {
// Open popover
const trigger = await screen.findByRole('button', { name: /NIV/i }, { timeout: 10_000 });
await userEvent.click(trigger);

// Select a new version (NASB2020) - this should push out ASV
const nasbOption = await screen.findByRole('listitem', {
name: /New American Standard Bible 2020/i,
});
await userEvent.click(nasbOption);

// Verify localStorage was updated with max 3 versions, NASB2020 at the top
const storedVersions = getStoredRecentVersions();
await expect(storedVersions.length).toBe(3);
await expect(storedVersions[0]?.title).toContain('New American Standard Bible 2020');
await expect(storedVersions.map((v) => v.localized_abbreviation)).not.toContain('ASV');
},
};
Loading