From c16e59c7faa61ba6cff5cae1744751c3c933375f Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Sat, 14 Mar 2026 06:39:27 +0100 Subject: [PATCH] 587 option hide show non working days (#613) Co-authored-by: Camille Moussu --- .../features/Settings/WorkingDays.test.tsx | 467 ++++++++++++++++++ src/components/Calendar/Calendar.tsx | 14 + src/components/Event/EventRepeat.tsx | 86 +--- src/components/Event/WeekDaySelector.tsx | 92 ++++ src/features/Settings/GeneralSettings.tsx | 339 +++++++++++++ .../Settings/NotificationSettings.tsx | 55 +++ src/features/Settings/SettingsPage.tsx | 360 ++------------ src/features/Settings/SettingsSlice.ts | 35 +- src/features/User/userAPI.ts | 23 +- src/locales/en.json | 5 +- src/locales/fr.json | 5 +- src/locales/ru.json | 5 +- src/locales/vi.json | 5 +- 13 files changed, 1087 insertions(+), 404 deletions(-) create mode 100644 __test__/features/Settings/WorkingDays.test.tsx create mode 100644 src/components/Event/WeekDaySelector.tsx create mode 100644 src/features/Settings/GeneralSettings.tsx create mode 100644 src/features/Settings/NotificationSettings.tsx diff --git a/__test__/features/Settings/WorkingDays.test.tsx b/__test__/features/Settings/WorkingDays.test.tsx new file mode 100644 index 0000000..a1a85ef --- /dev/null +++ b/__test__/features/Settings/WorkingDays.test.tsx @@ -0,0 +1,467 @@ +import SettingsPage from "@/features/Settings/SettingsPage"; +import settingsReducer from "@/features/Settings/SettingsSlice"; +import userReducer, { + getOpenPaasUserDataAsync, +} from "@/features/User/userSlice"; +import { api } from "@/utils/apiUtils"; +import { configureStore } from "@reduxjs/toolkit"; +import "@testing-library/jest-dom"; +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { renderWithProviders } from "../../utils/Renderwithproviders"; + +jest.mock("@/utils/apiUtils"); + +const basePreloadedState = { + user: { + userData: { + sub: "test", + email: "test@test.com", + family_name: "Doe", + name: "John", + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + organiserData: null, + tokens: null, + coreConfig: { + language: "en", + datetime: { timeZone: "UTC" }, + }, + alarmEmailsEnabled: false, + loading: false, + error: null, + }, + settings: { + language: "en", + timeZone: "UTC", + isBrowserDefaultTimeZone: false, + view: "settings", + businessHours: { + start: "8:0", + end: "19:0", + daysOfWeek: [1, 2, 3, 4, 5], + }, + workingDays: false, + }, +}; + +describe("Working Days and Business Hours Settings", () => { + beforeEach(() => { + jest.clearAllMocks(); + (api.patch as jest.Mock).mockResolvedValue({ status: 204 }); + }); + + describe("Initial rendering", () => { + it("renders the working days section with day selector", () => { + renderWithProviders(, basePreloadedState); + + expect( + screen.getByText("settings.chooseWorkingDays") + ).toBeInTheDocument(); + }); + + it("renders the show only working days toggle", () => { + renderWithProviders(, basePreloadedState); + + expect( + screen.getByRole("switch", { name: /settings.showOnlyWorkingDays/i }) + ).toBeInTheDocument(); + }); + + it("shows working days toggle as unchecked when workingDays is false", () => { + renderWithProviders(, basePreloadedState); + + const toggle = screen.getByRole("switch", { + name: /settings.showOnlyWorkingDays/i, + }) as HTMLInputElement; + expect(toggle.checked).toBe(false); + }); + + it("shows working days toggle as checked when workingDays is true", () => { + renderWithProviders(, { + ...basePreloadedState, + settings: { ...basePreloadedState.settings, workingDays: true }, + }); + + const toggle = screen.getByRole("switch", { + name: /settings.showOnlyWorkingDays/i, + }) as HTMLInputElement; + expect(toggle.checked).toBe(true); + }); + + it("shows working days toggle as unchecked when workingDays is null", () => { + renderWithProviders(, { + ...basePreloadedState, + settings: { ...basePreloadedState.settings, workingDays: null }, + }); + + const toggle = screen.getByRole("switch", { + name: /settings.showOnlyWorkingDays/i, + }) as HTMLInputElement; + expect(toggle.checked).toBe(false); + }); + }); + + describe("workingDays toggle", () => { + it("updates workingDays immediately (optimistic update) and calls API", async () => { + const { store } = renderWithProviders( + , + basePreloadedState + ); + + const toggle = screen.getByRole("switch", { + name: /settings.showOnlyWorkingDays/i, + }); + fireEvent.click(toggle); + + await waitFor(() => { + expect(store.getState().settings.workingDays).toBe(true); + }); + + await waitFor(() => { + expect(api.patch).toHaveBeenCalledWith( + "api/configurations?scope=user", + expect.objectContaining({ + json: expect.arrayContaining([ + expect.objectContaining({ + name: "linagora.esn.calendar", + configurations: expect.arrayContaining([ + expect.objectContaining({ + name: "workingDays", + value: true, + }), + ]), + }), + ]), + }) + ); + }); + }); + + it("rolls back workingDays if API call fails", async () => { + (api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); + + const { store } = renderWithProviders( + , + basePreloadedState + ); + + const toggle = screen.getByRole("switch", { + name: /settings.showOnlyWorkingDays/i, + }); + fireEvent.click(toggle); + + await waitFor( + () => { + expect(store.getState().settings.workingDays).toBe(false); + }, + { timeout: 3000 } + ); + + await waitFor(() => { + expect( + screen.getByText("settings.workingDaysUpdateError") + ).toBeInTheDocument(); + }); + }); + + it("toggles workingDays from true to false", async () => { + const { store } = renderWithProviders(, { + ...basePreloadedState, + settings: { ...basePreloadedState.settings, workingDays: true }, + }); + + const toggle = screen.getByRole("switch", { + name: /settings.showOnlyWorkingDays/i, + }); + fireEvent.click(toggle); + + await waitFor(() => { + expect(store.getState().settings.workingDays).toBe(false); + }); + + await waitFor(() => { + expect(api.patch).toHaveBeenCalledWith( + "api/configurations?scope=user", + expect.objectContaining({ + json: expect.arrayContaining([ + expect.objectContaining({ + name: "linagora.esn.calendar", + configurations: expect.arrayContaining([ + expect.objectContaining({ + name: "workingDays", + value: false, + }), + ]), + }), + ]), + }) + ); + }); + }); + }); + + describe("businessHours day selector", () => { + it("updates businessHours in Redux immediately when a day is clicked", async () => { + const { store } = renderWithProviders( + , + basePreloadedState + ); + + // Sunday (0) is not in initial daysOfWeek [1,2,3,4,5], click it to add + const sundayButton = screen.getByRole("button", { + name: /sunday/i, + }); + fireEvent.click(sundayButton); + + await waitFor(() => { + expect(store.getState().settings.businessHours?.daysOfWeek).toContain( + 0 + ); + }); + }); + + it("removes a day from businessHours when an already selected day is clicked", async () => { + const { store } = renderWithProviders( + , + basePreloadedState + ); + + // Monday (1) is in initial daysOfWeek, click to remove + const mondayButton = screen.getByRole("button", { name: /monday/i }); + fireEvent.click(mondayButton); + + await waitFor(() => { + expect( + store.getState().settings.businessHours?.daysOfWeek + ).not.toContain(1); + }); + }); + + it("debounces API call when multiple days are clicked rapidly", async () => { + jest.useFakeTimers(); + + renderWithProviders(, basePreloadedState); + + const sundayButton = screen.getByRole("button", { name: /sunday/i }); + const saturdayButton = screen.getByRole("button", { name: /saturday/i }); + + fireEvent.click(sundayButton); + fireEvent.click(saturdayButton); + fireEvent.click(sundayButton); + + // API should not have been called yet + expect(api.patch).not.toHaveBeenCalled(); + + // Advance timers past debounce threshold + jest.runAllTimers(); + + await waitFor(() => { + expect(api.patch).toHaveBeenCalledTimes(1); + }); + + jest.useRealTimers(); + }); + + it("sends businessHours as array to API", async () => { + jest.useFakeTimers(); + + renderWithProviders(, basePreloadedState); + + const sundayButton = screen.getByRole("button", { name: /sunday/i }); + fireEvent.click(sundayButton); + + jest.runAllTimers(); + + await waitFor(() => { + expect(api.patch).toHaveBeenCalledWith( + "api/configurations?scope=user", + expect.objectContaining({ + json: expect.arrayContaining([ + expect.objectContaining({ + name: "core", + configurations: expect.arrayContaining([ + expect.objectContaining({ + name: "businessHours", + value: expect.arrayContaining([ + expect.objectContaining({ + start: "8:0", + end: "19:0", + daysOfWeek: expect.arrayContaining([0]), + }), + ]), + }), + ]), + }), + ]), + }) + ); + }); + + jest.useRealTimers(); + }); + + it("rolls back businessHours if API call fails", async () => { + jest.useFakeTimers(); + (api.patch as jest.Mock).mockRejectedValue(new Error("API Error")); + + const { store } = renderWithProviders( + , + basePreloadedState + ); + + const sundayButton = screen.getByRole("button", { name: /sunday/i }); + fireEvent.click(sundayButton); + + jest.runAllTimers(); + + await waitFor( + () => { + expect( + store.getState().settings.businessHours?.daysOfWeek + ).not.toContain(0); + }, + { timeout: 3000 } + ); + + await waitFor(() => { + expect( + screen.getByText("settings.workingDaysUpdateError") + ).toBeInTheDocument(); + }); + + jest.useRealTimers(); + }); + }); + + describe("getOpenPaasUserDataAsync integration", () => { + it("populates businessHours from core module configurations", async () => { + const store = configureStore({ + reducer: { user: userReducer, settings: settingsReducer }, + preloadedState: { + user: { + userData: { + sub: "test", + email: "test@test.com", + family_name: "Doe", + name: "John", + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + organiserData: null, + tokens: null, + coreConfig: { language: "en", datetime: { timeZone: "UTC" } }, + alarmEmailsEnabled: null, + loading: false, + error: null, + }, + settings: { + language: "en", + timeZone: "UTC", + isBrowserDefaultTimeZone: false, + view: "calendar", + businessHours: null, + workingDays: null, + }, + }, + }); + + const mockResponse = { + id: "667037022b752d0026472254", + firstname: "John", + lastname: "Doe", + preferredEmail: "test@test.com", + configurations: { + modules: [ + { + name: "core", + configurations: [ + { + name: "businessHours", + value: [ + { start: "8:0", end: "19:0", daysOfWeek: [1, 2, 3, 4, 5] }, + ], + }, + ], + }, + { + name: "linagora.esn.calendar", + configurations: [{ name: "workingDays", value: true }], + }, + ], + }, + }; + + await store.dispatch( + getOpenPaasUserDataAsync.fulfilled(mockResponse, "", undefined) + ); + + const state = store.getState(); + expect(state.settings.businessHours).toEqual({ + start: "8:0", + end: "19:0", + daysOfWeek: [1, 2, 3, 4, 5], + }); + expect(state.settings.workingDays).toBe(true); + }); + + it("sets businessHours to null when not present in API response", async () => { + const store = configureStore({ + reducer: { user: userReducer, settings: settingsReducer }, + preloadedState: { + user: { + userData: { + sub: "test", + email: "test@test.com", + family_name: "Doe", + name: "John", + sid: "mockSid", + openpaasId: "667037022b752d0026472254", + }, + organiserData: null, + tokens: null, + coreConfig: { language: "en", datetime: { timeZone: "UTC" } }, + alarmEmailsEnabled: null, + loading: false, + error: null, + }, + settings: { + language: "en", + timeZone: "UTC", + isBrowserDefaultTimeZone: false, + view: "calendar", + businessHours: null, + workingDays: null, + }, + }, + }); + + const mockResponse = { + id: "667037022b752d0026472254", + firstname: "John", + lastname: "Doe", + preferredEmail: "test@test.com", + configurations: { + modules: [ + { + name: "core", + configurations: [], + }, + { + name: "linagora.esn.calendar", + configurations: [{ name: "workingDays", value: null }], + }, + ], + }, + }; + + await store.dispatch( + getOpenPaasUserDataAsync.fulfilled(mockResponse, "", undefined) + ); + + const state = store.getState(); + expect(state.settings.businessHours).toBeNull(); + expect(state.settings.workingDays).toBeNull(); + }); + }); +}); diff --git a/src/components/Calendar/Calendar.tsx b/src/components/Calendar/Calendar.tsx index 1e9f585..4c8b413 100644 --- a/src/components/Calendar/Calendar.tsx +++ b/src/components/Calendar/Calendar.tsx @@ -78,9 +78,21 @@ export default function CalendarApp({ const theme = useTheme(); const view = useAppSelector((state) => state.settings.view); const userData = useAppSelector((state) => state.user.userData); + const workingDays = useAppSelector( + (state) => state.settings.businessHours?.daysOfWeek + ); + const hideWorkingDays = useAppSelector((state) => state.settings.workingDays); + const hideDeclinedEvents = useAppSelector( (state) => state.settings.hideDeclinedEvents ); + const hiddenDays = useMemo(() => { + if (!hideWorkingDays || !workingDays || workingDays.length === 0) return []; + const validWorkingDays = workingDays.filter((d) => d >= 0 && d <= 6); + if (validWorkingDays.length === 0) return []; + return [0, 1, 2, 3, 4, 5, 6].filter((d) => !validWorkingDays.includes(d)); + }, [hideWorkingDays, workingDays]); + const calendars = useAppSelector((state) => state.calendars.list); const isPending = useAppSelector((state) => state.calendars.pending); const displayWeekNumbers = useAppSelector( @@ -413,6 +425,7 @@ export default function CalendarApp({ {menubarProps?.isIframe && } {view === "calendar" && ( { if (ref) { calendarRef.current = ref.getApi(); @@ -428,6 +441,7 @@ export default function CalendarApp({ firstDay={1} editable={true} locale={localeMap[lang]} + hiddenDays={hiddenDays} selectable={true} timeZone={timezone} height={"100%"} diff --git a/src/components/Event/EventRepeat.tsx b/src/components/Event/EventRepeat.tsx index fce821a..d72b40e 100644 --- a/src/components/Event/EventRepeat.tsx +++ b/src/components/Event/EventRepeat.tsx @@ -23,6 +23,7 @@ import "dayjs/locale/vi"; import { useI18n } from "twake-i18n"; import { ReadOnlyDateField } from "./components/ReadOnlyPickerField"; import { LONG_DATE_FORMAT } from "./utils/dateTimeFormatters"; +import { FC_DAYS, WeekDaySelector } from "./WeekDaySelector"; export default function RepeatEvent({ repetition, @@ -57,31 +58,6 @@ export default function RepeatEvent({ const defaultEndDate = dayjs(eventStart).add(1, "day").format("YYYY-MM-DD"); - const handleDayChange = (dayCode: string) => { - const currentDays = repetition.byday || []; - const updatedDays = currentDays.includes(dayCode) - ? currentDays.filter((d) => d !== dayCode) - : [...currentDays, dayCode]; - - setRepetition({ - ...repetition, - byday: updatedDays.length > 0 ? updatedDays : null, - }); - }; - - const getDayLabel = (day: string) => { - const dayMap: { [key: string]: string } = { - MO: t("event.repeat.days.monday"), - TU: t("event.repeat.days.tuesday"), - WE: t("event.repeat.days.wednesday"), - TH: t("event.repeat.days.thursday"), - FR: t("event.repeat.days.friday"), - SA: t("event.repeat.days.saturday"), - SU: t("event.repeat.days.sunday"), - }; - return dayMap[day] || day; - }; - return ( @@ -150,51 +126,21 @@ export default function RepeatEvent({ {/* Weekly selection */} {repetition.freq === "weekly" && ( - - {days.map((dayCode) => { - const isSelected = repetition.byday?.includes(dayCode) ?? false; - const fullLabel = getDayLabel(dayCode); - const label = fullLabel.charAt(0); - - return ( - { - if (!isOwn) return; - handleDayChange(dayCode); - }} - sx={{ - width: 40, - height: 40, - borderRadius: "4px", - border: "1px solid", - borderColor: isSelected ? "primary.main" : "#AEAEC0", - color: isSelected ? "#fff" : "#8C9CAF", - fontSize: 16, - fontWeight: 400, - lineHeight: "24px", - display: "flex", - alignItems: "center", - justifyContent: "center", - textAlign: "center", - bgcolor: isSelected ? "primary.main" : "transparent", - cursor: isOwn ? "pointer" : "default", - "&:hover": isOwn - ? { - borderColor: "primary.main", - bgcolor: "primary.main", - color: "#fff", - } - : undefined, - }} - > - {label} - - ); - })} - + FC_DAYS.find((d) => d.ics === ics)?.fc ?? -1) + .filter((d) => d !== -1)} + onChange={(fcDays) => { + const icsDays = fcDays + .map((fc) => FC_DAYS.find((d) => d.fc === fc)?.ics ?? "") + .filter(Boolean); + setRepetition({ + ...repetition, + byday: icsDays.length > 0 ? icsDays : null, + }); + }} + disabled={!isOwn} + /> )} diff --git a/src/components/Event/WeekDaySelector.tsx b/src/components/Event/WeekDaySelector.tsx new file mode 100644 index 0000000..66714c2 --- /dev/null +++ b/src/components/Event/WeekDaySelector.tsx @@ -0,0 +1,92 @@ +import { Box } from "@linagora/twake-mui"; +import { useI18n } from "twake-i18n"; + +interface WeekDaySelectorProps { + selectedDays: number[]; // FullCalendar format: 0=Sun, 1=Mon... + onChange: (days: number[]) => void; + disabled?: boolean; +} + +export const FC_DAYS = [ + { fc: 1, ics: "MO" }, + { fc: 2, ics: "TU" }, + { fc: 3, ics: "WE" }, + { fc: 4, ics: "TH" }, + { fc: 5, ics: "FR" }, + { fc: 6, ics: "SA" }, + { fc: 0, ics: "SU" }, +]; + +export function WeekDaySelector({ + selectedDays, + onChange, + disabled, +}: WeekDaySelectorProps) { + const { t } = useI18n(); + + const getDayLabel = (ics: string) => { + const dayMap: Record = { + MO: t("event.repeat.days.monday"), + TU: t("event.repeat.days.tuesday"), + WE: t("event.repeat.days.wednesday"), + TH: t("event.repeat.days.thursday"), + FR: t("event.repeat.days.friday"), + SA: t("event.repeat.days.saturday"), + SU: t("event.repeat.days.sunday"), + }; + return dayMap[ics] || ics; + }; + + const handleToggle = (fcDay: number) => { + if (disabled) return; + const updated = selectedDays.includes(fcDay) + ? selectedDays.filter((d) => d !== fcDay) + : [...selectedDays, fcDay]; + onChange(updated); + }; + + return ( + + {FC_DAYS.map(({ fc, ics }) => { + const isSelected = selectedDays.includes(fc); + const fullLabel = getDayLabel(ics); + + return ( + handleToggle(fc)} + disabled={disabled} + sx={{ + width: 40, + height: 40, + borderRadius: "4px", + border: "1px solid", + borderColor: isSelected ? "primary.main" : "#AEAEC0", + color: isSelected ? "#fff" : "#8C9CAF", + fontSize: 16, + fontWeight: 400, + display: "flex", + alignItems: "center", + justifyContent: "center", + bgcolor: isSelected ? "primary.main" : "transparent", + cursor: disabled ? "default" : "pointer", + "&:hover": !disabled + ? { + borderColor: "primary.main", + bgcolor: "primary.main", + color: "#fff", + } + : undefined, + }} + > + {fullLabel.charAt(0)} + + ); + })} + + ); +} diff --git a/src/features/Settings/GeneralSettings.tsx b/src/features/Settings/GeneralSettings.tsx new file mode 100644 index 0000000..f8a7058 --- /dev/null +++ b/src/features/Settings/GeneralSettings.tsx @@ -0,0 +1,339 @@ +import { useAppDispatch, useAppSelector } from "@/app/hooks"; +import { useTimeZoneList } from "@/components/Calendar/TimezoneSelector"; +import { WeekDaySelector } from "@/components/Event/WeekDaySelector"; +import { TimezoneAutocomplete } from "@/components/Timezone/TimezoneAutocomplete"; +import { browserDefaultTimeZone, getTimezoneOffset } from "@/utils/timezone"; +import { + Box, + FormControl, + FormControlLabel, + MenuItem, + Select, + SelectChangeEvent, + Switch, + Typography, +} from "@linagora/twake-mui"; +import { useRef, useCallback, useEffect } from "react"; +import { useI18n } from "twake-i18n"; +import { + setLanguage as setUserLanguage, + setTimezone as setUserTimeZone, + updateUserConfigurationsAsync, +} from "../User/userSlice"; +import { AVAILABLE_LANGUAGES } from "./constants"; +import { + BusinessHour, + setBusinessHours, + setDisplayWeekNumbers, + setHideDeclinedEvents, + setIsBrowserDefaultTimeZone, + setLanguage as setSettingsLanguage, + setTimeZone as setSettingsTimeZone, + setWorkingDays, +} from "./SettingsSlice"; + +interface GeneralSettingsProps { + onLanguageError: () => void; + onTimeZoneError: () => void; + onHideDeclinedEventsError: () => void; + onDisplayWeekNumbersError: () => void; + onWorkingDaysError: () => void; +} + +export function GeneralSettings({ + onLanguageError, + onTimeZoneError, + onHideDeclinedEventsError, + onDisplayWeekNumbersError, + onWorkingDaysError, +}: GeneralSettingsProps) { + const dispatch = useAppDispatch(); + const { t } = useI18n(); + + const previousConfig = useAppSelector((state) => state.user.coreConfig); + const userLanguage = useAppSelector( + (state) => state.user?.coreConfig.language + ); + const settingsLanguage = useAppSelector((state) => state.settings?.language); + const currentLanguage = userLanguage || settingsLanguage || "en"; + + const timezoneList = useTimeZoneList(); + const userTimeZone = useAppSelector( + (state) => state.user?.coreConfig?.datetime?.timeZone + ); + const settingTimeZone = useAppSelector((state) => state.settings?.timeZone); + const currentTimeZone = + userTimeZone ?? settingTimeZone ?? browserDefaultTimeZone; + const isBrowserDefault = useAppSelector( + (state) => state.settings.isBrowserDefaultTimeZone + ); + + const hideDeclinedEvents = useAppSelector( + (state) => state.settings?.hideDeclinedEvents + ); + const displayWeekNumbers = useAppSelector( + (state) => state.settings?.displayWeekNumbers + ); + const workingDays = useAppSelector((state) => state.settings.workingDays); + const businessHours = useAppSelector((state) => state.settings.businessHours); + const pendingBusinessHoursRef = useRef(null); + const businessHoursTimeoutRef = useRef | null>( + null + ); + + const handleLanguageChange = (event: SelectChangeEvent) => { + const newLanguage = event.target.value; + const previousLanguage = currentLanguage; + dispatch(setUserLanguage(newLanguage)); + dispatch(setSettingsLanguage(newLanguage)); + dispatch(updateUserConfigurationsAsync({ language: newLanguage })) + .unwrap() + .catch(() => { + dispatch(setUserLanguage(previousLanguage)); + dispatch(setSettingsLanguage(previousLanguage)); + onLanguageError(); + }); + }; + + const handleTimeZoneChange = (newTimeZone: string) => { + const previousTimeZone = currentTimeZone; + dispatch(setUserTimeZone(newTimeZone)); + dispatch(setSettingsTimeZone(newTimeZone)); + dispatch( + updateUserConfigurationsAsync({ timezone: newTimeZone, previousConfig }) + ) + .unwrap() + .catch(() => { + dispatch(setUserTimeZone(previousTimeZone)); + dispatch(setSettingsTimeZone(previousTimeZone)); + onTimeZoneError(); + }); + }; + + const handleTimeZoneDefaultChange = (isDefault: boolean) => { + const previousTimeZone = currentTimeZone; + dispatch(setIsBrowserDefaultTimeZone(isDefault)); + if (isDefault) { + dispatch(setUserTimeZone(null)); + dispatch(setSettingsTimeZone(browserDefaultTimeZone)); + dispatch( + updateUserConfigurationsAsync({ timezone: null, previousConfig }) + ) + .unwrap() + .catch(() => { + dispatch(setUserTimeZone(previousTimeZone)); + dispatch(setSettingsTimeZone(previousTimeZone)); + dispatch(setIsBrowserDefaultTimeZone(!isDefault)); + onTimeZoneError(); + }); + } + }; + + const handleHideDeclinedEvents = (value: boolean) => { + dispatch(setHideDeclinedEvents(value)); + dispatch(updateUserConfigurationsAsync({ hideDeclinedEvents: value })) + .unwrap() + .catch(() => { + dispatch(setHideDeclinedEvents(!value)); + onHideDeclinedEventsError(); + }); + }; + + const handleDisplayWeekNumbers = (value: boolean) => { + dispatch(setDisplayWeekNumbers(value)); + dispatch(updateUserConfigurationsAsync({ displayWeekNumbers: value })) + .unwrap() + .catch(() => { + dispatch(setDisplayWeekNumbers(!value)); + onDisplayWeekNumbersError(); + }); + }; + + const handleBusinessHour = useCallback( + ({ days }: { days: number[] }) => { + const previousHours = businessHours; + const value: BusinessHour | null = businessHours + ? { ...businessHours, daysOfWeek: days } + : null; + + dispatch(setBusinessHours(value)); + pendingBusinessHoursRef.current = value; + + if (businessHoursTimeoutRef.current) { + clearTimeout(businessHoursTimeoutRef.current); + } + + businessHoursTimeoutRef.current = setTimeout(() => { + dispatch( + updateUserConfigurationsAsync({ + businessHours: pendingBusinessHoursRef.current, + }) + ) + .unwrap() + .catch(() => { + dispatch(setBusinessHours(previousHours)); + onWorkingDaysError(); + }); + }, 500); + }, + [businessHours, dispatch, onWorkingDaysError] + ); + + useEffect(() => { + return () => { + if (businessHoursTimeoutRef.current) { + clearTimeout(businessHoursTimeoutRef.current); + } + }; + }, []); + + const handleWorkingDays = (value: boolean) => { + dispatch(setWorkingDays(value)); + dispatch(updateUserConfigurationsAsync({ workingDays: value })) + .unwrap() + .catch(() => { + dispatch(setWorkingDays(!value)); + onWorkingDaysError(); + }); + }; + + return ( + + {/* Language */} + + + {t("settings.language") || "Language"} + + + {t("settings.languageDescription") || + "This will be the language used in your Twake Calendar"} + + + + + + + {/* Timezone */} + + + {t("settings.timeZone")} + + + + + handleTimeZoneDefaultChange(!isBrowserDefault) + } + aria-label={t("settings.timeZoneBrowserDefault")} + /> + } + label={t("settings.timeZoneBrowserDefault")} + labelPlacement="start" + sx={{ + minWidth: 400, + justifyContent: "space-between", + marginLeft: 0, + mb: 2, + }} + /> + {!isBrowserDefault && ( + + )} + + + + + {/* Working */} + + + {t("settings.chooseWorkingDays")} + + handleBusinessHour({ days })} + /> + + handleWorkingDays(!workingDays)} + /> + } + label={t("settings.showOnlyWorkingDays")} + labelPlacement="start" + sx={{ + minWidth: 400, + justifyContent: "space-between", + marginLeft: 0, + }} + /> + + + + {/* Calendar & Events */} + + + {t("settings.calAndEvent")} + + + handleHideDeclinedEvents(!hideDeclinedEvents)} + aria-label={t("settings.showDeclinedEvent")} + /> + } + label={t("settings.showDeclinedEvent")} + labelPlacement="start" + sx={{ + minWidth: 400, + justifyContent: "space-between", + marginLeft: 0, + }} + /> + handleDisplayWeekNumbers(!displayWeekNumbers)} + aria-label={t("settings.displayWeekNumbers")} + /> + } + label={t("settings.displayWeekNumbers")} + labelPlacement="start" + sx={{ + minWidth: 400, + justifyContent: "space-between", + marginLeft: 0, + }} + /> + + + + ); +} diff --git a/src/features/Settings/NotificationSettings.tsx b/src/features/Settings/NotificationSettings.tsx new file mode 100644 index 0000000..b2bd96e --- /dev/null +++ b/src/features/Settings/NotificationSettings.tsx @@ -0,0 +1,55 @@ +import { useAppDispatch, useAppSelector } from "@/app/hooks"; +import { FormControlLabel, Switch, Typography, Box } from "@linagora/twake-mui"; +import React from "react"; +import { useI18n } from "twake-i18n"; +import { + setAlarmEmails, + updateUserConfigurationsAsync, +} from "../User/userSlice"; + +interface NotificationsSettingsProps { + onAlarmEmailsError: () => void; +} + +export function NotificationsSettings({ + onAlarmEmailsError, +}: NotificationsSettingsProps) { + const dispatch = useAppDispatch(); + const { t } = useI18n(); + const alarmEmailsEnabled = useAppSelector( + (state) => state.user?.alarmEmailsEnabled ?? true + ); + + const handleAlarmEmailsToggle = ( + event: React.ChangeEvent + ) => { + const newValue = event.target.checked; + const previousValue = alarmEmailsEnabled; + dispatch(setAlarmEmails(newValue)); + dispatch(updateUserConfigurationsAsync({ alarmEmails: newValue })) + .unwrap() + .catch(() => { + dispatch(setAlarmEmails(previousValue)); + onAlarmEmailsError(); + }); + }; + + return ( + + + {t("settings.notifications.deliveryMethod")} + + + } + label={t("settings.notifications.email")} + labelPlacement="start" + sx={{ minWidth: 400, justifyContent: "space-between", marginLeft: 0 }} + /> + + ); +} diff --git a/src/features/Settings/SettingsPage.tsx b/src/features/Settings/SettingsPage.tsx index 71f5898..e338e17 100644 --- a/src/features/Settings/SettingsPage.tsx +++ b/src/features/Settings/SettingsPage.tsx @@ -1,45 +1,24 @@ -import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { useTimeZoneList } from "@/components/Calendar/TimezoneSelector"; -import { TimezoneAutocomplete } from "@/components/Timezone/TimezoneAutocomplete"; -import { browserDefaultTimeZone, getTimezoneOffset } from "@/utils/timezone"; +import { useAppDispatch } from "@/app/hooks"; import { Box, - FormControl, - FormControlLabel, IconButton, List, ListItem, ListItemIcon, ListItemText, - MenuItem, - Select, - SelectChangeEvent, Snackbar, - Switch, Tab, Tabs, Typography, } from "@linagora/twake-mui"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import SettingsIcon from "@mui/icons-material/Settings"; -import React, { useState } from "react"; +import { useState } from "react"; import { useI18n } from "twake-i18n"; -import { - setAlarmEmails, - setLanguage as setUserLanguage, - setTimezone as setUserTimeZone, - updateUserConfigurationsAsync, -} from "../User/userSlice"; -import { AVAILABLE_LANGUAGES } from "./constants"; +import { GeneralSettings } from "./GeneralSettings"; +import { NotificationsSettings } from "./NotificationSettings"; import "./SettingsPage.styl"; -import { - setDisplayWeekNumbers, - setHideDeclinedEvents, - setIsBrowserDefaultTimeZone, - setLanguage as setSettingsLanguage, - setTimeZone as setSettingsTimeZone, - setView, -} from "./SettingsSlice"; +import { setView } from "./SettingsSlice"; type SidebarNavItem = "settings" | "sync"; type SettingsSubTab = "settings" | "notifications"; @@ -47,39 +26,12 @@ type SettingsSubTab = "settings" | "notifications"; export default function SettingsPage({ isInIframe }: { isInIframe?: boolean }) { const dispatch = useAppDispatch(); const { t } = useI18n(); - const previousConfig = useAppSelector((state) => state.user.coreConfig); - const userLanguage = useAppSelector( - (state) => state.user?.coreConfig.language - ); - const settingsLanguage = useAppSelector((state) => state.settings?.language); - const currentLanguage = userLanguage || settingsLanguage || "en"; - - const timezoneList = useTimeZoneList(); - const userTimeZone = useAppSelector( - (state) => state.user?.coreConfig?.datetime?.timeZone - ); - const settingTimeZone = useAppSelector((state) => state.settings?.timeZone); - const currentTimeZone = - userTimeZone ?? settingTimeZone ?? browserDefaultTimeZone; - const isBrowserDefault = useAppSelector( - (state) => state.settings.isBrowserDefaultTimeZone - ); - const alarmEmailsEnabled = useAppSelector( - (state) => state.user?.alarmEmailsEnabled ?? true - ); - - const hideDeclinedEvents = useAppSelector( - (state) => state.settings?.hideDeclinedEvents - ); - - const displayWeekNumbers = useAppSelector( - (state) => state.settings?.displayWeekNumbers - ); 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); @@ -87,6 +39,8 @@ export default function SettingsPage({ isInIframe }: { isInIframe?: boolean }) { useState(false); const [displayWeekNumbersErrorOpen, setDisplayWeekNumbersErrorOpen] = useState(false); + const [workingDaysErrorOpen, setWorkingDaysErrorOpen] = useState(false); + const handleBackClick = () => { dispatch(setView("calendar")); }; @@ -104,143 +58,18 @@ export default function SettingsPage({ isInIframe }: { isInIframe?: boolean }) { ) => { setActiveSettingsSubTab(newValue); }; - - const handleLanguageChange = (event: SelectChangeEvent) => { - const newLanguage = event.target.value; - const previousLanguage = currentLanguage; - - // Optimistic update - update UI immediately - dispatch(setUserLanguage(newLanguage)); - dispatch(setSettingsLanguage(newLanguage)); - - // Call API in background, don't wait for it - dispatch(updateUserConfigurationsAsync({ language: newLanguage })) - .unwrap() - .catch((error) => { - console.error("Failed to update language:", error); - // Rollback on error - dispatch(setUserLanguage(previousLanguage)); - dispatch(setSettingsLanguage(previousLanguage)); - setLanguageErrorOpen(true); - }); - }; - const handleLanguageErrorClose = () => { setLanguageErrorOpen(false); }; - - const handleTimeZoneChange = (newTimeZone: string) => { - const previousTimeZone = currentTimeZone; - - // Optimistic update - update UI immediately - dispatch(setUserTimeZone(newTimeZone)); - dispatch(setSettingsTimeZone(newTimeZone)); - - // Call API in background, don't wait for it - dispatch( - updateUserConfigurationsAsync({ timezone: newTimeZone, previousConfig }) - ) - .unwrap() - .catch((error) => { - console.error("Failed to update TimeZone:", error); - // Rollback on error - dispatch(setUserTimeZone(previousTimeZone)); - dispatch(setSettingsTimeZone(previousTimeZone)); - setTimeZoneErrorOpen(true); - }); - }; - - const handleTimeZoneDefaultChange = (isDefault: boolean) => { - const previousTimeZone = currentTimeZone; - - // Optimistic update - update UI immediately - dispatch(setIsBrowserDefaultTimeZone(isDefault)); - if (isDefault) { - dispatch(setUserTimeZone(null)); - dispatch(setSettingsTimeZone(browserDefaultTimeZone)); - - // Call API in background, don't wait for it - dispatch( - updateUserConfigurationsAsync({ timezone: null, previousConfig }) - ) - .unwrap() - .catch((error) => { - console.error("Failed to update TimeZone:", error); - // Rollback on error - dispatch(setUserTimeZone(previousTimeZone)); - dispatch(setSettingsTimeZone(previousTimeZone)); - dispatch(setIsBrowserDefaultTimeZone(!isDefault)); - setTimeZoneErrorOpen(true); - }); - } - }; - const handleTimeZoneErrorClose = () => { setTimeZoneErrorOpen(false); }; - - const handleHideDeclinedEvents = (doHideDeclinedEvents: boolean) => { - // Optimistic update - update UI immediately - dispatch(setHideDeclinedEvents(doHideDeclinedEvents)); - - // Call API in background, don't wait for it - dispatch( - updateUserConfigurationsAsync({ - hideDeclinedEvents: doHideDeclinedEvents, - }) - ) - .unwrap() - .catch((error) => { - console.error("Failed to update hide declined event:", error); - dispatch(setHideDeclinedEvents(!doHideDeclinedEvents)); - setHideDeclinedEventsErrorOpen(true); - }); - }; const handleHideDeclinedEventsErrorClose = () => { setHideDeclinedEventsErrorOpen(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); }; - - const handleDisplayWeekNumbers = (doDisplayWeekNumbers: boolean) => { - // Optimistic update - update UI immediately - dispatch(setDisplayWeekNumbers(doDisplayWeekNumbers)); - - // Call API in background, don't wait for it - dispatch( - updateUserConfigurationsAsync({ - displayWeekNumbers: doDisplayWeekNumbers, - }) - ) - .unwrap() - .catch((error) => { - console.error("Failed to update the week number setting:", error); - dispatch(setDisplayWeekNumbers(!doDisplayWeekNumbers)); - setDisplayWeekNumbersErrorOpen(true); - }); - }; const handleDisplayWeekNumbersErrorClose = () => { setDisplayWeekNumbersErrorOpen(false); }; @@ -300,157 +129,33 @@ export default function SettingsPage({ isInIframe }: { isInIframe?: boolean }) { {activeNavItem === "settings" && ( <> {activeSettingsSubTab === "settings" && ( - - - - {t("settings.language") || "Language"} - - - {t("settings.languageDescription") || - "This will be the language used in your Twake Calendar"} - - - - - - - - {t("settings.timeZone")} - - - - - handleTimeZoneDefaultChange(!isBrowserDefault) - } - aria-label={t("settings.timeZoneBrowserDefault")} - /> - } - label={t("settings.timeZoneBrowserDefault")} - labelPlacement="start" - sx={{ - minWidth: 400, - justifyContent: "space-between", - marginLeft: 0, - mb: 2, - }} - /> - {!isBrowserDefault && ( - - )} - - - - - - {t("settings.calAndEvent")} - - - - handleHideDeclinedEvents(!hideDeclinedEvents) - } - aria-label={t("settings.showDeclinedEvent")} - /> - } - label={t("settings.showDeclinedEvent")} - labelPlacement="start" - sx={{ - minWidth: 400, - justifyContent: "space-between", - marginLeft: 0, - }} - /> - - handleDisplayWeekNumbers(!displayWeekNumbers) - } - aria-label={t("settings.displayWeekNumbers")} - /> - } - label={t("settings.displayWeekNumbers")} - labelPlacement="start" - sx={{ - minWidth: 400, - justifyContent: "space-between", - marginLeft: 0, - }} - /> - - - + setLanguageErrorOpen(true)} + onTimeZoneError={() => setTimeZoneErrorOpen(true)} + onHideDeclinedEventsError={() => + setHideDeclinedEventsErrorOpen(true) + } + onDisplayWeekNumbersError={() => + setDisplayWeekNumbersErrorOpen(true) + } + onWorkingDaysError={() => setWorkingDaysErrorOpen(true)} + /> )} {activeSettingsSubTab === "notifications" && ( - - - {t("settings.notifications.deliveryMethod") || - "Delivery method"} - - - } - label={t("settings.notifications.email") || "Email"} - labelPlacement="start" - sx={{ - minWidth: 400, - justifyContent: "space-between", - marginLeft: 0, - }} - /> - + setAlarmEmailsErrorOpen(true)} + /> )} )} - {activeNavItem === "sync" && ( - - - {t("settings.sync.empty") || "Sync settings coming soon"} - - - )} + {activeNavItem === "sync" && ( + + + {t("settings.sync.empty") || "Sync settings coming soon"} + + + )} - + setWorkingDaysErrorOpen(false)} + message={t("settings.workingDaysUpdateError")} + /> ); } diff --git a/src/features/Settings/SettingsSlice.ts b/src/features/Settings/SettingsSlice.ts index 6341ea9..75e2c83 100644 --- a/src/features/Settings/SettingsSlice.ts +++ b/src/features/Settings/SettingsSlice.ts @@ -10,8 +10,14 @@ export interface SettingsState { hideDeclinedEvents: boolean | null; displayWeekNumbers: boolean; view: "calendar" | "settings" | "search"; + businessHours: BusinessHour | null; + workingDays: boolean | null; +} +export interface BusinessHour { + start: string; + end: string; + daysOfWeek: number[]; } - const savedLang = localStorage.getItem("lang"); const defaultLang = savedLang ?? window.LANG ?? "en"; @@ -27,6 +33,8 @@ const initialState: SettingsState = { hideDeclinedEvents: null, displayWeekNumbers: true, view: "calendar", + businessHours: null, + workingDays: null, }; export const settingsSlice = createSlice({ @@ -56,6 +64,12 @@ export const settingsSlice = createSlice({ ) => { state.view = action.payload; }, + setBusinessHours: (state, action: PayloadAction) => { + state.businessHours = action.payload ?? null; + }, + setWorkingDays: (state, action: PayloadAction) => { + state.workingDays = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(getOpenPaasUserDataAsync.fulfilled, (state, action) => { @@ -90,6 +104,23 @@ export const settingsSlice = createSlice({ ? hideDeclinedEventsConfig.value : null; + const businessHoursConfig = coreModule?.configurations?.find( + (config: ConfigurationItem) => config.name === "businessHours" + ); + state.businessHours = + Array.isArray(businessHoursConfig?.value) && + businessHoursConfig?.value?.[0] + ? businessHoursConfig.value[0] + : null; + + // From esnCalendarModule (alongside hideDeclinedEvents) + const workingDaysConfig = esnCalendarModule?.configurations?.find( + (config: ConfigurationItem) => config.name === "workingDays" + ); + state.workingDays = + typeof workingDaysConfig?.value === "boolean" + ? workingDaysConfig.value + : null; const calendarModule = action.payload.configurations?.modules?.find( (module: ModuleConfiguration) => module.name === "calendar" ); @@ -112,5 +143,7 @@ export const { setIsBrowserDefaultTimeZone, setHideDeclinedEvents, setDisplayWeekNumbers, + setBusinessHours, + setWorkingDays, } = settingsSlice.actions; export default settingsSlice.reducer; diff --git a/src/features/User/userAPI.ts b/src/features/User/userAPI.ts index 6e649c4..f6ed783 100644 --- a/src/features/User/userAPI.ts +++ b/src/features/User/userAPI.ts @@ -1,5 +1,6 @@ import { User } from "@/components/Attendees/PeopleSearch"; import { api } from "@/utils/apiUtils"; +import { BusinessHour } from "../Settings/SettingsSlice"; import { OpenPaasUserData } from "./type/OpenPaasUserData"; import { ConfigurationItem, @@ -49,6 +50,8 @@ export interface UserConfigurationUpdates { previousConfig?: Record; alarmEmails?: boolean; hideDeclinedEvents?: boolean; + workingDays?: boolean; + businessHours?: BusinessHour | null; } export async function updateUserConfigurations( @@ -76,22 +79,34 @@ export async function updateUserConfigurations( }, }); } + if (updates.businessHours !== undefined) { + coreConfigs.push({ + name: "businessHours", + value: updates.businessHours ? [updates.businessHours] : [], + }); + } if (updates.alarmEmails !== undefined) { calendarConfigs.push({ name: "alarmEmails", value: updates.alarmEmails, }); } + if (updates.displayWeekNumbers !== undefined) { + calendarConfigs.push({ + name: "displayWeekNumbers", + value: updates.displayWeekNumbers, + }); + } if (updates.hideDeclinedEvents !== undefined) { esnCalendarConfigs.push({ name: "hideDeclinedEvents", value: updates.hideDeclinedEvents, }); } - if (updates.displayWeekNumbers !== undefined) { - calendarConfigs.push({ - name: "displayWeekNumbers", - value: updates.displayWeekNumbers, + if (updates.workingDays !== undefined) { + esnCalendarConfigs.push({ + name: "workingDays", + value: updates.workingDays, }); } diff --git a/src/locales/en.json b/src/locales/en.json index 8111161..38c9027 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -257,7 +257,10 @@ "calAndEvent": "Calendar and events", "showDeclinedEvent": "Show declined events", "hideDeclinedEventsUpdateError": "Failed to update hide declined event.", - "back": "Back to calendar" + "back": "Back to calendar", + "chooseWorkingDays": "Choose your working days", + "showOnlyWorkingDays": "Show only working days", + "workingDaysUpdateError": "Failed to update working days setting" }, "eventPreview": { "emailAttendees": "Email attendees", diff --git a/src/locales/fr.json b/src/locales/fr.json index faab80c..9bbe057 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -258,7 +258,10 @@ "calAndEvent": "Calendrier et événements", "showDeclinedEvent": "Afficher les événements déclinés", "hideDeclinedEventsUpdateError": "Échec de la mise à jour du masquage des événements déclinés.", - "back": "Retour au calendrier" + "back": "Retour au calendrier", + "chooseWorkingDays": "Choisissez vos jours ouvrés", + "showOnlyWorkingDays": "Afficher uniquement les jours sélectionnés", + "workingDaysUpdateError": "Échec de la mise à jour des jours ouvrés" }, "eventPreview": { "emailAttendees": "Envoyer un e-mail aux participants", diff --git a/src/locales/ru.json b/src/locales/ru.json index 69b86f6..ef63987 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -258,7 +258,10 @@ "calAndEvent": "Календарь и мероприятия", "showDeclinedEvent": "Показать отклонённые мероприятия", "hideDeclinedEventsUpdateError": "Не удалось обновить скрытие отклонённых событий.", - "back": "Вернуться к календарю" + "back": "Вернуться к календарю", + "chooseWorkingDays": "Выберите ваши рабочие дни", + "showOnlyWorkingDays": "Показывать только рабочие дни", + "workingDaysUpdateError": "Не удалось обновить настройку рабочих дней" }, "eventPreview": { "emailAttendees": "Написать участникам", diff --git a/src/locales/vi.json b/src/locales/vi.json index 2a92d53..1fb7a2e 100644 --- a/src/locales/vi.json +++ b/src/locales/vi.json @@ -256,7 +256,10 @@ "calAndEvent": "Lịch và sự kiện", "showDeclinedEvent": "Hiển thị các sự kiện đã từ chối", "hideDeclinedEventsUpdateError": "Không cập nhật được các sự kiện ẩn bị từ chối.", - "back": "Quay lại lịch" + "back": "Quay lại lịch", + "chooseWorkingDays": "Chọn ngày làm việc của bạn", + "showOnlyWorkingDays": "Chỉ hiển thị ngày làm việc", + "workingDaysUpdateError": "Không thể cập nhật cài đặt ngày làm việc" }, "eventPreview": { "emailAttendees": "Gửi email cho người tham gia",