Public Access
feat(ui): add people lists for game owners and community stats
This commit is contained in:
1 parent
b213b067dc
commit
3aed243e35
22 files changed
+425
-12
No files matched your search
@@ -17,6 +17,7 @@ const game = {
|
||||
const renderDetail = (status) => {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
|
||||
'GET /api/rewards/wallet': json(200, {balance: 0, catalog: [], owned: [], equipped: {COMPLETED_EFFECT: 'FX_GOLD_SHINE'}, history: []}),
|
||||
'GET /api/library/games/220': json(200, {...game, status}),
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import CompletedHint from '../components/CompletedHint.jsx';
|
||||
import FinishedHint from '../components/FinishedHint.jsx';
|
||||
import GameCorrectionDialog from '../components/GameCorrectionDialog.jsx';
|
||||
import EditionsCard from '../components/EditionsCard.jsx';
|
||||
import OwnersCard from '../components/people/OwnersCard.jsx';
|
||||
import GameMergeDialog from '../components/GameMergeDialog.jsx';
|
||||
import LinkGameDialog from '../components/LinkGameDialog.jsx';
|
||||
import PlayingHint from '../components/PlayingHint.jsx';
|
||||
@@ -100,6 +101,8 @@ export default function GameDetail() {
|
||||
|
||||
<EditionsCard game={data} auditor={auditor} onRequest={() => setLinking(true)} onChanged={reload}/>
|
||||
|
||||
<OwnersCard key={`owners-${data.appid}`} appid={data.appid}/>
|
||||
|
||||
{manual && (
|
||||
<ManualGameCard key={`manual-${data.appid}`} game={data}
|
||||
onSaved={(changes) => setData((d) => ({...d, ...changes}))}/>
|
||||
|
||||
@@ -22,6 +22,7 @@ beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {role: 'AUDITOR', activeRole: 'AUDITOR'}),
|
||||
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
|
||||
'GET /api/library/games/220': json(200, game),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ const echo = (url, init) => json(200, JSON.parse(init.body));
|
||||
// The user and the game at /library/<shown.appid>; `routes` adds or replaces answers.
|
||||
const answer = (shown, routes = {}, me = {settings: {}}) => fetchRoutes({
|
||||
'GET /api/users/me': json(200, me),
|
||||
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
|
||||
[`GET /api/library/games/${shown.appid}`]: json(200, shown),
|
||||
...routes,
|
||||
});
|
||||
@@ -38,6 +39,27 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('GameDetail', () => {
|
||||
it('showsHowManyOthersOwnTheGameAndOnlyNamesTheListedOnes', async () => {
|
||||
answer(game, {'GET /api/stats/owners': json(200, {listed: [{displayName: 'Lobo'}], listedTotal: 1, privateCount: 1,
|
||||
includesYou: true, page: 0, totalPages: 1})});
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText('PEOPLE.OTHER_OWNERS:2')).toBeTruthy();
|
||||
expect(screen.getByRole('link', {name: 'Lobo'}).getAttribute('href')).toBe('/u/Lobo');
|
||||
expect(screen.getByText('PEOPLE.PRIVATE:1')).toBeTruthy();
|
||||
expect(screen.queryByText('PEOPLE.YOU')).toBeNull();
|
||||
expect(fetch.mock.calls.find(([url]) => url.includes('/api/stats/owners'))[0]).toContain('appid=220&page=0');
|
||||
});
|
||||
|
||||
it('showsNoOwnersTitleWhenNobodyElseOwnsTheGame', async () => {
|
||||
answer(game, {'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0,
|
||||
includesYou: true, page: 0, totalPages: 0})});
|
||||
renderDetail();
|
||||
|
||||
expect(await screen.findByText('PEOPLE.NONE')).toBeTruthy();
|
||||
expect(screen.queryByText(/PEOPLE.OTHER_OWNERS/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the coming badge of a game that is not released yet', async () => {
|
||||
answer({...game, source: 'STEAM', coming: true, comingOn: '2026-12-04'});
|
||||
renderDetail();
|
||||
@@ -447,6 +469,7 @@ describe('GameDetail session', () => {
|
||||
it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: false, page: 0, totalPages: 1}),
|
||||
'GET /api/library/games/-7': json(200, {...game, appid: 220, source: 'STEAM'}),
|
||||
'GET /api/library/games/220': json(200, {...game, appid: 220, source: 'STEAM'}),
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {
|
||||
@@ -12,10 +13,12 @@ import GameCover from '../components/GameCover.jsx';
|
||||
import FitText from '../components/FitText.jsx';
|
||||
import Logo from '../components/Logo.jsx';
|
||||
import StatCard from '../components/StatCard.jsx';
|
||||
import PeopleDialog from '../components/people/PeopleDialog.jsx';
|
||||
import {YearBars} from '../components/stats/FinishedStats.jsx';
|
||||
import {TagBars} from '../components/stats/TasteStats.jsx';
|
||||
import {StatDrillProvider, useStatDrill, useTagDrill} from '../components/stats/StatDrill.jsx';
|
||||
import useSession from '../hooks/useSession.js';
|
||||
import {fetchUsers} from '../utils/people.js';
|
||||
import {genreKey} from '../utils/stats.js';
|
||||
|
||||
import usePublicStats from '../hooks/usePublicStats.js';
|
||||
@@ -97,13 +100,14 @@ export default function LandingPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {data, isLoading} = usePublicStats();
|
||||
const {active} = useSession();
|
||||
const [people, setPeople] = useState(null);
|
||||
|
||||
const auth = data.auth;
|
||||
const core = data.core;
|
||||
|
||||
const stats = [
|
||||
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users],
|
||||
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2],
|
||||
[t('LANDING.TOTAL_USERS'), auth?.totalUsers, Users, undefined, undefined, 'TOTAL_USERS'],
|
||||
[t('LANDING.STEAM_LINKED'), auth?.usersWithSteamLinked, Link2, undefined, undefined, 'STEAM_LINKED'],
|
||||
[t('LANDING.TOTAL_GAMES'), core?.totalLibraryEntries, Gamepad2, undefined, 'ENTRIES'],
|
||||
[t('LANDING.GAMES_PLAYED'), core?.totalGamesPlayed, Sparkles, undefined, 'PLAYED'],
|
||||
[t('LANDING.DISTINCT_GAMES'), core?.totalDistinctGames, Blocks, undefined, 'OWNED'],
|
||||
@@ -139,9 +143,11 @@ export default function LandingPage() {
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
{isLoading
|
||||
? stats.map(([label]) => <Skeleton key={label} className="h-36 rounded-xl"/>)
|
||||
: stats.map(([label, value, icon, suffix, key]) => (
|
||||
: stats.map(([label, value, icon, suffix, key, usersKey]) => (
|
||||
<StatCard key={label} label={label} value={value ?? 0} icon={icon} suffix={suffix}
|
||||
drill={key && {params: {key}}}/>
|
||||
drill={key && {params: {key}}}
|
||||
onClick={active && usersKey && value > 0
|
||||
? () => setPeople({title: label, key: usersKey}) : undefined}/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -167,6 +173,10 @@ export default function LandingPage() {
|
||||
<CommunityBars core={core}/>
|
||||
</section>
|
||||
</div>
|
||||
{people && (
|
||||
<PeopleDialog title={people.title} onClose={() => setPeople(null)}
|
||||
load={(page) => fetchUsers({key: people.key, page})}/>
|
||||
)}
|
||||
</StatDrillProvider>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import LandingPage from './LandingPage.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('../hooks/useSession.js', () => ({default: () => ({active: false})}));
|
||||
const session = vi.hoisted(() => ({active: false}));
|
||||
vi.mock('../hooks/useSession.js', () => ({default: () => session}));
|
||||
|
||||
// Auth and Core answer the same path; one object serves both shapes.
|
||||
const answer = (core) => fetchRoutes({'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], ...core})});
|
||||
@@ -14,7 +15,10 @@ const answer = (core) => fetchRoutes({'GET /api/stats/public': json(200, {topPla
|
||||
const renderPage = () => render(<MemoryRouter><LandingPage/></MemoryRouter>);
|
||||
|
||||
describe('LandingPage', () => {
|
||||
afterEach(cleanup);
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
session.active = false;
|
||||
});
|
||||
|
||||
it('shows games per platform, top genres and finished per year', async () => {
|
||||
answer({
|
||||
@@ -76,4 +80,24 @@ describe('LandingPage', () => {
|
||||
|
||||
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_GAMES/})).toBeNull();
|
||||
});
|
||||
|
||||
it('opensTheUserListOnlyWithASession', async () => {
|
||||
fetchRoutes({
|
||||
'GET /api/stats/public': json(200, {topPlayedGames: [], topOwnedGames: [], totalUsers: 4, usersWithSteamLinked: 2}),
|
||||
'GET /api/stats/users': json(200, {listed: [{displayName: 'Lobo'}], listedTotal: 1, privateCount: 3,
|
||||
includesYou: false, page: 0, totalPages: 1}),
|
||||
});
|
||||
const {unmount} = renderPage();
|
||||
await screen.findByText('LANDING.TOTAL_USERS');
|
||||
expect(screen.queryByRole('button', {name: /LANDING\.TOTAL_USERS/})).toBeNull();
|
||||
unmount();
|
||||
|
||||
session.active = true;
|
||||
renderPage();
|
||||
fireEvent.click(await screen.findByRole('button', {name: /LANDING\.TOTAL_USERS/}));
|
||||
|
||||
expect(await screen.findByRole('link', {name: 'Lobo'})).toBeTruthy();
|
||||
expect(screen.getByText('PEOPLE.PRIVATE:3')).toBeTruthy();
|
||||
expect(fetch.mock.calls.at(-1)[0]).toContain('/api/stats/users?key=TOTAL_USERS&page=0');
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ const authRoutes = (me) => ({
|
||||
'PUT /api/users/settings/appearance': noBody,
|
||||
'PUT /api/users/settings/profile-style': noBody,
|
||||
'PUT /api/users/settings/favorite-platform': noBody,
|
||||
'PUT /api/users/settings/owner-lists': noBody,
|
||||
'GET /api/version': json(404, {}),
|
||||
});
|
||||
|
||||
@@ -65,6 +66,27 @@ describe('SettingsPage public profile', () => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('disablesTheOwnerListSwitchWhileTheProfileIsPrivate', async () => {
|
||||
renderPage(profile({settings: {useDarkTheme: true, language: 'de', publicProfile: false, showInOwnerLists: true}}));
|
||||
|
||||
const toggle = await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'});
|
||||
|
||||
expect(toggle.disabled).toBe(true);
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false');
|
||||
});
|
||||
|
||||
it('savesTheOwnerListSwitchThroughItsOwnEndpoint', async () => {
|
||||
renderPage(profile());
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}));
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('switch', {name: 'SETTINGS.OWNER_LISTS'}).getAttribute('aria-checked')).toBe('true'));
|
||||
const call = fetch.mock.calls.find(([url]) => url.endsWith('/settings/owner-lists'));
|
||||
expect(call[1].method).toBe('PUT');
|
||||
expect(JSON.parse(call[1].body)).toEqual({enabled: true});
|
||||
expect(puts().filter((body) => 'useDarkTheme' in body)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('keeps the public profile when another setting is saved', async () => {
|
||||
renderPage(profile());
|
||||
|
||||
|
||||
Reference in new issue
Block a user