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 - **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. 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 - **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 or priority, paging. Each row shows the unlocked achievements. A game you own on several 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`: 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). 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 - **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 - `/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 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 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 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 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. 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 -->|"Same game as... (request)"| Request["Core POST /api/library/games/{appid}/link-requests"]
Detail -->|"Unlink (auditor)"| Unlink["Core DELETE /api/library/games/{appid}/link"] Detail -->|"Unlink (auditor)"| Unlink["Core DELETE /api/library/games/{appid}/link"]
Split --> Reload["reload the detail"] 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 - `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 ```mermaid
flowchart LR flowchart LR
+9 -1
View File
@@ -289,7 +289,10 @@
"SORT_BY": "Sortieren:", "SORT_BY": "Sortieren:",
"SORT_PLAYTIME_DESC": "Meistgespielt", "SORT_PLAYTIME_DESC": "Meistgespielt",
"SORT_PLAYTIME_ASC": "Am wenigsten gespielt", "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", "SORT_FIRST_SEEN": "Neueste in Bibliothek",
"PREV": "Zurück", "PREV": "Zurück",
"NEXT": "Weiter", "NEXT": "Weiter",
@@ -318,6 +321,7 @@
"EDITIONS": "{{count}} Editionen in deiner Bibliothek" "EDITIONS": "{{count}} Editionen in deiner Bibliothek"
}, },
"GAME_DETAIL": { "GAME_DETAIL": {
"MORE": "Weitere Aktionen",
"BACK": "Zurück", "BACK": "Zurück",
"BACK_TO_LIBRARY": "Zur Bibliothek", "BACK_TO_LIBRARY": "Zur Bibliothek",
"LOADING": "Lade Spiel …", "LOADING": "Lade Spiel …",
@@ -840,6 +844,10 @@
"NEVER": "nie" "NEVER": "nie"
}, },
"LINKS": { "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:", "ALSO_IN_LIBRARY": "Auch in deiner Bibliothek:",
"SAME_AS": "Dasselbe Spiel wie...", "SAME_AS": "Dasselbe Spiel wie...",
"LINK_WITH": "Verknüpfen mit...", "LINK_WITH": "Verknüpfen mit...",
+9 -1
View File
@@ -289,7 +289,10 @@
"SORT_BY": "Sort by:", "SORT_BY": "Sort by:",
"SORT_PLAYTIME_DESC": "Most played", "SORT_PLAYTIME_DESC": "Most played",
"SORT_PLAYTIME_ASC": "Least 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", "SORT_FIRST_SEEN": "Newest in library",
"PREV": "Previous", "PREV": "Previous",
"NEXT": "Next", "NEXT": "Next",
@@ -318,6 +321,7 @@
"EDITIONS": "{{count}} editions in your library" "EDITIONS": "{{count}} editions in your library"
}, },
"GAME_DETAIL": { "GAME_DETAIL": {
"MORE": "More actions",
"BACK": "Back", "BACK": "Back",
"BACK_TO_LIBRARY": "Back to library", "BACK_TO_LIBRARY": "Back to library",
"LOADING": "Loading game …", "LOADING": "Loading game …",
@@ -840,6 +844,10 @@
"NEVER": "never" "NEVER": "never"
}, },
"LINKS": { "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:", "ALSO_IN_LIBRARY": "Also in your library:",
"SAME_AS": "Same game as...", "SAME_AS": "Same game as...",
"LINK_WITH": "Link with...", "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 {useTranslation} from 'react-i18next';
import {toast} from 'sonner';
import {ListTodo, Loader2} from 'lucide-react'; import {ListTodo, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -8,58 +7,25 @@ import {Card} from '@/components/ui/card';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import {Textarea} from '@/components/ui/textarea'; import {Textarea} from '@/components/ui/textarea';
import RatingStars from './RatingStars.jsx'; import {changedFields, NO_PRIORITY} from '../utils/backlog.js';
import {STATUS_META} from './GameStatusBadge.jsx'; import {GAME_PRIORITIES, MAX_NOTES_LENGTH} from '../utils/limits.js';
import {changedFields, NO_PRIORITY, saveBacklogEntry} from '../utils/backlog.js'; import useSaveBacklogField from '../hooks/useSaveBacklogField.js';
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
import useUnsavedChanges from '../hooks/useUnsavedChanges.js'; import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
const toForm = (entry) => ({ /**
status: entry.status ?? 'BACKLOG', * Priority (saved on change) and notes (saved with the Save button). Status and rating live in the hero card.
rating: entry.rating ?? 0, *
notes: entry.notes ?? '', * @param onSaved gets the saved backlog entry
priority: entry.priority ?? NO_PRIORITY, */
});
export default function BacklogCard({game, onSaved}) { export default function BacklogCard({game, onSaved}) {
const {t} = useTranslation(); const {t} = useTranslation();
const [saved, setSaved] = useState(() => toForm(game)); const [notes, setNotes] = useState(game.notes ?? '');
const [form, setForm] = useState(saved); const {save, pending, busy} = useSaveBacklogField(game.appid, {onSaved});
// The PlayingHint can change the status from outside; keep the other unsaved edits. const savingNotes = pending?.field === 'notes';
useEffect(() => { const dirty = 'notes' in changedFields({notes: game.notes}, {notes});
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;
useUnsavedChanges(dirty); useUnsavedChanges(dirty);
const set = (field) => (value) => setForm((f) => ({...f, [field]: value})); const priority = pending?.field === 'priority' ? pending.value : (game.priority ?? NO_PRIORITY);
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);
}
};
return ( return (
<Card className="p-6 gap-5"> <Card className="p-6 gap-5">
@@ -68,30 +34,9 @@ export default function BacklogCard({game, onSaved}) {
{t('BACKLOG.TITLE')} {t('BACKLOG.TITLE')}
</h2> </h2>
{/* Locked while saving: the answer replaces the form, so an edit made meanwhile would be lost. */} <div className="space-y-2 sm:max-w-xs">
<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">
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label> <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> <SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem> <SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
@@ -102,34 +47,29 @@ export default function BacklogCard({game, onSaved}) {
</Select> </Select>
</div> </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="space-y-2">
<div className="flex items-baseline justify-between"> <div className="flex items-baseline justify-between">
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label> <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> </div>
{/* Locked while saving: an edit made meanwhile would be lost. */}
<Textarea <Textarea
id="backlog-notes" id="backlog-notes"
rows={3} rows={3}
maxLength={MAX_NOTES_LENGTH} maxLength={MAX_NOTES_LENGTH}
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')} placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
value={form.notes} value={notes}
onChange={(e) => set('notes')(e.target.value)} disabled={savingNotes}
onChange={(e) => setNotes(e.target.value)}
/> />
</div> </div>
</fieldset>
<div className="flex justify-end gap-2"> <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')} {t('BACKLOG.RESET')}
</Button> </Button>
<Button onClick={save} disabled={!dirty || busy}> <Button onClick={() => save('notes', notes)} disabled={!dirty || busy}>
{busy && <Loader2 className="animate-spin"/>} {savingNotes && <Loader2 className="animate-spin"/>}
{t('BACKLOG.SAVE')} {t('BACKLOG.SAVE')}
</Button> </Button>
</div> </div>
+70 -18
View File
@@ -2,6 +2,7 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; 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} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import BacklogCard from './BacklogCard.jsx'; 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 game = {appid: 220, status: 'PLAYING', rating: 4, notes: 'old', priority: null};
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'}); 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', () => { describe('BacklogCard', () => {
beforeEach(() => { beforeEach(() => {
// jsdom lacks both; the Radix select needs them when it opens.
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.scrollIntoView ??= () => {};
localStorage.setItem('tbp_session', '1'); localStorage.setItem('tbp_session', '1');
// Core answers the whole entry; the fields not sent come from the server. // 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(() => { afterEach(() => {
cleanup(); cleanup();
vi.clearAllMocks();
localStorage.clear(); 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>); render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>);
expect(saveButton().disabled).toBe(true); 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); 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(); const onSaved = vi.fn();
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>); 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.change(screen.getByRole('textbox'), {target: {value: ' '}});
fireEvent.click(saveButton()); fireEvent.click(saveButton());
await waitFor(() => expect(onSaved).toHaveBeenCalled()); await waitFor(() => expect(onSaved).toHaveBeenCalled());
const [url, init] = fetch.mock.calls[0]; expect(patchBodies()).toEqual([{notes: null}]);
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);
}); });
it('sends only the changed notes, so an older tab does not overwrite the other fields', async () => { it('sendsOnlyTheChangedNotes', async () => {
const onSaved = vi.fn(); const onSaved = vi.fn();
render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>); render(<MemoryRouter><BacklogCard game={game} onSaved={onSaved}/></MemoryRouter>);
@@ -56,31 +100,39 @@ describe('BacklogCard', () => {
fireEvent.click(saveButton()); fireEvent.click(saveButton());
await waitFor(() => expect(onSaved).toHaveBeenCalled()); 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; 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)})); answer = () => resolve(json(200, {...game, ...JSON.parse(init.body)}));
})}); })});
render(<MemoryRouter><BacklogCard game={game}/></MemoryRouter>); 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()); 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('textbox').matches(':disabled')).toBe(true);
expect(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:1'}).matches(':disabled')).toBe(true);
await act(async () => answer()); await act(async () => answer());
expect(screen.getByRole('textbox').matches(':disabled')).toBe(false); 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>); render(<MemoryRouter><BacklogCard game={{...game, notes: null}}/></MemoryRouter>);
fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}}); fireEvent.change(screen.getByRole('textbox'), {target: {value: ' '}});
expect(saveButton().disabled).toBe(true); 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 {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import GameStatusBadge from './GameStatusBadge.jsx'; import GameStatusBadge from './GameStatusBadge.jsx';
import UnlinkDialog from './UnlinkDialog.jsx';
import FitText from './FitText.jsx'; import FitText from './FitText.jsx';
import {splitGame, unlinkGame} from '../utils/links.js'; import {splitGame, unlinkGame} from '../utils/links.js';
import {platformLabel} from '../utils/platforms.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')} <Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
</Button> </Button>
{game.editions?.length > 0 && ( {game.editions?.length > 0 && (
<Button type="button" variant="outline" size="sm" disabled={busy} <UnlinkDialog size="sm" icon={Unlink} label={t('LINKS.SPLIT')} title={t('LINKS.SPLIT_CONFIRM_TITLE')}
onClick={() => run(splitGame, 'LINKS.SPLIT_DONE')}> description={t('LINKS.SPLIT_CONFIRM_TEXT')} confirmLabel={t('LINKS.SPLIT')} disabled={busy}
<Unlink/>{t('LINKS.SPLIT')} onConfirm={() => run(splitGame, 'LINKS.SPLIT_DONE')}/>
</Button>
)} )}
{auditor && game.linked && ( {auditor && game.linked && (
<Button type="button" variant="outline" size="sm" disabled={busy} <UnlinkDialog size="sm" icon={Link2Off} label={t('LINKS.UNLINK')} title={t('LINKS.UNLINK_CONFIRM_TITLE')}
onClick={() => run(unlinkGame, 'LINKS.UNLINKED')}> description={t('LINKS.UNLINK_CONFIRM_TEXT')} confirmLabel={t('LINKS.UNLINK')} disabled={busy}
<Link2Off/>{t('LINKS.UNLINK')} onConfirm={() => run(unlinkGame, 'LINKS.UNLINKED')}/>
</Button>
)} )}
</div> </div>
</Card> </Card>
+34 -4
View File
@@ -1,10 +1,10 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; 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 {MemoryRouter} from 'react-router-dom';
import {toast} from 'sonner'; import {toast} from 'sonner';
import EditionsCard from './EditionsCard.jsx'; 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('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
@@ -16,6 +16,11 @@ const renderCard = (game, onChanged = vi.fn()) => {
return onChanged; 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', () => { describe('EditionsCard', () => {
beforeEach(() => vi.clearAllMocks()); beforeEach(() => vi.clearAllMocks());
afterEach(cleanup); afterEach(cleanup);
@@ -33,11 +38,21 @@ describe('EditionsCard', () => {
expect(screen.getByText('LINKS.SHARED_STATUS')).toBeTruthy(); 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 () => { it('splitsTheGameAndReloads', async () => {
splitGame.mockResolvedValue({ok: true, status: 204}); splitGame.mockResolvedValue({ok: true, status: 204});
const onChanged = renderCard({appid: 220, editions: [edition]}); const onChanged = renderCard({appid: 220, editions: [edition]});
fireEvent.click(screen.getByText('LINKS.SPLIT')); confirmSplit();
await waitFor(() => expect(onChanged).toHaveBeenCalled()); await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(splitGame).toHaveBeenCalledWith(220); expect(splitGame).toHaveBeenCalledWith(220);
@@ -48,9 +63,24 @@ describe('EditionsCard', () => {
splitGame.mockResolvedValue({ok: false, status: 404}); splitGame.mockResolvedValue({ok: false, status: 404});
const onChanged = renderCard({appid: 220, editions: [edition]}); const onChanged = renderCard({appid: 220, editions: [edition]});
fireEvent.click(screen.getByText('LINKS.SPLIT')); confirmSplit();
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR')); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR'));
expect(onChanged).not.toHaveBeenCalled(); 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 {useState} from 'react';
import {useTranslation} from 'react-i18next'; 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 {toast} from 'sonner';
import {Badge} from '@/components/ui/badge'; import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {cn} from '@/lib/utils'; import {cn} from '@/lib/utils';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import FitText from './FitText.jsx'; import FitText from './FitText.jsx';
import ComingBadge from './ComingBadge.jsx'; import ComingBadge from './ComingBadge.jsx';
import GameStatusBadge from './GameStatusBadge.jsx'; import ProfileArtItems from './ProfileArtItems.jsx';
import ProfileArtMenu from './ProfileArtMenu.jsx'; import RatingStars from './RatingStars.jsx';
import StatusMenu from './StatusMenu.jsx';
import TimeToBeat from './TimeToBeat.jsx'; import TimeToBeat from './TimeToBeat.jsx';
import useSaveAppearance from '../hooks/useSaveAppearance.js'; 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 {usePreferences} from '../context/PreferencesContext.jsx';
import {UNDO_MS} from '../utils/backlog.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {setGameHidden} from '../utils/hiddenGames.js'; import {setGameHidden} from '../utils/hiddenGames.js';
import {isSteamKey} from '../utils/manualGames.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. // 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}; 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 {t} = useTranslation();
const {appearance, equipped} = usePreferences(); const {appearance, equipped} = usePreferences();
const saveAppearance = useSaveAppearance(); const saveAppearance = useSaveAppearance();
const [hiding, setHiding] = useState(false); 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); const onSteam = isSteamKey(game.appid);
// Core checks the link too; a link from the API must never run script. // 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 isBanner = appearance.showBanner && appearance.bannerAppid === game.appid;
const detailFx = detailEffect(equipped, game.status); const detailFx = detailEffect(equipped, game.status);
const toggleHidden = async () => { const applyHidden = async (hidden, {undoable = false} = {}) => {
const hidden = !game.hidden;
setHiding(true); setHiding(true);
try { try {
const response = await setGameHidden(game.appid, hidden); const response = await setGameHidden(game.appid, hidden);
@@ -50,7 +68,12 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
return; return;
} }
onHiddenChanged(hidden); 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) { } catch (err) {
console.error('Hide game error:', err); console.error('Hide game error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE')); 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)})} {t('GAME_DETAIL.PLAYTIME', {time: formatPlaytime(game.totalPlaytimeMinutes ?? game.playtimeMinutes)})}
</Badge> </Badge>
<ComingBadge game={game}/> <ComingBadge game={game}/>
<GameStatusBadge status={game.status}/> <StatusMenu status={game.status} busy={statusBusy}
onSelect={(status) => setStatus(status, {previous: game.status})}/>
{platformLabel(game) && ( {platformLabel(game) && (
<Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(game)}</Badge> <Badge variant="outline"><Gamepad2 className="size-3"/>{platformLabel(game)}</Badge>
)} )}
</div> </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"> <div className="mt-3">
<TimeToBeat time={game.timeToBeat}/> <TimeToBeat time={game.timeToBeat}/>
</div> </div>
@@ -94,52 +123,56 @@ export default function GameHeroCard({game, auditor, profileStyle, onCorrect, on
</a> </a>
</Button> </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> </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"> <div className="flex flex-wrap gap-2">
{onSteam && (<> {onSteam && (
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<a href={`https://store.steampowered.com/app/${game.appid}`} target="_blank" rel="noopener noreferrer"> <a href={`https://store.steampowered.com/app/${game.appid}`} target="_blank" rel="noopener noreferrer">
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/> {t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
</a> </a>
</Button> </Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon-sm" aria-label={t('GAME_DETAIL.MORE')}><Ellipsis/></Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{onSteam && (<>
{isBackground ? ( {isBackground ? (
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}> <DropdownMenuItem onSelect={() => saveAppearance({backgroundAppid: null})}>
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')} <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')} <ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
</Button> </DropdownMenuItem>
)} )}
{isBanner ? ( {isBanner ? (
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}> <DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: null})}>
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')} <RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
</Button> </DropdownMenuItem>
) : ( ) : (
<Button variant="outline" size="sm" <DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
onClick={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')} <PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
</Button> </DropdownMenuItem>
)} )}
{profileStyle && ( {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/>} {game.hidden ? <Eye/> : <EyeOff/>}
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')} {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>
</div> </div>
</Card> </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 {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import {hoursToMinutes, removeGame, restoreGame, toHours, updateManualGame} from '../utils/manualGames.js'; 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 {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.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. * 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)})} {t('GAME_DETAIL.PLAYED_RECENTLY', {time: formatPlaytime(game.playtime2Weeks)})}
</p> </p>
</div> </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"/>} {busy && <Loader2 className="animate-spin"/>}
{t('GAME_DETAIL.SET_PLAYING')} {t('GAME_DETAIL.SET_PLAYING')}
</Button> </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 value 0 for no rating
* @param labelledBy id of the element that names the group * @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(); 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 ( return (
<div role="group" aria-labelledby={labelledBy} className="flex h-9 items-center gap-1"> <div role="group" aria-labelledby={labelledBy} className="flex h-9 items-center gap-1">
{[1, 2, 3, 4, 5].map((n) => ( {[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-label={t('BACKLOG.RATING_STARS', {count: n})}
aria-pressed={value === n} aria-pressed={value === n}
onClick={() => onChange(value === n ? 0 : 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', <Star className={cn('size-6 transition-colors',
n <= value ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/> 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. * "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(); const {t} = useTranslation();
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button type="button" variant="outline" disabled={disabled} className={className}> <Button type="button" variant="outline" size={size} disabled={disabled} className={className}>
<Unlink/>{label} <Icon/>{label}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <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 {useRef, useState} from 'react';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {saveGameStatus} from '../utils/backlog.js'; import {saveGameStatus, UNDO_MS} from '../utils/backlog.js';
import usePointsCheck from './usePointsCheck.js'; import usePointsCheck from './usePointsCheck.js';
/** /**
* Returns `{setStatus, busy}`. `setStatus(status)` saves the status (PUT .../status), shows the saved or error toast, * Returns `{setStatus, busy}`. `setStatus(status, {previous})` saves the status (PUT .../status), shows the saved or
* with `checkPoints` asks the points check with its toast, then calls `onChanged(entry)`. `entry` is the saved backlog * error toast, with `checkPoints` asks the points check with its toast, then calls `onChanged(entry)`. `entry` is the
* entry, or null when the body of a successful answer is empty or not JSON. A second call while one runs does nothing. * saved backlog entry, or null when the body of a successful answer is empty or not JSON. A second call while one runs
* Resolves to true when saved. * 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. * 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 [busy, setBusy] = useState(false);
const running = useRef(false); const running = useRef(false);
const setStatus = async (status) => { const save = async (status, previous) => {
if (running.current) return false;
running.current = true;
setBusy(true);
try { try {
const response = await saveGameStatus(appid, status); const response = await saveGameStatus(appid, status);
if (response.status === 401) return false; if (response.status === 401) return false;
@@ -30,7 +30,12 @@ export default function useSetStatus(appid, {onChanged, checkPoints = false} = {
return false; return false;
} }
const entry = await response.json().catch(() => null); 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')); toast.success(t('BACKLOG.SAVED'));
}
if (checkPoints) check(appid, {status, tell: true}); if (checkPoints) check(appid, {status, tell: true});
onChanged?.(entry); onChanged?.(entry);
return true; return true;
@@ -38,6 +43,15 @@ export default function useSetStatus(appid, {onChanged, checkPoints = false} = {
console.error('set status error:', err); console.error('set status error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE')); toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
return false; return false;
}
};
const setStatus = async (status, {previous} = {}) => {
if (running.current) return false;
running.current = true;
setBusy(true);
try {
return await save(status, previous);
} finally { } finally {
running.current = false; running.current = false;
setBusy(false); setBusy(false);
+46
View File
@@ -157,4 +157,50 @@ describe('useSetStatus', () => {
expect(result.current.busy).toBe(false); 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" variant="ghost"
size="icon-sm" size="icon-sm"
aria-label={t('BACKLOG_ORDER.MOVE_DRAG')} 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)} {...handleProps(index)}
> >
<GripVertical/> <GripVertical/>
@@ -154,6 +154,7 @@ export default function BacklogPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="pointer-coarse:size-11"
aria-label={t('BACKLOG_ORDER.MOVE_UP')} aria-label={t('BACKLOG_ORDER.MOVE_UP')}
disabled={index === 0} disabled={index === 0}
onClick={() => move(index, index - 1)} onClick={() => move(index, index - 1)}
@@ -163,6 +164,7 @@ export default function BacklogPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="pointer-coarse:size-11"
aria-label={t('BACKLOG_ORDER.MOVE_DOWN')} aria-label={t('BACKLOG_ORDER.MOVE_DOWN')}
disabled={index === games.length - 1} disabled={index === games.length - 1}
onClick={() => move(index, index + 1)} onClick={() => move(index, index + 1)}
@@ -171,7 +173,7 @@ export default function BacklogPage() {
</Button> </Button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <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/> <EllipsisVertical/>
</Button> </Button>
</DropdownMenuTrigger> </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}); if (data && String(data.appid) !== appid) navigate(`/library/${data.appid}`, {replace: true});
}, [data, appid, navigate]); }, [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 manual = !!data && data.source !== 'STEAM';
const errorBlock = error && ( const errorBlock = error && (
<div className="py-12 text-center text-destructive space-y-4"> <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} <GameHeroCard game={data} auditor={auditor} profileStyle={savedProfileStyle ?? currentUser?.profileStyle}
onCorrect={() => setCorrecting(true)} onMerge={() => setMerging(true)} onCorrect={() => setCorrecting(true)} onMerge={() => setMerging(true)}
onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))} onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
onProfileStyleSaved={setSavedProfileStyle}/> onProfileStyleSaved={setSavedProfileStyle} onSaved={onEntrySaved}/>
{data.lastPlayedAt && ( {data.lastPlayedAt && (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -111,12 +117,7 @@ export default function GameDetail() {
onDismissed={() => setData((d) => ({...d, finishedHint: null}))}/> onDismissed={() => setData((d) => ({...d, finishedHint: null}))}/>
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/> <CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
<BacklogCard key={data.appid} game={data} <BacklogCard key={data.appid} game={data} onSaved={onEntrySaved}/>
onSaved={(entry) => {
setData((d) => ({...d, ...entry}));
// The other editions share status, rating and notes.
if (data.editions?.length) reload();
}}/>
<PlatformsCard game={data}/> <PlatformsCard game={data}/>
+42 -26
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; 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 GameDetail from './GameDetail.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {renderWithProviders} from '../test/renderWithProviders.jsx'; import {renderWithProviders} from '../test/renderWithProviders.jsx';
@@ -19,6 +19,18 @@ const game = {
const renderDetail = (appid = 220) => renderWithProviders(<GameDetail/>, const renderDetail = (appid = 220) => renderWithProviders(<GameDetail/>,
{path: '/library/:appid', entries: [`/library/${appid}`], routes: {'/library': <p>library page</p>}}); {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)); const echo = (url, init) => json(200, JSON.parse(init.body));
// The user and the game at /library/<shown.appid>; `routes` adds or replaces answers. // 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}); answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': echo});
renderDetail(); 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(); expect(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy();
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT'); const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/library/games/220/hidden'); expect(url).toContain('/api/library/games/220/hidden');
expect(JSON.parse(init.body)).toEqual({hidden: true}); 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()); 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 () => { it('keeps the game shown when Core refuses', async () => {
answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(500, {})}); answer({...game, source: 'STEAM', hidden: false}, {'PUT /api/library/games/220/hidden': json(500, {})});
renderDetail(); 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')); await waitFor(() => expect(toast.error).toHaveBeenCalledWith('GAME_DETAIL.HIDE_ERROR'));
expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull(); expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull();
@@ -118,12 +130,12 @@ describe('GameDetail', () => {
{'PUT /api/library/games/-1942/hidden': echo}); {'PUT /api/library/games/-1942/hidden': echo});
renderDetail(-1942); 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(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull(); 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()); await waitFor(() => expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull());
}); });
@@ -194,29 +206,31 @@ describe('GameDetail', () => {
answer(game, {'PUT /api/users/settings/appearance': echo}); answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail(); 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'); const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(url).toContain('/api/users/settings/appearance'); expect(url).toContain('/api/users/settings/appearance');
expect(JSON.parse(init.body).backgroundAppid).toBe(220); expect(JSON.parse(init.body).backgroundAppid).toBe(220);
fireEvent.click(remove); await waitFor(async () => expect(await hasMoreItem('GAME_DETAIL.REMOVE_BACKGROUND')).toBe(true));
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy(); 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 () => { it('uses the game as banner and goes back to the automatic banner', async () => {
answer(game, {'PUT /api/users/settings/appearance': echo}); answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail(); 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'); const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
expect(JSON.parse(init.body)).toMatchObject({bannerAppid: 220, showBanner: true}); expect(JSON.parse(init.body)).toMatchObject({bannerAppid: 220, showBanner: true});
fireEvent.click(auto); await waitFor(async () => expect(await hasMoreItem('SETTINGS.BANNER_AUTO')).toBe(true));
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})).toBeTruthy(); 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 () => { 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}); answer(game, {'PUT /api/users/settings/appearance': echo});
renderDetail(); 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)); await waitFor(() => expect(fetch.mock.calls.some(([, call]) => call?.method === 'PUT')).toBe(true));
const [, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT'); 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(await screen.findByLabelText('MANUAL_GAME.PLATFORM')).toBeTruthy();
expect(screen.getAllByText('Nintendo Switch').length).toBeGreaterThan(0); expect(screen.getAllByText('Nintendo Switch').length).toBeGreaterThan(0);
expect(container.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy(); expect(container.querySelector('img[src="https://images.igdb.com/z.jpg"]')).toBeTruthy();
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeNull(); expect(await hasMoreItem('GAME_DETAIL.HIDE')).toBe(true);
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_BANNER'})).toBeNull(); 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.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull();
expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy(); expect(screen.getByText('MANUAL_GAME.NO_ACHIEVEMENTS')).toBeTruthy();
}); });
@@ -327,7 +342,7 @@ describe('GameDetail of a game added by hand', () => {
answer({...game, source: 'STEAM'}); answer({...game, source: 'STEAM'});
renderDetail(); 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(); 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), 'PUT /api/users/settings/profile-style': json(204),
}); });
renderDetail(); 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 backgroundAppid = 620; // set in a second tab
fireEvent.keyDown(menu, {key: 'Enter'}); 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), 'PUT /api/users/settings/profile-style': json(204),
}); });
renderDetail(); 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 bannerAppid = 620; // another tab picked another game
fireEvent.keyDown(menu, {key: 'Enter'}); 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')) const styleBodies = () => fetch.mock.calls.filter(([url, init]) => init?.method === 'PUT' && url.endsWith('/profile-style'))
.map(([, init]) => JSON.parse(init.body)); .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(); renderDetail();
await openMenu(); await openMenu();
@@ -402,7 +417,7 @@ describe('GameDetail of a game added by hand', () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'}); loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail(); renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'CORRECTION.OPEN'})); await pickMore('CORRECTION.OPEN');
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy(); expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
}); });
@@ -411,7 +426,7 @@ describe('GameDetail of a game added by hand', () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'}); loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail(); renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'MERGE.OPEN'})); await pickMore('MERGE.OPEN');
expect(await screen.findByText('MERGE.TITLE')).toBeTruthy(); 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(); expect(await screen.findByRole('link', {name: 'Half-Life 2 PS5'})).toBeTruthy();
fireEvent.click(await screen.findByRole('button', {name: 'LINKS.UNLINK'})); 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)); await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(true));
expect(toast.success).toHaveBeenCalledWith('LINKS.UNLINKED'); 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())); 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. // Let the answer of /me reach the page before looking for the button.
await act(async () => {}); await act(async () => {});
expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull(); expect(await hasMoreItem('CORRECTION.OPEN')).toBe(false);
expect(screen.queryByRole('button', {name: 'MERGE.OPEN'})).toBeNull(); expect(await hasMoreItem('MERGE.OPEN')).toBe(false);
expect(screen.queryByRole('button', {name: 'LINKS.UNLINK'})).toBeNull(); expect(screen.queryByRole('button', {name: 'LINKS.UNLINK'})).toBeNull();
cleanup(); cleanup();
} }
+81 -59
View File
@@ -7,7 +7,7 @@ import useForceSync from '../hooks/useForceSync.js';
import {formatPlaytime} from '../utils/games.js'; import {formatPlaytime} from '../utils/games.js';
import {libraryQuery, parseSources, parseStatus} from '../utils/backlog.js'; import {libraryQuery, parseSources, parseStatus} from '../utils/backlog.js';
import {GAME_SOURCES, GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.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 {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
@@ -21,7 +21,10 @@ import GameCover from '../components/GameCover.jsx';
import FitText from '../components/FitText.jsx'; import FitText from '../components/FitText.jsx';
import LibraryTabs from '../components/LibraryTabs.jsx'; import LibraryTabs from '../components/LibraryTabs.jsx';
import ComingBadge from '../components/ComingBadge.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 PageContainer from '../components/PageContainer.jsx';
import HintText from '../components/HintText.jsx'; import HintText from '../components/HintText.jsx';
import PlatformIcons from '../components/PlatformIcons.jsx'; import PlatformIcons from '../components/PlatformIcons.jsx';
@@ -42,10 +45,12 @@ const SHORT_GAME_MINUTES = 600;
const SORT_OPTIONS = [ const SORT_OPTIONS = [
{value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'}, {value: 'playtimeMinutes,desc', labelKey: 'LIBRARY.SORT_PLAYTIME_DESC'},
{value: 'playtimeMinutes,asc', labelKey: 'LIBRARY.SORT_PLAYTIME_ASC'}, {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: 'firstSeenAt,desc', labelKey: 'LIBRARY.SORT_FIRST_SEEN'},
{value: 'rating,desc', labelKey: 'LIBRARY.SORT_RATING'}, {value: 'rating,desc', labelKey: 'LIBRARY.SORT_RATING'},
{value: 'priority,desc', labelKey: 'LIBRARY.SORT_PRIORITY'}, {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. // 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}; 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() { export default function GameLibrary() {
const {t} = useTranslation(); const {t} = useTranslation();
const {equipped} = usePreferences(); const {equipped} = usePreferences();
@@ -193,6 +258,12 @@ export default function GameLibrary() {
updateParams({page: target}); 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 {trigger: triggerSync, busy: syncBusy} = useForceSync(sync.watch);
const syncing = syncBusy || sync.isRunning; const syncing = syncBusy || sync.isRunning;
@@ -266,7 +337,7 @@ export default function GameLibrary() {
size="sm" size="sm"
variant={listStatus === s && !hiddenOnly ? 'default' : 'outline'} variant={listStatus === s && !hiddenOnly ? 'default' : 'outline'}
aria-pressed={listStatus === s && !hiddenOnly} 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)} onClick={() => onStatusChange(s)}
> >
{s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')} {s ? t(`BACKLOG.STATUS.${s}`) : t('LIBRARY.FILTER_ALL')}
@@ -276,7 +347,7 @@ export default function GameLibrary() {
size="sm" size="sm"
variant={listShortOnly ? 'default' : 'outline'} variant={listShortOnly ? 'default' : 'outline'}
aria-pressed={listShortOnly} 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})} onClick={() => updateParams({maxMinutes: listShortOnly ? null : SHORT_GAME_MINUTES, hidden: null, page: 0})}
> >
<Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')} <Timer className="size-3"/>{t('LIBRARY.FILTER_SHORT')}
@@ -285,7 +356,7 @@ export default function GameLibrary() {
size="sm" size="sm"
variant={hiddenOnly ? 'default' : 'outline'} variant={hiddenOnly ? 'default' : 'outline'}
aria-pressed={hiddenOnly} 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} onClick={onHiddenChange}
> >
<EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')} <EyeOff className="size-3"/>{t('LIBRARY.FILTER_HIDDEN')}
@@ -299,7 +370,7 @@ export default function GameLibrary() {
size="sm" size="sm"
variant={listSources.includes(s) ? 'default' : 'outline'} variant={listSources.includes(s) ? 'default' : 'outline'}
aria-pressed={listSources.includes(s)} 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)} onClick={() => toggleSource(s)}
> >
{t(`LIBRARY.SOURCE.${s}`)} {t(`LIBRARY.SOURCE.${s}`)}
@@ -351,64 +422,15 @@ export default function GameLibrary() {
<Card className="gap-0 py-0"> <Card className="gap-0 py-0">
<ul className="divide-y"> <ul className="divide-y">
{data.content.map((g) => ( {data.content.map((g) => (
<li key={g.appid}> <LibraryRow key={g.appid} game={g} sort={sort} equipped={equipped} onStatus={applyEntry}/>
<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>
))} ))}
</ul> </ul>
</Card> </Card>
)} )}
{ready && data.totalPages > 1 && ( {ready && data.totalPages > 1 && (
<div className="flex items-center justify-between gap-2 text-sm text-muted-foreground"> <Pagination page={page} totalPages={data.totalPages} onChange={goToPage}
<Button variant="outline" size="sm" onClick={() => goToPage(page - 1)} disabled={page <= 0}> summary={`${t('LIBRARY.PAGE_OF', {current: data.page + 1, total: data.totalPages})} · ${data.totalElements} ${t('LIBRARY.GAMES')}`}/>
<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>
)} )}
</PageContainer> </PageContainer>
); );
+110 -2
View File
@@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {StrictMode} from 'react'; import {StrictMode} from 'react';
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; 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 {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
import GameLibrary, {clearLibraryPageCache} from './GameLibrary.jsx'; import GameLibrary, {clearLibraryPageCache} from './GameLibrary.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -12,7 +12,7 @@ import {clearSession, markSession} from '../utils/auth.js';
import {toast} from 'sonner'; import {toast} from 'sonner';
vi.mock('react-i18next', () => import('../test/i18n-mock.js')); 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 syncOptions = vi.hoisted(() => ({current: null}));
const syncState = vi.hoisted(() => ({lastSyncedAt: null, queued: false, isRunning: false})); const syncState = vi.hoisted(() => ({lastSyncedAt: null, queued: false, isRunning: false}));
vi.mock('../hooks/useLibrarySync.js', () => ({default: (opts) => { vi.mock('../hooks/useLibrarySync.js', () => ({default: (opts) => {
@@ -268,6 +268,114 @@ describe('GameLibrary sort', () => {
await waitFor(() => expect(fetch).toHaveBeenCalled()); await waitFor(() => expect(fetch).toHaveBeenCalled());
expect(new URL(fetch.mock.calls[0][0], 'http://x').searchParams.get('sort')).toBe(sort); 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', () => { describe('GameLibrary short games', () => {
+3 -2
View File
@@ -280,7 +280,8 @@ const emptyKey = (budget, mood) => {
function PickCard({game}) { function PickCard({game}) {
const {t, i18n} = useTranslation(); const {t, i18n} = useTranslation();
const [started, setStarted] = useState(false); 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); const reason = reasonText(game.reason, t, i18n.language);
return ( return (
@@ -311,7 +312,7 @@ function PickCard({game}) {
<Check className="size-4"/>{t('PLAY_NEXT.STARTED')} <Check className="size-4"/>{t('PLAY_NEXT.STARTED')}
</p> </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/>} {busy ? <Loader2 className="animate-spin"/> : <Play/>}
{t('PLAY_NEXT.START_PLAYING')} {t('PLAY_NEXT.START_PLAYING')}
</Button> </Button>
+16 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; 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 {toast} from 'sonner';
import {MemoryRouter} from 'react-router-dom'; import {MemoryRouter} from 'react-router-dom';
import PlayNextPage from './PlayNextPage.jsx'; import PlayNextPage from './PlayNextPage.jsx';
@@ -326,6 +326,21 @@ describe('PlayNextPage', () => {
expect(JSON.parse(puts[0][1].body)).toEqual({status: 'PLAYING'}); 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 () => { it('shows the points toast after Start', async () => {
mockRows([row('NEXT_UP', game(7))]); mockRows([row('NEXT_UP', game(7))]);
renderPage(); 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. // Radix Select has no empty value, so "no priority" needs its own value in the form.
export const NO_PRIORITY = 'NONE'; 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 parseStatus = (value) => (GAME_STATUSES.includes(value) ? value : null);
export const parseSources = (value) => GAME_SOURCES.filter((s) => (value ?? '').split(',').includes(s)); 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]);
});
});