709 adapt creation modal to mobile view (#744)
* [#709] adapt creation modal to mobile view * [#709] extracted time and date pickers to relevant subcomponents * [#709] add bottom sheet to creation modal * [#709] set autocomplete to off in title and location * [#709] remove autofocus in event update modal
This commit is contained in:
@@ -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'
|
||||
|
||||
|
||||
Generated
+17
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -192,7 +192,7 @@ function ResponsiveDialog({
|
||||
{...otherDialogProps}
|
||||
>
|
||||
<DialogTitle sx={titleSx} {...dialogTitleProps}>
|
||||
{isExpanded && onExpandToggle ? (
|
||||
{isExpanded && onExpandToggle && !isMobile ? (
|
||||
<IconButton
|
||||
onClick={onExpandToggle}
|
||||
aria-label="show less"
|
||||
@@ -209,7 +209,7 @@ function ResponsiveDialog({
|
||||
>
|
||||
<Box>{title}</Box>
|
||||
<Box>
|
||||
{onExpandToggle && (
|
||||
{onExpandToggle && !isMobile && (
|
||||
<IconButton
|
||||
onClick={onExpandToggle}
|
||||
aria-label="expand"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
|
||||
import { TextField } from '@linagora/twake-mui'
|
||||
import { Notes as NotesIcon } from '@mui/icons-material'
|
||||
import React from 'react'
|
||||
@@ -17,8 +18,9 @@ export function AddDescButton({
|
||||
showMore: boolean
|
||||
description: string
|
||||
setDescription: (d: string) => void
|
||||
}) {
|
||||
}): JSX.Element {
|
||||
const { t } = useI18n()
|
||||
const { isTooSmall: isMobile } = useScreenSizeDetection()
|
||||
const descriptionInputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -30,7 +32,7 @@ export function AddDescButton({
|
||||
const descriptionField = (
|
||||
<FieldWithLabel
|
||||
label={t('event.form.description')}
|
||||
isExpanded={showMore}
|
||||
isExpanded={showMore && !isMobile}
|
||||
sx={{ padding: 0, margin: 0 }}
|
||||
>
|
||||
<TextField
|
||||
|
||||
@@ -42,7 +42,7 @@ import { OwnerCaption } from '../Calendar/OwnerCaption'
|
||||
import { SnackbarAlert } from '../Loading/SnackBarAlert'
|
||||
import { TimezoneAutocomplete } from '../Timezone/TimezoneAutocomplete'
|
||||
import { AddDescButton } from './AddDescButton'
|
||||
import { DateTimeFields } from './components/DateTimeFields'
|
||||
import { DateTimeFields } from './components/DateTimeFields/DateTimeFields'
|
||||
import { DateTimeSummary } from './components/DateTimeSummary'
|
||||
import { FieldWithLabel } from './components/FieldWithLabel'
|
||||
import { SectionPreviewRow } from './components/SectionPreviewRow'
|
||||
@@ -51,6 +51,10 @@ import { useAllDayToggle } from './hooks/useAllDayToggle'
|
||||
import { combineDateTime, splitDateTime } from './utils/dateTimeHelpers'
|
||||
import { validateEventForm } from './utils/formValidation'
|
||||
import { Resource, ResourceSearch } from '../Attendees/ResourceSearch'
|
||||
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
|
||||
import { SmallTimezoneSelector } from '../Timezone/SmallTimeZoneSelector'
|
||||
import ArrowDropDown from '@mui/icons-material/ArrowDropDown'
|
||||
import { getTimezoneOffset } from '@/utils/timezone'
|
||||
|
||||
interface EventFormFieldsProps {
|
||||
// Form state
|
||||
@@ -171,9 +175,12 @@ export default function EventFormFields({
|
||||
onHasEndDateChangedChange,
|
||||
selectedResources,
|
||||
setSelectedResources
|
||||
}: EventFormFieldsProps) {
|
||||
}: EventFormFieldsProps): JSX.Element {
|
||||
const { t } = useI18n()
|
||||
|
||||
const { isTooSmall: isMobile } = useScreenSizeDetection()
|
||||
const [timezoneDrawerOpen, setTimezoneDrawerOpen] = React.useState(false)
|
||||
|
||||
// Internal state for 4 separate fields
|
||||
const [startDate, setStartDate] = React.useState('')
|
||||
const [startTime, setStartTime] = React.useState('')
|
||||
@@ -278,16 +285,16 @@ export default function EventFormFields({
|
||||
|
||||
// Auto-focus title field when modal opens (skip in test environment)
|
||||
React.useEffect(() => {
|
||||
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<void> => {
|
||||
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 (
|
||||
<>
|
||||
<FieldWithLabel
|
||||
label={showMore ? t('event.form.title') : ''}
|
||||
isExpanded={showMore}
|
||||
isExpanded={showMore && !isMobile}
|
||||
>
|
||||
<TextField
|
||||
fullWidth
|
||||
autoComplete="off"
|
||||
label=""
|
||||
inputProps={{ 'aria-label': t('event.form.title') }}
|
||||
placeholder={t('event.form.titlePlaceholder')}
|
||||
@@ -510,7 +526,7 @@ export default function EventFormFields({
|
||||
? ''
|
||||
: t('event.form.dateTime')
|
||||
}
|
||||
isExpanded={showMore}
|
||||
isExpanded={showMore && !isMobile}
|
||||
>
|
||||
{!showMore && !hasClickedDateTimeSection ? (
|
||||
<DateTimeSummary
|
||||
@@ -554,76 +570,109 @@ export default function EventFormFields({
|
||||
</FieldWithLabel>
|
||||
|
||||
{!(!showMore && !hasClickedDateTimeSection) && (
|
||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
||||
<Box display="flex" gap={2} alignItems="center">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox checked={allday} onChange={handleAllDayToggle} />
|
||||
}
|
||||
label={
|
||||
<Typography variant="h6">{t('event.form.allDay')}</Typography>
|
||||
}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={
|
||||
showRepeat ||
|
||||
(typeOfAction === 'solo' && !!repetition?.freq)
|
||||
}
|
||||
disabled={typeOfAction === 'solo'}
|
||||
onChange={() => {
|
||||
const newShowRepeat = !showRepeat
|
||||
setShowRepeat(newShowRepeat)
|
||||
if (newShowRepeat) {
|
||||
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)
|
||||
<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={() => {
|
||||
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={
|
||||
<Typography variant="h6">{t('event.form.repeat')}</Typography>
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
{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)}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<Typography variant="h6">{t('event.form.repeat')}</Typography>
|
||||
}
|
||||
/>
|
||||
<TimezoneAutocomplete
|
||||
value={timezone}
|
||||
onChange={setTimezone}
|
||||
zones={timezoneList.zones}
|
||||
getTimezoneOffset={(tzName: string) =>
|
||||
timezoneList.getTimezoneOffset(tzName, new Date(start))
|
||||
}
|
||||
showIcon={false}
|
||||
width={220}
|
||||
size="small"
|
||||
placeholder={t('event.form.timezonePlaceholder')}
|
||||
hideBorder
|
||||
inputPadding="8px 65px 8px 0px"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{!(!showMore && !hasClickedDateTimeSection) &&
|
||||
(showRepeat || (typeOfAction === 'solo' && repetition?.freq)) && (
|
||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
||||
<FieldWithLabel label=" " isExpanded={showMore && !isMobile}>
|
||||
<RepeatEvent
|
||||
repetition={repetition}
|
||||
eventStart={new Date(start)}
|
||||
@@ -635,7 +684,7 @@ export default function EventFormFields({
|
||||
|
||||
<FieldWithLabel
|
||||
label={showMore ? t('event.form.participants') : ''}
|
||||
isExpanded={showMore}
|
||||
isExpanded={showMore && !isMobile}
|
||||
>
|
||||
<AttendeeSelector
|
||||
attendees={attendees}
|
||||
@@ -651,7 +700,7 @@ export default function EventFormFields({
|
||||
|
||||
<FieldWithLabel
|
||||
label={showMore ? t('event.form.videoMeeting') : ''}
|
||||
isExpanded={showMore}
|
||||
isExpanded={showMore && !isMobile}
|
||||
>
|
||||
{!showMore ? (
|
||||
hasVideoConference && meetingLink ? (
|
||||
@@ -671,7 +720,7 @@ export default function EventFormFields({
|
||||
{t('event.form.joinVisioConference')}
|
||||
</Button>
|
||||
<IconButton
|
||||
onClick={handleCopyMeetingLink}
|
||||
onClick={() => 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')}
|
||||
</Button>
|
||||
<IconButton
|
||||
onClick={handleCopyMeetingLink}
|
||||
onClick={() => 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}
|
||||
/>
|
||||
|
||||
<FieldWithLabel
|
||||
label={
|
||||
showMore || hasClickedLocationSection ? t('event.form.location') : ''
|
||||
}
|
||||
isExpanded={showMore}
|
||||
isExpanded={showMore && !isMobile}
|
||||
>
|
||||
{!showMore && !hasClickedLocationSection ? (
|
||||
<SectionPreviewRow
|
||||
@@ -783,6 +833,7 @@ export default function EventFormFields({
|
||||
</SectionPreviewRow>
|
||||
) : (
|
||||
<TextField
|
||||
autoComplete="off"
|
||||
fullWidth
|
||||
label=""
|
||||
inputRef={locationInputRef}
|
||||
@@ -800,7 +851,7 @@ export default function EventFormFields({
|
||||
label={
|
||||
showMore || hasClickedCalendarSection ? t('event.form.calendar') : ''
|
||||
}
|
||||
isExpanded={showMore}
|
||||
isExpanded={showMore && !isMobile}
|
||||
>
|
||||
{!showMore && !hasClickedCalendarSection ? (
|
||||
<SectionPreviewRow
|
||||
@@ -865,7 +916,7 @@ export default function EventFormFields({
|
||||
<>
|
||||
<FieldWithLabel
|
||||
label={t('event.form.resource')}
|
||||
isExpanded={showMore}
|
||||
isExpanded={showMore && !isMobile}
|
||||
>
|
||||
<FormControl fullWidth margin="dense" size="small">
|
||||
<ResourceSearch
|
||||
@@ -882,7 +933,7 @@ export default function EventFormFields({
|
||||
|
||||
<FieldWithLabel
|
||||
label={t('event.form.notification')}
|
||||
isExpanded={showMore}
|
||||
isExpanded={showMore && !isMobile}
|
||||
>
|
||||
<FormControl fullWidth margin="dense" size="small">
|
||||
<Select
|
||||
@@ -934,7 +985,7 @@ export default function EventFormFields({
|
||||
|
||||
<FieldWithLabel
|
||||
label={t('event.form.showMeAs')}
|
||||
isExpanded={showMore}
|
||||
isExpanded={showMore && !isMobile}
|
||||
>
|
||||
<FormControl fullWidth margin="dense" size="small">
|
||||
<Select
|
||||
@@ -950,12 +1001,12 @@ export default function EventFormFields({
|
||||
|
||||
<FieldWithLabel
|
||||
label={t('event.form.visibleTo')}
|
||||
isExpanded={showMore}
|
||||
isExpanded={showMore && !isMobile}
|
||||
>
|
||||
<ToggleButtonGroup
|
||||
value={eventClass}
|
||||
exclusive
|
||||
onChange={(e, newValue) => {
|
||||
onChange={(_e, newValue: string) => {
|
||||
if (newValue !== null) {
|
||||
setEventClass(newValue)
|
||||
}
|
||||
|
||||
@@ -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<DateTimeFieldsProps> = ({
|
||||
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<number | null>(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<DatePickerSlotProps<true>> => ({
|
||||
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<DatePickerFieldProps | TimePickerFieldProps> &
|
||||
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<TimePickerFieldProps> &
|
||||
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 (
|
||||
<LocalizationProvider
|
||||
key={lang}
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale={lang ?? 'en'}
|
||||
localeText={{
|
||||
okButtonLabel: t('common.ok'),
|
||||
cancelButtonLabel: t('common.cancel'),
|
||||
todayButtonLabel: t('menubar.today')
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
display="flex"
|
||||
flexDirection="column"
|
||||
className={`date-time-group ${
|
||||
isExpanded || shouldShowFullFieldsInNormal ? 'show-full-field' : ''
|
||||
}`.trim()}
|
||||
sx={{ maxWidth: showMore ? 'calc(100% - 145px)' : '100%' }}
|
||||
>
|
||||
{isExpanded || shouldShowFullFieldsInNormal ? (
|
||||
<>
|
||||
<Box
|
||||
display="flex"
|
||||
gap={1}
|
||||
flexDirection={isMobile ? 'column' : 'row'}
|
||||
alignItems="center"
|
||||
>
|
||||
<Box sx={{ maxWidth: '300px', width: '48%' }}>
|
||||
<DatePicker
|
||||
format={LONG_DATE_FORMAT}
|
||||
value={startDateValue}
|
||||
onChange={handleStartDateChange}
|
||||
slots={{ field: ReadOnlyDateField }}
|
||||
slotProps={{
|
||||
...getSlotProps(
|
||||
'start-date-input',
|
||||
false,
|
||||
t('dateTimeFields.startDate')
|
||||
),
|
||||
field: getFieldSlotProps(
|
||||
'start-date-input',
|
||||
false,
|
||||
t('dateTimeFields.startDate')
|
||||
),
|
||||
layout: { sx: dateCalendarLayoutSx }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{shouldShowTimeFields && (
|
||||
<Box sx={{ width: '110px' }}>
|
||||
<TimePicker
|
||||
ampm={false}
|
||||
value={startTimeValue}
|
||||
onChange={handleStartTimeChange}
|
||||
thresholdToRenderTimeInASingleColumn={48}
|
||||
timeSteps={{ minutes: 30 }}
|
||||
slots={{
|
||||
field: EditableTimeField,
|
||||
actionBar: () => null
|
||||
}}
|
||||
slotProps={{
|
||||
openPickerButton: { sx: { display: 'none' } },
|
||||
popper: { sx: timePickerPopperSx },
|
||||
field: getTimeFieldSlotProps(
|
||||
'start-time-input',
|
||||
false,
|
||||
t('dateTimeFields.startTime')
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box
|
||||
display="flex"
|
||||
gap={1}
|
||||
flexDirection={isMobile ? 'column' : 'row'}
|
||||
alignItems={isMobile ? 'stretch' : 'center'}
|
||||
>
|
||||
<Box sx={{ maxWidth: '300px', width: '48%' }}>
|
||||
<DatePicker
|
||||
format={LONG_DATE_FORMAT}
|
||||
value={endDateValue}
|
||||
onChange={handleEndDateChange}
|
||||
slots={{ field: ReadOnlyDateField }}
|
||||
slotProps={{
|
||||
...getSlotProps(
|
||||
'end-date-input',
|
||||
!!validation.errors.dateTime,
|
||||
t('dateTimeFields.endDate')
|
||||
),
|
||||
field: getFieldSlotProps(
|
||||
'end-date-input',
|
||||
!!validation.errors.dateTime,
|
||||
t('dateTimeFields.endDate')
|
||||
),
|
||||
layout: { sx: dateCalendarLayoutSx }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{shouldShowTimeFields && (
|
||||
<Box sx={{ width: '110px' }}>
|
||||
<TimePicker
|
||||
ampm={false}
|
||||
value={endTimeValue}
|
||||
onChange={handleEndTimeChange}
|
||||
thresholdToRenderTimeInASingleColumn={48}
|
||||
timeSteps={{ minutes: 30 }}
|
||||
slots={{
|
||||
field: EditableTimeField,
|
||||
actionBar: () => null
|
||||
}}
|
||||
slotProps={{
|
||||
openPickerButton: { sx: { display: 'none' } },
|
||||
popper: { sx: timePickerPopperSx },
|
||||
field: getTimeFieldSlotProps(
|
||||
'end-time-input',
|
||||
!!validation.errors.dateTime,
|
||||
t('dateTimeFields.endTime')
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
) : shouldShowEndDateNormal ? (
|
||||
<Box
|
||||
display="flex"
|
||||
gap={1}
|
||||
flexDirection={isMobile ? 'column' : 'row'}
|
||||
alignItems={isMobile ? 'stretch' : 'center'}
|
||||
>
|
||||
<Box sx={{ maxWidth: '300px', width: '48%' }}>
|
||||
<DatePicker
|
||||
format={LONG_DATE_FORMAT}
|
||||
value={startDateValue}
|
||||
onChange={handleStartDateChange}
|
||||
slots={{ field: ReadOnlyDateField }}
|
||||
slotProps={{
|
||||
...getSlotProps(
|
||||
'start-date-input',
|
||||
false,
|
||||
t('dateTimeFields.startDate')
|
||||
),
|
||||
field: getFieldSlotProps(
|
||||
'start-date-input',
|
||||
false,
|
||||
t('dateTimeFields.startDate')
|
||||
),
|
||||
layout: { sx: dateCalendarLayoutSx }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ maxWidth: '300px', width: '48%' }}>
|
||||
<DatePicker
|
||||
format={LONG_DATE_FORMAT}
|
||||
value={endDateValue}
|
||||
onChange={handleEndDateChange}
|
||||
slots={{ field: ReadOnlyDateField }}
|
||||
slotProps={{
|
||||
...getSlotProps(
|
||||
'end-date-input',
|
||||
!!validation.errors.dateTime,
|
||||
t('dateTimeFields.endDate')
|
||||
),
|
||||
field: getFieldSlotProps(
|
||||
'end-date-input',
|
||||
!!validation.errors.dateTime,
|
||||
t('dateTimeFields.endDate')
|
||||
),
|
||||
layout: { sx: dateCalendarLayoutSx }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box
|
||||
display="flex"
|
||||
gap={1}
|
||||
flexDirection={isMobile ? 'column' : 'row'}
|
||||
alignItems={isMobile ? 'stretch' : 'center'}
|
||||
>
|
||||
<Box
|
||||
sx={isMobile ? undefined : { maxWidth: '300px', width: '48%' }}
|
||||
>
|
||||
<DatePicker
|
||||
format={LONG_DATE_FORMAT}
|
||||
value={startDateValue}
|
||||
onChange={handleStartDateChange}
|
||||
slots={{ field: ReadOnlyDateField }}
|
||||
slotProps={{
|
||||
...getSlotProps('start-date-input', false, startDateLabel),
|
||||
field: getFieldSlotProps(
|
||||
'start-date-input',
|
||||
false,
|
||||
startDateLabel
|
||||
),
|
||||
layout: { sx: dateCalendarLayoutSx }
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box display="flex" gap={1} flexDirection="row">
|
||||
<Box sx={{ width: isMobile ? '100%' : '110px' }}>
|
||||
<TimePicker
|
||||
ampm={false}
|
||||
value={startTimeValue}
|
||||
onChange={handleStartTimeChange}
|
||||
disabled={allday}
|
||||
thresholdToRenderTimeInASingleColumn={48}
|
||||
timeSteps={{ minutes: 30 }}
|
||||
slots={{
|
||||
field: EditableTimeField,
|
||||
actionBar: () => null
|
||||
}}
|
||||
slotProps={{
|
||||
openPickerButton: { sx: { display: 'none' } },
|
||||
popper: { sx: timePickerPopperSx },
|
||||
field: getTimeFieldSlotProps(
|
||||
'start-time-input',
|
||||
false,
|
||||
t('dateTimeFields.startTime')
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
{!allday && (
|
||||
<Typography
|
||||
sx={{
|
||||
alignSelf: 'center',
|
||||
mx: 0.5,
|
||||
mt: 0.5
|
||||
}}
|
||||
>
|
||||
-
|
||||
</Typography>
|
||||
)}
|
||||
<Box sx={{ width: isMobile ? '100%' : '110px' }}>
|
||||
<TimePicker
|
||||
ampm={false}
|
||||
value={endTimeValue}
|
||||
onChange={handleEndTimeChange}
|
||||
disabled={allday}
|
||||
thresholdToRenderTimeInASingleColumn={48}
|
||||
timeSteps={{ minutes: 30 }}
|
||||
slots={{
|
||||
field: EditableTimeField,
|
||||
actionBar: () => null
|
||||
}}
|
||||
slotProps={{
|
||||
openPickerButton: { sx: { display: 'none' } },
|
||||
popper: { sx: timePickerPopperSx },
|
||||
field: getTimeFieldSlotProps(
|
||||
'end-time-input',
|
||||
!!validation.errors.dateTime,
|
||||
t('dateTimeFields.endTime')
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Second row: Error message */}
|
||||
{validation.errors.dateTime && (
|
||||
<Box display="flex" gap={1} flexDirection="row">
|
||||
<Box
|
||||
sx={{
|
||||
width: '1%'
|
||||
}}
|
||||
/>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'error.main' }}>
|
||||
{validation.errors.dateTime}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof dayjs> | null
|
||||
endDateValue: ReturnType<typeof dayjs> | 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<AllDayDateLayoutProps> = ({
|
||||
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 (
|
||||
<Box display="flex" gap={1} sx={containerProps}>
|
||||
<Box sx={dateBoxSx}>
|
||||
<DatePickerField
|
||||
value={startDateValue}
|
||||
onChange={onStartDateChange}
|
||||
testId="start-date-input"
|
||||
label={t('dateTimeFields.startDate')}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={dateBoxSx}>
|
||||
<DatePickerField
|
||||
value={endDateValue}
|
||||
onChange={onEndDateChange}
|
||||
testId="end-date-input"
|
||||
label={t('dateTimeFields.endDate')}
|
||||
hasError={hasError}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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<CompactDateTimeLayoutProps> = ({
|
||||
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 (
|
||||
<Box display="flex" gap={1} sx={containerProps}>
|
||||
<Box sx={dateBoxSx}>
|
||||
<DatePickerField
|
||||
value={startDateValue}
|
||||
onChange={onStartDateChange}
|
||||
testId="start-date-input"
|
||||
label={startDateLabel}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box display="flex" gap={1} flexDirection="row" sx={timeRowSx}>
|
||||
<Box sx={timeBoxSx}>
|
||||
<TimePickerField
|
||||
value={startTimeValue}
|
||||
onChange={onStartTimeChange}
|
||||
testId="start-time-input"
|
||||
label={t('dateTimeFields.startTime')}
|
||||
disabled={allday}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!allday && (
|
||||
<Typography sx={{ alignSelf: 'center', mx: 0.5, mt: 0.5 }}>
|
||||
-
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={timeBoxSx}>
|
||||
<TimePickerField
|
||||
value={endTimeValue}
|
||||
onChange={onEndTimeChange}
|
||||
testId="end-time-input"
|
||||
label={t('dateTimeFields.endTime')}
|
||||
hasError={hasError}
|
||||
disabled={allday}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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<DatePickerFieldProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
testId,
|
||||
label,
|
||||
hasError = false
|
||||
}) => (
|
||||
<DatePicker
|
||||
format={LONG_DATE_FORMAT}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
slots={{ field: ReadOnlyDateField }}
|
||||
slotProps={{
|
||||
...getDateSlotProps(testId, hasError, label),
|
||||
field: getDateFieldSlotProps(testId, hasError, label),
|
||||
layout: { sx: dateCalendarLayoutSx }
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -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<DateTimeErrorProps> = ({ message }) => {
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Box display="flex" gap={1} flexDirection="row">
|
||||
<Box sx={{ width: '1%' }} />
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'error.main' }}>
|
||||
{message}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -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<DateTimeFieldsProps> = ({
|
||||
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 (
|
||||
<LocalizationProvider
|
||||
key={lang}
|
||||
dateAdapter={AdapterDayjs}
|
||||
adapterLocale={lang ?? 'en'}
|
||||
localeText={{
|
||||
okButtonLabel: t('common.ok'),
|
||||
cancelButtonLabel: t('common.cancel'),
|
||||
todayButtonLabel: t('menubar.today')
|
||||
}}
|
||||
>
|
||||
<Box display="flex" flexDirection="column" className={containerClassName}>
|
||||
<DateTimeLayoutContent
|
||||
layoutMode={layoutMode}
|
||||
startDateValue={startDateValue}
|
||||
startTimeValue={startTimeValue}
|
||||
endDateValue={endDateValue}
|
||||
endTimeValue={endTimeValue}
|
||||
hasError={hasError}
|
||||
isMobile={isMobile}
|
||||
allday={allday}
|
||||
startDateLabel={startDateLabel}
|
||||
shouldShowTimeFields={shouldShowTimeFields}
|
||||
onStartDateChange={handleStartDateChange}
|
||||
onStartTimeChange={handleStartTimeChange}
|
||||
onEndDateChange={handleEndDateChange}
|
||||
onEndTimeChange={handleEndTimeChange}
|
||||
/>
|
||||
<DateTimeError message={validation.errors.dateTime} />
|
||||
</Box>
|
||||
</LocalizationProvider>
|
||||
)
|
||||
}
|
||||
@@ -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 <ExpandedDateTimeLayout {...props} />
|
||||
}
|
||||
if (layoutMode === LAYOUT_MODE.ALL_DAY) {
|
||||
return <AllDayDateLayout {...props} />
|
||||
}
|
||||
return <CompactDateTimeLayout {...props} />
|
||||
}
|
||||
@@ -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<typeof dayjs> | null
|
||||
startTimeValue: ReturnType<typeof dayjs> | null
|
||||
endDateValue: ReturnType<typeof dayjs> | null
|
||||
endTimeValue: ReturnType<typeof dayjs> | 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<ExpandedDateTimeLayoutProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<Box display="flex" gap={1} sx={containerProps}>
|
||||
<Box sx={dateBoxSx}>
|
||||
<DatePickerField
|
||||
value={startDateValue}
|
||||
onChange={onStartDateChange}
|
||||
testId="start-date-input"
|
||||
label={t('dateTimeFields.startDate')}
|
||||
/>
|
||||
</Box>
|
||||
{shouldShowTimeFields && (
|
||||
<Box width="110px">
|
||||
<TimePickerField
|
||||
value={startTimeValue}
|
||||
onChange={onStartTimeChange}
|
||||
testId="start-time-input"
|
||||
label={t('dateTimeFields.startTime')}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box display="flex" gap={1} sx={containerProps}>
|
||||
<Box sx={dateBoxSx}>
|
||||
<DatePickerField
|
||||
value={endDateValue}
|
||||
onChange={onEndDateChange}
|
||||
testId="end-date-input"
|
||||
label={t('dateTimeFields.endDate')}
|
||||
hasError={hasError}
|
||||
/>
|
||||
</Box>
|
||||
{shouldShowTimeFields && (
|
||||
<Box width="110px">
|
||||
<TimePickerField
|
||||
value={endTimeValue}
|
||||
onChange={onEndTimeChange}
|
||||
testId="end-time-input"
|
||||
label={t('dateTimeFields.endTime')}
|
||||
hasError={hasError}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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<TimePickerFieldProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
testId,
|
||||
label,
|
||||
hasError = false,
|
||||
disabled = false
|
||||
}) => (
|
||||
<TimePicker
|
||||
ampm={false}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
thresholdToRenderTimeInASingleColumn={48}
|
||||
timeSteps={{ minutes: 30 }}
|
||||
slots={{
|
||||
field: EditableTimeField,
|
||||
actionBar: () => null
|
||||
}}
|
||||
slotProps={{
|
||||
openPickerButton: { sx: { display: 'none' } },
|
||||
popper: { sx: timePickerPopperSx },
|
||||
field: getTimeFieldSlotProps(testId, hasError, label)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
@@ -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<DatePickerSlotProps<true>> => ({
|
||||
textField: {
|
||||
...baseFieldProps(testId, hasError, label)
|
||||
}
|
||||
})
|
||||
|
||||
export const getDateFieldSlotProps = (
|
||||
testId: string,
|
||||
hasError = false,
|
||||
label?: string
|
||||
): Partial<DatePickerFieldProps> &
|
||||
Pick<
|
||||
TextFieldProps,
|
||||
| 'size'
|
||||
| 'margin'
|
||||
| 'fullWidth'
|
||||
| 'InputLabelProps'
|
||||
| 'error'
|
||||
| 'sx'
|
||||
| 'inputProps'
|
||||
> => baseFieldProps(testId, hasError, label)
|
||||
|
||||
export const getTimeFieldSlotProps = (
|
||||
testId: string,
|
||||
hasError = false,
|
||||
label?: string
|
||||
): Partial<TimePickerFieldProps> &
|
||||
Pick<
|
||||
TextFieldProps,
|
||||
| 'size'
|
||||
| 'margin'
|
||||
| 'fullWidth'
|
||||
| 'InputLabelProps'
|
||||
| 'error'
|
||||
| 'sx'
|
||||
| 'inputProps'
|
||||
> => baseFieldProps(testId, hasError, label)
|
||||
@@ -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<number | null>(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
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }}
|
||||
>
|
||||
<Box sx={{ px: 2 }}>
|
||||
<TextField
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { formatEventChipTitle } from '@/components/Calendar/utils/calendarUtils'
|
||||
import { ResponsiveDialog } from '@/components/Dialog'
|
||||
import { DateTimeFields } from '@/components/Event/components/DateTimeFields'
|
||||
import { DateTimeFields } from '@/components/Event/components/DateTimeFields/DateTimeFields'
|
||||
import { FieldWithLabel } from '@/components/Event/components/FieldWithLabel'
|
||||
import { splitDateTime } from '@/components/Event/utils/dateTimeHelpers'
|
||||
import { Box, Button, TextField, Typography } from '@linagora/twake-mui'
|
||||
|
||||
Reference in New Issue
Block a user