feat(library): set status and rating in one step (#56)

Library rows: the status badge is a menu that saves at once.
Game page: status and rating in the top card, saved on click.
Status changes and hiding a game show an Undo toast.
Other game actions moved into a "..." menu; split and unlink ask first.
New sorts: name, last played, time to beat; "last synced" is gone.
Page numbers and 44 px touch targets on phones.
This commit is contained in:
lobo committed 2026-10-21 11:09:44 +02:00
1 parent f9f0d94e2c
commit 45f5087227
32 files changed
+1055 -316

No files matched your search

+9 -5
View File
@@ -75,8 +75,10 @@ flowchart LR
```
- **Landing page**: next to the community numbers and the top games, three blocks: games per platform, top genres and
finished games per year of all users.
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements. A game you own on several
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set matches on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, newest
in library, rating, priority, name, last played or time to beat (games never played or without a time to beat come last), page
numbers with Previous / Next. Each row shows the unlocked achievements, and its status badge is a menu: choosing a status saves at once
(with an Undo in the toast and the points check). While sorted by rating or priority the row shows them. A game you own on several
platforms is one row (Core's counted edition) with a small badge per platform next to the title (`PlatformIcons`:
ST, PS or the platform name, from `platforms[]`; lucide has no brand icons).
- **Editions**: the editions of one game (Steam and PS5 copy, remaster, port) are linked automatically from IGDB's
@@ -314,7 +316,7 @@ flowchart LR
- `/questions` (`QuestionsPage`, nav item with the open count; the badge is hidden on the page, whose title shows the
live count) lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides
which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets
the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`.
the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `GameHeroCard`.
Below the cards, a closed "Removed games" section (`RemovedGamesSection`, `GET /api/library/removed-games`) has a
Restore button per game (`POST /api/library/games/{appid}/restore`). The toast after "Remove from library" on a
manual game has an Undo that restores it the same way.
@@ -339,10 +341,12 @@ flowchart LR
Detail -->|"Same game as... (request)"| Request["Core POST /api/library/games/{appid}/link-requests"]
Detail -->|"Unlink (auditor)"| Unlink["Core DELETE /api/library/games/{appid}/link"]
Split --> Reload["reload the detail"]
Backlog["BacklogCard save"] -->|"game has editions"| Reload
Backlog["GameHeroCard / BacklogCard save"] -->|"game has editions"| Reload
```
- The game page edits at once: status (menu with Undo) and rating in `GameHeroCard`, priority in `BacklogCard`; only the
notes there have a Save button. Background, banner, profile art, hide (with Undo) and the auditor tools sit in the "..." menu of the hero card.
- `EditionsCard` shows "Not the same game" on every game that has other editions in the own library. The call goes
through `src/utils/links.js` (`splitGame`); `404 NOT_LINKED` shows the generic error toast.
through `src/utils/links.js` (`splitGame`) after a confirm dialog (unlink asks too); `404 NOT_LINKED` shows the generic error toast.
```mermaid
flowchart LR
+9 -1
View File
@@ -289,7 +289,10 @@
"SORT_BY": "Sortieren:",
"SORT_PLAYTIME_DESC": "Meistgespielt",
"SORT_PLAYTIME_ASC": "Am wenigsten gespielt",
"SORT_LAST_SEEN": "Zuletzt synchronisiert",
"SORT_NAME": "Name A–Z",
"SORT_LAST_PLAYED": "Zuletzt gespielt",
"SORT_TIME_TO_BEAT": "Kürzeste Spieldauer",
"PAGINATION": "Seiten",
"SORT_FIRST_SEEN": "Neueste in Bibliothek",
"PREV": "Zurück",
"NEXT": "Weiter",
@@ -318,6 +321,7 @@
"EDITIONS": "{{count}} Editionen in deiner Bibliothek"
},
"GAME_DETAIL": {
"MORE": "Weitere Aktionen",
"BACK": "Zurück",
"BACK_TO_LIBRARY": "Zur Bibliothek",
"LOADING": "Lade Spiel …",
@@ -840,6 +844,10 @@
"NEVER": "nie"
},
"LINKS": {
"SPLIT_CONFIRM_TITLE": "Nicht dasselbe Spiel?",
"SPLIT_CONFIRM_TEXT": "Das Spiel wird ein eigener Eintrag in deiner Bibliothek mit eigenem Status, eigener Bewertung und eigenen Notizen. Die Verknüpfung für andere Nutzer bleibt.",
"UNLINK_CONFIRM_TITLE": "Für alle trennen?",
"UNLINK_CONFIRM_TEXT": "Entfernt die Verknüpfung dieser Spiele für alle Nutzer.",
"ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:",
"SAME_AS": "Dasselbe Spiel wie...",
"LINK_WITH": "Verknüpfen mit...",
+9 -1
View File
@@ -289,7 +289,10 @@
"SORT_BY": "Sort by:",
"SORT_PLAYTIME_DESC": "Most played",
"SORT_PLAYTIME_ASC": "Least played",
"SORT_LAST_SEEN": "Last synced",
"SORT_NAME": "Name A–Z",
"SORT_LAST_PLAYED": "Last played",
"SORT_TIME_TO_BEAT": "Shortest to beat",
"PAGINATION": "Pages",
"SORT_FIRST_SEEN": "Newest in library",
"PREV": "Previous",
"NEXT": "Next",
@@ -318,6 +321,7 @@
"EDITIONS": "{{count}} editions in your library"
},
"GAME_DETAIL": {
"MORE": "More actions",
"BACK": "Back",
"BACK_TO_LIBRARY": "Back to library",
"LOADING": "Loading game …",
@@ -840,6 +844,10 @@
"NEVER": "never"
},
"LINKS": {
"SPLIT_CONFIRM_TITLE": "Not the same game?",
"SPLIT_CONFIRM_TEXT": "The game becomes a separate entry in your library with its own status, rating and notes. The link for other users stays.",
"UNLINK_CONFIRM_TITLE": "Unlink for everyone?",
"UNLINK_CONFIRM_TEXT": "Removes the link between these games for every user.",
"ALSO_IN_LIBRARY": "Also in your library:",
"SAME_AS": "Same game as...",
"LINK_WITH": "Link with...",
+24 -84
View File
@@ -1,6 +1,5 @@
import {useEffect, useState} from 'react';
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ListTodo, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
@@ -8,58 +7,25 @@ import {Card} from '@/components/ui/card';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Textarea} from '@/components/ui/textarea';
import RatingStars from './RatingStars.jsx';
import {STATUS_META} from './GameStatusBadge.jsx';
import {changedFields, NO_PRIORITY, saveBacklogEntry} from '../utils/backlog.js';
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
import {changedFields, NO_PRIORITY} from '../utils/backlog.js';
import {GAME_PRIORITIES, MAX_NOTES_LENGTH} from '../utils/limits.js';
import useSaveBacklogField from '../hooks/useSaveBacklogField.js';
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
const toForm = (entry) => ({
status: entry.status ?? 'BACKLOG',
rating: entry.rating ?? 0,
notes: entry.notes ?? '',
priority: entry.priority ?? NO_PRIORITY,
});
/**
* Priority (saved on change) and notes (saved with the Save button). Status and rating live in the hero card.
*
* @param onSaved gets the saved backlog entry
*/
export default function BacklogCard({game, onSaved}) {
const {t} = useTranslation();
const [saved, setSaved] = useState(() => toForm(game));
const [form, setForm] = useState(saved);
const [notes, setNotes] = useState(game.notes ?? '');
const {save, pending, busy} = useSaveBacklogField(game.appid, {onSaved});
// The PlayingHint can change the status from outside; keep the other unsaved edits.
useEffect(() => {
setSaved((current) => (current.status === game.status ? current : {...current, status: game.status}));
setForm((current) => (current.status === game.status ? current : {...current, status: game.status}));
}, [game.status]);
const [busy, setBusy] = useState(false);
const changes = changedFields(saved, form);
const dirty = Object.keys(changes).length > 0;
const savingNotes = pending?.field === 'notes';
const dirty = 'notes' in changedFields({notes: game.notes}, {notes});
useUnsavedChanges(dirty);
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
const save = async () => {
setBusy(true);
try {
const response = await saveBacklogEntry(game.appid, changes);
if (response.status === 401) return;
if (!response.ok) {
toast.error(t('BACKLOG.SAVE_FAILED'));
return;
}
const entry = await response.json();
const next = toForm(entry);
setSaved(next);
onSaved?.(entry);
setForm(next);
toast.success(t('BACKLOG.SAVED'));
} catch (err) {
console.error('backlog save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
const priority = pending?.field === 'priority' ? pending.value : (game.priority ?? NO_PRIORITY);
return (
<Card className="p-6 gap-5">
@@ -68,30 +34,9 @@ export default function BacklogCard({game, onSaved}) {
{t('BACKLOG.TITLE')}
</h2>
{/* Locked while saving: the answer replaces the form, so an edit made meanwhile would be lost. */}
<fieldset disabled={busy} className="contents">
<div className="grid gap-5 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="backlog-status">{t('BACKLOG.STATUS_LABEL')}</Label>
<Select value={form.status} onValueChange={set('status')}>
<SelectTrigger id="backlog-status" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
{GAME_STATUSES.map((s) => {
const {icon: Icon, text} = STATUS_META[s];
return (
<SelectItem key={s} value={s}>
<Icon className={text}/>
{t(`BACKLOG.STATUS.${s}`)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<div className="space-y-2 sm:max-w-xs">
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
<Select value={form.priority} onValueChange={set('priority')}>
<Select value={priority} onValueChange={(value) => save('priority', value)} disabled={busy}>
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent>
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
@@ -102,34 +47,29 @@ export default function BacklogCard({game, onSaved}) {
</Select>
</div>
<div className="space-y-2">
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
<RatingStars value={form.rating} onChange={set('rating')} labelledBy="backlog-rating"/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-baseline justify-between">
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
<span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {MAX_NOTES_LENGTH}</span>
<span className="text-xs text-muted-foreground tabular-nums">{notes.length} / {MAX_NOTES_LENGTH}</span>
</div>
{/* Locked while saving: an edit made meanwhile would be lost. */}
<Textarea
id="backlog-notes"
rows={3}
maxLength={MAX_NOTES_LENGTH}
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
value={form.notes}
onChange={(e) => set('notes')(e.target.value)}
value={notes}
disabled={savingNotes}
onChange={(e) => setNotes(e.target.value)}
/>
</div>
</fieldset>
<div className="flex justify-end gap-2">
<Button variant="ghost" onClick={() => setForm(saved)} disabled={!dirty || busy}>
<Button variant="ghost" onClick={() => setNotes(game.notes ?? '')} disabled={!dirty || busy}>
{t('BACKLOG.RESET')}
</Button>
<Button onClick={save} disabled={!dirty || busy}>
{busy && <Loader2 className="animate-spin"/>}
<Button onClick={() => save('notes', notes)} disabled={!dirty || busy}>
{savingNotes && <Loader2 className="animate-spin"/>}
{t('BACKLOG.SAVE')}
</Button>
</div>
+70 -18
View File
@@ -2,6 +2,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import BacklogCard from './BacklogCard.jsx';
@@ -10,45 +11,88 @@ vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {appid: 220, status: 'PLAYING', rating: 4, notes: 'old', priority: null};
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
const PATCH = 'PATCH /api/library/games/220/backlog';
const openPriority = () => fireEvent.keyDown(screen.getByRole('combobox'), {key: 'Enter'});
const patchBodies = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PATCH').map(([, init]) => JSON.parse(init.body));
describe('BacklogCard', () => {
beforeEach(() => {
// jsdom lacks both; the Radix select needs them when it opens.
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.scrollIntoView ??= () => {};
localStorage.setItem('tbp_session', '1');
// Core answers the whole entry; the fields not sent come from the server.
fetchRoutes({'PATCH /api/library/games/220/backlog': (url, init) => json(200, {...game, ...JSON.parse(init.body)})});
fetchRoutes({[PATCH]: (url, init) => json(200, {...game, ...JSON.parse(init.body)})});
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
localStorage.clear();
});
it('enables saving only after a change', () => {
it('showsNeitherStatusNorRating', () => {
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
expect(screen.queryByText('BACKLOG.STATUS_LABEL')).toBeNull();
expect(screen.queryByText('BACKLOG.RATING_LABEL')).toBeNull();
expect(screen.queryByRole('button', {name: /BACKLOG.RATING_STARS/})).toBeNull();
});
it('savesAPriorityAtOnceAndOnlyThatField', async () => {
const onSaved = vi.fn();
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
openPriority();
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.HIGH'}));
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({...game, priority: 'HIGH'}));
expect(patchBodies()).toEqual([{priority: 'HIGH'}]);
});
it('clearsThePriorityWithNull', async () => {
const onSaved = vi.fn();
render(<MemoryRouter><BacklogCard game={{...game, priority: 'LOW'}} onSaved={onSaved}/></MemoryRouter>);
openPriority();
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.NONE'}));
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(patchBodies()).toEqual([{priority: null}]);
});
it('showsTheSavedPriorityAgainWhenSavingFails', async () => {
fetchRoutes({[PATCH]: json(500, {})});
render(<MemoryRouter><BacklogCard game={{...game, priority: 'LOW'}}/></MemoryRouter>);
openPriority();
fireEvent.click(await screen.findByRole('option', {name: 'BACKLOG.PRIORITY.HIGH'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
expect(screen.getByRole('combobox').textContent).toContain('BACKLOG.PRIORITY.LOW');
});
it('enablesSavingOnlyAfterANotesChange', () => {
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
expect(saveButton().disabled).toBe(true);
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
fireEvent.change(screen.getByRole('textbox'), {target: {value: 'new notes'}});
expect(saveButton().disabled).toBe(false);
});
it('clears the rating on a click on the current star and sends blank notes as null', async () => {
it('sendsBlankNotesAsNull', async () => {
const onSaved = vi.fn();
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
fireEvent.click(saveButton());
await waitFor(() => expect(onSaved).toHaveBeenCalled());
const [url, init] = fetch.mock.calls[0];
expect(url).toMatch(/\/api\/library\/games\/220\/backlog$/);
expect(init.method).toBe('PATCH');
expect(JSON.parse(init.body)).toEqual({rating: null, notes: null});
expect(saveButton().disabled).toBe(true);
expect(patchBodies()).toEqual([{notes: null}]);
});
it('sends only the changed notes, so an older tab does not overwrite the other fields', async () => {
it('sendsOnlyTheChangedNotes', async () => {
const onSaved = vi.fn();
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
@@ -56,31 +100,39 @@ describe('BacklogCard', () => {
fireEvent.click(saveButton());
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({notes: 'new notes'});
expect(patchBodies()).toEqual([{notes: 'new notes'}]);
});
it('locks the fields while the save runs, so nothing typed then is lost', async () => {
it('locksTheNotesWhileTheSaveRuns', async () => {
let answer;
fetchRoutes({'PATCH /api/library/games/220/backlog': (url, init) => new Promise((resolve) => {
fetchRoutes({[PATCH]: (url, init) => new Promise((resolve) => {
answer = () => resolve(json(200, {...game, ...JSON.parse(init.body)}));
})});
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
fireEvent.change(screen.getByRole('textbox'), {target: {value: 'new notes'}});
fireEvent.click(saveButton());
// :disabled, not .disabled: the fieldset locks them, not their own attribute.
expect(screen.getByRole('textbox').matches(':disabled')).toBe(true);
expect(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:1'}).matches(':disabled')).toBe(true);
await act(async () => answer());
expect(screen.getByRole('textbox').matches(':disabled')).toBe(false);
});
it('does not count blank notes instead of none as a change', () => {
it('doesNotCountBlankNotesInsteadOfNoneAsAChange', () => {
render(<MemoryRouter><BacklogCard game={{...game, notes: null}}/></MemoryRouter>);
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
expect(saveButton().disabled).toBe(true);
});
it('resetBringsBackTheSavedNotes', () => {
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
fireEvent.change(screen.getByRole('textbox'), {target: {value: 'draft'}});
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RESET'}));
expect(screen.getByRole('textbox').value).toBe('old');
expect(saveButton().disabled).toBe(true);
});
});
+7 -8
View File
@@ -7,6 +7,7 @@ import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import GameStatusBadge from './GameStatusBadge.jsx';
import UnlinkDialog from './UnlinkDialog.jsx';
import FitText from './FitText.jsx';
import {splitGame, unlinkGame} from '../utils/links.js';
import {platformLabel} from '../utils/platforms.js';
@@ -65,16 +66,14 @@ export default function EditionsCard({game, auditor, onRequest, onChanged}) {
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
</Button>
{game.editions?.length > 0 && (
<Button type="button" variant="outline" size="sm" disabled={busy}
onClick={() => run(splitGame, 'LINKS.SPLIT_DONE')}>
<Unlink/>{t('LINKS.SPLIT')}
</Button>
<UnlinkDialog size="sm" icon={Unlink} label={t('LINKS.SPLIT')} title={t('LINKS.SPLIT_CONFIRM_TITLE')}
description={t('LINKS.SPLIT_CONFIRM_TEXT')} confirmLabel={t('LINKS.SPLIT')} disabled={busy}
onConfirm={() => run(splitGame, 'LINKS.SPLIT_DONE')}/>
)}
{auditor && game.linked && (
<Button type="button" variant="outline" size="sm" disabled={busy}
onClick={() => run(unlinkGame, 'LINKS.UNLINKED')}>
<Link2Off/>{t('LINKS.UNLINK')}
</Button>
<UnlinkDialog size="sm" icon={Link2Off} label={t('LINKS.UNLINK')} title={t('LINKS.UNLINK_CONFIRM_TITLE')}
description={t('LINKS.UNLINK_CONFIRM_TEXT')} confirmLabel={t('LINKS.UNLINK')} disabled={busy}
onConfirm={() => run(unlinkGame, 'LINKS.UNLINKED')}/>
)}
</div>
</Card>
+34 -4
View File
@@ -1,10 +1,10 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import EditionsCard from './EditionsCard.jsx';
import {splitGame} from '../utils/links.js';
import {splitGame, unlinkGame} from '../utils/links.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -16,6 +16,11 @@ const renderCard = (game, onChanged = vi.fn()) => {
return onChanged;
};
const confirmSplit = () => {
fireEvent.click(screen.getByRole('button', {name: 'LINKS.SPLIT'}));
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', {name: 'LINKS.SPLIT'}));
};
describe('EditionsCard', () => {
beforeEach(() => vi.clearAllMocks());
afterEach(cleanup);
@@ -33,11 +38,21 @@ describe('EditionsCard', () => {
expect(screen.getByText('LINKS.SHARED_STATUS')).toBeTruthy();
});
it('asksBeforeSplittingAndSendsNothingOnCancel', () => {
renderCard({appid: 220, editions: [edition]});
fireEvent.click(screen.getByRole('button', {name: 'LINKS.SPLIT'}));
expect(screen.getByText('LINKS.SPLIT_CONFIRM_TITLE')).toBeTruthy();
fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CANCEL'}));
expect(splitGame).not.toHaveBeenCalled();
});
it('splitsTheGameAndReloads', async () => {
splitGame.mockResolvedValue({ok: true, status: 204});
const onChanged = renderCard({appid: 220, editions: [edition]});
fireEvent.click(screen.getByText('LINKS.SPLIT'));
confirmSplit();
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(splitGame).toHaveBeenCalledWith(220);
@@ -48,9 +63,24 @@ describe('EditionsCard', () => {
splitGame.mockResolvedValue({ok: false, status: 404});
const onChanged = renderCard({appid: 220, editions: [edition]});
fireEvent.click(screen.getByText('LINKS.SPLIT'));
confirmSplit();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR'));
expect(onChanged).not.toHaveBeenCalled();
});
it('asksBeforeUnlinkingAndUnlinksOnConfirm', async () => {
unlinkGame.mockResolvedValue({ok: true, status: 204});
const onChanged = vi.fn();
render(<MemoryRouter><EditionsCard game={{appid: 220, linked: true, editions: []}} auditor onRequest={vi.fn()}
onChanged={onChanged}/></MemoryRouter>);
fireEvent.click(screen.getByRole('button', {name: 'LINKS.UNLINK'}));
expect(unlinkGame).not.toHaveBeenCalled();
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', {name: 'LINKS.UNLINK'}));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(unlinkGame).toHaveBeenCalledWith(220);
expect(toast.success).toHaveBeenCalledWith('LINKS.UNLINKED');
});
});
+65 -32
View File
@@ -1,20 +1,31 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, Timer} from 'lucide-react';
import {Clock, Ellipsis, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, PanelTop, PencilLine, RotateCcw, Timer} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx';
import ComingBadge from './ComingBadge.jsx';
import GameStatusBadge from './GameStatusBadge.jsx';
import ProfileArtMenu from './ProfileArtMenu.jsx';
import ProfileArtItems from './ProfileArtItems.jsx';
import RatingStars from './RatingStars.jsx';
import StatusMenu from './StatusMenu.jsx';
import TimeToBeat from './TimeToBeat.jsx';
import useSaveAppearance from '../hooks/useSaveAppearance.js';
import useSaveBacklogField from '../hooks/useSaveBacklogField.js';
import useSetStatus from '../hooks/useSetStatus.js';
import {usePreferences} from '../context/PreferencesContext.jsx';
import {UNDO_MS} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js';
import {setGameHidden} from '../utils/hiddenGames.js';
import {isSteamKey} from '../utils/manualGames.js';
@@ -25,11 +36,19 @@ const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
// The spans of each completed effect decoration; effects.css places them.
const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6, 'fx-detail-platinum': 6};
export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
/**
* Status and rating are edited here and save at once. A status change needs no points check: GameDetail's
* GamePointsLine sees the new status and does it.
*
* @param onSaved gets the saved backlog entry after a status or rating change
*/
export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved, onSaved}) {
const {t} = useTranslation();
const {appearance, equipped} = usePreferences();
const saveAppearance = useSaveAppearance();
const [hiding, setHiding] = useState(false);
const {setStatus, busy: statusBusy} = useSetStatus(game.appid, {onChanged: onSaved});
const {save, pending, busy: ratingBusy} = useSaveBacklogField(game.appid, {onSaved});
const onSteam = isSteamKey(game.appid);
// Core checks the link too; a link from the API must never run script.
@@ -39,8 +58,7 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
const isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
const detailFx = detailEffect(equipped, game.status);
const toggleHidden = async () => {
const hidden = !game.hidden;
const applyHidden = async (hidden, {undoable = false} = {}) => {
setHiding(true);
try {
const response = await setGameHidden(game.appid, hidden);
@@ -50,7 +68,12 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
return;
}
onHiddenChanged(hidden);
toast.success(t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST'));
const message = t(hidden ? 'GAME_DETAIL.HIDDEN_TOAST' : 'GAME_DETAIL.SHOWN_TOAST');
if (undoable) {
toast.success(message, {duration: UNDO_MS, action: {label: t('MANUAL_GAME.UNDO'), onClick: () => applyHidden(!hidden)}});
} else {
toast.success(message);
}
} catch (err) {
console.error('Hide game error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
@@ -79,11 +102,17 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
{t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.totalPlaytimeMinutes ?? game.playtimeMinutes)})}
</Badge>
<ComingBadge game={game}/>
<GameStatusBadge status={game.status}/>
<StatusMenu status={game.status} busy={statusBusy}
onSelect={(status) => setStatus(status, {previous: game.status})}/>
{platformLabel(game) && (
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(game)}</Badge>
)}
</div>
<div className="mt-3 flex items-center gap-2">
<span id="hero-rating" className="sr-only">{t('BACKLOG.RATING_LABEL')}</span>
<RatingStars value={pending?.field === 'rating' ? pending.value : (game.rating ?? 0)}
onChange={(value) => save('rating', value)} labelledBy="hero-rating" disabled={ratingBusy}/>
</div>
<div className="mt-3">
<TimeToBeat time={game.timeToBeat}/>
</div>
@@ -94,52 +123,56 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
</a>
</Button>
)}
{auditor && (
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={onCorrect}>
<PencilLine/>{t('CORRECTION.OPEN')}
</Button>
)}
{auditor && (
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={onMerge}>
<Merge/>{t('MERGE.OPEN')}
</Button>
)}
</div>
{/* Store page, background and banner need Steam art, so a game off Steam has none of them. */}
{/* Background, banner and profile art need Steam art, so a game off Steam has none of them. */}
<div className="flex flex-wrap gap-2">
{onSteam && (<>
{onSteam && (
<Button asChild variant="outline" size="sm">
<a href={`https://store.steampowered.com/app/${game.appid}`} target="_blank" rel="noopener noreferrer">
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
</a>
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon-sm" aria-label={t('GAME_DETAIL.MORE')}><Ellipsis/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{onSteam && (<>
{isBackground ? (
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
<DropdownMenuItem onSelect={() => saveAppearance({backgroundAppid: null})}>
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
</Button>
</DropdownMenuItem>
) : (
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: game.appid})}>
<DropdownMenuItem onSelect={() => saveAppearance({backgroundAppid: game.appid})}>
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
</Button>
</DropdownMenuItem>
)}
{isBanner ? (
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}>
<DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: null})}>
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
</Button>
</DropdownMenuItem>
) : (
<Button variant="outline" size="sm"
onClick={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
<DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
</Button>
</DropdownMenuItem>
)}
{profileStyle && (
<ProfileArtMenu appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
<ProfileArtItems appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
)}
<DropdownMenuSeparator/>
</>)}
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
<DropdownMenuItem disabled={hiding} onSelect={() => applyHidden(!game.hidden, {undoable: true})}>
{game.hidden ? <Eye/> : <EyeOff/>}
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
</Button>
</DropdownMenuItem>
{auditor && (<>
<DropdownMenuSeparator/>
<DropdownMenuItem onSelect={onCorrect}><PencilLine/>{t('CORRECTION.OPEN')}</DropdownMenuItem>
<DropdownMenuItem onSelect={onMerge}><Merge/>{t('MERGE.OPEN')}</DropdownMenuItem>
</>)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
+109
View File
@@ -0,0 +1,109 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import GameHeroCard from './GameHeroCard.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: {success: vi.fn(), error: vi.fn()}}));
const game = {appid: 220, name: 'Half-Life 2', status: 'BACKLOG', rating: null, hidden: false, playtimeMinutes: 10};
const routes = (extra = {}) => fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
'PATCH /api/library/games/220/backlog': (url, init) => json(200, {...game, ...JSON.parse(init.body)}),
'PUT /api/library/games/220/hidden': (url, init) => json(200, JSON.parse(init.body)),
...extra,
});
const renderCard = (props = {}) => renderWithProviders(
<GameHeroCard game={game} auditor={false} onCorrect={vi.fn()} onMerge={vi.fn()} onHiddenChanged={vi.fn()}
onProfileStyleSaved={vi.fn()} onSaved={vi.fn()} {...props}/>);
const openMore = async () => fireEvent.keyDown(await screen.findByRole('button', {name: 'GAME_DETAIL.MORE'}), {key: 'Enter'});
const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method === method);
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
localStorage.clear();
});
describe('GameHeroCard', () => {
it('keepsTheMoreMenuForArtHideAndAuditorTools', async () => {
routes();
renderCard({auditor: true, profileStyle: {}});
expect(screen.getByRole('link', {name: /GAME_DETAIL.OPEN_IN_STEAM/})).toBeTruthy();
await openMore();
const names = (await screen.findAllByRole('menuitem')).map((item) => item.textContent);
expect(names).toEqual(['GAME_DETAIL.SET_BACKGROUND', 'GAME_DETAIL.SET_BANNER', 'GAME_DETAIL.HIDE', 'CORRECTION.OPEN', 'MERGE.OPEN']);
expect(screen.getAllByRole('menuitemcheckbox')).toHaveLength(2);
expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
});
it('offersNoAuditorToolsToAUser', async () => {
routes();
renderCard();
await openMore();
const names = (await screen.findAllByRole('menuitem')).map((item) => item.textContent);
expect(names).not.toContain('CORRECTION.OPEN');
expect(names).not.toContain('MERGE.OPEN');
});
it('hidesWithAnUndoThatShowsTheGameAgain', async () => {
routes();
const onHiddenChanged = vi.fn();
renderCard({onHiddenChanged});
await openMore();
fireEvent.click(await screen.findByRole('menuitem', {name: 'GAME_DETAIL.HIDE'}));
await waitFor(() => expect(onHiddenChanged).toHaveBeenCalledWith(true));
const [message, options] = toast.success.mock.calls.at(-1);
expect(message).toBe('GAME_DETAIL.HIDDEN_TOAST');
expect(options.action.label).toBe('MANUAL_GAME.UNDO');
await act(async () => options.action.onClick());
expect(onHiddenChanged).toHaveBeenLastCalledWith(false);
expect(calls('PUT').map(([, init]) => JSON.parse(init.body))).toEqual([{hidden: true}, {hidden: false}]);
// The undo is a plain toast.
expect(toast.success.mock.calls.at(-1)).toEqual(['GAME_DETAIL.SHOWN_TOAST']);
});
it('savesTheStatusFromTheMenuWithoutAskingThePointsItself', async () => {
routes();
const onSaved = vi.fn();
renderCard({onSaved});
fireEvent.keyDown(screen.getByRole('button', {name: /BACKLOG.STATUS_LABEL/}), {key: 'Enter'});
fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.PLAYING'}));
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(expect.objectContaining({status: 'PLAYING'})));
expect(JSON.parse(calls('PUT')[0][1].body)).toEqual({status: 'PLAYING'});
// GameDetail's GamePointsLine does the points check when the status changes; none here (no route would fail).
expect(toast.success.mock.calls.at(-1)[1].action.label).toBe('MANUAL_GAME.UNDO');
});
it('savesTheRatingAtOnce', async () => {
routes();
const onSaved = vi.fn();
renderCard({onSaved});
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(calls('PATCH').map(([, init]) => JSON.parse(init.body))).toEqual([{rating: 4}]);
});
});
+1 -2
View File
@@ -9,11 +9,10 @@ import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {hoursToMinutes, removeGame, restoreGame, toHours, updateManualGame} from '../utils/manualGames.js';
import {UNDO_MS} from '../utils/backlog.js';
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.js';
const UNDO_MS = 8000;
/**
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
*
+39
View File
@@ -0,0 +1,39 @@
import {useTranslation} from 'react-i18next';
import {ChevronLeft, ChevronRight} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {pageNumbers} from '../utils/pagination.js';
/**
* Previous / page numbers / Next. Below the sm breakpoint only the current number shows.
*
* @param page current page, 0-based
* @param onChange gets the 0-based target page
* @param summary node shown after the buttons
*/
export default function Pagination({page, totalPages, onChange, summary}) {
const {t} = useTranslation();
return (
<nav aria-label={t('LIBRARY.PAGINATION')} className="flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground">
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={() => onChange(page - 1)} disabled={page <= 0}>
<ChevronLeft/>{t('LIBRARY.PREV')}
</Button>
{pageNumbers(page, totalPages).map((n, i) => (n === null
? <span key={`gap-${i}`} aria-hidden="true" className="hidden px-1 sm:inline">…</span>
: (
<Button key={n} size="sm" variant={n === page ? 'default' : 'outline'}
aria-current={n === page ? 'page' : undefined}
className={n === page ? 'tabular-nums' : 'hidden tabular-nums sm:inline-flex'}
onClick={() => onChange(n)}>
{n + 1}
</Button>
)))}
<Button variant="outline" size="sm" onClick={() => onChange(page + 1)} disabled={page >= totalPages - 1}>
{t('LIBRARY.NEXT')}<ChevronRight/>
</Button>
</div>
<span className="text-center">{summary}</span>
</nav>
);
}
+1 -1
View File
@@ -25,7 +25,7 @@ export default function PlayingHint({game, onChanged}) {
{t('GAME_DETAIL.PLAYED_RECENTLY', {time: formatPlaytime(game.playtime2Weeks)})}
</p>
</div>
<Button size="sm" className="self-end sm:self-auto" onClick={() => setStatus('PLAYING')} disabled={busy}>
<Button size="sm" className="self-end sm:self-auto" onClick={() => setStatus('PLAYING', {previous: 'BACKLOG'})} disabled={busy}>
{busy && <Loader2 className="animate-spin"/>}
{t('GAME_DETAIL.SET_PLAYING')}
</Button>
+38
View File
@@ -0,0 +1,38 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {DropdownMenuCheckboxItem, DropdownMenuLabel} from '@/components/ui/dropdown-menu';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
/**
* Menu items to use the art of this Steam game as banner or background of the public profile; a checked item removes
* it again. Render inside a DropdownMenuContent.
*
* @param onSaved gets the whole saved style
*/
export default function ProfileArtItems({appid, style, onSaved}) {
const {t} = useTranslation();
const save = useSaveProfileStyle();
const [saving, setSaving] = useState(false);
const toggle = async (field) => {
setSaving(true);
const saved = await save((stored) => ({[field]: stored[field] === appid ? null : appid}), style);
if (saved) onSaved(saved);
setSaving(false);
};
return (
<>
<DropdownMenuLabel>{t('GAME_DETAIL.PROFILE_ART')}</DropdownMenuLabel>
<DropdownMenuCheckboxItem checked={style.bannerAppid === appid} disabled={saving}
onCheckedChange={() => toggle('bannerAppid')}>
{t('GAME_DETAIL.PROFILE_BANNER')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={style.backgroundAppid === appid} disabled={saving}
onCheckedChange={() => toggle('backgroundAppid')}>
{t('GAME_DETAIL.PROFILE_BACKGROUND')}
</DropdownMenuCheckboxItem>
</>
);
}
-47
View File
@@ -1,47 +0,0 @@
import {useState} from 'react';
import {useTranslation} from 'react-i18next';
import {UserRound} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
/**
* Uses the art of this Steam game as banner or background of the public profile; a checked item removes it again.
*
* @param onSaved gets the whole saved style
*/
export default function ProfileArtMenu({appid, style, onSaved}) {
const {t} = useTranslation();
const save = useSaveProfileStyle();
const [saving, setSaving] = useState(false);
const toggle = async (field) => {
setSaving(true);
const saved = await save((stored) => ({[field]: stored[field] === appid ? null : appid}), style);
if (saved) onSaved(saved);
setSaving(false);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={saving}><UserRound/>{t('GAME_DETAIL.PROFILE_ART')}</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuCheckboxItem checked={style.bannerAppid === appid} onCheckedChange={() => toggle('bannerAppid')}>
{t('GAME_DETAIL.PROFILE_BANNER')}
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem checked={style.backgroundAppid === appid}
onCheckedChange={() => toggle('backgroundAppid')}>
{t('GAME_DETAIL.PROFILE_BACKGROUND')}
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
+12 -2
View File
@@ -8,9 +8,19 @@ import {cn} from '@/lib/utils';
*
* @param value 0 for no rating
* @param labelledBy id of the element that names the group
* @param readOnly shows the stars as one image, without buttons
*/
export default function RatingStars({value, onChange, labelledBy, disabled}) {
export default function RatingStars({value, onChange, labelledBy, disabled, readOnly}) {
const {t} = useTranslation();
if (readOnly) {
return (
<span role="img" aria-label={t('BACKLOG.RATING_STARS', {count: value})} className="inline-flex gap-0.5">
{[1, 2, 3, 4, 5].map((n) => (
<Star key={n} className={cn('size-3.5', n <= value ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40')}/>
))}
</span>
);
}
return (
<div role="group" aria-labelledby={labelledBy} className="flex h-9 items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => (
@@ -21,7 +31,7 @@ export default function RatingStars({value, onChange, labelledBy, disabled}) {
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
aria-pressed={value === n}
onClick={() => onChange(value === n ? 0 : n)}
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
className="rounded-sm p-0.5 pointer-coarse:size-11 pointer-coarse:p-2.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<Star className={cn('size-6 transition-colors',
n <= value ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
+48
View File
@@ -0,0 +1,48 @@
import {useTranslation} from 'react-i18next';
import {ChevronDown} from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {cn} from '@/lib/utils';
import GameStatusBadge, {STATUS_META} from './GameStatusBadge.jsx';
import {GAME_STATUSES} from '../utils/limits.js';
/**
* The status badge as a menu: choosing another status calls `onSelect(status)`; the current one does nothing.
*
* @param busy disables the trigger while a save runs
* @param compact see GameStatusBadge
*/
export default function StatusMenu({status, onSelect, busy, compact, className}) {
const {t} = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" disabled={busy}
aria-label={`${t('BACKLOG.STATUS_LABEL')}: ${t(`BACKLOG.STATUS.${status}`)}`}
className={cn('inline-flex items-center gap-1 rounded-md outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:opacity-50 pointer-coarse:min-h-11', className)}>
<GameStatusBadge status={status} compact={compact}/>
<ChevronDown className="size-3 text-muted-foreground"/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuRadioGroup value={status} onValueChange={(s) => s !== status && onSelect(s)}>
{GAME_STATUSES.map((s) => {
const {icon: Icon, text} = STATUS_META[s];
return (
<DropdownMenuRadioItem key={s} value={s}>
<Icon className={text}/>
{t(`BACKLOG.STATUS.${s}`)}
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
+49
View File
@@ -0,0 +1,49 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
import StatusMenu from './StatusMenu.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
afterEach(cleanup);
const open = () => fireEvent.keyDown(screen.getByRole('button', {name: /BACKLOG.STATUS_LABEL/}), {key: 'Enter'});
describe('StatusMenu', () => {
it('namesTheCurrentStatusAndListsAllFive', async () => {
render(<StatusMenu status="PLAYING" onSelect={vi.fn()}/>);
expect(screen.getByRole('button', {name: 'BACKLOG.STATUS_LABEL: BACKLOG.STATUS.PLAYING'})).toBeTruthy();
open();
const items = await screen.findAllByRole('menuitemradio');
expect(items).toHaveLength(5);
expect(screen.getByRole('menuitemradio', {name: 'BACKLOG.STATUS.PLAYING'}).getAttribute('aria-checked')).toBe('true');
});
it('callsOnSelectForAnotherStatus', async () => {
const onSelect = vi.fn();
render(<StatusMenu status="PLAYING" onSelect={onSelect}/>);
open();
fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.FINISHED'}));
expect(onSelect).toHaveBeenCalledWith('FINISHED');
});
it('doesNothingForTheCurrentStatus', async () => {
const onSelect = vi.fn();
render(<StatusMenu status="PLAYING" onSelect={onSelect}/>);
open();
fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.PLAYING'}));
expect(onSelect).not.toHaveBeenCalled();
});
it('isDisabledWhileBusy', () => {
render(<StatusMenu status="PLAYING" onSelect={vi.fn()} busy/>);
expect(screen.getByRole('button', {name: /BACKLOG.STATUS_LABEL/}).disabled).toBe(true);
});
});
+6 -3
View File
@@ -8,15 +8,18 @@ import {
/**
* "Unlink" button that asks first. The description says what the unlink does and does not do.
*
* @param size size of the trigger button
* @param icon icon component of the trigger button
*/
export default function UnlinkDialog({label, title, description, confirmLabel, onConfirm, disabled, className}) {
export default function UnlinkDialog({label, title, description, confirmLabel, onConfirm, disabled, className, size, icon: Icon = Unlink}) {
const {t} = useTranslation();
return (
<Dialog>
<DialogTrigger asChild>
<Button type="button" variant="outline" disabled={disabled} className={className}>
<Unlink/>{label}
<Button type="button" variant="outline" size={size} disabled={disabled} className={className}>
<Icon/>{label}
</Button>
</DialogTrigger>
<DialogContent>
+45
View File
@@ -0,0 +1,45 @@
import {useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
/**
* Returns `{save, pending, busy}`. `save(field, value)` sends that one field (PATCH .../backlog; 0 or NONE clear it),
* shows the saved or error toast and calls `onSaved(entry)` with the whole saved entry. One request at a time: a call
* while one runs does nothing. Resolves to true when saved.
*
* `pending` is `{field, value}` while saving, else null. Show `pending.value` for that field meanwhile: a failed save
* then falls back to the saved value by itself.
*/
export default function useSaveBacklogField(appid, {onSaved} = {}) {
const {t} = useTranslation();
const [pending, setPending] = useState(null);
const running = useRef(false);
const save = async (field, value) => {
if (running.current) return false;
running.current = true;
setPending({field, value});
try {
const response = await saveBacklogEntry(appid, {[field]: toBacklogRequest({[field]: value})[field]});
if (response.status === 401) return false;
if (!response.ok) {
toast.error(t('BACKLOG.SAVE_FAILED'));
return false;
}
const entry = await response.json();
onSaved?.(entry);
toast.success(t('BACKLOG.SAVED'));
return true;
} catch (err) {
console.error('backlog save error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
return false;
} finally {
running.current = false;
setPending(null);
}
};
return {save, pending, busy: pending !== null};
}
+106
View File
@@ -0,0 +1,106 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, renderHook} from '@testing-library/react';
import {toast} from 'sonner';
import useSaveBacklogField from './useSaveBacklogField.js';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const PATCH = 'PATCH /api/library/games/220/backlog';
const bodies = (fetchMock) => fetchMock.mock.calls.map(([, init]) => JSON.parse(init.body));
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
localStorage.clear();
});
const render = (options) => renderHook(() => useSaveBacklogField(220, options));
describe('useSaveBacklogField', () => {
it('sendsOnlyTheOneFieldAndTellsOnSaved', async () => {
const fetchMock = fetchRoutes({[PATCH]: () => json(200, {appid: 220, rating: 5})});
const onSaved = vi.fn();
const {result} = render({onSaved});
let saved;
await act(async () => {
saved = await result.current.save('rating', 5);
});
expect(saved).toBe(true);
expect(bodies(fetchMock)).toEqual([{rating: 5}]);
expect(onSaved).toHaveBeenCalledWith({appid: 220, rating: 5});
expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
});
it.each([['rating', 0, {rating: null}], ['priority', 'NONE', {priority: null}], ['notes', ' ', {notes: null}]])(
'clearsAFieldWithNull_%s', async (field, value, body) => {
const fetchMock = fetchRoutes({[PATCH]: () => json(200, {})});
const {result} = render();
await act(() => result.current.save(field, value));
expect(bodies(fetchMock)).toEqual([body]);
});
it('holdsTheValueWhileSavingAndDropsItAfterAFailedSave', async () => {
let release;
fetchRoutes({[PATCH]: () => new Promise((resolve) => {
release = () => resolve(json(500, {}));
})});
const onSaved = vi.fn();
const {result} = render({onSaved});
let saving;
await act(async () => {
saving = result.current.save('priority', 'HIGH');
});
expect(result.current.pending).toEqual({field: 'priority', value: 'HIGH'});
expect(result.current.busy).toBe(true);
await act(async () => {
release();
await saving;
});
expect(result.current.pending).toBeNull();
expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED');
expect(onSaved).not.toHaveBeenCalled();
});
it('sendsOneRequestWhenCalledTwice', async () => {
let release;
const fetchMock = fetchRoutes({[PATCH]: () => new Promise((resolve) => {
release = () => resolve(json(200, {}));
})});
const {result} = render();
await act(async () => {
const first = result.current.save('rating', 3);
expect(await result.current.save('rating', 4)).toBe(false);
release();
await first;
});
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('showsTheNetworkErrorWhenFetchRejects', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
fetchRoutes({[PATCH]: () => {
throw new Error('offline');
}});
const {result} = render();
await act(() => result.current.save('rating', 3));
expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE');
expect(result.current.pending).toBeNull();
});
});
+23 -9
View File
@@ -1,14 +1,17 @@
import {useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {saveGameStatus} from '../utils/backlog.js';
import {saveGameStatus, UNDO_MS} from '../utils/backlog.js';
import usePointsCheck from './usePointsCheck.js';
/**
* Returns `{setStatus, busy}`. `setStatus(status)` saves the status (PUT .../status), shows the saved or error toast,
* with `checkPoints` asks the points check with its toast, then calls `onChanged(entry)`. `entry` is the saved backlog
* entry, or null when the body of a successful answer is empty or not JSON. A second call while one runs does nothing.
* Resolves to true when saved.
* Returns `{setStatus, busy}`. `setStatus(status, {previous})` saves the status (PUT .../status), shows the saved or
* error toast, with `checkPoints` asks the points check with its toast, then calls `onChanged(entry)`. `entry` is the
* saved backlog entry, or null when the body of a successful answer is empty or not JSON. A second call while one runs
* does nothing. Resolves to true when saved.
*
* With `previous` (the status before, when it differs) the saved toast has an Undo action that saves `previous` again,
* also with `checkPoints` and `onChanged`. Undo does not use the busy lock: the caller may be gone by then.
*
* GameDetail's GamePointsLine already tells the points; do not set `checkPoints` there.
*/
@@ -18,10 +21,7 @@ export default function useSetStatus(appid, {onChanged, checkPoints = false} = {
const [busy, setBusy] = useState(false);
const running = useRef(false);
const setStatus = async (status) => {
if (running.current) return false;
running.current = true;
setBusy(true);
const save = async (status, previous) => {
try {
const response = await saveGameStatus(appid, status);
if (response.status === 401) return false;
@@ -30,7 +30,12 @@ export default function useSetStatus(appid, {onChanged, checkPoints = false} = {
return false;
}
const entry = await response.json().catch(() => null);
if (previous && previous !== status) {
toast.success(t('BACKLOG.SAVED'),
{duration: UNDO_MS, action: {label: t('MANUAL_GAME.UNDO'), onClick: () => save(previous)}});
} else {
toast.success(t('BACKLOG.SAVED'));
}
if (checkPoints) check(appid, {status, tell: true});
onChanged?.(entry);
return true;
@@ -38,6 +43,15 @@ export default function useSetStatus(appid, {onChanged, checkPoints = false} = {
console.error('set status error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
return false;
}
};
const setStatus = async (status, {previous} = {}) => {
if (running.current) return false;
running.current = true;
setBusy(true);
try {
return await save(status, previous);
} finally {
running.current = false;
setBusy(false);
+46
View File
@@ -157,4 +157,50 @@ describe('useSetStatus', () => {
expect(result.current.busy).toBe(false);
});
it('showsUndoOnlyWithAPreviousStatusThatDiffers', async () => {
fetchRoutes({[PUT]: () => json(200, {})});
const {result} = render();
await act(() => result.current.setStatus('PLAYING'));
await act(() => result.current.setStatus('PLAYING', {previous: 'PLAYING'}));
expect(toast.success.mock.calls.every(([, options]) => options === undefined)).toBe(true);
await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'}));
const [message, options] = toast.success.mock.calls.at(-1);
expect(message).toBe('BACKLOG.SAVED');
expect(options.action.label).toBe('MANUAL_GAME.UNDO');
expect(options.duration).toBe(8000);
});
it('undoSavesThePreviousStatusAndTellsOnChanged', async () => {
const fetchMock = fetchRoutes({[PUT]: (url, init) => json(200, JSON.parse(init.body))});
const onChanged = vi.fn();
const {result} = render({onChanged, checkPoints: true});
await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'}));
onChanged.mockClear();
pointsCheck.mockClear();
await act(async () => toast.success.mock.calls[0][1].action.onClick());
expect(JSON.parse(fetchMock.mock.calls[1][1].body)).toEqual({status: 'BACKLOG'});
expect(onChanged).toHaveBeenCalledWith({status: 'BACKLOG'});
expect(pointsCheck).toHaveBeenCalledWith(220, {status: 'BACKLOG', tell: true});
// The undo itself has no Undo.
expect(toast.success.mock.calls.at(-1)).toEqual(['BACKLOG.SAVED']);
});
it('undoShowsSaveFailedOnAServerError', async () => {
let calls = 0;
fetchRoutes({[PUT]: () => json(++calls === 1 ? 200 : 500, {})});
const onChanged = vi.fn();
const {result} = render({onChanged});
await act(() => result.current.setStatus('PLAYING', {previous: 'BACKLOG'}));
onChanged.mockClear();
await act(async () => toast.success.mock.calls[0][1].action.onClick());
expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED');
expect(onChanged).not.toHaveBeenCalled();
});
});
+4 -2
View File
@@ -146,7 +146,7 @@ export default function BacklogPage() {
variant="ghost"
size="icon-sm"
aria-label={t('BACKLOG_ORDER.MOVE_DRAG')}
className={cn('touch-none select-none cursor-grab', dragFrom === index && 'cursor-grabbing')}
className={cn('pointer-coarse:size-11 touch-none select-none cursor-grab', dragFrom === index && 'cursor-grabbing')}
{...handleProps(index)}
>
<GripVertical/>
@@ -154,6 +154,7 @@ export default function BacklogPage() {
<Button
variant="ghost"
size="icon-sm"
className="pointer-coarse:size-11"
aria-label={t('BACKLOG_ORDER.MOVE_UP')}
disabled={index === 0}
onClick={() => move(index, index - 1)}
@@ -163,6 +164,7 @@ export default function BacklogPage() {
<Button
variant="ghost"
size="icon-sm"
className="pointer-coarse:size-11"
aria-label={t('BACKLOG_ORDER.MOVE_DOWN')}
disabled={index === games.length - 1}
onClick={() => move(index, index + 1)}
@@ -171,7 +173,7 @@ export default function BacklogPage() {
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={t('BACKLOG_ORDER.MORE')}>
<Button variant="ghost" size="icon-sm" className="pointer-coarse:size-11" aria-label={t('BACKLOG_ORDER.MORE')}>
<EllipsisVertical/>
</Button>
</DropdownMenuTrigger>
+8 -7
View File
@@ -57,6 +57,12 @@ export default function GameDetail() {
if (data && String(data.appid) !== appid) navigate(`/library/${data.appid}`, {replace: true});
}, [data, appid, navigate]);
const onEntrySaved = (entry) => {
setData((d) => ({...d, ...entry}));
// The other editions share status, rating and notes.
if (data.editions?.length) reload();
};
const manual = !!data && data.source !== 'STEAM';
const errorBlock = error && (
<div className="py-12 text-center text-destructive space-y-4">
@@ -92,7 +98,7 @@ export default function GameDetail() {
<GameHeroCard game={data} auditor={auditor} profileStyle={savedProfileStyle ?? currentUser?.profileStyle}
onCorrect={() => setCorrecting(true)} onMerge={() => setMerging(true)}
onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
onProfileStyleSaved={setSavedProfileStyle}/>
onProfileStyleSaved={setSavedProfileStyle} onSaved={onEntrySaved}/>
{data.lastPlayedAt && (
<p className="text-sm text-muted-foreground">
@@ -111,12 +117,7 @@ export default function GameDetail() {
onDismissed={() => setData((d) => ({...d, finishedHint: null}))}/>
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<BacklogCard key={data.appid} game={data}
onSaved={(entry) => {
setData((d) => ({...d, ...entry}));
// The other editions share status, rating and notes.
if (data.editions?.length) reload();
}}/>
<BacklogCard key={data.appid} game={data} onSaved={onEntrySaved}/>
<PlatformsCard game={data}/>
+42 -26
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
import GameDetail from './GameDetail.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx';
@@ -19,6 +19,18 @@ const game = {
const renderDetail = (appid = 220) => renderWithProviders(<GameDetail/>,
{path: '/library/:appid', entries: [`/library/${appid}`], routes: {'/library': <p>library page</p>}});
const openMore = async () => fireEvent.keyDown(await screen.findByRole('button', {name: 'GAME_DETAIL.MORE'}), {key: 'Enter'});
const pickMore = async (name, role = 'menuitem') => {
await openMore();
fireEvent.click(await screen.findByRole(role, {name}));
};
const hasMoreItem = async (name) => {
await openMore();
const found = !!(await screen.findAllByRole('menuitem')).find((item) => item.textContent === name);
fireEvent.keyDown(document.activeElement, {key: 'Escape'});
return found;
};
const echo = (url, init) => json(200, JSON.parse(init.body));
// The user and the game at /library/<shown.appid>; `routes` adds or replaces answers.
@@ -90,24 +102,24 @@ describe('GameDetail', () => {
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': echo});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
await pickMore('GAME_DETAIL.HIDE');
expect(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy();
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/library/games/220/hidden');
expect(JSON.parse(init.body)).toEqual({hidden: true});
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.UNHIDE'}));
await pickMore('GAME_DETAIL.UNHIDE');
await waitFor(() => expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull());
expect(screen.getByRole('button', {name: 'GAME_DETAIL.HIDE'})).toBeTruthy();
expect(await hasMoreItem('GAME_DETAIL.HIDE')).toBe(true);
});
it('keeps the game shown when Core refuses', async () => {
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(500, {})});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
await pickMore('GAME_DETAIL.HIDE');
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('GAME_DETAIL.HIDE_ERROR'));
expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull();
@@ -118,12 +130,12 @@ describe('GameDetail', () => {
{'PUT /api/library/games/-1942/hidden': echo});
renderDetail(-1942);
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
await pickMore('GAME_DETAIL.HIDE');
expect(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull();
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.UNHIDE'}));
await pickMore('GAME_DETAIL.UNHIDE');
await waitFor(() => expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull());
});
@@ -194,29 +206,31 @@ describe('GameDetail', () => {
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'}));
await pickMore('GAME_DETAIL.SET_BACKGROUND');
const remove = await screen.findByRole('button', {name: 'GAME_DETAIL.REMOVE_BACKGROUND'});
await waitFor(() => expect(fetch.mock.calls.some(([, call]) => call?.method === 'PUT')).toBe(true));
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/users/settings/appearance');
expect(JSON.parse(init.body).backgroundAppid).toBe(220);
fireEvent.click(remove);
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
await waitFor(async () => expect(await hasMoreItem('GAME_DETAIL.REMOVE_BACKGROUND')).toBe(true));
await pickMore('GAME_DETAIL.REMOVE_BACKGROUND');
await waitFor(async () => expect(await hasMoreItem('GAME_DETAIL.SET_BACKGROUND')).toBe(true));
});
it('uses the game as banner and goes back to the automatic banner', async () => {
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
await pickMore('GAME_DETAIL.SET_BANNER');
const auto = await screen.findByRole('button', {name: 'SETTINGS.BANNER_AUTO'});
await waitFor(() => expect(fetch.mock.calls.some(([, call]) => call?.method === 'PUT')).toBe(true));
const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(JSON.parse(init.body)).toMatchObject({bannerAppid: 220, showBanner: true});
fireEvent.click(auto);
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})).toBeTruthy();
await waitFor(async () => expect(await hasMoreItem('SETTINGS.BANNER_AUTO')).toBe(true));
await pickMore('SETTINGS.BANNER_AUTO');
await waitFor(async () => expect(await hasMoreItem('GAME_DETAIL.SET_BANNER')).toBe(true));
});
it('offers "use as banner" for the picked game while the banner is off, and turns it on', async () => {
@@ -224,7 +238,7 @@ describe('GameDetail', () => {
answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'}));
await pickMore('GAME_DETAIL.SET_BANNER');
await waitFor(() => expect(fetch.mock.calls.some(([, call]) => call?.method === 'PUT')).toBe(true));
const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
@@ -250,8 +264,9 @@ describe('GameDetail of a game added by hand', () => {
expect(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
expect(screen.getAllByText('Nintendo Switch').length).toBeGreaterThan(0);
expect(container.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy();
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeNull();
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})).toBeNull();
expect(await hasMoreItem('GAME_DETAIL.HIDE')).toBe(true);
expect(await hasMoreItem('GAME_DETAIL.SET_BACKGROUND')).toBe(false);
expect(await hasMoreItem('GAME_DETAIL.SET_BANNER')).toBe(false);
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull();
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
});
@@ -327,7 +342,7 @@ describe('GameDetail of a game added by hand', () => {
answer({...game, source: 'STEAM'});
renderDetail();
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
expect(await hasMoreItem('GAME_DETAIL.SET_BACKGROUND')).toBe(true);
expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull();
});
@@ -338,7 +353,7 @@ describe('GameDetail of a game added by hand', () => {
'PUT /api/users/settings/profile-style': json(204),
});
renderDetail();
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.MORE'});
backgroundAppid = 620; // set in a second tab
fireEvent.keyDown(menu, {key: 'Enter'});
@@ -357,7 +372,7 @@ describe('GameDetail of a game added by hand', () => {
'PUT /api/users/settings/profile-style': json(204),
});
renderDetail();
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'});
const menu = await screen.findByRole('button', {name: 'GAME_DETAIL.MORE'});
bannerAppid = 620; // another tab picked another game
fireEvent.keyDown(menu, {key: 'Enter'});
@@ -379,7 +394,7 @@ describe('GameDetail of a game added by hand', () => {
});
const styleBodies = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body));
const openMenu = async () => fireEvent.keyDown(await screen.findByRole('button', {name: 'GAME_DETAIL.PROFILE_ART'}), {key: 'Enter'});
const openMenu = openMore;
renderDetail();
await openMenu();
@@ -402,7 +417,7 @@ describe('GameDetail of a game added by hand', () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'CORRECTION.OPEN'}));
await pickMore('CORRECTION.OPEN');
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
});
@@ -411,7 +426,7 @@ describe('GameDetail of a game added by hand', () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'MERGE.OPEN'}));
await pickMore('MERGE.OPEN');
expect(await screen.findByText('MERGE.TITLE')).toBeTruthy();
});
@@ -423,6 +438,7 @@ describe('GameDetail of a game added by hand', () => {
expect(await screen.findByRole('link', {name: 'Half-Life 2 PS5'})).toBeTruthy();
fireEvent.click(await screen.findByRole('button', {name: 'LINKS.UNLINK'}));
fireEvent.click(within(await screen.findByRole('dialog')).getByRole('button', {name: 'LINKS.UNLINK'}));
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(true));
expect(toast.success).toHaveBeenCalledWith('LINKS.UNLINKED');
@@ -445,8 +461,8 @@ describe('GameDetail of a game added by hand', () => {
await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
// Let the answer of /me reach the page before looking for the button.
await act(async () => {});
expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
expect(screen.queryByRole('button', {name: 'MERGE.OPEN'})).toBeNull();
expect(await hasMoreItem('CORRECTION.OPEN')).toBe(false);
expect(await hasMoreItem('MERGE.OPEN')).toBe(false);
expect(screen.queryByRole('button', {name: 'LINKS.UNLINK'})).toBeNull();
cleanup();
}
+81 -59
View File
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js';
import {libraryQuery, parseSources, parseStatus} from '../utils/backlog.js';
import {GAME_SOURCES, GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
import {ArrowUpDown, ChevronLeft, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react';
import {ArrowUpDown, ChevronRight, Clock, EyeOff, Gamepad2, Layers, Library, Plus, RefreshCw, Search, SearchX, Timer, Trophy, Upload} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
@@ -21,7 +21,10 @@ import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx';
import ComingBadge from '../components/ComingBadge.jsx';
import GameStatusBadge from '../components/GameStatusBadge.jsx';
import Pagination from '../components/Pagination.jsx';
import RatingStars from '../components/RatingStars.jsx';
import StatusMenu from '../components/StatusMenu.jsx';
import useSetStatus from '../hooks/useSetStatus.js';
import PageContainer from '../components/PageContainer.jsx';
import HintText from '../components/HintText.jsx';
import PlatformIcons from '../components/PlatformIcons.jsx';
@@ -42,10 +45,12 @@ const SHORT_GAME_MINUTES = 600;
const SORT_OPTIONS = [
{value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'},
{value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'},
{value: 'lastSeenAt,desc', labelKey: 'LIBRARY.SORT_LAST_SEEN'},
{value: 'firstSeenAt,desc', labelKey: 'LIBRARY.SORT_FIRST_SEEN'},
{value: 'rating,desc', labelKey: 'LIBRARY.SORT_RATING'},
{value: 'priority,desc', labelKey: 'LIBRARY.SORT_PRIORITY'},
{value: 'name,asc', labelKey: 'LIBRARY.SORT_NAME'},
{value: 'lastPlayedAt,desc', labelKey: 'LIBRARY.SORT_LAST_PLAYED'},
{value: 'timeToBeat,asc', labelKey: 'LIBRARY.SORT_TIME_TO_BEAT'},
];
// Last loaded page, so a back navigation from a game renders at once and the scroll position can be restored.
@@ -58,6 +63,66 @@ export function clearLibraryPageCache() {
lastPage = {query: null, data: null, stamp: null};
}
// The status, rating and priority of an answered backlog entry, in the row of that game. No entry: no change.
const patchRow = (page, appid, entry) => (!entry ? page : {
...page,
content: page.content.map((row) => (row.appid === appid
? {...row, status: entry.status, rating: entry.rating, priority: entry.priority} : row)),
});
/**
* One game row. The whole row opens the game (the link stretches over it); the status menu sits above that link. A
* status set here asks the points check, which the game page does itself.
*/
function LibraryRow({game: g, sort, equipped, onStatus}) {
const {t} = useTranslation();
const {setStatus, busy} = useSetStatus(g.appid, {checkPoints: true, onChanged: (entry) => onStatus(g.appid, entry)});
return (
<li className="group relative flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50">
<Link to={`/library/${g.appid}`}
className="flex min-w-0 flex-1 items-center gap-3 sm:gap-4 after:absolute after:inset-0">
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="@container min-w-0 flex-1">
<span className="block font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</span>
<PlatformIcons platforms={g.platforms}/>
</span>
</Link>
{!g.platforms?.length && platformLabel(g) && (
<Badge variant="outline" className="relative hidden sm:inline-flex" title={platformLabel(g)}>
<Gamepad2 className="size-3"/>{platformLabel(g)}
</Badge>
)}
{g.editions >= 2 && (
<Badge variant="outline" className="relative tabular-nums" title={t('LIBRARY.EDITIONS', {count: g.editions})}>
<Layers className="size-3"/>x{g.editions}
</Badge>
)}
<ComingBadge game={g} compact/>
<StatusMenu status={g.status} compact busy={busy} className="relative z-10"
onSelect={(status) => setStatus(status, {previous: g.status})}/>
{sort.startsWith('rating') && g.rating > 0 && <RatingStars readOnly value={g.rating}/>}
{sort.startsWith('priority') && g.priority && (
<Badge variant="secondary">{t(`BACKLOG.PRIORITY.${g.priority}`)}</Badge>
)}
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
<Badge variant="secondary" className="relative hidden sm:inline-flex tabular-nums"
title={t('LIBRARY.ACHIEVEMENTS', {unlocked: g.achievementsUnlocked, total: g.achievementsTotal})}>
<Trophy className={cn('size-3', g.achievementsPerfect && 'text-amber-500')}/>
{g.achievementsUnlocked}/{g.achievementsTotal}
</Badge>
)}
<Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/>
{formatPlaytime(g.totalPlaytimeMinutes ?? g.playtimeMinutes)}
</Badge>
<ChevronRight className="size-4 text-muted-foreground"/>
</li>
);
}
export default function GameLibrary() {
const {t} = useTranslation();
const {equipped} = usePreferences();
@@ -193,6 +258,12 @@ export default function GameLibrary() {
updateParams({page: target});
};
// A row that no longer matches the status filter stays until the next load, so Undo can still reach it.
const applyEntry = (appid, entry) => {
setData((d) => patchRow(d, appid, entry));
if (lastPage.data) lastPage = {...lastPage, data: patchRow(lastPage.data, appid, entry)};
};
const {trigger: triggerSync, busy: syncBusy} = useForceSync(sync.watch);
const syncing = syncBusy || sync.isRunning;
@@ -266,7 +337,7 @@ export default function GameLibrary() {
size="sm"
variant={listStatus === s && !hiddenOnly ? 'default' : 'outline'}
aria-pressed={listStatus === s && !hiddenOnly}
className="h-7 rounded-full px-3 text-xs"
className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
onClick={() => onStatusChange(s)}
>
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
@@ -276,7 +347,7 @@ export default function GameLibrary() {
size="sm"
variant={listShortOnly ? 'default' : 'outline'}
aria-pressed={listShortOnly}
className="h-7 rounded-full px-3 text-xs"
className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
onClick={() => updateParams({maxMinutes: listShortOnly ? null : SHORT_GAME_MINUTES, hidden: null, page: 0})}
>
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
@@ -285,7 +356,7 @@ export default function GameLibrary() {
size="sm"
variant={hiddenOnly ? 'default' : 'outline'}
aria-pressed={hiddenOnly}
className="h-7 rounded-full px-3 text-xs"
className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
onClick={onHiddenChange}
>
<EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')}
@@ -299,7 +370,7 @@ export default function GameLibrary() {
size="sm"
variant={listSources.includes(s) ? 'default' : 'outline'}
aria-pressed={listSources.includes(s)}
className="h-7 rounded-full px-3 text-xs"
className="h-7 rounded-full px-3 text-xs pointer-coarse:h-11 pointer-coarse:px-4"
onClick={() => toggleSource(s)}
>
{t(`LIBRARY.SOURCE.${s}`)}
@@ -351,64 +422,15 @@ export default function GameLibrary() {
<Card className="gap-0 py-0">
<ul className="divide-y">
{data.content.map((g) => (
<li key={g.appid}>
<Link
to={`/library/${g.appid}`}
className="group flex items-center gap-3 sm:gap-4 px-4 sm:px-5 py-3 transition-colors hover:bg-muted/50"
>
<GameCover appid={g.appid} imageUrl={g.imageUrl} fx={coverEffect(equipped, g.status)}
className="w-16 sm:w-24 shrink-0"/>
<span className="@container min-w-0 flex-1">
<span className="block font-medium group-hover:text-primary transition-colors">
<FitText text={g.name ?? `App ${g.appid}`}/>
</span>
<PlatformIcons platforms={g.platforms}/>
</span>
{!g.platforms?.length && platformLabel(g) && (
<Badge variant="outline" className="hidden sm:inline-flex" title={platformLabel(g)}>
<Gamepad2 className="size-3"/>{platformLabel(g)}
</Badge>
)}
{g.editions >= 2 && (
<Badge variant="outline" className="tabular-nums" title={t('LIBRARY.EDITIONS', {count: g.editions})}>
<Layers className="size-3"/>x{g.editions}
</Badge>
)}
<ComingBadge game={g} compact/>
<GameStatusBadge status={g.status} compact/>
{g.achievementsTotal > 0 && g.achievementsUnlocked != null && (
<Badge variant="secondary" className="hidden sm:inline-flex tabular-nums"
title={t('LIBRARY.ACHIEVEMENTS', {unlocked: g.achievementsUnlocked, total: g.achievementsTotal})}>
<Trophy className={cn('size-3', g.achievementsPerfect && 'text-amber-500')}/>
{g.achievementsUnlocked}/{g.achievementsTotal}
</Badge>
)}
<Badge variant="secondary" className="tabular-nums">
<Clock className="size-3"/>
{formatPlaytime(g.totalPlaytimeMinutes ?? g.playtimeMinutes)}
</Badge>
<ChevronRight className="size-4 text-muted-foreground"/>
</Link>
</li>
<LibraryRow key={g.appid} game={g} sort={sort} equipped={equipped} onStatus={applyEntry}/>
))}
</ul>
</Card>
)}
{ready && data.totalPages > 1 && (
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground">
<Button variant="outline" size="sm" onClick={() => goToPage(page - 1)} disabled={page <= 0}>
<ChevronLeft/>{t('LIBRARY.PREV')}
</Button>
<span className="text-center">
{t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})}
{' · '}
{data.totalElements} {t('LIBRARY.GAMES')}
</span>
<Button variant="outline" size="sm" onClick={() => goToPage(page + 1)} disabled={page >= data.totalPages - 1}>
{t('LIBRARY.NEXT')}<ChevronRight/>
</Button>
</div>
<Pagination page={page} totalPages={data.totalPages} onChange={goToPage}
summary={`${t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})} · ${data.totalElements} ${t('LIBRARY.GAMES')}`}/>
)}
</PageContainer>
);
+110 -2
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
import {StrictMode} from 'react';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
import GameLibrary, {clearLibraryPageCache} from './GameLibrary.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -12,7 +12,7 @@ import {clearSession, markSession} from '../utils/auth.js';
import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
const syncOptions = vi.hoisted(() => ({current: null}));
const syncState = vi.hoisted(() => ({lastSyncedAt: null, queued: false, isRunning: false}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: (opts) => {
@@ -268,6 +268,114 @@ describe('GameLibrary sort', () => {
await waitFor(() => expect(fetch).toHaveBeenCalled());
expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe(sort);
});
it.each(['name,asc', 'lastPlayedAt,desc', 'timeToBeat,asc'])('sends the new sort %s from the URL', async (sort) => {
renderLibrary(`/library?sort=${encodeURIComponent(sort)}`);
await waitFor(() => expect(fetch).toHaveBeenCalled());
expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe(sort);
});
it('fallsBackToTheDefaultSortForTheOldLastSeenLink', async () => {
renderLibrary('/library?sort=lastSeenAt%2Cdesc');
await waitFor(() => expect(fetch).toHaveBeenCalled());
expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe('playtimeMinutes,desc');
});
it('offersTheNewSortsAndNotLastSeen', async () => {
// jsdom lacks both; the Radix select needs them when it opens.
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.scrollIntoView ??= () => {};
renderLibrary();
fireEvent.keyDown(await screen.findByRole('combobox', {name: 'LIBRARY.SORT_BY'}), {key: 'Enter'});
const labels = (await screen.findAllByRole('option')).map((option) => option.textContent);
expect(labels).toEqual(expect.arrayContaining(['LIBRARY.SORT_NAME', 'LIBRARY.SORT_LAST_PLAYED', 'LIBRARY.SORT_TIME_TO_BEAT']));
expect(labels).not.toContain('LIBRARY.SORT_LAST_SEEN');
});
});
describe('GameLibrary row', () => {
const rows = [
{appid: 220, name: 'Half-Life 2', playtimeMinutes: 0, status: 'BACKLOG', rating: 4, priority: 'HIGH'},
{appid: 620, name: 'Portal 2', playtimeMinutes: 0, status: 'PLAYING', rating: null, priority: null},
];
it('keepsTheWholeRowALinkToTheGame', async () => {
answer(rows);
renderLibrary();
const link = await screen.findByRole('link', {name: /Half-Life 2/});
expect(link.getAttribute('href')).toBe('/library/220');
});
it('setsTheStatusFromTheRowMenuAsksThePointsAndShowsTheNewBadge', async () => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games': json(200, {...page, content: rows, totalElements: 2, totalPages: 1}),
'GET /api/library/games-count': json(200, {count: 2, hasSteamId: true, canSync: true, sources: []}),
'PUT /api/library/games/220/status': (url, init) => json(200, {...rows[0], ...JSON.parse(init.body)}),
'GET /api/rewards/games/220': json(200, {balance: 5, missing: {}, paid: []}),
'GET /api/rewards/wallet': json(200, {balance: 5, catalog: [], owned: [], equipped: {}, history: []}),
});
renderLibrary();
fireEvent.keyDown(await screen.findByRole('button', {name: 'BACKLOG.STATUS_LABEL: BACKLOG.STATUS.BACKLOG'}), {key: 'Enter'});
fireEvent.click(await screen.findByRole('menuitemradio', {name: 'BACKLOG.STATUS.FINISHED'}));
expect(await screen.findByRole('button', {name: 'BACKLOG.STATUS_LABEL: BACKLOG.STATUS.FINISHED'})).toBeTruthy();
const put = fetch.mock.calls.find(([, init]) => init?.method === 'PUT');
expect(JSON.parse(put[1].body)).toEqual({status: 'FINISHED'});
await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.includes('/api/rewards/games/220'))).toBe(true));
expect(toast.success.mock.calls.some(([, options]) => options?.action?.label === 'MANUAL_GAME.UNDO')).toBe(true);
});
it('showsTheStarsOnlyWhenSortedByRating', async () => {
answer(rows);
renderLibrary('/library?sort=rating%2Cdesc');
expect(await screen.findByRole('img', {name: 'BACKLOG.RATING_STARS:4'})).toBeTruthy();
expect(screen.queryByText('BACKLOG.PRIORITY.HIGH')).toBeNull();
cleanup();
answer(rows);
renderLibrary();
await screen.findByRole('link', {name: /Half-Life 2/});
expect(screen.queryByRole('img', {name: /BACKLOG.RATING_STARS/})).toBeNull();
});
it('showsThePriorityOnlyWhenSortedByPriority', async () => {
answer(rows);
renderLibrary('/library?sort=priority%2Cdesc');
expect(await screen.findByText('BACKLOG.PRIORITY.HIGH')).toBeTruthy();
expect(screen.queryByRole('img', {name: /BACKLOG.RATING_STARS/})).toBeNull();
});
});
describe('GameLibrary pagination', () => {
const manyPages = () => fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games': json(200, {...page, page: 4, totalElements: 240, totalPages: 12}),
'GET /api/library/games-count': json(200, {count: 240, hasSteamId: true, canSync: true, sources: []}),
});
it('showsPageNumbersMarksTheCurrentOneAndGoesToAClickedPage', async () => {
manyPages();
const lib = renderLibrary('/library?page=4');
const nav = await screen.findByRole('navigation', {name: 'LIBRARY.PAGINATION'});
const current = within(nav).getByRole('button', {current: 'page'});
expect(current.textContent).toBe('5');
expect(within(nav).getAllByRole('button').map((b) => b.textContent)).toEqual(['LIBRARY.PREV', '1', '4', '5', '6', '12', 'LIBRARY.NEXT']);
fireEvent.click(within(nav).getByRole('button', {name: '12'}));
await waitFor(() => expect(lib.params().get('page')).toBe('11'));
});
});
describe('GameLibrary short games', () => {
+3 -2
View File
@@ -280,7 +280,8 @@ const emptyKey = (budget, mood) => {
function PickCard({game}) {
const {t, i18n} = useTranslation();
const [started, setStarted] = useState(false);
const {setStatus, busy} = useSetStatus(game.appid, {checkPoints: true, onChanged: () => setStarted(true)});
// The picker lists backlog games only, so Undo goes back to BACKLOG.
const {setStatus, busy} = useSetStatus(game.appid, {checkPoints: true, onChanged: (entry) => setStarted(entry?.status !== 'BACKLOG')});
const reason = reasonText(game.reason, t, i18n.language);
return (
@@ -311,7 +312,7 @@ function PickCard({game}) {
<Check className="size-4"/>{t('PLAY_NEXT.STARTED')}
</p>
) : (
<Button size="sm" className="w-full" onClick={() => setStatus('PLAYING')} disabled={busy}>
<Button size="sm" className="w-full" onClick={() => setStatus('PLAYING', {previous: 'BACKLOG'})} disabled={busy}>
{busy ? <Loader2 className="animate-spin"/> : <Play/>}
{t('PLAY_NEXT.START_PLAYING')}
</Button>
+16 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {toast} from 'sonner';
import {MemoryRouter} from 'react-router-dom';
import PlayNextPage from './PlayNextPage.jsx';
@@ -326,6 +326,21 @@ describe('PlayNextPage', () => {
expect(JSON.parse(puts[0][1].body)).toEqual({status: 'PLAYING'});
});
it('puts the card back to the Start button when the user undoes it', async () => {
mockRows([row('NEXT_UP', game(7))]);
renderPage();
await screen.findByText('Game 7');
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.START_PLAYING'}));
await screen.findByText('PLAY_NEXT.STARTED');
const [, options] = toast.success.mock.calls.find(([, call]) => call?.action);
await act(async () => options.action.onClick());
expect(await screen.findByRole('button', {name: 'PLAY_NEXT.START_PLAYING'})).toBeTruthy();
const puts = globalThis.fetch.mock.calls.filter(([, call]) => call?.method === 'PUT');
expect(JSON.parse(puts.at(-1)[1].body)).toEqual({status: 'BACKLOG'});
});
it('shows the points toast after Start', async () => {
mockRows([row('NEXT_UP', game(7))]);
renderPage();
+3
View File
@@ -4,6 +4,9 @@ import {GAME_SOURCES, GAME_STATUSES} from './limits.js';
// Radix Select has no empty value, so "no priority" needs its own value in the form.
export const NO_PRIORITY = 'NONE';
// How long a toast with an Undo action stays.
export const UNDO_MS = 8000;
export const parseStatus = (value) => (GAME_STATUSES.includes(value) ? value : null);
export const parseSources = (value) => GAME_SOURCES.filter((s) => (value ?? '').split(',').includes(s));
+13
View File
@@ -0,0 +1,13 @@
/**
* The page buttons to show: first, last and the current page with its neighbours, as 0-based numbers. `null` marks a
* gap of two or more pages; a gap of one page shows that page instead.
*/
export const pageNumbers = (page, totalPages) => {
const shown = new Set([0, totalPages - 1, page - 1, page, page + 1].filter((n) => n >= 0 && n < totalPages));
const sorted = [...shown].sort((a, b) => a - b);
return sorted.flatMap((n, i) => {
const gap = i > 0 ? n - sorted[i - 1] : 1;
if (gap === 2) return [n - 1, n];
return gap > 2 ? [null, n] : [n];
});
};
+25
View File
@@ -0,0 +1,25 @@
import {describe, expect, it} from 'vitest';
import {pageNumbers} from './pagination.js';
describe('pageNumbers', () => {
it('showsEveryPageOfAShortList', () => {
expect(pageNumbers(0, 1)).toEqual([0]);
expect(pageNumbers(2, 5)).toEqual([0, 1, 2, 3, 4]);
});
it('keepsFirstLastAndTheNeighboursOfTheCurrentPage', () => {
expect(pageNumbers(5, 12)).toEqual([0, null, 4, 5, 6, null, 11]);
});
it('marksOneGapAtTheEdges', () => {
expect(pageNumbers(0, 12)).toEqual([0, 1, null, 11]);
expect(pageNumbers(1, 12)).toEqual([0, 1, 2, null, 11]);
expect(pageNumbers(11, 12)).toEqual([0, null, 10, 11]);
});
it('showsAPageInsteadOfAGapOfOne', () => {
expect(pageNumbers(3, 7)).toEqual([0, 1, 2, 3, 4, 5, 6]);
expect(pageNumbers(4, 9)).toEqual([0, null, 3, 4, 5, null, 8]);
expect(pageNumbers(2, 6)).toEqual([0, 1, 2, 3, 4, 5]);
});
});