Skip to content

Commit 332e770

Browse files
committed
✨(frontend) sync user and frontend language
On Language change in the frontend, the user language is updated via API. If user language is available, it will be preferred and set in the frontend.
1 parent 3da8de5 commit 332e770

File tree

8 files changed

+128
-16
lines changed

8 files changed

+128
-16
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to
2121
- 🌐(backend) add german translation #259
2222
- ♻️(frontend) simplify stores #402
2323
- ✨(frontend) update $css Box props type to add styled components RuleSet #423
24+
- ✨(frontend) sync user and frontend language #401
2425

2526
## Fixed
2627

src/backend/core/api/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ class UserSerializer(serializers.ModelSerializer):
1818

1919
class Meta:
2020
model = models.User
21-
fields = ["id", "email", "full_name", "short_name"]
21+
fields = ["id", "email", "full_name", "short_name", "language"]
2222
read_only_fields = ["id", "email", "full_name", "short_name"]
2323

2424

src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
1-
import { expect, test } from '@playwright/test';
1+
import { Page, expect, test } from '@playwright/test';
22

33
test.beforeEach(async ({ page }) => {
44
await page.goto('/');
55
});
66

77
test.describe('Language', () => {
8-
test('checks the language picker', async ({ page }) => {
8+
test('checks language switching', async ({ page }) => {
9+
const header = page.locator('header').first();
10+
11+
// initial language should be english
912
await expect(
1013
page.getByRole('button', {
1114
name: 'Create a new document',
1215
}),
1316
).toBeVisible();
1417

15-
const header = page.locator('header').first();
16-
await header.getByRole('combobox').getByText('English').click();
17-
await header.getByRole('option', { name: 'Français' }).click();
18+
// switch to french
19+
await waitForLanguageSwitch(page, Languages.French);
20+
1821
await expect(
1922
header.getByRole('combobox').getByText('Français'),
2023
).toBeVisible();
@@ -63,12 +66,41 @@ test.describe('Language', () => {
6366
// Check for English 404 response
6467
await check404Response('Not found.');
6568

66-
// Switch language to French
67-
const header = page.locator('header').first();
68-
await header.getByRole('combobox').getByText('English').click();
69-
await header.getByRole('option', { name: 'Français' }).click();
69+
await waitForLanguageSwitch(page, Languages.French);
7070

7171
// Check for French 404 response
7272
await check404Response('Pas trouvé.');
7373
});
7474
});
75+
76+
test.afterEach(async ({ page }) => {
77+
// Switch back to English - important for other tests to run, as this updates the language in the user entity
78+
// and in turn updates the language of the frontend.
79+
// Therefore continuing tests, messages that are expected to be english would be french
80+
await waitForLanguageSwitch(page, Languages.English);
81+
});
82+
83+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
84+
enum Languages {
85+
English = 'English',
86+
French = 'Français',
87+
}
88+
const serverSideLanguageValues: Record<Languages, string> = {
89+
[Languages.English]: 'en-us',
90+
[Languages.French]: 'fr-fr',
91+
};
92+
async function waitForLanguageSwitch(page: Page, lang: Languages) {
93+
const header = page.locator('header').first();
94+
await header.getByRole('combobox').click();
95+
96+
const [response] = await Promise.all([
97+
page.waitForResponse(
98+
(resp) =>
99+
resp.url().includes('/user') && resp.request().method() === 'PATCH',
100+
),
101+
header.getByRole('option', { name: lang }).click(),
102+
]);
103+
104+
const updatedUserResponse = await response.json();
105+
expect(updatedUserResponse.language).toBe(serverSideLanguageValues[lang]);
106+
}

src/frontend/apps/impress/src/core/AppProvider.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
33
import { useEffect } from 'react';
44

55
import { useCunninghamTheme } from '@/cunningham';
6-
import '@/i18n/initI18n';
6+
import { LANGUAGES_ALLOWED } from '@/i18n/conf';
7+
import i18n from '@/i18n/initI18n';
78
import { useResponsiveStore } from '@/stores/';
89

9-
import { Auth } from './auth/';
10+
import { Auth, useAuthStore } from './auth/';
1011

1112
/**
1213
* QueryClient:
@@ -26,6 +27,7 @@ const queryClient = new QueryClient({
2627

2728
export function AppProvider({ children }: { children: React.ReactNode }) {
2829
const { theme } = useCunninghamTheme();
30+
const { userData } = useAuthStore();
2931

3032
const initializeResizeListener = useResponsiveStore(
3133
(state) => state.initializeResizeListener,
@@ -36,6 +38,16 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
3638
return cleanupResizeListener;
3739
}, [initializeResizeListener]);
3840

41+
useEffect(() => {
42+
if (userData?.language) {
43+
Object.keys(LANGUAGES_ALLOWED).forEach((key) => {
44+
if (userData.language.includes(key) && i18n.language !== key) {
45+
void i18n.changeLanguage(key);
46+
}
47+
});
48+
}
49+
}, [userData?.language]);
50+
3951
return (
4052
<QueryClientProvider client={queryClient}>
4153
<CunninghamProvider theme={theme}>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import { UserLanguage } from '@/i18n/types';
2+
13
/**
24
* Represents user retrieved from the API.
35
* @interface User
46
* @property {string} id - The id of the user.
57
* @property {string} email - The email of the user.
68
* @property {string} name - The name of the user.
9+
* @property {string} language - The language of the user.
710
*/
811
export interface User {
912
id: string;
1013
email: string;
1114
full_name: string;
1215
short_name: string;
16+
language: UserLanguage;
1317
}

src/frontend/apps/impress/src/features/language/LanguagePicker.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import styled from 'styled-components';
66
import { Box, Text } from '@/components/';
77
import { LANGUAGES_ALLOWED } from '@/i18n/conf';
88

9+
import { useSetUserLanguage } from './api/useChangeUserLanguage';
10+
911
const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
1012
flex-shrink: 0;
1113
width: auto;
@@ -33,6 +35,7 @@ const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
3335

3436
export const LanguagePicker = () => {
3537
const { t, i18n } = useTranslation();
38+
const { mutateAsync: setUserLanguage } = useSetUserLanguage();
3639
const { preload: languages } = i18n.options;
3740

3841
const optionsPicker = useMemo(() => {
@@ -63,13 +66,22 @@ export const LanguagePicker = () => {
6366
showLabelWhenSelected={false}
6467
clearable={false}
6568
hideLabel
66-
defaultValue={i18n.language}
69+
value={i18n.language}
6770
className="c_select__no_bg"
6871
options={optionsPicker}
6972
onChange={(e) => {
70-
i18n.changeLanguage(e.target.value as string).catch((err) => {
71-
console.error('Error changing language', err);
72-
});
73+
void i18n
74+
.changeLanguage(e.target.value as string)
75+
.catch((err) => {
76+
console.error('Error changing language', err);
77+
})
78+
.then(() => {
79+
setUserLanguage({
80+
language: i18n.language === 'fr' ? 'fr-fr' : 'en-us',
81+
}).catch((err) => {
82+
console.error('Error changing users language', err);
83+
});
84+
});
7385
}}
7486
/>
7587
);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useMutation, useQueryClient } from '@tanstack/react-query';
2+
3+
import { APIError, errorCauses, fetchAPI } from '@/api';
4+
import { User, useAuthStore } from '@/core';
5+
6+
interface SetUserLanguageParams {
7+
language: User['language'];
8+
}
9+
10+
export const setUserLanguage = async ({
11+
language,
12+
}: SetUserLanguageParams): Promise<User> => {
13+
const { userData } = useAuthStore.getState();
14+
15+
if (!userData?.id) {
16+
console.warn('Id of user is needed for this request.');
17+
return {} as Promise<User>;
18+
}
19+
20+
const response = await fetchAPI(`users/${userData.id}/`, {
21+
method: 'PATCH',
22+
body: JSON.stringify({
23+
language,
24+
}),
25+
});
26+
27+
if (!response.ok) {
28+
throw new APIError(
29+
`Failed to change the user language to ${language}`,
30+
await errorCauses(response, {
31+
value: language,
32+
type: 'language',
33+
}),
34+
);
35+
}
36+
37+
return response.json() as Promise<User>;
38+
};
39+
40+
export function useSetUserLanguage() {
41+
const queryClient = useQueryClient();
42+
return useMutation<User, APIError, SetUserLanguageParams>({
43+
mutationFn: setUserLanguage,
44+
onSuccess: () => {
45+
void queryClient.invalidateQueries({
46+
queryKey: ['set-user-language'],
47+
});
48+
},
49+
});
50+
}

src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ export class ApiPlugin implements WorkboxPlugin {
216216
email: 'dummy-email',
217217
full_name: 'dummy-full-name',
218218
short_name: 'dummy-short-name',
219+
language: 'en-us',
219220
},
220221
abilities: {
221222
destroy: false,

0 commit comments

Comments
 (0)