[#285] added duration enforcement to date and time in dateTimeFields (#343)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2025-11-25 11:16:45 +01:00
committed by GitHub
parent fd588b4749
commit 25078c783e
4 changed files with 498 additions and 176 deletions
+248
View File
@@ -0,0 +1,248 @@
import {
screen,
fireEvent,
waitFor,
act,
render,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import {
DateTimeFieldsProps,
DateTimeFields,
} from "../../src/components/Event/components/DateTimeFields";
jest.mock("cozy-ui/transpiled/react/providers/I18n", () => ({
useI18n: () => ({
t: (key: string) => key,
lang: "en",
}),
}));
describe("DateTimeFields", () => {
const mockHandlers = {
onStartDateChange: jest.fn(),
onStartTimeChange: jest.fn(),
onEndDateChange: jest.fn(),
onEndTimeChange: jest.fn(),
onToggleEndDate: jest.fn(),
};
const defaultProps: DateTimeFieldsProps = {
startDate: "2025-07-18",
startTime: "09:00",
endDate: "2025-07-18",
endTime: "10:00",
allday: false,
showMore: true,
hasEndDateChanged: false,
showEndDate: false,
validation: {
errors: {
dateTime: "",
},
},
...mockHandlers,
};
const renderField = async (props: Partial<DateTimeFieldsProps> = {}) => {
await act(async () =>
render(<DateTimeFields {...defaultProps} {...props} />)
);
};
beforeEach(() => {
jest.clearAllMocks();
});
it("moves END forward when START moves after END (normal mode)", async () => {
await renderField({
startDate: "2025-01-01",
startTime: "10:00",
endDate: "2025-01-01",
endTime: "11:00",
showMore: true,
});
const startTimeInput = screen.getByTestId("start-time-input");
fireEvent.change(startTimeInput, { target: { value: "12:00" } });
await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("12:00")
);
await waitFor(() =>
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("13:00")
);
});
it("moves END forward by full duration when START date jumps after END date", async () => {
await renderField({
startDate: "2025-01-01",
startTime: "09:00",
endDate: "2025-01-01",
endTime: "10:00",
showMore: true,
});
const calendarButton = screen.getAllByRole("button");
await userEvent.click(calendarButton[0]);
const dayButton = screen.getByRole("gridcell", { name: "3" });
await userEvent.click(dayButton);
await waitFor(() =>
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-01-03")
);
await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-01-03")
);
});
it("moves START backward when END moves before START (normal mode)", async () => {
await renderField({
startDate: "2025-01-01",
startTime: "10:00",
endDate: "2025-01-01",
endTime: "11:00",
showMore: true,
});
const endTimeInput = screen.getByTestId("end-time-input");
fireEvent.change(endTimeInput, { target: { value: "08:00" } });
await waitFor(() =>
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("08:00")
);
await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("07:00")
);
});
it("moves START backward properly when END date jumps before START date", async () => {
await renderField({
startDate: "2025-01-05",
startTime: "09:00",
endDate: "2025-01-05",
endTime: "10:00",
showMore: true,
});
const calendarButton = screen.getAllByRole("button");
await userEvent.click(calendarButton[2]);
const dayButton = screen.getByRole("gridcell", { name: "3" });
await userEvent.click(dayButton);
await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-01-03")
);
await waitFor(() =>
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-01-03")
);
});
it("pushes END forward in whole days for allday events", async () => {
await renderField({
allday: true,
startDate: "2025-02-01",
endDate: "2025-02-03",
showEndDate: true,
});
const calendarButton = screen.getAllByRole("button");
await userEvent.click(calendarButton[0]);
const dayButton = screen.getByRole("gridcell", { name: "10" });
await userEvent.click(dayButton);
await waitFor(() =>
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-02-10")
);
await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-02-12")
);
});
it("moves START backward in whole days for allday when END moves earlier", async () => {
await renderField({
allday: true,
startDate: "2025-05-10",
endDate: "2025-05-15",
showEndDate: true,
});
const calendarButton = screen.getAllByRole("button");
await userEvent.click(calendarButton[1]);
const dayButton = screen.getByRole("gridcell", { name: "1" });
await userEvent.click(dayButton);
await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith("2025-05-01")
);
await waitFor(() =>
expect(mockHandlers.onStartDateChange).toHaveBeenCalledWith("2025-04-26")
);
});
it("does not call handlers if invalid (null) date value", async () => {
await renderField({
showMore: true,
});
const startDateInput = screen.getByTestId("start-date-input");
fireEvent.change(startDateInput, { target: { value: "" } });
expect(mockHandlers.onStartDateChange).not.toHaveBeenCalled();
expect(mockHandlers.onEndDateChange).not.toHaveBeenCalled();
});
it("shift to preserve original duration (normal case)", async () => {
await renderField({
startDate: "2025-01-01",
startTime: "09:00",
endDate: "2025-01-01",
endTime: "10:00",
showMore: true,
});
const startTimeInput = screen.getByTestId("start-time-input");
fireEvent.change(startTimeInput, { target: { value: "09:30" } });
await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("09:30")
);
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("10:30");
});
it("preserves 1-hour duration across midnight when changing start time from 22:30 to 23:45", async () => {
await renderField({
startDate: "2025-01-15",
startTime: "22:30",
endDate: "2025-01-15",
endTime: "23:30",
showMore: true,
});
const startTimeInput = screen.getByTestId("start-time-input");
// Change start time from 22:30 to 23:45
fireEvent.change(startTimeInput, { target: { value: "23:45" } });
await waitFor(() =>
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("23:45")
);
// End date should move to the next day
await waitFor(() =>
expect(mockHandlers.onEndDateChange).toHaveBeenCalledWith(
"2025-01-16",
"00:45"
)
);
});
});
+9 -8
View File
@@ -265,9 +265,9 @@ export default function EventFormFields({
// Change handlers for 4 separate fields
const handleStartDateChange = React.useCallback(
(newDate: string) => {
(newDate: string, newTime?: string) => {
setStartDate(newDate);
const newStart = combineDateTime(newDate, startTime);
const newStart = combineDateTime(newDate, newTime ? newTime : startTime);
if (onStartChange) {
onStartChange(newStart);
@@ -279,9 +279,9 @@ export default function EventFormFields({
);
const handleStartTimeChange = React.useCallback(
(newTime: string) => {
(newTime: string, newDate?: string) => {
setStartTime(newTime);
const newStart = combineDateTime(startDate, newTime);
const newStart = combineDateTime(newDate ? newDate : startDate, newTime);
if (onStartChange) {
onStartChange(newStart);
} else {
@@ -292,13 +292,13 @@ export default function EventFormFields({
);
const handleEndDateChange = React.useCallback(
(newDate: string) => {
(newDate: string, newTime?: string) => {
setEndDate(newDate);
// Track if user changed end date in extended mode
if (showMore) {
setHasEndDateChanged(true);
}
const newEnd = combineDateTime(newDate, endTime);
const newEnd = combineDateTime(newDate, newTime ? newTime : endTime);
if (onEndChange) {
onEndChange(newEnd);
} else {
@@ -309,9 +309,10 @@ export default function EventFormFields({
);
const handleEndTimeChange = React.useCallback(
(newTime: string) => {
(newTime: string, newDate?: string) => {
setEndTime(newTime);
const newEnd = combineDateTime(endDate, newTime);
const newEnd = combineDateTime(newDate ? newDate : endDate, newTime);
if (onEndChange) {
onEndChange(newEnd);
} else {
+226 -168
View File
@@ -1,4 +1,4 @@
import React from "react";
import React, { useMemo } from "react";
import { Box, Typography } from "@mui/material";
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
import { TimePicker } from "@mui/x-date-pickers/TimePicker";
@@ -13,6 +13,9 @@ import "dayjs/locale/en";
import "dayjs/locale/ru";
import "dayjs/locale/vi";
import { PickerValue } from "@mui/x-date-pickers/internals";
import { dtDate, dtTime, toDateTime } from "../utils/dateTimeHelpers";
dayjs.extend(customParseFormat);
/**
@@ -35,7 +38,7 @@ export interface DateTimeFieldsProps {
};
onStartDateChange: (date: string) => void;
onStartTimeChange: (time: string) => void;
onEndDateChange: (date: string) => void;
onEndDateChange: (date: string, time?: string) => void;
onEndTimeChange: (time: string) => void;
}
@@ -60,15 +63,199 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
onEndTimeChange,
}) => {
const { t, lang } = useI18n();
const initialDurationRef = React.useRef<number | null>(null);
const isUserActionRef = React.useRef(false);
const getCurrentDuration = React.useCallback((): number => {
const start = toDateTime(startDate, startTime);
const end = toDateTime(endDate, endTime);
if (allday) {
return Math.max(end.startOf("day").diff(start.startOf("day"), "day"), 0);
} else {
return Math.max(end.diff(start, "minute"), 0);
}
}, [startDate, startTime, endDate, endTime, allday]);
React.useEffect(() => {
if (!isUserActionRef.current && startDate && endDate) {
initialDurationRef.current = getCurrentDuration();
}
queueMicrotask(() => {
isUserActionRef.current = false;
});
}, [startDate, endDate, startTime, endTime, getCurrentDuration]);
const spansMultipleDays = React.useMemo(() => {
return startDate !== endDate;
}, [startDate, endDate]);
const isExpanded = showMore;
const shouldShowEndDateNormal = allday || !!showEndDate;
const shouldShowFullFieldsInNormal = !allday && hasEndDateChanged;
const shouldShowEndDateNormal = allday || showEndDate;
const shouldShowFullFieldsInNormal =
(!allday && hasEndDateChanged) || spansMultipleDays;
const showSingleDateField =
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal;
const startDateLabel = showSingleDateField
? t("dateTimeFields.date")
: t("dateTimeFields.startDate");
const handleStartDateChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
isUserActionRef.current = true;
const newDateStr = dtDate(value as Dayjs);
const duration = initialDurationRef.current ?? getCurrentDuration();
onStartDateChange(newDateStr);
// Preserve duration by adjusting end
const newStart = toDateTime(newDateStr, startTime);
let newEnd: Dayjs;
if (allday) {
newEnd = newStart.add(duration, "day");
} else {
newEnd = newStart.add(duration, "minute");
}
const newEndDate = dtDate(newEnd);
const newEndTime = dtTime(newEnd);
if (newEndDate !== endDate) {
onEndDateChange(newEndDate);
}
if (!allday && newEndTime !== endTime) {
onEndTimeChange(newEndTime);
}
};
const handleStartTimeChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
isUserActionRef.current = true;
const newTimeStr = dtTime(value as Dayjs);
const duration = initialDurationRef.current ?? getCurrentDuration();
onStartTimeChange(newTimeStr);
const newStart = toDateTime(startDate, newTimeStr);
const newEnd = newStart.add(duration, "minute");
const newEndDate = dtDate(newEnd);
const newEndTime = dtTime(newEnd);
if (newEndDate !== endDate && newEndTime !== endTime) {
onEndDateChange(newEndDate, newEndTime);
} else {
if (newEndDate !== endDate) {
onEndDateChange(newEndDate);
}
if (!allday && newEndTime !== endTime) {
onEndTimeChange(newEndTime);
}
}
};
const handleEndDateChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
isUserActionRef.current = true;
const newDateStr = dtDate(value as Dayjs);
const newEnd = toDateTime(newDateStr, endTime);
const currentStart = toDateTime(startDate, startTime);
if (newEnd.isBefore(currentStart)) {
const duration = initialDurationRef.current ?? getCurrentDuration();
let newStart: Dayjs;
if (allday) {
newStart = newEnd.subtract(duration, "day");
} else {
newStart = newEnd.subtract(duration, "minute");
}
const newStartDate = dtDate(newStart);
const newStartTime = dtTime(newStart);
if (newStartDate !== startDate) {
onStartDateChange(newStartDate);
}
if (!allday && newStartTime !== startTime) {
onStartTimeChange(newStartTime);
}
} else {
initialDurationRef.current = newEnd.diff(
currentStart,
allday ? "day" : "minute"
);
}
onEndDateChange(newDateStr);
};
const handleEndTimeChange = (value: PickerValue) => {
if (!value || !value.isValid()) return;
isUserActionRef.current = true;
const newTimeStr = dtTime(value as Dayjs);
const newEnd = toDateTime(endDate, newTimeStr);
const currentStart = toDateTime(startDate, startTime);
// If end is before start, adjust start to maintain duration
if (newEnd.isBefore(currentStart)) {
const duration = initialDurationRef.current ?? getCurrentDuration();
const newStart = newEnd.subtract(duration, "minute");
const newStartDate = dtDate(newStart);
const newStartTime = dtTime(newStart);
if (newStartDate !== startDate) {
onStartDateChange(newStartDate);
}
if (newStartTime !== startTime) {
onStartTimeChange(newStartTime);
}
} else {
// Update duration when user changes end
initialDurationRef.current = newEnd.diff(currentStart, "minute");
}
onEndTimeChange(newTimeStr);
};
// Memoize parsed date/time values
const startDateValue = useMemo(
() => (startDate ? dayjs(startDate) : null),
[startDate]
);
const startTimeValue = useMemo(
() => (startTime ? dayjs(startTime, "HH:mm") : null),
[startTime]
);
const endDateValue = useMemo(
() => (endDate ? dayjs(endDate) : null),
[endDate]
);
const endTimeValue = useMemo(
() => (endTime ? dayjs(endTime, "HH:mm") : null),
[endTime]
);
const getSlotProps = (testId: string, hasError = false) => ({
textField: {
size: "small" as const,
margin: "dense" as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: hasError,
sx: { width: "100%" },
inputProps: { "data-testid": testId },
},
});
return (
<LocalizationProvider
dateAdapter={AdapterDayjs}
@@ -91,25 +278,9 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<DatePicker
label={t("dateTimeFields.startDate")}
format={LONG_DATE_FORMAT}
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: {
size: "small",
margin: "dense" as const,
fullWidth: true,
InputLabelProps: { shrink: true },
sx: { width: "100%" },
inputProps: { "data-testid": "start-date-input" },
},
}}
value={startDateValue}
onChange={handleStartDateChange}
slotProps={getSlotProps("start-date-input")}
/>
</Box>
{!allday && (
@@ -117,25 +288,9 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker
label={t("dateTimeFields.startTime")}
ampm={false}
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: {
size: "small",
margin: "dense" as const,
fullWidth: true,
InputLabelProps: { shrink: true },
sx: { width: "100%" },
inputProps: { "data-testid": "start-time-input" },
},
}}
value={startTimeValue}
onChange={handleStartTimeChange}
slotProps={getSlotProps("start-time-input")}
/>
</Box>
)}
@@ -145,26 +300,12 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<DatePicker
label={t("dateTimeFields.endDate")}
format={LONG_DATE_FORMAT}
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: {
size: "small",
margin: "dense" as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: !!validation.errors.dateTime,
sx: { width: "100%" },
inputProps: { "data-testid": "end-date-input" },
},
}}
value={endDateValue}
onChange={handleEndDateChange}
slotProps={getSlotProps(
"end-date-input",
!!validation.errors.dateTime
)}
/>
</Box>
{!allday && (
@@ -172,26 +313,12 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<TimePicker
label={t("dateTimeFields.endTime")}
ampm={false}
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: {
size: "small",
margin: "dense" as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: !!validation.errors.dateTime,
sx: { width: "100%" },
inputProps: { "data-testid": "end-time-input" },
},
}}
value={endTimeValue}
onChange={handleEndTimeChange}
slotProps={getSlotProps(
"end-time-input",
!!validation.errors.dateTime
)}
/>
</Box>
)}
@@ -203,51 +330,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<DatePicker
label={t("dateTimeFields.startDate")}
format={LONG_DATE_FORMAT}
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: {
size: "small",
margin: "dense" as const,
fullWidth: true,
InputLabelProps: { shrink: true },
sx: { width: "100%" },
inputProps: { "data-testid": "start-date-input" },
},
}}
value={startDateValue}
onChange={handleStartDateChange}
slotProps={getSlotProps("start-date-input")}
/>
</Box>
<Box sx={{ maxWidth: "300px", width: "48%" }}>
<DatePicker
label={t("dateTimeFields.endDate")}
format={LONG_DATE_FORMAT}
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: {
size: "small",
margin: "dense" as const,
fullWidth: true,
InputLabelProps: { shrink: true },
error: !!validation.errors.dateTime,
sx: { width: "100%" },
inputProps: { "data-testid": "end-date-input" },
},
}}
value={endDateValue}
onChange={handleEndDateChange}
slotProps={getSlotProps(
"end-date-input",
!!validation.errors.dateTime
)}
/>
</Box>
</Box>
@@ -257,66 +354,27 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
<DatePicker
label={startDateLabel}
format={LONG_DATE_FORMAT}
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: {
size: "small",
margin: "dense" as const,
fullWidth: true,
InputLabelProps: { shrink: true },
sx: { width: "100%" },
inputProps: { "data-testid": "start-date-input" },
},
}}
value={startDateValue}
onChange={handleStartDateChange}
slotProps={getSlotProps("start-date-input")}
/>
</Box>
<Box sx={{ maxWidth: "110px" }}>
<TimePicker
label={t("dateTimeFields.startTime")}
ampm={false}
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);
}}
value={startTimeValue}
onChange={handleStartTimeChange}
disabled={allday}
slotProps={{
textField: {
size: "small",
margin: "dense" as const,
fullWidth: true,
InputLabelProps: { shrink: true },
sx: { width: "100%" },
inputProps: { "data-testid": "start-time-input" },
},
}}
slotProps={getSlotProps("start-time-input")}
/>
</Box>
<Box sx={{ maxWidth: "110px" }}>
<TimePicker
label={t("dateTimeFields.endTime")}
ampm={false}
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);
}}
value={endTimeValue}
onChange={handleEndTimeChange}
disabled={allday}
slotProps={{
textField: {
@@ -1,4 +1,5 @@
import moment from "moment-timezone";
import dayjs, { Dayjs } from "dayjs";
/**
* Helper functions for date/time string manipulation
@@ -68,3 +69,17 @@ export function convertFormDateTimeToISO(
}
return momentDate.toDate().toISOString();
}
/** Convert date + time strings → Dayjs */
export const toDateTime = (date: string, time: string): Dayjs => {
const d = dayjs(date, "YYYY-MM-DD", true);
if (!time) return d.startOf("day");
const [h, m] = time.split(":").map(Number);
return d.hour(h).minute(m).second(0).millisecond(0);
};
/** Extract date “YYYY-MM-DD” */
export const dtDate = (d: Dayjs) => d.format("YYYY-MM-DD");
/** Extract time “HH:mm” */
export const dtTime = (d: Dayjs) => d.format("HH:mm");