diff --git a/CHANGELOG.md b/CHANGELOG.md index c831e4eec..7d2ce0758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- ✨(frontend) add customization for translations #857 + ## [3.3.0] - 2025-05-06 ### Added diff --git a/docs/theming.md b/docs/theming.md index 2c511d718..ab30bb50d 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -53,4 +53,18 @@ Below is a visual example of a configured footer ⬇️: ![Footer Configuration Example](./assets/footer-configurable.png) +---- + +# **Custom Translations** 📝 + +The translations can be partially overridden from the theme customization file. + +### Settings 🔧 + +```shellscript +THEME_CUSTOMIZATION_FILE_PATH= +``` + +### Example of JSON +The json must follow some rules: https://github.com/suitenumerique/docs/blob/main/src/helm/env.d/dev/configuration/theme/demo.json \ No newline at end of file diff --git a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts index 21bb085ec..7dd938737 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts @@ -119,6 +119,26 @@ test.describe('Config', () => { .first(), ).toBeAttached(); }); + + test('it checks theme_customization.translations config', async ({ + page, + }) => { + await overrideConfig(page, { + theme_customization: { + translations: { + en: { + translation: { + Docs: 'MyCustomDocs', + }, + }, + }, + }, + }); + + await page.goto('/'); + + await expect(page.getByText('MyCustomDocs')).toBeAttached(); + }); }); test.describe('Config: Not loggued', () => { 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 5805d0104..048020837 100644 --- a/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts +++ b/src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts @@ -128,8 +128,16 @@ export async function waitForLanguageSwitch( lang: TestLanguageValue, ) { const header = page.locator('header').first(); - await header.getByRole('button', { name: 'arrow_drop_down' }).click(); + const languagePicker = header.locator('.--docs--language-picker-text'); + const isAlreadyTargetLanguage = await languagePicker + .innerText() + .then((text) => text.toLowerCase().includes(lang.label.toLowerCase())); + if (isAlreadyTargetLanguage) { + return; + } + + await languagePicker.click(); const responsePromise = page.waitForResponse( (resp) => resp.url().includes('/user') && resp.request().method() === 'PATCH', diff --git a/src/frontend/apps/impress/src/core/api/useUserUpdate.ts b/src/frontend/apps/impress/src/core/api/useUserUpdate.ts new file mode 100644 index 000000000..6798edf0c --- /dev/null +++ b/src/frontend/apps/impress/src/core/api/useUserUpdate.ts @@ -0,0 +1,48 @@ +import { + UseMutationResult, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; + +import { APIError, errorCauses, fetchAPI } from '@/api'; +import { User } from '@/features/auth/api/types'; +import { KEY_AUTH } from '@/features/auth/api/useAuthQuery'; + +type UserUpdateRequest = Partial; + +async function updateUser(userUpdateData: UserUpdateRequest): Promise { + const response = await fetchAPI(`users/${userUpdateData.id}/`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(userUpdateData), + }); + if (!response.ok) { + throw new APIError( + `Failed to update the user`, + await errorCauses(response, userUpdateData), + ); + } + return response.json() as Promise; +} + +export const useUserUpdate = (): UseMutationResult< + User, + APIError, + UserUpdateRequest +> => { + const queryClient = useQueryClient(); + + const mutationResult = useMutation({ + mutationFn: updateUser, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: [KEY_AUTH] }); + }, + onError: (error) => { + console.error('Error updating user', error); + }, + }); + + return mutationResult; +}; diff --git a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx index 371e7c356..6694f6b94 100644 --- a/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx +++ b/src/frontend/apps/impress/src/core/config/ConfigProvider.tsx @@ -1,10 +1,15 @@ import { Loader } from '@openfun/cunningham-react'; import Head from 'next/head'; -import { PropsWithChildren, useEffect } from 'react'; +import { PropsWithChildren, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { Box } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; -import { useLanguageSynchronizer } from '@/features/language/'; +import { useAuthQuery } from '@/features/auth'; +import { + useCustomTranslations, + useSynchronizedLanguage, +} from '@/features/language'; import { useAnalytics } from '@/libs'; import { CrispProvider, PostHogAnalytic } from '@/services'; import { useSentryStore } from '@/stores/useSentryStore'; @@ -13,10 +18,35 @@ import { useConfig } from './api/useConfig'; export const ConfigProvider = ({ children }: PropsWithChildren) => { const { data: conf } = useConfig(); + const { data: user } = useAuthQuery(); const { setSentry } = useSentryStore(); const { setTheme } = useCunninghamTheme(); + const { changeLanguageSynchronized } = useSynchronizedLanguage(); + const { customizeTranslations } = useCustomTranslations(); const { AnalyticsProvider } = useAnalytics(); - const { synchronizeLanguage } = useLanguageSynchronizer(); + const { i18n } = useTranslation(); + const languageSynchronized = useRef(false); + + useEffect(() => { + if (!user || languageSynchronized.current) { + return; + } + + const targetLanguage = + user?.language ?? i18n.resolvedLanguage ?? i18n.language; + + void changeLanguageSynchronized(targetLanguage, user).then(() => { + languageSynchronized.current = true; + }); + }, [user, i18n.resolvedLanguage, i18n.language, changeLanguageSynchronized]); + + useEffect(() => { + if (!conf?.theme_customization?.translations) { + return; + } + + customizeTranslations(conf.theme_customization.translations); + }, [conf?.theme_customization?.translations, customizeTranslations]); useEffect(() => { if (!conf?.SENTRY_DSN) { @@ -34,10 +64,6 @@ export const ConfigProvider = ({ children }: PropsWithChildren) => { setTheme(conf.FRONTEND_THEME); }, [conf?.FRONTEND_THEME, setTheme]); - useEffect(() => { - void synchronizeLanguage(); - }, [synchronizeLanguage]); - useEffect(() => { if (!conf?.POSTHOG_KEY) { return; diff --git a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx index 761c588cd..584500dea 100644 --- a/src/frontend/apps/impress/src/core/config/api/useConfig.tsx +++ b/src/frontend/apps/impress/src/core/config/api/useConfig.tsx @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { Resource } from 'i18next'; import { APIError, errorCauses, fetchAPI } from '@/api'; import { Theme } from '@/cunningham/'; @@ -7,9 +8,10 @@ import { PostHogConf } from '@/services'; interface ThemeCustomization { footer?: FooterType; + translations?: Resource; } -interface ConfigResponse { +export interface ConfigResponse { AI_FEATURE_ENABLED?: boolean; COLLABORATION_WS_URL?: string; COLLABORATION_WS_NOT_CONNECTED_READY_ONLY?: boolean; diff --git a/src/frontend/apps/impress/src/features/auth/api/types.ts b/src/frontend/apps/impress/src/features/auth/api/types.ts index 6d911e516..680329d1c 100644 --- a/src/frontend/apps/impress/src/features/auth/api/types.ts +++ b/src/frontend/apps/impress/src/features/auth/api/types.ts @@ -11,5 +11,5 @@ export interface User { email: string; full_name: string; short_name: string; - language: string; + language?: string; } diff --git a/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx b/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx deleted file mode 100644 index a11e1ac59..000000000 --- a/src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; - -import { APIError, errorCauses, fetchAPI } from '@/api'; -import { User } from '@/features/auth/api/types'; - -export interface ChangeUserLanguageParams { - userId: User['id']; - language: User['language']; -} - -export const changeUserLanguage = async ({ - userId, - language, -}: ChangeUserLanguageParams): Promise => { - const response = await fetchAPI(`users/${userId}/`, { - method: 'PATCH', - body: JSON.stringify({ - language, - }), - }); - - if (!response.ok) { - throw new APIError( - `Failed to change the user language to ${language}`, - await errorCauses(response, { - value: language, - type: 'language', - }), - ); - } - - return response.json() as Promise; -}; - -export function useChangeUserLanguage() { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: changeUserLanguage, - onSuccess: () => { - void queryClient.invalidateQueries({ - queryKey: ['change-user-language'], - }); - }, - }); -} diff --git a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx similarity index 61% rename from src/frontend/apps/impress/src/features/language/LanguagePicker.tsx rename to src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx index 950b4f2bb..7691e8ec3 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx @@ -1,42 +1,33 @@ -import { Settings } from 'luxon'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; import { DropdownMenu, Icon, Text } from '@/components/'; import { useConfig } from '@/core'; - -import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer'; -import { getMatchingLocales } from './utils/locale'; +import { useAuthQuery } from '@/features/auth'; +import { + getMatchingLocales, + useSynchronizedLanguage, +} from '@/features/language'; export const LanguagePicker = () => { const { t, i18n } = useTranslation(); const { data: conf } = useConfig(); - const { synchronizeLanguage } = useLanguageSynchronizer(); - const language = i18n.languages[0]; - Settings.defaultLocale = language; + const { data: user } = useAuthQuery(); + const { changeLanguageSynchronized } = useSynchronizedLanguage(); + const language = i18n.language; // Compute options for dropdown const optionsPicker = useMemo(() => { const backendOptions = conf?.LANGUAGES ?? [[language, language]]; - return backendOptions.map(([backendLocale, label]) => { - // Determine if the option is selected - const isSelected = - getMatchingLocales([backendLocale], [language]).length > 0; - // Define callback for updating both frontend and backend languages - const callback = () => { - i18n - .changeLanguage(backendLocale) - .then(() => { - void synchronizeLanguage('toBackend'); - }) - .catch((err) => { - console.error('Error changing language', err); - }); + return backendOptions.map(([backendLocale, backendLabel]) => { + return { + label: backendLabel, + isSelected: getMatchingLocales([backendLocale], [language]).length > 0, + callback: () => changeLanguageSynchronized(backendLocale, user), }; - return { label, isSelected, callback }; }); - }, [conf, i18n, language, synchronizeLanguage]); + }, [changeLanguageSynchronized, conf?.LANGUAGES, language, user]); // Extract current language label for display const currentLanguageLabel = diff --git a/src/frontend/apps/impress/src/features/language/components/index.ts b/src/frontend/apps/impress/src/features/language/components/index.ts new file mode 100644 index 000000000..b5818aa74 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/components/index.ts @@ -0,0 +1 @@ +export * from './LanguagePicker'; diff --git a/src/frontend/apps/impress/src/features/language/hooks/index.ts b/src/frontend/apps/impress/src/features/language/hooks/index.ts new file mode 100644 index 000000000..5c6dd71a8 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useSynchronizedLanguage'; +export * from './useCustomTranslations'; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useCustomTranslations.ts b/src/frontend/apps/impress/src/features/language/hooks/useCustomTranslations.ts new file mode 100644 index 000000000..a0f3a7d64 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/useCustomTranslations.ts @@ -0,0 +1,27 @@ +import { Resource } from 'i18next'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const useCustomTranslations = () => { + const { i18n } = useTranslation(); + + // Overwrite translations with a resource + const customizeTranslations = useCallback( + (currentCustomTranslations: Resource) => { + Object.entries(currentCustomTranslations).forEach(([lng, namespaces]) => { + Object.entries(namespaces).forEach(([ns, value]) => { + i18n.addResourceBundle(lng, ns, value, true, true); + }); + }); + // trigger re-render + if (Object.entries(currentCustomTranslations).length > 0) { + void i18n.changeLanguage(i18n.language); + } + }, + [i18n], + ); + + return { + customizeTranslations, + }; +}; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts deleted file mode 100644 index e6bb23b99..000000000 --- a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { useCallback, useMemo, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -import { useConfig } from '@/core'; -import { useAuthQuery } from '@/features/auth/api'; -import { useChangeUserLanguage } from '@/features/language/api/useChangeUserLanguage'; -import { getMatchingLocales } from '@/features/language/utils/locale'; -import { availableFrontendLanguages } from '@/i18n/initI18n'; - -export const useLanguageSynchronizer = () => { - const { data: conf, isSuccess: confInitialized } = useConfig(); - const { data: user, isSuccess: userInitialized } = useAuthQuery(); - const { i18n } = useTranslation(); - const { mutateAsync: changeUserLanguage } = useChangeUserLanguage(); - const languageSynchronizing = useRef(false); - - const availableBackendLanguages = useMemo(() => { - return conf?.LANGUAGES.map(([locale]) => locale); - }, [conf?.LANGUAGES]); - - const synchronizeLanguage = useCallback( - async (direction?: 'toBackend' | 'toFrontend') => { - if ( - languageSynchronizing.current || - !userInitialized || - !confInitialized || - !availableBackendLanguages || - !availableFrontendLanguages - ) { - return; - } - languageSynchronizing.current = true; - - try { - const userPreferredLanguages = user.language ? [user.language] : []; - const setOrDetectedLanguages = i18n.languages; - - // Default direction depends on whether a user already has a language preference - direction = - direction ?? - (userPreferredLanguages.length ? 'toFrontend' : 'toBackend'); - - if (direction === 'toBackend') { - // Update user's preference from frontends's language - const closestBackendLanguage = - getMatchingLocales( - availableBackendLanguages, - setOrDetectedLanguages, - )[0] || availableBackendLanguages[0]; - await changeUserLanguage({ - userId: user.id, - language: closestBackendLanguage, - }); - } else { - // Update frontends's language from user's preference - const closestFrontendLanguage = - getMatchingLocales( - availableFrontendLanguages, - userPreferredLanguages, - )[0] || availableFrontendLanguages[0]; - if (i18n.resolvedLanguage !== closestFrontendLanguage) { - await i18n.changeLanguage(closestFrontendLanguage); - } - } - } catch (error) { - console.error('Error synchronizing language', error); - } finally { - languageSynchronizing.current = false; - } - }, - [ - i18n, - user, - userInitialized, - confInitialized, - availableBackendLanguages, - changeUserLanguage, - ], - ); - - return { synchronizeLanguage }; -}; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useSynchronizedLanguage.ts b/src/frontend/apps/impress/src/features/language/hooks/useSynchronizedLanguage.ts new file mode 100644 index 000000000..997dc8bc0 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/useSynchronizedLanguage.ts @@ -0,0 +1,71 @@ +import { useCallback, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useUserUpdate } from '@/core/api/useUserUpdate'; +import { useConfig } from '@/core/config/api/useConfig'; +import { User } from '@/features/auth'; +import { getMatchingLocales } from '@/features/language/utils/locale'; + +export const useSynchronizedLanguage = () => { + const { i18n } = useTranslation(); + const { mutateAsync: updateUser } = useUserUpdate(); + const { data: config } = useConfig(); + const isSynchronizingLanguage = useRef(false); + + const availableFrontendLanguages = useMemo( + () => Object.keys(i18n?.options?.resources || { en: '<- fallback' }), + [i18n?.options?.resources], + ); + const availableBackendLanguages = useMemo( + () => config?.LANGUAGES?.map(([locale]) => locale) || [], + [config?.LANGUAGES], + ); + + const changeBackendLanguage = useCallback( + async (language: string, user?: User) => { + const closestBackendLanguage = getMatchingLocales( + availableBackendLanguages, + [language], + )[0]; + + if (user && user.language !== closestBackendLanguage) { + await updateUser({ id: user.id, language: closestBackendLanguage }); + } + }, + [availableBackendLanguages, updateUser], + ); + + const changeFrontendLanguage = useCallback( + async (language: string) => { + const closestFrontendLanguage = getMatchingLocales( + availableFrontendLanguages, + [language], + )[0]; + if ( + i18n.isInitialized && + i18n.resolvedLanguage !== closestFrontendLanguage + ) { + await i18n.changeLanguage(closestFrontendLanguage); + } + }, + [availableFrontendLanguages, i18n], + ); + + const changeLanguageSynchronized = useCallback( + async (language: string, user?: User) => { + if (!isSynchronizingLanguage.current) { + isSynchronizingLanguage.current = true; + await changeFrontendLanguage(language); + await changeBackendLanguage(language, user); + isSynchronizingLanguage.current = false; + } + }, + [changeBackendLanguage, changeFrontendLanguage], + ); + + return { + changeLanguageSynchronized, + changeFrontendLanguage, + changeBackendLanguage, + }; +}; diff --git a/src/frontend/apps/impress/src/features/language/index.ts b/src/frontend/apps/impress/src/features/language/index.ts index 4b60c8bd4..d3732c1e9 100644 --- a/src/frontend/apps/impress/src/features/language/index.ts +++ b/src/frontend/apps/impress/src/features/language/index.ts @@ -1,2 +1,3 @@ -export * from './hooks/useLanguageSynchronizer'; -export * from './LanguagePicker'; +export * from './hooks'; +export * from './components'; +export * from './utils'; diff --git a/src/frontend/apps/impress/src/features/language/utils/index.ts b/src/frontend/apps/impress/src/features/language/utils/index.ts new file mode 100644 index 000000000..5501675d5 --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/utils/index.ts @@ -0,0 +1 @@ +export * from './locale'; diff --git a/src/frontend/apps/impress/src/i18n/initI18n.ts b/src/frontend/apps/impress/src/i18n/initI18n.ts index 40700e64e..090226407 100644 --- a/src/frontend/apps/impress/src/i18n/initI18n.ts +++ b/src/frontend/apps/impress/src/i18n/initI18n.ts @@ -4,36 +4,38 @@ import { initReactI18next } from 'react-i18next'; import resources from './translations.json'; -export const availableFrontendLanguages: readonly string[] = - Object.keys(resources); +// Add an initialization guard +let isInitialized = false; -i18next - .use(LanguageDetector) - .use(initReactI18next) - .init({ - resources, - fallbackLng: 'en', - debug: false, - detection: { - order: ['cookie', 'navigator'], // detection order - caches: ['cookie'], // Use cookies to store the language preference - lookupCookie: 'docs_language', - cookieMinutes: 525600, // Expires after one year - cookieOptions: { - path: '/', - sameSite: 'lax', +// Initialize i18next with the base translations only once +if (!isInitialized && !i18next.isInitialized) { + isInitialized = true; + + i18next + .use(LanguageDetector) + .use(initReactI18next) + .init({ + resources, + fallbackLng: 'en', + debug: false, + detection: { + order: ['cookie', 'navigator'], + caches: ['cookie'], + lookupCookie: 'docs_language', + cookieMinutes: 525600, + cookieOptions: { + path: '/', + sameSite: 'lax', + }, + }, + interpolation: { + escapeValue: false, }, - }, - interpolation: { - escapeValue: false, - }, - preload: availableFrontendLanguages, - lowerCaseLng: true, - nsSeparator: false, - keySeparator: false, - }) - .catch(() => { - throw new Error('i18n initialization failed'); - }); + lowerCaseLng: true, + nsSeparator: false, + keySeparator: false, + }) + .catch((e) => console.error('i18n initialization failed:', e)); +} export default i18next; diff --git a/src/frontend/apps/impress/src/utils/storages.ts b/src/frontend/apps/impress/src/utils/storages.ts new file mode 100644 index 000000000..b7f152c91 --- /dev/null +++ b/src/frontend/apps/impress/src/utils/storages.ts @@ -0,0 +1,52 @@ +/** + * @fileOverview This module provides utilities to interact with local storage safely. + */ + +interface SyncStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** + * @namespace safeLocalStorage + * @description A utility for safely interacting with localStorage. + * It checks if the `window` object is defined before attempting to access localStorage, + * preventing errors in environments where `window` is not available. + */ +export const safeLocalStorage: SyncStorage = { + /** + * Retrieves an item from localStorage. + * @param {string} key - The key of the item to retrieve. + * @returns {string | null} The item's value, or null if the item does not exist or if localStorage is not available. + */ + getItem: (key: string): string | null => { + if (typeof window === 'undefined') { + return null; + } + return localStorage.getItem(key); + }, + /** + * Sets an item in localStorage. + * @param {string} key - The key of the item to set. + * @param {string} value - The value to set for the item. + * @returns {void} + */ + setItem: (key: string, value: string): void => { + if (typeof window === 'undefined') { + return; + } + localStorage.setItem(key, value); + }, + /** + * Removes an item from localStorage. + * @param {string} key - The key of the item to remove. + * @returns {void} + */ + removeItem: (key: string): void => { + if (typeof window === 'undefined') { + return; + } + localStorage.removeItem(key); + }, +}; diff --git a/src/helm/env.d/dev/configuration/theme/demo.json b/src/helm/env.d/dev/configuration/theme/demo.json index 29200adac..1013e1221 100644 --- a/src/helm/env.d/dev/configuration/theme/demo.json +++ b/src/helm/env.d/dev/configuration/theme/demo.json @@ -1,4 +1,12 @@ { + "translations": { + "en": { + "translation": { + "Docs": "MyDocs", + "New doc": "+" + } + } + }, "footer": { "default": { "logo": {