Merge pull request #873 from sharetribe/date-input-availability

Show available time slots in date input
This commit is contained in:
Hannu Lyytikäinen 2018-07-26 18:02:33 +03:00 committed by GitHub
commit f1c4228d36
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 135 additions and 14 deletions

View file

@ -199,7 +199,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

@ -5,12 +5,20 @@
* N.B. *isOutsideRange* in defaultProps is defining what dates are available to booking.
*/
import React, { Component } from 'react';
import { bool, func, instanceOf, shape, string } from 'prop-types';
import { SingleDatePicker, isInclusivelyAfterDay, isInclusivelyBeforeDay } from 'react-dates';
import { bool, func, instanceOf, shape, string, arrayOf } from 'prop-types';
import {
SingleDatePicker,
isInclusivelyAfterDay,
isInclusivelyBeforeDay,
isSameDay,
} from 'react-dates';
import { intlShape, injectIntl } from 'react-intl';
import classNames from 'classnames';
import moment from 'moment';
import config from '../../config';
import { propTypes, TIME_SLOT_DAY } from '../../util/types';
import { dateFromAPIToLocalNoon } from '../../util/dates';
import { ensureTimeSlot } from '../../util/data';
import NextMonthIcon from './NextMonthIcon';
import PreviousMonthIcon from './PreviousMonthIcon';
@ -97,6 +105,17 @@ const defaultProps = {
},
};
// Checks if time slot (propTypes.timeSlot) start time equals a day (moment)
const timeSlotEqualsDay = (timeSlot, 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);
const isDay = ensureTimeSlot(timeSlot).attributes.type === TIME_SLOT_DAY;
return isDay && isSameDay(day, moment(localStartDate));
};
class DateInputComponent extends Component {
constructor(props) {
super(props);
@ -143,6 +162,7 @@ class DateInputComponent extends Component {
value,
children,
render,
timeSlots,
...datePickerProps
} = this.props;
/* eslint-enable no-unused-vars */
@ -151,6 +171,10 @@ class DateInputComponent extends Component {
const date = value && value.date instanceof Date ? moment(value.date) : initialMoment;
const isDayBlocked = timeSlots
? day => !timeSlots.find(timeSlot => timeSlotEqualsDay(timeSlot, day))
: () => false;
const placeholder = placeholderText || intl.formatMessage({ id: 'FieldDateInput.placeholder' });
const screenReaderInputText =
@ -180,6 +204,7 @@ class DateInputComponent extends Component {
placeholder={placeholder}
screenReaderInputMessage={screenReaderInputText}
phrases={{ closeDatePicker: closeDatePickerText, clearDate: clearDateText }}
isDayBlocked={isDayBlocked}
/>
</div>
);
@ -190,6 +215,7 @@ DateInputComponent.defaultProps = {
className: null,
useMobileMargins: false,
...defaultProps,
timeSlots: null,
};
DateInputComponent.propTypes = {
@ -213,6 +239,7 @@ DateInputComponent.propTypes = {
value: shape({
date: instanceOf(Date),
}),
timeSlots: arrayOf(propTypes.timeSlot),
};
export default injectIntl(DateInputComponent);

View file

@ -4,13 +4,24 @@ import { Form as FinalForm, FormSpy } from 'react-final-form';
import moment from 'moment';
import { Button } from '../../components';
import { required, bookingDateRequired, composeValidators } from '../../util/validators';
import { createTimeSlots } from '../../util/test-data';
import FieldDateInput from './FieldDateInput';
const createAvailableTimeSlots = (dayCount, availableDayCount) => {
const slots = createTimeSlots(new Date(), dayCount);
const availableSlotIndices = Array.from({ length: availableDayCount }, () =>
Math.floor(Math.random() * dayCount)
);
return availableSlotIndices.sort().map(i => slots[i]);
};
const FormComponent = props => (
<FinalForm
{...props}
render={fieldRenderProps => {
const {
style,
form,
handleSubmit,
onChange,
@ -26,6 +37,7 @@ const FormComponent = props => (
return (
<form
style={style}
onSubmit={e => {
e.preventDefault();
handleSubmit(e);
@ -42,15 +54,14 @@ const FormComponent = props => (
/>
);
const defaultFormName = 'FieldDateInputExampleForm';
export const Empty = {
component: FormComponent,
props: {
style: { marginBottom: '140px' },
dateInputProps: {
name: 'bookingDate',
useMobileMargins: false,
id: `${defaultFormName}.bookingDate`,
id: `EmptyDateInputForm.bookingDate`,
label: 'Date',
placeholderText: moment().format('ddd, MMMM D'),
format: null,
@ -70,3 +81,31 @@ export const Empty = {
},
group: 'custom inputs',
};
export const WithAvailableTimeSlots = {
component: FormComponent,
props: {
dateInputProps: {
name: 'bookingDate',
useMobileMargins: false,
id: `AvailableTimeSlotsDateInputForm.bookingDate`,
label: 'Date',
placeholderText: moment().format('ddd, MMMM D'),
format: null,
timeSlots: createAvailableTimeSlots(90, 60),
validate: composeValidators(required('Required'), bookingDateRequired('Date is not valid')),
onBlur: () => console.log('onBlur called from DateInput props.'),
onFocus: () => console.log('onFocus called from DateInput props.'),
},
onChange: formState => {
const { date } = formState.values;
if (date) {
console.log('Changed to', moment(date).format('L'));
}
},
onSubmit: values => {
console.log('Submitting a form with values:', values);
},
},
group: 'custom inputs',
};

View file

@ -5,10 +5,11 @@
* you should convert value.date to start date and end date before submitting it to API
*/
import React, { Component } from 'react';
import { bool, object, string } from 'prop-types';
import { bool, object, string, arrayOf } from 'prop-types';
import { Field } from 'react-final-form';
import classNames from 'classnames';
import { ValidationError } from '../../components';
import { propTypes } from '../../util/types';
import DateInput from './DateInput';
import css from './FieldDateInput.css';
@ -77,6 +78,7 @@ FieldDateInputComponent.defaultProps = {
id: null,
label: null,
placeholderText: null,
timeSlots: null,
};
FieldDateInputComponent.propTypes = {
@ -86,6 +88,7 @@ FieldDateInputComponent.propTypes = {
id: string,
label: string,
placeholderText: string,
timeSlots: arrayOf(propTypes.timeSlot),
input: object.isRequired,
meta: object.isRequired,
};

View file

@ -497,7 +497,7 @@ ListingPageComponent.defaultProps = {
showListingError: null,
reviews: [],
fetchReviewsError: null,
timeSlots: [],
timeSlots: null,
fetchTimeSlotsError: null,
sendEnquiryError: null,
categoriesConfig: config.custom.categories,

View file

@ -133,7 +133,7 @@ export const denormalisedResponseEntities = sdkResponse => {
/**
* Create shell objects to ensure that attributes etc. exists.
*
* @param {Object} transaction entity object, which is to be ensured agains null values
* @param {Object} transaction entity object, which is to be ensured against null values
*/
export const ensureTransaction = (transaction, booking = null, listing = null, provider = null) => {
const empty = {
@ -150,7 +150,7 @@ export const ensureTransaction = (transaction, booking = null, listing = null, p
/**
* Create shell objects to ensure that attributes etc. exists.
*
* @param {Object} booking entity object, which is to be ensured agains null values
* @param {Object} booking entity object, which is to be ensured against null values
*/
export const ensureBooking = booking => {
const empty = { id: null, type: 'booking', attributes: {} };
@ -160,7 +160,7 @@ export const ensureBooking = booking => {
/**
* Create shell objects to ensure that attributes etc. exists.
*
* @param {Object} listing entity object, which is to be ensured agains null values
* @param {Object} listing entity object, which is to be ensured against null values
*/
export const ensureListing = listing => {
const empty = {
@ -175,7 +175,7 @@ export const ensureListing = listing => {
/**
* Create shell objects to ensure that attributes etc. exists.
*
* @param {Object} listing entity object, which is to be ensured agains null values
* @param {Object} listing entity object, which is to be ensured against null values
*/
export const ensureOwnListing = listing => {
const empty = {
@ -190,7 +190,7 @@ export const ensureOwnListing = listing => {
/**
* Create shell objects to ensure that attributes etc. exists.
*
* @param {Object} user entity object, which is to be ensured agains null values
* @param {Object} user entity object, which is to be ensured against null values
*/
export const ensureUser = user => {
const empty = { id: null, type: 'user', attributes: { profile: {} } };
@ -200,13 +200,23 @@ export const ensureUser = user => {
/**
* Create shell objects to ensure that attributes etc. exists.
*
* @param {Object} current user entity object, which is to be ensured agains null values
* @param {Object} current user entity object, which is to be ensured against null values
*/
export const ensureCurrentUser = user => {
const empty = { id: null, type: 'currentUser', attributes: { profile: {} }, profileImage: {} };
return { ...empty, ...user };
};
/**
* Create shell objects to ensure that attributes etc. exists.
*
* @param {Object} time slot entity object, which is to be ensured against null values
*/
export const ensureTimeSlot = timeSlot => {
const empty = { id: null, type: 'timeSlot', attributes: {} };
return { ...empty, ...timeSlot };
};
/**
* Get the display name of the given user. This function handles
* missing data (e.g. when the user object is still being downloaded),

View file

@ -1,4 +1,5 @@
import Decimal from 'decimal.js';
import moment from 'moment';
import { types as sdkTypes } from './sdkLoader';
import { nightsBetween } from '../util/dates';
import {
@ -7,6 +8,7 @@ import {
TX_TRANSITION_ACTOR_CUSTOMER,
TX_TRANSITION_ACTOR_PROVIDER,
LISTING_STATE_PUBLISHED,
TIME_SLOT_DAY,
} from '../util/types';
const { UUID, LatLng, Money } = sdkTypes;
@ -212,6 +214,34 @@ export const createReview = (id, attributes = {}, includes = {}) => {
};
};
/**
* Creates an array of time slot objects.
*
* @param {Date} startDate date when the time slots start
* @param {Number} numberOfDays number of time slots to create
*
* @return {Array} array of time slots
*/
export const createTimeSlots = (startDate, numberOfDays) => {
const startTime = moment.utc(startDate).startOf('day');
return Array.from({ length: numberOfDays }, (v, i) => i).map(i => {
return {
id: new UUID(i),
type: 'timeSlot',
attributes: {
start: moment(startTime)
.add(i, 'days')
.toDate(),
end: moment(startTime)
.add(i + 1, 'days')
.toDate(),
type: TIME_SLOT_DAY,
},
};
});
};
// Default config for currency formatting in tests and examples.
export const currencyConfig = {
style: 'currency',

View file

@ -187,11 +187,15 @@ propTypes.booking = shape({
}),
});
// A time slot that covers one day, having a start and end date.
export const TIME_SLOT_DAY = 'time-slot/day';
// Denormalised time slot object
propTypes.timeSlot = shape({
id: propTypes.uuid.isRequired,
type: propTypes.value('timeSlot').isRequired,
attributes: shape({
type: oneOf([TIME_SLOT_DAY]).isRequired,
end: instanceOf(Date).isRequired,
start: instanceOf(Date).isRequired,
}),