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
@@ -281,6 +281,9 @@ flowchart LR
|
|||||||
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 `BacklogCard`.
|
||||||
|
Below the cards, a closed "Removed games" section (`RemovedGamesSection`, `GET /api/library/removed-games`) has a
|
||||||
|
Restore button per game (`POST /api/library/games/{appid}/restore`). The toast after "Remove from library" on a
|
||||||
|
manual game has an Undo that restores it the same way.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -312,6 +315,7 @@ flowchart LR
|
|||||||
Header["AppHeader badge"] --> Count["Core /api/library/questions/count"]
|
Header["AppHeader badge"] --> Count["Core /api/library/questions/count"]
|
||||||
Page["QuestionsPage"] --> List["Core /api/library/questions"]
|
Page["QuestionsPage"] --> List["Core /api/library/questions"]
|
||||||
Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"]
|
Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"]
|
||||||
|
Page -->|"Removed games, Restore"| Removed["Core /api/library/removed-games, POST /games/{appid}/restore"]
|
||||||
```
|
```
|
||||||
- Owner and user lists (`src/components/people/`, `src/utils/people.js`) need a login and show listed users by name
|
- Owner and user lists (`src/components/people/`, `src/utils/people.js`) need a login and show listed users by name
|
||||||
(a link to `/u/<name>`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is
|
(a link to `/u/<name>`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is
|
||||||
|
|||||||
+8
-1
@@ -540,10 +540,11 @@
|
|||||||
"PLATFORM": "Plattform",
|
"PLATFORM": "Plattform",
|
||||||
"HOURS": "Gespielte Stunden",
|
"HOURS": "Gespielte Stunden",
|
||||||
"REMOVE": "Aus der Bibliothek entfernen",
|
"REMOVE": "Aus der Bibliothek entfernen",
|
||||||
"REMOVE_CONFIRM": "Dieses Spiel mit Status, Bewertung und Notizen entfernen?",
|
"REMOVE_CONFIRM": "Dieses Spiel entfernen? Du kannst es unter Fragen wiederherstellen.",
|
||||||
"REMOVE_YES": "Ja, entfernen",
|
"REMOVE_YES": "Ja, entfernen",
|
||||||
"REMOVE_NO": "Abbrechen",
|
"REMOVE_NO": "Abbrechen",
|
||||||
"REMOVED": "Spiel entfernt.",
|
"REMOVED": "Spiel entfernt.",
|
||||||
|
"UNDO": "Rückgängig",
|
||||||
"NO_ACHIEVEMENTS": "Erfolge und Trophäen gibt es nur für Spiele von Steam oder PlayStation.",
|
"NO_ACHIEVEMENTS": "Erfolge und Trophäen gibt es nur für Spiele von Steam oder PlayStation.",
|
||||||
"SELECT": "{{name}} auswählen",
|
"SELECT": "{{name}} auswählen",
|
||||||
"SELECTED": "{{count}} ausgewählt",
|
"SELECTED": "{{count}} ausgewählt",
|
||||||
@@ -1341,6 +1342,12 @@
|
|||||||
"STATUS": "Statuswechsel"
|
"STATUS": "Statuswechsel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"REMOVED_GAMES": {
|
||||||
|
"TITLE_COUNT": "Entfernte Spiele ({{count}})",
|
||||||
|
"RESTORE": "Wiederherstellen",
|
||||||
|
"RESTORED": "Spiel wiederhergestellt.",
|
||||||
|
"HINT": "Wiederhergestellte Spiele behalten Status, Bewertung und Notizen."
|
||||||
|
},
|
||||||
"QUESTIONS": {
|
"QUESTIONS": {
|
||||||
"TITLE": "Fragen",
|
"TITLE": "Fragen",
|
||||||
"TITLE_COUNT": "Fragen ({{count}})",
|
"TITLE_COUNT": "Fragen ({{count}})",
|
||||||
|
|||||||
+8
-1
@@ -540,10 +540,11 @@
|
|||||||
"PLATFORM": "Platform",
|
"PLATFORM": "Platform",
|
||||||
"HOURS": "Hours played",
|
"HOURS": "Hours played",
|
||||||
"REMOVE": "Remove from library",
|
"REMOVE": "Remove from library",
|
||||||
"REMOVE_CONFIRM": "Remove this game with its status, rating and notes?",
|
"REMOVE_CONFIRM": "Remove this game? You can restore it under Questions.",
|
||||||
"REMOVE_YES": "Yes, remove",
|
"REMOVE_YES": "Yes, remove",
|
||||||
"REMOVE_NO": "Cancel",
|
"REMOVE_NO": "Cancel",
|
||||||
"REMOVED": "Game removed.",
|
"REMOVED": "Game removed.",
|
||||||
|
"UNDO": "Undo",
|
||||||
"NO_ACHIEVEMENTS": "Achievements and trophies exist only for games from Steam or PlayStation.",
|
"NO_ACHIEVEMENTS": "Achievements and trophies exist only for games from Steam or PlayStation.",
|
||||||
"SELECT": "Select {{name}}",
|
"SELECT": "Select {{name}}",
|
||||||
"SELECTED": "{{count}} selected",
|
"SELECTED": "{{count}} selected",
|
||||||
@@ -1341,6 +1342,12 @@
|
|||||||
"STATUS": "Status changes"
|
"STATUS": "Status changes"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"REMOVED_GAMES": {
|
||||||
|
"TITLE_COUNT": "Removed games ({{count}})",
|
||||||
|
"RESTORE": "Restore",
|
||||||
|
"RESTORED": "Game restored.",
|
||||||
|
"HINT": "Restored games keep their status, rating and notes."
|
||||||
|
},
|
||||||
"QUESTIONS": {
|
"QUESTIONS": {
|
||||||
"TITLE": "Questions",
|
"TITLE": "Questions",
|
||||||
"TITLE_COUNT": "Questions ({{count}})",
|
"TITLE_COUNT": "Questions ({{count}})",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ describe('App', () => {
|
|||||||
'POST /api/auth/refresh': json(401, {}),
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||||
|
'GET /api/library/removed-games': json(200, {results: []}),
|
||||||
});
|
});
|
||||||
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
|
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
|
||||||
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
|
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 {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
||||||
import {canCorrect} from '../utils/corrections.js';
|
import {canCorrect} from '../utils/corrections.js';
|
||||||
import {fetchInbox} from '../utils/inbox.js';
|
import {fetchInbox} from '../utils/inbox.js';
|
||||||
|
import {fetchRemovedGames} from '../utils/manualGames.js';
|
||||||
import {fetchQuestionCount} from '../utils/questions.js';
|
import {fetchQuestionCount} from '../utils/questions.js';
|
||||||
import {expireSession, markSession} from '../utils/auth.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.
|
// 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 [openQuestions, setOpenQuestions] = useState(0);
|
||||||
|
const [hasRemovedGames, setHasRemovedGames] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return undefined;
|
if (!isAuthenticated) return undefined;
|
||||||
let current = true;
|
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}) => {
|
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 () => {
|
return () => {
|
||||||
current = false;
|
current = false;
|
||||||
};
|
};
|
||||||
}, [isAuthenticated, pathname]);
|
}, [isAuthenticated, pathname]);
|
||||||
const shownQuestions = pathname === '/questions' ? 0 : openQuestions;
|
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) => {
|
const switchRole = async (role) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ async function loadChip(count) {
|
|||||||
'GET /api/library/steam-profile': json(200, {}),
|
'GET /api/library/steam-profile': json(200, {}),
|
||||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
'GET /api/library/questions/count': json(200, {count}),
|
'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: []}),
|
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
});
|
});
|
||||||
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
||||||
@@ -56,6 +57,7 @@ describe('AppHeader points chip', () => {
|
|||||||
'GET /api/library/steam-profile': json(200, {}),
|
'GET /api/library/steam-profile': json(200, {}),
|
||||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
'GET /api/library/questions/count': json(200, {count: 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: []}),
|
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
});
|
});
|
||||||
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ const loggedInAs = (me, routes = {}, options = {}) => {
|
|||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}),
|
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/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);
|
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');
|
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 () => {
|
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})},
|
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 0})},
|
||||||
{entries: ['/questions']});
|
{entries: ['/questions']});
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import {Button} from '@/components/ui/button';
|
|||||||
import {Card} from '@/components/ui/card';
|
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, 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 {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.
|
||||||
*
|
*
|
||||||
@@ -54,8 +56,16 @@ export default function ManualGameCard({game, onSaved}) {
|
|||||||
toast.success(t('BACKLOG.SAVED'));
|
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), () => {
|
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.
|
// The game is gone, so are its unsaved edits on this page.
|
||||||
discardChanges();
|
discardChanges();
|
||||||
navigate('/library', {replace: true});
|
navigate('/library', {replace: true});
|
||||||
|
|||||||
@@ -64,6 +64,43 @@ describe('ManualGameCard', () => {
|
|||||||
await waitFor(() => expect(router.state.location.pathname).toBe('/library'));
|
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 () => {
|
it('shows an error when the save fails', async () => {
|
||||||
answer({'PUT /api/library/games/-1025/manual': json(500, {})});
|
answer({'PUT /api/library/games/-1025/manual': json(500, {})});
|
||||||
const onSaved = vi.fn();
|
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 PageContainer from '../components/PageContainer.jsx';
|
||||||
import QuestionCard from '../components/QuestionCard.jsx';
|
import QuestionCard from '../components/QuestionCard.jsx';
|
||||||
|
import RemovedGamesSection from '../components/RemovedGamesSection.jsx';
|
||||||
import usePointsCheck from '../hooks/usePointsCheck.js';
|
import usePointsCheck from '../hooks/usePointsCheck.js';
|
||||||
import {answerQuestion, fetchQuestions} from '../utils/questions.js';
|
import {answerQuestion, fetchQuestions} from '../utils/questions.js';
|
||||||
import {loadExpected} from '../utils/rewards.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]}
|
<QuestionCard key={item.appid} item={item} busy={busyAppid !== null} expectedPoints={expected[item.appid]}
|
||||||
onAnswer={(choice) => answer(item, choice)}/>
|
onAnswer={(choice) => answer(item, choice)}/>
|
||||||
))}
|
))}
|
||||||
|
<RemovedGamesSection/>
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ const booked = (appid) => ({balance: 30, missing: {}, paid: [{kind: 'FINISHED',
|
|||||||
const WALLET = {
|
const WALLET = {
|
||||||
'GET /api/rewards/wallet': json(200, {balance: 30, catalog: [], owned: [], equipped: {}, history: []}),
|
'GET /api/rewards/wallet': json(200, {balance: 30, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
'GET /api/rewards/expected': json(200, {}),
|
'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 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/'));
|
const pointsCalls = () => globalThis.fetch.mock.calls.filter(([url]) => url.includes('/api/rewards/games/'));
|
||||||
@@ -40,6 +41,16 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('QuestionsPage', () => {
|
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 () => {
|
it('showsOneCardPerGameWithTheCountInTheTitle', async () => {
|
||||||
renderPage({'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'FINISHED')]})});
|
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 LIBRARY_URL = () => `${import.meta.env.VITE_CORE_API_URL}/api/library`;
|
||||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
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'});
|
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