Skip to content

Commit

Permalink
fix(i18n): 🐛 sync user- and frontend language
Browse files Browse the repository at this point in the history
  • Loading branch information
rvveber committed Nov 4, 2024
1 parent 8ff6dd4 commit c645b5b
Show file tree
Hide file tree
Showing 13 changed files with 145 additions and 47 deletions.
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,13 @@ db.sqlite3
.vscode/
*.iml
.devcontainer

# Devenv
.devenv*
devenv.local.nix
devenv.*
devenv.lock

# Direnv
.direnv
.envrc
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ and this project adheres to

## [Unreleased]

## Changed

- ♻️(frontend) sync user- and frontend language #401

## Fixed

- 🐛(i18n) invitation e-mails in receivers language #401

## [1.7.0] - 2024-10-24

## Added
Expand Down
50 changes: 41 additions & 9 deletions src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import { expect, test } from '@playwright/test';
import { Page, expect, test } from '@playwright/test';

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

test.describe('Language', () => {
test('checks the language picker', async ({ page }) => {
test('checks language switching', async ({ page }) => {
const header = page.locator('header').first();

// initial language should be english
await expect(
page.getByRole('button', {
name: 'Create a new document',
}),
).toBeVisible();

const header = page.locator('header').first();
await header.getByRole('combobox').getByText('English').click();
await header.getByRole('option', { name: 'Français' }).click();
// switch to french
await waitForLanguageSwitch(page, Languages.French);

await expect(
header.getByRole('combobox').getByText('Français'),
).toBeVisible();
Expand Down Expand Up @@ -51,12 +54,41 @@ test.describe('Language', () => {
// Check for English 404 response
await check404Response('Not found.');

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

// Check for French 404 response
await check404Response('Pas trouvé.');
});
});

test.afterEach(async ({ page }) => {
// Switch back to English - important for other tests to run, as this updates the language in the user entity
// and in turn updates the language of the frontend.
// Therefore continuing tests, messages that are expected to be english would be french
await waitForLanguageSwitch(page, Languages.English);
});

// eslint-disable-next-line @typescript-eslint/no-unused-vars
enum Languages {
English = 'English',
French = 'Français',
}
const serverSideLanguageValues: Record<Languages, string> = {
[Languages.English]: 'en-us',
[Languages.French]: 'fr-fr',
};
async function waitForLanguageSwitch(page: Page, lang: Languages) {
const header = page.locator('header').first();
await header.getByRole('combobox').click();

const [response] = await Promise.all([
page.waitForResponse(
(resp) =>
resp.url().includes('/user') && resp.request().method() === 'PATCH',
),
header.getByRole('option', { name: lang }).click(),
]);

const updatedUserResponse = await response.json();
expect(updatedUserResponse.language).toBe(serverSideLanguageValues[lang]);
}
16 changes: 14 additions & 2 deletions src/frontend/apps/impress/src/core/AppProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useEffect } from 'react';

import { useCunninghamTheme } from '@/cunningham';
import '@/i18n/initI18n';
import { LANGUAGES_ALLOWED } from '@/i18n/conf';
import i18n from '@/i18n/initI18n';
import { useResponsiveStore } from '@/stores/';

import { Auth } from './auth/';
import { Auth, useAuthStore } from './auth/';

/**
* QueryClient:
Expand All @@ -26,6 +27,7 @@ const queryClient = new QueryClient({

export function AppProvider({ children }: { children: React.ReactNode }) {
const { theme } = useCunninghamTheme();
const { userData } = useAuthStore();

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

useEffect(() => {
if (userData?.language) {
Object.keys(LANGUAGES_ALLOWED).forEach((key) => {
if (userData.language.includes(key) && i18n.language !== key) {
void i18n.changeLanguage(key);
}
});
}
}, [userData?.language]);

return (
<QueryClientProvider client={queryClient}>
<CunninghamProvider theme={theme}>
Expand Down
4 changes: 4 additions & 0 deletions src/frontend/apps/impress/src/core/auth/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { UserLanguage } from '@/i18n/types';

/**
* Represents user retrieved from the API.
* @interface User
* @property {string} id - The id of the user.
* @property {string} email - The email of the user.
* @property {string} name - The name of the user.
* @property {string} language - The language of the user.
*/
export interface User {
id: string;
email: string;
full_name: string;
short_name: string;
language: UserLanguage;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { APIError, errorCauses, fetchAPI } from '@/api';
import { User } from '@/core/auth';
import { Doc, Role } from '@/features/docs/doc-management';
import { OptionType } from '@/features/docs/members/members-add/types';
import { ContentLanguage } from '@/i18n/types';

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

Expand All @@ -14,20 +13,15 @@ interface CreateDocInvitationParams {
email: User['email'];
role: Role;
docId: Doc['id'];
contentLanguage: ContentLanguage;
}

export const createDocInvitation = async ({
email,
role,
docId,
contentLanguage,
}: CreateDocInvitationParams): Promise<Invitation> => {
const response = await fetchAPI(`documents/${docId}/invitations/`, {
method: 'POST',
headers: {
'Content-Language': contentLanguage,
},
body: JSON.stringify({
email,
role,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import {
Role,
} from '@/features/docs/doc-management';
import { KEY_LIST_DOC_ACCESSES } from '@/features/docs/members/members-list';
import { ContentLanguage } from '@/i18n/types';

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

Expand All @@ -19,20 +18,15 @@ interface CreateDocAccessParams {
role: Role;
docId: Doc['id'];
memberId: User['id'];
contentLanguage: ContentLanguage;
}

export const createDocAccess = async ({
memberId,
role,
docId,
contentLanguage,
}: CreateDocAccessParams): Promise<Access> => {
const response = await fetchAPI(`documents/${docId}/accesses/`, {
method: 'POST',
headers: {
'Content-Language': contentLanguage,
},
body: JSON.stringify({
user_id: memberId,
role,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { APIError } from '@/api';
import { Box, Card, IconBG } from '@/components';
import { Doc, Role } from '@/features/docs/doc-management';
import { useCreateDocInvitation } from '@/features/docs/members/invitation-list/';
import { useLanguage } from '@/i18n/hooks/useLanguage';
import { useResponsiveStore } from '@/stores';

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

export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
const { contentLanguage } = useLanguage();
const { t } = useTranslation();
const { isSmallMobile } = useResponsiveStore();
const [selectedUsers, setSelectedUsers] = useState<OptionsSelect>([]);
Expand All @@ -56,7 +54,6 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
email: selectedUser.value.email,
role: selectedRole,
docId: doc.id,
contentLanguage,
});
break;

Expand All @@ -65,7 +62,6 @@ export const AddMembers = ({ currentRole, doc }: ModalAddMembersProps) => {
role: selectedRole,
docId: doc.id,
memberId: selectedUser.value.id,
contentLanguage,
});
break;
}
Expand Down
20 changes: 16 additions & 4 deletions src/frontend/apps/impress/src/features/language/LanguagePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import styled from 'styled-components';
import { Box, Text } from '@/components/';
import { LANGUAGES_ALLOWED } from '@/i18n/conf';

import { useSetUserLanguage } from './api/useChangeUserLanguage';

const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`
flex-shrink: 0;
width: auto;
Expand Down Expand Up @@ -33,6 +35,7 @@ const SelectStyled = styled(Select)<{ $isSmall?: boolean }>`

export const LanguagePicker = () => {
const { t, i18n } = useTranslation();
const { mutateAsync: setUserLanguage } = useSetUserLanguage();
const { preload: languages } = i18n.options;

const optionsPicker = useMemo(() => {
Expand Down Expand Up @@ -63,13 +66,22 @@ export const LanguagePicker = () => {
showLabelWhenSelected={false}
clearable={false}
hideLabel
defaultValue={i18n.language}
value={i18n.language}
className="c_select__no_bg"
options={optionsPicker}
onChange={(e) => {
i18n.changeLanguage(e.target.value as string).catch((err) => {
console.error('Error changing language', err);
});
void i18n
.changeLanguage(e.target.value as string)
.catch((err) => {
console.error('Error changing language', err);
})
.then(() => {
setUserLanguage({
language: i18n.language === 'fr' ? 'fr-fr' : 'en-us',
}).catch((err) => {
console.error('Error changing users language', err);
});
});
}}
/>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { APIError, errorCauses, fetchAPI } from '@/api';
import { User, useAuthStore } from '@/core';

interface SetUserLanguageParams {
language: User['language'];
}

export const setUserLanguage = async ({
language,
}: SetUserLanguageParams): Promise<User> => {
const { userData } = useAuthStore.getState();

if (!userData?.id) {
console.warn('Id of user is needed for this request.');
return {} as Promise<User>;
}

const response = await fetchAPI(`users/${userData.id}/`, {
method: 'PATCH',
body: JSON.stringify({
language,
}),
});

if (!response.ok) {
throw new APIError(
`Failed to change the user language to ${language}`,
await errorCauses(response, {
value: language,
type: 'language',
}),
);
}

return response.json() as Promise<User>;
};

export function useSetUserLanguage() {
const queryClient = useQueryClient();
return useMutation<User, APIError, SetUserLanguageParams>({
mutationFn: setUserLanguage,
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['set-user-language'],
});
},
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ export class ApiPlugin implements WorkboxPlugin {
email: 'dummy-email',
full_name: 'dummy-full-name',
short_name: 'dummy-short-name',
language: 'en-us',
},
abilities: {
destroy: false,
Expand Down
15 changes: 0 additions & 15 deletions src/frontend/apps/impress/src/i18n/hooks/useLanguage.tsx

This file was deleted.

2 changes: 1 addition & 1 deletion src/frontend/apps/impress/src/i18n/types.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
// See: https://github.com/numerique-gouv/impress/blob/ac58341984c99c10ebfac7f8bbe1e8756c48e4d4/src/backend/impress/settings.py#L156-L161
export type ContentLanguage = 'en-us' | 'fr-fr';
export type UserLanguage = 'en-us' | 'fr-fr';

0 comments on commit c645b5b

Please sign in to comment.