Public Access
feat(audit): game catalog search and time to beat correction
New page /audit/games: search every game and open the correction dialog without owning it. Time to beat as hours + minutes. Audit log game links open /audit/games?appid=. Menu entry next to the link queue.
This commit is contained in:
1 parent
6627a8db66
commit
0c300c455d
14 files changed
+439
-19
No files matched your search
@@ -1,7 +1,7 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Settings, Shield, Sun} from 'lucide-react';
|
||||
import {ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
@@ -158,6 +158,9 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
||||
</DropdownMenuItem>
|
||||
{canCorrect(currentUser) && (
|
||||
<>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/audit/games"><Search/>{t('NAV.GAME_CATALOG')}</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/audit/links"><Link2/>{t('NAV.LINK_QUEUE')}</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -88,12 +88,13 @@ describe('AppHeader', () => {
|
||||
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
||||
});
|
||||
|
||||
it('offersTheLinkQueueAndAuditLogToAnAuditorOnly', async () => {
|
||||
it('offersTheCatalogLinkQueueAndAuditLogToAnAuditorOnly', async () => {
|
||||
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
|
||||
openMenu();
|
||||
|
||||
expect(await screen.findByText('NAV.LINK_QUEUE')).toBeTruthy();
|
||||
expect(await screen.findByText('NAV.GAME_CATALOG')).toBeTruthy();
|
||||
expect(screen.getByText('NAV.LINK_QUEUE')).toBeTruthy();
|
||||
expect(screen.getByText('NAV.AUDIT_LOG')).toBeTruthy();
|
||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||
});
|
||||
@@ -108,6 +109,7 @@ describe('AppHeader', () => {
|
||||
expect(screen.getByText('NAV.WHATS_NEW')).toBeTruthy();
|
||||
expect(screen.queryByText('NAV.ADMIN')).toBeNull();
|
||||
expect(screen.queryByText('NAV.LINK_QUEUE')).toBeNull();
|
||||
expect(screen.queryByText('NAV.GAME_CATALOG')).toBeNull();
|
||||
expect(screen.queryByText('NAV.ADMIN_NEWS')).toBeNull();
|
||||
expect(screen.queryByText('NAV.ADMIN_BROADCASTS')).toBeNull();
|
||||
expect(screen.queryByText('NAV.VIEW_AS.MENU')).toBeNull();
|
||||
|
||||
@@ -10,12 +10,19 @@ import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {CORRECTABLE_MOODS, IGDB_GENRES, resetCorrection, saveCorrection} from '../utils/corrections.js';
|
||||
import {genreKey} from '../utils/stats.js';
|
||||
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH} from '../utils/limits.js';
|
||||
import {MAX_CORRECTION_GENRES, MAX_CORRECTION_LENGTH, MAX_TIME_TO_BEAT_MINUTES} from '../utils/limits.js';
|
||||
|
||||
const REFUSAL_KEY = {INVALID_CORRECTION: 'CORRECTION.INVALID'};
|
||||
|
||||
const ENDLESS_CHOICES = [[null, 'UNKNOWN'], [true, 'YES'], [false, 'NO']];
|
||||
|
||||
const toHoursAndMinutes = (minutes) => (minutes ? {h: String(Math.floor(minutes / 60)), m: String(minutes % 60)} : {h: '', m: ''});
|
||||
|
||||
const toMinutes = ({h, m}) => (h.trim() === '' && m.trim() === '' ? null
|
||||
: Math.round(Number(h || 0) * 60 + Number(m || 0)));
|
||||
|
||||
const hasNegativePart = ({normally, completely}) => [normally.h, normally.m, completely.h, completely.m].some((part) => Number(part) < 0);
|
||||
|
||||
const initialValues = (game) => ({
|
||||
name: game.name ?? '',
|
||||
image: game.imageUrl ?? '',
|
||||
@@ -23,10 +30,19 @@ const initialValues = (game) => ({
|
||||
moods: game.moods ?? [],
|
||||
endless: game.endless ?? null,
|
||||
genres: game.genres ?? [],
|
||||
time_to_beat: {
|
||||
normally: toHoursAndMinutes(game.timeToBeat?.normallyMinutes),
|
||||
completely: toHoursAndMinutes(game.timeToBeat?.completelyMinutes),
|
||||
},
|
||||
});
|
||||
|
||||
// An empty text field means "none": the Steam header, or no speedrun.
|
||||
const toValue = (value) => (typeof value === 'string' ? value.trim() || null : value);
|
||||
const toValue = (field, value) => {
|
||||
if (field === 'time_to_beat') {
|
||||
return {normallyMinutes: toMinutes(value.normally), completelyMinutes: toMinutes(value.completely)};
|
||||
}
|
||||
return typeof value === 'string' ? value.trim() || null : value;
|
||||
};
|
||||
|
||||
const toggled = (list, item) => (list.includes(item) ? list.filter((each) => each !== item) : [...list, item]);
|
||||
|
||||
@@ -81,6 +97,14 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
||||
}
|
||||
};
|
||||
|
||||
const save = (field) => {
|
||||
if (field === 'time_to_beat' && hasNegativePart(values.time_to_beat)) {
|
||||
toast.error(t('CORRECTION.NEGATIVE'));
|
||||
return;
|
||||
}
|
||||
send(field, () => saveCorrection(game.appid, field, toValue(field, values[field])));
|
||||
};
|
||||
|
||||
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">
|
||||
@@ -90,7 +114,7 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
||||
{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])))}>
|
||||
onClick={() => save(field)}>
|
||||
{busy === field ? <Loader2 className="animate-spin"/> : <Save/>}{t('CORRECTION.SAVE')}
|
||||
</Button>
|
||||
{corrected.has(field) && (
|
||||
@@ -116,6 +140,25 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
||||
</div>
|
||||
);
|
||||
|
||||
const timeInputs = (kind, label) => {
|
||||
const setPart = (part, text) => set('time_to_beat', {
|
||||
...values.time_to_beat, [kind]: {...values.time_to_beat[kind], [part]: text},
|
||||
});
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="w-20 text-sm">{label}</span>
|
||||
<Input type="number" min="0" max={MAX_TIME_TO_BEAT_MINUTES / 60} step="1" className="w-24"
|
||||
aria-label={`${label} ${t('CORRECTION.HOURS')}`} value={values.time_to_beat[kind].h}
|
||||
onChange={(e) => setPart('h', e.target.value)}/>
|
||||
<span className="text-sm text-muted-foreground">{t('CORRECTION.HOURS')}</span>
|
||||
<Input type="number" min="0" max="59" step="1" className="w-20"
|
||||
aria-label={`${label} ${t('CORRECTION.MINUTES')}`} value={values.time_to_beat[kind].m}
|
||||
onChange={(e) => setPart('m', e.target.value)}/>
|
||||
<span className="text-sm text-muted-foreground">{t('CORRECTION.MINUTES')}</span>
|
||||
</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))];
|
||||
|
||||
@@ -149,6 +192,12 @@ export default function GameCorrectionDialog({game, open, onOpenChange, onChange
|
||||
))}
|
||||
{row('genres', choices('genres', genreOptions,
|
||||
(genre) => t(`STATS.GENRE.${genreKey(genre)}`, {defaultValue: genre}), MAX_CORRECTION_GENRES))}
|
||||
{row('time_to_beat', (
|
||||
<div id="correction-time_to_beat" className="space-y-2">
|
||||
{timeInputs('normally', t('GAME_DETAIL.TTB_NORMALLY'))}
|
||||
{timeInputs('completely', t('GAME_DETAIL.TTB_COMPLETELY'))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -13,12 +13,13 @@ 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'}],
|
||||
timeToBeat: {hastilyMinutes: 300, normallyMinutes: 750, completelyMinutes: null},
|
||||
};
|
||||
|
||||
const GAME_URL = '/api/library/games/620/corrections';
|
||||
const corrections = (answer) => fetchRoutes({
|
||||
[`PUT ${GAME_URL}/name`]: answer, [`PUT ${GAME_URL}/image`]: answer, [`PUT ${GAME_URL}/speedrun`]: answer,
|
||||
[`PUT ${GAME_URL}/moods`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
|
||||
[`PUT ${GAME_URL}/moods`]: answer, [`PUT ${GAME_URL}/time_to_beat`]: answer, [`DELETE ${GAME_URL}/name`]: answer,
|
||||
});
|
||||
|
||||
const renderDialog = (onChanged = vi.fn()) => {
|
||||
@@ -26,7 +27,7 @@ const renderDialog = (onChanged = vi.fn()) => {
|
||||
return onChanged;
|
||||
};
|
||||
|
||||
// The rows in order: name, image, speedrun, moods, endless, genres.
|
||||
// The rows in order: name, image, speedrun, moods, endless, genres, time_to_beat.
|
||||
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)}));
|
||||
@@ -69,6 +70,49 @@ describe('GameCorrectionDialog', () => {
|
||||
expect(sent().map((call) => call.body)).toEqual([{value: null}, {value: ['COOP']}]);
|
||||
});
|
||||
|
||||
it('shows the time to beat as hours and minutes and sends minutes, empty as null', async () => {
|
||||
renderDialog();
|
||||
|
||||
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS').value).toBe('12');
|
||||
expect(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES').value).toBe('30');
|
||||
expect(screen.getByLabelText('GAME_DETAIL.TTB_COMPLETELY CORRECTION.HOURS').value).toBe('');
|
||||
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
|
||||
fireEvent.click(saveButton(6));
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||
expect(sent()[0].body).toEqual({value: {normallyMinutes: 720, completelyMinutes: null}});
|
||||
});
|
||||
|
||||
it('sendsFractionalHoursAsExactMinutes', async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1.5'}});
|
||||
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: ''}});
|
||||
fireEvent.click(saveButton(6));
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledTimes(1));
|
||||
expect(sent()[0].body.value.normallyMinutes).toBe(90);
|
||||
});
|
||||
|
||||
it('refusesANegativeTimePartBeforeSaving', () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.HOURS'), {target: {value: '1'}});
|
||||
fireEvent.change(screen.getByLabelText('GAME_DETAIL.TTB_NORMALLY CORRECTION.MINUTES'), {target: {value: '-5'}});
|
||||
fireEvent.click(saveButton(6));
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('CORRECTION.NEGATIVE');
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('offers back to source for a corrected time to beat', () => {
|
||||
render(<MemoryRouter><GameCorrectionDialog open onOpenChange={vi.fn()} onChanged={vi.fn()}
|
||||
game={{...game, corrections: [{field: 'TIME_TO_BEAT', correctedAt: '2026-09-28T10:00:00Z'}]}}/></MemoryRouter>);
|
||||
|
||||
expect(screen.getAllByText('CORRECTION.CORRECTED')).toHaveLength(1);
|
||||
expect(screen.getByRole('button', {name: 'CORRECTION.BACK_TO_SOURCE'})).toBeTruthy();
|
||||
});
|
||||
|
||||
it('marks a corrected field and goes back to the source', async () => {
|
||||
const onChanged = renderDialog();
|
||||
|
||||
|
||||
Reference in new issue
Block a user