feat(DateTimeFields): implement free typing time input with dropdown
- Replace default TimePicker field with custom EditableTimeField - Allow free typing with multiple formats (HH:mm, HHmm, H, etc.) - Show dropdown on click while maintaining typing capability - Center-align time input text
This commit is contained in:
committed by
Benoit TELLIER
parent
12776d36a4
commit
627e9bf44b
@@ -66,6 +66,7 @@ describe("DateTimeFields", () => {
|
||||
const startTimeInput = screen.getByTestId("start-time-input");
|
||||
|
||||
fireEvent.change(startTimeInput, { target: { value: "12:00" } });
|
||||
fireEvent.blur(startTimeInput);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("12:00")
|
||||
@@ -109,6 +110,7 @@ describe("DateTimeFields", () => {
|
||||
const endTimeInput = screen.getByTestId("end-time-input");
|
||||
|
||||
fireEvent.change(endTimeInput, { target: { value: "08:00" } });
|
||||
fireEvent.blur(endTimeInput);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("08:00")
|
||||
@@ -208,11 +210,14 @@ describe("DateTimeFields", () => {
|
||||
const startTimeInput = screen.getByTestId("start-time-input");
|
||||
|
||||
fireEvent.change(startTimeInput, { target: { value: "09:30" } });
|
||||
fireEvent.blur(startTimeInput);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("09:30")
|
||||
);
|
||||
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("10:30");
|
||||
await waitFor(() =>
|
||||
expect(mockHandlers.onEndTimeChange).toHaveBeenCalledWith("10:30")
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves 1-hour duration across midnight when changing start time from 22:30 to 23:45", async () => {
|
||||
@@ -228,6 +233,7 @@ describe("DateTimeFields", () => {
|
||||
|
||||
// Change start time from 22:30 to 23:45
|
||||
fireEvent.change(startTimeInput, { target: { value: "23:45" } });
|
||||
fireEvent.blur(startTimeInput);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mockHandlers.onStartTimeChange).toHaveBeenCalledWith("23:45")
|
||||
|
||||
@@ -406,9 +406,10 @@ describe("CalendarApp integration", () => {
|
||||
fireEvent.change(startDateInput, {
|
||||
target: { value: "08:00" },
|
||||
});
|
||||
fireEvent.blur(startDateInput);
|
||||
});
|
||||
|
||||
// Wait for the 0.1s delay in handleStartTimeChangeWithClose to complete
|
||||
// Wait for blur handler to complete
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { userAttendee } from "../../features/User/userDataTypes";
|
||||
import { PeopleSearch, User } from "./PeopleSearch";
|
||||
|
||||
@@ -37,7 +38,9 @@ export default function UserSearch({
|
||||
selectedUsers={selectedUsers}
|
||||
objectTypes={["user", "contact"]}
|
||||
disabled={disabled}
|
||||
small={small}
|
||||
inputSlot={
|
||||
small ? (params) => <TextField {...params} size="small" /> : undefined
|
||||
}
|
||||
onChange={(event: any, value: User[]) => {
|
||||
setAttendees(
|
||||
value.map((a: User) => ({
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Autocomplete from "@mui/material/Autocomplete";
|
||||
import Autocomplete, {
|
||||
AutocompleteRenderInputParams,
|
||||
} from "@mui/material/Autocomplete";
|
||||
import Avatar from "@mui/material/Avatar";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import ListItem from "@mui/material/ListItem";
|
||||
@@ -22,6 +24,14 @@ export interface User {
|
||||
color?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ExtendedAutocompleteRenderInputParams extends AutocompleteRenderInputParams {
|
||||
error?: boolean;
|
||||
helperText?: string | null;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
export function PeopleSearch({
|
||||
selectedUsers,
|
||||
onChange,
|
||||
@@ -30,7 +40,7 @@ export function PeopleSearch({
|
||||
freeSolo,
|
||||
onToggleEventPreview,
|
||||
placeholder,
|
||||
small,
|
||||
inputSlot,
|
||||
}: {
|
||||
selectedUsers: User[];
|
||||
onChange: Function;
|
||||
@@ -39,7 +49,9 @@ export function PeopleSearch({
|
||||
freeSolo?: boolean;
|
||||
onToggleEventPreview?: () => void;
|
||||
placeholder?: string;
|
||||
small?: boolean;
|
||||
inputSlot?: (
|
||||
params: ExtendedAutocompleteRenderInputParams
|
||||
) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -146,53 +158,95 @@ export function PeopleSearch({
|
||||
);
|
||||
onChange(event, mapped);
|
||||
}}
|
||||
renderInput={(params) => (
|
||||
<>
|
||||
<label htmlFor={params.id} className="visually-hidden">
|
||||
{t("peopleSearch.label")}
|
||||
</label>
|
||||
<TextField
|
||||
{...params}
|
||||
size={small ? "small" : "medium"}
|
||||
error={!!inputError}
|
||||
helperText={inputError}
|
||||
placeholder={searchPlaceholder}
|
||||
label=""
|
||||
inputProps={{
|
||||
...params.inputProps,
|
||||
renderInput={(params) => {
|
||||
const inputProps = {
|
||||
...params.InputProps,
|
||||
startAdornment: (
|
||||
<>
|
||||
<PeopleOutlineOutlinedIcon
|
||||
sx={{ mr: 1, color: "action.active" }}
|
||||
/>
|
||||
{params.InputProps.startAdornment}
|
||||
</>
|
||||
),
|
||||
endAdornment: (
|
||||
<>
|
||||
{loading ? (
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
};
|
||||
|
||||
const enhancedParamsWithInputProps = {
|
||||
...params,
|
||||
InputProps: inputProps,
|
||||
inputProps: {
|
||||
...params.inputProps,
|
||||
autoComplete: "off",
|
||||
},
|
||||
};
|
||||
|
||||
const { InputProps, ...enhancedParams } =
|
||||
enhancedParamsWithInputProps;
|
||||
|
||||
const defaultTextFieldProps = {
|
||||
error: !!inputError,
|
||||
helperText: inputError,
|
||||
placeholder: searchPlaceholder,
|
||||
label: "",
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && onToggleEventPreview) {
|
||||
e.preventDefault();
|
||||
onToggleEventPreview();
|
||||
}
|
||||
},
|
||||
slotProps: {
|
||||
input: {
|
||||
...inputProps,
|
||||
autoComplete: "off",
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && onToggleEventPreview) {
|
||||
e.preventDefault();
|
||||
onToggleEventPreview();
|
||||
}
|
||||
}}
|
||||
slotProps={{
|
||||
input: {
|
||||
...params.InputProps,
|
||||
autoComplete: "off",
|
||||
startAdornment: (
|
||||
<>
|
||||
<PeopleOutlineOutlinedIcon
|
||||
style={{ marginRight: 8, color: "rgba(0, 0, 0, 0.54)" }}
|
||||
/>
|
||||
{params.InputProps.startAdornment}
|
||||
</>
|
||||
),
|
||||
endAdornment: (
|
||||
<>
|
||||
{loading ? (
|
||||
<CircularProgress color="inherit" size={20} />
|
||||
) : null}
|
||||
{params.InputProps.endAdornment}
|
||||
</>
|
||||
),
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (inputSlot) {
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={params.id} className="visually-hidden">
|
||||
{t("peopleSearch.label")}
|
||||
</label>
|
||||
{inputSlot({
|
||||
...enhancedParamsWithInputProps,
|
||||
error: !!inputError,
|
||||
helperText: inputError,
|
||||
placeholder: searchPlaceholder,
|
||||
label: "",
|
||||
onKeyDown: (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && onToggleEventPreview) {
|
||||
e.preventDefault();
|
||||
onToggleEventPreview();
|
||||
}
|
||||
},
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<label htmlFor={params.id} className="visually-hidden">
|
||||
{t("peopleSearch.label")}
|
||||
</label>
|
||||
<TextField
|
||||
{...enhancedParams}
|
||||
{...defaultTextFieldProps}
|
||||
InputProps={inputProps}
|
||||
size="medium"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
renderOption={(props, option) => {
|
||||
if (selectedUsers.find((u) => u.email === option.email)) return null;
|
||||
const { key, ...otherProps } = props as any;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { useAppDispatch, useAppSelector } from "../../app/hooks";
|
||||
import { getCalendars } from "../../features/Calendars/CalendarApi";
|
||||
import { addSharedCalendarAsync } from "../../features/Calendars/CalendarSlice";
|
||||
import { Calendars } from "../../features/Calendars/CalendarTypes";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { PeopleSearch, User } from "../Attendees/PeopleSearch";
|
||||
import { ResponsiveDialog } from "../Dialog";
|
||||
import { ColorPicker } from "./CalendarColorPicker";
|
||||
@@ -275,7 +276,7 @@ export default function CalendarSearch({
|
||||
<PeopleSearch
|
||||
objectTypes={["user"]}
|
||||
selectedUsers={selectedUsers}
|
||||
small
|
||||
inputSlot={(params) => <TextField {...params} size="small" />}
|
||||
onChange={async (event: any, value: User[]) => {
|
||||
setSelectedUsers(value);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../../features/Calendars/CalendarSlice";
|
||||
import { Calendars } from "../../features/Calendars/CalendarTypes";
|
||||
import { setView } from "../../features/Settings/SettingsSlice";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { User, PeopleSearch } from "../Attendees/PeopleSearch";
|
||||
import { getAccessiblePair } from "./utils/calendarColorsUtils";
|
||||
|
||||
@@ -105,7 +106,7 @@ export function TempCalendarsInput({
|
||||
onChange={handleUserChange}
|
||||
onToggleEventPreview={handleToggleEventPreview}
|
||||
placeholder={t("peopleSearch.availabilityPlaceholder")}
|
||||
small
|
||||
inputSlot={(params) => <TextField {...params} size="small" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -181,6 +181,19 @@ export default function EventFormFields({
|
||||
onHasEndDateChangedChange?.(hasEndDateChanged);
|
||||
}, [hasEndDateChanged, onHasEndDateChangedChange]);
|
||||
|
||||
// Reset hasEndDateChanged when startDate === endDate in normal mode
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!showMore &&
|
||||
hasEndDateChanged &&
|
||||
startDate &&
|
||||
endDate &&
|
||||
startDate === endDate
|
||||
) {
|
||||
setHasEndDateChanged(false);
|
||||
}
|
||||
}, [showMore, hasEndDateChanged, startDate, endDate]);
|
||||
|
||||
// Use all-day toggle hook
|
||||
const { handleAllDayToggle } = useAllDayToggle({
|
||||
allday,
|
||||
@@ -449,7 +462,12 @@ export default function EventFormFields({
|
||||
onStartTimeChange={handleStartTimeChange}
|
||||
onEndDateChange={handleEndDateChange}
|
||||
onEndTimeChange={handleEndTimeChange}
|
||||
showEndDate={showMore || allday}
|
||||
showEndDate={
|
||||
showMore ||
|
||||
allday ||
|
||||
(hasEndDateChanged && startDate !== endDate) ||
|
||||
(!showMore && !allday && startDate !== endDate)
|
||||
}
|
||||
onToggleEndDate={() => {}}
|
||||
/>
|
||||
</FieldWithLabel>
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import { TextFieldProps } from "@mui/material/TextField";
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import { DatePickerFieldProps } from "@mui/x-date-pickers/DatePicker";
|
||||
import { TimePicker } from "@mui/x-date-pickers/TimePicker";
|
||||
import { TimePickerFieldProps } from "@mui/x-date-pickers/TimePicker";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
@@ -16,6 +19,7 @@ import "dayjs/locale/vi";
|
||||
import { PickerValue } from "@mui/x-date-pickers/internals";
|
||||
import { dtDate, dtTime, toDateTime } from "../utils/dateTimeHelpers";
|
||||
import { ReadOnlyDateField } from "./ReadOnlyPickerField";
|
||||
import { EditableTimeField } from "./EditableTimeField";
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
@@ -88,15 +92,10 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
});
|
||||
}, [startDate, endDate, startTime, endTime, getCurrentDuration]);
|
||||
|
||||
const spansMultipleDays = React.useMemo(() => {
|
||||
return startDate !== endDate;
|
||||
}, [startDate, endDate]);
|
||||
|
||||
const isExpanded = showMore;
|
||||
const shouldShowEndDateNormal = allday || showEndDate;
|
||||
const shouldShowFullFieldsInNormal = isExpanded;
|
||||
const showSingleDateField =
|
||||
!isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal;
|
||||
const showSingleDateField = !isExpanded && !shouldShowEndDateNormal;
|
||||
const showFourFieldsNormal = !isExpanded && showEndDate && !allday;
|
||||
|
||||
const startDateLabel = showSingleDateField
|
||||
? t("dateTimeFields.date")
|
||||
@@ -204,10 +203,9 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
const newEnd = toDateTime(endDate, newTimeStr);
|
||||
const currentStart = toDateTime(startDate, startTime);
|
||||
|
||||
// Update duration when user changes end (if valid)
|
||||
if (!newEnd.isBefore(currentStart)) {
|
||||
initialDurationRef.current = newEnd.diff(currentStart, "minute");
|
||||
}
|
||||
// Always update duration ref to reflect current state, even if invalid (end < start)
|
||||
// This prevents stale duration from being used when user edits start time
|
||||
initialDurationRef.current = newEnd.diff(currentStart, "minute");
|
||||
|
||||
onEndTimeChange(newTimeStr);
|
||||
};
|
||||
@@ -253,7 +251,17 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
testId: string,
|
||||
hasError = false,
|
||||
testLabel?: string
|
||||
) => ({
|
||||
): Partial<DatePickerFieldProps | TimePickerFieldProps> &
|
||||
Pick<
|
||||
TextFieldProps,
|
||||
| "size"
|
||||
| "margin"
|
||||
| "fullWidth"
|
||||
| "InputLabelProps"
|
||||
| "error"
|
||||
| "sx"
|
||||
| "inputProps"
|
||||
> => ({
|
||||
size: "small" as const,
|
||||
margin: "dense" as const,
|
||||
fullWidth: true,
|
||||
@@ -266,33 +274,32 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
},
|
||||
});
|
||||
|
||||
const [isStartTimeOpen, setIsStartTimeOpen] = React.useState(false);
|
||||
const [isEndTimeOpen, setIsEndTimeOpen] = React.useState(false);
|
||||
|
||||
const handleTimeInputClick = (
|
||||
event: React.MouseEvent,
|
||||
setOpen: (open: boolean) => void
|
||||
) => {
|
||||
// Only open if clicking on the text (span)
|
||||
// In MUI X v6+, the sections are rendered as spans
|
||||
if ((event.target as HTMLElement).tagName === "SPAN") {
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTimeChangeWithClose = (value: PickerValue) => {
|
||||
setIsStartTimeOpen(false);
|
||||
setTimeout(() => {
|
||||
handleStartTimeChange(value);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleEndTimeChangeWithClose = (value: PickerValue) => {
|
||||
setIsEndTimeOpen(false);
|
||||
setTimeout(() => {
|
||||
handleEndTimeChange(value);
|
||||
}, 100);
|
||||
};
|
||||
const getTimeFieldSlotProps = (
|
||||
testId: string,
|
||||
hasError = false,
|
||||
testLabel?: string
|
||||
): Partial<TimePickerFieldProps> &
|
||||
Pick<
|
||||
TextFieldProps,
|
||||
| "size"
|
||||
| "margin"
|
||||
| "fullWidth"
|
||||
| "InputLabelProps"
|
||||
| "error"
|
||||
| "sx"
|
||||
| "inputProps"
|
||||
> => ({
|
||||
size: "small" as const,
|
||||
margin: "dense" as const,
|
||||
fullWidth: true,
|
||||
InputLabelProps: { shrink: true },
|
||||
error: hasError,
|
||||
sx: { width: "100%" },
|
||||
inputProps: {
|
||||
"data-testid": testId,
|
||||
...(testLabel ? { "aria-label": testLabel } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<LocalizationProvider
|
||||
@@ -309,7 +316,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
flexDirection="column"
|
||||
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
|
||||
>
|
||||
{shouldShowFullFieldsInNormal ? (
|
||||
{isExpanded || showFourFieldsNormal ? (
|
||||
<>
|
||||
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
|
||||
<Box sx={{ maxWidth: "300px", width: "48%" }}>
|
||||
@@ -328,7 +335,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
"start-date-input",
|
||||
false,
|
||||
t("dateTimeFields.startDate")
|
||||
) as any,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -337,21 +344,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
<TimePicker
|
||||
ampm={false}
|
||||
value={startTimeValue}
|
||||
onChange={handleStartTimeChangeWithClose}
|
||||
open={isStartTimeOpen}
|
||||
onClose={() => setIsStartTimeOpen(false)}
|
||||
onChange={handleStartTimeChange}
|
||||
thresholdToRenderTimeInASingleColumn={48}
|
||||
timeSteps={{ minutes: 30 }}
|
||||
slots={{ actionBar: () => null }}
|
||||
slots={{
|
||||
field: EditableTimeField,
|
||||
actionBar: () => null,
|
||||
}}
|
||||
slotProps={{
|
||||
...getSlotProps(
|
||||
"start-time-input",
|
||||
false,
|
||||
t("dateTimeFields.startTime")
|
||||
),
|
||||
openPickerButton: { sx: { display: "none" } },
|
||||
popper: {
|
||||
sx: {
|
||||
"& .MuiPaper-root": {
|
||||
width: "110px",
|
||||
minWidth: "110px",
|
||||
},
|
||||
"& .MuiMultiSectionDigitalClockSection-item": {
|
||||
justifyContent: "flex-start",
|
||||
width: "100%",
|
||||
@@ -359,20 +366,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
},
|
||||
},
|
||||
},
|
||||
textField: {
|
||||
...getSlotProps(
|
||||
"start-time-input",
|
||||
false,
|
||||
t("dateTimeFields.startTime")
|
||||
).textField,
|
||||
onClick: (e) =>
|
||||
handleTimeInputClick(e, setIsStartTimeOpen),
|
||||
sx: {
|
||||
"& .MuiPickersSectionList-section": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
},
|
||||
field: getTimeFieldSlotProps(
|
||||
"start-time-input",
|
||||
false,
|
||||
t("dateTimeFields.startTime")
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -395,7 +393,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
"end-date-input",
|
||||
!!validation.errors.dateTime,
|
||||
t("dateTimeFields.endDate")
|
||||
) as any,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -404,21 +402,21 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
<TimePicker
|
||||
ampm={false}
|
||||
value={endTimeValue}
|
||||
onChange={handleEndTimeChangeWithClose}
|
||||
open={isEndTimeOpen}
|
||||
onClose={() => setIsEndTimeOpen(false)}
|
||||
onChange={handleEndTimeChange}
|
||||
thresholdToRenderTimeInASingleColumn={48}
|
||||
timeSteps={{ minutes: 30 }}
|
||||
slots={{ actionBar: () => null }}
|
||||
slots={{
|
||||
field: EditableTimeField,
|
||||
actionBar: () => null,
|
||||
}}
|
||||
slotProps={{
|
||||
...getSlotProps(
|
||||
"end-time-input",
|
||||
!!validation.errors.dateTime,
|
||||
t("dateTimeFields.endTime")
|
||||
),
|
||||
openPickerButton: { sx: { display: "none" } },
|
||||
popper: {
|
||||
sx: {
|
||||
"& .MuiPaper-root": {
|
||||
width: "110px",
|
||||
minWidth: "110px",
|
||||
},
|
||||
"& .MuiMultiSectionDigitalClockSection-item": {
|
||||
justifyContent: "flex-start",
|
||||
width: "100%",
|
||||
@@ -426,20 +424,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
},
|
||||
},
|
||||
},
|
||||
textField: {
|
||||
...getSlotProps(
|
||||
"end-time-input",
|
||||
!!validation.errors.dateTime,
|
||||
t("dateTimeFields.endTime")
|
||||
).textField,
|
||||
onClick: (e) =>
|
||||
handleTimeInputClick(e, setIsEndTimeOpen),
|
||||
sx: {
|
||||
"& .MuiPickersSectionList-section": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
},
|
||||
field: getTimeFieldSlotProps(
|
||||
"end-time-input",
|
||||
!!validation.errors.dateTime,
|
||||
t("dateTimeFields.endTime")
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -464,7 +453,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
"start-date-input",
|
||||
false,
|
||||
t("dateTimeFields.startDate")
|
||||
) as any,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -484,7 +473,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
"end-date-input",
|
||||
!!validation.errors.dateTime,
|
||||
t("dateTimeFields.endDate")
|
||||
) as any,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -503,7 +492,7 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
"start-date-input",
|
||||
false,
|
||||
startDateLabel
|
||||
) as any,
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -511,22 +500,22 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
<TimePicker
|
||||
ampm={false}
|
||||
value={startTimeValue}
|
||||
onChange={handleStartTimeChangeWithClose}
|
||||
onChange={handleStartTimeChange}
|
||||
disabled={allday}
|
||||
open={isStartTimeOpen}
|
||||
onClose={() => setIsStartTimeOpen(false)}
|
||||
thresholdToRenderTimeInASingleColumn={48}
|
||||
timeSteps={{ minutes: 30 }}
|
||||
slots={{ actionBar: () => null }}
|
||||
slots={{
|
||||
field: EditableTimeField,
|
||||
actionBar: () => null,
|
||||
}}
|
||||
slotProps={{
|
||||
...getSlotProps(
|
||||
"start-time-input",
|
||||
false,
|
||||
t("dateTimeFields.startTime")
|
||||
),
|
||||
openPickerButton: { sx: { display: "none" } },
|
||||
popper: {
|
||||
sx: {
|
||||
"& .MuiPaper-root": {
|
||||
width: "110px",
|
||||
minWidth: "110px",
|
||||
},
|
||||
"& .MuiMultiSectionDigitalClockSection-item": {
|
||||
justifyContent: "flex-start",
|
||||
width: "100%",
|
||||
@@ -534,19 +523,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
},
|
||||
},
|
||||
},
|
||||
textField: {
|
||||
...getSlotProps(
|
||||
"start-time-input",
|
||||
false,
|
||||
t("dateTimeFields.startTime")
|
||||
).textField,
|
||||
onClick: (e) => handleTimeInputClick(e, setIsStartTimeOpen),
|
||||
sx: {
|
||||
"& .MuiPickersSectionList-section": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
},
|
||||
field: getTimeFieldSlotProps(
|
||||
"start-time-input",
|
||||
false,
|
||||
t("dateTimeFields.startTime")
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -565,35 +546,22 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
<TimePicker
|
||||
ampm={false}
|
||||
value={endTimeValue}
|
||||
onChange={handleEndTimeChangeWithClose}
|
||||
onChange={handleEndTimeChange}
|
||||
disabled={allday}
|
||||
open={isEndTimeOpen}
|
||||
onClose={() => setIsEndTimeOpen(false)}
|
||||
thresholdToRenderTimeInASingleColumn={48}
|
||||
timeSteps={{ minutes: 30 }}
|
||||
slots={{ actionBar: () => null }}
|
||||
slots={{
|
||||
field: EditableTimeField,
|
||||
actionBar: () => null,
|
||||
}}
|
||||
slotProps={{
|
||||
textField: {
|
||||
size: "small",
|
||||
margin: "dense" as const,
|
||||
fullWidth: true,
|
||||
InputLabelProps: { shrink: true },
|
||||
error: !!validation.errors.dateTime,
|
||||
inputProps: {
|
||||
"data-testid": "end-time-input",
|
||||
"aria-label": t("dateTimeFields.endTime"),
|
||||
},
|
||||
onClick: (e) => handleTimeInputClick(e, setIsEndTimeOpen),
|
||||
sx: {
|
||||
width: "100%",
|
||||
"& .MuiPickersSectionList-section": {
|
||||
cursor: "pointer",
|
||||
},
|
||||
},
|
||||
},
|
||||
openPickerButton: { sx: { display: "none" } },
|
||||
popper: {
|
||||
sx: {
|
||||
"& .MuiPaper-root": {
|
||||
width: "110px",
|
||||
minWidth: "110px",
|
||||
},
|
||||
"& .MuiMultiSectionDigitalClockSection-item": {
|
||||
justifyContent: "flex-start",
|
||||
width: "100%",
|
||||
@@ -601,6 +569,11 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
},
|
||||
},
|
||||
},
|
||||
field: getTimeFieldSlotProps(
|
||||
"end-time-input",
|
||||
!!validation.errors.dateTime,
|
||||
t("dateTimeFields.endTime")
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import React, { useState, useRef, useEffect, useCallback } from "react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import {
|
||||
PickerFieldAdapter,
|
||||
PickerValidationScope,
|
||||
useValidation,
|
||||
validateTime,
|
||||
} from "@mui/x-date-pickers/validation";
|
||||
import {
|
||||
useSplitFieldProps,
|
||||
useParsedFormat,
|
||||
usePickerContext,
|
||||
usePickerActionsContext,
|
||||
} from "@mui/x-date-pickers/hooks";
|
||||
import { PickerFieldProps } from "@mui/x-date-pickers/models";
|
||||
import { TimePickerFieldProps } from "@mui/x-date-pickers/TimePicker";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import { parseTimeInput } from "../utils/dateTimeHelpers";
|
||||
|
||||
type FieldType = "date" | "time" | "date-time";
|
||||
|
||||
type GenericPickerFieldProps = PickerFieldProps<any, any, any> & {
|
||||
fieldType: FieldType;
|
||||
validator: (
|
||||
value: any,
|
||||
context: PickerValidationScope,
|
||||
adapter: PickerFieldAdapter<any>
|
||||
) => string | null;
|
||||
};
|
||||
|
||||
const TIME_DISPLAY_FORMAT = "HH:mm";
|
||||
|
||||
/**
|
||||
* Editable field for time pickers. Allows free typing with format on blur/enter.
|
||||
* Click anywhere in the field to open picker.
|
||||
*/
|
||||
function EditableTimePickerField(props: GenericPickerFieldProps) {
|
||||
const { fieldType, validator, ...fieldProps } = props;
|
||||
const { internalProps, forwardedProps } = useSplitFieldProps(
|
||||
fieldProps,
|
||||
fieldType
|
||||
);
|
||||
|
||||
const pickerContext = usePickerContext();
|
||||
const pickerActions = usePickerActionsContext();
|
||||
const parsedFormat = useParsedFormat();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Get onChange from internalProps to call directly when value changes
|
||||
const onChange = internalProps.onChange as
|
||||
| ((value: Dayjs | null) => void)
|
||||
| undefined;
|
||||
|
||||
const getFormattedValue = useCallback(() => {
|
||||
if (pickerContext.value == null) return "";
|
||||
if (!pickerContext.value.isValid()) return "";
|
||||
return pickerContext.value.format(TIME_DISPLAY_FORMAT);
|
||||
}, [pickerContext.value]);
|
||||
|
||||
const [inputValue, setInputValue] = useState(getFormattedValue);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [pendingCommitValue, setPendingCommitValue] = useState<string | null>(
|
||||
null
|
||||
);
|
||||
const prevFormattedValueRef = useRef(getFormattedValue());
|
||||
const isDispatchingCloseEventRef = useRef(false);
|
||||
const hasDispatchedInMicrotaskRef = useRef(false);
|
||||
|
||||
// Sync input value when picker value changes from dropdown selection or external
|
||||
useEffect(() => {
|
||||
const newFormattedValue = getFormattedValue();
|
||||
const valueChanged = newFormattedValue !== prevFormattedValueRef.current;
|
||||
|
||||
// Sync when:
|
||||
// 1. Value changed (from dropdown selection or external)
|
||||
// 2. Not focused (external change)
|
||||
if (valueChanged || !isFocused) {
|
||||
setInputValue(newFormattedValue);
|
||||
}
|
||||
|
||||
// Close dropdown after selection (value changed while dropdown is open)
|
||||
if (valueChanged && pickerContext.open) {
|
||||
pickerContext.setOpen(false);
|
||||
// Clear focus after dropdown selection
|
||||
setIsFocused(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
|
||||
prevFormattedValueRef.current = newFormattedValue;
|
||||
}, [getFormattedValue, isFocused, pickerContext]);
|
||||
|
||||
const wasOpenRef = useRef(pickerContext.open);
|
||||
|
||||
// Handle dropdown open/close
|
||||
useEffect(() => {
|
||||
const wasOpen = wasOpenRef.current;
|
||||
const isOpen = pickerContext.open;
|
||||
|
||||
if (isOpen && !wasOpen && inputRef.current) {
|
||||
// Dropdown just opened - refocus input to allow typing
|
||||
const timer = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 10);
|
||||
wasOpenRef.current = isOpen;
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!isOpen && wasOpen) {
|
||||
// Dropdown just closed - check if we need to commit user input
|
||||
const current = getFormattedValue();
|
||||
if (isFocused && inputValue.trim() && inputValue !== current) {
|
||||
// Store input value to commit later (after parseAndUpdateTime is defined)
|
||||
setPendingCommitValue(inputValue);
|
||||
} else {
|
||||
setInputValue(current);
|
||||
}
|
||||
setIsFocused(false);
|
||||
// Blur input to clear DOM focus (removes Mui-focused class)
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
|
||||
wasOpenRef.current = isOpen;
|
||||
}, [pickerContext.open, getFormattedValue, isFocused, inputValue]);
|
||||
|
||||
const { hasValidationError } = useValidation({
|
||||
validator,
|
||||
value: pickerContext.value,
|
||||
timezone: pickerContext.timezone,
|
||||
props: internalProps,
|
||||
});
|
||||
|
||||
const parseAndUpdateTime = useCallback(
|
||||
(value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setInputValue(getFormattedValue());
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = parseTimeInput(
|
||||
trimmed,
|
||||
pickerContext.value as Dayjs | null
|
||||
);
|
||||
|
||||
if (newValue) {
|
||||
// Update picker internal state and trigger onChange in one call
|
||||
pickerActions.setValue(newValue, { changeImportance: "accept" });
|
||||
setInputValue(newValue.format(TIME_DISPLAY_FORMAT));
|
||||
} else {
|
||||
// Invalid input - reset to current value
|
||||
setInputValue(getFormattedValue());
|
||||
}
|
||||
},
|
||||
[pickerContext, getFormattedValue, onChange]
|
||||
);
|
||||
|
||||
// Commit pending input value when picker closes (after parseAndUpdateTime is defined)
|
||||
useEffect(() => {
|
||||
if (pendingCommitValue !== null) {
|
||||
parseAndUpdateTime(pendingCommitValue);
|
||||
setPendingCommitValue(null);
|
||||
}
|
||||
}, [pendingCommitValue, parseAndUpdateTime]);
|
||||
|
||||
// Listen for close events from other picker fields
|
||||
useEffect(() => {
|
||||
const handleCloseOtherPickers = () => {
|
||||
// Don't close if this field is the one dispatching the event
|
||||
if (isDispatchingCloseEventRef.current) {
|
||||
return;
|
||||
}
|
||||
// If this picker is open and another field is requesting to close others
|
||||
if (pickerContext.open) {
|
||||
pickerContext.setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"close-other-time-pickers",
|
||||
handleCloseOtherPickers
|
||||
);
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"close-other-time-pickers",
|
||||
handleCloseOtherPickers
|
||||
);
|
||||
};
|
||||
}, [pickerContext]);
|
||||
|
||||
const dispatchCloseOtherPickers = useCallback(() => {
|
||||
// Guard: prevent duplicate dispatch in the same microtask
|
||||
if (hasDispatchedInMicrotaskRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark that we've dispatched in this microtask
|
||||
hasDispatchedInMicrotaskRef.current = true;
|
||||
|
||||
// Set flag to prevent this field from closing its own picker
|
||||
isDispatchingCloseEventRef.current = true;
|
||||
// Notify other pickers to close
|
||||
window.dispatchEvent(new CustomEvent("close-other-time-pickers"));
|
||||
// Reset flags in next microtask to ensure event is processed first
|
||||
Promise.resolve().then(() => {
|
||||
isDispatchingCloseEventRef.current = false;
|
||||
hasDispatchedInMicrotaskRef.current = false;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
if (forwardedProps.disabled || forwardedProps.readOnly) return;
|
||||
|
||||
// Stop propagation to prevent parent from toggling the picker
|
||||
e.stopPropagation();
|
||||
|
||||
dispatchCloseOtherPickers();
|
||||
|
||||
// Always keep it open when clicked, or open it if closed
|
||||
if (!pickerContext.open) {
|
||||
pickerContext.setOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
if (forwardedProps.disabled || forwardedProps.readOnly) return;
|
||||
|
||||
setIsFocused(true);
|
||||
dispatchCloseOtherPickers();
|
||||
};
|
||||
|
||||
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
// If dropdown is open, don't parse input
|
||||
// MUI will handle selection and sync value via useEffect
|
||||
if (pickerContext.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Dropdown is closed - parse input and update value
|
||||
setIsFocused(false);
|
||||
parseAndUpdateTime(inputValue);
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
parseAndUpdateTime(inputValue);
|
||||
setIsFocused(false);
|
||||
pickerContext.setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
} else if (e.key === "Escape") {
|
||||
setInputValue(getFormattedValue());
|
||||
setIsFocused(false);
|
||||
pickerContext.setOpen(false);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
const mergedInputProps = {
|
||||
...forwardedProps.InputProps,
|
||||
ref: pickerContext.triggerRef,
|
||||
sx: {
|
||||
cursor: "text",
|
||||
...forwardedProps.InputProps?.sx,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<TextField
|
||||
{...forwardedProps}
|
||||
value={inputValue}
|
||||
onChange={handleChange}
|
||||
placeholder={parsedFormat as string}
|
||||
InputProps={mergedInputProps}
|
||||
inputRef={inputRef}
|
||||
error={hasValidationError || forwardedProps.error}
|
||||
focused={isFocused}
|
||||
onClick={handleClick}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={pickerContext.rootClassName}
|
||||
sx={{
|
||||
...pickerContext.rootSx,
|
||||
"& input": {
|
||||
textAlign: "center",
|
||||
},
|
||||
}}
|
||||
ref={pickerContext.rootRef}
|
||||
name={pickerContext.name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditableTimeField(props: TimePickerFieldProps) {
|
||||
return (
|
||||
<EditableTimePickerField
|
||||
{...props}
|
||||
fieldType="time"
|
||||
validator={validateTime}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import moment from "moment-timezone";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
/**
|
||||
* Helper functions for date/time string manipulation
|
||||
@@ -9,6 +12,8 @@ export const DATETIME_WITH_SECONDS_LENGTH = 19;
|
||||
export const DATETIME_FORMAT_WITH_SECONDS = "YYYY-MM-DDTHH:mm:ss";
|
||||
export const DATETIME_FORMAT_WITHOUT_SECONDS = "YYYY-MM-DDTHH:mm";
|
||||
|
||||
export const TIME_PARSE_FORMATS = ["HH:mm", "H:mm", "HHmm", "Hmm", "HH", "H"];
|
||||
|
||||
/**
|
||||
* Detect datetime format based on string length
|
||||
* @param datetime - Datetime string to analyze
|
||||
@@ -83,3 +88,35 @@ export const dtDate = (d: Dayjs) => d.format("YYYY-MM-DD");
|
||||
|
||||
/** Extract time “HH:mm” */
|
||||
export const dtTime = (d: Dayjs) => d.format("HH:mm");
|
||||
|
||||
/**
|
||||
* Parse flexible time string input into Dayjs object
|
||||
* Handles formats like: "HH:mm", "HHmm", "Hmm", "HH", "H"
|
||||
* Also handles 3-digit input normalization (e.g. "830" -> "08:30")
|
||||
*/
|
||||
export function parseTimeInput(
|
||||
value: string,
|
||||
currentDate: Dayjs | null
|
||||
): Dayjs | null {
|
||||
let trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize 3-digit input (e.g. "830" → "0830" → 08:30, "123" → "0123" → 01:23)
|
||||
if (/^\d{3}$/.test(trimmed)) {
|
||||
trimmed = trimmed.padStart(4, "0");
|
||||
}
|
||||
|
||||
const parsed = dayjs(trimmed, TIME_PARSE_FORMATS, true);
|
||||
if (parsed.isValid()) {
|
||||
const baseDate = currentDate || dayjs();
|
||||
return baseDate
|
||||
.hour(parsed.hour())
|
||||
.minute(parsed.minute())
|
||||
.second(0)
|
||||
.millisecond(0);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -45,9 +45,12 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
|
||||
let dateTimeError = "";
|
||||
|
||||
// Determine which fields are visible based on UI mode
|
||||
const showFullFields = showMore || hasEndDateChanged;
|
||||
const showFullFields =
|
||||
showMore ||
|
||||
allday ||
|
||||
hasEndDateChanged ||
|
||||
(!showMore && !allday && startDate !== endDate);
|
||||
const showTimeOnly = !allday && !showFullFields;
|
||||
const showDateOnly = allday;
|
||||
|
||||
// Validate start date
|
||||
if (!startDate || startDate.trim() === "") {
|
||||
@@ -123,29 +126,6 @@ export function validateEventForm(params: ValidationParams): ValidationResult {
|
||||
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);
|
||||
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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = isDateTimeValid;
|
||||
|
||||
@@ -758,16 +758,17 @@ function EventPopover({
|
||||
} else {
|
||||
newEvent.start = convertFormDateTimeToISO(start, timezone);
|
||||
if (end) {
|
||||
// In normal mode, only override end date when the end date field is not shown
|
||||
if (!showMore && !hasEndDateChanged) {
|
||||
const startDateOnly = (start || "").split("T")[0];
|
||||
// In normal mode, only override end date when the end date field is not shown and end date is same as start date
|
||||
const startDateOnly = (start || "").split("T")[0];
|
||||
const endDateOnly = (end || "").split("T")[0];
|
||||
if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
|
||||
const endTimeOnly = end.includes("T")
|
||||
? end.split("T")[1]?.slice(0, 5) || "00:00"
|
||||
: "00:00";
|
||||
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
|
||||
newEvent.end = convertFormDateTimeToISO(endDateTime, timezone);
|
||||
} else {
|
||||
// Extended mode or end date explicitly shown in normal mode: use actual end datetime
|
||||
// Extended mode or end date explicitly shown in normal mode or end date differs from start date: use actual end datetime
|
||||
newEvent.end = convertFormDateTimeToISO(end, timezone);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,16 +562,17 @@ function EventUpdateModal({
|
||||
} else {
|
||||
// For timed events
|
||||
startDate = convertFormDateTimeToISO(start, timezone);
|
||||
// In normal mode, only override end date when the end date field is not shown
|
||||
if (!showMore && !hasEndDateChanged) {
|
||||
const startDateOnly = (start || "").split("T")[0];
|
||||
// In normal mode, only override end date when the end date field is not shown and end date is same as start date
|
||||
const startDateOnly = (start || "").split("T")[0];
|
||||
const endDateOnly = (end || "").split("T")[0];
|
||||
if (!showMore && !hasEndDateChanged && startDateOnly === endDateOnly) {
|
||||
const endTimeOnly = end.includes("T")
|
||||
? end.split("T")[1]?.slice(0, 5) || "00:00"
|
||||
: "00:00";
|
||||
const endDateTime = `${startDateOnly}T${endTimeOnly}`;
|
||||
endDate = convertFormDateTimeToISO(endDateTime, timezone);
|
||||
} else {
|
||||
// Extended mode: use actual end datetime
|
||||
// Extended mode or end date explicitly shown in normal mode or end date differs from start date: use actual end datetime
|
||||
endDate = convertFormDateTimeToISO(end, timezone);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user