Public Access
feat(sync): add Set to 100% button for all-unlocked games
The sync details show a Set to 100% button for ALL_UNLOCKED games. The button uses the existing status PUT, checks points, and reloads the Dashboard. Refs #21
This commit is contained in:
1 parent
c966877b23
commit
bbc707de87
11 files changed
+210
-7
No files matched your search
@@ -16,8 +16,9 @@ const keepShown = (prev, body, markedAt) =>
|
||||
* device. A manual sync without changes says so once.
|
||||
*
|
||||
* @param lastSyncedAt the end of the last sync: a new value loads the result again
|
||||
* @param onChanged called after a game was set to COMPLETED from the details
|
||||
*/
|
||||
export default function SyncChangesBanner({lastSyncedAt}) {
|
||||
export default function SyncChangesBanner({lastSyncedAt, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const [result, setResult] = useState(null);
|
||||
const [dismissed, setDismissed] = useState(null);
|
||||
@@ -71,7 +72,7 @@ export default function SyncChangesBanner({lastSyncedAt}) {
|
||||
<Button variant="ghost" size="icon" className="size-11" aria-label={t('SYNC_CHANGES.CLOSE')} onClick={close}>
|
||||
<X/>
|
||||
</Button>
|
||||
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)}/>}
|
||||
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)} onChanged={onChanged}/>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import SyncCompletedButton from './SyncCompletedButton.jsx';
|
||||
import GameRow from './stats/GameRow.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {fetchSyncChanges, groupByKind} from '../utils/syncChanges.js';
|
||||
@@ -10,8 +11,10 @@ import {fetchSyncChanges, groupByKind} from '../utils/syncChanges.js';
|
||||
/**
|
||||
* The changes of the last sync, grouped by kind. Loads the result again when it opens: the achievements of a sync
|
||||
* arrive after its games.
|
||||
*
|
||||
* @param onChanged called after a game was set to COMPLETED from here
|
||||
*/
|
||||
export default function SyncChangesDialog({onClose}) {
|
||||
export default function SyncChangesDialog({onClose, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const [changes, setChanges] = useState(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -52,7 +55,12 @@ export default function SyncChangesDialog({onClose}) {
|
||||
<section key={kind} className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">{t(`SYNC_CHANGES.HEADING.${kind}`)}</h3>
|
||||
<ul className="space-y-2">
|
||||
{rows.map((change, index) => (
|
||||
{rows.map((change, index) => kind === 'ALL_UNLOCKED' ? (
|
||||
<li key={change.appid} className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1"><GameRow game={change} linked/></div>
|
||||
<SyncCompletedButton appid={change.appid} onChanged={onChanged}/>
|
||||
</li>
|
||||
) : (
|
||||
<li key={`${change.appid}-${change.fromStatus}-${index}`}>
|
||||
<GameRow game={change} value={value(change)} linked/>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import SyncChangesDialog from './SyncChangesDialog.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
vi.mock('../hooks/usePointsCheck.js', () => ({default: () => vi.fn()}));
|
||||
|
||||
const open = (changes) => {
|
||||
fetchRoutes({'GET /api/library/sync-changes': () => json(200, {syncedAt: 'x', seen: false, changes})});
|
||||
render(<MemoryRouter><SyncChangesDialog onClose={() => {}}/></MemoryRouter>);
|
||||
};
|
||||
|
||||
describe('SyncChangesDialog', () => {
|
||||
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('showsTheGameAndTheButtonForAllUnlocked', async () => {
|
||||
open([{kind: 'ALL_UNLOCKED', appid: 220, name: 'Half-Life 2'}]);
|
||||
|
||||
expect(await screen.findByText('SYNC_CHANGES.HEADING.ALL_UNLOCKED')).toBeTruthy();
|
||||
expect(screen.getByText('Half-Life 2')).toBeTruthy();
|
||||
expect(screen.getByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'})).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsNoButtonForOtherKinds', async () => {
|
||||
open([{kind: 'PLAYTIME', appid: 220, name: 'Half-Life 2', amount: 30}]);
|
||||
|
||||
await screen.findByText('Half-Life 2');
|
||||
expect(screen.queryByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'})).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import usePointsCheck from '../hooks/usePointsCheck.js';
|
||||
import {saveGameStatus} from '../utils/backlog.js';
|
||||
|
||||
/**
|
||||
* Sets a game to COMPLETED from the sync details, the same call as {@link CompletedHint}.
|
||||
*
|
||||
* @param onChanged called after the status was saved
|
||||
*/
|
||||
export default function SyncCompletedButton({appid, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const check = usePointsCheck();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const setCompleted = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await saveGameStatus(appid, 'COMPLETED');
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
}
|
||||
setDone(true);
|
||||
toast.success(t('BACKLOG.SAVED'));
|
||||
check(appid, {status: 'COMPLETED', tell: true});
|
||||
onChanged?.();
|
||||
} catch (err) {
|
||||
console.error('set completed error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (done) return <span className="text-sm text-muted-foreground">{t('SYNC_CHANGES.COMPLETED_DONE')}</span>;
|
||||
return (
|
||||
<Button size="sm" className="min-h-11 shrink-0" onClick={setCompleted} disabled={busy}>
|
||||
{busy && <Loader2 className="animate-spin"/>}
|
||||
{t('GAME_DETAIL.SET_COMPLETED')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {toast} from 'sonner';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import SyncCompletedButton from './SyncCompletedButton.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
const pointsCheck = vi.fn(() => Promise.resolve(null));
|
||||
vi.mock('../hooks/usePointsCheck.js', () => ({default: () => pointsCheck}));
|
||||
|
||||
const click = () => fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'}));
|
||||
|
||||
describe('SyncCompletedButton', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('setsTheStatusToCompletedAndShowsTheDoneText', async () => {
|
||||
fetchRoutes({'PUT /api/library/games/220/status': () => json(200, {status: 'COMPLETED'})});
|
||||
render(<SyncCompletedButton appid={220}/>);
|
||||
|
||||
click();
|
||||
|
||||
expect(await screen.findByText('SYNC_CHANGES.COMPLETED_DONE')).toBeTruthy();
|
||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({status: 'COMPLETED'});
|
||||
expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
});
|
||||
|
||||
it('keepsTheEnabledButtonWhenSavingFails', async () => {
|
||||
fetchRoutes({'PUT /api/library/games/220/status': () => json(500, {})});
|
||||
render(<SyncCompletedButton appid={220}/>);
|
||||
|
||||
click();
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
|
||||
expect(screen.getByRole('button', {name: 'GAME_DETAIL.SET_COMPLETED'}).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('sendsOnlyOneRequestOnADoubleClick', async () => {
|
||||
let release;
|
||||
fetchRoutes({'PUT /api/library/games/220/status': () => new Promise((resolve) => {
|
||||
release = () => resolve(json(200, {status: 'COMPLETED'}));
|
||||
})});
|
||||
render(<SyncCompletedButton appid={220}/>);
|
||||
|
||||
click();
|
||||
click();
|
||||
expect(screen.getByRole('button').disabled).toBe(true);
|
||||
release();
|
||||
|
||||
await screen.findByText('SYNC_CHANGES.COMPLETED_DONE');
|
||||
expect(fetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('settlesAndTellsThePointsAfterSaving', async () => {
|
||||
fetchRoutes({'PUT /api/library/games/220/status': () => json(200, {status: 'COMPLETED'})});
|
||||
render(<SyncCompletedButton appid={220}/>);
|
||||
|
||||
click();
|
||||
|
||||
await screen.findByText('SYNC_CHANGES.COMPLETED_DONE');
|
||||
expect(pointsCheck).toHaveBeenCalledWith(220, {status: 'COMPLETED', tell: true});
|
||||
});
|
||||
|
||||
it('callsOnChangedAfterSaving', async () => {
|
||||
fetchRoutes({'PUT /api/library/games/220/status': () => json(200, {status: 'COMPLETED'})});
|
||||
const onChanged = vi.fn();
|
||||
render(<SyncCompletedButton appid={220} onChanged={onChanged}/>);
|
||||
|
||||
click();
|
||||
|
||||
await screen.findByText('SYNC_CHANGES.COMPLETED_DONE');
|
||||
expect(onChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -77,6 +77,10 @@ const Dashboard = () => {
|
||||
stats.reload();
|
||||
},
|
||||
});
|
||||
const reloadAfterCompleted = () => {
|
||||
reloadLibrary();
|
||||
stats.reload();
|
||||
};
|
||||
const {watch} = sync;
|
||||
const forceSync = useForceSync(watch);
|
||||
useEffect(() => {
|
||||
@@ -140,7 +144,7 @@ const Dashboard = () => {
|
||||
<DashboardWelcomeCard bannerAppid={bannerAppid} avatarUrl={steamProfile?.avatarUrl}
|
||||
equipped={equipped}
|
||||
sync={sync} forceSync={forceSync} canSync={canSync} editing={editing} onCustomize={() => setEditing(true)}/>
|
||||
{canSync && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt}/>}
|
||||
{canSync && <SyncChangesBanner lastSyncedAt={sync.lastSyncedAt} onChanged={reloadAfterCompleted}/>}
|
||||
|
||||
<TileGrid tiles={DASHBOARD_TILES} titles={DASHBOARD_TITLES} layout={layout} renderTile={renderTile} editing={editing}
|
||||
defaultLayout={DEFAULT_DASHBOARD} onSave={saveLayout} onCancel={() => setEditing(false)}/>
|
||||
|
||||
@@ -3,12 +3,13 @@ import {apiFetch} from './api.js';
|
||||
const CORE = () => import.meta.env.VITE_CORE_API_URL;
|
||||
|
||||
// The order of the groups in the details.
|
||||
const KINDS = ['ADDED', 'REMOVED', 'PLAYTIME', 'ACHIEVEMENTS', 'STATUS'];
|
||||
const KINDS = ['ALL_UNLOCKED', 'ADDED', 'REMOVED', 'PLAYTIME', 'ACHIEVEMENTS', 'STATUS'];
|
||||
|
||||
/**
|
||||
* `{syncedAt, startedBy, seen, importedGames, changes: [{kind, appid, name, imageUrl, amount, fromStatus, toStatus}]}`,
|
||||
* or null when the user has no result (204) or the answer is not 200. PLAYTIME: amount in minutes, ACHIEVEMENTS:
|
||||
* amount is the number of new unlocks.
|
||||
* amount is the number of new unlocks. ALL_UNLOCKED: the sync unlocked the last
|
||||
* achievement; gone once the game is COMPLETED.
|
||||
*/
|
||||
export async function fetchSyncChanges() {
|
||||
const response = await apiFetch(`${CORE()}/api/library/sync-changes`);
|
||||
|
||||
@@ -39,6 +39,12 @@ describe('bannerOf', () => {
|
||||
expect(bannerOf(result({changes}))).toEqual(
|
||||
{mode: 'changes', imported: 0, added: 2, removed: 1, minutes: 75, achievements: 5, moves: 1});
|
||||
});
|
||||
|
||||
it('countsAllUnlockedNowhere', () => {
|
||||
const banner = bannerOf(result({changes: [{kind: 'ALL_UNLOCKED', appid: 1}]}));
|
||||
|
||||
expect(banner).toEqual({mode: 'changes', imported: 0, added: 0, removed: 0, minutes: 0, achievements: 0, moves: 0});
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupByKind', () => {
|
||||
@@ -50,4 +56,10 @@ describe('groupByKind', () => {
|
||||
expect(groups.map((group) => group.kind)).toEqual(['ADDED', 'PLAYTIME', 'STATUS']);
|
||||
expect(groups[0].changes.map((change) => change.appid)).toEqual([2, 4]);
|
||||
});
|
||||
|
||||
it('putsAllUnlockedFirst', () => {
|
||||
const groups = groupByKind([{kind: 'ADDED', appid: 1}, {kind: 'ALL_UNLOCKED', appid: 2}]);
|
||||
|
||||
expect(groups.map((group) => group.kind)).toEqual(['ALL_UNLOCKED', 'ADDED']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user