587 option hide show non working days (#613)
Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -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(<SettingsPage />, basePreloadedState);
|
||||
|
||||
expect(
|
||||
screen.getByText("settings.chooseWorkingDays")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the show only working days toggle", () => {
|
||||
renderWithProviders(<SettingsPage />, basePreloadedState);
|
||||
|
||||
expect(
|
||||
screen.getByRole("switch", { name: /settings.showOnlyWorkingDays/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows working days toggle as unchecked when workingDays is false", () => {
|
||||
renderWithProviders(<SettingsPage />, 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(<SettingsPage />, {
|
||||
...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(<SettingsPage />, {
|
||||
...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(
|
||||
<SettingsPage />,
|
||||
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(
|
||||
<SettingsPage />,
|
||||
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(<SettingsPage />, {
|
||||
...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(
|
||||
<SettingsPage />,
|
||||
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(
|
||||
<SettingsPage />,
|
||||
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(<SettingsPage />, 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(<SettingsPage />, 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(
|
||||
<SettingsPage />,
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 && <Menubar {...menubarProps} />}
|
||||
{view === "calendar" && (
|
||||
<FullCalendar
|
||||
key={hiddenDays.join(",")}
|
||||
ref={(ref) => {
|
||||
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%"}
|
||||
|
||||
@@ -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 (
|
||||
<Box>
|
||||
<Stack>
|
||||
@@ -150,51 +126,21 @@ export default function RepeatEvent({
|
||||
{/* Weekly selection */}
|
||||
{repetition.freq === "weekly" && (
|
||||
<Box mb={2}>
|
||||
<Box display="flex" gap={1}>
|
||||
{days.map((dayCode) => {
|
||||
const isSelected = repetition.byday?.includes(dayCode) ?? false;
|
||||
const fullLabel = getDayLabel(dayCode);
|
||||
const label = fullLabel.charAt(0);
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={dayCode}
|
||||
role="button"
|
||||
aria-label={fullLabel}
|
||||
onClick={() => {
|
||||
if (!isOwn) return;
|
||||
handleDayChange(dayCode);
|
||||
<WeekDaySelector
|
||||
selectedDays={(repetition.byday ?? [])
|
||||
.map((ics) => 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,
|
||||
});
|
||||
}}
|
||||
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}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
disabled={!isOwn}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<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[ics] || ics;
|
||||
};
|
||||
|
||||
const handleToggle = (fcDay: number) => {
|
||||
if (disabled) return;
|
||||
const updated = selectedDays.includes(fcDay)
|
||||
? selectedDays.filter((d) => d !== fcDay)
|
||||
: [...selectedDays, fcDay];
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box display="flex" gap={1}>
|
||||
{FC_DAYS.map(({ fc, ics }) => {
|
||||
const isSelected = selectedDays.includes(fc);
|
||||
const fullLabel = getDayLabel(ics);
|
||||
|
||||
return (
|
||||
<Box
|
||||
component="button"
|
||||
type="button"
|
||||
key={ics}
|
||||
aria-label={fullLabel}
|
||||
aria-pressed={isSelected}
|
||||
onClick={() => 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)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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<BusinessHour | null>(null);
|
||||
const businessHoursTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const handleLanguageChange = (event: SelectChangeEvent<string>) => {
|
||||
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 (
|
||||
<Box className="settings-tab-content">
|
||||
{/* Language */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("settings.language") || "Language"}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
{t("settings.languageDescription") ||
|
||||
"This will be the language used in your Twake Calendar"}
|
||||
</Typography>
|
||||
<FormControl size="small" sx={{ minWidth: 500 }}>
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onChange={handleLanguageChange}
|
||||
variant="outlined"
|
||||
aria-label={t("settings.languageSelector") || "Language selector"}
|
||||
>
|
||||
{AVAILABLE_LANGUAGES.map(({ code, label }) => (
|
||||
<MenuItem key={code} value={code}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* Timezone */}
|
||||
<Box
|
||||
sx={{
|
||||
mb: 4,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("settings.timeZone")}
|
||||
</Typography>
|
||||
<Box>
|
||||
<FormControl size="small" sx={{ minWidth: 500 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={isBrowserDefault}
|
||||
onChange={() =>
|
||||
handleTimeZoneDefaultChange(!isBrowserDefault)
|
||||
}
|
||||
aria-label={t("settings.timeZoneBrowserDefault")}
|
||||
/>
|
||||
}
|
||||
label={t("settings.timeZoneBrowserDefault")}
|
||||
labelPlacement="start"
|
||||
sx={{
|
||||
minWidth: 400,
|
||||
justifyContent: "space-between",
|
||||
marginLeft: 0,
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
{!isBrowserDefault && (
|
||||
<TimezoneAutocomplete
|
||||
value={currentTimeZone}
|
||||
zones={timezoneList.zones}
|
||||
getTimezoneOffset={getTimezoneOffset}
|
||||
onChange={handleTimeZoneChange}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Working */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("settings.chooseWorkingDays")}
|
||||
</Typography>
|
||||
<WeekDaySelector
|
||||
selectedDays={businessHours?.daysOfWeek ?? []}
|
||||
onChange={(days) => handleBusinessHour({ days })}
|
||||
/>
|
||||
<FormControl size="small" sx={{ minWidth: 500, mt: 2 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={Boolean(workingDays)}
|
||||
onChange={() => handleWorkingDays(!workingDays)}
|
||||
/>
|
||||
}
|
||||
label={t("settings.showOnlyWorkingDays")}
|
||||
labelPlacement="start"
|
||||
sx={{
|
||||
minWidth: 400,
|
||||
justifyContent: "space-between",
|
||||
marginLeft: 0,
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
{/* Calendar & Events */}
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("settings.calAndEvent")}
|
||||
</Typography>
|
||||
<FormControl size="small" sx={{ minWidth: 500 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={Boolean(!hideDeclinedEvents)}
|
||||
onChange={() => handleHideDeclinedEvents(!hideDeclinedEvents)}
|
||||
aria-label={t("settings.showDeclinedEvent")}
|
||||
/>
|
||||
}
|
||||
label={t("settings.showDeclinedEvent")}
|
||||
labelPlacement="start"
|
||||
sx={{
|
||||
minWidth: 400,
|
||||
justifyContent: "space-between",
|
||||
marginLeft: 0,
|
||||
}}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={Boolean(displayWeekNumbers)}
|
||||
onChange={() => handleDisplayWeekNumbers(!displayWeekNumbers)}
|
||||
aria-label={t("settings.displayWeekNumbers")}
|
||||
/>
|
||||
}
|
||||
label={t("settings.displayWeekNumbers")}
|
||||
labelPlacement="start"
|
||||
sx={{
|
||||
minWidth: 400,
|
||||
justifyContent: "space-between",
|
||||
marginLeft: 0,
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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<HTMLInputElement>
|
||||
) => {
|
||||
const newValue = event.target.checked;
|
||||
const previousValue = alarmEmailsEnabled;
|
||||
dispatch(setAlarmEmails(newValue));
|
||||
dispatch(updateUserConfigurationsAsync({ alarmEmails: newValue }))
|
||||
.unwrap()
|
||||
.catch(() => {
|
||||
dispatch(setAlarmEmails(previousValue));
|
||||
onAlarmEmailsError();
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box className="settings-tab-content">
|
||||
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||
{t("settings.notifications.deliveryMethod")}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={alarmEmailsEnabled}
|
||||
onChange={handleAlarmEmailsToggle}
|
||||
/>
|
||||
}
|
||||
label={t("settings.notifications.email")}
|
||||
labelPlacement="start"
|
||||
sx={{ minWidth: 400, justifyContent: "space-between", marginLeft: 0 }}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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<SidebarNavItem>("settings");
|
||||
const [activeSettingsSubTab, setActiveSettingsSubTab] =
|
||||
useState<SettingsSubTab>("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<string>) => {
|
||||
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<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);
|
||||
};
|
||||
|
||||
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,149 +129,26 @@ export default function SettingsPage({ isInIframe }: { isInIframe?: boolean }) {
|
||||
{activeNavItem === "settings" && (
|
||||
<>
|
||||
{activeSettingsSubTab === "settings" && (
|
||||
<Box className="settings-tab-content">
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("settings.language") || "Language"}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
sx={{ mb: 3 }}
|
||||
>
|
||||
{t("settings.languageDescription") ||
|
||||
"This will be the language used in your Twake Calendar"}
|
||||
</Typography>
|
||||
<FormControl size="small" sx={{ minWidth: 500 }}>
|
||||
<Select
|
||||
value={currentLanguage}
|
||||
onChange={handleLanguageChange}
|
||||
variant="outlined"
|
||||
aria-label={
|
||||
t("settings.languageSelector") || "Language selector"
|
||||
<GeneralSettings
|
||||
onLanguageError={() => setLanguageErrorOpen(true)}
|
||||
onTimeZoneError={() => setTimeZoneErrorOpen(true)}
|
||||
onHideDeclinedEventsError={() =>
|
||||
setHideDeclinedEventsErrorOpen(true)
|
||||
}
|
||||
>
|
||||
{AVAILABLE_LANGUAGES.map(({ code, label }) => (
|
||||
<MenuItem key={code} value={code}>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("settings.timeZone")}
|
||||
</Typography>
|
||||
<Box
|
||||
sx={{
|
||||
mb: 6,
|
||||
}}
|
||||
>
|
||||
<FormControl size="small" sx={{ minWidth: 500 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={isBrowserDefault}
|
||||
onChange={() =>
|
||||
handleTimeZoneDefaultChange(!isBrowserDefault)
|
||||
onDisplayWeekNumbersError={() =>
|
||||
setDisplayWeekNumbersErrorOpen(true)
|
||||
}
|
||||
aria-label={t("settings.timeZoneBrowserDefault")}
|
||||
onWorkingDaysError={() => setWorkingDaysErrorOpen(true)}
|
||||
/>
|
||||
}
|
||||
label={t("settings.timeZoneBrowserDefault")}
|
||||
labelPlacement="start"
|
||||
sx={{
|
||||
minWidth: 400,
|
||||
justifyContent: "space-between",
|
||||
marginLeft: 0,
|
||||
mb: 2,
|
||||
}}
|
||||
/>
|
||||
{!isBrowserDefault && (
|
||||
<TimezoneAutocomplete
|
||||
value={currentTimeZone}
|
||||
zones={timezoneList.zones}
|
||||
getTimezoneOffset={getTimezoneOffset}
|
||||
onChange={handleTimeZoneChange}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mb: 6 }}>
|
||||
<Typography variant="h6" sx={{ mb: 1 }}>
|
||||
{t("settings.calAndEvent")}
|
||||
</Typography>
|
||||
<FormControl size="small" sx={{ minWidth: 500 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={Boolean(!hideDeclinedEvents)}
|
||||
onChange={() =>
|
||||
handleHideDeclinedEvents(!hideDeclinedEvents)
|
||||
}
|
||||
aria-label={t("settings.showDeclinedEvent")}
|
||||
/>
|
||||
}
|
||||
label={t("settings.showDeclinedEvent")}
|
||||
labelPlacement="start"
|
||||
sx={{
|
||||
minWidth: 400,
|
||||
justifyContent: "space-between",
|
||||
marginLeft: 0,
|
||||
}}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={Boolean(displayWeekNumbers)}
|
||||
onChange={() =>
|
||||
handleDisplayWeekNumbers(!displayWeekNumbers)
|
||||
}
|
||||
aria-label={t("settings.displayWeekNumbers")}
|
||||
/>
|
||||
}
|
||||
label={t("settings.displayWeekNumbers")}
|
||||
labelPlacement="start"
|
||||
sx={{
|
||||
minWidth: 400,
|
||||
justifyContent: "space-between",
|
||||
marginLeft: 0,
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{activeSettingsSubTab === "notifications" && (
|
||||
<Box className="settings-tab-content">
|
||||
<Typography variant="h6" sx={{ mb: 3 }}>
|
||||
{t("settings.notifications.deliveryMethod") ||
|
||||
"Delivery method"}
|
||||
</Typography>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={alarmEmailsEnabled}
|
||||
onChange={handleAlarmEmailsToggle}
|
||||
aria-label={
|
||||
t("settings.notifications.email") || "Email"
|
||||
}
|
||||
<NotificationsSettings
|
||||
onAlarmEmailsError={() => setAlarmEmailsErrorOpen(true)}
|
||||
/>
|
||||
}
|
||||
label={t("settings.notifications.email") || "Email"}
|
||||
labelPlacement="start"
|
||||
sx={{
|
||||
minWidth: 400,
|
||||
justifyContent: "space-between",
|
||||
marginLeft: 0,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{activeNavItem === "sync" && (
|
||||
<Box className="settings-tab-content">
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
@@ -451,7 +157,6 @@ export default function SettingsPage({ isInIframe }: { isInIframe?: boolean }) {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
<Snackbar
|
||||
open={languageErrorOpen}
|
||||
autoHideDuration={4000}
|
||||
@@ -475,7 +180,6 @@ export default function SettingsPage({ isInIframe }: { isInIframe?: boolean }) {
|
||||
"Failed to update email notifications setting"
|
||||
}
|
||||
/>
|
||||
|
||||
<Snackbar
|
||||
open={hideDeclinedEventsErrorOpen}
|
||||
autoHideDuration={4000}
|
||||
@@ -488,6 +192,12 @@ export default function SettingsPage({ isInIframe }: { isInIframe?: boolean }) {
|
||||
onClose={handleDisplayWeekNumbersErrorClose}
|
||||
message={t("settings.displayWeekNumbersUpdateError")}
|
||||
/>
|
||||
<Snackbar
|
||||
open={workingDaysErrorOpen}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setWorkingDaysErrorOpen(false)}
|
||||
message={t("settings.workingDaysUpdateError")}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<BusinessHour | null>) => {
|
||||
state.businessHours = action.payload ?? null;
|
||||
},
|
||||
setWorkingDays: (state, action: PayloadAction<boolean | null>) => {
|
||||
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;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
+4
-1
@@ -258,7 +258,10 @@
|
||||
"calAndEvent": "Календарь и мероприятия",
|
||||
"showDeclinedEvent": "Показать отклонённые мероприятия",
|
||||
"hideDeclinedEventsUpdateError": "Не удалось обновить скрытие отклонённых событий.",
|
||||
"back": "Вернуться к календарю"
|
||||
"back": "Вернуться к календарю",
|
||||
"chooseWorkingDays": "Выберите ваши рабочие дни",
|
||||
"showOnlyWorkingDays": "Показывать только рабочие дни",
|
||||
"workingDaysUpdateError": "Не удалось обновить настройку рабочих дней"
|
||||
},
|
||||
"eventPreview": {
|
||||
"emailAttendees": "Написать участникам",
|
||||
|
||||
+4
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user