From f4ad26a8fa3c1db95e6dc5a0dbbc84346780ce65 Mon Sep 17 00:00:00 2001 From: rvveber Date: Tue, 8 Apr 2025 16:09:30 +0200 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8(frontend)=20Adds=20customization?= =?UTF-8?q?=20for=20translations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of customization PoC Signed-off-by: Robin Weber --- .../src/core/config/ConfigProvider.tsx | 40 +++++++-- .../impress/src/core/config/api/useConfig.tsx | 4 +- .../src/features/language/LanguagePicker.tsx | 8 +- .../language/hooks/useLanguageSynchronizer.ts | 46 ++++------ .../hooks/useTranslationsCustomizer.ts | 85 +++++++++++++++++++ .../impress/src/features/language/index.ts | 5 +- .../apps/impress/src/i18n/initI18n.ts | 60 ++++++------- .../apps/impress/src/utils/storages.ts | 52 ++++++++++++ 8 files changed, 230 insertions(+), 70 deletions(-) create mode 100644 src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts create mode 100644 src/frontend/apps/impress/src/utils/storages.ts 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/language/LanguagePicker.tsx b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx index 950b4f2bb..fd60e2208 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx @@ -5,6 +5,7 @@ import { css } from 'styled-components'; import { DropdownMenu, Icon, Text } from '@/components/'; import { useConfig } from '@/core'; +import { useAuthQuery } from '@/features/auth'; import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer'; import { getMatchingLocales } from './utils/locale'; @@ -12,6 +13,7 @@ import { getMatchingLocales } from './utils/locale'; export const LanguagePicker = () => { const { t, i18n } = useTranslation(); const { data: conf } = useConfig(); + const { data: user } = useAuthQuery(); const { synchronizeLanguage } = useLanguageSynchronizer(); const language = i18n.languages[0]; Settings.defaultLocale = language; @@ -28,7 +30,9 @@ export const LanguagePicker = () => { i18n .changeLanguage(backendLocale) .then(() => { - void synchronizeLanguage('toBackend'); + if (conf?.LANGUAGES && user) { + synchronizeLanguage(conf.LANGUAGES, user, 'toBackend'); + } }) .catch((err) => { console.error('Error changing language', err); @@ -36,7 +40,7 @@ export const LanguagePicker = () => { }; return { label, isSelected, callback }; }); - }, [conf, i18n, language, synchronizeLanguage]); + }, [conf?.LANGUAGES, i18n, language, synchronizeLanguage, user]); // Extract current language label for display const currentLanguageLabel = diff --git a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts index e6bb23b99..cded24a79 100644 --- a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts +++ b/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts @@ -1,37 +1,30 @@ -import { useCallback, useMemo, useRef } from 'react'; +import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { useConfig } from '@/core'; -import { useAuthQuery } from '@/features/auth/api'; +import type { ConfigResponse } from '@/core/config/api/useConfig'; +import { User } from '@/features/auth'; 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 - ) { + ( + languages: ConfigResponse['LANGUAGES'], + user: User, + direction?: 'toBackend' | 'toFrontend', + ) => { + if (languageSynchronizing.current || !availableFrontendLanguages) { return; } languageSynchronizing.current = true; try { + const availableBackendLanguages = languages.map(([locale]) => locale); const userPreferredLanguages = user.language ? [user.language] : []; const setOrDetectedLanguages = i18n.languages; @@ -41,25 +34,27 @@ export const useLanguageSynchronizer = () => { (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({ + changeUserLanguage({ userId: user.id, language: closestBackendLanguage, + }).catch((error) => { + console.error('Error changing user language', error); }); } 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); + i18n.changeLanguage(closestFrontendLanguage).catch((error) => { + console.error('Error changing frontend language', error); + }); } } } catch (error) { @@ -68,14 +63,7 @@ export const useLanguageSynchronizer = () => { languageSynchronizing.current = false; } }, - [ - i18n, - user, - userInitialized, - confInitialized, - availableBackendLanguages, - changeUserLanguage, - ], + [i18n, changeUserLanguage], ); return { synchronizeLanguage }; diff --git a/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts new file mode 100644 index 000000000..8ba3eaa7e --- /dev/null +++ b/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts @@ -0,0 +1,85 @@ +import i18next, { Resource, i18n } from 'i18next'; +import { useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { ConfigResponse } from '@/core/config/api/useConfig'; +import { safeLocalStorage } from '@/utils/storages'; + +export const useTranslationsCustomizer = () => { + const { i18n } = useTranslation(); + const translationsCustomizing = useRef(false); + + const customizeTranslations = useCallback( + ( + customTranslationsUrl: ConfigResponse['FRONTEND_CUSTOM_TRANSLATIONS_URL'], + cacheKey: string = 'CUSTOM_TRANSLATIONS', + ) => { + if (translationsCustomizing.current) { + return; + } + translationsCustomizing.current = true; + try { + if (!customTranslationsUrl) { + safeLocalStorage.setItem(cacheKey, ''); + } else { + const previousTranslationsString = safeLocalStorage.getItem(cacheKey); + if (previousTranslationsString) { + const previousTranslations = JSON.parse( + previousTranslationsString, + ) as Resource; + try { + applyTranslations(previousTranslations, i18n); + } catch (err: unknown) { + console.error('Error parsing cached translations:', err); + safeLocalStorage.setItem(cacheKey, ''); + } + } + + // Always update in background + fetchAndCacheTranslations(customTranslationsUrl, cacheKey) + .then((updatedTranslations) => { + if ( + updatedTranslations && + JSON.stringify(updatedTranslations) !== + previousTranslationsString + ) { + applyTranslations(updatedTranslations, i18n); + } + }) + .catch((err: unknown) => { + console.error('Error fetching custom translations:', err); + }); + } + } catch (err: unknown) { + console.error('Error updating custom translations:', err); + } finally { + translationsCustomizing.current = false; + } + }, + [i18n], + ); + + const applyTranslations = (translations: Resource, i18n: i18n) => { + Object.entries(translations).forEach(([lng, namespaces]) => { + Object.entries(namespaces).forEach(([ns, value]) => { + i18next.addResourceBundle(lng, ns, value, true, true); + }); + }); + const currentLanguage = i18n.language; + void i18next.changeLanguage(currentLanguage); + }; + + const fetchAndCacheTranslations = (url: string, CACHE_KEY: string) => { + return fetch(url).then((response) => { + if (!response.ok) { + throw new Error('Failed to fetch custom translations'); + } + return response.json().then((customTranslations: Resource) => { + safeLocalStorage.setItem(CACHE_KEY, JSON.stringify(customTranslations)); + return customTranslations; + }); + }); + }; + + return { customizeTranslations }; +}; 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/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); + }, +}; From dc06315566fc1f940397645b519a4af85c0827b6 Mon Sep 17 00:00:00 2001 From: rvveber Date: Tue, 8 Apr 2025 16:42:38 +0200 Subject: [PATCH 2/5] =?UTF-8?q?=F0=9F=93=9D(documentation)=20adds=20custom?= =?UTF-8?q?ization=20for=20translations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of customization PoC Signed-off-by: Robin Weber --- CHANGELOG.md | 4 ++++ docs/theming.md | 14 ++++++++++++++ src/helm/env.d/dev/configuration/theme/demo.json | 8 ++++++++ 3 files changed, 26 insertions(+) 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/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": { From 5962f7aae160d325f93807726b3255c4535aa3c5 Mon Sep 17 00:00:00 2001 From: rvveber Date: Wed, 7 May 2025 13:29:51 +0200 Subject: [PATCH 3/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20Separate=20m?= =?UTF-8?q?utations=20from=20queries=20for=20auth=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces dedicated mutations (for authentication/user operations) separating them from queries to align with best practices for data fetching and state management. Queries remain responsible for READ operations, while mutations now handle CREATE, UPDATE, and DELETE actions (for user data) improving separation of concerns. Signed-off-by: Robin Weber --- .../impress/src/core/api/useUserUpdate.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/frontend/apps/impress/src/core/api/useUserUpdate.ts 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; +}; From fa83955a77e6fba508bde71850d4ca7326591b74 Mon Sep 17 00:00:00 2001 From: rvveber Date: Wed, 7 May 2025 15:23:29 +0200 Subject: [PATCH 4/5] =?UTF-8?q?=E2=99=BB=EF=B8=8F(frontend)=20Refactor=20l?= =?UTF-8?q?anguage-related=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactors "useTranslationsCustomizer" to "useCustomTranslations" - Refactors "useLanguageSynchronizer" to "useSynchronizedLanguage" - Refactors "LanguagePicker" to better reflect its component role - Refactors "LanguagePicker" to use "useSynchronizedLangue" - Removes unused "useChangeUserLanguage" - To change the user language, use "useAuthMutation" instead Signed-off-by: Robin Weber --- .../impress/src/features/auth/api/types.ts | 2 +- .../language/api/useChangeUserLanguage.tsx | 45 ---------- .../{ => components}/LanguagePicker.tsx | 37 +++----- .../src/features/language/components/index.ts | 1 + .../src/features/language/hooks/index.ts | 2 + .../language/hooks/useCustomTranslations.ts | 27 ++++++ .../language/hooks/useLanguageSynchronizer.ts | 70 --------------- .../language/hooks/useSynchronizedLanguage.ts | 71 ++++++++++++++++ .../hooks/useTranslationsCustomizer.ts | 85 ------------------- .../src/features/language/utils/index.ts | 1 + 10 files changed, 115 insertions(+), 226 deletions(-) delete mode 100644 src/frontend/apps/impress/src/features/language/api/useChangeUserLanguage.tsx rename src/frontend/apps/impress/src/features/language/{ => components}/LanguagePicker.tsx (60%) create mode 100644 src/frontend/apps/impress/src/features/language/components/index.ts create mode 100644 src/frontend/apps/impress/src/features/language/hooks/index.ts create mode 100644 src/frontend/apps/impress/src/features/language/hooks/useCustomTranslations.ts delete mode 100644 src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts create mode 100644 src/frontend/apps/impress/src/features/language/hooks/useSynchronizedLanguage.ts delete mode 100644 src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts create mode 100644 src/frontend/apps/impress/src/features/language/utils/index.ts 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 60% rename from src/frontend/apps/impress/src/features/language/LanguagePicker.tsx rename to src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx index fd60e2208..7691e8ec3 100644 --- a/src/frontend/apps/impress/src/features/language/LanguagePicker.tsx +++ b/src/frontend/apps/impress/src/features/language/components/LanguagePicker.tsx @@ -1,4 +1,3 @@ -import { Settings } from 'luxon'; import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { css } from 'styled-components'; @@ -6,41 +5,29 @@ import { css } from 'styled-components'; import { DropdownMenu, Icon, Text } from '@/components/'; import { useConfig } from '@/core'; import { useAuthQuery } from '@/features/auth'; - -import { useLanguageSynchronizer } from './hooks/useLanguageSynchronizer'; -import { getMatchingLocales } from './utils/locale'; +import { + getMatchingLocales, + useSynchronizedLanguage, +} from '@/features/language'; export const LanguagePicker = () => { const { t, i18n } = useTranslation(); const { data: conf } = useConfig(); const { data: user } = useAuthQuery(); - const { synchronizeLanguage } = useLanguageSynchronizer(); - const language = i18n.languages[0]; - Settings.defaultLocale = language; + 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(() => { - if (conf?.LANGUAGES && user) { - synchronizeLanguage(conf.LANGUAGES, user, '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?.LANGUAGES, i18n, language, synchronizeLanguage, user]); + }, [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 cded24a79..000000000 --- a/src/frontend/apps/impress/src/features/language/hooks/useLanguageSynchronizer.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useCallback, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -import type { ConfigResponse } from '@/core/config/api/useConfig'; -import { User } from '@/features/auth'; -import { useChangeUserLanguage } from '@/features/language/api/useChangeUserLanguage'; -import { getMatchingLocales } from '@/features/language/utils/locale'; -import { availableFrontendLanguages } from '@/i18n/initI18n'; - -export const useLanguageSynchronizer = () => { - const { i18n } = useTranslation(); - const { mutateAsync: changeUserLanguage } = useChangeUserLanguage(); - const languageSynchronizing = useRef(false); - - const synchronizeLanguage = useCallback( - ( - languages: ConfigResponse['LANGUAGES'], - user: User, - direction?: 'toBackend' | 'toFrontend', - ) => { - if (languageSynchronizing.current || !availableFrontendLanguages) { - return; - } - languageSynchronizing.current = true; - - try { - const availableBackendLanguages = languages.map(([locale]) => locale); - 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') { - const closestBackendLanguage = - getMatchingLocales( - availableBackendLanguages, - setOrDetectedLanguages, - )[0] || availableBackendLanguages[0]; - changeUserLanguage({ - userId: user.id, - language: closestBackendLanguage, - }).catch((error) => { - console.error('Error changing user language', error); - }); - } else { - const closestFrontendLanguage = - getMatchingLocales( - availableFrontendLanguages, - userPreferredLanguages, - )[0] || availableFrontendLanguages[0]; - if (i18n.resolvedLanguage !== closestFrontendLanguage) { - i18n.changeLanguage(closestFrontendLanguage).catch((error) => { - console.error('Error changing frontend language', error); - }); - } - } - } catch (error) { - console.error('Error synchronizing language', error); - } finally { - languageSynchronizing.current = false; - } - }, - [i18n, 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/hooks/useTranslationsCustomizer.ts b/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts deleted file mode 100644 index 8ba3eaa7e..000000000 --- a/src/frontend/apps/impress/src/features/language/hooks/useTranslationsCustomizer.ts +++ /dev/null @@ -1,85 +0,0 @@ -import i18next, { Resource, i18n } from 'i18next'; -import { useCallback, useRef } from 'react'; -import { useTranslation } from 'react-i18next'; - -import type { ConfigResponse } from '@/core/config/api/useConfig'; -import { safeLocalStorage } from '@/utils/storages'; - -export const useTranslationsCustomizer = () => { - const { i18n } = useTranslation(); - const translationsCustomizing = useRef(false); - - const customizeTranslations = useCallback( - ( - customTranslationsUrl: ConfigResponse['FRONTEND_CUSTOM_TRANSLATIONS_URL'], - cacheKey: string = 'CUSTOM_TRANSLATIONS', - ) => { - if (translationsCustomizing.current) { - return; - } - translationsCustomizing.current = true; - try { - if (!customTranslationsUrl) { - safeLocalStorage.setItem(cacheKey, ''); - } else { - const previousTranslationsString = safeLocalStorage.getItem(cacheKey); - if (previousTranslationsString) { - const previousTranslations = JSON.parse( - previousTranslationsString, - ) as Resource; - try { - applyTranslations(previousTranslations, i18n); - } catch (err: unknown) { - console.error('Error parsing cached translations:', err); - safeLocalStorage.setItem(cacheKey, ''); - } - } - - // Always update in background - fetchAndCacheTranslations(customTranslationsUrl, cacheKey) - .then((updatedTranslations) => { - if ( - updatedTranslations && - JSON.stringify(updatedTranslations) !== - previousTranslationsString - ) { - applyTranslations(updatedTranslations, i18n); - } - }) - .catch((err: unknown) => { - console.error('Error fetching custom translations:', err); - }); - } - } catch (err: unknown) { - console.error('Error updating custom translations:', err); - } finally { - translationsCustomizing.current = false; - } - }, - [i18n], - ); - - const applyTranslations = (translations: Resource, i18n: i18n) => { - Object.entries(translations).forEach(([lng, namespaces]) => { - Object.entries(namespaces).forEach(([ns, value]) => { - i18next.addResourceBundle(lng, ns, value, true, true); - }); - }); - const currentLanguage = i18n.language; - void i18next.changeLanguage(currentLanguage); - }; - - const fetchAndCacheTranslations = (url: string, CACHE_KEY: string) => { - return fetch(url).then((response) => { - if (!response.ok) { - throw new Error('Failed to fetch custom translations'); - } - return response.json().then((customTranslations: Resource) => { - safeLocalStorage.setItem(CACHE_KEY, JSON.stringify(customTranslations)); - return customTranslations; - }); - }); - }; - - return { customizeTranslations }; -}; 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'; From 94e99784f3588465a703957d2fc4f405ff6dbe69 Mon Sep 17 00:00:00 2001 From: rvveber Date: Tue, 13 May 2025 12:51:09 +0200 Subject: [PATCH 5/5] =?UTF-8?q?=E2=9C=85(tests)=20Add=20&=20adapt=20langua?= =?UTF-8?q?ge=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Language will only be changed if different from current language - Added test for custom translations Signed-off-by: Robin Weber --- .../e2e/__tests__/app-impress/config.spec.ts | 20 +++++++++++++++++++ .../__tests__/app-impress/language.spec.ts | 10 +++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) 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',