Public Access
feat(audit): undo button for merges, hide manual games
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:
1 parent
0c300c455d
commit
ae904d946a
9 files changed
+189
-57
No files matched your search
@@ -23,7 +23,7 @@ const SPEEDRUN_PREFIX = 'https://www.speedrun.com/';
|
||||
// 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};
|
||||
|
||||
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 {appearance, equipped} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
@@ -102,43 +102,42 @@ export default function GameHeroCard({game, manual, auditor, profileStyle, onCor
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
{onSteam && <div className="flex flex-wrap gap-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={`https://store.steampowered.com/app/${game.appid}`} target="_blank" rel="noopener noreferrer">
|
||||
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
||||
</a>
|
||||
{/* Store page, background and banner need Steam art, so a game off Steam has none of them. */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{onSteam && (<>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<a href={`https://store.steampowered.com/app/${game.appid}`} target="_blank" rel="noopener noreferrer">
|
||||
{t('GAME_DETAIL.OPEN_IN_STEAM')}<ExternalLink/>
|
||||
</a>
|
||||
</Button>
|
||||
{isBackground ? (
|
||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
|
||||
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: game.appid})}>
|
||||
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
||||
</Button>
|
||||
)}
|
||||
{isBanner ? (
|
||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}>
|
||||
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
|
||||
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
||||
</Button>
|
||||
)}
|
||||
{profileStyle && (
|
||||
<ProfileArtMenu appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
|
||||
)}
|
||||
</>)}
|
||||
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
||||
{game.hidden ? <Eye/> : <EyeOff/>}
|
||||
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
||||
</Button>
|
||||
{isBackground ? (
|
||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: null})}>
|
||||
<ImageOff/>{t('GAME_DETAIL.REMOVE_BACKGROUND')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({backgroundAppid: game.appid})}>
|
||||
<ImageIcon/>{t('GAME_DETAIL.SET_BACKGROUND')}
|
||||
</Button>
|
||||
)}
|
||||
{isBanner ? (
|
||||
<Button variant="outline" size="sm" onClick={() => saveAppearance({bannerAppid: null})}>
|
||||
<RotateCcw/>{t('SETTINGS.BANNER_AUTO')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm"
|
||||
onClick={() => saveAppearance({bannerAppid: game.appid, showBanner: true})}>
|
||||
<PanelTop/>{t('GAME_DETAIL.SET_BANNER')}
|
||||
</Button>
|
||||
)}
|
||||
{profileStyle && (
|
||||
<ProfileArtMenu appid={game.appid} style={profileStyle} onSaved={onProfileStyleSaved}/>
|
||||
)}
|
||||
{!manual && (
|
||||
<Button variant="outline" size="sm" onClick={toggleHidden} disabled={hiding}>
|
||||
{game.hidden ? <Eye/> : <EyeOff/>}
|
||||
{t(game.hidden ? 'GAME_DETAIL.UNHIDE' : 'GAME_DETAIL.HIDE')}
|
||||
</Button>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}`));
|
||||
});
|
||||
});
|
||||
@@ -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}/>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -47,3 +47,9 @@ export const fetchAuditLog = ({action, page, size}) => {
|
||||
if (action) params.set('action', action);
|
||||
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'});
|
||||
Reference in new issue
Block a user