Skip to content

Commit c645b5b

Browse files
committed
fix(i18n): 🐛 sync user- and frontend language
1 parent 8ff6dd4 commit c645b5b

File tree

13 files changed

+145
-47
lines changed

13 files changed

+145
-47
lines changed

.gitignore

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,13 @@ db.sqlite3
7575
.vscode/
7676
*.iml
7777
.devcontainer
78+
79+
# Devenv
80+
.devenv*
81+
devenv.local.nix
82+
devenv.*
83+
devenv.lock
84+
85+
# Direnv
86+
.direnv
87+
.envrc

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@ and this project adheres to
99

1010
## [Unreleased]
1111

12+
## Changed
13+
14+
- ♻️(frontend) sync user- and frontend language #401
15+
16+
## Fixed
17+
18+
- 🐛(i18n) invitation e-mails in receivers language #401
19+
1220
## [1.7.0] - 2024-10-24
1321

1422
## Added

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();
@@ -51,12 +54,41 @@ test.describe('Language', () => {
5154
// Check for English 404 response
5255
await check404Response('Not found.');
5356

54-
// Switch language to French
55-
const header = page.locator('header').first();
56-
await header.getByRole('combobox').getByText('English').click();
57-
await header.getByRole('option', { name: 'Français' }).click();
57+
await waitForLanguageSwitch(page, Languages.French);
5858

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

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/docs/members/invitation-list/api/useCreateDocInvitation.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
44
import { User } from '@/core/auth';
55
import { Doc, Role } from '@/features/docs/doc-management';
66
import { OptionType } from '@/features/docs/members/members-add/types';
7-
import { ContentLanguage } from '@/i18n/types';
87

98
import { Invitation } from '../types';
109

@@ -14,20 +13,15 @@ interface CreateDocInvitationParams {
1413
email: User['email'];
1514
role: Role;
1615
docId: Doc['id'];
17-
contentLanguage: ContentLanguage;
1816
}
1917

2018
export const createDocInvitation = async ({
2119
email,
2220
role,
2321
docId,
24-
contentLanguage,
2522
}: CreateDocInvitationParams): Promise<Invitation> => {
2623
const response = await fetchAPI(`documents/${docId}/invitations/`, {
2724
method: 'POST',
28-
headers: {
29-
'Content-Language': contentLanguage,
30-
},
3125
body: JSON.stringify({
3226
email,
3327
role,

src/frontend/apps/impress/src/features/docs/members/members-add/api/useCreateDocAccess.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import {
99
Role,
1010
} from '@/features/docs/doc-management';
1111
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-list';
12-
import { ContentLanguage } from '@/i18n/types';
1312

1413
import { OptionType } from '../types';
1514

@@ -19,20 +18,15 @@ interface CreateDocAccessParams {
1918
role: Role;
2019
docId: Doc['id'];
2120
memberId: User['id'];
22-
contentLanguage: ContentLanguage;
2321
}
2422

2523
export const createDocAccess = async ({
2624
memberId,
2725
role,
2826
docId,
29-
contentLanguage,
3027
}: CreateDocAccessParams): Promise<Access> => {
3128
const response = await fetchAPI(`documents/${docId}/accesses/`, {
3229
method: 'POST',
33-
headers: {
34-
'Content-Language': contentLanguage,
35-
},
3630
body: JSON.stringify({
3731
user_id: memberId,
3832
role,

src/frontend/apps/impress/src/features/docs/members/members-add/components/AddMembers.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { APIError } from '@/api';
1010
import { Box, Card, IconBG } from '@/components';
1111
import { Doc, Role } from '@/features/docs/doc-management';
1212
import { useCreateDocInvitation } from '@/features/docs/members/invitation-list/';
13-
import { useLanguage } from '@/i18n/hooks/useLanguage';
1413
import { useResponsiveStore } from '@/stores';
1514

1615
import { useCreateDocAccess } from '../api';
@@ -36,7 +35,6 @@ interface ModalAddMembersProps {
3635
}
3736

3837
export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
39-
const { contentLanguage } = useLanguage();
4038
const { t } = useTranslation();
4139
const { isSmallMobile } = useResponsiveStore();
4240
const [selectedUsers, setSelectedUsers] = useState<OptionsSelect>([]);
@@ -56,7 +54,6 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
5654
email: selectedUser.value.email,
5755
role: selectedRole,
5856
docId: doc.id,
59-
contentLanguage,
6057
});
6158
break;
6259

@@ -65,7 +62,6 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
6562
role: selectedRole,
6663
docId: doc.id,
6764
memberId: selectedUser.value.id,
68-
contentLanguage,
6965
});
7066
break;
7167
}

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,

src/frontend/apps/impress/src/i18n/hooks/useLanguage.tsx

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
// See: https://github.com/numerique-gouv/impress/blob/ac58341984c99c10ebfac7f8bbe1e8756c48e4d4/src/backend/impress/settings.py#L156-L161
2-
export type ContentLanguage = 'en-us' | 'fr-fr';
2+
export type UserLanguage = 'en-us' | 'fr-fr';

0 commit comments

Comments
 (0)