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() {