Public Access
feat(library): add ordered backlog page and up-next dashboard tile
This commit is contained in:
1 parent
c158909083
commit
b213b067dc
21 files changed
+677
-24
No files matched your search
@@ -0,0 +1,195 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, screen, waitFor} from '@testing-library/react';
|
||||
import {toast} from 'sonner';
|
||||
import BacklogPage from './BacklogPage.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()}}));
|
||||
|
||||
const game = (appid, extra = {}) => ({appid, name: `Game ${appid}`, priority: null, timeToBeatMinutes: null, ...extra});
|
||||
const backlog = {games: [game(1), game(2), game(3), game(4, {coming: true, comingOn: '2099-01-01'})]};
|
||||
|
||||
// The providers load the user and the preferences; this page does not use them.
|
||||
const withAuth = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes});
|
||||
|
||||
const renderPage = () => renderWithProviders(<BacklogPage/>,
|
||||
{path: '/backlog', entries: ['/backlog'], guarded: true, routes: {'/library': <p>library</p>}});
|
||||
|
||||
const names = () => [...document.querySelectorAll('ol a[href^="/library/"]')].map((a) => a.textContent);
|
||||
const puts = () => globalThis.fetch.mock.calls.filter(([, init]) => init?.method === 'PUT')
|
||||
.map(([, init]) => JSON.parse(init.body).appids);
|
||||
const rowButton = (rowName, label) => {
|
||||
const row = screen.getByText(rowName).closest('li');
|
||||
return [...row.querySelectorAll('button')].find((b) => b.getAttribute('aria-label') === label);
|
||||
};
|
||||
|
||||
describe('BacklogPage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('tbp_session', '1');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows the games in the order of the server, a coming game too', async () => {
|
||||
withAuth({'GET /api/library/backlog': json(200, backlog)});
|
||||
renderPage();
|
||||
|
||||
await screen.findByText('Game 1');
|
||||
expect(names()).toEqual(['Game 1', 'Game 2', 'Game 3', 'Game 4']);
|
||||
expect(screen.getByText('LIBRARY.COMING', {exact: false})).toBeTruthy();
|
||||
});
|
||||
|
||||
it('moves a game up and down with the arrows and sends the whole list', async () => {
|
||||
withAuth({
|
||||
'GET /api/library/backlog': json(200, backlog),
|
||||
'PUT /api/library/backlog': json(204, {}),
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
fireEvent.click(rowButton('Game 3', 'BACKLOG_ORDER.MOVE_UP'));
|
||||
await waitFor(() => expect(puts()).toEqual([[1, 3, 2, 4]]));
|
||||
expect(names()).toEqual(['Game 1', 'Game 3', 'Game 2', 'Game 4']);
|
||||
|
||||
fireEvent.click(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DOWN'));
|
||||
await waitFor(() => expect(puts()).toEqual([[1, 3, 2, 4], [3, 1, 2, 4]]));
|
||||
});
|
||||
|
||||
it('does not offer to move the first game up or the last game down', async () => {
|
||||
withAuth({'GET /api/library/backlog': json(200, backlog)});
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
expect(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_UP').disabled).toBe(true);
|
||||
expect(rowButton('Game 4', 'BACKLOG_ORDER.MOVE_DOWN').disabled).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['BACKLOG_ORDER.MOVE_TOP', 'Game 3', [3, 1, 2, 4]],
|
||||
['BACKLOG_ORDER.MOVE_BOTTOM', 'Game 2', [1, 3, 4, 2]],
|
||||
])('%s from the menu sends the new order', async (item, row, expected) => {
|
||||
withAuth({
|
||||
'GET /api/library/backlog': json(200, backlog),
|
||||
'PUT /api/library/backlog': json(204, {}),
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
fireEvent.keyDown(rowButton(row, 'BACKLOG_ORDER.MORE'), {key: 'Enter'});
|
||||
fireEvent.click(await screen.findByRole('menuitem', {name: item}));
|
||||
|
||||
await waitFor(() => expect(puts()).toEqual([expected]));
|
||||
});
|
||||
|
||||
it('moves a game with the drag handle to the row under the pointer', async () => {
|
||||
withAuth({
|
||||
'GET /api/library/backlog': json(200, backlog),
|
||||
'PUT /api/library/backlog': json(204, {}),
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
document.querySelectorAll('ol > li').forEach((row, i) => {
|
||||
row.getBoundingClientRect = () => ({top: i * 100, height: 100});
|
||||
});
|
||||
const grip = rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG');
|
||||
|
||||
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
|
||||
fireEvent.pointerMove(grip, {clientY: 240, pointerId: 1});
|
||||
fireEvent.pointerUp(grip, {pointerId: 1});
|
||||
|
||||
await waitFor(() => expect(puts()).toEqual([[2, 3, 1, 4]]));
|
||||
expect(names()).toEqual(['Game 2', 'Game 3', 'Game 1', 'Game 4']);
|
||||
});
|
||||
|
||||
it('sends nothing when a drag ends on the row it started on, or is cancelled', async () => {
|
||||
withAuth({'GET /api/library/backlog': json(200, backlog)});
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
document.querySelectorAll('ol > li').forEach((row, i) => {
|
||||
row.getBoundingClientRect = () => ({top: i * 100, height: 100});
|
||||
});
|
||||
const grip = rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG');
|
||||
|
||||
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
|
||||
fireEvent.pointerUp(grip, {pointerId: 1});
|
||||
fireEvent.pointerDown(grip, {button: 0, pointerId: 1});
|
||||
fireEvent.pointerMove(grip, {clientY: 340, pointerId: 1});
|
||||
fireEvent.pointerCancel(grip, {pointerId: 1});
|
||||
|
||||
expect(puts()).toEqual([]);
|
||||
expect(names()).toEqual(['Game 1', 'Game 2', 'Game 3', 'Game 4']);
|
||||
});
|
||||
|
||||
it('shows a toast and loads the list again when saving fails', async () => {
|
||||
let loads = 0;
|
||||
withAuth({
|
||||
'GET /api/library/backlog': () => json(200, ++loads === 1 ? backlog : {games: [game(2), game(1)]}),
|
||||
'PUT /api/library/backlog': json(500, {}),
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
fireEvent.click(rowButton('Game 2', 'BACKLOG_ORDER.MOVE_UP'));
|
||||
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG_ORDER.SAVE_FAILED'));
|
||||
await waitFor(() => expect(names()).toEqual(['Game 2', 'Game 1']));
|
||||
});
|
||||
|
||||
it('sends only the newest list after a running save, so the server gets them in order', async () => {
|
||||
let release;
|
||||
const first = new Promise((resolve) => { release = resolve; });
|
||||
let puts_ = 0;
|
||||
withAuth({
|
||||
'GET /api/library/backlog': json(200, backlog),
|
||||
'PUT /api/library/backlog': async () => {
|
||||
if (++puts_ === 1) await first;
|
||||
return json(204, {});
|
||||
},
|
||||
});
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
fireEvent.click(rowButton('Game 2', 'BACKLOG_ORDER.MOVE_UP'));
|
||||
fireEvent.click(rowButton('Game 3', 'BACKLOG_ORDER.MOVE_UP'));
|
||||
fireEvent.click(rowButton('Game 4', 'BACKLOG_ORDER.MOVE_UP'));
|
||||
expect(puts()).toEqual([[2, 1, 3, 4]]);
|
||||
|
||||
release();
|
||||
|
||||
await waitFor(() => expect(puts()).toEqual([[2, 1, 3, 4], [2, 3, 4, 1]]));
|
||||
});
|
||||
|
||||
it('explains an empty backlog and links to the library', async () => {
|
||||
withAuth({'GET /api/library/backlog': json(200, {games: []})});
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('BACKLOG_ORDER.EMPTY')).toBeTruthy();
|
||||
// The tab and the link of the empty card.
|
||||
expect(screen.getAllByRole('link', {name: 'LIBRARY.TITLE'})).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows an error when the list cannot be loaded', async () => {
|
||||
withAuth({'GET /api/library/backlog': json(500, {})});
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText('BACKLOG_ORDER.LOAD_FAILED')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('lets only the drag handle stop the page scroll', async () => {
|
||||
withAuth({'GET /api/library/backlog': json(200, backlog)});
|
||||
renderPage();
|
||||
await screen.findByText('Game 1');
|
||||
|
||||
expect(rowButton('Game 1', 'BACKLOG_ORDER.MOVE_DRAG').className).toContain('touch-none');
|
||||
const row = screen.getByText('Game 1').closest('li');
|
||||
expect(row.className).not.toContain('touch-none');
|
||||
expect(screen.getByText('Game 1').closest('ol').className).not.toContain('touch-none');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user