diff --git a/src/components/Event/EventFormFields.tsx b/src/components/Event/EventFormFields.tsx index 19cdff2..03c80c6 100644 --- a/src/components/Event/EventFormFields.tsx +++ b/src/components/Event/EventFormFields.tsx @@ -1,128 +1,23 @@ -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 { defaultColors } from '@/utils/defaultColors' -import { makeDisplayName } from '@/utils/makeDisplayName' -import { renameDefault } from '@/utils/renameDefault' -import { - addVideoConferenceToDescription, - generateMeetingLink, - removeVideoConferenceFromDescription -} from '@/utils/videoConferenceUtils' -import { - Box, - Button, - Checkbox, - Divider, - 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 { TextField } from '@linagora/twake-mui' +import { useRef } from 'react' import { useI18n } from 'twake-i18n' import AttendeeSelector from '../Attendees/AttendeeSearch' -import { CalendarItemList } from '../Calendar/CalendarItemList' -import { OwnerCaption } from '../Calendar/OwnerCaption' -import { SnackbarAlert } from '../Loading/SnackBarAlert' -import { TimezoneAutocomplete } from '../Timezone/TimezoneAutocomplete' -import { AddDescButton } from './AddDescButton' -import { DateTimeFields } from './components/DateTimeFields/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' -import { Resource, ResourceSearch } from '../Attendees/ResourceSearch' +import { AddDescButton } from './AddDescButton' import { useScreenSizeDetection } from '@/useScreenSizeDetection' -import { SmallTimezoneSelector } from '../Timezone/SmallTimeZoneSelector' -import ArrowDropDown from '@mui/icons-material/ArrowDropDown' -import { getTimezoneOffset } from '@/utils/timezone' +import { CalendarSelectField } from './fields/CalendarSelectField' +import { EventDateTimeField } from './fields/EventDateTimeField' +import { VideoConferenceField } from './fields/VideoConferenceField' +import { EventFormFieldsExpanded } from './components/EventFormFieldsExpaned' +import { useAutoFocusTitle } from './hooks/useAutoFocusTitle' +import LocationField from './fields/LocationField' +import { EventFormFieldsProps } from './EventFormFields.types' -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: 'PUBLIC' | 'PRIVATE' | 'CONFIDENTIAL' - setEventClass: (eventClass: 'PUBLIC' | 'PRIVATE' | 'CONFIDENTIAL') => 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 - selectedResources: Resource[] - setSelectedResources: (resources: Resource[]) => 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 +const showInputLabel = (showMore: boolean, label: string): string => { + if (showMore) { + return label } - eventId?: string | null - - // 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 + return '' } export default function EventFormFields({ @@ -179,330 +74,22 @@ export default function EventFormFields({ const { t } = useI18n() const { isTooSmall: isMobile } = useScreenSizeDetection() - const [timezoneDrawerOpen, setTimezoneDrawerOpen] = React.useState(false) + const titleInputRef = useRef(null) - // 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) - - // Track whether the calendar Select dropdown is open - const [calendarSelectOpen, setCalendarSelectOpen] = React.useState(false) - - const delegatedCalendars = userPersonalCalendars.filter(cal => cal.delegated) - const personalCalendars = userPersonalCalendars.filter(cal => !cal.delegated) - const selectedCalendar = userPersonalCalendars.find( - cal => cal.id === calendarid - ) - const selectedOwnerDisplayName = selectedCalendar - ? (makeDisplayName(selectedCalendar) ?? '') - : '' - const isSelectedDelegated = !!selectedCalendar?.delegated - - // Reset all click-tracking state when modal closes - React.useEffect(() => { - if (!isOpen) { - setHasEndDateChanged(false) - setHasClickedDateTimeSection(false) - setHasClickedLocationSection(false) - setHasClickedCalendarSection(false) - setCalendarSelectOpen(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]) - - // Auto-open the calendar Select once it is mounted after clicking the preview row - React.useEffect(() => { - if (hasClickedCalendarSection) { - setCalendarSelectOpen(true) - } - }, [hasClickedCalendarSection]) - - // Use all-day toggle hook - const { handleAllDayToggle } = useAllDayToggle({ - allday, - start, - end, - startDate, - startTime, - endDate, - endTime, - setStartTime, - setEndTime, - setStart, - setEnd, - setAllDay, - onAllDayChange + useAutoFocusTitle({ + isOpen, + eventId, + titleInputRef, + showMore }) - // Ref for title input field to enable auto-focus - const titleInputRef = React.useRef(null) - const locationInputRef = React.useRef(null) - - // Track previous showMore state to detect changes - const prevShowMoreRef = React.useRef(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 && !eventId) { - 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 (): void => clearTimeout(timer) - } - } - }, [isOpen, eventId]) - - // 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 (): void => 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 = (): void => { - 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 (): Promise => { - if (meetingLink) { - try { - await navigator.clipboard.writeText(meetingLink) - setOpenToast(true) - } catch (err) { - console.error('Failed to copy link:', err) - } - } - } - - const handleDeleteVideoConference = (): void => { - setDescription(removeVideoConferenceFromDescription(description)) - setHasVideoConference(false) - setMeetingLink(null) - } - - const handleCalendarChange = (newCalendarId: string): void => { - setCalendarid(newCalendarId) - onCalendarChange?.(newCalendarId) - } - - const handleResourceChange = (resources: Resource[]): void => { - setSelectedResources(resources) - } - - const offset = getTimezoneOffset( - timezone ? timezone : timezoneList.browserTz, - new Date(start) - ) - const tzLabel = timezone - ? timezone.split('/').pop()?.replace(/_/g, ' ') - : timezoneList.browserTz + const isExpanded = showMore && !isMobile return ( <> + {/* DateTime, All-day, Timezone, Repeat */} + + + {/* Attendees */} - {!showMore && !hasClickedDateTimeSection ? ( - setHasClickedDateTimeSection(true)} - /> - ) : ( - {}} - /> - )} - - - {!(!showMore && !hasClickedDateTimeSection) && ( - - - - - } - label={ - {t('event.form.allDay')} - } - /> - { - const newShowRepeat = !showRepeat - setShowRepeat(newShowRepeat) - if (newShowRepeat) { - const days = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] - const eventStartDate = new Date(start) - const jsDay = eventStartDate.getDay() - const icsDay = days[(jsDay + 6) % 7] - setRepetition({ - freq: 'weekly', - interval: 1, - occurrences: 0, - endDate: '', - byday: [icsDay] - } as RepetitionObject) - } else { - setRepetition({ - freq: '', - interval: 1, - occurrences: 0, - endDate: '', - byday: null - } as RepetitionObject) - } - }} - /> - } - label={ - {t('event.form.repeat')} - } - /> - - {isMobile ? ( - <> - - setTimezoneDrawerOpen(false)} - value={timezone} - onChange={tz => { - setTimezone(tz) - setTimezoneDrawerOpen(false) - }} - referenceDate={new Date(start)} - /> - - ) : ( - - timezoneList.getTimezoneOffset(tzName, new Date(start)) - } - showIcon={false} - width={220} - size="small" - placeholder={t('event.form.timezonePlaceholder')} - hideBorder - inputPadding="8px 65px 8px 0px" - /> - )} - - - )} - - {!(!showMore && !hasClickedDateTimeSection) && - (showRepeat || (typeOfAction === 'solo' && repetition?.freq)) && ( - - - - )} - - - - {!showMore ? ( - hasVideoConference && meetingLink ? ( - - - void handleCopyMeetingLink()} - size="small" - sx={{ color: 'primary.main' }} - aria-label={t('event.form.copyMeetingLink')} - title={t('event.form.copyMeetingLink')} - > - - - - - - - ) : ( - - } - onClick={handleAddVideoConference} - > - {t('event.form.addVisioConference')} - - ) - ) : ( - - - - {hasVideoConference && meetingLink && ( - <> - - void handleCopyMeetingLink()} - size="small" - sx={{ color: 'primary.main' }} - aria-label={t('event.form.copyMeetingLink')} - title={t('event.form.copyMeetingLink')} - > - - - - - - - )} - - )} - + {/* Video conference */} + + {/* Description toggle */} - - {!showMore && !hasClickedLocationSection ? ( - } - onClick={() => setHasClickedLocationSection(true)} - > - {location || t('event.form.locationPlaceholder')} - - ) : ( - setLocation(e.target.value)} - size={isMobile ? 'medium' : 'small'} - margin="dense" - /> - )} - + {/* Location */} + - - {!showMore && !hasClickedCalendarSection ? ( - cal.id === calendarid) - ?.color?.light ?? defaultColors[0].light, - width: 24, - height: 24 - }} - /> - } - onClick={() => setHasClickedCalendarSection(true)} - > - {selectedCalendar?.name ? ( - - - {renameDefault( - selectedCalendar.name, - selectedOwnerDisplayName, - t, - !isSelectedDelegated - )} - - - - ) : ( - t('event.form.calendar') - )} - - ) : ( - - - - )} - + {/* Calendar selector */} + - {showMore && ( - <> - - - ( - - )} - onChange={(_event: React.SyntheticEvent, value: Resource[]) => { - handleResourceChange(value) - }} - hideLabel={true} - /> - - - - - - - - - - - - - - - - - { - if (newValue !== null) { - setEventClass(newValue) - } - }} - size="medium" - > - - - {t('event.form.visibleAll')} - - - - {t('event.form.visibleParticipants')} - - - - - )} - ) diff --git a/src/components/Event/EventFormFields.types.ts b/src/components/Event/EventFormFields.types.ts new file mode 100644 index 0000000..5b3993b --- /dev/null +++ b/src/components/Event/EventFormFields.types.ts @@ -0,0 +1,73 @@ +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { RepetitionObject } from '@/features/Events/EventsTypes' +import { userAttendee } from '@/features/User/models/attendee' +import { Resource } from '../Attendees/ResourceSearch' + +export 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: 'PUBLIC' | 'PRIVATE' | 'CONFIDENTIAL' + setEventClass: (eventClass: 'PUBLIC' | 'PRIVATE' | 'CONFIDENTIAL') => 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 + selectedResources: Resource[] + setSelectedResources: (resources: Resource[]) => 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 + } + eventId?: string | null + + // 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 +} diff --git a/src/components/Event/components/DateTimeFields/DateTimeExpanded.tsx b/src/components/Event/components/DateTimeFields/DateTimeExpanded.tsx new file mode 100644 index 0000000..66a5414 --- /dev/null +++ b/src/components/Event/components/DateTimeFields/DateTimeExpanded.tsx @@ -0,0 +1,379 @@ +import { RepetitionObject } from '@/features/Events/EventsTypes' +import { getTimezoneOffset } from '@/utils/timezone' +import { + Box, + Button, + Checkbox, + FormControlLabel, + Typography +} from '@linagora/twake-mui' +import ArrowDropDown from '@mui/icons-material/ArrowDropDown' +import React, { useCallback, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { DateTimeFields } from './DateTimeFields' +import { FieldWithLabel } from '../FieldWithLabel' +import { useAllDayToggle } from '../../hooks/useAllDayToggle' +import { combineDateTime } from '../../utils/dateTimeHelpers' +import { validateEventForm } from '../../utils/formValidation' +import type { + EventDateTimeFieldProps, + TimezoneListResult +} from '../../fields/DateTimeField.types' +import { TimezoneAutocomplete } from '@/components/Timezone/TimezoneAutocomplete' +import { SmallTimezoneSelector } from '@/components/Timezone/SmallTimeZoneSelector' +import { DateTimeRepeatPanel } from './DateTimeSubPanels' + +interface DateTimeControlsRowProps { + allday: boolean + handleAllDayToggle: () => void + showRepeat: boolean + setShowRepeat: (value: boolean) => void + typeOfAction?: 'solo' | 'all' + repetition: RepetitionObject + setRepetition: (value: RepetitionObject) => void + start: string + timezone: string + setTimezone: (value: string) => void + timezoneList: TimezoneListResult + showMore: boolean + offset: string + tzLabel: string | undefined +} + +const DateTimeControlsRow: React.FC = ({ + allday, + handleAllDayToggle, + showRepeat, + setShowRepeat, + typeOfAction, + repetition, + setRepetition, + start, + timezone, + setTimezone, + timezoneList, + showMore, + offset, + tzLabel +}) => { + const { t } = useI18n() + const { isTooSmall: isMobile } = useScreenSizeDetection() + const [timezoneDrawerOpen, setTimezoneDrawerOpen] = useState(false) + + const handleRepeatToggle = (): void => { + const newShowRepeat = !showRepeat + setShowRepeat(newShowRepeat) + if (newShowRepeat) { + const days = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'] + const eventStartDate = new Date(start) + const jsDay = eventStartDate.getDay() + const icsDay = days[(jsDay + 6) % 7] + setRepetition({ + freq: 'weekly', + interval: 1, + occurrences: 0, + endDate: '', + byday: [icsDay] + } as RepetitionObject) + } else { + setRepetition({ + freq: '', + interval: 1, + occurrences: 0, + endDate: '', + byday: null + } as RepetitionObject) + } + } + + return ( + + + + + } + label={ + {t('event.form.allDay')} + } + /> + + } + label={ + {t('event.form.repeat')} + } + /> + + + {/* Timezone selector */} + {isMobile ? ( + <> + + setTimezoneDrawerOpen(false)} + value={timezone} + onChange={tz => { + setTimezone(tz) + setTimezoneDrawerOpen(false) + }} + referenceDate={new Date(start)} + /> + + ) : ( + + timezoneList.getTimezoneOffset(tzName, new Date(start)) + } + showIcon={false} + width={220} + size="small" + placeholder={t('event.form.timezonePlaceholder')} + hideBorder + inputPadding="8px 65px 8px 0px" + /> + )} + + + ) +} + +export const DateTimeExpanded: React.FC< + Pick< + EventDateTimeFieldProps, + | 'start' + | 'setStart' + | 'end' + | 'setEnd' + | 'allday' + | 'setAllDay' + | 'timezone' + | 'setTimezone' + | 'repetition' + | 'setRepetition' + | 'showRepeat' + | 'setShowRepeat' + | 'showMore' + | 'showValidationErrors' + | 'timezoneList' + | 'typeOfAction' + | 'onStartChange' + | 'onEndChange' + | 'onAllDayChange' + > & { + startDate: string + startTime: string + endDate: string + endTime: string + hasEndDateChanged: boolean + setStartTime: (time: string) => void + setEndTime: (time: string) => void + setStartDate: (date: string) => void + setEndDate: (date: string) => void + setHasEndDateChanged: (isEndDateChanged: boolean) => void + } +> = ({ + start, + setStart, + end, + setEnd, + allday, + setAllDay, + timezone, + setTimezone, + repetition, + setRepetition, + showRepeat, + setShowRepeat, + showMore, + hasEndDateChanged, + setHasEndDateChanged, + showValidationErrors, + timezoneList, + typeOfAction, + onStartChange, + onEndChange, + onAllDayChange, + startDate, + startTime, + endDate, + endTime, + setStartTime, + setEndTime, + setStartDate, + setEndDate +}) => { + const { handleAllDayToggle } = useAllDayToggle({ + allday, + start, + end, + startDate, + startTime, + endDate, + endTime, + setStartTime, + setEndTime, + setStart, + setEnd, + setAllDay, + onAllDayChange + }) + + const validation = validateEventForm({ + startDate, + startTime, + endDate, + endTime, + allday, + showValidationErrors, + hasEndDateChanged, + showMore + }) + + const isMultiDayRange = startDate !== endDate + const endDateExplicitlySet = hasEndDateChanged && isMultiDayRange + const endDateImplicitlyVisible = !allday && isMultiDayRange + const showEndDate = + showMore || allday || endDateExplicitlySet || endDateImplicitlyVisible + + const offset = getTimezoneOffset( + timezone ? timezone : timezoneList.browserTz, + new Date(start) + ) + const tzLabel = timezone + ? timezone.split('/').pop()?.replace(/_/g, ' ') + : timezoneList.browserTz + + const repeatIsActive = + showRepeat || (typeOfAction === 'solo' && Boolean(repetition?.freq)) + + const handleStartDateChange = useCallback( + (newDate: string, newTime?: string) => { + setStartDate(newDate) + const newStart = combineDateTime(newDate, newTime ?? startTime) + if (onStartChange) { + onStartChange(newStart) + } else { + setStart(newStart) + } + }, + [setStartDate, startTime, onStartChange, setStart] + ) + + const handleStartTimeChange = useCallback( + (newTime: string, newDate?: string) => { + setStartTime(newTime) + const newStart = combineDateTime(newDate ?? startDate, newTime) + if (onStartChange) { + onStartChange(newStart) + } else { + setStart(newStart) + } + }, + [setStartTime, startDate, onStartChange, setStart] + ) + + const handleEndDateChange = useCallback( + (newDate: string, newTime?: string) => { + setEndDate(newDate) + if (showMore) setHasEndDateChanged(true) + const newEnd = combineDateTime(newDate, newTime ?? endTime) + if (onEndChange) { + onEndChange(newEnd) + } else { + setEnd(newEnd) + } + }, + [setEndDate, showMore, setHasEndDateChanged, endTime, onEndChange, setEnd] + ) + + const handleEndTimeChange = useCallback( + (newTime: string, newDate?: string) => { + setEndTime(newTime) + const newEnd = combineDateTime(newDate ?? endDate, newTime) + if (onEndChange) { + onEndChange(newEnd) + } else { + setEnd(newEnd) + } + }, + [endDate, onEndChange, setEnd, setEndTime] + ) + + return ( + <> + {}} + /> + + {/* All-day, Repeat checkboxes + Timezone selector row */} + + + {repeatIsActive && ( + + )} + + ) +} diff --git a/src/components/Event/components/DateTimeFields/DateTimeSubPanels.tsx b/src/components/Event/components/DateTimeFields/DateTimeSubPanels.tsx new file mode 100644 index 0000000..c5a5c73 --- /dev/null +++ b/src/components/Event/components/DateTimeFields/DateTimeSubPanels.tsx @@ -0,0 +1,76 @@ +import { RepetitionObject } from '@/features/Events/EventsTypes' +import React from 'react' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { DateTimeSummary } from '../DateTimeSummary' +import { FieldWithLabel } from '../FieldWithLabel' +import RepeatEvent from '../../EventRepeat' + +interface DateTimeSummarySectionProps { + startDate: string + startTime: string + endDate: string + endTime: string + allday: boolean + timezone: string + repetition: RepetitionObject + hasEndDateChanged: boolean + onExpand: () => void +} + +export const DateTimeSummarySection: React.FC = ({ + startDate, + startTime, + endDate, + endTime, + allday, + timezone, + repetition, + hasEndDateChanged, + onExpand +}) => { + const isMultiDay = startDate !== endDate + const showEndDate = allday || (hasEndDateChanged && isMultiDay) || isMultiDay + + return ( + + ) +} + +interface DateTimeRepeatPanelProps { + repetition: RepetitionObject + setRepetition: (value: RepetitionObject) => void + start: string + typeOfAction?: 'solo' | 'all' + showMore: boolean +} + +export const DateTimeRepeatPanel: React.FC = ({ + repetition, + setRepetition, + start, + typeOfAction, + showMore +}) => { + const { isTooSmall: isMobile } = useScreenSizeDetection() + + return ( + + + + ) +} diff --git a/src/components/Event/components/EventFormFieldsExpaned.tsx b/src/components/Event/components/EventFormFieldsExpaned.tsx new file mode 100644 index 0000000..09c05a5 --- /dev/null +++ b/src/components/Event/components/EventFormFieldsExpaned.tsx @@ -0,0 +1,75 @@ +import { FormControl, TextField } from '@linagora/twake-mui' +import React from 'react' +import { useI18n } from 'twake-i18n' +import { FieldWithLabel } from './FieldWithLabel' +import { Resource, ResourceSearch } from '../../Attendees/ResourceSearch' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { FreeBusyField } from '../fields/FreeBusyField' +import { NotificationField } from '../fields/NotificationField' +import { VisibilityField } from '../fields/VisibilityField' +import { EventFormFieldsProps } from '../EventFormFields.types' + +export const EventFormFieldsExpanded: React.FC< + Pick< + EventFormFieldsProps, + | 'alarm' + | 'setAlarm' + | 'busy' + | 'setBusy' + | 'eventClass' + | 'setEventClass' + | 'showMore' + | 'selectedResources' + | 'setSelectedResources' + > +> = ({ + alarm, + setAlarm, + busy, + setBusy, + eventClass, + setEventClass, + showMore, + selectedResources, + setSelectedResources +}) => { + const { t } = useI18n() + const { isTooSmall: isMobile } = useScreenSizeDetection() + + return ( + <> + {showMore && ( + + + } + onChange={(_event: React.SyntheticEvent, value: Resource[]) => + setSelectedResources(value) + } + hideLabel={true} + /> + + + )} + + + + + + + + ) +} diff --git a/src/components/Event/fields/CalendarSelectField.tsx b/src/components/Event/fields/CalendarSelectField.tsx new file mode 100644 index 0000000..cd4d68e --- /dev/null +++ b/src/components/Event/fields/CalendarSelectField.tsx @@ -0,0 +1,196 @@ +import { Calendar } from '@/features/Calendars/CalendarTypes' +import { defaultColors } from '@/utils/defaultColors' +import { makeDisplayName } from '@/utils/makeDisplayName' +import { renameDefault } from '@/utils/renameDefault' +import { + Box, + Divider, + FormControl, + Select, + SelectChangeEvent, + Typography +} from '@linagora/twake-mui' +import SquareRoundedIcon from '@mui/icons-material/SquareRounded' +import React, { useEffect, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { CalendarItemList } from '../../Calendar/CalendarItemList' +import { OwnerCaption } from '../../Calendar/OwnerCaption' +import { FieldWithLabel } from '../components/FieldWithLabel' +import { SectionPreviewRow } from '../components/SectionPreviewRow' + +export interface CalendarSelectFieldProps { + calendarid: string + setCalendarid: (value: string) => void + userPersonalCalendars: Calendar[] + showMore: boolean + /** Optional callback for additional logic on change (e.g. UpdateModal tracks newCalId) */ + onCalendarChange?: (newCalendarId: string) => void +} + +const CalendarSelectFieldCollapsed: React.FC<{ + calendarid: string + userPersonalCalendars: Calendar[] + selectedCalendar: Calendar | undefined + selectedOwnerDisplayName: string + isSelectedDelegated: boolean + setHasClickedCalendarSection: (value: boolean) => void +}> = ({ + calendarid, + userPersonalCalendars, + selectedCalendar, + selectedOwnerDisplayName, + isSelectedDelegated, + setHasClickedCalendarSection +}) => { + const { t } = useI18n() + + return ( + cal.id === calendarid)?.color + ?.light ?? defaultColors[0].light, + width: 24, + height: 24 + }} + /> + } + onClick={() => setHasClickedCalendarSection(true)} + > + {selectedCalendar?.name ? ( + + + {renameDefault( + selectedCalendar.name, + selectedOwnerDisplayName, + t, + !isSelectedDelegated + )} + + + + ) : ( + t('event.form.calendar') + )} + + ) +} + +const CalendarSelectFieldExpanded: React.FC<{ + calendarid: string + setCalendarid: (value: string) => void + userPersonalCalendars: Calendar[] + calendarSelectOpen: boolean + setCalendarSelectOpen: (value: boolean) => void + onCalendarChange?: (newCalendarId: string) => void +}> = ({ + calendarid, + setCalendarid, + userPersonalCalendars, + calendarSelectOpen, + setCalendarSelectOpen, + onCalendarChange +}) => { + const { t } = useI18n() + + const delegatedCalendars = userPersonalCalendars.filter(cal => cal.delegated) + const personalCalendars = userPersonalCalendars.filter(cal => !cal.delegated) + + const handleCalendarChange = (newCalendarId: string): void => { + setCalendarid(newCalendarId) + onCalendarChange?.(newCalendarId) + } + + return ( + + + + ) +} + +export const CalendarSelectField: React.FC = ({ + calendarid, + setCalendarid, + userPersonalCalendars, + showMore, + onCalendarChange +}) => { + const { t } = useI18n() + + // Local UI state + const [hasClickedCalendarSection, setHasClickedCalendarSection] = + useState(false) + const [calendarSelectOpen, setCalendarSelectOpen] = useState(false) + + // Auto-open the Select once it mounts after clicking the preview row + useEffect(() => { + const openSelectAfterClick = (): void => { + if (hasClickedCalendarSection) { + setCalendarSelectOpen(true) + } + } + + openSelectAfterClick() + }, [hasClickedCalendarSection]) + + const selectedCalendar = userPersonalCalendars.find( + cal => cal.id === calendarid + ) + const selectedOwnerDisplayName = selectedCalendar + ? (makeDisplayName(selectedCalendar) ?? '') + : '' + const isSelectedDelegated = !!selectedCalendar?.delegated + + const isCollapsed = !showMore && !hasClickedCalendarSection + + return ( + + {isCollapsed ? ( + + ) : ( + + )} + + ) +} diff --git a/src/components/Event/fields/DateTimeField.types.ts b/src/components/Event/fields/DateTimeField.types.ts new file mode 100644 index 0000000..6fc1cef --- /dev/null +++ b/src/components/Event/fields/DateTimeField.types.ts @@ -0,0 +1,44 @@ +import { RepetitionObject } from '@/features/Events/EventsTypes' + +export interface TimezoneListResult { + zones: string[] + browserTz: string + getTimezoneOffset: (tzName: string, date: Date) => string +} + +export interface EventDateTimeFieldProps { + /** Controlled start value (ISO or YYYY-MM-DD) */ + start: string + setStart: (value: string) => void + /** Controlled end value */ + end: string + setEnd: (value: string) => void + /** All-day flag */ + allday: boolean + setAllDay: (value: boolean) => void + /** Current timezone name */ + timezone: string + setTimezone: (value: string) => void + /** Repetition rule */ + repetition: RepetitionObject + setRepetition: (value: RepetitionObject) => void + showRepeat: boolean + setShowRepeat: (value: boolean) => void + /** Whether the full (expanded) dialog mode is active */ + showMore: boolean + /** show/hide validation error messages */ + showValidationErrors: boolean + timezoneList: TimezoneListResult + typeOfAction?: 'solo' | 'all' + /** Optional callbacks for calendar preview sync (EventModal only) */ + onStartChange?: (newStart: string) => void + onEndChange?: (newEnd: string) => void + onAllDayChange?: ( + newAllDay: boolean, + newStart: string, + newEnd: string + ) => void + onHasEndDateChangedChange?: (has: boolean) => void + /** Notify parent when the validation state changes */ + onValidationChange?: (isValid: boolean) => void +} diff --git a/src/components/Event/fields/EventDateTimeField.tsx b/src/components/Event/fields/EventDateTimeField.tsx new file mode 100644 index 0000000..5a1a894 --- /dev/null +++ b/src/components/Event/fields/EventDateTimeField.tsx @@ -0,0 +1,126 @@ +import React, { useCallback, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { FieldWithLabel } from '../components/FieldWithLabel' +import { useDateTimeSplit } from '../hooks/useDateTimeSplit' +import { DateTimeExpanded } from '../components/DateTimeFields/DateTimeExpanded' +import { DateTimeSummarySection } from '../components/DateTimeFields/DateTimeSubPanels' +import type { EventDateTimeFieldProps } from './DateTimeField.types' + +export const EventDateTimeField: React.FC = ({ + start, + setStart, + end, + setEnd, + allday, + setAllDay, + timezone, + setTimezone, + repetition, + setRepetition, + showRepeat, + setShowRepeat, + showMore, + showValidationErrors, + timezoneList, + typeOfAction, + onStartChange, + onEndChange, + onAllDayChange, + onHasEndDateChangedChange, + onValidationChange +}) => { + const { t } = useI18n() + const { isTooSmall: isMobile } = useScreenSizeDetection() + const [hasClickedDateTimeSection, setHasClickedDateTimeSection] = + useState(false) + const [hasEndDateChanged, setHasEndDateChanged] = useState(false) + + // Validation callback for DateTimeField → notify parent + const handleValidationChange = useCallback( + (isValid: boolean) => { + onValidationChange?.(isValid) + }, + [onValidationChange] + ) + + const { + startDate, + setStartDate, + startTime, + setStartTime, + endDate, + setEndDate, + endTime, + setEndTime + } = useDateTimeSplit({ + start, + end, + allday, + showMore, + hasEndDateChanged, + setHasEndDateChanged, + showValidationErrors, + onEndChange, + setEnd, + onHasEndDateChangedChange, + onValidationChange: handleValidationChange + }) + + const isCollapsed = !showMore && !hasClickedDateTimeSection + + return ( + <> + + {isCollapsed ? ( + setHasClickedDateTimeSection(true)} + /> + ) : ( + + )} + + + ) +} diff --git a/src/components/Event/fields/FreeBusyField.tsx b/src/components/Event/fields/FreeBusyField.tsx new file mode 100644 index 0000000..f41b729 --- /dev/null +++ b/src/components/Event/fields/FreeBusyField.tsx @@ -0,0 +1,40 @@ +import { + FormControl, + MenuItem, + Select, + SelectChangeEvent +} from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' +import { FieldWithLabel } from '../components/FieldWithLabel' + +export interface FreeBusyFieldProps { + busy: string + setBusy: (value: string) => void + /** Only rendered in expanded (showMore) mode */ + showMore: boolean +} + +export const FreeBusyField: React.FC = ({ + busy, + setBusy, + showMore +}) => { + const { t } = useI18n() + + if (!showMore) return null + + return ( + + + + + + ) +} diff --git a/src/components/Event/fields/LocationField.tsx b/src/components/Event/fields/LocationField.tsx new file mode 100644 index 0000000..d8d7bc6 --- /dev/null +++ b/src/components/Event/fields/LocationField.tsx @@ -0,0 +1,72 @@ +import { TextField } from '@linagora/twake-mui' +import { LocationOn as LocationIcon } from '@mui/icons-material' +import { useRef, useState } from 'react' +import { useI18n } from 'twake-i18n' +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { useEventLocation } from '../hooks/useEventLocation' +import { EventFormFieldsProps } from '../EventFormFields.types' +import { FieldWithLabel } from '../components/FieldWithLabel' +import { SectionPreviewRow } from '../components/SectionPreviewRow' + +const showInputLabel = (showMore: boolean, label: string): string => { + if (showMore) { + return label + } + return '' +} + +export default function LocationField({ + location, + setLocation, + showMore, + isOpen = false +}: Pick< + EventFormFieldsProps, + 'location' | 'setLocation' | 'showMore' | 'isOpen' +>): JSX.Element { + const { t } = useI18n() + const { isTooSmall: isMobile } = useScreenSizeDetection() + + const [hasClickedLocationSection, setHasClickedLocationSection] = + useState(false) + + const locationInputRef = useRef(null) + + useEventLocation({ + isOpen, + hasClickedLocationSection, + setHasClickedLocationSection, + locationInputRef + }) + + const isLocationExpanded = showMore || hasClickedLocationSection + + return ( + + {!isLocationExpanded ? ( + } + onClick={() => setHasClickedLocationSection(true)} + > + {location || t('event.form.locationPlaceholder')} + + ) : ( + setLocation(e.target.value)} + size={isMobile ? 'medium' : 'small'} + margin="dense" + /> + )} + + ) +} diff --git a/src/components/Event/fields/NotificationField.tsx b/src/components/Event/fields/NotificationField.tsx new file mode 100644 index 0000000..8de6d90 --- /dev/null +++ b/src/components/Event/fields/NotificationField.tsx @@ -0,0 +1,76 @@ +import { + FormControl, + MenuItem, + Select, + SelectChangeEvent +} from '@linagora/twake-mui' +import { useI18n } from 'twake-i18n' +import { FieldWithLabel } from '../components/FieldWithLabel' + +export interface NotificationFieldProps { + alarm: string + setAlarm: (value: string) => void + /** Only rendered in expanded (showMore) mode */ + showMore: boolean +} + +export const NotificationField: React.FC = ({ + alarm, + setAlarm, + showMore +}) => { + const { t } = useI18n() + + if (!showMore) return null + + return ( + + + + + + ) +} diff --git a/src/components/Event/fields/VideoConferenceField.tsx b/src/components/Event/fields/VideoConferenceField.tsx new file mode 100644 index 0000000..3d193e0 --- /dev/null +++ b/src/components/Event/fields/VideoConferenceField.tsx @@ -0,0 +1,243 @@ +import iconCamera from '@/static/images/icon-camera.svg' +import { + addVideoConferenceToDescription, + generateMeetingLink, + removeVideoConferenceFromDescription +} from '@/utils/videoConferenceUtils' +import { Box, Button, IconButton } from '@linagora/twake-mui' +import { + Close as DeleteIcon, + ContentCopy as CopyIcon +} from '@mui/icons-material' +import React from 'react' +import { useI18n } from 'twake-i18n' +import { SnackbarAlert } from '../../Loading/SnackBarAlert' +import { FieldWithLabel } from '../components/FieldWithLabel' +import { SectionPreviewRow } from '../components/SectionPreviewRow' + +export interface VideoConferenceFieldProps { + hasVideoConference: boolean + setHasVideoConference: (value: boolean) => void + meetingLink: string | null + setMeetingLink: (value: string | null) => void + description: string + setDescription: (value: string) => void + showMore: boolean + /** setShowDescription is called when a conference link is added in simple mode */ + setShowDescription?: (value: boolean) => void +} + +const VideoConferenceFieldInShortMode: React.FC<{ + hasVideoConference: boolean + meetingLink: string | null + cameraIcon: React.ReactNode + handleCopyMeetingLink: () => Promise + handleDeleteVideoConference: () => void + handleAddVideoConference: () => void +}> = ({ + hasVideoConference, + meetingLink, + cameraIcon, + handleCopyMeetingLink, + handleDeleteVideoConference, + handleAddVideoConference +}) => { + const { t } = useI18n() + + if (hasVideoConference && meetingLink) { + return ( + + + void handleCopyMeetingLink()} + size="small" + sx={{ color: 'primary.main' }} + aria-label={t('event.form.copyMeetingLink')} + title={t('event.form.copyMeetingLink')} + > + + + + + + + ) + } + + return ( + + {t('event.form.addVisioConference')} + + ) +} + +const VideoConferenceFieldInExpanedMode: React.FC<{ + hasVideoConference: boolean + meetingLink: string | null + cameraIcon: React.ReactNode + handleCopyMeetingLink: () => Promise + handleDeleteVideoConference: () => void + handleAddVideoConference: () => void +}> = ({ + hasVideoConference, + meetingLink, + cameraIcon, + handleCopyMeetingLink, + handleDeleteVideoConference, + handleAddVideoConference +}) => { + const { t } = useI18n() + + return ( + + + + {hasVideoConference && meetingLink && ( + <> + + void handleCopyMeetingLink()} + size="small" + sx={{ color: 'primary.main' }} + aria-label={t('event.form.copyMeetingLink')} + title={t('event.form.copyMeetingLink')} + > + + + + + + + )} + + ) +} + +export const VideoConferenceField: React.FC = ({ + hasVideoConference, + setHasVideoConference, + meetingLink, + setMeetingLink, + description, + setDescription, + showMore, + setShowDescription +}) => { + const { t } = useI18n() + const [openToast, setOpenToast] = React.useState(false) + + const handleAddVideoConference = (): void => { + const newMeetingLink = generateMeetingLink() + const updatedDescription = addVideoConferenceToDescription( + description, + newMeetingLink + ) + setDescription(updatedDescription) + setHasVideoConference(true) + setMeetingLink(newMeetingLink) + if (showMore) { + setShowDescription?.(true) + } + } + + const handleCopyMeetingLink = async (): Promise => { + if (!meetingLink) return + try { + await navigator.clipboard.writeText(meetingLink) + setOpenToast(true) + } catch (err) { + console.error('Failed to copy link:', err) + } + } + + const handleDeleteVideoConference = (): void => { + setDescription(removeVideoConferenceFromDescription(description)) + setHasVideoConference(false) + setMeetingLink(null) + } + + const cameraIcon = ( + camera + ) + + return ( + <> + + {!showMore ? ( + + ) : ( + + )} + + + + + ) +} diff --git a/src/components/Event/fields/VisibilityField.tsx b/src/components/Event/fields/VisibilityField.tsx new file mode 100644 index 0000000..f660b5e --- /dev/null +++ b/src/components/Event/fields/VisibilityField.tsx @@ -0,0 +1,46 @@ +import { ToggleButton, ToggleButtonGroup } from '@linagora/twake-mui' +import { Public as PublicIcon } from '@mui/icons-material' +import LockOutlineIcon from '@mui/icons-material/LockOutline' +import { useI18n } from 'twake-i18n' +import { FieldWithLabel } from '../components/FieldWithLabel' + +export interface VisibilityFieldProps { + eventClass: 'PUBLIC' | 'PRIVATE' | 'CONFIDENTIAL' + setEventClass: (value: 'PUBLIC' | 'PRIVATE' | 'CONFIDENTIAL') => void + /** Only rendered in expanded (showMore) mode */ + showMore: boolean +} + +export const VisibilityField: React.FC = ({ + eventClass, + setEventClass, + showMore +}) => { + const { t } = useI18n() + + if (!showMore) return null + + return ( + + { + if (newValue !== null) { + setEventClass(newValue as 'PUBLIC' | 'PRIVATE' | 'CONFIDENTIAL') + } + }} + size="medium" + > + + + {t('event.form.visibleAll')} + + + + {t('event.form.visibleParticipants')} + + + + ) +} diff --git a/src/components/Event/hooks/useAutoEndTime.ts b/src/components/Event/hooks/useAutoEndTime.ts new file mode 100644 index 0000000..ef62693 --- /dev/null +++ b/src/components/Event/hooks/useAutoEndTime.ts @@ -0,0 +1,52 @@ +import { useEffect } from 'react' +import { combineDateTime } from '../utils/dateTimeHelpers' + +interface UseAutoEndTimeParams { + startTime: string + endTime: string + allday: boolean + startDate: string + endDate: string + setEndTime: (value: string) => void + onEndChange?: (newEnd: string) => void + setEnd: (value: string) => void +} + +/** + * Auto-calculates end time as startTime + 1 hour when endTime is not yet set. + * Extracted from useDateTimeSplit to keep each hook under the CC threshold. + */ +export function useAutoEndTime({ + startTime, + endTime, + allday, + startDate, + endDate, + setEndTime, + onEndChange, + setEnd +}: UseAutoEndTimeParams): void { + useEffect(() => { + const needsAutoEnd = Boolean(startTime) && !endTime && !allday + if (!needsAutoEnd) return + 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, + setEndTime + ]) +} diff --git a/src/components/Event/hooks/useAutoFocusTitle.ts b/src/components/Event/hooks/useAutoFocusTitle.ts new file mode 100644 index 0000000..58e8ba1 --- /dev/null +++ b/src/components/Event/hooks/useAutoFocusTitle.ts @@ -0,0 +1,58 @@ +import { useEffect, useRef } from 'react' + +// Effects that auto-focus the title input must not run in the test environment +// because jsdom has no layout engine and focus() calls produce false positives. +const isNotTestEnv = process.env.NODE_ENV !== 'test' + +function useFocusTitleOnOpen( + isOpen: boolean, + eventId: string | null | undefined, + titleInputRef: React.RefObject +): void { + useEffect(() => { + const isNewEventOpen = isOpen && !eventId + if (!isNewEventOpen || !isNotTestEnv) return + const timer = setTimeout(() => { + titleInputRef.current?.focus() + }, 100) + return (): void => clearTimeout(timer) + }, [isOpen, eventId, titleInputRef]) +} + +function useFocusTitleOnToggle( + isOpen: boolean, + showMore: boolean, + titleInputRef: React.RefObject +): void { + const prevShowMoreRef = useRef(undefined) + + useEffect(() => { + const isFirstRender = prevShowMoreRef.current === undefined + const hasChanged = prevShowMoreRef.current !== showMore + prevShowMoreRef.current = showMore + + const shouldFocusOnToggle = + !isFirstRender && isOpen && isNotTestEnv && hasChanged + if (!shouldFocusOnToggle) return + + const timer = setTimeout(() => { + titleInputRef.current?.focus() + }, 150) + return (): void => clearTimeout(timer) + }, [showMore, isOpen, titleInputRef]) +} + +export const useAutoFocusTitle = ({ + isOpen, + eventId, + titleInputRef, + showMore +}: { + isOpen: boolean + eventId?: string | null + titleInputRef: React.RefObject + showMore: boolean +}): void => { + useFocusTitleOnOpen(isOpen, eventId, titleInputRef) + useFocusTitleOnToggle(isOpen, showMore, titleInputRef) +} diff --git a/src/components/Event/hooks/useDateTimeSplit.ts b/src/components/Event/hooks/useDateTimeSplit.ts new file mode 100644 index 0000000..8c3ec8d --- /dev/null +++ b/src/components/Event/hooks/useDateTimeSplit.ts @@ -0,0 +1,123 @@ +import { useEffect, useState } from 'react' +import { splitDateTime } from '../utils/dateTimeHelpers' +import { validateEventForm } from '../utils/formValidation' +import { useAutoEndTime } from './useAutoEndTime' + +interface UseDateTimeSplitParams { + start: string + end: string + allday: boolean + showMore: boolean + hasEndDateChanged: boolean + setHasEndDateChanged: (value: boolean) => void + showValidationErrors: boolean + onEndChange?: (newEnd: string) => void + setEnd: (value: string) => void + onHasEndDateChangedChange?: (has: boolean) => void + onValidationChange?: (isValid: boolean) => void +} + +interface UseDateTimeSplitResult { + startDate: string + setStartDate: (value: string) => void + startTime: string + setStartTime: (value: string) => void + endDate: string + setEndDate: (value: string) => void + endTime: string + setEndTime: (value: string) => void +} + +const changeDateAndTime = ( + datetime: string, + setDate: (date: string) => void, + setTime: (time: string) => void +): void => { + if (!datetime) return + + const { date, time } = splitDateTime(datetime) + setDate(date) + setTime(time) +} + +export function useDateTimeSplit({ + start, + end, + allday, + showMore, + hasEndDateChanged, + setHasEndDateChanged, + showValidationErrors, + onEndChange, + setEnd, + onHasEndDateChangedChange, + onValidationChange +}: UseDateTimeSplitParams): UseDateTimeSplitResult { + const [startDate, setStartDate] = useState('') + const [startTime, setStartTime] = useState('') + const [endDate, setEndDate] = useState('') + const [endTime, setEndTime] = useState('') + + // Sync split fields from parent ISO strings + useEffect(() => { + changeDateAndTime(start, setStartDate, setStartTime) + }, [start]) + + useEffect(() => { + changeDateAndTime(end, setEndDate, setEndTime) + }, [end]) + + // Notify parent about validation + const isValid = validateEventForm({ + startDate, + startTime, + endDate, + endTime, + allday, + showValidationErrors, + hasEndDateChanged, + showMore + }).isValid + + useEffect(() => { + onValidationChange?.(isValid) + }, [isValid, onValidationChange]) + + // Notify parent when end-date-changed flag changes + useEffect(() => { + onHasEndDateChangedChange?.(hasEndDateChanged) + }, [hasEndDateChanged, onHasEndDateChangedChange]) + + // Reset end-date-changed when dates fall back to same day in normal mode + useEffect(() => { + const shouldReset = + !showMore && + hasEndDateChanged && + Boolean(startDate) && + startDate === endDate + if (shouldReset) setHasEndDateChanged(false) + }, [showMore, hasEndDateChanged, startDate, endDate, setHasEndDateChanged]) + + // Auto end-time calculation delegated to its own hook + useAutoEndTime({ + startTime, + endTime, + allday, + startDate, + endDate, + setEndTime, + onEndChange, + setEnd + }) + + return { + startDate, + setStartDate, + startTime, + setStartTime, + endDate, + setEndDate, + endTime, + setEndTime + } +} diff --git a/src/components/Event/hooks/useEventLocation.ts b/src/components/Event/hooks/useEventLocation.ts new file mode 100644 index 0000000..1fdb58d --- /dev/null +++ b/src/components/Event/hooks/useEventLocation.ts @@ -0,0 +1,27 @@ +import React, { useEffect } from 'react' + +export const useEventLocation = ({ + isOpen, + hasClickedLocationSection, + setHasClickedLocationSection, + locationInputRef +}: { + isOpen: boolean + hasClickedLocationSection: boolean + setHasClickedLocationSection: (hasClicked: boolean) => void + locationInputRef: React.RefObject +}): void => { + // Reset location click state when modal closes + useEffect(() => { + if (!isOpen) { + setHasClickedLocationSection(false) + } + }, [isOpen, setHasClickedLocationSection]) + + // Focus location field when user clicks the location preview row + useEffect(() => { + if (hasClickedLocationSection && process.env.NODE_ENV !== 'test') { + locationInputRef.current?.focus() + } + }, [hasClickedLocationSection, locationInputRef]) +}