From bd33a9304074c3f7ffa52e37085cdc6e1355ee47 Mon Sep 17 00:00:00 2001 From: lenhanphung <44486647+lenhanphung@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:54:24 +0700 Subject: [PATCH] feat: #339 Add email notification toggle setting and update tests (#387) --- .../features/Settings/SettingsPage.test.tsx | 186 +++++++++++++++++- src/features/Settings/SettingsPage.tsx | 69 ++++++- src/features/User/userAPI.ts | 36 +++- src/features/User/userSlice.ts | 30 ++- src/locales/en.json | 3 + src/locales/fr.json | 3 + src/locales/ru.json | 3 + src/locales/vi.json | 3 + 8 files changed, 316 insertions(+), 17 deletions(-) diff --git a/__test__/features/Settings/SettingsPage.test.tsx b/__test__/features/Settings/SettingsPage.test.tsx index 658e734..db85826 100644 --- a/__test__/features/Settings/SettingsPage.test.tsx +++ b/__test__/features/Settings/SettingsPage.test.tsx @@ -28,6 +28,7 @@ describe("SettingsPage", () => { language: "en", datetime: { timeZone: "UTC" }, }, + alarmEmailsEnabled: false, loading: false, error: null, }, @@ -90,7 +91,7 @@ describe("SettingsPage", () => { fireEvent.click(notificationsTab); expect( - screen.getByText(/settings.notifications.empty/i) + screen.getByText(/settings.notifications.deliveryMethod/i) ).toBeInTheDocument(); }); @@ -253,7 +254,7 @@ describe("SettingsPage", () => { ); }); - it("shows empty state in Notifications tab", () => { + it("shows Email toggle in Notifications tab", () => { renderWithProviders(, preloadedState); const notificationsTab = screen.getByRole("tab", { @@ -262,7 +263,10 @@ describe("SettingsPage", () => { fireEvent.click(notificationsTab); expect( - screen.getByText("settings.notifications.empty") + screen.getByText(/settings.notifications.deliveryMethod/i) + ).toBeInTheDocument(); + expect( + screen.getByRole("switch", { name: /settings.notifications.email/i }) ).toBeInTheDocument(); }); @@ -658,5 +662,181 @@ describe("SettingsPage", () => { ); }); }); + + describe("Alarm Emails Settings", () => { + beforeEach(() => { + (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + }); + + it("displays alarmEmails toggle with correct initial state", () => { + const stateWithAlarmEmailsEnabled = { + ...preloadedState, + user: { + ...preloadedState.user, + alarmEmailsEnabled: true, + }, + }; + renderWithProviders(, stateWithAlarmEmailsEnabled); + + const notificationsTab = screen.getByRole("tab", { + name: /settings.notifications/i, + }); + fireEvent.click(notificationsTab); + + const toggle = screen.getByRole("switch", { + name: /settings.notifications.email/i, + }) as HTMLInputElement; + expect(toggle).toBeInTheDocument(); + expect(toggle.checked).toBe(true); + }); + + it("displays alarmEmails toggle as true when alarmEmailsEnabled is null", () => { + const stateWithNullAlarmEmails = { + ...preloadedState, + user: { + ...preloadedState.user, + alarmEmailsEnabled: null, + }, + }; + renderWithProviders(, stateWithNullAlarmEmails); + + const notificationsTab = screen.getByRole("tab", { + name: /settings.notifications/i, + }); + fireEvent.click(notificationsTab); + + const toggle = screen.getByRole("switch", { + name: /settings.notifications.email/i, + }) as HTMLInputElement; + expect(toggle).toBeInTheDocument(); + expect(toggle.checked).toBe(true); + }); + + it("updates alarmEmails immediately (optimistic update) and calls API in background", async () => { + (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + + const { store } = renderWithProviders(, preloadedState); + + const notificationsTab = screen.getByRole("tab", { + name: /settings.notifications/i, + }); + fireEvent.click(notificationsTab); + + const toggle = screen.getByRole("switch", { + name: /settings.notifications.email/i, + }) as HTMLInputElement; + expect(toggle.checked).toBe(false); + + fireEvent.click(toggle); + + // AlarmEmails should be updated immediately (optimistic update) + await waitFor(() => { + const state = store.getState(); + expect(state.user?.alarmEmailsEnabled).toBe(true); + }); + + // API should be called in background + await waitFor(() => { + expect(api.patch).toHaveBeenCalledWith( + "api/configurations?scope=user", + expect.objectContaining({ + json: expect.arrayContaining([ + expect.objectContaining({ + name: "calendar", + configurations: expect.arrayContaining([ + expect.objectContaining({ + name: "alarmEmails", + value: true, + }), + ]), + }), + ]), + }) + ); + }); + }); + + it("rolls back alarmEmails change if API call fails", async () => { + (api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); + + const { store } = renderWithProviders(, preloadedState); + + const notificationsTab = screen.getByRole("tab", { + name: /settings.notifications/i, + }); + fireEvent.click(notificationsTab); + + const toggle = screen.getByRole("switch", { + name: /settings.notifications.email/i, + }) as HTMLInputElement; + expect(toggle.checked).toBe(false); + + fireEvent.click(toggle); + + // Wait for rollback - alarmEmails should be rolled back to false after error + await waitFor( + () => { + const state = store.getState(); + expect(state.user?.alarmEmailsEnabled).toBe(false); + }, + { timeout: 3000 } + ); + }); + + it("sends false value when toggle is turned off", async () => { + (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + + const stateWithAlarmEmailsEnabled = { + ...preloadedState, + user: { + ...preloadedState.user, + alarmEmailsEnabled: true, + }, + }; + + const { store } = renderWithProviders( + , + stateWithAlarmEmailsEnabled + ); + + const notificationsTab = screen.getByRole("tab", { + name: /settings.notifications/i, + }); + fireEvent.click(notificationsTab); + + const toggle = screen.getByRole("switch", { + name: /settings.notifications.email/i, + }) as HTMLInputElement; + expect(toggle.checked).toBe(true); + + fireEvent.click(toggle); + + // AlarmEmails should be updated immediately (optimistic update) + await waitFor(() => { + const state = store.getState(); + expect(state.user?.alarmEmailsEnabled).toBe(false); + }); + + // API should be called with false value + await waitFor(() => { + expect(api.patch).toHaveBeenCalledWith( + "api/configurations?scope=user", + expect.objectContaining({ + json: expect.arrayContaining([ + expect.objectContaining({ + name: "calendar", + configurations: expect.arrayContaining([ + expect.objectContaining({ + name: "alarmEmails", + value: false, + }), + ]), + }), + ]), + }) + ); + }); + }); + }); }); }); diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx index f197138..688f078 100644 --- a/src/features/Settings/SettingsPage.tsx +++ b/src/features/Settings/SettingsPage.tsx @@ -13,8 +13,8 @@ import { MenuItem, Typography, Snackbar, - FormControlLabel, Switch, + FormControlLabel, } from "@mui/material"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import SettingsIcon from "@mui/icons-material/Settings"; @@ -30,6 +30,7 @@ import { updateUserConfigurationsAsync, setLanguage as setUserLanguage, setTimezone as setUserTimeZone, + setAlarmEmails, } from "../User/userSlice"; import { AVAILABLE_LANGUAGES } from "./constants"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; @@ -64,13 +65,16 @@ export default function SettingsPage() { const isBrowserDefault = useAppSelector( (state) => state.settings.isBrowserDefaultTimeZone ); - + const alarmEmailsEnabled = useAppSelector( + (state) => state.user?.alarmEmailsEnabled ?? true + ); const [activeNavItem, setActiveNavItem] = useState("settings"); const [activeSettingsSubTab, setActiveSettingsSubTab] = useState("settings"); const [languageErrorOpen, setLanguageErrorOpen] = useState(false); const [timeZoneErrorOpen, setTimeZoneErrorOpen] = useState(false); + const [alarmEmailsErrorOpen, setAlarmEmailsErrorOpen] = useState(false); const handleBackClick = () => { dispatch(setView("calendar")); @@ -164,6 +168,30 @@ export default function SettingsPage() { setTimeZoneErrorOpen(false); }; + const handleAlarmEmailsToggle = ( + event: React.ChangeEvent + ) => { + const newValue = event.target.checked; + const previousValue = alarmEmailsEnabled; + + // Optimistic update - update UI immediately + dispatch(setAlarmEmails(newValue)); + + // Call API in background, don't wait for it + dispatch(updateUserConfigurationsAsync({ alarmEmails: newValue })) + .unwrap() + .catch((error) => { + console.error("Failed to update alarm emails:", error); + // Rollback on error + dispatch(setAlarmEmails(previousValue)); + setAlarmEmailsErrorOpen(true); + }); + }; + + const handleAlarmEmailsErrorClose = () => { + setAlarmEmailsErrorOpen(false); + }; + return (
@@ -291,10 +319,32 @@ export default function SettingsPage() { )} {activeSettingsSubTab === "notifications" && ( - - {t("settings.notifications.empty") || - "Notifications settings coming soon"} + + {t("settings.notifications.deliveryMethod") || + "Delivery method"} + + } + label={t("settings.notifications.email") || "Email"} + labelPlacement="start" + sx={{ + minWidth: 400, + justifyContent: "space-between", + marginLeft: 0, + }} + /> )} @@ -322,6 +372,15 @@ export default function SettingsPage() { onClose={handleTimeZoneErrorClose} message={t("settings.timeZoneUpdateError")} /> +
); } diff --git a/src/features/User/userAPI.ts b/src/features/User/userAPI.ts index 741e801..63fb28c 100644 --- a/src/features/User/userAPI.ts +++ b/src/features/User/userAPI.ts @@ -39,12 +39,14 @@ export interface UserConfigurationUpdates { notifications?: Record; timezone?: string | null; previousConfig?: Record; + alarmEmails?: boolean; } export async function updateUserConfigurations( updates: UserConfigurationUpdates ): Promise { const coreConfigs: Array<{ name: string; value: any }> = []; + const calendarConfigs: Array<{ name: string; value: any }> = []; if (updates.language !== undefined) { coreConfigs.push({ name: "language", value: updates.language }); @@ -61,17 +63,37 @@ export async function updateUserConfigurations( }, }); } + if (updates.alarmEmails !== undefined) { + calendarConfigs.push({ + name: "alarmEmails", + value: updates.alarmEmails, + }); + } - if (coreConfigs.length === 0) { + const modules: Array<{ + name: string; + configurations: Array<{ name: string; value: any }>; + }> = []; + + if (coreConfigs.length > 0) { + modules.push({ + name: "core", + configurations: coreConfigs, + }); + } + + if (calendarConfigs.length > 0) { + modules.push({ + name: "calendar", + configurations: calendarConfigs, + }); + } + + if (modules.length === 0) { return Promise.resolve({ status: 204 }); } return await api.patch(`api/configurations?scope=user`, { - json: [ - { - name: "core", - configurations: coreConfigs, - }, - ], + json: modules, }); } diff --git a/src/features/User/userSlice.ts b/src/features/User/userSlice.ts index 19758f1..e82c3e7 100644 --- a/src/features/User/userSlice.ts +++ b/src/features/User/userSlice.ts @@ -51,6 +51,7 @@ export const userSlice = createSlice({ timeZone: null as string | null, }, } as Record, + alarmEmailsEnabled: null as boolean | null, loading: true, error: null as unknown as string | null, }, @@ -82,6 +83,9 @@ export const userSlice = createSlice({ state.userData.timezone = action.payload; } }, + setAlarmEmails: (state, action) => { + state.alarmEmailsEnabled = action.payload; + }, clearError: (state) => { state.error = null; }, @@ -153,6 +157,19 @@ export const userSlice = createSlice({ } } } + + // Extract alarmEmails from configurations.modules + const calendarModule = action.payload.configurations.modules.find( + (module: any) => module.name === "calendar" + ); + if (calendarModule?.configurations) { + const alarmEmailsConfig = calendarModule.configurations.find( + (config: any) => config.name === "alarmEmails" + ); + if (alarmEmailsConfig) { + state.alarmEmailsEnabled = alarmEmailsConfig.value === true; + } + } } }) .addCase(getOpenPaasUserDataAsync.pending, (state) => { @@ -181,6 +198,9 @@ export const userSlice = createSlice({ state.userData.timezone = action.payload.timezone; } } + if (action.payload.alarmEmails !== undefined) { + state.alarmEmailsEnabled = action.payload.alarmEmails === true; + } }) .addCase(updateUserConfigurationsAsync.rejected, (state, action) => { if (action.payload?.status !== 401) { @@ -192,7 +212,13 @@ export const userSlice = createSlice({ }); // Action creators are generated for each case reducer function -export const { setUserData, setTokens, setLanguage, setTimezone, clearError } = - userSlice.actions; +export const { + setUserData, + setTokens, + setLanguage, + setTimezone, + setAlarmEmails, + clearError, +} = userSlice.actions; export default userSlice.reducer; diff --git a/src/locales/en.json b/src/locales/en.json index 3875ed0..9adb7b5 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -217,6 +217,9 @@ "timeZoneBrowserDefault": "Detect time zone automatically", "timeZoneUpdateError": "Failed to update time zone", "notifications.empty": "Notifications settings coming soon", + "notifications.email": "Email", + "notifications.deliveryMethod": "Delivery method", + "alarmEmailsUpdateError": "Failed to update email notifications setting", "sync.empty": "Sync settings coming soon", "back": "Back to calendar" }, diff --git a/src/locales/fr.json b/src/locales/fr.json index 785d9ab..17229e0 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -217,6 +217,9 @@ "timeZoneBrowserDefault": "Détection automatique du fuseau horaire", "timeZoneUpdateError": "Échec de la mise à jour du fuseau horaire", "notifications.empty": "Paramètres de notifications à venir", + "notifications.email": "Email", + "notifications.deliveryMethod": "Mode de réception", + "alarmEmailsUpdateError": "Échec de la mise à jour du mode de notification par e-mail", "sync.empty": "Paramètres de synchronisation à venir", "back": "Retour au calendrier" }, diff --git a/src/locales/ru.json b/src/locales/ru.json index 9ee5aa8..bcc2b7c 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -217,6 +217,9 @@ "timeZoneBrowserDefault": "Определять часовой пояс автоматически", "timeZoneUpdateError": "Не удалось обновить часовой пояс", "notifications.empty": "Настройки уведомлений скоро появятся", + "notifications.email": "Email", + "notifications.deliveryMethod": "Способ доставки", + "alarmEmailsUpdateError": "Не удалось обновить настройки уведомлений по email", "sync.empty": "Настройки синхронизации скоро появятся", "back": "Вернуться к календарю" }, diff --git a/src/locales/vi.json b/src/locales/vi.json index 2ac32f9..30af6a8 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -217,6 +217,9 @@ "timeZoneBrowserDefault": "Tự động phát hiện múi giờ", "timeZoneUpdateError": "Không cập nhật được múi giờ", "notifications.empty": "Cài đặt thông báo sắp có", + "notifications.email": "Email", + "notifications.deliveryMethod": "Phương thức gửi", + "alarmEmailsUpdateError": "Cập nhật cài đặt thông báo email thất bại", "sync.empty": "Cài đặt đồng bộ sắp có", "back": "Quay lại lịch" },