From 01b7ad3f30bc3f5395aca9274f01f25a5a08f954 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Fri, 19 Jan 2024 10:43:09 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=90(app-desk)=20install=20internationa?= =?UTF-8?q?lization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Install internationalization in the Desk app. We use react-i18next. --- src/frontend/apps/desk/package.json | 2 + src/frontend/apps/desk/src/app/layout.tsx | 2 + src/frontend/apps/desk/src/app/page.tsx | 3 + src/frontend/apps/desk/src/custom-next.d.ts | 10 ++-- .../desk/src/i18n/__tests__/utils.spec.ts | 55 +++++++++++++++++++ src/frontend/apps/desk/src/i18n/conf.ts | 3 + src/frontend/apps/desk/src/i18n/initI18n.ts | 29 ++++++++++ .../apps/desk/src/i18n/translations.json | 12 ++++ src/frontend/apps/desk/src/i18n/utils.ts | 26 +++++++++ src/frontend/apps/desk/src/tests/utils.tsx | 2 + .../e2e/__tests__/app-desk/language.spec.ts | 6 ++ src/frontend/yarn.lock | 29 +++++++++- 12 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 src/frontend/apps/desk/src/i18n/__tests__/utils.spec.ts create mode 100644 src/frontend/apps/desk/src/i18n/conf.ts create mode 100644 src/frontend/apps/desk/src/i18n/initI18n.ts create mode 100644 src/frontend/apps/desk/src/i18n/translations.json create mode 100644 src/frontend/apps/desk/src/i18n/utils.ts diff --git a/src/frontend/apps/desk/package.json b/src/frontend/apps/desk/package.json index 507300f..856752e 100644 --- a/src/frontend/apps/desk/package.json +++ b/src/frontend/apps/desk/package.json @@ -17,9 +17,11 @@ "dependencies": { "@openfun/cunningham-react": "2.4.0", "@tanstack/react-query": "5.18.1", + "i18next": "23.7.16", "next": "14.1.0", "react": "18.2.0", "react-dom": "18.2.0", + "react-i18next": "14.0.0", "styled-components": "6.1.8", "zustand": "4.5.0" }, diff --git a/src/frontend/apps/desk/src/app/layout.tsx b/src/frontend/apps/desk/src/app/layout.tsx index a3d1cb6..5615285 100644 --- a/src/frontend/apps/desk/src/app/layout.tsx +++ b/src/frontend/apps/desk/src/app/layout.tsx @@ -5,7 +5,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import './globals.css'; + import { useCunninghamTheme } from '@/cunningham'; +import '@/i18n/initI18n'; const queryClient = new QueryClient(); diff --git a/src/frontend/apps/desk/src/app/page.tsx b/src/frontend/apps/desk/src/app/page.tsx index b03f20b..0377d1e 100644 --- a/src/frontend/apps/desk/src/app/page.tsx +++ b/src/frontend/apps/desk/src/app/page.tsx @@ -2,6 +2,7 @@ import { Loader } from '@openfun/cunningham-react'; import { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; import useAuthStore from '@/auth/useAuthStore'; @@ -10,6 +11,7 @@ import { Teams } from './Teams'; export default function Home() { const { initAuth, authenticated, initialized } = useAuthStore(); + const { t } = useTranslation(); useEffect(() => { if (initialized) { @@ -26,6 +28,7 @@ export default function Home() { return (
+

{t('Hello Desk !')}

); diff --git a/src/frontend/apps/desk/src/custom-next.d.ts b/src/frontend/apps/desk/src/custom-next.d.ts index 9a0765c..339ee05 100644 --- a/src/frontend/apps/desk/src/custom-next.d.ts +++ b/src/frontend/apps/desk/src/custom-next.d.ts @@ -6,10 +6,10 @@ declare module '*.svg' { namespace NodeJS { interface ProcessEnv { - NEXT_PUBLIC_API_URL: string; - NEXT_PUBLIC_KEYCLOAK_URL: string; - NEXT_PUBLIC_KEYCLOAK_REALM: string; - NEXT_PUBLIC_KEYCLOAK_CLIENT_ID: string; - NEXT_PUBLIC_KEYCLOAK_LOGIN: string; + NEXT_PUBLIC_API_URL?: string; + NEXT_PUBLIC_KEYCLOAK_URL?: string; + NEXT_PUBLIC_KEYCLOAK_REALM?: string; + NEXT_PUBLIC_KEYCLOAK_CLIENT_ID?: string; + NEXT_PUBLIC_KEYCLOAK_LOGIN?: string; } } diff --git a/src/frontend/apps/desk/src/i18n/__tests__/utils.spec.ts b/src/frontend/apps/desk/src/i18n/__tests__/utils.spec.ts new file mode 100644 index 0000000..38cee3d --- /dev/null +++ b/src/frontend/apps/desk/src/i18n/__tests__/utils.spec.ts @@ -0,0 +1,55 @@ +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/desk/src/i18n/conf.ts b/src/frontend/apps/desk/src/i18n/conf.ts new file mode 100644 index 0000000..25c64b4 --- /dev/null +++ b/src/frontend/apps/desk/src/i18n/conf.ts @@ -0,0 +1,3 @@ +export const LANGUAGES_ALLOWED = ['en', 'fr']; +export const LANGUAGE_LOCAL_STORAGE = 'people-language'; +export const BASE_LANGUAGE = 'fr'; diff --git a/src/frontend/apps/desk/src/i18n/initI18n.ts b/src/frontend/apps/desk/src/i18n/initI18n.ts new file mode 100644 index 0000000..2cff5f9 --- /dev/null +++ b/src/frontend/apps/desk/src/i18n/initI18n.ts @@ -0,0 +1,29 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +import { LANGUAGES_ALLOWED, LANGUAGE_LOCAL_STORAGE } from './conf'; +import resources from './translations.json'; +import { getLanguage } from './utils'; + +i18n + .use(initReactI18next) + .init({ + resources, + lng: getLanguage(), + interpolation: { + escapeValue: false, + }, + preload: LANGUAGES_ALLOWED, + }) + .catch(() => { + 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/desk/src/i18n/translations.json b/src/frontend/apps/desk/src/i18n/translations.json new file mode 100644 index 0000000..706b934 --- /dev/null +++ b/src/frontend/apps/desk/src/i18n/translations.json @@ -0,0 +1,12 @@ +{ + "en": { + "translation": { + "Hello Desk !": "Hello Desk !" + } + }, + "fr": { + "translation": { + "Hello Desk !": "Bienvenue sur Desk !" + } + } +} diff --git a/src/frontend/apps/desk/src/i18n/utils.ts b/src/frontend/apps/desk/src/i18n/utils.ts new file mode 100644 index 0000000..8b520ca --- /dev/null +++ b/src/frontend/apps/desk/src/i18n/utils.ts @@ -0,0 +1,26 @@ +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 LANGUAGES_ALLOWED.includes(language) ? language : BASE_LANGUAGE; +}; diff --git a/src/frontend/apps/desk/src/tests/utils.tsx b/src/frontend/apps/desk/src/tests/utils.tsx index 6733d0f..a43961a 100644 --- a/src/frontend/apps/desk/src/tests/utils.tsx +++ b/src/frontend/apps/desk/src/tests/utils.tsx @@ -2,6 +2,8 @@ import { CunninghamProvider } from '@openfun/cunningham-react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { PropsWithChildren } from 'react'; +import '@/i18n/initI18n'; + export const AppWrapper = ({ children }: PropsWithChildren) => { const queryClient = new QueryClient({ defaultOptions: { diff --git a/src/frontend/apps/e2e/__tests__/app-desk/language.spec.ts b/src/frontend/apps/e2e/__tests__/app-desk/language.spec.ts index 21a1937..0b35bb9 100644 --- a/src/frontend/apps/e2e/__tests__/app-desk/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-desk/language.spec.ts @@ -8,6 +8,12 @@ test.beforeEach(async ({ page }) => { }); test.describe("Language", () => { + test("checks translation library works", async ({ page }) => { + await expect( + page.locator("h1").first().getByText("Bienvenue sur Desk !"), + ).toBeVisible(); + }); + test("checks the language picker", async ({ page }) => { const header = page.locator("header").first(); diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 85110fa..06c6374 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -268,7 +268,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.22.5" -"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.22.15", "@babel/runtime@^7.23.2", "@babel/runtime@^7.9.2": +"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.5", "@babel/runtime@^7.22.15", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.9.2": version "7.23.8" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.8.tgz#8ee6fe1ac47add7122902f257b8ddf55c898f650" integrity sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw== @@ -4150,6 +4150,13 @@ html-escaper@^2.0.0: resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== +html-parse-stringify@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz#dfc1017347ce9f77c8141a507f233040c59c55d2" + integrity sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg== + dependencies: + void-elements "3.1.0" + html-tags@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce" @@ -4177,6 +4184,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@23.7.16: + version "23.7.16" + resolved "https://registry.yarnpkg.com/i18next/-/i18next-23.7.16.tgz#7026d18b7a3ac9e2ecfeb78da5e4da5ca33312ef" + integrity sha512-SrqFkMn9W6Wb43ZJ9qrO6U2U4S80RsFMA7VYFSqp7oc7RllQOYDCdRfsse6A7Cq/V8MnpxKvJCYgM8++27n4Fw== + dependencies: + "@babel/runtime" "^7.23.2" + iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" @@ -5749,6 +5763,14 @@ react-dom@18.2.0: loose-envify "^1.1.0" scheduler "^0.23.0" +react-i18next@14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-14.0.0.tgz#eb39d2245fd1024237828c955f770e409a1ccb12" + integrity sha512-OCrS8rHNAmnr8ggGRDxjakzihrMW7HCbsplduTm3EuuQ6fyvWGT41ksZpqbduYoqJurBmEsEVZ1pILSUWkHZng== + dependencies: + "@babel/runtime" "^7.22.5" + html-parse-stringify "^3.0.1" + react-is@^16.13.1: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -6574,6 +6596,11 @@ v8-to-istanbul@^9.0.1: "@types/istanbul-lib-coverage" "^2.0.1" convert-source-map "^2.0.0" +void-elements@3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" + integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== + w3c-xmlserializer@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073"