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:
lenhanphung
2025-10-24 16:23:10 +07:00
committed by Benoit TELLIER
parent 701f2f2b71
commit 01f79553c0
11 changed files with 657 additions and 441 deletions
@@ -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;
+51 -434
View File
@@ -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 (
<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";
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<HTMLInputElement>(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 (
<>
<FieldWithLabel
@@ -565,246 +453,26 @@ export default function EventFormFields({
)}
<FieldWithLabel label="Date & Time" isExpanded={showMore}>
<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) => 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>
<DateTimeFields
startDate={startDate}
startTime={startTime}
endDate={endDate}
endTime={endTime}
allday={allday}
showMore={showMore}
validation={validation}
onStartDateChange={handleStartDateChange}
onStartTimeChange={handleStartTimeChange}
onEndDateChange={handleEndDateChange}
onEndTimeChange={handleEndTimeChange}
/>
</FieldWithLabel>
<FieldWithLabel label=" " isExpanded={showMore}>
<Box display="flex" gap={2} alignItems="center">
<FormControlLabel
control={
<Checkbox
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);
}
}}
/>
<Checkbox checked={allday} onChange={handleAllDayToggle} />
}
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 : "",
},
};
}
+1 -1
View File
@@ -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({
+3 -2
View File
@@ -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,
+2 -3
View File
@@ -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";