♻️(frontend) Refactor language-related code

- 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 <weber@b1-systems.de>
This commit is contained in:
rvveber
2025-05-07 15:23:29 +02:00
parent 5962f7aae1
commit fa83955a77
10 changed files with 115 additions and 226 deletions

View File

@@ -11,5 +11,5 @@ export interface User {
email: string;
full_name: string;
short_name: string;
language: string;
language?: string;
}

View File

@@ -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<User> => {
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<User>;
};
export function useChangeUserLanguage() {
const queryClient = useQueryClient();
return useMutation<User, APIError, ChangeUserLanguageParams>({
mutationFn: changeUserLanguage,
onSuccess: () => {
void queryClient.invalidateQueries({
queryKey: ['change-user-language'],
});
},
});
}

View File

@@ -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 =

View File

@@ -0,0 +1 @@
export * from './LanguagePicker';

View File

@@ -0,0 +1,2 @@
export * from './useSynchronizedLanguage';
export * from './useCustomTranslations';

View File

@@ -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,
};
};

View File

@@ -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 };
};

View File

@@ -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,
};
};

View File

@@ -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 };
};

View File

@@ -0,0 +1 @@
export * from './locale';