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",
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(<SettingsPage />, 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(<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,
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<SidebarNavItem>("settings");
const [activeSettingsSubTab, setActiveSettingsSubTab] =
useState<SettingsSubTab>("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<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 (
<main className="main-layout settings-layout">
<Box className="settings-sidebar">
@@ -291,10 +319,32 @@ export default function SettingsPage() {
)}
{activeSettingsSubTab === "notifications" && (
<Box className="settings-tab-content">
<Typography variant="body1" color="text.secondary">
{t("settings.notifications.empty") ||
"Notifications settings coming soon"}
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 3 }}
>
{t("settings.notifications.deliveryMethod") ||
"Delivery method"}
</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>
)}
</>
@@ -322,6 +372,15 @@ export default function SettingsPage() {
onClose={handleTimeZoneErrorClose}
message={t("settings.timeZoneUpdateError")}
/>
<Snackbar
open={alarmEmailsErrorOpen}
autoHideDuration={4000}
onClose={handleAlarmEmailsErrorClose}
message={
t("settings.alarmEmailsUpdateError") ||
"Failed to update email notifications setting"
}
/>
</main>
);
}
+29 -7
View File
@@ -39,12 +39,14 @@ export interface UserConfigurationUpdates {
notifications?: Record<string, unknown>;
timezone?: string | null;
previousConfig?: Record<string, any>;
alarmEmails?: boolean;
}
export async function updateUserConfigurations(
updates: UserConfigurationUpdates
): Promise<Response | { status: number }> {
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,
});
}
+28 -2
View File
@@ -51,6 +51,7 @@ export const userSlice = createSlice({
timeZone: null as string | null,
},
} as Record<string, any>,
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;
+3
View File
@@ -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"
},
+3
View File
@@ -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"
},
+3
View File
@@ -217,6 +217,9 @@
"timeZoneBrowserDefault": "Определять часовой пояс автоматически",
"timeZoneUpdateError": "Не удалось обновить часовой пояс",
"notifications.empty": "Настройки уведомлений скоро появятся",
"notifications.email": "Email",
"notifications.deliveryMethod": "Способ доставки",
"alarmEmailsUpdateError": "Не удалось обновить настройки уведомлений по email",
"sync.empty": "Настройки синхронизации скоро появятся",
"back": "Вернуться к календарю"
},
+3
View File
@@ -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"
},