EditListingAvailabilityForm component

This commit is contained in:
Vesa Luusua 2019-01-02 19:18:39 +02:00
parent fe36cf84cc
commit 74a50e5323
10 changed files with 1162 additions and 0 deletions

View file

@ -63,6 +63,7 @@ import * as UserCard from './components/UserCard/UserCard.example';
// forms
import * as BookingDatesForm from './forms/BookingDatesForm/BookingDatesForm.example';
import * as EditListingAvailabilityForm from './forms/EditListingAvailabilityForm/EditListingAvailabilityForm.example';
import * as EditListingDescriptionForm from './forms/EditListingDescriptionForm/EditListingDescriptionForm.example';
import * as EditListingFeaturesForm from './forms/EditListingFeaturesForm/EditListingFeaturesForm.example';
import * as EditListingLocationForm from './forms/EditListingLocationForm/EditListingLocationForm.example';
@ -93,6 +94,7 @@ export {
BookingPanel,
Button,
Colors,
EditListingAvailabilityForm,
EditListingDescriptionForm,
EditListingFeaturesForm,
EditListingLocationForm,

View file

@ -0,0 +1,57 @@
@import '../../marketplace.css';
.root {
/* Dimensions */
width: 100%;
height: auto;
/* Layout */
display: flex;
flex-grow: 1;
flex-direction: column;
padding-top: 1px;
}
.title {
margin-bottom: 24px;
}
.error {
color: var(--failColor);
}
.calendarWrapper {
flex-grow: 1;
position: relative;
width: 100%;
margin-bottom: 24px;
}
.submitButton {
margin-top: auto;
margin-bottom: 24px;
flex-shrink: 0;
}
@media (--viewportMedium) {
.root {
padding-top: 2px;
margin-top: -16px;
}
.title {
margin-bottom: 32px;
}
}
@media (--viewportLarge) {
.calendarWrapper {
flex-grow: 0;
margin-bottom: 24px;
}
.submitButton {
display: inline-block;
width: 241px;
margin-top: 86px;
}
}

View file

@ -0,0 +1,31 @@
/* eslint-disable no-console */
import EditListingAvailabilityForm from './EditListingAvailabilityForm';
export const Empty = {
component: EditListingAvailabilityForm,
props: {
onSubmit: values => {
console.log('Submit EditListingAvailabilityForm with (unformatted) values:', values);
},
saveActionMsg: 'Save rules',
updated: false,
updateInProgress: false,
availability: {
calendar: {
// '2018-12': {
// bookings: [],
// exceptions: [],
// fetchExceptionsError: null,
// fetchExceptionsInProgress: false,
// fetchBookingsError: null,
// fetchBookingsInProgress: false,
// },
},
onCreateAvailabilityException: () => console.log('onCreateAvailabilityException called'),
onDeleteAvailabilityException: () => console.log('onDeleteAvailabilityException called'),
onFetchAvailabilityExceptions: () => console.log('onFetchAvailabilityExceptions called'),
onFetchBookings: () => console.log('onFetchBookings called'),
},
},
group: 'forms',
};

View file

@ -0,0 +1,89 @@
import React, { Component } from 'react';
import { bool, func, object, string } from 'prop-types';
import { compose } from 'redux';
import { Form as FinalForm } from 'react-final-form';
import { intlShape, injectIntl, FormattedMessage } from 'react-intl';
import classNames from 'classnames';
import { propTypes } from '../../util/types';
import { Form, Button } from '../../components';
import ManageAvailabilityCalendar from './ManageAvailabilityCalendar';
import css from './EditListingAvailabilityForm.css';
export class EditListingAvailabilityFormComponent extends Component {
render() {
return (
<FinalForm
{...this.props}
render={fieldRenderProps => {
const {
className,
rootClassName,
disabled,
handleSubmit,
//intl,
invalid,
pristine,
saveActionMsg,
updated,
updateError,
updateInProgress,
availability,
availabilityPlan,
listingId,
} = fieldRenderProps;
const errorMessage = updateError ? (
<p className={css.error}>
<FormattedMessage id="EditListingAvailabilityForm.updateFailed" />
</p>
) : null;
const classes = classNames(rootClassName || css.root, className);
const submitReady = updated && pristine;
const submitInProgress = updateInProgress;
const submitDisabled = invalid || disabled || submitInProgress;
return (
<Form className={classes} onSubmit={handleSubmit}>
{errorMessage}
<div className={css.calendarWrapper}>
<ManageAvailabilityCalendar
availability={availability}
availabilityPlan={availabilityPlan}
listingId={listingId}
/>
</div>
<Button
className={css.submitButton}
type="submit"
inProgress={submitInProgress}
disabled={submitDisabled}
ready={submitReady}
>
{saveActionMsg}
</Button>
</Form>
);
}}
/>
);
}
}
EditListingAvailabilityFormComponent.defaultProps = {
updateError: null,
};
EditListingAvailabilityFormComponent.propTypes = {
intl: intlShape.isRequired,
onSubmit: func.isRequired,
saveActionMsg: string.isRequired,
updated: bool.isRequired,
updateError: propTypes.error,
updateInProgress: bool.isRequired,
availability: object.isRequired,
};
export default compose(injectIntl)(EditListingAvailabilityFormComponent);

View file

@ -0,0 +1,39 @@
// NOTE: renderdeep doesn't work due to map integration
import React from 'react';
import { renderShallow } from '../../util/test-helpers';
import { fakeIntl } from '../../util/test-data';
import { EditListingAvailabilityFormComponent } from './EditListingAvailabilityForm';
const noop = () => null;
describe('EditListingAvailabilityForm', () => {
it('matches snapshot', () => {
const tree = renderShallow(
<EditListingAvailabilityFormComponent
intl={fakeIntl}
dispatch={noop}
onSubmit={v => v}
saveActionMsg="Save rules"
updated={false}
updateInProgress={false}
availability={{
calendar: {
// '2018-12': {
// bookings: [],
// exceptions: [],
// fetchExceptionsError: null,
// fetchExceptionsInProgress: false,
// fetchBookingsError: null,
// fetchBookingsInProgress: false,
// },
},
onCreateAvailabilityException: noop,
onDeleteAvailabilityException: noop,
onFetchAvailabilityExceptions: noop,
onFetchBookings: noop,
}}
/>
);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,405 @@
@import '../../marketplace.css';
:root {
--ManageAvailabilityCalendar_gridColor: #e0e0e0;
--ManageAvailabilityCalendar_availableColor: #ffffff;
--ManageAvailabilityCalendar_availableColorHover: #fafafa;
--ManageAvailabilityCalendar_blockedColor: #ebebeb;
--ManageAvailabilityCalendar_blockedColorHover: #e6e6e6;
--ManageAvailabilityCalendar_reservedColor: #e6fff0;
--ManageAvailabilityCalendar_reservedColorHover: #e1faeb;
--ManageAvailabilityCalendar_failedColor: #fff2f2;
}
.root {
@apply --marketplaceH5FontStyles;
margin-top: 0;
margin-bottom: 0;
overflow-x: hidden;
z-index: 0;
& :global(.CalendarMonth_caption) {
@apply --marketplaceH3FontStyles;
font-weight: var(--fontWeightMedium);
text-align: left;
padding-top: 18px;
padding-bottom: 55px;
margin-left: 97px;
margin-top: 0;
margin-bottom: 0;
& strong {
font-weight: 500;
letter-spacing: 0.2px;
}
}
& :global(.DayPicker) {
margin: 0 auto;
box-shadow: none;
}
& :global(.DayPicker__horizontal) {
background-color: transparent;
margin-left: -18px;
}
& :global(.DayPicker_weekHeader) {
top: 65px;
& small {
@apply --marketplaceH5FontStyles;
}
}
& :global(.DayPicker_weekHeader_li) {
text-align: left;
}
& :global(.DayPickerNavigation__horizontal) {
width: 80px;
margin-left: 18px;
position: relative;
& :first-child {
border-top-left-radius: 2px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
border-bottom-left-radius: 2px;
}
& :last-child {
/* The navigation arrows have 9px padding. Add -9px margin to
align the arrows with the calendar */
left: 35px;
right: unset;
border-top-left-radius: 0;
border-top-right-radius: 2px;
border-bottom-right-radius: 2px;
border-bottom-left-radius: 0;
}
}
& :global(.DayPickerNavigation_button__horizontal) {
width: 36px;
height: 36px;
position: absolute;
top: 13px;
left: 0;
display: flex;
justify-content: center;
align-items: center;
border: solid 1px var(--matterColorNegative);
background-color: var(--matterColorBright);
&:hover {
background-color: var(--ManageAvailabilityCalendar_availableColorHover);
& svg {
fill: var(--matterColorDark);
}
}
&:focus {
outline: none;
background-color: var(--ManageAvailabilityCalendar_availableColorHover);
& svg {
fill: var(--matterColorDark);
}
}
}
& :global(.CalendarMonthGrid) {
background-color: transparent;
}
& :global(.CalendarMonth) {
background-color: transparent;
}
& :global(.CalendarMonth_table) {
border: 1px solid var(--ManageAvailabilityCalendar_gridColor);
}
& :global(.CalendarDay__default) {
border: 0;
background-color: var(--ManageAvailabilityCalendar_gridColor);
&:hover {
border: 0;
background-color: var(--ManageAvailabilityCalendar_gridColor);
}
}
& :global(.CalendarDay__selected) {
color: var(--matterColor);
}
& :global(.DayPickerKeyboardShortcuts_show__bottomRight) {
right: -20px;
right: 19px;
bottom: -24px;
}
}
.dayWrapper {
position: relative;
width: 100%;
height: 100%;
}
.day {
display: flex;
width: 100%;
height: 100%;
justify-content: flex-end;
align-items: flex-end;
padding: 8px;
&:hover {
background-color: var(--ManageAvailabilityCalendar_availableColorHover);
}
}
.dayNumber {
font-size: 14px;
line-height: 14px;
}
.default {
composes: day;
background-color: var(--ManageAvailabilityCalendar_availableColor);
}
.outsideRange {
background-color: var(--ManageAvailabilityCalendar_blockedColor);
&:hover {
background-color: var(--ManageAvailabilityCalendar_blockedColorHover);
}
& .dayNumber {
text-decoration: line-through;
color: lightgrey;
}
}
.today {
background-color: var(--ManageAvailabilityCalendar_availableColor);
& .dayNumber {
position: relative;
text-decoration: none;
/* underline */
&:after {
position: absolute;
content: '';
height: 2px;
width: 100%;
right: 0;
bottom: -4px;
left: 0;
margin: 0 auto;
background-color: var(--matterColor);
}
}
}
.blocked {
background-color: var(--ManageAvailabilityCalendar_blockedColor);
&:hover {
background-color: var(--ManageAvailabilityCalendar_blockedColorHover);
}
}
.reserved {
background-color: var(--ManageAvailabilityCalendar_reservedColor);
&:hover {
background-color: var(--ManageAvailabilityCalendar_reservedColorHover);
}
}
.inProgress {
width: 12px;
height: 12px;
stroke: var(--matterColor);
stroke-width: 3px;
}
.exceptionError {
opacity: 1;
/* Animation */
animation-name: errored;
animation-duration: 800ms;
animation-iteration-count: 1;
animation-timing-function: ease;
}
@keyframes errored {
30%,
70% {
background-color: var(--ManageAvailabilityCalendar_failedColor);
}
}
.monthElement {
display: flex;
position: relative;
}
.monthString {
font-weight: var(--fontWeightSemiBold);
&:first-letter {
text-transform: uppercase;
}
}
.monthInProgress {
width: 12px;
height: 12px;
margin-top: 7px;
margin-left: 8px;
stroke: var(--marketplaceColor);
stroke-width: 4px;
}
.error {
@apply --marketplaceH4FontStyles;
color: var(--failColor);
margin: 6px 0 24px 0;
}
.rootNextMonthIcon,
.rootPreviousMonthIcon {
stroke: var(--matterColor);
fill: var(--matterColor);
}
.legend {
display: flex;
flex-flow: row;
flex-wrap: wrap;
}
.legendRow {
display: flex;
flex-grow: row;
line-height: 24px;
margin-right: 18px;
margin-bottom: 6px;
}
.legendColor {
width: 20px;
height: 20px;
padding-top: 2px;
padding-bottom: 2px;
border-radius: 4px;
margin-top: 2px;
margin-right: 8px;
}
.legendAvailableColor {
composes: legendColor;
background-color: var(--matterColorLight);
border: solid 2px var(--matterColorNegative);
}
.legendReservedColor {
composes: legendColor;
background-color: var(--ManageAvailabilityCalendar_reservedColor);
}
.legendBlockedColor {
composes: legendColor;
background-color: var(--ManageAvailabilityCalendar_blockedColorHover);
}
.legendText {
@apply --marketplaceH4FontStyles;
white-space: nowrap;
margin: 0;
}
@media (min-width: 550px) {
/* day aka table cell content should have bigger paddings when there's more space */
.day {
padding: 16px;
}
.dayNumber {
font-size: 16px;
line-height: 16px;
}
}
/* Standard break points */
@media (--viewportMedium) {
.root {
margin-top: 0;
margin-bottom: 0;
& :global(.CalendarMonth_caption) {
font-size: 24px;
padding-bottom: 62px;
}
& :global(.DayPicker_weekHeader) {
top: 67px;
}
& :global(.DayPickerNavigation__horizontal) {
& :last-child {
/* The navigation arrows have 9px padding. Add -9px margin to
align the arrows with the calendar */
left: 39px;
}
}
& :global(.DayPickerNavigation_button__horizontal) {
width: 40px;
height: 40px;
}
}
.inProgress {
width: 16px;
height: 16px;
}
.monthInProgress {
width: 16px;
height: 16px;
margin-top: 5px;
}
.error {
margin: 8px 0 24px 0;
}
.legendRow {
margin-right: 24px;
margin-bottom: 8px;
}
.legendText {
margin: 0;
}
}
@media (--viewportLarge) {
.root {
& :global(.DayPickerNavigation_button__horizontal) {
background-color: var(--matterColorLight);
}
}
.legend {
margin: 0;
}
}
@media (--viewportXLarge) {
.root {
& :global(.CalendarMonth_caption) {
margin-left: 108px;
}
}
}

View file

@ -0,0 +1,499 @@
import React, { Component } from 'react';
import { func, object, shape, string } from 'prop-types';
import {
DayPickerSingleDateController,
isSameDay,
isInclusivelyBeforeDay,
isInclusivelyAfterDay,
} from 'react-dates';
import { FormattedMessage } from 'react-intl';
import memoize from 'lodash/memoize';
import classNames from 'classnames';
import moment from 'moment';
import {
ensureBooking,
ensureAvailabilityException,
ensureDayAvailabilityPlan,
} from '../../util/data';
import { DAYS_OF_WEEK } from '../../util/types';
import { monthIdString } from '../../util/dates';
import { IconArrowHead, IconSpinner } from '../../components';
import css from './ManageAvailabilityCalendar.css';
// Constants
const HORIZONTAL_ORIENTATION = 'horizontal';
const MAX_AVAILABILITY_EXCEPTIONS_RANGE = 180;
const TODAY_MOMENT = moment().startOf('day');
const END_OF_RANGE_MOMENT = TODAY_MOMENT.clone()
.add(MAX_AVAILABILITY_EXCEPTIONS_RANGE - 1, 'days')
.startOf('day');
// Constants for calculating day width (aka table cell dimensions)
const TABLE_BORDER = 2;
const TABLE_COLUMNS = 7;
const MIN_CONTENT_WIDTH = 272;
const MIN_CELL_WIDTH = Math.floor(MIN_CONTENT_WIDTH / TABLE_COLUMNS); // 38
const MAX_CONTENT_WIDTH_DESKTOP = 756;
const MAX_CELL_WIDTH_DESKTOP = Math.floor(MAX_CONTENT_WIDTH_DESKTOP / TABLE_COLUMNS); // 108
const VIEWPORT_LARGE = 1024;
// Helper functions
// Calculate the width for a calendar day (table cell)
const dayWidth = (wrapperWidth, windowWith) => {
if (windowWith >= VIEWPORT_LARGE) {
// NOTE: viewportLarge has a layout with sidebar.
// In that layout 30% is reserved for paddings and 282 px goes to sidebar and gutter.
const width = windowWith * 0.7 - 282;
return width > MAX_CONTENT_WIDTH_DESKTOP
? MAX_CELL_WIDTH_DESKTOP
: Math.floor((width - TABLE_BORDER) / TABLE_COLUMNS);
} else {
return wrapperWidth > MIN_CONTENT_WIDTH
? Math.floor((wrapperWidth - TABLE_BORDER) / TABLE_COLUMNS)
: MIN_CELL_WIDTH;
}
};
// Get a function that returns the start of the previous month
const prevMonthFn = currentMoment =>
currentMoment
.clone()
.subtract(1, 'months')
.startOf('month');
// Get a function that returns the start of the next month
const nextMonthFn = currentMoment =>
currentMoment
.clone()
.add(1, 'months')
.startOf('month');
// Get the start and end Dates in UTC
const dateStartAndEndInUTC = date => {
const start = moment(date)
.utc()
.startOf('day')
.toDate();
const end = moment(date)
.utc()
.add(1, 'days')
.startOf('day')
.toDate();
return { start, end };
};
const momentToUTCDate = dateMoment =>
dateMoment
.clone()
.utc()
.add(dateMoment.utcOffset(), 'minutes')
.toDate();
// outside range -><- today ... today+MAX_AVAILABILITY_EXCEPTIONS_RANGE -1 -><- outside range
const isDateOutsideRange = date => {
return (
!isInclusivelyAfterDay(date, TODAY_MOMENT) || !isInclusivelyBeforeDay(date, END_OF_RANGE_MOMENT)
);
};
const isOutsideRange = memoize(isDateOutsideRange);
const isMonthInRange = monthMoment => {
const isAfterThisMonth = monthMoment.isSameOrAfter(TODAY_MOMENT, 'month');
const isBeforeEndOfRange = monthMoment.isSameOrBefore(END_OF_RANGE_MOMENT, 'month');
return isAfterThisMonth && isBeforeEndOfRange;
};
const isPast = date => !isInclusivelyAfterDay(date, TODAY_MOMENT);
const isAfterEndOfRange = date => !isInclusivelyBeforeDay(date, END_OF_RANGE_MOMENT);
const isBooked = (bookings, day) => {
return !!bookings.find(b => {
const booking = ensureBooking(b);
const start = booking.attributes.start;
const end = booking.attributes.end;
const dayInUTC = day.clone().utc();
// '[)' means that the range start is inclusive and range end is exclusive
return dayInUTC.isBetween(moment(start).utc(), moment(end).utc(), null, '[)');
});
};
const findException = (exceptions, day) => {
return exceptions.find(exception => {
const availabilityException = ensureAvailabilityException(exception.availabilityException);
const start = availabilityException.attributes.start;
const dayInUTC = day.clone().utc();
return isSameDay(moment(start).utc(), dayInUTC);
});
};
const isBlocked = (availabilityPlan, exception, date) => {
const planEntries = ensureDayAvailabilityPlan(availabilityPlan).entries;
const seatsFromPlan = planEntries.find(
weekDayEntry => weekDayEntry.dayOfWeek === DAYS_OF_WEEK[date.isoWeekday() - 1]
).seats;
const seatsFromException =
exception && ensureAvailabilityException(exception.availabilityException).attributes.seats;
const seats = exception ? seatsFromException : seatsFromPlan;
return seats === 0;
};
const dateModifiers = (availabilityPlan, exceptions, bookings, date) => {
const exception = findException(exceptions, date);
return {
isOutsideRange: isOutsideRange(date),
isSameDay: isSameDay(date, TODAY_MOMENT),
isBlocked: isBlocked(availabilityPlan, exception, date),
isBooked: isBooked(bookings, date),
isInProgress: exception && exception.inProgress,
isFailed: exception && exception.error,
};
};
const renderDayContents = (calendar, availabilityPlan) => date => {
const { exceptions = [], bookings = [] } = calendar[monthIdString(date)] || {};
const { isOutsideRange, isSameDay, isBlocked, isBooked, isInProgress, isFailed } = dateModifiers(
availabilityPlan,
exceptions,
bookings,
date
);
const dayClasses = classNames(css.default, {
[css.outsideRange]: isOutsideRange,
[css.today]: isSameDay,
[css.blocked]: isBlocked,
[css.reserved]: isBooked,
[css.exceptionError]: isFailed,
});
return (
<div className={css.dayWrapper}>
<span className={dayClasses}>
{isInProgress ? (
<IconSpinner rootClassName={css.inProgress} />
) : (
<span className={css.dayNumber}>{date.format('D')}</span>
)}
</span>
</div>
);
};
const makeDraftException = (exceptions, start, end, seats) => {
const draft = ensureAvailabilityException({ attributes: { start, end, seats } });
return { availabilityException: draft };
};
////////////////////////////////
// ManageAvailabilityCalendar //
////////////////////////////////
class ManageAvailabilityCalendar extends Component {
constructor(props) {
super(props);
// DOM refs
this.dayPickerWrapper = null;
this.dayPicker = null;
this.state = {
currentMonth: moment().startOf('month'),
focused: true,
date: null,
};
this.fetchMonthData = this.fetchMonthData.bind(this);
this.onDayAvailabilityChange = this.onDayAvailabilityChange.bind(this);
this.onDateChange = this.onDateChange.bind(this);
this.onFocusChange = this.onFocusChange.bind(this);
this.onMonthClick = this.onMonthClick.bind(this);
}
componentDidMount() {
// Fetch month data if user have navigated to availability tab in EditListingWizard
this.fetchMonthData(this.state.currentMonth);
// Fetch next month too.
this.fetchMonthData(nextMonthFn(this.state.currentMonth));
}
fetchMonthData(monthMoment) {
const { availability, listingId } = this.props;
// Don't fetch exceptions for past months or too far in the future
if (isMonthInRange(monthMoment)) {
// Use "today", if the first day of given month is in the past
const startMoment = isPast(monthMoment) ? TODAY_MOMENT : monthMoment;
const start = momentToUTCDate(startMoment);
// Use END_OF_RANGE_MOMENT, if the first day of the next month is too far in the future
const nextMonthMoment = nextMonthFn(monthMoment);
const endMoment = isAfterEndOfRange(nextMonthMoment)
? END_OF_RANGE_MOMENT.clone().add(1, 'days')
: nextMonthMoment;
const end = momentToUTCDate(endMoment);
// Fetch AvailabilityExceptions for this month
availability.onFetchAvailabilityExceptions({ listingId, start, end });
// Fetch Bookings for this month (if they are in pending or accepted state)
const state = ['pending', 'accepted'].join(',');
availability.onFetchBookings({ listingId, start, end, state });
}
}
onDayAvailabilityChange(date, seats, exceptions) {
const { availabilityPlan, listingId } = this.props;
const { start, end } = dateStartAndEndInUTC(date);
const planEntries = ensureDayAvailabilityPlan(availabilityPlan).entries;
const seatsFromPlan = planEntries.find(
weekDayEntry => weekDayEntry.dayOfWeek === DAYS_OF_WEEK[date.isoWeekday() - 1]
).seats;
const currentException = findException(exceptions, date);
const draftException = makeDraftException(exceptions, start, end, seatsFromPlan);
const exception = currentException || draftException;
const hasAvailabilityException = currentException && currentException.availabilityException.id;
if (hasAvailabilityException) {
const id = currentException.availabilityException.id;
const isResetToPlanSeats = seatsFromPlan === seats;
if (isResetToPlanSeats) {
// Delete the exception, if the exception is redundant
// (it has the same content as what user has in the plan).
this.props.availability.onDeleteAvailabilityException({
id,
currentException: exception,
seats: seatsFromPlan,
});
} else {
// If availability exception exists, delete it first and then create a new one.
// NOTE: currently, API does not support update (only deleting and creating)
this.props.availability
.onDeleteAvailabilityException({ id, currentException: exception, seats: seatsFromPlan })
.then(r => {
const params = { listingId, start, end, seats, currentException: exception };
this.props.availability.onCreateAvailabilityException(params);
});
}
} else {
// If there is no existing AvailabilityExceptions, just create a new one
const params = { listingId, start, end, seats, currentException: exception };
this.props.availability.onCreateAvailabilityException(params);
}
}
onDateChange(date) {
this.setState({ date });
const { availabilityPlan, availability } = this.props;
const calendar = availability.calendar;
const { exceptions = [], bookings = [] } = calendar[monthIdString(date)] || {};
const { isPast, isBlocked, isBooked, isInProgress } = dateModifiers(
availabilityPlan,
exceptions,
bookings,
date
);
if (isBooked || isPast || isInProgress) {
// Cannot allow or block a reserved or a past date or inProgress
return;
} else if (isBlocked) {
// Unblock the date (seats = 1)
this.onDayAvailabilityChange(date, 1, exceptions);
} else {
// Block the date (seats = 0)
this.onDayAvailabilityChange(date, 0, exceptions);
}
}
onFocusChange() {
// Force the state.focused to always be truthy so that date is always selectable
this.setState({ focused: true });
}
onMonthClick(monthFn) {
const onMonthChanged = this.props.onMonthChanged;
this.setState(
prevState => ({ currentMonth: monthFn(prevState.currentMonth) }),
() => {
// Callback function after month has been updated.
// react-dates component has next and previous months ready (but inivisible).
// we try to populate those invisible months before user advances there.
this.fetchMonthData(monthFn(this.state.currentMonth));
// If previous fetch for month data failed, try again.
const monthId = monthIdString(this.state.currentMonth);
const currentMonthData = this.props.availability.calendar[monthId];
const { fetchExceptionsError, fetchBookingsError } = currentMonthData || {};
if (currentMonthData && (fetchExceptionsError || fetchBookingsError)) {
this.fetchMonthData(this.state.currentMonth);
}
// Call onMonthChanged function if it has been passed in among props.
if (onMonthChanged) {
onMonthChanged(monthIdString(this.state.currentMonth));
}
}
);
}
render() {
const {
className,
rootClassName,
listingId,
availability,
availabilityPlan,
onMonthChanged,
monthFormat,
...rest
} = this.props;
const { focused, date, currentMonth } = this.state;
const { clientWidth: width } = this.dayPickerWrapper || { clientWidth: 0 };
const hasWindow = typeof window !== 'undefined';
const windowWidth = hasWindow ? window.innerWidth : 0;
const daySize = dayWidth(width, windowWidth);
const calendarGridWidth = daySize * TABLE_COLUMNS + TABLE_BORDER;
const calendar = availability.calendar;
const currentMonthData = calendar[monthIdString(currentMonth)];
const {
fetchExceptionsInProgress,
fetchBookingsInProgress,
fetchExceptionsError,
fetchBookingsError,
} = currentMonthData || {};
const isMonthDataFetched =
!isMonthInRange(currentMonth) ||
(!!currentMonthData && !fetchExceptionsInProgress && !fetchBookingsInProgress);
const monthName = currentMonth.format('MMMM');
const classes = classNames(rootClassName || css.root, className);
return (
<div
className={classes}
ref={c => {
this.dayPickerWrapper = c;
}}
>
{width > 0 ? (
<div style={{ width: `${calendarGridWidth}px` }}>
<DayPickerSingleDateController
{...rest}
ref={c => {
this.dayPicker = c;
}}
numberOfMonths={1}
navPrev={<IconArrowHead direction="left" />}
navNext={<IconArrowHead direction="right" />}
weekDayFormat="ddd"
daySize={daySize}
renderDayContents={renderDayContents(calendar, availabilityPlan)}
focused={focused}
date={date}
onDateChange={this.onDateChange}
onFocusChange={this.onFocusChange}
onPrevMonthClick={() => this.onMonthClick(prevMonthFn)}
onNextMonthClick={() => this.onMonthClick(nextMonthFn)}
hideKeyboardShortcutsPanel
horizontalMonthPadding={9}
renderMonthElement={({ month }) => (
<div className={css.monthElement}>
<span className={css.monthString}>{month.format(monthFormat)}</span>
{!isMonthDataFetched ? <IconSpinner rootClassName={css.monthInProgress} /> : null}
</div>
)}
/>
</div>
) : null}
<div className={css.legend} style={{ width: `${calendarGridWidth}px` }}>
<div className={css.legendRow}>
<span className={css.legendAvailableColor} />
<span className={css.legendText}>
<FormattedMessage id="EditListingAvailabilityForm.availableDay" />
</span>
</div>
<div className={css.legendRow}>
<span className={css.legendBlockedColor} />
<span className={css.legendText}>
<FormattedMessage id="EditListingAvailabilityForm.blockedDay" />
</span>
</div>
<div className={css.legendRow}>
<span className={css.legendReservedColor} />
<span className={css.legendText}>
<FormattedMessage id="EditListingAvailabilityForm.bookedDay" />
</span>
</div>
</div>
{fetchExceptionsError && fetchBookingsError ? (
<p className={css.error}>
<FormattedMessage
id="EditListingAvailabilityForm.fetchMonthDataFailed"
values={{ month: monthName }}
/>
</p>
) : null}
</div>
);
}
}
ManageAvailabilityCalendar.defaultProps = {
className: null,
rootClassName: null,
// day presentation and interaction related props
renderCalendarDay: undefined,
renderDayContents: null,
isDayBlocked: () => false,
isOutsideRange,
isDayHighlighted: () => false,
enableOutsideDays: true,
// calendar presentation and interaction related props
orientation: HORIZONTAL_ORIENTATION,
withPortal: false,
initialVisibleMonth: null,
numberOfMonths: 2,
onOutsideClick() {},
keepOpenOnDateSelect: false,
renderCalendarInfo: null,
isRTL: false,
// navigation related props
navPrev: null,
navNext: null,
onPrevMonthClick() {},
onNextMonthClick() {},
// internationalization
monthFormat: 'MMMM YYYY',
onMonthChanged: null,
};
ManageAvailabilityCalendar.propTypes = {
className: string,
rootClassName: string,
availability: shape({
calendar: object.isRequired,
onFetchAvailabilityExceptions: func.isRequired,
onFetchBookings: func.isRequired,
onDeleteAvailabilityException: func.isRequired,
onCreateAvailabilityException: func.isRequired,
}).isRequired,
onMonthChanged: func,
};
export default ManageAvailabilityCalendar;

View file

@ -0,0 +1,34 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`EditListingAvailabilityForm matches snapshot 1`] = `
<ReactFinalForm
availability={
Object {
"calendar": Object {},
"onCreateAvailabilityException": [Function],
"onDeleteAvailabilityException": [Function],
"onFetchAvailabilityExceptions": [Function],
"onFetchBookings": [Function],
}
}
dispatch={[Function]}
intl={
Object {
"formatDate": [Function],
"formatHTMLMessage": [Function],
"formatMessage": [Function],
"formatNumber": [Function],
"formatPlural": [Function],
"formatRelative": [Function],
"formatTime": [Function],
"now": [Function],
}
}
onSubmit={[Function]}
render={[Function]}
saveActionMsg="Save rules"
updateError={null}
updateInProgress={false}
updated={false}
/>
`;

View file

@ -1,5 +1,6 @@
export { default as BookingDatesForm } from './BookingDatesForm/BookingDatesForm';
export { default as ContactDetailsForm } from './ContactDetailsForm/ContactDetailsForm';
export { default as EditListingAvailabilityForm } from './EditListingAvailabilityForm/EditListingAvailabilityForm';
export { default as EditListingDescriptionForm } from './EditListingDescriptionForm/EditListingDescriptionForm';
export { default as EditListingFeaturesForm } from './EditListingFeaturesForm/EditListingFeaturesForm';
export { default as EditListingLocationForm } from './EditListingLocationForm/EditListingLocationForm';

View file

@ -128,6 +128,11 @@
"DateInput.closeDatePicker": "Close",
"DateInput.defaultPlaceholder": "Date input",
"DateInput.screenReaderInputMessage": "Date input",
"EditListingAvailabilityForm.fetchMonthDataFailed": "Oops, couldn't load data for {month}, please try again.",
"EditListingAvailabilityForm.availableDay": "Available",
"EditListingAvailabilityForm.blockedDay": "Not available",
"EditListingAvailabilityForm.bookedDay": "Booked",
"EditListingAvailabilityForm.updateFailed": "Failed to update listing. Please try again.",
"EditListingDescriptionForm.categoryLabel": "Sauna type",
"EditListingDescriptionForm.categoryPlaceholder": "Choose the type of your sauna…",
"EditListingDescriptionForm.categoryRequired": "You need to select a category for your sauna.",