diff --git a/src/components/Calendar/handlers/eventHandlers.ts b/src/components/Calendar/handlers/eventHandlers.ts index a707d38..67e875d 100644 --- a/src/components/Calendar/handlers/eventHandlers.ts +++ b/src/components/Calendar/handlers/eventHandlers.ts @@ -16,7 +16,7 @@ import { getEvent } from "../../../features/Events/EventApi"; import { refreshCalendars } from "../../Event/utils/eventUtils"; import { updateTempCalendar } from "../utils/calendarUtils"; import { User } from "../../Attendees/PeopleSearch"; -import { formatLocalDateTime } from "../../../features/Events/EventModal"; +import { formatLocalDateTime } from "../../Event/EventFormFields"; export interface EventHandlersProps { setSelectedRange: (range: DateSelectArg | null) => void; diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index c80ee59..4ab9db9 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -41,7 +41,7 @@ export const FieldWithLabel = React.memo( isExpanded, children, }: { - label: string; + label: string | React.ReactNode; isExpanded: boolean; children: React.ReactNode; }) => { @@ -126,6 +126,7 @@ interface EventFormFieldsProps { setShowDescription: (showDescription: boolean) => void; showRepeat: boolean; setShowRepeat: (showRepeat: boolean) => void; + isOpen?: boolean; // Data userPersonnalCalendars: Calendars[]; @@ -138,8 +139,16 @@ interface EventFormFieldsProps { // Event handlers onStartChange?: (newStart: string) => void; onEndChange?: (newEnd: string) => void; - onAllDayChange?: (newAllDay: boolean) => void; + onAllDayChange?: ( + newAllDay: boolean, + newStart: string, + newEnd: string + ) => void; onCalendarChange?: (newCalendarId: number) => void; + + // Validation + onValidationChange?: (isValid: boolean) => void; + showValidationErrors?: boolean; } export default function EventFormFields({ @@ -179,13 +188,131 @@ export default function EventFormFields({ 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( @@ -219,18 +346,28 @@ export default function EventFormFields({ }; const handleStartChange = (newStart: string) => { - setStart(newStart); - onStartChange?.(newStart); + if (onStartChange) { + onStartChange(newStart); + } else { + setStart(newStart); + } }; const handleEndChange = (newEnd: string) => { - setEnd(newEnd); - onEndChange?.(newEnd); + if (onEndChange) { + onEndChange(newEnd); + } else { + setEnd(newEnd); + } }; - const handleAllDayChange = (newAllDay: boolean) => { + const handleAllDayChange = ( + newAllDay: boolean, + newStart: string, + newEnd: string + ) => { setAllDay(newAllDay); - onAllDayChange?.(newAllDay); + onAllDayChange?.(newAllDay, newStart, newEnd); }; const handleCalendarChange = (newCalendarId: number) => { @@ -250,15 +387,27 @@ export default function EventFormFields({ return ( <> - + + Title * + + } + isExpanded={showMore} + > setTitle(e.target.value)} + onChange={(e) => { + setTitle(e.target.value); + }} + error={!!validation.errors.title} + helperText={validation.errors.title} size="small" margin="dense" + inputRef={titleInputRef} /> @@ -307,40 +456,46 @@ export default function EventFormFields({ )} - - - {showMore && ( - - Start - - )} - handleStartChange(e.target.value)} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> - - - {showMore && ( - - End - - )} - handleEndChange(e.target.value)} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> + + + + {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 }} + /> + @@ -353,32 +508,96 @@ export default function EventFormFields({ checked={allday} onChange={() => { const newAllDay = !allday; + let newStart = start; + let newEnd = end; if (newAllDay) { - // No allday => allday: existing logic - const endDate = new Date(end); - const startDate = new Date(start); - if (endDate.getDate() === startDate.getDate()) { - endDate.setDate(startDate.getDate() + 1); - setEnd(formatLocalDateTime(endDate)); + // 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 { - // Allday => no allday: set end date = start date with rounded time - const startDate = new Date(start); - const currentTime = getRoundedCurrentTime(); + // 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})/); - // Set start time - startDate.setHours(currentTime.getHours()); - startDate.setMinutes(currentTime.getMinutes()); - setStart(formatLocalDateTime(startDate)); + if (originalStartMatch && originalEndMatch) { + // Parse current date (YYYY-MM-DD) + const currentDate = start.split("T")[0]; - // Set end date = start date, with time 30 minutes after start - const endDate = new Date(startDate); - endDate.setMinutes(endDate.getMinutes() + 30); - setEnd(formatLocalDateTime(endDate)); + // 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; + } + } + } } - handleAllDayChange(newAllDay); + // 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); + } }} /> } @@ -598,7 +817,26 @@ export default function EventFormFields({ ); } -export function formatLocalDateTime(date: Date): string { +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() diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index 7739d94..7a74f44 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -1,26 +1,4 @@ import { CalendarApi, DateSelectArg } from "@fullcalendar/core"; -import { - 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 { Box, Button } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import React, { @@ -29,80 +7,27 @@ import React, { useMemo, useCallback, useRef, + startTransition, } from "react"; import { useAppDispatch, useAppSelector } from "../../app/hooks"; -import AttendeeSelector from "../../components/Attendees/AttendeeSearch"; import { ResponsiveDialog } from "../../components/Dialog"; import { putEventAsync } from "../Calendars/CalendarSlice"; import { Calendars } from "../Calendars/CalendarTypes"; import { userAttendee } from "../User/userDataTypes"; import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { createSelector } from "@reduxjs/toolkit"; -import RepeatEvent from "../../components/Event/EventRepeat"; import { TIMEZONES } from "../../utils/timezone-data"; -import { - generateMeetingLink, - addVideoConferenceToDescription, -} from "../../utils/videoConferenceUtils"; +import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils"; import { getTimezoneOffset, resolveTimezone, } from "../../components/Calendar/TimezoneSelector"; import { getCalendarRange } from "../../utils/dateUtils"; import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils"; -import { TimezoneAutocomplete } from "../../components/Timezone/TimezoneAutocomplete"; - -// Helper component for field with label -const FieldWithLabel = React.memo( - ({ - label, - isExpanded, - children, - }: { - label: string; - 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"; +import EventFormFields, { + formatLocalDateTime, + formatDateTimeInTimezone, +} from "../../components/Event/EventFormFields"; function EventPopover({ anchorEl, @@ -128,8 +53,8 @@ function EventPopover({ useAppSelector((state) => state.user.userData?.openpaasId) ?? ""; const tempList = useAppSelector((state) => state.calendars.templist); const selectPersonnalCalendars = createSelector( - (state) => state.calendars, - (calendars) => + (state: any) => state.calendars, + (calendars: any) => Object.keys(calendars.list) .map((id) => { if (id.split("/")[0] === userId) { @@ -149,7 +74,7 @@ function EventPopover({ Intl.DateTimeFormat().resolvedOptions().timeZone ); - return { zones, browserTz }; + return { zones, browserTz, getTimezoneOffset }; }, []); const calendarTimezone = useAppSelector((state) => state.calendars.timeZone); @@ -186,9 +111,7 @@ function EventPopover({ const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC"); const [busy, setBusy] = useState(event?.transp ?? "OPAQUE"); const [timezone, setTimezone] = useState( - event?.timezone - ? resolveTimezone(event.timezone) - : resolveTimezone(calendarTimezone) + event?.timezone ? resolveTimezone(event.timezone) : calendarTimezone ); const [hasVideoConference, setHasVideoConference] = useState( event?.x_openpass_videoconference ? true : false @@ -196,6 +119,8 @@ function EventPopover({ const [meetingLink, setMeetingLink] = useState( event?.x_openpass_videoconference || null ); + const [isFormValid, setIsFormValid] = useState(false); + const [showValidationErrors, setShowValidationErrors] = useState(false); // Use ref to track if we've already initialized to avoid infinite loop const isInitializedRef = useRef(false); @@ -206,13 +131,6 @@ function EventPopover({ userPersonnalCalendarsRef.current = userPersonnalCalendars; }, [userPersonnalCalendars]); - // Sync timezone with Redux when modal opens or timezone changes - useEffect(() => { - if (open && !event?.timezone && calendarTimezone) { - setTimezone(resolveTimezone(calendarTimezone)); - } - }, [open, calendarTimezone, event?.timezone]); - const resetAllStateToDefault = useCallback(() => { setShowMore(false); setShowDescription(false); @@ -229,31 +147,152 @@ function EventPopover({ setAlarm(""); setEventClass("PUBLIC"); setBusy("OPAQUE"); - setTimezone(resolveTimezone(calendarTimezone)); + setTimezone(calendarTimezone); setHasVideoConference(false); setMeetingLink(null); }, [calendarTimezone]); - useEffect(() => { - if (selectedRange) { - setStart( - selectedRange ? formatLocalDateTime(selectedRange.start, timezone) : "" - ); - setEnd( - selectedRange ? formatLocalDateTime(selectedRange.end, timezone) : "" - ); - } - }, [selectedRange]); + // Track if we should sync from selectedRange (only on initial selection, not on toggle) + const shouldSyncFromRangeRef = useRef(true); + const prevOpenRef = useRef(false); - // Initialize state when event prop changes + // Sync timezone when modal opens or calendarTimezone changes useEffect(() => { - if (event) { + // Detect modal opening (transition from closed to open) + const isOpening = open && !prevOpenRef.current; + + if (isOpening) { + shouldSyncFromRangeRef.current = true; + setShowValidationErrors(false); + + // Set timezone to calendar timezone for new events when opening + const isNewEvent = !event || !event.uid; + if (isNewEvent) { + const resolvedTimezone = resolveTimezone(calendarTimezone); + setTimezone(resolvedTimezone); + } + } + + // Update previous open state + prevOpenRef.current = open; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, event?.uid, calendarTimezone]); + + // Separately sync timezone when calendarTimezone changes while modal is open for new events + useEffect(() => { + if (open && (!event || !event.uid)) { + const resolvedTimezone = resolveTimezone(calendarTimezone); + setTimezone(resolvedTimezone); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [calendarTimezone, open, event?.uid]); + + // Set start/end times when modal opens for new event creation + useEffect(() => { + // Only run when modal opens and not duplicating an event + // Check if event has uid to determine if it's a valid event (not empty object) + if (!shouldSyncFromRangeRef.current || !open || (event && event.uid)) { + return; + } + + if (selectedRange && selectedRange.start && selectedRange.end) { + // selectedRange gives us the visual time displayed on calendar + // Use selectedRange.startStr and endStr if available (from FullCalendar) + if (selectedRange.startStr && selectedRange.endStr) { + // Check if they are strings (from FullCalendar) or need conversion + const startStr = + typeof selectedRange.startStr === "string" + ? selectedRange.startStr + : formatLocalDateTime(selectedRange.start); + const endStr = + typeof selectedRange.endStr === "string" + ? selectedRange.endStr + : formatLocalDateTime(selectedRange.end); + + // Use the string values directly to preserve the displayed time + setStart( + selectedRange.allDay ? startStr.split("T")[0] : startStr.slice(0, 16) // YYYY-MM-DDTHH:mm + ); + setEnd( + selectedRange.allDay ? endStr.split("T")[0] : endStr.slice(0, 16) + ); + } else { + // Fallback: format Date objects using local time components + // Only set if both start and end are valid + const formattedStart = formatLocalDateTime(selectedRange.start); + const formattedEnd = formatLocalDateTime(selectedRange.end); + if (formattedStart) setStart(formattedStart); + if (formattedEnd) setEnd(formattedEnd); + } + } else { + // No valid selectedRange - use default times + // Start time = current time + 1 hour (rounded up to the hour) + // End time = start time + 1 hour + const now = new Date(); + const nextHour = new Date(now); + nextHour.setHours(now.getHours() + 1); + nextHour.setMinutes(0); + nextHour.setSeconds(0); + nextHour.setMilliseconds(0); + + const endTime = new Date(nextHour); + endTime.setHours(nextHour.getHours() + 1); + + // Format using local time (browser timezone) + const formattedStart = formatLocalDateTime(nextHour); + const formattedEnd = formatLocalDateTime(endTime); + + if (formattedStart) setStart(formattedStart); + if (formattedEnd) setEnd(formattedEnd); + } + + shouldSyncFromRangeRef.current = false; + }, [selectedRange, open, event]); + + // Initialize state when event prop changes (duplicate event or tempEvent with attendees) + useEffect(() => { + if (event && event.uid) { // Editing existing event - populate fields with event data setTitle(event.title ?? ""); setDescription(event.description ?? ""); setLocation(event.location ?? ""); - setStart(event.start ? event.start : ""); - setEnd(event.end ? event.end : ""); + + // Get event's timezone for formatting + const eventTimezone = event.timezone + ? resolveTimezone(event.timezone) + : calendarTimezone; + + // Handle all-day events properly + const isAllDay = event.allday ?? false; + setAllDay(isAllDay); + + // Format dates based on all-day status and timezone + if (event.start) { + if (isAllDay) { + // For all-day events, use date format (YYYY-MM-DD) + const startDate = new Date(event.start); + setStart(startDate.toISOString().split("T")[0]); + } else { + // For timed events, format in the event's timezone + setStart(formatDateTimeInTimezone(event.start, eventTimezone)); + } + } else { + setStart(""); + } + + if (event.end) { + if (isAllDay) { + // For all-day events, use date format (YYYY-MM-DD) + const endDate = new Date(event.end); + setEnd(endDate.toISOString().split("T")[0]); + } else { + // For timed events, format in the event's timezone + setEnd(formatDateTimeInTimezone(event.end, eventTimezone)); + } + } else { + setEnd(""); + } + setCalendarid( event.calId ? userPersonnalCalendarsRef.current.findIndex( @@ -261,7 +300,6 @@ function EventPopover({ ) : 0 ); - setAllDay(event.allday ?? false); setRepetition(event.repetition ?? ({} as RepetitionObject)); setShowRepeat(event.repetition?.freq ? true : false); setAttendees( @@ -274,9 +312,7 @@ function EventPopover({ setAlarm(event.alarm?.trigger ?? ""); setEventClass(event.class ?? "PUBLIC"); setBusy(event.transp ?? "OPAQUE"); - setTimezone( - event.timezone ? resolveTimezone(event.timezone) : calendarTimezone - ); + setTimezone(eventTimezone); setHasVideoConference(event.x_openpass_videoconference ? true : false); setMeetingLink(event.x_openpass_videoconference || null); @@ -294,13 +330,23 @@ function EventPopover({ setDescription(event.description); } } + } else if (event && event.attendee && event.attendee.length > 0) { + // Handle tempEvent case (no uid but has attendees from temp calendar search) + setAttendees( + event.attendee.filter((a) => a.cal_address !== organizer?.cal_address) + ); } }, [event, organizer?.cal_address, calendarTimezone]); - // Reset state when creating new event (event is undefined) + // Reset state when creating new event (event is empty object or undefined) useEffect(() => { - if (!event && isInitializedRef.current) { + const isCreatingNew = !event || !event.uid; + const wasInitialized = isInitializedRef.current; + + if (isCreatingNew && wasInitialized) { // Creating new event - reset all fields to default + // Note: start and end are handled by the selectedRange useEffect + // Note: timezone is handled by separate useEffect above setShowMore(false); setShowDescription(false); setShowRepeat(false); @@ -308,61 +354,117 @@ function EventPopover({ setDescription(""); setAttendees([]); setLocation(""); - setStart(""); - setEnd(""); setCalendarid(0); setAllDay(false); setRepetition({} as RepetitionObject); setAlarm(""); setEventClass("PUBLIC"); setBusy("OPAQUE"); - setTimezone(timezoneList.browserTz); setHasVideoConference(false); setMeetingLink(null); } - isInitializedRef.current = true; - }, [event, calendarTimezone]); - 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); - // You could add a toast notification here - console.log("Meeting link copied to clipboard"); - } catch (err) { - console.error("Failed to copy link:", err); - } + if (!isCreatingNew) { + isInitializedRef.current = true; } - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [event?.uid]); - const handleDeleteVideoConference = () => { - // Remove video conference footer from description - const updatedDescription = description.replace( - /\nVisio: https?:\/\/[^\s]+/, - "" - ); - setDescription(updatedDescription); - setHasVideoConference(false); - setMeetingLink(null); - }; + const handleStartChange = useCallback( + (newStart: string) => { + setStart(newStart); + + // Defer visual feedback (non-urgent) + startTransition(() => { + setSelectedRange((prev: DateSelectArg | null) => { + const newRange = { + ...prev, + start: new Date(newStart), + startStr: newStart, + allDay: allday, + } as DateSelectArg; + calendarRef.current?.select(newRange); + return newRange; + }); + }); + }, + // calendarRef and setSelectedRange are stable refs/setters from parent + // eslint-disable-next-line react-hooks/exhaustive-deps + [allday] + ); + + const handleEndChange = useCallback( + (newEnd: string) => { + setEnd(newEnd); + + // Defer visual feedback (non-urgent) + startTransition(() => { + setSelectedRange((prev: DateSelectArg | null) => { + const newRange = { + ...prev, + end: new Date(newEnd), + endStr: newEnd, + allDay: allday, + } as DateSelectArg; + calendarRef.current?.select(newRange); + return newRange; + }); + }); + }, + // calendarRef and setSelectedRange are stable refs/setters from parent + // eslint-disable-next-line react-hooks/exhaustive-deps + [allday] + ); + + const handleAllDayChange = useCallback( + (newAllDay: boolean, newStart: string, newEnd: string) => { + // Update critical state immediately (checkbox response) + setAllDay(newAllDay); + setStart(newStart); + setEnd(newEnd); + + // Defer visual feedback updates (non-urgent) + startTransition(() => { + setSelectedRange((prev: DateSelectArg | null) => { + const newRange = { + ...prev, + startStr: newAllDay ? newStart.split("T")[0] : newStart, + endStr: newAllDay ? newEnd.split("T")[0] : newEnd, + start: new Date( + newAllDay ? newStart.split("T")[0] + "T00:00:00" : newStart + ), + end: new Date( + newAllDay ? newEnd.split("T")[0] + "T00:00:00" : newEnd + ), + allDay: newAllDay, + } as DateSelectArg; + calendarRef.current?.select(newRange); + return newRange; + }); + }); + }, + // calendarRef and setSelectedRange are stable refs/setters from parent + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); const handleClose = () => { onClose({}, "backdropClick"); + setShowValidationErrors(false); resetAllStateToDefault(); + setStart(""); + setEnd(""); + shouldSyncFromRangeRef.current = true; // Reset for next time }; const handleSave = async () => { + // Show validation errors when Save is clicked + setShowValidationErrors(true); + + // Check if form is valid before saving + if (!isFormValid) { + return; + } const newEventUID = crypto.randomUUID(); const newEvent: CalendarEvent = { @@ -401,6 +503,9 @@ function EventPopover({ newEvent.attendee = newEvent.attendee.concat(attendees); } + // Reset validation state when validation passes + setShowValidationErrors(false); + // Close popup immediately onClose({}, "backdropClick"); @@ -433,7 +538,7 @@ function EventPopover({ Cancel )} - @@ -449,387 +554,53 @@ function EventPopover({ onExpandToggle={() => setShowMore(!showMore)} actions={dialogActions} > - - setTitle(e.target.value)} - size="small" - margin="dense" - /> - - - {!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 - - )} - { - const newStart = e.target.value; - setStart(newStart); - - // Update selectedRange for visual feedback - const startISO = formatLocalDateTime( - new Date(newStart), - timezone - ); - const newRange = { - ...selectedRange, - start: new Date(startISO), - startStr: startISO, - allDay: allday, - }; - setSelectedRange(newRange); - calendarRef.current?.select(newRange); - }} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> - - - {showMore && ( - - End - - )} - { - const newEnd = e.target.value; - setEnd(newEnd); - - // Update selectedRange for visual feedback - const endISO = formatLocalDateTime(new Date(newEnd), timezone); - const newRange = { - ...selectedRange, - end: new Date(endISO), - endStr: endISO, - allDay: allday, - }; - setSelectedRange(newRange); - calendarRef.current?.select(newRange); - }} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> - - - - - - { - const endDate = new Date(end); - const startDate = new Date(start); - setAllDay(!allday); - if (endDate.getDate() === startDate.getDate()) { - endDate.setDate(startDate.getDate() + 1); - setEnd(formatLocalDateTime(endDate, timezone)); - } - - const newRange = { - ...selectedRange, - startStr: allday ? start.split("T")[0] : start, - endStr: allday - ? endDate.toISOString().split("T")[0] - : endDate.toISOString(), - start: new Date(allday ? start.split("T")[0] : start), - end: new Date( - allday - ? endDate.toISOString().split("T")[0] - : endDate.toISOString() - ), - allDay: allday, - }; - setSelectedRange(newRange); - }} - /> - } - label="All day" - /> - { - const newShowRepeat = !showRepeat; - setShowRepeat(newShowRepeat); - if (newShowRepeat) { - setRepetition({ - freq: "daily", - interval: 1, - occurrences: 0, - endDate: "", - selectedDays: [], - } as RepetitionObject); - } else { - setRepetition({ - freq: "", - interval: 1, - occurrences: 0, - endDate: "", - selectedDays: [], - } as RepetitionObject); - } - }} - /> - } - label="Repeat" - /> - - - - - {showRepeat && ( - - - - )} - - - - - - - - - - {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 default EventPopover; - -export function formatLocalDateTime(date: Date, timeZone?: string) { - 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"); -} diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index cd06ee7..95bedf7 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -84,9 +84,9 @@ function EventUpdateModal({ const calendarsList = useAppSelector((state) => state.calendars.list); const userPersonnalCalendars: Calendars[] = useMemo(() => { - const allCalendars = Object.values(calendarsList); + const allCalendars = Object.values(calendarsList) as Calendars[]; return allCalendars.filter( - (c) => c.id?.split("/")[0] === user.userData?.openpaasId + (c: Calendars) => c.id?.split("/")[0] === user.userData?.openpaasId ); }, [calendarsList, user.userData?.openpaasId]); @@ -158,6 +158,8 @@ function EventUpdateModal({ const [attendees, setAttendees] = useState([]); const [hasVideoConference, setHasVideoConference] = useState(false); const [meetingLink, setMeetingLink] = useState(null); + const [isFormValid, setIsFormValid] = useState(false); + const [showValidationErrors, setShowValidationErrors] = useState(false); const resetAllStateToDefault = useCallback(() => { setShowMore(false); @@ -188,6 +190,9 @@ function EventUpdateModal({ // Initialize form state when event data is available useEffect(() => { if (event && open) { + // Reset validation errors when modal opens + setShowValidationErrors(false); + // Editing existing event - populate fields with event data setTitle(event.title ?? ""); setDescription(event.description ?? ""); @@ -258,7 +263,8 @@ function EventUpdateModal({ setAttendees( event.attendee ? event.attendee.filter( - (a) => a.cal_address !== event.organizer?.cal_address + (a: userAttendee) => + a.cal_address !== event.organizer?.cal_address ) : [] ); @@ -302,11 +308,21 @@ function EventUpdateModal({ const handleClose = () => { closeModal(); + setShowValidationErrors(false); resetAllStateToDefault(); + setFreshEvent(null); initializedKeyRef.current = null; }; const handleSave = async () => { + // Show validation errors when Save is clicked + setShowValidationErrors(true); + + // Check if form is valid before saving + if (!isFormValid) { + return; + } + if (!event) return; const organizer = event.organizer; @@ -317,6 +333,9 @@ function EventUpdateModal({ return; } + // Reset validation state when validation passes + setShowValidationErrors(false); + // Handle recurrence instances const [baseUID, recurrenceId] = event.uid.split("/"); @@ -507,7 +526,7 @@ function EventUpdateModal({ }) ) .unwrap() - .catch((error) => { + .catch((error: any) => { dispatch(updateEventLocal({ calId, event: oldEvent })); showErrorNotification("Failed to update event. Changes reverted."); }); @@ -666,12 +685,10 @@ function EventUpdateModal({ )} - {showMore && ( - - )} - + @@ -726,6 +743,7 @@ function EventUpdateModal({ setShowDescription={setShowDescription} showRepeat={typeOfAction !== "solo" && showRepeat} setShowRepeat={setShowRepeat} + isOpen={open} userPersonnalCalendars={userPersonnalCalendars} timezoneList={timezoneList} onCalendarChange={(newCalendarId) => { @@ -734,6 +752,8 @@ function EventUpdateModal({ setNewCalId(selectedCalendar.id); } }} + onValidationChange={setIsFormValid} + showValidationErrors={showValidationErrors} /> );