Add availability to FieldDateRangeInput

This commit is contained in:
Hannu Lyytikainen 2018-08-08 16:23:48 +03:00
parent 391aa3c51c
commit 1263340faf
5 changed files with 318 additions and 38 deletions

View file

@ -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) {

View file

@ -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'))
);
};
};

View file

@ -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}
/>
</div>
);
@ -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);

View file

@ -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 => (
<FinalForm
{...props}
render={fieldRenderProps => {
const {
style,
form,
handleSubmit,
onChange,
@ -23,6 +38,7 @@ const FormComponent = props => (
return (
<form
style={style}
onSubmit={e => {
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',
};

View file

@ -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]),