Skip to content

Feature/custom translations frontend #857

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ and this project adheres to

## [Unreleased]

### Added

- ✨(frontend) add customization for translations #857

## [3.3.0] - 2025-05-06

### Added
Expand Down
14 changes: 14 additions & 0 deletions docs/theming.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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
20 changes: 20 additions & 0 deletions src/frontend/apps/e2e/__tests__/app-impress/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
10 changes: 9 additions & 1 deletion src/frontend/apps/e2e/__tests__/app-impress/language.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
48 changes: 48 additions & 0 deletions src/frontend/apps/impress/src/core/api/useUserUpdate.ts
Original file line number Diff line number Diff line change
@@ -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<User>;

async function updateUser(userUpdateData: UserUpdateRequest): Promise<User> {
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<User>;
}

export const useUserUpdate = (): UseMutationResult<
User,
APIError,
UserUpdateRequest
> => {
const queryClient = useQueryClient();

const mutationResult = useMutation<User, APIError, UserUpdateRequest>({
mutationFn: updateUser,
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: [KEY_AUTH] });
},
onError: (error) => {
console.error('Error updating user', error);
},
});

return mutationResult;
};
40 changes: 33 additions & 7 deletions src/frontend/apps/impress/src/core/config/ConfigProvider.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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) {
Expand All @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/frontend/apps/impress/src/core/config/api/useConfig.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { Resource } from 'i18next';

import { APIError, errorCauses, fetchAPI } from '@/api';
import { Theme } from '@/cunningham/';
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/apps/impress/src/features/auth/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ export interface User {
email: string;
full_name: string;
short_name: string;
language: string;
language?: string;
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './LanguagePicker';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './useSynchronizedLanguage';
export * from './useCustomTranslations';
Original file line number Diff line number Diff line change
@@ -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,
};
};
Loading
Loading