From 1056220719dd0f6b1a206a499ef26e2bfb88c0a4 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Sun, 7 Dec 2025 07:43:00 +0100 Subject: [PATCH] [#339] added API calls to timezone selectors (#396) Co-authored-by: Camille Moussu --- .../Settings/SettingUserSync.test.tsx | 5 + .../features/Settings/SettingsPage.test.tsx | 278 +++++++++++++++++- src/features/Settings/SettingsPage.tsx | 57 ++-- src/features/User/userAPI.ts | 2 +- 4 files changed, 310 insertions(+), 32 deletions(-) diff --git a/__test__/features/Settings/SettingUserSync.test.tsx b/__test__/features/Settings/SettingUserSync.test.tsx index d596209..5a68ef8 100644 --- a/__test__/features/Settings/SettingUserSync.test.tsx +++ b/__test__/features/Settings/SettingUserSync.test.tsx @@ -13,6 +13,8 @@ import { api } from "../../../src/utils/apiUtils"; import { browserDefaultTimeZone } from "../../../src/utils/timezone"; import { renderWithProviders } from "../../utils/Renderwithproviders"; +jest.mock("../../../src/utils/apiUtils"); + describe("Timezone synchronization after getOpenPaasUserDataAsync", () => { let apiGetSpy: jest.SpyInstance; @@ -315,6 +317,9 @@ describe("Timezone Logic - Backend to Frontend Flow", () => { }); describe("User Actions - Changing Timezone", () => { + beforeEach(() => { + (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + }); test("User enables browser default => Settings gets browser TZ, User gets null", async () => { const { store } = renderWithProviders(, { user: { diff --git a/__test__/features/Settings/SettingsPage.test.tsx b/__test__/features/Settings/SettingsPage.test.tsx index b387c1a..658e734 100644 --- a/__test__/features/Settings/SettingsPage.test.tsx +++ b/__test__/features/Settings/SettingsPage.test.tsx @@ -24,12 +24,17 @@ describe("SettingsPage", () => { }, organiserData: null, tokens: null, - coreConfig: { language: "en" }, + coreConfig: { + language: "en", + datetime: { timeZone: "UTC" }, + }, loading: false, error: null, }, settings: { language: "en", + timeZone: "UTC", + isBrowserDefaultTimeZone: false, view: "settings", }, }; @@ -110,12 +115,14 @@ describe("SettingsPage", () => { }, organiserData: null, tokens: null, - coreConfig: { language: "fr" }, + coreConfig: { language: "fr", datetime: { timeZone: "UTC" } }, loading: false, error: null, }, settings: { language: "en", + timeZone: "UTC", + isBrowserDefaultTimeZone: false, view: "settings", }, }; @@ -260,6 +267,9 @@ describe("SettingsPage", () => { }); describe("Timezone Settings", () => { + beforeEach(() => { + (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + }); it("displays timezone selector in Settings tab", () => { renderWithProviders(, preloadedState); @@ -289,6 +299,7 @@ describe("SettingsPage", () => { settings: { language: "en", timeZone: "UTC", + isBrowserDefaultTimeZone: false, view: "settings", }, }; @@ -298,7 +309,7 @@ describe("SettingsPage", () => { expect(screen.getByDisplayValue(/America\/New York/i)).toBeDefined(); }); - it("updates timezone immediately (optimistic update)", async () => { + it("updates timezone immediately (optimistic update) and calls API", async () => { const { store } = renderWithProviders(, preloadedState); const timezoneInput = screen.getAllByRole("combobox")[1]; @@ -319,6 +330,27 @@ describe("SettingsPage", () => { const state = store.getState(); expect(state.settings.timeZone).toBe("Europe/Paris"); }); + + 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: "datetime", + value: expect.objectContaining({ + timeZone: "Europe/Paris", + }), + }), + ]), + }), + ]), + }) + ); + }); }); it("handles timezone change with different timezone values", async () => { @@ -340,7 +372,30 @@ describe("SettingsPage", () => { expect(state.settings.timeZone).toBe("Asia/Tokyo"); }); - // Test with America/Los_Angeles + 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: "datetime", + value: expect.objectContaining({ + timeZone: "Asia/Tokyo", + }), + }), + ]), + }), + ]), + }) + ); + }); + + jest.clearAllMocks(); + (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + fireEvent.change(timezoneInput, { target: { value: "America/Los_Angeles" }, }); @@ -357,6 +412,172 @@ describe("SettingsPage", () => { const state = store.getState(); expect(state.settings.timeZone).toBe("America/Los_Angeles"); }); + + 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: "datetime", + value: expect.objectContaining({ + timeZone: "America/Los_Angeles", + }), + }), + ]), + }), + ]), + }) + ); + }); + }); + + it("enables browser default timezone and calls API with null", async () => { + const { store } = renderWithProviders(, preloadedState); + + const browserDefaultSwitch = screen.getByRole("switch", { + name: /settings.timeZoneBrowserDefault/i, + }); + + expect(browserDefaultSwitch).not.toBeChecked(); + + fireEvent.click(browserDefaultSwitch); + + await waitFor(() => { + expect(browserDefaultSwitch).toBeChecked(); + }); + + await waitFor(() => { + const state = store.getState(); + expect(state.settings.isBrowserDefaultTimeZone).toBe(true); + }); + + await waitFor(() => { + const state = store.getState(); + expect(state.user?.coreConfig.datetime.timeZone).toBeNull(); + }); + + 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: "datetime", + value: expect.objectContaining({ + timeZone: null, + }), + }), + ]), + }), + ]), + }) + ); + }); + }); + + it("disables browser default timezone and shows timezone selector", async () => { + const stateWithBrowserDefault = { + ...preloadedState, + user: { + ...preloadedState.user, + coreConfig: { + language: "en", + datetime: { timeZone: null }, + }, + }, + settings: { + ...preloadedState.settings, + isBrowserDefaultTimeZone: true, + }, + }; + + renderWithProviders(, stateWithBrowserDefault); + + const browserDefaultSwitch = screen.getByRole("switch", { + name: /settings.timeZoneBrowserDefault/i, + }); + + expect(browserDefaultSwitch).toBeChecked(); + + expect(screen.getAllByRole("combobox")).toHaveLength(1); + + fireEvent.click(browserDefaultSwitch); + + await waitFor(() => { + expect(browserDefaultSwitch).not.toBeChecked(); + }); + + await waitFor(() => { + expect(screen.getAllByRole("combobox")).toHaveLength(2); + }); + }); + + it("rolls back timezone change if API call fails", async () => { + (api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); + + const { store } = renderWithProviders(, preloadedState); + + const timezoneInput = screen.getAllByRole("combobox")[1]; + + fireEvent.change(timezoneInput, { target: { value: "Europe/Paris" } }); + const option = await screen.findByText(/Europe\/Paris/i); + fireEvent.click(option); + + await waitFor(() => { + const state = store.getState(); + expect(state.user?.coreConfig.datetime.timeZone).toBe("UTC"); + }); + await waitFor( + () => { + const state = store.getState(); + expect(state.settings.timeZone).toBe("UTC"); + }, + { timeout: 3000 } + ); + + await waitFor(() => { + expect( + screen.getByText("settings.timeZoneUpdateError") + ).toBeInTheDocument(); + }); + }); + + it("rolls back browser default change if API call fails", async () => { + (api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); + + const { store } = renderWithProviders(, preloadedState); + + const browserDefaultSwitch = screen.getByRole("switch", { + name: /settings.timeZoneBrowserDefault/i, + }); + + fireEvent.click(browserDefaultSwitch); + + await waitFor( + () => { + const state = store.getState(); + expect(state.settings.isBrowserDefaultTimeZone).toBe(false); + }, + { timeout: 3000 } + ); + + await waitFor(() => { + const state = store.getState(); + expect(state.user?.coreConfig.datetime.timeZone).toBe("UTC"); + }); + + await waitFor(() => { + expect( + screen.getByText("settings.timeZoneUpdateError") + ).toBeInTheDocument(); + }); }); it("uses UTC as default timezone when no timezone is set", () => { @@ -379,6 +600,7 @@ describe("SettingsPage", () => { settings: { language: "en", timeZone: undefined, + isBrowserDefaultTimeZone: false, view: "settings", }, }; @@ -388,5 +610,53 @@ describe("SettingsPage", () => { const timezoneInput = screen.getByDisplayValue("(UTC) UTC"); expect(timezoneInput).toBeDefined(); }); + + it("preserves other datetime properties when updating timezone", async () => { + const stateWithDatetimeConfig = { + ...preloadedState, + user: { + ...preloadedState.user, + coreConfig: { + language: "en", + datetime: { + timeZone: "UTC", + format: "24h", + firstDayOfWeek: 1, + }, + }, + }, + }; + + renderWithProviders(, stateWithDatetimeConfig); + + const timezoneInput = screen.getAllByRole("combobox")[1]; + + fireEvent.change(timezoneInput, { target: { value: "Europe/Paris" } }); + const option = await screen.findByText(/Europe\/Paris/i); + fireEvent.click(option); + + 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: "datetime", + value: { + timeZone: "Europe/Paris", + format: "24h", + firstDayOfWeek: 1, + }, + }), + ]), + }), + ]), + }) + ); + }); + }); }); }); diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx index ddbdb7b..f197138 100644 --- a/src/features/Settings/SettingsPage.tsx +++ b/src/features/Settings/SettingsPage.tsx @@ -47,7 +47,7 @@ type SettingsSubTab = "settings" | "notifications"; export default function SettingsPage() { const dispatch = useAppDispatch(); const { t } = useI18n(); - // const previousConfig = useAppSelector((state) => state.user.coreConfig); + const previousConfig = useAppSelector((state) => state.user.coreConfig); const userLanguage = useAppSelector( (state) => state.user?.coreConfig.language ); @@ -115,46 +115,49 @@ export default function SettingsPage() { }; const handleTimeZoneChange = (newTimeZone: string) => { - // const previousTimeZone = currentTimeZone; + const previousTimeZone = currentTimeZone; // Optimistic update - update UI immediately dispatch(setUserTimeZone(newTimeZone)); dispatch(setSettingsTimeZone(newTimeZone)); - // // Call API in background, don't wait for it - // dispatch( - // updateUserConfigurationsAsync({ timezone: newTimeZone, previousConfig }) - // ) - // .unwrap() - // .catch((error) => { - // console.error("Failed to update TimeZone:", error); - // // Rollback on error - // dispatch(setUserTimeZone(previousTimeZone)); - // dispatch(setSettingsTimeZone(previousTimeZone)); - // setTimeZoneErrorOpen(true); - // }); + // Call API in background, don't wait for it + dispatch( + updateUserConfigurationsAsync({ timezone: newTimeZone, previousConfig }) + ) + .unwrap() + .catch((error) => { + console.error("Failed to update TimeZone:", error); + // Rollback on error + dispatch(setUserTimeZone(previousTimeZone)); + dispatch(setSettingsTimeZone(previousTimeZone)); + setTimeZoneErrorOpen(true); + }); }; const handleTimeZoneDefaultChange = (isDefault: boolean) => { + const previousTimeZone = currentTimeZone; + // Optimistic update - update UI immediately dispatch(setIsBrowserDefaultTimeZone(isDefault)); if (isDefault) { dispatch(setUserTimeZone(null)); dispatch(setSettingsTimeZone(browserDefaultTimeZone)); - } - // // Call API in background, don't wait for it - // dispatch( - // updateUserConfigurationsAsync({ timezone: newTimeZone, previousConfig }) - // ) - // .unwrap() - // .catch((error) => { - // console.error("Failed to update TimeZone:", error); - // // Rollback on error - // dispatch(setUserTimeZone(previousTimeZone)); - // dispatch(setSettingsTimeZone(previousTimeZone)); - // setTimeZoneErrorOpen(true); - // }); + // Call API in background, don't wait for it + dispatch( + updateUserConfigurationsAsync({ timezone: null, previousConfig }) + ) + .unwrap() + .catch((error) => { + console.error("Failed to update TimeZone:", error); + // Rollback on error + dispatch(setUserTimeZone(previousTimeZone)); + dispatch(setSettingsTimeZone(previousTimeZone)); + dispatch(setIsBrowserDefaultTimeZone(!isDefault)); + setTimeZoneErrorOpen(true); + }); + } }; const handleTimeZoneErrorClose = () => { diff --git a/src/features/User/userAPI.ts b/src/features/User/userAPI.ts index ff0c5d9..741e801 100644 --- a/src/features/User/userAPI.ts +++ b/src/features/User/userAPI.ts @@ -37,7 +37,7 @@ export async function getUserDetails(id: string) { export interface UserConfigurationUpdates { language?: string; notifications?: Record; - timezone?: string; + timezone?: string | null; previousConfig?: Record; }