fix(settings): keep the language and save settings one after the other

This commit is contained in:
lobo committed 2026-09-29 13:27:31 +02:00
1 parent 14dd59f9e0
commit f605917874
25 files changed
+423 -98

No files matched your search

+4 -4
View File
@@ -213,18 +213,18 @@ const Dashboard = () => {
} else if (profileResponse.status === 401) {
expireSession();
} else {
setError(t('INFO.ERROR.LOAD_PROFILE'));
setError('INFO.ERROR.LOAD_PROFILE');
}
} catch (err) {
console.error('Dashboard fetch error:', err);
setError(t('INFO.ERROR.NETWORK_FAILURE'));
setError('INFO.ERROR.NETWORK_FAILURE');
} finally {
setIsLoading(false);
}
};
fetchUserData();
}, [navigate, expireSession, t, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
}, [navigate, expireSession, fetchSteamCount, fetchTopGames, fetchPlaying, fetchSummary, fetchActivity, fetchAchievements]);
const hasSteamId = !!userData?.steamId;
const showLibrary = hasSteamId || steamData.count > 0;
@@ -248,7 +248,7 @@ const Dashboard = () => {
if (error) {
return (
<div className="py-24 text-center text-destructive">
{error}
{t(error)}
</div>
);
}
+15 -7
View File
@@ -89,6 +89,14 @@ export default function GameDetail() {
shownLevel.current = {appid: data.appid, level: celebrationLevel(data)};
}, [data]);
// Another game starts empty, already in this render. A reload of the same game keeps the old data, so the edit
// cards keep what is typed.
const [dataAppid, setDataAppid] = useState(appid);
if (dataAppid !== appid) {
setDataAppid(appid);
setData(null);
}
useEffect(() => {
let cancelled = false;
if (!hasSession()) {
@@ -111,11 +119,11 @@ export default function GameDetail() {
return;
}
if (response.status === 404) {
setError(t('GAME_DETAIL.NOT_FOUND'));
setError('GAME_DETAIL.NOT_FOUND');
return;
}
if (!response.ok) {
setError(t('GAME_DETAIL.ERROR_LOAD'));
setError('GAME_DETAIL.ERROR_LOAD');
return;
}
const json = await response.json();
@@ -123,7 +131,7 @@ export default function GameDetail() {
} catch (err) {
if (!cancelled) {
console.error('GameDetail load error:', err);
setError(t('INFO.ERROR.NETWORK_FAILURE'));
setError('INFO.ERROR.NETWORK_FAILURE');
}
} finally {
if (!cancelled) setIsLoading(false);
@@ -132,7 +140,7 @@ export default function GameDetail() {
return () => {
cancelled = true;
};
}, [appid, navigate, expireSession, t, reloads]);
}, [appid, navigate, expireSession, reloads]);
const sortedAchievements = useMemo(() => {
if (!data?.achievements) return [];
@@ -188,7 +196,7 @@ export default function GameDetail() {
<ArrowLeft/>{t('GAME_DETAIL.BACK')}
</Button>
{isLoading && (
{isLoading && !data && (
<div className="space-y-6">
<Skeleton className="h-52 rounded-xl"/>
<Skeleton className="h-40 rounded-xl"/>
@@ -197,12 +205,12 @@ export default function GameDetail() {
{error && !isLoading && (
<div className="py-12 text-center text-destructive space-y-4">
<p>{error}</p>
<p>{t(error)}</p>
<Button asChild variant="outline"><Link to="/library">{t('GAME_DETAIL.BACK_TO_LIBRARY')}</Link></Button>
</div>
)}
{!isLoading && !error && data && (
{!error && data && (
<>
{data.hidden && (
<div role="status" className="flex items-center gap-2 rounded-lg border bg-muted/50 px-4 py-3 text-sm text-muted-foreground">
+44
View File
@@ -0,0 +1,44 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
import {MemoryRouter, Route, Routes} from 'react-router-dom';
import GameDetail from './GameDetail.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
// Stands in for a saved correction, which loads the game again.
vi.mock('../components/GameCorrectionDialog.jsx', () => ({
default: ({onChanged}) => <button type="button" onClick={onChanged}>correction saved</button>,
}));
const game = {
appid: 220, name: 'Half-Life 2', playtimeMinutes: 100, playtime2Weeks: 90, status: 'BACKLOG',
rating: null, notes: null, priority: null, hasAchievements: false, statsVisible: false, achievements: [],
};
const gameLoads = () => fetch.mock.calls.filter(([url]) => url.endsWith('/api/library/games/220')).length;
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
globalThis.fetch = vi.fn(async (url) => ({ok: true, status: 200,
json: async () => (url.includes('/api/users/me') ? {role: 'AUDITOR', activeRole: 'AUDITOR'} : game)}));
});
afterEach(() => {
cleanup();
localStorage.clear();
});
it('keeps unsaved notes while the game loads again after a correction', async () => {
render(
<MemoryRouter initialEntries={['/library/220']}><PreferencesProvider>
<Routes><Route path="/library/:appid" element={<GameDetail/>}/></Routes>
</PreferencesProvider></MemoryRouter>,
);
fireEvent.change(await screen.findByLabelText('BACKLOG.NOTES_LABEL'), {target: {value: 'my draft'}});
fireEvent.click(await screen.findByRole('button', {name: 'correction saved'}));
await waitFor(() => expect(gameLoads()).toBe(2));
await waitFor(() => expect(screen.getByLabelText('BACKLOG.NOTES_LABEL').value).toBe('my draft'));
});
+4 -4
View File
@@ -89,7 +89,7 @@ export default function GameLibrary() {
}
if (!response.ok) {
setError(t('LIBRARY.ERROR_LOAD'));
setError('LIBRARY.ERROR_LOAD');
return;
}
@@ -97,11 +97,11 @@ export default function GameLibrary() {
setData(json);
} catch (err) {
console.error('Library load error:', err);
setError(t('INFO.ERROR.NETWORK_FAILURE'));
setError('INFO.ERROR.NETWORK_FAILURE');
} finally {
setIsLoading(false);
}
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly, t]);
}, [navigate, expireSession, page, size, sort, listStatus, q, listShortOnly, hiddenOnly]);
useEffect(() => {
load();
@@ -287,7 +287,7 @@ export default function GameLibrary() {
</Card>
)}
{error && <div className="py-12 text-center text-destructive">{error}</div>}
{error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
{ready && data.content.length === 0 && (
<Card className="items-center py-16 text-center text-muted-foreground">
+6 -6
View File
@@ -51,11 +51,11 @@ export default function PublicProfilePage() {
`${import.meta.env.VITE_API_URL}/api/public/profiles/${encodeURIComponent(name)}`);
if (cancelled) return;
if (profileResponse.status === 404) {
setError(t('PROFILE.NOT_FOUND'));
setError('PROFILE.NOT_FOUND');
return;
}
if (!profileResponse.ok) {
setError(t('PROFILE.ERROR_LOAD'));
setError('PROFILE.ERROR_LOAD');
return;
}
const found = await profileResponse.json();
@@ -64,7 +64,7 @@ export default function PublicProfilePage() {
});
if (cancelled) return;
if (!libraryResponse.ok) {
setError(t('PROFILE.ERROR_LOAD'));
setError('PROFILE.ERROR_LOAD');
return;
}
const data = await libraryResponse.json();
@@ -74,20 +74,20 @@ export default function PublicProfilePage() {
}
} catch (err) {
console.error('Public profile load error:', err);
if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE'));
if (!cancelled) setError('INFO.ERROR.NETWORK_FAILURE');
}
})();
return () => {
cancelled = true;
};
}, [name, t, reloads]);
}, [name, reloads]);
if (error) {
return (
<PageContainer className="p-4 md:p-8">
<Card className="items-center py-16 text-center text-muted-foreground">
<UserRoundX className="size-10"/>
<p className="max-w-sm text-sm">{error}</p>
<p className="max-w-sm text-sm">{t(error)}</p>
<Link to="/" className="text-sm font-medium text-primary hover:underline">{t('PROFILE.TO_START')}</Link>
</Card>
</PageContainer>
+15 -38
View File
@@ -31,6 +31,7 @@ import {coverUrl} from '../utils/games.js';
import {apiFetch, refreshSession} from '../utils/api.js';
import {hasSession, markSession} from '../utils/auth.js';
import {FAVORITE_PLATFORMS, saveFavoritePlatform} from '../utils/platforms.js';
import {LANGUAGES, saveSettings} from '../utils/settings.js';
const BACKEND_ERROR_KEY = {
DISPLAY_NAME_ALREADY_IN_USE: 'SETTINGS.ERROR_DISPLAY_NAME_IN_USE',
@@ -44,7 +45,6 @@ const STEAM_LINK_TOASTS = {
failed: {show: toast.error, key: 'SETTINGS.STEAM_LINK.FAILED'},
};
const SUPPORTED_LANGUAGES = ['en', 'de'];
// Radix Select has no empty value.
const NO_FAVORITE = 'NONE';
@@ -81,7 +81,7 @@ export default function SettingsPage() {
formState: {errors, isSubmitting},
} = useForm({
resolver: zodResolver(settingsSchema),
defaultValues: {useDarkTheme: true, language: 'en', displayName: ''},
defaultValues: {displayName: ''},
});
// A language change gives a new `t` and runs the effect again; the toast shows once.
@@ -123,42 +123,32 @@ export default function SettingsPage() {
}
if (!response.ok) {
setLoadError(t('INFO.ERROR.LOAD_PROFILE'));
setLoadError('INFO.ERROR.LOAD_PROFILE');
return;
}
const data = await response.json();
setProfile(data);
reset({
useDarkTheme: !!data.settings?.useDarkTheme,
language: data.settings?.language || 'en',
displayName: data.displayName ?? '',
});
reset({displayName: data.displayName ?? ''});
} catch (err) {
console.error('Settings load error:', err);
setLoadError(t('INFO.ERROR.NETWORK_FAILURE'));
setLoadError('INFO.ERROR.NETWORK_FAILURE');
} finally {
setIsLoading(false);
}
};
load();
}, [navigate, expireSession, reset, t, steamOutcome]);
}, [navigate, expireSession, reset, steamOutcome]);
const persistSettings = async (payload) => {
const persistSettings = async (send) => {
if (!hasSession()) {
navigate('/login');
return {ok: false};
}
try {
const response = await apiFetch(`${import.meta.env.VITE_API_URL}/api/users/settings`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
const response = await send();
if (response.status === 401) {
expireSession();
@@ -181,23 +171,14 @@ export default function SettingsPage() {
}
};
// The backend replaces all settings, so every save sends the saved values of the fields it does not change.
const savedSettings = () => ({
useDarkTheme: isDarkMode,
language: i18n.language,
displayName: profile?.displayName ?? null,
publicProfile: !!profile?.settings?.publicProfile,
});
const handleDarkThemeToggle = async () => {
const next = !isDarkMode;
setDarkMode(next); // apply on the fly
await persistSettings({...savedSettings(), useDarkTheme: next});
await persistSettings(() => saveSettings({useDarkTheme: next}));
};
const handleLanguageChange = async (next) => {
setLanguage(next); // apply on the fly
await persistSettings({...savedSettings(), language: next});
await persistSettings(() => setLanguage(next));
};
const handleFavoritePlatformChange = async (value) => {
@@ -210,7 +191,7 @@ export default function SettingsPage() {
const handlePublicProfileToggle = async () => {
const next = !profile?.settings?.publicProfile;
const result = await persistSettings({...savedSettings(), publicProfile: next});
const result = await persistSettings(() => saveSettings({publicProfile: next}));
if (result.ok) {
setProfile((p) => ({...p, settings: {...p.settings, publicProfile: next}}));
}
@@ -237,12 +218,8 @@ export default function SettingsPage() {
const onSubmitProfile = async (values) => {
// Without a name the profile has no link, so removing the name also makes it private.
const publicProfile = savedSettings().publicProfile && !!values.displayName;
const result = await persistSettings({
...savedSettings(),
displayName: values.displayName,
publicProfile,
});
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},
@@ -262,7 +239,7 @@ export default function SettingsPage() {
}
if (loadError) {
return <div className="py-24 text-center text-destructive">{loadError}</div>;
return <div className="py-24 text-center text-destructive">{t(loadError)}</div>;
}
const publicProfile = !!profile?.settings?.publicProfile;
@@ -313,7 +290,7 @@ export default function SettingsPage() {
<SelectValue/>
</SelectTrigger>
<SelectContent>
{SUPPORTED_LANGUAGES.map((lang) => (
{LANGUAGES.map((lang) => (
<SelectItem key={lang} value={lang}>
{t(`SETTINGS.LANGUAGE_OPTIONS.${lang.toUpperCase()}`)}
</SelectItem>
+33 -1
View File
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {act, cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react';
import {MemoryRouter} from 'react-router-dom';
import SettingsPage from './SettingsPage.jsx';
import {PreferencesProvider} from '../context/PreferencesContext.jsx';
@@ -29,6 +29,24 @@ function renderPage(me) {
render(<MemoryRouter><PreferencesProvider><SettingsPage/></PreferencesProvider></MemoryRouter>);
}
/**
* Auth with PUT /api/users/settings answered one by one through answer(); GET /api/users/me shows what was saved.
*/
function fakeAuth(me) {
const saved = structuredClone(me);
const open = [];
globalThis.fetch = vi.fn((url, init) => {
if (init?.method !== 'PUT') return Promise.resolve({ok: true, status: 200, json: async () => structuredClone(saved)});
return new Promise((resolve) => open.push(() => {
const body = JSON.parse(init.body);
saved.displayName = body.displayName;
Object.assign(saved.settings, {useDarkTheme: body.useDarkTheme, language: body.language, publicProfile: body.publicProfile});
resolve({ok: true, status: 204, json: async () => { throw new SyntaxError('no body'); }});
}));
});
return {answer: () => open.shift()()};
}
describe('SettingsPage public profile', () => {
beforeEach(() => localStorage.setItem('tbp_session', '1'));
@@ -46,6 +64,20 @@ describe('SettingsPage public profile', () => {
expect(puts()[0].publicProfile).toBe(true);
});
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>);
fireEvent.click(await screen.findByRole('switch', {name: 'SETTINGS.PUBLIC_PROFILE'}));
await waitFor(() => expect(puts()).toHaveLength(1));
fireEvent.click(screen.getByRole('switch', {name: 'SETTINGS.DARK_THEME'}));
await act(async () => auth.answer());
await waitFor(() => expect(puts()).toHaveLength(2));
expect(puts()[1]).toMatchObject({publicProfile: false, useDarkTheme: false, displayName: 'Lobo'});
await act(async () => auth.answer());
});
it('turns the public profile off and shows the link while it is on', async () => {
renderPage(profile());
+4 -4
View File
@@ -40,20 +40,20 @@ export default function WishlistPage() {
return;
}
if (!response.ok) {
setError(t('WISHLIST.ERROR_LOAD'));
setError('WISHLIST.ERROR_LOAD');
return;
}
const json = await response.json();
if (!cancelled) setGames(json.games ?? []);
} catch (err) {
console.error('Wishlist load error:', err);
if (!cancelled) setError(t('INFO.ERROR.NETWORK_FAILURE'));
if (!cancelled) setError('INFO.ERROR.NETWORK_FAILURE');
}
})();
return () => {
cancelled = true;
};
}, [navigate, expireSession, t]);
}, [navigate, expireSession]);
const shown = (games ?? []).filter((g) => !onlyOnSale || g.discountPct > 0);
const onSaleCount = (games ?? []).filter((g) => g.discountPct > 0).length;
@@ -91,7 +91,7 @@ export default function WishlistPage() {
</Card>
)}
{error && <div className="py-12 text-center text-destructive">{error}</div>}
{error && <div className="py-12 text-center text-destructive">{t(error)}</div>}
{games !== null && shown.length === 0 && (
<Card className="items-center py-16 text-center text-muted-foreground">
+54
View File
@@ -0,0 +1,54 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
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 Dashboard from './Dashboard.jsx';
import GameDetail from './GameDetail.jsx';
import GameLibrary from './GameLibrary.jsx';
import PublicProfilePage from './PublicProfilePage.jsx';
import SettingsPage from './SettingsPage.jsx';
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'],
];
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
switchLanguage('de');
globalThis.fetch = vi.fn(async () => ({ok: false, status: 500, json: async () => ({})}));
});
afterEach(() => {
cleanup();
localStorage.clear();
});
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) => {
const tree = () => (
<MemoryRouter initialEntries={[url]}>
<PreferencesProvider><Routes><Route path={path} element={page}/></Routes></PreferencesProvider>
</MemoryRouter>
);
const {rerender} = render(tree());
await screen.findByText(`de:${errorKey}`);
const requests = fetch.mock.calls.length;
switchLanguage('en');
rerender(tree());
expect(await screen.findByText(`en:${errorKey}`)).toBeTruthy();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetch.mock.calls.length).toBe(requests);
});
});