From c623f4b35bba7ce914fdbd0f9d60f0b2b07f4a14 Mon Sep 17 00:00:00 2001 From: Basile Spaenlehauer Date: Wed, 27 Nov 2024 15:38:02 +0100 Subject: [PATCH] feat: add language detector (#493) * fix: wip * fix: add unit tests * chore: remove debug in i18n config * fix: use role button * fix: tests --- .github/workflows/test.yml | 3 ++ cypress/e2e/player/autoLogin.cy.ts | 2 +- cypress/e2e/player/redirections.cy.ts | 2 +- package.json | 4 +- pnpm-lock.yaml | 10 ++++ public/locales/it/auth.json | 2 +- src/AuthContext.tsx | 12 +++++ src/components/langs.ts | 23 ++++---- src/config/i18n.ts | 13 +++++ src/main.tsx | 54 ++++--------------- src/modules/auth/components/Footer.tsx | 12 +++-- src/modules/auth/components/Redirection.tsx | 1 - .../player/item/ItemForbiddenScreen.tsx | 1 - .../player/$rootId/$itemId/autoLogin.tsx | 7 ++- 14 files changed, 78 insertions(+), 68 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a088a141e..159b89833 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -49,6 +49,9 @@ jobs: env: VITE_RECAPTCHA_SITE_KEY: 123456789 + - name: Unit tests + run: pnpm test:unit + cypress: name: Cypress runs-on: ubuntu-latest diff --git a/cypress/e2e/player/autoLogin.cy.ts b/cypress/e2e/player/autoLogin.cy.ts index 1b8e3a055..a0cf0e194 100644 --- a/cypress/e2e/player/autoLogin.cy.ts +++ b/cypress/e2e/player/autoLogin.cy.ts @@ -53,7 +53,7 @@ describe('Auto Login on pseudonimized item', () => { }; cy.visit(buildAutoLoginPath(routeArgs)); cy.get(`#${AUTO_LOGIN_CONTAINER_ID}`).should('be.visible'); - cy.get(`#${AUTO_LOGIN_CONTAINER_ID} [role="button"]`).click(); + cy.get(`#${AUTO_LOGIN_CONTAINER_ID} button`).click(); // checks that the user was correctly redirected to the item page const { searchParams: _, ...pathArgs } = routeArgs; diff --git a/cypress/e2e/player/redirections.cy.ts b/cypress/e2e/player/redirections.cy.ts index 94fdd18ea..06432f9c7 100644 --- a/cypress/e2e/player/redirections.cy.ts +++ b/cypress/e2e/player/redirections.cy.ts @@ -54,7 +54,7 @@ describe('Item page', () => { }); it('Should redirect to auth with url parameter', () => { - cy.get(`#${FORBIDDEN_CONTENT_CONTAINER_ID} [role="button"]`) + cy.get(`#${FORBIDDEN_CONTENT_CONTAINER_ID} button`) .should('be.visible') .click(); cy.url().should('include', `?url=`); diff --git a/package.json b/package.json index 1441fa5da..013969767 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "date-fns": "4.1.0", "http-status-codes": "2.3.0", "i18next": "24.0.2", + "i18next-browser-languagedetector": "8.0.0", "i18next-fetch-backend": "6.0.0", "jwt-decode": "4.0.0", "lodash.truncate": "4.4.2", @@ -75,8 +76,9 @@ "preinstall": "npx only-allow pnpm", "cypress:open": "env-cmd -f ./.env.test cypress open --browser chrome", "cypress": "pnpm test", + "cypress:run": "env-cmd -f ./.env.test cypress run --browser chrome", "test": "pnpm build:test && concurrently -k -s first \"pnpm preview:test\" \"pnpm cypress:run\"", - "cypress:run": "env-cmd -f ./.env.test cypress run --browser chrome" + "test:unit": "vitest" }, "browserslist": { "production": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f1dda1d3a..db6740b14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: i18next: specifier: 24.0.2 version: 24.0.2(typescript@5.7.2) + i18next-browser-languagedetector: + specifier: 8.0.0 + version: 8.0.0 i18next-fetch-backend: specifier: 6.0.0 version: 6.0.0 @@ -3580,6 +3583,9 @@ packages: engines: {node: '>=18'} hasBin: true + i18next-browser-languagedetector@8.0.0: + resolution: {integrity: sha512-zhXdJXTTCoG39QsrOCiOabnWj2jecouOqbchu3EfhtSHxIB5Uugnm9JaizenOy39h7ne3+fLikIjeW88+rgszw==} + i18next-fetch-backend@6.0.0: resolution: {integrity: sha512-kVqnydqLVMZfVlOuP2nf71cREydlxEKLH43jUXAFdOku/GF+6b9fBg31anoos5XncdhdtiYgL9fheqMrtXRwng==} engines: {node: '>=18'} @@ -9509,6 +9515,10 @@ snapshots: husky@9.1.7: {} + i18next-browser-languagedetector@8.0.0: + dependencies: + '@babel/runtime': 7.26.0 + i18next-fetch-backend@6.0.0: {} i18next@24.0.2(typescript@5.7.2): diff --git a/public/locales/it/auth.json b/public/locales/it/auth.json index 528416be7..f480845d4 100644 --- a/public/locales/it/auth.json +++ b/public/locales/it/auth.json @@ -15,7 +15,7 @@ "RESEND_EMAIL_BUTTON": "Rinvia l'e-mail", "SIGN_IN_BUTTON": "Registrazione", "SIGN_IN_PASSWORD_BUTTON": "Login", - "LOGIN_TITLE": "Registrazione", + "LOGIN_TITLE": "Connessione", "SIGN_IN_LINK_TEXT": "Hai già un account? Accedi", "SIGN_IN_SUCCESS_EMAIL_PROBLEM": "Se non hai ricevuto nessuna email, controlla lo spam. Se hai utilizzato un'e-mail istituzionale (es. scuola, azienda), potrebbe essere stata bloccata e dovrai attendere fino a quando l'e-mail non verrà rilasciata dal tuo sistema antispam.", "SIGN_IN_SUCCESS_TEXT": "Controlla la tua casella di posta {{email}} per recuperare il tuo link di accesso personale per accedere a Graasp. L'operazione può richiedere diversi minuti.", diff --git a/src/AuthContext.tsx b/src/AuthContext.tsx index 9a323d235..503838cc3 100644 --- a/src/AuthContext.tsx +++ b/src/AuthContext.tsx @@ -3,6 +3,7 @@ import { createContext, useCallback, useContext, + useEffect, useMemo, } from 'react'; @@ -63,6 +64,17 @@ export function AuthProvider({ [useLogin], ); + useEffect(() => { + if (currentMember) { + localStorage.setItem( + 'i18nextLng', + getCurrentAccountLang(currentMember, DEFAULT_LANG), + ); + } else { + localStorage.removeItem('i18nextLng'); + } + }, [currentMember]); + const value = useMemo(() => { if (currentMember) { return { diff --git a/src/components/langs.ts b/src/components/langs.ts index 021fac245..22d5a81bc 100644 --- a/src/components/langs.ts +++ b/src/components/langs.ts @@ -1,4 +1,4 @@ -import { DEFAULT_LANG, langs } from '@graasp/translations'; +import { DEFAULT_LANG } from '@graasp/translations'; import { Locale } from 'date-fns'; import { ar } from 'date-fns/locale/ar'; @@ -9,14 +9,19 @@ import { fr } from 'date-fns/locale/fr'; import { it } from 'date-fns/locale/it'; const dateFnsLocales = { - [langs.en]: enUS, - [langs.fr]: fr, - [langs.de]: de, - [langs.it]: it, - [langs.es]: es, - [langs.ar]: ar, -}; + en: enUS, + fr: fr, + de: de, + it: it, + es: es, + ar: ar, +} as const; export function getLocalForDateFns(i18nLocale: string): Locale { - return dateFnsLocales[i18nLocale] ?? dateFnsLocales[DEFAULT_LANG]; + if (Object.keys(dateFnsLocales).includes(i18nLocale)) { + const locale = i18nLocale as keyof typeof dateFnsLocales; + return dateFnsLocales[locale]; + } else { + return dateFnsLocales[DEFAULT_LANG]; + } } diff --git a/src/config/i18n.ts b/src/config/i18n.ts index b6bfc98da..3af002c82 100644 --- a/src/config/i18n.ts +++ b/src/config/i18n.ts @@ -1,14 +1,27 @@ import { initReactI18next } from 'react-i18next'; import i18n from 'i18next'; +import LangDetector from 'i18next-browser-languagedetector'; import Fetch from 'i18next-fetch-backend'; i18n .use(Fetch) + .use(LangDetector) .use(initReactI18next) .init({ fallbackLng: 'en', + ns: 'common', backend: { loadPath: '/locales/{{lng}}/{{ns}}.json', }, + // specify which languages are supported + // 1. prefers exact match across all supported langs + // 2. prefers lang without locale + // 3. uses fallback + supportedLngs: ['fr', 'de', 'it', 'es', 'ar', 'en'], + // options for the language detector + detection: { + order: ['localStorage', 'navigator', 'querystring'], + caches: ['localStorage'], + }, }); diff --git a/src/main.tsx b/src/main.tsx index 1720745a8..8e0a9304c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,14 +8,8 @@ import 'react-toastify/dist/ReactToastify.css'; import { CssBaseline } from '@mui/material'; import { Direction, ThemeProvider as MuiThemeProvider } from '@mui/material'; -import { - BUILDER_ITEMS_PREFIX, - ClientHostManager, - Context, - getCurrentAccountLang, -} from '@graasp/sdk'; +import { BUILDER_ITEMS_PREFIX, ClientHostManager, Context } from '@graasp/sdk'; import rtlPlugin from '@graasp/stylis-plugin-rtl'; -import { DEFAULT_LANG } from '@graasp/translations'; import { theme } from '@graasp/ui'; import createCache from '@emotion/cache'; @@ -38,7 +32,7 @@ import { SENTRY_DSN, SENTRY_ENV, } from './config/env'; -import { QueryClientProvider, hooks, queryClient } from './config/queryClient'; +import { QueryClientProvider, queryClient } from './config/queryClient'; import { routeTree } from './routeTree.gen'; SentryInit({ @@ -128,47 +122,17 @@ function ThemeWrapper({ children }: ThemeWrapperProps): JSX.Element { ); } -function TranslationWrapper({ children }: { children: ReactNode }) { - const { data: currentMember } = hooks.useCurrentMember(); - const { i18n } = useTranslation(); - - // react to member changes and update the language - useEffect( - () => { - let lang = DEFAULT_LANG; - if (currentMember) { - lang = - getCurrentAccountLang(currentMember, DEFAULT_LANG) ?? DEFAULT_LANG; - } else { - // get the language from the preferred lang of the browser UI - // this is usually what we take on first render - const navigatorLang = navigator.language; - // normalize lang (remove the locale part, "it-CH" -> "it") - lang = navigatorLang.split('-')[0]; - } - i18n.changeLanguage(lang); - console.debug(lang); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [currentMember], - ); - - return children; -} - function App() { return ( - - - - - - - - - + + + + + + + ); diff --git a/src/modules/auth/components/Footer.tsx b/src/modules/auth/components/Footer.tsx index acb32043d..6ccb0acae 100644 --- a/src/modules/auth/components/Footer.tsx +++ b/src/modules/auth/components/Footer.tsx @@ -1,10 +1,11 @@ +import { useTranslation } from 'react-i18next'; + import { Box, Typography } from '@mui/material'; -import { useTheme } from '@graasp/ui'; +import LanguageSwitch from '@/components/ui/LanguageSwitch'; export function Footer() { - const { languageSelect } = useTheme(); - + const { i18n } = useTranslation(); return ( - {languageSelect} + i18n.changeLanguage(lang)} + /> © Graasp 2014 - {new Date().getFullYear()} diff --git a/src/modules/auth/components/Redirection.tsx b/src/modules/auth/components/Redirection.tsx index c4df34dca..519aadc98 100644 --- a/src/modules/auth/components/Redirection.tsx +++ b/src/modules/auth/components/Redirection.tsx @@ -50,7 +50,6 @@ export function Redirection({ children }: Props) { {t(AUTH.REDIRECTION_DESCRIPTION)} } diff --git a/src/modules/player/item/ItemForbiddenScreen.tsx b/src/modules/player/item/ItemForbiddenScreen.tsx index 49ce54b82..9176e7518 100644 --- a/src/modules/player/item/ItemForbiddenScreen.tsx +++ b/src/modules/player/item/ItemForbiddenScreen.tsx @@ -42,7 +42,6 @@ export function ItemForbiddenScreen(): JSX.Element { /> {isAuthenticated ? ( @@ -122,7 +121,7 @@ function AutoLogin(): JSX.Element { return ( {t('AUTO_LOGIN_WELCOME_TITLE')} -