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
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user