From 701f2f2b716c3c5dfbcccfe3ac6eb72d41c277a5 Mon Sep 17 00:00:00 2001 From: lenhanphung Date: Fri, 24 Oct 2025 16:02:16 +0700 Subject: [PATCH] feat: Split datetime fields - Replace single datetime-local field with 4 separate fields: - Add helper functions: * splitDateTime(): Split datetime string into date and time parts * combineDateTime(): Combine date and time into datetime string - Implement internal state management: * startDate, startTime, endDate, endTime internal states * Sync effects to update internal states from start/end props * Change handlers for each field that combine values and call parent handlers - Fix test cases: * Update EventModal.test.tsx, EventUpdateModal.test.tsx to use new field labels * Change from 'Start'/'End' to 'Start Date'/'Start Time'/'End Time'/'End Date' --- __test__/features/Events/EventModal.test.tsx | 7 +- .../features/Events/EventUpdateModal.test.tsx | 20 +- .../Calendar/utils/calendarColorsUtils.tsx | 6 +- src/components/Event/EventFormFields.tsx | 460 +++++++++++++----- src/features/Events/EventModal.tsx | 8 + 5 files changed, 361 insertions(+), 140 deletions(-) diff --git a/__test__/features/Events/EventModal.test.tsx b/__test__/features/Events/EventModal.test.tsx index 587061a..2eda43d 100644 --- a/__test__/features/Events/EventModal.test.tsx +++ b/__test__/features/Events/EventModal.test.tsx @@ -152,7 +152,8 @@ describe("EventPopover", () => { allDay: false, } as unknown as DateSelectArg); - expect(screen.getByLabelText("Start")).toHaveValue("2026-07-20T10:00"); + expect(screen.getByLabelText("Start Date")).toHaveValue("2026-07-20"); + expect(screen.getByLabelText("Start Time")).toHaveValue("10:00"); }); it("updates inputs on change", () => { @@ -276,10 +277,10 @@ describe("EventPopover", () => { target: { value: newEvent.title }, }); fireEvent.click(screen.getByLabelText("All day")); - fireEvent.change(screen.getByLabelText("Start"), { + fireEvent.change(screen.getByLabelText("Start Date"), { target: { value: newEvent.start.split("T")[0] }, }); - fireEvent.change(screen.getByLabelText("End"), { + fireEvent.change(screen.getByLabelText("End Date"), { target: { value: newEvent.end.split("T")[0] }, }); diff --git a/__test__/features/Events/EventUpdateModal.test.tsx b/__test__/features/Events/EventUpdateModal.test.tsx index ccd5e56..cbda6cc 100644 --- a/__test__/features/Events/EventUpdateModal.test.tsx +++ b/__test__/features/Events/EventUpdateModal.test.tsx @@ -92,16 +92,20 @@ describe("EventUpdateModal Timezone Handling", () => { const titleInput = screen.getByDisplayValue("Timezone Event"); expect(titleInput).toBeInTheDocument(); - // Verify the start time input exists - // Since showMore is false by default, label should be "Start" + // Verify the start date and time inputs exist + // Since showMore is false by default, labels should be "Start Date" and "Start Time" await waitFor(() => { - const startInput = screen.getByLabelText("Start"); - expect(startInput).toBeInTheDocument(); - expect(startInput).toHaveAttribute("type", "datetime-local"); + const startDateInput = screen.getByLabelText("Start Date"); + const startTimeInput = screen.getByLabelText("Start Time"); - // The value should be formatted as 2025-01-15T14:00 (2PM in Bangkok time) - // EventUpdateModal uses formatDateTimeInTimezone which formats in event's original timezone - expect(startInput).toHaveValue("2025-01-15T14:00"); + expect(startDateInput).toBeInTheDocument(); + expect(startTimeInput).toBeInTheDocument(); + expect(startDateInput).toHaveAttribute("type", "date"); + expect(startTimeInput).toHaveAttribute("type", "time"); + + // The values should be split from 2025-01-15T14:00 + expect(startDateInput).toHaveValue("2025-01-15"); + expect(startTimeInput).toHaveValue("14:00"); }); }); diff --git a/src/components/Calendar/utils/calendarColorsUtils.tsx b/src/components/Calendar/utils/calendarColorsUtils.tsx index 209c536..b8abb45 100644 --- a/src/components/Calendar/utils/calendarColorsUtils.tsx +++ b/src/components/Calendar/utils/calendarColorsUtils.tsx @@ -9,7 +9,7 @@ export function updateDarkColor( dispatch: ThunkDispatch ) { Object.values(calendars).forEach((cal) => { - if (!cal?.color?.light) return; + if (!cal?.color?.light || typeof cal.color.light !== "string") return; const baseColor = cal.color.light; const isDefault = Object.values(defaultColors).find( @@ -29,6 +29,10 @@ export function updateDarkColor( } export function getAccessiblePair(baseColor: string, theme: Theme): string { + if (typeof baseColor !== "string") { + return theme.palette.getContrastText("#000"); + } + const contrastToBlack = getContrastRatio(baseColor, "#000"); const contrastToWhite = getContrastRatio(baseColor, "#fff"); const isLight = contrastToBlack > contrastToWhite; diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index e0ecf0d..c10b22e 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -35,6 +35,23 @@ import { 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( ({ @@ -200,9 +217,18 @@ export default function EventFormFields({ showValidationErrors = false, }: EventFormFieldsProps) { // Store original time before toggling to all-day - const originalTimeRef = React.useRef<{ start: string; end: string } | null>( - null - ); + 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(""); // Ref for title input field to enable auto-focus const titleInputRef = React.useRef(null); @@ -251,44 +277,135 @@ export default function EventFormFields({ prevShowMoreRef.current = showMore; }, [showMore, isOpen]); + // Sync start prop to startDate/startTime + React.useEffect(() => { + if (start) { + const { date, time } = splitDateTime(start); + setStartDate(date); + setStartTime(time); + } + }, [start]); + + // Sync end prop to endDate/endTime + React.useEffect(() => { + if (end) { + const { date, time } = splitDateTime(end); + setEndDate(date); + setEndTime(time); + } + }, [end]); + + // Sync allday prop changes to detect all-day slot clicks + React.useEffect(() => { + if (allday && (!startTime || !endTime)) { + // This is likely from all-day slot click - set time fields empty + setStartTime(""); + setEndTime(""); + + // Mark this as coming from all-day slot for later uncheck logic + originalTimeRef.current = { + start: "", + end: "", + endDate: endDate, + fromAllDaySlot: true, + }; + } + }, [allday, startTime, endTime, endDate]); + + // Change handlers for 4 separate fields + const handleStartDateChange = React.useCallback( + (newDate: string) => { + setStartDate(newDate); + const newStart = combineDateTime(newDate, startTime); + if (onStartChange) { + onStartChange(newStart); + } else { + setStart(newStart); + } + }, + [startTime, onStartChange, setStart] + ); + + const handleStartTimeChange = React.useCallback( + (newTime: string) => { + setStartTime(newTime); + const newStart = combineDateTime(startDate, newTime); + if (onStartChange) { + onStartChange(newStart); + } else { + setStart(newStart); + } + }, + [startDate, onStartChange, setStart] + ); + + const handleEndDateChange = React.useCallback( + (newDate: string) => { + setEndDate(newDate); + const newEnd = combineDateTime(newDate, endTime); + if (onEndChange) { + onEndChange(newEnd); + } else { + setEnd(newEnd); + } + }, + [endTime, onEndChange, setEnd] + ); + + const handleEndTimeChange = React.useCallback( + (newTime: string) => { + setEndTime(newTime); + const newEnd = combineDateTime(endDate, newTime); + if (onEndChange) { + onEndChange(newEnd); + } else { + setEnd(newEnd); + } + }, + [endDate, onEndChange, setEnd] + ); + // 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() === "") { + // Validate start date + if (!startDate || startDate.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"; + dateTimeError = "Start date is required"; } - // Check if end date is provided and valid - else if (!end || endStr.trim() === "") { + // Validate start time (if not all-day) + else if (!allday && (!startTime || startTime.trim() === "")) { isDateTimeValid = false; - dateTimeError = "End date/time is required"; - } else if (isNaN(new Date(end).getTime())) { - isDateTimeValid = false; - dateTimeError = "Invalid end date/time"; + dateTimeError = "Start time is required"; } - // Check if end is after start + // 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 startDate = new Date(start); - const endDate = new Date(end); - if (endDate <= startDate) { + 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"; } @@ -300,11 +417,18 @@ export default function EventFormFields({ isValid, errors: { title: shouldShowTitleError ? "Title is required" : "", - start: showValidationErrors ? startError : "", dateTime: showValidationErrors ? dateTimeError : "", }, }; - }, [title, start, end, showValidationErrors]); + }, [ + title, + startDate, + startTime, + endDate, + endTime, + allday, + showValidationErrors, + ]); // Notify parent about validation changes React.useEffect(() => { @@ -346,22 +470,6 @@ export default function EventFormFields({ setMeetingLink(null); }; - const handleStartChange = (newStart: string) => { - if (onStartChange) { - onStartChange(newStart); - } else { - setStart(newStart); - } - }; - - const handleEndChange = (newEnd: string) => { - if (onEndChange) { - onEndChange(newEnd); - } else { - setEnd(newEnd); - } - }; - const handleAllDayChange = ( newAllDay: boolean, newStart: string, @@ -457,47 +565,109 @@ export default function EventFormFields({ )} - - - + + {/* First row: 4 fields */} + + {/* Start Date - 30% */} + {showMore && ( - Start + Start Date )} handleStartChange(e.target.value)} - error={!!validation.errors.start} - helperText={validation.errors.start} + label={!showMore ? "Start Date" : ""} + type="date" + value={startDate} + onChange={(e) => handleStartDateChange(e.target.value)} size="small" margin="dense" InputLabelProps={{ shrink: true }} /> - + + {/* Start Time - 20% */} + {showMore && ( - End + Start Time )} handleEndChange(e.target.value)} + label={!showMore ? "Start Time" : ""} + type="time" + value={startTime} + onChange={(e) => 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} - helperText={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} + + + + )} @@ -513,90 +683,124 @@ export default function EventFormFields({ let newEnd = end; if (newAllDay) { - // OFF => ON: Save original time before converting to all-day + // OFF => ON: Save original time AND original end date if (start.includes("T")) { - originalTimeRef.current = { start, end }; + originalTimeRef.current = { + start: startTime, + end: endTime, + endDate: endDate, // Save original end date + }; } - // 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"); + // Reset time to empty, keep only date part + setStartTime(""); + setEndTime(""); + newStart = startDate; + newEnd = endDate; - // 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; - } - } + // 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 if available + // ON => OFF: Restore original time AND original end date 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})/); - - if (originalStartMatch && originalEndMatch) { - // Parse current date (YYYY-MM-DD) - const currentDate = start.split("T")[0]; - - // 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())) { + // 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}`; - startDate.setHours(currentTime.getHours()); - startDate.setMinutes(currentTime.getMinutes()); - const formattedStart = formatLocalDateTime(startDate); - if (formattedStart) { - newStart = formattedStart; - } + newStart = combineDateTime(startDate, timeStr); - const endDate = new Date(startDate); - endDate.setMinutes(endDate.getMinutes() + 30); - const formattedEnd = formatLocalDateTime(endDate); - if (formattedEnd) { - newEnd = formattedEnd; - } + // 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); } } - // 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); } }} diff --git a/src/features/Events/EventModal.tsx b/src/features/Events/EventModal.tsx index a095256..ee794cc 100644 --- a/src/features/Events/EventModal.tsx +++ b/src/features/Events/EventModal.tsx @@ -201,6 +201,11 @@ function EventPopover({ } if (selectedRange && selectedRange.start && selectedRange.end) { + // Auto-check allday if selectedRange is all-day + if (selectedRange.allDay) { + setAllDay(true); + } + // selectedRange gives us the visual time displayed on calendar // Use selectedRange.startStr and endStr if available (from FullCalendar) if (selectedRange.startStr && selectedRange.endStr) { @@ -249,6 +254,9 @@ function EventPopover({ if (formattedStart) setStart(formattedStart); if (formattedEnd) setEnd(formattedEnd); + + // Default to non-all-day when no selectedRange + setAllDay(false); } shouldSyncFromRangeRef.current = false;