feat(library): add the game data correction dialog
Build, Test & Push Frontend / quality-check (push) Successful in 17s
Build, Test & Push Frontend / docker (push) Successful in 25s

This commit is contained in:
lobo committed 2026-09-28 09:31:39 +02:00
1 parent afe00ceae9
commit 6601277961
9 files changed
+434 -3

No files matched your search

+158
View File
@@ -0,0 +1,158 @@
import {useEffect, useRef, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2, RotateCcw, Save} from 'lucide-react';
import {toast} from 'sonner';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
import {genreKey} from '../utils/stats.js';
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
// Core refuses more.
const MAX_GENRES = 10;
const initialValues = (game) => ({
name: game.name ?? '',
image: game.imageUrl ?? '',
speedrun: game.speedrunUrl ?? '',
moods: game.moods ?? [],
endless: game.endless ?? null,
genres: game.genres ?? [],
});
// An empty text field means "none": the Steam header, or no speedrun.
const toValue = (value) => (typeof value === 'string' ? value.trim() || null : value);
const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]);
/**
* AUDITOR or higher: corrects the shared data of one game for every user. Each field saves on its own;
* onChanged loads the game again.
*/
export default function GameCorrectionDialog({game, open, onOpenChange, onChanged}) {
const {t} = useTranslation();
const expireSession = useSessionExpired();
const [values, setValues] = useState(() => initialValues(game));
const [busy, setBusy] = useState(null);
// The field whose save or reset the next game from GameDetail answers; the other fields keep their edits.
const sentField = useRef(null);
useEffect(() => {
if (open) setValues(initialValues(game));
// Only when the dialog opens; a reload after a save is handled below.
}, [open]);
useEffect(() => {
const field = sentField.current;
if (field) {
sentField.current = null;
setValues((v) => ({...v, [field]: initialValues(game)[field]}));
}
}, [game]);
const corrected = new Set((game.corrections ?? []).map((c) => c.field.toLowerCase()));
const set = (field, value) => setValues((v) => ({...v, [field]: value}));
const send = async (field, request) => {
setBusy(field);
try {
const response = await request();
if (response.status === 401) {
expireSession();
return;
}
if (response.ok) {
toast.success(t('CORRECTION.SAVED'));
sentField.current = field;
onChanged?.();
return;
}
const data = await response.json().catch(() => ({}));
toast.error(data.code === 'INVALID_CORRECTION' ? t('CORRECTION.INVALID', {message: data.message})
: response.status === 403 ? t('CORRECTION.NO_ACCESS') : t('CORRECTION.ERROR'));
} catch (err) {
console.error('Correction error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(null);
}
};
const row = (field, input) => (
<div key={field} className="space-y-2 border-b pb-4 last:border-b-0">
<div className="flex items-center gap-2">
<Label htmlFor={`correction-${field}`}>{t(`CORRECTION.FIELD.${field.toUpperCase()}`)}</Label>
{corrected.has(field) && <Badge variant="outline">{t('CORRECTION.CORRECTED')}</Badge>}
</div>
{input}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" disabled={busy !== null}
onClick={() => send(field, () => saveCorrection(game.appid, field, toValue(values[field])))}>
{busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')}
</Button>
{corrected.has(field) && (
<Button type="button" size="sm" variant="outline" disabled={busy !== null}
onClick={() => send(field, () => resetCorrection(game.appid, field))}>
<RotateCcw/>{t('CORRECTION.BACK_TO_SOURCE')}
</Button>
)}
</div>
</div>
);
const choices = (field, options, label, max = Infinity) => (
<div id={`correction-${field}`} className="flex flex-wrap gap-2">
{options.map((option) => (
<Button key={option} type="button" size="sm" aria-pressed={values[field].includes(option)}
disabled={!values[field].includes(option) && values[field].length >= max}
variant={values[field].includes(option) ? 'default' : 'outline'}
onClick={() => set(field, toggled(values[field], option))}>
{label(option)}
</Button>
))}
</div>
);
// A genre IGDB added later still shows, so it can be removed.
const genreOptions = [...IGDB_GENRES, ...values.genres.filter((g) => !IGDB_GENRES.includes(g))];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('CORRECTION.TITLE')}</DialogTitle>
<DialogDescription>{t('CORRECTION.TEXT')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{row('name', <Input id="correction-name" value={values.name} maxLength={512}
onChange={(e) => set('name', e.target.value)}/>)}
{row('image', <Input id="correction-image" value={values.image} maxLength={512}
placeholder={t('CORRECTION.IMAGE_HINT')}
onChange={(e) => set('image', e.target.value)}/>)}
{row('speedrun', <Input id="correction-speedrun" value={values.speedrun} maxLength={512}
placeholder={t('CORRECTION.SPEEDRUN_HINT')}
onChange={(e) => set('speedrun', e.target.value)}/>)}
{row('moods', choices('moods', CORRECTABLE_MOODS, (mood) => t(`PLAY_NEXT.MOOD.${mood}`)))}
{row('endless', (
<div id="correction-endless" className="flex flex-wrap gap-2">
{ENDLESS_CHOICES.map(([choice, key]) => (
<Button key={key} type="button" size="sm" aria-pressed={values.endless === choice}
variant={values.endless === choice ? 'default' : 'outline'}
onClick={() => set('endless', choice)}>
{t(`CORRECTION.ENDLESS.${key}`)}
</Button>
))}
</div>
))}
{row('genres', choices('genres', genreOptions,
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_GENRES))}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,114 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, 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 GameCorrectionDialog from './GameCorrectionDialog.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const game = {
appid: 620, name: 'Portal 2', imageUrl: null, speedrunUrl: 'https://www.speedrun.com/portal_2',
moods: ['STORY'], endless: null, genres: ['Puzzle'],
corrections: [{field: 'NAME', correctedAt: '2026-09-28T10:00:00Z'}],
};
const answer = (status, body = {}) => ({ok: status < 300, status, json: async () => body});
const renderDialog = (onChanged = vi.fn()) => {
render(<MemoryRouter><GameCorrectionDialog game={game} open onOpenChange={vi.fn()} onChanged={onChanged}/></MemoryRouter>);
return onChanged;
};
// The rows in order: name, image, speedrun, moods, endless, genres.
const saveButton = (row) => screen.getAllByRole('button', {name: 'CORRECTION.SAVE'})[row];
const sent = () => fetch.mock.calls.map(([url, init]) => ({url, method: init?.method, body: init?.body && JSON.parse(init.body)}));
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async () => answer(204));
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GameCorrectionDialog', () => {
it('saves one field and loads the game again', async () => {
const onChanged = renderDialog();
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.NAME'), {target: {value: ' Portal Two '}});
fireEvent.click(saveButton(0));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(sent()).toEqual([{url: expect.stringContaining('/api/library/games/620/corrections/name'), method: 'PUT',
body: {value: 'Portal Two'}}]);
expect(toast.success).toHaveBeenCalledWith('CORRECTION.SAVED');
});
it('sends an empty link as "no speedrun" and the chosen moods as a list', async () => {
renderDialog();
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.SPEEDRUN'), {target: {value: ' '}});
fireEvent.click(saveButton(2));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.COOP'}));
fireEvent.click(screen.getByRole('button', {name: 'PLAY_NEXT.MOOD.STORY'}));
fireEvent.click(saveButton(3));
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]);
});
it('marks a corrected field and goes back to the source', async () => {
const onChanged = renderDialog();
expect(screen.getAllByText('CORRECTION.CORRECTED')).toHaveLength(1);
fireEvent.click(screen.getByRole('button', {name: 'CORRECTION.BACK_TO_SOURCE'}));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
expect(sent()).toEqual([{url: expect.stringContaining('/corrections/name'), method: 'DELETE', body: undefined}]);
});
it('shows why Core refused a value', async () => {
globalThis.fetch = vi.fn(async () => answer(400, {code: 'INVALID_CORRECTION', message: 'image must be https'}));
const onChanged = renderDialog();
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.IMAGE'), {target: {value: 'http://x'}});
fireEvent.click(saveButton(1));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('CORRECTION.INVALID'));
expect(onChanged).not.toHaveBeenCalled();
});
it('keeps an unsaved edit of another field after the reload', async () => {
const onChanged = vi.fn();
const {rerender} = render(<MemoryRouter><GameCorrectionDialog game={game} open onOpenChange={vi.fn()}
onChanged={onChanged}/></MemoryRouter>);
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.SPEEDRUN'), {target: {value: 'https://www.speedrun.com/portal'}});
fireEvent.change(screen.getByLabelText('CORRECTION.FIELD.NAME'), {target: {value: 'Portal Two'}});
fireEvent.click(saveButton(0));
await waitFor(() => expect(onChanged).toHaveBeenCalled());
// GameDetail loads the game again and passes the new object.
rerender(<MemoryRouter><GameCorrectionDialog game={{...game, name: 'Portal Two'}} open onOpenChange={vi.fn()}
onChanged={onChanged}/></MemoryRouter>);
expect(screen.getByLabelText('CORRECTION.FIELD.SPEEDRUN').value).toBe('https://www.speedrun.com/portal');
expect(screen.getByLabelText('CORRECTION.FIELD.NAME').value).toBe('Portal Two');
});
it('offers no eleventh genre', () => {
renderDialog();
const genres = ['Adventure', 'Arcade', 'Fighting', 'Indie', 'MOBA', 'Music', 'Pinball', 'Platform', 'Racing'];
genres.forEach((genre) => fireEvent.click(screen.getByRole('button', {name: `STATS.GENRE.${genre.toUpperCase()}`})));
expect(screen.getByRole('button', {name: 'STATS.GENRE.SHOOTER'}).disabled).toBe(true);
expect(screen.getByRole('button', {name: 'STATS.GENRE.PUZZLE'}).disabled).toBe(false);
});
});