feat(tiles): let users arrange the dashboard and profile tiles
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 28s

This commit is contained in:
lobo committed 2026-09-28 15:03:16 +02:00
1 parent b55fcfec92
commit aa9e83e0cb
24 files changed
+792 -426

No files matched your search

+84 -9
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, describe, expect, it, vi} from 'vitest';
import {cleanup, render, screen, waitFor} from '@testing-library/react';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import PublicProfilePage from './PublicProfilePage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -14,8 +14,10 @@ const library = {
finished: [{appid: 620, name: 'Portal 2', playtimeMinutes: 900, status: 'COMPLETED', rating: 5}],
};
function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library} = {}) {
globalThis.fetch = vi.fn(async (url) => {
function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer = library, me} = {}) {
globalThis.fetch = vi.fn(async (url, init) => {
if (init?.method === 'PUT') return {ok: true, status: 204, json: async () => ({})};
if (url.includes('/api/users/me')) return {ok: !!me, status: me ? 200 : 401, json: async () => me};
if (url.includes('/api/public/profiles/library')) {
return {ok: libraryStatus === 200, status: libraryStatus, json: async () => answer};
}
@@ -65,7 +67,7 @@ describe('PublicProfilePage', () => {
it('shows only the stat groups the user shares, without links', async () => {
library.stats = {pile: null, trend: null, topGames: null, taste: null, finished: null,
habits: {moonTrips: 2.5, longestDay: null, underOneHourGames: 0, underOneHourShare: null}};
mockBackends();
mockBackends({style: {tiles: [{id: 'STATS_HABITS', size: 'M'}, {id: 'STATS_PILE', size: 'M'}]}});
renderPage();
expect(await screen.findByText('STATS.HABITS.TITLE')).toBeTruthy();
@@ -75,7 +77,7 @@ describe('PublicProfilePage', () => {
});
it('applies the owner\'s accent and corners to the page only, with banner and background', async () => {
mockBackends({style: {accent: 'red', corners: 'round', bannerAppid: 620, backgroundAppid: 220, sections: ['PLAYING']}});
mockBackends({style: {accent: 'red', corners: 'round', bannerAppid: 620, backgroundAppid: 220, tiles: [{id: 'PLAYING', size: 'M'}]}});
const {container} = renderPage();
await screen.findByText('Half-Life 2');
@@ -88,13 +90,13 @@ describe('PublicProfilePage', () => {
it('shows the sections in the owner\'s order and leaves out hidden ones', async () => {
mockBackends({
style: {sections: ['FINISHED', 'PLAYING', 'SUMMARY']},
style: {tiles: [{id: 'FINISHED', size: 'M'}, {id: 'PLAYING', size: 'S'}, {id: 'SUMMARY', size: 'M'}]},
answer: {...library, byStatus: null, finishedGames: 60},
});
const {container} = renderPage();
await screen.findByText('Portal 2');
expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section))
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile))
.toEqual(['FINISHED', 'PLAYING', 'SUMMARY']);
expect(screen.getByText('60')).toBeTruthy();
expect(screen.queryByText('BACKLOG.STATUS.BACKLOG')).toBeNull();
@@ -105,7 +107,7 @@ describe('PublicProfilePage', () => {
const {container} = renderPage();
await screen.findByText('PROFILE.GAMES');
expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section)).toEqual(['SUMMARY']);
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)).toEqual(['SUMMARY']);
});
it('shows every section for an Auth without a style', async () => {
@@ -113,11 +115,84 @@ describe('PublicProfilePage', () => {
const {container} = renderPage();
await screen.findByText('Portal 2');
expect([...container.querySelectorAll('[data-section]')].map((el) => el.dataset.section))
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile))
.toEqual(['SUMMARY', 'STATUS', 'PLAYING', 'FINISHED']);
expect(container.querySelector('[data-accent="violet"]')).toBeTruthy();
});
it('shows the small playing tile with half the games and at half width', async () => {
const games = Array.from({length: 8}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 60, status: 'PLAYING'}));
mockBackends({style: {tiles: [{id: 'PLAYING', size: 'S'}]}, answer: {...library, playing: games}});
const {container} = renderPage();
await screen.findByText('Game 0');
expect(screen.queryByText('Game 4')).toBeNull();
expect(container.querySelector('[data-tile="PLAYING"]').className).toContain('md:col-span-6');
});
const libraryLoads = () => fetch.mock.calls.filter(([url]) => url.includes('/api/public/profiles/library')).length;
const putBody = () => JSON.parse(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[1].body);
it('offers "Customize" to the owner and hides a tile without a new load', async () => {
localStorage.setItem('tbp_session', '1');
try {
mockBackends({me: {displayName: 'lobo', role: 'USER'}});
const {container} = renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
// The test texts carry no tile name; the first tile is SUMMARY.
fireEvent.click(screen.getAllByRole('button', {name: 'TILES.HIDE'})[0]);
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
expect(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeTruthy();
expect(fetch.mock.calls.find(([, init]) => init?.method === 'PUT')[0]).toMatch(/\/api\/users\/settings\/profile-tiles$/);
expect(putBody().tiles.map((tile) => tile.id)).toEqual(['STATUS', 'PLAYING', 'FINISHED']);
expect([...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile)).not.toContain('SUMMARY');
expect(libraryLoads()).toBe(1);
} finally {
localStorage.clear();
}
});
it('loads again after adding a tile, because Core sent no data for it', async () => {
localStorage.setItem('tbp_session', '1');
try {
mockBackends({me: {displayName: 'Lobo', role: 'USER'}});
renderPage();
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
fireEvent.click(screen.getByRole('button', {name: 'STATS.PILE.TITLE'}));
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
await waitFor(() => expect(libraryLoads()).toBe(2));
expect(putBody().tiles.at(-1)).toEqual({id: 'STATS_PILE', size: 'M'});
} finally {
localStorage.clear();
}
});
it('shows no "Customize" to a visitor', async () => {
localStorage.setItem('tbp_session', '1');
try {
mockBackends({me: {displayName: 'Someone', role: 'USER'}});
renderPage();
await screen.findByText('Portal 2');
await waitFor(() => expect(fetch).toHaveBeenCalledWith(expect.stringContaining('/api/users/me'), expect.anything()));
expect(screen.queryByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeNull();
} finally {
localStorage.clear();
}
});
it('keeps three numbers in a row on a phone', async () => {
mockBackends();
const {container} = renderPage();
await screen.findByText('PROFILE.GAMES');
const summary = container.querySelector('[data-tile="SUMMARY"] [data-slot="card"]');
expect(summary.className.split(' ')).toContain('grid-cols-3');
});
it('says the profile is unknown or private on a 404', async () => {
mockBackends({profileStatus: 404});
renderPage();