diff --git a/CHANGELOG.md b/CHANGELOG.md index 844a0555b..92335725a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to - 🌐(backend) add german translation #259 - ♻️(frontend) simplify stores #402 - ✨(frontend) update $css Box props type to add styled components RuleSet #423 +- ✨(frontend) sync user and frontend language #401 ## Fixed diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 2947e3b8f..8967b6e27 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -18,7 +18,7 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = ["id", "email", "full_name", "short_name"] + fields = ["id", "email", "full_name", "short_name", "language"] read_only_fields = ["id", "email", "full_name", "short_name"] diff --git a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts index 9d7e3f3dc..960662262 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -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(); @@ -63,12 +66,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.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]); +} diff --git a/src/frontend/apps/impress/src/core/AppProvider.tsx b/src/frontend/apps/impress/src/core/AppProvider.tsx index 9c6df0802..f76f95a03 100644 --- a/src/frontend/apps/impress/src/core/AppProvider.tsx +++ b/src/frontend/apps/impress/src/core/AppProvider.tsx @@ -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: @@ -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, @@ -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 ( diff --git a/src/frontend/apps/impress/src/core/auth/api/types.ts b/src/frontend/apps/impress/src/core/auth/api/types.ts index ef1893606..b50095a78 100644 --- a/src/frontend/apps/impress/src/core/auth/api/types.ts +++ b/src/frontend/apps/impress/src/core/auth/api/types.ts @@ -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; } diff --git a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx index 0953fae5b..de84bf2ca 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx @@ -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; @@ -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(() => { @@ -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); + }); + }); }} /> ); diff --git a/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx b/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx new file mode 100644 index 000000000..d544ddf09 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx @@ -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 => { + const { userData } = useAuthStore.getState(); + + if (!userData?.id) { + console.warn('Id of user is needed for this request.'); + return {} as Promise; + } + + 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; +}; + +export function useSetUserLanguage() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: setUserLanguage, + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: ['set-user-language'], + }); + }, + }); +} diff --git a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts b/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts index 5e13d7430..98fef92be 100644 --- a/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts +++ b/src/frontend/apps/impress/src/features/service-worker/ApiPlugin.ts @@ -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,