Form validation for event create/update (#218)

* refactor: migrate EventModal to use shared EventFormFields component

* feat: add form validation for event create/update

- Validate title is required
- Validate end datetime must be after start datetime
- Disable save button when form is invalid
- Show error messages below invalid fields with red highlight (MUI)
- Title validation in create mode only shows error after field is touched
- Track touched state to avoid showing errors on initial load

* feat: improve event modal UX with auto-focus and default times

- Auto-focus title field when opening create/update event modal
- Set default datetime when creating event without selecting range:
  * Start time = current time + 1 hour (rounded to :00)
  * End time = start time + 1 hour
- Applied duplicate event to display datetime from original event
- Fix logic to detect empty event object vs valid event with uid

* fix: improve validation UX and add required field indicator

- Only show validation errors when modal is open (isOpen check)
- Add red asterisk (*) to Title label to indicate required field
- Prevent validation error flash when closing modal
- Fix TypeScript errors in EventUpdateModal
- Keep validation behavior: only show error after user types and deletes

* feat: auto-focus title field when toggling between normal and extended mode in event modal


* update: only validation when click on save button

* fix: prevent form data loss and validation errors in event modals

Fixes:
1. Form fields no longer clear when changing start/end dates
2. Extended mode stays active when modifying dates
3. No validation errors shown after successful save
This commit is contained in:
lenhanphung
2025-10-22 16:52:38 +07:00
committed by GitHub
parent a7a4bb4507
commit 1c8826facc
4 changed files with 634 additions and 605 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 "../../../features/Events/EventModal";
import { formatLocalDateTime } from "../../Event/EventFormFields";
export interface EventHandlersProps {
setSelectedRange: (range: DateSelectArg | null) => void;
+301 -63
View File
@@ -41,7 +41,7 @@ export const FieldWithLabel = React.memo(
isExpanded,
children,
}: {
label: string;
label: string | React.ReactNode;
isExpanded: boolean;
children: React.ReactNode;
}) => {
@@ -126,6 +126,7 @@ interface EventFormFieldsProps {
setShowDescription: (showDescription: boolean) => void;
showRepeat: boolean;
setShowRepeat: (showRepeat: boolean) => void;
isOpen?: boolean;
// Data
userPersonnalCalendars: Calendars[];
@@ -138,8 +139,16 @@ interface EventFormFieldsProps {
// Event handlers
onStartChange?: (newStart: string) => void;
onEndChange?: (newEnd: string) => void;
onAllDayChange?: (newAllDay: boolean) => void;
onAllDayChange?: (
newAllDay: boolean,
newStart: string,
newEnd: string
) => void;
onCalendarChange?: (newCalendarId: number) => void;
// Validation
onValidationChange?: (isValid: boolean) => void;
showValidationErrors?: boolean;
}
export default function EventFormFields({
@@ -179,13 +188,131 @@ export default function EventFormFields({
setShowDescription,
showRepeat,
setShowRepeat,
isOpen = false,
userPersonnalCalendars,
timezoneList,
onStartChange,
onEndChange,
onAllDayChange,
onCalendarChange,
onValidationChange,
showValidationErrors = false,
}: EventFormFieldsProps) {
// Store original time before toggling to all-day
const originalTimeRef = React.useRef<{ start: string; end: string } | null>(
null
);
// Ref for title input field to enable auto-focus
const titleInputRef = React.useRef<HTMLInputElement>(null);
// Track previous showMore state to detect changes
const prevShowMoreRef = React.useRef<boolean | undefined>(undefined);
// Auto-focus title field when modal opens (skip in test environment)
React.useEffect(() => {
if (isOpen) {
if (titleInputRef.current && process.env.NODE_ENV !== "test") {
// Use setTimeout to ensure the dialog is fully rendered
const timer = setTimeout(() => {
titleInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}
}, [isOpen]);
// Auto-focus title field when toggling between normal and extended mode
React.useEffect(() => {
// Skip on initial render (when prevShowMoreRef is undefined)
if (
prevShowMoreRef.current !== undefined &&
isOpen &&
process.env.NODE_ENV !== "test"
) {
const hasChanged = prevShowMoreRef.current !== showMore;
if (hasChanged) {
// Simple setTimeout approach with sufficient delay for layout changes
const timer = setTimeout(() => {
if (titleInputRef.current) {
titleInputRef.current.focus();
}
}, 150);
// Update previous value before returning cleanup
prevShowMoreRef.current = showMore;
return () => clearTimeout(timer);
}
}
// Always update previous value
prevShowMoreRef.current = showMore;
}, [showMore, isOpen]);
// Validation logic
const validateForm = React.useCallback(() => {
// Title validation
const isTitleValid = title.trim().length > 0;
const shouldShowTitleError = showValidationErrors && !isTitleValid;
// Date/time validation
let isDateTimeValid = true;
let dateTimeError = "";
let startError = "";
// Convert to string if needed
const startStr = typeof start === "string" ? start : String(start || "");
const endStr = typeof end === "string" ? end : String(end || "");
// Check if start date is provided and valid
if (!start || startStr.trim() === "") {
isDateTimeValid = false;
dateTimeError = "Start date/time is required";
startError = "Start date/time is required";
} else if (isNaN(new Date(start).getTime())) {
isDateTimeValid = false;
dateTimeError = "Invalid start date/time";
startError = "Invalid start date/time";
}
// Check if end date is provided and valid
else if (!end || endStr.trim() === "") {
isDateTimeValid = false;
dateTimeError = "End date/time is required";
} else if (isNaN(new Date(end).getTime())) {
isDateTimeValid = false;
dateTimeError = "Invalid end date/time";
}
// Check if end is after start
else {
const startDate = new Date(start);
const endDate = new Date(end);
if (endDate <= startDate) {
isDateTimeValid = false;
dateTimeError = "End time must be after start time";
}
}
const isValid = isTitleValid && isDateTimeValid;
return {
isValid,
errors: {
title: shouldShowTitleError ? "Title is required" : "",
start: showValidationErrors ? startError : "",
dateTime: showValidationErrors ? dateTimeError : "",
},
};
}, [title, start, end, showValidationErrors]);
// Notify parent about validation changes
React.useEffect(() => {
const validation = validateForm();
onValidationChange?.(validation.isValid);
}, [validateForm, onValidationChange]);
const validation = validateForm();
const handleAddVideoConference = () => {
const newMeetingLink = generateMeetingLink();
const updatedDescription = addVideoConferenceToDescription(
@@ -219,18 +346,28 @@ export default function EventFormFields({
};
const handleStartChange = (newStart: string) => {
setStart(newStart);
onStartChange?.(newStart);
if (onStartChange) {
onStartChange(newStart);
} else {
setStart(newStart);
}
};
const handleEndChange = (newEnd: string) => {
setEnd(newEnd);
onEndChange?.(newEnd);
if (onEndChange) {
onEndChange(newEnd);
} else {
setEnd(newEnd);
}
};
const handleAllDayChange = (newAllDay: boolean) => {
const handleAllDayChange = (
newAllDay: boolean,
newStart: string,
newEnd: string
) => {
setAllDay(newAllDay);
onAllDayChange?.(newAllDay);
onAllDayChange?.(newAllDay, newStart, newEnd);
};
const handleCalendarChange = (newCalendarId: number) => {
@@ -250,15 +387,27 @@ export default function EventFormFields({
return (
<>
<FieldWithLabel label="Title" isExpanded={showMore}>
<FieldWithLabel
label={
<>
Title <span style={{ color: "red" }}>*</span>
</>
}
isExpanded={showMore}
>
<TextField
fullWidth
label={!showMore ? "Title" : ""}
placeholder="Add title"
value={title}
onChange={(e) => setTitle(e.target.value)}
onChange={(e) => {
setTitle(e.target.value);
}}
error={!!validation.errors.title}
helperText={validation.errors.title}
size="small"
margin="dense"
inputRef={titleInputRef}
/>
</FieldWithLabel>
@@ -307,40 +456,46 @@ export default function EventFormFields({
)}
<FieldWithLabel label="Date & Time" isExpanded={showMore}>
<Box display="flex" gap={2}>
<Box flexGrow={1}>
{showMore && (
<Typography variant="caption" display="block" mb={0.5}>
Start
</Typography>
)}
<TextField
fullWidth
label={!showMore ? "Start" : ""}
type={allday ? "date" : "datetime-local"}
value={allday ? start.split("T")[0] : start}
onChange={(e) => handleStartChange(e.target.value)}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
</Box>
<Box flexGrow={1}>
{showMore && (
<Typography variant="caption" display="block" mb={0.5}>
End
</Typography>
)}
<TextField
fullWidth
label={!showMore ? "End" : ""}
type={allday ? "date" : "datetime-local"}
value={allday ? end.split("T")[0] : end}
onChange={(e) => handleEndChange(e.target.value)}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
<Box display="flex" gap={2} flexDirection="column">
<Box display="flex" gap={2}>
<Box flexGrow={1}>
{showMore && (
<Typography variant="caption" display="block" mb={0.5}>
Start
</Typography>
)}
<TextField
fullWidth
label={!showMore ? "Start" : ""}
type={allday ? "date" : "datetime-local"}
value={allday ? start.split("T")[0] : start}
onChange={(e) => handleStartChange(e.target.value)}
error={!!validation.errors.start}
helperText={validation.errors.start}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
</Box>
<Box flexGrow={1}>
{showMore && (
<Typography variant="caption" display="block" mb={0.5}>
End
</Typography>
)}
<TextField
fullWidth
label={!showMore ? "End" : ""}
type={allday ? "date" : "datetime-local"}
value={allday ? end.split("T")[0] : end}
onChange={(e) => handleEndChange(e.target.value)}
error={!!validation.errors.dateTime}
helperText={validation.errors.dateTime}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
</Box>
</Box>
</Box>
</FieldWithLabel>
@@ -353,32 +508,96 @@ export default function EventFormFields({
checked={allday}
onChange={() => {
const newAllDay = !allday;
let newStart = start;
let newEnd = end;
if (newAllDay) {
// No allday => allday: existing logic
const endDate = new Date(end);
const startDate = new Date(start);
if (endDate.getDate() === startDate.getDate()) {
endDate.setDate(startDate.getDate() + 1);
setEnd(formatLocalDateTime(endDate));
// OFF => ON: Save original time before converting to all-day
if (start.includes("T")) {
originalTimeRef.current = { start, end };
}
// Convert to date-only format only if both dates are valid
if (start && end) {
const startDate = start.includes("T")
? new Date(start)
: new Date(start + "T00:00:00");
const endDate = end.includes("T")
? new Date(end)
: new Date(end + "T00:00:00");
// Check if dates are valid before proceeding
if (
!isNaN(startDate.getTime()) &&
!isNaN(endDate.getTime())
) {
// If same day, extend end to next day
if (endDate.getDate() === startDate.getDate()) {
endDate.setDate(startDate.getDate() + 1);
}
const formattedEnd = formatLocalDateTime(endDate);
if (formattedEnd) {
newEnd = formattedEnd;
}
}
}
} else {
// Allday => no allday: set end date = start date with rounded time
const startDate = new Date(start);
const currentTime = getRoundedCurrentTime();
// ON => OFF: Restore original time if available
if (originalTimeRef.current) {
// Extract hours/minutes from original time strings
const originalStartMatch =
originalTimeRef.current.start.match(/T(\d{2}):(\d{2})/);
const originalEndMatch =
originalTimeRef.current.end.match(/T(\d{2}):(\d{2})/);
// Set start time
startDate.setHours(currentTime.getHours());
startDate.setMinutes(currentTime.getMinutes());
setStart(formatLocalDateTime(startDate));
if (originalStartMatch && originalEndMatch) {
// Parse current date (YYYY-MM-DD)
const currentDate = start.split("T")[0];
// Set end date = start date, with time 30 minutes after start
const endDate = new Date(startDate);
endDate.setMinutes(endDate.getMinutes() + 30);
setEnd(formatLocalDateTime(endDate));
// Reconstruct datetime with original time
newStart = `${currentDate}T${originalStartMatch[1]}:${originalStartMatch[2]}`;
newEnd = `${currentDate}T${originalEndMatch[1]}:${originalEndMatch[2]}`;
}
// Clear stored time after use
originalTimeRef.current = null;
} else if (start) {
// No original time, use rounded current time
const startDate = start.includes("T")
? new Date(start)
: new Date(start + "T00:00:00");
// Check if start date is valid
if (!isNaN(startDate.getTime())) {
const currentTime = getRoundedCurrentTime();
startDate.setHours(currentTime.getHours());
startDate.setMinutes(currentTime.getMinutes());
const formattedStart = formatLocalDateTime(startDate);
if (formattedStart) {
newStart = formattedStart;
}
const endDate = new Date(startDate);
endDate.setMinutes(endDate.getMinutes() + 30);
const formattedEnd = formatLocalDateTime(endDate);
if (formattedEnd) {
newEnd = formattedEnd;
}
}
}
}
handleAllDayChange(newAllDay);
// Only update local state if no callback (for backwards compatibility)
if (!onAllDayChange) {
setStart(newStart);
setEnd(newEnd);
setAllDay(newAllDay);
} else {
// Let callback handle all state updates to avoid duplicate renders
handleAllDayChange(newAllDay, newStart, newEnd);
}
}}
/>
}
@@ -598,7 +817,26 @@ export default function EventFormFields({
);
}
export function formatLocalDateTime(date: Date): string {
export function formatLocalDateTime(date: Date, timeZone?: string): string {
// Guard against invalid or undefined dates
if (!date || isNaN(date.getTime())) {
return "";
}
if (timeZone) {
const formatter = new Intl.DateTimeFormat("en-CA", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone,
});
const formatted = formatter.format(date);
return formatted.replace(", ", "T");
}
const pad = (n: number) => n.toString().padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate()