Files
workavia-calendar-front/src/components/Event/EventFormFields.tsx
T
Camille Moussu d6e464afad [#421] added eslint check to CI (#534)
Co-authored-by: Camille Moussu <cmoussu@linagora.com>
2026-02-10 10:41:37 +01:00

899 lines
27 KiB
TypeScript

import { Calendar } from "@/features/Calendars/CalendarTypes";
import { RepetitionObject } from "@/features/Events/EventsTypes";
import { userAttendee } from "@/features/User/models/attendee";
import iconCamera from "@/static/images/icon-camera.svg";
import {
addVideoConferenceToDescription,
generateMeetingLink,
removeVideoConferenceFromDescription,
} from "@/utils/videoConferenceUtils";
import {
Box,
Button,
Checkbox,
FormControl,
FormControlLabel,
IconButton,
MenuItem,
Select,
SelectChangeEvent,
TextField,
ToggleButton,
ToggleButtonGroup,
Typography,
} from "@linagora/twake-mui";
import {
Close as DeleteIcon,
ContentCopy as CopyIcon,
LocationOn as LocationIcon,
Public as PublicIcon,
} from "@mui/icons-material";
import LockOutlineIcon from "@mui/icons-material/LockOutline";
import SquareRoundedIcon from "@mui/icons-material/SquareRounded";
import React from "react";
import { useI18n } from "twake-i18n";
import AttendeeSelector from "../Attendees/AttendeeSearch";
import { CalendarItemList } from "../Calendar/CalendarItemList";
import { SnackbarAlert } from "../Loading/SnackBarAlert";
import { TimezoneAutocomplete } from "../Timezone/TimezoneAutocomplete";
import { AddDescButton } from "./AddDescButton";
import { DateTimeFields } from "./components/DateTimeFields";
import { DateTimeSummary } from "./components/DateTimeSummary";
import { FieldWithLabel } from "./components/FieldWithLabel";
import { SectionPreviewRow } from "./components/SectionPreviewRow";
import RepeatEvent from "./EventRepeat";
import { useAllDayToggle } from "./hooks/useAllDayToggle";
import { combineDateTime, splitDateTime } from "./utils/dateTimeHelpers";
import { validateEventForm } from "./utils/formValidation";
interface EventFormFieldsProps {
// Form state
title: string;
setTitle: (title: string) => void;
description: string;
setDescription: (description: string) => void;
location: string;
setLocation: (location: string) => void;
start: string;
setStart: (start: string) => void;
end: string;
setEnd: (end: string) => void;
allday: boolean;
setAllDay: (allday: boolean) => void;
repetition: RepetitionObject;
setRepetition: (repetition: RepetitionObject) => void;
typeOfAction?: "solo" | "all";
attendees: userAttendee[];
setAttendees: (attendees: userAttendee[]) => void;
alarm: string;
setAlarm: (alarm: string) => void;
busy: string;
setBusy: (busy: string) => void;
eventClass: string;
setEventClass: (eventClass: string) => void;
timezone: string;
setTimezone: (timezone: string) => void;
calendarid: string;
setCalendarid: (calendarid: string) => void;
hasVideoConference: boolean;
setHasVideoConference: (hasVideoConference: boolean) => void;
meetingLink: string | null;
setMeetingLink: (meetingLink: string | null) => void;
// UI state
showMore: boolean;
showDescription: boolean;
setShowDescription: (showDescription: boolean) => void;
showRepeat: boolean;
setShowRepeat: (showRepeat: boolean) => void;
isOpen?: boolean;
// Data
userPersonalCalendars: Calendar[];
timezoneList: {
zones: string[];
browserTz: string;
getTimezoneOffset: (tzName: string, date: Date) => string;
};
// Event handlers
onStartChange?: (newStart: string) => void;
onEndChange?: (newEnd: string) => void;
onAllDayChange?: (
newAllDay: boolean,
newStart: string,
newEnd: string
) => void;
onCalendarChange?: (newCalendarId: string) => void;
// Validation
onValidationChange?: (isValid: boolean) => void;
showValidationErrors?: boolean;
onHasEndDateChangedChange?: (has: boolean) => void;
}
export default function EventFormFields({
title,
setTitle,
description,
setDescription,
location,
setLocation,
start,
setStart,
end,
setEnd,
allday,
setAllDay,
repetition,
setRepetition,
typeOfAction,
attendees,
setAttendees,
alarm,
setAlarm,
busy,
setBusy,
eventClass,
setEventClass,
timezone,
setTimezone,
calendarid,
setCalendarid,
hasVideoConference,
setHasVideoConference,
meetingLink,
setMeetingLink,
showMore,
showDescription,
setShowDescription,
showRepeat,
setShowRepeat,
isOpen = false,
userPersonalCalendars,
timezoneList,
onStartChange,
onEndChange,
onAllDayChange,
onCalendarChange,
onValidationChange,
showValidationErrors = false,
onHasEndDateChangedChange,
}: EventFormFieldsProps) {
const { t } = useI18n();
// Internal state for 4 separate fields
const [startDate, setStartDate] = React.useState("");
const [startTime, setStartTime] = React.useState("");
const [endDate, setEndDate] = React.useState("");
const [endTime, setEndTime] = React.useState("");
// Track if user has manually changed end date in extended mode
const [hasEndDateChanged, setHasEndDateChanged] = React.useState(false);
// Track if user has clicked on datetime clickable section in normal mode
// Once clicked, normal mode will always show full fields until modal closes
const [hasClickedDateTimeSection, setHasClickedDateTimeSection] =
React.useState(false);
// Track if user has clicked on location section in normal mode
const [hasClickedLocationSection, setHasClickedLocationSection] =
React.useState(false);
// Track if user has clicked on calendar section in normal mode
const [hasClickedCalendarSection, setHasClickedCalendarSection] =
React.useState(false);
// Reset hasEndDateChanged and hasClickedDateTimeSection when modal closes
React.useEffect(() => {
if (!isOpen) {
setHasEndDateChanged(false);
setHasClickedDateTimeSection(false);
setHasClickedLocationSection(false);
setHasClickedCalendarSection(false);
}
}, [isOpen]);
// Notify parent about end date change visibility state
React.useEffect(() => {
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,
start,
end,
startDate,
startTime,
endDate,
endTime,
setStartTime,
setEndTime,
setStart,
setEnd,
setAllDay,
onAllDayChange,
});
// Ref for title input field to enable auto-focus
const titleInputRef = React.useRef<HTMLInputElement>(null);
const locationInputRef = React.useRef<HTMLInputElement>(null);
// Track previous showMore state to detect changes
const prevShowMoreRef = React.useRef<boolean | undefined>(undefined);
// Focus location field when user clicks the location preview row
React.useEffect(() => {
if (hasClickedLocationSection && process.env.NODE_ENV !== "test") {
locationInputRef.current?.focus();
}
}, [hasClickedLocationSection]);
// Auto-focus title field when modal opens (skip in test environment)
React.useEffect(() => {
if (isOpen) {
if (titleInputRef.current && process.env.NODE_ENV !== "test") {
// Use setTimeout to ensure the dialog is fully rendered
const timer = setTimeout(() => {
titleInputRef.current?.focus();
}, 100);
return () => clearTimeout(timer);
}
}
}, [isOpen]);
// Auto-focus title field when toggling between normal and extended mode
React.useEffect(() => {
// Skip on initial render (when prevShowMoreRef is undefined)
if (
prevShowMoreRef.current !== undefined &&
isOpen &&
process.env.NODE_ENV !== "test"
) {
const hasChanged = prevShowMoreRef.current !== showMore;
if (hasChanged) {
// Simple setTimeout approach with sufficient delay for layout changes
const timer = setTimeout(() => {
if (titleInputRef.current) {
titleInputRef.current.focus();
}
}, 150);
// Update previous value before returning cleanup
prevShowMoreRef.current = showMore;
return () => clearTimeout(timer);
}
}
// Always update previous value
prevShowMoreRef.current = showMore;
}, [showMore, isOpen]);
// Sync start prop to startDate/startTime
React.useEffect(() => {
if (start) {
const { date, time } = splitDateTime(start);
setStartDate(date);
setStartTime(time);
}
}, [start]);
// Sync end prop to endDate/endTime
React.useEffect(() => {
if (end) {
const { date, time } = splitDateTime(end);
setEndDate(date);
setEndTime(time);
}
}, [end]);
// Change handlers for 4 separate fields
const handleStartDateChange = React.useCallback(
(newDate: string, newTime?: string) => {
setStartDate(newDate);
const newStart = combineDateTime(newDate, newTime ? newTime : startTime);
if (onStartChange) {
onStartChange(newStart);
} else {
setStart(newStart);
}
},
[startTime, onStartChange, setStart]
);
const handleStartTimeChange = React.useCallback(
(newTime: string, newDate?: string) => {
setStartTime(newTime);
const newStart = combineDateTime(newDate ? newDate : startDate, newTime);
if (onStartChange) {
onStartChange(newStart);
} else {
setStart(newStart);
}
},
[startDate, onStartChange, setStart]
);
const handleEndDateChange = React.useCallback(
(newDate: string, newTime?: string) => {
setEndDate(newDate);
// Track if user changed end date in extended mode
if (showMore) {
setHasEndDateChanged(true);
}
const newEnd = combineDateTime(newDate, newTime ? newTime : endTime);
if (onEndChange) {
onEndChange(newEnd);
} else {
setEnd(newEnd);
}
},
[endTime, onEndChange, setEnd, showMore]
);
const handleEndTimeChange = React.useCallback(
(newTime: string, newDate?: string) => {
setEndTime(newTime);
const newEnd = combineDateTime(newDate ? newDate : endDate, newTime);
if (onEndChange) {
onEndChange(newEnd);
} else {
setEnd(newEnd);
}
},
[endDate, onEndChange, setEnd]
);
// Validation logic
const validateForm = React.useCallback(() => {
return validateEventForm({
startDate,
startTime,
endDate,
endTime,
allday,
showValidationErrors,
hasEndDateChanged,
showMore,
});
}, [
startDate,
startTime,
endDate,
endTime,
allday,
showValidationErrors,
hasEndDateChanged,
showMore,
]);
// Notify parent about validation changes
React.useEffect(() => {
const validation = validateForm();
onValidationChange?.(validation.isValid);
}, [validateForm, onValidationChange]);
// 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 = () => {
const newMeetingLink = generateMeetingLink();
const updatedDescription = addVideoConferenceToDescription(
description,
newMeetingLink
);
setDescription(updatedDescription);
setHasVideoConference(true);
setMeetingLink(newMeetingLink);
if (showMore) {
setShowDescription(true);
}
};
const [openToast, setOpenToast] = React.useState(false);
const handleCopyMeetingLink = async () => {
if (meetingLink) {
try {
await navigator.clipboard.writeText(meetingLink);
setOpenToast(true);
} catch (err) {
console.error("Failed to copy link:", err);
}
}
};
const handleDeleteVideoConference = () => {
setDescription(removeVideoConferenceFromDescription(description));
setHasVideoConference(false);
setMeetingLink(null);
};
const handleCalendarChange = (newCalendarId: string) => {
setCalendarid(newCalendarId);
onCalendarChange?.(newCalendarId);
};
return (
<>
<FieldWithLabel
label={showMore ? t("event.form.title") : ""}
isExpanded={showMore}
>
<TextField
fullWidth
label=""
inputProps={{ "aria-label": t("event.form.title") }}
placeholder={t("event.form.titlePlaceholder")}
value={title}
onChange={(e) => {
setTitle(e.target.value);
}}
size="small"
margin="dense"
inputRef={titleInputRef}
/>
</FieldWithLabel>
<FieldWithLabel
label={
!showMore && !hasClickedDateTimeSection
? ""
: t("event.form.dateTime")
}
isExpanded={showMore}
>
{!showMore && !hasClickedDateTimeSection ? (
<DateTimeSummary
startDate={startDate}
startTime={startTime}
endDate={endDate}
endTime={endTime}
allday={allday}
timezone={timezone}
repetition={repetition}
showEndDate={
allday ||
(hasEndDateChanged && startDate !== endDate) ||
(!allday && startDate !== endDate)
}
onClick={() => setHasClickedDateTimeSection(true)}
/>
) : (
<DateTimeFields
startDate={startDate}
startTime={startTime}
endDate={endDate}
endTime={endTime}
allday={allday}
showMore={showMore}
hasEndDateChanged={hasEndDateChanged}
validation={validation}
onStartDateChange={handleStartDateChange}
onStartTimeChange={handleStartTimeChange}
onEndDateChange={handleEndDateChange}
onEndTimeChange={handleEndTimeChange}
showEndDate={
showMore ||
allday ||
(hasEndDateChanged && startDate !== endDate) ||
(!showMore && !allday && startDate !== endDate)
}
onToggleEndDate={() => {}}
/>
)}
</FieldWithLabel>
{!(!showMore && !hasClickedDateTimeSection) && (
<FieldWithLabel label=" " isExpanded={showMore}>
<Box display="flex" gap={2} alignItems="center">
<FormControlLabel
control={
<Checkbox checked={allday} onChange={handleAllDayToggle} />
}
label={
<Typography variant="h6">{t("event.form.allDay")}</Typography>
}
/>
<FormControlLabel
control={
<Checkbox
checked={
showRepeat ||
(typeOfAction === "solo" && !!repetition?.freq)
}
disabled={typeOfAction === "solo"}
onChange={() => {
const newShowRepeat = !showRepeat;
setShowRepeat(newShowRepeat);
if (newShowRepeat) {
setRepetition({
freq: "daily",
interval: 1,
occurrences: 0,
endDate: "",
byday: null,
} as RepetitionObject);
} else {
setRepetition({
freq: "",
interval: 1,
occurrences: 0,
endDate: "",
byday: null,
} as RepetitionObject);
}
}}
/>
}
label={
<Typography variant="h6">{t("event.form.repeat")}</Typography>
}
/>
<TimezoneAutocomplete
value={timezone}
onChange={setTimezone}
zones={timezoneList.zones}
getTimezoneOffset={(tzName: string) =>
timezoneList.getTimezoneOffset(tzName, new Date(start))
}
showIcon={false}
width={220}
size="small"
placeholder={t("event.form.timezonePlaceholder")}
/>
</Box>
</FieldWithLabel>
)}
{!(!showMore && !hasClickedDateTimeSection) &&
(showRepeat || (typeOfAction === "solo" && repetition?.freq)) && (
<FieldWithLabel label=" " isExpanded={showMore}>
<RepeatEvent
repetition={repetition}
eventStart={new Date(start)}
setRepetition={setRepetition}
isOwn={typeOfAction !== "solo"}
/>
</FieldWithLabel>
)}
<FieldWithLabel
label={showMore ? t("event.form.participants") : ""}
isExpanded={showMore}
>
<AttendeeSelector
attendees={attendees}
setAttendees={setAttendees}
placeholder={t("event.form.addGuestsPlaceholder")}
inputSlot={(params) => <TextField {...params} size="small" />}
/>
</FieldWithLabel>
<FieldWithLabel
label={showMore ? t("event.form.videoMeeting") : ""}
isExpanded={showMore}
>
{!showMore ? (
hasVideoConference && meetingLink ? (
<Box display="flex" gap={1} alignItems="center">
<Button
startIcon={
<img src={iconCamera} alt="camera" width={24} height={24} />
}
onClick={() =>
window.open(meetingLink, "_blank", "noopener,noreferrer")
}
size="medium"
variant="contained"
color="primary"
sx={{ borderRadius: "4px", mr: 1 }}
>
{t("event.form.joinVisioConference")}
</Button>
<IconButton
onClick={handleCopyMeetingLink}
size="small"
sx={{ color: "primary.main" }}
aria-label={t("event.form.copyMeetingLink")}
title={t("event.form.copyMeetingLink")}
>
<CopyIcon />
</IconButton>
<IconButton
onClick={handleDeleteVideoConference}
size="small"
sx={{ color: "error.main" }}
aria-label={t("event.form.removeVideoConference")}
title={t("event.form.removeVideoConference")}
>
<DeleteIcon />
</IconButton>
</Box>
) : (
<SectionPreviewRow
icon={
<img src={iconCamera} alt="camera" width={24} height={24} />
}
onClick={handleAddVideoConference}
>
{t("event.form.addVisioConference")}
</SectionPreviewRow>
)
) : (
<Box display="flex" gap={1} alignItems="center">
<Button
startIcon={
<img src={iconCamera} alt="camera" width={24} height={24} />
}
onClick={handleAddVideoConference}
size="medium"
variant="contained"
color="secondary"
sx={{
borderRadius: "4px",
display: hasVideoConference ? "none" : "flex",
}}
>
{t("event.form.addVisioConference")}
</Button>
{hasVideoConference && meetingLink && (
<>
<Button
startIcon={
<img src={iconCamera} alt="camera" width={24} height={24} />
}
onClick={() =>
window.open(meetingLink, "_blank", "noopener,noreferrer")
}
size="medium"
variant="contained"
color="primary"
sx={{
borderRadius: "4px",
mr: 1,
}}
>
{t("event.form.joinVisioConference")}
</Button>
<IconButton
onClick={handleCopyMeetingLink}
size="small"
sx={{ color: "primary.main" }}
aria-label={t("event.form.copyMeetingLink")}
title={t("event.form.copyMeetingLink")}
>
<CopyIcon />
</IconButton>
<IconButton
onClick={handleDeleteVideoConference}
size="small"
sx={{ color: "error.main" }}
aria-label={t("event.form.removeVideoConference")}
title={t("event.form.removeVideoConference")}
>
<DeleteIcon />
</IconButton>
</>
)}
</Box>
)}
</FieldWithLabel>
<AddDescButton
showDescription={showDescription}
setShowDescription={setShowDescription}
showMore={showMore}
description={description}
setDescription={setDescription}
/>
<FieldWithLabel
label={
showMore || hasClickedLocationSection ? t("event.form.location") : ""
}
isExpanded={showMore}
>
{!showMore && !hasClickedLocationSection ? (
<SectionPreviewRow
icon={<LocationIcon />}
onClick={() => setHasClickedLocationSection(true)}
>
{location || t("event.form.locationPlaceholder")}
</SectionPreviewRow>
) : (
<TextField
fullWidth
label=""
inputRef={locationInputRef}
inputProps={{ "aria-label": t("event.form.location") }}
placeholder={t("event.form.locationPlaceholder")}
value={location}
onChange={(e) => setLocation(e.target.value)}
size="small"
margin="dense"
/>
)}
</FieldWithLabel>
<FieldWithLabel
label={
showMore || hasClickedCalendarSection ? t("event.form.calendar") : ""
}
isExpanded={showMore}
>
{!showMore && !hasClickedCalendarSection ? (
<SectionPreviewRow
icon={
<SquareRoundedIcon
sx={{
color:
userPersonalCalendars.find((cal) => cal.id === calendarid)
?.color?.light ?? "#3788D8",
width: 24,
height: 24,
}}
/>
}
onClick={() => setHasClickedCalendarSection(true)}
>
{userPersonalCalendars.find((cal) => cal.id === calendarid)?.name ||
t("event.form.calendar")}
</SectionPreviewRow>
) : (
<FormControl fullWidth margin="dense" size="small">
<Select
value={calendarid ?? ""}
label=""
SelectDisplayProps={{ "aria-label": t("event.form.calendar") }}
displayEmpty
onChange={(e: SelectChangeEvent) =>
handleCalendarChange(e.target.value)
}
>
{CalendarItemList(userPersonalCalendars)}
</Select>
</FormControl>
)}
</FieldWithLabel>
{showMore && (
<>
<FieldWithLabel
label={t("event.form.notification")}
isExpanded={showMore}
>
<FormControl fullWidth margin="dense" size="small">
<Select
labelId="notification"
value={alarm}
displayEmpty
onChange={(e: SelectChangeEvent) => setAlarm(e.target.value)}
>
<MenuItem value="">{t("event.form.notifications.")}</MenuItem>
<MenuItem value="-PT1M">
{t("event.form.notifications.-PT1M")}
</MenuItem>
<MenuItem value="-PT5M">
{t("event.form.notifications.-PT5M")}
</MenuItem>
<MenuItem value="-PT10M">
{t("event.form.notifications.-PT10M")}
</MenuItem>
<MenuItem value="-PT15M">
{t("event.form.notifications.-PT15M")}
</MenuItem>
<MenuItem value="-PT30M">
{t("event.form.notifications.-PT30M")}
</MenuItem>
<MenuItem value="-PT1H">
{t("event.form.notifications.-PT1H")}
</MenuItem>
<MenuItem value="-PT2H">
{t("event.form.notifications.-PT2H")}
</MenuItem>
<MenuItem value="-PT5H">
{t("event.form.notifications.-PT5H")}
</MenuItem>
<MenuItem value="-PT12H">
{t("event.form.notifications.-PT12H")}
</MenuItem>
<MenuItem value="-PT1D">
{t("event.form.notifications.-PT1D")}
</MenuItem>
<MenuItem value="-PT2D">
{t("event.form.notifications.-PT2D")}
</MenuItem>
<MenuItem value="-PT1W">
{t("event.form.notifications.-PT1W")}
</MenuItem>
</Select>
</FormControl>
</FieldWithLabel>
<FieldWithLabel
label={t("event.form.showMeAs")}
isExpanded={showMore}
>
<FormControl fullWidth margin="dense" size="small">
<Select
labelId="busy"
value={busy}
onChange={(e: SelectChangeEvent) => setBusy(e.target.value)}
>
<MenuItem value={"TRANSPARENT"}>
{t("event.form.free")}
</MenuItem>
<MenuItem value={"OPAQUE"}>{t("event.form.busy")}</MenuItem>
</Select>
</FormControl>
</FieldWithLabel>
<FieldWithLabel
label={t("event.form.visibleTo")}
isExpanded={showMore}
>
<ToggleButtonGroup
value={eventClass}
exclusive
onChange={(e, newValue) => {
if (newValue !== null) {
setEventClass(newValue);
}
}}
size="medium"
>
<ToggleButton value="PUBLIC" sx={{ minWidth: "160px" }}>
<PublicIcon sx={{ mr: 1 }} />
{t("event.form.visibleAll")}
</ToggleButton>
<ToggleButton value="PRIVATE" sx={{ minWidth: "160px" }}>
<LockOutlineIcon sx={{ mr: 1 }} />
{t("event.form.visibleParticipants")}
</ToggleButton>
</ToggleButtonGroup>
</FieldWithLabel>
</>
)}
<SnackbarAlert
setOpen={setOpenToast}
open={openToast}
message={t("event.form.meetCopied")}
/>
</>
);
}