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
+189 -57

No files matched your search

+35 -3
View File
@@ -1,17 +1,18 @@
import {useCallback, useEffect, useState} from 'react';
import {Link} from 'react-router-dom';
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 {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
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';
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'];
const ALL = 'ALL';
@@ -29,6 +30,7 @@ export default function AuditLogPage() {
const [page, setPage] = useState(0);
const [result, setResult] = useState({content: [], totalPages: 0});
const [status, setStatus] = useState('loading');
const [undoing, setUndoing] = useState(false);
const load = useCallback(async () => {
try {
@@ -54,6 +56,30 @@ export default function AuditLogPage() {
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) => {
setAction(value);
setPage(0);
@@ -100,6 +126,12 @@ export default function AuditLogPage() {
<GameRef appid={entry.targetAppid} name={entry.targetGameName} fallback={entry.targetAppid}/>
</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>
))}
</ul>
+47 -2
View File
@@ -5,16 +5,21 @@ import {MemoryRouter} from 'react-router-dom';
import AuditLogPage from './AuditLogPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
import {fetchRoutes, json} from '../test/fetchRoutes.js';
import {toast} from 'sonner';
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',
targetAppid: -7, targetGameName: 'Dark Souls Remastered', detail: '"Dark Souls" with "Dark Souls Remastered"',
createdAt: '2026-10-01T10:00:00Z', ...over});
const answer = (content, totalPages = 1) => json(200, {content, page: 0, size: 20, totalElements: content.length, totalPages});
const renderPage = (route) => {
fetchRoutes({'GET /api/library/audit-log': route});
const merge = (over) => entry({id: 7, action: 'GAME_MERGE', gameName: null, targetGameName: 'Hades', undoable: true,
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>);
};
@@ -24,6 +29,8 @@ beforeEach(() => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.clearAllMocks();
localStorage.clear();
});
@@ -58,4 +65,42 @@ describe('AuditLogPage', () => {
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)};
}, [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';
return (
@@ -76,7 +81,7 @@ export default function GameDetail() {
<EyeOff className="size-4 shrink-0"/>{t('GAME_DETAIL.HIDDEN_NOTE')}
</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)}
onHiddenChanged={(hidden) => setData((d) => ({...d, hidden}))}
onProfileStyleSaved={setSavedProfileStyle}/>
+25 -6
View File
@@ -64,7 +64,7 @@ describe('GameDetail', () => {
});
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();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
@@ -73,12 +73,19 @@ describe('GameDetail', () => {
expect(screen.queryByText('GAME_DETAIL.HIDDEN_NOTE')).toBeNull();
});
it('offers no hide for a game added by hand', async () => {
answer({...game, source: 'MANUAL', platform: 'PS5', hidden: false});
renderDetail();
it('hides a game added by hand and shows it again', async () => {
answer({...game, appid: -1942, source: 'MANUAL', platform: 'PS5', hidden: false},
{'PUT /api/library/games/-1942/hidden': echo});
renderDetail(-1942);
await screen.findByText('GAME_DETAIL.OPEN_IN_STEAM');
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.HIDE'})).toBeNull();
fireEvent.click(await screen.findByRole('button', {name: 'GAME_DETAIL.HIDE'}));
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 () => {
@@ -419,4 +426,16 @@ describe('GameDetail session', () => {
expect(toast.error).not.toHaveBeenCalled();
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');
});
});