587 option hide show non working days (#613)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2026-03-14 06:39:27 +01:00
committed by GitHub
parent 57f9693892
commit c16e59c7fa
13 changed files with 1087 additions and 404 deletions
@@ -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();
});
});
});
+14
View File
@@ -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%"}
+16 -70
View File
@@ -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);
}}
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>
<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,
});
}}
disabled={!isOwn}
/>
</Box>
)}
+92
View File
@@ -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>
);
}
+339
View File
@@ -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>
);
}
+35 -325
View File
@@ -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,157 +129,33 @@ 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"
}
>
{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)
}
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>
<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>
<GeneralSettings
onLanguageError={() => setLanguageErrorOpen(true)}
onTimeZoneError={() => setTimeZoneErrorOpen(true)}
onHideDeclinedEventsError={() =>
setHideDeclinedEventsErrorOpen(true)
}
onDisplayWeekNumbersError={() =>
setDisplayWeekNumbersErrorOpen(true)
}
onWorkingDaysError={() => setWorkingDaysErrorOpen(true)}
/>
)}
{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"
}
/>
}
label={t("settings.notifications.email") || "Email"}
labelPlacement="start"
sx={{
minWidth: 400,
justifyContent: "space-between",
marginLeft: 0,
}}
/>
</Box>
<NotificationsSettings
onAlarmEmailsError={() => setAlarmEmailsErrorOpen(true)}
/>
)}
</>
)}
{activeNavItem === "sync" && (
<Box className="settings-tab-content">
<Typography variant="body1" color="text.secondary">
{t("settings.sync.empty") || "Sync settings coming soon"}
</Typography>
</Box>
)}
</Box>
{activeNavItem === "sync" && (
<Box className="settings-tab-content">
<Typography variant="body1" color="text.secondary">
{t("settings.sync.empty") || "Sync settings coming soon"}
</Typography>
</Box>
)}
</Box>
<Snackbar
open={languageErrorOpen}
@@ -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>
);
}
+34 -1
View File
@@ -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;
+19 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -258,7 +258,10 @@
"calAndEvent": "Календарь и мероприятия",
"showDeclinedEvent": "Показать отклонённые мероприятия",
"hideDeclinedEventsUpdateError": "Не удалось обновить скрытие отклонённых событий.",
"back": "Вернуться к календарю"
"back": "Вернуться к календарю",
"chooseWorkingDays": "Выберите ваши рабочие дни",
"showOnlyWorkingDays": "Показывать только рабочие дни",
"workingDaysUpdateError": "Не удалось обновить настройку рабочих дней"
},
"eventPreview": {
"emailAttendees": "Написать участникам",
+4 -1
View File
@@ -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",