Public Access
feat(sync): show sync changes in banner and details dialog
This commit is contained in:
1 parent
e054e13a59
commit
dabb0c941c
9 files changed
+425
No files matched your search
@@ -0,0 +1,77 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {X} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import SyncChangesDialog from './SyncChangesDialog.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {bannerOf, fetchSyncChanges, markSyncChangesSeen} from '../utils/syncChanges.js';
|
||||
|
||||
const keepShown = (prev, body, markedAt) =>
|
||||
prev && body && !prev.seen && body.seen && body.syncedAt === prev.syncedAt && body.syncedAt === markedAt;
|
||||
|
||||
/**
|
||||
* What the last sync changed, in one line with the details in a dialog. The X marks the result as seen on every
|
||||
* device. A manual sync without changes says so once.
|
||||
*
|
||||
* @param lastSyncedAt the end of the last sync: a new value loads the result again
|
||||
*/
|
||||
export default function SyncChangesBanner({lastSyncedAt}) {
|
||||
const {t} = useTranslation();
|
||||
const [result, setResult] = useState(null);
|
||||
const [dismissed, setDismissed] = useState(null);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const marked = useRef(null);
|
||||
const banner = result && dismissed !== result.syncedAt ? bannerOf(result) : null;
|
||||
const nothing = banner?.mode === 'nothing';
|
||||
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
// An error, a 204 and a 404 (server without the endpoint) show nothing.
|
||||
fetchSyncChanges()
|
||||
// The "nothing changed" line was marked seen at once: its own result, now seen, must not hide it.
|
||||
.then((body) => current && setResult((prev) => keepShown(prev, body, marked.current) ? prev : body))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [lastSyncedAt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nothing || marked.current === result.syncedAt) return;
|
||||
marked.current = result.syncedAt;
|
||||
markSyncChangesSeen(result.syncedAt).catch(() => undefined);
|
||||
}, [nothing, result]);
|
||||
|
||||
if (!banner) return null;
|
||||
|
||||
const close = () => {
|
||||
setDismissed(result.syncedAt);
|
||||
markSyncChangesSeen(result.syncedAt).catch(() => undefined);
|
||||
};
|
||||
|
||||
const parts = nothing ? [t('SYNC_CHANGES.NOTHING')] : [
|
||||
banner.imported > 0 && t('SYNC_CHANGES.IMPORTED', {count: banner.imported}),
|
||||
banner.added > 0 && t('SYNC_CHANGES.ADDED', {count: banner.added}),
|
||||
banner.removed > 0 && t('SYNC_CHANGES.REMOVED', {count: banner.removed}),
|
||||
banner.minutes > 0 && t('SYNC_CHANGES.PLAYTIME', {time: formatPlaytime(banner.minutes)}),
|
||||
banner.achievements > 0 && t('SYNC_CHANGES.ACHIEVEMENTS', {count: banner.achievements}),
|
||||
banner.moves > 0 && t('SYNC_CHANGES.MOVES', {count: banner.moves}),
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<Card className="flex-row flex-wrap items-center gap-x-3 gap-y-2 p-3 text-sm">
|
||||
<span className="min-w-0 flex-1">{parts.join(' · ')}</span>
|
||||
{!nothing && (
|
||||
<Button variant="outline" size="sm" className="min-h-11" onClick={() => setDetailsOpen(true)}>
|
||||
{t('SYNC_CHANGES.DETAILS')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="size-11" aria-label={t('SYNC_CHANGES.CLOSE')} onClick={close}>
|
||||
<X/>
|
||||
</Button>
|
||||
{detailsOpen && <SyncChangesDialog onClose={() => setDetailsOpen(false)}/>}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import SyncChangesBanner from './SyncChangesBanner.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const SYNCED_AT = '2026-10-04T10:00:00.123456Z';
|
||||
const CHANGES = '/api/library/sync-changes';
|
||||
const SEEN = '/api/library/sync-changes/seen';
|
||||
const result = (extra = {}) => ({syncedAt: SYNCED_AT, startedBy: 'AUTO', seen: false, importedGames: 0, changes: [], ...extra});
|
||||
const changed = result({
|
||||
changes: [
|
||||
{kind: 'ADDED', appid: 11, name: 'New Game'},
|
||||
{kind: 'PLAYTIME', appid: 22, name: 'Played Game', amount: 90},
|
||||
{kind: 'STATUS', appid: 22, name: 'Played Game', fromStatus: 'BACKLOG', toStatus: 'PLAYING'},
|
||||
],
|
||||
});
|
||||
|
||||
const renderBanner = () => render(<MemoryRouter><SyncChangesBanner lastSyncedAt="a"/></MemoryRouter>);
|
||||
const puts = (mock) => mock.mock.calls.filter(([, init]) => init?.method === 'PUT');
|
||||
|
||||
describe('SyncChangesBanner', () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it('showsTheSummary', async () => {
|
||||
fetchRoutes({[`GET ${CHANGES}`]: json(200, changed)});
|
||||
renderBanner();
|
||||
|
||||
expect(await screen.findByText('SYNC_CHANGES.ADDED:1 · SYNC_CHANGES.PLAYTIME · SYNC_CHANGES.MOVES:1')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('closesWithXAndMarksTheResultSeen', async () => {
|
||||
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, changed), [`PUT ${SEEN}`]: json(204, {})});
|
||||
renderBanner();
|
||||
await screen.findByText(/SYNC_CHANGES.ADDED/);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'SYNC_CHANGES.CLOSE'}));
|
||||
|
||||
expect(screen.queryByText(/SYNC_CHANGES.ADDED/)).toBeNull();
|
||||
expect(puts(mock)).toHaveLength(1);
|
||||
expect(JSON.parse(puts(mock)[0][1].body)).toEqual({syncedAt: SYNCED_AT});
|
||||
});
|
||||
|
||||
it('saysNothingChangedOnceForAManualSyncAndMarksItSeenAtOnce', async () => {
|
||||
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, result({startedBy: 'MANUAL'})), [`PUT ${SEEN}`]: json(204, {})});
|
||||
renderBanner();
|
||||
|
||||
expect(await screen.findByText('SYNC_CHANGES.NOTHING')).toBeTruthy();
|
||||
await waitFor(() => expect(puts(mock)).toHaveLength(1));
|
||||
expect(screen.queryByRole('button', {name: 'SYNC_CHANGES.DETAILS'})).toBeNull();
|
||||
});
|
||||
|
||||
it('nothingChangedStaysWhenLastSyncedAtArrivesLater', async () => {
|
||||
let seen = false;
|
||||
const mock = fetchRoutes({
|
||||
[`GET ${CHANGES}`]: () => json(200, result({startedBy: 'MANUAL', seen})),
|
||||
[`PUT ${SEEN}`]: () => {
|
||||
seen = true;
|
||||
return json(204, {});
|
||||
},
|
||||
});
|
||||
// The dashboard renders before its first sync-status answer: lastSyncedAt is null, then the real value.
|
||||
const {rerender} = render(<MemoryRouter><SyncChangesBanner lastSyncedAt={null}/></MemoryRouter>);
|
||||
await screen.findByText('SYNC_CHANGES.NOTHING');
|
||||
await waitFor(() => expect(puts(mock)).toHaveLength(1));
|
||||
|
||||
rerender(<MemoryRouter><SyncChangesBanner lastSyncedAt="a"/></MemoryRouter>);
|
||||
await waitFor(() => expect(mock.mock.calls.filter(([url]) => url.endsWith(CHANGES))).toHaveLength(2));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(screen.queryByText('SYNC_CHANGES.NOTHING')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsNothingForAnEmptyAutomaticSync', async () => {
|
||||
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, result())});
|
||||
const {container} = renderBanner();
|
||||
|
||||
await waitFor(() => expect(mock).toHaveBeenCalled());
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('showsNothingWithoutAResult', async () => {
|
||||
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(204, {})});
|
||||
const {container} = renderBanner();
|
||||
|
||||
await waitFor(() => expect(mock).toHaveBeenCalled());
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('showsNothingWhenCoreHasNoEndpointYet', async () => {
|
||||
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(404, {})});
|
||||
const {container} = renderBanner();
|
||||
|
||||
await waitFor(() => expect(mock).toHaveBeenCalled());
|
||||
expect(container.textContent).toBe('');
|
||||
});
|
||||
|
||||
it('listsTheChangesGroupedByKindWithLinksToTheGames', async () => {
|
||||
fetchRoutes({[`GET ${CHANGES}`]: json(200, changed)});
|
||||
renderBanner();
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SYNC_CHANGES.DETAILS'}));
|
||||
|
||||
expect(await screen.findByText('SYNC_CHANGES.HEADING.ADDED')).toBeTruthy();
|
||||
expect(screen.getByText('SYNC_CHANGES.HEADING.PLAYTIME')).toBeTruthy();
|
||||
expect(screen.getByText('SYNC_CHANGES.HEADING.STATUS')).toBeTruthy();
|
||||
expect(screen.queryByText('SYNC_CHANGES.HEADING.REMOVED')).toBeNull();
|
||||
const link = screen.getAllByRole('link').find((a) => a.getAttribute('href') === '/library/11');
|
||||
expect(link).toBeTruthy();
|
||||
expect(screen.getByText('+1.5h')).toBeTruthy();
|
||||
expect(screen.getByText('BACKLOG.STATUS.BACKLOG → BACKLOG.STATUS.PLAYING')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('loadsTheResultAgainWhenTheDetailsOpen', async () => {
|
||||
const mock = fetchRoutes({[`GET ${CHANGES}`]: json(200, changed)});
|
||||
renderBanner();
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SYNC_CHANGES.DETAILS'}));
|
||||
|
||||
await screen.findByText('SYNC_CHANGES.HEADING.ADDED');
|
||||
|
||||
expect(mock.mock.calls.filter(([url]) => url.endsWith(CHANGES))).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {Dialog, DialogContent, DialogDescription, DialogTitle} from '@/components/ui/dialog';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameRow from './stats/GameRow.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
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.
|
||||
*/
|
||||
export default function SyncChangesDialog({onClose}) {
|
||||
const {t} = useTranslation();
|
||||
const [changes, setChanges] = useState(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
fetchSyncChanges()
|
||||
.then((body) => {
|
||||
if (!current) return;
|
||||
if (body) setChanges(body.changes ?? []);
|
||||
else setFailed(true);
|
||||
})
|
||||
.catch(() => current && setFailed(true));
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = (change) => {
|
||||
switch (change.kind) {
|
||||
case 'PLAYTIME': return `+${formatPlaytime(change.amount)}`;
|
||||
case 'ACHIEVEMENTS': return `+${change.amount}`;
|
||||
case 'STATUS': return `${t(`BACKLOG.STATUS.${change.fromStatus}`)} → ${t(`BACKLOG.STATUS.${change.toStatus}`)}`;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent
|
||||
className="max-h-[85vh] overflow-y-auto overflow-x-hidden max-sm:top-auto max-sm:bottom-0 max-sm:max-w-full max-sm:translate-y-0 max-sm:rounded-b-none sm:max-w-xl">
|
||||
<DialogTitle className="pr-6">{t('SYNC_CHANGES.TITLE')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{t('SYNC_CHANGES.TITLE')}</DialogDescription>
|
||||
{changes === null && !failed && <Skeleton className="h-24 rounded-xl"/>}
|
||||
{failed && <p className="text-sm text-muted-foreground">{t('SYNC_CHANGES.ERROR')}</p>}
|
||||
{changes && changes.length === 0 && <p className="text-sm text-muted-foreground">{t('SYNC_CHANGES.EMPTY')}</p>}
|
||||
{changes && groupByKind(changes).map(({kind, changes: rows}) => (
|
||||
<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) => (
|
||||
<li key={`${change.appid}-${change.fromStatus}-${index}`}>
|
||||
<GameRow game={change} value={value(change)} linked/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user