import React from "react"; import { Box, Button, Checkbox, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, Select, SelectChangeEvent, TextField, Typography, ToggleButtonGroup, ToggleButton, } from "@mui/material"; import { Description as DescriptionIcon, Public as PublicIcon, Lock as LockIcon, CameraAlt as VideocamIcon, ContentCopy as CopyIcon, Close as DeleteIcon, } from "@mui/icons-material"; import AttendeeSelector from "../Attendees/AttendeeSearch"; import RepeatEvent from "./EventRepeat"; import { RepetitionObject } from "../../features/Events/EventsTypes"; import { userAttendee } from "../../features/User/userDataTypes"; import { Calendars } from "../../features/Calendars/CalendarTypes"; import { generateMeetingLink, addVideoConferenceToDescription, } from "../../utils/videoConferenceUtils"; import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete"; import { CalendarItemList } from "../Calendar/CalendarItemList"; // Helper component for field with label export const FieldWithLabel = React.memo( ({ label, isExpanded, children, }: { label: string | React.ReactNode; isExpanded: boolean; children: React.ReactNode; }) => { if (!isExpanded) { // Normal mode: label on top return ( {label} {children} ); } // Extended mode: label on left return ( {label} {children} ); } ); FieldWithLabel.displayName = "FieldWithLabel"; interface EventFormFieldsProps { // Form state title: string; setTitle: (title: string) => void; description: string; setDescription: (description: string) => void; location: string; setLocation: (location: string) => void; start: string; setStart: (start: string) => void; end: string; setEnd: (end: string) => void; allday: boolean; setAllDay: (allday: boolean) => void; repetition: RepetitionObject; setRepetition: (repetition: RepetitionObject) => void; typeOfAction?: "solo" | "all"; attendees: userAttendee[]; setAttendees: (attendees: userAttendee[]) => void; alarm: string; setAlarm: (alarm: string) => void; busy: string; setBusy: (busy: string) => void; eventClass: string; setEventClass: (eventClass: string) => void; timezone: string; setTimezone: (timezone: string) => void; calendarid: string; setCalendarid: (calendarid: string) => void; hasVideoConference: boolean; setHasVideoConference: (hasVideoConference: boolean) => void; meetingLink: string | null; setMeetingLink: (meetingLink: string | null) => void; // UI state showMore: boolean; showDescription: boolean; setShowDescription: (showDescription: boolean) => void; showRepeat: boolean; setShowRepeat: (showRepeat: boolean) => void; isOpen?: boolean; // Data userPersonnalCalendars: Calendars[]; timezoneList: { zones: string[]; browserTz: string; getTimezoneOffset: (tzName: string) => string; }; // Event handlers onStartChange?: (newStart: string) => void; onEndChange?: (newEnd: string) => void; onAllDayChange?: ( newAllDay: boolean, newStart: string, newEnd: string ) => void; onCalendarChange?: (newCalendarId: string) => void; // Validation onValidationChange?: (isValid: boolean) => void; showValidationErrors?: boolean; } export default function EventFormFields({ title, setTitle, description, setDescription, location, setLocation, start, setStart, end, setEnd, allday, setAllDay, repetition, setRepetition, typeOfAction, attendees, setAttendees, alarm, setAlarm, busy, setBusy, eventClass, setEventClass, timezone, setTimezone, calendarid, setCalendarid, hasVideoConference, setHasVideoConference, meetingLink, setMeetingLink, showMore, showDescription, setShowDescription, showRepeat, setShowRepeat, isOpen = false, userPersonnalCalendars, timezoneList, onStartChange, onEndChange, onAllDayChange, onCalendarChange, onValidationChange, showValidationErrors = false, }: EventFormFieldsProps) { // Store original time before toggling to all-day const originalTimeRef = React.useRef<{ start: string; end: string } | null>( null ); // Ref for title input field to enable auto-focus const titleInputRef = React.useRef(null); // Track previous showMore state to detect changes const prevShowMoreRef = React.useRef(undefined); // Auto-focus title field when modal opens (skip in test environment) React.useEffect(() => { if (isOpen) { if (titleInputRef.current && process.env.NODE_ENV !== "test") { // Use setTimeout to ensure the dialog is fully rendered const timer = setTimeout(() => { titleInputRef.current?.focus(); }, 100); return () => clearTimeout(timer); } } }, [isOpen]); // Auto-focus title field when toggling between normal and extended mode React.useEffect(() => { // Skip on initial render (when prevShowMoreRef is undefined) if ( prevShowMoreRef.current !== undefined && isOpen && process.env.NODE_ENV !== "test" ) { const hasChanged = prevShowMoreRef.current !== showMore; if (hasChanged) { // Simple setTimeout approach with sufficient delay for layout changes const timer = setTimeout(() => { if (titleInputRef.current) { titleInputRef.current.focus(); } }, 150); // Update previous value before returning cleanup prevShowMoreRef.current = showMore; return () => clearTimeout(timer); } } // Always update previous value prevShowMoreRef.current = showMore; }, [showMore, isOpen]); // Validation logic const validateForm = React.useCallback(() => { // Title validation const isTitleValid = title.trim().length > 0; const shouldShowTitleError = showValidationErrors && !isTitleValid; // Date/time validation let isDateTimeValid = true; let dateTimeError = ""; let startError = ""; // Convert to string if needed const startStr = typeof start === "string" ? start : String(start || ""); const endStr = typeof end === "string" ? end : String(end || ""); // Check if start date is provided and valid if (!start || startStr.trim() === "") { isDateTimeValid = false; dateTimeError = "Start date/time is required"; startError = "Start date/time is required"; } else if (isNaN(new Date(start).getTime())) { isDateTimeValid = false; dateTimeError = "Invalid start date/time"; startError = "Invalid start date/time"; } // Check if end date is provided and valid else if (!end || endStr.trim() === "") { isDateTimeValid = false; dateTimeError = "End date/time is required"; } else if (isNaN(new Date(end).getTime())) { isDateTimeValid = false; dateTimeError = "Invalid end date/time"; } // Check if end is after start else { const startDate = new Date(start); const endDate = new Date(end); if (endDate <= startDate) { isDateTimeValid = false; dateTimeError = "End time must be after start time"; } } const isValid = isTitleValid && isDateTimeValid; return { isValid, errors: { title: shouldShowTitleError ? "Title is required" : "", start: showValidationErrors ? startError : "", dateTime: showValidationErrors ? dateTimeError : "", }, }; }, [title, start, end, showValidationErrors]); // Notify parent about validation changes React.useEffect(() => { const validation = validateForm(); onValidationChange?.(validation.isValid); }, [validateForm, onValidationChange]); const validation = validateForm(); const handleAddVideoConference = () => { const newMeetingLink = generateMeetingLink(); const updatedDescription = addVideoConferenceToDescription( description, newMeetingLink ); setDescription(updatedDescription); setHasVideoConference(true); setMeetingLink(newMeetingLink); }; const handleCopyMeetingLink = async () => { if (meetingLink) { try { await navigator.clipboard.writeText(meetingLink); } catch (err) { console.error("Failed to copy link:", err); } } }; const handleDeleteVideoConference = () => { // Remove video conference footer from description const updatedDescription = description.replace( /\nVisio: https?:\/\/[^\s]+/, "" ); setDescription(updatedDescription); setHasVideoConference(false); setMeetingLink(null); }; const handleStartChange = (newStart: string) => { if (onStartChange) { onStartChange(newStart); } else { setStart(newStart); } }; const handleEndChange = (newEnd: string) => { if (onEndChange) { onEndChange(newEnd); } else { setEnd(newEnd); } }; const handleAllDayChange = ( newAllDay: boolean, newStart: string, newEnd: string ) => { setAllDay(newAllDay); onAllDayChange?.(newAllDay, newStart, newEnd); }; const handleCalendarChange = (newCalendarId: string) => { setCalendarid(newCalendarId); onCalendarChange?.(newCalendarId); }; const getRoundedCurrentTime = () => { const now = new Date(); const minutes = now.getMinutes(); const roundedMinutes = minutes < 30 ? 0 : 30; now.setMinutes(roundedMinutes); now.setSeconds(0); now.setMilliseconds(0); return now; }; return ( <> Title * } isExpanded={showMore} > { setTitle(e.target.value); }} error={!!validation.errors.title} helperText={validation.errors.title} size="small" margin="dense" inputRef={titleInputRef} /> {!showDescription && ( )} {showDescription && ( setDescription(e.target.value)} size="small" margin="dense" multiline minRows={2} maxRows={10} sx={{ "& .MuiInputBase-root": { maxHeight: "33%", overflowY: "auto", }, "& textarea": { resize: "vertical", }, }} /> )} {showMore && ( Start )} handleStartChange(e.target.value)} error={!!validation.errors.start} helperText={validation.errors.start} size="small" margin="dense" InputLabelProps={{ shrink: true }} /> {showMore && ( End )} handleEndChange(e.target.value)} error={!!validation.errors.dateTime} helperText={validation.errors.dateTime} size="small" margin="dense" InputLabelProps={{ shrink: true }} /> { const newAllDay = !allday; let newStart = start; let newEnd = end; if (newAllDay) { // OFF => ON: Save original time before converting to all-day if (start.includes("T")) { originalTimeRef.current = { start, end }; } // Convert to date-only format only if both dates are valid if (start && end) { const startDate = start.includes("T") ? new Date(start) : new Date(start + "T00:00:00"); const endDate = end.includes("T") ? new Date(end) : new Date(end + "T00:00:00"); // Check if dates are valid before proceeding if ( !isNaN(startDate.getTime()) && !isNaN(endDate.getTime()) ) { // If same day, extend end to next day if (endDate.getDate() === startDate.getDate()) { endDate.setDate(startDate.getDate() + 1); } const formattedEnd = formatLocalDateTime(endDate); if (formattedEnd) { newEnd = formattedEnd; } } } } else { // ON => OFF: Restore original time if available if (originalTimeRef.current) { // Extract hours/minutes from original time strings const originalStartMatch = originalTimeRef.current.start.match(/T(\d{2}):(\d{2})/); const originalEndMatch = originalTimeRef.current.end.match(/T(\d{2}):(\d{2})/); if (originalStartMatch && originalEndMatch) { // Parse current date (YYYY-MM-DD) const currentDate = start.split("T")[0]; // Reconstruct datetime with original time newStart = `${currentDate}T${originalStartMatch[1]}:${originalStartMatch[2]}`; newEnd = `${currentDate}T${originalEndMatch[1]}:${originalEndMatch[2]}`; } // Clear stored time after use originalTimeRef.current = null; } else if (start) { // No original time, use rounded current time const startDate = start.includes("T") ? new Date(start) : new Date(start + "T00:00:00"); // Check if start date is valid if (!isNaN(startDate.getTime())) { const currentTime = getRoundedCurrentTime(); startDate.setHours(currentTime.getHours()); startDate.setMinutes(currentTime.getMinutes()); const formattedStart = formatLocalDateTime(startDate); if (formattedStart) { newStart = formattedStart; } const endDate = new Date(startDate); endDate.setMinutes(endDate.getMinutes() + 30); const formattedEnd = formatLocalDateTime(endDate); if (formattedEnd) { newEnd = formattedEnd; } } } } // Only update local state if no callback (for backwards compatibility) if (!onAllDayChange) { setStart(newStart); setEnd(newEnd); setAllDay(newAllDay); } else { // Let callback handle all state updates to avoid duplicate renders handleAllDayChange(newAllDay, newStart, newEnd); } }} /> } label="All day" /> { const newShowRepeat = !showRepeat; setShowRepeat(newShowRepeat); if (newShowRepeat) { setRepetition({ freq: "daily", interval: 1, occurrences: 0, endDate: "", byday: null, } as RepetitionObject); } else { setRepetition({ freq: "", interval: 1, occurrences: 0, endDate: "", byday: null, } as RepetitionObject); } }} /> } label="Repeat" /> {(showRepeat || (typeOfAction === "solo" && repetition?.freq)) && ( )} {hasVideoConference && meetingLink && ( <> )} setLocation(e.target.value)} size="small" margin="dense" /> {!showMore && ( Calendar )} {/* Extended options */} {showMore && ( <> { if (newValue !== null) { setEventClass(newValue); } }} size="small" > All Participants )} ); } export function formatLocalDateTime(date: Date, timeZone?: string): string { // Guard against invalid or undefined dates if (!date || isNaN(date.getTime())) { return ""; } if (timeZone) { const formatter = new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, timeZone, }); const formatted = formatter.format(date); return formatted.replace(", ", "T"); } const pad = (n: number) => n.toString().padStart(2, "0"); return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad( date.getDate() )}T${pad(date.getHours())}:${pad(date.getMinutes())}`; } export function formatDateTimeInTimezone( isoString: string, timezone: string ): string { // Parse the ISO string as UTC const utcDate = new Date(isoString); // Format the date in the target timezone const formatter = new Intl.DateTimeFormat("en-CA", { timeZone: timezone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }); const parts = formatter.formatToParts(utcDate); const getValue = (type: string) => parts.find((p) => p.type === type)?.value || ""; return `${getValue("year")}-${getValue("month")}-${getValue("day")}T${getValue("hour")}:${getValue("minute")}`; }