feat: #333 load language from backend configuration (#377)

* feat: #333 load language from backend configuration

* feat(settings): sync language with user configs api

* Update __test__/features/Settings/SettingsPage.test.tsx

Co-authored-by: Lê Nhân Phụng <lenhanphung@Phung-Mac-M4.local>
Co-authored-by: Benoit TELLIER <btellier@linagora.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
lenhanphung
2025-12-02 16:06:11 +07:00
committed by GitHub
parent 541a681e17
commit c408e1818f
7 changed files with 364 additions and 51 deletions
+130 -39
View File
@@ -2,8 +2,16 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom"; import "@testing-library/jest-dom";
import SettingsPage from "../../../src/features/Settings/SettingsPage"; import SettingsPage from "../../../src/features/Settings/SettingsPage";
import { renderWithProviders } from "../../utils/Renderwithproviders"; import { renderWithProviders } from "../../utils/Renderwithproviders";
import { api } from "../../../src/utils/apiUtils";
jest.mock("../../../src/utils/apiUtils");
describe("SettingsPage", () => { describe("SettingsPage", () => {
beforeEach(() => {
jest.clearAllMocks();
localStorage.clear();
});
const preloadedState = { const preloadedState = {
user: { user: {
userData: { userData: {
@@ -14,6 +22,11 @@ describe("SettingsPage", () => {
sid: "mockSid", sid: "mockSid",
openpaasId: "667037022b752d0026472254", openpaasId: "667037022b752d0026472254",
}, },
organiserData: null,
tokens: null,
language: "en",
loading: false,
error: null,
}, },
settings: { settings: {
language: "en", language: "en",
@@ -82,49 +95,95 @@ describe("SettingsPage", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("displays all available language options", async () => { it("displays all available language options and uses language from user state", async () => {
const stateWithUserLanguage = {
user: {
userData: {
sub: "test",
email: "test@test.com",
family_name: "Doe",
name: "John",
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
organiserData: null,
tokens: null,
language: "fr",
loading: false,
error: null,
},
settings: {
language: "en",
view: "settings",
},
};
renderWithProviders(<SettingsPage />, stateWithUserLanguage);
const languageSelect = screen.getByLabelText("settings.languageSelector");
// Verify Select exists
expect(languageSelect).toBeInTheDocument();
// Verify that the underlying native input reflects the user language ("fr")
const nativeInput = languageSelect.querySelector(
'input[aria-hidden="true"]'
) as HTMLInputElement | null;
expect(nativeInput).not.toBeNull();
expect(nativeInput?.value).toBe("fr");
});
it("updates language immediately (optimistic update) and calls API in background", async () => {
(api.patch as jest.Mock).mockResolvedValue({ status: 204 });
const { store } = renderWithProviders(<SettingsPage />, preloadedState);
const languageSelect = screen.getByLabelText("settings.languageSelector");
// MUI Select uses a native input element - find and change it
const nativeInput = languageSelect.querySelector(
'input[aria-hidden="true"]'
) as HTMLInputElement;
expect(nativeInput).toBeInTheDocument();
// Simulate change event on the native input
Object.defineProperty(nativeInput, "value", {
writable: true,
value: "fr",
});
fireEvent.change(nativeInput, { target: { value: "fr" } });
// Language should be updated immediately (optimistic update)
await waitFor(() => {
const state = store.getState();
expect(state.user?.language).toBe("fr");
expect(state.settings.language).toBe("fr");
});
// API should be called in background
await waitFor(() => {
expect(api.patch).toHaveBeenCalledWith(
"api/configurations?scope=user",
expect.objectContaining({
json: expect.arrayContaining([
expect.objectContaining({
name: "core",
configurations: expect.arrayContaining([
expect.objectContaining({ name: "language", value: "fr" }),
]),
}),
]),
})
);
});
});
it("saves language change to localStorage immediately (optimistic update)", async () => {
(api.patch as jest.Mock).mockResolvedValue({ status: 204 });
renderWithProviders(<SettingsPage />, preloadedState); renderWithProviders(<SettingsPage />, preloadedState);
const languageSelect = screen.getByLabelText("settings.languageSelector"); const languageSelect = screen.getByLabelText("settings.languageSelector");
// Click on the select to open dropdown
fireEvent.mouseDown(languageSelect);
// Wait for menu to appear - MUI Select uses Menu internally
// Note: In test environment, Select may not open menu, so we verify Select exists and has correct value
expect(languageSelect).toBeInTheDocument();
expect(languageSelect).toHaveTextContent("English");
});
it("dispatches setLanguage action when language is changed", async () => {
const { store } = renderWithProviders(<SettingsPage />, preloadedState);
const languageSelect = screen.getByLabelText("settings.languageSelector");
// MUI Select uses a native input element - find and change it
const nativeInput = languageSelect.querySelector(
'input[aria-hidden="true"]'
) as HTMLInputElement;
expect(nativeInput).toBeInTheDocument();
// Simulate change event on the native input
Object.defineProperty(nativeInput, "value", {
writable: true,
value: "fr",
});
fireEvent.change(nativeInput, { target: { value: "fr" } });
await waitFor(() => {
const state = store.getState();
expect(state.settings.language).toBe("fr");
});
});
it("saves language change to localStorage", async () => {
const { store } = renderWithProviders(<SettingsPage />, preloadedState);
const languageSelect = screen.getByLabelText("settings.languageSelector");
// MUI Select uses a native input element - find and change it // MUI Select uses a native input element - find and change it
const nativeInput = languageSelect.querySelector( const nativeInput = languageSelect.querySelector(
'input[aria-hidden="true"]' 'input[aria-hidden="true"]'
@@ -138,11 +197,43 @@ describe("SettingsPage", () => {
}); });
fireEvent.change(nativeInput, { target: { value: "fr" } }); fireEvent.change(nativeInput, { target: { value: "fr" } });
// localStorage should be updated immediately (optimistic update)
await waitFor(() => { await waitFor(() => {
expect(localStorage.getItem("lang")).toBe("fr"); expect(localStorage.getItem("lang")).toBe("fr");
}); });
}); });
it("rolls back language change if API call fails", async () => {
(api.patch as jest.Mock).mockRejectedValue(new Error("API Error"));
const { store } = renderWithProviders(<SettingsPage />, preloadedState);
const languageSelect = screen.getByLabelText("settings.languageSelector");
// MUI Select uses a native input element - find and change it
const nativeInput = languageSelect.querySelector(
'input[aria-hidden="true"]'
) as HTMLInputElement;
expect(nativeInput).toBeInTheDocument();
// Simulate change event on the native input
Object.defineProperty(nativeInput, "value", {
writable: true,
value: "fr",
});
fireEvent.change(nativeInput, { target: { value: "fr" } });
// Wait for rollback - language should be rolled back to "en" after error
await waitFor(
() => {
const state = store.getState();
expect(state.user?.language).toBe("en");
expect(state.settings.language).toBe("en");
},
{ timeout: 3000 }
);
});
it("shows empty state in Notifications tab", () => { it("shows empty state in Notifications tab", () => {
renderWithProviders(<SettingsPage />, preloadedState); renderWithProviders(<SettingsPage />, preloadedState);
+56 -2
View File
@@ -1,12 +1,15 @@
import { clientConfig } from "../../../src/features/User/oidcAuth"; import { clientConfig } from "../../../src/features/User/oidcAuth";
import { getOpenPaasUser } from "../../../src/features/User/userAPI"; import {
getOpenPaasUser,
updateUserConfigurations,
} from "../../../src/features/User/userAPI";
import { api } from "../../../src/utils/apiUtils"; import { api } from "../../../src/utils/apiUtils";
jest.mock("../../../src/utils/apiUtils"); jest.mock("../../../src/utils/apiUtils");
clientConfig.url = "https://example.com"; clientConfig.url = "https://example.com";
describe("getOpenPaasUserId", () => { describe("getOpenPaasUser", () => {
it("should fetch and return user data", async () => { it("should fetch and return user data", async () => {
const mockUser = { id: "123", name: "OpenPaas User" }; const mockUser = { id: "123", name: "OpenPaas User" };
@@ -20,3 +23,54 @@ describe("getOpenPaasUserId", () => {
expect(result).toEqual(mockUser); expect(result).toEqual(mockUser);
}); });
}); });
describe("updateUserConfigurations", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("should PATCH configurations with language update", async () => {
const mockResponse = { status: 204 };
(api.patch as jest.Mock).mockResolvedValue(mockResponse);
await updateUserConfigurations({ language: "vi" });
expect(api.patch).toHaveBeenCalledWith("api/configurations?scope=user", {
json: [
{
name: "core",
configurations: [{ name: "language", value: "vi" }],
},
],
});
});
it("should PATCH configurations with multiple updates", async () => {
const mockResponse = { status: 204 };
(api.patch as jest.Mock).mockResolvedValue(mockResponse);
await updateUserConfigurations({
language: "fr",
timezone: "Europe/Paris",
});
expect(api.patch).toHaveBeenCalledWith("api/configurations?scope=user", {
json: [
{
name: "core",
configurations: [
{ name: "language", value: "fr" },
{ name: "timezone", value: "Europe/Paris" },
],
},
],
});
});
it("should handle empty updates without calling API", async () => {
const result = await updateUserConfigurations({});
expect(api.patch).not.toHaveBeenCalled();
expect(result).toEqual({ status: 204 });
});
});
+20 -2
View File
@@ -13,6 +13,7 @@ import { useAppDispatch, useAppSelector } from "./app/hooks";
import { push } from "redux-first-history"; import { push } from "redux-first-history";
import { ErrorSnackbar } from "./components/Error/ErrorSnackbar"; import { ErrorSnackbar } from "./components/Error/ErrorSnackbar";
import I18n from "cozy-ui/transpiled/react/providers/I18n"; import I18n from "cozy-ui/transpiled/react/providers/I18n";
import { AVAILABLE_LANGUAGES } from "./features/Settings/constants";
import { import {
enGB, enGB,
@@ -29,16 +30,33 @@ import vi from "./locales/vi.json";
const locale = { en, fr, ru, vi }; const locale = { en, fr, ru, vi };
const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale }; const dateLocales = { en: enGB, fr: frLocale, ru: ruLocale, vi: viLocale };
const SUPPORTED_LANGUAGES = AVAILABLE_LANGUAGES.map((lang) => lang.code);
type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number];
const isValidLanguage = (
lang: string | null | undefined
): lang is SupportedLanguage => {
return !!lang && SUPPORTED_LANGUAGES.includes(lang as SupportedLanguage);
};
function App() { function App() {
const error = useAppSelector((state) => state.user.error); const error = useAppSelector((state) => state.user.error);
const lang = useAppSelector((state) => state.settings.language); const userLanguage = useAppSelector((state) => state.user.language);
const settingsLanguage = useAppSelector((state) => state.settings.language);
const savedLang = localStorage.getItem("lang");
const defaultLang = (window as any).LANG;
const lang =
[userLanguage, settingsLanguage, savedLang, defaultLang].find(
(l): l is string => isValidLanguage(l)
) || "en";
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
useEffect(() => { useEffect(() => {
if (error) { if (error) {
dispatch(push("/error")); dispatch(push("/error"));
} }
}); }, [error, dispatch]);
return ( return (
<CustomThemeProvider> <CustomThemeProvider>
+41 -4
View File
@@ -12,12 +12,17 @@ import {
Select, Select,
MenuItem, MenuItem,
Typography, Typography,
Snackbar,
} from "@mui/material"; } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import SyncIcon from "@mui/icons-material/Sync"; import SyncIcon from "@mui/icons-material/Sync";
import { useAppDispatch, useAppSelector } from "../../app/hooks"; import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { setView, setLanguage } from "./SettingsSlice"; import { setView, setLanguage as setSettingsLanguage } from "./SettingsSlice";
import {
updateUserConfigurationsAsync,
setLanguage as setUserLanguage,
} from "../User/userSlice";
import { AVAILABLE_LANGUAGES } from "./constants"; import { AVAILABLE_LANGUAGES } from "./constants";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import "./SettingsPage.styl"; import "./SettingsPage.styl";
@@ -27,11 +32,15 @@ type SettingsSubTab = "settings" | "notifications";
export default function SettingsPage() { export default function SettingsPage() {
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { t, lang } = useI18n(); const { t } = useI18n();
const userLanguage = useAppSelector((state) => state.user?.language);
const settingsLanguage = useAppSelector((state) => state.settings?.language);
const currentLanguage = userLanguage || settingsLanguage || "en";
const [activeNavItem, setActiveNavItem] = const [activeNavItem, setActiveNavItem] =
useState<SidebarNavItem>("settings"); useState<SidebarNavItem>("settings");
const [activeSettingsSubTab, setActiveSettingsSubTab] = const [activeSettingsSubTab, setActiveSettingsSubTab] =
useState<SettingsSubTab>("settings"); useState<SettingsSubTab>("settings");
const [languageErrorOpen, setLanguageErrorOpen] = useState(false);
const handleBackClick = () => { const handleBackClick = () => {
dispatch(setView("calendar")); dispatch(setView("calendar"));
@@ -52,7 +61,27 @@ export default function SettingsPage() {
}; };
const handleLanguageChange = (event: any) => { const handleLanguageChange = (event: any) => {
dispatch(setLanguage(event.target.value)); const newLanguage = event.target.value;
const previousLanguage = currentLanguage;
// Optimistic update - update UI immediately
dispatch(setUserLanguage(newLanguage));
dispatch(setSettingsLanguage(newLanguage));
// Call API in background, don't wait for it
dispatch(updateUserConfigurationsAsync({ language: newLanguage }))
.unwrap()
.catch((error) => {
console.error("Failed to update language:", error);
// Rollback on error
dispatch(setUserLanguage(previousLanguage));
dispatch(setSettingsLanguage(previousLanguage));
setLanguageErrorOpen(true);
});
};
const handleLanguageErrorClose = () => {
setLanguageErrorOpen(false);
}; };
return ( return (
@@ -122,7 +151,7 @@ export default function SettingsPage() {
</Typography> </Typography>
<FormControl size="small" sx={{ minWidth: 500 }}> <FormControl size="small" sx={{ minWidth: 500 }}>
<Select <Select
value={lang} value={currentLanguage}
onChange={handleLanguageChange} onChange={handleLanguageChange}
variant="outlined" variant="outlined"
aria-label={ aria-label={
@@ -157,6 +186,14 @@ export default function SettingsPage() {
)} )}
</Box> </Box>
</Box> </Box>
<Snackbar
open={languageErrorOpen}
autoHideDuration={4000}
onClose={handleLanguageErrorClose}
message={
t("settings.languageUpdateError") || "Failed to update language"
}
/>
</main> </main>
); );
} }
+35
View File
@@ -33,3 +33,38 @@ export async function getUserDetails(id: string) {
const user = await api.get(`api/users/${id}`).json(); const user = await api.get(`api/users/${id}`).json();
return user; return user;
} }
export interface UserConfigurationUpdates {
language?: string;
notifications?: Record<string, unknown>;
timezone?: string;
}
export async function updateUserConfigurations(
updates: UserConfigurationUpdates
): Promise<Response | { status: number }> {
const coreConfigs: Array<{ name: string; value: any }> = [];
if (updates.language !== undefined) {
coreConfigs.push({ name: "language", value: updates.language });
}
if (updates.notifications !== undefined) {
coreConfigs.push({ name: "notifications", value: updates.notifications });
}
if (updates.timezone !== undefined) {
coreConfigs.push({ name: "timezone", value: updates.timezone });
}
if (coreConfigs.length === 0) {
return Promise.resolve({ status: 204 });
}
return await api.patch(`api/configurations?scope=user`, {
json: [
{
name: "core",
configurations: coreConfigs,
},
],
});
}
+20
View File
@@ -6,8 +6,28 @@ export interface userData {
sid: string; sid: string;
sub: string; sub: string;
openpaasId?: string; openpaasId?: string;
language?: string;
} }
export interface UserConfigurations {
modules?: Array<{
name: string;
configurations?: Array<{
name: string;
value: any;
}>;
}>;
}
export interface NotificationSettings {
email?: boolean;
push?: boolean;
}
export type NotificationSettingsExtended = NotificationSettings & {
[key: string]: unknown;
};
export interface userOrganiser { export interface userOrganiser {
cn: string; cn: string;
cal_address: string; cal_address: string;
+62 -4
View File
@@ -1,15 +1,19 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit"; import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { userData, userOrganiser } from "./userDataTypes"; import { userData, userOrganiser } from "./userDataTypes";
import { getOpenPaasUser } from "./userAPI"; import {
getOpenPaasUser,
updateUserConfigurations,
UserConfigurationUpdates,
} from "./userAPI";
import { formatReduxError } from "../../utils/errorUtils"; import { formatReduxError } from "../../utils/errorUtils";
export const getOpenPaasUserDataAsync = createAsyncThunk< export const getOpenPaasUserDataAsync = createAsyncThunk<
Record<string, string>, Record<string, any>,
void, void,
{ rejectValue: { message: string; status?: number } } { rejectValue: { message: string; status?: number } }
>("user/getOpenPaasUserData", async (_, { rejectWithValue }) => { >("user/getOpenPaasUserData", async (_, { rejectWithValue }) => {
try { try {
const user = (await getOpenPaasUser()) as Record<string, string>; const user = (await getOpenPaasUser()) as Record<string, any>;
return user; return user;
} catch (err: any) { } catch (err: any) {
return rejectWithValue({ return rejectWithValue({
@@ -19,12 +23,29 @@ export const getOpenPaasUserDataAsync = createAsyncThunk<
} }
}); });
export const updateUserConfigurationsAsync = createAsyncThunk<
UserConfigurationUpdates,
UserConfigurationUpdates,
{ rejectValue: { message: string; status?: number } }
>("user/updateConfigurations", async (updates, { rejectWithValue }) => {
try {
await updateUserConfigurations(updates);
return updates;
} catch (err: any) {
return rejectWithValue({
message: formatReduxError(err),
status: err.response?.status,
});
}
});
export const userSlice = createSlice({ export const userSlice = createSlice({
name: "user", name: "user",
initialState: { initialState: {
userData: null as unknown as userData, userData: null as unknown as userData,
organiserData: null as unknown as userOrganiser, organiserData: null as unknown as userOrganiser,
tokens: null as unknown as Record<string, string>, tokens: null as unknown as Record<string, string>,
language: null as string | null,
loading: true, loading: true,
error: null as unknown as string | null, error: null as unknown as string | null,
}, },
@@ -41,6 +62,12 @@ export const userSlice = createSlice({
setTokens: (state, action) => { setTokens: (state, action) => {
state.tokens = action.payload; state.tokens = action.payload;
}, },
setLanguage: (state, action) => {
state.language = action.payload;
if (state.userData) {
state.userData.language = action.payload;
}
},
clearError: (state) => { clearError: (state) => {
state.error = null; state.error = null;
}, },
@@ -62,6 +89,22 @@ export const userSlice = createSlice({
state.organiserData.cal_address = action.payload.preferredEmail; state.organiserData.cal_address = action.payload.preferredEmail;
state.userData.email = action.payload.preferredEmail; state.userData.email = action.payload.preferredEmail;
} }
// Extract language from configurations.modules
if (action.payload.configurations?.modules) {
const coreModule = action.payload.configurations.modules.find(
(module: any) => module.name === "core"
);
if (coreModule?.configurations) {
const languageConfig = coreModule.configurations.find(
(config: any) => config.name === "language"
);
if (languageConfig?.value) {
state.language = languageConfig.value;
state.userData.language = languageConfig.value;
}
}
}
}) })
.addCase(getOpenPaasUserDataAsync.pending, (state) => { .addCase(getOpenPaasUserDataAsync.pending, (state) => {
state.loading = true; state.loading = true;
@@ -72,11 +115,26 @@ export const userSlice = createSlice({
state.error = state.error =
action.payload?.message || "Failed to fetch user information"; action.payload?.message || "Failed to fetch user information";
} }
})
.addCase(updateUserConfigurationsAsync.fulfilled, (state, action) => {
if (action.payload.language !== undefined) {
state.language = action.payload.language;
if (state.userData) {
state.userData.language = action.payload.language;
}
}
})
.addCase(updateUserConfigurationsAsync.rejected, (state, action) => {
if (action.payload?.status !== 401) {
state.error =
action.payload?.message || "Failed to update user configurations";
}
}); });
}, },
}); });
// Action creators are generated for each case reducer function // Action creators are generated for each case reducer function
export const { setUserData, setTokens, clearError } = userSlice.actions; export const { setUserData, setTokens, setLanguage, clearError } =
userSlice.actions;
export default userSlice.reducer; export default userSlice.reducer;