This commit is contained in:
@@ -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,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -217,6 +217,9 @@
|
||||
"timeZoneBrowserDefault": "Определять часовой пояс автоматически",
|
||||
"timeZoneUpdateError": "Не удалось обновить часовой пояс",
|
||||
"notifications.empty": "Настройки уведомлений скоро появятся",
|
||||
"notifications.email": "Email",
|
||||
"notifications.deliveryMethod": "Способ доставки",
|
||||
"alarmEmailsUpdateError": "Не удалось обновить настройки уведомлений по email",
|
||||
"sync.empty": "Настройки синхронизации скоро появятся",
|
||||
"back": "Вернуться к календарю"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user