diff --git a/src/components/Calendar/handlers/eventHandlers.ts b/src/components/Calendar/handlers/eventHandlers.ts index 67e875d..11dc84c 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 "../../Event/EventFormFields"; +import { formatLocalDateTime } from "../../Event/utils/dateTimeFormatters"; export interface EventHandlersProps { setSelectedRange: (range: DateSelectArg | null) => void; diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index c10b22e..bba2d7a 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -34,75 +34,16 @@ import { } from "../../utils/videoConferenceUtils"; import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete"; import { CalendarItemList } from "../Calendar/CalendarItemList"; - -// Helper to split datetime string (YYYY-MM-DDTHH:mm) to date and time -function splitDateTime(datetime: string): { date: string; time: string } { - if (!datetime) return { date: "", time: "" }; - const parts = datetime.split("T"); - return { - date: parts[0] || "", - time: parts[1]?.slice(0, 5) || "", // HH:mm only - }; -} - -// Helper to combine date and time to datetime string -function combineDateTime(date: string, time: string): string { - if (!date) return ""; - if (!time) return date; // Date only for all-day - return `${date}T${time}`; -} - -// 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"; +import { FieldWithLabel } from "./components/FieldWithLabel"; +import { DateTimeFields } from "./components/DateTimeFields"; +import { useAllDayToggle } from "./hooks/useAllDayToggle"; +import { splitDateTime, combineDateTime } from "./utils/dateTimeHelpers"; +import { + formatLocalDateTime, + formatDateTimeInTimezone, + getRoundedCurrentTime, +} from "./utils/dateTimeFormatters"; +import { validateEventForm } from "./utils/formValidation"; interface EventFormFieldsProps { // Form state @@ -216,20 +157,30 @@ export default function EventFormFields({ onValidationChange, showValidationErrors = false, }: EventFormFieldsProps) { - // Store original time before toggling to all-day - const originalTimeRef = React.useRef<{ - start: string; - end: string; - endDate?: string; // Add this field - fromAllDaySlot?: boolean; // Track if came from all-day slot click - } | null>(null); - // Internal state for 4 separate fields const [startDate, setStartDate] = React.useState(""); const [startTime, setStartTime] = React.useState(""); const [endDate, setEndDate] = React.useState(""); const [endTime, setEndTime] = React.useState(""); + // Use all-day toggle hook + const { originalTimeRef, handleAllDayToggle } = useAllDayToggle({ + allday, + start, + end, + startDate, + startTime, + endDate, + endTime, + setStartTime, + setEndTime, + setEndDate, + setStart, + setEnd, + setAllDay, + onAllDayChange, + }); + // Ref for title input field to enable auto-focus const titleInputRef = React.useRef(null); @@ -367,59 +318,15 @@ export default function EventFormFields({ // Validation logic const validateForm = React.useCallback(() => { - const isTitleValid = title.trim().length > 0; - const shouldShowTitleError = showValidationErrors && !isTitleValid; - - let isDateTimeValid = true; - let dateTimeError = ""; - - // Validate start date - if (!startDate || startDate.trim() === "") { - isDateTimeValid = false; - dateTimeError = "Start date is required"; - } - // Validate start time (if not all-day) - else if (!allday && (!startTime || startTime.trim() === "")) { - isDateTimeValid = false; - dateTimeError = "Start time is required"; - } - // Validate end date - else if (!endDate || endDate.trim() === "") { - isDateTimeValid = false; - dateTimeError = "End date is required"; - } - // Validate end time (if not all-day) - else if (!allday && (!endTime || endTime.trim() === "")) { - isDateTimeValid = false; - dateTimeError = "End time is required"; - } - // Validate end > start - else { - const startDateTime = allday - ? new Date(startDate + "T00:00:00") - : new Date(combineDateTime(startDate, startTime)); - const endDateTime = allday - ? new Date(endDate + "T00:00:00") - : new Date(combineDateTime(endDate, endTime)); - - if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) { - isDateTimeValid = false; - dateTimeError = "Invalid date/time"; - } else if (endDateTime <= startDateTime) { - isDateTimeValid = false; - dateTimeError = "End time must be after start time"; - } - } - - const isValid = isTitleValid && isDateTimeValid; - - return { - isValid, - errors: { - title: shouldShowTitleError ? "Title is required" : "", - dateTime: showValidationErrors ? dateTimeError : "", - }, - }; + return validateEventForm({ + title, + startDate, + startTime, + endDate, + endTime, + allday, + showValidationErrors, + }); }, [ title, startDate, @@ -470,30 +377,11 @@ export default function EventFormFields({ setMeetingLink(null); }; - 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 ( <> - - {/* First row: 4 fields */} - - {/* Start Date - 30% */} - - {showMore && ( - - Start Date - - )} - handleStartDateChange(e.target.value)} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> - - - {/* Start Time - 20% */} - - {showMore && ( - - Start Time - - )} - handleStartTimeChange(e.target.value)} - disabled={allday} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> - - - {/* End Time - 20% */} - - {showMore && ( - - End Time - - )} - handleEndTimeChange(e.target.value)} - disabled={allday} - error={!!validation.errors.dateTime} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> - - - {/* End Date - 30% */} - - {showMore && ( - - End Date - - )} - handleEndDateChange(e.target.value)} - error={!!validation.errors.dateTime} - size="small" - margin="dense" - InputLabelProps={{ shrink: true }} - /> - - - - {/* Second row: Error message - 2 columns 50% each */} - {validation.errors.dateTime && ( - - {/* Empty left column - 50% */} - - - {/* Error message right column - 50% */} - - - {validation.errors.dateTime} - - - - )} - + { - const newAllDay = !allday; - let newStart = start; - let newEnd = end; - - if (newAllDay) { - // OFF => ON: Save original time AND original end date - if (start.includes("T")) { - originalTimeRef.current = { - start: startTime, - end: endTime, - endDate: endDate, // Save original end date - }; - } - - // Reset time to empty, keep only date part - setStartTime(""); - setEndTime(""); - newStart = startDate; - newEnd = endDate; - - // If same day, extend end to next day - if (startDate === endDate) { - const nextDay = new Date(endDate); - nextDay.setDate(nextDay.getDate() + 1); - newEnd = nextDay.toISOString().split("T")[0]; - setEndDate(newEnd); // Update internal endDate state - } - } else { - // ON => OFF: Restore original time AND original end date - if (originalTimeRef.current) { - // Check if this came from all-day slot click - if (originalTimeRef.current.fromAllDaySlot) { - // From all-day slot: set endDate = startDate, use default time - const currentTime = getRoundedCurrentTime(); - const hours = String(currentTime.getHours()).padStart( - 2, - "0" - ); - const minutes = String( - currentTime.getMinutes() - ).padStart(2, "0"); - const timeStr = `${hours}:${minutes}`; - - newStart = combineDateTime(startDate, timeStr); - - // End time = start time + 1 hour - const endTimeDate = new Date(currentTime); - endTimeDate.setHours(endTimeDate.getHours() + 1); - const endHours = String( - endTimeDate.getHours() - ).padStart(2, "0"); - const endMinutes = String( - endTimeDate.getMinutes() - ).padStart(2, "0"); - const endTimeStr = `${endHours}:${endMinutes}`; - - newEnd = combineDateTime(startDate, endTimeStr); // Use startDate as endDate - - // Update internal states - setStartTime(timeStr); - setEndTime(endTimeStr); - setEndDate(startDate); // Set endDate = startDate - } else { - // Normal case: restore original time AND original end date - const restoredEndDate = - originalTimeRef.current.endDate || endDate; - - newStart = combineDateTime( - startDate, - originalTimeRef.current.start - ); - newEnd = combineDateTime( - restoredEndDate, - originalTimeRef.current.end - ); - - // Update internal states - setStartTime(originalTimeRef.current.start); - setEndTime(originalTimeRef.current.end); - setEndDate(restoredEndDate); - } - - originalTimeRef.current = null; - } else { - // No original time: use rounded current time with 1 hour duration - const currentTime = getRoundedCurrentTime(); - const hours = String(currentTime.getHours()).padStart( - 2, - "0" - ); - const minutes = String(currentTime.getMinutes()).padStart( - 2, - "0" - ); - const timeStr = `${hours}:${minutes}`; - - newStart = combineDateTime(startDate, timeStr); - - // End time = start time + 1 hour (not 30 mins) - const endTimeDate = new Date(currentTime); - endTimeDate.setHours(endTimeDate.getHours() + 1); // Changed from setMinutes +30 - const endHours = String(endTimeDate.getHours()).padStart( - 2, - "0" - ); - const endMinutes = String( - endTimeDate.getMinutes() - ).padStart(2, "0"); - const endTimeStr = `${endHours}:${endMinutes}`; - - newEnd = combineDateTime(endDate, endTimeStr); - - // Update internal states - setStartTime(timeStr); - setEndTime(endTimeStr); - } - } - - if (!onAllDayChange) { - setStart(newStart); - setEnd(newEnd); - setAllDay(newAllDay); - } else { - handleAllDayChange(newAllDay, newStart, newEnd); - } - }} - /> + } label="All day" /> @@ -1017,54 +685,3 @@ export default function EventFormFields({ ); } - -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")}`; -} diff --git a/src/components/Event/components/DateTimeFields.tsx b/src/components/Event/components/DateTimeFields.tsx new file mode 100644 index 0000000..818b0a8 --- /dev/null +++ b/src/components/Event/components/DateTimeFields.tsx @@ -0,0 +1,143 @@ +import React from "react"; +import { Box, TextField, Typography } from "@mui/material"; + +/** + * Props for DateTimeFields component + */ +export interface DateTimeFieldsProps { + startDate: string; + startTime: string; + endDate: string; + endTime: string; + allday: boolean; + showMore: boolean; + validation: { + errors: { + dateTime: string; + }; + }; + onStartDateChange: (date: string) => void; + onStartTimeChange: (time: string) => void; + onEndDateChange: (date: string) => void; + onEndTimeChange: (time: string) => void; +} + +/** + * DateTimeFields component - 4 separate date/time input fields + * Layout: Start Date (30%) | Start Time (20%) | End Time (20%) | End Date (30%) + */ +export const DateTimeFields: React.FC = ({ + startDate, + startTime, + endDate, + endTime, + allday, + showMore, + validation, + onStartDateChange, + onStartTimeChange, + onEndDateChange, + onEndTimeChange, +}) => { + return ( + + {/* First row: 4 fields */} + + {/* Start Date - 30% */} + + {showMore && ( + + Start Date + + )} + onStartDateChange(e.target.value)} + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + /> + + + {/* Start Time - 20% */} + + {showMore && ( + + Start Time + + )} + onStartTimeChange(e.target.value)} + disabled={allday} + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + /> + + + {/* End Time - 20% */} + + {showMore && ( + + End Time + + )} + onEndTimeChange(e.target.value)} + disabled={allday} + error={!!validation.errors.dateTime} + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + /> + + + {/* End Date - 30% */} + + {showMore && ( + + End Date + + )} + onEndDateChange(e.target.value)} + error={!!validation.errors.dateTime} + size="small" + margin="dense" + InputLabelProps={{ shrink: true }} + /> + + + + {/* Second row: Error message - 2 columns 50% each */} + {validation.errors.dateTime && ( + + {/* Empty left column - 50% */} + + + {/* Error message right column - 50% */} + + + {validation.errors.dateTime} + + + + )} + + ); +}; diff --git a/src/components/Event/components/FieldWithLabel.tsx b/src/components/Event/components/FieldWithLabel.tsx new file mode 100644 index 0000000..85b4dc0 --- /dev/null +++ b/src/components/Event/components/FieldWithLabel.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Box, Typography } from "@mui/material"; + +/** + * Helper component for field with label + * Supports two modes: normal (label on top) and expanded (label on left) + */ +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} + + ); + } +); diff --git a/src/components/Event/hooks/useAllDayToggle.ts b/src/components/Event/hooks/useAllDayToggle.ts new file mode 100644 index 0000000..5a2e953 --- /dev/null +++ b/src/components/Event/hooks/useAllDayToggle.ts @@ -0,0 +1,193 @@ +import React from "react"; +import { combineDateTime } from "../utils/dateTimeHelpers"; +import { getRoundedCurrentTime } from "../utils/dateTimeFormatters"; + +/** + * Parameters for all-day toggle hook + */ +export interface AllDayToggleParams { + allday: boolean; + start: string; + end: string; + startDate: string; + startTime: string; + endDate: string; + endTime: string; + setStartTime: (time: string) => void; + setEndTime: (time: string) => void; + setEndDate: (date: string) => void; + setStart: (start: string) => void; + setEnd: (end: string) => void; + setAllDay: (allday: boolean) => void; + onAllDayChange?: (allday: boolean, start: string, end: string) => void; +} + +/** + * Handlers returned by all-day toggle hook + */ +export interface AllDayToggleHandlers { + originalTimeRef: React.MutableRefObject<{ + start: string; + end: string; + endDate?: string; + fromAllDaySlot?: boolean; + } | null>; + handleAllDayToggle: () => void; +} + +/** + * Custom hook for managing all-day toggle logic + * Handles saving/restoring time values and endDate logic + */ +export function useAllDayToggle( + params: AllDayToggleParams +): AllDayToggleHandlers { + const { + allday, + start, + end, + startDate, + startTime, + endDate, + endTime, + setStartTime, + setEndTime, + setEndDate, + setStart, + setEnd, + setAllDay, + onAllDayChange, + } = params; + + // Store original time before toggling to all-day + const originalTimeRef = React.useRef<{ + start: string; + end: string; + endDate?: string; + fromAllDaySlot?: boolean; + } | null>(null); + + const handleAllDayToggle = React.useCallback(() => { + const newAllDay = !allday; + let newStart = start; + let newEnd = end; + + if (newAllDay) { + // OFF => ON: Save original time AND original end date + if (start.includes("T")) { + originalTimeRef.current = { + start: startTime, + end: endTime, + endDate: endDate, // Save original end date + }; + } + + // Reset time to empty, keep only date part + setStartTime(""); + setEndTime(""); + newStart = startDate; + newEnd = endDate; + + // If same day, extend end to next day + if (startDate === endDate) { + const nextDay = new Date(endDate); + nextDay.setDate(nextDay.getDate() + 1); + newEnd = nextDay.toISOString().split("T")[0]; + setEndDate(newEnd); // Update internal endDate state + } + } else { + // ON => OFF: Restore original time AND original end date + if (originalTimeRef.current) { + // Check if this came from all-day slot click + if (originalTimeRef.current.fromAllDaySlot) { + // From all-day slot: set endDate = startDate, use default time + const currentTime = getRoundedCurrentTime(); + const hours = String(currentTime.getHours()).padStart(2, "0"); + const minutes = String(currentTime.getMinutes()).padStart(2, "0"); + const timeStr = `${hours}:${minutes}`; + + newStart = combineDateTime(startDate, timeStr); + + // End time = start time + 1 hour + const endTimeDate = new Date(currentTime); + endTimeDate.setHours(endTimeDate.getHours() + 1); + const endHours = String(endTimeDate.getHours()).padStart(2, "0"); + const endMinutes = String(endTimeDate.getMinutes()).padStart(2, "0"); + const endTimeStr = `${endHours}:${endMinutes}`; + + newEnd = combineDateTime(startDate, endTimeStr); // Use startDate as endDate + + // Update internal states + setStartTime(timeStr); + setEndTime(endTimeStr); + setEndDate(startDate); // Set endDate = startDate + } else { + // Normal case: restore original time AND original end date + const restoredEndDate = originalTimeRef.current.endDate || endDate; + + newStart = combineDateTime(startDate, originalTimeRef.current.start); + newEnd = combineDateTime( + restoredEndDate, + originalTimeRef.current.end + ); + + // Update internal states + setStartTime(originalTimeRef.current.start); + setEndTime(originalTimeRef.current.end); + setEndDate(restoredEndDate); + } + + originalTimeRef.current = null; + } else { + // No original time: use rounded current time with 1 hour duration + const currentTime = getRoundedCurrentTime(); + const hours = String(currentTime.getHours()).padStart(2, "0"); + const minutes = String(currentTime.getMinutes()).padStart(2, "0"); + const timeStr = `${hours}:${minutes}`; + + newStart = combineDateTime(startDate, timeStr); + + // End time = start time + 1 hour (not 30 mins) + const endTimeDate = new Date(currentTime); + endTimeDate.setHours(endTimeDate.getHours() + 1); + const endHours = String(endTimeDate.getHours()).padStart(2, "0"); + const endMinutes = String(endTimeDate.getMinutes()).padStart(2, "0"); + const endTimeStr = `${endHours}:${endMinutes}`; + + newEnd = combineDateTime(endDate, endTimeStr); + + // Update internal states + setStartTime(timeStr); + setEndTime(endTimeStr); + } + } + + if (!onAllDayChange) { + setStart(newStart); + setEnd(newEnd); + setAllDay(newAllDay); + } else { + onAllDayChange(newAllDay, newStart, newEnd); + } + }, [ + allday, + start, + end, + startDate, + startTime, + endDate, + endTime, + setStartTime, + setEndTime, + setEndDate, + setStart, + setEnd, + setAllDay, + onAllDayChange, + ]); + + return { + originalTimeRef, + handleAllDayToggle, + }; +} diff --git a/src/components/Event/utils/dateTimeFormatters.ts b/src/components/Event/utils/dateTimeFormatters.ts new file mode 100644 index 0000000..cba91a5 --- /dev/null +++ b/src/components/Event/utils/dateTimeFormatters.ts @@ -0,0 +1,80 @@ +/** + * Date/time formatting utilities + */ + +/** + * Format a Date object to local datetime string (YYYY-MM-DDTHH:mm) + * @param date - Date object to format + * @param timeZone - Optional timezone for formatting + * @returns Formatted datetime 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() + )}T${pad(date.getHours())}:${pad(date.getMinutes())}`; +} + +/** + * Format an ISO datetime string in a specific timezone + * @param isoString - ISO datetime string + * @param timezone - Target timezone + * @returns Formatted datetime string in target timezone + */ +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")}`; +} + +/** + * Get current time rounded to nearest 30 minutes + * @returns Rounded Date object + */ +export function getRoundedCurrentTime(): Date { + 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; +} diff --git a/src/components/Event/utils/dateTimeHelpers.ts b/src/components/Event/utils/dateTimeHelpers.ts new file mode 100644 index 0000000..78454d7 --- /dev/null +++ b/src/components/Event/utils/dateTimeHelpers.ts @@ -0,0 +1,32 @@ +/** + * Helper functions for date/time string manipulation + */ + +/** + * Split datetime string (YYYY-MM-DDTHH:mm) into date and time parts + * @param datetime - ISO datetime string + * @returns Object with date and time strings + */ +export function splitDateTime(datetime: string): { + date: string; + time: string; +} { + if (!datetime) return { date: "", time: "" }; + const parts = datetime.split("T"); + return { + date: parts[0] || "", + time: parts[1]?.slice(0, 5) || "", // HH:mm only + }; +} + +/** + * Combine date and time strings into datetime string + * @param date - Date string (YYYY-MM-DD) + * @param time - Time string (HH:mm) + * @returns Combined datetime string or date only if no time + */ +export function combineDateTime(date: string, time: string): string { + if (!date) return ""; + if (!time) return date; // Date only for all-day + return `${date}T${time}`; +} diff --git a/src/components/Event/utils/formValidation.ts b/src/components/Event/utils/formValidation.ts new file mode 100644 index 0000000..ad3f370 --- /dev/null +++ b/src/components/Event/utils/formValidation.ts @@ -0,0 +1,96 @@ +import { combineDateTime } from "./dateTimeHelpers"; + +/** + * Validation parameters for event form + */ +export interface ValidationParams { + title: string; + startDate: string; + startTime: string; + endDate: string; + endTime: string; + allday: boolean; + showValidationErrors: boolean; +} + +/** + * Validation result for event form + */ +export interface ValidationResult { + isValid: boolean; + errors: { + title: string; + dateTime: string; + }; +} + +/** + * Validate event form fields + * @param params - Validation parameters + * @returns Validation result with errors + */ +export function validateEventForm(params: ValidationParams): ValidationResult { + const { + title, + startDate, + startTime, + endDate, + endTime, + allday, + showValidationErrors, + } = params; + + const isTitleValid = title.trim().length > 0; + const shouldShowTitleError = showValidationErrors && !isTitleValid; + + let isDateTimeValid = true; + let dateTimeError = ""; + + // Validate start date + if (!startDate || startDate.trim() === "") { + isDateTimeValid = false; + dateTimeError = "Start date is required"; + } + // Validate start time (if not all-day) + else if (!allday && (!startTime || startTime.trim() === "")) { + isDateTimeValid = false; + dateTimeError = "Start time is required"; + } + // Validate end date + else if (!endDate || endDate.trim() === "") { + isDateTimeValid = false; + dateTimeError = "End date is required"; + } + // Validate end time (if not all-day) + else if (!allday && (!endTime || endTime.trim() === "")) { + isDateTimeValid = false; + dateTimeError = "End time is required"; + } + // Validate end > start + else { + const startDateTime = allday + ? new Date(startDate + "T00:00:00") + : new Date(combineDateTime(startDate, startTime)); + const endDateTime = allday + ? new Date(endDate + "T00:00:00") + : new Date(combineDateTime(endDate, endTime)); + + if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) { + isDateTimeValid = false; + dateTimeError = "Invalid date/time"; + } else if (endDateTime <= startDateTime) { + isDateTimeValid = false; + dateTimeError = "End time must be after start time"; + } + } + + const isValid = isTitleValid && isDateTimeValid; + + return { + isValid, + errors: { + title: shouldShowTitleError ? "Title is required" : "", + dateTime: showValidationErrors ? dateTimeError : "", + }, + }; +} diff --git a/src/features/Events/EventDisplay.tsx b/src/features/Events/EventDisplay.tsx index 9c889b8..37ec911 100644 --- a/src/features/Events/EventDisplay.tsx +++ b/src/features/Events/EventDisplay.tsx @@ -46,7 +46,7 @@ import { import { Calendars } from "../Calendars/CalendarTypes"; import { userAttendee } from "../User/userDataTypes"; import { getEvent } from "./EventApi"; -import { formatLocalDateTime } from "../../components/Event/EventFormFields"; +import { formatLocalDateTime } from "../../components/Event/utils/dateTimeFormatters"; import { CalendarEvent, RepetitionObject } from "./EventsTypes"; export default function EventDisplayModal({ diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index ee794cc..402f22a 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -24,10 +24,11 @@ import { } from "../../components/Calendar/TimezoneSelector"; import { getCalendarRange } from "../../utils/dateUtils"; import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils"; -import EventFormFields, { +import EventFormFields from "../../components/Event/EventFormFields"; +import { formatLocalDateTime, formatDateTimeInTimezone, -} from "../../components/Event/EventFormFields"; +} from "../../components/Event/utils/dateTimeFormatters"; function EventPopover({ anchorEl, diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index 5ef98dc..52cc63a 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -17,9 +17,8 @@ import { userAttendee } from "../User/userDataTypes"; import { CalendarEvent, RepetitionObject } from "./EventsTypes"; import { TIMEZONES } from "../../utils/timezone-data"; import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils"; -import EventFormFields, { - formatDateTimeInTimezone, -} from "../../components/Event/EventFormFields"; +import EventFormFields from "../../components/Event/EventFormFields"; +import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters"; import { getEvent, deleteEvent, putEvent } from "./EventApi"; import { refreshCalendars } from "../../components/Event/utils/eventUtils"; import { getCalendarRange } from "../../utils/dateUtils";