mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-27 19:42:11 +10:00
Add availability to FieldDateRangeInput
This commit is contained in:
parent
391aa3c51c
commit
1263340faf
5 changed files with 318 additions and 38 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
200
src/components/FieldDateRangeInput/DateRangeInput.helpers.js
Normal file
200
src/components/FieldDateRangeInput/DateRangeInput.helpers.js
Normal 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'))
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue