Public Access
feat(library): add the game data correction dialog
This commit is contained in:
1 parent
afe00ceae9
commit
6601277961
9 files changed
+434
-3
No files matched your search
@@ -57,6 +57,8 @@ To Be Played is split into three repositories:
|
|||||||
- **Admin** (PLATFORM_ADMIN only, `/admin/users`): search the users, block, unblock and delete them (the email is
|
- **Admin** (PLATFORM_ADMIN only, `/admin/users`): search the users, block, unblock and delete them (the email is
|
||||||
typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge
|
typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge
|
||||||
shows the lower role. Auth checks every right on the server.
|
shows the lower role. Auth checks every right on the server.
|
||||||
|
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image,
|
||||||
|
speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own.
|
||||||
- **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
|
||||||
|
|||||||
@@ -518,5 +518,32 @@
|
|||||||
"PAGE_OF": "Seite {{current}} von {{total}}",
|
"PAGE_OF": "Seite {{current}} von {{total}}",
|
||||||
"PREVIOUS": "Zurück",
|
"PREVIOUS": "Zurück",
|
||||||
"NEXT": "Weiter"
|
"NEXT": "Weiter"
|
||||||
|
},
|
||||||
|
"CORRECTION": {
|
||||||
|
"OPEN": "Daten korrigieren",
|
||||||
|
"TITLE": "Spieldaten korrigieren",
|
||||||
|
"TEXT": "Änderungen gelten für alle Nutzer und bleiben auch nach dem nächsten Abgleich mit Steam, IGDB oder speedrun.com.",
|
||||||
|
"SAVE": "Speichern",
|
||||||
|
"BACK_TO_SOURCE": "Zurück zur Quelle",
|
||||||
|
"CORRECTED": "Korrigiert",
|
||||||
|
"SAVED": "Korrektur gespeichert",
|
||||||
|
"INVALID": "Nicht gespeichert: {{message}}",
|
||||||
|
"NO_ACCESS": "Nur Auditoren können Spieldaten korrigieren.",
|
||||||
|
"ERROR": "Die Korrektur konnte nicht gespeichert werden.",
|
||||||
|
"IMAGE_HINT": "https://… (leer: das Steam-Headerbild)",
|
||||||
|
"SPEEDRUN_HINT": "https://www.speedrun.com/… (leer: kein Speedrun)",
|
||||||
|
"FIELD": {
|
||||||
|
"NAME": "Name",
|
||||||
|
"IMAGE": "URL des Headerbilds",
|
||||||
|
"SPEEDRUN": "Speedrun-Link",
|
||||||
|
"MOODS": "Stimmungen",
|
||||||
|
"ENDLESS": "Endloses Spiel",
|
||||||
|
"GENRES": "Genres"
|
||||||
|
},
|
||||||
|
"ENDLESS": {
|
||||||
|
"UNKNOWN": "Unbekannt",
|
||||||
|
"YES": "Ja",
|
||||||
|
"NO": "Nein"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -518,5 +518,32 @@
|
|||||||
"PAGE_OF": "Page {{current}} of {{total}}",
|
"PAGE_OF": "Page {{current}} of {{total}}",
|
||||||
"PREVIOUS": "Previous",
|
"PREVIOUS": "Previous",
|
||||||
"NEXT": "Next"
|
"NEXT": "Next"
|
||||||
|
},
|
||||||
|
"CORRECTION": {
|
||||||
|
"OPEN": "Correct data",
|
||||||
|
"TITLE": "Correct game data",
|
||||||
|
"TEXT": "Changes apply to every user and stay after the next refresh from Steam, IGDB or speedrun.com.",
|
||||||
|
"SAVE": "Save",
|
||||||
|
"BACK_TO_SOURCE": "Back to source",
|
||||||
|
"CORRECTED": "Corrected",
|
||||||
|
"SAVED": "Correction saved",
|
||||||
|
"INVALID": "Not saved: {{message}}",
|
||||||
|
"NO_ACCESS": "Only auditors can correct game data.",
|
||||||
|
"ERROR": "The correction could not be saved.",
|
||||||
|
"IMAGE_HINT": "https://… (empty: the Steam header)",
|
||||||
|
"SPEEDRUN_HINT": "https://www.speedrun.com/… (empty: no speedrun)",
|
||||||
|
"FIELD": {
|
||||||
|
"NAME": "Name",
|
||||||
|
"IMAGE": "Header image URL",
|
||||||
|
"SPEEDRUN": "Speedrun link",
|
||||||
|
"MOODS": "Moods",
|
||||||
|
"ENDLESS": "Endless game",
|
||||||
|
"GENRES": "Genres"
|
||||||
|
},
|
||||||
|
"ENDLESS": {
|
||||||
|
"UNKNOWN": "Unknown",
|
||||||
|
"YES": "Yes",
|
||||||
|
"NO": "No"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useEffect, useMemo, useState} from 'react';
|
import {useEffect, useMemo, useState} from 'react';
|
||||||
import {Link, useNavigate, useParams} from 'react-router-dom';
|
import {Link, useNavigate, useParams} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, PanelTop, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react';
|
import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, PanelTop, PencilLine, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
@@ -15,15 +15,18 @@ import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
|||||||
import ManualGameCard from '../components/ManualGameCard.jsx';
|
import ManualGameCard from '../components/ManualGameCard.jsx';
|
||||||
import CompletedHint from '../components/CompletedHint.jsx';
|
import CompletedHint from '../components/CompletedHint.jsx';
|
||||||
import FinishedHint from '../components/FinishedHint.jsx';
|
import FinishedHint from '../components/FinishedHint.jsx';
|
||||||
|
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
|
||||||
import PlayingHint from '../components/PlayingHint.jsx';
|
import PlayingHint from '../components/PlayingHint.jsx';
|
||||||
import TimeToBeat from '../components/TimeToBeat.jsx';
|
import TimeToBeat from '../components/TimeToBeat.jsx';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {apiFetch} from '../utils/api.js';
|
||||||
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
||||||
|
import {canCorrect} from '../utils/corrections.js';
|
||||||
import {setGameHidden} from '../utils/hiddenGames.js';
|
import {setGameHidden} from '../utils/hiddenGames.js';
|
||||||
import {hasSession} from '../utils/auth.js';
|
import {hasSession} from '../utils/auth.js';
|
||||||
import {isSteamKey} from '../utils/manualGames.js';
|
import {isSteamKey} from '../utils/manualGames.js';
|
||||||
@@ -63,6 +66,9 @@ export default function GameDetail() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [hiding, setHiding] = useState(false);
|
const [hiding, setHiding] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
const [correcting, setCorrecting] = useState(false);
|
||||||
|
const [reloads, setReloads] = useState(0);
|
||||||
|
const auditor = canCorrect(useCurrentUser(true));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -107,7 +113,7 @@ export default function GameDetail() {
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [appid, navigate, expireSession, t]);
|
}, [appid, navigate, expireSession, t, reloads]);
|
||||||
|
|
||||||
const sortedAchievements = useMemo(() => {
|
const sortedAchievements = useMemo(() => {
|
||||||
if (!data?.achievements) return [];
|
if (!data?.achievements) return [];
|
||||||
@@ -211,6 +217,11 @@ export default function GameDetail() {
|
|||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{auditor && (
|
||||||
|
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={() => setCorrecting(true)}>
|
||||||
|
<PencilLine/>{t('CORRECTION.OPEN')}
|
||||||
|
</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. */}
|
Hide is here too: only a Steam game can be hidden. */}
|
||||||
@@ -339,6 +350,11 @@ export default function GameDetail() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{/* Outside the content: the reload after a save must not close it. */}
|
||||||
|
{data && auditor && (
|
||||||
|
<GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting}
|
||||||
|
onChanged={() => setReloads((n) => n + 1)}/>
|
||||||
|
)}
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||||
import GameDetail from './GameDetail.jsx';
|
import GameDetail from './GameDetail.jsx';
|
||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
@@ -241,4 +241,34 @@ describe('GameDetail of a game added by hand', () => {
|
|||||||
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
|
expect(await screen.findByRole('button', {name: 'GAME_DETAIL.SET_BACKGROUND'})).toBeTruthy();
|
||||||
expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull();
|
expect(screen.queryByText('MANUAL_GAME.TITLE')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('corrections', () => {
|
||||||
|
const loggedInAs = (me) => {
|
||||||
|
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
|
||||||
|
json: async () => (url.includes('/api/users/me') ? me : game)}));
|
||||||
|
};
|
||||||
|
|
||||||
|
it('offers "correct data" to an active auditor', async () => {
|
||||||
|
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
|
||||||
|
renderDetail();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', {name: 'CORRECTION.OPEN'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('offers nothing to a user or to an admin viewing as user', async () => {
|
||||||
|
for (const me of [{role: 'USER', activeRole: 'USER'}, {role: 'PLATFORM_ADMIN', activeRole: 'USER'}]) {
|
||||||
|
loggedInAs(me);
|
||||||
|
renderDetail();
|
||||||
|
|
||||||
|
await screen.findByText('Half-Life 2');
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||||
|
// Let the answer of /me reach the page before looking for the button.
|
||||||
|
await act(async () => {});
|
||||||
|
expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
|
||||||
|
cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import {apiFetch} from './api.js';
|
||||||
|
import {ROLES} from './admin.js';
|
||||||
|
|
||||||
|
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||||
|
|
||||||
|
const url = (appid, field) =>
|
||||||
|
`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/corrections/${field}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IGDB's genre names, the values Core stores. Each has a STATS.GENRE key (see genreKey).
|
||||||
|
*/
|
||||||
|
export const IGDB_GENRES = ['Adventure', 'Arcade', 'Card & Board Game', 'Fighting', "Hack and slash/Beat 'em up",
|
||||||
|
'Indie', 'MOBA', 'Music', 'Pinball', 'Platform', 'Point-and-click', 'Puzzle', 'Quiz/Trivia', 'Racing',
|
||||||
|
'Real Time Strategy (RTS)', 'Role-playing (RPG)', 'Shooter', 'Simulator', 'Sport', 'Strategy', 'Tactical',
|
||||||
|
'Turn-based strategy (TBS)', 'Visual Novel'];
|
||||||
|
|
||||||
|
// SPEEDRUN is the speedrun link, not a stored mood.
|
||||||
|
export const CORRECTABLE_MOODS = ['STORY', 'COOP', 'CHILL', 'ACTION', 'SCARY'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An AUDITOR or higher, by the active role: an admin viewing as USER cannot correct.
|
||||||
|
*/
|
||||||
|
export const canCorrect = (currentUser) =>
|
||||||
|
!!currentUser && ROLES.indexOf(currentUser.activeRole) >= ROLES.indexOf('AUDITOR');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT: 204, 400 {code: INVALID_CORRECTION, message}, 403 below AUDITOR, 404 unknown game.
|
||||||
|
*/
|
||||||
|
export const saveCorrection = (appid, field, value) =>
|
||||||
|
apiFetch(url(appid, field), {method: 'PUT', headers: JSON_HEADERS, body: JSON.stringify({value})});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE: back to the source's value, 204.
|
||||||
|
*/
|
||||||
|
export const resetCorrection = (appid, field) => apiFetch(url(appid, field), {method: 'DELETE'});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import {describe, expect, it} from 'vitest';
|
||||||
|
import en from '../../public/i18n/en.json';
|
||||||
|
import de from '../../public/i18n/de.json';
|
||||||
|
import {canCorrect, IGDB_GENRES} from './corrections.js';
|
||||||
|
import {genreKey} from './stats.js';
|
||||||
|
|
||||||
|
describe('corrections', () => {
|
||||||
|
it('knows a text for every IGDB genre it offers', () => {
|
||||||
|
for (const genre of IGDB_GENRES) {
|
||||||
|
expect(en.STATS.GENRE[genreKey(genre)], genre).toBeTruthy();
|
||||||
|
expect(de.STATS.GENRE[genreKey(genre)], genre).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets only an active AUDITOR or higher correct', () => {
|
||||||
|
expect(canCorrect({role: 'AUDITOR', activeRole: 'AUDITOR'})).toBe(true);
|
||||||
|
expect(canCorrect({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'})).toBe(true);
|
||||||
|
expect(canCorrect({role: 'PLATFORM_ADMIN', activeRole: 'USER'})).toBe(false);
|
||||||
|
expect(canCorrect({role: 'USER', activeRole: 'USER'})).toBe(false);
|
||||||
|
expect(canCorrect(null)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user