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:
lenhanphung
2025-10-28 15:57:47 +07:00
committed by Benoit TELLIER
parent 5078128e10
commit 61358c081a
4 changed files with 187 additions and 62 deletions
+48 -1
View File
@@ -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>
+16 -10
View File
@@ -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(
+62
View File
@@ -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);
}