Public Access
feat(questions): add questions page with open count badge
Add /questions page and a nav badge with the open count. Core decides which question is open. The page only shows and answers. Move RatingStars out of BacklogCard so both pages share it.
This commit is contained in:
1 parent
1dab069a4d
commit
d3ad19e2fa
15 files changed
+567
-33
No files matched your search
@@ -252,6 +252,17 @@ flowchart LR
|
|||||||
Dialog -->|"community row, login"| Owners["Core /api/stats/owners"]
|
Dialog -->|"community row, login"| Owners["Core /api/stats/owners"]
|
||||||
Landing["Landing user tiles, login"] --> Users["Auth /api/stats/users"]
|
Landing["Landing user tiles, login"] --> Users["Auth /api/stats/users"]
|
||||||
```
|
```
|
||||||
|
- `/questions` (`QuestionsPage`, nav item with the open count; the badge is hidden on the page, whose title shows the
|
||||||
|
live count) lists one card per game with an open question (Start it? / Finished? / Dropped? / Rate it?). Core decides
|
||||||
|
which question is open; `src/utils/questions.js` only loads the list and sends the answer. A yes to Finished? gets
|
||||||
|
the Rate it? card back at once. `409` / `404` reload the list. `RatingStars` is shared with `BacklogCard`.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Header["AppHeader badge"] --> Count["Core /api/library/questions/count"]
|
||||||
|
Page["QuestionsPage"] --> List["Core /api/library/questions"]
|
||||||
|
Page -->|"Yes / No / stars"| Answer["Core POST /api/library/questions/{appid}"]
|
||||||
|
```
|
||||||
- Owner and user lists (`src/components/people/`, `src/utils/people.js`) need a login and show listed users by name
|
- Owner and user lists (`src/components/people/`, `src/utils/people.js`) need a login and show listed users by name
|
||||||
(a link to `/u/<name>`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is
|
(a link to `/u/<name>`), everyone else as "+N private", the viewer as "You". The setting "show me in owner lists" is
|
||||||
saved on its own (`PUT /api/users/settings/owner-lists`), never through `saveSettings`.
|
saved on its own (`PUT /api/users/settings/owner-lists`), never through `saveSettings`.
|
||||||
|
|||||||
@@ -392,6 +392,9 @@
|
|||||||
"INBOX": "Postfach",
|
"INBOX": "Postfach",
|
||||||
"INBOX_UNREAD_one": "Postfach, {{count}} neue Nachricht",
|
"INBOX_UNREAD_one": "Postfach, {{count}} neue Nachricht",
|
||||||
"INBOX_UNREAD_other": "Postfach, {{count}} neue Nachrichten",
|
"INBOX_UNREAD_other": "Postfach, {{count}} neue Nachrichten",
|
||||||
|
"QUESTIONS": "Fragen",
|
||||||
|
"QUESTIONS_OPEN_one": "Fragen, {{count}} offen",
|
||||||
|
"QUESTIONS_OPEN_other": "Fragen, {{count}} offen",
|
||||||
"WHATS_NEW": "Neuigkeiten",
|
"WHATS_NEW": "Neuigkeiten",
|
||||||
"ADMIN_NEWS": "Neuigkeiten verwalten",
|
"ADMIN_NEWS": "Neuigkeiten verwalten",
|
||||||
"ADMIN_BROADCASTS": "Rundmail",
|
"ADMIN_BROADCASTS": "Rundmail",
|
||||||
@@ -1291,5 +1294,31 @@
|
|||||||
"ACHIEVEMENTS": "Neue Erfolge oder Trophäen",
|
"ACHIEVEMENTS": "Neue Erfolge oder Trophäen",
|
||||||
"STATUS": "Statuswechsel"
|
"STATUS": "Statuswechsel"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"QUESTIONS": {
|
||||||
|
"TITLE": "Fragen",
|
||||||
|
"TITLE_COUNT": "Fragen ({{count}})",
|
||||||
|
"EMPTY": "Gerade gibt es nichts zu entscheiden.",
|
||||||
|
"GONE": "Diese Frage ist schon beantwortet. Die Liste wird neu geladen.",
|
||||||
|
"YES": "Ja",
|
||||||
|
"NO": "Nein",
|
||||||
|
"SKIP": "Überspringen",
|
||||||
|
"START": {
|
||||||
|
"ASK": "Angefangen?",
|
||||||
|
"REASON": "Du hast {{playtime}} gespielt, das Spiel liegt aber noch im Backlog."
|
||||||
|
},
|
||||||
|
"FINISHED": {
|
||||||
|
"ASK": "Durchgespielt?",
|
||||||
|
"REASON": "Du hast {{playtime}} gespielt.",
|
||||||
|
"REASON_TTB": "Du hast {{playtime}} gespielt. Die meisten brauchen {{ttb}}."
|
||||||
|
},
|
||||||
|
"DROPPED": {
|
||||||
|
"ASK": "Abgebrochen?",
|
||||||
|
"REASON": "Keine Spielzeit seit {{date}}."
|
||||||
|
},
|
||||||
|
"RATE": {
|
||||||
|
"ASK": "Bewerten?",
|
||||||
|
"REASON": "Du hast es durchgespielt. Wie war es?"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,6 +392,9 @@
|
|||||||
"INBOX": "Inbox",
|
"INBOX": "Inbox",
|
||||||
"INBOX_UNREAD_one": "Inbox, {{count}} new note",
|
"INBOX_UNREAD_one": "Inbox, {{count}} new note",
|
||||||
"INBOX_UNREAD_other": "Inbox, {{count}} new notes",
|
"INBOX_UNREAD_other": "Inbox, {{count}} new notes",
|
||||||
|
"QUESTIONS": "Questions",
|
||||||
|
"QUESTIONS_OPEN_one": "Questions, {{count}} open",
|
||||||
|
"QUESTIONS_OPEN_other": "Questions, {{count}} open",
|
||||||
"WHATS_NEW": "What's new",
|
"WHATS_NEW": "What's new",
|
||||||
"ADMIN_NEWS": "News",
|
"ADMIN_NEWS": "News",
|
||||||
"ADMIN_BROADCASTS": "Mail",
|
"ADMIN_BROADCASTS": "Mail",
|
||||||
@@ -1291,5 +1294,31 @@
|
|||||||
"ACHIEVEMENTS": "New achievements or trophies",
|
"ACHIEVEMENTS": "New achievements or trophies",
|
||||||
"STATUS": "Status changes"
|
"STATUS": "Status changes"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"QUESTIONS": {
|
||||||
|
"TITLE": "Questions",
|
||||||
|
"TITLE_COUNT": "Questions ({{count}})",
|
||||||
|
"EMPTY": "Nothing to decide right now.",
|
||||||
|
"GONE": "That question was already answered. The list is loaded again.",
|
||||||
|
"YES": "Yes",
|
||||||
|
"NO": "No",
|
||||||
|
"SKIP": "Skip",
|
||||||
|
"START": {
|
||||||
|
"ASK": "Start it?",
|
||||||
|
"REASON": "You played {{playtime}}, but it is still in your backlog."
|
||||||
|
},
|
||||||
|
"FINISHED": {
|
||||||
|
"ASK": "Finished?",
|
||||||
|
"REASON": "You played {{playtime}}.",
|
||||||
|
"REASON_TTB": "You played {{playtime}}. Most players need {{ttb}}."
|
||||||
|
},
|
||||||
|
"DROPPED": {
|
||||||
|
"ASK": "Dropped?",
|
||||||
|
"REASON": "No playtime since {{date}}."
|
||||||
|
},
|
||||||
|
"RATE": {
|
||||||
|
"ASK": "Rate it?",
|
||||||
|
"REASON": "You finished it. How was it?"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,7 @@ import AdminHintsPage from './pages/AdminHintsPage.jsx';
|
|||||||
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
import AdminBroadcastPage from './pages/AdminBroadcastPage.jsx';
|
||||||
import AdminNewsPage from './pages/AdminNewsPage.jsx';
|
import AdminNewsPage from './pages/AdminNewsPage.jsx';
|
||||||
import InboxPage from './pages/InboxPage.jsx';
|
import InboxPage from './pages/InboxPage.jsx';
|
||||||
|
import QuestionsPage from './pages/QuestionsPage.jsx';
|
||||||
import WhatsNewPage from './pages/WhatsNewPage.jsx';
|
import WhatsNewPage from './pages/WhatsNewPage.jsx';
|
||||||
import WelcomePage from './pages/WelcomePage.jsx';
|
import WelcomePage from './pages/WelcomePage.jsx';
|
||||||
import WelcomeGate from './components/WelcomeGate.jsx';
|
import WelcomeGate from './components/WelcomeGate.jsx';
|
||||||
@@ -93,6 +94,7 @@ function App() {
|
|||||||
<Route path="/settings" element={<SettingsPage/>}/>
|
<Route path="/settings" element={<SettingsPage/>}/>
|
||||||
<Route path="/whats-new" element={<WhatsNewPage/>}/>
|
<Route path="/whats-new" element={<WhatsNewPage/>}/>
|
||||||
<Route path="/inbox" element={<InboxPage/>}/>
|
<Route path="/inbox" element={<InboxPage/>}/>
|
||||||
|
<Route path="/questions" element={<QuestionsPage/>}/>
|
||||||
<Route path="/welcome" element={<WelcomePage/>}/>
|
<Route path="/welcome" element={<WelcomePage/>}/>
|
||||||
<Route path="/audit/games" element={<GameCatalogPage/>}/>
|
<Route path="/audit/games" element={<GameCatalogPage/>}/>
|
||||||
<Route path="/audit/links" element={<LinkQueuePage/>}/>
|
<Route path="/audit/links" element={<LinkQueuePage/>}/>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ describe('App', () => {
|
|||||||
'GET /api/library/steam-profile': json(200, null),
|
'GET /api/library/steam-profile': json(200, null),
|
||||||
'POST /api/auth/refresh': json(401, {}),
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
|
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||||
});
|
});
|
||||||
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
|
const {router} = renderWithProviders(<App/>, {entries: ['/library']});
|
||||||
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
|
await waitFor(() => expect(screen.getByText('Half-Life 2')).toBeTruthy());
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
import {Link, NavLink, useLocation, useNavigate} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Bell, ChartColumn, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, Users} from 'lucide-react';
|
import {Bell, ChartColumn, CircleQuestionMark, Coins, Dices, Eye, Languages, LayoutDashboard, Library, Link2, LogOut, Mail, Megaphone, Moon, Newspaper, ScrollText, Search, Settings, Shield, Sun, Users} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {Badge} from '@/components/ui/badge';
|
import {Badge} from '@/components/ui/badge';
|
||||||
@@ -26,6 +26,7 @@ import useSteamProfile from '../hooks/useSteamProfile.js';
|
|||||||
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
import {fetchPendingCount, ROLES, viewAs} from '../utils/admin.js';
|
||||||
import {canCorrect} from '../utils/corrections.js';
|
import {canCorrect} from '../utils/corrections.js';
|
||||||
import {fetchInbox} from '../utils/inbox.js';
|
import {fetchInbox} from '../utils/inbox.js';
|
||||||
|
import {fetchQuestionCount} from '../utils/questions.js';
|
||||||
import {expireSession, markSession} from '../utils/auth.js';
|
import {expireSession, markSession} from '../utils/auth.js';
|
||||||
|
|
||||||
const NAV_ITEMS = [
|
const NAV_ITEMS = [
|
||||||
@@ -33,10 +34,18 @@ const NAV_ITEMS = [
|
|||||||
// The wishlist and the backlog order are tabs of the library.
|
// The wishlist and the backlog order are tabs of the library.
|
||||||
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']},
|
{to: '/library', labelKey: 'NAV.LIBRARY', Icon: Library, alsoActiveOn: ['/wishlist', '/backlog']},
|
||||||
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
{to: '/play-next', labelKey: 'NAV.PLAY_NEXT', Icon: Dices},
|
||||||
|
{to: '/questions', labelKey: 'NAV.QUESTIONS', Icon: CircleQuestionMark},
|
||||||
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
|
{to: '/stats', labelKey: 'NAV.STATS', Icon: ChartColumn},
|
||||||
{to: '/', labelKey: 'NAV.COMMUNITY', Icon: Users, iconOnly: true},
|
{to: '/', labelKey: 'NAV.COMMUNITY', Icon: Users, iconOnly: true},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const CountBadge = ({count}) => (
|
||||||
|
<span aria-hidden="true"
|
||||||
|
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
||||||
|
{count > 9 ? '9+' : count}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
const navLinkClass = (isActive) => cn(
|
const navLinkClass = (isActive) => cn(
|
||||||
'flex items-center gap-2 rounded-lg px-1 py-2 text-sm font-medium transition-colors sm:px-3',
|
'flex items-center gap-2 rounded-lg px-1 py-2 text-sm font-medium transition-colors sm:px-3',
|
||||||
isActive
|
isActive
|
||||||
@@ -86,6 +95,22 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
}, [isAuthenticated, pathname]);
|
}, [isAuthenticated, pathname]);
|
||||||
const shownUnread = pathname === '/inbox' ? 0 : unread;
|
const shownUnread = pathname === '/inbox' ? 0 : unread;
|
||||||
|
|
||||||
|
// Loaded again on every page change. The questions page shows the live count in its title, so it has no badge.
|
||||||
|
const [openQuestions, setOpenQuestions] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return undefined;
|
||||||
|
let current = true;
|
||||||
|
fetchQuestionCount().then(({ok, data}) => {
|
||||||
|
if (current && ok) setOpenQuestions(data.count);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
current = false;
|
||||||
|
};
|
||||||
|
}, [isAuthenticated, pathname]);
|
||||||
|
const shownQuestions = pathname === '/questions' ? 0 : openQuestions;
|
||||||
|
// Below sm the Questions icon shows only with open questions or on its own page; the points chip then gives up its number.
|
||||||
|
const questionsIconHidden = openQuestions === 0 && pathname !== '/questions';
|
||||||
|
|
||||||
const switchRole = async (role) => {
|
const switchRole = async (role) => {
|
||||||
try {
|
try {
|
||||||
const response = await viewAs(role === currentUser.role ? null : role);
|
const response = await viewAs(role === currentUser.role ? null : role);
|
||||||
@@ -129,13 +154,19 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
|
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<nav className="flex items-center sm:gap-1">
|
<nav className="flex items-center sm:gap-1">
|
||||||
{NAV_ITEMS.map(({to, labelKey, Icon, alsoActiveOn = [], iconOnly}) => (
|
{NAV_ITEMS.map(({to, labelKey, Icon, alsoActiveOn = [], iconOnly}) => {
|
||||||
<NavLink key={to} to={to} title={t(labelKey)}
|
const count = to === '/questions' ? shownQuestions : 0;
|
||||||
className={({isActive}) => navLinkClass(isActive || alsoActiveOn.includes(pathname))}>
|
return (
|
||||||
<Icon className="size-4"/>
|
<NavLink key={to} to={to} title={t(labelKey)}
|
||||||
{!iconOnly && <span className="hidden lg:inline">{t(labelKey)}</span>}
|
aria-label={count > 0 ? t(`${labelKey}_OPEN`, {count}) : undefined}
|
||||||
</NavLink>
|
className={({isActive}) => cn(navLinkClass(isActive || alsoActiveOn.includes(pathname)), 'relative',
|
||||||
))}
|
to === '/questions' && questionsIconHidden && 'max-sm:hidden')}>
|
||||||
|
<Icon className="size-4"/>
|
||||||
|
{!iconOnly && <span className="hidden lg:inline">{t(labelKey)}</span>}
|
||||||
|
{count > 0 && <CountBadge count={count}/>}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -146,19 +177,17 @@ export default function AppHeader({isAuthenticated, onLogout}) {
|
|||||||
)}
|
)}
|
||||||
{isAuthenticated && wallet && (
|
{isAuthenticated && wallet && (
|
||||||
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className="min-w-0 shrink lg:shrink-0">
|
<Button asChild variant="ghost" size="sm" title={`${t('POINTS.TITLE')}: ${wallet.balance}`} className="min-w-0 shrink lg:shrink-0">
|
||||||
<Link to="/points"><Coins className="text-primary"/><span className="min-w-0 truncate tabular-nums">{wallet.balance}</span></Link>
|
<Link to="/points" aria-label={`${t('POINTS.TITLE')}: ${wallet.balance}`}>
|
||||||
|
<Coins className="text-primary"/>
|
||||||
|
<span className={cn('min-w-0 truncate tabular-nums', !questionsIconHidden && 'max-sm:hidden')}>{wallet.balance}</span>
|
||||||
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{isAuthenticated && (
|
{isAuthenticated && (
|
||||||
<Button asChild variant="ghost" size="icon" title={t('NAV.INBOX')} className="relative shrink-0">
|
<Button asChild variant="ghost" size="icon" title={t('NAV.INBOX')} className="relative shrink-0">
|
||||||
<Link to="/inbox" aria-label={shownUnread > 0 ? t('NAV.INBOX_UNREAD', {count: shownUnread}) : t('NAV.INBOX')}>
|
<Link to="/inbox" aria-label={shownUnread > 0 ? t('NAV.INBOX_UNREAD', {count: shownUnread}) : t('NAV.INBOX')}>
|
||||||
<Bell/>
|
<Bell/>
|
||||||
{shownUnread > 0 && (
|
{shownUnread > 0 && <CountBadge count={shownUnread}/>}
|
||||||
<span aria-hidden="true"
|
|
||||||
className="absolute -top-1 -right-1 flex size-4 items-center justify-center rounded-full bg-primary text-[10px] font-bold text-primary-foreground">
|
|
||||||
{shownUnread > 9 ? '9+' : shownUnread}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,13 +18,44 @@ function Load() {
|
|||||||
return <button onClick={reloadWallet}>load</button>;
|
return <button onClick={reloadWallet}>load</button>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadChip(count) {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
|
||||||
|
'GET /api/library/steam-profile': json(200, {}),
|
||||||
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
|
'GET /api/library/questions/count': json(200, {count}),
|
||||||
|
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
|
});
|
||||||
|
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
||||||
|
fireEvent.click(screen.getByText('load'));
|
||||||
|
return screen.findByTitle('POINTS.TITLE: 135');
|
||||||
|
}
|
||||||
|
|
||||||
describe('AppHeader points chip', () => {
|
describe('AppHeader points chip', () => {
|
||||||
|
it('hides the number below sm while the questions icon shows, and keeps the balance as label', async () => {
|
||||||
|
const chip = await loadChip(2);
|
||||||
|
await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'});
|
||||||
|
|
||||||
|
expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135');
|
||||||
|
expect(screen.getByText('135').className).toContain('max-sm:hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the number at every width while the questions icon is hidden', async () => {
|
||||||
|
const chip = await loadChip(0);
|
||||||
|
await screen.findByTitle('NAV.QUESTIONS');
|
||||||
|
|
||||||
|
expect(chip.getAttribute('aria-label')).toBe('POINTS.TITLE: 135');
|
||||||
|
expect(screen.getByText('135').className).not.toContain('max-sm:hidden');
|
||||||
|
});
|
||||||
|
|
||||||
it('shows the balance as a link to the points page once the wallet is loaded', async () => {
|
it('shows the balance as a link to the points page once the wallet is loaded', async () => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
fetchRoutes({
|
fetchRoutes({
|
||||||
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
|
'GET /api/users/me': json(200, {role: 'USER', activeRole: 'USER'}),
|
||||||
'GET /api/library/steam-profile': json(200, {}),
|
'GET /api/library/steam-profile': json(200, {}),
|
||||||
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
|
'GET /api/library/questions/count': json(200, {count: 0}),
|
||||||
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
'GET /api/rewards/wallet': json(200, {balance: 135, catalog: [], owned: [], equipped: {}, history: []}),
|
||||||
});
|
});
|
||||||
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
renderWithProviders(<><Load/><AppHeader isAuthenticated onLogout={() => {}}/></>);
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ afterEach(() => {
|
|||||||
const loggedInAs = (me, routes = {}, options = {}) => {
|
const loggedInAs = (me, routes = {}, options = {}) => {
|
||||||
localStorage.setItem('tbp_session', '1');
|
localStorage.setItem('tbp_session', '1');
|
||||||
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}),
|
fetchRoutes({'GET /api/users/me': json(200, me), 'GET /api/library/steam-profile': json(200, {}),
|
||||||
'GET /api/inbox': json(200, {results: [], unread: 0}), ...routes});
|
'GET /api/inbox': json(200, {results: [], unread: 0}),
|
||||||
|
'GET /api/library/questions/count': json(200, {count: 0}), ...routes});
|
||||||
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>, options);
|
renderWithProviders(<AppHeader isAuthenticated onLogout={() => {}}/>, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -86,6 +87,44 @@ describe('AppHeader', () => {
|
|||||||
expect(link.textContent).toBe('');
|
expect(link.textContent).toBe('');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows how many questions are open', async () => {
|
||||||
|
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 12})});
|
||||||
|
|
||||||
|
const link = await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:12'});
|
||||||
|
|
||||||
|
expect(link.getAttribute('href')).toBe('/questions');
|
||||||
|
expect(link.textContent).toBe('NAV.QUESTIONS9+');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides the questions icon below sm while no question is open', async () => {
|
||||||
|
loggedInAs({role: 'USER', activeRole: 'USER'});
|
||||||
|
|
||||||
|
expect((await screen.findByTitle('NAV.QUESTIONS')).className).toContain('max-sm:hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the questions icon at every width while questions are open', async () => {
|
||||||
|
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 2})});
|
||||||
|
|
||||||
|
expect((await screen.findByRole('link', {name: 'NAV.QUESTIONS_OPEN:2'})).className).not.toContain('max-sm:hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the questions icon below sm on the questions page, without a badge', async () => {
|
||||||
|
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 0})},
|
||||||
|
{entries: ['/questions']});
|
||||||
|
|
||||||
|
expect((await screen.findByTitle('NAV.QUESTIONS')).className).not.toContain('max-sm:hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no question count on the questions page, whose title has it', async () => {
|
||||||
|
loggedInAs({role: 'USER', activeRole: 'USER'}, {'GET /api/library/questions/count': json(200, {count: 3})},
|
||||||
|
{entries: ['/questions']});
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/library/questions/count'), expect.anything()));
|
||||||
|
|
||||||
|
expect(screen.getByTitle('NAV.QUESTIONS').textContent).toBe('NAV.QUESTIONS');
|
||||||
|
expect(screen.queryByRole('link', {name: 'NAV.QUESTIONS_OPEN:3'})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('offers the admin page and "view as" to an admin', async () => {
|
it('offers the admin page and "view as" to an admin', async () => {
|
||||||
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
loggedInAs({role: 'PLATFORM_ADMIN', activeRole: 'PLATFORM_ADMIN'},
|
||||||
{'GET /api/admin/users/pending-count': json(200, {count: 0})});
|
{'GET /api/admin/users/pending-count': json(200, {count: 0})});
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import {useEffect, useState} from 'react';
|
import {useEffect, useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {ListTodo, Loader2, Star} from 'lucide-react';
|
import {ListTodo, Loader2} from 'lucide-react';
|
||||||
|
|
||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card} from '@/components/ui/card';
|
import {Card} from '@/components/ui/card';
|
||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||||
import {Textarea} from '@/components/ui/textarea';
|
import {Textarea} from '@/components/ui/textarea';
|
||||||
import {cn} from '@/lib/utils';
|
import RatingStars from './RatingStars.jsx';
|
||||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||||
import {changedFields, NO_PRIORITY, saveBacklogEntry} from '../utils/backlog.js';
|
import {changedFields, NO_PRIORITY, saveBacklogEntry} from '../utils/backlog.js';
|
||||||
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
||||||
@@ -104,21 +104,7 @@ export default function BacklogCard({game, onSaved}) {
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
|
<Label id="backlog-rating">{t('BACKLOG.RATING_LABEL')}</Label>
|
||||||
<div role="group" aria-labelledby="backlog-rating" className="flex h-9 items-center gap-1">
|
<RatingStars value={form.rating} onChange={set('rating')} labelledBy="backlog-rating"/>
|
||||||
{[1, 2, 3, 4, 5].map((n) => (
|
|
||||||
<button
|
|
||||||
key={n}
|
|
||||||
type="button"
|
|
||||||
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
|
|
||||||
aria-pressed={form.rating === n}
|
|
||||||
onClick={() => set('rating')(form.rating === n ? 0 : n)}
|
|
||||||
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
|
||||||
>
|
|
||||||
<Star className={cn('size-6 transition-colors',
|
|
||||||
n <= form.rating ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import {Link} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
|
||||||
|
import {Button} from '@/components/ui/button';
|
||||||
|
import {Card} from '@/components/ui/card';
|
||||||
|
import FitText from './FitText.jsx';
|
||||||
|
import GameCover from './GameCover.jsx';
|
||||||
|
import RatingStars from './RatingStars.jsx';
|
||||||
|
import {formatDate} from '../utils/format.js';
|
||||||
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One open question of one game. The page decides what an answer does.
|
||||||
|
*
|
||||||
|
* @param item a result of `GET /api/library/questions`
|
||||||
|
* @param busy true while an answer is on its way
|
||||||
|
* @param onAnswer `({yes, rating?})`; a yes to the rating question comes with the stars
|
||||||
|
*/
|
||||||
|
export default function QuestionCard({item, busy, onAnswer}) {
|
||||||
|
const {t, i18n} = useTranslation();
|
||||||
|
const {appid, name, imageUrl, question, playtimeMinutes, normallyMinutes, lastActivity} = item;
|
||||||
|
const reasonKey = question === 'FINISHED' && normallyMinutes > 0 ? 'FINISHED.REASON_TTB' : `${question}.REASON`;
|
||||||
|
const reason = t(`QUESTIONS.${reasonKey}`, {
|
||||||
|
playtime: formatPlaytime(playtimeMinutes),
|
||||||
|
ttb: formatPlaytime(normallyMinutes),
|
||||||
|
date: lastActivity ? formatDate(lastActivity, i18n.language) : '',
|
||||||
|
});
|
||||||
|
const askId = `question-${appid}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="flex-row items-start gap-3 p-4">
|
||||||
|
<Link to={`/library/${appid}`} className="w-16 shrink-0 sm:w-24">
|
||||||
|
<GameCover appid={appid} imageUrl={imageUrl}/>
|
||||||
|
</Link>
|
||||||
|
<div className="min-w-0 flex-1 space-y-2">
|
||||||
|
<Link to={`/library/${appid}`} className="@container block min-w-0 font-medium hover:text-primary">
|
||||||
|
<FitText text={name ?? `App ${appid}`}/>
|
||||||
|
</Link>
|
||||||
|
<p id={askId} className="text-base font-semibold">{t(`QUESTIONS.${question}.ASK`)}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{reason}</p>
|
||||||
|
{question === 'RATE' ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<RatingStars value={0} labelledBy={askId} disabled={busy}
|
||||||
|
onChange={(rating) => onAnswer({yes: true, rating})}/>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={busy}
|
||||||
|
onClick={() => onAnswer({yes: false})}>
|
||||||
|
{t('QUESTIONS.SKIP')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button type="button" size="sm" disabled={busy} onClick={() => onAnswer({yes: true})}>
|
||||||
|
{t('QUESTIONS.YES')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" size="sm" disabled={busy}
|
||||||
|
onClick={() => onAnswer({yes: false})}>
|
||||||
|
{t('QUESTIONS.NO')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import QuestionCard from './QuestionCard.jsx';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const item = (overrides) => ({appid: 10, name: 'Hades', imageUrl: null, question: 'RATE', status: 'FINISHED',
|
||||||
|
playtimeMinutes: 1500, normallyMinutes: null, lastActivity: null, ...overrides});
|
||||||
|
|
||||||
|
const renderCard = (props, onAnswer = vi.fn()) => {
|
||||||
|
render(<MemoryRouter><QuestionCard item={item(props)} busy={false} onAnswer={onAnswer}/></MemoryRouter>);
|
||||||
|
return onAnswer;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('QuestionCard', () => {
|
||||||
|
it('sendsTheStarsAsARating', () => {
|
||||||
|
const onAnswer = renderCard();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:4'}));
|
||||||
|
|
||||||
|
expect(onAnswer).toHaveBeenCalledWith({yes: true, rating: 4});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendsNoForSkip', () => {
|
||||||
|
const onAnswer = renderCard();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.SKIP'}));
|
||||||
|
|
||||||
|
expect(onAnswer).toHaveBeenCalledWith({yes: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answersYesAndNoWithoutARating', () => {
|
||||||
|
const onAnswer = renderCard({question: 'START', status: 'BACKLOG'});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.NO'}));
|
||||||
|
|
||||||
|
expect(onAnswer.mock.calls).toEqual([[{yes: true}], [{yes: false}]]);
|
||||||
|
expect(screen.queryByRole('button', {name: 'QUESTIONS.SKIP'})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('linksTheNameToTheGamePage', () => {
|
||||||
|
renderCard();
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', {name: 'Hades'}).getAttribute('href')).toBe('/library/10');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Star} from 'lucide-react';
|
||||||
|
|
||||||
|
import {cn} from '@/lib/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Five star buttons. Clicking the chosen star again sends 0, so the parent decides what that means.
|
||||||
|
*
|
||||||
|
* @param value 0 for no rating
|
||||||
|
* @param labelledBy id of the element that names the group
|
||||||
|
*/
|
||||||
|
export default function RatingStars({value, onChange, labelledBy, disabled}) {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
return (
|
||||||
|
<div role="group" aria-labelledby={labelledBy} className="flex h-9 items-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={t('BACKLOG.RATING_STARS', {count: n})}
|
||||||
|
aria-pressed={value === n}
|
||||||
|
onClick={() => onChange(value === n ? 0 : n)}
|
||||||
|
className="rounded-sm p-0.5 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
|
>
|
||||||
|
<Star className={cn('size-6 transition-colors',
|
||||||
|
n <= value ? 'fill-amber-400 text-amber-400' : 'text-muted-foreground/40 hover:text-amber-400')}/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {Loader2} from 'lucide-react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
|
import QuestionCard from '../components/QuestionCard.jsx';
|
||||||
|
import {answerQuestion, fetchQuestions} from '../utils/questions.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The open questions about the user's games: one card per game, answered one click at a time. Core decides which
|
||||||
|
* question is open; a yes changes the game like the game page does.
|
||||||
|
*/
|
||||||
|
export default function QuestionsPage() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const [items, setItems] = useState([]);
|
||||||
|
const [status, setStatus] = useState('loading');
|
||||||
|
const [busyAppid, setBusyAppid] = useState(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const {ok, status: code, data} = await fetchQuestions();
|
||||||
|
if (code === 401) return;
|
||||||
|
if (!ok) {
|
||||||
|
setStatus('error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItems(data.results);
|
||||||
|
setStatus('ready');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const answer = async (item, choice) => {
|
||||||
|
setBusyAppid(item.appid);
|
||||||
|
try {
|
||||||
|
const {ok, status: code, data} = await answerQuestion(item.appid, {question: item.question, ...choice});
|
||||||
|
if (code === 401) return;
|
||||||
|
if (ok) {
|
||||||
|
// The next question of the same game (Rate it? after Finished?) takes the place of the card.
|
||||||
|
setItems((current) => current.flatMap((entry) => (entry.appid === item.appid ? [data.next].filter(Boolean) : [entry])));
|
||||||
|
toast.success(t('BACKLOG.SAVED'));
|
||||||
|
} else if (code === 404 || code === 409) {
|
||||||
|
toast(t('QUESTIONS.GONE'));
|
||||||
|
await load();
|
||||||
|
} else if (code === 0) {
|
||||||
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
} else {
|
||||||
|
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setBusyAppid(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer className="space-y-4 px-4 py-6 md:px-8">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">
|
||||||
|
{status === 'ready' && items.length > 0 ? t('QUESTIONS.TITLE_COUNT', {count: items.length}) : t('QUESTIONS.TITLE')}
|
||||||
|
</h1>
|
||||||
|
{status === 'loading' && <Loader2 className="size-5 animate-spin text-muted-foreground"/>}
|
||||||
|
{status === 'error' && <p className="text-sm text-destructive">{t('INFO.ERROR.NETWORK_FAILURE')}</p>}
|
||||||
|
{status === 'ready' && items.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('QUESTIONS.EMPTY')}</p>
|
||||||
|
)}
|
||||||
|
{status === 'ready' && items.map((item) => (
|
||||||
|
<QuestionCard key={item.appid} item={item} busy={busyAppid !== null}
|
||||||
|
onAnswer={(choice) => answer(item, choice)}/>
|
||||||
|
))}
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||||
|
import {MemoryRouter} from 'react-router-dom';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import QuestionsPage from './QuestionsPage.jsx';
|
||||||
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: Object.assign(vi.fn(), {success: vi.fn(), error: vi.fn()})}));
|
||||||
|
|
||||||
|
const card = (appid, question, name = `Game ${appid}`) => ({appid, name, imageUrl: null, question, status: 'PLAYING',
|
||||||
|
playtimeMinutes: 1500, normallyMinutes: null, lastActivity: null});
|
||||||
|
|
||||||
|
const renderPage = (routes) => {
|
||||||
|
const mock = fetchRoutes(routes);
|
||||||
|
render(<MemoryRouter><PreferencesProvider><QuestionsPage/></PreferencesProvider></MemoryRouter>);
|
||||||
|
return mock;
|
||||||
|
};
|
||||||
|
|
||||||
|
const bodyOf = (mock) => JSON.parse(mock.mock.calls.find(([, init]) => init?.method === 'POST')[1].body);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QuestionsPage', () => {
|
||||||
|
it('showsOneCardPerGameWithTheCountInTheTitle', async () => {
|
||||||
|
renderPage({'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'FINISHED')]})});
|
||||||
|
|
||||||
|
expect(await screen.findByText('Game 1')).toBeTruthy();
|
||||||
|
expect(screen.getByText('Game 2')).toBeTruthy();
|
||||||
|
expect(screen.getByText('QUESTIONS.START.ASK')).toBeTruthy();
|
||||||
|
expect(screen.getByText('QUESTIONS.TITLE_COUNT:2')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removesTheCardAfterAYes', async () => {
|
||||||
|
const mock = renderPage({
|
||||||
|
'GET /api/library/questions': json(200, {results: [card(1, 'START'), card(2, 'START')]}),
|
||||||
|
'POST /api/library/questions/1': json(200, {next: null}),
|
||||||
|
});
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole('button', {name: 'QUESTIONS.YES'})[0]);
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.queryByText('Game 1')).toBeNull());
|
||||||
|
expect(screen.getByText('Game 2')).toBeTruthy();
|
||||||
|
expect(bodyOf(mock)).toEqual({question: 'START', yes: true});
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('BACKLOG.SAVED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removesTheCardAfterANo', async () => {
|
||||||
|
const mock = renderPage({
|
||||||
|
'GET /api/library/questions': json(200, {results: [card(1, 'DROPPED')]}),
|
||||||
|
'POST /api/library/questions/1': json(200, {next: null}),
|
||||||
|
});
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.NO'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
|
||||||
|
expect(bodyOf(mock)).toEqual({question: 'DROPPED', yes: false});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replacesTheCardWithTheRateQuestionAfterFinished', async () => {
|
||||||
|
renderPage({
|
||||||
|
'GET /api/library/questions': json(200, {results: [card(1, 'FINISHED')]}),
|
||||||
|
'POST /api/library/questions/1': json(200, {next: card(1, 'RATE')}),
|
||||||
|
});
|
||||||
|
await screen.findByText('QUESTIONS.FINISHED.ASK');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('QUESTIONS.RATE.ASK')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('QUESTIONS.FINISHED.ASK')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sendsTheRatingOfTheStars', async () => {
|
||||||
|
const mock = renderPage({
|
||||||
|
'GET /api/library/questions': json(200, {results: [card(1, 'RATE')]}),
|
||||||
|
'POST /api/library/questions/1': json(200, {next: null}),
|
||||||
|
});
|
||||||
|
await screen.findByText('QUESTIONS.RATE.ASK');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'BACKLOG.RATING_STARS:5'}));
|
||||||
|
|
||||||
|
await screen.findByText('QUESTIONS.EMPTY');
|
||||||
|
expect(bodyOf(mock)).toEqual({question: 'RATE', yes: true, rating: 5});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loadsTheListAgainWhenTheQuestionIsGone', async () => {
|
||||||
|
let results = [card(1, 'START')];
|
||||||
|
renderPage({
|
||||||
|
'GET /api/library/questions': () => json(200, {results}),
|
||||||
|
'POST /api/library/questions/1': () => {
|
||||||
|
results = [];
|
||||||
|
return json(409, {code: 'QUESTION_GONE'});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
|
||||||
|
|
||||||
|
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
|
||||||
|
expect(toast).toHaveBeenCalledWith('QUESTIONS.GONE');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keepsTheCardAndSaysSoWhenSavingFails', async () => {
|
||||||
|
renderPage({
|
||||||
|
'GET /api/library/questions': json(200, {results: [card(1, 'START')]}),
|
||||||
|
'POST /api/library/questions/1': json(500, {}),
|
||||||
|
});
|
||||||
|
await screen.findByText('Game 1');
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'QUESTIONS.YES'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
|
||||||
|
expect(screen.getByText('Game 1')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('showsTheEmptyState', async () => {
|
||||||
|
renderPage({'GET /api/library/questions': json(200, {results: []})});
|
||||||
|
|
||||||
|
expect(await screen.findByText('QUESTIONS.EMPTY')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import {request} from './api.js';
|
||||||
|
|
||||||
|
const QUESTIONS = () => `${import.meta.env.VITE_CORE_API_URL}/api/library/questions`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/library/questions: `{results: [{appid, name, imageUrl, question, status, playtimeMinutes, normallyMinutes,
|
||||||
|
* lastActivity}]}`, one open question per game, most important first. `question` is `START`, `FINISHED`, `DROPPED` or
|
||||||
|
* `RATE`; `normallyMinutes` is IGDB's time to beat or null; `lastActivity` (an ISO time) only comes with `DROPPED`.
|
||||||
|
*/
|
||||||
|
export const fetchQuestions = () => request(QUESTIONS());
|
||||||
|
|
||||||
|
/** GET /api/library/questions/count: `{count}`, the number of cards the list has. */
|
||||||
|
export const fetchQuestionCount = () => request(`${QUESTIONS()}/count`);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/library/questions/{appid}: `{question, yes, rating?}`. A yes to `RATE` needs `rating` (1 to 5). 200 is
|
||||||
|
* `{next}`: the next question of the same game (e.g. `RATE` after `FINISHED`) or null. 404 `GAME_NOT_FOUND`, 409
|
||||||
|
* `QUESTION_GONE` (the question is not open any more).
|
||||||
|
*/
|
||||||
|
export const answerQuestion = (appid, {question, yes, rating}) => request(`${QUESTIONS()}/${appid}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({question, yes, rating}),
|
||||||
|
});
|
||||||
Reference in new issue
Block a user