Public Access
feat(questions): list and restore removed games
Add a "Removed games" section on the questions page and an Undo toast after removing a manual game (#39).
This commit is contained in:
1 parent
c126592887
commit
84b16a2ca8
14 files changed
+280
-9
No files matched your search
@@ -36,6 +36,7 @@ describe('App', () => {
|
||||
'POST /api/auth/refresh': json(401, {}),
|
||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||
'GET /api/library/removed-games': json(200, {results: []}),
|
||||
});
|
||||
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
|
||||
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
|
||||
|
||||
@@ -26,6 +26,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
||||
import {canCorrect} from '../utils/corrections.js';
|
||||
import {fetchInbox} from '../utils/inbox.js';
|
||||
import {fetchRemovedGames} from '../utils/manualGames.js';
|
||||
import {fetchQuestionCount} from '../utils/questions.js';
|
||||
import {expireSession, markSession} from '../utils/auth.js';
|
||||
|
||||
@@ -97,19 +98,29 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
|
||||
// Loaded again on every page change. The questions page shows the live count in its title, so it has no badge.
|
||||
const [openQuestions, setOpenQuestions] = useState(0);
|
||||
const [hasRemovedGames, setHasRemovedGames] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return undefined;
|
||||
let current = true;
|
||||
// The removed games are restored on the questions page, so they keep its link on a phone. Asked only
|
||||
// while no question is open: an open question keeps the link anyway.
|
||||
fetchQuestionCount().then(({ok, data}) => {
|
||||
if (current && ok) setOpenQuestions(data.count);
|
||||
if (!current || !ok) return;
|
||||
setOpenQuestions(data.count);
|
||||
if (data.count === 0 && pathname !== '/questions') {
|
||||
fetchRemovedGames().then((removed) => {
|
||||
if (current && removed.ok) setHasRemovedGames(removed.data.results.length > 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [isAuthenticated, pathname]);
|
||||
const shownQuestions = pathname === '/questions' ? 0 : openQuestions;
|
||||
// Below sm the Questions icon shows only with open questions or on its own page; the points chip then gives up its number.
|
||||
const questionsIconHidden = openQuestions === 0 && pathname !== '/questions';
|
||||
|
||||
// Below sm the Questions icon shows only with open questions, removed games or on its own page; the points chip then gives up its number.
|
||||
const questionsIconHidden = openQuestions === 0 && !hasRemovedGames && pathname !== '/questions';
|
||||
|
||||
const switchRole = async (role) => {
|
||||
try {
|
||||
|
||||
@@ -25,6 +25,7 @@ async function loadChip(count) {
|
||||
'GET /api/library/steam-profile': json(200, {}),
|
||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||
'GET /api/library/questions/count': json(200, {count}),
|
||||
'GET /api/library/removed-games': json(200, {results: []}),
|
||||
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
||||
});
|
||||
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
||||
@@ -56,6 +57,7 @@ describe('AppHeader points chip', () => {
|
||||
'GET /api/library/steam-profile': json(200, {}),
|
||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||
'GET /api/library/removed-games': json(200, {results: []}),
|
||||
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
||||
});
|
||||
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
||||
|
||||
@@ -20,7 +20,8 @@ const loggedInAs = (me, routes = {}, options = {}) => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}),
|
||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||
'GET /api/library/questions/count': json(200, {count: 0}), ...routes});
|
||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||
'GET /api/library/removed-games': json(200, {results: []}), ...routes});
|
||||
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>, options);
|
||||
};
|
||||
|
||||
@@ -108,6 +109,24 @@ describe('AppHeader', () => {
|
||||
expect((await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'})).className).not.toContain('max-sm:hidden');
|
||||
});
|
||||
|
||||
it('keeps the questions icon at every width while the user has removed games', async () => {
|
||||
loggedInAs({role: 'USER', activeRole: 'USER'},
|
||||
{'GET /api/library/removed-games': json(200, {results: [{appid: 1, name: 'Game'}]})});
|
||||
|
||||
await vi.waitFor(() => expect(screen.getByTitle('NAV.QUESTIONS').className).not.toContain('max-sm:hidden'));
|
||||
});
|
||||
|
||||
it('skipsTheRemovedGamesCallWhileQuestionsAreOpen', async () => {
|
||||
const removed = vi.fn(() => json(200, {results: []}));
|
||||
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 2}),
|
||||
'GET /api/library/removed-games': removed});
|
||||
|
||||
await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(removed).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the questions icon below sm on the questions page, without a badge', async () => {
|
||||
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 0})},
|
||||
{entries: ['/questions']});
|
||||
|
||||
@@ -8,10 +8,12 @@ import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
||||
import {hoursToMinutes, removeGame, restoreGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
||||
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.js';
|
||||
|
||||
const UNDO_MS = 8000;
|
||||
|
||||
/**
|
||||
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
|
||||
*
|
||||
@@ -54,8 +56,16 @@ export default function ManualGameCard({game, onSaved}) {
|
||||
toast.success(t('BACKLOG.SAVED'));
|
||||
});
|
||||
|
||||
const undoRemove = () => run(() => restoreGame(game.appid), () => {
|
||||
toast.success(t('REMOVED_GAMES.RESTORED'));
|
||||
navigate(`/library/${game.appid}`);
|
||||
});
|
||||
|
||||
const remove = () => run(() => removeGame(game.appid), () => {
|
||||
toast.success(t('MANUAL_GAME.REMOVED'));
|
||||
toast.success(t('MANUAL_GAME.REMOVED'), {
|
||||
duration: UNDO_MS,
|
||||
action: {label: t('MANUAL_GAME.UNDO'), onClick: undoRemove},
|
||||
});
|
||||
// The game is gone, so are its unsaved edits on this page.
|
||||
discardChanges();
|
||||
navigate('/library', {replace: true});
|
||||
|
||||
@@ -64,6 +64,43 @@ describe('ManualGameCard', () => {
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/library'));
|
||||
});
|
||||
|
||||
it('offers an undo after the remove that restores the game and opens it', async () => {
|
||||
answer({
|
||||
'DELETE /api/library/games/-1025': json(204, null),
|
||||
'POST /api/library/games/-1025/restore': json(204, null),
|
||||
});
|
||||
const {router} = renderCard();
|
||||
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'}));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/library'));
|
||||
|
||||
const [message, options] = toast.success.mock.calls.find(([text]) => text === 'MANUAL_GAME.REMOVED');
|
||||
expect(message).toBe('MANUAL_GAME.REMOVED');
|
||||
expect(options.action.label).toBe('MANUAL_GAME.UNDO');
|
||||
await options.action.onClick();
|
||||
|
||||
const [url] = fetch.mock.calls.find(([, init]) => init?.method === 'POST');
|
||||
expect(url).toContain('/api/library/games/-1025/restore');
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/library/-1025'));
|
||||
expect(toast.success).toHaveBeenCalledWith('REMOVED_GAMES.RESTORED');
|
||||
});
|
||||
|
||||
it('shows an error when the undo fails', async () => {
|
||||
answer({
|
||||
'DELETE /api/library/games/-1025': json(204, null),
|
||||
'POST /api/library/games/-1025/restore': json(500, {}),
|
||||
});
|
||||
const {router} = renderCard();
|
||||
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'}));
|
||||
await waitFor(() => expect(router.state.location.pathname).toBe('/library'));
|
||||
|
||||
await toast.success.mock.calls.find(([text]) => text === 'MANUAL_GAME.REMOVED')[1].action.onClick();
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED');
|
||||
expect(router.state.location.pathname).toBe('/library');
|
||||
});
|
||||
|
||||
it('shows an error when the save fails', async () => {
|
||||
answer({'PUT /api/library/games/-1025/manual': json(500, {})});
|
||||
const onSaved = vi.fn();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import {fetchRemovedGames, restoreGame} from '../utils/manualGames.js';
|
||||
|
||||
/**
|
||||
* The games the user removed, in a closed `<details>`, one Restore button each. Shows nothing when there are none or
|
||||
* the list does not load: it is a safety net, not a page.
|
||||
*/
|
||||
export default function RemovedGamesSection() {
|
||||
const {t} = useTranslation();
|
||||
const [games, setGames] = useState([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
fetchRemovedGames().then(({ok, data}) => {
|
||||
if (active && ok) setGames(data.results);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const restore = async (appid) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await restoreGame(appid);
|
||||
if (response.status === 401) return;
|
||||
if (response.ok || response.status === 404) {
|
||||
setGames((current) => current.filter((game) => game.appid !== appid));
|
||||
if (response.ok) toast.success(t('REMOVED_GAMES.RESTORED'));
|
||||
} else {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('restore error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (games.length === 0) return null;
|
||||
return (
|
||||
<details className="rounded-lg border p-4">
|
||||
<summary className="cursor-pointer font-medium">{t('REMOVED_GAMES.TITLE_COUNT', {count: games.length})}</summary>
|
||||
<p className="mt-3 text-sm text-muted-foreground">{t('REMOVED_GAMES.HINT')}</p>
|
||||
<ul className="mt-3 space-y-3">
|
||||
{games.map((game) => (
|
||||
<li key={game.appid} className="flex items-center gap-3">
|
||||
<div className="w-16 shrink-0">
|
||||
<GameCover appid={game.appid} imageUrl={game.imageUrl}/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{game.name ?? `App ${game.appid}`}</p>
|
||||
{game.platform && <p className="text-sm text-muted-foreground">{game.platform}</p>}
|
||||
</div>
|
||||
<Button type="button" size="sm" variant="outline" disabled={busy} onClick={() => restore(game.appid)}>
|
||||
{t('REMOVED_GAMES.RESTORE')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import RemovedGamesSection from './RemovedGamesSection.jsx';
|
||||
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 removed = (appid, name) => ({appid, name, imageUrl: null, source: 'MANUAL', platform: 'Switch', removedAt: '2026-10-10T10:00:00Z'});
|
||||
const LIST = 'GET /api/library/removed-games';
|
||||
|
||||
const renderSection = (routes) => {
|
||||
fetchRoutes(routes);
|
||||
return render(<MemoryRouter><RemovedGamesSection/></MemoryRouter>);
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('RemovedGamesSection', () => {
|
||||
it('isClosedByDefaultAndListsTheGames', async () => {
|
||||
const {container} = renderSection({[LIST]: json(200, {results: [removed(-1, 'Zelda'), removed(-2, 'Mario')]})});
|
||||
|
||||
expect(await screen.findByText('REMOVED_GAMES.TITLE_COUNT:2')).toBeTruthy();
|
||||
expect(container.querySelector('details').open).toBe(false);
|
||||
expect(screen.getByText('Zelda')).toBeTruthy();
|
||||
expect(screen.getByText('Mario')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsNoLinkToTheGamePage', async () => {
|
||||
renderSection({[LIST]: json(200, {results: [removed(-1, 'Zelda')]})});
|
||||
await screen.findByText('Zelda');
|
||||
|
||||
expect(screen.queryByRole('link')).toBeNull();
|
||||
});
|
||||
|
||||
it('restoresAGameAndDropsItsRow', async () => {
|
||||
renderSection({
|
||||
[LIST]: json(200, {results: [removed(-1, 'Zelda'), removed(-2, 'Mario')]}),
|
||||
'POST /api/library/games/-1/restore': json(204, null),
|
||||
});
|
||||
await screen.findByText('Zelda');
|
||||
|
||||
fireEvent.click(screen.getAllByRole('button', {name: 'REMOVED_GAMES.RESTORE'})[0]);
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('Zelda')).toBeNull());
|
||||
expect(screen.getByText('Mario')).toBeTruthy();
|
||||
expect(toast.success).toHaveBeenCalledWith('REMOVED_GAMES.RESTORED');
|
||||
});
|
||||
|
||||
it('showsAnErrorAndKeepsTheRowWhenTheRestoreFails', async () => {
|
||||
renderSection({
|
||||
[LIST]: json(200, {results: [removed(-1, 'Zelda')]}),
|
||||
'POST /api/library/games/-1/restore': json(500, {}),
|
||||
});
|
||||
await screen.findByText('Zelda');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'REMOVED_GAMES.RESTORE'}));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
|
||||
expect(screen.getByText('Zelda')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('rendersNothingForAnEmptyList', async () => {
|
||||
const {container} = renderSection({[LIST]: json(200, {results: []})});
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
it('rendersNothingWhenTheListFails', async () => {
|
||||
const {container} = renderSection({[LIST]: json(500, {})});
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
expect(container.innerHTML).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {toast} from 'sonner';
|
||||
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import QuestionCard from '../components/QuestionCard.jsx';
|
||||
import RemovedGamesSection from '../components/RemovedGamesSection.jsx';
|
||||
import usePointsCheck from '../hooks/usePointsCheck.js';
|
||||
import {answerQuestion, fetchQuestions} from '../utils/questions.js';
|
||||
import {loadExpected} from '../utils/rewards.js';
|
||||
@@ -84,6 +85,7 @@ export default function QuestionsPage() {
|
||||
<QuestionCard key={item.appid} item={item} busy={busyAppid !== null} expectedPoints={expected[item.appid]}
|
||||
onAnswer={(choice) => answer(item, choice)}/>
|
||||
))}
|
||||
<RemovedGamesSection/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ const booked = (appid) => ({balance: 30, missing: {}, paid: [{kind: 'FINISHED',
|
||||
const WALLET = {
|
||||
'GET /api/rewards/wallet': json(200, {balance: 30, catalog: [], owned: [], equipped: {}, history: []}),
|
||||
'GET /api/rewards/expected': json(200, {}),
|
||||
'GET /api/library/removed-games': json(200, {results: []}),
|
||||
};
|
||||
const expectedCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/expected'));
|
||||
const pointsCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/'));
|
||||
@@ -40,6 +41,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('QuestionsPage', () => {
|
||||
it('showsTheRemovedGamesSectionAlsoWithoutQuestions', async () => {
|
||||
renderPage({
|
||||
'GET /api/library/questions': json(200, {results: []}),
|
||||
'GET /api/library/removed-games': json(200, {results: [{appid: -1, name: 'Zelda', imageUrl: null, source: 'MANUAL', platform: 'Switch'}]}),
|
||||
});
|
||||
|
||||
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
|
||||
expect(await screen.findByText('REMOVED_GAMES.TITLE_COUNT:1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsOneCardPerGameWithTheCountInTheTitle', async () => {
|
||||
renderPage({'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'FINISHED')]})});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {apiFetch} from './api.js';
|
||||
import {apiFetch, request} from './api.js';
|
||||
|
||||
const LIBRARY_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library`;
|
||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
@@ -34,3 +34,11 @@ export const updateManualGame = (appid, {platform, playtimeMinutes}) =>
|
||||
});
|
||||
|
||||
export const removeGame = (appid) => apiFetch(`${LIBRARY_URL()}/games/${appid}`, {method: 'DELETE'});
|
||||
|
||||
/**
|
||||
* GET /api/library/removed-games: `{results: [{appid, name, imageUrl, source, platform, removedAt}]}`, the games the
|
||||
* user removed, newest first.
|
||||
*/
|
||||
export const fetchRemovedGames = () => request(`${LIBRARY_URL()}/removed-games`);
|
||||
|
||||
export const restoreGame = (appid) => apiFetch(`${LIBRARY_URL()}/games/${appid}/restore`, {method: 'POST'});
|
||||
Reference in new issue
Block a user