Public Access
refactor(hooks): add useSetStatus for game status saves
Replaces 5 copies of the save code in hints, PlayNextPage and sync. An empty 2xx body now counts as saved, not a network error. Refs #46
This commit is contained in:
1 parent
bbc707de87
commit
73ad4fd3b8
10 files changed
+239
-114
No files matched your search
@@ -1,11 +1,9 @@
|
|||||||
import {useState} from 'react';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
|
||||||
import {Loader2, Trophy} from 'lucide-react';
|
import {Loader2, Trophy} from 'lucide-react';
|
||||||
|
|
||||||
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 {saveGameStatus} from '../utils/backlog.js';
|
import useSetStatus from '../hooks/useSetStatus.js';
|
||||||
import {allAchievementsUnlocked} from '../utils/confetti.js';
|
import {allAchievementsUnlocked} from '../utils/confetti.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,36 +13,17 @@ import {allAchievementsUnlocked} from '../utils/confetti.js';
|
|||||||
*/
|
*/
|
||||||
export default function CompletedHint({game, onChanged}) {
|
export default function CompletedHint({game, onChanged}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [busy, setBusy] = useState(false);
|
const {setStatus, busy} = useSetStatus(game.appid, {onChanged});
|
||||||
|
|
||||||
if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
|
if (!allAchievementsUnlocked(game) || game.status === 'COMPLETED') return null;
|
||||||
|
|
||||||
const setCompleted = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await saveGameStatus(game.appid, 'COMPLETED');
|
|
||||||
if (response.status === 401) return;
|
|
||||||
if (!response.ok) {
|
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onChanged(await response.json());
|
|
||||||
toast.success(t('BACKLOG.SAVED'));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('set completed error:', err);
|
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="gap-3 border-amber-500/40 bg-amber-500/5 p-4 sm:flex-row sm:items-center sm:gap-4">
|
<Card className="gap-3 border-amber-500/40 bg-amber-500/5 p-4 sm:flex-row sm:items-center sm:gap-4">
|
||||||
<div className="flex flex-1 items-start gap-3 sm:items-center">
|
<div className="flex flex-1 items-start gap-3 sm:items-center">
|
||||||
<Trophy className="size-5 shrink-0 text-amber-500"/>
|
<Trophy className="size-5 shrink-0 text-amber-500"/>
|
||||||
<p className="text-sm">{t('GAME_DETAIL.ALL_UNLOCKED')}</p>
|
<p className="text-sm">{t('GAME_DETAIL.ALL_UNLOCKED')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" className="self-end sm:self-auto" onClick={setCompleted} disabled={busy}>
|
<Button size="sm" className="self-end sm:self-auto" onClick={() => setStatus('COMPLETED')} disabled={busy}>
|
||||||
{busy && <Loader2 className="animate-spin"/>}
|
{busy && <Loader2 className="animate-spin"/>}
|
||||||
{t('GAME_DETAIL.SET_COMPLETED')}
|
{t('GAME_DETAIL.SET_COMPLETED')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import CompletedHint from './CompletedHint.jsx';
|
|||||||
|
|
||||||
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()}}));
|
||||||
|
vi.mock('../hooks/usePointsCheck.js', () => ({default: () => vi.fn()}));
|
||||||
|
|
||||||
const game = {appid: 220, status: 'FINISHED', hasAchievements: true, statsVisible: true, achievementsTotal: 10, achievementsUnlocked: 10};
|
const game = {appid: 220, status: 'FINISHED', hasAchievements: true, statsVisible: true, achievementsTotal: 10, achievementsUnlocked: 10};
|
||||||
const renderHint = (props) => render(<MemoryRouter><CompletedHint game={game} onChanged={() => {}} {...props}/></MemoryRouter>);
|
const renderHint = (props) => render(<MemoryRouter><CompletedHint game={game} onChanged={() => {}} {...props}/></MemoryRouter>);
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {Flag, Loader2} from 'lucide-react';
|
|||||||
|
|
||||||
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 {dismissFinishedHint, saveGameStatus} from '../utils/backlog.js';
|
import useSetStatus from '../hooks/useSetStatus.js';
|
||||||
|
import {dismissFinishedHint} from '../utils/backlog.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,37 +17,32 @@ import {formatPlaytime} from '../utils/games.js';
|
|||||||
*/
|
*/
|
||||||
export default function FinishedHint({game, onChanged, onDismissed}) {
|
export default function FinishedHint({game, onChanged, onDismissed}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [busy, setBusy] = useState(false);
|
const {setStatus, busy: saving} = useSetStatus(game.appid, {onChanged});
|
||||||
|
const [dismissing, setDismissing] = useState(false);
|
||||||
|
const busy = saving || dismissing;
|
||||||
|
|
||||||
const hint = game.finishedHint;
|
const hint = game.finishedHint;
|
||||||
// A status saved elsewhere on the page answers the question too.
|
// A status saved elsewhere on the page answers the question too.
|
||||||
if (!hint || (game.status !== 'BACKLOG' && game.status !== 'PLAYING')) return null;
|
if (!hint || (game.status !== 'BACKLOG' && game.status !== 'PLAYING')) return null;
|
||||||
|
|
||||||
const run = async (request, onOk) => {
|
const notYet = async () => {
|
||||||
setBusy(true);
|
setDismissing(true);
|
||||||
try {
|
try {
|
||||||
const response = await request();
|
const response = await dismissFinishedHint(game.appid, hint.type);
|
||||||
if (response.status === 401) return;
|
if (response.status === 401) return;
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await onOk(response);
|
onDismissed();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('finished hint error:', err);
|
console.error('finished hint error:', err);
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setDismissing(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setFinished = () => run(() => saveGameStatus(game.appid, 'FINISHED'), async (response) => {
|
|
||||||
onChanged(await response.json());
|
|
||||||
toast.success(t('BACKLOG.SAVED'));
|
|
||||||
});
|
|
||||||
|
|
||||||
const notYet = () => run(() => dismissFinishedHint(game.appid, hint.type), () => onDismissed());
|
|
||||||
|
|
||||||
const text = hint.type === 'ENDING'
|
const text = hint.type === 'ENDING'
|
||||||
? t('GAME_DETAIL.FINISHED_ENDING', {achievement: hint.achievement})
|
? t('GAME_DETAIL.FINISHED_ENDING', {achievement: hint.achievement})
|
||||||
: t('GAME_DETAIL.FINISHED_PLAYTIME', {
|
: t('GAME_DETAIL.FINISHED_PLAYTIME', {
|
||||||
@@ -62,7 +58,7 @@ export default function FinishedHint({game, onChanged, onDismissed}) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={notYet} disabled={busy}>{t('GAME_DETAIL.NOT_YET')}</Button>
|
<Button size="sm" variant="outline" onClick={notYet} disabled={busy}>{t('GAME_DETAIL.NOT_YET')}</Button>
|
||||||
<Button size="sm" onClick={setFinished} disabled={busy}>
|
<Button size="sm" onClick={() => setStatus('FINISHED')} disabled={busy}>
|
||||||
{busy && <Loader2 className="animate-spin"/>}
|
{busy && <Loader2 className="animate-spin"/>}
|
||||||
{t('GAME_DETAIL.SET_FINISHED')}
|
{t('GAME_DETAIL.SET_FINISHED')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.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()}}));
|
||||||
|
vi.mock('../hooks/usePointsCheck.js', () => ({default: () => vi.fn()}));
|
||||||
|
|
||||||
const ending = {appid: 322110, status: 'PLAYING', playtimeMinutes: 600, timeToBeat: {normallyMinutes: 500},
|
const ending = {appid: 322110, status: 'PLAYING', playtimeMinutes: 600, timeToBeat: {normallyMinutes: 500},
|
||||||
finishedHint: {type: 'ENDING', achievement: 'Survivor'}};
|
finishedHint: {type: 'ENDING', achievement: 'Survivor'}};
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import {useState} from 'react';
|
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
|
||||||
import {Gamepad2, Loader2} from 'lucide-react';
|
import {Gamepad2, Loader2} from 'lucide-react';
|
||||||
|
|
||||||
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 {saveGameStatus} from '../utils/backlog.js';
|
import useSetStatus from '../hooks/useSetStatus.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,29 +13,10 @@ import {formatPlaytime} from '../utils/games.js';
|
|||||||
*/
|
*/
|
||||||
export default function PlayingHint({game, onChanged}) {
|
export default function PlayingHint({game, onChanged}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [busy, setBusy] = useState(false);
|
const {setStatus, busy} = useSetStatus(game.appid, {onChanged});
|
||||||
|
|
||||||
if (game.status !== 'BACKLOG' || !(game.playtime2Weeks > 0)) return null;
|
if (game.status !== 'BACKLOG' || !(game.playtime2Weeks > 0)) return null;
|
||||||
|
|
||||||
const setPlaying = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await saveGameStatus(game.appid, 'PLAYING');
|
|
||||||
if (response.status === 401) return;
|
|
||||||
if (!response.ok) {
|
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
onChanged(await response.json());
|
|
||||||
toast.success(t('BACKLOG.SAVED'));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('set playing error:', err);
|
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="gap-3 border-primary/40 bg-primary/5 p-4 sm:flex-row sm:items-center sm:gap-4">
|
<Card className="gap-3 border-primary/40 bg-primary/5 p-4 sm:flex-row sm:items-center sm:gap-4">
|
||||||
<div className="flex flex-1 items-start gap-3 sm:items-center">
|
<div className="flex flex-1 items-start gap-3 sm:items-center">
|
||||||
@@ -46,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={setPlaying} disabled={busy}>
|
<Button size="sm" className="self-end sm:self-auto" onClick={() => setStatus('PLAYING')} disabled={busy}>
|
||||||
{busy && <Loader2 className="animate-spin"/>}
|
{busy && <Loader2 className="animate-spin"/>}
|
||||||
{t('GAME_DETAIL.SET_PLAYING')}
|
{t('GAME_DETAIL.SET_PLAYING')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import PlayingHint from './PlayingHint.jsx';
|
|||||||
|
|
||||||
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()}}));
|
||||||
|
vi.mock('../hooks/usePointsCheck.js', () => ({default: () => vi.fn()}));
|
||||||
|
|
||||||
const game = {appid: 220, status: 'BACKLOG', rating: 3, notes: 'keep', priority: 'HIGH', playtime2Weeks: 90};
|
const game = {appid: 220, status: 'BACKLOG', rating: 3, notes: 'keep', priority: 'HIGH', playtime2Weeks: 90};
|
||||||
const renderHint = (props) => render(<MemoryRouter><PlayingHint game={game} onChanged={() => {}} {...props}/></MemoryRouter>);
|
const renderHint = (props) => render(<MemoryRouter><PlayingHint game={game} onChanged={() => {}} {...props}/></MemoryRouter>);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
|
||||||
import {Loader2} from 'lucide-react';
|
import {Loader2} from 'lucide-react';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import usePointsCheck from '../hooks/usePointsCheck.js';
|
import useSetStatus from '../hooks/useSetStatus.js';
|
||||||
import {saveGameStatus} from '../utils/backlog.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets a game to COMPLETED from the sync details, the same call as {@link CompletedHint}.
|
* Sets a game to COMPLETED from the sync details, the same call as {@link CompletedHint}.
|
||||||
@@ -14,34 +12,18 @@ import {saveGameStatus} from '../utils/backlog.js';
|
|||||||
*/
|
*/
|
||||||
export default function SyncCompletedButton({appid, onChanged}) {
|
export default function SyncCompletedButton({appid, onChanged}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const check = usePointsCheck();
|
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
|
const {setStatus, busy} = useSetStatus(appid, {
|
||||||
const setCompleted = async () => {
|
checkPoints: true,
|
||||||
setBusy(true);
|
onChanged: (entry) => {
|
||||||
try {
|
|
||||||
const response = await saveGameStatus(appid, 'COMPLETED');
|
|
||||||
if (response.status === 401) return;
|
|
||||||
if (!response.ok) {
|
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setDone(true);
|
setDone(true);
|
||||||
toast.success(t('BACKLOG.SAVED'));
|
onChanged?.(entry);
|
||||||
check(appid, {status: 'COMPLETED', tell: true});
|
},
|
||||||
onChanged?.();
|
});
|
||||||
} catch (err) {
|
|
||||||
console.error('set completed error:', err);
|
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (done) return <span className="text-sm text-muted-foreground">{t('SYNC_CHANGES.COMPLETED_DONE')}</span>;
|
if (done) return <span className="text-sm text-muted-foreground">{t('SYNC_CHANGES.COMPLETED_DONE')}</span>;
|
||||||
return (
|
return (
|
||||||
<Button size="sm" className="min-h-11 shrink-0" onClick={setCompleted} disabled={busy}>
|
<Button size="sm" className="min-h-11 shrink-0" onClick={() => setStatus('COMPLETED')} disabled={busy}>
|
||||||
{busy && <Loader2 className="animate-spin"/>}
|
{busy && <Loader2 className="animate-spin"/>}
|
||||||
{t('GAME_DETAIL.SET_COMPLETED')}
|
{t('GAME_DETAIL.SET_COMPLETED')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import {useRef, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import {saveGameStatus} from '../utils/backlog.js';
|
||||||
|
import usePointsCheck from './usePointsCheck.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `{setStatus, busy}`. `setStatus(status)` saves the status (PUT .../status), shows the saved or error toast,
|
||||||
|
* with `checkPoints` asks the points check with its toast, then calls `onChanged(entry)`. `entry` is the saved backlog
|
||||||
|
* entry, or null when the body of a successful answer is empty or not JSON. A second call while one runs does nothing.
|
||||||
|
* Resolves to true when saved.
|
||||||
|
*
|
||||||
|
* GameDetail's GamePointsLine already tells the points; do not set `checkPoints` there.
|
||||||
|
*/
|
||||||
|
export default function useSetStatus(appid, {onChanged, checkPoints = false} = {}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const check = usePointsCheck();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const running = useRef(false);
|
||||||
|
|
||||||
|
const setStatus = async (status) => {
|
||||||
|
if (running.current) return false;
|
||||||
|
running.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const response = await saveGameStatus(appid, status);
|
||||||
|
if (response.status === 401) return false;
|
||||||
|
if (!response.ok) {
|
||||||
|
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const entry = await response.json().catch(() => null);
|
||||||
|
toast.success(t('BACKLOG.SAVED'));
|
||||||
|
if (checkPoints) check(appid, {status, tell: true});
|
||||||
|
onChanged?.(entry);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('set status error:', err);
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
running.current = false;
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {setStatus, busy};
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// @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 useSetStatus from './useSetStatus.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 pointsCheck = vi.fn(() => Promise.resolve(null));
|
||||||
|
vi.mock('./usePointsCheck.js', () => ({default: () => pointsCheck}));
|
||||||
|
|
||||||
|
const PUT = 'PUT /api/library/games/220/status';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const render = (options) => renderHook(() => useSetStatus(220, options));
|
||||||
|
|
||||||
|
describe('useSetStatus', () => {
|
||||||
|
it('savesTheStatusAndShowsTheSavedToast', async () => {
|
||||||
|
const fetchMock = fetchRoutes({[PUT]: () => json(200, {status: 'PLAYING'})});
|
||||||
|
const {result} = render();
|
||||||
|
|
||||||
|
let saved;
|
||||||
|
await act(async () => {
|
||||||
|
saved = await result.current.setStatus('PLAYING');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved).toBe(true);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
|
||||||
|
const [, init] = fetchMock.mock.calls[0];
|
||||||
|
expect(JSON.parse(init.body)).toEqual({status: 'PLAYING'});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passesTheSavedEntryToOnChanged', async () => {
|
||||||
|
fetchRoutes({[PUT]: () => json(200, {status: 'PLAYING', appid: 220})});
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
const {result} = render({onChanged});
|
||||||
|
|
||||||
|
await act(() => result.current.setStatus('PLAYING'));
|
||||||
|
|
||||||
|
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||||
|
expect(onChanged).toHaveBeenCalledWith({status: 'PLAYING', appid: 220});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('countsAnEmptyBodyAsSavedAndPassesNull', async () => {
|
||||||
|
fetchRoutes({[PUT]: () => new Response(null, {status: 200})});
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
const {result} = render({onChanged});
|
||||||
|
|
||||||
|
let saved;
|
||||||
|
await act(async () => {
|
||||||
|
saved = await result.current.setStatus('PLAYING');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved).toBe(true);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
expect(onChanged).toHaveBeenCalledWith(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsSaveFailedOnAServerError', async () => {
|
||||||
|
fetchRoutes({[PUT]: () => json(500, {})});
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
const {result} = render({onChanged});
|
||||||
|
|
||||||
|
await act(() => result.current.setStatus('PLAYING'));
|
||||||
|
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED');
|
||||||
|
expect(onChanged).not.toHaveBeenCalled();
|
||||||
|
expect(result.current.busy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsTheNetworkErrorWhenFetchRejects', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
fetchRoutes({[PUT]: () => {
|
||||||
|
throw new Error('offline');
|
||||||
|
}});
|
||||||
|
const {result} = render();
|
||||||
|
|
||||||
|
await act(() => result.current.setStatus('PLAYING'));
|
||||||
|
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('staysSilentOn401', async () => {
|
||||||
|
fetchRoutes({[PUT]: () => json(401, {}), 'POST /api/auth/refresh': () => json(401, {})});
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
const {result} = render({onChanged});
|
||||||
|
|
||||||
|
let saved;
|
||||||
|
await act(async () => {
|
||||||
|
saved = await result.current.setStatus('PLAYING');
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved).toBe(false);
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
expect(toast.success).not.toHaveBeenCalled();
|
||||||
|
expect(onChanged).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendsOneRequestWhenCalledTwice', async () => {
|
||||||
|
let release;
|
||||||
|
const held = new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const fetchMock = fetchRoutes({[PUT]: () => held});
|
||||||
|
const {result} = render();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
const first = result.current.setStatus('PLAYING');
|
||||||
|
const second = await result.current.setStatus('PLAYING');
|
||||||
|
expect(second).toBe(false);
|
||||||
|
release(json(200, {}));
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checksPointsOnlyWhenAsked', async () => {
|
||||||
|
fetchRoutes({[PUT]: () => json(200, {})});
|
||||||
|
const plain = render();
|
||||||
|
await act(() => plain.result.current.setStatus('PLAYING'));
|
||||||
|
expect(pointsCheck).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const asking = render({checkPoints: true});
|
||||||
|
await act(() => asking.result.current.setStatus('PLAYING'));
|
||||||
|
expect(pointsCheck).toHaveBeenCalledWith(220, {status: 'PLAYING', tell: true});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isBusyWhileSaving', async () => {
|
||||||
|
let release;
|
||||||
|
const held = new Promise((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
fetchRoutes({[PUT]: () => held});
|
||||||
|
const {result} = render();
|
||||||
|
|
||||||
|
let pending;
|
||||||
|
await act(async () => {
|
||||||
|
pending = result.current.setStatus('PLAYING');
|
||||||
|
});
|
||||||
|
expect(result.current.busy).toBe(true);
|
||||||
|
await act(async () => {
|
||||||
|
release(json(200, {}));
|
||||||
|
await pending;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.busy).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,8 +11,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
|||||||
import GameCover from '../components/GameCover.jsx';
|
import GameCover from '../components/GameCover.jsx';
|
||||||
import FitText from '../components/FitText.jsx';
|
import FitText from '../components/FitText.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import usePointsCheck from '../hooks/usePointsCheck.js';
|
import useSetStatus from '../hooks/useSetStatus.js';
|
||||||
import {saveGameStatus} from '../utils/backlog.js';
|
|
||||||
import {formatDate} from '../utils/format.js';
|
import {formatDate} from '../utils/format.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import {request} from '../utils/api.js';
|
import {request} from '../utils/api.js';
|
||||||
@@ -280,31 +279,10 @@ const emptyKey = (budget, mood) => {
|
|||||||
|
|
||||||
function PickCard({game}) {
|
function PickCard({game}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
const [busy, setBusy] = useState(false);
|
|
||||||
const [started, setStarted] = useState(false);
|
const [started, setStarted] = useState(false);
|
||||||
const check = usePointsCheck();
|
const {setStatus, busy} = useSetStatus(game.appid, {checkPoints: true, onChanged: () => setStarted(true)});
|
||||||
const reason = reasonText(game.reason, t, i18n.language);
|
const reason = reasonText(game.reason, t, i18n.language);
|
||||||
|
|
||||||
const startPlaying = async () => {
|
|
||||||
setBusy(true);
|
|
||||||
try {
|
|
||||||
const response = await saveGameStatus(game.appid, 'PLAYING');
|
|
||||||
if (response.status === 401) return;
|
|
||||||
if (!response.ok) {
|
|
||||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setStarted(true);
|
|
||||||
toast.success(t('BACKLOG.SAVED'));
|
|
||||||
check(game.appid, {status: 'PLAYING', tell: true});
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Start playing error:', err);
|
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
|
||||||
} finally {
|
|
||||||
setBusy(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="gap-3 overflow-hidden p-0">
|
<Card className="gap-3 overflow-hidden p-0">
|
||||||
<Link to={`/library/${game.appid}`} className="block">
|
<Link to={`/library/${game.appid}`} className="block">
|
||||||
@@ -333,7 +311,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={startPlaying} disabled={busy}>
|
<Button size="sm" className="w-full" onClick={() => setStatus('PLAYING')} 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>
|
||||||
|
|||||||
Reference in new issue
Block a user