#808 extract event form field component (#815)

Co-authored-by: lethemanh <lethemanh@lethemanhs-MacBook-Pro.local>
This commit is contained in:
lethemanh
2026-04-21 14:39:41 +07:00
committed by GitHub
parent c8cab5d55d
commit 805a63b585
17 changed files with 1794 additions and 921 deletions
File diff suppressed because it is too large Load Diff
@@ -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
}
@@ -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<DateTimeControlsRowProps> = ({
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 (
<FieldWithLabel label=" " isExpanded={showMore && !isMobile}>
<Box
display="flex"
gap={2}
alignItems={isMobile ? 'start' : 'center'}
flexDirection={isMobile ? 'column' : 'row'}
>
<Box>
<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={handleRepeatToggle}
/>
}
label={
<Typography variant="h6">{t('event.form.repeat')}</Typography>
}
/>
</Box>
{/* Timezone selector */}
{isMobile ? (
<>
<Button
variant="text"
size="small"
onClick={() => setTimezoneDrawerOpen(true)}
sx={{ textTransform: 'none', px: 0, color: 'text.secondary' }}
>
<Typography variant="h6">
({offset}) {tzLabel}
</Typography>
<ArrowDropDown />
</Button>
<SmallTimezoneSelector
open={timezoneDrawerOpen}
onClose={() => setTimezoneDrawerOpen(false)}
value={timezone}
onChange={tz => {
setTimezone(tz)
setTimezoneDrawerOpen(false)
}}
referenceDate={new Date(start)}
/>
</>
) : (
<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')}
hideBorder
inputPadding="8px 65px 8px 0px"
/>
)}
</Box>
</FieldWithLabel>
)
}
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 (
<>
<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={showEndDate}
onToggleEndDate={() => {}}
/>
{/* All-day, Repeat checkboxes + Timezone selector row */}
<DateTimeControlsRow
allday={allday}
handleAllDayToggle={handleAllDayToggle}
showRepeat={showRepeat}
setShowRepeat={setShowRepeat}
typeOfAction={typeOfAction}
repetition={repetition}
setRepetition={setRepetition}
start={start}
timezone={timezone}
setTimezone={setTimezone}
timezoneList={timezoneList}
showMore={showMore}
offset={offset}
tzLabel={tzLabel}
/>
{repeatIsActive && (
<DateTimeRepeatPanel
repetition={repetition}
setRepetition={setRepetition}
start={start}
typeOfAction={typeOfAction}
showMore={showMore}
/>
)}
</>
)
}
@@ -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<DateTimeSummarySectionProps> = ({
startDate,
startTime,
endDate,
endTime,
allday,
timezone,
repetition,
hasEndDateChanged,
onExpand
}) => {
const isMultiDay = startDate !== endDate
const showEndDate = allday || (hasEndDateChanged && isMultiDay) || isMultiDay
return (
<DateTimeSummary
startDate={startDate}
startTime={startTime}
endDate={endDate}
endTime={endTime}
allday={allday}
timezone={timezone}
repetition={repetition}
showEndDate={showEndDate}
onClick={onExpand}
/>
)
}
interface DateTimeRepeatPanelProps {
repetition: RepetitionObject
setRepetition: (value: RepetitionObject) => void
start: string
typeOfAction?: 'solo' | 'all'
showMore: boolean
}
export const DateTimeRepeatPanel: React.FC<DateTimeRepeatPanelProps> = ({
repetition,
setRepetition,
start,
typeOfAction,
showMore
}) => {
const { isTooSmall: isMobile } = useScreenSizeDetection()
return (
<FieldWithLabel label=" " isExpanded={showMore && !isMobile}>
<RepeatEvent
repetition={repetition}
eventStart={new Date(start)}
setRepetition={setRepetition}
isOwn={typeOfAction !== 'solo'}
/>
</FieldWithLabel>
)
}
@@ -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 && (
<FieldWithLabel
label={t('event.form.resource')}
isExpanded={showMore && !isMobile}
>
<FormControl fullWidth margin="dense" size="small">
<ResourceSearch
objectTypes={['resource']}
selectedResources={selectedResources}
inputSlot={params => <TextField {...params} size="small" />}
onChange={(_event: React.SyntheticEvent, value: Resource[]) =>
setSelectedResources(value)
}
hideLabel={true}
/>
</FormControl>
</FieldWithLabel>
)}
<NotificationField
alarm={alarm}
setAlarm={setAlarm}
showMore={showMore}
/>
<FreeBusyField busy={busy} setBusy={setBusy} showMore={showMore} />
<VisibilityField
eventClass={eventClass}
setEventClass={setEventClass}
showMore={showMore}
/>
</>
)
}
@@ -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 (
<SectionPreviewRow
icon={
<SquareRoundedIcon
sx={{
color:
userPersonalCalendars.find(cal => cal.id === calendarid)?.color
?.light ?? defaultColors[0].light,
width: 24,
height: 24
}}
/>
}
onClick={() => setHasClickedCalendarSection(true)}
>
{selectedCalendar?.name ? (
<Box style={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="body2" sx={{ wordBreak: 'break-word' }}>
{renameDefault(
selectedCalendar.name,
selectedOwnerDisplayName,
t,
!isSelectedDelegated
)}
</Typography>
<OwnerCaption
showCaption={
isSelectedDelegated && selectedCalendar.name !== '#default'
}
ownerDisplayName={selectedOwnerDisplayName}
/>
</Box>
) : (
t('event.form.calendar')
)}
</SectionPreviewRow>
)
}
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 (
<FormControl fullWidth margin="dense" size="small">
<Select
value={calendarid ?? ''}
label=""
SelectDisplayProps={{ 'aria-label': t('event.form.calendar') }}
displayEmpty
open={calendarSelectOpen}
onOpen={() => setCalendarSelectOpen(true)}
onClose={() => setCalendarSelectOpen(false)}
onChange={(e: SelectChangeEvent) =>
handleCalendarChange(e.target.value)
}
>
{CalendarItemList(personalCalendars)}
{delegatedCalendars.length > 0 && personalCalendars.length > 0 && (
<Divider component="li" />
)}
{CalendarItemList(delegatedCalendars)}
</Select>
</FormControl>
)
}
export const CalendarSelectField: React.FC<CalendarSelectFieldProps> = ({
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 (
<FieldWithLabel
label={isCollapsed ? '' : t('event.form.calendar')}
isExpanded={showMore}
>
{isCollapsed ? (
<CalendarSelectFieldCollapsed
calendarid={calendarid}
userPersonalCalendars={userPersonalCalendars}
setHasClickedCalendarSection={setHasClickedCalendarSection}
selectedCalendar={selectedCalendar}
selectedOwnerDisplayName={selectedOwnerDisplayName}
isSelectedDelegated={isSelectedDelegated}
/>
) : (
<CalendarSelectFieldExpanded
calendarid={calendarid}
setCalendarid={setCalendarid}
userPersonalCalendars={userPersonalCalendars}
calendarSelectOpen={calendarSelectOpen}
setCalendarSelectOpen={setCalendarSelectOpen}
onCalendarChange={onCalendarChange}
/>
)}
</FieldWithLabel>
)
}
@@ -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
}
@@ -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<EventDateTimeFieldProps> = ({
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 (
<>
<FieldWithLabel
label={isCollapsed ? '' : t('event.form.dateTime')}
isExpanded={showMore && !isMobile}
>
{isCollapsed ? (
<DateTimeSummarySection
startDate={startDate}
startTime={startTime}
endDate={endDate}
endTime={endTime}
allday={allday}
timezone={timezone}
repetition={repetition}
hasEndDateChanged={hasEndDateChanged}
onExpand={() => setHasClickedDateTimeSection(true)}
/>
) : (
<DateTimeExpanded
start={start}
setStart={setStart}
end={end}
setEnd={setEnd}
allday={allday}
setAllDay={setAllDay}
timezone={timezone}
setTimezone={setTimezone}
repetition={repetition}
setRepetition={setRepetition}
showRepeat={showRepeat}
setShowRepeat={setShowRepeat}
showMore={showMore}
hasEndDateChanged={hasEndDateChanged}
setHasEndDateChanged={setHasEndDateChanged}
showValidationErrors={showValidationErrors}
timezoneList={timezoneList}
typeOfAction={typeOfAction}
onStartChange={onStartChange}
onEndChange={onEndChange}
onAllDayChange={onAllDayChange}
startDate={startDate}
setStartDate={setStartDate}
startTime={startTime}
setStartTime={setStartTime}
endDate={endDate}
setEndDate={setEndDate}
endTime={endTime}
setEndTime={setEndTime}
/>
)}
</FieldWithLabel>
</>
)
}
@@ -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<FreeBusyFieldProps> = ({
busy,
setBusy,
showMore
}) => {
const { t } = useI18n()
if (!showMore) return null
return (
<FieldWithLabel label={t('event.form.showMeAs')} isExpanded>
<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>
)
}
@@ -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<HTMLInputElement>(null)
useEventLocation({
isOpen,
hasClickedLocationSection,
setHasClickedLocationSection,
locationInputRef
})
const isLocationExpanded = showMore || hasClickedLocationSection
return (
<FieldWithLabel
label={showInputLabel(isLocationExpanded, t('event.form.location'))}
isExpanded={isLocationExpanded && !isMobile}
>
{!isLocationExpanded ? (
<SectionPreviewRow
icon={<LocationIcon />}
onClick={() => setHasClickedLocationSection(true)}
>
{location || t('event.form.locationPlaceholder')}
</SectionPreviewRow>
) : (
<TextField
autoComplete="off"
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={isMobile ? 'medium' : 'small'}
margin="dense"
/>
)}
</FieldWithLabel>
)
}
@@ -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<NotificationFieldProps> = ({
alarm,
setAlarm,
showMore
}) => {
const { t } = useI18n()
if (!showMore) return null
return (
<FieldWithLabel label={t('event.form.notification')} isExpanded>
<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>
)
}
@@ -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<void>
handleDeleteVideoConference: () => void
handleAddVideoConference: () => void
}> = ({
hasVideoConference,
meetingLink,
cameraIcon,
handleCopyMeetingLink,
handleDeleteVideoConference,
handleAddVideoConference
}) => {
const { t } = useI18n()
if (hasVideoConference && meetingLink) {
return (
<Box display="flex" gap={1} alignItems="center">
<Button
startIcon={cameraIcon}
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={() => void 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>
)
}
return (
<SectionPreviewRow icon={cameraIcon} onClick={handleAddVideoConference}>
{t('event.form.addVisioConference')}
</SectionPreviewRow>
)
}
const VideoConferenceFieldInExpanedMode: React.FC<{
hasVideoConference: boolean
meetingLink: string | null
cameraIcon: React.ReactNode
handleCopyMeetingLink: () => Promise<void>
handleDeleteVideoConference: () => void
handleAddVideoConference: () => void
}> = ({
hasVideoConference,
meetingLink,
cameraIcon,
handleCopyMeetingLink,
handleDeleteVideoConference,
handleAddVideoConference
}) => {
const { t } = useI18n()
return (
<Box display="flex" gap={1} alignItems="center">
<Button
startIcon={cameraIcon}
onClick={handleAddVideoConference}
size="medium"
variant="contained"
color="secondary"
sx={{
borderRadius: '4px',
display: hasVideoConference ? 'none' : 'flex'
}}
>
{t('event.form.addVisioConference')}
</Button>
{hasVideoConference && meetingLink && (
<>
<Button
startIcon={cameraIcon}
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={() => void 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>
)
}
export const VideoConferenceField: React.FC<VideoConferenceFieldProps> = ({
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<void> => {
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 = (
<img src={iconCamera} alt="camera" width={24} height={24} />
)
return (
<>
<FieldWithLabel
label={showMore ? t('event.form.videoMeeting') : ''}
isExpanded={showMore}
>
{!showMore ? (
<VideoConferenceFieldInShortMode
hasVideoConference={hasVideoConference}
meetingLink={meetingLink}
cameraIcon={cameraIcon}
handleCopyMeetingLink={handleCopyMeetingLink}
handleDeleteVideoConference={handleDeleteVideoConference}
handleAddVideoConference={handleAddVideoConference}
/>
) : (
<VideoConferenceFieldInExpanedMode
hasVideoConference={hasVideoConference}
meetingLink={meetingLink}
cameraIcon={cameraIcon}
handleCopyMeetingLink={handleCopyMeetingLink}
handleDeleteVideoConference={handleDeleteVideoConference}
handleAddVideoConference={handleAddVideoConference}
/>
)}
</FieldWithLabel>
<SnackbarAlert
setOpen={setOpenToast}
open={openToast}
message={t('event.form.meetCopied')}
/>
</>
)
}
@@ -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<VisibilityFieldProps> = ({
eventClass,
setEventClass,
showMore
}) => {
const { t } = useI18n()
if (!showMore) return null
return (
<FieldWithLabel label={t('event.form.visibleTo')} isExpanded>
<ToggleButtonGroup
value={eventClass}
exclusive
onChange={(_e, newValue: string) => {
if (newValue !== null) {
setEventClass(newValue as 'PUBLIC' | 'PRIVATE' | 'CONFIDENTIAL')
}
}}
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>
)
}
@@ -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
])
}
@@ -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<HTMLInputElement>
): 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<HTMLInputElement>
): void {
const prevShowMoreRef = useRef<boolean | undefined>(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<HTMLInputElement>
showMore: boolean
}): void => {
useFocusTitleOnOpen(isOpen, eventId, titleInputRef)
useFocusTitleOnToggle(isOpen, showMore, titleInputRef)
}
@@ -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
}
}
@@ -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<HTMLInputElement>
}): 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])
}