Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
@@ -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"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -265,9 +265,9 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
// Change handlers for 4 separate fields
|
// Change handlers for 4 separate fields
|
||||||
const handleStartDateChange = React.useCallback(
|
const handleStartDateChange = React.useCallback(
|
||||||
(newDate: string) => {
|
(newDate: string, newTime?: string) => {
|
||||||
setStartDate(newDate);
|
setStartDate(newDate);
|
||||||
const newStart = combineDateTime(newDate, startTime);
|
const newStart = combineDateTime(newDate, newTime ? newTime : startTime);
|
||||||
|
|
||||||
if (onStartChange) {
|
if (onStartChange) {
|
||||||
onStartChange(newStart);
|
onStartChange(newStart);
|
||||||
@@ -279,9 +279,9 @@ export default function EventFormFields({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleStartTimeChange = React.useCallback(
|
const handleStartTimeChange = React.useCallback(
|
||||||
(newTime: string) => {
|
(newTime: string, newDate?: string) => {
|
||||||
setStartTime(newTime);
|
setStartTime(newTime);
|
||||||
const newStart = combineDateTime(startDate, newTime);
|
const newStart = combineDateTime(newDate ? newDate : startDate, newTime);
|
||||||
if (onStartChange) {
|
if (onStartChange) {
|
||||||
onStartChange(newStart);
|
onStartChange(newStart);
|
||||||
} else {
|
} else {
|
||||||
@@ -292,13 +292,13 @@ export default function EventFormFields({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleEndDateChange = React.useCallback(
|
const handleEndDateChange = React.useCallback(
|
||||||
(newDate: string) => {
|
(newDate: string, newTime?: string) => {
|
||||||
setEndDate(newDate);
|
setEndDate(newDate);
|
||||||
// Track if user changed end date in extended mode
|
// Track if user changed end date in extended mode
|
||||||
if (showMore) {
|
if (showMore) {
|
||||||
setHasEndDateChanged(true);
|
setHasEndDateChanged(true);
|
||||||
}
|
}
|
||||||
const newEnd = combineDateTime(newDate, endTime);
|
const newEnd = combineDateTime(newDate, newTime ? newTime : endTime);
|
||||||
if (onEndChange) {
|
if (onEndChange) {
|
||||||
onEndChange(newEnd);
|
onEndChange(newEnd);
|
||||||
} else {
|
} else {
|
||||||
@@ -309,9 +309,10 @@ export default function EventFormFields({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleEndTimeChange = React.useCallback(
|
const handleEndTimeChange = React.useCallback(
|
||||||
(newTime: string) => {
|
(newTime: string, newDate?: string) => {
|
||||||
setEndTime(newTime);
|
setEndTime(newTime);
|
||||||
const newEnd = combineDateTime(endDate, newTime);
|
|
||||||
|
const newEnd = combineDateTime(newDate ? newDate : endDate, newTime);
|
||||||
if (onEndChange) {
|
if (onEndChange) {
|
||||||
onEndChange(newEnd);
|
onEndChange(newEnd);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useMemo } from "react";
|
||||||
import { Box, 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";
|
||||||
@@ -13,6 +13,9 @@ import "dayjs/locale/en";
|
|||||||
import "dayjs/locale/ru";
|
import "dayjs/locale/ru";
|
||||||
import "dayjs/locale/vi";
|
import "dayjs/locale/vi";
|
||||||
|
|
||||||
|
import { PickerValue } from "@mui/x-date-pickers/internals";
|
||||||
|
import { dtDate, dtTime, toDateTime } from "../utils/dateTimeHelpers";
|
||||||
|
|
||||||
dayjs.extend(customParseFormat);
|
dayjs.extend(customParseFormat);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,7 +38,7 @@ export interface DateTimeFieldsProps {
|
|||||||
};
|
};
|
||||||
onStartDateChange: (date: string) => void;
|
onStartDateChange: (date: string) => void;
|
||||||
onStartTimeChange: (time: string) => void;
|
onStartTimeChange: (time: string) => void;
|
||||||
onEndDateChange: (date: string) => void;
|
onEndDateChange: (date: string, time?: string) => void;
|
||||||
onEndTimeChange: (time: string) => void;
|
onEndTimeChange: (time: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,15 +63,199 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
onEndTimeChange,
|
onEndTimeChange,
|
||||||
}) => {
|
}) => {
|
||||||
const { t, lang } = useI18n();
|
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 isExpanded = showMore;
|
||||||
const shouldShowEndDateNormal = allday || !!showEndDate;
|
const shouldShowEndDateNormal = allday || showEndDate;
|
||||||
const shouldShowFullFieldsInNormal = !allday && hasEndDateChanged;
|
const shouldShowFullFieldsInNormal =
|
||||||
|
(!allday && hasEndDateChanged) || spansMultipleDays;
|
||||||
const showSingleDateField =
|
const showSingleDateField =
|
||||||
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal;
|
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal;
|
||||||
|
|
||||||
const startDateLabel = showSingleDateField
|
const startDateLabel = showSingleDateField
|
||||||
? t("dateTimeFields.date")
|
? t("dateTimeFields.date")
|
||||||
: t("dateTimeFields.startDate");
|
: 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 (
|
return (
|
||||||
<LocalizationProvider
|
<LocalizationProvider
|
||||||
dateAdapter={AdapterDayjs}
|
dateAdapter={AdapterDayjs}
|
||||||
@@ -91,25 +278,9 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
label={t("dateTimeFields.startDate")}
|
label={t("dateTimeFields.startDate")}
|
||||||
format={LONG_DATE_FORMAT}
|
format={LONG_DATE_FORMAT}
|
||||||
value={startDate ? dayjs(startDate) : null}
|
value={startDateValue}
|
||||||
onChange={(newValue) => {
|
onChange={handleStartDateChange}
|
||||||
const value = newValue as Dayjs | null;
|
slotProps={getSlotProps("start-date-input")}
|
||||||
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" },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{!allday && (
|
{!allday && (
|
||||||
@@ -117,25 +288,9 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
<TimePicker
|
<TimePicker
|
||||||
label={t("dateTimeFields.startTime")}
|
label={t("dateTimeFields.startTime")}
|
||||||
ampm={false}
|
ampm={false}
|
||||||
value={startTime ? dayjs(startTime, "HH:mm") : null}
|
value={startTimeValue}
|
||||||
onChange={(newValue) => {
|
onChange={handleStartTimeChange}
|
||||||
const value = newValue as Dayjs | null;
|
slotProps={getSlotProps("start-time-input")}
|
||||||
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" },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -145,26 +300,12 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
label={t("dateTimeFields.endDate")}
|
label={t("dateTimeFields.endDate")}
|
||||||
format={LONG_DATE_FORMAT}
|
format={LONG_DATE_FORMAT}
|
||||||
value={endDate ? dayjs(endDate) : null}
|
value={endDateValue}
|
||||||
onChange={(newValue) => {
|
onChange={handleEndDateChange}
|
||||||
const value = newValue as Dayjs | null;
|
slotProps={getSlotProps(
|
||||||
if (!value || !value.isValid()) {
|
"end-date-input",
|
||||||
return;
|
!!validation.errors.dateTime
|
||||||
}
|
)}
|
||||||
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" },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{!allday && (
|
{!allday && (
|
||||||
@@ -172,26 +313,12 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
<TimePicker
|
<TimePicker
|
||||||
label={t("dateTimeFields.endTime")}
|
label={t("dateTimeFields.endTime")}
|
||||||
ampm={false}
|
ampm={false}
|
||||||
value={endTime ? dayjs(endTime, "HH:mm") : null}
|
value={endTimeValue}
|
||||||
onChange={(newValue) => {
|
onChange={handleEndTimeChange}
|
||||||
const value = newValue as Dayjs | null;
|
slotProps={getSlotProps(
|
||||||
if (!value || !value.isValid()) {
|
"end-time-input",
|
||||||
return;
|
!!validation.errors.dateTime
|
||||||
}
|
)}
|
||||||
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" },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
@@ -203,51 +330,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
label={t("dateTimeFields.startDate")}
|
label={t("dateTimeFields.startDate")}
|
||||||
format={LONG_DATE_FORMAT}
|
format={LONG_DATE_FORMAT}
|
||||||
value={startDate ? dayjs(startDate) : null}
|
value={startDateValue}
|
||||||
onChange={(newValue) => {
|
onChange={handleStartDateChange}
|
||||||
const value = newValue as Dayjs | null;
|
slotProps={getSlotProps("start-date-input")}
|
||||||
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" },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ maxWidth: "300px", width: "48%" }}>
|
<Box sx={{ maxWidth: "300px", width: "48%" }}>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
label={t("dateTimeFields.endDate")}
|
label={t("dateTimeFields.endDate")}
|
||||||
format={LONG_DATE_FORMAT}
|
format={LONG_DATE_FORMAT}
|
||||||
value={endDate ? dayjs(endDate) : null}
|
value={endDateValue}
|
||||||
onChange={(newValue) => {
|
onChange={handleEndDateChange}
|
||||||
const value = newValue as Dayjs | null;
|
slotProps={getSlotProps(
|
||||||
if (!value || !value.isValid()) {
|
"end-date-input",
|
||||||
return;
|
!!validation.errors.dateTime
|
||||||
}
|
)}
|
||||||
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" },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -257,66 +354,27 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
|||||||
<DatePicker
|
<DatePicker
|
||||||
label={startDateLabel}
|
label={startDateLabel}
|
||||||
format={LONG_DATE_FORMAT}
|
format={LONG_DATE_FORMAT}
|
||||||
value={startDate ? dayjs(startDate) : null}
|
value={startDateValue}
|
||||||
onChange={(newValue) => {
|
onChange={handleStartDateChange}
|
||||||
const value = newValue as Dayjs | null;
|
slotProps={getSlotProps("start-date-input")}
|
||||||
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" },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ maxWidth: "110px" }}>
|
<Box sx={{ maxWidth: "110px" }}>
|
||||||
<TimePicker
|
<TimePicker
|
||||||
label={t("dateTimeFields.startTime")}
|
label={t("dateTimeFields.startTime")}
|
||||||
ampm={false}
|
ampm={false}
|
||||||
value={startTime ? dayjs(startTime, "HH:mm") : null}
|
value={startTimeValue}
|
||||||
onChange={(newValue) => {
|
onChange={handleStartTimeChange}
|
||||||
const value = newValue as Dayjs | null;
|
|
||||||
if (!value || !value.isValid()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const formatted = value?.format("HH:mm") || "";
|
|
||||||
onStartTimeChange(formatted);
|
|
||||||
}}
|
|
||||||
disabled={allday}
|
disabled={allday}
|
||||||
slotProps={{
|
slotProps={getSlotProps("start-time-input")}
|
||||||
textField: {
|
|
||||||
size: "small",
|
|
||||||
margin: "dense" as const,
|
|
||||||
fullWidth: true,
|
|
||||||
InputLabelProps: { shrink: true },
|
|
||||||
sx: { width: "100%" },
|
|
||||||
inputProps: { "data-testid": "start-time-input" },
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ maxWidth: "110px" }}>
|
<Box sx={{ maxWidth: "110px" }}>
|
||||||
<TimePicker
|
<TimePicker
|
||||||
label={t("dateTimeFields.endTime")}
|
label={t("dateTimeFields.endTime")}
|
||||||
ampm={false}
|
ampm={false}
|
||||||
value={endTime ? dayjs(endTime, "HH:mm") : null}
|
value={endTimeValue}
|
||||||
onChange={(newValue) => {
|
onChange={handleEndTimeChange}
|
||||||
const value = newValue as Dayjs | null;
|
|
||||||
if (!value || !value.isValid()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const formatted = value?.format("HH:mm") || "";
|
|
||||||
onEndTimeChange(formatted);
|
|
||||||
}}
|
|
||||||
disabled={allday}
|
disabled={allday}
|
||||||
slotProps={{
|
slotProps={{
|
||||||
textField: {
|
textField: {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import moment from "moment-timezone";
|
import moment from "moment-timezone";
|
||||||
|
import dayjs, { Dayjs } from "dayjs";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper functions for date/time string manipulation
|
* Helper functions for date/time string manipulation
|
||||||
@@ -68,3 +69,17 @@ export function convertFormDateTimeToISO(
|
|||||||
}
|
}
|
||||||
return momentDate.toDate().toISOString();
|
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");
|
||||||
|
|||||||
Reference in New Issue
Block a user