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:
committed by
Benoit TELLIER
parent
41b4c41215
commit
c1f37e3ebd
@@ -104,6 +104,7 @@ interface EventFormFieldsProps {
|
||||
// Validation
|
||||
onValidationChange?: (isValid: boolean) => void;
|
||||
showValidationErrors?: boolean;
|
||||
onHasEndDateChangedChange?: (has: boolean) => void;
|
||||
}
|
||||
|
||||
export default function EventFormFields({
|
||||
@@ -152,6 +153,7 @@ export default function EventFormFields({
|
||||
onCalendarChange,
|
||||
onValidationChange,
|
||||
showValidationErrors = false,
|
||||
onHasEndDateChangedChange,
|
||||
}: EventFormFieldsProps) {
|
||||
// Internal state for 4 separate fields
|
||||
const [startDate, setStartDate] = React.useState("");
|
||||
@@ -159,8 +161,20 @@ export default function EventFormFields({
|
||||
const [endDate, setEndDate] = React.useState("");
|
||||
const [endTime, setEndTime] = React.useState("");
|
||||
|
||||
// UI state for showing/hiding end date field
|
||||
// keep local flag if needed for future UI toggles (not used now)
|
||||
// Track if user has manually changed end date in extended mode
|
||||
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
|
||||
const { handleAllDayToggle } = useAllDayToggle({
|
||||
@@ -275,6 +289,10 @@ export default function EventFormFields({
|
||||
const handleEndDateChange = React.useCallback(
|
||||
(newDate: string) => {
|
||||
setEndDate(newDate);
|
||||
// Track if user changed end date in extended mode
|
||||
if (showMore) {
|
||||
setHasEndDateChanged(true);
|
||||
}
|
||||
const newEnd = combineDateTime(newDate, endTime);
|
||||
if (onEndChange) {
|
||||
onEndChange(newEnd);
|
||||
@@ -282,7 +300,7 @@ export default function EventFormFields({
|
||||
setEnd(newEnd);
|
||||
}
|
||||
},
|
||||
[endTime, onEndChange, setEnd]
|
||||
[endTime, onEndChange, setEnd, showMore]
|
||||
);
|
||||
|
||||
const handleEndTimeChange = React.useCallback(
|
||||
@@ -308,6 +326,8 @@ export default function EventFormFields({
|
||||
endTime,
|
||||
allday,
|
||||
showValidationErrors,
|
||||
hasEndDateChanged,
|
||||
showMore,
|
||||
});
|
||||
}, [
|
||||
title,
|
||||
@@ -317,6 +337,8 @@ export default function EventFormFields({
|
||||
endTime,
|
||||
allday,
|
||||
showValidationErrors,
|
||||
hasEndDateChanged,
|
||||
showMore,
|
||||
]);
|
||||
|
||||
// Notify parent about validation changes
|
||||
@@ -457,6 +479,7 @@ export default function EventFormFields({
|
||||
endTime={endTime}
|
||||
allday={allday}
|
||||
showMore={showMore}
|
||||
hasEndDateChanged={hasEndDateChanged}
|
||||
validation={validation}
|
||||
onStartDateChange={handleStartDateChange}
|
||||
onStartTimeChange={handleStartTimeChange}
|
||||
@@ -606,7 +629,7 @@ export default function EventFormFields({
|
||||
)}
|
||||
<Select
|
||||
labelId="calendar-select-label"
|
||||
value={calendarid}
|
||||
value={calendarid ?? ""}
|
||||
label={!showMore ? "Calendar" : ""}
|
||||
displayEmpty
|
||||
onChange={(e: SelectChangeEvent) =>
|
||||
|
||||
@@ -3,10 +3,13 @@ import { Box, Typography } from "@mui/material";
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import { TimePicker } from "@mui/x-date-pickers/TimePicker";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment";
|
||||
import moment, { Moment } from "moment";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import { LONG_DATE_FORMAT } from "../utils/dateTimeFormatters";
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
/**
|
||||
* Props for DateTimeFields component
|
||||
*/
|
||||
@@ -17,6 +20,7 @@ export interface DateTimeFieldsProps {
|
||||
endTime: string;
|
||||
allday: boolean;
|
||||
showMore: boolean;
|
||||
hasEndDateChanged: boolean;
|
||||
showEndDate: boolean;
|
||||
onToggleEndDate: () => void;
|
||||
validation: {
|
||||
@@ -41,6 +45,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
endTime,
|
||||
allday,
|
||||
showMore,
|
||||
hasEndDateChanged,
|
||||
showEndDate,
|
||||
onToggleEndDate,
|
||||
validation,
|
||||
@@ -51,23 +56,33 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
}) => {
|
||||
const isExpanded = showMore;
|
||||
const shouldShowEndDateNormal = allday || !!showEndDate;
|
||||
const shouldShowFullFieldsInNormal = !allday && hasEndDateChanged;
|
||||
const showSingleDateField =
|
||||
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal;
|
||||
const startDateLabel = showSingleDateField ? "Date" : "Start Date";
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="en">
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs} adapterLocale="en">
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
|
||||
>
|
||||
{isExpanded ? (
|
||||
{isExpanded || shouldShowFullFieldsInNormal ? (
|
||||
<>
|
||||
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
|
||||
<Box sx={{ maxWidth: "280px", width: "45%" }}>
|
||||
<Box sx={{ maxWidth: "300px", width: "48%" }}>
|
||||
<DatePicker
|
||||
label="Start Date"
|
||||
format={LONG_DATE_FORMAT}
|
||||
value={startDate ? moment(startDate) : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onStartDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||
value={startDate ? dayjs(startDate) : null}
|
||||
onChange={(newValue) => {
|
||||
const value = newValue as Dayjs | null;
|
||||
if (!value || !value.isValid()) {
|
||||
return;
|
||||
}
|
||||
const formatted = value.format("YYYY-MM-DD");
|
||||
onStartDateChange(formatted);
|
||||
}}
|
||||
slotProps={{
|
||||
textField: {
|
||||
@@ -86,9 +101,14 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
<TimePicker
|
||||
label="Start Time"
|
||||
ampm={false}
|
||||
value={startTime ? moment(startTime, "HH:mm") : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onStartTimeChange(newValue?.format("HH:mm") || "");
|
||||
value={startTime ? dayjs(startTime, "HH:mm") : null}
|
||||
onChange={(newValue) => {
|
||||
const value = newValue as Dayjs | null;
|
||||
if (!value || !value.isValid()) {
|
||||
return;
|
||||
}
|
||||
const formatted = value?.format("HH:mm") || "";
|
||||
onStartTimeChange(formatted);
|
||||
}}
|
||||
slotProps={{
|
||||
textField: {
|
||||
@@ -105,13 +125,18 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
|
||||
<Box sx={{ maxWidth: "280px", width: "45%" }}>
|
||||
<Box sx={{ maxWidth: "300px", width: "48%" }}>
|
||||
<DatePicker
|
||||
label="End Date"
|
||||
format={LONG_DATE_FORMAT}
|
||||
value={endDate ? moment(endDate) : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onEndDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||
value={endDate ? dayjs(endDate) : null}
|
||||
onChange={(newValue) => {
|
||||
const value = newValue as Dayjs | null;
|
||||
if (!value || !value.isValid()) {
|
||||
return;
|
||||
}
|
||||
const formatted = value.format("YYYY-MM-DD");
|
||||
onEndDateChange(formatted);
|
||||
}}
|
||||
slotProps={{
|
||||
textField: {
|
||||
@@ -131,9 +156,14 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
<TimePicker
|
||||
label="End Time"
|
||||
ampm={false}
|
||||
value={endTime ? moment(endTime, "HH:mm") : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onEndTimeChange(newValue?.format("HH:mm") || "");
|
||||
value={endTime ? dayjs(endTime, "HH:mm") : null}
|
||||
onChange={(newValue) => {
|
||||
const value = newValue as Dayjs | null;
|
||||
if (!value || !value.isValid()) {
|
||||
return;
|
||||
}
|
||||
const formatted = value?.format("HH:mm") || "";
|
||||
onEndTimeChange(formatted);
|
||||
}}
|
||||
slotProps={{
|
||||
textField: {
|
||||
@@ -153,13 +183,18 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
</>
|
||||
) : shouldShowEndDateNormal ? (
|
||||
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
|
||||
<Box sx={{ maxWidth: "280px", width: "45%" }}>
|
||||
<Box sx={{ maxWidth: "300px", width: "48%" }}>
|
||||
<DatePicker
|
||||
label="Start Date"
|
||||
format={LONG_DATE_FORMAT}
|
||||
value={startDate ? moment(startDate) : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onStartDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||
value={startDate ? dayjs(startDate) : null}
|
||||
onChange={(newValue) => {
|
||||
const value = newValue as Dayjs | null;
|
||||
if (!value || !value.isValid()) {
|
||||
return;
|
||||
}
|
||||
const formatted = value.format("YYYY-MM-DD");
|
||||
onStartDateChange(formatted);
|
||||
}}
|
||||
slotProps={{
|
||||
textField: {
|
||||
@@ -173,13 +208,18 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ maxWidth: "280px", width: "45%" }}>
|
||||
<Box sx={{ maxWidth: "300px", width: "48%" }}>
|
||||
<DatePicker
|
||||
label="End Date"
|
||||
format={LONG_DATE_FORMAT}
|
||||
value={endDate ? moment(endDate) : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onEndDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||
value={endDate ? dayjs(endDate) : null}
|
||||
onChange={(newValue) => {
|
||||
const value = newValue as Dayjs | null;
|
||||
if (!value || !value.isValid()) {
|
||||
return;
|
||||
}
|
||||
const formatted = value.format("YYYY-MM-DD");
|
||||
onEndDateChange(formatted);
|
||||
}}
|
||||
slotProps={{
|
||||
textField: {
|
||||
@@ -197,13 +237,18 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
</Box>
|
||||
) : (
|
||||
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
|
||||
<Box sx={{ maxWidth: "280px", width: "45%" }}>
|
||||
<Box sx={{ maxWidth: "300px", width: "48%" }}>
|
||||
<DatePicker
|
||||
label="Start Date"
|
||||
label={startDateLabel}
|
||||
format={LONG_DATE_FORMAT}
|
||||
value={startDate ? moment(startDate) : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onStartDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||
value={startDate ? dayjs(startDate) : null}
|
||||
onChange={(newValue) => {
|
||||
const value = newValue as Dayjs | null;
|
||||
if (!value || !value.isValid()) {
|
||||
return;
|
||||
}
|
||||
const formatted = value.format("YYYY-MM-DD");
|
||||
onStartDateChange(formatted);
|
||||
}}
|
||||
slotProps={{
|
||||
textField: {
|
||||
@@ -221,9 +266,14 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
<TimePicker
|
||||
label="Start Time"
|
||||
ampm={false}
|
||||
value={startTime ? moment(startTime, "HH:mm") : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onStartTimeChange(newValue?.format("HH:mm") || "");
|
||||
value={startTime ? dayjs(startTime, "HH:mm") : null}
|
||||
onChange={(newValue) => {
|
||||
const value = newValue as Dayjs | null;
|
||||
if (!value || !value.isValid()) {
|
||||
return;
|
||||
}
|
||||
const formatted = value?.format("HH:mm") || "";
|
||||
onStartTimeChange(formatted);
|
||||
}}
|
||||
disabled={allday}
|
||||
slotProps={{
|
||||
@@ -242,9 +292,14 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
<TimePicker
|
||||
label="End Time"
|
||||
ampm={false}
|
||||
value={endTime ? moment(endTime, "HH:mm") : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onEndTimeChange(newValue?.format("HH:mm") || "");
|
||||
value={endTime ? dayjs(endTime, "HH:mm") : null}
|
||||
onChange={(newValue) => {
|
||||
const value = newValue as Dayjs | null;
|
||||
if (!value || !value.isValid()) {
|
||||
return;
|
||||
}
|
||||
const formatted = value?.format("HH:mm") || "";
|
||||
onEndTimeChange(formatted);
|
||||
}}
|
||||
disabled={allday}
|
||||
slotProps={{
|
||||
@@ -266,7 +321,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
{/* Second row: Error message */}
|
||||
{validation.errors.dateTime && (
|
||||
<Box display="flex" gap={1} flexDirection="row">
|
||||
<Box sx={{ width: isExpanded ? "0" : allday ? "45%" : "64%" }} />
|
||||
<Box
|
||||
sx={{
|
||||
width: "1%",
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: "error.main" }}>
|
||||
{validation.errors.dateTime}
|
||||
|
||||
@@ -11,6 +11,8 @@ export interface ValidationParams {
|
||||
endTime: string;
|
||||
allday: boolean;
|
||||
showValidationErrors: boolean;
|
||||
hasEndDateChanged?: boolean;
|
||||
showMore?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +40,8 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
|
||||
endTime,
|
||||
allday,
|
||||
showValidationErrors,
|
||||
hasEndDateChanged = false,
|
||||
showMore = false,
|
||||
} = params;
|
||||
|
||||
const isTitleValid = title.trim().length > 0;
|
||||
@@ -46,6 +50,11 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
|
||||
let isDateTimeValid = true;
|
||||
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
|
||||
if (!startDate || startDate.trim() === "") {
|
||||
isDateTimeValid = false;
|
||||
@@ -54,21 +63,78 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
|
||||
// Validate start time (if not all-day)
|
||||
else if (!allday && (!startTime || startTime.trim() === "")) {
|
||||
isDateTimeValid = false;
|
||||
dateTimeError = "Start time and End time is required";
|
||||
dateTimeError = "Start time is required";
|
||||
}
|
||||
// Validate end date
|
||||
else if (!endDate || endDate.trim() === "") {
|
||||
isDateTimeValid = false;
|
||||
dateTimeError = "End date is required";
|
||||
}
|
||||
// Validate end time (if not all-day)
|
||||
else if (!allday && (!endTime || endTime.trim() === "")) {
|
||||
isDateTimeValid = false;
|
||||
dateTimeError = "End time is required";
|
||||
}
|
||||
// Validate end vs start
|
||||
else {
|
||||
if (allday) {
|
||||
// Validate end fields based on UI mode
|
||||
else if (showFullFields) {
|
||||
// 4 fields mode: validate both end date and end time
|
||||
if (!endDate || endDate.trim() === "") {
|
||||
isDateTimeValid = false;
|
||||
dateTimeError = "End date is required";
|
||||
} else if (!allday && (!endTime || endTime.trim() === "")) {
|
||||
isDateTimeValid = false;
|
||||
dateTimeError = "End time is required";
|
||||
} else {
|
||||
// Validate total datetime
|
||||
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 [y, m, d] = ymd.split("-").map((v) => parseInt(v, 10));
|
||||
if (!y || !m || !d) return new Date(NaN);
|
||||
@@ -85,17 +151,6 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user