diff --git a/CHANGELOG.md b/CHANGELOG.md index 4955f2f65..d71c98de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to - 🚸(backend) improve users similarity search and sort results #391 - ♻️(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/backend/demo/defaults.py b/src/backend/demo/defaults.py index 4f6fb5a2f..4b082e39c 100644 --- a/src/backend/demo/defaults.py +++ b/src/backend/demo/defaults.py @@ -7,17 +7,12 @@ } DEV_USERS = [ + {"username": "impress", "email": "impress@impress.world", "language": "en-us"}, + {"username": "user-e2e-webkit", "email": "user@webkit.e2e", "language": "en-us"}, + {"username": "user-e2e-firefox", "email": "user@firefox.e2e", "language": "en-us"}, { - "username": "impress", - "email": "impress@impress.world", + "username": "user-e2e-chromium", + "email": "user@chromium.e2e", + "language": "en-us", }, - { - "username": "user-e2e-webkit", - "email": "user@webkit.e2e", - }, - { - "username": "user-e2e-firefox", - "email": "user@firefox.e2e", - }, - {"username": "user-e2e-chromium", "email": "user@chromium.e2e"}, ] diff --git a/src/backend/demo/management/commands/create_demo.py b/src/backend/demo/management/commands/create_demo.py index 49cde524b..02d34f8d5 100644 --- a/src/backend/demo/management/commands/create_demo.py +++ b/src/backend/demo/management/commands/create_demo.py @@ -172,7 +172,8 @@ def create_demo(stdout): is_superuser=False, is_active=True, is_staff=False, - language=random.choice(settings.LANGUAGES)[0], + language=dev_user["language"] + or random.choice(settings.LANGUAGES)[0], ) ) diff --git a/src/frontend/apps/e2e/__tests__/app-impress/common.ts b/src/frontend/apps/e2e/__tests__/app-impress/common.ts index 900d32651..1b7056652 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/common.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/common.ts @@ -238,3 +238,39 @@ export const mockedAccesses = async (page: Page, json?: object) => { } }); }; + +// language helper +export const TestLanguage = { + English: { + label: 'English', + expectedLocale: ['en-us'], + }, + French: { + label: 'Français', + expectedLocale: ['fr-fr'], + }, +} as const; + +type TestLanguageKey = keyof typeof TestLanguage; +type TestLanguageValue = (typeof TestLanguage)[TestLanguageKey]; + +export async function waitForLanguageSwitch( + page: Page, + lang: TestLanguageValue, +) { + 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.label }).click(), + ]); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const updatedUserResponse = await response.json(); + + expect(lang.expectedLocale).toContain(updatedUserResponse.language); +} diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts index d41660a5d..3771ebcb0 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-editor.spec.ts @@ -9,41 +9,6 @@ test.beforeEach(async ({ page }) => { }); test.describe('Doc Editor', () => { - test('it check translations of the slash menu when changing language', async ({ - page, - browserName, - }) => { - await createDoc(page, 'doc-toolbar', browserName, 1); - - const header = page.locator('header').first(); - const editor = page.locator('.ProseMirror'); - // Trigger slash menu to show english menu - await editor.click(); - await editor.fill('/'); - await expect(page.getByText('Headings', { exact: true })).toBeVisible(); - await header.click(); - await expect(page.getByText('Headings', { exact: true })).toBeHidden(); - - // Reset menu - await editor.click(); - await editor.fill(''); - - // Change language to French - await header.click(); - await header.getByRole('combobox').getByText('English').click(); - await header.getByRole('option', { name: 'Français' }).click(); - await expect( - header.getByRole('combobox').getByText('Français'), - ).toBeVisible(); - - // Trigger slash menu to show french menu - await editor.click(); - await editor.fill('/'); - await expect(page.getByText('Titres', { exact: true })).toBeVisible(); - await header.click(); - await expect(page.getByText('Titres', { exact: true })).toBeHidden(); - }); - test('it checks default toolbar buttons are displayed', async ({ page, browserName, diff --git a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts index 8450b6f33..58a4ec079 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/doc-member-create.spec.ts @@ -111,9 +111,6 @@ test.describe('Document create member', () => { await expect(page.getByText(`Invitation sent to ${email}`)).toBeVisible(); const responseCreateInvitation = await responsePromiseCreateInvitation; expect(responseCreateInvitation.ok()).toBeTruthy(); - expect( - responseCreateInvitation.request().headers()['content-language'], - ).toBe('en-us'); // Check user added await expect( @@ -121,9 +118,6 @@ test.describe('Document create member', () => { ).toBeVisible(); const responseAddUser = await responsePromiseAddUser; expect(responseAddUser.ok()).toBeTruthy(); - expect(responseAddUser.request().headers()['content-language']).toBe( - 'en-us', - ); const listInvitation = page.getByLabel('List invitation card'); await expect(listInvitation.locator('li').getByText(email)).toBeVisible(); @@ -225,46 +219,6 @@ test.describe('Document create member', () => { expect(responseCreateInvitationFail.ok()).toBeFalsy(); }); - test('The invitation endpoint get the language of the website', async ({ - page, - browserName, - }) => { - await createDoc(page, 'user-invitation', browserName, 1); - - const header = page.locator('header').first(); - await header.getByRole('combobox').getByText('EN').click(); - await header.getByRole('option', { name: 'FR' }).click(); - - await page.getByRole('button', { name: 'Partager' }).click(); - - const inputSearch = page.getByLabel( - /Trouver un membre à ajouter au document/, - ); - - const email = randomName('test@test.fr', browserName, 1)[0]; - await inputSearch.fill(email); - await page.getByRole('option', { name: email }).click(); - - // Choose a role - await page.getByRole('combobox', { name: /Choisissez un rôle/ }).click(); - await page.getByRole('option', { name: 'Administrateur' }).click(); - - const responsePromiseCreateInvitation = page.waitForResponse( - (response) => - response.url().includes('/invitations/') && response.status() === 201, - ); - - await page.getByRole('button', { name: 'Valider' }).click(); - - // Check invitation sent - await expect(page.getByText(`Invitation envoyée à ${email}`)).toBeVisible(); - const responseCreateInvitation = await responsePromiseCreateInvitation; - expect(responseCreateInvitation.ok()).toBeTruthy(); - expect( - responseCreateInvitation.request().headers()['content-language'], - ).toBe('fr-fr'); - }); - test('it manages invitation', async ({ page, browserName }) => { await createDoc(page, 'user-invitation', browserName, 1); 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..68cd9e7a8 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,25 @@ import { expect, test } from '@playwright/test'; +import { TestLanguage, createDoc, waitForLanguageSwitch } from './common'; + 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, TestLanguage.French); + await expect( header.getByRole('combobox').getByText('Français'), ).toBeVisible(); @@ -63,12 +68,44 @@ 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, TestLanguage.French); // Check for French 404 response await check404Response('Pas trouvé.'); }); + + test('it check translations of the slash menu when changing language', async ({ + page, + browserName, + }) => { + await createDoc(page, 'doc-toolbar', browserName, 1); + + const header = page.locator('header').first(); + const editor = page.locator('.ProseMirror'); + // Trigger slash menu to show english menu + await editor.click(); + await editor.fill('/'); + await expect(page.getByText('Headings', { exact: true })).toBeVisible(); + await header.click(); + await expect(page.getByText('Headings', { exact: true })).toBeHidden(); + + // Reset menu + await editor.click(); + await editor.fill(''); + + // Change language to French + await waitForLanguageSwitch(page, TestLanguage.French); + + // Trigger slash menu to show french menu + await editor.click(); + await editor.fill('/'); + await expect(page.getByText('Titres', { exact: true })).toBeVisible(); + await header.click(); + await expect(page.getByText('Titres', { exact: true })).toBeHidden(); + }); +}); + +test.afterEach(async ({ page }) => { + // Switch back to English - important for other tests to run as expected + await waitForLanguageSwitch(page, TestLanguage.English); }); 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..07097f550 100644 --- a/src/frontend/apps/impress/src/core/auth/api/types.ts +++ b/src/frontend/apps/impress/src/core/auth/api/types.ts @@ -4,10 +4,12 @@ * @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: string; } diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx index d6fd9c0a6..fc156cd83 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/BlockNoteEditor.tsx @@ -102,7 +102,7 @@ export const BlockNoteEditor = ({ } = useCreateDocAttachment(); const { setHeadings, resetHeadings } = useHeadingStore(); const { i18n } = useTranslation(); - const lang = i18n.language; + const lang = i18n.resolvedLanguage; const uploadFile = useCallback( async (file: File) => { diff --git a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx index 0953fae5b..07c4fd7c8 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx @@ -1,10 +1,11 @@ import { Select } from '@openfun/cunningham-react'; -import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; import { Box, Text } from '@/components/'; -import { LANGUAGES_ALLOWED } from '@/i18n/conf'; +import { BASE_LANGUAGE, LANGUAGES_ALLOWED } from '@/i18n/conf'; + +import { useSetUserLanguage } from './api/useChangeUserLanguage'; const SelectStyled = styled(Select)<{ $isSmall?: boolean }>` flex-shrink: 0; @@ -33,29 +34,27 @@ const SelectStyled = styled(Select)<{ $isSmall?: boolean }>` export const LanguagePicker = () => { const { t, i18n } = useTranslation(); - const { preload: languages } = i18n.options; + const { mutateAsync: setUserLanguage } = useSetUserLanguage(); - const optionsPicker = useMemo(() => { - return (languages || []).map((lang) => ({ - value: lang, - label: lang, - render: () => ( - - - translate - - - {LANGUAGES_ALLOWED[lang]} - - - ), - })); - }, [languages]); + const optionsPicker = Object.keys(LANGUAGES_ALLOWED).map((lang) => ({ + value: lang, + label: lang, + render: () => ( + + + translate + + + {LANGUAGES_ALLOWED[lang]} + + + ), + })); return ( { showLabelWhenSelected={false} clearable={false} hideLabel - defaultValue={i18n.language} + value={ + Object.keys(LANGUAGES_ALLOWED).find( + (key) => + key === i18n.language || + key.startsWith(i18n.language.split('-')[0]), + ) ?? BASE_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) + .then(() => { + setUserLanguage({ language: i18n.language }).catch((err) => { + console.error('Error changing users language', err); + }); + }) + .catch((err) => { + console.error('Error changing 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, diff --git a/src/frontend/apps/impress/src/i18n/conf.ts b/src/frontend/apps/impress/src/i18n/conf.ts index dd42827c4..73bd38d2b 100644 --- a/src/frontend/apps/impress/src/i18n/conf.ts +++ b/src/frontend/apps/impress/src/i18n/conf.ts @@ -1,7 +1,7 @@ export const LANGUAGES_ALLOWED: { [key: string]: string } = { - en: 'English', - fr: 'Français', - de: 'Deutsch', + 'en-us': 'English', + 'fr-fr': 'Français', + 'de-de': 'Deutsch', }; export const LANGUAGE_COOKIE_NAME = 'docs_language'; -export const BASE_LANGUAGE = 'en'; +export const BASE_LANGUAGE = Object.keys(LANGUAGES_ALLOWED)[0]; diff --git a/src/frontend/apps/impress/src/i18n/initI18n.ts b/src/frontend/apps/impress/src/i18n/initI18n.ts index cc98586b2..1fee919ec 100644 --- a/src/frontend/apps/impress/src/i18n/initI18n.ts +++ b/src/frontend/apps/impress/src/i18n/initI18n.ts @@ -2,7 +2,7 @@ import i18n from 'i18next'; import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; -import { BASE_LANGUAGE, LANGUAGES_ALLOWED, LANGUAGE_COOKIE_NAME } from './conf'; +import { LANGUAGES_ALLOWED, LANGUAGE_COOKIE_NAME } from './conf'; import resources from './translations.json'; i18n @@ -10,18 +10,22 @@ i18n .use(initReactI18next) .init({ resources, - fallbackLng: BASE_LANGUAGE, - supportedLngs: Object.keys(LANGUAGES_ALLOWED), detection: { order: ['cookie', 'navigator'], // detection order caches: ['cookie'], // Use cookies to store the language preference lookupCookie: LANGUAGE_COOKIE_NAME, cookieMinutes: 525600, // Expires after one year + cookieOptions: { + sameSite: 'lax', + }, }, interpolation: { escapeValue: false, }, - preload: Object.keys(LANGUAGES_ALLOWED), + preload: Object.keys(LANGUAGES_ALLOWED).map( + (locale) => locale.split('-')[0], + ), + lowerCaseLng: true, nsSeparator: false, keySeparator: false, }) diff --git a/src/frontend/apps/impress/src/i18n/types.ts b/src/frontend/apps/impress/src/i18n/types.ts deleted file mode 100644 index ccedc350c..000000000 --- a/src/frontend/apps/impress/src/i18n/types.ts +++ /dev/null @@ -1,2 +0,0 @@ -// See: https://github.com/numerique-gouv/impress/blob/ac58341984c99c10ebfac7f8bbe1e8756c48e4d4/src/backend/impress/settings.py#L156-L161 -export type UserLanguage = 'en-us' | 'fr-fr';