Event form UX improvements:
- Add long date format display (Monday, April 23, 2024) - Use 24h time format - Show/hide time fields based on all-day state - Remove auto-update of end date when start date changes - Fix validation for all-day events - Auto-expand for multi-day events - Set default time when toggling off all-day from all-day slot
This commit is contained in:
committed by
Benoit TELLIER
parent
61358c081a
commit
41b4c41215
@@ -263,7 +263,7 @@ describe("EventPopover", () => {
|
|||||||
const newEvent = {
|
const newEvent = {
|
||||||
title: "Meeting",
|
title: "Meeting",
|
||||||
start: "2025-07-18T00:00:00.000Z",
|
start: "2025-07-18T00:00:00.000Z",
|
||||||
end: "2025-07-19T00:00:00.000Z",
|
end: "2025-07-18T00:00:00.000Z",
|
||||||
allday: false,
|
allday: false,
|
||||||
uid: "6045c603-11ab-43c5-bc30-0641420bb3a8",
|
uid: "6045c603-11ab-43c5-bc30-0641420bb3a8",
|
||||||
description: "Discuss project",
|
description: "Discuss project",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
SelectChangeEvent,
|
SelectChangeEvent,
|
||||||
TextField,
|
TextField,
|
||||||
Typography,
|
|
||||||
ToggleButtonGroup,
|
ToggleButtonGroup,
|
||||||
ToggleButton,
|
ToggleButton,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
@@ -38,12 +37,8 @@ import { FieldWithLabel } from "./components/FieldWithLabel";
|
|||||||
import { DateTimeFields } from "./components/DateTimeFields";
|
import { DateTimeFields } from "./components/DateTimeFields";
|
||||||
import { useAllDayToggle } from "./hooks/useAllDayToggle";
|
import { useAllDayToggle } from "./hooks/useAllDayToggle";
|
||||||
import { splitDateTime, combineDateTime } from "./utils/dateTimeHelpers";
|
import { splitDateTime, combineDateTime } from "./utils/dateTimeHelpers";
|
||||||
import { getEndDateForStartChange } from "./utils/dateRules";
|
import {} from "./utils/dateRules";
|
||||||
import {
|
import {} from "./utils/dateTimeFormatters";
|
||||||
formatLocalDateTime,
|
|
||||||
formatDateTimeInTimezone,
|
|
||||||
getRoundedCurrentTime,
|
|
||||||
} from "./utils/dateTimeFormatters";
|
|
||||||
import { validateEventForm } from "./utils/formValidation";
|
import { validateEventForm } from "./utils/formValidation";
|
||||||
|
|
||||||
interface EventFormFieldsProps {
|
interface EventFormFieldsProps {
|
||||||
@@ -165,10 +160,10 @@ export default function EventFormFields({
|
|||||||
const [endTime, setEndTime] = React.useState("");
|
const [endTime, setEndTime] = React.useState("");
|
||||||
|
|
||||||
// UI state for showing/hiding end date field
|
// UI state for showing/hiding end date field
|
||||||
const [showEndDate, setShowEndDate] = React.useState(false);
|
// keep local flag if needed for future UI toggles (not used now)
|
||||||
|
|
||||||
// Use all-day toggle hook
|
// Use all-day toggle hook
|
||||||
const { originalTimeRef, handleAllDayToggle } = useAllDayToggle({
|
const { handleAllDayToggle } = useAllDayToggle({
|
||||||
allday,
|
allday,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
@@ -178,7 +173,6 @@ export default function EventFormFields({
|
|||||||
endTime,
|
endTime,
|
||||||
setStartTime,
|
setStartTime,
|
||||||
setEndTime,
|
setEndTime,
|
||||||
setEndDate,
|
|
||||||
setStart,
|
setStart,
|
||||||
setEnd,
|
setEnd,
|
||||||
setAllDay,
|
setAllDay,
|
||||||
@@ -250,47 +244,19 @@ export default function EventFormFields({
|
|||||||
}
|
}
|
||||||
}, [end]);
|
}, [end]);
|
||||||
|
|
||||||
// Sync allday prop changes to detect all-day slot clicks
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (allday && (!startTime || !endTime)) {
|
|
||||||
// This is likely from all-day slot click - set time fields empty
|
|
||||||
setStartTime("");
|
|
||||||
setEndTime("");
|
|
||||||
|
|
||||||
// Mark this as coming from all-day slot for later uncheck logic
|
|
||||||
originalTimeRef.current = {
|
|
||||||
start: "",
|
|
||||||
end: "",
|
|
||||||
endDate: endDate,
|
|
||||||
fromAllDaySlot: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [allday, startTime, endTime, endDate]);
|
|
||||||
|
|
||||||
// Change handlers for 4 separate fields
|
// Change handlers for 4 separate fields
|
||||||
const handleStartDateChange = React.useCallback(
|
const handleStartDateChange = React.useCallback(
|
||||||
(newDate: string) => {
|
(newDate: string) => {
|
||||||
setStartDate(newDate);
|
setStartDate(newDate);
|
||||||
const newStart = combineDateTime(newDate, startTime);
|
const newStart = combineDateTime(newDate, startTime);
|
||||||
|
|
||||||
// Update start
|
|
||||||
if (onStartChange) {
|
if (onStartChange) {
|
||||||
onStartChange(newStart);
|
onStartChange(newStart);
|
||||||
} else {
|
} else {
|
||||||
setStart(newStart);
|
setStart(newStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rule: update end date when start date changes
|
|
||||||
const nextEndDate = getEndDateForStartChange(newDate, allday);
|
|
||||||
setEndDate(nextEndDate);
|
|
||||||
const newEnd = combineDateTime(nextEndDate, endTime || startTime);
|
|
||||||
if (onEndChange) {
|
|
||||||
onEndChange(newEnd);
|
|
||||||
} else {
|
|
||||||
setEnd(newEnd);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[startTime, endTime, onStartChange, onEndChange, setStart, setEnd, allday]
|
[startTime, onStartChange, setStart]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStartTimeChange = React.useCallback(
|
const handleStartTimeChange = React.useCallback(
|
||||||
@@ -359,19 +325,6 @@ export default function EventFormFields({
|
|||||||
onValidationChange?.(validation.isValid);
|
onValidationChange?.(validation.isValid);
|
||||||
}, [validateForm, onValidationChange]);
|
}, [validateForm, onValidationChange]);
|
||||||
|
|
||||||
// Auto-calculate end date from start date if not already set
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (startDate && !endDate) {
|
|
||||||
setEndDate(startDate);
|
|
||||||
const newEnd = combineDateTime(startDate, endTime || startTime);
|
|
||||||
if (onEndChange) {
|
|
||||||
onEndChange(newEnd);
|
|
||||||
} else {
|
|
||||||
setEnd(newEnd);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [startDate, endDate, startTime, endTime, onEndChange, setEnd]);
|
|
||||||
|
|
||||||
// Auto-calculate end time from start time (+ 1 hour) if not already set
|
// Auto-calculate end time from start time (+ 1 hour) if not already set
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (startTime && !endTime && !allday) {
|
if (startTime && !endTime && !allday) {
|
||||||
@@ -412,7 +365,6 @@ export default function EventFormFields({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteVideoConference = () => {
|
const handleDeleteVideoConference = () => {
|
||||||
// Remove video conference footer from description
|
|
||||||
const updatedDescription = description.replace(
|
const updatedDescription = description.replace(
|
||||||
/\nVisio: https?:\/\/[^\s]+/,
|
/\nVisio: https?:\/\/[^\s]+/,
|
||||||
""
|
""
|
||||||
@@ -510,8 +462,8 @@ export default function EventFormFields({
|
|||||||
onStartTimeChange={handleStartTimeChange}
|
onStartTimeChange={handleStartTimeChange}
|
||||||
onEndDateChange={handleEndDateChange}
|
onEndDateChange={handleEndDateChange}
|
||||||
onEndTimeChange={handleEndTimeChange}
|
onEndTimeChange={handleEndTimeChange}
|
||||||
showEndDate={showEndDate}
|
showEndDate={showMore || allday}
|
||||||
onToggleEndDate={() => setShowEndDate(!showEndDate)}
|
onToggleEndDate={() => {}}
|
||||||
/>
|
/>
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
|
|
||||||
@@ -666,7 +618,6 @@ export default function EventFormFields({
|
|||||||
</FormControl>
|
</FormControl>
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
|
|
||||||
{/* Extended options */}
|
|
||||||
{showMore && (
|
{showMore && (
|
||||||
<>
|
<>
|
||||||
<FieldWithLabel label="Notification" isExpanded={showMore}>
|
<FieldWithLabel label="Notification" isExpanded={showMore}>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { Box, IconButton, Tooltip, Typography } from "@mui/material";
|
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 { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment";
|
||||||
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
|
||||||
import moment, { Moment } from "moment";
|
import moment, { Moment } from "moment";
|
||||||
|
import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Props for DateTimeFields component
|
* Props for DateTimeFields component
|
||||||
@@ -49,105 +49,134 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
onEndDateChange,
|
onEndDateChange,
|
||||||
onEndTimeChange,
|
onEndTimeChange,
|
||||||
}) => {
|
}) => {
|
||||||
|
const isExpanded = showMore;
|
||||||
|
const shouldShowEndDateNormal = allday || !!showEndDate;
|
||||||
return (
|
return (
|
||||||
<LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="en">
|
<LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="en">
|
||||||
<Box
|
<Box
|
||||||
display="flex"
|
display="flex"
|
||||||
gap={1}
|
|
||||||
flexDirection="column"
|
flexDirection="column"
|
||||||
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
|
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
|
||||||
>
|
>
|
||||||
{/* First row: 4 fields */}
|
{isExpanded ? (
|
||||||
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
|
<>
|
||||||
{/* Start Date - 30% */}
|
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
|
||||||
<Box sx={{ flexGrow: 0.3, flexBasis: "25%", maxWidth: "150px" }}>
|
<Box sx={{ maxWidth: "280px", width: "45%" }}>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
label="Start Date"
|
label="Start Date"
|
||||||
value={startDate ? moment(startDate) : null}
|
format={LONG_DATE_FORMAT}
|
||||||
onChange={(newValue: Moment | null) => {
|
value={startDate ? moment(startDate) : null}
|
||||||
onStartDateChange(newValue?.format("YYYY-MM-DD") || "");
|
onChange={(newValue: Moment | null) => {
|
||||||
}}
|
onStartDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||||
slotProps={{
|
}}
|
||||||
textField: {
|
slotProps={{
|
||||||
size: "small",
|
textField: {
|
||||||
margin: "dense" as const,
|
size: "small",
|
||||||
fullWidth: true,
|
margin: "dense" as const,
|
||||||
InputLabelProps: { shrink: true },
|
fullWidth: true,
|
||||||
sx: { width: "100%" },
|
InputLabelProps: { shrink: true },
|
||||||
inputProps: { "data-testid": "start-date-input" },
|
sx: { width: "100%" },
|
||||||
},
|
inputProps: { "data-testid": "start-date-input" },
|
||||||
}}
|
},
|
||||||
/>
|
}}
|
||||||
</Box>
|
/>
|
||||||
|
|
||||||
{/* Start Time - 20% */}
|
|
||||||
<Box sx={{ flexGrow: 0.2, flexBasis: "25%", maxWidth: "150px" }}>
|
|
||||||
<TimePicker
|
|
||||||
label="Start Time"
|
|
||||||
value={startTime ? moment(startTime, "HH:mm") : null}
|
|
||||||
onChange={(newValue: Moment | null) => {
|
|
||||||
onStartTimeChange(newValue?.format("HH:mm") || "");
|
|
||||||
}}
|
|
||||||
disabled={allday}
|
|
||||||
slotProps={{
|
|
||||||
textField: {
|
|
||||||
size: "small",
|
|
||||||
margin: "dense" as const,
|
|
||||||
fullWidth: true,
|
|
||||||
InputLabelProps: { shrink: true },
|
|
||||||
sx: { width: "100%" },
|
|
||||||
inputProps: { "data-testid": "start-time-input" },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* End Time - 20% */}
|
|
||||||
<Box sx={{ flexGrow: 0.2, flexBasis: "25%", maxWidth: "150px" }}>
|
|
||||||
<TimePicker
|
|
||||||
label="End Time"
|
|
||||||
value={endTime ? moment(endTime, "HH:mm") : null}
|
|
||||||
onChange={(newValue: Moment | null) => {
|
|
||||||
onEndTimeChange(newValue?.format("HH:mm") || "");
|
|
||||||
}}
|
|
||||||
disabled={allday}
|
|
||||||
slotProps={{
|
|
||||||
textField: {
|
|
||||||
size: "small",
|
|
||||||
margin: "dense" as const,
|
|
||||||
fullWidth: true,
|
|
||||||
InputLabelProps: { shrink: true },
|
|
||||||
error: !!validation.errors.dateTime,
|
|
||||||
sx: { width: "100%" },
|
|
||||||
inputProps: { "data-testid": "end-time-input" },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* End Date - Conditional rendering */}
|
|
||||||
<Box sx={{ flexGrow: 0.3, flexBasis: "25%", maxWidth: "150px" }}>
|
|
||||||
{!showEndDate ? (
|
|
||||||
// Show "..." button to reveal end date
|
|
||||||
<Box
|
|
||||||
display="flex"
|
|
||||||
justifyContent="flex-start"
|
|
||||||
sx={{ mt: showMore ? 0 : 0 }}
|
|
||||||
>
|
|
||||||
<Tooltip title="Show end date">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={onToggleEndDate}
|
|
||||||
aria-label="Show end date"
|
|
||||||
>
|
|
||||||
<MoreHorizIcon />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
{!allday && (
|
||||||
// Show End Date picker (no hide button)
|
<Box sx={{ width: "110px" }}>
|
||||||
|
<TimePicker
|
||||||
|
label="Start Time"
|
||||||
|
ampm={false}
|
||||||
|
value={startTime ? moment(startTime, "HH:mm") : null}
|
||||||
|
onChange={(newValue: Moment | null) => {
|
||||||
|
onStartTimeChange(newValue?.format("HH:mm") || "");
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
size: "small",
|
||||||
|
margin: "dense" as const,
|
||||||
|
fullWidth: true,
|
||||||
|
InputLabelProps: { shrink: true },
|
||||||
|
sx: { width: "100%" },
|
||||||
|
inputProps: { "data-testid": "start-time-input" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
|
||||||
|
<Box sx={{ maxWidth: "280px", width: "45%" }}>
|
||||||
|
<DatePicker
|
||||||
|
label="End Date"
|
||||||
|
format={LONG_DATE_FORMAT}
|
||||||
|
value={endDate ? moment(endDate) : null}
|
||||||
|
onChange={(newValue: Moment | null) => {
|
||||||
|
onEndDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
size: "small",
|
||||||
|
margin: "dense" as const,
|
||||||
|
fullWidth: true,
|
||||||
|
InputLabelProps: { shrink: true },
|
||||||
|
error: !!validation.errors.dateTime,
|
||||||
|
sx: { width: "100%" },
|
||||||
|
inputProps: { "data-testid": "end-date-input" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{!allday && (
|
||||||
|
<Box sx={{ width: "110px" }}>
|
||||||
|
<TimePicker
|
||||||
|
label="End Time"
|
||||||
|
ampm={false}
|
||||||
|
value={endTime ? moment(endTime, "HH:mm") : null}
|
||||||
|
onChange={(newValue: Moment | null) => {
|
||||||
|
onEndTimeChange(newValue?.format("HH:mm") || "");
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
size: "small",
|
||||||
|
margin: "dense" as const,
|
||||||
|
fullWidth: true,
|
||||||
|
InputLabelProps: { shrink: true },
|
||||||
|
error: !!validation.errors.dateTime,
|
||||||
|
sx: { width: "100%" },
|
||||||
|
inputProps: { "data-testid": "end-time-input" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
) : shouldShowEndDateNormal ? (
|
||||||
|
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
|
||||||
|
<Box sx={{ maxWidth: "280px", width: "45%" }}>
|
||||||
|
<DatePicker
|
||||||
|
label="Start Date"
|
||||||
|
format={LONG_DATE_FORMAT}
|
||||||
|
value={startDate ? moment(startDate) : null}
|
||||||
|
onChange={(newValue: Moment | null) => {
|
||||||
|
onStartDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
size: "small",
|
||||||
|
margin: "dense" as const,
|
||||||
|
fullWidth: true,
|
||||||
|
InputLabelProps: { shrink: true },
|
||||||
|
sx: { width: "100%" },
|
||||||
|
inputProps: { "data-testid": "start-date-input" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ maxWidth: "280px", width: "45%" }}>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
label="End Date"
|
label="End Date"
|
||||||
|
format={LONG_DATE_FORMAT}
|
||||||
value={endDate ? moment(endDate) : null}
|
value={endDate ? moment(endDate) : null}
|
||||||
onChange={(newValue: Moment | null) => {
|
onChange={(newValue: Moment | null) => {
|
||||||
onEndDateChange(newValue?.format("YYYY-MM-DD") || "");
|
onEndDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||||
@@ -164,18 +193,81 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
) : (
|
||||||
|
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
|
||||||
|
<Box sx={{ maxWidth: "280px", width: "45%" }}>
|
||||||
|
<DatePicker
|
||||||
|
label="Start Date"
|
||||||
|
format={LONG_DATE_FORMAT}
|
||||||
|
value={startDate ? moment(startDate) : null}
|
||||||
|
onChange={(newValue: Moment | null) => {
|
||||||
|
onStartDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||||
|
}}
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
size: "small",
|
||||||
|
margin: "dense" as const,
|
||||||
|
fullWidth: true,
|
||||||
|
InputLabelProps: { shrink: true },
|
||||||
|
sx: { width: "100%" },
|
||||||
|
inputProps: { "data-testid": "start-date-input" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ maxWidth: "110px" }}>
|
||||||
|
<TimePicker
|
||||||
|
label="Start Time"
|
||||||
|
ampm={false}
|
||||||
|
value={startTime ? moment(startTime, "HH:mm") : null}
|
||||||
|
onChange={(newValue: Moment | null) => {
|
||||||
|
onStartTimeChange(newValue?.format("HH:mm") || "");
|
||||||
|
}}
|
||||||
|
disabled={allday}
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
size: "small",
|
||||||
|
margin: "dense" as const,
|
||||||
|
fullWidth: true,
|
||||||
|
InputLabelProps: { shrink: true },
|
||||||
|
sx: { width: "100%" },
|
||||||
|
inputProps: { "data-testid": "start-time-input" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ maxWidth: "110px" }}>
|
||||||
|
<TimePicker
|
||||||
|
label="End Time"
|
||||||
|
ampm={false}
|
||||||
|
value={endTime ? moment(endTime, "HH:mm") : null}
|
||||||
|
onChange={(newValue: Moment | null) => {
|
||||||
|
onEndTimeChange(newValue?.format("HH:mm") || "");
|
||||||
|
}}
|
||||||
|
disabled={allday}
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
size: "small",
|
||||||
|
margin: "dense" as const,
|
||||||
|
fullWidth: true,
|
||||||
|
InputLabelProps: { shrink: true },
|
||||||
|
error: !!validation.errors.dateTime,
|
||||||
|
sx: { width: "100%" },
|
||||||
|
inputProps: { "data-testid": "end-time-input" },
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Second row: Error message - 2 columns 50% each */}
|
{/* Second row: Error message */}
|
||||||
{validation.errors.dateTime && (
|
{validation.errors.dateTime && (
|
||||||
<Box display="flex" gap={1} flexDirection="row">
|
<Box display="flex" gap={1} flexDirection="row">
|
||||||
{/* Empty left column - 50% */}
|
<Box sx={{ width: isExpanded ? "0" : allday ? "45%" : "64%" }} />
|
||||||
<Box sx={{ flexGrow: 0.5, flexBasis: "50%" }} />
|
<Box>
|
||||||
|
|
||||||
{/* Error message right column - 50% */}
|
|
||||||
<Box sx={{ flexGrow: 0.5, flexBasis: "50%" }}>
|
|
||||||
<Typography variant="caption" sx={{ color: "error.main" }}>
|
<Typography variant="caption" sx={{ color: "error.main" }}>
|
||||||
{validation.errors.dateTime}
|
{validation.errors.dateTime}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { combineDateTime } from "../utils/dateTimeHelpers";
|
import { combineDateTime } from "../utils/dateTimeHelpers";
|
||||||
import { getEndDateForToggle } from "../utils/dateRules";
|
|
||||||
import { getRoundedCurrentTime } from "../utils/dateTimeFormatters";
|
import { getRoundedCurrentTime } from "../utils/dateTimeFormatters";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -16,7 +15,6 @@ export interface AllDayToggleParams {
|
|||||||
endTime: string;
|
endTime: string;
|
||||||
setStartTime: (time: string) => void;
|
setStartTime: (time: string) => void;
|
||||||
setEndTime: (time: string) => void;
|
setEndTime: (time: string) => void;
|
||||||
setEndDate: (date: string) => void;
|
|
||||||
setStart: (start: string) => void;
|
setStart: (start: string) => void;
|
||||||
setEnd: (end: string) => void;
|
setEnd: (end: string) => void;
|
||||||
setAllDay: (allday: boolean) => void;
|
setAllDay: (allday: boolean) => void;
|
||||||
@@ -53,7 +51,6 @@ export function useAllDayToggle(
|
|||||||
endTime,
|
endTime,
|
||||||
setStartTime,
|
setStartTime,
|
||||||
setEndTime,
|
setEndTime,
|
||||||
setEndDate,
|
|
||||||
setStart,
|
setStart,
|
||||||
setEnd,
|
setEnd,
|
||||||
setAllDay,
|
setAllDay,
|
||||||
@@ -73,97 +70,32 @@ export function useAllDayToggle(
|
|||||||
let newStart = start;
|
let newStart = start;
|
||||||
let newEnd = end;
|
let newEnd = end;
|
||||||
|
|
||||||
if (newAllDay) {
|
if (!newAllDay) {
|
||||||
// OFF => ON: Save original time AND original end date
|
const hasTimeParts = start.includes("T") && end.includes("T");
|
||||||
if (start.includes("T")) {
|
if (!hasTimeParts && !startTime && !endTime) {
|
||||||
originalTimeRef.current = {
|
const now = new Date();
|
||||||
start: startTime,
|
now.setSeconds(0);
|
||||||
end: endTime,
|
now.setMilliseconds(0);
|
||||||
endDate: endDate, // Save original end date
|
const nextHour = new Date(now);
|
||||||
};
|
nextHour.setMinutes(0);
|
||||||
}
|
nextHour.setHours(now.getHours() + 1);
|
||||||
|
|
||||||
// Reset time to empty, keep only date part
|
const startHours = String(nextHour.getHours()).padStart(2, "0");
|
||||||
setStartTime("");
|
const startMinutes = String(nextHour.getMinutes()).padStart(2, "0");
|
||||||
setEndTime("");
|
const startTimeStr = `${startHours}:${startMinutes}`;
|
||||||
newStart = startDate;
|
|
||||||
const nextEndDate = getEndDateForToggle({
|
|
||||||
nextAllDay: true,
|
|
||||||
fromAllDaySlot: !!originalTimeRef.current?.fromAllDaySlot,
|
|
||||||
startDate,
|
|
||||||
previousEndDate: endDate,
|
|
||||||
originalEndDate: originalTimeRef.current?.endDate,
|
|
||||||
});
|
|
||||||
newEnd = nextEndDate;
|
|
||||||
setEndDate(nextEndDate);
|
|
||||||
} else {
|
|
||||||
// ON => OFF: Restore original time AND original end date
|
|
||||||
if (originalTimeRef.current) {
|
|
||||||
// Check if this came from all-day slot click
|
|
||||||
if (originalTimeRef.current.fromAllDaySlot) {
|
|
||||||
// From all-day slot: set endDate = startDate, use default time
|
|
||||||
const currentTime = getRoundedCurrentTime();
|
|
||||||
const hours = String(currentTime.getHours()).padStart(2, "0");
|
|
||||||
const minutes = String(currentTime.getMinutes()).padStart(2, "0");
|
|
||||||
const timeStr = `${hours}:${minutes}`;
|
|
||||||
|
|
||||||
newStart = combineDateTime(startDate, timeStr);
|
const endHourDate = new Date(nextHour);
|
||||||
|
endHourDate.setHours(endHourDate.getHours() + 1);
|
||||||
// End time = start time + 1 hour
|
const endHours = String(endHourDate.getHours()).padStart(2, "0");
|
||||||
const endTimeDate = new Date(currentTime);
|
const endMinutes = String(endHourDate.getMinutes()).padStart(2, "0");
|
||||||
endTimeDate.setHours(endTimeDate.getHours() + 1);
|
|
||||||
const endHours = String(endTimeDate.getHours()).padStart(2, "0");
|
|
||||||
const endMinutes = String(endTimeDate.getMinutes()).padStart(2, "0");
|
|
||||||
const endTimeStr = `${endHours}:${endMinutes}`;
|
|
||||||
|
|
||||||
newEnd = combineDateTime(startDate, endTimeStr); // Use startDate as endDate
|
|
||||||
|
|
||||||
// Update internal states
|
|
||||||
setStartTime(timeStr);
|
|
||||||
setEndTime(endTimeStr);
|
|
||||||
setEndDate(startDate); // Set endDate = startDate
|
|
||||||
} else {
|
|
||||||
// Normal case: restore original time AND original end date
|
|
||||||
const restoredEndDate = getEndDateForToggle({
|
|
||||||
nextAllDay: false,
|
|
||||||
startDate,
|
|
||||||
previousEndDate: endDate,
|
|
||||||
originalEndDate: originalTimeRef.current.endDate,
|
|
||||||
});
|
|
||||||
|
|
||||||
newStart = combineDateTime(startDate, originalTimeRef.current.start);
|
|
||||||
newEnd = combineDateTime(
|
|
||||||
restoredEndDate,
|
|
||||||
originalTimeRef.current.end
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update internal states
|
|
||||||
setStartTime(originalTimeRef.current.start);
|
|
||||||
setEndTime(originalTimeRef.current.end);
|
|
||||||
setEndDate(restoredEndDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
originalTimeRef.current = null;
|
|
||||||
} else {
|
|
||||||
// No original time: use rounded current time with 1 hour duration
|
|
||||||
const currentTime = getRoundedCurrentTime();
|
|
||||||
const hours = String(currentTime.getHours()).padStart(2, "0");
|
|
||||||
const minutes = String(currentTime.getMinutes()).padStart(2, "0");
|
|
||||||
const timeStr = `${hours}:${minutes}`;
|
|
||||||
|
|
||||||
newStart = combineDateTime(startDate, timeStr);
|
|
||||||
|
|
||||||
// End time = start time + 1 hour (not 30 mins)
|
|
||||||
const endTimeDate = new Date(currentTime);
|
|
||||||
endTimeDate.setHours(endTimeDate.getHours() + 1);
|
|
||||||
const endHours = String(endTimeDate.getHours()).padStart(2, "0");
|
|
||||||
const endMinutes = String(endTimeDate.getMinutes()).padStart(2, "0");
|
|
||||||
const endTimeStr = `${endHours}:${endMinutes}`;
|
const endTimeStr = `${endHours}:${endMinutes}`;
|
||||||
|
|
||||||
newEnd = combineDateTime(endDate, endTimeStr);
|
const startDateOnly = start.split("T")[0] || startDate;
|
||||||
|
const endDateOnly = end.split("T")[0] || endDate || startDateOnly;
|
||||||
|
newStart = combineDateTime(startDateOnly, startTimeStr);
|
||||||
|
newEnd = combineDateTime(endDateOnly, endTimeStr);
|
||||||
|
|
||||||
// Update internal states
|
setStartTime(startTimeStr);
|
||||||
setStartTime(timeStr);
|
|
||||||
setEndTime(endTimeStr);
|
setEndTime(endTimeStr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +117,6 @@ export function useAllDayToggle(
|
|||||||
endTime,
|
endTime,
|
||||||
setStartTime,
|
setStartTime,
|
||||||
setEndTime,
|
setEndTime,
|
||||||
setEndDate,
|
|
||||||
setStart,
|
setStart,
|
||||||
setEnd,
|
setEnd,
|
||||||
setAllDay,
|
setAllDay,
|
||||||
|
|||||||
@@ -78,3 +78,6 @@ export function getRoundedCurrentTime(): Date {
|
|||||||
now.setMilliseconds(0);
|
now.setMilliseconds(0);
|
||||||
return now;
|
return now;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Long date display format for date pickers */
|
||||||
|
export const LONG_DATE_FORMAT = "dddd, MMMM D, YYYY";
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ 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 is required";
|
dateTimeError = "Start time and End time is required";
|
||||||
}
|
}
|
||||||
// Validate end date
|
// Validate end date
|
||||||
else if (!endDate || endDate.trim() === "") {
|
else if (!endDate || endDate.trim() === "") {
|
||||||
@@ -66,21 +66,36 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
|
|||||||
isDateTimeValid = false;
|
isDateTimeValid = false;
|
||||||
dateTimeError = "End time is required";
|
dateTimeError = "End time is required";
|
||||||
}
|
}
|
||||||
// Validate end > start
|
// Validate end vs start
|
||||||
else {
|
else {
|
||||||
const startDateTime = allday
|
if (allday) {
|
||||||
? new Date(startDate + "T00:00:00")
|
const toLocalDate = (ymd: string) => {
|
||||||
: new Date(combineDateTime(startDate, startTime));
|
const [y, m, d] = ymd.split("-").map((v) => parseInt(v, 10));
|
||||||
const endDateTime = allday
|
if (!y || !m || !d) return new Date(NaN);
|
||||||
? new Date(endDate + "T00:00:00")
|
return new Date(y, m - 1, d);
|
||||||
: new Date(combineDateTime(endDate, endTime));
|
};
|
||||||
|
|
||||||
if (isNaN(startDateTime.getTime()) || isNaN(endDateTime.getTime())) {
|
const startOnly = toLocalDate(startDate);
|
||||||
isDateTimeValid = false;
|
const endOnly = toLocalDate(endDate);
|
||||||
dateTimeError = "Invalid date/time";
|
|
||||||
} else if (endDateTime <= startDateTime) {
|
if (isNaN(startOnly.getTime()) || isNaN(endOnly.getTime())) {
|
||||||
isDateTimeValid = false;
|
isDateTimeValid = false;
|
||||||
dateTimeError = "End time must be after start time";
|
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 time must be after start time";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,12 +221,21 @@ function EventPopover({
|
|||||||
: formatLocalDateTime(selectedRange.end);
|
: formatLocalDateTime(selectedRange.end);
|
||||||
|
|
||||||
// Use the string values directly to preserve the displayed time
|
// Use the string values directly to preserve the displayed time
|
||||||
setStart(
|
const startValue = selectedRange.allDay
|
||||||
selectedRange.allDay ? startStr.split("T")[0] : startStr.slice(0, 16) // YYYY-MM-DDTHH:mm
|
? startStr.split("T")[0]
|
||||||
);
|
: startStr.slice(0, 16); // YYYY-MM-DDTHH:mm
|
||||||
setEnd(
|
const endValue = selectedRange.allDay
|
||||||
selectedRange.allDay ? endStr.split("T")[0] : endStr.slice(0, 16)
|
? endStr.split("T")[0]
|
||||||
);
|
: endStr.slice(0, 16);
|
||||||
|
setStart(startValue);
|
||||||
|
setEnd(endValue);
|
||||||
|
|
||||||
|
// If start date != end date, open extended mode
|
||||||
|
const startDateOnly = startValue.slice(0, 10);
|
||||||
|
const endDateOnly = endValue.slice(0, 10);
|
||||||
|
if (startDateOnly !== endDateOnly) {
|
||||||
|
setShowMore(true);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// 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
|
||||||
@@ -234,6 +243,13 @@ function EventPopover({
|
|||||||
const formattedEnd = formatLocalDateTime(selectedRange.end);
|
const formattedEnd = formatLocalDateTime(selectedRange.end);
|
||||||
if (formattedStart) setStart(formattedStart);
|
if (formattedStart) setStart(formattedStart);
|
||||||
if (formattedEnd) setEnd(formattedEnd);
|
if (formattedEnd) setEnd(formattedEnd);
|
||||||
|
if (formattedStart && formattedEnd) {
|
||||||
|
const startDateOnly = formattedStart.slice(0, 10);
|
||||||
|
const endDateOnly = formattedEnd.slice(0, 10);
|
||||||
|
if (startDateOnly !== endDateOnly) {
|
||||||
|
setShowMore(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No valid selectedRange - use default times
|
// No valid selectedRange - use default times
|
||||||
@@ -479,7 +495,7 @@ function EventPopover({
|
|||||||
calId: calList[calendarid].id,
|
calId: calList[calendarid].id,
|
||||||
title,
|
title,
|
||||||
URL: `/calendars/${calList[calendarid].id}/${newEventUID}.ics`,
|
URL: `/calendars/${calList[calendarid].id}/${newEventUID}.ics`,
|
||||||
start: new Date(start).toISOString(),
|
start: "",
|
||||||
allday,
|
allday,
|
||||||
uid: newEventUID,
|
uid: newEventUID,
|
||||||
description,
|
description,
|
||||||
@@ -503,8 +519,19 @@ function EventPopover({
|
|||||||
alarm: { trigger: alarm, action: "EMAIL" },
|
alarm: { trigger: alarm, action: "EMAIL" },
|
||||||
x_openpass_videoconference: meetingLink || undefined,
|
x_openpass_videoconference: meetingLink || undefined,
|
||||||
};
|
};
|
||||||
if (end) {
|
|
||||||
newEvent.end = new Date(end).toISOString();
|
if (allday) {
|
||||||
|
const startDateOnly = (start || "").split("T")[0];
|
||||||
|
const endDateOnlyBase = (end || start || "").split("T")[0];
|
||||||
|
const startDateObj = new Date(`${startDateOnly}T00:00:00`);
|
||||||
|
const endDateObj = new Date(`${endDateOnlyBase}T00:00:00`);
|
||||||
|
newEvent.start = startDateObj.toISOString();
|
||||||
|
newEvent.end = endDateObj.toISOString();
|
||||||
|
} else {
|
||||||
|
newEvent.start = new Date(start).toISOString();
|
||||||
|
if (end) {
|
||||||
|
newEvent.end = new Date(end).toISOString();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attendees.length > 0) {
|
if (attendees.length > 0) {
|
||||||
|
|||||||
Reference in New Issue
Block a user