Public Access
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de199a20b7 | ||
|
|
13b0d5e7e9 | ||
|
|
e5091de50e |
No files matched your search
@@ -119,7 +119,8 @@ To Be Played is split into three repositories:
|
||||
a repaired cover comes back as `imageUrl`.
|
||||
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
|
||||
- **Admin** (PLATFORM_ADMIN only, `/admin/users`): search the users, block, unblock and delete them (the email is
|
||||
typed to confirm a delete). "View as" in the menu switches the own session to User or Auditor and back; a badge
|
||||
typed to confirm a delete), and set the role of another user in the "Roles" dialog, which also lists the role
|
||||
changes. "View as" in the menu switches the own session to User or Auditor and back; a badge
|
||||
shows the lower role. Auth checks every right on the server. Registrations that wait show "Waits" with Accept
|
||||
and Deny (a "Waiting only" filter lists just them); the menu shows how many wait, loaded again on every page change
|
||||
and menu opening. A request whose address is not confirmed shows "Not confirmed" and only a Delete button. The login tells a waiting user that the admin has not decided yet.
|
||||
|
||||
+15
-2
@@ -166,7 +166,8 @@
|
||||
"DONE": "Dein Konto ist gelöscht.",
|
||||
"WRONG_PASSWORD": "Das Passwort ist falsch.",
|
||||
"TOO_MANY": "Zu viele falsche Passwörter. Bitte in {{minutes}} Minuten erneut versuchen.",
|
||||
"ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen."
|
||||
"ERROR": "Das Konto konnte nicht gelöscht werden. Bitte später erneut versuchen.",
|
||||
"LAST_ADMIN": "Du bist der letzte Admin. Mache zuerst einen anderen Nutzer zum Admin."
|
||||
},
|
||||
"PROFILE_TILES_HINT": "Wähle, was dein Profil zeigt und wo: öffne dein Profil und klicke auf \"Anpassen\".",
|
||||
"FAVORITE_PLATFORM": "Lieblingsplattform",
|
||||
@@ -503,6 +504,7 @@
|
||||
},
|
||||
"TOP_GAMES": {
|
||||
"TITLE": "Top-Spiele",
|
||||
"SHARE": "Anteil an der Spielzeit dieser Spiele",
|
||||
"EMPTY": "Noch keine Spielzeit.",
|
||||
"LOYAL": "Treuestes Spiel",
|
||||
"MONTHS_one": "in {{count}} Monat gespielt",
|
||||
@@ -647,7 +649,18 @@
|
||||
"PASSWORD_RESET_TEXT": "Die Person bekommt eine Mail mit einem Link, um ein neues Passwort zu wählen (1 Stunde gültig). Du siehst das Passwort nie.",
|
||||
"DONE_PASSWORD_RESET": "Reset-Mail gesendet.",
|
||||
"ERROR_NOT_PENDING": "Diese Registrierung ist schon entschieden.",
|
||||
"ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab."
|
||||
"ERROR_TARGET_IS_PENDING": "Nimm die Registrierung erst an oder lehne sie ab.",
|
||||
"ROLES": "Rollen",
|
||||
"ROLES_TITLE": "Rollen von {{email}}",
|
||||
"ROLES_TEXT": "Die Änderung wirkt nach etwa 30 Sekunden. Der Nutzer muss die Seite eventuell neu laden.",
|
||||
"SAVE_ROLE": "Speichern",
|
||||
"DONE_ROLE": "Rolle gespeichert.",
|
||||
"ROLE_HISTORY": "Änderungen",
|
||||
"ROLE_HISTORY_EMPTY": "Noch keine Rollenänderungen.",
|
||||
"ROLE_HISTORY_ERROR": "Die Änderungen konnten nicht geladen werden.",
|
||||
"ERROR_ROLE_CHANGED": "Die Rolle hat sich inzwischen geändert. Die Liste wird neu geladen.",
|
||||
"ERROR_ACTOR_NOT_ADMIN": "Du bist kein Admin mehr.",
|
||||
"ERROR_TARGET_IS_BLOCKED": "Entsperre den Nutzer zuerst."
|
||||
},
|
||||
"CORRECTION": {
|
||||
"OPEN": "Daten korrigieren",
|
||||
|
||||
+15
-2
@@ -166,7 +166,8 @@
|
||||
"DONE": "Your account is deleted.",
|
||||
"WRONG_PASSWORD": "The password is wrong.",
|
||||
"TOO_MANY": "Too many wrong passwords. Please try again in {{minutes}} minutes.",
|
||||
"ERROR": "The account could not be deleted. Please try again later."
|
||||
"ERROR": "The account could not be deleted. Please try again later.",
|
||||
"LAST_ADMIN": "You are the last admin. Make another user admin first."
|
||||
},
|
||||
"PROFILE_TILES_HINT": "Choose what your profile shows, and where: open your profile and click \"Customize\".",
|
||||
"FAVORITE_PLATFORM": "Favorite platform",
|
||||
@@ -503,6 +504,7 @@
|
||||
},
|
||||
"TOP_GAMES": {
|
||||
"TITLE": "Top games",
|
||||
"SHARE": "Share of the play time of these games",
|
||||
"EMPTY": "No playtime yet.",
|
||||
"LOYAL": "Most loyal game",
|
||||
"MONTHS_one": "played in {{count}} month",
|
||||
@@ -647,7 +649,18 @@
|
||||
"PASSWORD_RESET_TEXT": "The user gets a mail with a link to choose a new password (valid 1 hour). You never see the password.",
|
||||
"DONE_PASSWORD_RESET": "Reset mail sent.",
|
||||
"ERROR_NOT_PENDING": "This registration is decided already.",
|
||||
"ERROR_TARGET_IS_PENDING": "Accept or deny the registration first."
|
||||
"ERROR_TARGET_IS_PENDING": "Accept or deny the registration first.",
|
||||
"ROLES": "Roles",
|
||||
"ROLES_TITLE": "Roles of {{email}}",
|
||||
"ROLES_TEXT": "The change works within about 30 seconds. The user may need to reload the page.",
|
||||
"SAVE_ROLE": "Save",
|
||||
"DONE_ROLE": "Role saved.",
|
||||
"ROLE_HISTORY": "Changes",
|
||||
"ROLE_HISTORY_EMPTY": "No role changes yet.",
|
||||
"ROLE_HISTORY_ERROR": "The changes could not be loaded.",
|
||||
"ERROR_ROLE_CHANGED": "The role changed in the meantime. The list is loaded again.",
|
||||
"ERROR_ACTOR_NOT_ADMIN": "You are no admin any more.",
|
||||
"ERROR_TARGET_IS_BLOCKED": "Unblock the user first."
|
||||
},
|
||||
"CORRECTION": {
|
||||
"OPEN": "Correct data",
|
||||
|
||||
@@ -5,8 +5,11 @@ import {Activity} from 'lucide-react';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import FitText from './FitText.jsx';
|
||||
import ChartPicker from './stats/ChartPicker.jsx';
|
||||
import GamePlayChart from './stats/GamePlayChart.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {formatDay} from '../utils/format.js';
|
||||
import {chartProps, linePoints, playChart} from '../utils/statCharts.js';
|
||||
|
||||
const CHART_HEIGHT = 80;
|
||||
const BAR_WIDTH = 10;
|
||||
@@ -14,42 +17,66 @@ const BAR_GAP = 2;
|
||||
|
||||
/**
|
||||
* @param activity the answer of GET /api/library/activity
|
||||
* @param charts the chart form per group of the user
|
||||
* @param onChart `(group, form)`: shows the pickers
|
||||
*/
|
||||
export default function ActivityCard({activity}) {
|
||||
export default function ActivityCard({activity, charts = null, onChart = null}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {days, totalMinutes, recentGames} = activity;
|
||||
const max = Math.max(1, ...days.map((d) => d.minutes));
|
||||
const width = days.length * (BAR_WIDTH + BAR_GAP);
|
||||
const dayChart = chartProps(charts, onChart, 'ACTIVITY');
|
||||
const gameChart = chartProps(charts, onChart, 'ACTIVITY_GAMES');
|
||||
const line = dayChart.chart === 'LINE' && days.length > 1;
|
||||
const points = line ? linePoints(days.map((d) => d.minutes), width, CHART_HEIGHT) : [];
|
||||
const recent = recentGames.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtime2Weeks}));
|
||||
const form = playChart(gameChart.chart, recent);
|
||||
const dayTitle = (d) => `${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`;
|
||||
|
||||
return (
|
||||
<Card className="gap-5 p-5">
|
||||
<div className="flex items-baseline justify-between gap-4">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4">
|
||||
<h2 className="flex items-center gap-2 font-semibold">
|
||||
<Activity className="size-4 text-primary"/>
|
||||
{t('DASHBOARD.ACTIVITY')}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-x-3">
|
||||
<span className="text-sm text-muted-foreground tabular-nums">
|
||||
{t('DASHBOARD.ACTIVITY_TOTAL', {days: days.length, time: formatPlaytime(totalMinutes)})}
|
||||
</span>
|
||||
{dayChart.onChart && <ChartPicker group="ACTIVITY" value={dayChart.chart} onChange={dayChart.onChart}/>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalMinutes === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('DASHBOARD.ACTIVITY_EMPTY')}</p>
|
||||
) : (
|
||||
<div>
|
||||
<div className="relative">
|
||||
<svg viewBox={`0 0 ${width} ${CHART_HEIGHT}`} preserveAspectRatio="none" className="h-20 w-full"
|
||||
role="img" aria-label={t('DASHBOARD.ACTIVITY')}>
|
||||
{days.map((d, i) => {
|
||||
{line ? (
|
||||
<polyline points={points.map((p) => `${p.x},${p.y}`).join(' ')} fill="none"
|
||||
className="stroke-primary" strokeWidth={2} strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"/>
|
||||
) : days.map((d, i) => {
|
||||
const height = (d.minutes / max) * CHART_HEIGHT;
|
||||
return (
|
||||
<rect key={d.day} data-testid="activity-bar" x={i * (BAR_WIDTH + BAR_GAP)}
|
||||
y={CHART_HEIGHT - height} width={BAR_WIDTH} height={height} rx={2}
|
||||
className="fill-primary">
|
||||
<title>{`${formatDay(d.day, i18n.language)}: ${formatPlaytime(d.minutes)}`}</title>
|
||||
<title>{dayTitle(d)}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
{/* Dots are HTML over the stretched svg, so they stay round and can show a tooltip. */}
|
||||
{line && days.map((d, i) => (
|
||||
<span key={d.day} title={dayTitle(d)} data-testid="activity-dot"
|
||||
className="absolute size-2.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary"
|
||||
style={{left: `${(points[i].x / width) * 100}%`, top: `${(points[i].y / CHART_HEIGHT) * 100}%`}}/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 flex justify-between text-xs text-muted-foreground">
|
||||
<span>{formatDay(days[0].day, i18n.language)}</span>
|
||||
<span>{formatDay(days[days.length - 1].day, i18n.language)}</span>
|
||||
@@ -59,7 +86,14 @@ export default function ActivityCard({activity}) {
|
||||
|
||||
{recentGames.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3">
|
||||
<span className="text-sm font-medium">{t('DASHBOARD.RECENTLY_PLAYED')}</span>
|
||||
{gameChart.onChart && (
|
||||
<ChartPicker group="ACTIVITY_GAMES" value={gameChart.chart} onChange={gameChart.onChart}
|
||||
label={`${t('STATS.CHART.LABEL')}: ${t('DASHBOARD.RECENTLY_PLAYED')}`}/>
|
||||
)}
|
||||
</div>
|
||||
{form !== 'LIST' ? <GamePlayChart games={recent} chart={form} linked/> : (
|
||||
<ul className="space-y-2">
|
||||
{recentGames.map((g) => (
|
||||
<li key={g.appid}>
|
||||
@@ -73,6 +107,7 @@ export default function ActivityCard({activity}) {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, render, screen} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, render, screen, within} from '@testing-library/react';
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import ActivityCard from './ActivityCard.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const days = (minutes) => minutes.map((m, i) => ({day: `2026-09-${String(10 + i).padStart(2, '0')}`, minutes: m}));
|
||||
const renderCard = (activity) => render(<MemoryRouter><ActivityCard activity={activity}/></MemoryRouter>);
|
||||
const renderCard = (activity, props) => render(<MemoryRouter><ActivityCard activity={activity} {...props}/></MemoryRouter>);
|
||||
const recent = (n) => Array.from({length: n}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 900,
|
||||
playtime2Weeks: 600 - i * 10, status: 'PLAYING'}));
|
||||
|
||||
describe('ActivityCard', () => {
|
||||
afterEach(cleanup);
|
||||
@@ -35,4 +37,81 @@ describe('ActivityCard', () => {
|
||||
|
||||
expect(screen.getByText('DASHBOARD.ACTIVITY_EMPTY')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('drawsALineForTheStoredLineForm', () => {
|
||||
const {container} = renderCard({days: days([0, 60, 120]), totalMinutes: 180, recentGames: []},
|
||||
{charts: {ACTIVITY: 'LINE'}});
|
||||
|
||||
expect(screen.queryAllByTestId('activity-bar')).toHaveLength(0);
|
||||
const line = container.querySelector('polyline');
|
||||
expect(line.getAttribute('vector-effect')).toBe('non-scaling-stroke');
|
||||
const dots = screen.getAllByTestId('activity-dot');
|
||||
expect(dots).toHaveLength(3);
|
||||
expect(dots[1].getAttribute('title')).toMatch(/: 1\.0h$/);
|
||||
expect(container.querySelectorAll('svg[role="img"] polyline')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('drawsBarsForALineWithOneDay', () => {
|
||||
renderCard({days: days([60]), totalMinutes: 60, recentGames: []}, {charts: {ACTIVITY: 'LINE'}});
|
||||
|
||||
expect(screen.getAllByTestId('activity-bar')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('showsNoPickerWithoutOnChart', () => {
|
||||
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)});
|
||||
|
||||
expect(screen.queryByRole('group')).toBeNull();
|
||||
});
|
||||
|
||||
it('showsTwoPickersWithDifferentNamesWithOnChart', () => {
|
||||
const onChart = vi.fn();
|
||||
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)}, {onChart});
|
||||
|
||||
const day = screen.getByRole('group', {name: 'STATS.CHART.LABEL'});
|
||||
expect(screen.getByRole('group', {name: 'STATS.CHART.LABEL: DASHBOARD.RECENTLY_PLAYED'})).toBeTruthy();
|
||||
fireEvent.click(within(day).getByRole('button', {name: 'STATS.CHART.LINE'}));
|
||||
|
||||
expect(onChart).toHaveBeenCalledWith('ACTIVITY', 'LINE');
|
||||
});
|
||||
|
||||
it('recentGamesPieLinksTheGamesAndOtherIsPlain', () => {
|
||||
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(7)},
|
||||
{charts: {ACTIVITY_GAMES: 'PIE'}});
|
||||
|
||||
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(6);
|
||||
const links = screen.getAllByRole('link');
|
||||
expect(links).toHaveLength(5);
|
||||
expect(links[0].getAttribute('href')).toBe('/library/100');
|
||||
expect(screen.getByText('STATS.CHART.OTHER').closest('a,button')).toBeNull();
|
||||
expect(screen.getByText('9.8h')).toBeTruthy();
|
||||
expect(screen.getByText('STATS.TOP_GAMES.SHARE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('recentGamesPieWithOneGameDrawsTheList', () => {
|
||||
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(1)},
|
||||
{charts: {ACTIVITY_GAMES: 'DONUT'}});
|
||||
|
||||
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(0);
|
||||
expect(screen.getByRole('link', {name: /Game 0/}).getAttribute('href')).toBe('/library/100');
|
||||
});
|
||||
|
||||
it('recentGamesBarsHaveNoCovers', () => {
|
||||
const {container} = renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: recent(2)},
|
||||
{charts: {ACTIVITY_GAMES: 'BARS'}});
|
||||
|
||||
expect(container.querySelectorAll('img')).toHaveLength(0);
|
||||
expect(screen.getByRole('link', {name: /Game 1/}).getAttribute('href')).toBe('/library/101');
|
||||
});
|
||||
|
||||
it('twoGamesWithTheSameNameGetTheirOwnSlice', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const games = [{appid: 1, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 300, status: 'PLAYING'},
|
||||
{appid: 2, name: 'Prey', playtimeMinutes: 900, playtime2Weeks: 200, status: 'PLAYING'}];
|
||||
renderCard({days: days([0, 60]), totalMinutes: 60, recentGames: games}, {charts: {ACTIVITY_GAMES: 'PIE'}});
|
||||
|
||||
const sameKey = error.mock.calls.some((args) => args.join(' ').includes('same key'));
|
||||
error.mockRestore();
|
||||
expect(sameKey).toBe(false);
|
||||
expect(screen.getAllByRole('link', {name: /Prey/}).map((a) => a.getAttribute('href'))).toEqual(['/library/1', '/library/2']);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,8 @@ export default function DeleteAccountCard({email}) {
|
||||
}
|
||||
if (response.status === 403) {
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.WRONG_PASSWORD'));
|
||||
} else if (response.status === 409) {
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.LAST_ADMIN'));
|
||||
} else if (response.status === 429) {
|
||||
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||
toast.error(t('SETTINGS.DELETE_ACCOUNT.TOO_MANY', {minutes}));
|
||||
|
||||
@@ -78,6 +78,18 @@ describe('DeleteAccountCard', () => {
|
||||
expect(localStorage.getItem('tbp_session')).toBe('1');
|
||||
});
|
||||
|
||||
it('tells the last admin to make another admin first and stays on the page', async () => {
|
||||
deleteAnswers(json(409, {code: 'LAST_ADMIN'}));
|
||||
renderCard();
|
||||
openAndFill(EMAIL, 'secret');
|
||||
|
||||
fireEvent.click(confirmButton());
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('SETTINGS.DELETE_ACCOUNT.LAST_ADMIN'));
|
||||
expect(confirmButton()).toBeTruthy();
|
||||
expect(localStorage.getItem('tbp_session')).toBe('1');
|
||||
});
|
||||
|
||||
it('shows the lock message after too many wrong passwords', async () => {
|
||||
deleteAnswers({...json(429, {}), headers: new Headers({'Retry-After': '900'})});
|
||||
renderCard();
|
||||
|
||||
@@ -4,13 +4,22 @@ import {ArrowRight, Trophy} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import ChartPicker from './stats/ChartPicker.jsx';
|
||||
import GamePlayChart from './stats/GamePlayChart.jsx';
|
||||
import GameCover from './GameCover.jsx';
|
||||
import FitText from './FitText.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {coverEffect} from '../utils/rewards.js';
|
||||
import {playChart} from '../utils/statCharts.js';
|
||||
|
||||
export default function MostPlayedCard({games, syncing, equipped}) {
|
||||
/**
|
||||
* @param chart LIST, BARS, PIE or DONUT
|
||||
* @param onChart `(form)`: shows the picker
|
||||
*/
|
||||
export default function MostPlayedCard({games, syncing, equipped, chart = 'LIST', onChart}) {
|
||||
const {t} = useTranslation();
|
||||
const mapped = games.map((g) => ({appid: g.appid, name: g.name, minutes: g.playtimeMinutes}));
|
||||
const form = playChart(chart, mapped);
|
||||
const maxMinutes = games[0]?.playtimeMinutes || 1;
|
||||
return (
|
||||
<Card className="gap-0 py-0">
|
||||
@@ -19,15 +28,20 @@ export default function MostPlayedCard({games, syncing, equipped}) {
|
||||
<Trophy className="size-4 text-amber-500"/>
|
||||
{t('DASHBOARD.MOST_PLAYED')}
|
||||
</h2>
|
||||
<div className="flex flex-wrap items-center gap-x-3">
|
||||
{onChart && <ChartPicker group="TOP_GAMES" value={chart} onChange={onChart}/>}
|
||||
<Button asChild variant="ghost" size="sm">
|
||||
<Link to="/library">{t('DASHBOARD.SHOW_ALL')}<ArrowRight/></Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{games.length === 0 ? (
|
||||
<p className="px-5 py-10 text-center text-sm text-muted-foreground">
|
||||
{syncing ? t('LIBRARY.SYNCING_FIRST_TIME') : t('LIBRARY.EMPTY')}
|
||||
</p>
|
||||
) : form !== 'LIST' ? (
|
||||
<div className="px-5 py-4"><GamePlayChart games={mapped} chart={form} linked/></div>
|
||||
) : (
|
||||
<ol className="divide-y">
|
||||
{games.map((g, index) => (
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {changeUserRole, fetchRoleChanges, REFUSAL_KEY, ROLES} from '../utils/admin.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
|
||||
function RoleDialogContent({user, onClose, onChanged}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [chosen, setChosen] = useState(user.role);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// null while loading, 'error' when it failed
|
||||
const [changes, setChanges] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
fetchRoleChanges(user.id, {signal: controller.signal})
|
||||
.then(async (response) => {
|
||||
if (controller.signal.aborted || response.status === 401) return;
|
||||
setChanges(response.ok ? (await response.json()).results : 'error');
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
console.error('Role changes error:', err);
|
||||
setChanges('error');
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [user.id]);
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await changeUserRole(user.id, chosen, user.role);
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t('ADMIN.DONE_ROLE'));
|
||||
onChanged();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const data = await response.json().catch(() => ({}));
|
||||
toast.error(t(REFUSAL_KEY[data.code] ?? 'ADMIN.ERROR'));
|
||||
if (data.code === 'ROLE_CHANGED') {
|
||||
// The dialog holds the old user; reopen it from the reloaded list.
|
||||
onChanged();
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Role change error:', err);
|
||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('ADMIN.ROLES_TITLE', {email: user.email})}</DialogTitle>
|
||||
<DialogDescription>{t('ADMIN.ROLES_TEXT')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ROLES.map((role) => (
|
||||
<Button key={role} type="button" variant={chosen === role ? 'default' : 'outline'}
|
||||
aria-pressed={chosen === role} onClick={() => setChosen(role)}>
|
||||
{t(`ROLES.${role}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium">{t('ADMIN.ROLE_HISTORY')}</p>
|
||||
{changes === null && <Loader2 className="size-4 animate-spin text-muted-foreground"/>}
|
||||
{changes === 'error' && <p className="text-sm text-destructive">{t('ADMIN.ROLE_HISTORY_ERROR')}</p>}
|
||||
{Array.isArray(changes) && changes.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">{t('ADMIN.ROLE_HISTORY_EMPTY')}</p>
|
||||
)}
|
||||
{Array.isArray(changes) && changes.length > 0 && (
|
||||
<ul className="max-h-48 space-y-1 overflow-y-auto text-sm">
|
||||
{changes.map((change, index) => (
|
||||
<li key={index} className="flex flex-wrap gap-x-2 text-muted-foreground">
|
||||
<span>{formatDate(change.changedAt, i18n.language)}</span>
|
||||
<span>{change.actorEmail ?? `#${change.actorUserId}`}</span>
|
||||
<span>{`${t(`ROLES.${change.oldRole}`)} → ${t(`ROLES.${change.newRole}`)}`}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button type="button" variant="outline">{t('ADMIN.CANCEL')}</Button>
|
||||
</DialogClose>
|
||||
<Button type="button" disabled={busy || chosen === user.role} onClick={save}>
|
||||
{busy && <Loader2 className="animate-spin"/>}
|
||||
{t('ADMIN.SAVE_ROLE')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the role of another user and shows its role changes. Open while `user` is set.
|
||||
*/
|
||||
export default function UserRoleDialog({user, onClose, onChanged}) {
|
||||
return (
|
||||
<Dialog open={!!user} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
{user && <RoleDialogContent user={user} onClose={onClose} onChanged={onChanged}/>}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// @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 UserRoleDialog from './UserRoleDialog.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
const USER = {id: 7, email: 'player@example.com', role: 'USER'};
|
||||
const LOG = {results: [{actorUserId: 1, actorEmail: 'admin@example.com', oldRole: 'USER', newRole: 'AUDITOR',
|
||||
changedAt: '2026-10-01T10:00:00Z'}]};
|
||||
|
||||
const withRoutes = (routes) => fetchRoutes({'GET /api/admin/users/7/role-changes': json(200, LOG), ...routes});
|
||||
const renderDialog = () => {
|
||||
const onClose = vi.fn();
|
||||
const onChanged = vi.fn();
|
||||
render(<UserRoleDialog user={USER} onClose={onClose} onChanged={onChanged}/>);
|
||||
return {onClose, onChanged};
|
||||
};
|
||||
const save = () => screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'});
|
||||
const puts = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT');
|
||||
|
||||
describe('UserRoleDialog', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
withRoutes({'PUT /api/admin/users/7/role': json(204)});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the change log of the user', async () => {
|
||||
renderDialog();
|
||||
|
||||
expect(await screen.findByText('admin@example.com')).toBeTruthy();
|
||||
expect(screen.getByText('ROLES.USER → ROLES.AUDITOR')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('says so when the log is empty or cannot be loaded', async () => {
|
||||
withRoutes({'GET /api/admin/users/7/role-changes': json(200, {results: []})});
|
||||
renderDialog();
|
||||
expect(await screen.findByText('ADMIN.ROLE_HISTORY_EMPTY')).toBeTruthy();
|
||||
cleanup();
|
||||
|
||||
withRoutes({'GET /api/admin/users/7/role-changes': json(500, {})});
|
||||
renderDialog();
|
||||
expect(await screen.findByText('ADMIN.ROLE_HISTORY_ERROR')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('starts at the current role and cannot save an unchanged role', () => {
|
||||
renderDialog();
|
||||
|
||||
expect(screen.getByRole('button', {name: 'ROLES.USER'}).getAttribute('aria-pressed')).toBe('true');
|
||||
expect(save().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('saves the chosen role with the role it saw, then reloads and closes', async () => {
|
||||
const {onChanged, onClose} = renderDialog();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'}));
|
||||
fireEvent.click(save());
|
||||
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
||||
expect(JSON.parse(puts()[0][1].body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'});
|
||||
expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_ROLE');
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows why Auth refuses', async () => {
|
||||
withRoutes({'PUT /api/admin/users/7/role': json(409, {code: 'ACTOR_NOT_ADMIN'})});
|
||||
const {onChanged} = renderDialog();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'}));
|
||||
fireEvent.click(save());
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ACTOR_NOT_ADMIN'));
|
||||
expect(onChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the list again when the role changed in the meantime', async () => {
|
||||
withRoutes({'PUT /api/admin/users/7/role': json(409, {code: 'ROLE_CHANGED'})});
|
||||
const {onChanged} = renderDialog();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ROLES.AUDITOR'}));
|
||||
fireEvent.click(save());
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ROLE_CHANGED'));
|
||||
expect(onChanged).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -8,11 +8,12 @@ import {STAT_CHARTS} from '../../utils/statCharts.js';
|
||||
*
|
||||
* @param value the shown form
|
||||
* @param onChange `(form)`
|
||||
* @param label the accessible name; the default fits a card with one picker
|
||||
*/
|
||||
export default function ChartPicker({group, value, onChange}) {
|
||||
export default function ChartPicker({group, value, onChange, label}) {
|
||||
const {t} = useTranslation();
|
||||
return (
|
||||
<div role="group" aria-label={t('STATS.CHART.LABEL')} className="flex flex-wrap">
|
||||
<div role="group" aria-label={label ?? t('STATS.CHART.LABEL')} className="flex flex-wrap">
|
||||
{STAT_CHARTS[group].map((form) => (
|
||||
<Button key={form} variant={value === form ? 'secondary' : 'ghost'} size="sm"
|
||||
className="h-7 px-2 max-sm:min-h-11" aria-pressed={value === form}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
|
||||
import {DrillRow} from './StatDrill.jsx';
|
||||
import SliceChart from './SliceChart.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {gameHref} from '../../utils/statGames.js';
|
||||
import {topSlices} from '../../utils/statCharts.js';
|
||||
|
||||
/**
|
||||
* Play time per game as bars without covers, a pie or a donut. The list form stays in each card.
|
||||
*
|
||||
* @param games `[{appid, name, minutes}]`
|
||||
* @param chart BARS, PIE or DONUT
|
||||
* @param linked the games are the viewer's own: rows and slices open the detail page
|
||||
*/
|
||||
export default function GamePlayChart({games, chart, linked}) {
|
||||
const {t} = useTranslation();
|
||||
const named = games.map((g) => ({...g, name: g.name ?? `App ${g.appid}`}));
|
||||
if (chart === 'BARS') {
|
||||
const max = Math.max(1, ...named.map((g) => g.minutes));
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{named.map((g) => (
|
||||
<li key={g.appid} className="text-sm">
|
||||
<DrillRow to={linked ? gameHref(g.appid) : undefined}>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="truncate" title={g.name}>{g.name}</span>
|
||||
<span className="shrink-0 text-muted-foreground tabular-nums">{formatPlaytime(g.minutes)}</span>
|
||||
</div>
|
||||
<div aria-hidden="true" className="mt-1 h-2.5 rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary"
|
||||
style={{width: `${Math.max(2, (g.minutes / max) * 100)}%`}}/>
|
||||
</div>
|
||||
</DrillRow>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SliceChart slices={topSlices(named, (g) => g.minutes)} donut={chart === 'DONUT'}
|
||||
hrefOf={(slice) => (linked ? gameHref(slice.item.appid) : null)}
|
||||
valueText={(slice) => formatPlaytime(slice.value)} note={t('STATS.TOP_GAMES.SHARE')}/>
|
||||
);
|
||||
}
|
||||
@@ -10,15 +10,23 @@ import {sliceColors, slicePaths} from '../../utils/statCharts.js';
|
||||
const RADIUS = 50;
|
||||
const HOLE = 30;
|
||||
|
||||
const useSliceTarget = (slice, drillKey, label, linkOf) => useDrillTarget({
|
||||
// Names are not unique (two games can share one); a game slice has an app id.
|
||||
const sliceKey = (slice) => (slice.other ? 'other' : slice.item?.appid ?? slice.name);
|
||||
|
||||
const useSliceTarget = (slice, drillKey, label, linkOf, hrefOf) => {
|
||||
const target = useDrillTarget({
|
||||
to: slice.other ? null : linkOf?.(slice.name),
|
||||
params: slice.other ? null : {key: drillKey, arg: slice.name},
|
||||
title: slice.other ? '' : label(slice.name),
|
||||
count: slice.value,
|
||||
});
|
||||
});
|
||||
if (!hrefOf) return target;
|
||||
const to = !slice.other && slice.value > 0 ? hrefOf(slice) : null;
|
||||
return to ? {to} : null;
|
||||
};
|
||||
|
||||
function SlicePath({slice, drillKey, label, linkOf, d, color}) {
|
||||
const target = useSliceTarget(slice, drillKey, label, linkOf);
|
||||
function SlicePath({slice, drillKey, label, linkOf, hrefOf, d, color}) {
|
||||
const target = useSliceTarget(slice, drillKey, label, linkOf, hrefOf);
|
||||
const navigate = useNavigate();
|
||||
if (!d) return null;
|
||||
return (
|
||||
@@ -27,18 +35,19 @@ function SlicePath({slice, drillKey, label, linkOf, d, color}) {
|
||||
);
|
||||
}
|
||||
|
||||
function SliceRow({slice, drillKey, label, linkOf, color}) {
|
||||
function SliceRow({slice, drillKey, label, linkOf, hrefOf, valueText, color}) {
|
||||
const {t} = useTranslation();
|
||||
const target = useSliceTarget(slice, drillKey, label, linkOf);
|
||||
const target = useSliceTarget(slice, drillKey, label, linkOf, hrefOf);
|
||||
const games = t('STATS.TASTE.GAMES', {count: slice.value});
|
||||
const text = slice.other ? t('STATS.CHART.OTHER') : label(slice.name);
|
||||
return (
|
||||
<li className="text-sm">
|
||||
<DrillRow onSelect={target?.onClick} to={target?.to} className="flex items-center gap-2">
|
||||
<span className={cn('size-3 shrink-0 rounded-sm', !color && 'bg-muted')}
|
||||
style={color ? {background: color} : undefined}/>
|
||||
<span className="min-w-0 flex-1 truncate">{slice.other ? t('STATS.CHART.OTHER') : label(slice.name)}</span>
|
||||
<span className="min-w-0 flex-1 truncate" title={text}>{text}</span>
|
||||
<span className="ml-auto shrink-0 whitespace-nowrap text-muted-foreground tabular-nums">
|
||||
{slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games}
|
||||
{valueText ? valueText(slice) : slice.item ? `${formatPlaytime(slice.item.minutes)} · ${games}` : games}
|
||||
</span>
|
||||
</DrillRow>
|
||||
</li>
|
||||
@@ -52,12 +61,14 @@ function SliceRow({slice, drillKey, label, linkOf, color}) {
|
||||
* @param drillKey the key of the games request; the slice name is its argument
|
||||
* @param label shown name of a slice name
|
||||
* @param linkOf library URL of a slice name; the own views link there instead of opening the dialog
|
||||
* @param hrefOf `(slice) => url|null`: a slice links there instead of using `linkOf` or the dialog; never "other" or 0
|
||||
* @param valueText `(slice) => string`: the text at the right of a legend row
|
||||
* @param center shown in the hole of a donut
|
||||
* @param note a line under the legend
|
||||
*
|
||||
* A slice without games has a legend row only.
|
||||
*/
|
||||
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, center, note}) {
|
||||
export default function SliceChart({slices, donut = false, drillKey, label = (name) => name, linkOf, hrefOf, valueText, center, note}) {
|
||||
const colors = sliceColors(slices);
|
||||
const paths = slicePaths(slices.map((slice) => slice.value), RADIUS, donut ? HOLE : 0);
|
||||
return (
|
||||
@@ -66,8 +77,8 @@ export default function SliceChart({slices, donut = false, drillKey, label = (na
|
||||
<div className="relative size-40 max-w-40 shrink-0">
|
||||
<svg viewBox={`0 0 ${2 * RADIUS} ${2 * RADIUS}`} aria-hidden="true" className="size-full">
|
||||
{slices.map((slice, index) => (
|
||||
<SlicePath key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
|
||||
label={label} linkOf={linkOf} d={paths[index]} color={colors[index]}/>
|
||||
<SlicePath key={sliceKey(slice)} slice={slice} drillKey={drillKey}
|
||||
label={label} linkOf={linkOf} hrefOf={hrefOf} d={paths[index]} color={colors[index]}/>
|
||||
))}
|
||||
</svg>
|
||||
{donut && center && (
|
||||
@@ -78,8 +89,9 @@ export default function SliceChart({slices, donut = false, drillKey, label = (na
|
||||
</div>
|
||||
<ul className="w-full min-w-0 flex-1 space-y-1">
|
||||
{slices.map((slice, index) => (
|
||||
<SliceRow key={slice.other ? 'other' : slice.name} slice={slice} drillKey={drillKey}
|
||||
label={label} linkOf={linkOf} color={colors[index]}/>
|
||||
<SliceRow key={sliceKey(slice)} slice={slice} drillKey={drillKey}
|
||||
label={label} linkOf={linkOf} hrefOf={hrefOf} valueText={valueText}
|
||||
color={colors[index]}/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -3,26 +3,35 @@ import {Trophy} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import StatsSection from './StatsSection.jsx';
|
||||
import ChartPicker from './ChartPicker.jsx';
|
||||
import GamePlayChart from './GamePlayChart.jsx';
|
||||
import GameRow from './GameRow.jsx';
|
||||
import {useStatDrill} from './StatDrill.jsx';
|
||||
import {formatPlaytime} from '../../utils/games.js';
|
||||
import {playChart} from '../../utils/statCharts.js';
|
||||
|
||||
export default function TopGamesStats({topGames, linked = true}) {
|
||||
/**
|
||||
* @param chart LIST, BARS, PIE or DONUT
|
||||
* @param onChart `(form)`: shows the picker
|
||||
*/
|
||||
export default function TopGamesStats({topGames, linked = true, chart = 'LIST', onChart}) {
|
||||
const {t} = useTranslation();
|
||||
const drill = useStatDrill();
|
||||
const max = Math.max(1, ...topGames.mostPlayed.map((g) => g.minutes));
|
||||
const form = playChart(chart, topGames.mostPlayed);
|
||||
return (
|
||||
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}>
|
||||
<StatsSection title={t('STATS.TOP_GAMES.TITLE')} icon={Trophy}
|
||||
action={onChart && <ChartPicker group="TOP_GAMES" value={chart} onChange={onChart}/>}>
|
||||
{topGames.mostPlayed.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('STATS.TOP_GAMES.EMPTY')}</p>
|
||||
) : (
|
||||
) : form === 'LIST' ? (
|
||||
<ol className="space-y-2">
|
||||
{topGames.mostPlayed.map((g) => (
|
||||
<li key={g.appid}><GameRow game={g} value={formatPlaytime(g.minutes)} linked={linked}
|
||||
share={g.minutes / max}/></li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
) : <GamePlayChart games={topGames.mostPlayed} chart={form} linked={linked}/>}
|
||||
{drill && topGames.mostPlayed.length > 0 && (
|
||||
<Button variant="ghost" size="sm" className="self-start max-sm:min-h-11"
|
||||
onClick={() => drill.open(t('STATS.TOP_GAMES.TITLE'), {key: 'MOST_PLAYED'})}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import TasteStats from './TasteStats.jsx';
|
||||
import HabitsStats from './HabitsStats.jsx';
|
||||
import FinishedStats from './FinishedStats.jsx';
|
||||
import PlatformStats from './PlatformStats.jsx';
|
||||
import {chartOf} from '../../utils/statCharts.js';
|
||||
import {chartProps} from '../../utils/statCharts.js';
|
||||
|
||||
/**
|
||||
* One stat group as a tile; null while its data is not there. A function, not a component: a tile grid leaves out a
|
||||
@@ -15,17 +15,18 @@ import {chartOf} from '../../utils/statCharts.js';
|
||||
* @param stats the answer of GET /api/library/stats, or the stats of a public profile
|
||||
* @param linked true for the user's own games: the dashboard and the owner on the profile
|
||||
* @param charts the chart form per group of the user whose stats these are
|
||||
* @param onChart `(group, form)`: shows the picker; null for visitors
|
||||
*/
|
||||
export default function statGroupTile(id, stats, linked = true, charts = null) {
|
||||
export default function statGroupTile(id, stats, linked = true, charts = null, onChart = null) {
|
||||
switch (id) {
|
||||
case 'STATS_OVERVIEW': return stats?.overview ? <OverviewStats overview={stats.overview}/> : null;
|
||||
case 'STATS_PILE': return stats?.pile ? <PileStats pile={stats.pile} linked={linked}/> : null;
|
||||
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend} chart={chartOf(charts, 'TREND')}/> : null;
|
||||
case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked}/> : null;
|
||||
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste} chart={chartOf(charts, 'TASTE')}/> : null;
|
||||
case 'STATS_TREND': return stats?.trend ? <TrendStats trend={stats.trend} {...chartProps(charts, onChart, 'TREND')}/> : null;
|
||||
case 'STATS_TOP_GAMES': return stats?.topGames ? <TopGamesStats topGames={stats.topGames} linked={linked} {...chartProps(charts, onChart, 'TOP_GAMES')}/> : null;
|
||||
case 'STATS_TASTE': return stats?.taste ? <TasteStats taste={stats.taste} {...chartProps(charts, onChart, 'TASTE')}/> : null;
|
||||
case 'STATS_HABITS': return stats?.habits ? <HabitsStats habits={stats.habits}/> : null;
|
||||
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished} chart={chartOf(charts, 'FINISHED')}/> : null;
|
||||
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms} chart={chartOf(charts, 'PLATFORMS')}/> : null;
|
||||
case 'STATS_FINISHED': return stats?.finished ? <FinishedStats finished={stats.finished} {...chartProps(charts, onChart, 'FINISHED')}/> : null;
|
||||
case 'STATS_PLATFORMS': return stats?.platforms ? <PlatformStats platforms={stats.platforms} {...chartProps(charts, onChart, 'PLATFORMS')}/> : null;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ async function loadInto(path, set, pick = (data) => data) {
|
||||
|
||||
export default function useDashboardData() {
|
||||
const {reload: reloadUser} = useCurrentUserContext();
|
||||
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
|
||||
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false, syncStarted: false});
|
||||
const [topGames, setTopGames] = useState([]);
|
||||
const [playing, setPlaying] = useState([]);
|
||||
const [summary, setSummary] = useState(null);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import {useRef} from 'react';
|
||||
|
||||
import useSaveResponse from './useSaveResponse.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import {saveStatChart, STAT_CHARTS} from '../utils/statCharts.js';
|
||||
|
||||
/**
|
||||
* The chart forms of the signed-in user and `change(group, form)`: shows the form at once, saves null for the
|
||||
* default and puts the old form back when the save fails.
|
||||
*/
|
||||
export default function useStatCharts() {
|
||||
const {user, updateUser} = useCurrentUserContext();
|
||||
const runSave = useSaveResponse();
|
||||
const charts = user?.settings?.statCharts;
|
||||
|
||||
const setChart = (group, form) => updateUser((u) => {
|
||||
const next = {...u.settings?.statCharts};
|
||||
if (form) next[group] = form;
|
||||
else delete next[group];
|
||||
return {...u, settings: {...u.settings, statCharts: next}};
|
||||
});
|
||||
const latest = useRef({});
|
||||
const change = async (group, form) => {
|
||||
const before = charts?.[group] ?? null;
|
||||
const sent = form === STAT_CHARTS[group][0] ? null : form;
|
||||
const id = (latest.current[group] ?? 0) + 1;
|
||||
latest.current[group] = id;
|
||||
setChart(group, sent);
|
||||
// A failed older save must not undo a newer choice.
|
||||
if (!await runSave(() => saveStatChart(group, sent)) && latest.current[group] === id) setChart(group, before);
|
||||
};
|
||||
return {charts, change};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, Loader2, Search, ShieldCheck, Trash2, X} from 'lucide-react';
|
||||
import {Ban, Check, ChevronLeft, ChevronRight, KeyRound, Loader2, Search, ShieldCheck, Trash2, UserCog, X} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
@@ -18,21 +18,13 @@ import {
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, sendPasswordReset, setUserBlocked} from '../utils/admin.js';
|
||||
import UserRoleDialog from '../components/UserRoleDialog.jsx';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import {decideUser, deleteUnconfirmedUser, deleteUser, fetchUsers, REFUSAL_KEY, sendPasswordReset, setUserBlocked} from '../utils/admin.js';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const REFUSAL_KEY = {
|
||||
SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION',
|
||||
TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN',
|
||||
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
|
||||
NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING',
|
||||
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
|
||||
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
|
||||
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
|
||||
NOT_ACTIVE: 'ADMIN.ERROR_NOT_ACTIVE',
|
||||
};
|
||||
|
||||
const formatDateOrNull = (value, locale) => (value ? formatDate(value, locale) : null);
|
||||
|
||||
@@ -54,6 +46,8 @@ export default function AdminUsersPage() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Only the newest load may show its answer: a slow answer to an older search must not replace the list.
|
||||
const nextSignal = useAbortPrevious();
|
||||
const ownId = useCurrentUserContext().user?.id;
|
||||
const [roleUser, setRoleUser] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const signal = nextSignal();
|
||||
@@ -223,6 +217,11 @@ export default function AdminUsersPage() {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{user.status === 'ACTIVE' && !user.blocked && !user.deletedAt && user.id !== ownId && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setRoleUser(user)}>
|
||||
<UserCog/>{t('ADMIN.ROLES')}
|
||||
</Button>
|
||||
)}
|
||||
{user.role !== 'PLATFORM_ADMIN' && !user.deletedAt && user.status !== 'PENDING' && user.status !== 'UNCONFIRMED' && (
|
||||
<div className="flex gap-2">
|
||||
{user.status === 'ACTIVE' && !user.blocked && (
|
||||
@@ -289,6 +288,7 @@ export default function AdminUsersPage() {
|
||||
</DialogContent>
|
||||
)}
|
||||
</Dialog>
|
||||
<UserRoleDialog user={roleUser} onClose={() => setRoleUser(null)} onChanged={load}/>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
vi.mock('../context/CurrentUserContext.jsx', () => ({useCurrentUserContext: () => ({user: {id: 1}})}));
|
||||
|
||||
const PLAYER = {
|
||||
id: 7, email: 'player@example.com', displayName: 'Player', role: 'USER', registrationDate: '2026-09-01T10:00:00',
|
||||
@@ -187,6 +188,67 @@ describe('AdminUsersPage', () => {
|
||||
expect(calls('DELETE')[0][0]).toMatch(/\/api\/admin\/users\/10\/unconfirmed$/);
|
||||
});
|
||||
|
||||
it('offers the roles of an active user and of another admin, not of the own, blocked, deleted or waiting rows', async () => {
|
||||
withRoutes({
|
||||
'GET /api/admin/users': json(200, page([ADMIN, {...ADMIN, id: 2, email: 'admin2@example.com'}, PLAYER,
|
||||
{...PLAYER, id: 11, email: 'blocked@example.com', blocked: true}, DENIED, NEWCOMER, UNCONFIRMED])),
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText('player@example.com');
|
||||
|
||||
expect(screen.getAllByRole('button', {name: 'ADMIN.ROLES'})).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('saves a new role from the dialog and loads the list again', async () => {
|
||||
withRoutes({
|
||||
'GET /api/admin/users/7/role-changes': json(200, {results: []}),
|
||||
'PUT /api/admin/users/7/role': json(204),
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText('player@example.com');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'}));
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'ROLES.AUDITOR'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'}));
|
||||
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN.DONE_ROLE'));
|
||||
expect(JSON.parse(calls('PUT')[0][1].body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'});
|
||||
await waitFor(() => expect(calls('GET').filter(([url]) => url.endsWith('/api/admin/users') || url.includes('/api/admin/users?'))).toHaveLength(2));
|
||||
});
|
||||
|
||||
it('afterRoleChangedTheNextSaveSendsTheReloadedRole', async () => {
|
||||
let lists = 0;
|
||||
let puts = 0;
|
||||
withRoutes({
|
||||
'GET /api/admin/users': () => {
|
||||
lists += 1;
|
||||
return json(200, page([ADMIN, lists === 1 ? PLAYER : {...PLAYER, role: 'AUDITOR'}]));
|
||||
},
|
||||
'GET /api/admin/users/7/role-changes': json(200, {results: []}),
|
||||
'PUT /api/admin/users/7/role': () => {
|
||||
puts += 1;
|
||||
return puts === 1 ? json(409, {code: 'ROLE_CHANGED'}) : json(204);
|
||||
},
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText('player@example.com');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'}));
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'ROLES.PLATFORM_ADMIN'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'}));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN.ERROR_ROLE_CHANGED'));
|
||||
await waitFor(() => expect(lists).toBe(2));
|
||||
|
||||
if (!screen.queryByRole('button', {name: 'ADMIN.SAVE_ROLE'})) {
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.ROLES'}));
|
||||
}
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'ROLES.PLATFORM_ADMIN'}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'ADMIN.SAVE_ROLE'}));
|
||||
|
||||
await waitFor(() => expect(calls('PUT')).toHaveLength(2));
|
||||
expect(JSON.parse(calls('PUT')[1][1].body)).toEqual({role: 'PLATFORM_ADMIN', expectedRole: 'AUDITOR'});
|
||||
});
|
||||
|
||||
it('says no access to a user without the admin role', async () => {
|
||||
withRoutes({'GET /api/admin/users': json(403, {})});
|
||||
renderPage();
|
||||
|
||||
+12
-5
@@ -1,4 +1,4 @@
|
||||
import {useState} from 'react';
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {RefreshCw} from 'lucide-react';
|
||||
@@ -23,6 +23,8 @@ import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSteamProfile from '../hooks/useSteamProfile.js';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import useStatCharts from '../hooks/useStatCharts.js';
|
||||
import {chartProps} from '../utils/statCharts.js';
|
||||
import TileGrid from '../components/TileGrid.jsx';
|
||||
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
@@ -42,6 +44,7 @@ const Dashboard = () => {
|
||||
const [shuffling, setShuffling] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const runSave = useSaveResponse();
|
||||
const {charts, change} = useStatCharts();
|
||||
const layout = normalizeLayout(userData?.settings?.dashboardTiles, DASHBOARD_TILES, DEFAULT_DASHBOARD);
|
||||
// The stat groups of the shown stat tiles, and TREND for the pile card and the "pile grew" hint. Waits for the
|
||||
// saved layout in /me.
|
||||
@@ -71,7 +74,11 @@ const Dashboard = () => {
|
||||
stats.reload();
|
||||
},
|
||||
});
|
||||
const forceSync = useForceSync(sync.watch);
|
||||
const {watch} = sync;
|
||||
const forceSync = useForceSync(watch);
|
||||
useEffect(() => {
|
||||
if (steamData.syncStarted) watch();
|
||||
}, [steamData, watch]);
|
||||
|
||||
if (isLoading) {
|
||||
return <DashboardSkeleton/>;
|
||||
@@ -98,7 +105,7 @@ const Dashboard = () => {
|
||||
case 'GAME_COUNT': return showLibrary && <SteamGameCount targetCount={steamData.count}/>;
|
||||
case 'MOST_PLAYED':
|
||||
return showLibrary && <MostPlayedCard games={topGames.slice(0, TOP_GAMES[size])} syncing={sync.isRunning}
|
||||
equipped={equipped}/>;
|
||||
equipped={equipped} {...chartProps(charts, change, 'TOP_GAMES')}/>;
|
||||
case 'PLAYING':
|
||||
return showLibrary && (
|
||||
<CurrentlyPlayingCard games={playing.slice(0, PLAYING_GAMES[size])} syncing={sync.isRunning} wide={size === 'L'}/>
|
||||
@@ -117,9 +124,9 @@ const Dashboard = () => {
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity}/>;
|
||||
case 'ACTIVITY': return hasSteamId && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
|
||||
case 'ACHIEVEMENTS': return hasSteamId && achievements && <AchievementsCard summary={achievements}/>;
|
||||
default: return statGroupTile(id, stats.stats, true, userData?.settings?.statCharts);
|
||||
default: return statGroupTile(id, stats.stats, true, charts, change);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, screen, waitFor, within} from '@testing-library/react';
|
||||
import Dashboard from './Dashboard.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})}));
|
||||
const watchSync = vi.hoisted(() => vi.fn());
|
||||
vi.mock('../hooks/useLibrarySync.js', () => ({
|
||||
default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false, watch: watchSync}),
|
||||
}));
|
||||
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
||||
|
||||
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
|
||||
@@ -42,6 +45,7 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
watchSync.mockClear();
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -109,7 +113,47 @@ describe('Dashboard', () => {
|
||||
expect(screen.getByRole('button', {name: /2024/})).toBeTruthy();
|
||||
expect(screen.getByText('2023: 0')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', {name: /2023/})).toBeNull();
|
||||
expect(screen.queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull();
|
||||
expect(screen.getByRole('group', {name: 'STATS.CHART.LABEL'})).toBeTruthy();
|
||||
});
|
||||
|
||||
const putBodies = () => fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
|
||||
|
||||
it('aTasteTilePickerSavesAndRedraws', async () => {
|
||||
fetchRoutes({
|
||||
...dashboardRoutes({dashboardTiles: [{id: 'STATS_TASTE', size: 'M'}]}),
|
||||
'GET /api/library/stats': json(200, {trend: null, taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200},
|
||||
{name: 'Puzzle', games: 1, minutes: 300}], moods: []}}),
|
||||
'PUT /api/users/settings/stat-chart': json(204, {}),
|
||||
});
|
||||
const {container} = renderDashboard();
|
||||
await screen.findByText('STATS.TASTE.TITLE');
|
||||
|
||||
fireEvent.click(within(screen.getByRole('group', {name: 'STATS.CHART.LABEL'})).getByRole('button', {name: 'STATS.CHART.PIE'}));
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('path.stroke-card').length).toBeGreaterThan(0));
|
||||
expect(putBodies()).toEqual([{group: 'TASTE', chart: 'PIE'}]);
|
||||
});
|
||||
|
||||
it('theMostPlayedCardShowsTheTopGamesForm', async () => {
|
||||
const container = mockDashboard({dashboardTiles: [{id: 'MOST_PLAYED', size: 'L'}], statCharts: {TOP_GAMES: 'DONUT'}});
|
||||
|
||||
await screen.findByRole('group', {name: 'STATS.CHART.LABEL'});
|
||||
expect(container.querySelectorAll('path.stroke-card')).toHaveLength(6);
|
||||
const card = container.querySelector('[data-tile="MOST_PLAYED"]');
|
||||
expect(within(card).getAllByRole('link', {name: /Game/})).toHaveLength(5);
|
||||
expect(within(card).getByText('STATS.CHART.OTHER').closest('a,button')).toBeNull();
|
||||
});
|
||||
|
||||
it('theActivityPickerSavesTheForm', async () => {
|
||||
fetchRoutes({...dashboardRoutes(), 'PUT /api/users/settings/stat-chart': json(204, {})});
|
||||
const {container} = renderDashboard();
|
||||
await screen.findByText('DASHBOARD.ACTIVITY');
|
||||
|
||||
const card = container.querySelector('[data-tile="ACTIVITY"]');
|
||||
fireEvent.click(within(card).getByRole('button', {name: 'STATS.CHART.LINE'}));
|
||||
|
||||
await waitFor(() => expect(container.querySelector('polyline')).not.toBeNull());
|
||||
expect(putBodies()).toEqual([{group: 'ACTIVITY', chart: 'LINE'}]);
|
||||
});
|
||||
|
||||
it('leaves out a stat tile whose data did not come', async () => {
|
||||
@@ -177,5 +221,22 @@ describe('Dashboard', () => {
|
||||
|
||||
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('watches the sync the server started for this visit', async () => {
|
||||
fetchRoutes({
|
||||
...dashboardRoutes(),
|
||||
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, syncStarted: true}),
|
||||
});
|
||||
renderDashboard();
|
||||
|
||||
await waitFor(() => expect(watchSync).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it('does not watch a sync when the server started none', async () => {
|
||||
fetchRoutes(dashboardRoutes());
|
||||
renderDashboard();
|
||||
|
||||
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
||||
expect(watchSync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {act, cleanup, screen} from '@testing-library/react';
|
||||
import Dashboard from './Dashboard.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const empty = {content: [], total: 0, byStatus: {}};
|
||||
const wait = (ms) => act(() => vi.advanceTimersByTimeAsync(ms));
|
||||
const calls = (path) => fetch.mock.calls.filter(([url]) => new URL(url, 'http://x').pathname.endsWith(path)).length;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({shouldAdvanceTime: true});
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('Dashboard sync on a visit', () => {
|
||||
it('showsTheSyncingBadgeDuringTheVisitSyncAndReloadsBannerAndLibraryAfterIt', async () => {
|
||||
let syncStatus = {lastSyncStatus: 'OK', lastSyncedAt: '2026-10-03T10:00:00Z'};
|
||||
let syncChanges = json(204, {});
|
||||
fetchRoutes({
|
||||
'GET /api/library/summary': json(200, empty),
|
||||
'GET /api/hints': json(200, {results: []}),
|
||||
'GET /api/news': json(200, {results: []}),
|
||||
'GET /api/library/sync-changes': () => syncChanges,
|
||||
'GET /api/users/me': json(200, {email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'}),
|
||||
'GET /api/library/games': json(200, empty),
|
||||
'GET /api/library/stats': json(200, {trend: null}),
|
||||
'GET /api/library/steam-profile': json(200, {personaName: 'Lobo', avatarUrl: null}),
|
||||
'GET /api/library/activity': json(200, {totalMinutes: 0, days: [], recentGames: []}),
|
||||
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true, syncStarted: true}),
|
||||
'GET /api/library/sync-status': () => json(200, syncStatus),
|
||||
'GET /api/library/achievements/summary': json(200, {unlocked: 0, startedGames: 0, perfectGames: 0,
|
||||
averageCompletion: 0, pendingGames: 0, hiddenGames: 0, closest: []}),
|
||||
});
|
||||
renderWithProviders(<Dashboard/>, {entries: ['/dashboard']});
|
||||
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
||||
expect(screen.queryByText('LIBRARY.SYNCING')).toBeNull();
|
||||
|
||||
syncStatus = {lastSyncStatus: 'RUNNING', lastSyncedAt: '2026-10-03T10:00:00Z'};
|
||||
await wait(3000);
|
||||
expect(screen.getByText('LIBRARY.SYNCING')).toBeTruthy();
|
||||
const gamesBefore = calls('/api/library/games');
|
||||
const changesBefore = calls('/api/library/sync-changes');
|
||||
|
||||
syncStatus = {lastSyncStatus: 'OK', lastSyncedAt: '2026-10-04T08:00:00Z'};
|
||||
syncChanges = json(200, {syncedAt: '2026-10-04T08:00:00Z', startedBy: 'LOGIN', seen: false, importedGames: 0,
|
||||
changes: [{kind: 'ADDED', appid: 1, name: 'New game', amount: 0}]});
|
||||
await wait(3000);
|
||||
|
||||
expect(screen.queryByText('LIBRARY.SYNCING')).toBeNull();
|
||||
expect(await screen.findByText('SYNC_CHANGES.ADDED:1')).toBeTruthy();
|
||||
expect(calls('/api/library/sync-changes')).toBeGreaterThan(changesBefore);
|
||||
expect(calls('/api/library/games')).toBeGreaterThan(gamesBefore);
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||
import {DEFAULT_PROFILE, PROFILE_TILES, PROFILE_TITLES, normalizeLayout, saveTiles} from '../utils/tiles.js';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import useStatCharts from '../hooks/useStatCharts.js';
|
||||
import HeroBanner from '../components/HeroBanner.jsx';
|
||||
import PageBackground from '../components/PageBackground.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
@@ -46,6 +47,7 @@ export default function PublicProfilePage() {
|
||||
const [reloads, setReloads] = useState(0);
|
||||
const viewer = useCurrentUser(useSession().active);
|
||||
const runSave = useSaveResponse();
|
||||
const own = useStatCharts();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -144,7 +146,8 @@ export default function PublicProfilePage() {
|
||||
&& <Playing games={library.playing.slice(0, PLAYING_GAMES[size])} compact={size === 'S'}/>;
|
||||
case 'FINISHED':
|
||||
return library.finished?.length > 0 && <Finished games={library.finished.slice(0, FINISHED_GAMES[size])} equipped={equipped}/>;
|
||||
default: return statGroupTile(id, library.stats, owner, profile.style?.statCharts);
|
||||
default: return statGroupTile(id, library.stats, owner, (owner ? own.charts : null) ?? profile.style?.statCharts,
|
||||
owner ? own.change : null);
|
||||
}
|
||||
};
|
||||
// Core sends only the data of the shown tiles (the ticket says which), so a new tile needs a new load.
|
||||
|
||||
@@ -408,6 +408,54 @@ describe('PublicProfilePage', () => {
|
||||
}
|
||||
});
|
||||
|
||||
const tasteStats = {pile: null, trend: null, finished: null, habits: null, topGames: null, platforms: null,
|
||||
taste: {genres: [{name: 'Shooter', games: 2, minutes: 1200}, {name: 'Puzzle', games: 1, minutes: 300}], moods: []}};
|
||||
|
||||
it('theOwnerGetsThePickerOnATasteTile', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
library.stats = tasteStats;
|
||||
try {
|
||||
mockBackends({me: {displayName: 'lobo', role: 'USER', settings: {}}, style: {tiles: [{id: 'STATS_TASTE', size: 'M'}]},
|
||||
extra: {'PUT /api/users/settings/stat-chart': json(204, {})}});
|
||||
const {container} = renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'STATS.CHART.DONUT'}));
|
||||
|
||||
await waitFor(() => expect(container.querySelectorAll('path.stroke-card').length).toBeGreaterThan(0));
|
||||
const bodies = fetch.mock.calls.filter(([, init]) => init?.method === 'PUT').map(([, init]) => JSON.parse(init.body));
|
||||
expect(bodies).toEqual([{group: 'TASTE', chart: 'DONUT'}]);
|
||||
} finally {
|
||||
delete library.stats;
|
||||
localStorage.clear();
|
||||
}
|
||||
});
|
||||
|
||||
const twoGameStats = {...topGamesStats, topGames: {mostLoyal: null, mostPlayed: [
|
||||
{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900}, {appid: 220, name: 'Half-Life 2', imageUrl: null, minutes: 300}]}};
|
||||
|
||||
it('topGamesPieLinksForTheOwnerAndIsPlainForAVisitor', async () => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
library.stats = twoGameStats;
|
||||
try {
|
||||
const style = {tiles: [{id: 'STATS_TOP_GAMES', size: 'M'}], statCharts: {TOP_GAMES: 'PIE'}};
|
||||
mockBackends({me: {displayName: 'lobo', role: 'USER', settings: {statCharts: {TOP_GAMES: 'PIE'}}}, style});
|
||||
const {unmount} = renderPage();
|
||||
expect((await screen.findByRole('link', {name: /Portal 2/})).getAttribute('href')).toBe('/library/620');
|
||||
unmount();
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
|
||||
mockBackends({style});
|
||||
renderPage();
|
||||
await screen.findByText('Portal 2');
|
||||
expect(screen.queryByRole('link', {name: /Portal 2/})).toBeNull();
|
||||
expect(screen.queryByRole('button', {name: /Portal 2/})).toBeNull();
|
||||
} finally {
|
||||
delete library.stats;
|
||||
localStorage.clear();
|
||||
}
|
||||
});
|
||||
|
||||
it('showsBarsForAFormTheProfileDoesNotKnow', async () => {
|
||||
library.stats = platformStats;
|
||||
try {
|
||||
|
||||
+9
-31
@@ -1,4 +1,3 @@
|
||||
import {useRef} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {RefreshCw} from 'lucide-react';
|
||||
|
||||
@@ -15,37 +14,16 @@ import FinishedStats from '../components/stats/FinishedStats.jsx';
|
||||
import PlatformStats from '../components/stats/PlatformStats.jsx';
|
||||
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import {chartOf, saveStatChart, STAT_CHARTS} from '../utils/statCharts.js';
|
||||
import useStatCharts from '../hooks/useStatCharts.js';
|
||||
import {chartProps} from '../utils/statCharts.js';
|
||||
|
||||
const ALL_GROUPS = [];
|
||||
|
||||
export default function StatsPage() {
|
||||
const {t} = useTranslation();
|
||||
const {stats, failed, reload} = useUserStats(ALL_GROUPS);
|
||||
const {user, updateUser} = useCurrentUserContext();
|
||||
const runSave = useSaveResponse();
|
||||
const charts = user?.settings?.statCharts;
|
||||
|
||||
// The new form shows at once; a failed save puts only this group back.
|
||||
const setChart = (group, form) => updateUser((u) => {
|
||||
const next = {...u.settings?.statCharts};
|
||||
if (form) next[group] = form;
|
||||
else delete next[group];
|
||||
return {...u, settings: {...u.settings, statCharts: next}};
|
||||
});
|
||||
const latest = useRef({});
|
||||
const change = async (group, form) => {
|
||||
const before = charts?.[group] ?? null;
|
||||
const sent = form === STAT_CHARTS[group][0] ? null : form;
|
||||
const id = (latest.current[group] ?? 0) + 1;
|
||||
latest.current[group] = id;
|
||||
setChart(group, sent);
|
||||
// A failed older save must not undo a newer choice.
|
||||
if (!await runSave(() => saveStatChart(group, sent)) && latest.current[group] === id) setChart(group, before);
|
||||
};
|
||||
const chartProps = (group) => ({chart: chartOf(charts, group), onChart: (form) => change(group, form)});
|
||||
const {charts, change} = useStatCharts();
|
||||
const props = (group) => chartProps(charts, change, group);
|
||||
|
||||
return (
|
||||
<StatDrillProvider source="own">
|
||||
@@ -73,12 +51,12 @@ export default function StatsPage() {
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="md:col-span-2"><OverviewStats overview={stats.overview}/></div>
|
||||
<PileStats pile={stats.pile}/>
|
||||
<TrendStats trend={stats.trend} {...chartProps('TREND')}/>
|
||||
<TopGamesStats topGames={stats.topGames}/>
|
||||
<TasteStats taste={stats.taste} {...chartProps('TASTE')}/>
|
||||
<PlatformStats platforms={stats.platforms} {...chartProps('PLATFORMS')}/>
|
||||
<TrendStats trend={stats.trend} {...props('TREND')}/>
|
||||
<TopGamesStats topGames={stats.topGames} {...props('TOP_GAMES')}/>
|
||||
<TasteStats taste={stats.taste} {...props('TASTE')}/>
|
||||
<PlatformStats platforms={stats.platforms} {...props('PLATFORMS')}/>
|
||||
<HabitsStats habits={stats.habits}/>
|
||||
<FinishedStats finished={stats.finished} {...chartProps('FINISHED')}/>
|
||||
<FinishedStats finished={stats.finished} {...props('FINISHED')}/>
|
||||
</div>
|
||||
)}
|
||||
</PageContainer>
|
||||
|
||||
@@ -292,10 +292,10 @@ describe('StatsPage', () => {
|
||||
await screen.findByText('STATS.PLATFORMS.TITLE');
|
||||
|
||||
expect(slices('STATS.PLATFORMS.TITLE')).toHaveLength(0);
|
||||
for (const title of ['PLATFORMS', 'TASTE', 'FINISHED', 'TREND']) {
|
||||
for (const title of ['PLATFORMS', 'TASTE', 'FINISHED', 'TREND', 'TOP_GAMES']) {
|
||||
expect(within(picker(`STATS.${title}.TITLE`)).getAllByRole('button').length).toBeGreaterThan(1);
|
||||
}
|
||||
expect(within(section('STATS.TOP_GAMES.TITLE')).queryByRole('group', {name: 'STATS.CHART.LABEL'})).toBeNull();
|
||||
expect(within(picker('STATS.TOP_GAMES.TITLE')).getByRole('button', {name: 'STATS.CHART.LIST'}).getAttribute('aria-pressed')).toBe('true');
|
||||
expect(within(picker('STATS.PLATFORMS.TITLE')).getByRole('button', {name: 'STATS.CHART.BARS'}).getAttribute('aria-pressed')).toBe('true');
|
||||
});
|
||||
|
||||
@@ -315,6 +315,48 @@ describe('StatsPage', () => {
|
||||
expect(putBodies().at(-1)).toEqual({group: 'PLATFORMS', chart: null});
|
||||
});
|
||||
|
||||
const twoGames = () => stats({topGames: {
|
||||
mostPlayed: [{appid: 620, name: 'Portal 2', imageUrl: null, minutes: 900},
|
||||
{appid: 220, name: 'Half-Life 2', imageUrl: null, minutes: 300}],
|
||||
mostLoyal: {appid: 620, name: 'Portal 2', imageUrl: null, months: 3, minutes: 900}}});
|
||||
|
||||
it('topGamesPieLinksEachGameAndShowsTheShareNote', async () => {
|
||||
answerStats(json(200, twoGames()));
|
||||
renderPage();
|
||||
await screen.findByText('STATS.TOP_GAMES.TITLE');
|
||||
|
||||
fireEvent.click(within(picker('STATS.TOP_GAMES.TITLE')).getByRole('button', {name: 'STATS.CHART.PIE'}));
|
||||
|
||||
await waitFor(() => expect(slices('STATS.TOP_GAMES.TITLE')).toHaveLength(2));
|
||||
expect(putBodies()).toEqual([{group: 'TOP_GAMES', chart: 'PIE'}]);
|
||||
const card = section('STATS.TOP_GAMES.TITLE');
|
||||
expect(within(card).getByRole('link', {name: /Half-Life 2/}).getAttribute('href')).toBe('/library/220');
|
||||
expect(within(card).getByRole('link', {name: /Half-Life 2/}).textContent).toContain('5.0h');
|
||||
expect(within(card).getByText('STATS.TOP_GAMES.SHARE')).toBeTruthy();
|
||||
expect(within(card).getByText('STATS.TOP_GAMES.LOYAL')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('topGamesPieWithOneGameDrawsTheList', async () => {
|
||||
answerStats(json(200, stats()), {TOP_GAMES: 'PIE'});
|
||||
renderPage();
|
||||
await screen.findByText('STATS.TOP_GAMES.TITLE');
|
||||
|
||||
expect(slices('STATS.TOP_GAMES.TITLE')).toHaveLength(0);
|
||||
expect(section('STATS.TOP_GAMES.TITLE').querySelectorAll('img').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('topGamesBarsHaveNoCoversAndLinkTheGame', async () => {
|
||||
answerStats(json(200, twoGames()), {TOP_GAMES: 'BARS'});
|
||||
renderPage();
|
||||
await screen.findByText('STATS.TOP_GAMES.TITLE');
|
||||
|
||||
const card = section('STATS.TOP_GAMES.TITLE');
|
||||
const bars = within(card).getAllByRole('link', {name: /Half-Life 2/});
|
||||
expect(bars[0].getAttribute('href')).toBe('/library/220');
|
||||
expect(bars[0].querySelector('img')).toBeNull();
|
||||
expect(bars[0].querySelectorAll('.bg-primary')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('showsTheStoredFormOfTheUser', async () => {
|
||||
answerStats(json(200, stats()), {PLATFORMS: 'DONUT', TREND: 'BARS'});
|
||||
renderPage();
|
||||
|
||||
@@ -4,6 +4,20 @@ const AUTH = () => import.meta.env.VITE_API_URL;
|
||||
|
||||
export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
|
||||
|
||||
export const REFUSAL_KEY = {
|
||||
SELF_ACTION: 'ADMIN.ERROR_SELF_ACTION',
|
||||
TARGET_IS_ADMIN: 'ADMIN.ERROR_TARGET_IS_ADMIN',
|
||||
ACTOR_NOT_ADMIN: 'ADMIN.ERROR_ACTOR_NOT_ADMIN',
|
||||
ROLE_CHANGED: 'ADMIN.ERROR_ROLE_CHANGED',
|
||||
TARGET_IS_BLOCKED: 'ADMIN.ERROR_TARGET_IS_BLOCKED',
|
||||
ALREADY_DELETED: 'ADMIN.ERROR_ALREADY_DELETED',
|
||||
NOT_PENDING: 'ADMIN.ERROR_NOT_PENDING',
|
||||
TARGET_IS_PENDING: 'ADMIN.ERROR_TARGET_IS_PENDING',
|
||||
TARGET_IS_UNCONFIRMED: 'ADMIN.ERROR_TARGET_IS_UNCONFIRMED',
|
||||
NOT_UNCONFIRMED: 'ADMIN.ERROR_NOT_UNCONFIRMED',
|
||||
NOT_ACTIVE: 'ADMIN.ERROR_NOT_ACTIVE',
|
||||
};
|
||||
|
||||
/**
|
||||
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
|
||||
*
|
||||
@@ -34,6 +48,23 @@ export const setUserBlocked = (userId, blocked) =>
|
||||
body: JSON.stringify({blocked}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Sets the role of another user. `expectedRole` is the role the admin saw. 204, or 409 with code SELF_ACTION,
|
||||
* ACTOR_NOT_ADMIN, ROLE_CHANGED, TARGET_IS_BLOCKED, ALREADY_DELETED, TARGET_IS_PENDING or TARGET_IS_UNCONFIRMED.
|
||||
*/
|
||||
export const changeUserRole = (userId, role, expectedRole) =>
|
||||
apiFetch(`${AUTH()}/api/admin/users/${userId}/role`, {
|
||||
method: 'PUT',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({role, expectedRole}),
|
||||
});
|
||||
|
||||
/**
|
||||
* GET the role changes of a user, newest first: {results: [{actorUserId, actorEmail, oldRole, newRole, changedAt}]}.
|
||||
* `actorEmail` is null after the actor is purged.
|
||||
*/
|
||||
export const fetchRoleChanges = (userId, init) => apiFetch(`${AUTH()}/api/admin/users/${userId}/role-changes`, init);
|
||||
|
||||
/**
|
||||
* Hard delete of a request whose address is not confirmed: 204, or 409 NOT_UNCONFIRMED for any other status.
|
||||
*/
|
||||
|
||||
+25
-1
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {refreshSession} from './api.js';
|
||||
import {viewAs} from './admin.js';
|
||||
import {changeUserRole, fetchRoleChanges, viewAs} from './admin.js';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
describe('viewAs', () => {
|
||||
@@ -25,3 +25,27 @@ describe('viewAs', () => {
|
||||
await switching;
|
||||
});
|
||||
});
|
||||
|
||||
describe('role changes', () => {
|
||||
it('changeUserRole puts the new and the expected role', async () => {
|
||||
fetchRoutes({'PUT /api/admin/users/7/role': json(204)});
|
||||
|
||||
await changeUserRole(7, 'AUDITOR', 'USER');
|
||||
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/admin\/users\/7\/role$/);
|
||||
expect(init.method).toBe('PUT');
|
||||
expect(JSON.parse(init.body)).toEqual({role: 'AUDITOR', expectedRole: 'USER'});
|
||||
});
|
||||
|
||||
it('fetchRoleChanges gets the log of the user', async () => {
|
||||
fetchRoutes({'GET /api/admin/users/7/role-changes': json(200, {results: []})});
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
await fetchRoleChanges(7, {signal});
|
||||
|
||||
const [url, init] = fetch.mock.calls[0];
|
||||
expect(url).toMatch(/\/api\/admin\/users\/7\/role-changes$/);
|
||||
expect(init.signal).toBe(signal);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,9 @@ export const STAT_CHARTS = {
|
||||
TASTE: ['BARS', 'PIE', 'DONUT', 'LIST'],
|
||||
FINISHED: ['BARS', 'LINE', 'LIST'],
|
||||
TREND: ['LINE', 'BARS'],
|
||||
TOP_GAMES: ['LIST', 'BARS', 'PIE', 'DONUT'],
|
||||
ACTIVITY: ['BARS', 'LINE'],
|
||||
ACTIVITY_GAMES: ['LIST', 'BARS', 'PIE', 'DONUT'],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -21,6 +24,24 @@ export const chartOf = (charts, group) => {
|
||||
return forms.includes(stored) ? stored : forms[0];
|
||||
};
|
||||
|
||||
/**
|
||||
* The `chart` and `onChart` props of a stat view.
|
||||
*
|
||||
* @param onChart `(group, form)`, or null: the view shows no picker
|
||||
*/
|
||||
export const chartProps = (charts, onChart, group) => ({
|
||||
chart: chartOf(charts, group),
|
||||
onChart: onChart ? (form) => onChart(group, form) : undefined,
|
||||
});
|
||||
|
||||
/**
|
||||
* The form to draw for play time per game: a pie or donut of fewer than two games with play time is a list.
|
||||
*
|
||||
* @param games `[{minutes}]`
|
||||
*/
|
||||
export const playChart = (chart, games) =>
|
||||
(chart === 'PIE' || chart === 'DONUT') && games.filter((g) => g.minutes > 0).length < 2 ? 'LIST' : chart;
|
||||
|
||||
/**
|
||||
* The slices of a pie: the items with a value, biggest first. More than `keep + 1` items: the `keep` biggest and
|
||||
* one "other" slice with the rest.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
import {chartOf, linePoints, sliceColors, slicePaths, statusSlices, topSlices, trendBars, yearSeries} from './statCharts.js';
|
||||
import {chartOf, chartProps, linePoints, playChart, sliceColors, slicePaths, statusSlices, topSlices, trendBars, yearSeries} from './statCharts.js';
|
||||
import {GAME_STATUSES} from './limits.js';
|
||||
|
||||
describe('chartOf', () => {
|
||||
@@ -19,6 +19,48 @@ describe('chartOf', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('chartOf for the play time groups', () => {
|
||||
it('falls back to the first form', () => {
|
||||
expect(chartOf(null, 'TOP_GAMES')).toBe('LIST');
|
||||
expect(chartOf({TOP_GAMES: 'RADAR'}, 'TOP_GAMES')).toBe('LIST');
|
||||
expect(chartOf({}, 'ACTIVITY')).toBe('BARS');
|
||||
expect(chartOf({ACTIVITY: 'LINE'}, 'ACTIVITY')).toBe('LINE');
|
||||
expect(chartOf({ACTIVITY_GAMES: 'PIE'}, 'ACTIVITY_GAMES')).toBe('PIE');
|
||||
expect(chartOf({ACTIVITY_GAMES: 'LINE'}, 'ACTIVITY_GAMES')).toBe('LIST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('chartProps', () => {
|
||||
it('has no onChart without a handler', () => {
|
||||
expect(chartProps({TASTE: 'PIE'}, null, 'TASTE')).toEqual({chart: 'PIE', onChart: undefined});
|
||||
});
|
||||
|
||||
it('calls the handler with the group and the form', () => {
|
||||
const calls = [];
|
||||
const props = chartProps(null, (group, form) => calls.push([group, form]), 'TREND');
|
||||
|
||||
props.onChart('BARS');
|
||||
|
||||
expect(props.chart).toBe('LINE');
|
||||
expect(calls).toEqual([['TREND', 'BARS']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('playChart', () => {
|
||||
const game = (minutes) => ({appid: 1, name: 'G', minutes});
|
||||
|
||||
it('draws the list when a pie has fewer than two games with play time', () => {
|
||||
expect(playChart('PIE', [game(10)])).toBe('LIST');
|
||||
expect(playChart('DONUT', [game(0), game(10)])).toBe('LIST');
|
||||
});
|
||||
|
||||
it('keeps the form otherwise', () => {
|
||||
expect(playChart('PIE', [game(5), game(10)])).toBe('PIE');
|
||||
expect(playChart('BARS', [])).toBe('BARS');
|
||||
expect(playChart('LIST', [game(10)])).toBe('LIST');
|
||||
});
|
||||
});
|
||||
|
||||
describe('topSlices', () => {
|
||||
const item = (name, games) => ({name, games});
|
||||
const games = (i) => i.games;
|
||||
|
||||
@@ -28,7 +28,7 @@ export const markSyncChangesSeen = (syncedAt) =>
|
||||
/**
|
||||
* What the dashboard shows of a result: null (nothing), `{mode: 'nothing'}` for a manual sync that changed nothing, or
|
||||
* `{mode: 'changes', imported, added, removed, minutes, achievements, moves}`. A result the user saw, and an empty
|
||||
* result of an automatic sync, show nothing.
|
||||
* result of an automatic or a visit sync, show nothing.
|
||||
*/
|
||||
export function bannerOf(result) {
|
||||
if (!result || result.seen) return null;
|
||||
|
||||
@@ -16,6 +16,10 @@ describe('bannerOf', () => {
|
||||
expect(bannerOf(result())).toBeNull();
|
||||
});
|
||||
|
||||
it('showsNothingForAnEmptyLoginSync', () => {
|
||||
expect(bannerOf(result({startedBy: 'LOGIN'}))).toBeNull();
|
||||
});
|
||||
|
||||
it('saysNothingChangedForAnEmptyManualSync', () => {
|
||||
expect(bannerOf(result({startedBy: 'MANUAL'}))).toEqual({mode: 'nothing'});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user