feat(library): add the merge duplicate dialog
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 24s

This commit is contained in:
lobo committed 2026-09-28 11:25:45 +02:00
1 parent 01ae372b2d
commit 78ba0700c8
10 files changed
+355 -84

No files matched your search

+3 -1
View File
@@ -32,7 +32,7 @@ To Be Played is split into three repositories:
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements. in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit, - **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
a platform and optional hours. Works without a Steam ID. a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a a link to the game's speedrun.com page when it has verified runs, a hint when Steam shows recent playtime for a
game still in the backlog, and one to set the status to 100 % when game still in the backlog, and one to set the status to 100 % when
@@ -62,6 +62,8 @@ To Be Played is split into three repositories:
"Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them). "Accept" hint stays until "OK". Admins write, preview and end hints on `/admin/hints` (Auth stores them).
- **Correct game data** (AUDITOR or higher): "Correct data" on the game detail page changes name, header image, - **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. speedrun link, moods, endless flag and genres for every user; each field goes back to the source on its own.
"Merge duplicate" joins two entries of the same game (e.g. the Steam game and the same game added by hand) for
every user.
- **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
+16
View File
@@ -547,6 +547,22 @@
"NO": "Nein" "NO": "Nein"
} }
}, },
"MERGE": {
"OPEN": "Duplikat zusammenführen",
"TITLE": "Mit demselben Spiel zusammenführen",
"HINT": "Suche den anderen Eintrag von „{{name}}“, z. B. das Steam-Spiel oder ein von Hand hinzugefügtes Spiel. Das Zusammenführen gilt für alle Nutzer.",
"CONFIRM": "„{{name}}“ und „{{other}}“ für alle Nutzer zu einem Spiel zusammenführen? Bibliotheken mit beiden behalten einen Eintrag mit dem zuletzt gesetzten Status, der Bewertung und den Notizen. Das lässt sich nicht rückgängig machen.",
"CONFIRM_BUTTON": "Zusammenführen",
"BACK": "Zurück",
"DONE": "Spiele zusammengeführt",
"NO_ACCESS": "Nur Auditoren können Spiele zusammenführen.",
"ERROR": {
"SAME_GAME": "Ein Spiel kann nicht mit sich selbst zusammengeführt werden.",
"STEAM_GAMES": "Zwei Steam-Spiele können nicht zusammengeführt werden.",
"GAME_NOT_FOUND": "Dieses Spiel ist noch nicht in ToBePlayed und kann nicht zusammengeführt werden.",
"OTHER": "Die Spiele konnten nicht zusammengeführt werden."
}
},
"HINT": { "HINT": {
"CLOSE": "Schließen", "CLOSE": "Schließen",
"DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen", "DO_NOT_SHOW_AGAIN": "Nicht mehr anzeigen",
+16
View File
@@ -547,6 +547,22 @@
"NO": "No" "NO": "No"
} }
}, },
"MERGE": {
"OPEN": "Merge duplicate",
"TITLE": "Merge with the same game",
"HINT": "Find the other entry of \"{{name}}\", e.g. the Steam game or a game added by hand. The merge applies to every user.",
"CONFIRM": "Merge \"{{name}}\" and \"{{other}}\" into one game for every user? Libraries with both keep one entry with the status, rating and notes set last. This cannot be undone.",
"CONFIRM_BUTTON": "Merge",
"BACK": "Back",
"DONE": "Games merged",
"NO_ACCESS": "Only auditors can merge games.",
"ERROR": {
"SAME_GAME": "A game cannot be merged with itself.",
"STEAM_GAMES": "Two Steam games cannot be merged.",
"GAME_NOT_FOUND": "This game is not in ToBePlayed yet, so it cannot be merged.",
"OTHER": "The games could not be merged."
}
},
"HINT": { "HINT": {
"CLOSE": "Close", "CLOSE": "Close",
"DO_NOT_SHOW_AGAIN": "Do not show again", "DO_NOT_SHOW_AGAIN": "Do not show again",
+5 -82
View File
@@ -1,22 +1,19 @@
import {useEffect, useState} from 'react'; import {useState} from 'react';
import {useNavigate} from 'react-router-dom'; import {useNavigate} from 'react-router-dom';
import {useTranslation} from 'react-i18next'; import {useTranslation} from 'react-i18next';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {ArrowLeft, Loader2, Plus, Search, X} from 'lucide-react'; import {ArrowLeft, Loader2, Plus, X} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Button} from '@/components/ui/button'; import {Button} from '@/components/ui/button';
import {Card} from '@/components/ui/card'; import {Card} from '@/components/ui/card';
import {Input} from '@/components/ui/input'; import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label'; import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'; import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
import GameCover from './GameCover.jsx'; import GameCover from './GameCover.jsx';
import GameSearch from './GameSearch.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js'; import useSessionExpired from '../hooks/useSessionExpired.js';
import {GAME_STATUSES} from '../utils/backlog.js'; import {GAME_STATUSES} from '../utils/backlog.js';
import {addGame, hoursToMinutes, searchGames} from '../utils/manualGames.js'; import {addGame, hoursToMinutes} from '../utils/manualGames.js';
const SEARCH_DEBOUNCE_MS = 400;
const MIN_QUERY = 2;
/** /**
* Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch. * Search IGDB and add a game the user owns outside Steam, e.g. on PlayStation or Switch.
@@ -26,44 +23,8 @@ export default function AddGamePanel({onClose}) {
const navigate = useNavigate(); const navigate = useNavigate();
const expireSession = useSessionExpired(); const expireSession = useSessionExpired();
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
// idle | loading | done | unavailable | failed
const [searchState, setSearchState] = useState('idle');
const [results, setResults] = useState([]);
const [picked, setPicked] = useState(null); const [picked, setPicked] = useState(null);
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < MIN_QUERY) {
setSearchState('idle');
setResults([]);
return undefined;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setSearchState('loading');
try {
const response = await searchGames(trimmed, controller.signal);
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
setSearchState('failed');
return;
}
const data = await response.json();
setResults(data.results ?? []);
setSearchState(data.available ? 'done' : 'unavailable');
} catch (err) {
if (err.name !== 'AbortError') setSearchState('failed');
}
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query, expireSession]);
return ( return (
<Card className="p-5 gap-4"> <Card className="p-5 gap-4">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
@@ -81,45 +42,7 @@ export default function AddGamePanel({onClose}) {
) : ( ) : (
<> <>
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p> <p className="text-sm text-muted-foreground">{t('MANUAL_GAME.ADD_HINT')}</p>
<div className="relative"> <GameSearch query={query} onQueryChange={setQuery} onPick={setPicked} blockInLibrary/>
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input type="search" autoFocus value={query} maxLength={100} className="pl-8"
placeholder={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
aria-label={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
onChange={(e) => setQuery(e.target.value)}/>
</div>
{searchState === 'loading' && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin"/>{t('MANUAL_GAME.SEARCHING')}
</p>
)}
{searchState === 'unavailable' && <p className="text-sm text-muted-foreground">{t('MANUAL_GAME.UNAVAILABLE')}</p>}
{searchState === 'failed' && <p className="text-sm text-destructive">{t('MANUAL_GAME.SEARCH_FAILED')}</p>}
{searchState === 'done' && results.length === 0 && (
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.NO_HITS')}</p>
)}
{searchState === 'done' && results.length > 0 && (
<ul className="divide-y rounded-md border">
{results.map((hit) => (
<li key={hit.igdbId}>
<button type="button" disabled={hit.inLibrary} onClick={() => setPicked(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{hit.name}{hit.year ? ` (${hit.year})` : ''}
</span>
<span className="block truncate text-xs text-muted-foreground">
{hit.platforms.join(' · ')}
</span>
</span>
{hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
</button>
</li>
))}
</ul>
)}
</> </>
)} )}
</Card> </Card>
+88
View File
@@ -0,0 +1,88 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {ArrowLeft, Loader2, Merge} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from '@/components/ui/dialog';
import GameSearch from './GameSearch.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {mergeGame} from '../utils/corrections.js';
import {isSteamKey} from '../utils/manualGames.js';
const KNOWN_ERRORS = new Set(['SAME_GAME', 'STEAM_GAMES', 'GAME_NOT_FOUND']);
/**
* AUDITOR or higher: merges this game with another key of the same game, for every user. onMerged gets the key
* the game lives under now.
*/
export default function GameMergeDialog({game, open, onOpenChange, onMerged}) {
const {t} = useTranslation();
const expireSession = useSessionExpired();
const [query, setQuery] = useState('');
const [picked, setPicked] = useState(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (open) {
setQuery('');
setPicked(null);
}
}, [open]);
const merge = async () => {
setBusy(true);
try {
const response = await mergeGame(game.appid, picked.appid);
if (response.status === 401) {
expireSession();
return;
}
const data = await response.json().catch(() => ({}));
if (response.ok) {
toast.success(t('MERGE.DONE'));
onOpenChange(false);
onMerged(data.appid);
return;
}
toast.error(KNOWN_ERRORS.has(data.code) ? t(`MERGE.ERROR.${data.code}`)
: response.status === 403 ? t('MERGE.NO_ACCESS') : t('MERGE.ERROR.OTHER'));
} catch (err) {
console.error('Merge error:', err);
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
} finally {
setBusy(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{t('MERGE.TITLE')}</DialogTitle>
<DialogDescription>{t('MERGE.HINT', {name: game.name})}</DialogDescription>
</DialogHeader>
{picked ? (
<div className="space-y-4">
<p className="text-sm">{t('MERGE.CONFIRM', {name: game.name, other: picked.name})}</p>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" disabled={busy} onClick={() => setPicked(null)}>
<ArrowLeft/>{t('MERGE.BACK')}
</Button>
<Button type="button" variant="destructive" disabled={busy} onClick={merge}>
{busy ? <Loader2 className="animate-spin"/> : <Merge/>}{t('MERGE.CONFIRM_BUTTON')}
</Button>
</div>
</div>
) : (
<div className="space-y-3">
{/* Core merges only games it knows and refuses two Steam games, so those are not offered. */}
<GameSearch query={query} onQueryChange={setQuery} onPick={setPicked}
hide={(hit) => !hit.inCatalog || hit.appid === game.appid
|| (isSteamKey(hit.appid) && isSteamKey(game.appid))}/>
</div>
)}
</DialogContent>
</Dialog>
);
}
+83
View File
@@ -0,0 +1,83 @@
// @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 GameMergeDialog from './GameMergeDialog.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
const hades = {appid: 1145360, name: 'Hades'};
const hit = (appid, name) => ({appid, igdbId: Math.abs(appid), name, year: 2020, imageUrl: null,
platforms: ['Switch'], inLibrary: false, inCatalog: true});
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
function renderDialog(game, post, results) {
globalThis.fetch = vi.fn(async (url, init) => init?.method === 'POST'
? post
: json(200, {available: true, results}));
const onMerged = vi.fn();
render(<MemoryRouter><GameMergeDialog game={game} open onOpenChange={vi.fn()} onMerged={onMerged}/></MemoryRouter>);
return onMerged;
}
const search = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
describe('GameMergeDialog', () => {
it('merges the picked game after the confirm step', async () => {
const onMerged = renderDialog(hades, json(200, {appid: 1145360}),
[hit(1145360, 'Hades'), hit(-113112, 'Hades (Switch)'), hit(1145350, 'Hades II'),
{...hit(-4711, 'Hades Fan Remake'), inCatalog: false}]);
search('hades');
const offered = await screen.findByRole('button', {name: /Hades \(Switch\)/});
// The page's own game, other Steam games and games not in ToBePlayed are not offered.
expect(screen.queryByRole('button', {name: /^Hades \(2020\)/})).toBeNull();
expect(screen.queryByRole('button', {name: /Hades II/})).toBeNull();
expect(screen.queryByRole('button', {name: /Fan Remake/})).toBeNull();
fireEvent.click(offered);
expect(fetch).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', {name: 'MERGE.CONFIRM_BUTTON'}));
await waitFor(() => expect(onMerged).toHaveBeenCalledWith(1145360));
const [url, init] = fetch.mock.calls[1];
expect(url).toContain('/api/library/games/1145360/merge');
expect(JSON.parse(init.body)).toEqual({otherAppid: -113112});
expect(toast.success).toHaveBeenCalledWith('MERGE.DONE');
});
it('goes back from the confirm step without merging', async () => {
renderDialog(hades, json(200, {}), [hit(-113112, 'Hades (Switch)')]);
search('hades');
fireEvent.click(await screen.findByRole('button', {name: /Hades \(Switch\)/}));
fireEvent.click(screen.getByRole('button', {name: 'MERGE.BACK'}));
expect(await screen.findByRole('searchbox')).toBeTruthy();
expect(fetch).toHaveBeenCalledTimes(1);
});
it('says why Core refused the merge', async () => {
const onMerged = renderDialog({appid: -113112, name: 'Hades (Switch)'},
json(404, {code: 'GAME_NOT_FOUND'}), [hit(1145360, 'Hades')]);
search('hades');
fireEvent.click(await screen.findByRole('button', {name: /Hades \(2020\)/}));
fireEvent.click(screen.getByRole('button', {name: 'MERGE.CONFIRM_BUTTON'}));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('MERGE.ERROR.GAME_NOT_FOUND'));
expect(onMerged).not.toHaveBeenCalled();
});
});
+111
View File
@@ -0,0 +1,111 @@
import {useEffect, useState} from 'react';
import {useTranslation} from 'react-i18next';
import {Loader2, Search} from 'lucide-react';
import {Badge} from '@/components/ui/badge';
import {Input} from '@/components/ui/input';
import GameCover from './GameCover.jsx';
import useSessionExpired from '../hooks/useSessionExpired.js';
import {searchGames} from '../utils/manualGames.js';
const SEARCH_DEBOUNCE_MS = 400;
const MIN_QUERY = 2;
/**
* The search field and the hits of Core's game search. The parent keeps the query, so it stays when the user goes
* back.
*
* @param hide hits not offered at all
* @param blockInLibrary hits already in the own library are shown, but cannot be picked
*/
export default function GameSearch({query, onQueryChange, onPick, hide = () => false, blockInLibrary = false}) {
const {t} = useTranslation();
const {searchState, results} = useGameSearch(query);
const hits = results.filter((hit) => !hide(hit));
return (
<>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground"/>
<Input type="search" autoFocus value={query} maxLength={100} className="pl-8"
placeholder={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
aria-label={t('MANUAL_GAME.SEARCH_PLACEHOLDER')}
onChange={(e) => onQueryChange(e.target.value)}/>
</div>
{searchState === 'loading' && (
<p className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin"/>{t('MANUAL_GAME.SEARCHING')}
</p>
)}
{searchState === 'unavailable' && <p className="text-sm text-muted-foreground">{t('MANUAL_GAME.UNAVAILABLE')}</p>}
{searchState === 'failed' && <p className="text-sm text-destructive">{t('MANUAL_GAME.SEARCH_FAILED')}</p>}
{searchState === 'done' && hits.length === 0 && (
<p className="text-sm text-muted-foreground">{t('MANUAL_GAME.NO_HITS')}</p>
)}
{searchState === 'done' && hits.length > 0 && (
<ul className="divide-y rounded-md border">
{hits.map((hit) => (
<li key={hit.appid}>
<button type="button" disabled={blockInLibrary && hit.inLibrary} onClick={() => onPick(hit)}
className="flex w-full items-center gap-3 px-3 py-2 text-left transition-colors hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-60">
<GameCover appid={hit.appid} imageUrl={hit.imageUrl} className="w-20 shrink-0"/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{hit.name}{hit.year ? ` (${hit.year})` : ''}
</span>
<span className="block truncate text-xs text-muted-foreground">
{hit.platforms.join(' · ')}
</span>
</span>
{blockInLibrary && hit.inLibrary && <Badge variant="secondary">{t('MANUAL_GAME.IN_LIBRARY')}</Badge>}
</button>
</li>
))}
</ul>
)}
</>
);
}
// Asks Core 400 ms after the last key press; searchState is idle, loading, done, unavailable or failed.
function useGameSearch(query) {
const expireSession = useSessionExpired();
const [searchState, setSearchState] = useState('idle');
const [results, setResults] = useState([]);
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < MIN_QUERY) {
setSearchState('idle');
setResults([]);
return undefined;
}
const controller = new AbortController();
const timer = setTimeout(async () => {
setSearchState('loading');
try {
const response = await searchGames(trimmed, controller.signal);
if (response.status === 401) {
expireSession();
return;
}
if (!response.ok) {
setSearchState('failed');
return;
}
const data = await response.json();
setResults(data.results ?? []);
setSearchState(data.available ? 'done' : 'unavailable');
} catch (err) {
if (err.name !== 'AbortError') setSearchState('failed');
}
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(timer);
controller.abort();
};
}, [query, expireSession]);
return {searchState, results};
}
+15 -1
View File
@@ -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, PencilLine, RotateCcw, ShieldOff, Timer, Trophy} from 'lucide-react'; import {ArrowLeft, Clock, ExternalLink, Eye, EyeOff, Gamepad2, Image as ImageIcon, ImageOff, Merge, 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';
@@ -16,6 +16,7 @@ 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 GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
import GameMergeDialog from '../components/GameMergeDialog.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';
@@ -67,6 +68,7 @@ export default function GameDetail() {
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 [correcting, setCorrecting] = useState(false);
const [merging, setMerging] = useState(false);
const [reloads, setReloads] = useState(0); const [reloads, setReloads] = useState(0);
const auditor = canCorrect(useCurrentUser(true)); const auditor = canCorrect(useCurrentUser(true));
@@ -222,6 +224,11 @@ export default function GameDetail() {
<PencilLine/>{t('CORRECTION.OPEN')} <PencilLine/>{t('CORRECTION.OPEN')}
</Button> </Button>
)} )}
{auditor && (
<Button variant="outline" size="sm" className="mt-3 ml-2" onClick={() => setMerging(true)}>
<Merge/>{t('MERGE.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. */}
@@ -355,6 +362,13 @@ export default function GameDetail() {
<GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting} <GameCorrectionDialog game={data} open={correcting} onOpenChange={setCorrecting}
onChanged={() => setReloads((n) => n + 1)}/> onChanged={() => setReloads((n) => n + 1)}/>
)} )}
{auditor && data && (
<GameMergeDialog game={data} open={merging} onOpenChange={setMerging} onMerged={(merged) => {
// A new key loads through the address; the same key needs a reload.
if (merged === data.appid) setReloads((n) => n + 1);
else navigate(`/library/${merged}`, {replace: true});
}}/>
)}
</PageContainer> </PageContainer>
); );
} }
+10
View File
@@ -257,6 +257,15 @@ describe('GameDetail of a game added by hand', () => {
expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy(); expect(await screen.findByText('CORRECTION.TITLE')).toBeTruthy();
}); });
it('offers "merge duplicate" to an active auditor', async () => {
loggedInAs({role: 'AUDITOR', activeRole: 'AUDITOR'});
renderDetail();
fireEvent.click(await screen.findByRole('button', {name: 'MERGE.OPEN'}));
expect(await screen.findByText('MERGE.TITLE')).toBeTruthy();
});
it('offers nothing to a user or to an admin viewing as user', async () => { 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'}]) { for (const me of [{role: 'USER', activeRole: 'USER'}, {role: 'PLATFORM_ADMIN', activeRole: 'USER'}]) {
loggedInAs(me); loggedInAs(me);
@@ -267,6 +276,7 @@ describe('GameDetail of a game added by hand', () => {
// Let the answer of /me reach the page before looking for the button. // Let the answer of /me reach the page before looking for the button.
await act(async () => {}); await act(async () => {});
expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull(); expect(screen.queryByRole('button', {name: 'CORRECTION.OPEN'})).toBeNull();
expect(screen.queryByRole('button', {name: 'MERGE.OPEN'})).toBeNull();
cleanup(); cleanup();
} }
}); });
+8
View File
@@ -33,3 +33,11 @@ export const saveCorrection = (appid, field, value) =>
* DELETE: back to the source's value, 204. * DELETE: back to the source's value, 204.
*/ */
export const resetCorrection = (appid, field) => apiFetch(url(appid, field), {method: 'DELETE'}); export const resetCorrection = (appid, field) => apiFetch(url(appid, field), {method: 'DELETE'});
/**
* POST: 200 {appid} = the key the game lives under now; 400 {code: SAME_GAME | STEAM_GAMES}, 403 below AUDITOR,
* 404 {code: GAME_NOT_FOUND} for a game not in ToBePlayed.
*/
export const mergeGame = (appid, otherAppid) =>
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}/merge`,
{method: 'POST', headers: JSON_HEADERS, body: JSON.stringify({otherAppid})});