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
+211
-9
No files matched your search
@@ -177,7 +177,9 @@ flowchart LR
|
|||||||
settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private
|
settings page lists who you follow ("Unfollow") and who follows you ("Remove", a private follower shows as "Private
|
||||||
user"). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist
|
user"). Core writes one inbox note per followed user and batch (new games, achievements, status changes, wishlist
|
||||||
adds; what the profile hides is left out). `/inbox` (bell in the header, with the unread count) lists the newest 50 notes
|
adds; what the profile hides is left out). `/inbox` (bell in the header, with the unread count) lists the newest 50 notes
|
||||||
and marks them read; a user who goes private ends the follow with a note. Two more notes come from the user's own
|
and marks them read; a digest note opens inline (click) and lists its games, achievements, status changes and wishlist
|
||||||
|
items, while you still follow the person and the part is public (5 achievements per game, then "show all"); a user
|
||||||
|
who goes private ends the follow with a note. Two more notes come from the user's own
|
||||||
PlayStation link (`PSN_EXPIRING` about 2 days before it ends, `PSN_EXPIRED` when it has ended), with a link to the
|
PlayStation link (`PSN_EXPIRING` about 2 days before it ends, `PSN_EXPIRED` when it has ended), with a link to the
|
||||||
settings. Notes older than 90 days are deleted.
|
settings. Notes older than 90 days are deleted.
|
||||||
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
|
- **What's new**: a dialog on the dashboard shows the news entries the user has not seen yet, newest first. "Got it"
|
||||||
|
|||||||
+8
-1
@@ -969,7 +969,14 @@
|
|||||||
"PART_STATUS_CHANGES_one": "{{count}} Statuswechsel",
|
"PART_STATUS_CHANGES_one": "{{count}} Statuswechsel",
|
||||||
"PART_STATUS_CHANGES_other": "{{count}} Statuswechsel",
|
"PART_STATUS_CHANGES_other": "{{count}} Statuswechsel",
|
||||||
"PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag",
|
"PART_WISHLIST_ADDS_one": "{{count}} Wunschlisten-Eintrag",
|
||||||
"PART_WISHLIST_ADDS_other": "{{count}} Wunschlisten-Einträge"
|
"PART_WISHLIST_ADDS_other": "{{count}} Wunschlisten-Einträge",
|
||||||
|
"DETAIL_NEW_GAME": "Neue Spiele",
|
||||||
|
"DETAIL_ACHIEVEMENT": "Erfolge und Trophäen",
|
||||||
|
"DETAIL_STATUS": "Statusänderungen",
|
||||||
|
"DETAIL_WISHLIST": "Wunschliste",
|
||||||
|
"SHOW_ALL": "Alle {{count}} anzeigen",
|
||||||
|
"DETAILS_GONE": "Keine Details mehr: du folgst dieser Person nicht mehr oder der Bereich ist jetzt privat.",
|
||||||
|
"DETAILS_ERROR": "Die Details konnten nicht geladen werden."
|
||||||
},
|
},
|
||||||
"NEWS": {
|
"NEWS": {
|
||||||
"TITLE": "Neuigkeiten",
|
"TITLE": "Neuigkeiten",
|
||||||
|
|||||||
+8
-1
@@ -969,7 +969,14 @@
|
|||||||
"PART_STATUS_CHANGES_one": "{{count}} status change",
|
"PART_STATUS_CHANGES_one": "{{count}} status change",
|
||||||
"PART_STATUS_CHANGES_other": "{{count}} status changes",
|
"PART_STATUS_CHANGES_other": "{{count}} status changes",
|
||||||
"PART_WISHLIST_ADDS_one": "{{count}} new wishlist item",
|
"PART_WISHLIST_ADDS_one": "{{count}} new wishlist item",
|
||||||
"PART_WISHLIST_ADDS_other": "{{count}} new wishlist items"
|
"PART_WISHLIST_ADDS_other": "{{count}} new wishlist items",
|
||||||
|
"DETAIL_NEW_GAME": "New games",
|
||||||
|
"DETAIL_ACHIEVEMENT": "Achievements and trophies",
|
||||||
|
"DETAIL_STATUS": "Status changes",
|
||||||
|
"DETAIL_WISHLIST": "Wishlist",
|
||||||
|
"SHOW_ALL": "Show all {{count}}",
|
||||||
|
"DETAILS_GONE": "No details any more: you no longer follow this person or the part is private now.",
|
||||||
|
"DETAILS_ERROR": "The details could not be loaded."
|
||||||
},
|
},
|
||||||
"NEWS": {
|
"NEWS": {
|
||||||
"TITLE": "What's new",
|
"TITLE": "What's new",
|
||||||
|
|||||||
+68
-2
@@ -1,15 +1,16 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Link} from 'react-router-dom';
|
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 {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
import {cn} from '@/lib/utils';
|
import {cn} from '@/lib/utils';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {formatDate} from '../utils/format.js';
|
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 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.
|
* 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 {t, i18n} = useTranslation();
|
||||||
const [notes, setNotes] = useState([]);
|
const [notes, setNotes] = useState([]);
|
||||||
const [status, setStatus] = useState('loading');
|
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(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -42,6 +47,59 @@ export default function InboxPage() {
|
|||||||
return t('INBOX.DIGEST', {name, parts});
|
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 (
|
return (
|
||||||
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||||
<Card>
|
<Card>
|
||||||
@@ -60,11 +118,19 @@ export default function InboxPage() {
|
|||||||
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
|
{formatDate(note.createdAt, i18n.language, {dateStyle: 'medium'})}
|
||||||
</p>
|
</p>
|
||||||
<p className={cn('break-words text-sm', !note.read && 'font-semibold')}>
|
<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)}
|
{text(note)}
|
||||||
|
<ChevronDown className={cn('size-4 shrink-0 transition-transform', open === note.id && 'rotate-180')}/>
|
||||||
|
</button>
|
||||||
|
) : text(note)}
|
||||||
{PSN_KINDS.includes(note.kind) && (
|
{PSN_KINDS.includes(note.kind) && (
|
||||||
<> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></>
|
<> <Link to="/settings" className="underline">{t('INBOX.OPEN_SETTINGS')}</Link></>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
{open === note.id && <div className="space-y-2 pt-1">{detailBlock(note.id)}</div>}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
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 InboxPage from './InboxPage.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
@@ -67,4 +67,82 @@ describe('InboxPage', () => {
|
|||||||
expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy();
|
expect(await screen.findByText('INBOX.LOAD_ERROR')).toBeTruthy();
|
||||||
expect(putCalls()).toEqual([]);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
+25
-2
@@ -4,11 +4,18 @@ const INBOX = () => `${import.meta.env.VITE_CORE_API_URL}/api/inbox`;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/inbox: `{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt,
|
* GET /api/inbox: `{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt,
|
||||||
* read}], unread}`, the newest 50 notes. `kind` is `DIGEST`, `VISIBILITY`, `PSN_EXPIRING` or `PSN_EXPIRED` (the PSN
|
* read, details}], unread}`, the newest 50 notes. `kind` is `DIGEST`, `VISIBILITY`, `PSN_EXPIRING` or `PSN_EXPIRED`
|
||||||
* kinds have no `displayName`); `unread` counts all unread notes.
|
* (the PSN kinds have no `displayName`); `unread` counts all unread notes. `details` is true when the note can be
|
||||||
|
* opened (`fetchInboxDetails`): it has detail rows and the followed user is still followed and public.
|
||||||
*/
|
*/
|
||||||
export const fetchInbox = () => request(INBOX());
|
export const fetchInbox = () => request(INBOX());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/inbox/{id}/details: `{results: [{kind, gameName, itemName, fromStatus, toStatus}]}`; 404 for a note that is
|
||||||
|
* not the caller's.
|
||||||
|
*/
|
||||||
|
export const fetchInboxDetails = (id) => request(`${INBOX()}/${id}/details`);
|
||||||
|
|
||||||
/** All notes count as read. 204. */
|
/** All notes count as read. 204. */
|
||||||
export const markInboxRead = () => request(`${INBOX()}/read`, {method: 'PUT'});
|
export const markInboxRead = () => request(`${INBOX()}/read`, {method: 'PUT'});
|
||||||
|
|
||||||
@@ -21,3 +28,19 @@ const PARTS = [['NEW_GAMES', 'newGames'], ['ACHIEVEMENTS', 'achievements'], ['ST
|
|||||||
*/
|
*/
|
||||||
export const digestParts = (note) =>
|
export const digestParts = (note) =>
|
||||||
PARTS.filter(([, field]) => note[field] > 0).map(([key, field]) => ({key, count: note[field]}));
|
PARTS.filter(([, field]) => note[field] > 0).map(([key, field]) => ({key, count: note[field]}));
|
||||||
|
|
||||||
|
const DETAIL_KINDS = ['NEW_GAME', 'ACHIEVEMENT', 'STATUS', 'WISHLIST'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups details for the page: `[{kind, games: [{gameName, items: [detail]}]}]` in the fixed kind order, only kinds
|
||||||
|
* with rows. Games keep the order of their first row.
|
||||||
|
*/
|
||||||
|
export const groupDetails = (details) =>
|
||||||
|
DETAIL_KINDS.map((kind) => {
|
||||||
|
const games = new Map();
|
||||||
|
details.filter((detail) => detail.kind === kind).forEach((detail) => {
|
||||||
|
if (!games.has(detail.gameName)) games.set(detail.gameName, {gameName: detail.gameName, items: []});
|
||||||
|
games.get(detail.gameName).items.push(detail);
|
||||||
|
});
|
||||||
|
return {kind, games: [...games.values()]};
|
||||||
|
}).filter((group) => group.games.length > 0);
|
||||||
+20
-1
@@ -1,5 +1,5 @@
|
|||||||
import {describe, expect, it} from 'vitest';
|
import {describe, expect, it} from 'vitest';
|
||||||
import {digestParts} from './inbox.js';
|
import {digestParts, groupDetails} from './inbox.js';
|
||||||
|
|
||||||
describe('digestParts', () => {
|
describe('digestParts', () => {
|
||||||
it('keepsOnlyTheNonZeroCountsInAFixedOrder', () => {
|
it('keepsOnlyTheNonZeroCountsInAFixedOrder', () => {
|
||||||
@@ -13,3 +13,22 @@ describe('digestParts', () => {
|
|||||||
expect(digestParts({newGames: 0, achievements: 0, statusChanges: 0, wishlistAdds: 0})).toEqual([]);
|
expect(digestParts({newGames: 0, achievements: 0, statusChanges: 0, wishlistAdds: 0})).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('groupDetails', () => {
|
||||||
|
const row = (kind, gameName, extra) => ({kind, gameName, itemName: null, fromStatus: null, toStatus: null, ...extra});
|
||||||
|
|
||||||
|
it('putsTheKindsInAFixedOrderGroupsPerGameAndLeavesEmptyKindsOut', () => {
|
||||||
|
const details = [row('WISHLIST', 'W'), row('ACHIEVEMENT', 'A', {itemName: 'one'}), row('NEW_GAME', 'A'),
|
||||||
|
row('ACHIEVEMENT', 'B', {itemName: 'x'}), row('ACHIEVEMENT', 'A', {itemName: 'two'})];
|
||||||
|
|
||||||
|
const groups = groupDetails(details);
|
||||||
|
|
||||||
|
expect(groups.map((group) => group.kind)).toEqual(['NEW_GAME', 'ACHIEVEMENT', 'WISHLIST']);
|
||||||
|
expect(groups[1].games.map((game) => game.gameName)).toEqual(['A', 'B']);
|
||||||
|
expect(groups[1].games[0].items.map((item) => item.itemName)).toEqual(['one', 'two']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isEmptyForNoDetails', () => {
|
||||||
|
expect(groupDetails([])).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user