Public Access
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:
1 parent
f9f0d94e2c
commit
45f5087227
32 files changed
+1091
-352
No files matched your search
@@ -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
@@ -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
@@ -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...",
|
||||||
|
|||||||
+44
-104
@@ -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,68 +34,42 @@ 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">
|
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
|
||||||
<div className="grid gap-5 sm:grid-cols-3">
|
<Select value={priority} onValueChange={(value) => save('priority', value)} disabled={busy}>
|
||||||
<div className="space-y-2">
|
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
|
||||||
<Label htmlFor="backlog-status">{t('BACKLOG.STATUS_LABEL')}</Label>
|
<SelectContent>
|
||||||
<Select value={form.status} onValueChange={set('status')}>
|
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
|
||||||
<SelectTrigger id="backlog-status" className="w-full"><SelectValue/></SelectTrigger>
|
{GAME_PRIORITIES.map((p) => (
|
||||||
<SelectContent>
|
<SelectItem key={p} value={p}>{t(`BACKLOG.PRIORITY.${p}`)}</SelectItem>
|
||||||
{GAME_STATUSES.map((s) => {
|
))}
|
||||||
const {icon: Icon, text} = STATUS_META[s];
|
</SelectContent>
|
||||||
return (
|
</Select>
|
||||||
<SelectItem key={s} value={s}>
|
</div>
|
||||||
<Icon className={text}/>
|
|
||||||
{t(`BACKLOG.STATUS.${s}`)}
|
|
||||||
</SelectItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="backlog-priority">{t('BACKLOG.PRIORITY_LABEL')}</Label>
|
<div className="flex items-baseline justify-between">
|
||||||
<Select value={form.priority} onValueChange={set('priority')}>
|
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
|
||||||
<SelectTrigger id="backlog-priority" className="w-full"><SelectValue/></SelectTrigger>
|
<span className="text-xs text-muted-foreground tabular-nums">{notes.length} / {MAX_NOTES_LENGTH}</span>
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={NO_PRIORITY}>{t('BACKLOG.PRIORITY.NONE')}</SelectItem>
|
|
||||||
{GAME_PRIORITIES.map((p) => (
|
|
||||||
<SelectItem key={p} value={p}>{t(`BACKLOG.PRIORITY.${p}`)}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
|
|
||||||
<RatingStars value={form.rating} onChange={set('rating')} labelledBy="backlog-rating"/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{/* Locked while saving: an edit made meanwhile would be lost. */}
|
||||||
<div className="space-y-2">
|
<Textarea
|
||||||
<div className="flex items-baseline justify-between">
|
id="backlog-notes"
|
||||||
<Label htmlFor="backlog-notes">{t('BACKLOG.NOTES_LABEL')}</Label>
|
rows={3}
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">{form.notes.length} / {MAX_NOTES_LENGTH}</span>
|
maxLength={MAX_NOTES_LENGTH}
|
||||||
</div>
|
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
|
||||||
<Textarea
|
value={notes}
|
||||||
id="backlog-notes"
|
disabled={savingNotes}
|
||||||
rows={3}
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
maxLength={MAX_NOTES_LENGTH}
|
/>
|
||||||
placeholder={t('BACKLOG.NOTES_PLACEHOLDER')}
|
</div>
|
||||||
value={form.notes}
|
|
||||||
onChange={(e) => set('notes')(e.target.value)}
|
|
||||||
/>
|
|
||||||
</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>
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
{isBackground ? (
|
)}
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
|
<DropdownMenu>
|
||||||
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
<DropdownMenuTrigger asChild>
|
||||||
</Button>
|
<Button variant="outline" size="icon-sm" aria-label={t('GAME_DETAIL.MORE')}><Ellipsis/></Button>
|
||||||
) : (
|
</DropdownMenuTrigger>
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: game.appid})}>
|
<DropdownMenuContent align="start">
|
||||||
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
{onSteam && (<>
|
||||||
</Button>
|
{isBackground ? (
|
||||||
)}
|
<DropdownMenuItem onSelect={() => saveAppearance({backgroundAppid: null})}>
|
||||||
{isBanner ? (
|
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
||||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}>
|
</DropdownMenuItem>
|
||||||
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
) : (
|
||||||
</Button>
|
<DropdownMenuItem onSelect={() => saveAppearance({backgroundAppid: game.appid})}>
|
||||||
) : (
|
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
||||||
<Button variant="outline" size="sm"
|
</DropdownMenuItem>
|
||||||
onClick={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
|
)}
|
||||||
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
{isBanner ? (
|
||||||
</Button>
|
<DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: null})}>
|
||||||
)}
|
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
||||||
{profileStyle && (
|
</DropdownMenuItem>
|
||||||
<ProfileArtMenu appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
|
) : (
|
||||||
)}
|
<DropdownMenuItem onSelect={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
|
||||||
</>)}
|
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
||||||
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
</DropdownMenuItem>
|
||||||
{game.hidden ? <Eye/> : <EyeOff/>}
|
)}
|
||||||
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
{profileStyle && (
|
||||||
</Button>
|
<ProfileArtItems appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
|
||||||
|
)}
|
||||||
|
<DropdownMenuSeparator/>
|
||||||
|
</>)}
|
||||||
|
<DropdownMenuItem disabled={hiding} onSelect={() => applyHidden(!game.hidden, {undoable: true})}>
|
||||||
|
{game.hidden ? <Eye/> : <EyeOff/>}
|
||||||
|
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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')}/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+24
-10
@@ -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);
|
||||||
toast.success(t('BACKLOG.SAVED'));
|
if (previous && previous !== status) {
|
||||||
|
toast.success(t('BACKLOG.SAVED'),
|
||||||
|
{duration: UNDO_MS, action: {label: t('MANUAL_GAME.UNDO'), onClick: () => save(previous)}});
|
||||||
|
} else {
|
||||||
|
toast.success(t('BACKLOG.SAVED'));
|
||||||
|
}
|
||||||
if (checkPoints) check(appid, {status, tell: true});
|
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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}/>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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];
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user