Public Access
fix(ux): apply UX quick wins (#54)
Get started links to registration and says an admin approves accounts. Back navigation restores the library scroll; search no longer fills history. Steam and PSN unlink ask first. Any close marks news as seen. Confetti plays only when the level rises on the page. Count-ups respect reduced motion. Html lang follows the UI language. Add a 404 page, translated role and dialog close label.
This commit is contained in:
1 parent
60dcfe8992
commit
a60010e055
28 files changed
+427
-58
No files matched your search
@@ -132,6 +132,10 @@
|
||||
"VERIFY": "Mit Steam bestätigen",
|
||||
"CHANGE": "Anderes Steam-Konto verknüpfen",
|
||||
"UNLINK": "Trennen",
|
||||
"UNLINK_TITLE": "Steam-Konto trennen?",
|
||||
"UNLINK_EXPLAIN": "Der Steam-Sync stoppt. Deine Spiele bleiben in deiner Bibliothek. Für einen neuen Sync verknüpfst du das Konto wieder.",
|
||||
"UNLINK_CONFIRM": "Ja, trennen",
|
||||
"UNLINK_ERROR": "Das Steam-Konto konnte nicht getrennt werden.",
|
||||
"VERIFIED": "Bestätigt",
|
||||
"NOT_VERIFIED": "Nicht bestätigt",
|
||||
"NONE": "Noch kein Steam-Konto verknüpft. Du meldest dich bei Steam an, und Steam nennt uns deine Steam-ID; dein Steam-Passwort sehen wir nie.",
|
||||
@@ -159,6 +163,9 @@
|
||||
"SYNC_HINT": "Jeder Sync holt neu gespielte Titel. Sichere Treffer werden hinzugefügt, unsichere warten in der Import-Prüfung.",
|
||||
"REVIEW": "Wartende Titel prüfen",
|
||||
"UNLINK": "Trennen",
|
||||
"UNLINK_TITLE": "PlayStation-Konto trennen?",
|
||||
"UNLINK_EXPLAIN": "Der PlayStation-Sync stoppt. Importierte Spiele bleiben in deiner Bibliothek. Für einen neuen Sync fügst du einen neuen NPSSO-Code ein.",
|
||||
"UNLINK_CONFIRM": "Ja, trennen",
|
||||
"UNLINKED": "PlayStation-Konto getrennt. Importierte Spiele bleiben in deiner Bibliothek.",
|
||||
"INVALID": "PlayStation hat den Code nicht akzeptiert. Er ist vielleicht abgelaufen: melde dich neu an und kopiere einen neuen.",
|
||||
"UNAVAILABLE": "PlayStation hat nicht geantwortet. Bitte versuche es später noch einmal.",
|
||||
@@ -367,6 +374,7 @@
|
||||
"WELCOME": "Dein Game-Backlog unter Kontrolle",
|
||||
"DESCRIPTION": "Organisiere deinen Pile of Shame. Verfolge, verwalte und erobere deinen Game-Backlog wie nie zuvor.",
|
||||
"GET_STARTED": "Jetzt loslegen",
|
||||
"APPROVAL_HINT": "Ein Admin schaltet jedes neue Konto vor der ersten Anmeldung frei.",
|
||||
"LOGIN": "Anmelden",
|
||||
"LOGOUT": "Abmelden"
|
||||
},
|
||||
@@ -674,6 +682,13 @@
|
||||
"GAMES_other": "{{count}} Spiele"
|
||||
}
|
||||
},
|
||||
"DIALOG": {
|
||||
"CANCEL": "Abbrechen",
|
||||
"CLOSE": "Schließen"
|
||||
},
|
||||
"NOT_FOUND": {
|
||||
"TITLE": "Seite nicht gefunden"
|
||||
},
|
||||
"ROLES": {
|
||||
"USER": "Nutzer",
|
||||
"AUDITOR": "Auditor",
|
||||
|
||||
@@ -132,6 +132,10 @@
|
||||
"VERIFY": "Verify with Steam",
|
||||
"CHANGE": "Link another Steam account",
|
||||
"UNLINK": "Unlink",
|
||||
"UNLINK_TITLE": "Unlink Steam account?",
|
||||
"UNLINK_EXPLAIN": "The Steam sync stops. Your games stay in your library. To sync again, link the account again.",
|
||||
"UNLINK_CONFIRM": "Yes, unlink",
|
||||
"UNLINK_ERROR": "Could not unlink the Steam account.",
|
||||
"VERIFIED": "Verified",
|
||||
"NOT_VERIFIED": "Not verified",
|
||||
"NONE": "No Steam account linked yet. You sign in on Steam, and Steam tells us your Steam ID; we never see your Steam password.",
|
||||
@@ -159,6 +163,9 @@
|
||||
"SYNC_HINT": "Every sync brings in newly played titles. Clear matches are added; unclear ones wait in the import review.",
|
||||
"REVIEW": "Review waiting titles",
|
||||
"UNLINK": "Unlink",
|
||||
"UNLINK_TITLE": "Unlink PlayStation account?",
|
||||
"UNLINK_EXPLAIN": "The PlayStation sync stops. Imported games stay in your library. To sync again, paste a new NPSSO code.",
|
||||
"UNLINK_CONFIRM": "Yes, unlink",
|
||||
"UNLINKED": "PlayStation account unlinked. Imported games stay in your library.",
|
||||
"INVALID": "PlayStation did not accept the code. It may have expired: sign in again and copy a new one.",
|
||||
"UNAVAILABLE": "PlayStation did not answer. Please try again later.",
|
||||
@@ -367,6 +374,7 @@
|
||||
"WELCOME": "Control your game backlog",
|
||||
"DESCRIPTION": "Organize your pile of shame. Track, manage, and conquer your gaming backlog like never before.",
|
||||
"GET_STARTED": "Get Started",
|
||||
"APPROVAL_HINT": "An admin approves every new account before the first login.",
|
||||
"LOGIN": "Log in",
|
||||
"LOGOUT": "Log out"
|
||||
},
|
||||
@@ -674,6 +682,13 @@
|
||||
"GAMES_other": "{{count}} games"
|
||||
}
|
||||
},
|
||||
"DIALOG": {
|
||||
"CANCEL": "Cancel",
|
||||
"CLOSE": "Close"
|
||||
},
|
||||
"NOT_FOUND": {
|
||||
"TITLE": "Page not found"
|
||||
},
|
||||
"ROLES": {
|
||||
"USER": "User",
|
||||
"AUDITOR": "Auditor",
|
||||
|
||||
+5
-1
@@ -5,6 +5,7 @@ import {useEffect} from 'react';
|
||||
import LandingPage from './pages/LandingPage';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import ImprintPage from './pages/ImprintPage';
|
||||
import NotFoundPage from './pages/NotFoundPage.jsx';
|
||||
import Dashboard from "./pages/Dashboard.jsx";
|
||||
import RegistrationPage from './pages/RegistrationPage.jsx';
|
||||
import ConfirmEmailPage from './pages/ConfirmEmailPage.jsx';
|
||||
@@ -46,7 +47,7 @@ import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
|
||||
import {logout} from './utils/api.js';
|
||||
|
||||
function App() {
|
||||
const {t} = useTranslation();
|
||||
const {t, i18n} = useTranslation();
|
||||
const {active: isAuthenticated} = useSession();
|
||||
const {appearance, equipped, syncPreferences} = usePreferences();
|
||||
|
||||
@@ -54,6 +55,8 @@ function App() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => { document.documentElement.lang = i18n.language; }, [i18n.language]);
|
||||
|
||||
// On login and on page load; the cached look shows until Auth answers.
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) syncPreferences();
|
||||
@@ -110,6 +113,7 @@ function App() {
|
||||
</Route>
|
||||
<Route path="/u/:name" element={<PublicProfilePage/>}/>
|
||||
<Route path="/impressum" element={<ImprintPage/>}/>
|
||||
<Route path="*" element={<NotFoundPage/>}/>
|
||||
</Routes>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('App', () => {
|
||||
it('sets the document language to the UI language', () => {
|
||||
fetchRoutes({'GET /api/users/me': json(200, {settings: {}})});
|
||||
localStorage.clear();
|
||||
renderWithProviders(<App/>, {entries: ['/impressum']});
|
||||
|
||||
expect(document.documentElement.lang).toBe('de');
|
||||
});
|
||||
|
||||
it('ends the session on a 401 that the refresh cannot fix', async () => {
|
||||
let expired = false;
|
||||
const library = () => (expired ? json(401, {}) : json(200, page));
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function AccountInfoCard() {
|
||||
const {user: profile} = useCurrentUserContext();
|
||||
const accountRows = [
|
||||
[t('SETTINGS.EMAIL'), profile?.email],
|
||||
[t('SETTINGS.ROLE'), profile?.role],
|
||||
[t('SETTINGS.ROLE'), profile?.role && t(`ROLES.${profile.role}`)],
|
||||
[t('SETTINGS.CREATED_DATE'), formatDate(profile?.createdDate, i18n.language)],
|
||||
[t('SETTINGS.LAST_LOGIN'), formatDate(profile?.lastLogin, i18n.language)],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, screen} from '@testing-library/react';
|
||||
import AccountInfoCard from './AccountInfoCard.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('AccountInfoCard', () => {
|
||||
it('shows the role by its translated name, not the enum value', async () => {
|
||||
fetchRoutes({'GET /api/users/me': json(200, {email: 'a@b.de', role: 'PLATFORM_ADMIN', settings: {}})});
|
||||
renderWithProviders(<AccountInfoCard/>, {path: '/settings', entries: ['/settings']});
|
||||
|
||||
expect(await screen.findByText('ROLES.PLATFORM_ADMIN')).toBeTruthy();
|
||||
expect(screen.queryByText('PLATFORM_ADMIN')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
import {useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Gamepad, Link2, Loader2, Unlink} from 'lucide-react';
|
||||
import {Gamepad, Link2, Loader2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import UnlinkDialog from './UnlinkDialog.jsx';
|
||||
import usePsnAccount from '../hooks/usePsnAccount.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import {isExpired, linkPsn, NPSSO_PAGE_URL, unlinkPsn} from '../utils/psn.js';
|
||||
@@ -102,9 +103,9 @@ export default function PsnAccountCard() {
|
||||
<Link to={`/import?id=${account.reviewImportId}`}>{t('SETTINGS.PSN.REVIEW')}</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button type="button" variant="outline" onClick={unlink} disabled={busy}>
|
||||
<Unlink/>{t('SETTINGS.PSN.UNLINK')}
|
||||
</Button>
|
||||
<UnlinkDialog label={t('SETTINGS.PSN.UNLINK')} title={t('SETTINGS.PSN.UNLINK_TITLE')}
|
||||
description={t('SETTINGS.PSN.UNLINK_EXPLAIN')}
|
||||
confirmLabel={t('SETTINGS.PSN.UNLINK_CONFIRM')} onConfirm={unlink} disabled={busy}/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -118,13 +118,28 @@ describe('PsnAccountCard', () => {
|
||||
expect(screen.queryByText('SETTINGS.PSN.IMPORT')).toBeNull();
|
||||
});
|
||||
|
||||
it('unlinks and shows the link form again', async () => {
|
||||
it('asks before it unlinks and sends nothing on cancel', async () => {
|
||||
renderCard({'GET /api/library/psn-account': json(200, linkedAccount(inDays(5)))});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK'}));
|
||||
|
||||
expect(await screen.findByText('SETTINGS.PSN.UNLINK_EXPLAIN')).toBeTruthy();
|
||||
expect(calls('DELETE')).toHaveLength(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CANCEL'}));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('SETTINGS.PSN.UNLINK_EXPLAIN')).toBeNull());
|
||||
expect(calls('DELETE')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unlinks after the confirmation and shows the link form again', async () => {
|
||||
renderCard({
|
||||
'GET /api/library/psn-account': json(200, linkedAccount(inDays(5))),
|
||||
'DELETE /api/library/psn-account': noBody,
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.PSN.UNLINK/}));
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK'}));
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.PSN.UNLINK_CONFIRM'}));
|
||||
|
||||
expect(await screen.findByLabelText('SETTINGS.PSN.NPSSO_LABEL')).toBeTruthy();
|
||||
expect(toast.success).toHaveBeenCalledWith('SETTINGS.PSN.UNLINKED');
|
||||
|
||||
@@ -2,14 +2,15 @@ import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useReducedMotion from '../hooks/useReducedMotion.js';
|
||||
import {useDrillTarget} from './stats/StatDrill.jsx';
|
||||
|
||||
function useCountUp(target, durationMs = 1600) {
|
||||
function useCountUp(target, instant, durationMs = 1600) {
|
||||
const [value, setValue] = useState(0);
|
||||
useEffect(() => {
|
||||
const safeTarget = typeof target === 'number' && Number.isFinite(target) ? target : 0;
|
||||
if (safeTarget === 0) {
|
||||
setValue(0);
|
||||
if (instant || safeTarget === 0) {
|
||||
setValue(safeTarget);
|
||||
return;
|
||||
}
|
||||
let raf;
|
||||
@@ -22,7 +23,7 @@ function useCountUp(target, durationMs = 1600) {
|
||||
};
|
||||
raf = requestAnimationFrame(step);
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [target, durationMs]);
|
||||
}, [target, instant, durationMs]);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -32,7 +33,8 @@ function useCountUp(target, durationMs = 1600) {
|
||||
*/
|
||||
export default function StatCard({label, value, icon: Icon, suffix, drill, onClick}) {
|
||||
const {i18n} = useTranslation();
|
||||
const animated = useCountUp(value);
|
||||
const reduce = useReducedMotion();
|
||||
const animated = useCountUp(value, reduce);
|
||||
const target = useDrillTarget({title: label, count: value, ...drill}) ?? (onClick && {onClick});
|
||||
const card = (
|
||||
<Card className="gap-3 p-5">
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen} from '@testing-library/react';
|
||||
import StatCard from './StatCard.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete document.documentElement.dataset.reduceMotion;
|
||||
});
|
||||
|
||||
describe('StatCard', () => {
|
||||
it('shows the final number at once when the user wants reduced motion', () => {
|
||||
document.documentElement.dataset.reduceMotion = 'true';
|
||||
render(<StatCard label="Games" value={1234}/>);
|
||||
|
||||
expect(screen.getByText('1.234')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('counts up from 0 otherwise', () => {
|
||||
render(<StatCard label="Games" value={1234}/>);
|
||||
|
||||
expect(screen.getByText('0')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,12 @@
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {BadgeCheck, CircleAlert, Gamepad2, Link2, Loader2, Unlink} from 'lucide-react';
|
||||
import {BadgeCheck, CircleAlert, Gamepad2, Link2, Loader2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import UnlinkDialog from './UnlinkDialog.jsx';
|
||||
import {markSession} from '../utils/auth.js';
|
||||
import {startSteamLink, unlinkSteam} from '../utils/steamLink.js';
|
||||
|
||||
@@ -36,7 +37,7 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||
const response = await unlinkSteam();
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('SETTINGS.STEAM_LINK.ERROR'));
|
||||
toast.error(t('SETTINGS.STEAM_LINK.UNLINK_ERROR'));
|
||||
return;
|
||||
}
|
||||
// The new access cookie has no Steam ID; the new stamp makes the header load again.
|
||||
@@ -79,10 +80,10 @@ export default function SteamAccountCard({steamId, verified, onUnlinked}) {
|
||||
{t(!steamId ? 'SETTINGS.STEAM_LINK.LINK' : verified ? 'SETTINGS.STEAM_LINK.CHANGE' : 'SETTINGS.STEAM_LINK.VERIFY')}
|
||||
</Button>
|
||||
{steamId && (
|
||||
<Button type="button" variant="outline" onClick={unlink} disabled={busy}
|
||||
className="h-auto max-w-full whitespace-normal py-2 text-left">
|
||||
<Unlink/>{t('SETTINGS.STEAM_LINK.UNLINK')}
|
||||
</Button>
|
||||
<UnlinkDialog label={t('SETTINGS.STEAM_LINK.UNLINK')} title={t('SETTINGS.STEAM_LINK.UNLINK_TITLE')}
|
||||
description={t('SETTINGS.STEAM_LINK.UNLINK_EXPLAIN')}
|
||||
confirmLabel={t('SETTINGS.STEAM_LINK.UNLINK_CONFIRM')} onConfirm={unlink} disabled={busy}
|
||||
className="h-auto max-w-full whitespace-normal py-2 text-left"/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -4,15 +4,21 @@ import {useTranslation} from 'react-i18next';
|
||||
import {ArrowRight, Gamepad2} from 'lucide-react';
|
||||
|
||||
import {Card} from '@/components/ui/card';
|
||||
import useReducedMotion from '../hooks/useReducedMotion.js';
|
||||
|
||||
const DURATION_MS = 1000;
|
||||
const COUNT_CLASS = 'block text-5xl font-bold tracking-tight tabular-nums';
|
||||
|
||||
const SteamGameCount = ({targetCount}) => {
|
||||
const {t} = useTranslation();
|
||||
const reduce = useReducedMotion();
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduce) {
|
||||
setCount(targetCount);
|
||||
return undefined;
|
||||
}
|
||||
let frame;
|
||||
let startTime;
|
||||
const animate = (timestamp) => {
|
||||
@@ -25,7 +31,7 @@ const SteamGameCount = ({targetCount}) => {
|
||||
};
|
||||
frame = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [targetCount]);
|
||||
}, [targetCount, reduce]);
|
||||
|
||||
return (
|
||||
<Card className="relative justify-between overflow-hidden p-6">
|
||||
|
||||
@@ -7,7 +7,10 @@ import SteamGameCount from './SteamGameCount.jsx';
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
describe('SteamGameCount', () => {
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
delete document.documentElement.dataset.reduceMotion;
|
||||
});
|
||||
|
||||
it('linksTheCountToTheLibraryOnlyWhenThereAreGames', () => {
|
||||
const {container, rerender} = render(<MemoryRouter><SteamGameCount targetCount={0}/></MemoryRouter>);
|
||||
@@ -17,4 +20,12 @@ describe('SteamGameCount', () => {
|
||||
expect(container.querySelectorAll('a[href="/library"]')).toHaveLength(2);
|
||||
expect(screen.getByText('DASHBOARD.STEAM_GAMES')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsTheFinalCountAtOnceUnderReducedMotion', () => {
|
||||
document.documentElement.dataset.reduceMotion = 'true';
|
||||
|
||||
render(<MemoryRouter><SteamGameCount targetCount={123}/></MemoryRouter>);
|
||||
|
||||
expect(screen.getByText('123')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Unlink} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
/**
|
||||
* "Unlink" button that asks first. The description says what the unlink does and does not do.
|
||||
*/
|
||||
export default function UnlinkDialog({label, title, description, confirmLabel, onConfirm, disabled, className}) {
|
||||
const {t} = useTranslation();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button type="button" variant="outline" disabled={disabled} className={className}>
|
||||
<Unlink/>{label}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">{t('DIALOG.CANCEL')}</Button>
|
||||
</DialogClose>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="destructive" onClick={onConfirm}>{confirmLabel}</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,7 @@ import NewsEntry from './NewsEntry.jsx';
|
||||
import {fetchNews, markNewsSeen} from '../utils/news.js';
|
||||
|
||||
/**
|
||||
* The news entries the user has not seen, newest first. Only "Got it" marks them seen: closing the dialog any other
|
||||
* way shows them again on the next dashboard visit.
|
||||
* The news entries the user has not seen, newest first. Any close (Got it, X, Esc, click outside) marks them seen.
|
||||
*/
|
||||
export default function WhatsNewDialog() {
|
||||
const {t} = useTranslation();
|
||||
@@ -39,7 +38,7 @@ export default function WhatsNewDialog() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog open={open} onOpenChange={(next) => { if (!next) gotIt(); }}>
|
||||
<DialogContent className="max-h-[90vh] overflow-y-auto" aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('NEWS.TITLE')}</DialogTitle>
|
||||
|
||||
@@ -50,13 +50,24 @@ describe('WhatsNewDialog', () => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes with X without marking anything seen', async () => {
|
||||
it('marks the entries seen when closed with X', async () => {
|
||||
await showNews(NEW);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'Close'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CLOSE'}));
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
expect(putCalls()).toEqual([]);
|
||||
expect(putCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('marks the entries seen when closed with Esc', async () => {
|
||||
await showNews(NEW);
|
||||
|
||||
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
|
||||
await act(async () => undefined);
|
||||
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
expect(putCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { XIcon } from "lucide-react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
@@ -26,6 +27,7 @@ function DialogContent({
|
||||
children,
|
||||
...props
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<DialogPrimitive.Portal>
|
||||
<DialogPrimitive.Overlay
|
||||
@@ -43,7 +45,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Close
|
||||
className="absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
<span className="sr-only">{t('DIALOG.CLOSE')}</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPrimitive.Portal>
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import {createBrowserRouter, RouterProvider} from 'react-router-dom';
|
||||
import {createBrowserRouter, RouterProvider, ScrollRestoration} from 'react-router-dom';
|
||||
import App from './App.jsx';
|
||||
import {PreferencesProvider} from './context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from './context/CurrentUserContext.jsx';
|
||||
@@ -14,6 +14,7 @@ const router = createBrowserRouter([{
|
||||
<PreferencesProvider>
|
||||
<CurrentUserProvider>
|
||||
<App/>
|
||||
<ScrollRestoration/>
|
||||
</CurrentUserProvider>
|
||||
</PreferencesProvider>
|
||||
),
|
||||
|
||||
@@ -482,14 +482,26 @@ describe('GameDetail confetti', () => {
|
||||
return container.querySelector('canvas[data-size]');
|
||||
};
|
||||
|
||||
it('plays a small one for a finished game', async () => {
|
||||
expect((await opened({status: 'FINISHED'})).dataset.size).toBe('small');
|
||||
it('plays nothing on opening a finished game', async () => {
|
||||
expect(await opened({status: 'FINISHED'})).toBeNull();
|
||||
});
|
||||
|
||||
it('plays a big one when every achievement is unlocked too', async () => {
|
||||
const canvas = await opened({status: 'COMPLETED', hasAchievements: true, statsVisible: true,
|
||||
achievementsTotal: 3, achievementsUnlocked: 3});
|
||||
expect(canvas.dataset.size).toBe('big');
|
||||
it('plays nothing on opening a completed game', async () => {
|
||||
expect(await opened({status: 'COMPLETED', hasAchievements: true, statsVisible: true,
|
||||
achievementsTotal: 3, achievementsUnlocked: 3})).toBeNull();
|
||||
});
|
||||
|
||||
it('plays a small one when the user sets the game to finished here', async () => {
|
||||
answer({...game, status: 'PLAYING', finishedHint: {type: 'ENDING', achievement: 'Survivor'}}, {
|
||||
'PUT /api/library/games/220/status': (url, init) => json(200, {...game, ...JSON.parse(init.body), finishedHint: null}),
|
||||
});
|
||||
const {container} = renderDetail();
|
||||
await screen.findByText('Half-Life 2');
|
||||
expect(container.querySelector('canvas[data-size]')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'GAME_DETAIL.SET_FINISHED'}));
|
||||
|
||||
await waitFor(() => expect(container.querySelector('canvas[data-size="small"]')).toBeTruthy());
|
||||
});
|
||||
|
||||
it('plays nothing for a game not finished', async () => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import {coverEffect} from '../utils/rewards.js';
|
||||
import {apiFetch, request} from '../utils/api.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
import {platformLabel} from '../utils/platforms.js';
|
||||
import {sessionStamp} from '../utils/auth.js';
|
||||
|
||||
const PAGE_SIZES = [20, 40, 80];
|
||||
const DEFAULT_SIZE = 20;
|
||||
@@ -45,6 +46,16 @@ const SORT_OPTIONS = [
|
||||
{value: 'priority,desc', labelKey: 'LIBRARY.SORT_PRIORITY'},
|
||||
];
|
||||
|
||||
// Last loaded page, so a back navigation from a game renders at once and the scroll position can be restored.
|
||||
let lastPage = {query: null, data: null, stamp: null};
|
||||
|
||||
// A new login gets a new stamp, so one user's rows never show for the next.
|
||||
const cachedFor = (query) => (lastPage.query === query && lastPage.stamp === sessionStamp() ? lastPage.data : null);
|
||||
|
||||
export function clearLibraryPageCache() {
|
||||
lastPage = {query: null, data: null, stamp: null};
|
||||
}
|
||||
|
||||
export default function GameLibrary() {
|
||||
const {t} = useTranslation();
|
||||
const {equipped} = usePreferences();
|
||||
@@ -73,16 +84,18 @@ export default function GameLibrary() {
|
||||
const [canSync, setCanSync] = useState(true);
|
||||
const [ownSources, setOwnSources] = useState([]);
|
||||
|
||||
const [data, setData] = useState({content: [], page: 0, size, totalElements: 0, totalPages: 0});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const query = libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, source: sourceKey});
|
||||
const cached = cachedFor(query);
|
||||
const [data, setData] = useState(cached ?? {content: [], page: 0, size, totalElements: 0, totalPages: 0});
|
||||
const [isLoading, setIsLoading] = useState(!cached);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const nextSignal = useAbortPrevious();
|
||||
const load = useCallback(async () => {
|
||||
const signal = nextSignal();
|
||||
setIsLoading(true);
|
||||
setIsLoading(!cachedFor(query));
|
||||
setError(null);
|
||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly, source: sourceKey})}`;
|
||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${query}`;
|
||||
const {ok, status, data: json} = await request(url, {signal});
|
||||
if (signal.aborted) return;
|
||||
setIsLoading(false);
|
||||
@@ -91,8 +104,9 @@ export default function GameLibrary() {
|
||||
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD');
|
||||
return;
|
||||
}
|
||||
lastPage = {query, data: json, stamp: sessionStamp()};
|
||||
setData(json);
|
||||
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, sourceKey]);
|
||||
}, [nextSignal, query]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
@@ -129,10 +143,10 @@ export default function GameLibrary() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateParams = useCallback((next) => {
|
||||
const updateParams = useCallback((next, options) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
|
||||
setSearchParams(params);
|
||||
setSearchParams(params, options);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing.
|
||||
@@ -143,7 +157,7 @@ export default function GameLibrary() {
|
||||
// updateParams changes with searchParams, so a status click during the delay restarts the timer with fresh params.
|
||||
useEffect(() => {
|
||||
if (search.trim() === q) return undefined;
|
||||
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS);
|
||||
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}, {replace: true}), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, q, updateParams]);
|
||||
|
||||
|
||||
@@ -3,11 +3,12 @@ import {StrictMode} from 'react';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {createMemoryRouter, RouterProvider, useLocation, useNavigate} from 'react-router-dom';
|
||||
import GameLibrary from './GameLibrary.jsx';
|
||||
import GameLibrary, {clearLibraryPageCache} from './GameLibrary.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
import {clearSession, markSession} from '../utils/auth.js';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
@@ -48,6 +49,7 @@ function renderLibrary(initialEntry = '/library') {
|
||||
}], {initialEntries: [initialEntry]});
|
||||
render(<RouterProvider router={router}/>);
|
||||
return {
|
||||
router,
|
||||
navigations: () => lib.navigations,
|
||||
params: () => new URLSearchParams(lib.location.search),
|
||||
back: () => lib.navigate(-1),
|
||||
@@ -57,6 +59,7 @@ function renderLibrary(initialEntry = '/library') {
|
||||
const typeInto = (value) => fireEvent.change(screen.getByRole('searchbox'), {target: {value}});
|
||||
|
||||
beforeEach(() => {
|
||||
clearLibraryPageCache();
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
answer();
|
||||
});
|
||||
@@ -66,6 +69,42 @@ afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('GameLibrary page cache', () => {
|
||||
it('renders the last loaded rows at once on return, without a skeleton', async () => {
|
||||
renderLibrary();
|
||||
await screen.findByText('Half-Life 2');
|
||||
cleanup();
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/library/games': () => new Promise(() => {}),
|
||||
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true, sources: []}),
|
||||
});
|
||||
|
||||
renderLibrary();
|
||||
|
||||
expect(screen.getByText('Half-Life 2')).toBeTruthy();
|
||||
expect(document.querySelector('[data-slot="skeleton"]')).toBeNull();
|
||||
});
|
||||
|
||||
// REVIEW-FINDING: the module cache survives a logout, so the next login on the same tab renders the previous user's rows
|
||||
it('reviewFinding_cacheDoesNotOutliveTheSession', async () => {
|
||||
renderLibrary();
|
||||
await screen.findByText('Half-Life 2');
|
||||
cleanup();
|
||||
clearSession();
|
||||
markSession();
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/library/games': () => new Promise(() => {}),
|
||||
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, canSync: true, sources: []}),
|
||||
});
|
||||
|
||||
renderLibrary();
|
||||
|
||||
expect(screen.queryByText('Half-Life 2')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameLibrary search', () => {
|
||||
it('shows the coming badge only for a game that is not released yet', async () => {
|
||||
answer([
|
||||
@@ -163,14 +202,26 @@ describe('GameLibrary search', () => {
|
||||
expect(lib.params().get('status')).toBe('DROPPED');
|
||||
});
|
||||
|
||||
it('replaces the history entry when the search is typed', async () => {
|
||||
const lib = renderLibrary();
|
||||
|
||||
typeInto('half');
|
||||
await waitFor(() => expect(lib.params().get('q')).toBe('half'));
|
||||
|
||||
expect(lib.router.state.historyAction).toBe('REPLACE');
|
||||
});
|
||||
|
||||
it('shows the URL search again after back navigation', async () => {
|
||||
const lib = renderLibrary();
|
||||
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.STATUS.DROPPED'}));
|
||||
await waitFor(() => expect(lib.params().get('status')).toBe('DROPPED'));
|
||||
typeInto('half');
|
||||
await waitFor(() => expect(lib.params().get('q')).toBe('half'));
|
||||
|
||||
await act(async () => lib.back());
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('searchbox').value).toBe(''));
|
||||
expect(lib.params().get('status')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -131,9 +131,10 @@ export default function LandingPage() {
|
||||
{active ? (
|
||||
<Link to="/dashboard">{t('NAV.TO_DASHBOARD')}<ArrowRight/></Link>
|
||||
) : (
|
||||
<Link to="/login">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
||||
<Link to="/register">{t('MAIN.GET_STARTED')}<ArrowRight/></Link>
|
||||
)}
|
||||
</Button>
|
||||
{!active && <p className="mt-4 text-sm text-muted-foreground">{t('MAIN.APPROVAL_HINT')}</p>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -36,6 +36,21 @@ describe('LandingPage', () => {
|
||||
expect(screen.getByText('2025')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('links "get started" to the registration and shows the approval hint, but not when logged in', async () => {
|
||||
answer({});
|
||||
const {unmount} = renderPage();
|
||||
|
||||
expect(screen.getByRole('link', {name: /MAIN.GET_STARTED/}).getAttribute('href')).toBe('/register');
|
||||
expect(screen.getByText('MAIN.APPROVAL_HINT')).toBeTruthy();
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
unmount();
|
||||
|
||||
session.active = true;
|
||||
renderPage();
|
||||
expect(screen.queryByText('MAIN.APPROVAL_HINT')).toBeNull();
|
||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('hides the three blocks while their lists are empty', async () => {
|
||||
answer({gamesPerPlatform: [], topGenres: [], finishedPerYear: []});
|
||||
renderPage();
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import useSession from '../hooks/useSession.js';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
const {t} = useTranslation();
|
||||
const {active} = useSession();
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-3xl mx-auto p-4 md:p-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-3xl font-bold tracking-tight">{t('NOT_FOUND.TITLE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild>
|
||||
{active ? <Link to="/dashboard">{t('NAV.TO_DASHBOARD')}</Link> : <Link to="/">{t('PROFILE.TO_START')}</Link>}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, screen, waitFor} from '@testing-library/react';
|
||||
import App from '../App.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
||||
|
||||
beforeEach(() => {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {}, history: []}),
|
||||
'GET /api/library/games-count': json(200, {hasSteamId: true}),
|
||||
'GET /api/library/steam-profile': json(200, null),
|
||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||
'GET /api/library/removed-games': json(200, {results: []}),
|
||||
'GET /api/news': json(200, {results: []}),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('NotFoundPage', () => {
|
||||
it('shows a not-found page with a link to the start page without a session', async () => {
|
||||
renderWithProviders(<App/>, {entries: ['/no-such-page']});
|
||||
|
||||
expect(await screen.findByText('NOT_FOUND.TITLE')).toBeTruthy();
|
||||
expect(screen.getByRole('link', {name: 'PROFILE.TO_START'}).getAttribute('href')).toBe('/');
|
||||
});
|
||||
|
||||
it('links to the dashboard with a session', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
renderWithProviders(<App/>, {entries: ['/no-such-page']});
|
||||
|
||||
await screen.findByText('NOT_FOUND.TITLE');
|
||||
await waitFor(() => expect(screen.getAllByRole('link', {name: 'NAV.TO_DASHBOARD'}).some(
|
||||
(link) => link.getAttribute('href') === '/dashboard')).toBe(true));
|
||||
});
|
||||
});
|
||||
@@ -24,11 +24,11 @@ const calls = (method) => fetch.mock.calls.filter(([, init]) => init?.method ===
|
||||
|
||||
const noBody = {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }};
|
||||
|
||||
function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
|
||||
function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings', unlinkAnswer = noBody} = {}) {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, me),
|
||||
'POST /api/users/steam-link': json(200, {redirectUrl}),
|
||||
'DELETE /api/users/steam-link': noBody,
|
||||
'DELETE /api/users/steam-link': unlinkAnswer,
|
||||
'PUT /api/users/settings': noBody,
|
||||
'POST /api/auth/refresh': noBody,
|
||||
'GET /api/version': json(404, {}),
|
||||
@@ -97,15 +97,39 @@ describe('SettingsPage Steam account', () => {
|
||||
expect(calls('POST').some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(false);
|
||||
});
|
||||
|
||||
it('unlinks the Steam account', async () => {
|
||||
it('asks before it unlinks and sends nothing on cancel', async () => {
|
||||
renderPage(profile({steamIdVerified: true}));
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: /SETTINGS.STEAM_LINK.UNLINK/}));
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK'}));
|
||||
|
||||
expect(await screen.findByText('SETTINGS.STEAM_LINK.UNLINK_EXPLAIN')).toBeTruthy();
|
||||
expect(calls('DELETE')).toHaveLength(0);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'DIALOG.CANCEL'}));
|
||||
|
||||
await waitFor(() => expect(screen.queryByText('SETTINGS.STEAM_LINK.UNLINK_EXPLAIN')).toBeNull());
|
||||
expect(calls('DELETE')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('unlinks the Steam account after the confirmation', async () => {
|
||||
renderPage(profile({steamIdVerified: true}));
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK'}));
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK_CONFIRM'}));
|
||||
|
||||
expect(await screen.findByText('SETTINGS.STEAM_LINK.NONE')).toBeTruthy();
|
||||
expect(calls('DELETE')[0][0]).toMatch(/\/api\/users\/steam-link$/);
|
||||
});
|
||||
|
||||
it('says the unlink failed with its own message', async () => {
|
||||
renderPage(profile({steamIdVerified: true}), {unlinkAnswer: json(500, {})});
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK'}));
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'SETTINGS.STEAM_LINK.UNLINK_CONFIRM'}));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.STEAM_LINK.UNLINK_ERROR'));
|
||||
});
|
||||
|
||||
it('never sends a Steam ID with the settings', async () => {
|
||||
renderPage(profile());
|
||||
|
||||
|
||||
@@ -15,15 +15,15 @@ export const celebrationLevel = (game) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* The confetti to play when the page shows this game: on opening a finished game, and when the level rises on the
|
||||
* same page (e.g. the user sets FINISHED). A reload of the same game plays nothing.
|
||||
* The confetti to play: only when the level rises while the page shows the game (the user sets Finished or completes
|
||||
* it here). Opening a finished game and a reload play nothing.
|
||||
*
|
||||
* @param before {appid, level} of the game shown before, null on opening the page
|
||||
* @returns 'small', 'big' or null
|
||||
*/
|
||||
export const celebrationFor = (before, game) => {
|
||||
if (before?.appid !== game.appid) return null;
|
||||
const level = celebrationLevel(game);
|
||||
const from = before?.appid === game.appid ? before.level : 0;
|
||||
if (level <= from) return null;
|
||||
if (level <= before.level) return null;
|
||||
return level === 2 ? 'big' : 'small';
|
||||
};
|
||||
@@ -19,9 +19,9 @@ describe('celebrationLevel', () => {
|
||||
});
|
||||
|
||||
describe('celebrationFor', () => {
|
||||
it('plays on opening a finished game', () => {
|
||||
expect(celebrationFor(null, game('FINISHED'))).toBe('small');
|
||||
expect(celebrationFor(null, game('COMPLETED', 4, 4))).toBe('big');
|
||||
it('plays nothing on opening a game, finished or not', () => {
|
||||
expect(celebrationFor(null, game('FINISHED'))).toBeNull();
|
||||
expect(celebrationFor(null, game('COMPLETED', 4, 4))).toBeNull();
|
||||
expect(celebrationFor(null, game('BACKLOG'))).toBeNull();
|
||||
});
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('celebrationFor', () => {
|
||||
expect(celebrationFor({appid: 620, level: 2}, game('PLAYING'))).toBeNull();
|
||||
});
|
||||
|
||||
it('plays for another game opened from the page', () => {
|
||||
expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 0, 0, 220))).toBe('small');
|
||||
it('plays nothing for another game opened from the page', () => {
|
||||
expect(celebrationFor({appid: 620, level: 1}, game('FINISHED', 0, 0, 220))).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user