diff --git a/src/components/FieldDateRangeInput/DateRangeInput.css b/src/components/FieldDateRangeInput/DateRangeInput.css index 7229c5ff..d9972619 100644 --- a/src/components/FieldDateRangeInput/DateRangeInput.css +++ b/src/components/FieldDateRangeInput/DateRangeInput.css @@ -192,6 +192,7 @@ background-color: var(--successColor); border-top-right-radius: calc(var(--DateRangeInput_selectionHeight) / 2); border-bottom-right-radius: calc(var(--DateRangeInput_selectionHeight) / 2); + color: var(--matterColorLight); } & :global(.CalendarDay:hover .renderedDay) { display: flex; @@ -209,7 +210,15 @@ color: var(--marketplaceColorDark); border: 0; } - & :global(.CalendarDay__blocked_out_of_range .renderedDay) { + /* Remove default bg-color and use our extra span instead '.renderedDay' */ + & :global(.CalendarDay__blocked_calendar), + & :global(.CalendarDay__blocked_calendar:active), + & :global(.CalendarDay__blocked_calendar:hover) { + background-color: transparent; + color: var(--marketplaceColorDark); + border: 0; + } + & :global(.CalendarDay__blocked_out_of_range .CalendarDay__blocked_calendar .renderedDay) { background-color: transparent; } & :global(.DateInput_fang) { diff --git a/src/components/FieldDateRangeInput/DateRangeInput.helpers.js b/src/components/FieldDateRangeInput/DateRangeInput.helpers.js new file mode 100644 index 00000000..f0cf01f6 --- /dev/null +++ b/src/components/FieldDateRangeInput/DateRangeInput.helpers.js @@ -0,0 +1,200 @@ +import moment from 'moment'; +import { isSameDay, isInclusivelyAfterDay, isInclusivelyBeforeDay } from 'react-dates'; + +import { ensureTimeSlot } from '../../util/data'; +import { START_DATE, END_DATE, dateFromAPIToLocalNoon } from '../../util/dates'; +import { LINE_ITEM_DAY, TIME_SLOT_DAY } from '../../util/types'; +import config from '../../config'; + +// Checks if time slot (propTypes.timeSlot) start time equals a day (moment) +const timeSlotEqualsDay = (timeSlot, day) => { + if (ensureTimeSlot(timeSlot).attributes.type === TIME_SLOT_DAY) { + // Time slots describe available dates by providing a start and + // an end date which is the following day. In the single date picker + // the start date is used to represent available dates. + const localStartDate = dateFromAPIToLocalNoon(timeSlot.attributes.start); + + return isSameDay(day, moment(localStartDate)); + } else { + return false; + } +}; + +/** + * Return a boolean indicating if given date can be found in an array + * of tile slots (start dates). + */ +const timeSlotsContain = (timeSlots, date) => { + return timeSlots.findIndex(slot => timeSlotEqualsDay(slot, date)) > -1; +}; + +const lastBlockedBetweenExclusive = (timeSlots, startDate, endDate) => { + if (startDate.isSame(endDate, 'date')) { + return undefined; + } + + return timeSlotsContain(timeSlots, endDate) + ? lastBlockedBetweenExclusive(timeSlots, startDate, moment(endDate).subtract(1, 'days')) + : endDate; +}; + +/** + * Find first blocked date between two dates. + * If none is found, undefined is returned. + * + * @param {Array} timeSlots propTypes.timeSlot objects + * @param {Moment} startDate start date, exclusive + * @param {Moment} endDate end date, exclusive + */ +const firstBlockedBetween = (timeSlots, startDate, endDate) => { + const firstDate = moment(startDate).add(1, 'days'); + if (firstDate.isSame(endDate, 'date')) { + return undefined; + } + + return timeSlotsContain(timeSlots, firstDate) + ? firstBlockedBetween(timeSlots, firstDate, endDate) + : firstDate; +}; + +/** + * Find last blocked date between two dates. + * If none is found, undefined is returned. + * + * @param {Array} timeSlots propTypes.timeSlot objects + * @param {Moment} startDate start date, exclusive + * @param {Moment} endDate end date, exclusive + */ +const lastBlockedBetween = (timeSlots, startDate, endDate) => { + const previousDate = moment(endDate).subtract(1, 'days'); + if (previousDate.isSame(startDate, 'date')) { + return undefined; + } + + return timeSlotsContain(timeSlots, previousDate) + ? lastBlockedBetween(timeSlots, startDate, previousDate) + : previousDate; +}; + +/** + * Check if a blocked date can be found between two dates. + * + * @param {Array} timeSlots propTypes.timeSlot objects + * @param {Moment} startDate start date, exclusive + * @param {Moment} endDate end date, exclusive + */ +export const isBlockedBetween = (timeSlots, startDate, endDate) => + !!firstBlockedBetween(timeSlots, startDate, endDate); + +export const isStartDateSelected = (timeSlots, startDate, endDate, focusedInput) => + timeSlots && startDate && (!endDate || focusedInput === END_DATE) && focusedInput !== START_DATE; + +export const apiEndDateToPickerDate = (unitType, endDate) => { + const isValid = endDate instanceof Date; + const isDaily = unitType === LINE_ITEM_DAY; + + if (!isValid) { + return null; + } else if (isDaily) { + // API end dates are exlusive, so we need to shift them with daily + // booking. + return moment(endDate).subtract(1, 'days'); + } else { + return moment(endDate); + } +}; + +export const pickerEndDateToApiDate = (unitType, endDate) => { + const isValid = endDate instanceof moment; + const isDaily = unitType === LINE_ITEM_DAY; + + if (!isValid) { + return null; + } else if (isDaily) { + // API end dates are exlusive, so we need to shift them with daily + // booking. + return endDate.add(1, 'days').toDate(); + } else { + return endDate.toDate(); + } +}; +/** + * Returns an isDayBlocked function that can be passed to + * a react-dates DateRangePicker component. + */ +export const isDayBlockedFn = (timeSlots, startDate, endDate, focusedInput) => { + const endOfRange = config.dayCountAvailableForBooking - 1; + const lastBookableDate = moment().add(endOfRange, 'days'); + + // start date selected, end date missing + const startDateSelected = isStartDateSelected(timeSlots, startDate, endDate, focusedInput); + + const nextBookingStarts = startDateSelected + ? firstBlockedBetween(timeSlots, startDate, moment(lastBookableDate).add(1, 'days')) + : null; + + return nextBookingStarts || !timeSlots + ? () => false + : day => !timeSlots.find(timeSlot => timeSlotEqualsDay(timeSlot, day)); +}; + +/** + * Returns an isOutsideRange function that can be passed to + * a react-dates DateRangePicker component. + */ +export const isOutsideRangeFn = ( + timeSlots, + startDate, + endDate, + previousStartDate, + focusedInput, + unitType +) => { + const endOfRange = config.dayCountAvailableForBooking - 1; + const lastBookableDate = moment().add(endOfRange, 'days'); + + // start date selected, end date missing + const startDateSelected = isStartDateSelected(timeSlots, startDate, endDate, focusedInput); + const nextBookingStarts = startDateSelected + ? firstBlockedBetween(timeSlots, startDate, moment(lastBookableDate).add(1, 'days')) + : null; + + if (nextBookingStarts) { + // end the range so that the booking can end at latest on + // nightly booking: the day the next booking starts + // daily booking: the day before the next booking starts + return day => { + const lastDayToEndBooking = apiEndDateToPickerDate(unitType, nextBookingStarts.toDate()); + + return ( + !isInclusivelyAfterDay(day, startDate) || !isInclusivelyBeforeDay(day, lastDayToEndBooking) + ); + }; + } + + // end date selected, start date missing + // -> limit the earliest start date for the booking so that it + // needs to be after the previous booked date + const endDateSelected = timeSlots && endDate && !startDate && focusedInput !== END_DATE; + const previousBookedDate = endDateSelected + ? lastBlockedBetween(timeSlots, moment(), endDate) + : null; + + if (previousBookedDate) { + return day => { + const firstDayToStartBooking = moment(previousBookedDate).add(1, 'days'); + return ( + !isInclusivelyAfterDay(day, firstDayToStartBooking) || + !isInclusivelyBeforeDay(day, lastBookableDate) + ); + }; + } + + // standard isOutsideRange function + return day => { + return ( + !isInclusivelyAfterDay(day, moment()) || + !isInclusivelyBeforeDay(day, moment().add(endOfRange, 'days')) + ); + }; +}; diff --git a/src/components/FieldDateRangeInput/DateRangeInput.js b/src/components/FieldDateRangeInput/DateRangeInput.js index 0033809e..27485f1f 100644 --- a/src/components/FieldDateRangeInput/DateRangeInput.js +++ b/src/components/FieldDateRangeInput/DateRangeInput.js @@ -5,7 +5,7 @@ * N.B. *isOutsideRange* in defaultProps is defining what dates are available to booking. */ import React, { Component } from 'react'; -import { bool, func, instanceOf, oneOf, shape, string } from 'prop-types'; +import { bool, func, instanceOf, oneOf, shape, string, arrayOf } from 'prop-types'; import { DateRangePicker, isInclusivelyAfterDay, isInclusivelyBeforeDay } from 'react-dates'; import { intlShape, injectIntl } from 'react-intl'; import classNames from 'classnames'; @@ -13,6 +13,13 @@ import moment from 'moment'; import { START_DATE, END_DATE } from '../../util/dates'; import { LINE_ITEM_DAY, propTypes } from '../../util/types'; import config from '../../config'; +import { + isDayBlockedFn, + isOutsideRangeFn, + isBlockedBetween, + apiEndDateToPickerDate, + pickerEndDateToApiDate, +} from './DateRangeInput.helpers'; import NextMonthIcon from './NextMonthIcon'; import PreviousMonthIcon from './PreviousMonthIcon'; @@ -30,36 +37,6 @@ export const ANCHOR_LEFT = 'left'; // value and moves on to another input within this component. const BLUR_TIMEOUT = 100; -const apiEndDateToPickerDate = (unitType, endDate) => { - const isValid = endDate instanceof Date; - const isDaily = unitType === LINE_ITEM_DAY; - - if (!isValid) { - return null; - } else if (isDaily) { - // API end dates are exlusive, so we need to shift them with daily - // booking. - return moment(endDate).subtract(1, 'days'); - } else { - return moment(endDate); - } -}; - -const pickerEndDateToApiDate = (unitType, endDate) => { - const isValid = endDate instanceof moment; - const isDaily = unitType === LINE_ITEM_DAY; - - if (!isValid) { - return null; - } else if (isDaily) { - // API end dates are exlusive, so we need to shift them with daily - // booking. - return endDate.add(1, 'days').toDate(); - } else { - return endDate.toDate(); - } -}; - // Possible configuration options of React-dates const defaultProps = { initialDates: null, // Possible initial date passed for the component @@ -145,6 +122,8 @@ class DateRangeInputComponent extends Component { this.state = { focusedInput: null, + currentStartDate: null, + previousStartDate: null, }; this.blurTimeoutId = null; @@ -170,6 +149,12 @@ class DateRangeInputComponent extends Component { const { startDate, endDate } = dates; const startDateAsDate = startDate instanceof moment ? startDate.toDate() : null; const endDateAsDate = pickerEndDateToApiDate(unitType, endDate); + + this.setState(prevState => ({ + currentStartDate: startDateAsDate, + previousStartDate: prevState.currentStartDate, + })); + this.props.onChange({ startDate: startDateAsDate, endDate: endDateAsDate }); } @@ -208,6 +193,7 @@ class DateRangeInputComponent extends Component { value, children, render, + timeSlots, ...datePickerProps } = this.props; /* eslint-enable no-unused-vars */ @@ -220,6 +206,34 @@ class DateRangeInputComponent extends Component { const endDate = apiEndDateToPickerDate(unitType, value ? value.endDate : null) || initialEndMoment; + // both dates are selected, a new start date before the previous start + // date is selected + const startDateUpdated = + timeSlots && + startDate && + endDate && + this.state.previousStartDate && + startDate.isBefore(this.state.previousStartDate); + + // clear the end date in case a blocked date can be found + // between previous start date and new start date + const clearEndDate = startDateUpdated + ? isBlockedBetween(timeSlots, startDate, moment(this.state.previousStartDate).add(1, 'days')) + : false; + + const endDateMaybe = clearEndDate ? null : endDate; + + let isDayBlocked = isDayBlockedFn(timeSlots, startDate, endDate, this.state.focusedInput); + + let isOutsideRange = isOutsideRangeFn( + timeSlots, + startDate, + endDateMaybe, + this.state.previousStartDate, + this.state.focusedInput, + unitType + ); + const startDatePlaceholderTxt = startDatePlaceholderText || intl.formatMessage({ id: 'FieldDateRangeInput.startDatePlaceholderText' }); @@ -247,13 +261,15 @@ class DateRangeInputComponent extends Component { focusedInput={this.state.focusedInput} onFocusChange={this.onFocusChange} startDate={startDate} - endDate={endDate} + endDate={endDateMaybe} minimumNights={isDaily ? 0 : 1} onDatesChange={this.onDatesChange} startDatePlaceholderText={startDatePlaceholderTxt} endDatePlaceholderText={endDatePlaceholderTxt} screenReaderInputMessage={screenReaderInputText} phrases={{ closeDatePicker: closeDatePickerText, clearDate: clearDateText }} + isDayBlocked={isDayBlocked} + isOutsideRange={isOutsideRange} /> ); @@ -263,6 +279,7 @@ class DateRangeInputComponent extends Component { DateRangeInputComponent.defaultProps = { className: null, useMobileMargins: false, + timeSlots: null, ...defaultProps, }; @@ -291,6 +308,7 @@ DateRangeInputComponent.propTypes = { startDate: instanceOf(Date), endDate: instanceOf(Date), }), + timeSlots: arrayOf(propTypes.timeSlot), }; export default injectIntl(DateRangeInputComponent); diff --git a/src/components/FieldDateRangeInput/FieldDateRangeInput.example.js b/src/components/FieldDateRangeInput/FieldDateRangeInput.example.js index 4a8bcecb..699c0669 100644 --- a/src/components/FieldDateRangeInput/FieldDateRangeInput.example.js +++ b/src/components/FieldDateRangeInput/FieldDateRangeInput.example.js @@ -5,13 +5,28 @@ import moment from 'moment'; import { Button } from '../../components'; import { required, bookingDatesRequired, composeValidators } from '../../util/validators'; import { LINE_ITEM_NIGHT } from '../../util/types'; +import { createTimeSlots } from '../../util/test-data'; import FieldDateRangeInput from './FieldDateRangeInput'; +const createAvailableTimeSlots = (dayCount, availableDayCount) => { + const slots = createTimeSlots(new Date(), dayCount); + + const availableSlotIndices = []; + while (availableSlotIndices.length < availableDayCount) { + const newIndex = Math.floor(Math.random() * dayCount); + if (availableSlotIndices.indexOf(newIndex) > -1) continue; + availableSlotIndices[availableSlotIndices.length] = newIndex; + } + + return availableSlotIndices.sort((a, b) => a - b).map(i => slots[i]); +}; + const FormComponent = props => ( { const { + style, form, handleSubmit, onChange, @@ -23,6 +38,7 @@ const FormComponent = props => ( return (
{ e.preventDefault(); handleSubmit(e); @@ -39,18 +55,17 @@ const FormComponent = props => ( /> ); -const defaultFormName = 'FieldDateRangeInputExampleForm'; - export const Empty = { component: FormComponent, props: { + style: { marginBottom: '140px' }, dateInputProps: { name: 'bookingDates', unitType: LINE_ITEM_NIGHT, - startDateId: `${defaultFormName}.bookingStartDate`, + startDateId: 'EmptyDateRangeInputForm.bookingStartDate', startDateLabel: 'Start date', startDatePlaceholderText: moment().format('ddd, MMMM D'), - endDateId: `${defaultFormName}.bookingEndDate`, + endDateId: 'EmptyDateRangeInputForm.bookingEndDate', endDateLabel: 'End date', endDatePlaceholderText: moment() .add(1, 'days') @@ -75,3 +90,39 @@ export const Empty = { }, group: 'custom inputs', }; + +export const WithAvailableTimeSlots = { + component: FormComponent, + props: { + dateInputProps: { + name: 'bookingDates', + unitType: LINE_ITEM_NIGHT, + startDateId: 'WithAvailableTimeSlotsDateRangeInputForm.bookingStartDate', + startDateLabel: 'Start date', + startDatePlaceholderText: moment().format('ddd, MMMM D'), + endDateId: 'WithAvailableTimeSlotsDateRangeInputForm.bookingEndDate', + endDateLabel: 'End date', + endDatePlaceholderText: moment() + .add(1, 'days') + .format('ddd, MMMM D'), + format: null, + timeSlots: createAvailableTimeSlots(90, 60), + validate: composeValidators( + required('Required'), + bookingDatesRequired('Start date is not valid', 'End date is not valid') + ), + onBlur: () => console.log('onBlur called from DateRangeInput props.'), + onFocus: () => console.log('onFocus called from DateRangeInput props.'), + }, + onChange: formState => { + const { startDate, endDate } = formState.values; + if (startDate || endDate) { + console.log('Changed to', moment(startDate).format('L'), moment(endDate).format('L')); + } + }, + onSubmit: values => { + console.log('Submitting a form with values:', values); + }, + }, + group: 'custom inputs', +}; diff --git a/src/components/FieldDateRangeInput/FieldDateRangeInput.js b/src/components/FieldDateRangeInput/FieldDateRangeInput.js index 17b0c07e..c0c4f4ec 100644 --- a/src/components/FieldDateRangeInput/FieldDateRangeInput.js +++ b/src/components/FieldDateRangeInput/FieldDateRangeInput.js @@ -6,7 +6,7 @@ */ import React, { Component } from 'react'; -import { bool, func, object, oneOf, string } from 'prop-types'; +import { bool, func, object, oneOf, string, arrayOf } from 'prop-types'; import { Field } from 'react-final-form'; import classNames from 'classnames'; import { START_DATE, END_DATE } from '../../util/dates'; @@ -160,6 +160,7 @@ FieldDateRangeInputComponent.defaultProps = { startDatePlaceholderText: null, focusedInput: null, onFocusedInputChange: null, + timeSlots: null, }; FieldDateRangeInputComponent.propTypes = { @@ -173,6 +174,7 @@ FieldDateRangeInputComponent.propTypes = { startDateId: string, startDateLabel: string, startDatePlaceholderText: string, + timeSlots: arrayOf(propTypes.timeSlot), input: object.isRequired, meta: object.isRequired, focusedInput: oneOf([START_DATE, END_DATE]),