diff --git a/__test__/components/DateTimeField.test.tsx b/__test__/components/DateTimeField.test.tsx index f32806c..f9345b7 100644 --- a/__test__/components/DateTimeField.test.tsx +++ b/__test__/components/DateTimeField.test.tsx @@ -1,7 +1,7 @@ import { DateTimeFields, DateTimeFieldsProps -} from '@/components/Event/components/DateTimeFields' +} from '@/components/Event/components/DateTimeFields/DateTimeFields' import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' diff --git a/package-lock.json b/package-lock.json index c225073..00e6646 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "@mui/x-date-pickers": "^8.14.0", "@reduxjs/toolkit": "^2.8.2", "@sentry/react": "^8.55.1", + "classnames": "^2.5.1", "cozy-external-bridge": "^1.2.1", "date-fns": "^4.1.0", "dayjs": "^1.11.19", @@ -3067,8 +3068,8 @@ }, "node_modules/@linagora/twake-mui": { "version": "1.2.0", - "resolved": "https://npm.pkg.github.com/download/@linagora/twake-mui/1.2.0/7b3eb115d70693ba2936ceaac70a8df3bae92672", - "integrity": "sha512-wKdkuJE48sFE5q2HWbrgI+FIZmBZefz5X5GAwiYHwvAD+9Fr/M+k973ZNSdaxwogWsQ604lZEBoIl13vKdc9EQ==", + "resolved": "https://registry.npmjs.org/@linagora/twake-mui/-/twake-mui-1.2.0.tgz", + "integrity": "sha512-urkpVOeCWpKNK4rdNrPUuUHCLMA60FG/7PocNCDp1Nn8GyjFeAWoxZNw980TJ785FjgExxELw363PQ0X78/mKw==", "license": "MIT", "dependencies": { "react": "^18.2.0", @@ -3083,6 +3084,14 @@ "@mui/material": "^7.1.2", "react": "^18.2.0", "react-dom": "^18.2.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": false + }, + "react-dom": { + "optional": false + } } }, "node_modules/@lottiefiles/dotlottie-react": { @@ -6284,6 +6293,12 @@ "dev": true, "license": "MIT" }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/cli-boxes": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", diff --git a/package.json b/package.json index 748bdca..018ba8f 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@mui/x-date-pickers": "^8.14.0", "@reduxjs/toolkit": "^2.8.2", "@sentry/react": "^8.55.1", + "classnames": "^2.5.1", "cozy-external-bridge": "^1.2.1", "date-fns": "^4.1.0", "dayjs": "^1.11.19", diff --git a/src/components/Dialog/ResponsiveDialog.tsx b/src/components/Dialog/ResponsiveDialog.tsx index 0fc1eb2..42950b2 100644 --- a/src/components/Dialog/ResponsiveDialog.tsx +++ b/src/components/Dialog/ResponsiveDialog.tsx @@ -192,7 +192,7 @@ function ResponsiveDialog({ {...otherDialogProps} > - {isExpanded && onExpandToggle ? ( + {isExpanded && onExpandToggle && !isMobile ? ( {title} - {onExpandToggle && ( + {onExpandToggle && !isMobile && ( void -}) { +}): JSX.Element { const { t } = useI18n() + const { isTooSmall: isMobile } = useScreenSizeDetection() const descriptionInputRef = React.useRef(null) React.useEffect(() => { @@ -30,7 +32,7 @@ export function AddDescButton({ const descriptionField = ( { - if (isOpen) { + 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 () => clearTimeout(timer) + return (): void => clearTimeout(timer) } } - }, [isOpen]) + }, [isOpen, eventId]) // Auto-focus title field when toggling between normal and extended mode React.useEffect(() => { @@ -309,7 +316,7 @@ export default function EventFormFields({ // Update previous value before returning cleanup prevShowMoreRef.current = showMore - return () => clearTimeout(timer) + return (): void => clearTimeout(timer) } } @@ -441,7 +448,7 @@ export default function EventFormFields({ const validation = validateForm() - const handleAddVideoConference = () => { + const handleAddVideoConference = (): void => { const newMeetingLink = generateMeetingLink() const updatedDescription = addVideoConferenceToDescription( description, @@ -457,7 +464,7 @@ export default function EventFormFields({ const [openToast, setOpenToast] = React.useState(false) - const handleCopyMeetingLink = async () => { + const handleCopyMeetingLink = async (): Promise => { if (meetingLink) { try { await navigator.clipboard.writeText(meetingLink) @@ -468,29 +475,38 @@ export default function EventFormFields({ } } - const handleDeleteVideoConference = () => { + const handleDeleteVideoConference = (): void => { setDescription(removeVideoConferenceFromDescription(description)) setHasVideoConference(false) setMeetingLink(null) } - const handleCalendarChange = (newCalendarId: string) => { + const handleCalendarChange = (newCalendarId: string): void => { setCalendarid(newCalendarId) onCalendarChange?.(newCalendarId) } - const handleResourceChange = (resources: Resource[]) => { + 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 + return ( <> {!showMore && !hasClickedDateTimeSection ? ( {!(!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.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)} /> - } - label={ - {t('event.form.repeat')} - } - /> - - timezoneList.getTimezoneOffset(tzName, new Date(start)) - } - showIcon={false} - width={220} - size="small" - placeholder={t('event.form.timezonePlaceholder')} - hideBorder - inputPadding="8px 65px 8px 0px" - /> + + ) : ( + + 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 ? ( @@ -671,7 +720,7 @@ export default function EventFormFields({ {t('event.form.joinVisioConference')} void handleCopyMeetingLink()} size="small" sx={{ color: 'primary.main' }} aria-label={t('event.form.copyMeetingLink')} @@ -737,7 +786,7 @@ export default function EventFormFields({ {t('event.form.joinVisioConference')} void handleCopyMeetingLink()} size="small" sx={{ color: 'primary.main' }} aria-label={t('event.form.copyMeetingLink')} @@ -766,13 +815,14 @@ export default function EventFormFields({ showMore={showMore} description={description} setDescription={setDescription} + isMobile={isMobile} /> {!showMore && !hasClickedLocationSection ? ( ) : ( {!showMore && !hasClickedCalendarSection ? ( { + onChange={(_e, newValue: string) => { if (newValue !== null) { setEventClass(newValue) } diff --git a/src/components/Event/components/DateTimeFields.tsx b/src/components/Event/components/DateTimeFields.tsx deleted file mode 100644 index 56f44c1..0000000 --- a/src/components/Event/components/DateTimeFields.tsx +++ /dev/null @@ -1,633 +0,0 @@ -import { - Box, - TextFieldProps, - Typography, - useMediaQuery, - useTheme -} from '@linagora/twake-mui' -import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' -import { - DatePicker, - DatePickerFieldProps, - DatePickerSlotProps -} from '@mui/x-date-pickers/DatePicker' -import { PickerValue } from '@mui/x-date-pickers/internals' -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' -import { - TimePicker, - TimePickerFieldProps -} from '@mui/x-date-pickers/TimePicker' -import dayjs, { Dayjs } from 'dayjs' -import 'dayjs/locale/en' -import 'dayjs/locale/fr' -import 'dayjs/locale/ru' -import 'dayjs/locale/vi' -import customParseFormat from 'dayjs/plugin/customParseFormat' -import React, { useMemo } from 'react' -import { useI18n } from 'twake-i18n' -import { LONG_DATE_FORMAT } from '../utils/dateTimeFormatters' -import { dtDate, dtTime, toDateTime } from '../utils/dateTimeHelpers' -import { EditableTimeField } from './EditableTimeField' -import { ReadOnlyDateField } from './ReadOnlyPickerField' - -dayjs.extend(customParseFormat) - -const timePickerPopperSx = { - '& .MuiPaper-root': { - width: '110px', - minWidth: '110px' - }, - '& .MuiMultiSectionDigitalClockSection-item': { - justifyContent: 'flex-start', - width: '100%', - textAlign: 'left' - } -} - -// twake-mui datePickerOverrides also uses this selector. Repeating ensures our override wins. -const dateCalendarLayoutSx = { - '& .MuiDateCalendar-root.MuiDateCalendar-root': { - width: '260px', - maxWidth: '260px', - padding: '0 15px' - } -} - -/** - * Props for DateTimeFields component - */ -export interface DateTimeFieldsProps { - startDate: string - startTime: string - endDate: string - endTime: string - allday: boolean - showMore: boolean - hasEndDateChanged: boolean - showEndDate: boolean - onToggleEndDate: () => void - validation: { - errors: { - dateTime: string - } - } - onStartDateChange: (date: string) => void - onStartTimeChange: (time: string) => void - onEndDateChange: (date: string, time?: string) => void - onEndTimeChange: (time: string) => void -} - -/** - * DateTimeFields component - 4 separate date/time input fields - * Layout: Start Date (30%) | Start Time (20%) | End Time (20%) | End Date (30%) - */ -export const DateTimeFields: React.FC = ({ - startDate, - startTime, - endDate, - endTime, - allday, - showMore, - hasEndDateChanged, - showEndDate, - validation, - onStartDateChange, - onStartTimeChange, - onEndDateChange, - onEndTimeChange -}): JSX.Element => { - const { t, lang } = useI18n() - const theme = useTheme() - const isMobile = useMediaQuery(theme.breakpoints.down('sm')) - - const initialDurationRef = React.useRef(null) - const isUserActionRef = React.useRef(false) - - const getCurrentDuration = React.useCallback((): number => { - const start = toDateTime(startDate, startTime) - const end = toDateTime(endDate, endTime) - - if (allday) { - return Math.max(end.startOf('day').diff(start.startOf('day'), 'day'), 0) - } else { - return Math.max(end.diff(start, 'minute'), 0) - } - }, [startDate, startTime, endDate, endTime, allday]) - - React.useEffect(() => { - if (!isUserActionRef.current && startDate && endDate) { - initialDurationRef.current = getCurrentDuration() - } - queueMicrotask(() => { - isUserActionRef.current = false - }) - }, [startDate, endDate, startTime, endTime, getCurrentDuration]) - - const spansMultipleDays = React.useMemo(() => { - return startDate !== endDate - }, [startDate, endDate]) - - const isExpanded = showMore - const shouldShowEndDateNormal = allday || showEndDate - // Show full 4 fields when: - // 1. Non-allday with hasEndDateChanged - // 2. Multiple days with hasEndDateChanged (supports drag from week/month view grid with allday checked) - // 3. Multiple days without allday (original behavior) - const shouldShowFullFieldsInNormal = - (!allday && hasEndDateChanged) || - (hasEndDateChanged && spansMultipleDays) || - (spansMultipleDays && !allday) - const showSingleDateField = - !isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal - - // When shouldShowFullFieldsInNormal is true, show time fields even if allday is true - // This supports the case: drag from week/month view grid with multiple days - const shouldShowTimeFields = !allday || shouldShowFullFieldsInNormal - - const startDateLabel = showSingleDateField - ? t('dateTimeFields.date') - : t('dateTimeFields.startDate') - - const handleStartDateChange = (value: PickerValue): void => { - if (!value || !value.isValid()) return - - isUserActionRef.current = true - const newDateStr = dtDate(value as Dayjs) - const duration = initialDurationRef.current ?? getCurrentDuration() - - onStartDateChange(newDateStr) - - // Preserve duration by adjusting end - const newStart = toDateTime(newDateStr, startTime) - let newEnd: Dayjs - - if (allday) { - newEnd = newStart.add(duration, 'day') - } else { - newEnd = newStart.add(duration, 'minute') - } - - const newEndDate = dtDate(newEnd) - const newEndTime = dtTime(newEnd) - - if (newEndDate !== endDate) { - onEndDateChange(newEndDate) - } - if (!allday && newEndTime !== endTime) { - onEndTimeChange(newEndTime) - } - } - - const handleStartTimeChange = (value: PickerValue): void => { - if (!value || !value.isValid()) return - - isUserActionRef.current = true - const newTimeStr = dtTime(value as Dayjs) - const duration = initialDurationRef.current ?? getCurrentDuration() - - onStartTimeChange(newTimeStr) - - const newStart = toDateTime(startDate, newTimeStr) - const newEnd = newStart.add(duration, 'minute') - - const newEndDate = dtDate(newEnd) - const newEndTime = dtTime(newEnd) - - if (newEndDate !== endDate && newEndTime !== endTime) { - onEndDateChange(newEndDate, newEndTime) - } else { - if (newEndDate !== endDate) { - onEndDateChange(newEndDate) - } - if (!allday && newEndTime !== endTime) { - onEndTimeChange(newEndTime) - } - } - } - - const handleEndDateChange = (value: PickerValue): void => { - if (!value || !value.isValid()) return - - isUserActionRef.current = true - const newDateStr = dtDate(value as Dayjs) - const newEnd = toDateTime(newDateStr, endTime) - const currentStart = toDateTime(startDate, startTime) - - if (newEnd.isBefore(currentStart)) { - const duration = initialDurationRef.current ?? getCurrentDuration() - let newStart: Dayjs - - if (allday) { - newStart = newEnd.subtract(duration, 'day') - } else { - newStart = newEnd.subtract(duration, 'minute') - } - - const newStartDate = dtDate(newStart) - const newStartTime = dtTime(newStart) - - if (newStartDate !== startDate) { - onStartDateChange(newStartDate) - } - if (!allday && newStartTime !== startTime) { - onStartTimeChange(newStartTime) - } - } else { - initialDurationRef.current = newEnd.diff( - currentStart, - allday ? 'day' : 'minute' - ) - } - - onEndDateChange(newDateStr) - } - - const handleEndTimeChange = (value: PickerValue): void => { - if (!value || !value.isValid()) return - - isUserActionRef.current = true - const newTimeStr = dtTime(value as Dayjs) - const newEnd = toDateTime(endDate, newTimeStr) - const currentStart = toDateTime(startDate, startTime) - - // Always update duration ref to reflect current state, even if invalid (end < start) - // This prevents stale duration from being used when user edits start time - initialDurationRef.current = newEnd.diff(currentStart, 'minute') - - onEndTimeChange(newTimeStr) - } - - // Memoize parsed date/time values - const startDateValue = useMemo( - () => (startDate ? dayjs(startDate) : null), - [startDate] - ) - const startTimeValue = useMemo( - () => (startTime ? dayjs(startTime, 'HH:mm') : null), - [startTime] - ) - const endDateValue = useMemo( - () => (endDate ? dayjs(endDate) : null), - [endDate] - ) - const endTimeValue = useMemo( - () => (endTime ? dayjs(endTime, 'HH:mm') : null), - [endTime] - ) - - const getSlotProps = ( - testId: string, - hasError = false, - testLabel?: string - ): Partial> => ({ - textField: { - size: 'small' as const, - margin: 'dense' as const, - fullWidth: true, - InputLabelProps: { shrink: true }, - error: hasError, - sx: { width: '100%' }, - inputProps: { - 'data-testid': testId, - ...(testLabel ? { 'aria-label': testLabel } : {}) - } - } - }) - - const getFieldSlotProps = ( - testId: string, - hasError = false, - testLabel?: string - ): Partial & - Pick< - TextFieldProps, - | 'size' - | 'margin' - | 'fullWidth' - | 'InputLabelProps' - | 'error' - | 'sx' - | 'inputProps' - > => ({ - size: 'small' as const, - margin: 'dense' as const, - fullWidth: true, - InputLabelProps: { shrink: true }, - error: hasError, - sx: { width: '100%' }, - inputProps: { - 'data-testid': testId, - ...(testLabel ? { 'aria-label': testLabel } : {}) - } - }) - - const getTimeFieldSlotProps = ( - testId: string, - hasError = false, - testLabel?: string - ): Partial & - Pick< - TextFieldProps, - | 'size' - | 'margin' - | 'fullWidth' - | 'InputLabelProps' - | 'error' - | 'sx' - | 'inputProps' - > => ({ - size: 'small' as const, - margin: 'dense' as const, - fullWidth: true, - InputLabelProps: { shrink: true }, - error: hasError, - sx: { width: '100%' }, - inputProps: { - 'data-testid': testId, - ...(testLabel ? { 'aria-label': testLabel } : {}) - } - }) - - return ( - - - {isExpanded || shouldShowFullFieldsInNormal ? ( - <> - - - - - {shouldShowTimeFields && ( - - null - }} - slotProps={{ - openPickerButton: { sx: { display: 'none' } }, - popper: { sx: timePickerPopperSx }, - field: getTimeFieldSlotProps( - 'start-time-input', - false, - t('dateTimeFields.startTime') - ) - }} - /> - - )} - - - - - - {shouldShowTimeFields && ( - - null - }} - slotProps={{ - openPickerButton: { sx: { display: 'none' } }, - popper: { sx: timePickerPopperSx }, - field: getTimeFieldSlotProps( - 'end-time-input', - !!validation.errors.dateTime, - t('dateTimeFields.endTime') - ) - }} - /> - - )} - - - ) : shouldShowEndDateNormal ? ( - - - - - - - - - ) : ( - - - - - - - null - }} - slotProps={{ - openPickerButton: { sx: { display: 'none' } }, - popper: { sx: timePickerPopperSx }, - field: getTimeFieldSlotProps( - 'start-time-input', - false, - t('dateTimeFields.startTime') - ) - }} - /> - - {!allday && ( - - - - - )} - - null - }} - slotProps={{ - openPickerButton: { sx: { display: 'none' } }, - popper: { sx: timePickerPopperSx }, - field: getTimeFieldSlotProps( - 'end-time-input', - !!validation.errors.dateTime, - t('dateTimeFields.endTime') - ) - }} - /> - - - - )} - - {/* Second row: Error message */} - {validation.errors.dateTime && ( - - - - - {validation.errors.dateTime} - - - - )} - - - ) -} diff --git a/src/components/Event/components/DateTimeFields/AllDayDateLayout.tsx b/src/components/Event/components/DateTimeFields/AllDayDateLayout.tsx new file mode 100644 index 0000000..1f0a448 --- /dev/null +++ b/src/components/Event/components/DateTimeFields/AllDayDateLayout.tsx @@ -0,0 +1,61 @@ +import { Box } from '@linagora/twake-mui' +import { PickerValue } from '@mui/x-date-pickers/internals' +import dayjs from 'dayjs' +import React from 'react' +import { useI18n } from 'twake-i18n' +import { DatePickerField } from './DatePickerField' + +export interface AllDayDateLayoutProps { + startDateValue: ReturnType | null + endDateValue: ReturnType | null + hasError: boolean + isMobile: boolean + onStartDateChange: (value: PickerValue) => void + onEndDateChange: (value: PickerValue) => void +} + +/** + * Side-by-side date pickers with no time fields. + * Used for all-day events or when showEndDate is true. + */ +export const AllDayDateLayout: React.FC = ({ + startDateValue, + endDateValue, + hasError, + isMobile, + onStartDateChange, + onEndDateChange +}) => { + const { t } = useI18n() + + const containerProps = { + flexDirection: isMobile ? 'column' : 'row', + alignItems: isMobile ? 'stretch' : 'center' + } + + const dateBoxSx = isMobile + ? { width: '100%' } + : { maxWidth: '300px', width: '48%' } + + return ( + + + + + + + + + ) +} diff --git a/src/components/Event/components/DateTimeFields/CompactDateTimeLayout.tsx b/src/components/Event/components/DateTimeFields/CompactDateTimeLayout.tsx new file mode 100644 index 0000000..210578a --- /dev/null +++ b/src/components/Event/components/DateTimeFields/CompactDateTimeLayout.tsx @@ -0,0 +1,97 @@ +import { Box, Typography } from '@linagora/twake-mui' +import { PickerValue } from '@mui/x-date-pickers/internals' +import { Dayjs } from 'dayjs' +import React from 'react' +import { useI18n } from 'twake-i18n' +import { DatePickerField } from './DatePickerField' +import { TimePickerField } from './TimePickerField' + +export interface CompactDateTimeLayoutProps { + startDateValue: Dayjs | null + startTimeValue: Dayjs | null + endTimeValue: Dayjs | null + hasError: boolean + isMobile: boolean + allday: boolean + startDateLabel: string + onStartDateChange: (value: PickerValue) => void + onStartTimeChange: (value: PickerValue) => void + onEndTimeChange: (value: PickerValue) => void +} + +/** + * Default compact layout: one date field + start and end time pickers inline. + * Used when the event is not multi-day and has no explicit end date shown. + */ +export const CompactDateTimeLayout: React.FC = ({ + startDateValue, + startTimeValue, + endTimeValue, + hasError, + isMobile, + allday, + startDateLabel, + onStartDateChange, + onStartTimeChange, + onEndTimeChange +}) => { + const { t } = useI18n() + + const containerProps = { + flexDirection: isMobile ? 'column' : 'row', + alignItems: isMobile ? 'stretch' : 'center' + } + + const dateBoxSx = isMobile + ? { width: '100%' } + : { maxWidth: '300px', width: '48%' } + + const timeRowSx = isMobile ? { width: '100%' } : undefined + + const timeBoxSx = { + flex: isMobile ? 1 : undefined, + width: isMobile ? undefined : '110px' + } + + return ( + + + + + + + + + + + {!allday && ( + + - + + )} + + + + + + + ) +} diff --git a/src/components/Event/components/DateTimeFields/DatePickerField.tsx b/src/components/Event/components/DateTimeFields/DatePickerField.tsx new file mode 100644 index 0000000..97d92aa --- /dev/null +++ b/src/components/Event/components/DateTimeFields/DatePickerField.tsx @@ -0,0 +1,39 @@ +import { DatePicker } from '@mui/x-date-pickers/DatePicker' +import { PickerValue } from '@mui/x-date-pickers/internals' +import { Dayjs } from 'dayjs' +import React from 'react' +import { LONG_DATE_FORMAT } from '../../utils/dateTimeFormatters' +import { ReadOnlyDateField } from '../ReadOnlyPickerField' +import { + dateCalendarLayoutSx, + getDateFieldSlotProps, + getDateSlotProps +} from './dateTimePickerSlotProps' + +export interface DatePickerFieldProps { + value: Dayjs | null + onChange: (value: PickerValue) => void + testId: string + label: string + hasError?: boolean +} + +export const DatePickerField: React.FC = ({ + value, + onChange, + testId, + label, + hasError = false +}) => ( + +) diff --git a/src/components/Event/components/DateTimeFields/DateTimeError.tsx b/src/components/Event/components/DateTimeFields/DateTimeError.tsx new file mode 100644 index 0000000..7f1cc88 --- /dev/null +++ b/src/components/Event/components/DateTimeFields/DateTimeError.tsx @@ -0,0 +1,22 @@ +import { Box, Typography } from '@linagora/twake-mui' +import React from 'react' + +export interface DateTimeErrorProps { + message: string +} + +export const DateTimeError: React.FC = ({ message }) => { + if (!message) { + return null + } + return ( + + + + + {message} + + + + ) +} diff --git a/src/components/Event/components/DateTimeFields/DateTimeFields.tsx b/src/components/Event/components/DateTimeFields/DateTimeFields.tsx new file mode 100644 index 0000000..68d565c --- /dev/null +++ b/src/components/Event/components/DateTimeFields/DateTimeFields.tsx @@ -0,0 +1,165 @@ +import { useScreenSizeDetection } from '@/useScreenSizeDetection' +import { Box } from '@linagora/twake-mui' +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs' +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' +import classNames from 'classnames' +import dayjs from 'dayjs' +import 'dayjs/locale/en' +import 'dayjs/locale/fr' +import 'dayjs/locale/ru' +import 'dayjs/locale/vi' +import customParseFormat from 'dayjs/plugin/customParseFormat' +import React, { useMemo } from 'react' +import { useI18n } from 'twake-i18n' +import { DateTimeError } from './DateTimeError' +import { DateTimeLayoutContent } from './DateTimeLayoutContent' +import { useDateTimeHandlers } from './useDateTimeHandlers' +import { useDateTimeLayout } from './useDateTimeLayout' +import { useDisplayFlags } from './useDisplayFlags' + +dayjs.extend(customParseFormat) + +export interface DateTimeFieldsProps { + startDate: string + startTime: string + endDate: string + endTime: string + allday: boolean + showMore: boolean + hasEndDateChanged: boolean + showEndDate: boolean + onToggleEndDate: () => void + validation: { + errors: { + dateTime: string + } + } + onStartDateChange: (date: string) => void + onStartTimeChange: (time: string) => void + onEndDateChange: (date: string, time?: string) => void + onEndTimeChange: (time: string) => void +} + +/** + * DateTimeFields component - orchestrates date/time layout variants. + * Handles state, duration-preserving event handlers, and selects the + * appropriate layout sub-component based on the current mode. + */ +export const DateTimeFields: React.FC = ({ + startDate, + startTime, + endDate, + endTime, + allday, + showMore, + hasEndDateChanged, + showEndDate, + validation, + onStartDateChange, + onStartTimeChange, + onEndDateChange, + onEndTimeChange +}) => { + const { t, lang } = useI18n() + const { isTooSmall: isMobile } = useScreenSizeDetection() + + const { + spansMultipleDays, + shouldShowFullFieldsInNormal, + showSingleDateField, + shouldShowTimeFields + } = useDisplayFlags({ + allday, + hasEndDateChanged, + startDate, + endDate, + showEndDate, + isExpanded: showMore + }) + + const startDateLabel = showSingleDateField + ? t('dateTimeFields.date') + : t('dateTimeFields.startDate') + + const layoutMode = useDateTimeLayout({ + hasEndDateChanged, + allday, + spansMultipleDays, + showMore, + showEndDate + }) + + const { + handleStartDateChange, + handleStartTimeChange, + handleEndDateChange, + handleEndTimeChange + } = useDateTimeHandlers({ + startDate, + startTime, + endDate, + endTime, + allday, + onStartDateChange, + onStartTimeChange, + onEndDateChange, + onEndTimeChange + }) + + const startDateValue = useMemo( + () => (startDate ? dayjs(startDate) : null), + [startDate] + ) + const startTimeValue = useMemo( + () => (startTime ? dayjs(startTime, 'HH:mm') : null), + [startTime] + ) + const endDateValue = useMemo( + () => (endDate ? dayjs(endDate) : null), + [endDate] + ) + const endTimeValue = useMemo( + () => (endTime ? dayjs(endTime, 'HH:mm') : null), + [endTime] + ) + + const hasError = !!validation.errors.dateTime + + const showFullField = showMore || shouldShowFullFieldsInNormal + const containerClassName = classNames('date-time-group', { + 'show-full-field': showFullField + }) + + return ( + + + + + + + ) +} diff --git a/src/components/Event/components/DateTimeFields/DateTimeLayoutContent.tsx b/src/components/Event/components/DateTimeFields/DateTimeLayoutContent.tsx new file mode 100644 index 0000000..c01befe --- /dev/null +++ b/src/components/Event/components/DateTimeFields/DateTimeLayoutContent.tsx @@ -0,0 +1,42 @@ +import { PickerValue } from '@mui/x-date-pickers/internals' +import { Dayjs } from 'dayjs' +import { AllDayDateLayout } from './AllDayDateLayout' +import { CompactDateTimeLayout } from './CompactDateTimeLayout' +import { LayoutMode, LAYOUT_MODE } from './useDateTimeLayout' +import { ExpandedDateTimeLayout } from './ExpandedDateTimeLayout' + +interface StartDateTime { + startDateValue: Dayjs | null + startTimeValue: Dayjs | null + onStartDateChange: (v: PickerValue) => void + onStartTimeChange: (v: PickerValue) => void +} + +interface EndDateTime { + endDateValue: Dayjs | null + endTimeValue: Dayjs | null + onEndDateChange: (v: PickerValue) => void + onEndTimeChange: (v: PickerValue) => void +} + +export interface DateTimeLayoutContentProps extends StartDateTime, EndDateTime { + layoutMode: LayoutMode + hasError: boolean + isMobile: boolean + allday: boolean + startDateLabel: string + shouldShowTimeFields: boolean +} + +export function DateTimeLayoutContent({ + layoutMode, + ...props +}: DateTimeLayoutContentProps): JSX.Element { + if (layoutMode === LAYOUT_MODE.EXPANDED) { + return + } + if (layoutMode === LAYOUT_MODE.ALL_DAY) { + return + } + return +} diff --git a/src/components/Event/components/DateTimeFields/ExpandedDateTimeLayout.tsx b/src/components/Event/components/DateTimeFields/ExpandedDateTimeLayout.tsx new file mode 100644 index 0000000..af059df --- /dev/null +++ b/src/components/Event/components/DateTimeFields/ExpandedDateTimeLayout.tsx @@ -0,0 +1,97 @@ +import { Box } from '@linagora/twake-mui' +import { PickerValue } from '@mui/x-date-pickers/internals' +import dayjs from 'dayjs' +import React from 'react' +import { useI18n } from 'twake-i18n' +import { DatePickerField } from './DatePickerField' +import { TimePickerField } from './TimePickerField' + +export interface ExpandedDateTimeLayoutProps { + startDateValue: ReturnType | null + startTimeValue: ReturnType | null + endDateValue: ReturnType | null + endTimeValue: ReturnType | null + hasError: boolean + isMobile: boolean + shouldShowTimeFields: boolean + onStartDateChange: (value: PickerValue) => void + onStartTimeChange: (value: PickerValue) => void + onEndDateChange: (value: PickerValue) => void + onEndTimeChange: (value: PickerValue) => void +} + +/** + * Two-row layout used when showMore (expanded) or shouldShowFullFieldsInNormal is true. + * Row 1: start date + start time + * Row 2: end date + end time + */ +export const ExpandedDateTimeLayout: React.FC = ({ + startDateValue, + startTimeValue, + endDateValue, + endTimeValue, + hasError, + isMobile, + shouldShowTimeFields, + onStartDateChange, + onStartTimeChange, + onEndDateChange, + onEndTimeChange +}) => { + const { t } = useI18n() + const containerProps = { + alignItems: isMobile ? 'stretch' : 'center' + } + + const dateBoxSx = isMobile + ? { width: '100%' } + : { maxWidth: '300px', width: '48%' } + + return ( + <> + + + + + {shouldShowTimeFields && ( + + + + )} + + + + + + + {shouldShowTimeFields && ( + + + + )} + + + ) +} diff --git a/src/components/Event/components/DateTimeFields/TimePickerField.tsx b/src/components/Event/components/DateTimeFields/TimePickerField.tsx new file mode 100644 index 0000000..bccbbb3 --- /dev/null +++ b/src/components/Event/components/DateTimeFields/TimePickerField.tsx @@ -0,0 +1,45 @@ +import { PickerValue } from '@mui/x-date-pickers/internals' +import { TimePicker } from '@mui/x-date-pickers/TimePicker' +import { Dayjs } from 'dayjs' +import React from 'react' +import { EditableTimeField } from '../EditableTimeField' +import { + getTimeFieldSlotProps, + timePickerPopperSx +} from './dateTimePickerSlotProps' + +export interface TimePickerFieldProps { + value: Dayjs | null + onChange: (value: PickerValue) => void + testId: string + label: string + hasError?: boolean + disabled?: boolean +} + +export const TimePickerField: React.FC = ({ + value, + onChange, + testId, + label, + hasError = false, + disabled = false +}) => ( + null + }} + slotProps={{ + openPickerButton: { sx: { display: 'none' } }, + popper: { sx: timePickerPopperSx }, + field: getTimeFieldSlotProps(testId, hasError, label) + }} + /> +) diff --git a/src/components/Event/components/DateTimeFields/dateTimePickerSlotProps.ts b/src/components/Event/components/DateTimeFields/dateTimePickerSlotProps.ts new file mode 100644 index 0000000..5b129dd --- /dev/null +++ b/src/components/Event/components/DateTimeFields/dateTimePickerSlotProps.ts @@ -0,0 +1,94 @@ +import { TextFieldProps } from '@linagora/twake-mui' +import { + DatePickerFieldProps, + DatePickerSlotProps +} from '@mui/x-date-pickers/DatePicker' +import { TimePickerFieldProps } from '@mui/x-date-pickers/TimePicker' + +export const dateCalendarLayoutSx = { + '& .MuiDateCalendar-root.MuiDateCalendar-root': { + width: '260px', + maxWidth: '260px', + padding: '0 15px' + } +} + +export const timePickerPopperSx = { + '& .MuiPaper-root': { + width: '110px', + minWidth: '110px' + }, + '& .MuiMultiSectionDigitalClockSection-item': { + justifyContent: 'flex-start', + width: '100%', + textAlign: 'left' + } +} + +const baseFieldProps = ( + testId: string, + hasError = false, + label?: string +): Pick< + TextFieldProps, + | 'size' + | 'margin' + | 'fullWidth' + | 'InputLabelProps' + | 'error' + | 'sx' + | 'inputProps' +> => ({ + size: 'small' as const, + margin: 'dense' as const, + fullWidth: true, + InputLabelProps: { shrink: true }, + error: hasError, + sx: { width: '100%' }, + inputProps: { + 'data-testid': testId, + ...(label ? { 'aria-label': label } : {}) + } +}) + +export const getDateSlotProps = ( + testId: string, + hasError = false, + label?: string +): Partial> => ({ + textField: { + ...baseFieldProps(testId, hasError, label) + } +}) + +export const getDateFieldSlotProps = ( + testId: string, + hasError = false, + label?: string +): Partial & + Pick< + TextFieldProps, + | 'size' + | 'margin' + | 'fullWidth' + | 'InputLabelProps' + | 'error' + | 'sx' + | 'inputProps' + > => baseFieldProps(testId, hasError, label) + +export const getTimeFieldSlotProps = ( + testId: string, + hasError = false, + label?: string +): Partial & + Pick< + TextFieldProps, + | 'size' + | 'margin' + | 'fullWidth' + | 'InputLabelProps' + | 'error' + | 'sx' + | 'inputProps' + > => baseFieldProps(testId, hasError, label) diff --git a/src/components/Event/components/DateTimeFields/useDateTimeHandlers.tsx b/src/components/Event/components/DateTimeFields/useDateTimeHandlers.tsx new file mode 100644 index 0000000..93304fe --- /dev/null +++ b/src/components/Event/components/DateTimeFields/useDateTimeHandlers.tsx @@ -0,0 +1,176 @@ +import { PickerValue } from '@mui/x-date-pickers/internals' +import { Dayjs } from 'dayjs' +import React from 'react' +import { dtDate, dtTime, toDateTime } from '../../utils/dateTimeHelpers' + +type DurationUnit = 'day' | 'minute' + +export interface DateTimeHandlersProps { + startDate: string + startTime: string + endDate: string + endTime: string + allday: boolean + onStartDateChange: (date: string) => void + onStartTimeChange: (time: string) => void + onEndDateChange: (date: string, time?: string) => void + onEndTimeChange: (time: string) => void +} + +function applyDateTimeUpdate({ + newDate, + newTime, + currentDate, + currentTime, + onDateChange, + onTimeChange +}: { + newDate: string + newTime: string + currentDate: string + currentTime: string + onDateChange: (date: string) => void + onTimeChange?: (time: string) => void +}): void { + if (newDate !== currentDate) onDateChange(newDate) + if (onTimeChange && newTime !== currentTime) onTimeChange(newTime) +} + +export function useDateTimeHandlers(props: DateTimeHandlersProps): { + handleStartDateChange: (value: PickerValue) => void + handleStartTimeChange: (value: PickerValue) => void + handleEndDateChange: (value: PickerValue) => void + handleEndTimeChange: (value: PickerValue) => void +} { + const { + startDate, + startTime, + endDate, + endTime, + allday, + onStartDateChange, + onStartTimeChange, + onEndDateChange, + onEndTimeChange + } = props + + const initialDurationRef = React.useRef(null) + const isUserActionRef = React.useRef(false) + + const durationUnit: DurationUnit = allday ? 'day' : 'minute' + + const getCurrentDuration = React.useCallback((): number => { + const start = toDateTime(startDate, startTime) + const end = toDateTime(endDate, endTime) + const duration = allday + ? end.startOf('day').diff(start.startOf('day'), 'day') + : end.diff(start, 'minute') + return Math.max(duration, 0) + }, [startDate, startTime, endDate, endTime, allday]) + + React.useEffect(() => { + const isExternalUpdate = !isUserActionRef.current + const hasBothDates = startDate && endDate + if (isExternalUpdate && hasBothDates) { + initialDurationRef.current = getCurrentDuration() + } + queueMicrotask(() => { + isUserActionRef.current = false + }) + }, [startDate, endDate, startTime, endTime, getCurrentDuration]) + + const handleStartDateChange = (value: PickerValue): void => { + if (!value?.isValid()) return + + isUserActionRef.current = true + const newDateStr = dtDate(value as Dayjs) + const duration = initialDurationRef.current ?? getCurrentDuration() + + onStartDateChange(newDateStr) + + const newStart = toDateTime(newDateStr, startTime) + const newEnd = newStart.add(duration, durationUnit) + + applyDateTimeUpdate({ + newDate: dtDate(newEnd), + newTime: dtTime(newEnd), + currentDate: endDate, + currentTime: endTime, + onDateChange: onEndDateChange, + onTimeChange: allday ? undefined : onEndTimeChange + }) + } + + const handleStartTimeChange = (value: PickerValue): void => { + if (!value?.isValid()) return + + isUserActionRef.current = true + const newTimeStr = dtTime(value as Dayjs) + const duration = initialDurationRef.current ?? getCurrentDuration() + + onStartTimeChange(newTimeStr) + + const newStart = toDateTime(startDate, newTimeStr) + const newEnd = newStart.add(duration, 'minute') + + const newEndDate = dtDate(newEnd) + const newEndTime = dtTime(newEnd) + const hasDateChanged = newEndDate !== endDate + const hasTimeChanged = newEndTime !== endTime + + if (hasDateChanged && hasTimeChanged) { + onEndDateChange(newEndDate, newEndTime) + return + } + if (hasDateChanged) onEndDateChange(newEndDate) + if (!allday && hasTimeChanged) onEndTimeChange(newEndTime) + } + + const handleEndDateChange = (value: PickerValue): void => { + if (!value?.isValid()) return + + isUserActionRef.current = true + const newDateStr = dtDate(value as Dayjs) + const newEnd = toDateTime(newDateStr, endTime) + const currentStart = toDateTime(startDate, startTime) + + if (!newEnd.isBefore(currentStart)) { + initialDurationRef.current = newEnd.diff(currentStart, durationUnit) + onEndDateChange(newDateStr) + return + } + + const duration = initialDurationRef.current ?? getCurrentDuration() + const newStart = newEnd.subtract(duration, durationUnit) + + applyDateTimeUpdate({ + newDate: dtDate(newStart), + newTime: dtTime(newStart), + currentDate: startDate, + currentTime: startTime, + onDateChange: onStartDateChange, + onTimeChange: allday ? undefined : onStartTimeChange + }) + + onEndDateChange(newDateStr) + } + + const handleEndTimeChange = (value: PickerValue): void => { + if (!value?.isValid()) return + + isUserActionRef.current = true + const newTimeStr = dtTime(value as Dayjs) + const newEnd = toDateTime(endDate, newTimeStr) + const currentStart = toDateTime(startDate, startTime) + + initialDurationRef.current = newEnd.diff(currentStart, 'minute') + onEndTimeChange(newTimeStr) + } + + return { + handleStartDateChange, + handleStartTimeChange, + handleEndDateChange, + handleEndTimeChange + } +} diff --git a/src/components/Event/components/DateTimeFields/useDateTimeLayout.tsx b/src/components/Event/components/DateTimeFields/useDateTimeLayout.tsx new file mode 100644 index 0000000..01d2993 --- /dev/null +++ b/src/components/Event/components/DateTimeFields/useDateTimeLayout.tsx @@ -0,0 +1,34 @@ +import { useMemo } from 'react' + +export const LAYOUT_MODE = { + EXPANDED: 'expanded', + ALL_DAY: 'allday', + COMPACT: 'compact' +} as const + +export type LayoutMode = (typeof LAYOUT_MODE)[keyof typeof LAYOUT_MODE] + +export interface DateTimeLayoutProps { + hasEndDateChanged: boolean + allday: boolean + spansMultipleDays: boolean + showMore: boolean + showEndDate: boolean +} + +export function useDateTimeLayout({ + hasEndDateChanged, + allday, + spansMultipleDays, + showMore, + showEndDate +}: DateTimeLayoutProps): LayoutMode { + return useMemo(() => { + const isSpecialRange = hasEndDateChanged || (!allday && spansMultipleDays) + if (showMore || isSpecialRange) { + return LAYOUT_MODE.EXPANDED + } + if (allday || showEndDate) return LAYOUT_MODE.ALL_DAY + return LAYOUT_MODE.COMPACT + }, [hasEndDateChanged, allday, spansMultipleDays, showMore, showEndDate]) +} diff --git a/src/components/Event/components/DateTimeFields/useDisplayFlags.tsx b/src/components/Event/components/DateTimeFields/useDisplayFlags.tsx new file mode 100644 index 0000000..7ddad9b --- /dev/null +++ b/src/components/Event/components/DateTimeFields/useDisplayFlags.tsx @@ -0,0 +1,54 @@ +import { useMemo } from 'react' + +export interface DisplayFlagsProps { + allday: boolean + hasEndDateChanged: boolean + startDate: string + endDate: string + showEndDate: boolean + isExpanded: boolean +} + +export function useDisplayFlags({ + allday, + hasEndDateChanged, + startDate, + endDate, + showEndDate, + isExpanded +}: DisplayFlagsProps): { + spansMultipleDays: boolean + shouldShowFullFieldsInNormal: boolean + showSingleDateField: boolean + shouldShowTimeFields: boolean +} { + const spansMultipleDays = useMemo( + () => startDate !== endDate, + [startDate, endDate] + ) + const isTimedEvent = useMemo(() => !allday, [allday]) + const shouldShowEndDateNormal = useMemo( + () => allday || showEndDate, + [allday, showEndDate] + ) + const shouldShowFullFieldsInNormal = useMemo( + () => hasEndDateChanged || (spansMultipleDays && isTimedEvent), + [hasEndDateChanged, spansMultipleDays, isTimedEvent] + ) + const showSingleDateField = useMemo( + () => + !isExpanded && !shouldShowEndDateNormal && !shouldShowFullFieldsInNormal, + [isExpanded, shouldShowEndDateNormal, shouldShowFullFieldsInNormal] + ) + const shouldShowTimeFields = useMemo( + () => isTimedEvent || shouldShowFullFieldsInNormal, + [isTimedEvent, shouldShowFullFieldsInNormal] + ) + + return { + spansMultipleDays, + shouldShowFullFieldsInNormal, + showSingleDateField, + shouldShowTimeFields + } +} diff --git a/src/components/Timezone/SmallTimeZoneSelector.tsx b/src/components/Timezone/SmallTimeZoneSelector.tsx index b2ab3d4..dcdb246 100644 --- a/src/components/Timezone/SmallTimeZoneSelector.tsx +++ b/src/components/Timezone/SmallTimeZoneSelector.tsx @@ -4,7 +4,8 @@ import { Box, TextField, List, - InputAdornment + InputAdornment, + useTheme } from '@linagora/twake-mui' import { Search as SearchIcon } from '@mui/icons-material' import React, { useState, useMemo, useEffect, useRef } from 'react' @@ -34,6 +35,8 @@ export const SmallTimezoneSelector: React.FC< > = ({ value, onChange, referenceDate, onClose, open }) => { const { t } = useI18n() + const theme = useTheme() + const [searchQuery, setSearchQuery] = useState('') const timezoneList = useTimeZoneList() @@ -118,6 +121,7 @@ export const SmallTimezoneSelector: React.FC< sx: { height: '90%', maxHeight: '90dvh' } } }} + sx={{ zIndex: theme.zIndex.modal + 100 }} >