refactor: Split EventFormFields.tsx into smaller modules
- Extract helper functions to utils/dateTimeHelpers.ts and utils/dateTimeFormatters.ts - Extract validation logic to utils/formValidation.ts - Extract FieldWithLabel component to components/FieldWithLabel.tsx - Extract DateTimeFields component to components/DateTimeFields.tsx - Extract all-day toggle logic to hooks/useAllDayToggle.ts - Update imports in EventModal.tsx, EventUpdateModal.tsx, EventDisplay.tsx, eventHandlers.ts - Maintain all existing functionality without breaking changes
This commit is contained in:
committed by
Benoit TELLIER
parent
701f2f2b71
commit
01f79553c0
@@ -16,7 +16,7 @@ import { getEvent } from "../../../features/Events/EventApi";
|
|||||||
import { refreshCalendars } from "../../Event/utils/eventUtils";
|
import { refreshCalendars } from "../../Event/utils/eventUtils";
|
||||||
import { updateTempCalendar } from "../utils/calendarUtils";
|
import { updateTempCalendar } from "../utils/calendarUtils";
|
||||||
import { User } from "../../Attendees/PeopleSearch";
|
import { User } from "../../Attendees/PeopleSearch";
|
||||||
import { formatLocalDateTime } from "../../Event/EventFormFields";
|
import { formatLocalDateTime } from "../../Event/utils/dateTimeFormatters";
|
||||||
|
|
||||||
export interface EventHandlersProps {
|
export interface EventHandlersProps {
|
||||||
setSelectedRange: (range: DateSelectArg | null) => void;
|
setSelectedRange: (range: DateSelectArg | null) => void;
|
||||||
|
|||||||
@@ -34,75 +34,16 @@ import {
|
|||||||
} from "../../utils/videoConferenceUtils";
|
} from "../../utils/videoConferenceUtils";
|
||||||
import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete";
|
import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete";
|
||||||
import { CalendarItemList } from "../Calendar/CalendarItemList";
|
import { CalendarItemList } from "../Calendar/CalendarItemList";
|
||||||
|
import { FieldWithLabel } from "./components/FieldWithLabel";
|
||||||
// Helper to split datetime string (YYYY-MM-DDTHH:mm) to date and time
|
import { DateTimeFields } from "./components/DateTimeFields";
|
||||||
function splitDateTime(datetime: string): { date: string; time: string } {
|
import { useAllDayToggle } from "./hooks/useAllDayToggle";
|
||||||
if (!datetime) return { date: "", time: "" };
|
import { splitDateTime, combineDateTime } from "./utils/dateTimeHelpers";
|
||||||
const parts = datetime.split("T");
|
import {
|
||||||
return {
|
formatLocalDateTime,
|
||||||
date: parts[0] || "",
|
formatDateTimeInTimezone,
|
||||||
time: parts[1]?.slice(0, 5) || "", // HH:mm only
|
getRoundedCurrentTime,
|
||||||
};
|
} from "./utils/dateTimeFormatters";
|
||||||
}
|
import { validateEventForm } from "./utils/formValidation";
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<Box>
|
|
||||||
<Typography
|
|
||||||
component="label"
|
|
||||||
sx={{
|
|
||||||
display: "block",
|
|
||||||
marginBottom: "4px",
|
|
||||||
fontSize: "0.875rem",
|
|
||||||
fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extended mode: label on left
|
|
||||||
return (
|
|
||||||
<Box display="flex" alignItems="center">
|
|
||||||
<Typography
|
|
||||||
component="label"
|
|
||||||
sx={{
|
|
||||||
minWidth: "115px",
|
|
||||||
marginRight: "12px",
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</Typography>
|
|
||||||
<Box flexGrow={1}>{children}</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
FieldWithLabel.displayName = "FieldWithLabel";
|
|
||||||
|
|
||||||
interface EventFormFieldsProps {
|
interface EventFormFieldsProps {
|
||||||
// Form state
|
// Form state
|
||||||
@@ -216,20 +157,30 @@ export default function EventFormFields({
|
|||||||
onValidationChange,
|
onValidationChange,
|
||||||
showValidationErrors = false,
|
showValidationErrors = false,
|
||||||
}: EventFormFieldsProps) {
|
}: 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
|
// Internal state for 4 separate fields
|
||||||
const [startDate, setStartDate] = React.useState("");
|
const [startDate, setStartDate] = React.useState("");
|
||||||
const [startTime, setStartTime] = React.useState("");
|
const [startTime, setStartTime] = React.useState("");
|
||||||
const [endDate, setEndDate] = React.useState("");
|
const [endDate, setEndDate] = React.useState("");
|
||||||
const [endTime, setEndTime] = 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
|
// Ref for title input field to enable auto-focus
|
||||||
const titleInputRef = React.useRef<HTMLInputElement>(null);
|
const titleInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -367,59 +318,15 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
// Validation logic
|
// Validation logic
|
||||||
const validateForm = React.useCallback(() => {
|
const validateForm = React.useCallback(() => {
|
||||||
const isTitleValid = title.trim().length > 0;
|
return validateEventForm({
|
||||||
const shouldShowTitleError = showValidationErrors && !isTitleValid;
|
title,
|
||||||
|
startDate,
|
||||||
let isDateTimeValid = true;
|
startTime,
|
||||||
let dateTimeError = "";
|
endDate,
|
||||||
|
endTime,
|
||||||
// Validate start date
|
allday,
|
||||||
if (!startDate || startDate.trim() === "") {
|
showValidationErrors,
|
||||||
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 : "",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
title,
|
title,
|
||||||
startDate,
|
startDate,
|
||||||
@@ -470,30 +377,11 @@ export default function EventFormFields({
|
|||||||
setMeetingLink(null);
|
setMeetingLink(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAllDayChange = (
|
|
||||||
newAllDay: boolean,
|
|
||||||
newStart: string,
|
|
||||||
newEnd: string
|
|
||||||
) => {
|
|
||||||
setAllDay(newAllDay);
|
|
||||||
onAllDayChange?.(newAllDay, newStart, newEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCalendarChange = (newCalendarId: string) => {
|
const handleCalendarChange = (newCalendarId: string) => {
|
||||||
setCalendarid(newCalendarId);
|
setCalendarid(newCalendarId);
|
||||||
onCalendarChange?.(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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
@@ -565,246 +453,26 @@ export default function EventFormFields({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FieldWithLabel label="Date & Time" isExpanded={showMore}>
|
<FieldWithLabel label="Date & Time" isExpanded={showMore}>
|
||||||
<Box display="flex" gap={1} flexDirection="column">
|
<DateTimeFields
|
||||||
{/* First row: 4 fields */}
|
startDate={startDate}
|
||||||
<Box
|
startTime={startTime}
|
||||||
display="flex"
|
endDate={endDate}
|
||||||
gap={1}
|
endTime={endTime}
|
||||||
flexDirection="row"
|
allday={allday}
|
||||||
alignItems="flex-start"
|
showMore={showMore}
|
||||||
>
|
validation={validation}
|
||||||
{/* Start Date - 30% */}
|
onStartDateChange={handleStartDateChange}
|
||||||
<Box sx={{ flexGrow: 0.3, flexBasis: "30%" }}>
|
onStartTimeChange={handleStartTimeChange}
|
||||||
{showMore && (
|
onEndDateChange={handleEndDateChange}
|
||||||
<Typography variant="caption" display="block" mb={0.5}>
|
onEndTimeChange={handleEndTimeChange}
|
||||||
Start Date
|
/>
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={!showMore ? "Start Date" : ""}
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => handleStartDateChange(e.target.value)}
|
|
||||||
size="small"
|
|
||||||
margin="dense"
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Start Time - 20% */}
|
|
||||||
<Box sx={{ flexGrow: 0.2, flexBasis: "20%" }}>
|
|
||||||
{showMore && (
|
|
||||||
<Typography variant="caption" display="block" mb={0.5}>
|
|
||||||
Start Time
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={!showMore ? "Start Time" : ""}
|
|
||||||
type="time"
|
|
||||||
value={startTime}
|
|
||||||
onChange={(e) => handleStartTimeChange(e.target.value)}
|
|
||||||
disabled={allday}
|
|
||||||
size="small"
|
|
||||||
margin="dense"
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* End Time - 20% */}
|
|
||||||
<Box sx={{ flexGrow: 0.2, flexBasis: "20%" }}>
|
|
||||||
{showMore && (
|
|
||||||
<Typography variant="caption" display="block" mb={0.5}>
|
|
||||||
End Time
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={!showMore ? "End Time" : ""}
|
|
||||||
type="time"
|
|
||||||
value={endTime}
|
|
||||||
onChange={(e) => handleEndTimeChange(e.target.value)}
|
|
||||||
disabled={allday}
|
|
||||||
error={!!validation.errors.dateTime}
|
|
||||||
size="small"
|
|
||||||
margin="dense"
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* End Date - 30% */}
|
|
||||||
<Box sx={{ flexGrow: 0.3, flexBasis: "30%" }}>
|
|
||||||
{showMore && (
|
|
||||||
<Typography variant="caption" display="block" mb={0.5}>
|
|
||||||
End Date
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
<TextField
|
|
||||||
fullWidth
|
|
||||||
label={!showMore ? "End Date" : ""}
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => handleEndDateChange(e.target.value)}
|
|
||||||
error={!!validation.errors.dateTime}
|
|
||||||
size="small"
|
|
||||||
margin="dense"
|
|
||||||
InputLabelProps={{ shrink: true }}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Second row: Error message - 2 columns 50% each */}
|
|
||||||
{validation.errors.dateTime && (
|
|
||||||
<Box display="flex" gap={1} flexDirection="row">
|
|
||||||
{/* Empty left column - 50% */}
|
|
||||||
<Box sx={{ flexGrow: 0.5, flexBasis: "50%" }} />
|
|
||||||
|
|
||||||
{/* Error message right column - 50% */}
|
|
||||||
<Box sx={{ flexGrow: 0.5, flexBasis: "50%" }}>
|
|
||||||
<Typography variant="caption" color="error">
|
|
||||||
{validation.errors.dateTime}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
|
|
||||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
<FieldWithLabel label=" " isExpanded={showMore}>
|
||||||
<Box display="flex" gap={2} alignItems="center">
|
<Box display="flex" gap={2} alignItems="center">
|
||||||
<FormControlLabel
|
<FormControlLabel
|
||||||
control={
|
control={
|
||||||
<Checkbox
|
<Checkbox checked={allday} onChange={handleAllDayToggle} />
|
||||||
checked={allday}
|
|
||||||
onChange={() => {
|
|
||||||
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"
|
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")}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<DateTimeFieldsProps> = ({
|
||||||
|
startDate,
|
||||||
|
startTime,
|
||||||
|
endDate,
|
||||||
|
endTime,
|
||||||
|
allday,
|
||||||
|
showMore,
|
||||||
|
validation,
|
||||||
|
onStartDateChange,
|
||||||
|
onStartTimeChange,
|
||||||
|
onEndDateChange,
|
||||||
|
onEndTimeChange,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Box display="flex" gap={1} flexDirection="column">
|
||||||
|
{/* First row: 4 fields */}
|
||||||
|
<Box display="flex" gap={1} flexDirection="row" alignItems="flex-start">
|
||||||
|
{/* Start Date - 30% */}
|
||||||
|
<Box sx={{ flexGrow: 0.3, flexBasis: "30%" }}>
|
||||||
|
{showMore && (
|
||||||
|
<Typography variant="caption" display="block" mb={0.5}>
|
||||||
|
Start Date
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={!showMore ? "Start Date" : ""}
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(e) => onStartDateChange(e.target.value)}
|
||||||
|
size="small"
|
||||||
|
margin="dense"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Start Time - 20% */}
|
||||||
|
<Box sx={{ flexGrow: 0.2, flexBasis: "20%" }}>
|
||||||
|
{showMore && (
|
||||||
|
<Typography variant="caption" display="block" mb={0.5}>
|
||||||
|
Start Time
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={!showMore ? "Start Time" : ""}
|
||||||
|
type="time"
|
||||||
|
value={startTime}
|
||||||
|
onChange={(e) => onStartTimeChange(e.target.value)}
|
||||||
|
disabled={allday}
|
||||||
|
size="small"
|
||||||
|
margin="dense"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* End Time - 20% */}
|
||||||
|
<Box sx={{ flexGrow: 0.2, flexBasis: "20%" }}>
|
||||||
|
{showMore && (
|
||||||
|
<Typography variant="caption" display="block" mb={0.5}>
|
||||||
|
End Time
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={!showMore ? "End Time" : ""}
|
||||||
|
type="time"
|
||||||
|
value={endTime}
|
||||||
|
onChange={(e) => onEndTimeChange(e.target.value)}
|
||||||
|
disabled={allday}
|
||||||
|
error={!!validation.errors.dateTime}
|
||||||
|
size="small"
|
||||||
|
margin="dense"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* End Date - 30% */}
|
||||||
|
<Box sx={{ flexGrow: 0.3, flexBasis: "30%" }}>
|
||||||
|
{showMore && (
|
||||||
|
<Typography variant="caption" display="block" mb={0.5}>
|
||||||
|
End Date
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label={!showMore ? "End Date" : ""}
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(e) => onEndDateChange(e.target.value)}
|
||||||
|
error={!!validation.errors.dateTime}
|
||||||
|
size="small"
|
||||||
|
margin="dense"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Second row: Error message - 2 columns 50% each */}
|
||||||
|
{validation.errors.dateTime && (
|
||||||
|
<Box display="flex" gap={1} flexDirection="row">
|
||||||
|
{/* Empty left column - 50% */}
|
||||||
|
<Box sx={{ flexGrow: 0.5, flexBasis: "50%" }} />
|
||||||
|
|
||||||
|
{/* Error message right column - 50% */}
|
||||||
|
<Box sx={{ flexGrow: 0.5, flexBasis: "50%" }}>
|
||||||
|
<Typography variant="caption" color="error">
|
||||||
|
{validation.errors.dateTime}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 (
|
||||||
|
<Box>
|
||||||
|
<Typography
|
||||||
|
component="label"
|
||||||
|
sx={{
|
||||||
|
display: "block",
|
||||||
|
marginBottom: "4px",
|
||||||
|
fontSize: "0.875rem",
|
||||||
|
fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extended mode: label on left
|
||||||
|
return (
|
||||||
|
<Box display="flex" alignItems="center">
|
||||||
|
<Typography
|
||||||
|
component="label"
|
||||||
|
sx={{
|
||||||
|
minWidth: "115px",
|
||||||
|
marginRight: "12px",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Typography>
|
||||||
|
<Box flexGrow={1}>{children}</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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 : "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
import { Calendars } from "../Calendars/CalendarTypes";
|
import { Calendars } from "../Calendars/CalendarTypes";
|
||||||
import { userAttendee } from "../User/userDataTypes";
|
import { userAttendee } from "../User/userDataTypes";
|
||||||
import { getEvent } from "./EventApi";
|
import { getEvent } from "./EventApi";
|
||||||
import { formatLocalDateTime } from "../../components/Event/EventFormFields";
|
import { formatLocalDateTime } from "../../components/Event/utils/dateTimeFormatters";
|
||||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||||
|
|
||||||
export default function EventDisplayModal({
|
export default function EventDisplayModal({
|
||||||
|
|||||||
@@ -24,10 +24,11 @@ import {
|
|||||||
} from "../../components/Calendar/TimezoneSelector";
|
} from "../../components/Calendar/TimezoneSelector";
|
||||||
import { getCalendarRange } from "../../utils/dateUtils";
|
import { getCalendarRange } from "../../utils/dateUtils";
|
||||||
import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils";
|
import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils";
|
||||||
import EventFormFields, {
|
import EventFormFields from "../../components/Event/EventFormFields";
|
||||||
|
import {
|
||||||
formatLocalDateTime,
|
formatLocalDateTime,
|
||||||
formatDateTimeInTimezone,
|
formatDateTimeInTimezone,
|
||||||
} from "../../components/Event/EventFormFields";
|
} from "../../components/Event/utils/dateTimeFormatters";
|
||||||
|
|
||||||
function EventPopover({
|
function EventPopover({
|
||||||
anchorEl,
|
anchorEl,
|
||||||
|
|||||||
@@ -17,9 +17,8 @@ import { userAttendee } from "../User/userDataTypes";
|
|||||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||||
import { TIMEZONES } from "../../utils/timezone-data";
|
import { TIMEZONES } from "../../utils/timezone-data";
|
||||||
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
|
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
|
||||||
import EventFormFields, {
|
import EventFormFields from "../../components/Event/EventFormFields";
|
||||||
formatDateTimeInTimezone,
|
import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters";
|
||||||
} from "../../components/Event/EventFormFields";
|
|
||||||
import { getEvent, deleteEvent, putEvent } from "./EventApi";
|
import { getEvent, deleteEvent, putEvent } from "./EventApi";
|
||||||
import { refreshCalendars } from "../../components/Event/utils/eventUtils";
|
import { refreshCalendars } from "../../components/Event/utils/eventUtils";
|
||||||
import { getCalendarRange } from "../../utils/dateUtils";
|
import { getCalendarRange } from "../../utils/dateUtils";
|
||||||
|
|||||||
Reference in New Issue
Block a user