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:
@@ -16,7 +16,7 @@ import { getEvent } from "../../../features/Events/EventApi";
|
|||||||
import { refreshCalendars } from "../../Event/utils/eventUtils";
|
import { refreshCalendars } from "../../Event/utils/eventUtils";
|
||||||
import { updateTempCalendar } from "../utils/calendarUtils";
|
import { updateTempCalendar } from "../utils/calendarUtils";
|
||||||
import { User } from "../../Attendees/PeopleSearch";
|
import { User } from "../../Attendees/PeopleSearch";
|
||||||
import { formatLocalDateTime } from "../../../features/Events/EventModal";
|
import { formatLocalDateTime } from "../../Event/EventFormFields";
|
||||||
|
|
||||||
export interface EventHandlersProps {
|
export interface EventHandlersProps {
|
||||||
setSelectedRange: (range: DateSelectArg | null) => void;
|
setSelectedRange: (range: DateSelectArg | null) => void;
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ export const FieldWithLabel = React.memo(
|
|||||||
isExpanded,
|
isExpanded,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string | React.ReactNode;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -126,6 +126,7 @@ interface EventFormFieldsProps {
|
|||||||
setShowDescription: (showDescription: boolean) => void;
|
setShowDescription: (showDescription: boolean) => void;
|
||||||
showRepeat: boolean;
|
showRepeat: boolean;
|
||||||
setShowRepeat: (showRepeat: boolean) => void;
|
setShowRepeat: (showRepeat: boolean) => void;
|
||||||
|
isOpen?: boolean;
|
||||||
|
|
||||||
// Data
|
// Data
|
||||||
userPersonnalCalendars: Calendars[];
|
userPersonnalCalendars: Calendars[];
|
||||||
@@ -138,8 +139,16 @@ interface EventFormFieldsProps {
|
|||||||
// Event handlers
|
// Event handlers
|
||||||
onStartChange?: (newStart: string) => void;
|
onStartChange?: (newStart: string) => void;
|
||||||
onEndChange?: (newEnd: string) => void;
|
onEndChange?: (newEnd: string) => void;
|
||||||
onAllDayChange?: (newAllDay: boolean) => void;
|
onAllDayChange?: (
|
||||||
|
newAllDay: boolean,
|
||||||
|
newStart: string,
|
||||||
|
newEnd: string
|
||||||
|
) => void;
|
||||||
onCalendarChange?: (newCalendarId: number) => void;
|
onCalendarChange?: (newCalendarId: number) => void;
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
onValidationChange?: (isValid: boolean) => void;
|
||||||
|
showValidationErrors?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EventFormFields({
|
export default function EventFormFields({
|
||||||
@@ -179,13 +188,131 @@ export default function EventFormFields({
|
|||||||
setShowDescription,
|
setShowDescription,
|
||||||
showRepeat,
|
showRepeat,
|
||||||
setShowRepeat,
|
setShowRepeat,
|
||||||
|
isOpen = false,
|
||||||
userPersonnalCalendars,
|
userPersonnalCalendars,
|
||||||
timezoneList,
|
timezoneList,
|
||||||
onStartChange,
|
onStartChange,
|
||||||
onEndChange,
|
onEndChange,
|
||||||
onAllDayChange,
|
onAllDayChange,
|
||||||
onCalendarChange,
|
onCalendarChange,
|
||||||
|
onValidationChange,
|
||||||
|
showValidationErrors = false,
|
||||||
}: EventFormFieldsProps) {
|
}: 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 handleAddVideoConference = () => {
|
||||||
const newMeetingLink = generateMeetingLink();
|
const newMeetingLink = generateMeetingLink();
|
||||||
const updatedDescription = addVideoConferenceToDescription(
|
const updatedDescription = addVideoConferenceToDescription(
|
||||||
@@ -219,18 +346,28 @@ export default function EventFormFields({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleStartChange = (newStart: string) => {
|
const handleStartChange = (newStart: string) => {
|
||||||
setStart(newStart);
|
if (onStartChange) {
|
||||||
onStartChange?.(newStart);
|
onStartChange(newStart);
|
||||||
|
} else {
|
||||||
|
setStart(newStart);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndChange = (newEnd: string) => {
|
const handleEndChange = (newEnd: string) => {
|
||||||
setEnd(newEnd);
|
if (onEndChange) {
|
||||||
onEndChange?.(newEnd);
|
onEndChange(newEnd);
|
||||||
|
} else {
|
||||||
|
setEnd(newEnd);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAllDayChange = (newAllDay: boolean) => {
|
const handleAllDayChange = (
|
||||||
|
newAllDay: boolean,
|
||||||
|
newStart: string,
|
||||||
|
newEnd: string
|
||||||
|
) => {
|
||||||
setAllDay(newAllDay);
|
setAllDay(newAllDay);
|
||||||
onAllDayChange?.(newAllDay);
|
onAllDayChange?.(newAllDay, newStart, newEnd);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCalendarChange = (newCalendarId: number) => {
|
const handleCalendarChange = (newCalendarId: number) => {
|
||||||
@@ -250,15 +387,27 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FieldWithLabel label="Title" isExpanded={showMore}>
|
<FieldWithLabel
|
||||||
|
label={
|
||||||
|
<>
|
||||||
|
Title <span style={{ color: "red" }}>*</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
isExpanded={showMore}
|
||||||
|
>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
label={!showMore ? "Title" : ""}
|
label={!showMore ? "Title" : ""}
|
||||||
placeholder="Add title"
|
placeholder="Add title"
|
||||||
value={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"
|
size="small"
|
||||||
margin="dense"
|
margin="dense"
|
||||||
|
inputRef={titleInputRef}
|
||||||
/>
|
/>
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
|
|
||||||
@@ -307,40 +456,46 @@ export default function EventFormFields({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<FieldWithLabel label="Date & Time" isExpanded={showMore}>
|
<FieldWithLabel label="Date & Time" isExpanded={showMore}>
|
||||||
<Box display="flex" gap={2}>
|
<Box display="flex" gap={2} flexDirection="column">
|
||||||
<Box flexGrow={1}>
|
<Box display="flex" gap={2}>
|
||||||
{showMore && (
|
<Box flexGrow={1}>
|
||||||
<Typography variant="caption" display="block" mb={0.5}>
|
{showMore && (
|
||||||
Start
|
<Typography variant="caption" display="block" mb={0.5}>
|
||||||
</Typography>
|
Start
|
||||||
)}
|
</Typography>
|
||||||
<TextField
|
)}
|
||||||
fullWidth
|
<TextField
|
||||||
label={!showMore ? "Start" : ""}
|
fullWidth
|
||||||
type={allday ? "date" : "datetime-local"}
|
label={!showMore ? "Start" : ""}
|
||||||
value={allday ? start.split("T")[0] : start}
|
type={allday ? "date" : "datetime-local"}
|
||||||
onChange={(e) => handleStartChange(e.target.value)}
|
value={allday ? start.split("T")[0] : start}
|
||||||
size="small"
|
onChange={(e) => handleStartChange(e.target.value)}
|
||||||
margin="dense"
|
error={!!validation.errors.start}
|
||||||
InputLabelProps={{ shrink: true }}
|
helperText={validation.errors.start}
|
||||||
/>
|
size="small"
|
||||||
</Box>
|
margin="dense"
|
||||||
<Box flexGrow={1}>
|
InputLabelProps={{ shrink: true }}
|
||||||
{showMore && (
|
/>
|
||||||
<Typography variant="caption" display="block" mb={0.5}>
|
</Box>
|
||||||
End
|
<Box flexGrow={1}>
|
||||||
</Typography>
|
{showMore && (
|
||||||
)}
|
<Typography variant="caption" display="block" mb={0.5}>
|
||||||
<TextField
|
End
|
||||||
fullWidth
|
</Typography>
|
||||||
label={!showMore ? "End" : ""}
|
)}
|
||||||
type={allday ? "date" : "datetime-local"}
|
<TextField
|
||||||
value={allday ? end.split("T")[0] : end}
|
fullWidth
|
||||||
onChange={(e) => handleEndChange(e.target.value)}
|
label={!showMore ? "End" : ""}
|
||||||
size="small"
|
type={allday ? "date" : "datetime-local"}
|
||||||
margin="dense"
|
value={allday ? end.split("T")[0] : end}
|
||||||
InputLabelProps={{ shrink: true }}
|
onChange={(e) => handleEndChange(e.target.value)}
|
||||||
/>
|
error={!!validation.errors.dateTime}
|
||||||
|
helperText={validation.errors.dateTime}
|
||||||
|
size="small"
|
||||||
|
margin="dense"
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
@@ -353,32 +508,96 @@ export default function EventFormFields({
|
|||||||
checked={allday}
|
checked={allday}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
const newAllDay = !allday;
|
const newAllDay = !allday;
|
||||||
|
let newStart = start;
|
||||||
|
let newEnd = end;
|
||||||
|
|
||||||
if (newAllDay) {
|
if (newAllDay) {
|
||||||
// No allday => allday: existing logic
|
// OFF => ON: Save original time before converting to all-day
|
||||||
const endDate = new Date(end);
|
if (start.includes("T")) {
|
||||||
const startDate = new Date(start);
|
originalTimeRef.current = { start, end };
|
||||||
if (endDate.getDate() === startDate.getDate()) {
|
}
|
||||||
endDate.setDate(startDate.getDate() + 1);
|
|
||||||
setEnd(formatLocalDateTime(endDate));
|
// 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 {
|
} else {
|
||||||
// Allday => no allday: set end date = start date with rounded time
|
// ON => OFF: Restore original time if available
|
||||||
const startDate = new Date(start);
|
if (originalTimeRef.current) {
|
||||||
const currentTime = getRoundedCurrentTime();
|
// 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
|
if (originalStartMatch && originalEndMatch) {
|
||||||
startDate.setHours(currentTime.getHours());
|
// Parse current date (YYYY-MM-DD)
|
||||||
startDate.setMinutes(currentTime.getMinutes());
|
const currentDate = start.split("T")[0];
|
||||||
setStart(formatLocalDateTime(startDate));
|
|
||||||
|
|
||||||
// Set end date = start date, with time 30 minutes after start
|
// Reconstruct datetime with original time
|
||||||
const endDate = new Date(startDate);
|
newStart = `${currentDate}T${originalStartMatch[1]}:${originalStartMatch[2]}`;
|
||||||
endDate.setMinutes(endDate.getMinutes() + 30);
|
newEnd = `${currentDate}T${originalEndMatch[1]}:${originalEndMatch[2]}`;
|
||||||
setEnd(formatLocalDateTime(endDate));
|
}
|
||||||
|
|
||||||
|
// 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");
|
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
|
||||||
date.getDate()
|
date.getDate()
|
||||||
|
|||||||
+302
-531
@@ -1,26 +1,4 @@
|
|||||||
import { CalendarApi, DateSelectArg } from "@fullcalendar/core";
|
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 { Box, Button } from "@mui/material";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import React, {
|
import React, {
|
||||||
@@ -29,80 +7,27 @@ import React, {
|
|||||||
useMemo,
|
useMemo,
|
||||||
useCallback,
|
useCallback,
|
||||||
useRef,
|
useRef,
|
||||||
|
startTransition,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||||
import AttendeeSelector from "../../components/Attendees/AttendeeSearch";
|
|
||||||
import { ResponsiveDialog } from "../../components/Dialog";
|
import { ResponsiveDialog } from "../../components/Dialog";
|
||||||
import { putEventAsync } from "../Calendars/CalendarSlice";
|
import { putEventAsync } from "../Calendars/CalendarSlice";
|
||||||
import { Calendars } from "../Calendars/CalendarTypes";
|
import { Calendars } from "../Calendars/CalendarTypes";
|
||||||
import { userAttendee } from "../User/userDataTypes";
|
import { userAttendee } from "../User/userDataTypes";
|
||||||
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
|
||||||
import { createSelector } from "@reduxjs/toolkit";
|
import { createSelector } from "@reduxjs/toolkit";
|
||||||
import RepeatEvent from "../../components/Event/EventRepeat";
|
|
||||||
import { TIMEZONES } from "../../utils/timezone-data";
|
import { TIMEZONES } from "../../utils/timezone-data";
|
||||||
import {
|
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
|
||||||
generateMeetingLink,
|
|
||||||
addVideoConferenceToDescription,
|
|
||||||
} from "../../utils/videoConferenceUtils";
|
|
||||||
import {
|
import {
|
||||||
getTimezoneOffset,
|
getTimezoneOffset,
|
||||||
resolveTimezone,
|
resolveTimezone,
|
||||||
} from "../../components/Calendar/TimezoneSelector";
|
} from "../../components/Calendar/TimezoneSelector";
|
||||||
import { getCalendarRange } from "../../utils/dateUtils";
|
import { getCalendarRange } from "../../utils/dateUtils";
|
||||||
import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils";
|
import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils";
|
||||||
import { TimezoneAutocomplete } from "../../components/Timezone/TimezoneAutocomplete";
|
import EventFormFields, {
|
||||||
|
formatLocalDateTime,
|
||||||
// Helper component for field with label
|
formatDateTimeInTimezone,
|
||||||
const FieldWithLabel = React.memo(
|
} from "../../components/Event/EventFormFields";
|
||||||
({
|
|
||||||
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";
|
|
||||||
|
|
||||||
function EventPopover({
|
function EventPopover({
|
||||||
anchorEl,
|
anchorEl,
|
||||||
@@ -128,8 +53,8 @@ function EventPopover({
|
|||||||
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
|
useAppSelector((state) => state.user.userData?.openpaasId) ?? "";
|
||||||
const tempList = useAppSelector((state) => state.calendars.templist);
|
const tempList = useAppSelector((state) => state.calendars.templist);
|
||||||
const selectPersonnalCalendars = createSelector(
|
const selectPersonnalCalendars = createSelector(
|
||||||
(state) => state.calendars,
|
(state: any) => state.calendars,
|
||||||
(calendars) =>
|
(calendars: any) =>
|
||||||
Object.keys(calendars.list)
|
Object.keys(calendars.list)
|
||||||
.map((id) => {
|
.map((id) => {
|
||||||
if (id.split("/")[0] === userId) {
|
if (id.split("/")[0] === userId) {
|
||||||
@@ -149,7 +74,7 @@ function EventPopover({
|
|||||||
Intl.DateTimeFormat().resolvedOptions().timeZone
|
Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||||
);
|
);
|
||||||
|
|
||||||
return { zones, browserTz };
|
return { zones, browserTz, getTimezoneOffset };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const calendarTimezone = useAppSelector((state) => state.calendars.timeZone);
|
const calendarTimezone = useAppSelector((state) => state.calendars.timeZone);
|
||||||
@@ -186,9 +111,7 @@ function EventPopover({
|
|||||||
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
|
const [eventClass, setEventClass] = useState(event?.class ?? "PUBLIC");
|
||||||
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
|
const [busy, setBusy] = useState(event?.transp ?? "OPAQUE");
|
||||||
const [timezone, setTimezone] = useState(
|
const [timezone, setTimezone] = useState(
|
||||||
event?.timezone
|
event?.timezone ? resolveTimezone(event.timezone) : calendarTimezone
|
||||||
? resolveTimezone(event.timezone)
|
|
||||||
: resolveTimezone(calendarTimezone)
|
|
||||||
);
|
);
|
||||||
const [hasVideoConference, setHasVideoConference] = useState(
|
const [hasVideoConference, setHasVideoConference] = useState(
|
||||||
event?.x_openpass_videoconference ? true : false
|
event?.x_openpass_videoconference ? true : false
|
||||||
@@ -196,6 +119,8 @@ function EventPopover({
|
|||||||
const [meetingLink, setMeetingLink] = useState<string | null>(
|
const [meetingLink, setMeetingLink] = useState<string | null>(
|
||||||
event?.x_openpass_videoconference || 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
|
// Use ref to track if we've already initialized to avoid infinite loop
|
||||||
const isInitializedRef = useRef(false);
|
const isInitializedRef = useRef(false);
|
||||||
@@ -206,13 +131,6 @@ function EventPopover({
|
|||||||
userPersonnalCalendarsRef.current = userPersonnalCalendars;
|
userPersonnalCalendarsRef.current = userPersonnalCalendars;
|
||||||
}, [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(() => {
|
const resetAllStateToDefault = useCallback(() => {
|
||||||
setShowMore(false);
|
setShowMore(false);
|
||||||
setShowDescription(false);
|
setShowDescription(false);
|
||||||
@@ -229,31 +147,152 @@ function EventPopover({
|
|||||||
setAlarm("");
|
setAlarm("");
|
||||||
setEventClass("PUBLIC");
|
setEventClass("PUBLIC");
|
||||||
setBusy("OPAQUE");
|
setBusy("OPAQUE");
|
||||||
setTimezone(resolveTimezone(calendarTimezone));
|
setTimezone(calendarTimezone);
|
||||||
setHasVideoConference(false);
|
setHasVideoConference(false);
|
||||||
setMeetingLink(null);
|
setMeetingLink(null);
|
||||||
}, [calendarTimezone]);
|
}, [calendarTimezone]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Track if we should sync from selectedRange (only on initial selection, not on toggle)
|
||||||
if (selectedRange) {
|
const shouldSyncFromRangeRef = useRef(true);
|
||||||
setStart(
|
const prevOpenRef = useRef(false);
|
||||||
selectedRange ? formatLocalDateTime(selectedRange.start, timezone) : ""
|
|
||||||
);
|
|
||||||
setEnd(
|
|
||||||
selectedRange ? formatLocalDateTime(selectedRange.end, timezone) : ""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [selectedRange]);
|
|
||||||
|
|
||||||
// Initialize state when event prop changes
|
// Sync timezone when modal opens or calendarTimezone changes
|
||||||
useEffect(() => {
|
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
|
// Editing existing event - populate fields with event data
|
||||||
setTitle(event.title ?? "");
|
setTitle(event.title ?? "");
|
||||||
setDescription(event.description ?? "");
|
setDescription(event.description ?? "");
|
||||||
setLocation(event.location ?? "");
|
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(
|
setCalendarid(
|
||||||
event.calId
|
event.calId
|
||||||
? userPersonnalCalendarsRef.current.findIndex(
|
? userPersonnalCalendarsRef.current.findIndex(
|
||||||
@@ -261,7 +300,6 @@ function EventPopover({
|
|||||||
)
|
)
|
||||||
: 0
|
: 0
|
||||||
);
|
);
|
||||||
setAllDay(event.allday ?? false);
|
|
||||||
setRepetition(event.repetition ?? ({} as RepetitionObject));
|
setRepetition(event.repetition ?? ({} as RepetitionObject));
|
||||||
setShowRepeat(event.repetition?.freq ? true : false);
|
setShowRepeat(event.repetition?.freq ? true : false);
|
||||||
setAttendees(
|
setAttendees(
|
||||||
@@ -274,9 +312,7 @@ function EventPopover({
|
|||||||
setAlarm(event.alarm?.trigger ?? "");
|
setAlarm(event.alarm?.trigger ?? "");
|
||||||
setEventClass(event.class ?? "PUBLIC");
|
setEventClass(event.class ?? "PUBLIC");
|
||||||
setBusy(event.transp ?? "OPAQUE");
|
setBusy(event.transp ?? "OPAQUE");
|
||||||
setTimezone(
|
setTimezone(eventTimezone);
|
||||||
event.timezone ? resolveTimezone(event.timezone) : calendarTimezone
|
|
||||||
);
|
|
||||||
setHasVideoConference(event.x_openpass_videoconference ? true : false);
|
setHasVideoConference(event.x_openpass_videoconference ? true : false);
|
||||||
setMeetingLink(event.x_openpass_videoconference || null);
|
setMeetingLink(event.x_openpass_videoconference || null);
|
||||||
|
|
||||||
@@ -294,13 +330,23 @@ function EventPopover({
|
|||||||
setDescription(event.description);
|
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]);
|
}, [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(() => {
|
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
|
// 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);
|
setShowMore(false);
|
||||||
setShowDescription(false);
|
setShowDescription(false);
|
||||||
setShowRepeat(false);
|
setShowRepeat(false);
|
||||||
@@ -308,61 +354,117 @@ function EventPopover({
|
|||||||
setDescription("");
|
setDescription("");
|
||||||
setAttendees([]);
|
setAttendees([]);
|
||||||
setLocation("");
|
setLocation("");
|
||||||
setStart("");
|
|
||||||
setEnd("");
|
|
||||||
setCalendarid(0);
|
setCalendarid(0);
|
||||||
setAllDay(false);
|
setAllDay(false);
|
||||||
setRepetition({} as RepetitionObject);
|
setRepetition({} as RepetitionObject);
|
||||||
setAlarm("");
|
setAlarm("");
|
||||||
setEventClass("PUBLIC");
|
setEventClass("PUBLIC");
|
||||||
setBusy("OPAQUE");
|
setBusy("OPAQUE");
|
||||||
setTimezone(timezoneList.browserTz);
|
|
||||||
setHasVideoConference(false);
|
setHasVideoConference(false);
|
||||||
setMeetingLink(null);
|
setMeetingLink(null);
|
||||||
}
|
}
|
||||||
isInitializedRef.current = true;
|
|
||||||
}, [event, calendarTimezone]);
|
|
||||||
|
|
||||||
const handleAddVideoConference = () => {
|
if (!isCreatingNew) {
|
||||||
const newMeetingLink = generateMeetingLink();
|
isInitializedRef.current = true;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [event?.uid]);
|
||||||
|
|
||||||
const handleDeleteVideoConference = () => {
|
const handleStartChange = useCallback(
|
||||||
// Remove video conference footer from description
|
(newStart: string) => {
|
||||||
const updatedDescription = description.replace(
|
setStart(newStart);
|
||||||
/\nVisio: https?:\/\/[^\s]+/,
|
|
||||||
""
|
// Defer visual feedback (non-urgent)
|
||||||
);
|
startTransition(() => {
|
||||||
setDescription(updatedDescription);
|
setSelectedRange((prev: DateSelectArg | null) => {
|
||||||
setHasVideoConference(false);
|
const newRange = {
|
||||||
setMeetingLink(null);
|
...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 = () => {
|
const handleClose = () => {
|
||||||
onClose({}, "backdropClick");
|
onClose({}, "backdropClick");
|
||||||
|
setShowValidationErrors(false);
|
||||||
resetAllStateToDefault();
|
resetAllStateToDefault();
|
||||||
|
setStart("");
|
||||||
|
setEnd("");
|
||||||
|
shouldSyncFromRangeRef.current = true; // Reset for next time
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
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 newEventUID = crypto.randomUUID();
|
||||||
|
|
||||||
const newEvent: CalendarEvent = {
|
const newEvent: CalendarEvent = {
|
||||||
@@ -401,6 +503,9 @@ function EventPopover({
|
|||||||
newEvent.attendee = newEvent.attendee.concat(attendees);
|
newEvent.attendee = newEvent.attendee.concat(attendees);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset validation state when validation passes
|
||||||
|
setShowValidationErrors(false);
|
||||||
|
|
||||||
// Close popup immediately
|
// Close popup immediately
|
||||||
onClose({}, "backdropClick");
|
onClose({}, "backdropClick");
|
||||||
|
|
||||||
@@ -433,7 +538,7 @@ function EventPopover({
|
|||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="contained" onClick={handleSave} disabled={!title}>
|
<Button variant="contained" onClick={handleSave}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -449,387 +554,53 @@ function EventPopover({
|
|||||||
onExpandToggle={() => setShowMore(!showMore)}
|
onExpandToggle={() => setShowMore(!showMore)}
|
||||||
actions={dialogActions}
|
actions={dialogActions}
|
||||||
>
|
>
|
||||||
<FieldWithLabel label="Title" isExpanded={showMore}>
|
<EventFormFields
|
||||||
<TextField
|
title={title}
|
||||||
fullWidth
|
setTitle={setTitle}
|
||||||
label={!showMore ? "Title" : ""}
|
description={description}
|
||||||
placeholder="Add title"
|
setDescription={setDescription}
|
||||||
value={title}
|
location={location}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
setLocation={setLocation}
|
||||||
size="small"
|
start={start}
|
||||||
margin="dense"
|
setStart={setStart}
|
||||||
/>
|
end={end}
|
||||||
</FieldWithLabel>
|
setEnd={setEnd}
|
||||||
|
allday={allday}
|
||||||
{!showDescription && (
|
setAllDay={setAllDay}
|
||||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
repetition={repetition}
|
||||||
<Box display="flex" gap={1} mb={1}>
|
setRepetition={setRepetition}
|
||||||
<Button
|
attendees={attendees}
|
||||||
startIcon={<DescriptionIcon />}
|
setAttendees={setAttendees}
|
||||||
onClick={() => setShowDescription(true)}
|
alarm={alarm}
|
||||||
size="small"
|
setAlarm={setAlarm}
|
||||||
sx={{
|
busy={busy}
|
||||||
textTransform: "none",
|
setBusy={setBusy}
|
||||||
color: "text.secondary",
|
eventClass={eventClass}
|
||||||
}}
|
setEventClass={setEventClass}
|
||||||
>
|
timezone={timezone}
|
||||||
Add description
|
setTimezone={setTimezone}
|
||||||
</Button>
|
calendarid={calendarid}
|
||||||
</Box>
|
setCalendarid={setCalendarid}
|
||||||
</FieldWithLabel>
|
hasVideoConference={hasVideoConference}
|
||||||
)}
|
setHasVideoConference={setHasVideoConference}
|
||||||
|
meetingLink={meetingLink}
|
||||||
{showDescription && (
|
setMeetingLink={setMeetingLink}
|
||||||
<FieldWithLabel label="Description" isExpanded={showMore}>
|
showMore={showMore}
|
||||||
<TextField
|
showDescription={showDescription}
|
||||||
fullWidth
|
setShowDescription={setShowDescription}
|
||||||
label={!showMore ? "Description" : ""}
|
showRepeat={showRepeat}
|
||||||
placeholder="Add description"
|
setShowRepeat={setShowRepeat}
|
||||||
value={description}
|
isOpen={open}
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
userPersonnalCalendars={userPersonnalCalendars}
|
||||||
size="small"
|
timezoneList={timezoneList}
|
||||||
margin="dense"
|
onStartChange={handleStartChange}
|
||||||
multiline
|
onEndChange={handleEndChange}
|
||||||
minRows={2}
|
onAllDayChange={handleAllDayChange}
|
||||||
maxRows={10}
|
onValidationChange={setIsFormValid}
|
||||||
sx={{
|
showValidationErrors={showValidationErrors}
|
||||||
"& .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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</ResponsiveDialog>
|
</ResponsiveDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EventPopover;
|
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");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -84,9 +84,9 @@ function EventUpdateModal({
|
|||||||
const calendarsList = useAppSelector((state) => state.calendars.list);
|
const calendarsList = useAppSelector((state) => state.calendars.list);
|
||||||
|
|
||||||
const userPersonnalCalendars: Calendars[] = useMemo(() => {
|
const userPersonnalCalendars: Calendars[] = useMemo(() => {
|
||||||
const allCalendars = Object.values(calendarsList);
|
const allCalendars = Object.values(calendarsList) as Calendars[];
|
||||||
return allCalendars.filter(
|
return allCalendars.filter(
|
||||||
(c) => c.id?.split("/")[0] === user.userData?.openpaasId
|
(c: Calendars) => c.id?.split("/")[0] === user.userData?.openpaasId
|
||||||
);
|
);
|
||||||
}, [calendarsList, user.userData?.openpaasId]);
|
}, [calendarsList, user.userData?.openpaasId]);
|
||||||
|
|
||||||
@@ -158,6 +158,8 @@ function EventUpdateModal({
|
|||||||
const [attendees, setAttendees] = useState<userAttendee[]>([]);
|
const [attendees, setAttendees] = useState<userAttendee[]>([]);
|
||||||
const [hasVideoConference, setHasVideoConference] = useState(false);
|
const [hasVideoConference, setHasVideoConference] = useState(false);
|
||||||
const [meetingLink, setMeetingLink] = useState<string | null>(null);
|
const [meetingLink, setMeetingLink] = useState<string | null>(null);
|
||||||
|
const [isFormValid, setIsFormValid] = useState(false);
|
||||||
|
const [showValidationErrors, setShowValidationErrors] = useState(false);
|
||||||
|
|
||||||
const resetAllStateToDefault = useCallback(() => {
|
const resetAllStateToDefault = useCallback(() => {
|
||||||
setShowMore(false);
|
setShowMore(false);
|
||||||
@@ -188,6 +190,9 @@ function EventUpdateModal({
|
|||||||
// Initialize form state when event data is available
|
// Initialize form state when event data is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (event && open) {
|
if (event && open) {
|
||||||
|
// Reset validation errors when modal opens
|
||||||
|
setShowValidationErrors(false);
|
||||||
|
|
||||||
// Editing existing event - populate fields with event data
|
// Editing existing event - populate fields with event data
|
||||||
setTitle(event.title ?? "");
|
setTitle(event.title ?? "");
|
||||||
setDescription(event.description ?? "");
|
setDescription(event.description ?? "");
|
||||||
@@ -258,7 +263,8 @@ function EventUpdateModal({
|
|||||||
setAttendees(
|
setAttendees(
|
||||||
event.attendee
|
event.attendee
|
||||||
? event.attendee.filter(
|
? 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 = () => {
|
const handleClose = () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
|
setShowValidationErrors(false);
|
||||||
resetAllStateToDefault();
|
resetAllStateToDefault();
|
||||||
|
setFreshEvent(null);
|
||||||
initializedKeyRef.current = null;
|
initializedKeyRef.current = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
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;
|
if (!event) return;
|
||||||
|
|
||||||
const organizer = event.organizer;
|
const organizer = event.organizer;
|
||||||
@@ -317,6 +333,9 @@ function EventUpdateModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reset validation state when validation passes
|
||||||
|
setShowValidationErrors(false);
|
||||||
|
|
||||||
// Handle recurrence instances
|
// Handle recurrence instances
|
||||||
const [baseUID, recurrenceId] = event.uid.split("/");
|
const [baseUID, recurrenceId] = event.uid.split("/");
|
||||||
|
|
||||||
@@ -507,7 +526,7 @@ function EventUpdateModal({
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.catch((error) => {
|
.catch((error: any) => {
|
||||||
dispatch(updateEventLocal({ calId, event: oldEvent }));
|
dispatch(updateEventLocal({ calId, event: oldEvent }));
|
||||||
showErrorNotification("Failed to update event. Changes reverted.");
|
showErrorNotification("Failed to update event. Changes reverted.");
|
||||||
});
|
});
|
||||||
@@ -666,12 +685,10 @@ function EventUpdateModal({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<Box display="flex" gap={1} ml={showMore ? "auto" : 0}>
|
<Box display="flex" gap={1} ml={showMore ? "auto" : 0}>
|
||||||
{showMore && (
|
<Button variant="outlined" onClick={handleClose}>
|
||||||
<Button variant="outlined" onClick={handleClose}>
|
Cancel
|
||||||
Cancel
|
</Button>
|
||||||
</Button>
|
<Button variant="contained" onClick={handleSave}>
|
||||||
)}
|
|
||||||
<Button variant="contained" onClick={handleSave} disabled={!title}>
|
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -726,6 +743,7 @@ function EventUpdateModal({
|
|||||||
setShowDescription={setShowDescription}
|
setShowDescription={setShowDescription}
|
||||||
showRepeat={typeOfAction !== "solo" && showRepeat}
|
showRepeat={typeOfAction !== "solo" && showRepeat}
|
||||||
setShowRepeat={setShowRepeat}
|
setShowRepeat={setShowRepeat}
|
||||||
|
isOpen={open}
|
||||||
userPersonnalCalendars={userPersonnalCalendars}
|
userPersonnalCalendars={userPersonnalCalendars}
|
||||||
timezoneList={timezoneList}
|
timezoneList={timezoneList}
|
||||||
onCalendarChange={(newCalendarId) => {
|
onCalendarChange={(newCalendarId) => {
|
||||||
@@ -734,6 +752,8 @@ function EventUpdateModal({
|
|||||||
setNewCalId(selectedCalendar.id);
|
setNewCalId(selectedCalendar.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onValidationChange={setIsFormValid}
|
||||||
|
showValidationErrors={showValidationErrors}
|
||||||
/>
|
/>
|
||||||
</ResponsiveDialog>
|
</ResponsiveDialog>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user