From 91edd6602f6918e260c39954398d23d179cc0ead Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:19:19 +0200 Subject: [PATCH 1/4] feat: date range picker --- .../common/FormikUIDayRangePicker.tsx | 187 ++++++++++++++++++ .../QuestionaryComponentVisitBasis.tsx | 110 +++++------ 2 files changed, 234 insertions(+), 63 deletions(-) create mode 100644 apps/frontend/src/components/common/FormikUIDayRangePicker.tsx diff --git a/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx b/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx new file mode 100644 index 0000000000..9a13d922d3 --- /dev/null +++ b/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx @@ -0,0 +1,187 @@ +import DateRangeIcon from '@mui/icons-material/DateRange'; +import { Button } from '@mui/material'; +import Paper from '@mui/material/Paper'; +import { styled } from '@mui/material/styles'; +import TextField from '@mui/material/TextField'; +import { DateTime } from 'luxon'; +import React, { useState, useEffect, useRef } from 'react'; +import { DayPicker, DateRange, Matcher } from 'react-day-picker'; +import 'react-day-picker/dist/style.css'; + +const StyledPickerWrapper = styled('div')(({ theme }) => ({ + padding: theme.spacing(2), + borderRadius: theme.shape.borderRadius, + '.rdp-range_start': { + background: `linear-gradient(90deg, transparent 50%, ${theme.palette.primary.main} 50%)`, + border: 'none', + }, + '.rdp-range_end': { + background: `linear-gradient(90deg, ${theme.palette.primary.main} 50%, transparent 50%)`, + border: 'none', + }, + '.rdp-range_start .rdp-day_button, .rdp-range_end .rdp-day_button': { + backgroundColor: theme.palette.primary.main, + border: 'none', + }, + '.rdp-range_middle .rdp-day_button': { + backgroundColor: theme.palette.primary.main, + borderColor: theme.palette.primary.main, + color: 'white', + }, + '.rdp-day': { + padding: 0, + width: '100%', + height: '100%', + }, + '.rdp-nav svg': { + fill: theme.palette.primary.dark, + }, +})); + +export interface DayRange { + from?: DateTime; + to?: DateTime; +} + +export interface DayRangePickerProps { + label?: string; + value: DayRange; + onChange: (range: DayRange) => void; + /** Luxon format token used to display the selected dates. */ + format?: string | null; + /** Earliest selectable date - days before it are disabled. */ + minDate?: DateTime; + disabled?: boolean; + required?: boolean; + error?: string; + helperText?: string; + id?: string; +} + +const formatDate = (date: DateTime | undefined, format?: string | null) => { + if (!date) { + return ''; + } + + return format + ? date.toFormat(format) + : date.toLocaleString(DateTime.DATE_SHORT); +}; + +export default function DayRangePicker({ + label, + value, + onChange, + format, + minDate, + disabled, + required, + error, + helperText, + id, +}: DayRangePickerProps) { + const [showPicker, setShowPicker] = useState(false); + const pickerRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + pickerRef.current && + !pickerRef.current.contains(event.target as Node) + ) { + setShowPicker(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const selected: DateRange = { + from: value?.from?.toJSDate(), + to: value?.to?.toJSDate(), + }; + + const handleSelect = (range: DateRange | undefined) => { + onChange({ + from: range?.from ? DateTime.fromJSDate(range.from) : undefined, + to: range?.to ? DateTime.fromJSDate(range.to) : undefined, + }); + }; + + const displayValue = value?.from + ? `${formatDate(value.from, format)} - ${formatDate(value.to, format)}` + : ''; + + const disabledDays: Matcher | undefined = minDate + ? { before: minDate.toJSDate() } + : undefined; + + return ( +
+
+ !disabled && setShowPicker((prev) => !prev)} + InputLabelProps={{ shrink: true }} + InputProps={{ readOnly: true }} + /> +
+
!disabled && setShowPicker((prev) => !prev)} + style={{ + marginLeft: '8px', + marginTop: '8px', + display: 'flex', + alignItems: 'center', + cursor: disabled ? 'default' : 'pointer', + }} + > + +
+ {showPicker && ( + + + + +
+ +
+
+ )} +
+ ); +} diff --git a/apps/frontend/src/components/questionary/questionaryComponents/VisitBasis/QuestionaryComponentVisitBasis.tsx b/apps/frontend/src/components/questionary/questionaryComponents/VisitBasis/QuestionaryComponentVisitBasis.tsx index 458fda33d9..80b24a0a98 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/VisitBasis/QuestionaryComponentVisitBasis.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/VisitBasis/QuestionaryComponentVisitBasis.tsx @@ -1,11 +1,10 @@ -import useTheme from '@mui/material/styles/useTheme'; -import { AdapterLuxon as DateAdapter } from '@mui/x-date-pickers/AdapterLuxon'; -import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; -import { Field } from 'formik'; +import { getIn } from 'formik'; import { DateTime } from 'luxon'; import React, { useContext } from 'react'; -import DatePicker from 'components/common/FormikUIDatePicker'; +import DayRangePicker, { + DayRange, +} from 'components/common/FormikUIDayRangePicker'; import { BasicComponentProps } from 'components/proposal/IBasicComponentProps'; import { createMissingContextErrorMessage, @@ -21,8 +20,24 @@ import { useFormattedDateTime } from 'hooks/admin/useFormattedDateTime'; import { SubmitActionDependencyContainer } from 'hooks/questionary/useSubmitActions'; import { VisitRegistrationSubmissionState } from 'models/questionary/visit/VisitRegistrationSubmissionState'; -function QuestionaryComponentVisitBasis({ answer }: BasicComponentProps) { - const theme = useTheme(); +// `startsAt`/`endsAt` are Luxon DateTimes once edited, but come back as ISO +// strings when the registration is loaded from the backend. +const toDateTime = (value: unknown): DateTime | undefined => { + if (value instanceof DateTime) { + return value; + } + + if (typeof value === 'string') { + return DateTime.fromISO(value); + } + + return undefined; +}; + +function QuestionaryComponentVisitBasis({ + answer, + formikProps, +}: BasicComponentProps) { const { dispatch, state } = useContext( QuestionaryContext ) as VisitRegistrationContextType; @@ -36,63 +51,32 @@ function QuestionaryComponentVisitBasis({ answer }: BasicComponentProps) { const id = answer.question.id; + const startsAtError = + getIn(formikProps.touched, `${id}.startsAt`) && + getIn(formikProps.errors, `${id}.startsAt`); + const endsAtError = + getIn(formikProps.touched, `${id}.endsAt`) && + getIn(formikProps.errors, `${id}.endsAt`); + return ( - - { - dispatch({ - type: 'ITEM_WITH_QUESTIONARY_MODIFIED', - itemWithQuestionary: { startsAt }, - }); - }} - // NOTE: This is needed just because Cypress testing a Material-UI datepicker is not working on Github actions https://stackoverflow.com/a/69986695/5619063 - desktopModeMediaQuery={theme.breakpoints.up('sm')} - /> - { - dispatch({ - type: 'ITEM_WITH_QUESTIONARY_MODIFIED', - itemWithQuestionary: { endsAt }, - }); - }} - // NOTE: This is needed just because Cypress testing a Material-UI datepicker is not working on Github actions https://stackoverflow.com/a/69986695/5619063 - desktopModeMediaQuery={theme.breakpoints.up('sm')} - /> - + { + dispatch({ + type: 'ITEM_WITH_QUESTIONARY_MODIFIED', + itemWithQuestionary: { startsAt: from, endsAt: to }, + }); + }} + /> ); } From d59c2ca938815a31e3085bd3668bad47c3d63843 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:25:45 +0200 Subject: [PATCH 2/4] fix: use mui popover instead of rendering date-picker inside modal --- .../common/FormikUIDayRangePicker.tsx | 107 +++++++----------- 1 file changed, 43 insertions(+), 64 deletions(-) diff --git a/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx b/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx index 9a13d922d3..c27ed0c2ba 100644 --- a/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx +++ b/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx @@ -1,10 +1,10 @@ import DateRangeIcon from '@mui/icons-material/DateRange'; -import { Button } from '@mui/material'; -import Paper from '@mui/material/Paper'; +import { Button, IconButton } from '@mui/material'; +import Popover from '@mui/material/Popover'; import { styled } from '@mui/material/styles'; import TextField from '@mui/material/TextField'; import { DateTime } from 'luxon'; -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState } from 'react'; import { DayPicker, DateRange, Matcher } from 'react-day-picker'; import 'react-day-picker/dist/style.css'; @@ -80,23 +80,8 @@ export default function DayRangePicker({ helperText, id, }: DayRangePickerProps) { - const [showPicker, setShowPicker] = useState(false); - const pickerRef = useRef(null); - - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if ( - pickerRef.current && - !pickerRef.current.contains(event.target as Node) - ) { - setShowPicker(false); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); + const [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); const selected: DateRange = { from: value?.from?.toJSDate(), @@ -119,9 +104,7 @@ export default function DayRangePicker({ : undefined; return ( -
+
!disabled && setShowPicker((prev) => !prev)} InputLabelProps={{ shrink: true }} InputProps={{ readOnly: true }} />
-
!disabled && setShowPicker((prev) => !prev)} - style={{ - marginLeft: '8px', - marginTop: '8px', - display: 'flex', - alignItems: 'center', - cursor: disabled ? 'default' : 'pointer', - }} + setAnchorEl(event.currentTarget)} + disabled={disabled} + data-cy={id ? `${id}-calendar-btn` : undefined} + aria-label="Open date range picker" + sx={{ marginLeft: 1, marginTop: 1 }} > -
- {showPicker && ( - + setAnchorEl(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + + + +
- - - -
setAnchorEl(null)} + variant="contained" + color="primary" + data-cy={id ? `${id}-done-btn` : undefined} > - -
- - )} + Done + +
+
); } From 1eb51636cd328c3132f44af27701a427886e832d Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:00:02 +0200 Subject: [PATCH 3/4] chore: apply stylistic and usability review suggestions to datepicker --- .../src/components/common/FormikUIDayRangePicker.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx b/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx index c27ed0c2ba..ac4f8e74f1 100644 --- a/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx +++ b/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx @@ -104,15 +104,16 @@ export default function DayRangePicker({ : undefined; return ( -
+
setAnchorEl(event.currentTarget)} data-cy={id} label={label} fullWidth required={required} disabled={disabled} - placeholder="dd/mm/yyyy - dd/mm/yyyy" + placeholder="DD/MM/YYYY - DD/MM/YYYY" value={displayValue} error={!!error} helperText={error ?? helperText} From 4b6f5d063be65acf9f46441165e2d7507b0b9ba2 Mon Sep 17 00:00:00 2001 From: Shivam K <292110273+shivoomiess@users.noreply.github.com> Date: Tue, 7 Jul 2026 12:02:17 +0200 Subject: [PATCH 4/4] fix: add review suggestions --- .../components/common/FormikUIDayRangePicker.tsx | 14 +++++++------- .../VisitBasis/QuestionaryComponentVisitBasis.tsx | 6 +++--- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx b/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx index ac4f8e74f1..d5ebc312e5 100644 --- a/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx +++ b/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx @@ -1,5 +1,5 @@ import DateRangeIcon from '@mui/icons-material/DateRange'; -import { Button, IconButton } from '@mui/material'; +import { Box, Button, IconButton } from '@mui/material'; import Popover from '@mui/material/Popover'; import { styled } from '@mui/material/styles'; import TextField from '@mui/material/TextField'; @@ -104,8 +104,8 @@ export default function DayRangePicker({ : undefined; return ( -
-
+ + setAnchorEl(event.currentTarget)} data-cy={id} @@ -120,7 +120,7 @@ export default function DayRangePicker({ InputLabelProps={{ shrink: true }} InputProps={{ readOnly: true }} /> -
+ setAnchorEl(event.currentTarget)} disabled={disabled} @@ -145,7 +145,7 @@ export default function DayRangePicker({ disabled={disabledDays} /> -
Done -
+ -
+ ); } diff --git a/apps/frontend/src/components/questionary/questionaryComponents/VisitBasis/QuestionaryComponentVisitBasis.tsx b/apps/frontend/src/components/questionary/questionaryComponents/VisitBasis/QuestionaryComponentVisitBasis.tsx index 80b24a0a98..c81797050a 100644 --- a/apps/frontend/src/components/questionary/questionaryComponents/VisitBasis/QuestionaryComponentVisitBasis.tsx +++ b/apps/frontend/src/components/questionary/questionaryComponents/VisitBasis/QuestionaryComponentVisitBasis.tsx @@ -28,10 +28,10 @@ const toDateTime = (value: unknown): DateTime | undefined => { } if (typeof value === 'string') { - return DateTime.fromISO(value); - } + const parsed = DateTime.fromISO(value); - return undefined; + return parsed.isValid ? parsed : undefined; + } }; function QuestionaryComponentVisitBasis({