Files
workavia-calendar-front/src/components/Calendar/Calendar.tsx
T
Camille Moussu cfb2b59584 [DELEGATION] Edit attendance and events, delete event for someone (#553)
* [#523] added way to manage attendance for delegated calendars

* [DELEGATION] #523 & #524 changed eventURL calculation for delegated events to have the rigth one

* [#524] added drag and drop authorisation to delegated event

* [#524] added tests

* [#524] support for the permission access

* [#524] no edit when delegated event is not public
2026-02-19 09:50:29 +01:00

852 lines
26 KiB
TypeScript

import { useAppDispatch, useAppSelector } from "@/app/hooks";
import { getCalendarDetailAsync } from "@/features/Calendars/services";
import EventPreviewModal from "@/features/Events/EventDisplayPreview";
import EventPopover from "@/features/Events/EventModal";
import { CalendarEvent } from "@/features/Events/EventsTypes";
import ImportAlert from "@/features/Events/ImportAlert";
import SearchResultsPage from "@/features/Search/SearchResultsPage";
import { setTimeZone } from "@/features/Settings/SettingsSlice";
import { setDisplayedDateAndRange } from "@/utils/CalendarRangeManager";
import {
formatDateToYYYYMMDDTHHMMSS,
getCalendarRange,
} from "@/utils/dateUtils";
import { extractEventBaseUuid } from "@/utils/extractEventBaseUuid";
import { setSelectedCalendars as setSelectedCalendarsToStorage } from "@/utils/storage/setSelectedCalendars";
import { useSelectedCalendars } from "@/utils/storage/useSelectedCalendars";
import { browserDefaultTimeZone } from "@/utils/timezone";
import type { EventApi, LocaleInput } from "@fullcalendar/core";
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import frLocale from "@fullcalendar/core/locales/fr";
import ruLocale from "@fullcalendar/core/locales/ru";
import viLocale from "@fullcalendar/core/locales/vi";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import momentTimezonePlugin from "@fullcalendar/moment-timezone";
import FullCalendar from "@fullcalendar/react";
import timeGridPlugin from "@fullcalendar/timegrid";
import { Box, Button, radius, useTheme } from "@linagora/twake-mui";
import AddIcon from "@mui/icons-material/Add";
import { MutableRefObject, useEffect, useMemo, useRef, useState } from "react";
import { useI18n } from "twake-i18n";
import { User } from "../Attendees/PeopleSearch";
import { EventErrorSnackbar } from "../Error/ErrorSnackbar";
import { EventErrorHandler } from "../Error/EventErrorHandler";
import { EditModeDialog } from "../Event/EditModeDialog";
import "./Calendar.styl";
import CalendarSelection from "./CalendarSelection";
import "./CustomCalendar.styl";
import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers";
import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
import { MiniCalendar } from "./MiniCalendar";
import { TempCalendarsInput } from "./TempCalendarsInput";
import { TimezoneSelector } from "./TimezoneSelector";
import { updateDarkColor } from "./utils/calendarColorsUtils";
import {
eventToFullCalendarFormat,
extractEvents,
updateSlotLabelVisibility,
} from "./utils/calendarUtils";
const localeMap: Record<string, LocaleInput | undefined> = {
fr: frLocale,
ru: ruLocale,
vi: viLocale,
en: undefined,
};
interface CalendarAppProps {
calendarRef: MutableRefObject<CalendarApi | null>;
onDateChange?: (date: Date) => void;
onViewChange?: (view: string) => void;
}
export default function CalendarApp({
calendarRef,
onDateChange,
onViewChange,
}: CalendarAppProps) {
const [selectedDate, setSelectedDate] = useState(new Date());
const [selectedMiniDate, setSelectedMiniDate] = useState(new Date());
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const dispatch = useAppDispatch();
const theme = useTheme();
const view = useAppSelector((state) => state.settings.view);
const userData = useAppSelector((state) => state.user.userData);
const hideDeclinedEvents = useAppSelector(
(state) => state.settings.hideDeclinedEvents
);
const calendars = useAppSelector((state) => state.calendars.list);
const isPending = useAppSelector((state) => state.calendars.pending);
const displayWeekNumbers = useAppSelector(
(state) => state.settings.displayWeekNumbers
);
const tempcalendars = useAppSelector((state) => state.calendars.templist);
const storedCalendars = useSelectedCalendars();
const [selectedCalendars, setSelectedCalendars] =
useState<string[]>(storedCalendars);
const calendarLightSignature = useMemo(() => {
return Object.values(calendars || {})
.map((cal) => `${cal.id}:${cal.color?.light ?? ""}`)
.sort()
.join("|");
}, [calendars]);
const calendarIdsString = useMemo(
() =>
Object.keys(calendars || {})
.sort()
.join(","),
[calendars]
);
const calendarIds = useMemo(
() => (calendarIdsString ? calendarIdsString.split(",") : []),
[calendarIdsString]
);
const dottedEvents: CalendarEvent[] = selectedCalendars.flatMap((calId) => {
const calendar = calendars[calId];
if (!calendar?.events) return [];
return Object.values(calendar.events);
});
const [currentView, setCurrentView] = useState("timeGridWeek");
const timezone =
useAppSelector((state) => state.settings.timeZone) ??
browserDefaultTimeZone;
const fetchedRangesRef = useRef<Record<string, string>>({});
// Auto-select personal calendars when first loaded
const initialLoadRef = useRef(true);
const [eventErrors, setEventErrors] = useState<string[]>([]);
const errorHandler = useRef(new EventErrorHandler());
// useEffect(() => {
// if (view === "search") {
// document.body.classList.add("dialog-expanded");
// } else {
// document.body.classList.remove("dialog-expanded");
// }
// }, [view]);
useEffect(() => {
const handler = errorHandler.current;
handler.setErrorCallback(setEventErrors);
return () => handler.setErrorCallback(() => {});
}, []);
const handleErrorClose = () => {
setEventErrors([]);
errorHandler.current.clearAll();
};
useEffect(() => {
if (initialLoadRef.current && calendarIds.length > 0 && userId) {
const cached = localStorage.getItem("selectedCalendars");
if (cached && cached.length > 0) {
const parsed = JSON.parse(cached) as string[];
const valid = parsed.filter((id) => calendars[id]);
setSelectedCalendars(valid);
} else {
const personalCalendarIds = calendarIds.filter(
(id) => extractEventBaseUuid(id) === userId
);
setSelectedCalendars(personalCalendarIds);
}
initialLoadRef.current = false;
}
}, [calendarIds, calendars, userId]);
// Save selected cals to cache
useEffect(() => {
if (calendarIds.length > 0) {
setSelectedCalendarsToStorage(selectedCalendars);
}
}, [selectedCalendars, calendarIds.length]);
const prevCalendarLightSignature = useRef<string | null>(null);
useEffect(() => {
if (!calendarLightSignature) {
prevCalendarLightSignature.current = calendarLightSignature;
return;
}
if (prevCalendarLightSignature.current === calendarLightSignature) {
return;
}
prevCalendarLightSignature.current = calendarLightSignature;
updateDarkColor(calendars || {}, theme, dispatch);
}, [calendarLightSignature, calendars, theme, dispatch]);
useEffect(() => {
if (calendarIds.length === 0) return;
const validCalendarIds = new Set(calendarIds);
setSelectedCalendars((prev) => {
const filtered = prev.filter((calId) => validCalendarIds.has(calId));
if (filtered.length === prev.length) {
const unchanged = filtered.every((id, index) => id === prev[index]);
if (unchanged) {
return prev;
}
}
return filtered;
});
}, [calendarIds]);
const calendarRange = useMemo(
() => getCalendarRange(selectedDate),
[selectedDate]
);
const calendarRangeStart = calendarRange.start.getTime();
const calendarRangeEnd = calendarRange.end.getTime();
const rangeStart = useMemo(
() => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeStart)),
[calendarRangeStart]
);
const rangeEnd = useMemo(
() => formatDateToYYYYMMDDTHHMMSS(new Date(calendarRangeEnd)),
[calendarRangeEnd]
);
// Create a stable string key for the range
const rangeKey = useMemo(
() => `${rangeStart}_${rangeEnd}`,
[rangeStart, rangeEnd]
);
const filteredEvents: CalendarEvent[] = extractEvents(
selectedCalendars,
calendars || {},
userData?.email,
hideDeclinedEvents
);
const tempCalendarIds = useMemo(
() => Object.keys(tempcalendars || {}).sort(),
[tempcalendars]
);
const filteredTempEvents: CalendarEvent[] = extractEvents(
tempCalendarIds,
tempcalendars || {},
userData?.email,
hideDeclinedEvents
);
const sortedSelectedCalendars = useMemo(
() => [...selectedCalendars].sort(),
[selectedCalendars]
);
const prefetchedCalendarsRef = useRef<Record<string, string>>({});
const tempFetchedRangesRef = useRef<Record<string, string>>({});
const activeLoadCompletedRef = useRef<boolean>(false);
const [activeLoadCompleted, setActiveLoadCompleted] =
useState<boolean>(false);
useEffect(() => {
activeLoadCompletedRef.current = false;
setActiveLoadCompleted(false);
}, [rangeKey]);
useEffect(() => {
if (isPending) return;
if (!rangeKey || sortedSelectedCalendars.length === 0) {
activeLoadCompletedRef.current = true;
setActiveLoadCompleted(true);
return;
}
let cancelled = false;
const ACTIVE_BATCH_SIZE = 5;
const loadCalendars = async () => {
try {
const pendingIds = sortedSelectedCalendars.filter(
(id) => fetchedRangesRef.current[id] !== rangeKey
);
if (pendingIds.length === 0) {
activeLoadCompletedRef.current = true;
setActiveLoadCompleted(true);
return;
}
for (
let i = 0;
i < pendingIds.length && !cancelled;
i += ACTIVE_BATCH_SIZE
) {
const chunk = pendingIds.slice(i, i + ACTIVE_BATCH_SIZE);
chunk.forEach((id) => {
fetchedRangesRef.current[id] = rangeKey;
prefetchedCalendarsRef.current[id] = "active";
});
const requests = chunk.map(async (id) => {
try {
await dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: rangeStart,
end: rangeEnd,
},
})
).unwrap();
} catch {
fetchedRangesRef.current[id] = "";
}
});
await Promise.all(requests);
}
} finally {
if (!cancelled) {
activeLoadCompletedRef.current = true;
setActiveLoadCompleted(true);
}
}
};
loadCalendars();
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, rangeKey, sortedSelectedCalendars, rangeStart, rangeEnd]);
useEffect(() => {
if (!rangeKey || !activeLoadCompleted || isPending) return;
const hiddenCalendars = calendarIds
.filter((id) => !selectedCalendars.includes(id))
.filter((id) => {
const prefetched = prefetchedCalendarsRef.current[id];
return prefetched !== rangeKey && prefetched !== "active";
});
if (hiddenCalendars.length === 0) return;
hiddenCalendars.forEach((id) => {
prefetchedCalendarsRef.current[id] = rangeKey;
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: rangeStart,
end: rangeEnd,
},
})
)
.unwrap()
.catch(() => {
prefetchedCalendarsRef.current[id] = "";
});
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
calendarIdsString,
selectedCalendars,
rangeKey,
dispatch,
rangeStart,
rangeEnd,
activeLoadCompleted,
isPending,
]);
const calendarsWithClearedCache = useMemo(() => {
return selectedCalendars
.map((id) => {
const cleared = calendars[id]?.lastCacheCleared;
return cleared ? { id, cleared } : null;
})
.filter(Boolean) as { id: string; cleared: number }[];
}, [selectedCalendars, calendars]);
const processedCacheClearRef = useRef<Record<string, number>>({});
useEffect(() => {
if (isPending) return;
calendarsWithClearedCache.forEach(({ id, cleared }) => {
if (processedCacheClearRef.current[id] === cleared) {
return;
}
processedCacheClearRef.current[id] = cleared;
delete fetchedRangesRef.current[id];
prefetchedCalendarsRef.current[id] = "";
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: rangeStart,
end: rangeEnd,
},
})
)
.unwrap()
.then(() => {
fetchedRangesRef.current[id] = rangeKey;
prefetchedCalendarsRef.current[id] = rangeKey;
})
.catch(() => {
fetchedRangesRef.current[id] = "";
prefetchedCalendarsRef.current[id] = "";
});
});
}, [
calendarsWithClearedCache,
dispatch,
rangeKey,
rangeStart,
rangeEnd,
isPending,
]);
useEffect(() => {
const currentIds = new Set(tempCalendarIds);
Object.keys(tempFetchedRangesRef.current).forEach((id) => {
if (!currentIds.has(id)) {
delete tempFetchedRangesRef.current[id];
}
});
}, [tempCalendarIds]);
useEffect(() => {
if (!rangeKey || tempCalendarIds.length === 0) return;
let cancelled = false;
const TEMP_BATCH_SIZE = 5;
const loadTempCalendars = async () => {
const pendingIds = tempCalendarIds.filter(
(id) => tempFetchedRangesRef.current[id] !== rangeKey
);
if (pendingIds.length === 0) {
return;
}
for (
let i = 0;
i < pendingIds.length && !cancelled;
i += TEMP_BATCH_SIZE
) {
const chunk = pendingIds.slice(i, i + TEMP_BATCH_SIZE);
chunk.forEach((id) => {
tempFetchedRangesRef.current[id] = rangeKey;
});
const requests = chunk.map(async (id) => {
try {
await dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: rangeStart,
end: rangeEnd,
},
calType: "temp",
})
).unwrap();
} catch {
tempFetchedRangesRef.current[id] = "";
}
});
await Promise.all(requests);
}
};
loadTempCalendars();
return () => {
cancelled = true;
};
}, [dispatch, rangeKey, tempCalendarIds, rangeStart, rangeEnd]);
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null);
const [openEventDisplay, setOpenEventDisplay] = useState(false);
const [eventDisplayedId, setEventDisplayedId] = useState("");
const [eventDisplayedTemp, setEventDisplayedTemp] = useState(false);
const [eventDisplayedCalId, setEventDisplayedCalId] = useState("");
// Listen for eventModalError event to reopen modal on API failure
useEffect(() => {
const handleEventModalError = (event: CustomEvent) => {
if (event.detail?.type === "create") {
// Reopen create event modal
setAnchorEl(document.body);
} else if (event.detail?.type === "update") {
// Store update modal info to sessionStorage for EventDisplayPreview to pick up
try {
sessionStorage.setItem(
"eventUpdateModalReopen",
JSON.stringify({
eventId: event.detail.eventId,
calId: event.detail.calId,
typeOfAction: event.detail.typeOfAction,
timestamp: Date.now(),
})
);
// Open EventDisplayPreview if it's not already open with matching event, so it can pick up the sessionStorage
if (
!openEventDisplay ||
eventDisplayedId !== event.detail.eventId ||
eventDisplayedCalId !== event.detail.calId
) {
setEventDisplayedId(event.detail.eventId);
setEventDisplayedCalId(event.detail.calId);
setEventDisplayedTemp(false);
setOpenEventDisplay(true);
} else {
// If EventDisplayPreview is already open, trigger reopen by dispatching a custom event
window.dispatchEvent(
new CustomEvent("eventUpdateModalReopen", {
detail: {
eventId: event.detail.eventId,
calId: event.detail.calId,
typeOfAction: event.detail.typeOfAction,
},
})
);
}
} catch {
// Ignore sessionStorage errors
}
}
};
window.addEventListener(
"eventModalError",
handleEventModalError as EventListener
);
return () => {
window.removeEventListener(
"eventModalError",
handleEventModalError as EventListener
);
};
}, [openEventDisplay, eventDisplayedId, eventDisplayedCalId]);
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
null
);
const [, setTypeOfAction] = useState<"solo" | "all" | undefined>(undefined);
const [afterChoiceFunc, setAfterChoiceFunc] = useState<
((type: "solo" | "all" | undefined) => void) | undefined
>();
const [, setSelectedEvent] = useState<CalendarEvent>({} as CalendarEvent);
const [selectedRange, setSelectedRange] = useState<DateSelectArg | null>(
null
);
const [tempUsers, setTempUsers] = useState<User[]>([]);
const [tempEvent, setTempEvent] = useState<CalendarEvent>(
{} as CalendarEvent
);
// Event handlers
const eventHandlers = useCalendarEventHandlers({
setSelectedRange,
setAnchorEl,
calendarRef,
dispatch,
setOpenEventDisplay,
setEventDisplayedId,
setEventDisplayedCalId,
setEventDisplayedTemp,
calendars,
setSelectedEvent,
setAfterChoiceFunc,
setOpenEditModePopup,
tempUsers,
setTempEvent,
timezone,
});
// View handlers
const viewHandlers = useCalendarViewHandlers({
calendarRef,
setSelectedDate,
setSelectedMiniDate,
onViewChange,
calendars,
tempcalendars,
errorHandler: errorHandler.current,
});
if (process.env.NODE_ENV === "test") {
window.__calendarRef = calendarRef;
}
const { t, lang } = useI18n();
return (
<main className="main-layout calendar-layout">
<Box
className="sidebar"
sx={{
paddingTop: 0,
paddingBottom: 3,
paddingLeft: 3,
paddingRight: 2,
width: "270px",
}}
>
<Box
sx={{
position: "sticky",
top: 0,
zIndex: 10,
backgroundColor: "#fff",
paddingTop: 3,
}}
>
<Button
size="medium"
variant="contained"
fullWidth
onClick={() =>
eventHandlers.handleDateSelect(null as unknown as DateSelectArg)
}
sx={{
borderRadius: radius.lg,
fontSize: "16px",
fontWeight: 500,
lineHeight: "normal",
}}
>
<AddIcon sx={{ marginRight: 0.5, fontSize: "20px" }} />{" "}
{t("event.createEvent")}
</Button>
</Box>
<MiniCalendar
calendarRef={calendarRef}
selectedDate={selectedMiniDate}
setSelectedMiniDate={setSelectedMiniDate}
dottedEvents={dottedEvents}
/>
<Box sx={{ mb: 3, mt: 2 }}>
<TempCalendarsInput
tempUsers={tempUsers}
setTempUsers={setTempUsers}
handleToggleEventPreview={() => {
eventHandlers.handleDateSelect(null as unknown as DateSelectArg);
}}
selectedCalendars={selectedCalendars}
setSelectedCalendars={setSelectedCalendars}
/>
</Box>
<div className="calendarList">
<CalendarSelection
selectedCalendars={selectedCalendars}
setSelectedCalendars={setSelectedCalendars}
/>
</div>
</Box>
<div className="calendar">
<ImportAlert />
{view === "calendar" && (
<FullCalendar
ref={(ref) => {
if (ref) {
calendarRef.current = ref.getApi();
}
}}
plugins={[
dayGridPlugin,
timeGridPlugin,
interactionPlugin,
momentTimezonePlugin,
]}
initialView="timeGridWeek"
firstDay={1}
editable={true}
locale={localeMap[lang]}
selectable={true}
timeZone={timezone}
height={"100%"}
select={eventHandlers.handleDateSelect}
nowIndicator
slotLabelClassNames={(arg) => [
updateSlotLabelVisibility(new Date(), arg, timezone),
]}
nowIndicatorContent={viewHandlers.handleNowIndicatorContent}
headerToolbar={false}
views={{
timeGridWeek: { titleFormat: { month: "long", year: "numeric" } },
}}
dayMaxEvents={true}
events={eventToFullCalendarFormat(
filteredEvents,
filteredTempEvents,
userId,
userData?.email,
isPending,
calendars
)}
eventOrder={(a: EventApi, b: EventApi) =>
a.extendedProps.priority - b.extendedProps.priority
}
weekNumbers={
currentView === "timeGridWeek" || currentView === "timeGridDay"
}
weekNumberFormat={{ week: "long" }}
weekNumberContent={(arg) => {
return (
<div className="weekSelector">
{displayWeekNumbers && (
<div>
{t("menubar.views.week")} {arg.num}
</div>
)}
<TimezoneSelector
value={timezone}
referenceDate={calendarRef.current?.getDate() ?? new Date()}
onChange={(newTimezone: string) =>
dispatch(setTimeZone(newTimezone))
}
/>
</div>
);
}}
dayCellContent={(arg) => {
const month = arg.date.toLocaleDateString(t("locale"), {
month: "short",
});
if (arg.view.type === "dayGridMonth") {
return (
<span
className={`fc-daygrid-day-number ${
arg.isToday ? "current-date" : ""
}`}
>
{arg.dayNumberText === "1" ? month : ""} {arg.dayNumberText}
</span>
);
}
}}
slotDuration={"00:30:00"}
slotLabelInterval={"01:00:00"}
scrollTime="12:00:00"
unselectAuto={false}
allDayText=""
slotLabelFormat={{
hour: "2-digit",
minute: "2-digit",
hour12: false,
}}
datesSet={(arg) => {
setCurrentView(arg.view.type);
const calendarCurrentDate =
calendarRef.current?.getDate() || new Date(arg.start);
setDisplayedDateAndRange(calendarCurrentDate);
if (arg.view.type === "dayGridMonth") {
const start = new Date(arg.start).getTime();
const end = new Date(arg.end).getTime();
const middle = start + (end - start) / 2;
setSelectedDate(new Date(middle));
setSelectedMiniDate(calendarCurrentDate);
} else {
setSelectedDate(calendarCurrentDate);
setSelectedMiniDate(calendarCurrentDate);
}
// Always use the calendar's current date for consistency
if (onDateChange) {
onDateChange(calendarCurrentDate);
}
// Notify parent about view change
if (onViewChange) {
onViewChange(arg.view.type);
}
// Update slot label visibility when view changes
setTimeout(() => {
updateSlotLabelVisibility(new Date());
}, 100);
}}
dayHeaderContent={(arg) => {
const date = arg.date.getDate();
const weekDay = arg.date
.toLocaleDateString(t("locale"), { weekday: "short" })
.toUpperCase();
return (
<div className="fc-daygrid-day-top">
<small>{weekDay}</small>
{arg.view.type !== "dayGridMonth" && (
<span
className={`fc-daygrid-day-number ${
arg.isToday ? "current-date" : ""
}`}
>
{date}
</span>
)}
</div>
);
}}
dayHeaderDidMount={viewHandlers.handleDayHeaderDidMount}
dayHeaderWillUnmount={viewHandlers.handleDayHeaderWillUnmount}
viewDidMount={viewHandlers.handleViewDidMount}
viewWillUnmount={viewHandlers.handleViewWillUnmount}
eventClick={eventHandlers.handleEventClick}
eventAllow={eventHandlers.handleEventAllow}
eventDrop={eventHandlers.handleEventDrop}
eventResize={eventHandlers.handleEventResize}
eventContent={viewHandlers.handleEventContent}
eventDidMount={viewHandlers.handleEventDidMount}
/>
)}
{view === "search" && <SearchResultsPage />}
<EventPopover
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={eventHandlers.handleClosePopover}
selectedRange={selectedRange}
setSelectedRange={setSelectedRange}
calendarRef={calendarRef}
event={tempEvent}
/>
<EditModeDialog
type={openEditModePopup}
setOpen={setOpenEditModePopup}
eventAction={(type: "solo" | "all" | undefined) => {
setTypeOfAction(type);
if (afterChoiceFunc) {
afterChoiceFunc(type);
}
}}
/>
{openEventDisplay && eventDisplayedId && eventDisplayedCalId && (
<EventPreviewModal
eventId={eventDisplayedId}
calId={eventDisplayedCalId}
tempEvent={eventDisplayedTemp}
open={openEventDisplay}
onClose={eventHandlers.handleCloseEventDisplay}
/>
)}
<EventErrorSnackbar messages={eventErrors} onClose={handleErrorClose} />
</div>
</main>
);
}