Public Access
feat(inbox): add expandable details to digest notes
Digest notes only showed counts. Now a digest note opens to list its
games and items, grouped by kind. The list loads once, from
GET /api/inbox/{id}/details. Achievements show 5 per game first.
This commit is contained in:
1 parent
600cf07b5a
commit
ad6f5575d2
7 files changed
+212
-10
No files matched your search
+69
-3
@@ -1,15 +1,16 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {ChevronDown, Loader2} from 'lucide-react';
|
||||
|
||||
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 {digestParts, fetchInbox, markInboxRead} from '../utils/inbox.js';
|
||||
import {digestParts, fetchInbox, fetchInboxDetails, groupDetails, markInboxRead} from '../utils/inbox.js';
|
||||
|
||||
const PSN_KINDS = ['PSN_EXPIRING', 'PSN_EXPIRED'];
|
||||
const ACHIEVEMENTS_SHOWN = 5;
|
||||
|
||||
/**
|
||||
* The newest notes about the people the user follows and about the PlayStation link. Opening the page marks them read.
|
||||
@@ -18,6 +19,10 @@ export default function InboxPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [open, setOpen] = useState(null);
|
||||
// note id -> {status: 'loading' | 'error' | 'ready', results}
|
||||
const [details, setDetails] = useState({});
|
||||
const [showAll, setShowAll] = useState({});
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@@ -42,6 +47,59 @@ export default function InboxPage() {
|
||||
return t('INBOX.DIGEST', {name, parts});
|
||||
};
|
||||
|
||||
const toggle = async (id) => {
|
||||
if (open === id) {
|
||||
setOpen(null);
|
||||
return;
|
||||
}
|
||||
setOpen(id);
|
||||
if (details[id]) return;
|
||||
setDetails((current) => ({...current, [id]: {status: 'loading', results: []}}));
|
||||
const {ok, status, data} = await fetchInboxDetails(id);
|
||||
setDetails((current) => ({...current, [id]: ok || status === 404 ? {status: 'ready', results: data?.results ?? []} : {status: 'error', results: []}}));
|
||||
};
|
||||
|
||||
const gameLines = (group, {gameName, items}, noteId) => {
|
||||
if (group.kind === 'STATUS') {
|
||||
return items.map((item, i) => (
|
||||
<li key={`${gameName}-${i}`}>
|
||||
{gameName}: {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}`;
|
||||
const shown = showAll[key] ? items : items.slice(0, ACHIEVEMENTS_SHOWN);
|
||||
return (
|
||||
<li key={gameName}>
|
||||
{gameName}
|
||||
<ul className="ml-4 list-disc">
|
||||
{shown.map((item, i) => <li key={i}>{item.itemName}</li>)}
|
||||
</ul>
|
||||
{!showAll[key] && items.length > ACHIEVEMENTS_SHOWN && (
|
||||
<button type="button" className="ml-4 underline"
|
||||
onClick={() => setShowAll((current) => ({...current, [key]: true}))}>
|
||||
{t('INBOX.SHOW_ALL', {count: items.length})}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
const detailBlock = (noteId) => {
|
||||
const entry = details[noteId];
|
||||
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);
|
||||
if (groups.length === 0) return <p className="text-xs text-muted-foreground">{t('INBOX.DETAILS_GONE')}</p>;
|
||||
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>
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||
<Card>
|
||||
@@ -60,11 +118,19 @@ export default function InboxPage() {
|
||||
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
|
||||
</p>
|
||||
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
|
||||
{text(note)}
|
||||
{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)}
|
||||
{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>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, screen, waitFor} from '@testing-library/react';
|
||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import InboxPage from './InboxPage.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||
@@ -67,4 +67,82 @@ describe('InboxPage', () => {
|
||||
expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy();
|
||||
expect(putCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
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'));
|
||||
const setup = (results, 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})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
};
|
||||
|
||||
it('opensANoteOnceShowsTheRowsGroupedAndClosesWithoutANewRequest', async () => {
|
||||
setup([row('NEW_GAME', 'Alpha'), row('ACHIEVEMENT', 'Alpha', {itemName: 'Win'}),
|
||||
row('STATUS', 'Beta', {fromStatus: 'BACKLOG', toStatus: 'PLAYING'})]);
|
||||
|
||||
const button = await screen.findByRole('button', {name: 'INBOX.DIGEST'});
|
||||
expect(button.getAttribute('aria-expanded')).toBe('false');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(await screen.findByText('INBOX.DETAIL_ACHIEVEMENT')).toBeTruthy();
|
||||
expect(screen.getByText('Win')).toBeTruthy();
|
||||
expect(screen.getByText(/Beta: BACKLOG\.STATUS\.BACKLOG → BACKLOG\.STATUS\.PLAYING/)).toBeTruthy();
|
||||
expect(detailCalls()).toHaveLength(1);
|
||||
|
||||
fireEvent.click(button);
|
||||
expect(screen.queryByText('INBOX.DETAIL_ACHIEVEMENT')).toBeNull();
|
||||
fireEvent.click(button);
|
||||
expect(await screen.findByText('INBOX.DETAIL_ACHIEVEMENT')).toBeTruthy();
|
||||
expect(detailCalls()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('givesANoteWithoutDetailsNoButtonAndNoRequest', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: false})], unread: 0})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
expect(await screen.findByText('INBOX.DIGEST')).toBeTruthy();
|
||||
expect(screen.queryByRole('button')).toBeNull();
|
||||
expect(detailCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
it('saysSoWhenTheResultIsEmpty', async () => {
|
||||
setup([]);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
|
||||
|
||||
expect(await screen.findByText('INBOX.DETAILS_GONE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('saysSoWhenTheNoteIsGone', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true})], unread: 0}),
|
||||
'GET /api/inbox/1/details': json(404, {})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
|
||||
|
||||
expect(await screen.findByText('INBOX.DETAILS_GONE')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsAnErrorWhenTheDetailsFail', async () => {
|
||||
fetchRoutes({'GET /api/inbox': json(200, {results: [note(1, {newGames: 1, details: true})], unread: 0}),
|
||||
'GET /api/inbox/1/details': json(500, {})});
|
||||
renderWithProviders(<InboxPage/>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
|
||||
|
||||
expect(await screen.findByText('INBOX.DETAILS_ERROR')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('showsFiveAchievementsOfAGameAndTheRestOnShowAll', async () => {
|
||||
setup(Array.from({length: 6}, (_, i) => row('ACHIEVEMENT', 'Alpha', {itemName: `ach${i}`})));
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {name: 'INBOX.DIGEST'}));
|
||||
|
||||
expect(await screen.findByText('ach4')).toBeTruthy();
|
||||
expect(screen.queryByText('ach5')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', {name: 'INBOX.SHOW_ALL:6'}));
|
||||
expect(screen.getByText('ach5')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user