Skip to content

Commit

Permalink
feat: add language detector (#493)
Browse files Browse the repository at this point in the history
* fix: wip

* fix: add unit tests

* chore: remove debug in i18n config

* fix: use role button

* fix: tests
  • Loading branch information
spaenleh authored Nov 27, 2024
1 parent af9b08b commit c623f4b
Show file tree
Hide file tree
Showing 14 changed files with 78 additions and 68 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cypress/e2e/player/autoLogin.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion cypress/e2e/player/redirections.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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=`);
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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": [
Expand Down
10 changes: 10 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion public/locales/it/auth.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <bold>{{email}}</bold> per recuperare il tuo link di accesso personale per accedere a Graasp. L'operazione può richiedere diversi minuti.",
Expand Down
12 changes: 12 additions & 0 deletions src/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
} from 'react';

Expand Down Expand Up @@ -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 {
Expand Down
23 changes: 14 additions & 9 deletions src/components/langs.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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];
}
}
13 changes: 13 additions & 0 deletions src/config/i18n.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
});
54 changes: 9 additions & 45 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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({
Expand Down Expand Up @@ -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 (
<HelmetProvider>
<QueryClientProvider client={queryClient}>
<TranslationWrapper>
<CssBaseline />
<ThemeWrapper>
<AuthProvider>
<ToastContainer stacked position="bottom-left" />
<InnerApp />
</AuthProvider>
</ThemeWrapper>
</TranslationWrapper>
<CssBaseline />
<ThemeWrapper>
<AuthProvider>
<ToastContainer stacked position="bottom-left" />
<InnerApp />
</AuthProvider>
</ThemeWrapper>
</QueryClientProvider>
</HelmetProvider>
);
Expand Down
12 changes: 8 additions & 4 deletions src/modules/auth/components/Footer.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box
display="flex"
Expand All @@ -13,7 +14,10 @@ export function Footer() {
flexDirection="column"
width="100%"
>
{languageSelect}
<LanguageSwitch
lang={i18n.language}
onChange={(lang) => i18n.changeLanguage(lang)}
/>
<Typography variant="caption" color="darkgrey">
© Graasp 2014 - {new Date().getFullYear()}
</Typography>
Expand Down
1 change: 0 additions & 1 deletion src/modules/auth/components/Redirection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ export function Redirection({ children }: Props) {
{t(AUTH.REDIRECTION_DESCRIPTION)}
</Typography>
<ButtonLink
role="button"
variant="contained"
to="/account"
endIcon={<ArrowRightIcon />}
Expand Down
1 change: 0 additions & 1 deletion src/modules/player/item/ItemForbiddenScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ export function ItemForbiddenScreen(): JSX.Element {
/>
{isAuthenticated ? (
<Button
role="button"
onClick={() => {
logout();
navigate(redirectionProps);
Expand Down
7 changes: 3 additions & 4 deletions src/routes/player/$rootId/$itemId/autoLogin.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';

import { Alert, Stack, Typography } from '@mui/material';
import { Alert, Button, Stack, Typography } from '@mui/material';

import { ItemLoginSchemaType } from '@graasp/sdk';
import { Button } from '@graasp/ui';

import { Navigate, createFileRoute, useNavigate } from '@tanstack/react-router';
import { zodValidator } from '@tanstack/zod-adapter';
Expand Down Expand Up @@ -95,7 +94,7 @@ function AutoLogin(): JSX.Element {
<Typography variant="h2">
{t('AUTO_LOGIN_ALREADY_LOGGED_IN')}
</Typography>
<Button onClick={signOut}>
<Button variant="contained" onClick={() => signOut}>
{t('AUTO_LOGIN_SIGN_OUT_AND_BACK_IN')}
</Button>
</Stack>
Expand All @@ -122,7 +121,7 @@ function AutoLogin(): JSX.Element {
return (
<Wrapper id={AUTO_LOGIN_CONTAINER_ID}>
<Typography variant="h2">{t('AUTO_LOGIN_WELCOME_TITLE')}</Typography>
<Button role="button" onClick={autoLogin}>
<Button variant="contained" onClick={autoLogin}>
{t('AUTO_LOGIN_START_BUTTON')}
</Button>
</Wrapper>
Expand Down

0 comments on commit c623f4b

Please sign in to comment.