🐛(i18n) same frontend and backend language using shared cookies
frontend: switch to cookie-based language selection backend: use cookie for language
This commit is contained in:
@@ -18,6 +18,7 @@ and this project adheres to
|
|||||||
## Fixed
|
## Fixed
|
||||||
|
|
||||||
- 🐛(backend) require right to manage document accesses to see invitations #369
|
- 🐛(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
|
- 🐛(frontend) add default toolbar buttons #355
|
||||||
|
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ and this project adheres to
|
|||||||
|
|
||||||
- ✨AI to doc editor #250
|
- ✨AI to doc editor #250
|
||||||
- ✨(backend) allow uploading more types of attachments #309
|
- ✨(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
|
## Changed
|
||||||
|
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ class Base(Configuration):
|
|||||||
|
|
||||||
# Languages
|
# Languages
|
||||||
LANGUAGE_CODE = values.Value("en-us")
|
LANGUAGE_CODE = values.Value("en-us")
|
||||||
|
LANGUAGE_COOKIE_NAME = "docs_language" # cookie & language is set from frontend
|
||||||
|
|
||||||
DRF_NESTED_MULTIPART_PARSER = {
|
DRF_NESTED_MULTIPART_PARSER = {
|
||||||
# output of parser is converted to querydict
|
# output of parser is converted to querydict
|
||||||
|
|||||||
@@ -25,4 +25,38 @@ test.describe('Language', () => {
|
|||||||
}),
|
}),
|
||||||
).toBeVisible();
|
).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é.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
"@openfun/cunningham-react": "2.9.4",
|
"@openfun/cunningham-react": "2.9.4",
|
||||||
"@tanstack/react-query": "5.59.15",
|
"@tanstack/react-query": "5.59.15",
|
||||||
"i18next": "23.16.2",
|
"i18next": "23.16.2",
|
||||||
|
"i18next-browser-languagedetector": "8.0.0",
|
||||||
"idb": "8.0.0",
|
"idb": "8.0.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"luxon": "3.5.0",
|
"luxon": "3.5.0",
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,5 +2,5 @@ export const LANGUAGES_ALLOWED: { [key: string]: string } = {
|
|||||||
en: 'English',
|
en: 'English',
|
||||||
fr: 'Français',
|
fr: 'Français',
|
||||||
};
|
};
|
||||||
export const LANGUAGE_LOCAL_STORAGE = 'impress-language';
|
export const LANGUAGE_COOKIE_NAME = 'docs_language';
|
||||||
export const BASE_LANGUAGE = 'fr';
|
export const BASE_LANGUAGE = 'en';
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
|
import LanguageDetector from 'i18next-browser-languagedetector';
|
||||||
import { initReactI18next } from 'react-i18next';
|
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 resources from './translations.json';
|
||||||
import { getLanguage } from './utils';
|
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
|
.use(LanguageDetector)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
resources,
|
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: {
|
interpolation: {
|
||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
},
|
},
|
||||||
@@ -20,11 +28,4 @@ i18n
|
|||||||
throw new Error('i18n initialization failed');
|
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;
|
export default i18n;
|
||||||
|
|||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -7167,6 +7167,13 @@ human-signals@^2.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
|
resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
|
||||||
integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
|
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:
|
i18next-parser@9.0.2:
|
||||||
version "9.0.2"
|
version "9.0.2"
|
||||||
resolved "https://registry.yarnpkg.com/i18next-parser/-/i18next-parser-9.0.2.tgz#f9d627422d33c352967556c8724975d58f1f5a95"
|
resolved "https://registry.yarnpkg.com/i18next-parser/-/i18next-parser-9.0.2.tgz#f9d627422d33c352967556c8724975d58f1f5a95"
|
||||||
|
|||||||
Reference in New Issue
Block a user