Public Access
Feed links go to the game and to the profile view of that game. Add /following page, nav item and an opt-in Following tile. Games not in your library show a catalog view with an add button.
This commit is contained in:
1 parent
720764bf68
commit
5be0d42bca
31 files changed
+692
-67
No files matched your search
@@ -9,6 +9,7 @@ import {Skeleton} from '@/components/ui/skeleton';
|
||||
import AchievementsCard from '../components/AchievementsCard.jsx';
|
||||
import ActivityCard from '../components/ActivityCard.jsx';
|
||||
import CurrentlyPlayingCard from '../components/CurrentlyPlayingCard.jsx';
|
||||
import FollowingCard from '../components/FollowingCard.jsx';
|
||||
import DashboardHint from '../components/DashboardHint.jsx';
|
||||
import DashboardWelcomeCard from '../components/DashboardWelcomeCard.jsx';
|
||||
import MostPlayedCard from '../components/MostPlayedCard.jsx';
|
||||
@@ -30,7 +31,7 @@ import TileGrid from '../components/TileGrid.jsx';
|
||||
import {StatDrillProvider} from '../components/stats/StatDrill.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
import {
|
||||
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, UP_NEXT_GAMES,
|
||||
DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, FOLLOWING_SHOWN, normalizeLayout, PLAYING_GAMES, saveTiles, statGroup, TOP_GAMES, UP_NEXT_GAMES,
|
||||
} from '../utils/tiles.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
@@ -134,6 +135,7 @@ const Dashboard = () => {
|
||||
);
|
||||
case 'ACTIVITY': return canSync && activity && <ActivityCard activity={activity} charts={charts} onChart={change}/>;
|
||||
case 'ACHIEVEMENTS': return canSync && achievements && <AchievementsCard summary={achievements}/>;
|
||||
case 'FOLLOWING': return <FollowingCard limit={FOLLOWING_SHOWN[size]}/>;
|
||||
default: return statGroupTile(id, stats.stats, true, charts, change);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -72,6 +72,22 @@ describe('Dashboard', () => {
|
||||
return renderDashboard().container;
|
||||
};
|
||||
|
||||
it('showsTheFollowingTileFromTheSavedLayoutAndHidesItByDefault', async () => {
|
||||
const routes = dashboardRoutes({dashboardTiles: [{id: 'FOLLOWING', size: 'M'}]});
|
||||
fetchRoutes({...routes, 'GET /api/follows': json(200, {followers: [],
|
||||
following: [{id: 1, displayName: 'Anna', lastActivityAt: null}]})});
|
||||
renderDashboard();
|
||||
|
||||
expect(await screen.findByRole('link', {name: 'Anna'})).toBeTruthy();
|
||||
cleanup();
|
||||
|
||||
fetchRoutes(dashboardRoutes());
|
||||
renderDashboard();
|
||||
await screen.findByText('DASHBOARD.CURRENTLY_PLAYING');
|
||||
|
||||
expect(fetch.mock.calls.some(([url]) => String(url).includes('/api/follows'))).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the Steam avatar and the most played game of the last two weeks as banner', async () => {
|
||||
const container = mockDashboard();
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Loader2, UserCheck} from 'lucide-react';
|
||||
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import FollowingList from '../components/people/FollowingList.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useFollowing from '../hooks/useFollowing.js';
|
||||
|
||||
/**
|
||||
* The people the user follows, newest activity first. The followers stay in the settings.
|
||||
*/
|
||||
export default function FollowingPage() {
|
||||
const {t} = useTranslation();
|
||||
const {following, failed, unfollow} = useFollowing();
|
||||
|
||||
return (
|
||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2"><UserCheck className="size-4 text-primary"/>{t('FOLLOW.TITLE')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{failed && <p className="text-sm text-destructive">{t('FOLLOW.LOAD_ERROR')}</p>}
|
||||
{!failed && !following && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||
{following && <FollowingList entries={following} onUnfollow={unfollow}/>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import FollowingPage from './FollowingPage.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('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const person = (id, displayName, lastActivityAt) => ({id, displayName, lastActivityAt});
|
||||
|
||||
describe('FollowingPage', () => {
|
||||
it('listsThePeopleByLastActivityNewestFirstAndThoseWithoutActivityLast', async () => {
|
||||
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: [
|
||||
person(1, 'Anna', null), person(2, 'Bert', '2026-10-01T10:00:00Z'), person(3, 'Cleo', '2026-10-09T10:00:00Z')]})});
|
||||
renderWithProviders(<FollowingPage/>);
|
||||
|
||||
await screen.findByRole('link', {name: 'Cleo'});
|
||||
|
||||
expect(screen.getAllByRole('link').map((link) => link.textContent)).toEqual(['Cleo', 'Bert', 'Anna']);
|
||||
expect(screen.getByRole('link', {name: 'Cleo'}).getAttribute('href')).toBe('/u/Cleo');
|
||||
expect(screen.getAllByText('FOLLOW.LAST_ACTIVITY')).toHaveLength(2);
|
||||
expect(screen.getByText('FOLLOW.NO_ACTIVITY')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('unfollowsByTheFollowRowAndLoadsTheListAgain', async () => {
|
||||
let list = [person(5, 'Anna', null), person(6, 'Bert', null)];
|
||||
const mock = fetchRoutes({
|
||||
'GET /api/follows': () => json(200, {followers: [], following: list}),
|
||||
'DELETE /api/follows/5': () => {
|
||||
list = [list[1]];
|
||||
return json(204);
|
||||
},
|
||||
});
|
||||
renderWithProviders(<FollowingPage/>);
|
||||
|
||||
fireEvent.click((await screen.findAllByRole('button', {name: 'FOLLOW.UNFOLLOW'}))[0]);
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('link', {name: 'Anna'})).toBeNull());
|
||||
expect(screen.getByRole('link', {name: 'Bert'})).toBeTruthy();
|
||||
expect(mock.mock.calls.filter(([, init]) => init?.method === 'DELETE')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('saysHowToFollowWhenThereIsNobody', async () => {
|
||||
fetchRoutes({'GET /api/follows': json(200, {followers: [], following: []})});
|
||||
renderWithProviders(<FollowingPage/>);
|
||||
|
||||
expect(await screen.findByText('FOLLOW.NO_FOLLOWING')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsAnErrorWhenTheListFails', async () => {
|
||||
fetchRoutes({'GET /api/follows': json(500, {})});
|
||||
renderWithProviders(<FollowingPage/>);
|
||||
|
||||
expect(await screen.findByText('FOLLOW.LOAD_ERROR')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {Button} from '@/components/ui/button';
|
||||
import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import BacklogCard from '../components/BacklogCard.jsx';
|
||||
import CatalogGameCard from '../components/CatalogGameCard.jsx';
|
||||
import Confetti from '../components/Confetti.jsx';
|
||||
import GameAchievements from '../components/GameAchievements.jsx';
|
||||
import GameHeroCard from '../components/GameHeroCard.jsx';
|
||||
@@ -57,6 +58,12 @@ export default function GameDetail() {
|
||||
}, [data, appid, navigate]);
|
||||
|
||||
const manual = !!data && data.source !== 'STEAM';
|
||||
const errorBlock = error && (
|
||||
<div className="py-12 text-center text-destructive space-y-4">
|
||||
<p>{t(error)}</p>
|
||||
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
@@ -71,12 +78,9 @@ export default function GameDetail() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !isLoading && (
|
||||
<div className="py-12 text-center text-destructive space-y-4">
|
||||
<p>{t(error)}</p>
|
||||
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Not in the library: the catalog view, which falls back to the same message. */}
|
||||
{error && !isLoading && (error === 'GAME_DETAIL.NOT_FOUND'
|
||||
? <CatalogGameCard appid={appid} fallback={errorBlock} onAdded={reload}/> : errorBlock)}
|
||||
|
||||
{!error && data && (
|
||||
<>
|
||||
|
||||
@@ -514,6 +514,64 @@ describe('GameDetail session', () => {
|
||||
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
|
||||
});
|
||||
|
||||
describe('a game that is not in the library', () => {
|
||||
const preview = {appid: 501, name: 'Hades', imageUrl: null, genres: ['Roguelike'],
|
||||
timeToBeat: {hastilyMinutes: 600, normallyMinutes: 1200, completelyMinutes: null}};
|
||||
const notOwned = (previewAnswer) => fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
'GET /api/library/games/501': json(404, {}),
|
||||
'GET /api/library/games/501/preview': previewAnswer,
|
||||
'GET /api/library/game-search': json(200, {results: []}),
|
||||
});
|
||||
|
||||
it('showsTheCatalogDataAndOffersToAddTheGame', async () => {
|
||||
notOwned(json(200, preview));
|
||||
renderDetail(501);
|
||||
|
||||
expect(await screen.findByText('Hades')).toBeTruthy();
|
||||
expect(screen.getByText('Roguelike')).toBeTruthy();
|
||||
expect(screen.getByText('CATALOG.NOT_IN_LIBRARY')).toBeTruthy();
|
||||
expect(screen.queryByText('GAME_DETAIL.NOT_FOUND')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', {name: 'CATALOG.ADD'}));
|
||||
|
||||
expect(screen.getByRole('searchbox').value).toBe('Hades');
|
||||
});
|
||||
|
||||
it('showsMyGamePageAfterAddingTheGameUnderTheSameKey', async () => {
|
||||
let owned = false;
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {favoritePlatform: 'PS5'}}),
|
||||
'GET /api/library/games/501': () => (owned ? json(200, {...game, appid: 501, name: 'Hades'}) : json(404, {})),
|
||||
'GET /api/library/games/501/preview': json(200, preview),
|
||||
'GET /api/library/game-search': json(200, {available: true, results: [
|
||||
{appid: 501, igdbId: 7, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PS5'], inLibrary: false}]}),
|
||||
'POST /api/library/games': () => {
|
||||
owned = true;
|
||||
return json(201, {appid: 501});
|
||||
},
|
||||
'GET /api/stats/owners': json(200, {listed: [], listedTotal: 0, privateCount: 0, includesYou: true, page: 0, totalPages: 0}),
|
||||
// The points check gives up on a failed answer; the sonner mock of this file has no plain toast().
|
||||
'GET /api/rewards/games/501': json(500, {}),
|
||||
});
|
||||
renderDetail(501);
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'CATALOG.ADD'}));
|
||||
fireEvent.click(await screen.findByRole('button', {name: /Hades/}));
|
||||
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.ADD'}));
|
||||
|
||||
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'POST')).toBe(true));
|
||||
await waitFor(() => expect(screen.queryByText('CATALOG.NOT_IN_LIBRARY')).toBeNull());
|
||||
});
|
||||
|
||||
it('showsTheNotFoundMessageWhenTheCatalogKnowsNoSuchGame', async () => {
|
||||
notOwned(json(404, {}));
|
||||
renderDetail(501);
|
||||
|
||||
expect(await screen.findByText('GAME_DETAIL.NOT_FOUND')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', {name: 'CATALOG.ADD'})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('replacesTheUrlOfAMergedKeyWithTheKeptGame', async () => {
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {}}),
|
||||
|
||||
+41
-19
@@ -7,6 +7,7 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {cn} from '@/lib/utils';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {formatDate} from '../utils/format.js';
|
||||
import {profileGameHref, profileHref} from '../utils/people.js';
|
||||
import {digestParts, fetchInbox, fetchInboxDetails, groupDetails, markInboxRead} from '../utils/inbox.js';
|
||||
|
||||
const PSN_KINDS = ['PSN_EXPIRING', 'PSN_EXPIRED'];
|
||||
@@ -43,8 +44,25 @@ export default function InboxPage() {
|
||||
if (PSN_KINDS.includes(note.kind)) return t(`INBOX.${note.kind}`);
|
||||
const name = note.displayName ?? t('FOLLOW.PRIVATE_USER');
|
||||
if (note.kind === 'VISIBILITY') return t('INBOX.VISIBILITY', {name});
|
||||
const parts = digestParts(note).map(({key, count}) => t(`INBOX.PART_${key}`, {count})).join(', ');
|
||||
return t('INBOX.DIGEST', {name, parts});
|
||||
return t('INBOX.DIGEST', {name, parts: partsText(note)});
|
||||
};
|
||||
|
||||
const partsText = (note) => digestParts(note).map(({key, count}) => t(`INBOX.PART_${key}`, {count})).join(', ');
|
||||
|
||||
// A link in a button is invalid, so the profile link sits next to the toggle button.
|
||||
const noteLine = (note) => {
|
||||
const linked = note.kind === 'DIGEST' && note.profileName;
|
||||
const label = linked ? t('INBOX.DIGEST_PARTS', {parts: partsText(note)}) : text(note);
|
||||
const line = note.kind === 'DIGEST' && note.details ? (
|
||||
<button type="button" aria-expanded={open === note.id}
|
||||
className="inline-flex cursor-pointer items-center gap-1 break-words text-left hover:underline"
|
||||
onClick={() => toggle(note.id)}>
|
||||
{label}
|
||||
<ChevronDown className={cn('size-4 shrink-0 transition-transform', open === note.id && 'rotate-180')}/>
|
||||
</button>
|
||||
) : label;
|
||||
if (!linked) return line;
|
||||
return <><Link to={profileHref(note.profileName)} className="underline">{note.displayName}</Link>: {line}</>;
|
||||
};
|
||||
|
||||
const toggle = async (id) => {
|
||||
@@ -59,20 +77,31 @@ export default function InboxPage() {
|
||||
setDetails((current) => ({...current, [id]: ok || status === 404 ? {status: 'ready', results: data?.results ?? []} : {status: 'error', results: []}}));
|
||||
};
|
||||
|
||||
const gameLines = (group, {gameName, items}, noteId) => {
|
||||
const gameLabel = (note, {gameName, appid}) => (
|
||||
<>
|
||||
{appid == null ? gameName : <Link to={`/library/${appid}`} className="hover:underline">{gameName}</Link>}
|
||||
{appid != null && note.profileName && (
|
||||
<Link to={profileGameHref(note.profileName, appid)}
|
||||
className="ml-2 text-xs text-muted-foreground underline">{t('INBOX.ON_PROFILE')}</Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const gameLines = (group, gameGroup, note) => {
|
||||
const {gameName, items} = gameGroup;
|
||||
if (group.kind === 'STATUS') {
|
||||
return items.map((item, i) => (
|
||||
<li key={`${gameName}-${i}`}>
|
||||
{gameName}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)}
|
||||
{gameLabel(note, gameGroup)}: {t(`BACKLOG.STATUS.${item.fromStatus}`)} → {t(`BACKLOG.STATUS.${item.toStatus}`)}
|
||||
</li>
|
||||
));
|
||||
}
|
||||
if (group.kind !== 'ACHIEVEMENT') return <li key={gameName}>{gameName}</li>;
|
||||
const key = `${noteId}:${gameName}`;
|
||||
if (group.kind !== 'ACHIEVEMENT') return <li key={gameName}>{gameLabel(note, gameGroup)}</li>;
|
||||
const key = `${note.id}:${gameName}`;
|
||||
const shown = showAll[key] ? items : items.slice(0, ACHIEVEMENTS_SHOWN);
|
||||
return (
|
||||
<li key={gameName}>
|
||||
{gameName}
|
||||
{gameLabel(note, gameGroup)}
|
||||
<ul className="ml-4 list-disc">
|
||||
{shown.map((item, i) => <li key={i}>{item.itemName}</li>)}
|
||||
</ul>
|
||||
@@ -86,8 +115,8 @@ export default function InboxPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const detailBlock = (noteId) => {
|
||||
const entry = details[noteId];
|
||||
const detailBlock = (note) => {
|
||||
const entry = details[note.id];
|
||||
if (!entry || entry.status === 'loading') return <Loader2 className="size-4 animate-spin text-muted-foreground"/>;
|
||||
if (entry.status === 'error') return <p className="text-xs text-destructive">{t('INBOX.DETAILS_ERROR')}</p>;
|
||||
const groups = groupDetails(entry.results);
|
||||
@@ -95,7 +124,7 @@ export default function InboxPage() {
|
||||
return groups.map((group) => (
|
||||
<div key={group.kind} className="space-y-1">
|
||||
<h3 className="text-xs font-semibold uppercase text-muted-foreground">{t(`INBOX.DETAIL_${group.kind}`)}</h3>
|
||||
<ul className="space-y-0.5 text-sm">{group.games.map((gameGroup) => gameLines(group, gameGroup, noteId))}</ul>
|
||||
<ul className="space-y-0.5 text-sm">{group.games.map((gameGroup) => gameLines(group, gameGroup, note))}</ul>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
@@ -118,19 +147,12 @@ export default function InboxPage() {
|
||||
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
|
||||
</p>
|
||||
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
|
||||
{note.kind === 'DIGEST' && note.details ? (
|
||||
<button type="button" aria-expanded={open === note.id}
|
||||
className="inline-flex cursor-pointer items-center gap-1 break-words text-left hover:underline"
|
||||
onClick={() => toggle(note.id)}>
|
||||
{text(note)}
|
||||
<ChevronDown className={cn('size-4 shrink-0 transition-transform', open === note.id && 'rotate-180')}/>
|
||||
</button>
|
||||
) : text(note)}
|
||||
{noteLine(note)}
|
||||
{PSN_KINDS.includes(note.kind) && (
|
||||
<> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></>
|
||||
)}
|
||||
</p>
|
||||
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note.id)}</div>}
|
||||
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note)}</div>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -68,6 +68,60 @@ describe('InboxPage', () => {
|
||||
expect(putCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
describe('links', () => {
|
||||
it('linksTheNameToTheProfileOnlyWhileTheProfileIsPublic', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [
|
||||
note(1, {newGames: 1, profileName: 'Subject', displayName: 'Subject'}),
|
||||
note(2, {newGames: 2, displayName: 'Gone', profileName: null})], unread: 0})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
expect((await screen.findByRole('link', {name: 'Subject'})).getAttribute('href')).toBe('/u/Subject');
|
||||
expect(screen.queryByRole('link', {name: 'Gone'})).toBeNull();
|
||||
expect(screen.getByText('INBOX.DIGEST')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keepsTheNameLinkOutsideTheButtonThatOpensTheDetails', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [
|
||||
note(1, {newGames: 1, details: true, profileName: 'Subject', displayName: 'Subject'})], unread: 0})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
const link = await screen.findByRole('link', {name: 'Subject'});
|
||||
|
||||
expect(link.closest('button')).toBeNull();
|
||||
expect(screen.getByRole('button', {name: 'INBOX.DIGEST_PARTS'})).toBeTruthy();
|
||||
});
|
||||
|
||||
const open = async (rows, extra) => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true, ...extra})], unread: 0}),
|
||||
'GET /api/inbox/1/details': json(200, {results: rows})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
fireEvent.click(await screen.findByRole('button'));
|
||||
};
|
||||
const game = (extra) => ({kind: 'NEW_GAME', gameName: 'Alpha', itemName: null, fromStatus: null, toStatus: null, ...extra});
|
||||
|
||||
it('linksTheGameToMyGamePageAndToTheProfileViewOfThatGame', async () => {
|
||||
await open([game({appid: 501})], {profileName: 'Subject'});
|
||||
|
||||
expect((await screen.findByRole('link', {name: 'Alpha'})).getAttribute('href')).toBe('/library/501');
|
||||
expect(screen.getByRole('link', {name: 'INBOX.ON_PROFILE'}).getAttribute('href')).toBe('/u/Subject?game=501');
|
||||
});
|
||||
|
||||
it('givesNoProfileLinkWithoutAPublicProfile', async () => {
|
||||
await open([game({appid: 501})], {profileName: null});
|
||||
|
||||
expect(await screen.findByRole('link', {name: 'Alpha'})).toBeTruthy();
|
||||
expect(screen.queryByRole('link', {name: 'INBOX.ON_PROFILE'})).toBeNull();
|
||||
});
|
||||
|
||||
it('leavesARowFromBeforeTheAppidAsPlainText', async () => {
|
||||
await open([game({})], {profileName: 'Subject'});
|
||||
|
||||
expect(await screen.findByText('Alpha')).toBeTruthy();
|
||||
expect(screen.queryByRole('link', {name: 'Alpha'})).toBeNull();
|
||||
expect(screen.queryByRole('link', {name: 'INBOX.ON_PROFILE'})).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('details', () => {
|
||||
const row = (kind, gameName, extra) => ({kind, gameName, itemName: null, fromStatus: null, toStatus: null, ...extra});
|
||||
const detailCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/details'));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {Link, useParams} from 'react-router-dom';
|
||||
import {Link, useParams, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {CircleCheck, Clock, Gamepad2, LayoutGrid, Library, Percent, Star, Trophy, UserRoundX} from 'lucide-react';
|
||||
|
||||
@@ -12,6 +12,7 @@ import FitText from '../components/FitText.jsx';
|
||||
import FollowButton from '../components/people/FollowButton.jsx';
|
||||
import ComingBadge from '../components/ComingBadge.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import ProfileGameCard from '../components/ProfileGameCard.jsx';
|
||||
import {formatDay} from '../utils/format.js';
|
||||
import {backgroundEffect, bannerEffect, coverEffect, equippedAccent, equippedSkin, lookClass} from '../utils/rewards.js';
|
||||
import PointsBadge from '../components/PointsBadge.jsx';
|
||||
@@ -41,6 +42,9 @@ const FINISHED_GAMES = {S: 6, M: 12};
|
||||
export default function PublicProfilePage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const {name} = useParams();
|
||||
const [params] = useSearchParams();
|
||||
// Only a game key goes into the request address.
|
||||
const game = /^-?\d+$/.test(params.get('game') ?? '') ? params.get('game') : null;
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [library, setLibrary] = useState(null);
|
||||
const [equipped, setEquipped] = useState({});
|
||||
@@ -190,6 +194,7 @@ export default function PublicProfilePage() {
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
{game && <ProfileGameCard ticket={profile.ticket} appid={game}/>}
|
||||
<StatDrillProvider source="profile" ticket={profile.ticket} linked={owner}>
|
||||
<TileGrid tiles={PROFILE_TILES} titles={PROFILE_TITLES} layout={layout} renderTile={renderTile}
|
||||
editing={owner && editing} defaultLayout={DEFAULT_PROFILE} onSave={saveLayout}
|
||||
|
||||
@@ -103,6 +103,39 @@ describe('PublicProfilePage', () => {
|
||||
expect(screen.getByText('Lobo').closest('[data-accent]').dataset.accent).toBe('red');
|
||||
});
|
||||
|
||||
describe('a game of the profile', () => {
|
||||
const gameAnswer = {appid: 501, name: 'Hades', imageUrl: null, status: 'PLAYING', playtimeMinutes: 90,
|
||||
achievementsUnlocked: 3, achievementsTotal: 10, completionPercent: 30};
|
||||
const gameCalls = () => fetch.mock.calls.filter(([url]) => String(url).includes('/library/games/'));
|
||||
|
||||
it('showsStatusPlaytimeAndAchievementsAboveTheTilesWithTheTicket', async () => {
|
||||
mockBackends({extra: {'GET /api/public/profiles/library/games/501': json(200, gameAnswer)}});
|
||||
renderPage('Lobo?game=501');
|
||||
|
||||
expect(await screen.findByText('Hades')).toBeTruthy();
|
||||
const card = within(screen.getByText('Hades').closest('[data-slot="card"]'));
|
||||
expect(card.getByText('PROFILE.GAME_ACHIEVEMENTS')).toBeTruthy();
|
||||
expect(card.getByText('BACKLOG.STATUS.PLAYING')).toBeTruthy();
|
||||
expect(gameCalls()[0][1].headers['X-Profile-Ticket']).toBe('the-ticket');
|
||||
});
|
||||
|
||||
it('saysSoWhenTheProfileDoesNotShowTheGame', async () => {
|
||||
mockBackends({extra: {'GET /api/public/profiles/library/games/501': json(404, {})}});
|
||||
renderPage('Lobo?game=501');
|
||||
|
||||
expect(await screen.findByText('PROFILE.GAME_NOT_SHOWN')).toBeTruthy();
|
||||
expect(screen.getByText('Portal 2')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('asksForNoGameWithoutTheParamOrWithAnythingButAKey', async () => {
|
||||
mockBackends();
|
||||
renderPage('Lobo?game=../x');
|
||||
|
||||
expect(await screen.findByText('Portal 2')).toBeTruthy();
|
||||
expect(gameCalls()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('asks Auth for the ticket, then Core for the library with it', async () => {
|
||||
mockBackends({name: 'Lo bo'});
|
||||
renderPage('Lo bo');
|
||||
|
||||
Reference in new issue
Block a user