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 {
|
import {
|
||||||
DateTimeFields,
|
DateTimeFields,
|
||||||
DateTimeFieldsProps
|
DateTimeFieldsProps
|
||||||
} from '@/components/Event/components/DateTimeFields'
|
} from '@/components/Event/components/DateTimeFields/DateTimeFields'
|
||||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
|
|
||||||
|
|||||||
Generated
+17
-2
@@ -24,6 +24,7 @@
|
|||||||
"@mui/x-date-pickers": "^8.14.0",
|
"@mui/x-date-pickers": "^8.14.0",
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"@sentry/react": "^8.55.1",
|
"@sentry/react": "^8.55.1",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
"cozy-external-bridge": "^1.2.1",
|
"cozy-external-bridge": "^1.2.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
@@ -3067,8 +3068,8 @@
|
|||||||
},
|
},
|
||||||
"node_modules/@linagora/twake-mui": {
|
"node_modules/@linagora/twake-mui": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://npm.pkg.github.com/download/@linagora/twake-mui/1.2.0/7b3eb115d70693ba2936ceaac70a8df3bae92672",
|
"resolved": "https://registry.npmjs.org/@linagora/twake-mui/-/twake-mui-1.2.0.tgz",
|
||||||
"integrity": "sha512-wKdkuJE48sFE5q2HWbrgI+FIZmBZefz5X5GAwiYHwvAD+9Fr/M+k973ZNSdaxwogWsQ604lZEBoIl13vKdc9EQ==",
|
"integrity": "sha512-urkpVOeCWpKNK4rdNrPUuUHCLMA60FG/7PocNCDp1Nn8GyjFeAWoxZNw980TJ785FjgExxELw363PQ0X78/mKw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
@@ -3083,6 +3084,14 @@
|
|||||||
"@mui/material": "^7.1.2",
|
"@mui/material": "^7.1.2",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0"
|
"react-dom": "^18.2.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"react": {
|
||||||
|
"optional": false
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lottiefiles/dotlottie-react": {
|
"node_modules/@lottiefiles/dotlottie-react": {
|
||||||
@@ -6284,6 +6293,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/cli-boxes": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"@mui/x-date-pickers": "^8.14.0",
|
"@mui/x-date-pickers": "^8.14.0",
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
"@sentry/react": "^8.55.1",
|
"@sentry/react": "^8.55.1",
|
||||||
|
"classnames": "^2.5.1",
|
||||||
"cozy-external-bridge": "^1.2.1",
|
"cozy-external-bridge": "^1.2.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ function ResponsiveDialog({
|
|||||||
{...otherDialogProps}
|
{...otherDialogProps}
|
||||||
>
|
>
|
||||||
<DialogTitle sx={titleSx} {...dialogTitleProps}>
|
<DialogTitle sx={titleSx} {...dialogTitleProps}>
|
||||||
{isExpanded && onExpandToggle ? (
|
{isExpanded && onExpandToggle && !isMobile ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={onExpandToggle}
|
onClick={onExpandToggle}
|
||||||
aria-label="show less"
|
aria-label="show less"
|
||||||
@@ -209,7 +209,7 @@ function ResponsiveDialog({
|
|||||||
>
|
>
|
||||||
<Box>{title}</Box>
|
<Box>{title}</Box>
|
||||||
<Box>
|
<Box>
|
||||||
{onExpandToggle && (
|
{onExpandToggle && !isMobile && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={onExpandToggle}
|
onClick={onExpandToggle}
|
||||||
aria-label="expand"
|
aria-label="expand"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useScreenSizeDetection } from '@/useScreenSizeDetection'
|
||||||
import { TextField } from '@linagora/twake-mui'
|
import { TextField } from '@linagora/twake-mui'
|
||||||
import { Notes as NotesIcon } from '@mui/icons-material'
|
import { Notes as NotesIcon } from '@mui/icons-material'
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
@@ -17,8 +18,9 @@ export function AddDescButton({
|
|||||||
showMore: boolean
|
showMore: boolean
|
||||||
description: string
|
description: string
|
||||||
setDescription: (d: string) => void
|
setDescription: (d: string) => void
|
||||||
}) {
|
}): JSX.Element {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
const { isTooSmall: isMobile } = useScreenSizeDetection()
|
||||||
const descriptionInputRef = React.useRef<HTMLTextAreaElement>(null)
|
const descriptionInputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -30,7 +32,7 @@ export function AddDescButton({
|
|||||||
const descriptionField = (
|
const descriptionField = (
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
label={t('event.form.description')}
|
label={t('event.form.description')}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore && !isMobile}
|
||||||
sx={{ padding: 0, margin: 0 }}
|
sx={{ padding: 0, margin: 0 }}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { OwnerCaption } from '../Calendar/OwnerCaption'
|
|||||||
import { SnackbarAlert } from '../Loading/SnackBarAlert'
|
import { SnackbarAlert } from '../Loading/SnackBarAlert'
|
||||||
import { TimezoneAutocomplete } from '../Timezone/TimezoneAutocomplete'
|
import { TimezoneAutocomplete } from '../Timezone/TimezoneAutocomplete'
|
||||||
import { AddDescButton } from './AddDescButton'
|
import { AddDescButton } from './AddDescButton'
|
||||||
import { DateTimeFields } from './components/DateTimeFields'
|
import { DateTimeFields } from './components/DateTimeFields/DateTimeFields'
|
||||||
import { DateTimeSummary } from './components/DateTimeSummary'
|
import { DateTimeSummary } from './components/DateTimeSummary'
|
||||||
import { FieldWithLabel } from './components/FieldWithLabel'
|
import { FieldWithLabel } from './components/FieldWithLabel'
|
||||||
import { SectionPreviewRow } from './components/SectionPreviewRow'
|
import { SectionPreviewRow } from './components/SectionPreviewRow'
|
||||||
@@ -51,6 +51,10 @@ import { useAllDayToggle } from './hooks/useAllDayToggle'
|
|||||||
import { combineDateTime, splitDateTime } from './utils/dateTimeHelpers'
|
import { combineDateTime, splitDateTime } from './utils/dateTimeHelpers'
|
||||||
import { validateEventForm } from './utils/formValidation'
|
import { validateEventForm } from './utils/formValidation'
|
||||||
import { Resource, ResourceSearch } from '../Attendees/ResourceSearch'
|
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 {
|
interface EventFormFieldsProps {
|
||||||
// Form state
|
// Form state
|
||||||
@@ -171,9 +175,12 @@ export default function EventFormFields({
|
|||||||
onHasEndDateChangedChange,
|
onHasEndDateChangedChange,
|
||||||
selectedResources,
|
selectedResources,
|
||||||
setSelectedResources
|
setSelectedResources
|
||||||
}: EventFormFieldsProps) {
|
}: EventFormFieldsProps): JSX.Element {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const { isTooSmall: isMobile } = useScreenSizeDetection()
|
||||||
|
const [timezoneDrawerOpen, setTimezoneDrawerOpen] = React.useState(false)
|
||||||
|
|
||||||
// Internal state for 4 separate fields
|
// Internal state for 4 separate fields
|
||||||
const [startDate, setStartDate] = React.useState('')
|
const [startDate, setStartDate] = React.useState('')
|
||||||
const [startTime, setStartTime] = 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)
|
// Auto-focus title field when modal opens (skip in test environment)
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen && !eventId) {
|
||||||
if (titleInputRef.current && process.env.NODE_ENV !== 'test') {
|
if (titleInputRef.current && process.env.NODE_ENV !== 'test') {
|
||||||
// Use setTimeout to ensure the dialog is fully rendered
|
// Use setTimeout to ensure the dialog is fully rendered
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
titleInputRef.current?.focus()
|
titleInputRef.current?.focus()
|
||||||
}, 100)
|
}, 100)
|
||||||
return () => clearTimeout(timer)
|
return (): void => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen, eventId])
|
||||||
|
|
||||||
// Auto-focus title field when toggling between normal and extended mode
|
// Auto-focus title field when toggling between normal and extended mode
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -309,7 +316,7 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
// Update previous value before returning cleanup
|
// Update previous value before returning cleanup
|
||||||
prevShowMoreRef.current = showMore
|
prevShowMoreRef.current = showMore
|
||||||
return () => clearTimeout(timer)
|
return (): void => clearTimeout(timer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -441,7 +448,7 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
const validation = validateForm()
|
const validation = validateForm()
|
||||||
|
|
||||||
const handleAddVideoConference = () => {
|
const handleAddVideoConference = (): void => {
|
||||||
const newMeetingLink = generateMeetingLink()
|
const newMeetingLink = generateMeetingLink()
|
||||||
const updatedDescription = addVideoConferenceToDescription(
|
const updatedDescription = addVideoConferenceToDescription(
|
||||||
description,
|
description,
|
||||||
@@ -457,7 +464,7 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
const [openToast, setOpenToast] = React.useState(false)
|
const [openToast, setOpenToast] = React.useState(false)
|
||||||
|
|
||||||
const handleCopyMeetingLink = async () => {
|
const handleCopyMeetingLink = async (): Promise<void> => {
|
||||||
if (meetingLink) {
|
if (meetingLink) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(meetingLink)
|
await navigator.clipboard.writeText(meetingLink)
|
||||||
@@ -468,29 +475,38 @@ export default function EventFormFields({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteVideoConference = () => {
|
const handleDeleteVideoConference = (): void => {
|
||||||
setDescription(removeVideoConferenceFromDescription(description))
|
setDescription(removeVideoConferenceFromDescription(description))
|
||||||
setHasVideoConference(false)
|
setHasVideoConference(false)
|
||||||
setMeetingLink(null)
|
setMeetingLink(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCalendarChange = (newCalendarId: string) => {
|
const handleCalendarChange = (newCalendarId: string): void => {
|
||||||
setCalendarid(newCalendarId)
|
setCalendarid(newCalendarId)
|
||||||
onCalendarChange?.(newCalendarId)
|
onCalendarChange?.(newCalendarId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResourceChange = (resources: Resource[]) => {
|
const handleResourceChange = (resources: Resource[]): void => {
|
||||||
setSelectedResources(resources)
|
setSelectedResources(resources)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const offset = getTimezoneOffset(
|
||||||
|
timezone ? timezone : timezoneList.browserTz,
|
||||||
|
new Date(start)
|
||||||
|
)
|
||||||
|
const tzLabel = timezone
|
||||||
|
? timezone.split('/').pop()?.replace(/_/g, ' ')
|
||||||
|
: timezoneList.browserTz
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
label={showMore ? t('event.form.title') : ''}
|
label={showMore ? t('event.form.title') : ''}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore && !isMobile}
|
||||||
>
|
>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
|
autoComplete="off"
|
||||||
label=""
|
label=""
|
||||||
inputProps={{ 'aria-label': t('event.form.title') }}
|
inputProps={{ 'aria-label': t('event.form.title') }}
|
||||||
placeholder={t('event.form.titlePlaceholder')}
|
placeholder={t('event.form.titlePlaceholder')}
|
||||||
@@ -510,7 +526,7 @@ export default function EventFormFields({
|
|||||||
? ''
|
? ''
|
||||||
: t('event.form.dateTime')
|
: t('event.form.dateTime')
|
||||||
}
|
}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore && !isMobile}
|
||||||
>
|
>
|
||||||
{!showMore && !hasClickedDateTimeSection ? (
|
{!showMore && !hasClickedDateTimeSection ? (
|
||||||
<DateTimeSummary
|
<DateTimeSummary
|
||||||
@@ -554,76 +570,109 @@ export default function EventFormFields({
|
|||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
|
|
||||||
{!(!showMore && !hasClickedDateTimeSection) && (
|
{!(!showMore && !hasClickedDateTimeSection) && (
|
||||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
<FieldWithLabel label=" " isExpanded={showMore && !isMobile}>
|
||||||
<Box display="flex" gap={2} alignItems="center">
|
<Box
|
||||||
<FormControlLabel
|
display="flex"
|
||||||
control={
|
gap={2}
|
||||||
<Checkbox checked={allday} onChange={handleAllDayToggle} />
|
alignItems={isMobile ? 'start' : 'center'}
|
||||||
}
|
flexDirection={isMobile ? 'column' : 'row'}
|
||||||
label={
|
>
|
||||||
<Typography variant="h6">{t('event.form.allDay')}</Typography>
|
<Box>
|
||||||
}
|
<FormControlLabel
|
||||||
/>
|
control={
|
||||||
<FormControlLabel
|
<Checkbox checked={allday} onChange={handleAllDayToggle} />
|
||||||
control={
|
}
|
||||||
<Checkbox
|
label={
|
||||||
checked={
|
<Typography variant="h6">{t('event.form.allDay')}</Typography>
|
||||||
showRepeat ||
|
}
|
||||||
(typeOfAction === 'solo' && !!repetition?.freq)
|
/>
|
||||||
}
|
<FormControlLabel
|
||||||
disabled={typeOfAction === 'solo'}
|
control={
|
||||||
onChange={() => {
|
<Checkbox
|
||||||
const newShowRepeat = !showRepeat
|
checked={
|
||||||
setShowRepeat(newShowRepeat)
|
showRepeat ||
|
||||||
if (newShowRepeat) {
|
(typeOfAction === 'solo' && !!repetition?.freq)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
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}
|
||||||
<TimezoneAutocomplete
|
zones={timezoneList.zones}
|
||||||
value={timezone}
|
getTimezoneOffset={(tzName: string) =>
|
||||||
onChange={setTimezone}
|
timezoneList.getTimezoneOffset(tzName, new Date(start))
|
||||||
zones={timezoneList.zones}
|
}
|
||||||
getTimezoneOffset={(tzName: string) =>
|
showIcon={false}
|
||||||
timezoneList.getTimezoneOffset(tzName, new Date(start))
|
width={220}
|
||||||
}
|
size="small"
|
||||||
showIcon={false}
|
placeholder={t('event.form.timezonePlaceholder')}
|
||||||
width={220}
|
hideBorder
|
||||||
size="small"
|
inputPadding="8px 65px 8px 0px"
|
||||||
placeholder={t('event.form.timezonePlaceholder')}
|
/>
|
||||||
hideBorder
|
)}
|
||||||
inputPadding="8px 65px 8px 0px"
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
</FieldWithLabel>
|
</FieldWithLabel>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!(!showMore && !hasClickedDateTimeSection) &&
|
{!(!showMore && !hasClickedDateTimeSection) &&
|
||||||
(showRepeat || (typeOfAction === 'solo' && repetition?.freq)) && (
|
(showRepeat || (typeOfAction === 'solo' && repetition?.freq)) && (
|
||||||
<FieldWithLabel label=" " isExpanded={showMore}>
|
<FieldWithLabel label=" " isExpanded={showMore && !isMobile}>
|
||||||
<RepeatEvent
|
<RepeatEvent
|
||||||
repetition={repetition}
|
repetition={repetition}
|
||||||
eventStart={new Date(start)}
|
eventStart={new Date(start)}
|
||||||
@@ -635,7 +684,7 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
label={showMore ? t('event.form.participants') : ''}
|
label={showMore ? t('event.form.participants') : ''}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore && !isMobile}
|
||||||
>
|
>
|
||||||
<AttendeeSelector
|
<AttendeeSelector
|
||||||
attendees={attendees}
|
attendees={attendees}
|
||||||
@@ -651,7 +700,7 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
label={showMore ? t('event.form.videoMeeting') : ''}
|
label={showMore ? t('event.form.videoMeeting') : ''}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore && !isMobile}
|
||||||
>
|
>
|
||||||
{!showMore ? (
|
{!showMore ? (
|
||||||
hasVideoConference && meetingLink ? (
|
hasVideoConference && meetingLink ? (
|
||||||
@@ -671,7 +720,7 @@ export default function EventFormFields({
|
|||||||
{t('event.form.joinVisioConference')}
|
{t('event.form.joinVisioConference')}
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleCopyMeetingLink}
|
onClick={() => void handleCopyMeetingLink()}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ color: 'primary.main' }}
|
sx={{ color: 'primary.main' }}
|
||||||
aria-label={t('event.form.copyMeetingLink')}
|
aria-label={t('event.form.copyMeetingLink')}
|
||||||
@@ -737,7 +786,7 @@ export default function EventFormFields({
|
|||||||
{t('event.form.joinVisioConference')}
|
{t('event.form.joinVisioConference')}
|
||||||
</Button>
|
</Button>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleCopyMeetingLink}
|
onClick={() => void handleCopyMeetingLink()}
|
||||||
size="small"
|
size="small"
|
||||||
sx={{ color: 'primary.main' }}
|
sx={{ color: 'primary.main' }}
|
||||||
aria-label={t('event.form.copyMeetingLink')}
|
aria-label={t('event.form.copyMeetingLink')}
|
||||||
@@ -766,13 +815,14 @@ export default function EventFormFields({
|
|||||||
showMore={showMore}
|
showMore={showMore}
|
||||||
description={description}
|
description={description}
|
||||||
setDescription={setDescription}
|
setDescription={setDescription}
|
||||||
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
label={
|
label={
|
||||||
showMore || hasClickedLocationSection ? t('event.form.location') : ''
|
showMore || hasClickedLocationSection ? t('event.form.location') : ''
|
||||||
}
|
}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore && !isMobile}
|
||||||
>
|
>
|
||||||
{!showMore && !hasClickedLocationSection ? (
|
{!showMore && !hasClickedLocationSection ? (
|
||||||
<SectionPreviewRow
|
<SectionPreviewRow
|
||||||
@@ -783,6 +833,7 @@ export default function EventFormFields({
|
|||||||
</SectionPreviewRow>
|
</SectionPreviewRow>
|
||||||
) : (
|
) : (
|
||||||
<TextField
|
<TextField
|
||||||
|
autoComplete="off"
|
||||||
fullWidth
|
fullWidth
|
||||||
label=""
|
label=""
|
||||||
inputRef={locationInputRef}
|
inputRef={locationInputRef}
|
||||||
@@ -800,7 +851,7 @@ export default function EventFormFields({
|
|||||||
label={
|
label={
|
||||||
showMore || hasClickedCalendarSection ? t('event.form.calendar') : ''
|
showMore || hasClickedCalendarSection ? t('event.form.calendar') : ''
|
||||||
}
|
}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore && !isMobile}
|
||||||
>
|
>
|
||||||
{!showMore && !hasClickedCalendarSection ? (
|
{!showMore && !hasClickedCalendarSection ? (
|
||||||
<SectionPreviewRow
|
<SectionPreviewRow
|
||||||
@@ -865,7 +916,7 @@ export default function EventFormFields({
|
|||||||
<>
|
<>
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
label={t('event.form.resource')}
|
label={t('event.form.resource')}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore && !isMobile}
|
||||||
>
|
>
|
||||||
<FormControl fullWidth margin="dense" size="small">
|
<FormControl fullWidth margin="dense" size="small">
|
||||||
<ResourceSearch
|
<ResourceSearch
|
||||||
@@ -882,7 +933,7 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
label={t('event.form.notification')}
|
label={t('event.form.notification')}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore && !isMobile}
|
||||||
>
|
>
|
||||||
<FormControl fullWidth margin="dense" size="small">
|
<FormControl fullWidth margin="dense" size="small">
|
||||||
<Select
|
<Select
|
||||||
@@ -934,7 +985,7 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
label={t('event.form.showMeAs')}
|
label={t('event.form.showMeAs')}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore && !isMobile}
|
||||||
>
|
>
|
||||||
<FormControl fullWidth margin="dense" size="small">
|
<FormControl fullWidth margin="dense" size="small">
|
||||||
<Select
|
<Select
|
||||||
@@ -950,12 +1001,12 @@ export default function EventFormFields({
|
|||||||
|
|
||||||
<FieldWithLabel
|
<FieldWithLabel
|
||||||
label={t('event.form.visibleTo')}
|
label={t('event.form.visibleTo')}
|
||||||
isExpanded={showMore}
|
isExpanded={showMore && !isMobile}
|
||||||
>
|
>
|
||||||
<ToggleButtonGroup
|
<ToggleButtonGroup
|
||||||
value={eventClass}
|
value={eventClass}
|
||||||
exclusive
|
exclusive
|
||||||
onChange={(e, newValue) => {
|
onChange={(_e, newValue: string) => {
|
||||||
if (newValue !== null) {
|
if (newValue !== null) {
|
||||||
setEventClass(newValue)
|
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,
|
Box,
|
||||||
TextField,
|
TextField,
|
||||||
List,
|
List,
|
||||||
InputAdornment
|
InputAdornment,
|
||||||
|
useTheme
|
||||||
} from '@linagora/twake-mui'
|
} from '@linagora/twake-mui'
|
||||||
import { Search as SearchIcon } from '@mui/icons-material'
|
import { Search as SearchIcon } from '@mui/icons-material'
|
||||||
import React, { useState, useMemo, useEffect, useRef } from 'react'
|
import React, { useState, useMemo, useEffect, useRef } from 'react'
|
||||||
@@ -34,6 +35,8 @@ export const SmallTimezoneSelector: React.FC<
|
|||||||
> = ({ value, onChange, referenceDate, onClose, open }) => {
|
> = ({ value, onChange, referenceDate, onClose, open }) => {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const theme = useTheme()
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const timezoneList = useTimeZoneList()
|
const timezoneList = useTimeZoneList()
|
||||||
|
|
||||||
@@ -118,6 +121,7 @@ export const SmallTimezoneSelector: React.FC<
|
|||||||
sx: { height: '90%', maxHeight: '90dvh' }
|
sx: { height: '90%', maxHeight: '90dvh' }
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
sx={{ zIndex: theme.zIndex.modal + 100 }}
|
||||||
>
|
>
|
||||||
<Box sx={{ px: 2 }}>
|
<Box sx={{ px: 2 }}>
|
||||||
<TextField
|
<TextField
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { formatEventChipTitle } from '@/components/Calendar/utils/calendarUtils'
|
import { formatEventChipTitle } from '@/components/Calendar/utils/calendarUtils'
|
||||||
import { ResponsiveDialog } from '@/components/Dialog'
|
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 { FieldWithLabel } from '@/components/Event/components/FieldWithLabel'
|
||||||
import { splitDateTime } from '@/components/Event/utils/dateTimeHelpers'
|
import { splitDateTime } from '@/components/Event/utils/dateTimeHelpers'
|
||||||
import { Box, Button, TextField, Typography } from '@linagora/twake-mui'
|
import { Box, Button, TextField, Typography } from '@linagora/twake-mui'
|
||||||
|
|||||||
Reference in New Issue
Block a user