feat(events): align Update modal with Create; extract shared form fields
- ux: close Update modal immediately on Save; run API in background - fix: remove stale single-instance when converting to repeating - test: adjust EventDisplay expectations - refactor: share form via components/Event/EventFormFields (used by Create/Update)
This commit is contained in:
committed by
Benoit TELLIER
parent
770257c03b
commit
42c953ccf9
@@ -12,7 +12,9 @@ import ICAL from "ical.js";
|
||||
export async function getEvent(event: CalendarEvent, isMaster?: boolean) {
|
||||
const response = await api.get(`dav${event.URL}`);
|
||||
const eventData = await response.text();
|
||||
|
||||
const eventical = ICAL.parse(eventData);
|
||||
|
||||
const eventjson = parseCalendarEvent(
|
||||
eventical[2][1][1],
|
||||
event.color ?? "",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,457 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { deleteEventAsync, putEventAsync } from "../Calendars/CalendarSlice";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import {
|
||||
Popover,
|
||||
Button,
|
||||
Box,
|
||||
Typography,
|
||||
ButtonGroup,
|
||||
Card,
|
||||
CardContent,
|
||||
Divider,
|
||||
IconButton,
|
||||
PopoverPosition,
|
||||
} from "@mui/material";
|
||||
import EditIcon from "@mui/icons-material/Edit";
|
||||
import EmailIcon from "@mui/icons-material/Email";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
||||
import LocationOnIcon from "@mui/icons-material/LocationOn";
|
||||
import VideocamIcon from "@mui/icons-material/Videocam";
|
||||
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
|
||||
import CircleIcon from "@mui/icons-material/Circle";
|
||||
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
|
||||
import EventDisplayModal, {
|
||||
InfoRow,
|
||||
renderAttendeeBadge,
|
||||
} from "./EventDisplay";
|
||||
import EventUpdateModal from "./EventUpdateModal";
|
||||
import { dlEvent, getEvent } from "./EventApi";
|
||||
import EventDuplication from "../../components/Event/EventDuplicate";
|
||||
import { CalendarEvent } from "./EventsTypes";
|
||||
|
||||
export default function EventPreviewModal({
|
||||
eventId,
|
||||
calId,
|
||||
tempEvent,
|
||||
anchorPosition,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
eventId: string;
|
||||
calId: string;
|
||||
tempEvent?: boolean;
|
||||
anchorPosition: PopoverPosition | null;
|
||||
open: boolean;
|
||||
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
|
||||
}) {
|
||||
const dispatch = useAppDispatch();
|
||||
const calendars = useAppSelector((state) => state.calendars);
|
||||
const calendar = tempEvent
|
||||
? calendars.templist[calId]
|
||||
: calendars.list[calId];
|
||||
const cachedEvent = calendar.events[eventId];
|
||||
const user = useAppSelector((state) => state.user);
|
||||
|
||||
const [showAllAttendees, setShowAllAttendees] = useState(false);
|
||||
const [openFullDisplay, setOpenFullDisplay] = useState(false);
|
||||
const [openUpdateModal, setOpenUpdateModal] = useState(false);
|
||||
const mailSpaUrl = (window as any).MAIL_SPA_URL ?? null;
|
||||
|
||||
// State for fresh event data
|
||||
const [currentEvent, setCurrentEvent] = useState<CalendarEvent | null>(null);
|
||||
|
||||
// Initialize with cached data immediately, then fetch fresh data
|
||||
useEffect(() => {
|
||||
if (open && cachedEvent) {
|
||||
// Show cached data immediately
|
||||
setCurrentEvent(cachedEvent);
|
||||
|
||||
// Fetch fresh data in background (only for non-temp events)
|
||||
if (!tempEvent) {
|
||||
const fetchFreshData = async () => {
|
||||
try {
|
||||
const freshData = await getEvent(cachedEvent);
|
||||
setCurrentEvent(freshData);
|
||||
} catch (err) {
|
||||
// Keep using cached data if API fails
|
||||
}
|
||||
};
|
||||
|
||||
fetchFreshData();
|
||||
}
|
||||
} else if (!open) {
|
||||
// Reset when popup closes
|
||||
setCurrentEvent(null);
|
||||
}
|
||||
}, [open, cachedEvent, eventId, calId, tempEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
// Only close if calendar is missing
|
||||
if (!calendar) {
|
||||
onClose({}, "backdropClick");
|
||||
}
|
||||
}, [calendar, onClose]);
|
||||
|
||||
if (!calendar || !currentEvent) return null;
|
||||
|
||||
const attendeeDisplayLimit = 3;
|
||||
|
||||
const attendees =
|
||||
currentEvent.attendee?.filter(
|
||||
(a) => a.cal_address !== currentEvent.organizer?.cal_address
|
||||
) || [];
|
||||
|
||||
const visibleAttendees = showAllAttendees
|
||||
? attendees
|
||||
: attendees.slice(0, attendeeDisplayLimit);
|
||||
|
||||
const currentUserAttendee = currentEvent.attendee?.find(
|
||||
(person) => person.cal_address === user.userData.email
|
||||
);
|
||||
|
||||
const organizer = currentEvent.attendee?.find(
|
||||
(a) => a.cal_address === currentEvent.organizer?.cal_address
|
||||
);
|
||||
|
||||
function handleRSVP(rsvp: string) {
|
||||
if (!currentEvent) return;
|
||||
|
||||
const newEvent: CalendarEvent = {
|
||||
...currentEvent,
|
||||
attendee: currentEvent.attendee?.map((a) =>
|
||||
a.cal_address === user.userData.email ? { ...a, partstat: rsvp } : a
|
||||
),
|
||||
};
|
||||
|
||||
dispatch(putEventAsync({ cal: calendar, newEvent }));
|
||||
onClose({}, "backdropClick");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popover
|
||||
open={open}
|
||||
anchorReference="anchorPosition"
|
||||
anchorPosition={anchorPosition ?? undefined}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Card style={{ width: 300, padding: 16, position: "relative" }}>
|
||||
{/* Top-right buttons */}
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 8,
|
||||
right: 8,
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{(window as any).DEBUG && (
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
const icsContent = await dlEvent(currentEvent);
|
||||
const blob = new Blob([icsContent], {
|
||||
type: "text/calendar",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `${eventId}.ics`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
>
|
||||
<FileDownloadOutlinedIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
<EventDuplication event={currentEvent} onClose={onClose} />
|
||||
{mailSpaUrl && attendees.length > 0 && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`${mailSpaUrl}/mailto/?uri=mailto:${currentEvent.attendee
|
||||
.map((a) => a.cal_address)
|
||||
.filter((mail) => mail !== user.userData.email)
|
||||
.join(",")}?subject=${currentEvent.title}`
|
||||
)
|
||||
}
|
||||
>
|
||||
<EmailIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{user.userData.email !== currentEvent.organizer?.cal_address && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setOpenFullDisplay(!openFullDisplay);
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon fontSize="small" />
|
||||
</IconButton>
|
||||
)}
|
||||
{user.userData.email === currentEvent.organizer?.cal_address && (
|
||||
<>
|
||||
<IconButton
|
||||
size="small"
|
||||
data-testid="edit-button"
|
||||
onClick={() => {
|
||||
setOpenUpdateModal(true);
|
||||
}}
|
||||
>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => {
|
||||
onClose({}, "backdropClick");
|
||||
dispatch(
|
||||
deleteEventAsync({
|
||||
calId,
|
||||
eventId,
|
||||
eventURL: currentEvent.URL,
|
||||
})
|
||||
);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => onClose({}, "backdropClick")}
|
||||
>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<CardContent style={{ paddingTop: 12 }}>
|
||||
{currentEvent.title && (
|
||||
<Typography
|
||||
variant="h6"
|
||||
fontWeight="bold"
|
||||
style={{
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
gutterBottom
|
||||
>
|
||||
{currentEvent.title}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
{/* Time info*/}
|
||||
<Typography variant="body2" color="textSecondary" gutterBottom>
|
||||
{formatDate(new Date(currentEvent.start), currentEvent.allday)}
|
||||
{currentEvent.end &&
|
||||
formatEnd(
|
||||
new Date(currentEvent.start),
|
||||
new Date(currentEvent.end),
|
||||
currentEvent.allday
|
||||
) &&
|
||||
` – ${formatEnd(new Date(currentEvent.start), new Date(currentEvent.end), currentEvent.allday)}`}
|
||||
</Typography>
|
||||
|
||||
{/* Location */}
|
||||
{currentEvent.location && (
|
||||
<InfoRow
|
||||
icon={<LocationOnIcon style={{ fontSize: 18 }} />}
|
||||
text={currentEvent.location}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Video */}
|
||||
{currentEvent.x_openpass_videoconference && (
|
||||
<InfoRow
|
||||
icon={<VideocamIcon style={{ fontSize: 18 }} />}
|
||||
text="Video conference available"
|
||||
data={currentEvent.x_openpass_videoconference}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Attendees */}
|
||||
{currentEvent.attendee?.length > 0 && (
|
||||
<Box style={{ marginBottom: 8 }}>
|
||||
<Typography variant="subtitle2">Attendees:</Typography>
|
||||
{organizer && renderAttendeeBadge(organizer, "org", true)}
|
||||
{visibleAttendees.map((a, idx) =>
|
||||
renderAttendeeBadge(a, idx.toString())
|
||||
)}
|
||||
{attendees.length > attendeeDisplayLimit && (
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="primary"
|
||||
style={{ cursor: "pointer", marginTop: 4 }}
|
||||
onClick={() => setShowAllAttendees(!showAllAttendees)}
|
||||
>
|
||||
{showAllAttendees
|
||||
? "Show less"
|
||||
: `Show more (${
|
||||
attendees.length - attendeeDisplayLimit
|
||||
} more)`}
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{currentEvent.error && (
|
||||
<InfoRow
|
||||
icon={
|
||||
<ErrorOutlineIcon color="error" style={{ fontSize: 18 }} />
|
||||
}
|
||||
text={currentEvent.error}
|
||||
error
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Calendar color dot */}
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<CalendarTodayIcon style={{ fontSize: 16 }} />
|
||||
<CircleIcon
|
||||
style={{
|
||||
color: calendar.color ?? "#3788D8",
|
||||
width: 12,
|
||||
height: 12,
|
||||
}}
|
||||
/>
|
||||
<Typography variant="body2">{calendar.name}</Typography>
|
||||
</Box>
|
||||
|
||||
<Divider style={{ marginBottom: 8 }} />
|
||||
|
||||
{/* RSVP */}
|
||||
{currentUserAttendee && (
|
||||
<Box>
|
||||
<Typography variant="body2" style={{ marginBottom: 8 }}>
|
||||
Will you attend?
|
||||
</Typography>
|
||||
<ButtonGroup size="small" fullWidth>
|
||||
<Button
|
||||
color={
|
||||
currentUserAttendee.partstat === "ACCEPTED"
|
||||
? "success"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("ACCEPTED")}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
color={
|
||||
currentUserAttendee.partstat === "TENTATIVE"
|
||||
? "warning"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("TENTATIVE")}
|
||||
>
|
||||
Maybe
|
||||
</Button>
|
||||
<Button
|
||||
color={
|
||||
currentUserAttendee.partstat === "DECLINED"
|
||||
? "error"
|
||||
: "primary"
|
||||
}
|
||||
onClick={() => handleRSVP("DECLINED")}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Description */}
|
||||
{currentEvent.description && (
|
||||
<Typography variant="body2" style={{ marginTop: 8 }}>
|
||||
{currentEvent.description}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Popover>
|
||||
<EventDisplayModal
|
||||
open={openFullDisplay}
|
||||
onClose={() => setOpenFullDisplay(false)}
|
||||
eventId={eventId}
|
||||
calId={calId}
|
||||
eventData={currentEvent}
|
||||
/>
|
||||
<EventUpdateModal
|
||||
eventId={eventId}
|
||||
calId={calId}
|
||||
open={openUpdateModal}
|
||||
onClose={() => setOpenUpdateModal(false)}
|
||||
eventData={currentEvent}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDate(date: Date, allday?: boolean) {
|
||||
if (allday) {
|
||||
return new Date(date).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
});
|
||||
} else {
|
||||
return new Date(date).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
weekday: "long",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatEnd(start: Date, end: Date, allday?: boolean) {
|
||||
const startDate = new Date(start);
|
||||
const endDate = new Date(end);
|
||||
|
||||
const sameDay =
|
||||
startDate.getFullYear() === endDate.getFullYear() &&
|
||||
startDate.getMonth() === endDate.getMonth() &&
|
||||
startDate.getDate() === endDate.getDate();
|
||||
|
||||
if (allday) {
|
||||
return sameDay
|
||||
? null
|
||||
: endDate.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
} else {
|
||||
if (sameDay) {
|
||||
return endDate.toLocaleTimeString(undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
return endDate.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
import { Box, Button } from "@mui/material";
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import { ResponsiveDialog } from "../../components/Dialog";
|
||||
import {
|
||||
putEventAsync,
|
||||
removeEvent,
|
||||
moveEventAsync,
|
||||
} from "../Calendars/CalendarSlice";
|
||||
import { Calendars } from "../Calendars/CalendarTypes";
|
||||
import { userAttendee } from "../User/userDataTypes";
|
||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||
import { TIMEZONES } from "../../utils/timezone-data";
|
||||
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
|
||||
import EventFormFields, {
|
||||
formatLocalDateTime,
|
||||
} from "../../components/Event/EventFormFields";
|
||||
import { getEvent } from "./EventApi";
|
||||
|
||||
function EventUpdateModal({
|
||||
eventId,
|
||||
calId,
|
||||
open,
|
||||
onClose,
|
||||
eventData,
|
||||
}: {
|
||||
eventId: string;
|
||||
calId: string;
|
||||
open: boolean;
|
||||
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
|
||||
eventData?: CalendarEvent | null;
|
||||
}) {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Get event from Redux store (cached data) as fallback
|
||||
const cachedEvent = useAppSelector(
|
||||
(state) => state.calendars.list[calId]?.events[eventId]
|
||||
);
|
||||
|
||||
// State for fresh event data
|
||||
const [freshEvent, setFreshEvent] = useState<CalendarEvent | null>(null);
|
||||
|
||||
// Use fresh data if available, otherwise use eventData from props, otherwise use cached data
|
||||
const event = freshEvent || eventData || cachedEvent;
|
||||
|
||||
// Fetch fresh event data when modal opens
|
||||
useEffect(() => {
|
||||
if (open && cachedEvent && !eventData) {
|
||||
const fetchFreshData = async () => {
|
||||
try {
|
||||
const freshData = await getEvent(cachedEvent);
|
||||
setFreshEvent(freshData);
|
||||
} catch (err) {
|
||||
// Keep using cached data if API fails
|
||||
}
|
||||
};
|
||||
|
||||
fetchFreshData();
|
||||
}
|
||||
}, [open, cachedEvent, eventData]);
|
||||
|
||||
const user = useAppSelector((state) => state.user);
|
||||
|
||||
const calendarsList = useAppSelector((state) => state.calendars.list);
|
||||
|
||||
const userPersonnalCalendars: Calendars[] = useMemo(() => {
|
||||
const allCalendars = Object.values(calendarsList);
|
||||
return allCalendars.filter(
|
||||
(c) => c.id?.split("/")[0] === user.userData?.openpaasId
|
||||
);
|
||||
}, [calendarsList, user.userData?.openpaasId]);
|
||||
|
||||
// Helper function to resolve timezone aliases
|
||||
const resolveTimezone = (tzName: string): string => {
|
||||
if (TIMEZONES.zones[tzName]) {
|
||||
return tzName;
|
||||
}
|
||||
if (TIMEZONES.aliases[tzName]) {
|
||||
return TIMEZONES.aliases[tzName].aliasTo;
|
||||
}
|
||||
return tzName;
|
||||
};
|
||||
|
||||
const timezoneList = useMemo(() => {
|
||||
const zones = Object.keys(TIMEZONES.zones).sort();
|
||||
const browserTz = resolveTimezone(
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
);
|
||||
|
||||
const getTimezoneOffset = (tzName: string): string => {
|
||||
const resolvedTz = resolveTimezone(tzName);
|
||||
const tzData = TIMEZONES.zones[resolvedTz];
|
||||
if (!tzData) return "";
|
||||
|
||||
const icsMatch = tzData.ics.match(/TZOFFSETTO:([+-]\d{4})/);
|
||||
if (!icsMatch) return "";
|
||||
|
||||
const offset = icsMatch[1];
|
||||
const hours = parseInt(offset.slice(0, 3));
|
||||
const minutes = parseInt(offset.slice(3));
|
||||
|
||||
if (minutes === 0) {
|
||||
return `UTC${hours >= 0 ? "+" : ""}${hours}`;
|
||||
}
|
||||
return `UTC${hours >= 0 ? "+" : ""}${hours}:${Math.abs(minutes).toString().padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return { zones, browserTz, getTimezoneOffset };
|
||||
}, []);
|
||||
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
const [showDescription, setShowDescription] = useState(
|
||||
event?.description ? true : false
|
||||
);
|
||||
const [showRepeat, setShowRepeat] = useState(
|
||||
event?.repetition?.freq ? true : false
|
||||
);
|
||||
|
||||
// Form state - initialize with empty values
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [location, setLocation] = useState("");
|
||||
const [start, setStart] = useState("");
|
||||
const [end, setEnd] = useState("");
|
||||
const [allday, setAllDay] = useState(false);
|
||||
const [repetition, setRepetition] = useState<RepetitionObject>(
|
||||
{} as RepetitionObject
|
||||
);
|
||||
const [alarm, setAlarm] = useState("");
|
||||
const [busy, setBusy] = useState("OPAQUE");
|
||||
const [eventClass, setEventClass] = useState("PUBLIC");
|
||||
const [timezone, setTimezone] = useState(
|
||||
resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||
);
|
||||
const [newCalId, setNewCalId] = useState(calId);
|
||||
const [calendarid, setCalendarid] = useState(0);
|
||||
|
||||
const [attendees, setAttendees] = useState<userAttendee[]>([]);
|
||||
const [hasVideoConference, setHasVideoConference] = useState(false);
|
||||
const [meetingLink, setMeetingLink] = useState<string | null>(null);
|
||||
const [important, setImportant] = useState(false);
|
||||
|
||||
const resetAllStateToDefault = useCallback(() => {
|
||||
setShowMore(false);
|
||||
setShowDescription(false);
|
||||
setShowRepeat(false);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setAttendees([]);
|
||||
setLocation("");
|
||||
setStart("");
|
||||
setEnd("");
|
||||
setCalendarid(0);
|
||||
setAllDay(false);
|
||||
setRepetition({} as RepetitionObject);
|
||||
setAlarm("");
|
||||
setEventClass("PUBLIC");
|
||||
setBusy("OPAQUE");
|
||||
setImportant(false);
|
||||
setTimezone(
|
||||
resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone)
|
||||
);
|
||||
setHasVideoConference(false);
|
||||
setMeetingLink(null);
|
||||
}, []);
|
||||
|
||||
// Prevent repeated initialization loops
|
||||
const initializedKeyRef = useRef<string | null>(null);
|
||||
|
||||
// Initialize form state when event data is available
|
||||
useEffect(() => {
|
||||
if (event && open) {
|
||||
// Editing existing event - populate fields with event data
|
||||
setTitle(event.title ?? "");
|
||||
setDescription(event.description ?? "");
|
||||
setLocation(event.location ?? "");
|
||||
|
||||
// Handle all-day events properly
|
||||
const isAllDay = event.allday ?? false;
|
||||
setAllDay(isAllDay);
|
||||
|
||||
// Format dates based on all-day status
|
||||
if (event.start) {
|
||||
const startDate = new Date(event.start);
|
||||
if (isAllDay) {
|
||||
// For all-day events, use date format (YYYY-MM-DD)
|
||||
setStart(startDate.toISOString().split("T")[0]);
|
||||
} else {
|
||||
// For timed events, use datetime format
|
||||
setStart(formatLocalDateTime(startDate));
|
||||
}
|
||||
} else {
|
||||
setStart("");
|
||||
}
|
||||
|
||||
if (event.end) {
|
||||
const endDate = new Date(event.end);
|
||||
if (isAllDay) {
|
||||
// For all-day events, use date format (YYYY-MM-DD)
|
||||
setEnd(endDate.toISOString().split("T")[0]);
|
||||
} else {
|
||||
// For timed events, use datetime format
|
||||
setEnd(formatLocalDateTime(endDate));
|
||||
}
|
||||
} else {
|
||||
setEnd("");
|
||||
}
|
||||
|
||||
// Find correct calendar index
|
||||
const currentCalIndex = userPersonnalCalendars.findIndex(
|
||||
(cal) => cal.id === calId
|
||||
);
|
||||
setCalendarid(currentCalIndex >= 0 ? currentCalIndex : 0);
|
||||
|
||||
// Handle repetition properly - check both current event and base event
|
||||
const baseEventId = event.uid.split("/")[0];
|
||||
const baseEvent = calendarsList[calId]?.events[baseEventId];
|
||||
const repetitionSource = event.repetition || baseEvent?.repetition;
|
||||
|
||||
if (repetitionSource && repetitionSource.freq) {
|
||||
const repetitionData: RepetitionObject = {
|
||||
freq: repetitionSource.freq,
|
||||
interval: repetitionSource.interval || 1,
|
||||
occurrences: repetitionSource.occurrences,
|
||||
endDate: repetitionSource.endDate,
|
||||
byday: repetitionSource.byday || null,
|
||||
};
|
||||
setRepetition(repetitionData);
|
||||
setShowRepeat(true);
|
||||
} else {
|
||||
setRepetition({} as RepetitionObject);
|
||||
setShowRepeat(false);
|
||||
}
|
||||
|
||||
setAttendees(
|
||||
event.attendee
|
||||
? event.attendee.filter(
|
||||
(a) => a.cal_address !== event.organizer?.cal_address
|
||||
)
|
||||
: []
|
||||
);
|
||||
setAlarm(event.alarm?.trigger ?? "");
|
||||
setEventClass(event.class ?? "PUBLIC");
|
||||
setBusy(event.transp ?? "OPAQUE");
|
||||
|
||||
const resolvedTimezone = event.timezone
|
||||
? resolveTimezone(event.timezone)
|
||||
: resolveTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
|
||||
setTimezone(resolvedTimezone);
|
||||
setHasVideoConference(event.x_openpass_videoconference ? true : false);
|
||||
setMeetingLink(event.x_openpass_videoconference || null);
|
||||
setNewCalId(event.calId || calId);
|
||||
|
||||
// Update description to include video conference footer if exists
|
||||
if (event.x_openpass_videoconference && event.description) {
|
||||
const hasVideoFooter = event.description.includes("Visio:");
|
||||
if (!hasVideoFooter) {
|
||||
setDescription(
|
||||
addVideoConferenceToDescription(
|
||||
event.description,
|
||||
event.x_openpass_videoconference
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setDescription(event.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [open, event, calId, userPersonnalCalendars, calendarsList]);
|
||||
|
||||
const handleClose = () => {
|
||||
onClose({}, "backdropClick");
|
||||
resetAllStateToDefault();
|
||||
initializedKeyRef.current = null;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!event) return;
|
||||
|
||||
const organizer = event.organizer;
|
||||
|
||||
const targetCalendar = userPersonnalCalendars[calendarid];
|
||||
if (!targetCalendar) {
|
||||
console.error("Target calendar not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle start and end dates based on all-day status
|
||||
let startDate: string;
|
||||
let endDate: string;
|
||||
|
||||
if (allday) {
|
||||
// For all-day events, use date format (YYYY-MM-DD)
|
||||
startDate = new Date(start).toISOString().split("T")[0];
|
||||
endDate = new Date(end).toISOString().split("T")[0];
|
||||
} else {
|
||||
// For timed events, use full datetime
|
||||
startDate = new Date(start).toISOString();
|
||||
endDate = new Date(end).toISOString();
|
||||
}
|
||||
|
||||
const newEvent: CalendarEvent = {
|
||||
calId: newCalId || calId,
|
||||
title,
|
||||
URL: event.URL ?? `/calendars/${newCalId || calId}/${event.uid}.ics`,
|
||||
start: startDate,
|
||||
end: endDate,
|
||||
allday,
|
||||
uid: event.uid,
|
||||
description,
|
||||
location,
|
||||
repetition,
|
||||
class: eventClass,
|
||||
organizer: organizer,
|
||||
timezone,
|
||||
attendee: organizer
|
||||
? [organizer as userAttendee, ...attendees]
|
||||
: attendees,
|
||||
transp: busy,
|
||||
color: targetCalendar?.color,
|
||||
alarm: { trigger: alarm, action: "EMAIL" },
|
||||
x_openpass_videoconference: meetingLink || undefined,
|
||||
};
|
||||
|
||||
// Close popup immediately for better UX
|
||||
onClose({}, "backdropClick");
|
||||
|
||||
// If converting from a non-repeating event to a repeating one,
|
||||
// remove the original single instance to avoid duplicates on the grid
|
||||
if (!event.repetition?.freq && repetition?.freq) {
|
||||
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
|
||||
}
|
||||
|
||||
// Handle recurrence instances
|
||||
const [baseId, recurrenceId] = event.uid.split("/");
|
||||
if (recurrenceId) {
|
||||
Object.keys(targetCalendar.events).forEach((element) => {
|
||||
if (element.split("/")[0] === baseId) {
|
||||
dispatch(removeEvent({ calendarUid: calId, eventUid: element }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Execute API calls in background
|
||||
dispatch(
|
||||
putEventAsync({
|
||||
cal: targetCalendar,
|
||||
newEvent,
|
||||
})
|
||||
);
|
||||
|
||||
// Handle calendar change
|
||||
if (newCalId !== calId) {
|
||||
dispatch(
|
||||
moveEventAsync({
|
||||
cal: targetCalendar,
|
||||
newEvent,
|
||||
newURL: `/calendars/${newCalId}/${event.uid}.ics`,
|
||||
})
|
||||
);
|
||||
dispatch(removeEvent({ calendarUid: calId, eventUid: event.uid }));
|
||||
}
|
||||
};
|
||||
|
||||
const dialogActions = (
|
||||
<Box display="flex" justifyContent="space-between" width="100%" px={2}>
|
||||
{!showMore && (
|
||||
<Button onClick={() => setShowMore(!showMore)}>Show More</Button>
|
||||
)}
|
||||
<Box display="flex" gap={1} ml={showMore ? "auto" : 0}>
|
||||
<Button variant="outlined" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="contained" onClick={handleSave} disabled={!title}>
|
||||
Save
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (!event) return null;
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title="Update Event"
|
||||
isExpanded={showMore}
|
||||
onExpandToggle={() => setShowMore(!showMore)}
|
||||
actions={dialogActions}
|
||||
>
|
||||
<EventFormFields
|
||||
title={title}
|
||||
setTitle={setTitle}
|
||||
description={description}
|
||||
setDescription={setDescription}
|
||||
location={location}
|
||||
setLocation={setLocation}
|
||||
start={start}
|
||||
setStart={setStart}
|
||||
end={end}
|
||||
setEnd={setEnd}
|
||||
allday={allday}
|
||||
setAllDay={setAllDay}
|
||||
repetition={repetition}
|
||||
setRepetition={setRepetition}
|
||||
attendees={attendees}
|
||||
setAttendees={setAttendees}
|
||||
alarm={alarm}
|
||||
setAlarm={setAlarm}
|
||||
busy={busy}
|
||||
setBusy={setBusy}
|
||||
eventClass={eventClass}
|
||||
setEventClass={setEventClass}
|
||||
timezone={timezone}
|
||||
setTimezone={setTimezone}
|
||||
calendarid={calendarid}
|
||||
setCalendarid={setCalendarid}
|
||||
important={important}
|
||||
setImportant={setImportant}
|
||||
hasVideoConference={hasVideoConference}
|
||||
setHasVideoConference={setHasVideoConference}
|
||||
meetingLink={meetingLink}
|
||||
setMeetingLink={setMeetingLink}
|
||||
showMore={showMore}
|
||||
showDescription={showDescription}
|
||||
setShowDescription={setShowDescription}
|
||||
showRepeat={showRepeat}
|
||||
setShowRepeat={setShowRepeat}
|
||||
userPersonnalCalendars={userPersonnalCalendars}
|
||||
timezoneList={timezoneList}
|
||||
onCalendarChange={(newCalendarId) => {
|
||||
const selectedCalendar = userPersonnalCalendars[newCalendarId];
|
||||
if (selectedCalendar) {
|
||||
setNewCalId(selectedCalendar.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventUpdateModal;
|
||||
@@ -30,7 +30,6 @@ export interface CalendarEvent {
|
||||
export interface RepetitionObject {
|
||||
freq: string;
|
||||
interval?: number;
|
||||
selectedDays?: string[];
|
||||
byday?: string[] | null;
|
||||
occurrences?: number;
|
||||
endDate?: string;
|
||||
|
||||
Reference in New Issue
Block a user