feat: improve date/time fields UI and logic

- Change 'Start Date' label to 'Date' when only single date field is visible in normal mode
- Fix all-day slot display: show correct start/end dates for single click and multi-day drag
- Update API save logic: add +1 day to end date for all-day events (API requirement)
- Migrate from moment to dayjs for date handling with AdapterDayjs
- Remove manual date clamping logic - MUI/dayjs handles invalid dates automatically
- Add validation guard to prevent invalid dates from clearing field values
- Update test case to match new all-day event API behavior
This commit is contained in:
lenhanphung
2025-11-05 13:34:21 +07:00
committed by Benoit TELLIER
parent 41b4c41215
commit c1f37e3ebd
6 changed files with 363 additions and 90 deletions
+5 -1
View File
@@ -312,11 +312,15 @@ describe("EventPopover", () => {
new Date(receivedPayload.newEvent.start) new Date(receivedPayload.newEvent.start)
).split("T")[0] ).split("T")[0]
).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)).split("T")[0]); ).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.start)).split("T")[0]);
const expectedAllDayEnd = new Date(newEvent.end);
expectedAllDayEnd.setUTCDate(expectedAllDayEnd.getUTCDate() + 1);
expect( expect(
formatDateToYYYYMMDDTHHMMSS( formatDateToYYYYMMDDTHHMMSS(
new Date(receivedPayload.newEvent.end || new Date()) new Date(receivedPayload.newEvent.end || new Date())
).split("T")[0] ).split("T")[0]
).toBe(formatDateToYYYYMMDDTHHMMSS(new Date(newEvent.end)).split("T")[0]); ).toBe(formatDateToYYYYMMDDTHHMMSS(expectedAllDayEnd).split("T")[0]);
expect(receivedPayload.newEvent.location).toBe(newEvent.location); expect(receivedPayload.newEvent.location).toBe(newEvent.location);
expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer); expect(receivedPayload.newEvent.organizer).toEqual(newEvent.organizer);
expect(receivedPayload.newEvent.color).toEqual( expect(receivedPayload.newEvent.color).toEqual(
+27 -4
View File
@@ -104,6 +104,7 @@ interface EventFormFieldsProps {
// Validation // Validation
onValidationChange?: (isValid: boolean) => void; onValidationChange?: (isValid: boolean) => void;
showValidationErrors?: boolean; showValidationErrors?: boolean;
onHasEndDateChangedChange?: (has: boolean) => void;
} }
export default function EventFormFields({ export default function EventFormFields({
@@ -152,6 +153,7 @@ export default function EventFormFields({
onCalendarChange, onCalendarChange,
onValidationChange, onValidationChange,
showValidationErrors = false, showValidationErrors = false,
onHasEndDateChangedChange,
}: EventFormFieldsProps) { }: EventFormFieldsProps) {
// Internal state for 4 separate fields // Internal state for 4 separate fields
const [startDate, setStartDate] = React.useState(""); const [startDate, setStartDate] = React.useState("");
@@ -159,8 +161,20 @@ export default function EventFormFields({
const [endDate, setEndDate] = React.useState(""); const [endDate, setEndDate] = React.useState("");
const [endTime, setEndTime] = React.useState(""); const [endTime, setEndTime] = React.useState("");
// UI state for showing/hiding end date field // Track if user has manually changed end date in extended mode
// keep local flag if needed for future UI toggles (not used now) const [hasEndDateChanged, setHasEndDateChanged] = React.useState(false);
// Reset hasEndDateChanged when modal closes
React.useEffect(() => {
if (!isOpen) {
setHasEndDateChanged(false);
}
}, [isOpen]);
// Notify parent about end date change visibility state
React.useEffect(() => {
onHasEndDateChangedChange?.(hasEndDateChanged);
}, [hasEndDateChanged, onHasEndDateChangedChange]);
// Use all-day toggle hook // Use all-day toggle hook
const { handleAllDayToggle } = useAllDayToggle({ const { handleAllDayToggle } = useAllDayToggle({
@@ -275,6 +289,10 @@ export default function EventFormFields({
const handleEndDateChange = React.useCallback( const handleEndDateChange = React.useCallback(
(newDate: string) => { (newDate: string) => {
setEndDate(newDate); setEndDate(newDate);
// Track if user changed end date in extended mode
if (showMore) {
setHasEndDateChanged(true);
}
const newEnd = combineDateTime(newDate, endTime); const newEnd = combineDateTime(newDate, endTime);
if (onEndChange) { if (onEndChange) {
onEndChange(newEnd); onEndChange(newEnd);
@@ -282,7 +300,7 @@ export default function EventFormFields({
setEnd(newEnd); setEnd(newEnd);
} }
}, },
[endTime, onEndChange, setEnd] [endTime, onEndChange, setEnd, showMore]
); );
const handleEndTimeChange = React.useCallback( const handleEndTimeChange = React.useCallback(
@@ -308,6 +326,8 @@ export default function EventFormFields({
endTime, endTime,
allday, allday,
showValidationErrors, showValidationErrors,
hasEndDateChanged,
showMore,
}); });
}, [ }, [
title, title,
@@ -317,6 +337,8 @@ export default function EventFormFields({
endTime, endTime,
allday, allday,
showValidationErrors, showValidationErrors,
hasEndDateChanged,
showMore,
]); ]);
// Notify parent about validation changes // Notify parent about validation changes
@@ -457,6 +479,7 @@ export default function EventFormFields({
endTime={endTime} endTime={endTime}
allday={allday} allday={allday}
showMore={showMore} showMore={showMore}
hasEndDateChanged={hasEndDateChanged}
validation={validation} validation={validation}
onStartDateChange={handleStartDateChange} onStartDateChange={handleStartDateChange}
onStartTimeChange={handleStartTimeChange} onStartTimeChange={handleStartTimeChange}
@@ -606,7 +629,7 @@ export default function EventFormFields({
)} )}
<Select <Select
labelId="calendar-select-label" labelId="calendar-select-label"
value={calendarid} value={calendarid ?? ""}
label={!showMore ? "Calendar" : ""} label={!showMore ? "Calendar" : ""}
displayEmpty displayEmpty
onChange={(e: SelectChangeEvent) => onChange={(e: SelectChangeEvent) =>
@@ -3,10 +3,13 @@ import { Box, Typography } from "@mui/material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker"; import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { TimePicker } from "@mui/x-date-pickers/TimePicker"; import { TimePicker } from "@mui/x-date-pickers/TimePicker";
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider"; import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment"; import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
import moment, { Moment } from "moment"; import dayjs, { Dayjs } from "dayjs";
import customParseFormat from "dayjs/plugin/customParseFormat";
import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters"; import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters";
dayjs.extend(customParseFormat);
/** /**
* Props for DateTimeFields component * Props for DateTimeFields component
*/ */
@@ -17,6 +20,7 @@ export interface DateTimeFieldsProps {
endTime: string; endTime: string;
allday: boolean; allday: boolean;
showMore: boolean; showMore: boolean;
hasEndDateChanged: boolean;
showEndDate: boolean; showEndDate: boolean;
onToggleEndDate: () => void; onToggleEndDate: () => void;
validation: { validation: {
@@ -41,6 +45,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
endTime, endTime,
allday, allday,
showMore, showMore,
hasEndDateChanged,
showEndDate, showEndDate,
onToggleEndDate, onToggleEndDate,
validation, validation,
@@ -51,23 +56,33 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
}) => { }) => {
const isExpanded = showMore; const isExpanded = showMore;
const shouldShowEndDateNormal = allday || !!showEndDate; const shouldShowEndDateNormal = allday || !!showEndDate;
const shouldShowFullFieldsInNormal = !allday && hasEndDateChanged;
const showSingleDateField =
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal;
const startDateLabel = showSingleDateField ? "Date" : "Start Date";
return ( return (
<LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="en"> <LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="en">
<Box <Box
display="flex" display="flex"
flexDirection="column" flexDirection="column"
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }} sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
> >
{isExpanded ? ( {isExpanded || shouldShowFullFieldsInNormal ? (
<> <>
<Box display="flex" gap={1} flexDirection="row" alignItems="center"> <Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "280px", width: "45%" }}> <Box sx={{ maxWidth: "300px", width: "48%" }}>
<DatePicker <DatePicker
label="Start Date" label="Start Date"
format={LONG_DATE_FORMAT} format={LONG_DATE_FORMAT}
value={startDate ? moment(startDate) : null} value={startDate ? dayjs(startDate) : null}
onChange={(newValue: Moment | null) => { onChange={(newValue) => {
onStartDateChange(newValue?.format("YYYY-MM-DD") || ""); const value = newValue as Dayjs | null;
if (!value || !value.isValid()) {
return;
}
const formatted = value.format("YYYY-MM-DD");
onStartDateChange(formatted);
}} }}
slotProps={{ slotProps={{
textField: { textField: {
@@ -86,9 +101,14 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker <TimePicker
label="Start Time" label="Start Time"
ampm={false} ampm={false}
value={startTime ? moment(startTime, "HH:mm") : null} value={startTime ? dayjs(startTime, "HH:mm") : null}
onChange={(newValue: Moment | null) => { onChange={(newValue) => {
onStartTimeChange(newValue?.format("HH:mm") || ""); const value = newValue as Dayjs | null;
if (!value || !value.isValid()) {
return;
}
const formatted = value?.format("HH:mm") || "";
onStartTimeChange(formatted);
}} }}
slotProps={{ slotProps={{
textField: { textField: {
@@ -105,13 +125,18 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
)} )}
</Box> </Box>
<Box display="flex" gap={1} flexDirection="row" alignItems="center"> <Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "280px", width: "45%" }}> <Box sx={{ maxWidth: "300px", width: "48%" }}>
<DatePicker <DatePicker
label="End Date" label="End Date"
format={LONG_DATE_FORMAT} format={LONG_DATE_FORMAT}
value={endDate ? moment(endDate) : null} value={endDate ? dayjs(endDate) : null}
onChange={(newValue: Moment | null) => { onChange={(newValue) => {
onEndDateChange(newValue?.format("YYYY-MM-DD") || ""); const value = newValue as Dayjs | null;
if (!value || !value.isValid()) {
return;
}
const formatted = value.format("YYYY-MM-DD");
onEndDateChange(formatted);
}} }}
slotProps={{ slotProps={{
textField: { textField: {
@@ -131,9 +156,14 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker <TimePicker
label="End Time" label="End Time"
ampm={false} ampm={false}
value={endTime ? moment(endTime, "HH:mm") : null} value={endTime ? dayjs(endTime, "HH:mm") : null}
onChange={(newValue: Moment | null) => { onChange={(newValue) => {
onEndTimeChange(newValue?.format("HH:mm") || ""); const value = newValue as Dayjs | null;
if (!value || !value.isValid()) {
return;
}
const formatted = value?.format("HH:mm") || "";
onEndTimeChange(formatted);
}} }}
slotProps={{ slotProps={{
textField: { textField: {
@@ -153,13 +183,18 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
</> </>
) : shouldShowEndDateNormal ? ( ) : shouldShowEndDateNormal ? (
<Box display="flex" gap={1} flexDirection="row" alignItems="center"> <Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "280px", width: "45%" }}> <Box sx={{ maxWidth: "300px", width: "48%" }}>
<DatePicker <DatePicker
label="Start Date" label="Start Date"
format={LONG_DATE_FORMAT} format={LONG_DATE_FORMAT}
value={startDate ? moment(startDate) : null} value={startDate ? dayjs(startDate) : null}
onChange={(newValue: Moment | null) => { onChange={(newValue) => {
onStartDateChange(newValue?.format("YYYY-MM-DD") || ""); const value = newValue as Dayjs | null;
if (!value || !value.isValid()) {
return;
}
const formatted = value.format("YYYY-MM-DD");
onStartDateChange(formatted);
}} }}
slotProps={{ slotProps={{
textField: { textField: {
@@ -173,13 +208,18 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
}} }}
/> />
</Box> </Box>
<Box sx={{ maxWidth: "280px", width: "45%" }}> <Box sx={{ maxWidth: "300px", width: "48%" }}>
<DatePicker <DatePicker
label="End Date" label="End Date"
format={LONG_DATE_FORMAT} format={LONG_DATE_FORMAT}
value={endDate ? moment(endDate) : null} value={endDate ? dayjs(endDate) : null}
onChange={(newValue: Moment | null) => { onChange={(newValue) => {
onEndDateChange(newValue?.format("YYYY-MM-DD") || ""); const value = newValue as Dayjs | null;
if (!value || !value.isValid()) {
return;
}
const formatted = value.format("YYYY-MM-DD");
onEndDateChange(formatted);
}} }}
slotProps={{ slotProps={{
textField: { textField: {
@@ -197,13 +237,18 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
</Box> </Box>
) : ( ) : (
<Box display="flex" gap={1} flexDirection="row" alignItems="center"> <Box display="flex" gap={1} flexDirection="row" alignItems="center">
<Box sx={{ maxWidth: "280px", width: "45%" }}> <Box sx={{ maxWidth: "300px", width: "48%" }}>
<DatePicker <DatePicker
label="Start Date" label={startDateLabel}
format={LONG_DATE_FORMAT} format={LONG_DATE_FORMAT}
value={startDate ? moment(startDate) : null} value={startDate ? dayjs(startDate) : null}
onChange={(newValue: Moment | null) => { onChange={(newValue) => {
onStartDateChange(newValue?.format("YYYY-MM-DD") || ""); const value = newValue as Dayjs | null;
if (!value || !value.isValid()) {
return;
}
const formatted = value.format("YYYY-MM-DD");
onStartDateChange(formatted);
}} }}
slotProps={{ slotProps={{
textField: { textField: {
@@ -221,9 +266,14 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker <TimePicker
label="Start Time" label="Start Time"
ampm={false} ampm={false}
value={startTime ? moment(startTime, "HH:mm") : null} value={startTime ? dayjs(startTime, "HH:mm") : null}
onChange={(newValue: Moment | null) => { onChange={(newValue) => {
onStartTimeChange(newValue?.format("HH:mm") || ""); const value = newValue as Dayjs | null;
if (!value || !value.isValid()) {
return;
}
const formatted = value?.format("HH:mm") || "";
onStartTimeChange(formatted);
}} }}
disabled={allday} disabled={allday}
slotProps={{ slotProps={{
@@ -242,9 +292,14 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker <TimePicker
label="End Time" label="End Time"
ampm={false} ampm={false}
value={endTime ? moment(endTime, "HH:mm") : null} value={endTime ? dayjs(endTime, "HH:mm") : null}
onChange={(newValue: Moment | null) => { onChange={(newValue) => {
onEndTimeChange(newValue?.format("HH:mm") || ""); const value = newValue as Dayjs | null;
if (!value || !value.isValid()) {
return;
}
const formatted = value?.format("HH:mm") || "";
onEndTimeChange(formatted);
}} }}
disabled={allday} disabled={allday}
slotProps={{ slotProps={{
@@ -266,7 +321,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
{/* Second row: Error message */} {/* Second row: Error message */}
{validation.errors.dateTime && ( {validation.errors.dateTime && (
<Box display="flex" gap={1} flexDirection="row"> <Box display="flex" gap={1} flexDirection="row">
<Box sx={{ width: isExpanded ? "0" : allday ? "45%" : "64%" }} /> <Box
sx={{
width: "1%",
}}
/>
<Box> <Box>
<Typography variant="caption" sx={{ color: "error.main" }}> <Typography variant="caption" sx={{ color: "error.main" }}>
{validation.errors.dateTime} {validation.errors.dateTime}
+80 -25
View File
@@ -11,6 +11,8 @@ export interface ValidationParams {
endTime: string; endTime: string;
allday: boolean; allday: boolean;
showValidationErrors: boolean; showValidationErrors: boolean;
hasEndDateChanged?: boolean;
showMore?: boolean;
} }
/** /**
@@ -38,6 +40,8 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
endTime, endTime,
allday, allday,
showValidationErrors, showValidationErrors,
hasEndDateChanged = false,
showMore = false,
} = params; } = params;
const isTitleValid = title.trim().length > 0; const isTitleValid = title.trim().length > 0;
@@ -46,6 +50,11 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
let isDateTimeValid = true; let isDateTimeValid = true;
let dateTimeError = ""; let dateTimeError = "";
// Determine which fields are visible based on UI mode
const showFullFields = showMore || hasEndDateChanged;
const showTimeOnly = !allday && !showFullFields;
const showDateOnly = allday;
// Validate start date // Validate start date
if (!startDate || startDate.trim() === "") { if (!startDate || startDate.trim() === "") {
isDateTimeValid = false; isDateTimeValid = false;
@@ -54,21 +63,78 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
// Validate start time (if not all-day) // Validate start time (if not all-day)
else if (!allday && (!startTime || startTime.trim() === "")) { else if (!allday && (!startTime || startTime.trim() === "")) {
isDateTimeValid = false; isDateTimeValid = false;
dateTimeError = "Start time and End time is required"; dateTimeError = "Start time is required";
} }
// Validate end date // Validate end fields based on UI mode
else if (!endDate || endDate.trim() === "") { else if (showFullFields) {
isDateTimeValid = false; // 4 fields mode: validate both end date and end time
dateTimeError = "End date is required"; if (!endDate || endDate.trim() === "") {
} isDateTimeValid = false;
// Validate end time (if not all-day) dateTimeError = "End date is required";
else if (!allday && (!endTime || endTime.trim() === "")) { } else if (!allday && (!endTime || endTime.trim() === "")) {
isDateTimeValid = false; isDateTimeValid = false;
dateTimeError = "End time is required"; dateTimeError = "End time is required";
} } else {
// Validate end vs start // Validate total datetime
else { if (allday) {
if (allday) { const toLocalDate = (ymd: string) => {
const [y, m, d] = ymd.split("-").map((v) => parseInt(v, 10));
if (!y || !m || !d) return new Date(NaN);
return new Date(y, m - 1, d);
};
const startOnly = toLocalDate(startDate);
const endOnly = toLocalDate(endDate);
if (isNaN(startOnly.getTime()) || isNaN(endOnly.getTime())) {
isDateTimeValid = false;
dateTimeError = "Invalid date";
} else if (endOnly < startOnly) {
isDateTimeValid = false;
dateTimeError = "End date must be on or after start date";
}
} else {
const startDateTime = new Date(combineDateTime(startDate, startTime));
const endDateTime = new Date(combineDateTime(endDate, endTime));
if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
isDateTimeValid = false;
dateTimeError = "Invalid date/time";
} else if (endDateTime <= startDateTime) {
isDateTimeValid = false;
dateTimeError = "End date/time must be after start date/time";
}
}
}
} else if (showTimeOnly) {
// 3 fields mode: validate time only (end time > start time, same day)
if (!endTime || endTime.trim() === "") {
isDateTimeValid = false;
dateTimeError = "End time is required";
} else {
// Compare times only (same day is assumed)
const startTimeParts = startTime.split(":");
const endTimeParts = endTime.split(":");
if (startTimeParts.length === 2 && endTimeParts.length === 2) {
const startMinutes =
parseInt(startTimeParts[0]) * 60 + parseInt(startTimeParts[1]);
const endMinutes =
parseInt(endTimeParts[0]) * 60 + parseInt(endTimeParts[1]);
if (endMinutes <= startMinutes) {
isDateTimeValid = false;
dateTimeError = "End time must be after start time";
}
} else {
isDateTimeValid = false;
dateTimeError = "Invalid time format";
}
}
} else if (showDateOnly) {
// 2 fields mode: validate date only
if (!endDate || endDate.trim() === "") {
isDateTimeValid = false;
dateTimeError = "End date is required";
} else {
const toLocalDate = (ymd: string) => { const toLocalDate = (ymd: string) => {
const [y, m, d] = ymd.split("-").map((v) => parseInt(v, 10)); const [y, m, d] = ymd.split("-").map((v) => parseInt(v, 10));
if (!y || !m || !d) return new Date(NaN); if (!y || !m || !d) return new Date(NaN);
@@ -85,17 +151,6 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
isDateTimeValid = false; isDateTimeValid = false;
dateTimeError = "End date must be on or after start date"; dateTimeError = "End date must be on or after start date";
} }
} else {
const startDateTime = new Date(combineDateTime(startDate, startTime));
const endDateTime = new Date(combineDateTime(endDate, endTime));
if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
isDateTimeValid = false;
dateTimeError = "Invalid date/time";
} else if (endDateTime <= startDateTime) {
isDateTimeValid = false;
dateTimeError = "End time must be after start time";
}
} }
} }
+131 -17
View File
@@ -29,6 +29,7 @@ import {
formatLocalDateTime, formatLocalDateTime,
formatDateTimeInTimezone, formatDateTimeInTimezone,
} from "../../components/Event/utils/dateTimeFormatters"; } from "../../components/Event/utils/dateTimeFormatters";
import { addDays } from "../../components/Event/utils/dateRules";
function EventPopover({ function EventPopover({
anchorEl, anchorEl,
@@ -96,7 +97,7 @@ function EventPopover({
const [start, setStart] = useState(event?.start ? event.start : ""); const [start, setStart] = useState(event?.start ? event.start : "");
const [end, setEnd] = useState(event?.end ? event.end : ""); const [end, setEnd] = useState(event?.end ? event.end : "");
const [calendarid, setCalendarid] = useState( const [calendarid, setCalendarid] = useState(
event?.calId ?? userPersonnalCalendars[0]?.id event?.calId ?? userPersonnalCalendars[0]?.id ?? ""
); );
const [allday, setAllDay] = useState(event?.allday ?? false); const [allday, setAllDay] = useState(event?.allday ?? false);
const [repetition, setRepetition] = useState<RepetitionObject>( const [repetition, setRepetition] = useState<RepetitionObject>(
@@ -121,6 +122,7 @@ function EventPopover({
); );
const [isFormValid, setIsFormValid] = useState(false); const [isFormValid, setIsFormValid] = useState(false);
const [showValidationErrors, setShowValidationErrors] = useState(false); const [showValidationErrors, setShowValidationErrors] = useState(false);
const [hasEndDateChanged, setHasEndDateChanged] = 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);
@@ -147,7 +149,13 @@ function EventPopover({
setLocation(""); setLocation("");
setStart(""); setStart("");
setEnd(""); setEnd("");
setCalendarid(userPersonnalCalendars[0]?.id); if (
userPersonnalCalendars &&
userPersonnalCalendars.length > 0 &&
userPersonnalCalendars[0]?.id
) {
setCalendarid(userPersonnalCalendars[0].id);
}
setAllDay(false); setAllDay(false);
setRepetition({} as RepetitionObject); setRepetition({} as RepetitionObject);
setAlarm(""); setAlarm("");
@@ -156,7 +164,7 @@ function EventPopover({
setTimezone(calendarTimezone); setTimezone(calendarTimezone);
setHasVideoConference(false); setHasVideoConference(false);
setMeetingLink(null); setMeetingLink(null);
}, [calendarTimezone]); }, [calendarTimezone, userPersonnalCalendars]);
// Track if we should sync from selectedRange (only on initial selection, not on toggle) // Track if we should sync from selectedRange (only on initial selection, not on toggle)
const shouldSyncFromRangeRef = useRef(true); const shouldSyncFromRangeRef = useRef(true);
@@ -224,9 +232,38 @@ function EventPopover({
const startValue = selectedRange.allDay const startValue = selectedRange.allDay
? startStr.split("T")[0] ? startStr.split("T")[0]
: startStr.slice(0, 16); // YYYY-MM-DDTHH:mm : startStr.slice(0, 16); // YYYY-MM-DDTHH:mm
const endValue = selectedRange.allDay let endValue = selectedRange.allDay
? endStr.split("T")[0] ? endStr.split("T")[0]
: endStr.slice(0, 16); : endStr.slice(0, 16);
// For all-day slots: detect single click vs drag multiple days
// FullCalendar uses exclusive end, so end date = actual end date + 1
// For UI display, we need to adjust:
// - Single click (end = start + 1): set end = start for UI
// - Drag multiple days (end = actual end + 1): set end = end - 1 for UI
if (selectedRange.allDay) {
const startDateOnly = startValue.slice(0, 10);
const endDateOnlyFromRange = endValue.slice(0, 10);
// Calculate days difference
const startDateObj = new Date(startDateOnly);
const endDateObj = new Date(endDateOnlyFromRange);
const daysDiff = Math.floor(
(endDateObj.getTime() - startDateObj.getTime()) /
(1000 * 60 * 60 * 24)
);
if (daysDiff <= 1) {
// Single click: FullCalendar gives end = start + 1, set end = start for UI
endValue = startValue;
} else {
// Drag multiple days: FullCalendar gives end = actual end + 1, subtract 1 for UI
const adjustedEndDate = new Date(endDateObj);
adjustedEndDate.setDate(adjustedEndDate.getDate() - 1);
endValue = adjustedEndDate.toISOString().split("T")[0];
}
}
setStart(startValue); setStart(startValue);
setEnd(endValue); setEnd(endValue);
@@ -240,7 +277,42 @@ function EventPopover({
// Fallback: format Date objects using local time components // Fallback: format Date objects using local time components
// Only set if both start and end are valid // Only set if both start and end are valid
const formattedStart = formatLocalDateTime(selectedRange.start); const formattedStart = formatLocalDateTime(selectedRange.start);
const formattedEnd = formatLocalDateTime(selectedRange.end); let formattedEnd = formatLocalDateTime(selectedRange.end);
// For all-day slots: detect single click vs drag multiple days
// FullCalendar uses exclusive end, so end date = actual end date + 1
// For UI display, we need to adjust:
// - Single click (end = start + 1): set end = start for UI
// - Drag multiple days (end = actual end + 1): set end = end - 1 for UI
if (selectedRange.allDay && formattedStart && formattedEnd) {
const startDateOnly = formattedStart.slice(0, 10);
const endDateOnly = formattedEnd.slice(0, 10);
// Calculate days difference
const startDateObj = new Date(startDateOnly);
const endDateObj = new Date(endDateOnly);
const daysDiff = Math.floor(
(endDateObj.getTime() - startDateObj.getTime()) /
(1000 * 60 * 60 * 24)
);
if (daysDiff <= 1) {
// Single click: FullCalendar gives end = start + 1, set end = start for UI
formattedEnd = formattedStart;
} else {
// Drag multiple days: FullCalendar gives end = actual end + 1, subtract 1 for UI
const adjustedEndDate = new Date(endDateObj);
adjustedEndDate.setDate(adjustedEndDate.getDate() - 1);
const adjustedEndDateStr = adjustedEndDate
.toISOString()
.split("T")[0];
// Preserve time part if exists, otherwise use date only
formattedEnd = formattedEnd.includes("T")
? `${adjustedEndDateStr}T${formattedEnd.split("T")[1]}`
: adjustedEndDateStr;
}
}
if (formattedStart) setStart(formattedStart); if (formattedStart) setStart(formattedStart);
if (formattedEnd) setEnd(formattedEnd); if (formattedEnd) setEnd(formattedEnd);
if (formattedStart && formattedEnd) { if (formattedStart && formattedEnd) {
@@ -323,7 +395,13 @@ function EventPopover({
setEnd(""); setEnd("");
} }
setCalendarid(userPersonnalCalendars[0].id); if (
userPersonnalCalendars &&
userPersonnalCalendars.length > 0 &&
userPersonnalCalendars[0]?.id
) {
setCalendarid(userPersonnalCalendars[0].id);
}
setRepetition(event.repetition ?? ({} as RepetitionObject)); setRepetition(event.repetition ?? ({} as RepetitionObject));
setShowRepeat(event.repetition?.freq ? true : false); setShowRepeat(event.repetition?.freq ? true : false);
setAttendees( setAttendees(
@@ -378,7 +456,13 @@ function EventPopover({
setDescription(""); setDescription("");
setAttendees([]); setAttendees([]);
setLocation(""); setLocation("");
setCalendarid(userPersonnalCalendars[0].id); if (
userPersonnalCalendars &&
userPersonnalCalendars.length > 0 &&
userPersonnalCalendars[0]?.id
) {
setCalendarid(userPersonnalCalendars[0].id);
}
setAllDay(false); setAllDay(false);
setRepetition({} as RepetitionObject); setRepetition({} as RepetitionObject);
setAlarm(""); setAlarm("");
@@ -407,7 +491,9 @@ function EventPopover({
startStr: newStart, startStr: newStart,
allDay: allday, allDay: allday,
} as DateSelectArg; } as DateSelectArg;
calendarRef.current?.select(newRange); setTimeout(() => {
calendarRef.current?.select(newRange);
}, 0);
return newRange; return newRange;
}); });
}); });
@@ -430,7 +516,9 @@ function EventPopover({
endStr: newEnd, endStr: newEnd,
allDay: allday, allDay: allday,
} as DateSelectArg; } as DateSelectArg;
calendarRef.current?.select(newRange); setTimeout(() => {
calendarRef.current?.select(newRange);
}, 0);
return newRange; return newRange;
}); });
}); });
@@ -462,7 +550,9 @@ function EventPopover({
), ),
allDay: newAllDay, allDay: newAllDay,
} as DateSelectArg; } as DateSelectArg;
calendarRef.current?.select(newRange); setTimeout(() => {
calendarRef.current?.select(newRange);
}, 0);
return newRange; return newRange;
}); });
}); });
@@ -491,10 +581,20 @@ function EventPopover({
} }
const newEventUID = crypto.randomUUID(); const newEventUID = crypto.randomUUID();
// Resolve target calendar safely
const targetCalendar: Calendars | undefined =
calList[calendarid] ||
userPersonnalCalendars[0] ||
(Object.values(calList)[0] as Calendars | undefined);
if (!targetCalendar || !targetCalendar.id) {
console.error("No target calendar available to save event");
return;
}
const newEvent: CalendarEvent = { const newEvent: CalendarEvent = {
calId: calList[calendarid].id, calId: targetCalendar.id,
title, title,
URL: `/calendars/${calList[calendarid].id}/${newEventUID}.ics`, URL: `/calendars/${targetCalendar.id}/${newEventUID}.ics`,
start: "", start: "",
allday, allday,
uid: newEventUID, uid: newEventUID,
@@ -515,22 +615,35 @@ function EventPopover({
}, },
], ],
transp: busy, transp: busy,
color: calList[calendarid]?.color, color: targetCalendar?.color,
alarm: { trigger: alarm, action: "EMAIL" }, alarm: { trigger: alarm, action: "EMAIL" },
x_openpass_videoconference: meetingLink || undefined, x_openpass_videoconference: meetingLink || undefined,
}; };
if (allday) { if (allday) {
const startDateOnly = (start || "").split("T")[0]; const startDateOnly = (start || "").split("T")[0];
const endDateOnlyBase = (end || start || "").split("T")[0]; const endDateOnlyUI = (end || start || "").split("T")[0];
// For all-day events, API needs end date = UI end date + 1 day
const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
const startDateObj = new Date(`${startDateOnly}T00:00:00`); const startDateObj = new Date(`${startDateOnly}T00:00:00`);
const endDateObj = new Date(`${endDateOnlyBase}T00:00:00`); const endDateObj = new Date(`${endDateOnlyAPI}T00:00:00`);
newEvent.start = startDateObj.toISOString(); newEvent.start = startDateObj.toISOString();
newEvent.end = endDateObj.toISOString(); newEvent.end = endDateObj.toISOString();
} else { } else {
newEvent.start = new Date(start).toISOString(); newEvent.start = new Date(start).toISOString();
if (end) { if (end) {
newEvent.end = new Date(end).toISOString(); // In normal mode, only override end date when the end date field is not shown
if (!showMore && !hasEndDateChanged) {
const startDateOnly = (start || "").split("T")[0];
const endTimeOnly = end.includes("T")
? end.split("T")[1]?.slice(0, 5) || "00:00"
: "00:00";
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
newEvent.end = new Date(endDateTime).toISOString();
} else {
// Extended mode or end date explicitly shown in normal mode: use actual end datetime
newEvent.end = new Date(end).toISOString();
}
} }
} }
@@ -550,7 +663,7 @@ function EventPopover({
// Save to API in background // Save to API in background
await dispatch( await dispatch(
putEventAsync({ putEventAsync({
cal: calList[calendarid], cal: targetCalendar,
newEvent, newEvent,
}) })
); );
@@ -633,6 +746,7 @@ function EventPopover({
onAllDayChange={handleAllDayChange} onAllDayChange={handleAllDayChange}
onValidationChange={setIsFormValid} onValidationChange={setIsFormValid}
showValidationErrors={showValidationErrors} showValidationErrors={showValidationErrors}
onHasEndDateChangedChange={setHasEndDateChanged}
/> />
</ResponsiveDialog> </ResponsiveDialog>
); );
+23 -5
View File
@@ -19,6 +19,7 @@ import { TIMEZONES } from "../../utils/timezone-data";
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils"; import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
import EventFormFields from "../../components/Event/EventFormFields"; import EventFormFields from "../../components/Event/EventFormFields";
import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters"; import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters";
import { addDays } from "../../components/Event/utils/dateRules";
import { getEvent, deleteEvent, putEvent } from "./EventApi"; import { getEvent, deleteEvent, putEvent } from "./EventApi";
import { refreshCalendars } from "../../components/Event/utils/eventUtils"; import { refreshCalendars } from "../../components/Event/utils/eventUtils";
import { getCalendarRange } from "../../utils/dateUtils"; import { getCalendarRange } from "../../utils/dateUtils";
@@ -154,7 +155,7 @@ function EventUpdateModal({
); );
const [newCalId, setNewCalId] = useState(calId); const [newCalId, setNewCalId] = useState(calId);
const [calendarid, setCalendarid] = useState( const [calendarid, setCalendarid] = useState(
calId ?? userPersonnalCalendars[0]?.id calId ?? userPersonnalCalendars[0]?.id ?? ""
); );
const [attendees, setAttendees] = useState<userAttendee[]>([]); const [attendees, setAttendees] = useState<userAttendee[]>([]);
@@ -162,6 +163,7 @@ function EventUpdateModal({
const [meetingLink, setMeetingLink] = useState<string | null>(null); const [meetingLink, setMeetingLink] = useState<string | null>(null);
const [isFormValid, setIsFormValid] = useState(false); const [isFormValid, setIsFormValid] = useState(false);
const [showValidationErrors, setShowValidationErrors] = useState(false); const [showValidationErrors, setShowValidationErrors] = useState(false);
const [hasEndDateChanged, setHasEndDateChanged] = useState(false);
const resetAllStateToDefault = useCallback(() => { const resetAllStateToDefault = useCallback(() => {
setShowMore(false); setShowMore(false);
@@ -379,12 +381,27 @@ function EventUpdateModal({
// For single events or "solo" edits, use the edited dates from form // For single events or "solo" edits, use the edited dates from form
if (allday) { if (allday) {
// For all-day events, use date format (YYYY-MM-DD) // For all-day events, use date format (YYYY-MM-DD)
startDate = new Date(start).toISOString().split("T")[0]; // API needs end date = UI end date + 1 day
endDate = new Date(end).toISOString().split("T")[0]; const startDateOnly = new Date(start).toISOString().split("T")[0];
const endDateOnlyUI = new Date(end).toISOString().split("T")[0];
const endDateOnlyAPI = addDays(endDateOnlyUI, 1);
startDate = startDateOnly;
endDate = endDateOnlyAPI;
} else { } else {
// For timed events, use full datetime // For timed events
startDate = new Date(start).toISOString(); startDate = new Date(start).toISOString();
endDate = new Date(end).toISOString(); // In normal mode, only override end date when the end date field is not shown
if (!showMore && !hasEndDateChanged) {
const startDateOnly = (start || "").split("T")[0];
const endTimeOnly = end.includes("T")
? end.split("T")[1]?.slice(0, 5) || "00:00"
: "00:00";
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
endDate = new Date(endDateTime).toISOString();
} else {
// Extended mode: use actual end datetime
endDate = new Date(end).toISOString();
}
} }
} }
@@ -753,6 +770,7 @@ function EventUpdateModal({
}} }}
onValidationChange={setIsFormValid} onValidationChange={setIsFormValid}
showValidationErrors={showValidationErrors} showValidationErrors={showValidationErrors}
onHasEndDateChangedChange={setHasEndDateChanged}
/> />
</ResponsiveDialog> </ResponsiveDialog>
); );