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"
},