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()
+302 -531
View File
@@ -1,26 +1,4 @@
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
import {
Checkbox,
FormControl,
FormControlLabel,
IconButton,
InputLabel,
MenuItem,
Select,
SelectChangeEvent,
TextField,
Typography,
ToggleButtonGroup,
ToggleButton,
} from "@mui/material";
import {
Description as DescriptionIcon,
Public as PublicIcon,
Lock as LockIcon,
CameraAlt as VideocamIcon,
ContentCopy as CopyIcon,
Close as DeleteIcon,
} from "@mui/icons-material";
import { Box, Button } from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import React, {
@@ -29,80 +7,27 @@ import React, {
useMemo,
useCallback,
useRef,
startTransition,
} from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
import { ResponsiveDialog } from "../../components/Dialog";
import { putEventAsync } from "../Calendars/CalendarSlice";
import { Calendars } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/userDataTypes";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { createSelector } from "@reduxjs/toolkit";
import RepeatEvent from "../../components/Event/EventRepeat";
import { TIMEZONES } from "../../utils/timezone-data";
import {
generateMeetingLink,
addVideoConferenceToDescription,
} from "../../utils/videoConferenceUtils";
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
import {
getTimezoneOffset,
resolveTimezone,
} from "../../components/Calendar/TimezoneSelector";
import { getCalendarRange } from "../../utils/dateUtils";
import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils";
import { TimezoneAutocomplete } from "../../components/Timezone/TimezoneAutocomplete";
// Helper component for field with label
const FieldWithLabel = React.memo(
({
label,
isExpanded,
children,
}: {
label: string;
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 EventFormFields, {
formatLocalDateTime,
formatDateTimeInTimezone,
} from "../../components/Event/EventFormFields";
function EventPopover({
anchorEl,
@@ -128,8 +53,8 @@ function EventPopover({
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
const tempList = useAppSelector((state) => state.calendars.templist);
const selectPersonnalCalendars = createSelector(
(state) => state.calendars,
(calendars) =>
(state: any) => state.calendars,
(calendars: any) =>
Object.keys(calendars.list)
.map((id) => {
if (id.split("/")[0] === userId) {
@@ -149,7 +74,7 @@ function EventPopover({
Intl.DateTimeFormat().resolvedOptions().timeZone
);
return { zones, browserTz };
return { zones, browserTz, getTimezoneOffset };
}, []);
const calendarTimezone = useAppSelector((state) => state.calendars.timeZone);
@@ -186,9 +111,7 @@ function EventPopover({
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
const [timezone, setTimezone] = useState(
event?.timezone
? resolveTimezone(event.timezone)
: resolveTimezone(calendarTimezone)
event?.timezone ? resolveTimezone(event.timezone) : calendarTimezone
);
const [hasVideoConference, setHasVideoConference] = useState(
event?.x_openpass_videoconference ? true : false
@@ -196,6 +119,8 @@ function EventPopover({
const [meetingLink, setMeetingLink] = useState<string | null>(
event?.x_openpass_videoconference || null
);
const [isFormValid, setIsFormValid] = useState(false);
const [showValidationErrors, setShowValidationErrors] = useState(false);
// Use ref to track if we've already initialized to avoid infinite loop
const isInitializedRef = useRef(false);
@@ -206,13 +131,6 @@ function EventPopover({
userPersonnalCalendarsRef.current = userPersonnalCalendars;
}, [userPersonnalCalendars]);
// Sync timezone with Redux when modal opens or timezone changes
useEffect(() => {
if (open && !event?.timezone && calendarTimezone) {
setTimezone(resolveTimezone(calendarTimezone));
}
}, [open, calendarTimezone, event?.timezone]);
const resetAllStateToDefault = useCallback(() => {
setShowMore(false);
setShowDescription(false);
@@ -229,31 +147,152 @@ function EventPopover({
setAlarm("");
setEventClass("PUBLIC");
setBusy("OPAQUE");
setTimezone(resolveTimezone(calendarTimezone));
setTimezone(calendarTimezone);
setHasVideoConference(false);
setMeetingLink(null);
}, [calendarTimezone]);
useEffect(() => {
if (selectedRange) {
setStart(
selectedRange ? formatLocalDateTime(selectedRange.start, timezone) : ""
);
setEnd(
selectedRange ? formatLocalDateTime(selectedRange.end, timezone) : ""
);
}
}, [selectedRange]);
// Track if we should sync from selectedRange (only on initial selection, not on toggle)
const shouldSyncFromRangeRef = useRef(true);
const prevOpenRef = useRef(false);
// Initialize state when event prop changes
// Sync timezone when modal opens or calendarTimezone changes
useEffect(() => {
if (event) {
// Detect modal opening (transition from closed to open)
const isOpening = open && !prevOpenRef.current;
if (isOpening) {
shouldSyncFromRangeRef.current = true;
setShowValidationErrors(false);
// Set timezone to calendar timezone for new events when opening
const isNewEvent = !event || !event.uid;
if (isNewEvent) {
const resolvedTimezone = resolveTimezone(calendarTimezone);
setTimezone(resolvedTimezone);
}
}
// Update previous open state
prevOpenRef.current = open;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, event?.uid, calendarTimezone]);
// Separately sync timezone when calendarTimezone changes while modal is open for new events
useEffect(() => {
if (open && (!event || !event.uid)) {
const resolvedTimezone = resolveTimezone(calendarTimezone);
setTimezone(resolvedTimezone);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [calendarTimezone, open, event?.uid]);
// Set start/end times when modal opens for new event creation
useEffect(() => {
// Only run when modal opens and not duplicating an event
// Check if event has uid to determine if it's a valid event (not empty object)
if (!shouldSyncFromRangeRef.current || !open || (event && event.uid)) {
return;
}
if (selectedRange && selectedRange.start && selectedRange.end) {
// selectedRange gives us the visual time displayed on calendar
// Use selectedRange.startStr and endStr if available (from FullCalendar)
if (selectedRange.startStr && selectedRange.endStr) {
// Check if they are strings (from FullCalendar) or need conversion
const startStr =
typeof selectedRange.startStr === "string"
? selectedRange.startStr
: formatLocalDateTime(selectedRange.start);
const endStr =
typeof selectedRange.endStr === "string"
? selectedRange.endStr
: formatLocalDateTime(selectedRange.end);
// Use the string values directly to preserve the displayed time
setStart(
selectedRange.allDay ? startStr.split("T")[0] : startStr.slice(0, 16) // YYYY-MM-DDTHH:mm
);
setEnd(
selectedRange.allDay ? endStr.split("T")[0] : endStr.slice(0, 16)
);
} else {
// Fallback: format Date objects using local time components
// Only set if both start and end are valid
const formattedStart = formatLocalDateTime(selectedRange.start);
const formattedEnd = formatLocalDateTime(selectedRange.end);
if (formattedStart) setStart(formattedStart);
if (formattedEnd) setEnd(formattedEnd);
}
} else {
// No valid selectedRange - use default times
// Start time = current time + 1 hour (rounded up to the hour)
// End time = start time + 1 hour
const now = new Date();
const nextHour = new Date(now);
nextHour.setHours(now.getHours() + 1);
nextHour.setMinutes(0);
nextHour.setSeconds(0);
nextHour.setMilliseconds(0);
const endTime = new Date(nextHour);
endTime.setHours(nextHour.getHours() + 1);
// Format using local time (browser timezone)
const formattedStart = formatLocalDateTime(nextHour);
const formattedEnd = formatLocalDateTime(endTime);
if (formattedStart) setStart(formattedStart);
if (formattedEnd) setEnd(formattedEnd);
}
shouldSyncFromRangeRef.current = false;
}, [selectedRange, open, event]);
// Initialize state when event prop changes (duplicate event or tempEvent with attendees)
useEffect(() => {
if (event && event.uid) {
// Editing existing event - populate fields with event data
setTitle(event.title ?? "");
setDescription(event.description ?? "");
setLocation(event.location ?? "");
setStart(event.start ? event.start : "");
setEnd(event.end ? event.end : "");
// Get event's timezone for formatting
const eventTimezone = event.timezone
? resolveTimezone(event.timezone)
: calendarTimezone;
// Handle all-day events properly
const isAllDay = event.allday ?? false;
setAllDay(isAllDay);
// Format dates based on all-day status and timezone
if (event.start) {
if (isAllDay) {
// For all-day events, use date format (YYYY-MM-DD)
const startDate = new Date(event.start);
setStart(startDate.toISOString().split("T")[0]);
} else {
// For timed events, format in the event's timezone
setStart(formatDateTimeInTimezone(event.start, eventTimezone));
}
} else {
setStart("");
}
if (event.end) {
if (isAllDay) {
// For all-day events, use date format (YYYY-MM-DD)
const endDate = new Date(event.end);
setEnd(endDate.toISOString().split("T")[0]);
} else {
// For timed events, format in the event's timezone
setEnd(formatDateTimeInTimezone(event.end, eventTimezone));
}
} else {
setEnd("");
}
setCalendarid(
event.calId
? userPersonnalCalendarsRef.current.findIndex(
@@ -261,7 +300,6 @@ function EventPopover({
)
: 0
);
setAllDay(event.allday ?? false);
setRepetition(event.repetition ?? ({} as RepetitionObject));
setShowRepeat(event.repetition?.freq ? true : false);
setAttendees(
@@ -274,9 +312,7 @@ function EventPopover({
setAlarm(event.alarm?.trigger ?? "");
setEventClass(event.class ?? "PUBLIC");
setBusy(event.transp ?? "OPAQUE");
setTimezone(
event.timezone ? resolveTimezone(event.timezone) : calendarTimezone
);
setTimezone(eventTimezone);
setHasVideoConference(event.x_openpass_videoconference ? true : false);
setMeetingLink(event.x_openpass_videoconference || null);
@@ -294,13 +330,23 @@ function EventPopover({
setDescription(event.description);
}
}
} else if (event && event.attendee && event.attendee.length > 0) {
// Handle tempEvent case (no uid but has attendees from temp calendar search)
setAttendees(
event.attendee.filter((a) => a.cal_address !== organizer?.cal_address)
);
}
}, [event, organizer?.cal_address, calendarTimezone]);
// Reset state when creating new event (event is undefined)
// Reset state when creating new event (event is empty object or undefined)
useEffect(() => {
if (!event && isInitializedRef.current) {
const isCreatingNew = !event || !event.uid;
const wasInitialized = isInitializedRef.current;
if (isCreatingNew && wasInitialized) {
// Creating new event - reset all fields to default
// Note: start and end are handled by the selectedRange useEffect
// Note: timezone is handled by separate useEffect above
setShowMore(false);
setShowDescription(false);
setShowRepeat(false);
@@ -308,61 +354,117 @@ function EventPopover({
setDescription("");
setAttendees([]);
setLocation("");
setStart("");
setEnd("");
setCalendarid(0);
setAllDay(false);
setRepetition({} as RepetitionObject);
setAlarm("");
setEventClass("PUBLIC");
setBusy("OPAQUE");
setTimezone(timezoneList.browserTz);
setHasVideoConference(false);
setMeetingLink(null);
}
isInitializedRef.current = true;
}, [event, calendarTimezone]);
const handleAddVideoConference = () => {
const newMeetingLink = generateMeetingLink();
const updatedDescription = addVideoConferenceToDescription(
description,
newMeetingLink
);
setDescription(updatedDescription);
setHasVideoConference(true);
setMeetingLink(newMeetingLink);
};
const handleCopyMeetingLink = async () => {
if (meetingLink) {
try {
await navigator.clipboard.writeText(meetingLink);
// You could add a toast notification here
console.log("Meeting link copied to clipboard");
} catch (err) {
console.error("Failed to copy link:", err);
}
if (!isCreatingNew) {
isInitializedRef.current = true;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [event?.uid]);
const handleDeleteVideoConference = () => {
// Remove video conference footer from description
const updatedDescription = description.replace(
/\nVisio: https?:\/\/[^\s]+/,
""
);
setDescription(updatedDescription);
setHasVideoConference(false);
setMeetingLink(null);
};
const handleStartChange = useCallback(
(newStart: string) => {
setStart(newStart);
// Defer visual feedback (non-urgent)
startTransition(() => {
setSelectedRange((prev: DateSelectArg | null) => {
const newRange = {
...prev,
start: new Date(newStart),
startStr: newStart,
allDay: allday,
} as DateSelectArg;
calendarRef.current?.select(newRange);
return newRange;
});
});
},
// calendarRef and setSelectedRange are stable refs/setters from parent
// eslint-disable-next-line react-hooks/exhaustive-deps
[allday]
);
const handleEndChange = useCallback(
(newEnd: string) => {
setEnd(newEnd);
// Defer visual feedback (non-urgent)
startTransition(() => {
setSelectedRange((prev: DateSelectArg | null) => {
const newRange = {
...prev,
end: new Date(newEnd),
endStr: newEnd,
allDay: allday,
} as DateSelectArg;
calendarRef.current?.select(newRange);
return newRange;
});
});
},
// calendarRef and setSelectedRange are stable refs/setters from parent
// eslint-disable-next-line react-hooks/exhaustive-deps
[allday]
);
const handleAllDayChange = useCallback(
(newAllDay: boolean, newStart: string, newEnd: string) => {
// Update critical state immediately (checkbox response)
setAllDay(newAllDay);
setStart(newStart);
setEnd(newEnd);
// Defer visual feedback updates (non-urgent)
startTransition(() => {
setSelectedRange((prev: DateSelectArg | null) => {
const newRange = {
...prev,
startStr: newAllDay ? newStart.split("T")[0] : newStart,
endStr: newAllDay ? newEnd.split("T")[0] : newEnd,
start: new Date(
newAllDay ? newStart.split("T")[0] + "T00:00:00" : newStart
),
end: new Date(
newAllDay ? newEnd.split("T")[0] + "T00:00:00" : newEnd
),
allDay: newAllDay,
} as DateSelectArg;
calendarRef.current?.select(newRange);
return newRange;
});
});
},
// calendarRef and setSelectedRange are stable refs/setters from parent
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
);
const handleClose = () => {
onClose({}, "backdropClick");
setShowValidationErrors(false);
resetAllStateToDefault();
setStart("");
setEnd("");
shouldSyncFromRangeRef.current = true; // Reset for next time
};
const handleSave = async () => {
// Show validation errors when Save is clicked
setShowValidationErrors(true);
// Check if form is valid before saving
if (!isFormValid) {
return;
}
const newEventUID = crypto.randomUUID();
const newEvent: CalendarEvent = {
@@ -401,6 +503,9 @@ function EventPopover({
newEvent.attendee = newEvent.attendee.concat(attendees);
}
// Reset validation state when validation passes
setShowValidationErrors(false);
// Close popup immediately
onClose({}, "backdropClick");
@@ -433,7 +538,7 @@ function EventPopover({
Cancel
</Button>
)}
<Button variant="contained" onClick={handleSave} disabled={!title}>
<Button variant="contained" onClick={handleSave}>
Save
</Button>
</Box>
@@ -449,387 +554,53 @@ function EventPopover({
onExpandToggle={() => setShowMore(!showMore)}
actions={dialogActions}
>
<FieldWithLabel label="Title" isExpanded={showMore}>
<TextField
fullWidth
label={!showMore ? "Title" : ""}
placeholder="Add title"
value={title}
onChange={(e) => setTitle(e.target.value)}
size="small"
margin="dense"
/>
</FieldWithLabel>
{!showDescription && (
<FieldWithLabel label=" " isExpanded={showMore}>
<Box display="flex" gap={1} mb={1}>
<Button
startIcon={<DescriptionIcon />}
onClick={() => setShowDescription(true)}
size="small"
sx={{
textTransform: "none",
color: "text.secondary",
}}
>
Add description
</Button>
</Box>
</FieldWithLabel>
)}
{showDescription && (
<FieldWithLabel label="Description" isExpanded={showMore}>
<TextField
fullWidth
label={!showMore ? "Description" : ""}
placeholder="Add description"
value={description}
onChange={(e) => setDescription(e.target.value)}
size="small"
margin="dense"
multiline
minRows={2}
maxRows={10}
sx={{
"& .MuiInputBase-root": {
maxHeight: "33%",
overflowY: "auto",
},
"& textarea": {
resize: "vertical",
},
}}
/>
</FieldWithLabel>
)}
<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) => {
const newStart = e.target.value;
setStart(newStart);
// Update selectedRange for visual feedback
const startISO = formatLocalDateTime(
new Date(newStart),
timezone
);
const newRange = {
...selectedRange,
start: new Date(startISO),
startStr: startISO,
allDay: allday,
};
setSelectedRange(newRange);
calendarRef.current?.select(newRange);
}}
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) => {
const newEnd = e.target.value;
setEnd(newEnd);
// Update selectedRange for visual feedback
const endISO = formatLocalDateTime(new Date(newEnd), timezone);
const newRange = {
...selectedRange,
end: new Date(endISO),
endStr: endISO,
allDay: allday,
};
setSelectedRange(newRange);
calendarRef.current?.select(newRange);
}}
size="small"
margin="dense"
InputLabelProps={{ shrink: true }}
/>
</Box>
</Box>
</FieldWithLabel>
<FieldWithLabel label=" " isExpanded={showMore}>
<Box display="flex" gap={2} alignItems="center">
<FormControlLabel
control={
<Checkbox
checked={allday}
onChange={() => {
const endDate = new Date(end);
const startDate = new Date(start);
setAllDay(!allday);
if (endDate.getDate() === startDate.getDate()) {
endDate.setDate(startDate.getDate() + 1);
setEnd(formatLocalDateTime(endDate, timezone));
}
const newRange = {
...selectedRange,
startStr: allday ? start.split("T")[0] : start,
endStr: allday
? endDate.toISOString().split("T")[0]
: endDate.toISOString(),
start: new Date(allday ? start.split("T")[0] : start),
end: new Date(
allday
? endDate.toISOString().split("T")[0]
: endDate.toISOString()
),
allDay: allday,
};
setSelectedRange(newRange);
}}
/>
}
label="All day"
/>
<FormControlLabel
control={
<Checkbox
checked={showRepeat}
onChange={() => {
const newShowRepeat = !showRepeat;
setShowRepeat(newShowRepeat);
if (newShowRepeat) {
setRepetition({
freq: "daily",
interval: 1,
occurrences: 0,
endDate: "",
selectedDays: [],
} as RepetitionObject);
} else {
setRepetition({
freq: "",
interval: 1,
occurrences: 0,
endDate: "",
selectedDays: [],
} as RepetitionObject);
}
}}
/>
}
label="Repeat"
/>
<TimezoneAutocomplete
value={timezone}
onChange={setTimezone}
zones={timezoneList.zones}
getTimezoneOffset={getTimezoneOffset}
showIcon={true}
width={240}
size="small"
placeholder="Select timezone"
/>
</Box>
</FieldWithLabel>
{showRepeat && (
<FieldWithLabel label=" " isExpanded={showMore}>
<RepeatEvent
repetition={repetition}
eventStart={selectedRange?.start ?? new Date()}
setRepetition={setRepetition}
/>
</FieldWithLabel>
)}
<FieldWithLabel label="Participants" isExpanded={showMore}>
<AttendeeSelector attendees={attendees} setAttendees={setAttendees} />
</FieldWithLabel>
<FieldWithLabel label="Video meeting" isExpanded={showMore}>
<Box display="flex" gap={1} alignItems="center">
<Button
startIcon={<VideocamIcon />}
onClick={handleAddVideoConference}
size="medium"
sx={{
textTransform: "none",
color: "text.secondary",
display: hasVideoConference ? "none" : "flex",
}}
>
Add Visio conference
</Button>
{hasVideoConference && meetingLink && (
<>
<Button
startIcon={<VideocamIcon />}
onClick={() => window.open(meetingLink, "_blank")}
size="medium"
variant="contained"
sx={{
textTransform: "none",
mr: 1,
}}
>
Join Visio conference
</Button>
<IconButton
onClick={handleCopyMeetingLink}
size="small"
sx={{ color: "primary.main" }}
aria-label="Copy meeting link"
title="Copy meeting link"
>
<CopyIcon />
</IconButton>
<IconButton
onClick={handleDeleteVideoConference}
size="small"
sx={{ color: "error.main" }}
aria-label="Remove video conference"
title="Remove video conference"
>
<DeleteIcon />
</IconButton>
</>
)}
</Box>
</FieldWithLabel>
<FieldWithLabel label="Location" isExpanded={showMore}>
<TextField
fullWidth
label={!showMore ? "Location" : ""}
placeholder="Add location"
value={location}
onChange={(e) => setLocation(e.target.value)}
size="small"
margin="dense"
/>
</FieldWithLabel>
<FieldWithLabel label="Calendar" isExpanded={showMore}>
<FormControl fullWidth margin="dense" size="small">
{!showMore && (
<InputLabel id="calendar-select-label">Calendar</InputLabel>
)}
<Select
labelId="calendar-select-label"
value={calendarid.toString()}
label={!showMore ? "Calendar" : ""}
displayEmpty
onChange={(e: SelectChangeEvent) =>
setCalendarid(Number(e.target.value))
}
>
{Object.keys(userPersonnalCalendars).map((calendar, index) => (
<MenuItem key={index} value={index}>
{userPersonnalCalendars[index].name}
</MenuItem>
))}
</Select>
</FormControl>
</FieldWithLabel>
{/* Extended options */}
{showMore && (
<>
<FieldWithLabel label="Notification" isExpanded={showMore}>
<FormControl fullWidth margin="dense" size="small">
<Select
labelId="notification"
value={alarm}
onChange={(e: SelectChangeEvent) => setAlarm(e.target.value)}
>
<MenuItem value={""}>No Notification</MenuItem>
<MenuItem value={"-PT1M"}>1 minute</MenuItem>
<MenuItem value={"-PT5M"}>2 minutes</MenuItem>
<MenuItem value={"-PT10M"}>10 minutes</MenuItem>
<MenuItem value={"-PT15M"}>15 minutes</MenuItem>
<MenuItem value={"-PT30M"}>30 minutes</MenuItem>
<MenuItem value={"-PT1H"}>1 hours</MenuItem>
<MenuItem value={"-PT2H"}>2 hours</MenuItem>
<MenuItem value={"-PT5H"}>5 hours</MenuItem>
<MenuItem value={"-PT12H"}>12 hours</MenuItem>
<MenuItem value={"-PT1D"}>1 day</MenuItem>
<MenuItem value={"-PT2D"}>2 days</MenuItem>
<MenuItem value={"-PT1W"}>1 week</MenuItem>
</Select>
</FormControl>
</FieldWithLabel>
<FieldWithLabel label="Show me as" isExpanded={showMore}>
<FormControl fullWidth margin="dense" size="small">
<Select
labelId="busy"
value={busy}
onChange={(e: SelectChangeEvent) => setBusy(e.target.value)}
>
<MenuItem value={"TRANSPARENT"}>Free</MenuItem>
<MenuItem value={"OPAQUE"}>Busy </MenuItem>
</Select>
</FormControl>
</FieldWithLabel>
<FieldWithLabel label="Visible to" isExpanded={showMore}>
<ToggleButtonGroup
value={eventClass}
exclusive
onChange={(e, newValue) => {
if (newValue !== null) {
setEventClass(newValue);
}
}}
size="small"
>
<ToggleButton value="PUBLIC" sx={{ width: "140px" }}>
<PublicIcon sx={{ mr: 1, fontSize: "16px" }} />
All
</ToggleButton>
<ToggleButton value="PRIVATE" sx={{ width: "140px" }}>
<LockIcon sx={{ mr: 1, fontSize: "16px" }} />
Participants
</ToggleButton>
</ToggleButtonGroup>
</FieldWithLabel>
</>
)}
<EventFormFields
title={title}
setTitle={setTitle}
description={description}
setDescription={setDescription}
location={location}
setLocation={setLocation}
start={start}
setStart={setStart}
end={end}
setEnd={setEnd}
allday={allday}
setAllDay={setAllDay}
repetition={repetition}
setRepetition={setRepetition}
attendees={attendees}
setAttendees={setAttendees}
alarm={alarm}
setAlarm={setAlarm}
busy={busy}
setBusy={setBusy}
eventClass={eventClass}
setEventClass={setEventClass}
timezone={timezone}
setTimezone={setTimezone}
calendarid={calendarid}
setCalendarid={setCalendarid}
hasVideoConference={hasVideoConference}
setHasVideoConference={setHasVideoConference}
meetingLink={meetingLink}
setMeetingLink={setMeetingLink}
showMore={showMore}
showDescription={showDescription}
setShowDescription={setShowDescription}
showRepeat={showRepeat}
setShowRepeat={setShowRepeat}
isOpen={open}
userPersonnalCalendars={userPersonnalCalendars}
timezoneList={timezoneList}
onStartChange={handleStartChange}
onEndChange={handleEndChange}
onAllDayChange={handleAllDayChange}
onValidationChange={setIsFormValid}
showValidationErrors={showValidationErrors}
/>
</ResponsiveDialog>
);
}
export default EventPopover;
export function formatLocalDateTime(date: Date, timeZone?: string) {
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");
}
+30 -10
View File
@@ -84,9 +84,9 @@ function EventUpdateModal({
const calendarsList = useAppSelector((state) => state.calendars.list);
const userPersonnalCalendars: Calendars[] = useMemo(() => {
const allCalendars = Object.values(calendarsList);
const allCalendars = Object.values(calendarsList) as Calendars[];
return allCalendars.filter(
(c) => c.id?.split("/")[0] === user.userData?.openpaasId
(c: Calendars) => c.id?.split("/")[0] === user.userData?.openpaasId
);
}, [calendarsList, user.userData?.openpaasId]);
@@ -158,6 +158,8 @@ function EventUpdateModal({
const [attendees, setAttendees] = useState<userAttendee[]>([]);
const [hasVideoConference, setHasVideoConference] = useState(false);
const [meetingLink, setMeetingLink] = useState<string | null>(null);
const [isFormValid, setIsFormValid] = useState(false);
const [showValidationErrors, setShowValidationErrors] = useState(false);
const resetAllStateToDefault = useCallback(() => {
setShowMore(false);
@@ -188,6 +190,9 @@ function EventUpdateModal({
// Initialize form state when event data is available
useEffect(() => {
if (event && open) {
// Reset validation errors when modal opens
setShowValidationErrors(false);
// Editing existing event - populate fields with event data
setTitle(event.title ?? "");
setDescription(event.description ?? "");
@@ -258,7 +263,8 @@ function EventUpdateModal({
setAttendees(
event.attendee
? event.attendee.filter(
(a) => a.cal_address !== event.organizer?.cal_address
(a: userAttendee) =>
a.cal_address !== event.organizer?.cal_address
)
: []
);
@@ -302,11 +308,21 @@ function EventUpdateModal({
const handleClose = () => {
closeModal();
setShowValidationErrors(false);
resetAllStateToDefault();
setFreshEvent(null);
initializedKeyRef.current = null;
};
const handleSave = async () => {
// Show validation errors when Save is clicked
setShowValidationErrors(true);
// Check if form is valid before saving
if (!isFormValid) {
return;
}
if (!event) return;
const organizer = event.organizer;
@@ -317,6 +333,9 @@ function EventUpdateModal({
return;
}
// Reset validation state when validation passes
setShowValidationErrors(false);
// Handle recurrence instances
const [baseUID, recurrenceId] = event.uid.split("/");
@@ -507,7 +526,7 @@ function EventUpdateModal({
})
)
.unwrap()
.catch((error) => {
.catch((error: any) => {
dispatch(updateEventLocal({ calId, event: oldEvent }));
showErrorNotification("Failed to update event. Changes reverted.");
});
@@ -666,12 +685,10 @@ function EventUpdateModal({
</Button>
)}
<Box display="flex" gap={1} ml={showMore ? "auto" : 0}>
{showMore && (
<Button variant="outlined" onClick={handleClose}>
Cancel
</Button>
)}
<Button variant="contained" onClick={handleSave} disabled={!title}>
<Button variant="outlined" onClick={handleClose}>
Cancel
</Button>
<Button variant="contained" onClick={handleSave}>
Save
</Button>
</Box>
@@ -726,6 +743,7 @@ function EventUpdateModal({
setShowDescription={setShowDescription}
showRepeat={typeOfAction !== "solo" && showRepeat}
setShowRepeat={setShowRepeat}
isOpen={open}
userPersonnalCalendars={userPersonnalCalendars}
timezoneList={timezoneList}
onCalendarChange={(newCalendarId) => {
@@ -734,6 +752,8 @@ function EventUpdateModal({
setNewCalId(selectedCalendar.id);
}
}}
onValidationChange={setIsFormValid}
showValidationErrors={showValidationErrors}
/>
</ResponsiveDialog>
);