feat(search): add game search page (#50)
Build, Test & Push Frontend / quality-check (push) Successful in 44s
Build, Test & Push Frontend / docker (push) Successful in 27s

Add /search to find games by name on IGDB.
A game in the library opens its game page. Other hits open a
detail view with an Add button. The query stays in the URL.
To fit the new nav icon on 360 px phones, the header padding is
smaller and the points chip hides while the Questions icon shows.
This commit is contained in:
lobo committed 2026-10-20 10:42:06 +02:00
1 parent 5be0d42bca
commit 60dcfe8992
13 files changed
+276 -16

No files matched your search

+2
View File
@@ -92,6 +92,8 @@ flowchart LR
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several
hits to add them at once through the import review. hits to add them at once through the import review.
The **Search** page (`/search`, nav item "Search") finds games by name: results with cover, year, platforms and an
"In library" badge; an own game opens its game page, any other hit opens a detail view with "Add".
- **Import** (`/import`): a list (one title per line) or a CSV (title, platform, hours or minutes, status). Core - **Import** (`/import`): a list (one title per line) or a CSV (title, platform, hours or minutes, status). Core
matches the lines with IGDB and saves them in the background; the page shows the steps (queued with the imports matches the lines with IGDB and saves them in the background; the page shows the steps (queued with the imports
before yours, searching on IGDB, your review, adding to the library, time to beat and moods, done) with the before yours, searching on IGDB, your review, adding to the library, time to beat and moods, done) with the
+5
View File
@@ -373,6 +373,7 @@
"NAV": { "NAV": {
"DASHBOARD": "Dashboard", "DASHBOARD": "Dashboard",
"LIBRARY": "Bibliothek", "LIBRARY": "Bibliothek",
"SEARCH": "Suche",
"SETTINGS": "Einstellungen", "SETTINGS": "Einstellungen",
"TO_DASHBOARD": "Zum Dashboard", "TO_DASHBOARD": "Zum Dashboard",
"MENU": "Menü", "MENU": "Menü",
@@ -841,6 +842,10 @@
"GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek." "GAME_NOT_FOUND": "Dieses Spiel ist nicht in deiner Bibliothek."
} }
}, },
"GAME_SEARCH": {
"TITLE": "Spiele finden",
"HINT": "Suche bei IGDB nach dem Namen. Eigene Spiele öffnen deine Spielseite, andere kannst du hinzufügen."
},
"CATALOG": { "CATALOG": {
"TITLE": "Spielekatalog", "TITLE": "Spielekatalog",
"SEARCH": "Nach Name suchen", "SEARCH": "Nach Name suchen",
+5
View File
@@ -373,6 +373,7 @@
"NAV": { "NAV": {
"DASHBOARD": "Dashboard", "DASHBOARD": "Dashboard",
"LIBRARY": "Library", "LIBRARY": "Library",
"SEARCH": "Search",
"SETTINGS": "Settings", "SETTINGS": "Settings",
"TO_DASHBOARD": "Go to dashboard", "TO_DASHBOARD": "Go to dashboard",
"MENU": "Menu", "MENU": "Menu",
@@ -841,6 +842,10 @@
"GAME_NOT_FOUND": "This game is not in your library." "GAME_NOT_FOUND": "This game is not in your library."
} }
}, },
"GAME_SEARCH": {
"TITLE": "Find games",
"HINT": "Search IGDB by name. Games you own open your game page; others can be added."
},
"CATALOG": { "CATALOG": {
"TITLE": "Game catalog", "TITLE": "Game catalog",
"SEARCH": "Search by name", "SEARCH": "Search by name",
+2
View File
@@ -30,6 +30,7 @@ import BacklogPage from './pages/BacklogPage.jsx';
import PlayNextPage from './pages/PlayNextPage.jsx'; import PlayNextPage from './pages/PlayNextPage.jsx';
import StatsPage from './pages/StatsPage.jsx'; import StatsPage from './pages/StatsPage.jsx';
import ImportPage from './pages/ImportPage.jsx'; import ImportPage from './pages/ImportPage.jsx';
import GameSearchPage from './pages/GameSearchPage.jsx';
import PointsPage from './pages/PointsPage.jsx'; import PointsPage from './pages/PointsPage.jsx';
import PublicProfilePage from './pages/PublicProfilePage.jsx'; import PublicProfilePage from './pages/PublicProfilePage.jsx';
import AppHeader from './components/AppHeader.jsx'; import AppHeader from './components/AppHeader.jsx';
@@ -86,6 +87,7 @@ function App() {
<Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/> <Route path="/dashboard" element={<WelcomeGate><Dashboard/></WelcomeGate>}/>
<Route path="/library" element={<GameLibrary/>}/> <Route path="/library" element={<GameLibrary/>}/>
<Route path="/library/:appid" element={<GameDetail/>}/> <Route path="/library/:appid" element={<GameDetail/>}/>
<Route path="/search" element={<GameSearchPage/>}/>
<Route path="/wishlist" element={<WishlistPage/>}/> <Route path="/wishlist" element={<WishlistPage/>}/>
<Route path="/backlog" element={<BacklogPage/>}/> <Route path="/backlog" element={<BacklogPage/>}/>
<Route path="/play-next" element={<PlayNextPage/>}/> <Route path="/play-next" element={<PlayNextPage/>}/>
+3 -3
View File
@@ -15,7 +15,7 @@ import GameSearch from './GameSearch.jsx';
import usePointsCheck from '../hooks/usePointsCheck.js'; import usePointsCheck from '../hooks/usePointsCheck.js';
import useStartImport from '../hooks/useStartImport.js'; import useStartImport from '../hooks/useStartImport.js';
import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js'; import {GAME_STATUSES, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import {addGame, hoursToMinutes} from '../utils/manualGames.js'; import {addGame, hitTitle, hoursToMinutes} from '../utils/manualGames.js';
import {preselectPlatform} from '../utils/platforms.js'; import {preselectPlatform} from '../utils/platforms.js';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx'; import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
@@ -86,7 +86,7 @@ export default function AddGamePanel({onClose, onAdded, initialQuery = ''}) {
); );
} }
function AddForm({hit, favorite, onBack, navigate, onAdded}) { export function AddForm({hit, favorite, onBack, navigate, onAdded}) {
const {t} = useTranslation(); const {t} = useTranslation();
const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite)); const [platform, setPlatform] = useState(() => preselectPlatform(null, hit.platforms, favorite));
// The favorite can arrive after the pick; it fills only a platform the user has not picked yet. // The favorite can arrive after the pick; it fills only a platform the user has not picked yet.
@@ -132,7 +132,7 @@ function AddForm({hit, favorite, onBack, navigate, onAdded}) {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-24 shrink-0"/> <GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-24 shrink-0"/>
<div className="@container min-w-0 flex-1 font-medium"> <div className="@container min-w-0 flex-1 font-medium">
<FitText text={`${hit.name}${hit.year ? ` (${hit.year})` : ''}`}/> <FitText text={hitTitle(hit)}/>
</div> </div>
</div> </div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
+5 -4
View File
@@ -34,6 +34,7 @@ const NAV_ITEMS = [
{to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard}, {to: '/dashboard', labelKey: 'NAV.DASHBOARD', Icon: LayoutDashboard},
// The wishlist and the backlog order are tabs of the library. // The wishlist and the backlog order are tabs of the library.
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']}, {to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']},
{to: '/search', labelKey: 'NAV.SEARCH', Icon: Search},
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices}, {to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
{to: '/questions', labelKey: 'NAV.QUESTIONS', Icon: CircleQuestionMark}, {to: '/questions', labelKey: 'NAV.QUESTIONS', Icon: CircleQuestionMark},
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn}, {to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
@@ -120,7 +121,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
}, [isAuthenticated, pathname]); }, [isAuthenticated, pathname]);
const shownQuestions = pathname === '/questions' ? 0 : openQuestions; const shownQuestions = pathname === '/questions' ? 0 : openQuestions;
// Below sm the Questions icon shows only with open questions, removed games or on its own page; the points chip then gives up its number. // Below sm the Questions icon shows only with open questions, removed games or on its own page; the points chip is hidden (/points stays in the menu).
const questionsIconHidden = openQuestions === 0 && !hasRemovedGames && pathname !== '/questions'; const questionsIconHidden = openQuestions === 0 && !hasRemovedGames && pathname !== '/questions';
const switchRole = async (role) => { const switchRole = async (role) => {
@@ -157,7 +158,7 @@ export default function AppHeader({isAuthenticated, onLogout}) {
return ( return (
<header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<PageContainer className="flex h-16 items-center gap-1 px-4 sm:gap-2 md:px-8"> <PageContainer className="flex h-16 items-center gap-1 px-3 sm:gap-2 sm:px-4 md:px-8">
<Link to={isAuthenticated ? '/dashboard' : '/'} aria-label="ToBePlayed" className="flex items-center gap-2 font-bold tracking-tight sm:mr-2 whitespace-nowrap"> <Link to={isAuthenticated ? '/dashboard' : '/'} aria-label="ToBePlayed" className="flex items-center gap-2 font-bold tracking-tight sm:mr-2 whitespace-nowrap">
<Logo className="h-8 w-auto shrink-0"/> <Logo className="h-8 w-auto shrink-0"/>
{/* On a narrow phone the menu and buttons need the room; the logo alone stays. */} {/* On a narrow phone the menu and buttons need the room; the logo alone stays. */}
@@ -188,10 +189,10 @@ export default function AppHeader({isAuthenticated, onLogout}) {
<Badge variant="secondary" className="absolute top-full left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none border-border">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge> <Badge variant="secondary" className="absolute top-full left-1/2 -translate-x-1/2 -translate-y-1/2 pointer-events-none border-border">{t('NAV.VIEW_AS.BADGE', {role: t(`ROLES.${viewingAs}`)})}</Badge>
)} )}
{isAuthenticated && wallet && ( {isAuthenticated && wallet && (
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className="min-w-0 shrink lg:shrink-0"> <Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className={cn('min-w-0 shrink lg:shrink-0', !questionsIconHidden && 'max-sm:hidden')}>
<Link to="/points" aria-label={`${t('POINTS.TITLE')}: ${wallet.balance}`}> <Link to="/points" aria-label={`${t('POINTS.TITLE')}: ${wallet.balance}`}>
<Coins className="text-primary"/> <Coins className="text-primary"/>
<span className={cn('min-w-0 truncate tabular-nums', !questionsIconHidden && 'max-sm:hidden')}>{wallet.balance}</span> <span className="min-w-0 truncate tabular-nums">{wallet.balance}</span>
</Link> </Link>
</Button> </Button>
)} )}
+4 -6
View File
@@ -34,20 +34,18 @@ async function loadChip(count) {
} }
describe('AppHeader points chip', () => { describe('AppHeader points chip', () => {
it('hides the number below sm while the questions icon shows, and keeps the balance as label', async () => { it('hides the chip below sm while the questions icon shows', async () => {
const chip = await loadChip(2); const chip = await loadChip(2);
await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'}); await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'});
expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135'); expect(chip.className).toContain('max-sm:hidden');
expect(screen.getByText('135').className).toContain('max-sm:hidden');
}); });
it('shows the number at every width while the questions icon is hidden', async () => { it('shows the chip at every width while the questions icon is hidden', async () => {
const chip = await loadChip(0); const chip = await loadChip(0);
await screen.findByTitle('NAV.QUESTIONS'); await screen.findByTitle('NAV.QUESTIONS');
expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135'); expect(chip.className).not.toContain('max-sm:hidden');
expect(screen.getByText('135').className).not.toContain('max-sm:hidden');
}); });
it('shows the balance as a link to the points page once the wallet is loaded', async () => { it('shows the balance as a link to the points page once the wallet is loaded', async () => {
+6
View File
@@ -55,6 +55,12 @@ describe('AppHeader', () => {
expect(screen.getByTitle('NAV.FOLLOWING').getAttribute('href')).toBe('/following'); expect(screen.getByTitle('NAV.FOLLOWING').getAttribute('href')).toBe('/following');
}); });
it('links to the game search when logged in', () => {
loggedInAs({role: 'USER', activeRole: 'USER'});
expect(screen.getByTitle('NAV.SEARCH').getAttribute('href')).toBe('/search');
});
it('links to the community stats when logged in', () => { it('links to the community stats when logged in', () => {
loggedInAs({role: 'USER', activeRole: 'USER'}); loggedInAs({role: 'USER', activeRole: 'USER'});
+5 -3
View File
@@ -6,7 +6,7 @@ import {Badge} from '@/components/ui/badge';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx'; import FitText from './FitText.jsx';
import {searchGames} from '../utils/manualGames.js'; import {hitTitle, searchGames} from '../utils/manualGames.js';
import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js'; import {MAX_GAME_SEARCH_LENGTH, MIN_GAME_SEARCH_LENGTH} from '../utils/limits.js';
const SEARCH_DEBOUNCE_MS = 400; const SEARCH_DEBOUNCE_MS = 400;
@@ -17,9 +17,11 @@ const SEARCH_DEBOUNCE_MS = 400;
* *
* @param hide hits not offered at all * @param hide hits not offered at all
* @param blockInLibrary hits already in the own library are shown, but cannot be picked * @param blockInLibrary hits already in the own library are shown, but cannot be picked
* @param markInLibrary hits in the own library get the "In library" badge; defaults to blockInLibrary
* @param selected with onToggle: igdb ids checked for a pick of several games; each hit gets a checkbox * @param selected with onToggle: igdb ids checked for a pick of several games; each hit gets a checkbox
*/ */
export default function GameSearch({query, onQueryChange, onPick, hide = () => false, blockInLibrary = false, export default function GameSearch({query, onQueryChange, onPick, hide = () => false, blockInLibrary = false,
markInLibrary = blockInLibrary,
selected, onToggle}) { selected, onToggle}) {
const {t} = useTranslation(); const {t} = useTranslation();
const {searchState, results} = useGameSearch(query); const {searchState, results} = useGameSearch(query);
@@ -62,13 +64,13 @@ export default function GameSearch({query, onQueryChange, onPick, hide = () => f
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/> <GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="@container min-w-0 flex-1"> <span className="@container min-w-0 flex-1">
<span className="block font-medium"> <span className="block font-medium">
<FitText text={`${hit.name}${hit.year ? ` (${hit.year})` : ''}`}/> <FitText text={hitTitle(hit)}/>
</span> </span>
<span className="block truncate text-xs text-muted-foreground"> <span className="block truncate text-xs text-muted-foreground">
{hit.platforms.join(' · ')} {hit.platforms.join(' · ')}
</span> </span>
</span> </span>
{blocked && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>} {markInLibrary && hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
</button> </button>
</li> </li>
); );
+69
View File
@@ -0,0 +1,69 @@
import {useEffect, useState} from 'react';
import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {ArrowLeft, Plus} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {AddForm} from './AddGamePanel.jsx';
import FitText from './FitText.jsx';
import GameCover from './GameCover.jsx';
import TimeToBeat from './TimeToBeat.jsx';
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
import {fetchPreview} from '../utils/catalog.js';
import {hitTitle} from '../utils/manualGames.js';
/**
* A search hit the user does not have: what the hit says, genres and time to beat when the catalog knows the game,
* and the add form behind an "Add" button.
*/
export default function SearchHitCard({hit, onBack}) {
const {t} = useTranslation();
const navigate = useNavigate();
const {user} = useCurrentUserContext();
const favorite = user?.settings?.favoritePlatform ?? null;
const [preview, setPreview] = useState(null);
const [adding, setAdding] = useState(false);
useEffect(() => {
let cancelled = false;
setPreview(null);
if (!hit.inCatalog) return undefined;
(async () => {
const {ok, data} = await fetchPreview(hit.appid);
if (!cancelled && ok && data) setPreview(data);
})();
return () => {
cancelled = true;
};
}, [hit.appid, hit.inCatalog]);
return (
<Card className="gap-4 p-5">
{adding ? (
<AddForm hit={hit} favorite={favorite} onBack={onBack} navigate={navigate}/>
) : (
<div className="flex flex-col gap-4 md:flex-row md:items-start">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-full md:w-72 md:shrink-0"/>
<div className="min-w-0 flex-1 space-y-3">
<h1 className="@container text-2xl font-bold">
<FitText text={hitTitle(hit)}/>
</h1>
{hit.platforms.length > 0 && (
<p className="text-sm text-muted-foreground">{hit.platforms.join(' · ')}</p>
)}
{preview?.genres?.length > 0 && (
<p className="text-sm text-muted-foreground">{preview.genres.join(', ')}</p>
)}
{preview && <TimeToBeat time={preview.timeToBeat}/>}
<p className="text-sm text-muted-foreground">{t('CATALOG.NOT_IN_LIBRARY')}</p>
<div className="flex flex-wrap gap-2">
<Button variant="ghost" onClick={onBack}><ArrowLeft/>{t('MANUAL_GAME.BACK_TO_SEARCH')}</Button>
<Button onClick={() => setAdding(true)}><Plus/>{t('CATALOG.ADD')}</Button>
</div>
</div>
</div>
)}
</Card>
);
}
+50
View File
@@ -0,0 +1,50 @@
import {useEffect, useState} from 'react';
import {useNavigate, useSearchParams} from 'react-router-dom';
import {useTranslation} from 'react-i18next';
import {Search} from 'lucide-react';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import GameSearch from '../components/GameSearch.jsx';
import PageContainer from '../components/PageContainer.jsx';
import SearchHitCard from '../components/SearchHitCard.jsx';
/**
* Find games by name on IGDB. A game the user owns opens its page; any other hit opens a detail view with "Add".
* The query lives in the URL, so it stays when the user comes back from a game page.
*/
export default function GameSearchPage() {
const {t} = useTranslation();
const navigate = useNavigate();
const [params, setParams] = useSearchParams();
const [query, setQuery] = useState(params.get('q') ?? '');
const [picked, setPicked] = useState(null);
// The input keeps its own state: a router update per keystroke runs in a transition and drops keys of a burst.
useEffect(() => {
const timer = setTimeout(() => setParams(query ? {q: query} : {}, {replace: true}), 400);
return () => clearTimeout(timer);
}, [query, setParams]);
const onQueryChange = (q) => {
setPicked(null);
setQuery(q);
};
const onPick = (hit) => (hit.inLibrary ? navigate(`/library/${hit.appid}`) : setPicked(hit));
return (
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
{picked ? (
<SearchHitCard hit={picked} onBack={() => setPicked(null)}/>
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Search className="size-4 text-primary"/>{t('GAME_SEARCH.TITLE')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">{t('GAME_SEARCH.HINT')}</p>
<GameSearch query={query} onQueryChange={onQueryChange} onPick={onPick} markInLibrary/>
</CardContent>
</Card>
)}
</PageContainer>
);
}
+118
View File
@@ -0,0 +1,118 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import GameSearchPage from './GameSearchPage.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const zelda = {
appid: -1025, igdbId: 1025, name: 'Zelda', year: 2017, imageUrl: 'https://images.igdb.com/z.jpg',
platforms: ['Nintendo Switch', 'Wii U'], inLibrary: false, inCatalog: true,
};
const portal = {...zelda, appid: 400, igdbId: 71, name: 'Portal', inLibrary: true};
function renderPage(found = json(200, {available: true, results: [zelda, portal]}), entries = ['/search']) {
fetchRoutes({
'GET /api/users/me': json(200, {role: 'USER', settings: {favoritePlatform: 'Nintendo Switch'}}),
'GET /api/library/game-search': found,
'GET /api/library/games/-1025/preview': json(200, {appid: -1025, name: 'Zelda', genres: ['Adventure'], timeToBeat: null}),
'POST /api/library/games': json(201, {appid: -1025}),
'GET /api/rewards/games/-1025': json(200, {balance: 15, missing: {}, paid: []}),
'GET /api/rewards/wallet': json(200, {balance: 15, catalog: [], owned: [], equipped: {}, history: []}),
});
return renderWithProviders(<GameSearchPage/>, {path: '/search', entries,
routes: {'/library/:appid': <p>detail page</p>}});
}
const searchCalls = () => fetch.mock.calls.filter(([url]) => url.includes('game-search'));
const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GameSearchPage', () => {
it('doesNotSearchBelowTwoCharacters', async () => {
renderPage();
search('z');
await new Promise((resolve) => setTimeout(resolve, 600));
expect(searchCalls()).toHaveLength(0);
});
it('showsHitsWithYearPlatformsAndInLibraryBadge', async () => {
renderPage();
search('zel');
expect(await screen.findByRole('button', {name: /Zelda \(2017\)/})).toBeTruthy();
expect(screen.getAllByText('Nintendo Switch · Wii U').length).toBe(2);
const portalButton = screen.getByRole('button', {name: /Portal/});
expect(portalButton.textContent).toContain('MANUAL_GAME.IN_LIBRARY');
expect(portalButton.disabled).toBe(false);
expect(screen.getByRole('button', {name: /Zelda/}).textContent).not.toContain('MANUAL_GAME.IN_LIBRARY');
});
it('opensTheOwnGamePageForAGameInTheLibrary', async () => {
const {router} = renderPage();
search('port');
fireEvent.click(await screen.findByRole('button', {name: /Portal/}));
expect(await screen.findByText('detail page')).toBeTruthy();
expect(router.state.location.pathname).toBe('/library/400');
});
it('showsTheDetailAndAddsAGameNotInTheLibrary', async () => {
renderPage();
search('zelda');
fireEvent.click(await screen.findByRole('button', {name: /Zelda/}));
expect(await screen.findByText('Adventure')).toBeTruthy();
expect(screen.getByText('CATALOG.NOT_IN_LIBRARY')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'CATALOG.ADD'}));
fireEvent.click(await screen.findByRole('button', {name: 'MANUAL_GAME.ADD'}));
expect(await screen.findByText('detail page')).toBeTruthy();
const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'POST');
expect(JSON.parse(init.body)).toEqual({
igdbId: 1025, platform: 'Nintendo Switch', status: 'BACKLOG', playtimeMinutes: 0});
});
it('keepsTheQueryInTheUrlAndSearchesOnEntry', async () => {
renderPage(undefined, ['/search?q=zelda']);
expect(screen.getByRole('searchbox').value).toBe('zelda');
expect(await screen.findByRole('button', {name: /Zelda/})).toBeTruthy();
expect(searchCalls()).toHaveLength(1);
});
it('keepsEveryKeystrokeOfABurst', () => {
renderPage();
const input = screen.getByRole('searchbox');
act(() => {
fireEvent.change(input, {target: {value: 'z'}});
fireEvent.change(input, {target: {value: `${input.value}e`}});
});
expect(input.value).toBe('ze');
});
it.each([
[json(200, {available: true, results: []}), 'MANUAL_GAME.NO_HITS'],
[json(200, {available: false, results: []}), 'MANUAL_GAME.UNAVAILABLE'],
[json(502, {}), 'MANUAL_GAME.SEARCH_FAILED'],
])('showsEmptyUnavailableAndFailedStates_%#', async (found, text) => {
renderPage(found);
search('zelda');
await waitFor(() => expect(screen.getByText(text)).toBeTruthy());
});
});
+2
View File
@@ -15,6 +15,8 @@ export const hoursToMinutes = (hours) => {
return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : 0; return Number.isFinite(value) && value > 0 ? Math.round(value * 60) : 0;
}; };
export const hitTitle = (hit) => `${hit.name}${hit.year ? ` (${hit.year})` : ''}`;
// The calls return the raw response; the caller handles the status. // The calls return the raw response; the caller handles the status.
export const searchGames = (query, signal) => export const searchGames = (query, signal) =>
apiFetch(`${LIBRARY_URL()}/game-search?${new URLSearchParams({q: query.trim()})}`, {signal}); apiFetch(`${LIBRARY_URL()}/game-search?${new URLSearchParams({q: query.trim()})}`, {signal});