feat(ui): replace end date field with '...' button toggle
- Hide end date by default; reveal via '...' button in both modes - Keep state/validation/API logic unchanged - Normalize labels to use MUI TextField labels across modes - Align button with fields; constrain widths for expanded mode
This commit is contained in:
committed by
Benoit TELLIER
parent
5078128e10
commit
61358c081a
@@ -38,6 +38,7 @@ import { FieldWithLabel } from "./components/FieldWithLabel";
|
||||
import { DateTimeFields } from "./components/DateTimeFields";
|
||||
import { useAllDayToggle } from "./hooks/useAllDayToggle";
|
||||
import { splitDateTime, combineDateTime } from "./utils/dateTimeHelpers";
|
||||
import { getEndDateForStartChange } from "./utils/dateRules";
|
||||
import {
|
||||
formatLocalDateTime,
|
||||
formatDateTimeInTimezone,
|
||||
@@ -163,6 +164,9 @@ export default function EventFormFields({
|
||||
const [endDate, setEndDate] = React.useState("");
|
||||
const [endTime, setEndTime] = React.useState("");
|
||||
|
||||
// UI state for showing/hiding end date field
|
||||
const [showEndDate, setShowEndDate] = React.useState(false);
|
||||
|
||||
// Use all-day toggle hook
|
||||
const { originalTimeRef, handleAllDayToggle } = useAllDayToggle({
|
||||
allday,
|
||||
@@ -268,13 +272,25 @@ export default function EventFormFields({
|
||||
(newDate: string) => {
|
||||
setStartDate(newDate);
|
||||
const newStart = combineDateTime(newDate, startTime);
|
||||
|
||||
// Update start
|
||||
if (onStartChange) {
|
||||
onStartChange(newStart);
|
||||
} else {
|
||||
setStart(newStart);
|
||||
}
|
||||
|
||||
// Rule: update end date when start date changes
|
||||
const nextEndDate = getEndDateForStartChange(newDate, allday);
|
||||
setEndDate(nextEndDate);
|
||||
const newEnd = combineDateTime(nextEndDate, endTime || startTime);
|
||||
if (onEndChange) {
|
||||
onEndChange(newEnd);
|
||||
} else {
|
||||
setEnd(newEnd);
|
||||
}
|
||||
},
|
||||
[startTime, onStartChange, setStart]
|
||||
[startTime, endTime, onStartChange, onEndChange, setStart, setEnd, allday]
|
||||
);
|
||||
|
||||
const handleStartTimeChange = React.useCallback(
|
||||
@@ -343,6 +359,35 @@ export default function EventFormFields({
|
||||
onValidationChange?.(validation.isValid);
|
||||
}, [validateForm, onValidationChange]);
|
||||
|
||||
// Auto-calculate end date from start date if not already set
|
||||
React.useEffect(() => {
|
||||
if (startDate && !endDate) {
|
||||
setEndDate(startDate);
|
||||
const newEnd = combineDateTime(startDate, endTime || startTime);
|
||||
if (onEndChange) {
|
||||
onEndChange(newEnd);
|
||||
} else {
|
||||
setEnd(newEnd);
|
||||
}
|
||||
}
|
||||
}, [startDate, endDate, startTime, endTime, onEndChange, setEnd]);
|
||||
|
||||
// Auto-calculate end time from start time (+ 1 hour) if not already set
|
||||
React.useEffect(() => {
|
||||
if (startTime && !endTime && !allday) {
|
||||
const [hours, minutes] = startTime.split(":");
|
||||
const endHour = (parseInt(hours) + 1) % 24;
|
||||
const calculatedEndTime = `${endHour.toString().padStart(2, "0")}:${minutes}`;
|
||||
setEndTime(calculatedEndTime);
|
||||
const newEnd = combineDateTime(endDate || startDate, calculatedEndTime);
|
||||
if (onEndChange) {
|
||||
onEndChange(newEnd);
|
||||
} else {
|
||||
setEnd(newEnd);
|
||||
}
|
||||
}
|
||||
}, [startTime, endTime, allday, endDate, startDate, onEndChange, setEnd]);
|
||||
|
||||
const validation = validateForm();
|
||||
|
||||
const handleAddVideoConference = () => {
|
||||
@@ -465,6 +510,8 @@ export default function EventFormFields({
|
||||
onStartTimeChange={handleStartTimeChange}
|
||||
onEndDateChange={handleEndDateChange}
|
||||
onEndTimeChange={handleEndTimeChange}
|
||||
showEndDate={showEndDate}
|
||||
onToggleEndDate={() => setShowEndDate(!showEndDate)}
|
||||
/>
|
||||
</FieldWithLabel>
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from "react";
|
||||
import { Box, Typography } from "@mui/material";
|
||||
import { Box, IconButton, Tooltip, Typography } from "@mui/material";
|
||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||
import { TimePicker } from "@mui/x-date-pickers/TimePicker";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers/LocalizationProvider";
|
||||
import { AdapterMoment } from "@mui/x-date-pickers/AdapterMoment";
|
||||
import MoreHorizIcon from "@mui/icons-material/MoreHoriz";
|
||||
import moment, { Moment } from "moment";
|
||||
|
||||
/**
|
||||
@@ -16,6 +17,8 @@ export interface DateTimeFieldsProps {
|
||||
endTime: string;
|
||||
allday: boolean;
|
||||
showMore: boolean;
|
||||
showEndDate: boolean;
|
||||
onToggleEndDate: () => void;
|
||||
validation: {
|
||||
errors: {
|
||||
dateTime: string;
|
||||
@@ -38,6 +41,8 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
endTime,
|
||||
allday,
|
||||
showMore,
|
||||
showEndDate,
|
||||
onToggleEndDate,
|
||||
validation,
|
||||
onStartDateChange,
|
||||
onStartTimeChange,
|
||||
@@ -46,18 +51,18 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterMoment} adapterLocale="en">
|
||||
<Box display="flex" gap={1} flexDirection="column">
|
||||
<Box
|
||||
display="flex"
|
||||
gap={1}
|
||||
flexDirection="column"
|
||||
sx={{ maxWidth: showMore ? "calc(100% - 145px)" : "100%" }}
|
||||
>
|
||||
{/* First row: 4 fields */}
|
||||
<Box display="flex" gap={1} flexDirection="row" alignItems="flex-start">
|
||||
<Box display="flex" gap={1} flexDirection="row" alignItems="center">
|
||||
{/* Start Date - 30% */}
|
||||
<Box sx={{ flexGrow: 0.3, flexBasis: "25%", minWidth: 0 }}>
|
||||
{showMore && (
|
||||
<Typography variant="caption" display="block" mb={0.5}>
|
||||
Start Date
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ flexGrow: 0.3, flexBasis: "25%", maxWidth: "150px" }}>
|
||||
<DatePicker
|
||||
label={!showMore ? "Start Date" : ""}
|
||||
label="Start Date"
|
||||
value={startDate ? moment(startDate) : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onStartDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||
@@ -68,22 +73,17 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
margin: "dense" as const,
|
||||
fullWidth: true,
|
||||
InputLabelProps: { shrink: true },
|
||||
"data-testid": "start-date-input",
|
||||
sx: { width: "100%" },
|
||||
inputProps: { "data-testid": "start-date-input" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Start Time - 20% */}
|
||||
<Box sx={{ flexGrow: 0.2, flexBasis: "25%", minWidth: 0 }}>
|
||||
{showMore && (
|
||||
<Typography variant="caption" display="block" mb={0.5}>
|
||||
Start Time
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ flexGrow: 0.2, flexBasis: "25%", maxWidth: "150px" }}>
|
||||
<TimePicker
|
||||
label={!showMore ? "Start Time" : ""}
|
||||
label="Start Time"
|
||||
value={startTime ? moment(startTime, "HH:mm") : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onStartTimeChange(newValue?.format("HH:mm") || "");
|
||||
@@ -95,22 +95,17 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
margin: "dense" as const,
|
||||
fullWidth: true,
|
||||
InputLabelProps: { shrink: true },
|
||||
"data-testid": "start-time-input",
|
||||
sx: { width: "100%" },
|
||||
inputProps: { "data-testid": "start-time-input" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* End Time - 20% */}
|
||||
<Box sx={{ flexGrow: 0.2, flexBasis: "25%", minWidth: 0 }}>
|
||||
{showMore && (
|
||||
<Typography variant="caption" display="block" mb={0.5}>
|
||||
End Time
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ flexGrow: 0.2, flexBasis: "25%", maxWidth: "150px" }}>
|
||||
<TimePicker
|
||||
label={!showMore ? "End Time" : ""}
|
||||
label="End Time"
|
||||
value={endTime ? moment(endTime, "HH:mm") : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onEndTimeChange(newValue?.format("HH:mm") || "");
|
||||
@@ -123,38 +118,53 @@ export const DateTimeFields: React.FC<DateTimeFieldsProps> = ({
|
||||
fullWidth: true,
|
||||
InputLabelProps: { shrink: true },
|
||||
error: !!validation.errors.dateTime,
|
||||
"data-testid": "end-time-input",
|
||||
sx: { width: "100%" },
|
||||
inputProps: { "data-testid": "end-time-input" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* End Date - 30% */}
|
||||
<Box sx={{ flexGrow: 0.3, flexBasis: "25%", minWidth: 0 }}>
|
||||
{showMore && (
|
||||
<Typography variant="caption" display="block" mb={0.5}>
|
||||
End Date
|
||||
</Typography>
|
||||
{/* End Date - Conditional rendering */}
|
||||
<Box sx={{ flexGrow: 0.3, flexBasis: "25%", maxWidth: "150px" }}>
|
||||
{!showEndDate ? (
|
||||
// Show "..." button to reveal end date
|
||||
<Box
|
||||
display="flex"
|
||||
justifyContent="flex-start"
|
||||
sx={{ mt: showMore ? 0 : 0 }}
|
||||
>
|
||||
<Tooltip title="Show end date">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onToggleEndDate}
|
||||
aria-label="Show end date"
|
||||
>
|
||||
<MoreHorizIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
) : (
|
||||
// Show End Date picker (no hide button)
|
||||
<DatePicker
|
||||
label="End Date"
|
||||
value={endDate ? moment(endDate) : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onEndDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||
}}
|
||||
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" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DatePicker
|
||||
label={!showMore ? "End Date" : ""}
|
||||
value={endDate ? moment(endDate) : null}
|
||||
onChange={(newValue: Moment | null) => {
|
||||
onEndDateChange(newValue?.format("YYYY-MM-DD") || "");
|
||||
}}
|
||||
slotProps={{
|
||||
textField: {
|
||||
size: "small",
|
||||
margin: "dense" as const,
|
||||
fullWidth: true,
|
||||
InputLabelProps: { shrink: true },
|
||||
error: !!validation.errors.dateTime,
|
||||
"data-testid": "end-date-input",
|
||||
sx: { width: "100%" },
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { combineDateTime } from "../utils/dateTimeHelpers";
|
||||
import { getEndDateForToggle } from "../utils/dateRules";
|
||||
import { getRoundedCurrentTime } from "../utils/dateTimeFormatters";
|
||||
|
||||
/**
|
||||
@@ -86,15 +87,15 @@ export function useAllDayToggle(
|
||||
setStartTime("");
|
||||
setEndTime("");
|
||||
newStart = startDate;
|
||||
newEnd = endDate;
|
||||
|
||||
// If same day, extend end to next day
|
||||
if (startDate === endDate) {
|
||||
const nextDay = new Date(endDate);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
newEnd = nextDay.toISOString().split("T")[0];
|
||||
setEndDate(newEnd); // Update internal endDate state
|
||||
}
|
||||
const nextEndDate = getEndDateForToggle({
|
||||
nextAllDay: true,
|
||||
fromAllDaySlot: !!originalTimeRef.current?.fromAllDaySlot,
|
||||
startDate,
|
||||
previousEndDate: endDate,
|
||||
originalEndDate: originalTimeRef.current?.endDate,
|
||||
});
|
||||
newEnd = nextEndDate;
|
||||
setEndDate(nextEndDate);
|
||||
} else {
|
||||
// ON => OFF: Restore original time AND original end date
|
||||
if (originalTimeRef.current) {
|
||||
@@ -123,7 +124,12 @@ export function useAllDayToggle(
|
||||
setEndDate(startDate); // Set endDate = startDate
|
||||
} else {
|
||||
// Normal case: restore original time AND original end date
|
||||
const restoredEndDate = originalTimeRef.current.endDate || endDate;
|
||||
const restoredEndDate = getEndDateForToggle({
|
||||
nextAllDay: false,
|
||||
startDate,
|
||||
previousEndDate: endDate,
|
||||
originalEndDate: originalTimeRef.current.endDate,
|
||||
});
|
||||
|
||||
newStart = combineDateTime(startDate, originalTimeRef.current.start);
|
||||
newEnd = combineDateTime(
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// Date rules helpers to normalize end date/time behavior
|
||||
|
||||
import { combineDateTime } from "./dateTimeHelpers";
|
||||
|
||||
/** Adds a number of days to a YYYY-MM-DD string and returns YYYY-MM-DD */
|
||||
export function addDays(dateStr: string, days: number): string {
|
||||
const d = new Date(dateStr);
|
||||
d.setDate(d.getDate() + days);
|
||||
return d.toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute endDate (YYYY-MM-DD) when startDate changes
|
||||
* - If all-day: endDate = startDate + 1 day
|
||||
* - Else: endDate = startDate
|
||||
*/
|
||||
export function getEndDateForStartChange(
|
||||
startDate: string,
|
||||
isAllDay: boolean
|
||||
): string {
|
||||
if (!startDate) return "";
|
||||
return isAllDay ? addDays(startDate, 1) : startDate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute new start/end (ISO-like strings) when toggling all-day
|
||||
* - nextAllDay = true:
|
||||
* - If fromAllDaySlot: endDate = startDate
|
||||
* - Else: endDate = startDate + 1 day
|
||||
* - Times are cleared (date-only)
|
||||
* - nextAllDay = false:
|
||||
* - end restored from originalEndDate if any, else previousEndDate, else start
|
||||
*/
|
||||
export function getEndDateForToggle(params: {
|
||||
nextAllDay: boolean;
|
||||
fromAllDaySlot?: boolean;
|
||||
startDate: string;
|
||||
previousEndDate: string;
|
||||
originalEndDate?: string;
|
||||
}): string {
|
||||
const {
|
||||
nextAllDay,
|
||||
fromAllDaySlot,
|
||||
startDate,
|
||||
previousEndDate,
|
||||
originalEndDate,
|
||||
} = params;
|
||||
if (nextAllDay) {
|
||||
return fromAllDaySlot ? startDate : addDays(startDate, 1);
|
||||
}
|
||||
return originalEndDate || previousEndDate || startDate;
|
||||
}
|
||||
|
||||
/** Utility to combine date with a fallback time (HH:mm) safely */
|
||||
export function combineWithFallback(
|
||||
dateStr: string,
|
||||
timeHHmm: string | undefined,
|
||||
fallbackTime: string
|
||||
): string {
|
||||
const time = timeHHmm && timeHHmm.trim() ? timeHHmm : fallbackTime;
|
||||
return combineDateTime(dateStr, time);
|
||||
}
|
||||
Reference in New Issue
Block a user