Public Access
feat(library): add not the same game button to editions
Part of Gitea #34 slice 1 of 3: one entry per game across platforms. Status, rating and notes are shared, so the detail page reloads after a save.
This commit is contained in:
1 parent
e136a7abc1
commit
25cc5ff2d8
7 files changed
+107
-14
No files matched your search
@@ -57,10 +57,12 @@ To Be Played is split into three repositories:
|
|||||||
finished games per year of all users.
|
finished games per year of all users.
|
||||||
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set counts on each of its sources), "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
|
- **Library**: status filters, source filter (Steam, PlayStation, manual; a linked set counts on each of its sources), "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.
|
||||||
- **Editions**: an auditor links the editions of one game (Steam and PS5 copy, remaster, port) for every user. A user
|
- **Editions**: the editions of one game (Steam and PS5 copy, remaster, port) are linked automatically from IGDB's
|
||||||
who owns two or more of a linked set sees "Also in your library" on the details page, an "x2" badge in the library
|
"same game" key; an auditor can also link or unlink them for every user. A user who owns two or more of a linked
|
||||||
and the filter "Hide duplicates"; the numbers count the set once. "Same game as..." on the details page asks the
|
set sees "Also in your library" on the details page, an "x2" badge in the library and the filter "Hide
|
||||||
auditors for a link.
|
duplicates"; the numbers count the set once. The editions of a set share one status, rating and notes (the page
|
||||||
|
reloads after a save to show it). "Not the same game" splits the game off its set for this user only, and it stays
|
||||||
|
split. "Same game as..." on the details page asks the auditors for a link.
|
||||||
- **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. Games already in ToBePlayed come first. Check several
|
a platform and optional hours. Works without a Steam ID. Games already in ToBePlayed come first. Check several
|
||||||
hits to add them at once through the import review.
|
hits to add them at once through the import review.
|
||||||
@@ -257,6 +259,17 @@ flowchart LR
|
|||||||
which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets
|
which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets
|
||||||
the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`.
|
the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Detail["GameDetail: EditionsCard"] -->|"Not the same game"| Split["Core DELETE /api/library/games/{appid}/set"]
|
||||||
|
Detail -->|"Same game as... (request)"| Request["Core POST /api/library/games/{appid}/link-requests"]
|
||||||
|
Detail -->|"Unlink (auditor)"| Unlink["Core DELETE /api/library/games/{appid}/link"]
|
||||||
|
Split --> Reload["reload the detail"]
|
||||||
|
Backlog["BacklogCard save"] -->|"game has editions"| Reload
|
||||||
|
```
|
||||||
|
- `EditionsCard` shows "Not the same game" on every game that has other editions in the own library. The call goes
|
||||||
|
through `src/utils/links.js` (`splitGame`); `404 NOT_LINKED` shows the generic error toast.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
Header["AppHeader badge"] --> Count["Core /api/library/questions/count"]
|
Header["AppHeader badge"] --> Count["Core /api/library/questions/count"]
|
||||||
|
|||||||
@@ -809,6 +809,9 @@
|
|||||||
"REQUEST": "Anfrage senden",
|
"REQUEST": "Anfrage senden",
|
||||||
"LINK": "Verknüpfen",
|
"LINK": "Verknüpfen",
|
||||||
"UNLINK": "Verknüpfung lösen",
|
"UNLINK": "Verknüpfung lösen",
|
||||||
|
"SPLIT": "Nicht dasselbe Spiel",
|
||||||
|
"SPLIT_DONE": "Spiel getrennt. Es steht jetzt allein in deiner Bibliothek.",
|
||||||
|
"SHARED_STATUS": "Status, Bewertung und Notizen gelten für alle diese Ausgaben.",
|
||||||
"DISMISS": "Nicht dasselbe",
|
"DISMISS": "Nicht dasselbe",
|
||||||
"REQUESTED": "Anfrage gesendet",
|
"REQUESTED": "Anfrage gesendet",
|
||||||
"LINKED": "Spiele verknüpft",
|
"LINKED": "Spiele verknüpft",
|
||||||
|
|||||||
@@ -809,6 +809,9 @@
|
|||||||
"REQUEST": "Send request",
|
"REQUEST": "Send request",
|
||||||
"LINK": "Link",
|
"LINK": "Link",
|
||||||
"UNLINK": "Unlink",
|
"UNLINK": "Unlink",
|
||||||
|
"SPLIT": "Not the same game",
|
||||||
|
"SPLIT_DONE": "Game split off. It stands alone in your library now.",
|
||||||
|
"SHARED_STATUS": "Status, rating and notes are shared by these editions.",
|
||||||
"DISMISS": "Not the same",
|
"DISMISS": "Not the same",
|
||||||
"REQUESTED": "Request sent",
|
"REQUESTED": "Request sent",
|
||||||
"LINKED": "Games linked",
|
"LINKED": "Games linked",
|
||||||
|
|||||||
@@ -1,37 +1,38 @@
|
|||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Layers, Link2Off} from 'lucide-react';
|
import {Layers, Link2Off, Unlink} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
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 GameStatusBadge from './GameStatusBadge.jsx';
|
import GameStatusBadge from './GameStatusBadge.jsx';
|
||||||
import FitText from './FitText.jsx';
|
import FitText from './FitText.jsx';
|
||||||
import {unlinkGame} from '../utils/links.js';
|
import {splitGame, unlinkGame} from '../utils/links.js';
|
||||||
import {platformLabel} from '../utils/platforms.js';
|
import {platformLabel} from '../utils/platforms.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The other editions of this game in the own library, and the buttons to ask for a link (everyone) or to unlink
|
* The other editions of this game in the own library, and the buttons to ask for a link (everyone), to split the game
|
||||||
* (auditor). Shown on every game: a request must be possible from any game.
|
* off its set (everyone, for the own library) or to unlink (auditor). Shown on every game: a request must be possible
|
||||||
|
* from any game.
|
||||||
*/
|
*/
|
||||||
export default function EditionsCard({game, auditor, onRequest, onChanged}) {
|
export default function EditionsCard({game, auditor, onRequest, onChanged}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const unlink = async () => {
|
const run = async (call, doneKey) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const response = await unlinkGame(game.appid);
|
const response = await call(game.appid);
|
||||||
if (response.status === 401) return;
|
if (response.status === 401) return;
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
toast.success(t('LINKS.UNLINKED'));
|
toast.success(t(doneKey));
|
||||||
onChanged();
|
onChanged();
|
||||||
} else {
|
} else {
|
||||||
toast.error(t('LINKS.ERROR'));
|
toast.error(t('LINKS.ERROR'));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Unlink error:', err);
|
console.error('Edition change error:', err);
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
@@ -56,14 +57,22 @@ export default function EditionsCard({game, auditor, onRequest, onChanged}) {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
<p className="text-muted-foreground">{t('LINKS.SHARED_STATUS')}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="button" variant="outline" size="sm" onClick={onRequest}>
|
<Button type="button" variant="outline" size="sm" onClick={onRequest}>
|
||||||
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
|
<Layers/>{t(auditor ? 'LINKS.LINK_WITH' : 'LINKS.SAME_AS')}
|
||||||
</Button>
|
</Button>
|
||||||
|
{game.editions?.length > 0 && (
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={busy}
|
||||||
|
onClick={() => run(splitGame, 'LINKS.SPLIT_DONE')}>
|
||||||
|
<Unlink/>{t('LINKS.SPLIT')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{auditor && game.linked && (
|
{auditor && game.linked && (
|
||||||
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={unlink}>
|
<Button type="button" variant="outline" size="sm" disabled={busy}
|
||||||
|
onClick={() => run(unlinkGame, 'LINKS.UNLINKED')}>
|
||||||
<Link2Off/>{t('LINKS.UNLINK')}
|
<Link2Off/>{t('LINKS.UNLINK')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// @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 EditionsCard from './EditionsCard.jsx';
|
||||||
|
import {splitGame} from '../utils/links.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
|
vi.mock('../utils/links.js', () => ({splitGame: vi.fn(), unlinkGame: vi.fn()}));
|
||||||
|
|
||||||
|
const edition = {appid: 221, name: 'Game (PS5)', platform: 'PS5', source: 'PSN', status: 'PLAYING'};
|
||||||
|
const renderCard = (game, onChanged = vi.fn()) => {
|
||||||
|
render(<MemoryRouter><EditionsCard game={game} auditor={false} onRequest={vi.fn()} onChanged={onChanged}/></MemoryRouter>);
|
||||||
|
return onChanged;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('EditionsCard', () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
it('showsTheSplitButtonAndTheSharedHintOnlyWithEditions', () => {
|
||||||
|
renderCard({appid: 220, editions: []});
|
||||||
|
|
||||||
|
expect(screen.queryByText('LINKS.SPLIT')).toBeNull();
|
||||||
|
expect(screen.queryByText('LINKS.SHARED_STATUS')).toBeNull();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
renderCard({appid: 220, editions: [edition]});
|
||||||
|
|
||||||
|
expect(screen.getByText('LINKS.SPLIT')).toBeTruthy();
|
||||||
|
expect(screen.getByText('LINKS.SHARED_STATUS')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('splitsTheGameAndReloads', async () => {
|
||||||
|
splitGame.mockResolvedValue({ok: true, status: 204});
|
||||||
|
const onChanged = renderCard({appid: 220, editions: [edition]});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('LINKS.SPLIT'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||||
|
expect(splitGame).toHaveBeenCalledWith(220);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('LINKS.SPLIT_DONE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsAnErrorAndDoesNotReloadWhenTheCallFails', async () => {
|
||||||
|
splitGame.mockResolvedValue({ok: false, status: 404});
|
||||||
|
const onChanged = renderCard({appid: 220, editions: [edition]});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('LINKS.SPLIT'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('LINKS.ERROR'));
|
||||||
|
expect(onChanged).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,7 +104,11 @@ export default function GameDetail() {
|
|||||||
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
<CompletedHint game={data} onChanged={(entry) => setData((d) => ({...d, ...entry}))}/>
|
||||||
|
|
||||||
<BacklogCard key={data.appid} game={data}
|
<BacklogCard key={data.appid} game={data}
|
||||||
onSaved={(entry) => setData((d) => ({...d, ...entry}))}/>
|
onSaved={(entry) => {
|
||||||
|
setData((d) => ({...d, ...entry}));
|
||||||
|
// The other editions share status, rating and notes.
|
||||||
|
if (data.editions?.length) reload();
|
||||||
|
}}/>
|
||||||
|
|
||||||
<EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/>
|
<EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/>
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ export const linkGames = (appid, otherAppid) =>
|
|||||||
*/
|
*/
|
||||||
export const unlinkGame = (appid) => apiFetch(`${base()}/games/${appid}/link`, {method: 'DELETE'});
|
export const unlinkGame = (appid) => apiFetch(`${base()}/games/${appid}/link`, {method: 'DELETE'});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE: the game stands alone in the own library from now on. 204, 404 NOT_LINKED.
|
||||||
|
*/
|
||||||
|
export const splitGame = (appid) => apiFetch(`${base()}/games/${appid}/set`, {method: 'DELETE'});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE: 204, 404 PAIR_NOT_OPEN.
|
* DELETE: 204, 404 PAIR_NOT_OPEN.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in new issue
Block a user