diff --git a/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx b/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx new file mode 100644 index 0000000000..d5ebc312e5 --- /dev/null +++ b/apps/frontend/src/components/common/FormikUIDayRangePicker.tsx @@ -0,0 +1,167 @@ +import DateRangeIcon from '@mui/icons-material/DateRange'; +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'; +import { DateTime } from 'luxon'; +import React, { useState } 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 [anchorEl, setAnchorEl] = useState(null); + const open = Boolean(anchorEl); + + 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 ( + + + setAnchorEl(event.currentTarget)} + data-cy={id} + label={label} + fullWidth + required={required} + disabled={disabled} + placeholder="DD/MM/YYYY - DD/MM/YYYY" + value={displayValue} + error={!!error} + helperText={error ?? helperText} + InputLabelProps={{ shrink: true }} + InputProps={{ readOnly: true }} + /> + + setAnchorEl(event.currentTarget)} + disabled={disabled} + data-cy={id ? `${id}-calendar-btn` : undefined} + aria-label="Open date range picker" + sx={{ marginLeft: 1, marginTop: 1 }} + > + + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} + transformOrigin={{ vertical: 'top', horizontal: 'right' }} + > + + + + + + + + + ); +} diff --git a/apps/frontend/src/components/questionary/questionaryComponents/VisitBasis/QuestionaryComponentVisitBasis.tsx b/apps/frontend/src/components/questionary/questionaryComponents/VisitBasis/QuestionaryComponentVisitBasis.tsx index 458fda33d9..c81797050a 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') { + const parsed = DateTime.fromISO(value); + + return parsed.isValid ? parsed : 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 }, + }); + }} + /> ); }