Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 167 additions & 0 deletions apps/frontend/src/components/common/FormikUIDayRangePicker.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null>(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;
Comment thread
shivoomiess marked this conversation as resolved.

return (
<Box style={{ display: 'flex' }}>
<Box style={{ flex: 1 }}>
<TextField
onClick={(event) => 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 }}
Comment thread
shivoomiess marked this conversation as resolved.
/>
</Box>
<IconButton
onClick={(event) => setAnchorEl(event.currentTarget)}
disabled={disabled}
data-cy={id ? `${id}-calendar-btn` : undefined}
aria-label="Open date range picker"
sx={{ marginLeft: 1, marginTop: 1 }}
>
<DateRangeIcon />
</IconButton>
<Popover
open={open}
anchorEl={anchorEl}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
Comment thread
shivoomiess marked this conversation as resolved.
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
>
<StyledPickerWrapper>
<DayPicker
mode="range"
selected={selected}
onSelect={handleSelect}
disabled={disabledDays}
/>
</StyledPickerWrapper>
<Box
style={{
display: 'flex',
justifyContent: 'flex-end',
padding: '8px',
}}
>
<Button
onClick={() => setAnchorEl(null)}
variant="contained"
color="primary"
data-cy={id ? `${id}-done-btn` : undefined}
>
Done
</Button>
</Box>
</Popover>
</Box>
);
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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;
Expand All @@ -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 (
<LocalizationProvider dateAdapter={DateAdapter}>
<Field
name={`${id}.startsAt`}
label="Visit start"
format={format}
component={DatePicker}
inputProps={{ placeholder: format }}
variant="inline"
disableToolbar
autoOk={true}
required
minDate={DateTime.now()}
textField={{
fullWidth: true,
required: true,
}}
InputLabelProps={{
shrink: true,
}}
onChange={(startsAt: DateTime) => {
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')}
/>
<Field
name={`${id}.endsAt`}
label="Visit end"
format={format}
component={DatePicker}
inputProps={{ placeholder: format }}
variant="inline"
disableToolbar
autoOk={true}
required
minDate={state.registration.startsAt}
textField={{
fullWidth: true,
required: true,
}}
InputLabelProps={{
shrink: true,
}}
onChange={(endsAt: DateTime) => {
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')}
/>
</LocalizationProvider>
<DayRangePicker
id={`${id}.dateRange`}
label="Visit start and end"
format={format}
required
minDate={DateTime.now()}
value={{
from: toDateTime(state.registration.startsAt),
to: toDateTime(state.registration.endsAt),
}}
error={startsAtError || endsAtError || undefined}
onChange={({ from, to }: DayRange) => {
dispatch({
type: 'ITEM_WITH_QUESTIONARY_MODIFIED',
itemWithQuestionary: { startsAt: from, endsAt: to },
});
}}
Comment thread
shivoomiess marked this conversation as resolved.
/>
);
}

Expand Down
Loading