From 25078c783eea0223007f4fac10481d43d3e28a26 Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Tue, 25 Nov 2025 11:16:45 +0100 Subject: [PATCH] [#285] added duration enforcement to date and time in dateTimeFields (#343) Co-authored-by: Camille Moussu --- __test__/components/DateTimeField.test.tsx | 248 +++++++++++ src/components/Event/EventFormFields.tsx | 17 +- .../Event/components/DateTimeFields.tsx | 394 ++++++++++-------- src/components/Event/utils/dateTimeHelpers.ts | 15 + 4 files changed, 498 insertions(+), 176 deletions(-) create mode 100644 __test__/components/DateTimeField.test.tsx diff --git a/__test__/components/DateTimeField.test.tsx b/__test__/components/DateTimeField.test.tsx new file mode 100644 index 0000000..b610977 --- /dev/null +++ b/__test__/components/DateTimeField.test.tsx @@ -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 = {}) => { + await act(async () => + render() + ); + }; + + 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" + ) + ); + }); +}); diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index ab3df44..dd222c9 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -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 { diff --git a/src/components/Event/components/DateTimeFields.tsx b/src/components/Event/components/DateTimeFields.tsx index fab7260..2f43f88 100644 --- a/src/components/Event/components/DateTimeFields.tsx +++ b/src/components/Event/components/DateTimeFields.tsx @@ -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 = ({ onEndTimeChange, }) => { const { t, lang } = useI18n(); + + const initialDurationRef = React.useRef(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 ( = ({ { - 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")} /> {!allday && ( @@ -117,25 +288,9 @@ export const DateTimeFields: React.FC = ({ { - 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")} /> )} @@ -145,26 +300,12 @@ export const DateTimeFields: React.FC = ({ { - 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 + )} /> {!allday && ( @@ -172,26 +313,12 @@ export const DateTimeFields: React.FC = ({ { - 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 + )} /> )} @@ -203,51 +330,21 @@ export const DateTimeFields: React.FC = ({ { - 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")} /> { - 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 + )} /> @@ -257,66 +354,27 @@ export const DateTimeFields: React.FC = ({ { - 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")} /> { - 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")} /> { - 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: { diff --git a/src/components/Event/utils/dateTimeHelpers.ts b/src/components/Event/utils/dateTimeHelpers.ts index 3549b6b..48cb1e9 100644 --- a/src/components/Event/utils/dateTimeHelpers.ts +++ b/src/components/Event/utils/dateTimeHelpers.ts @@ -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");