refactor: extract calendar utility functions and handlers

- Extract utility functions to utils/calendarUtils.ts
  * updateSlotLabelVisibility for slot label management
  * eventToFullCalendarFormat for event formatting
  * extractEvents for event filtering
  * updateCalsDetails for calendar detail updates
- Extract event handlers to handlers/eventHandlers.ts
  * handleDateSelect, handleClosePopover, handleEventClick
  * handleEventAllow, handleEventDrop, handleEventResize
- Extract mouse handlers to handlers/mouseHandlers.ts
  * handleMouseMove, handleMouseLeave
  * addMouseEventListeners, removeMouseEventListeners
- Extract view handlers to handlers/viewHandlers.ts
  * handleViewDidMount, handleViewWillUnmount
  * handleEventContent, handleEventDidMount
  * handleDayHeaderDidMount, handleDayHeaderWillUnmount
- Remove inline functions from Calendar.tsx
- Improve code organization and maintainability
This commit is contained in:
lenhanphung
2025-09-26 16:52:02 +07:00
parent 77df90be27
commit f3787643e7
5 changed files with 696 additions and 567 deletions
+56 -567
View File
@@ -1,5 +1,4 @@
import FullCalendar from "@fullcalendar/react";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
@@ -37,38 +36,14 @@ import LockIcon from "@mui/icons-material/Lock";
import { userAttendee } from "../../features/User/userDataTypes";
import { TempCalendarsInput } from "./TempCalendarsInput";
import Button from "@mui/material/Button";
// Function to hide/show slot labels based on current time
const updateSlotLabelVisibility = (currentTime: Date) => {
const slotLabels = document.querySelectorAll(".fc-timegrid-slot-label");
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
slotLabels.forEach((label) => {
const labelElement = label as HTMLElement;
const timeText = labelElement.textContent?.trim();
if (timeText && timeText.match(/^\d{1,2}:\d{2}$/)) {
const [hours, minutes] = timeText.split(":").map(Number);
const labelMinutes = hours * 60 + minutes;
// Calculate time difference in minutes
let timeDiff = Math.abs(currentMinutes - labelMinutes);
// Handle edge case around midnight (00:00)
if (timeDiff > 12 * 60) {
// More than 12 hours difference
timeDiff = 24 * 60 - timeDiff; // Wrap around
}
// Dim if within 15 minutes (before or after)
if (timeDiff <= 15) {
labelElement.style.opacity = "0.2";
} else {
labelElement.style.opacity = "1";
}
}
});
};
import {
updateSlotLabelVisibility,
eventToFullCalendarFormat,
extractEvents,
updateCalsDetails,
} from "./utils/calendarUtils";
import { useCalendarEventHandlers } from "./hooks/useCalendarEventHandlers";
import { useCalendarViewHandlers } from "./hooks/useCalendarViewHandlers";
interface CalendarAppProps {
calendarRef: React.RefObject<CalendarApi | null>;
@@ -84,20 +59,20 @@ export default function CalendarApp({
const [selectedDate, setSelectedDate] = useState(new Date());
const [selectedMiniDate, setSelectedMiniDate] = useState(new Date());
const tokens = useAppSelector((state) => state.user.tokens);
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const dispatch = useAppDispatch();
useEffect(() => {
if (!tokens || !user) {
if (!tokens || !userId) {
dispatch(push("/"));
}
}, [tokens, user]);
}, [tokens, userId]);
const calendars = useAppSelector((state) => state.calendars.list);
const tempcalendars =
useAppSelector((state) => state.calendars.templist) ?? {};
const pending = useAppSelector((state) => state.calendars.pending);
const userId =
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const selectPersonnalCalendars = createSelector(
(state) => state.calendars,
(calendars) =>
@@ -207,53 +182,39 @@ export default function CalendarApp({
{} as CalendarEvent
);
const handleDateSelect = (selectInfo: DateSelectArg) => {
setSelectedRange(selectInfo);
setAnchorEl(document.body); // fallback: we could use selectInfo.jsEvent.target if from a click
};
// Event handlers
const eventHandlers = useCalendarEventHandlers({
setSelectedRange,
setAnchorEl,
calendarRef,
selectedCalendars,
tempcalendars,
calendarRange,
dispatch,
setOpenEventDisplay,
setAnchorPosition,
setEventDisplayedId,
setEventDisplayedCalId,
setEventDisplayedTemp,
calendars,
});
const handleClosePopover = () => {
calendarRef.current?.unselect();
setAnchorEl(null);
setSelectedRange(null);
selectedCalendars.forEach((calId) =>
dispatch(
getCalendarDetailAsync({
calId,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
},
})
)
);
Object.keys(tempcalendars).forEach((calId) =>
dispatch(
getCalendarDetailAsync({
calId,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
},
calType: "temp",
})
)
);
};
const handleCloseEventDisplay = () => {
setAnchorPosition(null);
setOpenEventDisplay(false);
};
// View handlers
const viewHandlers = useCalendarViewHandlers({
calendarRef,
setSelectedDate,
setSelectedMiniDate,
onViewChange,
calendars,
tempcalendars,
});
const handleMonthUp = () => {
setSelectedMiniDate(
new Date(selectedMiniDate.getFullYear(), selectedMiniDate.getMonth() - 1)
);
eventHandlers.handleMonthUp(selectedMiniDate, setSelectedMiniDate);
};
const handleMonthDown = () => {
setSelectedMiniDate(
new Date(selectedMiniDate.getFullYear(), selectedMiniDate.getMonth() + 1)
);
eventHandlers.handleMonthDown(selectedMiniDate, setSelectedMiniDate);
};
if (process.env.NODE_ENV === "test") {
@@ -265,7 +226,9 @@ export default function CalendarApp({
<div className="sidebar">
<Button
variant="contained"
onClick={() => handleDateSelect(null as unknown as DateSelectArg)}
onClick={() =>
eventHandlers.handleDateSelect(null as unknown as DateSelectArg)
}
>
<AddIcon /> <p>Create Event</p>
</Button>
@@ -378,7 +341,7 @@ export default function CalendarApp({
selectable={true}
timeZone="local"
height={"100%"}
select={handleDateSelect}
select={eventHandlers.handleDateSelect}
nowIndicator
headerToolbar={false}
views={{
@@ -448,433 +411,21 @@ export default function CalendarApp({
</div>
);
}}
dayHeaderDidMount={(arg) => {
// Add click handler to day headers in week view
if (arg.view.type === "timeGridWeek") {
const headerEl = arg.el;
const handleDayHeaderClick = () => {
// Switch to day view and navigate to the clicked date
calendarRef.current?.changeView("timeGridDay", arg.date);
setSelectedDate(new Date(arg.date));
setSelectedMiniDate(new Date(arg.date));
// Notify parent about view change
if (onViewChange) {
onViewChange("timeGridDay");
}
};
headerEl.addEventListener("click", handleDayHeaderClick);
// Store the handler for cleanup
(headerEl as any).__dayHeaderClickHandler = handleDayHeaderClick;
}
}}
dayHeaderWillUnmount={(arg) => {
// Clean up event listeners to prevent memory leaks
const headerEl = arg.el;
if ((headerEl as any).__dayHeaderClickHandler) {
headerEl.removeEventListener(
"click",
(headerEl as any).__dayHeaderClickHandler
);
delete (headerEl as any).__dayHeaderClickHandler;
}
}}
viewDidMount={(arg) => {
// Update now indicator arrow with current time and hide nearby slot labels
const updateNowIndicator = () => {
const nowIndicatorArrow = document.querySelector(
".fc-timegrid-now-indicator-arrow"
) as HTMLElement;
if (nowIndicatorArrow) {
const now = new Date();
const timeString = now.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
nowIndicatorArrow.setAttribute("data-time", timeString);
// Hide slot labels that are too close to current time
updateSlotLabelVisibility(now);
}
};
// Update immediately and then every minute
updateNowIndicator();
const timeInterval = setInterval(updateNowIndicator, 60000);
// Watch for now indicator arrow creation and slot label changes
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (
element.classList?.contains(
"fc-timegrid-now-indicator-arrow"
) ||
element.querySelector?.(
".fc-timegrid-now-indicator-arrow"
) ||
element.classList?.contains("fc-timegrid-slot-label") ||
element.querySelector?.(".fc-timegrid-slot-label")
) {
setTimeout(() => {
updateNowIndicator();
updateSlotLabelVisibility(new Date());
}, 10);
}
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
// Store interval and observer for cleanup
(arg.el as any).__timeInterval = timeInterval;
(arg.el as any).__timeObserver = observer;
// Add global hover effect for week and day views
if (
arg.view.type === "timeGridWeek" ||
arg.view.type === "timeGridDay"
) {
const calendarEl = document.querySelector(".fc") as HTMLElement;
if (calendarEl) {
const handleMouseMove = (e: MouseEvent) => {
// Find the timegrid container
const timegridEl =
calendarEl.querySelector(".fc-timegrid-body");
if (!timegridEl) return;
// Check if mouse is over all-day events area (fc-scrollgrid-sync-table)
const allDayTable = calendarEl.querySelector(
".fc-scrollgrid-sync-table"
);
if (allDayTable) {
const allDayRect = allDayTable.getBoundingClientRect();
if (
e.clientY >= allDayRect.top &&
e.clientY <= allDayRect.bottom
) {
// Mouse is over all-day events area, don't show highlight
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
return;
}
}
// Check if mouse is over time slot labels (left side with hours)
const target = e.target as HTMLElement;
if (target && target.closest(".fc-timegrid-slot-label")) {
// Mouse is over time slot labels, don't show highlight
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
return;
}
// Get all day columns
const dayColumns =
timegridEl.querySelectorAll(".fc-timegrid-col");
if (dayColumns.length === 0) return;
// Remove previous highlights
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
// Find which day column the mouse is over
let targetColumn: Element | null = null;
for (const column of dayColumns) {
const rect = column.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right) {
targetColumn = column;
break;
}
}
if (targetColumn) {
const rect = targetColumn.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const hourHeight = rect.height / 24; // Assuming 24 hours
const hourIndex = Math.floor(relativeY / hourHeight);
// Only show highlight if mouse is actually over the timegrid area (not all-day events)
if (relativeY >= 0 && relativeY <= rect.height) {
// Create highlight for the specific hour in the specific day
const highlight = document.createElement("div");
highlight.className = "hour-highlight";
highlight.style.top = `${hourIndex * hourHeight}px`;
highlight.style.height = `${hourHeight}px`;
(targetColumn as HTMLElement).style.position = "relative";
targetColumn.appendChild(highlight);
}
}
};
const handleMouseLeave = () => {
// Remove all hour highlights when mouse leaves calendar
const timegridEl =
calendarEl.querySelector(".fc-timegrid-body");
if (timegridEl) {
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
}
};
calendarEl.addEventListener("mousemove", handleMouseMove);
calendarEl.addEventListener("mouseleave", handleMouseLeave);
// Store handlers for cleanup
(calendarEl as any).__calendarMouseMoveHandler =
handleMouseMove;
(calendarEl as any).__calendarMouseLeaveHandler =
handleMouseLeave;
}
}
}}
viewWillUnmount={(arg) => {
// Clean up time interval
if ((arg.el as any).__timeInterval) {
clearInterval((arg.el as any).__timeInterval);
delete (arg.el as any).__timeInterval;
}
// Clean up observer
if ((arg.el as any).__timeObserver) {
(arg.el as any).__timeObserver.disconnect();
delete (arg.el as any).__timeObserver;
}
// Clean up event listeners to prevent memory leaks
const calendarEl = document.querySelector(".fc") as HTMLElement;
if (calendarEl) {
if ((calendarEl as any).__calendarMouseMoveHandler) {
calendarEl.removeEventListener(
"mousemove",
(calendarEl as any).__calendarMouseMoveHandler
);
delete (calendarEl as any).__calendarMouseMoveHandler;
}
if ((calendarEl as any).__calendarMouseLeaveHandler) {
calendarEl.removeEventListener(
"mouseleave",
(calendarEl as any).__calendarMouseLeaveHandler
);
delete (calendarEl as any).__calendarMouseLeaveHandler;
}
}
}}
eventClick={(info) => {
info.jsEvent.preventDefault(); // don't let the browser navigate
if (info.event.url) {
window.open(info.event.url);
} else {
console.log(info.event);
setOpenEventDisplay(true);
setAnchorPosition({
top: info.jsEvent.clientY,
left: info.jsEvent.clientX,
});
setEventDisplayedId(info.event.extendedProps.uid);
setEventDisplayedCalId(info.event.extendedProps.calId);
setEventDisplayedTemp(info.event._def.extendedProps.temp);
}
}}
eventAllow={(dropInfo, draggedEvent) => {
if (
draggedEvent?.extendedProps.uid &&
draggedEvent.extendedProps.uid.split("/")[1]
) {
return false;
}
return true;
}}
eventDrop={(arg) => {
if (
!arg.event ||
!arg.event._def ||
!arg.event._def.extendedProps
) {
return;
}
const event =
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
const totalDeltaMs = getDeltaInMilliseconds(arg.delta);
const originalStart = new Date(event.start);
const computedNewStart = new Date(
originalStart.getTime() + totalDeltaMs
);
const originalEnd = new Date(event.end ?? "");
const computedNewEnd = new Date(
originalEnd.getTime() + totalDeltaMs
);
const newEvent = {
...event,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
} as CalendarEvent;
dispatch(
updateEventLocal({ calId: newEvent.calId, event: newEvent })
);
dispatch(
putEventAsync({ cal: calendars[newEvent.calId], newEvent })
);
}}
eventResize={(arg) => {
if (
!arg.event ||
!arg.event._def ||
!arg.event._def.extendedProps
) {
return;
}
const event =
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
if (event.uid.split("/")[1]) {
dispatch(getEventAsync(event));
}
const originalStart = new Date(event.start);
const computedNewStart = new Date(
originalStart.getTime() + getDeltaInMilliseconds(arg.startDelta)
);
const originalEnd = new Date(event.end ?? "");
const computedNewEnd = new Date(
originalEnd.getTime() + getDeltaInMilliseconds(arg.endDelta)
);
const newEvent = {
...event,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
} as CalendarEvent;
dispatch(
putEventAsync({ cal: calendars[newEvent.calId], newEvent })
);
}}
eventContent={(arg) => {
const event = arg.event;
if (
(!event._def.extendedProps.temp &&
!calendars[arg.event._def.extendedProps.calId]) ||
(event._def.extendedProps.temp &&
!tempcalendars[arg.event._def.extendedProps.calId])
)
return;
const attendees = event._def.extendedProps.attendee || [];
const isPrivate =
event._def.extendedProps.class === "PRIVATE" ||
event._def.extendedProps.class === "CONFIDENTIAL";
let Icon = null;
let titleStyle: React.CSSProperties = {};
const ownerEmails = new Set(
(event._def.extendedProps.temp ? tempcalendars : calendars)[
arg.event._def.extendedProps.calId
].ownerEmails?.map((email) => email.toLowerCase())
);
const delegated = (
event._def.extendedProps.temp ? tempcalendars : calendars
)[arg.event._def.extendedProps.calId].delegated;
const showSpecialDisplay = attendees.filter((att: userAttendee) =>
ownerEmails.has(att.cal_address.toLowerCase())
);
if (!delegated && showSpecialDisplay.length === 0) return null;
switch (showSpecialDisplay?.[0]?.partstat) {
case "DECLINED":
Icon = null;
titleStyle.textDecoration = "line-through";
break;
case "TENTATIVE":
Icon = HelpOutlineIcon;
break;
case "NEEDS-ACTION":
Icon = AccessTimeIcon;
break;
case "ACCEPTED":
Icon = null;
break;
default:
break;
}
return (
<div style={{ display: "flex", alignItems: "center" }}>
{isPrivate && (
<LockIcon
data-testid="lock-icon"
fontSize="small"
style={{ marginRight: "4px" }}
/>
)}
{Icon && (
<Icon fontSize="small" style={{ marginRight: "4px" }} />
)}
<span style={titleStyle}>{event.title}</span>
</div>
);
}}
eventDidMount={(arg) => {
const attendees = arg.event._def.extendedProps.attendee || [];
if (!calendars[arg.event._def.extendedProps.calId]) return;
const ownerEmails = new Set(
calendars[arg.event._def.extendedProps.calId].ownerEmails?.map(
(email) => email.toLowerCase()
)
);
const showSpecialDisplay = attendees.filter((att: userAttendee) =>
ownerEmails.has(att.cal_address.toLowerCase())
);
if (!showSpecialDisplay[0]) return;
// Clear previously applied classes
arg.el.classList.remove(
"declined-event",
"tentative-event",
"needs-action-event"
);
switch (showSpecialDisplay[0].partstat) {
case "DECLINED":
arg.el.classList.add("declined-event");
break;
case "TENTATIVE":
arg.el.classList.add("tentative-event");
break;
case "NEEDS-ACTION":
arg.el.classList.add("needs-action-event");
break;
default:
break;
}
}}
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}
/>
<EventPopover
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClosePopover}
onClose={eventHandlers.handleClosePopover}
selectedRange={selectedRange}
setSelectedRange={setSelectedRange}
calendarRef={calendarRef}
@@ -887,72 +438,10 @@ export default function CalendarApp({
tempEvent={eventDisplayedTemp}
anchorPosition={anchorPosition}
open={openEventDisplay}
onClose={handleCloseEventDisplay}
onClose={eventHandlers.handleCloseEventDisplay}
/>
)}
</div>
</main>
);
}
function eventToFullCalendarFormat(
filteredEvents: CalendarEvent[],
filteredTempEvents: CalendarEvent[],
userId: string | undefined
) {
return filteredEvents
.concat(filteredTempEvents.map((e) => ({ ...e, temp: true })))
.map((e) => {
if (e.calId.split("/")[0] === userId) {
return { ...e, editable: true };
}
return { ...e, editable: false };
});
}
function extractEvents(
selectedCalendars: string[],
calendars: Record<string, Calendars>
) {
let filteredEvents: CalendarEvent[] = [];
selectedCalendars.forEach((id) => {
if (calendars[id].events) {
filteredEvents = filteredEvents
.concat(
Object.keys(calendars[id].events).map(
(eventid) => calendars[id].events[eventid]
)
)
.filter((event) => !(event.status === "CANCELLED"));
}
});
return filteredEvents;
}
function updateCalsDetails(
selectedCalendars: string[],
pending: boolean,
calendars: Record<string, Calendars>,
rangeKey: string,
dispatch: Function,
calendarRange: { start: Date; end: Date },
calType?: "temp"
) {
selectedCalendars.forEach((id) => {
if (Object.keys(calendars[id].events).length > 0) {
return;
}
if (!pending && rangeKey) {
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
},
calType,
})
);
}
});
}
@@ -0,0 +1,197 @@
import { DateSelectArg } from "@fullcalendar/core";
import { CalendarApi } from "@fullcalendar/core";
import { CalendarEvent } from "../../../features/Events/EventsTypes";
import { Calendars } from "../../../features/Calendars/CalendarTypes";
import { getDeltaInMilliseconds } from "../../../utils/dateUtils";
import {
getCalendarDetailAsync,
getEventAsync,
putEventAsync,
updateEventLocal,
} from "../../../features/Calendars/CalendarSlice";
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
export interface EventHandlersProps {
setSelectedRange: (range: DateSelectArg | null) => void;
setAnchorEl: (el: HTMLElement | null) => void;
calendarRef: React.RefObject<CalendarApi | null>;
selectedCalendars: string[];
tempcalendars: Record<string, Calendars>;
calendarRange: { start: Date; end: Date };
dispatch: any;
setOpenEventDisplay: (open: boolean) => void;
setAnchorPosition: (position: { top: number; left: number } | null) => void;
setEventDisplayedId: (id: string) => void;
setEventDisplayedCalId: (id: string) => void;
setEventDisplayedTemp: (temp: boolean) => void;
calendars: Record<string, Calendars>;
}
export const createEventHandlers = (props: EventHandlersProps) => {
const {
setSelectedRange,
setAnchorEl,
calendarRef,
selectedCalendars,
tempcalendars,
calendarRange,
dispatch,
setOpenEventDisplay,
setAnchorPosition,
setEventDisplayedId,
setEventDisplayedCalId,
setEventDisplayedTemp,
calendars,
} = props;
const handleDateSelect = (selectInfo: DateSelectArg) => {
setSelectedRange(selectInfo);
setAnchorEl(document.body);
};
const handleClosePopover = () => {
calendarRef.current?.unselect();
setAnchorEl(null);
setSelectedRange(null);
selectedCalendars.forEach((calId) =>
dispatch(
getCalendarDetailAsync({
calId,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
},
})
)
);
Object.keys(tempcalendars).forEach((calId) =>
dispatch(
getCalendarDetailAsync({
calId,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
},
calType: "temp",
})
)
);
};
const handleCloseEventDisplay = () => {
setAnchorPosition(null);
setOpenEventDisplay(false);
};
const handleMonthUp = (
selectedMiniDate: Date,
setSelectedMiniDate: (date: Date) => void
) => {
setSelectedMiniDate(
new Date(selectedMiniDate.getFullYear(), selectedMiniDate.getMonth() - 1)
);
};
const handleMonthDown = (
selectedMiniDate: Date,
setSelectedMiniDate: (date: Date) => void
) => {
setSelectedMiniDate(
new Date(selectedMiniDate.getFullYear(), selectedMiniDate.getMonth() + 1)
);
};
const handleEventClick = (info: any) => {
info.jsEvent.preventDefault();
if (info.event.url) {
window.open(info.event.url);
} else {
console.log(info.event);
setOpenEventDisplay(true);
setAnchorPosition({
top: info.jsEvent.clientY,
left: info.jsEvent.clientX,
});
setEventDisplayedId(info.event.extendedProps.uid);
setEventDisplayedCalId(info.event.extendedProps.calId);
setEventDisplayedTemp(info.event._def.extendedProps.temp);
}
};
const handleEventAllow = (dropInfo: any, draggedEvent: any) => {
if (
draggedEvent?.extendedProps.uid &&
draggedEvent.extendedProps.uid.split("/")[1]
) {
return false;
}
return true;
};
const handleEventDrop = (arg: any) => {
if (!arg.event || !arg.event._def || !arg.event._def.extendedProps) {
return;
}
const event =
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
const totalDeltaMs = getDeltaInMilliseconds(arg.delta);
const originalStart = new Date(event.start);
const computedNewStart = new Date(originalStart.getTime() + totalDeltaMs);
const originalEnd = new Date(event.end ?? "");
const computedNewEnd = new Date(originalEnd.getTime() + totalDeltaMs);
const newEvent = {
...event,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
} as CalendarEvent;
dispatch(updateEventLocal({ calId: newEvent.calId, event: newEvent }));
dispatch(putEventAsync({ cal: calendars[newEvent.calId], newEvent }));
};
const handleEventResize = (arg: any) => {
if (!arg.event || !arg.event._def || !arg.event._def.extendedProps) {
return;
}
const event =
calendars[arg.event._def.extendedProps.calId].events[
arg.event._def.extendedProps.uid
];
if (event.uid.split("/")[1]) {
dispatch(getEventAsync(event));
}
const originalStart = new Date(event.start);
const computedNewStart = new Date(
originalStart.getTime() + getDeltaInMilliseconds(arg.startDelta)
);
const originalEnd = new Date(event.end ?? "");
const computedNewEnd = new Date(
originalEnd.getTime() + getDeltaInMilliseconds(arg.endDelta)
);
const newEvent = {
...event,
start: computedNewStart.toISOString(),
end: computedNewEnd.toISOString(),
} as CalendarEvent;
dispatch(putEventAsync({ cal: calendars[newEvent.calId], newEvent }));
};
return {
handleDateSelect,
handleClosePopover,
handleCloseEventDisplay,
handleMonthUp,
handleMonthDown,
handleEventClick,
handleEventAllow,
handleEventDrop,
handleEventResize,
};
};
@@ -0,0 +1,105 @@
export interface MouseHandlersProps {
calendarEl: HTMLElement;
}
export const createMouseHandlers = (props: MouseHandlersProps) => {
const { calendarEl } = props;
const handleMouseMove = (e: MouseEvent) => {
const timegridEl = calendarEl.querySelector(".fc-timegrid-body");
if (!timegridEl) return;
const allDayTable = calendarEl.querySelector(".fc-scrollgrid-sync-table");
if (allDayTable) {
const allDayRect = allDayTable.getBoundingClientRect();
if (e.clientY >= allDayRect.top && e.clientY <= allDayRect.bottom) {
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
return;
}
}
const target = e.target as HTMLElement;
if (target && target.closest(".fc-timegrid-slot-label")) {
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
return;
}
const dayColumns = timegridEl.querySelectorAll(".fc-timegrid-col");
if (dayColumns.length === 0) return;
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
let targetColumn: Element | null = null;
for (const column of dayColumns) {
const rect = column.getBoundingClientRect();
if (e.clientX >= rect.left && e.clientX <= rect.right) {
targetColumn = column;
break;
}
}
if (targetColumn) {
const rect = targetColumn.getBoundingClientRect();
const relativeY = e.clientY - rect.top;
const hourHeight = rect.height / 24;
const hourIndex = Math.floor(relativeY / hourHeight);
if (relativeY >= 0 && relativeY <= rect.height) {
const highlight = document.createElement("div");
highlight.className = "hour-highlight";
highlight.style.top = `${hourIndex * hourHeight}px`;
highlight.style.height = `${hourHeight}px`;
(targetColumn as HTMLElement).style.position = "relative";
targetColumn.appendChild(highlight);
}
}
};
const handleMouseLeave = () => {
const timegridEl = calendarEl.querySelector(".fc-timegrid-body");
if (timegridEl) {
timegridEl
.querySelectorAll(".hour-highlight")
.forEach((el: Element) => el.remove());
}
};
const addMouseEventListeners = () => {
calendarEl.addEventListener("mousemove", handleMouseMove);
calendarEl.addEventListener("mouseleave", handleMouseLeave);
(calendarEl as any).__calendarMouseMoveHandler = handleMouseMove;
(calendarEl as any).__calendarMouseLeaveHandler = handleMouseLeave;
};
const removeMouseEventListeners = () => {
if ((calendarEl as any).__calendarMouseMoveHandler) {
calendarEl.removeEventListener(
"mousemove",
(calendarEl as any).__calendarMouseMoveHandler
);
delete (calendarEl as any).__calendarMouseMoveHandler;
}
if ((calendarEl as any).__calendarMouseLeaveHandler) {
calendarEl.removeEventListener(
"mouseleave",
(calendarEl as any).__calendarMouseLeaveHandler
);
delete (calendarEl as any).__calendarMouseLeaveHandler;
}
};
return {
handleMouseMove,
handleMouseLeave,
addMouseEventListeners,
removeMouseEventListeners,
};
};
@@ -0,0 +1,245 @@
import React from "react";
import { CalendarApi } from "@fullcalendar/core";
import { updateSlotLabelVisibility } from "../utils/calendarUtils";
import { createMouseHandlers } from "./mouseHandlers";
import { userAttendee } from "../../../features/User/userDataTypes";
import { Calendars } from "../../../features/Calendars/CalendarTypes";
import HelpOutlineIcon from "@mui/icons-material/HelpOutline";
import AccessTimeIcon from "@mui/icons-material/AccessTime";
import LockIcon from "@mui/icons-material/Lock";
export interface ViewHandlersProps {
calendarRef: React.RefObject<CalendarApi | null>;
setSelectedDate: (date: Date) => void;
setSelectedMiniDate: (date: Date) => void;
onViewChange?: (view: string) => void;
calendars: Record<string, Calendars>;
tempcalendars: Record<string, Calendars>;
}
export const createViewHandlers = (props: ViewHandlersProps) => {
const {
calendarRef,
setSelectedDate,
setSelectedMiniDate,
onViewChange,
calendars,
tempcalendars,
} = props;
const handleDayHeaderDidMount = (arg: any) => {
if (arg.view.type === "timeGridWeek") {
const headerEl = arg.el;
const handleDayHeaderClick = () => {
calendarRef.current?.changeView("timeGridDay", arg.date);
setSelectedDate(new Date(arg.date));
setSelectedMiniDate(new Date(arg.date));
if (onViewChange) {
onViewChange("timeGridDay");
}
};
headerEl.addEventListener("click", handleDayHeaderClick);
(headerEl as any).__dayHeaderClickHandler = handleDayHeaderClick;
}
};
const handleDayHeaderWillUnmount = (arg: any) => {
const headerEl = arg.el;
if ((headerEl as any).__dayHeaderClickHandler) {
headerEl.removeEventListener(
"click",
(headerEl as any).__dayHeaderClickHandler
);
delete (headerEl as any).__dayHeaderClickHandler;
}
};
const handleViewDidMount = (arg: any) => {
const updateNowIndicator = () => {
const nowIndicatorArrow = document.querySelector(
".fc-timegrid-now-indicator-arrow"
) as HTMLElement;
if (nowIndicatorArrow) {
const now = new Date();
const timeString = now.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
nowIndicatorArrow.setAttribute("data-time", timeString);
updateSlotLabelVisibility(now);
}
};
updateNowIndicator();
const timeInterval = setInterval(updateNowIndicator, 60000);
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as Element;
if (
element.classList?.contains("fc-timegrid-now-indicator-arrow") ||
element.querySelector?.(".fc-timegrid-now-indicator-arrow") ||
element.classList?.contains("fc-timegrid-slot-label") ||
element.querySelector?.(".fc-timegrid-slot-label")
) {
setTimeout(() => {
updateNowIndicator();
updateSlotLabelVisibility(new Date());
}, 10);
}
}
});
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
(arg.el as any).__timeInterval = timeInterval;
(arg.el as any).__timeObserver = observer;
if (arg.view.type === "timeGridWeek" || arg.view.type === "timeGridDay") {
const calendarEl = document.querySelector(".fc") as HTMLElement;
if (calendarEl) {
const mouseHandlers = createMouseHandlers({ calendarEl });
mouseHandlers.addMouseEventListeners();
}
}
};
const handleViewWillUnmount = (arg: any) => {
if ((arg.el as any).__timeInterval) {
clearInterval((arg.el as any).__timeInterval);
delete (arg.el as any).__timeInterval;
}
if ((arg.el as any).__timeObserver) {
(arg.el as any).__timeObserver.disconnect();
delete (arg.el as any).__timeObserver;
}
const calendarEl = document.querySelector(".fc") as HTMLElement;
if (calendarEl) {
const mouseHandlers = createMouseHandlers({ calendarEl });
mouseHandlers.removeMouseEventListeners();
}
};
const handleEventContent = (arg: any) => {
const event = arg.event;
if (
(!event._def.extendedProps.temp &&
!calendars[arg.event._def.extendedProps.calId]) ||
(event._def.extendedProps.temp &&
!tempcalendars[arg.event._def.extendedProps.calId])
)
return;
const attendees = event._def.extendedProps.attendee || [];
const isPrivate =
event._def.extendedProps.class === "PRIVATE" ||
event._def.extendedProps.class === "CONFIDENTIAL";
let Icon = null;
let titleStyle: React.CSSProperties = {};
const ownerEmails = new Set(
(event._def.extendedProps.temp ? tempcalendars : calendars)[
arg.event._def.extendedProps.calId
].ownerEmails?.map((email) => email.toLowerCase())
);
const delegated = (
event._def.extendedProps.temp ? tempcalendars : calendars
)[arg.event._def.extendedProps.calId].delegated;
const showSpecialDisplay = attendees.filter((att: userAttendee) =>
ownerEmails.has(att.cal_address.toLowerCase())
);
if (!delegated && showSpecialDisplay.length === 0) return null;
switch (showSpecialDisplay?.[0]?.partstat) {
case "DECLINED":
Icon = null;
titleStyle.textDecoration = "line-through";
break;
case "TENTATIVE":
Icon = HelpOutlineIcon;
break;
case "NEEDS-ACTION":
Icon = AccessTimeIcon;
break;
case "ACCEPTED":
Icon = null;
break;
default:
break;
}
return React.createElement(
"div",
{ style: { display: "flex", alignItems: "center" } },
isPrivate &&
React.createElement(LockIcon, {
"data-testid": "lock-icon",
fontSize: "small",
style: { marginRight: "4px" },
}),
Icon &&
React.createElement(Icon, {
fontSize: "small",
style: { marginRight: "4px" },
}),
React.createElement("span", { style: titleStyle }, event.title)
);
};
const handleEventDidMount = (arg: any) => {
const attendees = arg.event._def.extendedProps.attendee || [];
if (!calendars[arg.event._def.extendedProps.calId]) return;
const ownerEmails = new Set(
calendars[arg.event._def.extendedProps.calId].ownerEmails?.map((email) =>
email.toLowerCase()
)
);
const showSpecialDisplay = attendees.filter((att: userAttendee) =>
ownerEmails.has(att.cal_address.toLowerCase())
);
if (!showSpecialDisplay[0]) return;
arg.el.classList.remove(
"declined-event",
"tentative-event",
"needs-action-event"
);
switch (showSpecialDisplay[0].partstat) {
case "DECLINED":
arg.el.classList.add("declined-event");
break;
case "TENTATIVE":
arg.el.classList.add("tentative-event");
break;
case "NEEDS-ACTION":
arg.el.classList.add("needs-action-event");
break;
default:
break;
}
};
return {
handleDayHeaderDidMount,
handleDayHeaderWillUnmount,
handleViewDidMount,
handleViewWillUnmount,
handleEventContent,
handleEventDidMount,
};
};
@@ -0,0 +1,93 @@
import { CalendarEvent } from "../../../features/Events/EventsTypes";
import { Calendars } from "../../../features/Calendars/CalendarTypes";
import { formatDateToYYYYMMDDTHHMMSS } from "../../../utils/dateUtils";
import { getCalendarDetailAsync } from "../../../features/Calendars/CalendarSlice";
export const updateSlotLabelVisibility = (currentTime: Date) => {
const slotLabels = document.querySelectorAll(".fc-timegrid-slot-label");
const currentMinutes = currentTime.getHours() * 60 + currentTime.getMinutes();
slotLabels.forEach((label) => {
const labelElement = label as HTMLElement;
const timeText = labelElement.textContent?.trim();
if (timeText && timeText.match(/^\d{1,2}:\d{2}$/)) {
const [hours, minutes] = timeText.split(":").map(Number);
const labelMinutes = hours * 60 + minutes;
let timeDiff = Math.abs(currentMinutes - labelMinutes);
if (timeDiff > 12 * 60) {
timeDiff = 24 * 60 - timeDiff;
}
if (timeDiff <= 15) {
labelElement.style.opacity = "0.2";
} else {
labelElement.style.opacity = "1";
}
}
});
};
export const eventToFullCalendarFormat = (
filteredEvents: CalendarEvent[],
filteredTempEvents: CalendarEvent[],
userId: string | undefined
) => {
return filteredEvents
.concat(filteredTempEvents.map((e) => ({ ...e, temp: true })))
.map((e) => {
if (e.calId.split("/")[0] === userId) {
return { ...e, editable: true };
}
return { ...e, editable: false };
});
};
export const extractEvents = (
selectedCalendars: string[],
calendars: Record<string, Calendars>
) => {
let filteredEvents: CalendarEvent[] = [];
selectedCalendars.forEach((id) => {
if (calendars[id].events) {
filteredEvents = filteredEvents
.concat(
Object.keys(calendars[id].events).map(
(eventid) => calendars[id].events[eventid]
)
)
.filter((event) => !(event.status === "CANCELLED"));
}
});
return filteredEvents;
};
export const updateCalsDetails = (
selectedCalendars: string[],
pending: boolean,
calendars: Record<string, Calendars>,
rangeKey: string,
dispatch: Function,
calendarRange: { start: Date; end: Date },
calType?: "temp"
) => {
selectedCalendars.forEach((id) => {
if (Object.keys(calendars[id].events).length > 0) {
return;
}
if (!pending && rangeKey) {
dispatch(
getCalendarDetailAsync({
calId: id,
match: {
start: formatDateToYYYYMMDDTHHMMSS(calendarRange.start),
end: formatDateToYYYYMMDDTHHMMSS(calendarRange.end),
},
calType,
})
);
}
});
};