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

+57 -4
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, 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, useLocation} from 'react-router-dom';
import Dashboard from './Dashboard.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -49,9 +49,13 @@ describe('Dashboard', () => {
});
const avatar = 'https://avatars.steamstatic.com/abc_full.jpg';
const mockDashboard = () => {
globalThis.fetch = vi.fn(async (url) => {
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000'});
const games = Array.from({length: 12}, (_, i) => ({appid: 100 + i, name: `Game ${i}`, playtimeMinutes: 1000 - i}));
const mockDashboard = (settings) => {
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({email: 'a@b.de', displayName: 'Lobo', steamId: '76561198000000000', settings});
if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) return ok({content: games, total: 12});
if (url.includes('/api/library/stats')) return ok({trend: null, habits: {moonTrips: 1, longestDay: null, underOneHourGames: 0, underOneHourShare: null}});
if (url.includes('/api/library/steam-profile')) return ok({personaName: 'Lobo', avatarUrl: avatar});
if (url.includes('/api/library/activity')) return ok({totalMinutes: 90, days: [{day: '2026-09-23', minutes: 0}, {day: '2026-09-24', minutes: 90}], recentGames: [
{appid: 1086940, name: "Baldur's Gate 3", playtimeMinutes: 900, playtime2Weeks: 90, status: 'PLAYING'}]});
@@ -74,6 +78,55 @@ describe('Dashboard', () => {
expect(container.querySelector('img[src$="/1086940/library_hero.jpg"]')).not.toBeNull();
});
const order = (container) => [...container.querySelectorAll('[data-tile]')].map((el) => el.dataset.tile);
it('shows today\'s tiles without a saved layout', async () => {
const container = mockDashboard();
await screen.findByText('DASHBOARD.ACHIEVEMENTS.TITLE');
expect(order(container)).toEqual(['GAME_COUNT', 'MOST_PLAYED', 'PLAYING', 'PILE_OF_SHAME', 'ACTIVITY', 'ACHIEVEMENTS']);
expect(screen.getByText('Game 4')).toBeTruthy();
expect(screen.queryByText('Game 5')).toBeNull();
});
it('shows a saved layout with its sizes and loads the stats of its stat tiles', async () => {
const container = mockDashboard({dashboardTiles: [{id: 'STATS_HABITS', size: 'L'}, {id: 'MOST_PLAYED', size: 'S'}]});
await screen.findByText('STATS.HABITS.TITLE');
expect(order(container)).toEqual(['STATS_HABITS', 'MOST_PLAYED']);
expect(screen.getByText('Game 2')).toBeTruthy();
expect(screen.queryByText('Game 3')).toBeNull();
// No pile tile, so no TREND.
expect(fetch).toHaveBeenCalledWith(expect.stringMatching(/\/api\/library\/stats\?groups=HABITS$/), expect.anything());
});
it('leaves out a stat tile whose data did not come', async () => {
const container = mockDashboard({dashboardTiles: [{id: 'STATS_PILE', size: 'M'}, {id: 'MOST_PLAYED', size: 'M'}]});
await screen.findByText('Game 0');
expect(order(container)).toEqual(['MOST_PLAYED']);
});
it('saves the default layout as "never changed"', async () => {
mockDashboard({dashboardTiles: [{id: 'MOST_PLAYED', size: 'S'}]});
fireEvent.click(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'}));
fireEvent.click(screen.getByRole('button', {name: 'TILES.RESET'}));
fireEvent.click(screen.getByRole('button', {name: 'TILES.DONE'}));
await waitFor(() => expect(fetch.mock.calls.some(([, init]) => init?.method === 'PUT')).toBe(true));
const [url, init] = fetch.mock.calls.find(([, i]) => i?.method === 'PUT');
expect(url).toMatch(/\/api\/users\/settings\/dashboard-tiles$/);
expect(JSON.parse(init.body)).toEqual({tiles: null});
expect(await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'})).toBeTruthy();
});
it('puts "Customize" into the welcome card', async () => {
mockDashboard();
const button = await screen.findByRole('button', {name: 'TILES.CUSTOMIZE'});
expect(button.closest('[data-slot="card"]')?.textContent ?? 'not in a card').toContain('DASHBOARD.WELCOME_BACK');
});
it('shows the achievement summary', async () => {
mockDashboard();