diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d70a16e..dd88e3d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to ## Fixed - 🐛(backend) require right to manage document accesses to see invitations #369 +- 🐛(i18n) same frontend and backend language using shared cookies #365 - 🐛(frontend) add default toolbar buttons #355 @@ -27,7 +28,7 @@ and this project adheres to - ✨AI to doc editor #250 - ✨(backend) allow uploading more types of attachments #309 -- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #300 +- ✨(frontend) add buttons to copy document to clipboard as HTML/Markdown #318 ## Changed diff --git a/src/backend/impress/settings.py b/src/backend/impress/settings.py index 8a6fa8e0..394bbc83 100755 --- a/src/backend/impress/settings.py +++ b/src/backend/impress/settings.py @@ -223,6 +223,7 @@ class Base(Configuration): # Languages LANGUAGE_CODE = values.Value("en-us") + LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend DRF_NESTED_MULTIPART_PARSER = { # output of parser is converted to querydict 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 cc787605..3ed6317a 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -25,4 +25,38 @@ test.describe('Language', () => { }), ).toBeVisible(); }); + + test('checks that backend uses the same language as the frontend', async ({ + page, + }) => { + // Helper function to intercept and assert 404 response + const check404Response = async (expectedDetail: string) => { + const expectedBackendResponse = page.waitForResponse( + (response) => + response.url().includes('/api') && + response.url().includes('non-existent-doc-uuid') && + response.status() === 404, + ); + + // Trigger the specific 404 XHR response by navigating to a non-existent document + await page.goto('/docs/non-existent-doc-uuid'); + + // Assert that the intercepted error message is in the expected language + const interceptedBackendResponse = await expectedBackendResponse; + expect(await interceptedBackendResponse.json()).toStrictEqual({ + detail: expectedDetail, + }); + }; + + // 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(); + + // Check for French 404 response + await check404Response('Pas trouvé.'); + }); }); diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index 8f3a17bd..66740a91 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -23,6 +23,7 @@ "@openfun/cunningham-react": "2.9.4", "@tanstack/react-query": "5.59.15", "i18next": "23.16.2", + "i18next-browser-languagedetector": "8.0.0", "idb": "8.0.0", "lodash": "4.17.21", "luxon": "3.5.0", diff --git a/src/frontend/apps/impress/src/i18n/__tests__/utils.spec.ts b/src/frontend/apps/impress/src/i18n/__tests__/utils.spec.ts deleted file mode 100644 index 38cee3d6..00000000 --- a/src/frontend/apps/impress/src/i18n/__tests__/utils.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { LANGUAGE_LOCAL_STORAGE } from '../conf'; -import { getLanguage, splitLocaleCode } from '../utils'; - -describe('i18n utils', () => { - afterEach(() => { - localStorage.removeItem(LANGUAGE_LOCAL_STORAGE); - }); - - it('checks language code is correctly splitted', () => { - expect(splitLocaleCode('fr_FR')).toEqual({ language: 'fr', region: 'FR' }); - expect(splitLocaleCode('en_US')).toEqual({ language: 'en', region: 'US' }); - expect(splitLocaleCode('en')).toEqual({ - language: 'en', - region: undefined, - }); - expect(splitLocaleCode('fr-FR')).toEqual({ language: 'fr', region: 'FR' }); - expect(splitLocaleCode('en-US')).toEqual({ language: 'en', region: 'US' }); - }); - - it('checks that we get expected language from local storage', () => { - localStorage.setItem(LANGUAGE_LOCAL_STORAGE, 'fr_FR'); - expect(getLanguage()).toEqual('fr'); - localStorage.removeItem(LANGUAGE_LOCAL_STORAGE); - - localStorage.setItem(LANGUAGE_LOCAL_STORAGE, 'en_FR'); - expect(getLanguage()).toEqual('en'); - - localStorage.setItem(LANGUAGE_LOCAL_STORAGE, 'xx_XX'); - expect(getLanguage()).toEqual('fr'); - }); - - it('checks that we get expected language from browser', () => { - Object.defineProperty(navigator, 'language', { - value: 'fr', - writable: false, - configurable: true, - }); - - expect(getLanguage()).toEqual('fr'); - - Object.defineProperty(navigator, 'language', { - value: 'en', - writable: false, - configurable: true, - }); - expect(getLanguage()).toEqual('en'); - - Object.defineProperty(navigator, 'language', { - value: 'xx', - writable: false, - configurable: true, - }); - expect(getLanguage()).toEqual('fr'); - }); -}); diff --git a/src/frontend/apps/impress/src/i18n/conf.ts b/src/frontend/apps/impress/src/i18n/conf.ts index d58f5730..2ecc8a22 100644 --- a/src/frontend/apps/impress/src/i18n/conf.ts +++ b/src/frontend/apps/impress/src/i18n/conf.ts @@ -2,5 +2,5 @@ export const LANGUAGES_ALLOWED: { [key: string]: string } = { en: 'English', fr: 'Français', }; -export const LANGUAGE_LOCAL_STORAGE = 'impress-language'; -export const BASE_LANGUAGE = 'fr'; +export const LANGUAGE_COOKIE_NAME = 'docs_language'; +export const BASE_LANGUAGE = 'en'; diff --git a/src/frontend/apps/impress/src/i18n/initI18n.ts b/src/frontend/apps/impress/src/i18n/initI18n.ts index f5a8f4be..e61b2dbc 100644 --- a/src/frontend/apps/impress/src/i18n/initI18n.ts +++ b/src/frontend/apps/impress/src/i18n/initI18n.ts @@ -1,15 +1,23 @@ import i18n from 'i18next'; +import LanguageDetector from 'i18next-browser-languagedetector'; import { initReactI18next } from 'react-i18next'; -import { LANGUAGES_ALLOWED, LANGUAGE_LOCAL_STORAGE } from './conf'; +import { BASE_LANGUAGE, LANGUAGES_ALLOWED, LANGUAGE_COOKIE_NAME } from './conf'; import resources from './translations.json'; -import { getLanguage } from './utils'; i18n + .use(LanguageDetector) .use(initReactI18next) .init({ resources, - lng: getLanguage(), + 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 + }, interpolation: { escapeValue: false, }, @@ -20,11 +28,4 @@ i18n throw new Error('i18n initialization failed'); }); -// Save language in local storage -i18n.on('languageChanged', (lng) => { - if (typeof window !== 'undefined') { - localStorage.setItem(LANGUAGE_LOCAL_STORAGE, lng); - } -}); - export default i18n; diff --git a/src/frontend/apps/impress/src/i18n/utils.ts b/src/frontend/apps/impress/src/i18n/utils.ts deleted file mode 100644 index edf51715..00000000 --- a/src/frontend/apps/impress/src/i18n/utils.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { - BASE_LANGUAGE, - LANGUAGES_ALLOWED, - LANGUAGE_LOCAL_STORAGE, -} from './conf'; - -export const splitLocaleCode = (language: string) => { - const locale = language.split(/[-_]/); - return { - language: locale[0], - region: locale.length === 2 ? locale[1] : undefined, - }; -}; - -export const getLanguage = () => { - if (typeof window === 'undefined') { - return BASE_LANGUAGE; - } - - const languageStore = - localStorage.getItem(LANGUAGE_LOCAL_STORAGE) || navigator?.language; - - const language = splitLocaleCode(languageStore).language; - - return Object.keys(LANGUAGES_ALLOWED).includes(language) - ? language - : BASE_LANGUAGE; -}; diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 427d00da..4909a50c 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -7167,6 +7167,13 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +i18next-browser-languagedetector@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.0.tgz#b6fdd9b43af67c47f2c26c9ba27710a1eaf31e2f" + integrity sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw== + dependencies: + "@babel/runtime" "^7.23.2" + i18next-parser@9.0.2: version "9.0.2" resolved "https://registry.yarnpkg.com/i18next-parser/-/i18next-parser-9.0.2.tgz#f9d627422d33c352967556c8724975d58f1f5a95"