Public Access
refactor(frontend): lint, one session guard, one request helper, one user context
This commit is contained in:
1 parent
111ab1e200
commit
d0ea9e900d
78 files changed
+2138
-624
No files matched your search
@@ -1,5 +1,4 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Loader2, Send, Square} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
@@ -12,8 +11,6 @@ import {Label} from '@/components/ui/label';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import HintCard from '../components/HintCard.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {createHint, endHint, fetchAdminHints} from '../utils/hints.js';
|
||||
import {MAX_HINT_LENGTH} from '../utils/limits.js';
|
||||
|
||||
@@ -35,8 +32,6 @@ const emptyForm = () => {
|
||||
*/
|
||||
export default function AdminHintsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [hints, setHints] = useState([]);
|
||||
const [status, setStatus] = useState('loading');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
@@ -45,10 +40,7 @@ export default function AdminHintsPage() {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchAdminHints();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
setStatus('forbidden');
|
||||
return;
|
||||
@@ -63,24 +55,17 @@ export default function AdminHintsPage() {
|
||||
console.error('Admin hints error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [expireSession]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
load();
|
||||
}, [load, navigate]);
|
||||
}, [load]);
|
||||
|
||||
const run = async (request, doneKey, onOk) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await request();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t(doneKey));
|
||||
onOk?.();
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
@@ -19,9 +18,7 @@ import {
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
const REFUSAL_KEY = {
|
||||
@@ -37,8 +34,6 @@ const formatDate = (value, locale) => (value ? new Date(value).toLocaleDateStrin
|
||||
*/
|
||||
export default function AdminUsersPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [query, setQuery] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
@@ -56,10 +51,7 @@ export default function AdminUsersPage() {
|
||||
try {
|
||||
const response = await fetchUsers(search, page, PAGE_SIZE);
|
||||
if (current !== loadCount.current) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 403) {
|
||||
setStatus('forbidden');
|
||||
return;
|
||||
@@ -76,15 +68,11 @@ export default function AdminUsersPage() {
|
||||
console.error('Admin users error:', err);
|
||||
setStatus('error');
|
||||
}
|
||||
}, [search, page, expireSession]);
|
||||
}, [search, page]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
load();
|
||||
}, [load, navigate]);
|
||||
}, [load]);
|
||||
|
||||
const closeDialog = () => {
|
||||
setPending(null);
|
||||
@@ -96,10 +84,7 @@ export default function AdminUsersPage() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = kind === 'delete' ? await deleteUser(user.id) : await setUserBlocked(user.id, !user.blocked);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.ok) {
|
||||
toast.success(t(kind === 'delete' ? 'ADMIN.DONE_DELETE' : user.blocked ? 'ADMIN.DONE_UNBLOCK' : 'ADMIN.DONE_BLOCK'));
|
||||
closeDialog();
|
||||
|
||||
+33
-135
@@ -1,5 +1,5 @@
|
||||
import React, {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {ArrowRight, Hash, LayoutGrid, Link2, RefreshCw, Trophy} from 'lucide-react';
|
||||
@@ -19,7 +19,6 @@ import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
import useForceSync from '../hooks/useForceSync.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import HeroBanner from '../components/HeroBanner.jsx';
|
||||
import SteamAvatar from '../components/SteamAvatar.jsx';
|
||||
@@ -30,8 +29,8 @@ import TileGrid from '../components/TileGrid.jsx';
|
||||
import statGroupTile from '../components/stats/statGroupTile.jsx';
|
||||
import {DASHBOARD_TILES, DASHBOARD_TITLES, DEFAULT_DASHBOARD, normalizeLayout, saveTiles, statGroup} from '../utils/tiles.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import {request} from '../utils/api.js';
|
||||
|
||||
// Games per tile size; the page loads the most for the large tile.
|
||||
const TOP_GAMES = {S: 3, M: 5, L: 10};
|
||||
@@ -40,13 +39,15 @@ const PLAYING_GAMES = {S: 2, M: 4, L: 8};
|
||||
const TOP_GAMES_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc';
|
||||
const PLAYING_URL = '/api/library/games?page=0&size=20&sort=playtimeMinutes%2Cdesc&status=PLAYING';
|
||||
|
||||
const JSON_HEADERS = {'Content-Type': 'application/json'};
|
||||
// A card whose request fails or has no JSON body stays empty.
|
||||
async function loadInto(path, set, pick = (data) => data) {
|
||||
const {ok, data} = await request(`${import.meta.env.VITE_CORE_API_URL}${path}`);
|
||||
if (ok && data != null) set(pick(data));
|
||||
}
|
||||
|
||||
const Dashboard = () => {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [userData, setUserData] = useState(null);
|
||||
const {user: userData, updateUser, reload: reloadUser} = useCurrentUserContext();
|
||||
const [steamData, setSteamData] = useState({count: 0, hasSteamId: false});
|
||||
const [topGames, setTopGames] = useState([]);
|
||||
const [playing, setPlaying] = useState([]);
|
||||
@@ -67,109 +68,26 @@ const Dashboard = () => {
|
||||
])].sort();
|
||||
// No groups would mean "all groups" to the stats endpoint.
|
||||
const hasStatGroups = statGroups.length > 0;
|
||||
const stats = useUserStats(statGroups, expireSession, hasSession() && userData != null && hasStatGroups);
|
||||
const stats = useUserStats(statGroups, userData != null && hasStatGroups);
|
||||
const reloadStats = stats.reload;
|
||||
|
||||
const fetchSteamCount = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
setSteamData(await r.json());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching steam data:', err);
|
||||
}
|
||||
}, [expireSession]);
|
||||
|
||||
const fetchTopGames = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${TOP_GAMES_URL}`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
const page = await r.json();
|
||||
setTopGames((page.content ?? []).slice(0, TOP_GAMES.L));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching top games:', err);
|
||||
}
|
||||
}, [expireSession]);
|
||||
|
||||
const fetchPlaying = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}${PLAYING_URL}`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
const page = await r.json();
|
||||
setPlaying((page.content ?? []).slice(0, PLAYING_GAMES.L));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching playing games:', err);
|
||||
}
|
||||
}, [expireSession]);
|
||||
const fetchSteamCount = useCallback(() => loadInto('/api/library/games-count', setSteamData), []);
|
||||
const fetchTopGames = useCallback(() => loadInto(TOP_GAMES_URL, setTopGames, (page) => (page.content ?? []).slice(0, TOP_GAMES.L)), []);
|
||||
const fetchPlaying = useCallback(() => loadInto(PLAYING_URL, setPlaying, (page) => (page.content ?? []).slice(0, PLAYING_GAMES.L)), []);
|
||||
const fetchActivity = useCallback(() => loadInto('/api/library/activity', setActivity), []);
|
||||
const fetchAchievements = useCallback(() => loadInto('/api/library/achievements/summary', setAchievements), []);
|
||||
|
||||
/**
|
||||
* @param excludeAppid the current suggestion, so the new pick differs from it
|
||||
* @returns false when the request failed
|
||||
* @returns false when the request failed; true on a 401, which ends the session without a "failed" message
|
||||
*/
|
||||
const fetchSummary = useCallback(async (excludeAppid) => {
|
||||
const query = excludeAppid ? `?exclude=${excludeAppid}` : '';
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return true;
|
||||
}
|
||||
if (r.ok) {
|
||||
setSummary(await r.json());
|
||||
setSummaryFailed(false);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching library summary:', err);
|
||||
}
|
||||
setSummaryFailed(true);
|
||||
return false;
|
||||
}, [expireSession]);
|
||||
|
||||
const fetchActivity = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/activity`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
setActivity(await r.json());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching activity:', err);
|
||||
}
|
||||
}, [expireSession]);
|
||||
|
||||
const fetchAchievements = useCallback(async () => {
|
||||
try {
|
||||
const r = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/achievements/summary`, {headers: JSON_HEADERS});
|
||||
if (r.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (r.ok) {
|
||||
setAchievements(await r.json());
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching achievements:', err);
|
||||
}
|
||||
}, [expireSession]);
|
||||
const {ok, status, data} = await request(`${import.meta.env.VITE_CORE_API_URL}/api/library/summary${query}`);
|
||||
if (ok) setSummary(data);
|
||||
setSummaryFailed(!ok && status !== 401);
|
||||
return ok || status === 401;
|
||||
}, []);
|
||||
|
||||
const shuffleSuggestion = async () => {
|
||||
setShuffling(true);
|
||||
@@ -191,40 +109,21 @@ const Dashboard = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUserData = async () => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const profileResponse = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
method: 'GET',
|
||||
headers: JSON_HEADERS,
|
||||
});
|
||||
|
||||
if (profileResponse.ok) {
|
||||
const data = await profileResponse.json();
|
||||
setUserData(data);
|
||||
|
||||
// Games added by hand need no Steam ID; history and achievements come only from Steam.
|
||||
await Promise.all(data.steamId
|
||||
? [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(), fetchActivity(), fetchAchievements()]
|
||||
: [fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary()]);
|
||||
} else if (profileResponse.status === 401) {
|
||||
expireSession();
|
||||
} else {
|
||||
setError('INFO.ERROR.LOAD_PROFILE');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Dashboard fetch error:', err);
|
||||
const {ok, status, data} = await reloadUser();
|
||||
if (ok) {
|
||||
// Games added by hand need no Steam ID; history and achievements come only from Steam.
|
||||
await Promise.all([fetchSteamCount(), fetchTopGames(), fetchPlaying(), fetchSummary(),
|
||||
...(data.steamId ? [fetchActivity(), fetchAchievements()] : [])]);
|
||||
} else if (status === 0) {
|
||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
} else if (status !== 401) {
|
||||
setError('INFO.ERROR.LOAD_PROFILE');
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
fetchUserData();
|
||||
}, [navigate, expireSession, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
||||
}, [reloadUser, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
|
||||
|
||||
const hasSteamId = !!userData?.steamId;
|
||||
const showLibrary = hasSteamId || steamData.count > 0;
|
||||
@@ -237,7 +136,6 @@ const Dashboard = () => {
|
||||
enabled: hasSteamId,
|
||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||
onComplete: reloadLibrary,
|
||||
onUnauthorized: expireSession,
|
||||
});
|
||||
const forceSync = useForceSync(reloadLibrary);
|
||||
|
||||
@@ -259,7 +157,7 @@ const Dashboard = () => {
|
||||
// The default is saved as "never changed", so a later change of the default reaches this user too.
|
||||
const tiles = JSON.stringify(next) === JSON.stringify(DEFAULT_DASHBOARD) ? null : next;
|
||||
if (!await runSave(() => saveTiles('dashboard-tiles', tiles))) return;
|
||||
setUserData((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
|
||||
updateUser((d) => ({...d, settings: {...d.settings, dashboardTiles: tiles}}));
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import RequireSession from '../components/RequireSession.jsx';
|
||||
import Dashboard from './Dashboard.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||
@@ -33,12 +35,12 @@ describe('Dashboard', () => {
|
||||
globalThis.fetch = vi.fn(async (url) =>
|
||||
url.includes('/api/users/me') ? ok({email: 'a@b.de', steamId: '76561198000000000'}) : unauthorized());
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider>
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<><Dashboard/><Spy/></>}/>
|
||||
<Route element={<RequireSession/>}><Route path="/dashboard" element={<><Dashboard/><Spy/></>}/></Route>
|
||||
<Route path="/login" element={<Spy/>}/>
|
||||
</Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(5));
|
||||
@@ -65,9 +67,9 @@ describe('Dashboard', () => {
|
||||
return ok({content: [], total: 0, byStatus: {}});
|
||||
});
|
||||
return render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider>
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider>
|
||||
<Dashboard/>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
).container;
|
||||
};
|
||||
|
||||
@@ -150,4 +152,23 @@ describe('Dashboard', () => {
|
||||
await waitFor(() => expect(container.querySelector(`img[src="${avatar}"]`)).not.toBeNull());
|
||||
expect(container.querySelector('img[src*="library_hero"]')).toBeNull();
|
||||
});
|
||||
it('shows the page when a card answers 200 without a JSON body', async () => {
|
||||
// No Steam ID, so no activity and achievements are asked.
|
||||
globalThis.fetch = vi.fn(async (url) => {
|
||||
if (url.includes('/api/users/me')) return ok({email: 'a@b.de', displayName: 'Lobo'});
|
||||
if (url.includes('/api/library/games-count')) return ok({count: 1, hasSteamId: true});
|
||||
if (url.includes('sort=playtimeMinutes') && !url.includes('status=')) {
|
||||
return {ok: true, status: 200, json: async () => { throw new SyntaxError('Unexpected token <'); }};
|
||||
}
|
||||
return ok({content: [], total: 0, byStatus: {}});
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/dashboard']}><PreferencesProvider><CurrentUserProvider>
|
||||
<Dashboard/>
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/DASHBOARD.HELLO/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ import PlayingHint from '../components/PlayingHint.jsx';
|
||||
import TimeToBeat from '../components/TimeToBeat.jsx';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import useCurrentUser from '../hooks/useCurrentUser.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
@@ -32,7 +31,6 @@ import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
||||
import {canCorrect} from '../utils/corrections.js';
|
||||
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
|
||||
import {setGameHidden} from '../utils/hiddenGames.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {isSteamKey} from '../utils/manualGames.js';
|
||||
import {platformLabel} from '../utils/platforms.js';
|
||||
|
||||
@@ -65,7 +63,6 @@ export default function GameDetail() {
|
||||
const {appearance} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
|
||||
const [data, setData] = useState(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -99,10 +96,6 @@ export default function GameDetail() {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
(async () => {
|
||||
@@ -114,10 +107,7 @@ export default function GameDetail() {
|
||||
},
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 404) {
|
||||
setError('GAME_DETAIL.NOT_FOUND');
|
||||
return;
|
||||
@@ -140,7 +130,7 @@ export default function GameDetail() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [appid, navigate, expireSession, reloads]);
|
||||
}, [appid, reloads]);
|
||||
|
||||
const sortedAchievements = useMemo(() => {
|
||||
if (!data?.achievements) return [];
|
||||
@@ -166,10 +156,7 @@ export default function GameDetail() {
|
||||
setHiding(true);
|
||||
try {
|
||||
const response = await setGameHidden(data.appid, hidden);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('GAME_DETAIL.HIDE_ERROR'));
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import GameDetail from './GameDetail.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
// Stands in for a saved correction, which loads the game again.
|
||||
@@ -31,9 +32,9 @@ afterEach(() => {
|
||||
|
||||
it('keeps unsaved notes while the game loads again after a correction', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
|
||||
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider><CurrentUserProvider>
|
||||
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import GameDetail from './GameDetail.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
import {resetBrokenImageReports} from '../utils/brokenImages.js';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
@@ -16,12 +17,12 @@ const game = {
|
||||
};
|
||||
|
||||
const renderDetail = (appid = 220) => render(
|
||||
<MemoryRouter initialEntries={[`/library/${appid}`]}><PreferencesProvider>
|
||||
<MemoryRouter initialEntries={[`/library/${appid}`]}><PreferencesProvider><CurrentUserProvider>
|
||||
<Routes>
|
||||
<Route path="/library/:appid" element={<GameDetail/>}/>
|
||||
<Route path="/library" element={<p>library page</p>}/>
|
||||
</Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
import {Link, useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {Link, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import useLibrarySync from '../hooks/useLibrarySync.js';
|
||||
import SyncStatusBadge from '../components/SyncStatusBadge.jsx';
|
||||
import useForceSync from '../hooks/useForceSync.js';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {libraryQuery, parseStatus} from '../utils/backlog.js';
|
||||
import {GAME_STATUSES, MAX_LIBRARY_SEARCH_LENGTH} from '../utils/limits.js';
|
||||
@@ -22,7 +21,6 @@ import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {platformLabel} from '../utils/platforms.js';
|
||||
|
||||
const PAGE_SIZES = [20, 40, 80];
|
||||
@@ -42,8 +40,6 @@ const SORT_OPTIONS = [
|
||||
|
||||
export default function GameLibrary() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const page = Math.max(0, parseInt(searchParams.get('page') ?? '0', 10) || 0);
|
||||
@@ -70,10 +66,6 @@ export default function GameLibrary() {
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
@@ -84,10 +76,7 @@ export default function GameLibrary() {
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
|
||||
if (!response.ok) {
|
||||
setError('LIBRARY.ERROR_LOAD');
|
||||
@@ -102,14 +91,13 @@ export default function GameLibrary() {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
||||
}, [page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) return undefined;
|
||||
let cancelled = false;
|
||||
apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/games-count`)
|
||||
.then((response) => (response.ok ? response.json() : null))
|
||||
@@ -127,26 +115,25 @@ export default function GameLibrary() {
|
||||
enabled: hasSteamId,
|
||||
expectInitialSync,
|
||||
onComplete: load,
|
||||
onUnauthorized: expireSession,
|
||||
});
|
||||
|
||||
const updateParams = (next) => {
|
||||
const updateParams = useCallback((next) => {
|
||||
const params = new URLSearchParams(searchParams);
|
||||
Object.entries(next).forEach(([k, v]) => (v === null || v === '' ? params.delete(k) : params.set(k, String(v))));
|
||||
setSearchParams(params);
|
||||
};
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// Back/forward navigation changes q without typing. Keeps a trailing space the user is still typing.
|
||||
useEffect(() => {
|
||||
setSearch((current) => (current.trim() === q ? current : q));
|
||||
}, [q]);
|
||||
|
||||
// searchParams is a dependency, so a status click during the delay restarts the timer with fresh params.
|
||||
// updateParams changes with searchParams, so a status click during the delay restarts the timer with fresh params.
|
||||
useEffect(() => {
|
||||
if (search.trim() === q) return undefined;
|
||||
const timer = setTimeout(() => updateParams({q: search.trim(), page: 0}), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search, q, searchParams]);
|
||||
}, [search, q, updateParams]);
|
||||
|
||||
const onStatusChange = (value) => {
|
||||
updateParams({status: value, hidden: null, page: 0});
|
||||
|
||||
+22
-45
@@ -1,5 +1,5 @@
|
||||
import {memo, useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {Link, useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {Link, useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {ArrowLeft, CheckCircle2, Circle, Loader2, RotateCcw, Search, Upload, XCircle} from 'lucide-react';
|
||||
@@ -12,15 +12,14 @@ import {Textarea} from '@/components/ui/textarea';
|
||||
import {cn} from '@/lib/utils';
|
||||
import GameSearch from '../components/GameSearch.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useStartImport from '../hooks/useStartImport.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
import {
|
||||
checkUndo, getImport, listImports, parseCsv, parseTextList, retryImport, saveImport, undoImport,
|
||||
} from '../utils/imports.js';
|
||||
import {GAME_STATUSES, MAX_IMPORT_ROWS, MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||
import {hoursToMinutes, toHours} from '../utils/manualGames.js';
|
||||
import {loadFavoritePlatform, preselectPlatform} from '../utils/platforms.js';
|
||||
import {preselectPlatform} from '../utils/platforms.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
const POLL_MS = 2000;
|
||||
// Statuses a worker still has: the page polls while it shows one.
|
||||
@@ -50,8 +49,6 @@ const SELECT_CLASS = 'h-9 w-full min-w-0 rounded-md border border-input bg-trans
|
||||
*/
|
||||
export default function ImportPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [imports, setImports] = useState(null);
|
||||
const id = searchParams.get('id');
|
||||
@@ -59,24 +56,17 @@ export default function ImportPage() {
|
||||
const loadImports = useCallback(async () => {
|
||||
try {
|
||||
const response = await listImports();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
setImports(response.ok ? (await response.json()).imports : []);
|
||||
} catch (err) {
|
||||
console.error('Import list error:', err);
|
||||
setImports([]);
|
||||
}
|
||||
}, [expireSession]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
loadImports();
|
||||
}, [navigate, loadImports]);
|
||||
}, [loadImports]);
|
||||
|
||||
// Without ?id=, an import a worker still has is shown first; one to review or failed is in the history.
|
||||
const open = imports?.find((entry) => RUNNING.includes(entry.status));
|
||||
@@ -103,14 +93,14 @@ export default function ImportPage() {
|
||||
{imports === null ? (
|
||||
<Loader2 className="animate-spin text-muted-foreground"/>
|
||||
) : shownId ? (
|
||||
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports} expireSession={expireSession}/>
|
||||
<ImportView key={shownId} id={shownId} onDone={done} onChanged={loadImports}/>
|
||||
) : (
|
||||
<ImportInput onCreated={(created) => {
|
||||
show(created);
|
||||
loadImports();
|
||||
}}/>
|
||||
)}
|
||||
{imports?.length > 0 && <History imports={imports} onOpen={show} onChanged={loadImports} expireSession={expireSession}/>}
|
||||
{imports?.length > 0 && <History imports={imports} onOpen={show} onChanged={loadImports}/>}
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
@@ -176,7 +166,7 @@ function ImportInput({onCreated}) {
|
||||
}
|
||||
|
||||
// Polls while a worker has the import.
|
||||
function ImportView({id, onDone, onChanged, expireSession}) {
|
||||
function ImportView({id, onDone, onChanged}) {
|
||||
const {t} = useTranslation();
|
||||
const [data, setData] = useState(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
@@ -185,11 +175,8 @@ function ImportView({id, onDone, onChanged, expireSession}) {
|
||||
const reload = () => setVersion((current) => current + 1);
|
||||
const lastStatus = useRef(null);
|
||||
// undefined while loading: the review preselects with it, so it waits.
|
||||
const [favorite, setFavorite] = useState(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
loadFavoritePlatform().then(setFavorite);
|
||||
}, []);
|
||||
const {user} = useCurrentUserContext();
|
||||
const favorite = user === undefined ? undefined : user?.settings?.favoritePlatform ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
let timer;
|
||||
@@ -198,10 +185,7 @@ function ImportView({id, onDone, onChanged, expireSession}) {
|
||||
try {
|
||||
const response = await getImport(id);
|
||||
if (cancelled) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
// A 4xx ends the polling; a 5xx while Core restarts is asked again.
|
||||
if (response.status >= 400 && response.status < 500) {
|
||||
setFailed(true);
|
||||
@@ -228,7 +212,7 @@ function ImportView({id, onDone, onChanged, expireSession}) {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [id, expireSession, version, onChanged]);
|
||||
}, [id, version, onChanged]);
|
||||
|
||||
if (failed) return <p className="text-sm text-destructive">{t('IMPORT.LOAD_FAILED')}</p>;
|
||||
if (!data || favorite === undefined) return <Loader2 className="animate-spin text-muted-foreground"/>;
|
||||
@@ -236,9 +220,9 @@ function ImportView({id, onDone, onChanged, expireSession}) {
|
||||
<div className="space-y-4">
|
||||
<ImportSteps data={data}/>
|
||||
{data.status === 'READY' ? (
|
||||
<Review data={data} favorite={favorite} onSaved={reload} onDone={onDone} expireSession={expireSession}/>
|
||||
<Review data={data} favorite={favorite} onSaved={reload} onDone={onDone}/>
|
||||
) : (
|
||||
<ImportProgress data={data} onRetried={reload} onDone={onDone} expireSession={expireSession}/>
|
||||
<ImportProgress data={data} onRetried={reload} onDone={onDone}/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -271,7 +255,7 @@ function ImportSteps({data}) {
|
||||
}
|
||||
|
||||
// What the current step does, with its numbers; Retry for a FAILED import.
|
||||
function ImportProgress({data, onRetried, onDone, expireSession}) {
|
||||
function ImportProgress({data, onRetried, onDone}) {
|
||||
const {t} = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const step = stepOf(data);
|
||||
@@ -281,9 +265,8 @@ function ImportProgress({data, onRetried, onDone, expireSession}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await retryImport(data.id);
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
} else if (response.status === 409) {
|
||||
if (response.status === 401) return;
|
||||
if (response.status === 409) {
|
||||
toast.error(t('IMPORT.RUNNING'));
|
||||
} else if (!response.ok) {
|
||||
toast.error(t('IMPORT.RETRY_FAILED'));
|
||||
@@ -356,7 +339,7 @@ export const initialChoice = (row, favorite) => {
|
||||
};
|
||||
};
|
||||
|
||||
function Review({data, favorite, onSaved, onDone, expireSession}) {
|
||||
function Review({data, favorite, onSaved, onDone}) {
|
||||
const {t} = useTranslation();
|
||||
const [choices, setChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -387,10 +370,7 @@ function Review({data, favorite, onSaved, onDone, expireSession}) {
|
||||
lineNo: row.lineNo, igdbId: choice.igdbId, platform: choice.platform.trim(), status: choice.status,
|
||||
playtimeMinutes: hoursToMinutes(choice.hours),
|
||||
})));
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('IMPORT.SAVE_FAILED'));
|
||||
return;
|
||||
@@ -491,7 +471,7 @@ const ReviewRow = memo(function ReviewRow({index, row, choice, onChange: changeL
|
||||
);
|
||||
});
|
||||
|
||||
function History({imports, onOpen, onChanged, expireSession}) {
|
||||
function History({imports, onOpen, onChanged}) {
|
||||
const {t, i18n} = useTranslation();
|
||||
const [confirm, setConfirm] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -500,10 +480,7 @@ function History({imports, onOpen, onChanged, expireSession}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await call();
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('IMPORT.UNDO_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -5,11 +5,12 @@ import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import ImportPage, {initialChoice, stepOf} from './ImportPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||
|
||||
const json = (status, body) => ({ok: status < 400, status, json: async () => body});
|
||||
|
||||
const hades = {igdbId: 1, name: 'Hades', year: 2020, imageUrl: null, platforms: ['PC', 'Switch'], inLibrary: false};
|
||||
const hades2 = {igdbId: 2, name: 'Hades II', year: 2025, imageUrl: null, platforms: ['PC'], inLibrary: false};
|
||||
@@ -25,19 +26,20 @@ const ready = {
|
||||
],
|
||||
};
|
||||
|
||||
// routes: 'METHOD path after /imports' -> response or (init) => response; any other URL (Auth) answers the favorite
|
||||
// routes: 'METHOD path after /imports' -> response or (init) => response; Auth answers the favorite
|
||||
function renderPage(routes, entry = '/import') {
|
||||
globalThis.fetch = vi.fn(async (url, init) => {
|
||||
if (!url.includes('/api/library/imports')) return json(200, {settings: {favoritePlatform: 'Switch'}});
|
||||
const path = new URL(url, 'http://x').pathname.replace(/.*\/api\/library\/imports/, '');
|
||||
const answer = routes[`${init?.method ?? 'GET'} ${path}`];
|
||||
if (!answer) throw new Error(`no route ${init?.method ?? 'GET'} ${path}`);
|
||||
return typeof answer === 'function' ? answer(init) : answer;
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': json(200, {settings: {favoritePlatform: 'Switch'}}),
|
||||
...Object.fromEntries(Object.entries(routes).map(([key, answer]) => {
|
||||
const [method, path] = key.split(' ');
|
||||
return [`${method} /api/library/imports${path}`,
|
||||
typeof answer === 'function' ? (url, init) => answer(init) : answer];
|
||||
})),
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={[entry]}>
|
||||
<Routes>
|
||||
<Route path="/import" element={<PreferencesProvider><ImportPage/></PreferencesProvider>}/>
|
||||
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
|
||||
<Route path="/login" element={<p>login</p>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
@@ -250,3 +252,24 @@ describe('ImportPage', () => {
|
||||
expect(await screen.findByText('IMPORT.MATCHING', {}, {timeout: 4000})).toBeTruthy();
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
describe('review findings', () => {
|
||||
it('shows the review when Auth cannot be reached for the favorite', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
fetchRoutes({
|
||||
'GET /api/users/me': () => Promise.reject(new TypeError('Failed to fetch')),
|
||||
'GET /api/library/imports': json(200, {imports: []}),
|
||||
'GET /api/library/imports/5': json(200, ready),
|
||||
});
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/import?id=5']}>
|
||||
<Routes>
|
||||
<Route path="/import" element={<PreferencesProvider><CurrentUserProvider><ImportPage/></CurrentUserProvider></PreferencesProvider>}/>
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByLabelText('IMPORT.LINE: Hades', {}, {timeout: 2000})).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import AuthLayout from '../components/AuthLayout.jsx';
|
||||
import FeedbackAlert from '../components/FeedbackAlert.jsx';
|
||||
import {hasSession, markSession, safeReturnPath} from '../utils/auth.js';
|
||||
import {clientFetch} from '../utils/api.js';
|
||||
import {useCurrentUserActions} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
const LOGIN_ERROR_KEY = {
|
||||
INVALID_CREDENTIALS: 'LOGIN.ERROR_INVALID_CREDENTIALS',
|
||||
@@ -20,6 +21,7 @@ const LOGIN_ERROR_KEY = {
|
||||
export default function LoginPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const {reload: reloadUser} = useCurrentUserActions();
|
||||
const {state} = useLocation();
|
||||
const target = safeReturnPath(state?.from) ?? '/dashboard';
|
||||
|
||||
@@ -54,6 +56,7 @@ export default function LoginPage() {
|
||||
|
||||
if (response.ok) {
|
||||
markSession();
|
||||
reloadUser();
|
||||
navigate(target, {replace: true});
|
||||
} else {
|
||||
const minutes = Math.ceil(Number(response.headers.get('Retry-After') ?? 900) / 60);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router-dom';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {toast} from 'sonner';
|
||||
import {Check, Loader2, Play, Shuffle, Timer} from 'lucide-react';
|
||||
@@ -10,11 +10,9 @@ import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {saveGameStatus} from '../utils/backlog.js';
|
||||
import {formatPlaytime} from '../utils/games.js';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
|
||||
const BUDGETS = [
|
||||
@@ -40,8 +38,6 @@ const pickerQuery = (maxMinutes, mood, exclude) => {
|
||||
|
||||
export default function PlayNextPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [budget, setBudget] = useState(BUDGETS[3]);
|
||||
const [mood, setMood] = useState('ANY');
|
||||
const [games, setGames] = useState(null);
|
||||
@@ -52,10 +48,6 @@ export default function PlayNextPage() {
|
||||
const latestRequest = useRef(0);
|
||||
|
||||
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
const request = ++latestRequest.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
@@ -63,10 +55,7 @@ export default function PlayNextPage() {
|
||||
const response = await apiFetch(
|
||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`);
|
||||
if (request !== latestRequest.current) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setError('PLAY_NEXT.ERROR_LOAD');
|
||||
return;
|
||||
@@ -82,7 +71,7 @@ export default function PlayNextPage() {
|
||||
if (request === latestRequest.current) setLoading(false);
|
||||
}
|
||||
// No `t` here: a language change gives a new `t`, and a reload would replace the random picks.
|
||||
}, [navigate, expireSession]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load(budget.maxMinutes, mood, []);
|
||||
@@ -161,7 +150,7 @@ export default function PlayNextPage() {
|
||||
|
||||
{games?.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{games.map((game) => <PickCard key={game.appid} game={game} expireSession={expireSession}/>)}
|
||||
{games.map((game) => <PickCard key={game.appid} game={game}/>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -197,7 +186,7 @@ const emptyKey = (budget, mood) => {
|
||||
return budget.maxMinutes ? 'PLAY_NEXT.EMPTY_BUDGET' : 'PLAY_NEXT.EMPTY';
|
||||
};
|
||||
|
||||
function PickCard({game, expireSession}) {
|
||||
function PickCard({game}) {
|
||||
const {t} = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [started, setStarted] = useState(false);
|
||||
@@ -206,10 +195,7 @@ function PickCard({game, expireSession}) {
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await saveGameStatus(game.appid, 'PLAYING');
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
toast.error(t('BACKLOG.SAVE_FAILED'));
|
||||
return;
|
||||
|
||||
@@ -4,6 +4,7 @@ import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/reac
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import PublicProfilePage from './PublicProfilePage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
@@ -27,9 +28,9 @@ function mockBackends({profileStatus = 200, libraryStatus = 200, style, answer =
|
||||
}
|
||||
|
||||
const renderPage = (name = 'Lobo') => render(
|
||||
<MemoryRouter initialEntries={[`/u/${name}`]}><PreferencesProvider>
|
||||
<MemoryRouter initialEntries={[`/u/${name}`]}><PreferencesProvider><CurrentUserProvider>
|
||||
<Routes><Route path="/u/:name" element={<PublicProfilePage/>}/></Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
</CurrentUserProvider></PreferencesProvider></MemoryRouter>,
|
||||
);
|
||||
|
||||
describe('PublicProfilePage', () => {
|
||||
|
||||
+20
-60
@@ -1,5 +1,5 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {useNavigate, useSearchParams} from 'react-router-dom';
|
||||
import {useSearchParams} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
@@ -20,7 +20,7 @@ import FormField from '../components/FormField.jsx';
|
||||
import SteamAccountCard from '../components/SteamAccountCard.jsx';
|
||||
import {settingsSchema} from '../utils/validation.js';
|
||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||
import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
||||
import {AccentPicker, CornersPicker} from '../components/StylePickers.jsx';
|
||||
import ProfileStyleSettings from '../components/ProfileStyleSettings.jsx';
|
||||
@@ -28,8 +28,8 @@ import {normalizeProfileStyle} from '../utils/profileStyle.js';
|
||||
import useSaveProfileStyle from '../hooks/useSaveProfileStyle.js';
|
||||
import useSaveResponse from '../hooks/useSaveResponse.js';
|
||||
import {coverUrl} from '../utils/games.js';
|
||||
import {apiFetch, refreshSession} from '../utils/api.js';
|
||||
import {hasSession, markSession} from '../utils/auth.js';
|
||||
import {refreshSession} from '../utils/api.js';
|
||||
import {markSession} from '../utils/auth.js';
|
||||
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
|
||||
import {LANGUAGES, saveSettings} from '../utils/settings.js';
|
||||
import {MAX_DISPLAY_NAME_LENGTH} from '../utils/limits.js';
|
||||
@@ -60,14 +60,12 @@ const formatDate = (value, locale) => {
|
||||
|
||||
export default function SettingsPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const {isDarkMode, setDarkMode, setLanguage, appearance} = usePreferences();
|
||||
const saveAppearance = useSaveAppearance();
|
||||
const saveStyle = useSaveProfileStyle();
|
||||
const runSave = useSaveResponse();
|
||||
|
||||
const [profile, setProfile] = useState(null);
|
||||
const {user: profile, updateUser: updateProfile, reload: reloadUser} = useCurrentUserContext();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState(null);
|
||||
const [savingProfileStyle, setSavingProfileStyle] = useState(false);
|
||||
@@ -99,62 +97,26 @@ export default function SettingsPage() {
|
||||
}, [steamOutcome, setSearchParams, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
if (steamOutcome === 'linked') {
|
||||
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
|
||||
await refreshSession().catch(() => false);
|
||||
markSession();
|
||||
}
|
||||
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/me?v=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setLoadError('INFO.ERROR.LOAD_PROFILE');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setProfile(data);
|
||||
reset({displayName: data.displayName ?? ''});
|
||||
} catch (err) {
|
||||
console.error('Settings load error:', err);
|
||||
setLoadError('INFO.ERROR.NETWORK_FAILURE');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (steamOutcome === 'linked') {
|
||||
// Refresh reads the new Steam ID from the database, so Core gets it in the token at once.
|
||||
await refreshSession().catch(() => false);
|
||||
markSession();
|
||||
}
|
||||
const {ok, status, data} = await reloadUser();
|
||||
if (ok) reset({displayName: data.displayName ?? ''});
|
||||
else if (status !== 401) setLoadError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'INFO.ERROR.LOAD_PROFILE');
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
load();
|
||||
}, [navigate, expireSession, reset, steamOutcome]);
|
||||
}, [reloadUser, reset, steamOutcome]);
|
||||
|
||||
const persistSettings = async (send) => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return {ok: false};
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await send();
|
||||
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return {ok: false};
|
||||
}
|
||||
if (response.status === 401) return {ok: false};
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
@@ -185,7 +147,7 @@ export default function SettingsPage() {
|
||||
const handleFavoritePlatformChange = async (value) => {
|
||||
const platform = value === NO_FAVORITE ? null : value;
|
||||
if (await runSave(() => saveFavoritePlatform(platform))) {
|
||||
setProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
|
||||
updateProfile((p) => ({...p, settings: {...p.settings, favoritePlatform: platform}}));
|
||||
toast.success(t('SETTINGS.SAVED'));
|
||||
}
|
||||
};
|
||||
@@ -194,14 +156,14 @@ export default function SettingsPage() {
|
||||
const next = !profile?.settings?.publicProfile;
|
||||
const result = await persistSettings(() => saveSettings({publicProfile: next}));
|
||||
if (result.ok) {
|
||||
setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
|
||||
updateProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
|
||||
}
|
||||
};
|
||||
|
||||
// Shown at once; the pickers wait for the answer, so the saves reach Auth one after the other.
|
||||
const handleProfileStyleChange = async (changes) => {
|
||||
const previous = normalizeProfileStyle(profile?.settings?.profileStyle);
|
||||
const showProfileStyle = (next) => setProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
|
||||
const showProfileStyle = (next) => updateProfile((p) => ({...p, settings: {...p.settings, profileStyle: next}}));
|
||||
showProfileStyle({...previous, ...changes});
|
||||
setSavingProfileStyle(true);
|
||||
showProfileStyle(await saveStyle(changes, previous) ?? previous);
|
||||
@@ -222,9 +184,7 @@ export default function SettingsPage() {
|
||||
const publicProfile = !!profile?.settings?.publicProfile && !!values.displayName;
|
||||
const result = await persistSettings(() => saveSettings({displayName: values.displayName, publicProfile}));
|
||||
if (result.ok) {
|
||||
setProfile((p) => (p ? {
|
||||
...p, displayName: values.displayName, settings: {...p.settings, publicProfile},
|
||||
} : p));
|
||||
updateProfile((p) => ({...p, displayName: values.displayName, settings: {...p.settings, publicProfile}}));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -382,7 +342,7 @@ export default function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
<SteamAccountCard steamId={profile?.steamId} verified={!!profile?.steamIdVerified}
|
||||
onUnlinked={() => setProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
|
||||
onUnlinked={() => updateProfile((p) => ({...p, steamId: null, steamIdVerified: false}))}/>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import SettingsPage from './SettingsPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const mock = await import('../test/i18n-mock.js');
|
||||
@@ -32,7 +33,7 @@ function renderPage(me, {redirectUrl = STEAM_URL, entry = '/settings'} = {}) {
|
||||
}
|
||||
return {ok: true, status: 200, json: async () => me};
|
||||
});
|
||||
render(<MemoryRouter initialEntries={[entry]}><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
render(<MemoryRouter initialEntries={[entry]}><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
|
||||
}
|
||||
|
||||
describe('SettingsPage Steam account', () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {MemoryRouter} from 'react-router-dom';
|
||||
import {toast} from 'sonner';
|
||||
import SettingsPage from './SettingsPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
// Like react-i18next: a language change re-renders every subscriber with a fresh `t`.
|
||||
vi.mock('react-i18next', async () => {
|
||||
@@ -44,7 +45,7 @@ describe('SettingsPage Steam toast', () => {
|
||||
});
|
||||
|
||||
it('shows the linked toast once, also after a language change', async () => {
|
||||
render(<MemoryRouter initialEntries={['/settings?steam=linked']}><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
render(<MemoryRouter initialEntries={['/settings?steam=linked']}><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
|
||||
await screen.findByText('SETTINGS.STEAM_LINK.VERIFIED');
|
||||
expect(toast.success).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing
|
||||
import {MemoryRouter} from 'react-router-dom';
|
||||
import SettingsPage from './SettingsPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const mock = await import('../test/i18n-mock.js');
|
||||
@@ -26,7 +27,7 @@ function renderPage(me) {
|
||||
// Both PUTs answer 204 without a body, like Auth does.
|
||||
? {ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }}
|
||||
: {ok: true, status: 200, json: async () => me}));
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
render(<MemoryRouter><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +67,7 @@ describe('SettingsPage public profile', () => {
|
||||
|
||||
it('builds a save on the one before it, also while that one is still open', async () => {
|
||||
const auth = fakeAuth(profile());
|
||||
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
|
||||
render(<MemoryRouter><PreferencesProvider><CurrentUserProvider><SettingsPage/></CurrentUserProvider></PreferencesProvider></MemoryRouter>);
|
||||
|
||||
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
|
||||
await waitFor(() => expect(puts()).toHaveLength(1));
|
||||
|
||||
+1
-12
@@ -1,5 +1,3 @@
|
||||
import {useEffect} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {RefreshCw} from 'lucide-react';
|
||||
|
||||
@@ -13,22 +11,13 @@ import TopGamesStats from '../components/stats/TopGamesStats.jsx';
|
||||
import TasteStats from '../components/stats/TasteStats.jsx';
|
||||
import HabitsStats from '../components/stats/HabitsStats.jsx';
|
||||
import FinishedStats from '../components/stats/FinishedStats.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import useUserStats from '../hooks/useUserStats.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const ALL_GROUPS = [];
|
||||
|
||||
export default function StatsPage() {
|
||||
const {t} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const loggedIn = hasSession();
|
||||
const {stats, failed, reload} = useUserStats(ALL_GROUPS, expireSession, loggedIn);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedIn) navigate('/login');
|
||||
}, [loggedIn, navigate]);
|
||||
const {stats, failed, reload} = useUserStats(ALL_GROUPS);
|
||||
|
||||
return (
|
||||
<PageContainer className="p-4 md:p-8 space-y-6">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useNavigate} from 'react-router-dom';
|
||||
import {useTranslation} from 'react-i18next';
|
||||
import {ExternalLink, Gift, Percent} from 'lucide-react';
|
||||
|
||||
@@ -9,36 +8,25 @@ import {Card} from '@/components/ui/card';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import GameCover from '../components/GameCover.jsx';
|
||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||
import useSessionExpired from '../hooks/useSessionExpired.js';
|
||||
import PageContainer from '../components/PageContainer.jsx';
|
||||
import {apiFetch} from '../utils/api.js';
|
||||
import {hasSession} from '../utils/auth.js';
|
||||
|
||||
const formatAdded = (instant, locale) =>
|
||||
instant ? new Date(instant).toLocaleDateString(locale, {year: 'numeric', month: 'short', day: 'numeric'}) : null;
|
||||
|
||||
export default function WishlistPage() {
|
||||
const {t, i18n} = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const expireSession = useSessionExpired();
|
||||
const [games, setGames] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [onlyOnSale, setOnlyOnSale] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSession()) {
|
||||
navigate('/login');
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const response = await apiFetch(`${import.meta.env.VITE_CORE_API_URL}/api/library/wishlist`);
|
||||
if (cancelled) return;
|
||||
if (response.status === 401) {
|
||||
expireSession();
|
||||
return;
|
||||
}
|
||||
if (response.status === 401) return;
|
||||
if (!response.ok) {
|
||||
setError('WISHLIST.ERROR_LOAD');
|
||||
return;
|
||||
@@ -53,7 +41,7 @@ export default function WishlistPage() {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [navigate, expireSession]);
|
||||
}, []);
|
||||
|
||||
const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0);
|
||||
const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes, useLocation} from 'react-router-dom';
|
||||
import RequireSession from '../components/RequireSession.jsx';
|
||||
import WishlistPage from './WishlistPage.jsx';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
|
||||
@@ -19,7 +20,7 @@ function LoginState() {
|
||||
const renderPage = () => render(
|
||||
<MemoryRouter initialEntries={['/wishlist']}><PreferencesProvider>
|
||||
<Routes>
|
||||
<Route path="/wishlist" element={<WishlistPage/>}/>
|
||||
<Route element={<RequireSession/>}><Route path="/wishlist" element={<WishlistPage/>}/></Route>
|
||||
<Route path="/login" element={<LoginState/>}/>
|
||||
</Routes>
|
||||
</PreferencesProvider></MemoryRouter>,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {cleanup, render, screen} from '@testing-library/react';
|
||||
import {MemoryRouter, Route, Routes} from 'react-router-dom';
|
||||
import {switchLanguage} from '../test/i18n-mock.js';
|
||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||
import Dashboard from './Dashboard.jsx';
|
||||
import GameDetail from './GameDetail.jsx';
|
||||
import GameLibrary from './GameLibrary.jsx';
|
||||
@@ -14,12 +15,12 @@ import WishlistPage from './WishlistPage.jsx';
|
||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||
|
||||
const pages = [
|
||||
['Dashboard', '/', '/', <Dashboard/>, 'INFO.ERROR.LOAD_PROFILE'],
|
||||
['GameDetail', '/games/:appid', '/games/220', <GameDetail/>, 'GAME_DETAIL.ERROR_LOAD'],
|
||||
['GameLibrary', '/library', '/library', <GameLibrary/>, 'LIBRARY.ERROR_LOAD'],
|
||||
['PublicProfilePage', '/u/:name', '/u/Lobo', <PublicProfilePage/>, 'PROFILE.ERROR_LOAD'],
|
||||
['SettingsPage', '/settings', '/settings', <SettingsPage/>, 'INFO.ERROR.LOAD_PROFILE'],
|
||||
['WishlistPage', '/wishlist', '/wishlist', <WishlistPage/>, 'WISHLIST.ERROR_LOAD'],
|
||||
['Dashboard', '/', '/', Dashboard, 'INFO.ERROR.LOAD_PROFILE'],
|
||||
['GameDetail', '/games/:appid', '/games/220', GameDetail, 'GAME_DETAIL.ERROR_LOAD'],
|
||||
['GameLibrary', '/library', '/library', GameLibrary, 'LIBRARY.ERROR_LOAD'],
|
||||
['PublicProfilePage', '/u/:name', '/u/Lobo', PublicProfilePage, 'PROFILE.ERROR_LOAD'],
|
||||
['SettingsPage', '/settings', '/settings', SettingsPage, 'INFO.ERROR.LOAD_PROFILE'],
|
||||
['WishlistPage', '/wishlist', '/wishlist', WishlistPage, 'WISHLIST.ERROR_LOAD'],
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -34,10 +35,10 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('a language change', () => {
|
||||
it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, page, errorKey) => {
|
||||
it.each(pages)('%s: loads nothing again and shows the error in the new language', async (name, path, url, Page, errorKey) => {
|
||||
const tree = () => (
|
||||
<MemoryRouter initialEntries={[url]}>
|
||||
<PreferencesProvider><Routes><Route path={path} element={page}/></Routes></PreferencesProvider>
|
||||
<PreferencesProvider><CurrentUserProvider><Routes><Route path={path} element={<Page/>}/></Routes></CurrentUserProvider></PreferencesProvider>
|
||||
</MemoryRouter>
|
||||
);
|
||||
const {rerender} = render(tree());
|
||||
|
||||
Reference in new issue
Block a user