feat: #339 Add email notification toggle setting and update tests (#387)

This commit is contained in:
lenhanphung
2025-12-08 16:54:24 +07:00
committed by GitHub
parent 1056220719
commit bd33a93040
8 changed files with 316 additions and 17 deletions
@@ -28,6 +28,7 @@ describe("SettingsPage", () => {
language: "en", language: "en",
datetime: { timeZone: "UTC" }, datetime: { timeZone: "UTC" },
}, },
alarmEmailsEnabled: false,
loading: false, loading: false,
error: null, error: null,
}, },
@@ -90,7 +91,7 @@ describe("SettingsPage", () => {
fireEvent.click(notificationsTab); fireEvent.click(notificationsTab);
expect( expect(
screen.getByText(/settings.notifications.empty/i) screen.getByText(/settings.notifications.deliveryMethod/i)
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
@@ -253,7 +254,7 @@ describe("SettingsPage", () => {
); );
}); });
it("shows empty state in Notifications tab", () => { it("shows Email toggle in Notifications tab", () => {
renderWithProviders(<SettingsPage />, preloadedState); renderWithProviders(<SettingsPage />, preloadedState);
const notificationsTab = screen.getByRole("tab", { const notificationsTab = screen.getByRole("tab", {
@@ -262,7 +263,10 @@ describe("SettingsPage", () => {
fireEvent.click(notificationsTab); fireEvent.click(notificationsTab);
expect( expect(
screen.getByText("settings.notifications.empty") screen.getByText(/settings.notifications.deliveryMethod/i)
).toBeInTheDocument();
expect(
screen.getByRole("switch", { name: /settings.notifications.email/i })
).toBeInTheDocument(); ).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(<SettingsPage />, 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(<SettingsPage />, 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(<SettingsPage />, 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(<SettingsPage />, 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(
<SettingsPage />,
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,
}),
]),
}),
]),
})
);
});
});
});
}); });
}); });
+64 -5
View File
@@ -13,8 +13,8 @@ import {
MenuItem, MenuItem,
Typography, Typography,
Snackbar, Snackbar,
FormControlLabel,
Switch, Switch,
FormControlLabel,
} from "@mui/material"; } from "@mui/material";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
@@ -30,6 +30,7 @@ import {
updateUserConfigurationsAsync, updateUserConfigurationsAsync,
setLanguage as setUserLanguage, setLanguage as setUserLanguage,
setTimezone as setUserTimeZone, setTimezone as setUserTimeZone,
setAlarmEmails,
} from "../User/userSlice"; } from "../User/userSlice";
import { AVAILABLE_LANGUAGES } from "./constants"; import { AVAILABLE_LANGUAGES } from "./constants";
import { useI18n } from "cozy-ui/transpiled/react/providers/I18n"; import { useI18n } from "cozy-ui/transpiled/react/providers/I18n";
@@ -64,13 +65,16 @@ export default function SettingsPage() {
const isBrowserDefault = useAppSelector( const isBrowserDefault = useAppSelector(
(state) => state.settings.isBrowserDefaultTimeZone (state) => state.settings.isBrowserDefaultTimeZone
); );
const alarmEmailsEnabled = useAppSelector(
(state) => state.user?.alarmEmailsEnabled ?? true
);
const [activeNavItem, setActiveNavItem] = const [activeNavItem, setActiveNavItem] =
useState<SidebarNavItem>("settings"); useState<SidebarNavItem>("settings");
const [activeSettingsSubTab, setActiveSettingsSubTab] = const [activeSettingsSubTab, setActiveSettingsSubTab] =
useState<SettingsSubTab>("settings"); useState<SettingsSubTab>("settings");
const [languageErrorOpen, setLanguageErrorOpen] = useState(false); const [languageErrorOpen, setLanguageErrorOpen] = useState(false);
const [timeZoneErrorOpen, setTimeZoneErrorOpen] = useState(false); const [timeZoneErrorOpen, setTimeZoneErrorOpen] = useState(false);
const [alarmEmailsErrorOpen, setAlarmEmailsErrorOpen] = useState(false);
const handleBackClick = () => { const handleBackClick = () => {
dispatch(setView("calendar")); dispatch(setView("calendar"));
@@ -164,6 +168,30 @@ export default function SettingsPage() {
setTimeZoneErrorOpen(false); setTimeZoneErrorOpen(false);
}; };
const handleAlarmEmailsToggle = (
event: React.ChangeEvent<HTMLInputElement>
) => {
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 ( return (
<main className="main-layout settings-layout"> <main className="main-layout settings-layout">
<Box className="settings-sidebar"> <Box className="settings-sidebar">
@@ -291,10 +319,32 @@ export default function SettingsPage() {
)} )}
{activeSettingsSubTab === "notifications" && ( {activeSettingsSubTab === "notifications" && (
<Box className="settings-tab-content"> <Box className="settings-tab-content">
<Typography variant="body1" color="text.secondary"> <Typography
{t("settings.notifications.empty") || variant="body2"
"Notifications settings coming soon"} color="text.secondary"
sx={{ mb: 3 }}
>
{t("settings.notifications.deliveryMethod") ||
"Delivery method"}
</Typography> </Typography>
<FormControlLabel
control={
<Switch
checked={alarmEmailsEnabled}
onChange={handleAlarmEmailsToggle}
aria-label={
t("settings.notifications.email") || "Email"
}
/>
}
label={t("settings.notifications.email") || "Email"}
labelPlacement="start"
sx={{
minWidth: 400,
justifyContent: "space-between",
marginLeft: 0,
}}
/>
</Box> </Box>
)} )}
</> </>
@@ -322,6 +372,15 @@ export default function SettingsPage() {
onClose={handleTimeZoneErrorClose} onClose={handleTimeZoneErrorClose}
message={t("settings.timeZoneUpdateError")} message={t("settings.timeZoneUpdateError")}
/> />
<Snackbar
open={alarmEmailsErrorOpen}
autoHideDuration={4000}
onClose={handleAlarmEmailsErrorClose}
message={
t("settings.alarmEmailsUpdateError") ||
"Failed to update email notifications setting"
}
/>
</main> </main>
); );
} }
+29 -7
View File
@@ -39,12 +39,14 @@ export interface UserConfigurationUpdates {
notifications?: Record<string, unknown>; notifications?: Record<string, unknown>;
timezone?: string | null; timezone?: string | null;
previousConfig?: Record<string, any>; previousConfig?: Record<string, any>;
alarmEmails?: boolean;
} }
export async function updateUserConfigurations( export async function updateUserConfigurations(
updates: UserConfigurationUpdates updates: UserConfigurationUpdates
): Promise<Response | { status: number }> { ): Promise<Response | { status: number }> {
const coreConfigs: Array<{ name: string; value: any }> = []; const coreConfigs: Array<{ name: string; value: any }> = [];
const calendarConfigs: Array<{ name: string; value: any }> = [];
if (updates.language !== undefined) { if (updates.language !== undefined) {
coreConfigs.push({ name: "language", value: updates.language }); 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 Promise.resolve({ status: 204 });
} }
return await api.patch(`api/configurations?scope=user`, { return await api.patch(`api/configurations?scope=user`, {
json: [ json: modules,
{
name: "core",
configurations: coreConfigs,
},
],
}); });
} }
+28 -2
View File
@@ -51,6 +51,7 @@ export const userSlice = createSlice({
timeZone: null as string | null, timeZone: null as string | null,
}, },
} as Record<string, any>, } as Record<string, any>,
alarmEmailsEnabled: null as boolean | null,
loading: true, loading: true,
error: null as unknown as string | null, error: null as unknown as string | null,
}, },
@@ -82,6 +83,9 @@ export const userSlice = createSlice({
state.userData.timezone = action.payload; state.userData.timezone = action.payload;
} }
}, },
setAlarmEmails: (state, action) => {
state.alarmEmailsEnabled = action.payload;
},
clearError: (state) => { clearError: (state) => {
state.error = null; 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) => { .addCase(getOpenPaasUserDataAsync.pending, (state) => {
@@ -181,6 +198,9 @@ export const userSlice = createSlice({
state.userData.timezone = action.payload.timezone; state.userData.timezone = action.payload.timezone;
} }
} }
if (action.payload.alarmEmails !== undefined) {
state.alarmEmailsEnabled = action.payload.alarmEmails === true;
}
}) })
.addCase(updateUserConfigurationsAsync.rejected, (state, action) => { .addCase(updateUserConfigurationsAsync.rejected, (state, action) => {
if (action.payload?.status !== 401) { if (action.payload?.status !== 401) {
@@ -192,7 +212,13 @@ export const userSlice = createSlice({
}); });
// Action creators are generated for each case reducer function // Action creators are generated for each case reducer function
export const { setUserData, setTokens, setLanguage, setTimezone, clearError } = export const {
userSlice.actions; setUserData,
setTokens,
setLanguage,
setTimezone,
setAlarmEmails,
clearError,
} = userSlice.actions;
export default userSlice.reducer; export default userSlice.reducer;
+3
View File
@@ -217,6 +217,9 @@
"timeZoneBrowserDefault": "Detect time zone automatically", "timeZoneBrowserDefault": "Detect time zone automatically",
"timeZoneUpdateError": "Failed to update time zone", "timeZoneUpdateError": "Failed to update time zone",
"notifications.empty": "Notifications settings coming soon", "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", "sync.empty": "Sync settings coming soon",
"back": "Back to calendar" "back": "Back to calendar"
}, },
+3
View File
@@ -217,6 +217,9 @@
"timeZoneBrowserDefault": "Détection automatique du fuseau horaire", "timeZoneBrowserDefault": "Détection automatique du fuseau horaire",
"timeZoneUpdateError": "Échec de la mise à jour du fuseau horaire", "timeZoneUpdateError": "Échec de la mise à jour du fuseau horaire",
"notifications.empty": "Paramètres de notifications à venir", "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", "sync.empty": "Paramètres de synchronisation à venir",
"back": "Retour au calendrier" "back": "Retour au calendrier"
}, },
+3
View File
@@ -217,6 +217,9 @@
"timeZoneBrowserDefault": "Определять часовой пояс автоматически", "timeZoneBrowserDefault": "Определять часовой пояс автоматически",
"timeZoneUpdateError": "Не удалось обновить часовой пояс", "timeZoneUpdateError": "Не удалось обновить часовой пояс",
"notifications.empty": "Настройки уведомлений скоро появятся", "notifications.empty": "Настройки уведомлений скоро появятся",
"notifications.email": "Email",
"notifications.deliveryMethod": "Способ доставки",
"alarmEmailsUpdateError": "Не удалось обновить настройки уведомлений по email",
"sync.empty": "Настройки синхронизации скоро появятся", "sync.empty": "Настройки синхронизации скоро появятся",
"back": "Вернуться к календарю" "back": "Вернуться к календарю"
}, },
+3
View File
@@ -217,6 +217,9 @@
"timeZoneBrowserDefault": "Tự động phát hiện múi giờ", "timeZoneBrowserDefault": "Tự động phát hiện múi giờ",
"timeZoneUpdateError": "Không cập nhật được 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.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ó", "sync.empty": "Cài đặt đồng bộ sắp có",
"back": "Quay lại lịch" "back": "Quay lại lịch"
}, },