Public Access
fix: abort old loads, stale sync state, warn before unsaved edits are lost
This commit is contained in:
1 parent
cbafa3d540
commit
9ddc50f819
28 files changed
+753
-121
No files matched your search
@@ -205,6 +205,7 @@
|
|||||||
"SYNCING": "Synchronisiere mit Steam …",
|
"SYNCING": "Synchronisiere mit Steam …",
|
||||||
"SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.",
|
"SYNCING_FIRST_TIME": "Erste Synchronisation mit Steam läuft. Deine Spiele erscheinen gleich.",
|
||||||
"SYNC_FAILED": "Synchronisation fehlgeschlagen. Bitte später erneut versuchen.",
|
"SYNC_FAILED": "Synchronisation fehlgeschlagen. Bitte später erneut versuchen.",
|
||||||
|
"SYNC_STALE": "Die Synchronisation dauert länger als sonst. Bitte lade die Seite später neu.",
|
||||||
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}",
|
"LAST_SYNCED": "Zuletzt synchronisiert {{when}}",
|
||||||
"FILTER_ALL": "Alle",
|
"FILTER_ALL": "Alle",
|
||||||
"SEARCH_PLACEHOLDER": "Spiel suchen …",
|
"SEARCH_PLACEHOLDER": "Spiel suchen …",
|
||||||
@@ -705,5 +706,8 @@
|
|||||||
"RETRY": "Nochmal versuchen",
|
"RETRY": "Nochmal versuchen",
|
||||||
"RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.",
|
"RETRY_FAILED": "Der Import konnte nicht neu gestartet werden.",
|
||||||
"ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter."
|
"ATTEMPT": "Versuch {{attempt}}: der letzte Versuch kam nicht weiter."
|
||||||
|
},
|
||||||
|
"UNSAVED_CHANGES": {
|
||||||
|
"CONFIRM": "Du hast Änderungen, die nicht gespeichert sind. Seite verlassen und die Änderungen verwerfen?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,6 +205,7 @@
|
|||||||
"SYNCING": "Syncing with Steam …",
|
"SYNCING": "Syncing with Steam …",
|
||||||
"SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.",
|
"SYNCING_FIRST_TIME": "First sync with Steam in progress. Your games will appear here shortly.",
|
||||||
"SYNC_FAILED": "Sync failed. Please try again later.",
|
"SYNC_FAILED": "Sync failed. Please try again later.",
|
||||||
|
"SYNC_STALE": "The sync takes longer than usual. Reload the page later.",
|
||||||
"LAST_SYNCED": "Last synced {{when}}",
|
"LAST_SYNCED": "Last synced {{when}}",
|
||||||
"FILTER_ALL": "All",
|
"FILTER_ALL": "All",
|
||||||
"SEARCH_PLACEHOLDER": "Search games …",
|
"SEARCH_PLACEHOLDER": "Search games …",
|
||||||
@@ -705,5 +706,8 @@
|
|||||||
"RETRY": "Try again",
|
"RETRY": "Try again",
|
||||||
"RETRY_FAILED": "Could not start the import again.",
|
"RETRY_FAILED": "Could not start the import again.",
|
||||||
"ATTEMPT": "Try {{attempt}}: the last try made no progress."
|
"ATTEMPT": "Try {{attempt}}: the last try made no progress."
|
||||||
|
},
|
||||||
|
"UNSAVED_CHANGES": {
|
||||||
|
"CONFIRM": "You have changes that are not saved. Leave this page and lose them?"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ import PageBackground from './components/PageBackground.jsx';
|
|||||||
import {usePreferences} from './context/PreferencesContext.jsx';
|
import {usePreferences} from './context/PreferencesContext.jsx';
|
||||||
import {clearSession} from './utils/auth.js';
|
import {clearSession} from './utils/auth.js';
|
||||||
import useSession from './hooks/useSession.js';
|
import useSession from './hooks/useSession.js';
|
||||||
|
import {UnsavedChangesGuard} from './hooks/useUnsavedChanges.js';
|
||||||
import {logout} from './utils/api.js';
|
import {logout} from './utils/api.js';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -84,6 +85,7 @@ function App() {
|
|||||||
</Link>
|
</Link>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
<UnsavedChangesGuard/>
|
||||||
<Toaster richColors position="bottom-right"/>
|
<Toaster richColors position="bottom-right"/>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {cn} from '@/lib/utils';
|
|||||||
import {STATUS_META} from './GameStatusBadge.jsx';
|
import {STATUS_META} from './GameStatusBadge.jsx';
|
||||||
import {NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
|
import {NO_PRIORITY, saveBacklogEntry, toBacklogRequest} from '../utils/backlog.js';
|
||||||
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
import {GAME_PRIORITIES, GAME_STATUSES, MAX_NOTES_LENGTH} from '../utils/limits.js';
|
||||||
|
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
|
||||||
|
|
||||||
const toForm = (entry) => ({
|
const toForm = (entry) => ({
|
||||||
status: entry.status ?? 'BACKLOG',
|
status: entry.status ?? 'BACKLOG',
|
||||||
@@ -33,6 +34,7 @@ export default function BacklogCard({game, onSaved}) {
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const dirty = JSON.stringify(form) !== JSON.stringify(saved);
|
const dirty = JSON.stringify(form) !== JSON.stringify(saved);
|
||||||
|
useUnsavedChanges(dirty);
|
||||||
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
|
const set = (field) => (value) => setForm((f) => ({...f, [field]: value}));
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {Input} from '@/components/ui/input';
|
|||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
import {hoursToMinutes, removeGame, toHours, updateManualGame} from '../utils/manualGames.js';
|
||||||
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
import {MAX_PLATFORM_LENGTH} from '../utils/limits.js';
|
||||||
|
import useUnsavedChanges, {discardChanges} from '../hooks/useUnsavedChanges.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
|
* Platform and hours of a game added by hand, and removing it. Steam games get both from the sync.
|
||||||
@@ -26,6 +27,7 @@ export default function ManualGameCard({game, onSaved}) {
|
|||||||
|
|
||||||
const playtimeMinutes = hoursToMinutes(hours);
|
const playtimeMinutes = hoursToMinutes(hours);
|
||||||
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
|
const dirty = platform.trim() !== (game.platform ?? '') || playtimeMinutes !== game.playtimeMinutes;
|
||||||
|
useUnsavedChanges(dirty);
|
||||||
|
|
||||||
const run = async (request, onOk) => {
|
const run = async (request, onOk) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -52,6 +54,8 @@ export default function ManualGameCard({game, onSaved}) {
|
|||||||
|
|
||||||
const remove = () => run(() => removeGame(game.appid), () => {
|
const remove = () => run(() => removeGame(game.appid), () => {
|
||||||
toast.success(t('MANUAL_GAME.REMOVED'));
|
toast.success(t('MANUAL_GAME.REMOVED'));
|
||||||
|
// The game is gone, so are its unsaved edits on this page.
|
||||||
|
discardChanges();
|
||||||
navigate('/library', {replace: true});
|
navigate('/library', {replace: true});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// @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 ManualGameCard from './ManualGameCard.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: -1025, platform: 'PS5', playtimeMinutes: 90};
|
||||||
|
|
||||||
|
const answer = (routes) => fetchRoutes({'GET /api/users/me': json(200, {settings: {}}), ...routes});
|
||||||
|
|
||||||
|
const renderCard = (onSaved = vi.fn()) => renderWithProviders(<ManualGameCard game={game} onSaved={onSaved}/>,
|
||||||
|
{path: '/library/:appid', entries: ['/library/-1025'], routes: {'/library': <p>library page</p>}});
|
||||||
|
|
||||||
|
const saveButton = () => screen.getByRole('button', {name: 'BACKLOG.SAVE'});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ManualGameCard', () => {
|
||||||
|
it('saves the trimmed platform and the minutes, then reports them', async () => {
|
||||||
|
answer({'PUT /api/library/games/-1025/manual': json(200, {})});
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
renderCard(onSaved);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: ' Switch '}});
|
||||||
|
fireEvent.change(screen.getByLabelText('MANUAL_GAME.HOURS'), {target: {value: '2'}});
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSaved).toHaveBeenCalledWith({platform: 'Switch', playtimeMinutes: 120}));
|
||||||
|
const [url, init] = fetch.mock.calls.find(([, call]) => call?.method === 'PUT');
|
||||||
|
expect(url).toContain('/api/library/games/-1025/manual');
|
||||||
|
expect(JSON.parse(init.body)).toEqual({platform: 'Switch', playtimeMinutes: 120});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the save button disabled while nothing changed', () => {
|
||||||
|
answer({});
|
||||||
|
renderCard();
|
||||||
|
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'PS5 '}});
|
||||||
|
expect(saveButton().disabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the game only after a confirm and opens the library', async () => {
|
||||||
|
answer({'DELETE /api/library/games/-1025': json(204, null)});
|
||||||
|
const {router} = renderCard();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE'}));
|
||||||
|
expect(fetch.mock.calls.some(([, init]) => init?.method === 'DELETE')).toBe(false);
|
||||||
|
fireEvent.click(screen.getByRole('button', {name: 'MANUAL_GAME.REMOVE_YES'}));
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/library'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an error when the save fails', async () => {
|
||||||
|
answer({'PUT /api/library/games/-1025/manual': json(500, {})});
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
renderCard(onSaved);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}});
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('BACKLOG.SAVE_FAILED'));
|
||||||
|
expect(onSaved).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows no toast on a 401', async () => {
|
||||||
|
answer({'PUT /api/library/games/-1025/manual': json(401, {}), 'POST /api/auth/refresh': json(401, {})});
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
renderCard(onSaved);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText('MANUAL_GAME.PLATFORM'), {target: {value: 'Switch'}});
|
||||||
|
fireEvent.click(saveButton());
|
||||||
|
|
||||||
|
await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.endsWith('/api/auth/refresh'))).toBe(true));
|
||||||
|
await waitFor(() => expect(saveButton().disabled).toBe(false));
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
expect(toast.success).not.toHaveBeenCalled();
|
||||||
|
expect(onSaved).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {CircleAlert, CircleCheck, RefreshCw} from 'lucide-react';
|
import {CircleAlert, CircleCheck, Clock, RefreshCw} from 'lucide-react';
|
||||||
|
|
||||||
const formatRelative = (iso, locale) => {
|
const formatRelative = (iso, locale) => {
|
||||||
if (!iso) return null;
|
if (!iso) return null;
|
||||||
@@ -12,7 +12,7 @@ const formatRelative = (iso, locale) => {
|
|||||||
return rtf.format(-Math.round(diffSec / 86400), 'day');
|
return rtf.format(-Math.round(diffSec / 86400), 'day');
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) {
|
export default function SyncStatusBadge({status, lastSyncedAt, isRunning, stale}) {
|
||||||
const {t, i18n} = useTranslation();
|
const {t, i18n} = useTranslation();
|
||||||
|
|
||||||
if (isRunning) {
|
if (isRunning) {
|
||||||
@@ -24,6 +24,15 @@ export default function SyncStatusBadge({status, lastSyncedAt, isRunning}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stale) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Clock className="size-4"/>
|
||||||
|
<span>{t('LIBRARY.SYNC_STALE')}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 'FAILED') {
|
if (status === 'FAILED') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import {useCallback, useEffect, useRef} from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns `nextSignal()` for a load that can run again before the last one answered: it aborts the last request and
|
||||||
|
* gives the signal for the next one. Unmounting aborts too. After the await, a load stops when its signal is aborted.
|
||||||
|
*/
|
||||||
|
export default function useAbortPrevious() {
|
||||||
|
const controller = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => () => controller.current?.abort(), []);
|
||||||
|
|
||||||
|
return useCallback(() => {
|
||||||
|
controller.current?.abort();
|
||||||
|
controller.current = new AbortController();
|
||||||
|
return controller.current.signal;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, describe, expect, it} from 'vitest';
|
||||||
|
import {cleanup, renderHook, waitFor} from '@testing-library/react';
|
||||||
|
import useCurrentUser from './useCurrentUser.js';
|
||||||
|
import {CurrentUserProvider, useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
const renderUser = () => renderHook(() => useCurrentUser(true), {wrapper: CurrentUserProvider});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useCurrentUser', () => {
|
||||||
|
it('maps the user from /me and normalizes the profile style', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {role: 'ADMIN', activeRole: 'USER', displayName: 'Lobo',
|
||||||
|
settings: {profileStyle: {accent: 'gold', corners: 'round', bannerAppid: 220, backgroundAppid: -5}}})});
|
||||||
|
const {result} = renderUser();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current).not.toBeNull());
|
||||||
|
expect(result.current).toEqual({role: 'ADMIN', activeRole: 'USER', displayName: 'Lobo',
|
||||||
|
profileStyle: {accent: 'violet', corners: 'round', bannerAppid: 220, backgroundAppid: null}});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the role as active role when there is no "view as"', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {role: 'USER', displayName: 'Lobo', settings: {}})});
|
||||||
|
const {result} = renderUser();
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current?.activeRole).toBe('USER'));
|
||||||
|
expect(result.current.profileStyle).toEqual({accent: 'violet', corners: 'default', bannerAppid: null,
|
||||||
|
backgroundAppid: null});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when not enabled', async () => {
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
fetchRoutes({'GET /api/users/me': json(200, {role: 'USER', settings: {}})});
|
||||||
|
const {result} = renderHook(() => ({mapped: useCurrentUser(false), user: useCurrentUserContext().user}),
|
||||||
|
{wrapper: CurrentUserProvider});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.user?.role).toBe('USER'));
|
||||||
|
expect(result.current.mapped).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null without a session and asks for no user', () => {
|
||||||
|
const fetch = fetchRoutes({});
|
||||||
|
const {result} = renderUser();
|
||||||
|
|
||||||
|
expect(result.current).toBeNull();
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,11 +3,9 @@ import {useTranslation} from 'react-i18next';
|
|||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {apiFetch} from '../utils/api.js';
|
||||||
|
|
||||||
const RELOAD_DELAY_MS = 4000;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a library sync (POST /api/library/sync), reports the result as a toast and calls
|
* Starts a library sync (POST /api/library/sync), reports the result as a toast and calls onStarted, e.g. the
|
||||||
* onStarted after a short delay, so the caller can reload once Steam data has arrived.
|
* `watch` of useLibrarySync, which reloads the data when the run is over.
|
||||||
*/
|
*/
|
||||||
export default function useForceSync(onStarted) {
|
export default function useForceSync(onStarted) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
@@ -30,7 +28,7 @@ export default function useForceSync(onStarted) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.success(t('LIBRARY.SYNC_STARTED'));
|
toast.success(t('LIBRARY.SYNC_STARTED'));
|
||||||
setTimeout(onStarted, RELOAD_DELAY_MS);
|
onStarted();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('sync trigger error:', err);
|
console.error('sync trigger error:', err);
|
||||||
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
toast.error(t('INFO.ERROR.NETWORK_FAILURE'));
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, renderHook} from '@testing-library/react';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
import useForceSync from './useForceSync.js';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), warning: vi.fn(), error: vi.fn()}}));
|
||||||
|
|
||||||
|
const SYNC = 'POST /api/library/sync';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
const trigger = async (onStarted) => {
|
||||||
|
const {result} = renderHook(() => useForceSync(onStarted));
|
||||||
|
await act(() => result.current.trigger());
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useForceSync', () => {
|
||||||
|
it('calls onStarted at once after the start, and not again later', async () => {
|
||||||
|
fetchRoutes({[SYNC]: json(202, {})});
|
||||||
|
const onStarted = vi.fn();
|
||||||
|
|
||||||
|
await trigger(onStarted);
|
||||||
|
expect(onStarted).toHaveBeenCalledTimes(1);
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(10_000));
|
||||||
|
|
||||||
|
expect(onStarted).toHaveBeenCalledTimes(1);
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('LIBRARY.SYNC_STARTED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says how long to wait on a 429', async () => {
|
||||||
|
fetchRoutes({[SYNC]: json(429, {retryAfterSeconds: 42})});
|
||||||
|
const onStarted = vi.fn();
|
||||||
|
|
||||||
|
await trigger(onStarted);
|
||||||
|
|
||||||
|
expect(toast.warning).toHaveBeenCalledWith('LIBRARY.SYNC_COOLDOWN');
|
||||||
|
expect(onStarted).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the start fails', async () => {
|
||||||
|
fetchRoutes({[SYNC]: json(500, {})});
|
||||||
|
await trigger(vi.fn());
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('LIBRARY.SYNC_FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so when the network is down', async () => {
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
fetchRoutes({[SYNC]: () => Promise.reject(new TypeError('Failed to fetch'))});
|
||||||
|
await trigger(vi.fn());
|
||||||
|
expect(toast.error).toHaveBeenCalledWith('INFO.ERROR.NETWORK_FAILURE');
|
||||||
|
});
|
||||||
|
});
|
||||||
+40
-22
@@ -1,4 +1,4 @@
|
|||||||
import {useEffect, useRef, useState} from 'react';
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {apiFetch} from '../utils/api.js';
|
||||||
import {hasSession} from '../utils/auth.js';
|
import {hasSession} from '../utils/auth.js';
|
||||||
|
|
||||||
@@ -7,28 +7,42 @@ const MAX_POLL_DURATION_MS = 120_000;
|
|||||||
const INITIAL_SYNC_GRACE_MS = 15_000;
|
const INITIAL_SYNC_GRACE_MS = 15_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Polls /api/library/sync-status while a sync is running (or expected),
|
* Polls /api/library/sync-status while a sync is running (or expected), and fires onComplete once when the run ends.
|
||||||
* and fires onComplete once when the run transitions to OK / FAILED.
|
* A run that still runs after 2 minutes is shown as `stale` and polling stops. `watch()` polls again after the
|
||||||
|
* user started a sync: that run counts as ended when `lastSyncedAt` changes, also when no poll saw it running.
|
||||||
*
|
*
|
||||||
* @param {Object} opts
|
* @param {Object} opts
|
||||||
* @param {boolean} opts.enabled - skip work entirely when false (e.g. user has no steamId)
|
* @param {boolean} opts.enabled - skip work entirely when false (e.g. user has no steamId)
|
||||||
* @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the
|
* @param {boolean} opts.expectInitialSync - if true and no row yet, keep polling (covers the
|
||||||
* gap between async sync dispatch and first DB write)
|
* gap between async sync dispatch and first DB write)
|
||||||
* @param {() => void} opts.onComplete - called once per transition to OK/FAILED
|
* @param {() => void} opts.onComplete - called once per run that ends
|
||||||
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, error: string|null}}
|
* @returns {{status: string|null, lastSyncedAt: string|null, isRunning: boolean, stale: boolean, error: string|null,
|
||||||
|
* watch: () => void}}
|
||||||
*/
|
*/
|
||||||
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) {
|
export default function useLibrarySync({enabled, expectInitialSync = false, onComplete}) {
|
||||||
const [state, setState] = useState({status: null, lastSyncedAt: null, error: null});
|
const [state, setState] = useState({status: null, lastSyncedAt: null, error: null, stale: false});
|
||||||
const previousRunningRef = useRef(null);
|
// count starts polling again; since is the last known end, so a new one shows that the started run is over
|
||||||
|
const [watched, setWatched] = useState({count: 0, since: undefined});
|
||||||
|
const lastSyncedAtRef = useRef(undefined);
|
||||||
|
// The count of the last watch whose run ended; a later restart of the polling must not report that run again.
|
||||||
|
const endedWatch = useRef(0);
|
||||||
const onCompleteRef = useRef(onComplete);
|
const onCompleteRef = useRef(onComplete);
|
||||||
onCompleteRef.current = onComplete;
|
onCompleteRef.current = onComplete;
|
||||||
|
|
||||||
|
const watch = useCallback(() => {
|
||||||
|
setWatched((current) => ({count: current.count + 1, since: lastSyncedAtRef.current}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return undefined;
|
if (!enabled) return undefined;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
let wasRunning = false;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
|
const watching = watched.count > endedWatch.current;
|
||||||
|
// Before the first answer the last end is unknown; then the first answer is the one to compare with.
|
||||||
|
let since = watched.since;
|
||||||
|
|
||||||
const fetchOnce = async () => {
|
const fetchOnce = async () => {
|
||||||
if (!hasSession()) return;
|
if (!hasSession()) return;
|
||||||
@@ -51,22 +65,24 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
|||||||
|
|
||||||
const status = json.lastSyncStatus;
|
const status = json.lastSyncStatus;
|
||||||
const lastSyncedAt = json.lastSyncedAt;
|
const lastSyncedAt = json.lastSyncedAt;
|
||||||
setState({status, lastSyncedAt, error: null});
|
lastSyncedAtRef.current = lastSyncedAt;
|
||||||
|
|
||||||
const isRunning = status === 'RUNNING';
|
const isRunning = status === 'RUNNING';
|
||||||
const wasRunning = previousRunningRef.current;
|
|
||||||
|
|
||||||
if (wasRunning === true && !isRunning) {
|
|
||||||
onCompleteRef.current?.();
|
|
||||||
}
|
|
||||||
previousRunningRef.current = isRunning;
|
|
||||||
|
|
||||||
const elapsed = Date.now() - startedAt;
|
const elapsed = Date.now() - startedAt;
|
||||||
const waitingForInitial =
|
const stale = isRunning && elapsed >= MAX_POLL_DURATION_MS;
|
||||||
expectInitialSync && status == null && elapsed < INITIAL_SYNC_GRACE_MS;
|
setState({status, lastSyncedAt, error: null, stale});
|
||||||
const stillWaiting = isRunning || waitingForInitial;
|
|
||||||
|
|
||||||
if (stillWaiting && elapsed < MAX_POLL_DURATION_MS) {
|
if (since === undefined) since = lastSyncedAt;
|
||||||
|
const ended = !isRunning && (wasRunning || (watching && lastSyncedAt !== since));
|
||||||
|
if (ended) {
|
||||||
|
endedWatch.current = watched.count;
|
||||||
|
onCompleteRef.current?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
wasRunning = isRunning;
|
||||||
|
|
||||||
|
const waitingForInitial = expectInitialSync && status == null;
|
||||||
|
const waitingForStart = (watching || waitingForInitial) && elapsed < INITIAL_SYNC_GRACE_MS;
|
||||||
|
if ((isRunning && !stale) || waitingForStart) {
|
||||||
timer = setTimeout(fetchOnce, POLL_INTERVAL_MS);
|
timer = setTimeout(fetchOnce, POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -82,12 +98,14 @@ export default function useLibrarySync({enabled, expectInitialSync = false, onCo
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (timer) clearTimeout(timer);
|
if (timer) clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [enabled, expectInitialSync]);
|
}, [enabled, expectInitialSync, watched]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: state.status,
|
status: state.status,
|
||||||
lastSyncedAt: state.lastSyncedAt,
|
lastSyncedAt: state.lastSyncedAt,
|
||||||
isRunning: state.status === 'RUNNING',
|
isRunning: state.status === 'RUNNING' && !state.stale,
|
||||||
|
stale: state.stale,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
|
watch,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, renderHook} from '@testing-library/react';
|
||||||
|
import useLibrarySync from './useLibrarySync.js';
|
||||||
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
|
||||||
|
const status = (lastSyncStatus, lastSyncedAt) => json(200, {lastSyncStatus, lastSyncedAt});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
localStorage.setItem('tbp_session', '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.useRealTimers();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useLibrarySync', () => {
|
||||||
|
it('shows a sync that runs longer than the poll limit as stale, not as running forever', async () => {
|
||||||
|
const fetch = fetchRoutes({'GET /api/library/sync-status': status('RUNNING', null)});
|
||||||
|
const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete: () => {}}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
expect(result.current.isRunning).toBe(true);
|
||||||
|
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(125_000));
|
||||||
|
|
||||||
|
expect(result.current.isRunning).toBe(false);
|
||||||
|
expect(result.current.stale).toBe(true);
|
||||||
|
const calls = fetch.mock.calls.length;
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(30_000));
|
||||||
|
expect(fetch.mock.calls.length).toBe(calls);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('after watch() reports the end of a sync once, also when it never saw it running', async () => {
|
||||||
|
let answer = status('OK', '2026-09-30T08:00:00Z');
|
||||||
|
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
|
||||||
|
act(() => result.current.watch());
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
// The sync was so fast that the next poll already sees it done.
|
||||||
|
answer = status('OK', '2026-09-30T08:05:00Z');
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(3_000));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(30_000));
|
||||||
|
|
||||||
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports the end of a running sync once', async () => {
|
||||||
|
let answer = status('RUNNING', '2026-09-30T08:00:00Z');
|
||||||
|
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
const {result} = renderHook(() => useLibrarySync({enabled: true, onComplete}));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
|
||||||
|
answer = status('FAILED', '2026-09-30T08:01:00Z');
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(3_000));
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(30_000));
|
||||||
|
|
||||||
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||||
|
expect(result.current.status).toBe('FAILED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onComplete only once when expectInitialSync flips after the watched run ended', async () => {
|
||||||
|
let answer = status('OK', '2026-09-30T08:00:00Z');
|
||||||
|
fetchRoutes({'GET /api/library/sync-status': () => answer});
|
||||||
|
const onComplete = vi.fn();
|
||||||
|
const {result, rerender} = renderHook(
|
||||||
|
({expectInitialSync}) => useLibrarySync({enabled: true, expectInitialSync, onComplete}),
|
||||||
|
{initialProps: {expectInitialSync: false}});
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
|
||||||
|
act(() => result.current.watch());
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
answer = status('OK', '2026-09-30T08:05:00Z');
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(3_000));
|
||||||
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// onComplete loads the list: isLoading goes true and back to false, and with 0 games expectInitialSync follows.
|
||||||
|
rerender({expectInitialSync: true});
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
rerender({expectInitialSync: false});
|
||||||
|
await act(() => vi.advanceTimersByTimeAsync(0));
|
||||||
|
|
||||||
|
expect(onComplete).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {useEffect} from 'react';
|
||||||
|
import {useBlocker} from 'react-router-dom';
|
||||||
|
import {useTranslation} from 'react-i18next';
|
||||||
|
import {hasSession} from '../utils/auth.js';
|
||||||
|
|
||||||
|
// One entry per component with unsaved edits. A router allows only one blocker, and GameDetail shows two such cards.
|
||||||
|
const owners = new Set();
|
||||||
|
let discardNext = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The next navigation drops the unsaved edits without asking, e.g. after the game itself was removed. Only that one:
|
||||||
|
* a flag in the location state would also skip the question on a later "back" to that entry.
|
||||||
|
*/
|
||||||
|
export const discardChanges = () => {
|
||||||
|
discardNext = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks the calling component as having unsaved edits while `dirty`. UnsavedChangesGuard then asks before a
|
||||||
|
* navigation drops them.
|
||||||
|
*/
|
||||||
|
export default function useUnsavedChanges(dirty) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty) return undefined;
|
||||||
|
const owner = {};
|
||||||
|
owners.add(owner);
|
||||||
|
return () => owners.delete(owner);
|
||||||
|
}, [dirty]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendered once, inside a data router. Asks with the browser's own confirm; a reload or a closed tab gets the
|
||||||
|
* browser's own warning. Without a session nothing is held back, so the login page always opens.
|
||||||
|
*/
|
||||||
|
export function UnsavedChangesGuard() {
|
||||||
|
const {t} = useTranslation();
|
||||||
|
const blocker = useBlocker(() => {
|
||||||
|
const discard = discardNext;
|
||||||
|
discardNext = false;
|
||||||
|
return owners.size > 0 && hasSession() && !discard;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (blocker.state !== 'blocked') return;
|
||||||
|
if (window.confirm(t('UNSAVED_CHANGES.CONFIRM'))) blocker.proceed();
|
||||||
|
else blocker.reset();
|
||||||
|
}, [blocker, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const warn = (event) => {
|
||||||
|
if (owners.size > 0) event.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', warn);
|
||||||
|
return () => window.removeEventListener('beforeunload', warn);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
import {act, cleanup, fireEvent, render, screen} from '@testing-library/react';
|
||||||
|
import {createMemoryRouter, Link, Outlet, RouterProvider, useNavigate} from 'react-router-dom';
|
||||||
|
import useUnsavedChanges, {discardChanges, UnsavedChangesGuard} from './useUnsavedChanges.js';
|
||||||
|
import {expireSession, markSession} from '../utils/auth.js';
|
||||||
|
|
||||||
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
|
||||||
|
function Editor({dirty = true}) {
|
||||||
|
useUnsavedChanges(dirty);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<p>editor</p>
|
||||||
|
<Link to="/other">leave</Link>
|
||||||
|
<button onClick={() => {
|
||||||
|
discardChanges();
|
||||||
|
navigate('/other');
|
||||||
|
}}>remove</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditor(dirty) {
|
||||||
|
const router = createMemoryRouter([{
|
||||||
|
element: <><UnsavedChangesGuard/><Outlet/></>,
|
||||||
|
children: [
|
||||||
|
{path: '/edit', element: <Editor dirty={dirty}/>},
|
||||||
|
{path: '/other', element: <p>other page</p>},
|
||||||
|
{path: '/login', element: <p>login</p>},
|
||||||
|
],
|
||||||
|
}], {initialEntries: ['/edit']});
|
||||||
|
render(<RouterProvider router={router}/>);
|
||||||
|
return router;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
markSession();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useUnsavedChanges', () => {
|
||||||
|
it('asks before a link leaves unsaved edits, and stays when the user says no', async () => {
|
||||||
|
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
renderEditor(true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('leave'));
|
||||||
|
|
||||||
|
expect(confirm).toHaveBeenCalledWith('UNSAVED_CHANGES.CONFIRM');
|
||||||
|
expect(await screen.findByText('editor')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves when the user says yes', async () => {
|
||||||
|
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||||
|
renderEditor(true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('leave'));
|
||||||
|
|
||||||
|
expect(await screen.findByText('other page')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not ask without unsaved edits, or when the page drops them on purpose', async () => {
|
||||||
|
const confirm = vi.spyOn(window, 'confirm');
|
||||||
|
renderEditor(false);
|
||||||
|
fireEvent.click(screen.getByText('leave'));
|
||||||
|
expect(await screen.findByText('other page')).toBeTruthy();
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
renderEditor(true);
|
||||||
|
fireEvent.click(screen.getByText('remove'));
|
||||||
|
expect(await screen.findByText('other page')).toBeTruthy();
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never holds back the login page when the session ended', async () => {
|
||||||
|
const confirm = vi.spyOn(window, 'confirm');
|
||||||
|
const router = renderEditor(true);
|
||||||
|
|
||||||
|
act(() => expireSession());
|
||||||
|
await act(() => router.navigate('/login'));
|
||||||
|
|
||||||
|
expect(screen.getByText('login')).toBeTruthy();
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lets the browser warn before a reload or close only while there are unsaved edits', () => {
|
||||||
|
renderEditor(true);
|
||||||
|
const warned = new Event('beforeunload', {cancelable: true});
|
||||||
|
window.dispatchEvent(warned);
|
||||||
|
expect(warned.defaultPrevented).toBe(true);
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
renderEditor(false);
|
||||||
|
const free = new Event('beforeunload', {cancelable: true});
|
||||||
|
window.dispatchEvent(free);
|
||||||
|
expect(free.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('asks before going back to an entry that once dropped changes on purpose', async () => {
|
||||||
|
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
const router = renderEditor(true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText('remove'));
|
||||||
|
await screen.findByText('other page');
|
||||||
|
await act(() => router.navigate('/edit'));
|
||||||
|
await screen.findByText('editor');
|
||||||
|
await act(() => router.navigate(-1));
|
||||||
|
|
||||||
|
expect(confirm).toHaveBeenCalledWith('UNSAVED_CHANGES.CONFIRM');
|
||||||
|
expect(router.state.location.pathname).toBe('/edit');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
|
import {Ban, ChevronLeft, ChevronRight, Loader2, Search, ShieldCheck, Trash2} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
@@ -19,6 +19,7 @@ import {Input} from '@/components/ui/input';
|
|||||||
import {Label} from '@/components/ui/label';
|
import {Label} from '@/components/ui/label';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
import {deleteUser, fetchUsers, setUserBlocked} from '../utils/admin.js';
|
||||||
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const REFUSAL_KEY = {
|
const REFUSAL_KEY = {
|
||||||
@@ -44,13 +45,13 @@ export default function AdminUsersPage() {
|
|||||||
const [typedEmail, setTypedEmail] = useState('');
|
const [typedEmail, setTypedEmail] = useState('');
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
// Only the newest load may show its answer: a slow answer to an older search must not replace the list.
|
// Only the newest load may show its answer: a slow answer to an older search must not replace the list.
|
||||||
const loadCount = useRef(0);
|
const nextSignal = useAbortPrevious();
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
const current = ++loadCount.current;
|
const signal = nextSignal();
|
||||||
try {
|
try {
|
||||||
const response = await fetchUsers(search, page, PAGE_SIZE);
|
const response = await fetchUsers(search, page, PAGE_SIZE, {signal});
|
||||||
if (current !== loadCount.current) return;
|
if (signal.aborted) return;
|
||||||
if (response.status === 401) return;
|
if (response.status === 401) return;
|
||||||
if (response.status === 403) {
|
if (response.status === 403) {
|
||||||
setStatus('forbidden');
|
setStatus('forbidden');
|
||||||
@@ -61,14 +62,15 @@ export default function AdminUsersPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (current !== loadCount.current) return;
|
if (signal.aborted) return;
|
||||||
setResult(data);
|
setResult(data);
|
||||||
setStatus('ready');
|
setStatus('ready');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (signal.aborted) return;
|
||||||
console.error('Admin users error:', err);
|
console.error('Admin users error:', err);
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
}
|
}
|
||||||
}, [search, page]);
|
}, [nextSignal, search, page]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ const Dashboard = () => {
|
|||||||
expectInitialSync: hasSteamId && steamData.count === 0,
|
expectInitialSync: hasSteamId && steamData.count === 0,
|
||||||
onComplete: reloadLibrary,
|
onComplete: reloadLibrary,
|
||||||
});
|
});
|
||||||
const forceSync = useForceSync(reloadLibrary);
|
const forceSync = useForceSync(sync.watch);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <DashboardSkeleton/>;
|
return <DashboardSkeleton/>;
|
||||||
@@ -227,6 +227,7 @@ const Dashboard = () => {
|
|||||||
status={sync.status}
|
status={sync.status}
|
||||||
lastSyncedAt={sync.lastSyncedAt}
|
lastSyncedAt={sync.lastSyncedAt}
|
||||||
isRunning={sync.isRunning}
|
isRunning={sync.isRunning}
|
||||||
|
stale={sync.stale}
|
||||||
/>
|
/>
|
||||||
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
<Button onClick={forceSync.trigger} disabled={syncBusy}>
|
||||||
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
<RefreshCw className={syncBusy ? 'animate-spin' : ''}/>
|
||||||
|
|||||||
+13
-33
@@ -26,7 +26,8 @@ import useSaveAppearance from '../hooks/useSaveAppearance.js';
|
|||||||
import {usePreferences} from '../context/PreferencesContext.jsx';
|
import {usePreferences} from '../context/PreferencesContext.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import ProfileArtMenu from '../components/ProfileArtMenu.jsx';
|
import ProfileArtMenu from '../components/ProfileArtMenu.jsx';
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {request} from '../utils/api.js';
|
||||||
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
import {ACHIEVEMENT_ICONS, reportBrokenImage} from '../utils/brokenImages.js';
|
||||||
import {canCorrect} from '../utils/corrections.js';
|
import {canCorrect} from '../utils/corrections.js';
|
||||||
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
|
import {celebrationFor, celebrationLevel} from '../utils/confetti.js';
|
||||||
@@ -94,43 +95,22 @@ export default function GameDetail() {
|
|||||||
setData(null);
|
setData(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nextSignal = useAbortPrevious();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const signal = nextSignal();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
const {ok, status, data: json} = await request(
|
||||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`;
|
`${import.meta.env.VITE_CORE_API_URL}/api/library/games/${appid}`, {signal});
|
||||||
const response = await apiFetch(url, {
|
if (signal.aborted) return;
|
||||||
headers: {
|
setIsLoading(false);
|
||||||
'Content-Type': 'application/json',
|
if (status === 401) return;
|
||||||
},
|
if (status === 404) setError('GAME_DETAIL.NOT_FOUND');
|
||||||
});
|
else if (!ok || !json) setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'GAME_DETAIL.ERROR_LOAD');
|
||||||
if (cancelled) return;
|
else setData(json);
|
||||||
if (response.status === 401) return;
|
|
||||||
if (response.status === 404) {
|
|
||||||
setError('GAME_DETAIL.NOT_FOUND');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
setError('GAME_DETAIL.ERROR_LOAD');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const json = await response.json();
|
|
||||||
setData(json);
|
|
||||||
} catch (err) {
|
|
||||||
if (!cancelled) {
|
|
||||||
console.error('GameDetail load error:', err);
|
|
||||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setIsLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
return () => {
|
}, [nextSignal, appid, reloads]);
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [appid, reloads]);
|
|
||||||
|
|
||||||
const sortedAchievements = useMemo(() => {
|
const sortedAchievements = useMemo(() => {
|
||||||
if (!data?.achievements) return [];
|
if (!data?.achievements) return [];
|
||||||
|
|||||||
@@ -369,3 +369,21 @@ describe('GameDetail confetti', () => {
|
|||||||
expect(await opened({status: 'PLAYING'})).toBeNull();
|
expect(await opened({status: 'PLAYING'})).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GameDetail session', () => {
|
||||||
|
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
|
||||||
|
toast.error.mockClear();
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/library/games/220': json(401, {}),
|
||||||
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
|
});
|
||||||
|
const {router} = renderWithProviders(<GameDetail/>, {path: '/library/:appid', entries: ['/library/220'],
|
||||||
|
guarded: true, routes: {'/login': <p>login</p>}});
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||||
|
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library/220'});
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText('GAME_DETAIL.ERROR_LOAD')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
-25
@@ -20,7 +20,8 @@ import GameCover from '../components/GameCover.jsx';
|
|||||||
import LibraryTabs from '../components/LibraryTabs.jsx';
|
import LibraryTabs from '../components/LibraryTabs.jsx';
|
||||||
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
import GameStatusBadge from '../components/GameStatusBadge.jsx';
|
||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {apiFetch, request} from '../utils/api.js';
|
||||||
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
import {platformLabel} from '../utils/platforms.js';
|
import {platformLabel} from '../utils/platforms.js';
|
||||||
|
|
||||||
const PAGE_SIZES = [20, 40, 80];
|
const PAGE_SIZES = [20, 40, 80];
|
||||||
@@ -65,33 +66,22 @@ export default function GameLibrary() {
|
|||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
const nextSignal = useAbortPrevious();
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
|
const signal = nextSignal();
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly})}`;
|
||||||
const url = `${import.meta.env.VITE_CORE_API_URL}/api/library/games?${libraryQuery({page, size, sort, status: listStatus, q, maxMinutes: listShortOnly ? SHORT_GAME_MINUTES : null, hidden: hiddenOnly})}`;
|
const {ok, status, data: json} = await request(url, {signal});
|
||||||
const response = await apiFetch(url, {
|
if (signal.aborted) return;
|
||||||
headers: {
|
setIsLoading(false);
|
||||||
'Content-Type': 'application/json',
|
if (status === 401) return;
|
||||||
},
|
if (!ok || !json) {
|
||||||
});
|
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'LIBRARY.ERROR_LOAD');
|
||||||
|
return;
|
||||||
if (response.status === 401) return;
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
setError('LIBRARY.ERROR_LOAD');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await response.json();
|
|
||||||
setData(json);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Library load error:', err);
|
|
||||||
setError('INFO.ERROR.NETWORK_FAILURE');
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
}, [page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
setData(json);
|
||||||
|
}, [nextSignal, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
@@ -156,7 +146,7 @@ export default function GameLibrary() {
|
|||||||
updateParams({page: target});
|
updateParams({page: target});
|
||||||
};
|
};
|
||||||
|
|
||||||
const {trigger: triggerSync, busy: syncBusy} = useForceSync(load);
|
const {trigger: triggerSync, busy: syncBusy} = useForceSync(sync.watch);
|
||||||
|
|
||||||
const syncing = syncBusy || sync.isRunning;
|
const syncing = syncBusy || sync.isRunning;
|
||||||
const ready = !isLoading && !error;
|
const ready = !isLoading && !error;
|
||||||
@@ -174,6 +164,7 @@ export default function GameLibrary() {
|
|||||||
status={sync.status}
|
status={sync.status}
|
||||||
lastSyncedAt={sync.lastSyncedAt}
|
lastSyncedAt={sync.lastSyncedAt}
|
||||||
isRunning={sync.isRunning}
|
isRunning={sync.isRunning}
|
||||||
|
stale={sync.stale}
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" size="sm" onClick={triggerSync} disabled={syncing}>
|
<Button variant="outline" size="sm" onClick={triggerSync} disabled={syncing}>
|
||||||
<RefreshCw className={syncing ? 'animate-spin' : ''}/>
|
<RefreshCw className={syncing ? 'animate-spin' : ''}/>
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ import GameLibrary from './GameLibrary.jsx';
|
|||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
vi.mock('../hooks/useLibrarySync.js', () => ({default: () => ({status: 'OK', lastSyncedAt: null, isRunning: false})}));
|
||||||
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
vi.mock('../hooks/useForceSync.js', () => ({default: () => ({trigger: vi.fn(), busy: false})}));
|
||||||
|
|
||||||
@@ -70,6 +73,36 @@ describe('GameLibrary search', () => {
|
|||||||
expect(screen.queryByText(/\/5$/)).toBeNull();
|
expect(screen.queryByText(/\/5$/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows the newest search when an older answer comes last', async () => {
|
||||||
|
const later = [];
|
||||||
|
const games = (name) => json(200, {...page, content: [{...page.content[0], name}], totalElements: 1, totalPages: 1});
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/library/games-count': json(200, {count: 1, hasSteamId: true}),
|
||||||
|
'GET /api/library/games': (url, init) => {
|
||||||
|
const q = new URL(url, 'http://x').searchParams.get('q');
|
||||||
|
if (!q) return games('Half-Life 2');
|
||||||
|
// The first search answers only after the second one.
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
later.push({q, answer: () => resolve(games(q === 'ha' ? 'Old answer' : 'New answer'))});
|
||||||
|
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
renderLibrary();
|
||||||
|
await screen.findByText('Half-Life 2');
|
||||||
|
|
||||||
|
typeInto('ha');
|
||||||
|
await waitFor(() => expect(later).toHaveLength(1));
|
||||||
|
typeInto('half');
|
||||||
|
await waitFor(() => expect(later).toHaveLength(2));
|
||||||
|
await act(async () => later[1].answer());
|
||||||
|
await act(async () => later[0].answer());
|
||||||
|
|
||||||
|
expect(screen.getByText('New answer')).toBeTruthy();
|
||||||
|
expect(screen.queryByText('Old answer')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('writes the trimmed search to the URL after the delay and resets the page', async () => {
|
it('writes the trimmed search to the URL after the delay and resets the page', async () => {
|
||||||
const lib = renderLibrary('/library?page=3');
|
const lib = renderLibrary('/library?page=3');
|
||||||
|
|
||||||
@@ -216,3 +249,21 @@ describe('GameLibrary with games added by hand', () => {
|
|||||||
expect(screen.getByText('LIBRARY.RESYNC')).toBeTruthy();
|
expect(screen.getByText('LIBRARY.RESYNC')).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('GameLibrary session', () => {
|
||||||
|
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/library/games': json(401, {}),
|
||||||
|
'GET /api/library/games-count': json(401, {}),
|
||||||
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
|
});
|
||||||
|
const {router} = renderWithProviders(<GameLibrary/>, {path: '/library', entries: ['/library?q=zelda'],
|
||||||
|
guarded: true, routes: {'/login': <p>login</p>}});
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||||
|
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/library?q=zelda'});
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText('LIBRARY.ERROR_LOAD')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,6 +20,7 @@ import {GAME_STATUSES, MAX_IMPORT_ROWS, MAX_PLATFORM_LENGTH} from '../utils/limi
|
|||||||
import {hoursToMinutes, toHours} from '../utils/manualGames.js';
|
import {hoursToMinutes, toHours} from '../utils/manualGames.js';
|
||||||
import {preselectPlatform} from '../utils/platforms.js';
|
import {preselectPlatform} from '../utils/platforms.js';
|
||||||
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
import {useCurrentUserContext} from '../context/CurrentUserContext.jsx';
|
||||||
|
import useUnsavedChanges from '../hooks/useUnsavedChanges.js';
|
||||||
|
|
||||||
const POLL_MS = 2000;
|
const POLL_MS = 2000;
|
||||||
// Statuses a worker still has: the page polls while it shows one.
|
// Statuses a worker still has: the page polls while it shows one.
|
||||||
@@ -341,7 +342,9 @@ export const initialChoice = (row, favorite) => {
|
|||||||
|
|
||||||
function Review({data, favorite, onSaved, onDone}) {
|
function Review({data, favorite, onSaved, onDone}) {
|
||||||
const {t} = useTranslation();
|
const {t} = useTranslation();
|
||||||
const [choices, setChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
|
const [initialChoices] = useState(() => data.rows.map((row) => initialChoice(row, favorite)));
|
||||||
|
const [choices, setChoices] = useState(initialChoices);
|
||||||
|
useUnsavedChanges(choices !== initialChoices);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
// Stable, so a change in one line re-renders only that line (up to 1000 lines).
|
// Stable, so a change in one line re-renders only that line (up to 1000 lines).
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ImportPage, {initialChoice, stepOf} from './ImportPage.jsx';
|
|||||||
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
|
||||||
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
import {CurrentUserProvider} from '../context/CurrentUserContext.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
|
||||||
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
|
||||||
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}}));
|
||||||
@@ -273,3 +274,19 @@ describe('review findings', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('ImportPage session', () => {
|
||||||
|
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(200, {settings: {}}),
|
||||||
|
'GET /api/library/imports': json(401, {}),
|
||||||
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
|
});
|
||||||
|
const {router} = renderWithProviders(<ImportPage/>, {path: '/import', entries: ['/import'], guarded: true,
|
||||||
|
routes: {'/login': <p>login</p>}});
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||||
|
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/import'});
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-23
@@ -1,4 +1,4 @@
|
|||||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
import {useCallback, useEffect, useState} from 'react';
|
||||||
import {Link} from 'react-router-dom';
|
import {Link} from 'react-router-dom';
|
||||||
import {useTranslation} from 'react-i18next';
|
import {useTranslation} from 'react-i18next';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
@@ -12,7 +12,8 @@ import GameCover from '../components/GameCover.jsx';
|
|||||||
import PageContainer from '../components/PageContainer.jsx';
|
import PageContainer from '../components/PageContainer.jsx';
|
||||||
import {saveGameStatus} from '../utils/backlog.js';
|
import {saveGameStatus} from '../utils/backlog.js';
|
||||||
import {formatPlaytime} from '../utils/games.js';
|
import {formatPlaytime} from '../utils/games.js';
|
||||||
import {apiFetch} from '../utils/api.js';
|
import {request} from '../utils/api.js';
|
||||||
|
import useAbortPrevious from '../hooks/useAbortPrevious.js';
|
||||||
|
|
||||||
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
|
// maxMinutes is IGDB's normal time to beat; a limit leaves out games without IGDB data.
|
||||||
const BUDGETS = [
|
const BUDGETS = [
|
||||||
@@ -45,33 +46,26 @@ export default function PlayNextPage() {
|
|||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
// A slow answer for an older budget or mood must not replace the newer one.
|
// A slow answer for an older budget or mood must not replace the newer one.
|
||||||
const latestRequest = useRef(0);
|
const nextSignal = useAbortPrevious();
|
||||||
|
|
||||||
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
|
const load = useCallback(async (maxMinutes, moodKey, exclude) => {
|
||||||
const request = ++latestRequest.current;
|
const signal = nextSignal();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
const {ok, status, data} = await request(
|
||||||
const response = await apiFetch(
|
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`, {signal});
|
||||||
`${import.meta.env.VITE_CORE_API_URL}/api/library/picker?${pickerQuery(maxMinutes, moodKey, exclude)}`);
|
if (signal.aborted) return;
|
||||||
if (request !== latestRequest.current) return;
|
setLoading(false);
|
||||||
if (response.status === 401) return;
|
if (status === 401) return;
|
||||||
if (!response.ok) {
|
if (!ok || !data) {
|
||||||
setError('PLAY_NEXT.ERROR_LOAD');
|
setError(status === 0 ? 'INFO.ERROR.NETWORK_FAILURE' : 'PLAY_NEXT.ERROR_LOAD');
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
const picked = (await response.json()).games ?? [];
|
|
||||||
if (request !== latestRequest.current) return;
|
|
||||||
setGames(picked);
|
|
||||||
setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Picker load error:', err);
|
|
||||||
if (request === latestRequest.current) setError('INFO.ERROR.NETWORK_FAILURE');
|
|
||||||
} finally {
|
|
||||||
if (request === latestRequest.current) setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
const picked = data.games ?? [];
|
||||||
|
setGames(picked);
|
||||||
|
setSeen([...exclude, ...picked.map((g) => g.appid)].slice(-MAX_EXCLUDES));
|
||||||
// No `t` here: a language change gives a new `t`, and a reload would replace the random picks.
|
// No `t` here: a language change gives a new `t`, and a reload would replace the random picks.
|
||||||
}, []);
|
}, [nextSignal]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load(budget.maxMinutes, mood, []);
|
load(budget.maxMinutes, mood, []);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {act, cleanup, fireEvent, screen, waitFor, within} from '@testing-library
|
|||||||
import SettingsPage from './SettingsPage.jsx';
|
import SettingsPage from './SettingsPage.jsx';
|
||||||
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
import {fetchRoutes, json} from '../test/fetchRoutes.js';
|
||||||
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
import {renderWithProviders} from '../test/renderWithProviders.jsx';
|
||||||
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
vi.mock('react-i18next', async () => {
|
vi.mock('react-i18next', async () => {
|
||||||
const mock = await import('../test/i18n-mock.js');
|
const mock = await import('../test/i18n-mock.js');
|
||||||
@@ -239,3 +240,28 @@ describe('SettingsPage favorite platform', () => {
|
|||||||
await waitFor(() => expect(puts()).toEqual([{platform: 'Switch 2'}, {platform: null}]));
|
await waitFor(() => expect(puts()).toEqual([{platform: 'Switch 2'}, {platform: null}]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('SettingsPage session', () => {
|
||||||
|
beforeEach(() => localStorage.setItem('tbp_session', '1'));
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the login page on a 401 that the refresh cannot fix, without an error', async () => {
|
||||||
|
toast.error.mockClear();
|
||||||
|
fetchRoutes({
|
||||||
|
'GET /api/users/me': json(401, {}),
|
||||||
|
'POST /api/auth/refresh': json(401, {}),
|
||||||
|
'GET /api/version': json(404, {}),
|
||||||
|
});
|
||||||
|
const {router} = renderWithProviders(<SettingsPage/>, {path: '/settings', entries: ['/settings'],
|
||||||
|
guarded: true, routes: {'/login': <p>login</p>}});
|
||||||
|
|
||||||
|
await waitFor(() => expect(router.state.location.pathname).toBe('/login'));
|
||||||
|
expect(router.state.location.state).toEqual({sessionExpired: true, from: '/settings'});
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
expect(screen.queryByText('INFO.ERROR.LOAD_PROFILE')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
+2
-2
@@ -7,8 +7,8 @@ export const ROLES = ['USER', 'AUDITOR', 'PLATFORM_ADMIN'];
|
|||||||
/**
|
/**
|
||||||
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
|
* GET /api/admin/users: {content, page, size, totalElements, totalPages}. PLATFORM_ADMIN only (403 otherwise).
|
||||||
*/
|
*/
|
||||||
export const fetchUsers = (query, page, size) =>
|
export const fetchUsers = (query, page, size, init) =>
|
||||||
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, page, size})}`);
|
apiFetch(`${AUTH()}/api/admin/users?${new URLSearchParams({query, page, size})}`, init);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 204, or 409 with code SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED.
|
* 204, or 409 with code SELF_ACTION, TARGET_IS_ADMIN or ALREADY_DELETED.
|
||||||
|
|||||||
@@ -89,5 +89,6 @@ export async function request(url, init = {}) {
|
|||||||
} catch {
|
} catch {
|
||||||
// no JSON body, e.g. a 204
|
// no JSON body, e.g. a 204
|
||||||
}
|
}
|
||||||
|
if (init.signal?.aborted) return {ok: false, status: 0, data: null};
|
||||||
return {ok: response.ok, status: response.status, data};
|
return {ok: response.ok, status: response.status, data};
|
||||||
}
|
}
|
||||||
@@ -209,6 +209,16 @@ describe('request', () => {
|
|||||||
expect(await request('/api/x', {method: 'PUT'})).toEqual({ok: false, status: 409, data: {code: 'CONFLICT'}});
|
expect(await request('/api/x', {method: 'PUT'})).toEqual({ok: false, status: 409, data: {code: 'CONFLICT'}});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('gives status 0 when the request is aborted while its body is read', async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
globalThis.fetch = vi.fn(async () => ({ok: true, status: 200, json: async () => {
|
||||||
|
controller.abort();
|
||||||
|
throw new DOMException('aborted', 'AbortError');
|
||||||
|
}}));
|
||||||
|
|
||||||
|
expect(await request('/api/x', {signal: controller.signal})).toEqual({ok: false, status: 0, data: null});
|
||||||
|
});
|
||||||
|
|
||||||
it('gives status 0 on a network error and logs it, but not an abort', async () => {
|
it('gives status 0 on a network error and logs it, but not an abort', async () => {
|
||||||
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
|
globalThis.fetch = vi.fn(async () => { throw new TypeError('Failed to fetch'); });
|
||||||
|
|||||||
Reference in new issue
Block a user