diff --git a/__test__/features/Settings/SettingsPage.test.tsx b/__test__/features/Settings/SettingsPage.test.tsx
index 20dc2f9..b6e5184 100644
--- a/__test__/features/Settings/SettingsPage.test.tsx
+++ b/__test__/features/Settings/SettingsPage.test.tsx
@@ -2,8 +2,16 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import "@testing-library/jest-dom";
import SettingsPage from "../../../src/features/Settings/SettingsPage";
import { renderWithProviders } from "../../utils/Renderwithproviders";
+import { api } from "../../../src/utils/apiUtils";
+
+jest.mock("../../../src/utils/apiUtils");
describe("SettingsPage", () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ localStorage.clear();
+ });
+
const preloadedState = {
user: {
userData: {
@@ -14,6 +22,11 @@ describe("SettingsPage", () => {
sid: "mockSid",
openpaasId: "667037022b752d0026472254",
},
+ organiserData: null,
+ tokens: null,
+ language: "en",
+ loading: false,
+ error: null,
},
settings: {
language: "en",
@@ -82,49 +95,95 @@ describe("SettingsPage", () => {
).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(, 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(, 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(, preloadedState);
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(, 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(, 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"]'
@@ -138,11 +197,43 @@ describe("SettingsPage", () => {
});
fireEvent.change(nativeInput, { target: { value: "fr" } });
+ // localStorage should be updated immediately (optimistic update)
await waitFor(() => {
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(, 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", () => {
renderWithProviders(, preloadedState);
diff --git a/__test__/features/user/userAPI.test.tsx b/__test__/features/user/userAPI.test.tsx
index 3fad487..da10527 100644
--- a/__test__/features/user/userAPI.test.tsx
+++ b/__test__/features/user/userAPI.test.tsx
@@ -1,12 +1,15 @@
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";
jest.mock("../../../src/utils/apiUtils");
clientConfig.url = "https://example.com";
-describe("getOpenPaasUserId", () => {
+describe("getOpenPaasUser", () => {
it("should fetch and return user data", async () => {
const mockUser = { id: "123", name: "OpenPaas User" };
@@ -20,3 +23,54 @@ describe("getOpenPaasUserId", () => {
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 });
+ });
+});
diff --git a/src/App.tsx b/src/App.tsx
index f04ee20..4c68700 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -13,6 +13,7 @@ import { useAppDispatch, useAppSelector } from "./app/hooks";
import { push } from "redux-first-history";
import { ErrorSnackbar } from "./components/Error/ErrorSnackbar";
import I18n from "cozy-ui/transpiled/react/providers/I18n";
+import { AVAILABLE_LANGUAGES } from "./features/Settings/constants";
import {
enGB,
@@ -29,16 +30,33 @@ import vi from "./locales/vi.json";
const locale = { en, fr, ru, vi };
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() {
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();
useEffect(() => {
if (error) {
dispatch(push("/error"));
}
- });
+ }, [error, dispatch]);
return (
diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx
index 3f1998d..af273eb 100644
--- a/src/features/Settings/SettingsPage.tsx
+++ b/src/features/Settings/SettingsPage.tsx
@@ -12,12 +12,17 @@ import {
Select,
MenuItem,
Typography,
+ Snackbar,
} from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SettingsIcon from "@mui/icons-material/Settings";
import SyncIcon from "@mui/icons-material/Sync";
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 { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
import "./SettingsPage.styl";
@@ -27,11 +32,15 @@ type SettingsSubTab = "settings" | "notifications";
export default function SettingsPage() {
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] =
useState("settings");
const [activeSettingsSubTab, setActiveSettingsSubTab] =
useState("settings");
+ const [languageErrorOpen, setLanguageErrorOpen] = useState(false);
const handleBackClick = () => {
dispatch(setView("calendar"));
@@ -52,7 +61,27 @@ export default function SettingsPage() {
};
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 (
@@ -122,7 +151,7 @@ export default function SettingsPage() {