feat(audit): undo button for merges, hide manual games
Build, Test & Push Frontend / quality-check (push) Successful in 31s
Build, Test & Push Frontend / docker (push) Successful in 23s

The audit log shows Undo on a merge row for 30 days, with a confirm
dialog and clear errors. The details page follows a merged key to the
kept game. Hide and Show now work for manual games too. de/en texts.
This commit is contained in:
lobo committed 2026-10-02 13:39:02 +02:00
1 parent 0c300c455d
commit ae904d946a
9 files changed
+159 -27

No files matched your search

+4 -4
View File
@@ -81,7 +81,7 @@ To Be Played is split into three repositories:
- **Finished?**: Core marks a game as finished by itself when the ending achievement is unlocked and the status was - **Finished?**: Core marks a game as finished by itself when the ending achievement is unlocked and the status was
never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than never set by hand. Otherwise the game page asks: "You unlocked the ending, finished?" or "You played longer than
most players need, finished?", with "Mark as finished" and "Not yet". most players need, finished?", with "Mark as finished" and "Not yet".
- **Hide games**: a Steam game (e.g. a playtest or a tool) can be hidden on its detail page. It leaves the lists, - **Hide games**: a game, Steam or added by hand (e.g. a playtest or a tool), can be hidden on its detail page. It leaves the lists,
the dashboard, the stats and the public profile; the library's "Hidden" filter shows it again for unhiding. the dashboard, the stats and the public profile; the library's "Hidden" filter shows it again for unhiding.
- **Wishlist** with store price and discount. - **Wishlist** with store price and discount.
- **Steam account** linked in the settings with "Sign in through Steam": the user signs in on Steam and Auth gets - **Steam account** linked in the settings with "Sign in through Steam": the user signs in on Steam and Auth gets
@@ -131,13 +131,13 @@ To Be Played is split into three repositories:
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image, - **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
speedrun link, moods, endless flag, genres and time to beat (hours and minutes) for every user; each field goes back to the source on its own. speedrun link, moods, endless flag, genres and time to beat (hours and minutes) for every user; each field goes back to the source on its own.
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for "Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
every user. every user; an auditor can undo a merge for 30 days from the audit log ("Undo"). An old link of a merged game opens the kept game.
- **Game catalog** (`/audit/games`, AUDITOR or higher): search every game any user has by name (cover, platforms, - **Game catalog** (`/audit/games`, AUDITOR or higher): search every game any user has by name (cover, platforms,
key), paged; "Correct" opens the correction dialog, also for a game the auditor does not own. The audit log links key), paged; "Correct" opens the correction dialog, also for a game the auditor does not own. The audit log links
its games here. its games here.
- **Link queue** (`/audit/links`, AUDITOR or higher): the pairs IGDB suggests and users asked for, with "requested by N - **Link queue** (`/audit/links`, AUDITOR or higher): the pairs IGDB suggests and users asked for, with "requested by N
players"; Link or Dismiss; paged, 20 pairs a page. **Audit log** (`/audit/log`): the newest merges, links, unlinks, dismissals, corrections and resets with who players"; Link or Dismiss; paged, 20 pairs a page. **Audit log** (`/audit/log`): the newest merges, merge undos, links, unlinks, dismissals, corrections and resets with who
did it, filter by action, paged. did it, filter by action, paged; "Undo" on a merge made in the last 30 days.
- **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the - **Delete my account** in the settings: a dialog asks for the own email and the password. Auth blocks the
account at once and removes it after 30 days; the page ends the session and goes to the landing page. account at once and removes it after 30 days; the page ends the session and goes to the landing page.
- **Build version** in the settings: commit and build time of Frontend, Auth and Core, each hash links to its - **Build version** in the settings: commit and build time of Frontend, Auth and Core, each hash links to its
+15 -2
View File
@@ -269,7 +269,7 @@
"OPEN_IN_STEAM": "Auf Steam ansehen", "OPEN_IN_STEAM": "Auf Steam ansehen",
"HIDE": "Spiel ausblenden", "HIDE": "Spiel ausblenden",
"UNHIDE": "Spiel wieder anzeigen", "UNHIDE": "Spiel wieder anzeigen",
"HIDDEN_NOTE": "Ausgeblendet: Das Spiel fehlt in deinen Listen, Statistiken und im öffentlichen Profil. Nur seine Spielzeit wird weiter aktualisiert.", "HIDDEN_NOTE": "Ausgeblendet: Das Spiel fehlt in deinen Listen, Statistiken und im öffentlichen Profil.",
"HIDDEN_TOAST": "Spiel ausgeblendet", "HIDDEN_TOAST": "Spiel ausgeblendet",
"SHOWN_TOAST": "Spiel wird wieder angezeigt", "SHOWN_TOAST": "Spiel wird wieder angezeigt",
"HIDE_ERROR": "Das Spiel konnte nicht geändert werden.", "HIDE_ERROR": "Das Spiel konnte nicht geändert werden.",
@@ -650,7 +650,7 @@
"OPEN": "Duplikat zusammenführen", "OPEN": "Duplikat zusammenführen",
"TITLE": "Mit demselben Spiel zusammenführen", "TITLE": "Mit demselben Spiel zusammenführen",
"HINT": "Suche den anderen Eintrag von „{{name}}“, z. B. das Steam-Spiel oder ein von Hand hinzugefügtes Spiel. Das Zusammenführen gilt für alle Nutzer.", "HINT": "Suche den anderen Eintrag von „{{name}}“, z. B. das Steam-Spiel oder ein von Hand hinzugefügtes Spiel. Das Zusammenführen gilt für alle Nutzer.",
"CONFIRM": "„{{name}}“ und „{{other}}“ für alle Nutzer zu einem Spiel zusammenführen? Bibliotheken mit beiden behalten einen Eintrag mit dem zuletzt gesetzten Status, der Bewertung und den Notizen. Das lässt sich nicht rückgängig machen.", "CONFIRM": "„{{name}}“ und „{{other}}“ für alle Nutzer zu einem Spiel zusammenführen? Bibliotheken mit beiden behalten einen Eintrag mit dem zuletzt gesetzten Status, der Bewertung und den Notizen. Das lässt sich 30 Tage lang im Audit-Log rückgängig machen.",
"CONFIRM_BUTTON": "Zusammenführen", "CONFIRM_BUTTON": "Zusammenführen",
"BACK": "Zurück", "BACK": "Zurück",
"DONE": "Spiele zusammengeführt", "DONE": "Spiele zusammengeführt",
@@ -710,11 +710,24 @@
"NO_ACCESS": "Nur Auditoren können diese Seite öffnen.", "NO_ACCESS": "Nur Auditoren können diese Seite öffnen.",
"LOAD_ERROR": "Das Protokoll konnte nicht geladen werden.", "LOAD_ERROR": "Das Protokoll konnte nicht geladen werden.",
"USER_ID": "Nutzer {{id}}", "USER_ID": "Nutzer {{id}}",
"UNDO": {
"BUTTON": "Rückgängig",
"CONFIRM": "Die Zusammenführung von „{{name}}“ und „{{other}}“ rückgängig machen? Beide Spiele kommen für alle Nutzer zurück. Status, Bewertung und Notizen, die seitdem gesetzt wurden, bleiben erhalten.",
"DONE": "Zusammenführung rückgängig gemacht.",
"ERROR": "Die Zusammenführung konnte nicht rückgängig gemacht werden.",
"ERROR_CODES": {
"MERGE_NOT_FOUND": "Diese Zusammenführung gibt es nicht.",
"UNDO_NOT_AVAILABLE": "Diese Zusammenführung wurde schon rückgängig gemacht oder ist zu alt.",
"NEWER_MERGE": "Mache zuerst die neuere Zusammenführung dieses Spiels rückgängig.",
"UNDO_EXPIRED": "Die Frist von 30 Tagen für diese Zusammenführung ist abgelaufen."
}
},
"PAGE_OF": "Seite {{current}} von {{total}}", "PAGE_OF": "Seite {{current}} von {{total}}",
"ACTION": { "ACTION": {
"LABEL": "Aktion", "LABEL": "Aktion",
"ALL": "Alle Aktionen", "ALL": "Alle Aktionen",
"GAME_MERGE": "Zusammengeführt", "GAME_MERGE": "Zusammengeführt",
"GAME_MERGE_UNDO": "Zusammenführung rückgängig",
"GAME_LINK": "Verknüpft", "GAME_LINK": "Verknüpft",
"GAME_UNLINK": "Verknüpfung gelöst", "GAME_UNLINK": "Verknüpfung gelöst",
"GAME_LINK_DISMISS": "Verworfen", "GAME_LINK_DISMISS": "Verworfen",
+15 -2
View File
@@ -269,7 +269,7 @@
"OPEN_IN_STEAM": "View on Steam", "OPEN_IN_STEAM": "View on Steam",
"HIDE": "Hide game", "HIDE": "Hide game",
"UNHIDE": "Show game again", "UNHIDE": "Show game again",
"HIDDEN_NOTE": "Hidden: this game is not in your library lists, stats or public profile. Only its playtime is still updated.", "HIDDEN_NOTE": "Hidden: this game is not in your library lists, stats or public profile.",
"HIDDEN_TOAST": "Game hidden", "HIDDEN_TOAST": "Game hidden",
"SHOWN_TOAST": "Game shown again", "SHOWN_TOAST": "Game shown again",
"HIDE_ERROR": "Could not change the game.", "HIDE_ERROR": "Could not change the game.",
@@ -650,7 +650,7 @@
"OPEN": "Merge duplicate", "OPEN": "Merge duplicate",
"TITLE": "Merge with the same game", "TITLE": "Merge with the same game",
"HINT": "Find the other entry of \"{{name}}\", e.g. the Steam game or a game added by hand. The merge applies to every user.", "HINT": "Find the other entry of \"{{name}}\", e.g. the Steam game or a game added by hand. The merge applies to every user.",
"CONFIRM": "Merge \"{{name}}\" and \"{{other}}\" into one game for every user? Libraries with both keep one entry with the status, rating and notes set last. This cannot be undone.", "CONFIRM": "Merge \"{{name}}\" and \"{{other}}\" into one game for every user? Libraries with both keep one entry with the status, rating and notes set last. This can be undone for 30 days in the audit log.",
"CONFIRM_BUTTON": "Merge", "CONFIRM_BUTTON": "Merge",
"BACK": "Back", "BACK": "Back",
"DONE": "Games merged", "DONE": "Games merged",
@@ -710,11 +710,24 @@
"NO_ACCESS": "Only auditors can open this page.", "NO_ACCESS": "Only auditors can open this page.",
"LOAD_ERROR": "The audit log could not be loaded.", "LOAD_ERROR": "The audit log could not be loaded.",
"USER_ID": "user {{id}}", "USER_ID": "user {{id}}",
"UNDO": {
"BUTTON": "Undo",
"CONFIRM": "Undo the merge of \"{{name}}\" and \"{{other}}\"? Both games come back for every user. Status, rating and notes set since the merge are kept.",
"DONE": "Merge undone.",
"ERROR": "The merge could not be undone.",
"ERROR_CODES": {
"MERGE_NOT_FOUND": "This merge does not exist.",
"UNDO_NOT_AVAILABLE": "This merge was already undone or is too old to undo.",
"NEWER_MERGE": "Undo the newer merge of this game first.",
"UNDO_EXPIRED": "The undo of this merge expired after 30 days."
}
},
"PAGE_OF": "Page {{current}} of {{total}}", "PAGE_OF": "Page {{current}} of {{total}}",
"ACTION": { "ACTION": {
"LABEL": "Action", "LABEL": "Action",
"ALL": "All actions", "ALL": "All actions",
"GAME_MERGE": "Merge", "GAME_MERGE": "Merge",
"GAME_MERGE_UNDO": "Merge undone",
"GAME_LINK": "Link", "GAME_LINK": "Link",
"GAME_UNLINK": "Unlink", "GAME_UNLINK": "Unlink",
"GAME_LINK_DISMISS": "Dismissed", "GAME_LINK_DISMISS": "Dismissed",
+6 -7
View File
@@ -23,7 +23,7 @@ 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}; const DETAIL_ELEMENTS = {'fx-detail-gold-shine': 8, 'fx-detail-neon-pulse': 1, 'fx-detail-holo': 1, 'fx-detail-ember': 6};
export default function GameHeroCard({game, manual, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) { export default function GameHeroCard({game, auditor, profileStyle, onCorrect, onMerge, onHiddenChanged, onProfileStyleSaved}) {
const {t} = useTranslation(); const {t} = useTranslation();
const {appearance, equipped} = usePreferences(); const {appearance, equipped} = usePreferences();
const saveAppearance = useSaveAppearance(); const saveAppearance = useSaveAppearance();
@@ -102,9 +102,9 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor
</Button> </Button>
)} )}
</div> </div>
{/* Store page, background and banner need Steam art, so a game off Steam has none of them. {/* Store page, background and banner need Steam art, so a game off Steam has none of them. */}
Hide is here too: only a Steam game can be hidden. */} <div className="flex flex-wrap gap-2">
{onSteam && <div className="flex flex-wrap gap-2"> {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/>
@@ -132,13 +132,12 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor
{profileStyle && ( {profileStyle && (
<ProfileArtMenu appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/> <ProfileArtMenu appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
)} )}
{!manual && ( </>)}
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}> <Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
{game.hidden ? <Eye/> : <EyeOff/>} {game.hidden ? <Eye/> : <EyeOff/>}
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')} {t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
</Button> </Button>
)} </div>
</div>}
</div> </div>
</Card> </Card>
); );
+35 -3
View File
@@ -1,17 +1,18 @@
import {useCallback, useEffect, useState} from 'react'; import {useCallback, useEffect, useState} from 'react';
import {Link} from 'react-router-dom'; import {Link} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {ChevronLeft, ChevronRight, Loader2} from 'lucide-react'; import {ChevronLeft, ChevronRight, Loader2, Undo2} from 'lucide-react';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'; import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import PageContainer from '../components/PageContainer.jsx'; import PageContainer from '../components/PageContainer.jsx';
import {fetchAuditLog} from '../utils/links.js'; import {toast} from 'sonner';
import {fetchAuditLog, undoMerge} from '../utils/links.js';
import {formatDateTime} from '../utils/format.js'; import {formatDateTime} from '../utils/format.js';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
const ACTIONS = ['GAME_MERGE', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS', 'GAME_CORRECT', const ACTIONS = ['GAME_MERGE', 'GAME_MERGE_UNDO', 'GAME_LINK', 'GAME_UNLINK', 'GAME_LINK_DISMISS', 'GAME_CORRECT',
'GAME_CORRECTION_RESET']; 'GAME_CORRECTION_RESET'];
const ALL = 'ALL'; const ALL = 'ALL';
@@ -29,6 +30,7 @@ export default function AuditLogPage() {
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [result, setResult] = useState({content: [], totalPages: 0}); const [result, setResult] = useState({content: [], totalPages: 0});
const [status, setStatus] = useState('loading'); const [status, setStatus] = useState('loading');
const [undoing, setUndoing] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -54,6 +56,30 @@ export default function AuditLogPage() {
load(); load();
}, [load]); }, [load]);
const onUndo = async (entry) => {
// The merged key has no catalog row any more; its name is the first one in the detail.
const name = entry.gameName ?? /^"(.*?)" into "/.exec(entry.detail ?? '')?.[1] ?? entry.appid;
const other = entry.targetGameName ?? entry.targetAppid;
if (!window.confirm(t('AUDIT.UNDO.CONFIRM', {name, other}))) return;
setUndoing(true);
try {
const response = await undoMerge(entry.id);
if (response.status === 401) return;
if (response.ok) {
toast.success(t('AUDIT.UNDO.DONE'));
} else {
const data = await response.json().catch(() => ({}));
toast.error(t(`AUDIT.UNDO.ERROR_CODES.${data.code}`, {defaultValue: t('AUDIT.UNDO.ERROR')}));
}
await load();
} catch (err) {
console.error('Undo merge error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setUndoing(false);
}
};
const onActionChange = (value) => { const onActionChange = (value) => {
setAction(value); setAction(value);
setPage(0); setPage(0);
@@ -100,6 +126,12 @@ export default function AuditLogPage() {
<GameRef appid={entry.targetAppid} name={entry.targetGameName} fallback={entry.targetAppid}/> <GameRef appid={entry.targetAppid} name={entry.targetGameName} fallback={entry.targetAppid}/>
</p> </p>
{entry.detail && <p className="break-words text-xs text-muted-foreground">{entry.detail}</p>} {entry.detail && <p className="break-words text-xs text-muted-foreground">{entry.detail}</p>}
{entry.action === 'GAME_MERGE' && entry.undoable && (
<Button type="button" variant="outline" size="sm" disabled={undoing}
onClick={() => onUndo(entry)}>
<Undo2/>{t('AUDIT.UNDO.BUTTON')}
</Button>
)}
</li> </li>
))} ))}
</ul> </ul>
+47 -2
View File
@@ -5,16 +5,21 @@ import {MemoryRouter} from 'react-router-dom';
import AuditLogPage from './AuditLogPage.jsx'; import AuditLogPage from './AuditLogPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js'; import {fetchRoutes, json} from '../test/fetchRoutes.js';
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()}}));
const entry = (over) => ({id: 1, actorUserId: 5, actorName: 'lobo', action: 'GAME_LINK', appid: 10, gameName: 'Dark Souls', const entry = (over) => ({id: 1, actorUserId: 5, actorName: 'lobo', action: 'GAME_LINK', appid: 10, gameName: 'Dark Souls',
targetAppid: -7, targetGameName: 'Dark Souls Remastered', detail: '"Dark Souls" with "Dark Souls Remastered"', targetAppid: -7, targetGameName: 'Dark Souls Remastered', detail: '"Dark Souls" with "Dark Souls Remastered"',
createdAt: '2026-10-01T10:00:00Z', ...over}); createdAt: '2026-10-01T10:00:00Z', ...over});
const answer = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages}); const answer = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
const renderPage = (route) => { const merge = (over) => entry({id: 7, action: 'GAME_MERGE', gameName: null, targetGameName: 'Hades', undoable: true,
fetchRoutes({'GET /api/library/audit-log': route}); detail: '"Hades (Switch)" into "Hades": 1 moved, 0 combined', ...over});
const renderPage = (route, more = {}) => {
fetchRoutes({'GET /api/library/audit-log': route, ...more});
render(<MemoryRouter><PreferencesProvider><AuditLogPage/></PreferencesProvider></MemoryRouter>); render(<MemoryRouter><PreferencesProvider><AuditLogPage/></PreferencesProvider></MemoryRouter>);
}; };
@@ -24,6 +29,8 @@ beforeEach(() => {
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.restoreAllMocks();
vi.clearAllMocks();
localStorage.clear(); localStorage.clear();
}); });
@@ -58,4 +65,42 @@ describe('AuditLogPage', () => {
expect(await screen.findByText('AUDIT.NO_ACCESS')).toBeTruthy(); expect(await screen.findByText('AUDIT.NO_ACCESS')).toBeTruthy();
}); });
it('showsUndoOnlyOnAnUndoableMerge', async () => {
renderPage(answer([merge({}), merge({id: 8, undoable: false}), entry({id: 9, undoable: true})]));
await screen.findAllByText('AUDIT.ACTION.GAME_MERGE');
expect(screen.getAllByRole('button', {name: /AUDIT.UNDO.BUTTON/})).toHaveLength(1);
});
it('undoesAfterConfirmAndReloads', async () => {
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
renderPage(answer([merge({})]), {'POST /api/library/merges/7/undo': json(204, null)});
fireEvent.click(await screen.findByRole('button', {name: /AUDIT.UNDO.BUTTON/}));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('AUDIT.UNDO.DONE'));
expect(confirm).toHaveBeenCalledWith('AUDIT.UNDO.CONFIRM');
expect(fetch.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(1);
expect(fetch.mock.calls.filter(([url]) => url.includes('/audit-log'))).toHaveLength(2);
});
it('doesNothingWhenTheConfirmIsCancelled', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(false);
renderPage(answer([merge({})]));
fireEvent.click(await screen.findByRole('button', {name: /AUDIT.UNDO.BUTTON/}));
expect(fetch.mock.calls.filter(([, init]) => init?.method === 'POST')).toHaveLength(0);
expect(toast.success).not.toHaveBeenCalled();
});
it.each(['NEWER_MERGE', 'UNDO_EXPIRED'])('showsTheMessageOf%s', async (code) => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
renderPage(answer([merge({})]), {'POST /api/library/merges/7/undo': json(409, {code})});
fireEvent.click(await screen.findByRole('button', {name: /AUDIT.UNDO.BUTTON/}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith(`AUDIT.UNDO.ERROR_CODES.${code}`));
});
}); });
+6 -1
View File
@@ -47,6 +47,11 @@ export default function GameDetail() {
shownLevel.current = {appid: data.appid, level: celebrationLevel(data)}; shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
}, [data]); }, [data]);
// An old key of a merged game: Core answers with the kept game.
useEffect(() => {
if (data && String(data.appid) !== appid) navigate(`/library/${data.appid}`, {replace: true});
}, [data, appid, navigate]);
const manual = data?.source === 'MANUAL'; const manual = data?.source === 'MANUAL';
return ( return (
@@ -76,7 +81,7 @@ export default function GameDetail() {
<EyeOff className="size-4 shrink-0"/>{t('GAME_DETAIL.HIDDEN_NOTE')} <EyeOff className="size-4 shrink-0"/>{t('GAME_DETAIL.HIDDEN_NOTE')}
</div> </div>
)} )}
<GameHeroCard game={data} manual={manual} 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}/>
+25 -6
View File
@@ -64,7 +64,7 @@ describe('GameDetail', () => {
}); });
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(409, {code: 'MANUAL_GAME'})}); 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'})); fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
@@ -73,12 +73,19 @@ describe('GameDetail', () => {
expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull(); expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull();
}); });
it('offers no hide for a game added by hand', async () => { it('hides a game added by hand and shows it again', async () => {
answer({...game, source: 'MANUAL', platform: 'PS5', hidden: false}); answer({...game, appid: -1942, source: 'MANUAL', platform: 'PS5', hidden: false},
renderDetail(); {'PUT /api/library/games/-1942/hidden': echo});
renderDetail(-1942);
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM'); fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.HIDE'})).toBeNull();
expect(await screen.findByText('GAME_DETAIL.HIDDEN_NOTE')).toBeTruthy();
expect(screen.queryByText('GAME_DETAIL.OPEN_IN_STEAM')).toBeNull();
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.UNHIDE'}));
await waitFor(() => expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull());
}); });
it('reports a broken achievement icon once and shows the empty tile', async () => { it('reports a broken achievement icon once and shows the empty tile', async () => {
@@ -419,4 +426,16 @@ describe('GameDetail session', () => {
expect(toast.error).not.toHaveBeenCalled(); expect(toast.error).not.toHaveBeenCalled();
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull(); expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
}); });
it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => {
fetchRoutes({
'GET /api/users/me': json(200, {settings: {}}),
'GET /api/library/games/-7': json(200, {...game, appid: 220, source: 'STEAM'}),
'GET /api/library/games/220': json(200, {...game, appid: 220, source: 'STEAM'}),
});
const {router} = renderDetail(-7);
await waitFor(() => expect(router.state.location.pathname).toBe('/library/220'));
expect(router.state.historyAction).toBe('REPLACE');
});
}); });
+6
View File
@@ -47,3 +47,9 @@ export const fetchAuditLog = ({action, page, size}) => {
if (action) params.set('action', action); if (action) params.set('action', action);
return apiFetch(`${base()}/audit-log?${params}`); return apiFetch(`${base()}/audit-log?${params}`);
}; };
/**
* POST: 204 = undone; 404 MERGE_NOT_FOUND, 409 UNDO_NOT_AVAILABLE / NEWER_MERGE, 410 UNDO_EXPIRED (body `{code}`),
* 403 below AUDITOR.
*/
export const undoMerge = (mergeId) => apiFetch(`${base()}/merges/${mergeId}/undo`, {method: 'POST'});