diff --git a/packages/travix-ui-kit/.eslintrc b/packages/travix-ui-kit/.eslintrc index 1e6473a..c9c964b 100644 --- a/packages/travix-ui-kit/.eslintrc +++ b/packages/travix-ui-kit/.eslintrc @@ -31,6 +31,7 @@ "comma-dangle": ["error", "always-multiline"], "import/no-extraneous-dependencies": ["error", {"devDependencies": true}], "max-len": [2, {"code": 120, "comments": 120, "tabWidth": 4}], + "react/jsx-indent": [2, 2], "react/react-in-jsx-scope": 0, "no-else-return": 0 } diff --git a/packages/travix-ui-kit/builder/index.js b/packages/travix-ui-kit/builder/index.js index a2e0efb..b7f90cf 100755 --- a/packages/travix-ui-kit/builder/index.js +++ b/packages/travix-ui-kit/builder/index.js @@ -3,6 +3,7 @@ const builder = require('./builder'); const pkg = require('../package.json'); const program = require('commander'); +const util = require('util'); program .version(pkg.version) @@ -16,4 +17,4 @@ program.parse(process.argv); builder(program) .then(() => console.log('Done!')) - .catch(console.error); + .catch(e => console.error(util.inspect(e, true, undefined, true))); diff --git a/packages/travix-ui-kit/components/_helpers.js b/packages/travix-ui-kit/components/_helpers.js index 6d1dbbb..694b371 100644 --- a/packages/travix-ui-kit/components/_helpers.js +++ b/packages/travix-ui-kit/components/_helpers.js @@ -22,8 +22,44 @@ function getDataAttributes(attributes = {}) { }, {}); } +/** + * @function leftPad + * @param {Number} value The value to be left padded + * @return {String} The value with a leading 0 if it's between 1 - 9 + */ +function leftPad(value, maxLength = 2, leftPaddedBy = '0') { + const valueStringified = value.toString(); + if (valueStringified.length >= maxLength) { + return valueStringified; + } + + return leftPaddedBy.repeat(maxLength - valueStringified.length) + valueStringified; +} + +/** + * Receives a date object and normalizes it to the proper hours, minutes, + * seconds and milliseconds. + * + * @method normalizeDate + * @param {Date} dateObject Date object to be normalized. + * @param {Number} hours Value to set the hours to. Defaults to 0 + * @param {Number} minutes Value to set the minutes to. Defaults to 0 + * @param {Number} seconds Value to set the seconds to. Defaults to 0 + * @param {Number} milliseconds Value to set the milliseconds to. Defaults to 0 + * @return {Date} The normalized date object. + */ +function normalizeDate(dateObject, hours = 0, minutes = 0, seconds = 0, milliseconds = 0) { + dateObject.setHours(hours); + dateObject.setMinutes(minutes); + dateObject.setSeconds(seconds); + dateObject.setMilliseconds(milliseconds); + return dateObject; +} + // Exports export default { getClassNamesWithMods, getDataAttributes, + leftPad, + normalizeDate, }; diff --git a/packages/travix-ui-kit/components/calendar/calendar.js b/packages/travix-ui-kit/components/calendar/calendar.js new file mode 100644 index 0000000..52e9fa0 --- /dev/null +++ b/packages/travix-ui-kit/components/calendar/calendar.js @@ -0,0 +1,314 @@ +import React, { Component, PropTypes } from 'react'; +import { getClassNamesWithMods, getDataAttributes, normalizeDate } from '../_helpers'; +import DaysPanel from './panels/days'; +import calendarConstants from './constants/calendar'; + +const { + CALENDAR_MOVE_TO_NEXT, + CALENDAR_MOVE_TO_PREVIOUS, + CALENDAR_SELECTION_TYPE_RANGE, +} = calendarConstants; + + +/** + * Processes the given props and the existing state and returns + * a new state. + * + * @function processProps + * @param {Object} props Props to base the new state on. + * @param {Object} state (Existing) state to be based on for the existing values. + * @return {Object} New state to be set/used. + * @static + */ +function processProps(props) { + const { initialDates, maxDate, minDate, selectionType } = props; + const maxLimit = maxDate ? normalizeDate(new Date(maxDate), 23, 59, 59, 999) : null; + + const renderDate = (initialDates && initialDates.length && initialDates[0]) ? new Date(initialDates[0]) : new Date(); + normalizeDate(renderDate); + + let minLimit = minDate ? normalizeDate(new Date(minDate)) : null; + let selectedDates = [null, null]; + + if (initialDates) { + selectedDates = selectedDates.map((item, idx) => { + if (!initialDates[idx]) { + return null; + } + + return normalizeDate(new Date(initialDates[idx])); + }); + } + + /** + * If a minDate or a maxDate is set, let's check if any selectedDates are outside of the boundaries. + * If so, resets the selectedDates. + */ + if (minLimit || maxLimit) { + const isAnyDateOutOfLimit = selectedDates.some(item => ( + item && ( + (minLimit && (minLimit.getTime() > item.getTime())) || + (maxLimit && (maxLimit.getTime() < item.getTime())) + ) + )); + + if (isAnyDateOutOfLimit) { + selectedDates = [null, null]; + console.warn(`A calendar instance contains a selectedDate outside of the minDate and maxDate boundaries`); // eslint-disable-line + } + } + + /** If initialDates is defined and we have a start date, we want to set it as the minLimit */ + if (selectedDates[0] && (selectionType === CALENDAR_SELECTION_TYPE_RANGE)) { + minLimit = selectedDates[0]; + } + + /** If the renderDate is not between any of the minLimit and/or maxDate, we need to redefine it. */ + if ( + (minLimit && (renderDate.getMonth() < minLimit.getMonth())) || + (maxLimit && (renderDate.getMonth() > maxLimit.getMonth())) + ) { + renderDate.setMonth(minLimit.getMonth()); + } + + return { + maxLimit, + minLimit, + renderDate, + selectedDates, + }; +} + +export default class Calendar extends Component { + constructor(props) { + super(); + + this.moveToMonth = this.moveToMonth.bind(this); + this.state = processProps(props); + } + + componentWillReceiveProps(newProps) { + const { initialDates, maxDate, minDate, selectionType } = newProps; + + let propsChanged = ( + (maxDate !== this.props.maxDate) || + (minDate !== this.props.minDate) || + (selectionType !== this.props.selectionType) + ); + + if (initialDates) { + if (this.props.initialDates) { + propsChanged = propsChanged || initialDates.some((item, idx) => item !== this.props.initialDates[idx]); + } else { + propsChanged = true; + } + } + + if (propsChanged) { + this.setState(() => processProps(newProps)); + } + } + + /** + * Changes the renderDate of the calendar to the previous or next month. + * Also triggers the onNavPreviousMonth/onNavNextMonth when the state gets changed + * and passes the new date to it. + * + * @method moveToMonth + * @param {String} direction Defines to which month is the calendar moving (previous or next). + */ + moveToMonth(direction) { + const { onNavNextMonth, onNavPreviousMonth } = this.props; + + this.setState(({ renderDate }) => { + renderDate.setMonth(renderDate.getMonth() + (direction === CALENDAR_MOVE_TO_PREVIOUS ? -1 : 1)); + return { renderDate }; + }, () => { + if ((direction === CALENDAR_MOVE_TO_PREVIOUS) && onNavPreviousMonth) { + onNavPreviousMonth(this.state.renderDate); + } else if ((direction === CALENDAR_MOVE_TO_NEXT) && onNavNextMonth) { + onNavNextMonth(this.state.renderDate); + } + }); + } + + /** + * Handler for the day's selection. Passed to the DaysPanel -> DaysView. + * Also triggers the onSelectDay function (when passed) after the state is updated, + * passing the selectedDates array to it. + * + * @method onSelectDay + * @param {Date} dateSelected Date selected by the user. + */ + onSelectDay(dateSelected) { + const { onSelectDay, selectionType, minDate } = this.props; + + this.setState((prevState) => { + let { minLimit, renderDate, selectedDates } = prevState; + + /** + * If the calendar's selectionType is 'normal', we always set the date selected + * to the first position of the selectedDates array. + * If the selectionType is 'range', we need to verify the following requirements: + * + * - If there's no start date selected, then the selected date becomes the start + * date and the minLimit becomes that same date. Prevents the range selection to the past. + * + * - If there's a start date already selected: + * + * - If there's no end date selected, then the selected date becomes the end date. Also + * if the start and end dates are the same, it will remove the minLimit as the layout renders + * them as a 'normal' selection. + * + * - If there's an end date selected and the user is clicking on the start date again, it + * clears the selections and the limits, resetting the range. + */ + if (selectionType === CALENDAR_SELECTION_TYPE_RANGE) { + if (selectedDates[0]) { + if (!selectedDates[1]) { + selectedDates[1] = dateSelected; + if (selectedDates[0].toDateString() === selectedDates[1].toDateString()) { + minLimit = minDate ? normalizeDate(new Date(minDate)) : null; + } + } else { + selectedDates = [null, null]; + minLimit = minDate ? normalizeDate(new Date(minDate)) : null; + } + } else { + selectedDates[0] = dateSelected; + minLimit = dateSelected; + selectedDates[1] = null; + } + } else { + selectedDates[0] = dateSelected; + } + + /** + * If the user selects a day of the previous or next month, the rendered month switches to + * the one of the selected date. + */ + if (dateSelected.getMonth() !== renderDate.getMonth()) { + renderDate = new Date(dateSelected.toDateString()); + } + + return { + minLimit, + renderDate, + selectedDates, + }; + }, () => { + if (onSelectDay) { + onSelectDay(this.state.selectedDates); + } + }); + } + + render() { + const { dataAttrs = {}, isDaySelectableFn, locale, mods = [], navButtons, selectionType } = this.props; + const { maxLimit, minLimit, renderDate, selectedDates } = this.state; + + const restProps = getDataAttributes(dataAttrs); + const className = getClassNamesWithMods('ui-calendar', mods); + + return ( +
+ this.moveToMonth(CALENDAR_MOVE_TO_NEXT)} + onNavPreviousMonth={() => this.moveToMonth(CALENDAR_MOVE_TO_PREVIOUS)} + onSelectDay={dt => this.onSelectDay(dt)} + renderDate={renderDate} + selectedDates={selectedDates} + selectionType={selectionType} + /> +
+ ); + } +} + +Calendar.defaultProps = { + selectionType: 'normal', +}; + +Calendar.propTypes = { + /** + * Data attribute. You can use it to set up GTM key or any custom data-* attribute + */ + dataAttrs: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.object, + ]), + + /** + * Optional. Initial value of the calendar. Defaults to the current date as per the locale. + */ + initialDates: PropTypes.array, + + /** + * Optional. Function to be triggered to evaluate if the date (passed as an argument) + * is selectable. Must return a boolean. + */ + isDaySelectableFn: PropTypes.func, + + /** + * Locale definitions, with the calendar's months and weekdays in the right language. + * Also contains the startWeekDay which defines in which week day starts the week. + */ + locale: PropTypes.shape({ + months: PropTypes.array, + weekDays: PropTypes.array, + startWeekDay: PropTypes.number, + }), + + /** + * Sets the max date boundary. Defaults to `null`. + */ + maxDate: PropTypes.string, + + /** + * Sets the min date boundary. Defaults to `null`. + */ + minDate: PropTypes.string, + + /** + * You can provide set of custom modifications. + */ + mods: PropTypes.arrayOf(PropTypes.string), + + navButtons: PropTypes.shape({ + days: PropTypes.shape({ + next: PropTypes.shape({ + ariaLabel: PropTypes.string, + displayValue: PropTypes.string, + }), + previous: PropTypes.shape({ + ariaLabel: PropTypes.string, + displayValue: PropTypes.string, + }), + }), + }), + + /** + * Function to be triggered when pressing the nav's "next" button. + */ + onNavNextMonth: PropTypes.func, + + /** + * Function to be triggered when pressing the nav's "previous" button. + */ + onNavPreviousMonth: PropTypes.func, + + /** + * Function to be triggered when selecting a day. + */ + onSelectDay: PropTypes.func, + + /** + * Optional. Type of date selection. + */ + selectionType: PropTypes.oneOf(['normal', 'range']), +}; diff --git a/packages/travix-ui-kit/components/calendar/calendar.md b/packages/travix-ui-kit/components/calendar/calendar.md new file mode 100644 index 0000000..f816ae0 --- /dev/null +++ b/packages/travix-ui-kit/components/calendar/calendar.md @@ -0,0 +1,102 @@ +# Basic calendar with attributes set: + +
+ ((dt.getDay() > 0) && (dt.getDay() < 6))} + locale={{ + months: [ + { name: 'Janeiro', short: 'Jan' }, + { name: 'Fevereiro', short: 'Fev' }, + { name: 'Março', short: 'Mar' }, + { name: 'Abril', short: 'Abr' }, + { name: 'Maio', short: 'Mai' }, + { name: 'Junho', short: 'Jun' }, + { name: 'Julho', short: 'Jul' }, + { name: 'Agosto', short: 'Ago' }, + { name: 'Setembro', short: 'Set' }, + { name: 'Outubro', short: 'Out' }, + { name: 'Novembro', short: 'Nov' }, + { name: 'Dezembro', short: 'Dez' }, + ], + weekDays: [ + { name: 'Domingo', short: 'Dom' }, + { name: 'Segunda', short: 'Seg' }, + { name: 'Terça', short: 'Ter' }, + { name: 'Quarta', short: 'Qua' }, + { name: 'Quinta', short: 'Qui' }, + { name: 'Sexta', short: 'Sex' }, + { name: 'Sábado', short: 'Sáb' }, + ], + }} + minDate="2017-04-01" + maxDate="2017-06-01" + navButtons={{ + days: { + next: { + displayValue: '›' + }, + previous: { + displayValue: '‹' + }, + } + }} + onSelectDay={dt => (document.getElementById('output_1').value = dt[0].toDateString())} + /> + +
+ + +--- + +# Range calendar with attributes set: + +
+ ((dt.getDay() > 0) && (dt.getDay() < 6))} + locale={{ + months: [ + { name: 'Janeiro', short: 'Jan' }, + { name: 'Fevereiro', short: 'Fev' }, + { name: 'Março', short: 'Mar' }, + { name: 'Abril', short: 'Abr' }, + { name: 'Maio', short: 'Mai' }, + { name: 'Junho', short: 'Jun' }, + { name: 'Julho', short: 'Jul' }, + { name: 'Agosto', short: 'Ago' }, + { name: 'Setembro', short: 'Set' }, + { name: 'Outubro', short: 'Out' }, + { name: 'Novembro', short: 'Nov' }, + { name: 'Dezembro', short: 'Dez' }, + ], + weekDays: [ + { name: 'Domingo', short: 'Dom' }, + { name: 'Segunda', short: 'Seg' }, + { name: 'Terça', short: 'Ter' }, + { name: 'Quarta', short: 'Qua' }, + { name: 'Quinta', short: 'Qui' }, + { name: 'Sexta', short: 'Sex' }, + { name: 'Sábado', short: 'Sáb' }, + ], + }} + minDate="2017-04-01" + maxDate="2017-06-01" + navButtons={{ + days: { + next: { + displayValue: '›' + }, + previous: { + displayValue: '‹' + }, + } + }} + onSelectDay={dt => { + document.getElementById('output_2').value = dt[0].toDateString() + ' / ' + dt[1].toDateString(); + }} + selectionType="range" + /> + +
+--- diff --git a/packages/travix-ui-kit/components/calendar/calendar.scss b/packages/travix-ui-kit/components/calendar/calendar.scss new file mode 100644 index 0000000..6b0ea33 --- /dev/null +++ b/packages/travix-ui-kit/components/calendar/calendar.scss @@ -0,0 +1,175 @@ +.ui-calendar { + display: flex; + flex-flow: column wrap; + height: $tx-calendar-height; + width: $tx-calendar-width; +} + +.ui-calendar-days-panel { + align-items: stretch; + display: flex; + flex-flow: column wrap; + flex: 1 1 100%; + + &_hidden { + display: none; + } +} + +.ui-calendar-days { + align-items: stretch; + display: flex; + flex-flow: column wrap; + flex: 1 1 100%; +} + +.ui-calendar-days__nav { + align-items: stretch; + background: $tx-calendar-nav-background; + border-radius: $tx-calendar-nav-border-radius; + display: flex; + flex: 0 0 $tx-calendar-nav-height; + flex-flow: row nowrap; + width: 100%; +} + +.ui-calendar-days__previous-month, +.ui-calendar-days__next-month { + align-self: center; + background: $tx-calendar-nav-button-background-color-end; + background: linear-gradient(to bottom,$tx-calendar-nav-button-background-color-start 0,$tx-calendar-nav-button-background-color-end 100%); + border: none; + border-radius: $tx-calendar-nav-button-border-radius; + box-shadow: 0 2px $tx-calendar-nav-button-box-shadow-color; + cursor: pointer; + color: $tx-calendar-nav-button-color; + flex: 0 0 $tx-calendar-nav-button-width; + font-weight: $tx-calendar-nav-button-font-weight; + height: $tx-calendar-nav-button-height; + margin: $tx-calendar-nav-button-margin; + + &[disabled] { + visibility: hidden; + } +} + +.ui-calendar-days__rendered-month { + align-self: center; + color: $tx-calendar-nav-label-color; + flex: 1 1 100%; + font-family: sans-serif; + font-weight: $tx-calendar-nav-label-font-weight; + text-align: center; +} + +.ui-calendar-days__weekdays { + align-items: center; + background-color: $tx-calendar-header-background-color; + display: flex; + flex: 0 0 $tx-calendar-header-height; + flex-flow: row nowrap; +} + +.ui-calendar-days__weekday { + color: $tx-calendar-header-weekday-color; + display: flex; + flex: 1 1 14.285%; + font-family: sans-serif; + font-size: $tx-calendar-header-weekday-font-size; + font-weight: $tx-calendar-header-weekday-font-weight; + justify-content: center; + text-transform: lowercase; +} + +.ui-calendar-days__options { + border: 1px solid $tx-calendar-options-border-color; + display: flex; + flex: 1 1 auto; + flex-flow: row wrap; +} + +.ui-calendar-days-option { + background: $tx-calendar-options-option-background-color; + border: none; + border-bottom: 1px solid $tx-calendar-options-option-border-bottom; + border-right: 1px solid $tx-calendar-options-option-border-right; + color: $tx-calendar-options-option-color; + cursor: pointer; + flex: 1 1 14.285%; + font-size: $tx-calendar-options-option-font-size; + outline: none; + + &:nth-of-type(7), + &:nth-of-type(14), + &:nth-of-type(21), + &:nth-of-type(28), + &:nth-of-type(35), + &:nth-of-type(42) { + border-right: none; + } + + &:nth-of-type(36), + &:nth-of-type(37), + &:nth-of-type(38), + &:nth-of-type(39), + &:nth-of-type(40), + &:nth-of-type(41), + &:nth-of-type(42) { + border-bottom: none; + } + + &[disabled] { + background: $tx-calendar-options-option-disabled-background-color; + color: $tx-calendar-options-option-disabled-color; + } + + &.ui-calendar-days-option_selected, + &.ui-calendar-days-option_selected-start, + &.ui-calendar-days-option_selected-end, + &:not([disabled]):hover { + background: $tx-calendar-options-option-hover-background-color; + color: $tx-calendar-options-option-hover-color; + } + + &.ui-calendar-days-option_selected-between { + background: $tx-calendar-options-option-range-between-background-color; + } + + &:not([disabled]):focus { + z-index: 1; + } +} + +.ui-calendar-days-option_selected-start, +.ui-calendar-days-option_selected-end { + position: relative; + + &:before, + &:after { + border: 18px solid $tx-calendar-options-option-range-after-border-color; + bottom: 0; + content: " "; + position: absolute; + top: 0; + } +} + +.ui-calendar-days-option_selected-start:after { + border-left: 10px solid $tx-calendar-options-option-range-start-after-border-color; + border-right: none; + right: -10px; +} + +.ui-calendar-days-option_selected-end:before { + border-left: none; + border-right: 10px solid $tx-calendar-options-option-range-end-after-border-color; + left: -10px; +} + +.ui-calendar-days-option_previous-month { + background: $tx-calendar-options-option-previous-month-background-color; +} + +.ui-calendar-days-option_next-month { + background: $tx-calendar-options-option-next-month-background-color; +} diff --git a/packages/travix-ui-kit/components/calendar/constants/calendar.js b/packages/travix-ui-kit/components/calendar/constants/calendar.js new file mode 100644 index 0000000..b89bfc6 --- /dev/null +++ b/packages/travix-ui-kit/components/calendar/constants/calendar.js @@ -0,0 +1,8 @@ +/** + * This file contains constants used by the calendar component. + */ +module.exports.CALENDAR_MOVE_TO_NEXT = 'next'; +module.exports.CALENDAR_MOVE_TO_PREVIOUS = 'previous'; + +module.exports.CALENDAR_SELECTION_TYPE_NORMAL = 'normal'; +module.exports.CALENDAR_SELECTION_TYPE_RANGE = 'range'; diff --git a/packages/travix-ui-kit/components/calendar/panels/days.js b/packages/travix-ui-kit/components/calendar/panels/days.js new file mode 100644 index 0000000..cf6c438 --- /dev/null +++ b/packages/travix-ui-kit/components/calendar/panels/days.js @@ -0,0 +1,6 @@ +import React from 'react'; +import DaysView from '../views/days'; + +export default function Days(props) { + return
; +} diff --git a/packages/travix-ui-kit/components/calendar/views/days.js b/packages/travix-ui-kit/components/calendar/views/days.js new file mode 100644 index 0000000..02475c0 --- /dev/null +++ b/packages/travix-ui-kit/components/calendar/views/days.js @@ -0,0 +1,353 @@ +import React, { Component, PropTypes } from 'react'; + +import { getClassNamesWithMods, getDataAttributes, leftPad } from '../../_helpers'; +import calendarConstants from '../constants/calendar'; + +const { CALENDAR_SELECTION_TYPE_RANGE } = calendarConstants; + +class Days extends Component { + constructor(props) { + super(props); + + this.getLocale = this.getLocale.bind(this); + this.isOptionEnabled = this.isOptionEnabled.bind(this); + this.renderDays = this.renderDays.bind(this); + this.renderNav = this.renderNav.bind(this); + this.renderWeekDays = this.renderWeekDays.bind(this); + + this.locale = this.getLocale(); + this.navButtons = this.getNavButtons(); + } + + /** + * Returns a merge between a default locale and a provided one via props. + * + * @method getLocale + * @return {Object} The final locale, which is stored in a property of the instance. + */ + getLocale() { + const { locale } = this.props; + + const defaultLocale = { + months: [ + { name: 'January', short: 'Jan' }, + { name: 'February', short: 'Feb' }, + { name: 'March', short: 'Mar' }, + { name: 'April', short: 'Apr' }, + { name: 'May', short: 'May' }, + { name: 'June', short: 'Jun' }, + { name: 'July', short: 'Jul' }, + { name: 'August', short: 'Aug' }, + { name: 'September', short: 'Sep' }, + { name: 'October', short: 'Oct' }, + { name: 'November', short: 'Nov' }, + { name: 'December', short: 'Dec' }, + ], + startWeekDay: 1, + weekDays: [ + { name: 'Sunday', short: 'Sun' }, + { name: 'Monday', short: 'Mon' }, + { name: 'Tuesday', short: 'Tue' }, + { name: 'Wednesday', short: 'Wed' }, + { name: 'Thursday', short: 'Thu' }, + { name: 'Friday', short: 'Fri' }, + { name: 'Saturday', short: 'Sat' }, + ], + }; + + return locale ? Object.assign(defaultLocale, locale) : defaultLocale; + } + + getNavButtons() { + const { navButtons } = this.props; + + const defaultNavButtons = { + days: { + next: { + displayValue: '>', + }, + previous: { + displayValue: '<', + }, + }, + }; + + return navButtons ? Object.assign(defaultNavButtons, navButtons) : defaultNavButtons; + } + + /** + * If any of the boundaries is defined and the date being rendered is out of those boundaries + * or if there's a function to check if the day is selectable and it returns false, + * returns false. + * + * @method isOptionEnabled + * @param {Date} dateToBeRendered Date object to be verified. + * @return {Boolean} Returns true when enabled, false when disabled. + */ + isOptionEnabled(dateToBeRendered) { + const { isDaySelectableFn, maxDate, minDate } = this.props; + + if ( + (maxDate && (maxDate.getTime() < dateToBeRendered.getTime())) || + (minDate && (minDate.getTime() > dateToBeRendered.getTime())) || + (isDaySelectableFn && !isDaySelectableFn(dateToBeRendered)) + ) { + return false; + } + + return true; + } + + /** + * Renders the options with the days in the view. + * It does so, by calculating the number of days needed from the previous month and, + * starting from that day, iterates 42 days (to fill in all the 'options' in the calendar). + * While doing so, it handles the classes that the option element must have. Depending on + * if it is a 'range' or 'normal' calendar will determine how the selection (when applicable) + * should be rendered. + * Also handles the special case where, in a 'range' calendar, if both start and end dates are + * equal, it displays as a 'normal' selection (with the 'selected' modifier). + * + * @method renderDays + * @return {HTMLElement} + */ + renderDays() { + const { onSelectDay, renderDate, selectedDates, selectionType } = this.props; + const { months, startWeekDay, weekDays } = this.locale; + + /** Calculates the amount of days that */ + const currentDate = new Date(renderDate.getFullYear(), renderDate.getMonth(), 1, 0, 0, 0); + const firstWeekDayOfMonth = currentDate.getDay(); + const numDaysOfPreviousMonth = firstWeekDayOfMonth < startWeekDay ? 6 : (firstWeekDayOfMonth - startWeekDay); + + currentDate.setDate(currentDate.getDate() - numDaysOfPreviousMonth); + + const options = []; + let counter = 0; + + const limits = selectedDates.map(item => (item ? item.getTime() : null)); + const selectedDatesAreEqual = (limits.length === 2) && (limits[0] === limits[1]); + + do { + const mods = []; + const selectedDate = selectedDates && selectedDates.find((item) => { + return item && (item.toDateString() === currentDate.toDateString()); + }); + + if (currentDate.getMonth() < renderDate.getMonth()) { + mods.push('previous-month'); + } else if (currentDate.getMonth() > renderDate.getMonth()) { + mods.push('next-month'); + } + + if (selectedDate) { + if ((selectionType === CALENDAR_SELECTION_TYPE_RANGE) && !selectedDatesAreEqual) { + mods.push((selectedDates.indexOf(selectedDate) === 0) ? 'selected-start' : 'selected-end'); + } else { + mods.push('selected'); + } + } else if (limits[1] && (limits[0] <= currentDate.getTime()) && (limits[1] >= currentDate.getTime())) { + mods.push('selected-between'); + } + + const className = getClassNamesWithMods('ui-calendar-days-option', mods); + + const dateInYYYYMMDD = [ + currentDate.getFullYear(), + leftPad(currentDate.getMonth() + 1), + leftPad(currentDate.getDate()), + ].join('-'); + + const ariaLabel = [ + `${weekDays[currentDate.getDay()].name},`, + currentDate.getDate(), + months[currentDate.getMonth()].name, + currentDate.getFullYear(), + ].join(' '); + + const onClickHandler = onSelectDay.bind(null, new Date(currentDate.toDateString())); + + options.push( + + ); + currentDate.setDate(currentDate.getDate() + 1); + counter += 1; + } while (counter < 42); + + return
{options}
; + } + + /** + * Renders the navigation of the days' calendar. + * + * @method renderNav + * @return {HTMLElement} + */ + renderNav() { + const { renderDate, minDate, maxDate, onNavPreviousMonth, onNavNextMonth } = this.props; + const locale = this.locale; + const { next, previous } = this.navButtons.days; + + const nextMonth = renderDate.getMonth() === 11 ? 0 : (renderDate.getMonth() + 1); + const previousMonth = renderDate.getMonth() === 0 ? 11 : (renderDate.getMonth() - 1); + + const isPreviousMonthDisabled = minDate && (minDate.getMonth() >= renderDate.getMonth()); + const isNextMonthDisabled = maxDate && (maxDate.getMonth() <= renderDate.getMonth()); + + return ( + + ); + } + + /** + * Renders the week days header of the days' calendar. + * + * @method renderWeekDays + * @return {HTMLElement} + */ + renderWeekDays() { + const { startWeekDay, weekDays } = this.locale; + return ( +
+ {weekDays.slice(startWeekDay).map((weekDay, idx) => { + return
{weekDay.short}
; + })} + {startWeekDay > 0 ?
{weekDays[0].short}
: null} +
+ ); + } + + render() { + const { dataAttrs, mods } = this.props; + const className = getClassNamesWithMods('ui-calendar-days', mods); + const restProps = getDataAttributes(dataAttrs); + + return ( +
+ {this.renderNav()} + {this.renderWeekDays()} + {this.renderDays()} +
+ ); + } +} + +Days.defaultProps = { + dataAttrs: {}, + hide: false, + maxDate: null, + minDate: null, + mods: [], +}; + +Days.propTypes = { + /** + * Data attribute. You can use it to set up GTM key or any custom data-* attribute + */ + dataAttrs: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.object, + ]), + + /** + * Optional. Function to be triggered to evaluate if the date (passed as an argument) + * is selectable. Must return a boolean. + */ + isDaySelectableFn: PropTypes.func, + + /** + * Locale definitions, with the calendar's months and weekdays in the right language. + * Also contains the startWeekDay which defines in which week day starts the week. + */ + locale: PropTypes.shape({ + months: PropTypes.array, + weekDays: PropTypes.array, + startWeekDay: PropTypes.number, + }), + + /** + * You can provide set of custom modifications. + */ + mods: PropTypes.arrayOf(PropTypes.string), + + /** + * Sets the max date boundary. Defaults to `null`. + */ + maxDate: PropTypes.objectOf(Date), + + /** + * Sets the min date boundary. Defaults to `null`. + */ + minDate: PropTypes.objectOf(Date), + + navButtons: PropTypes.shape({ + days: PropTypes.shape({ + next: PropTypes.shape({ + ariaLabel: PropTypes.string, + displayValue: PropTypes.string, + }), + previous: PropTypes.shape({ + ariaLabel: PropTypes.string, + displayValue: PropTypes.string, + }), + }), + }), + + /** + * Function to be triggered when pressing the nav's "next" button. + */ + onNavNextMonth: PropTypes.func, + + /** + * Function to be triggered when pressing the nav's "previous" button. + */ + onNavPreviousMonth: PropTypes.func, + + /** + * Function to be triggered when selecting a day. + */ + onSelectDay: PropTypes.func, + + /** + * Date to be rendered in the view + */ + renderDate: PropTypes.objectOf(Date).isRequired, + + /** + * Date that is selected (might not be the one rendered). + */ + selectedDates: PropTypes.arrayOf(PropTypes.objectOf(Date)), + + /** + * Optional. Type of date selection. + */ + selectionType: PropTypes.oneOf(['normal', 'range']), +}; + +export default Days; diff --git a/packages/travix-ui-kit/components/index.js b/packages/travix-ui-kit/components/index.js index 74fd3ad..da216b2 100644 --- a/packages/travix-ui-kit/components/index.js +++ b/packages/travix-ui-kit/components/index.js @@ -1,4 +1,5 @@ import Button from './button/button'; +import Calendar from './calendar/calendar'; import List from './list/list'; import Modal from './modal/modal'; import Price from './price/price'; @@ -7,6 +8,7 @@ import Spinner from './spinner/spinner'; export default { Button, + Calendar, List, Modal, Price, diff --git a/packages/travix-ui-kit/components/index.scss b/packages/travix-ui-kit/components/index.scss index 2b37c08..586bd71 100644 --- a/packages/travix-ui-kit/components/index.scss +++ b/packages/travix-ui-kit/components/index.scss @@ -1,4 +1,5 @@ @import "./button/button.scss"; +@import "./calendar/calendar.scss"; @import "./global/global.scss"; @import "./list/list.scss"; @import "./modal/modal.scss"; diff --git a/packages/travix-ui-kit/package.json b/packages/travix-ui-kit/package.json index 1b0375c..eaa1c7e 100644 --- a/packages/travix-ui-kit/package.json +++ b/packages/travix-ui-kit/package.json @@ -10,8 +10,8 @@ "prebuild:watch": "babel --copy-files ./components --out-dir lib --ignore *.scss,*.md -w &", "styleguide-server": "styleguidist server", "styleguide-build": "styleguidist build", - "test": "jest -c ./tests/jest.config.json", - "update-snapshots": "jest -c ./tests/jest.config.json -u", + "test": "TZ=utc jest -c ./tests/jest.config.json", + "update-snapshots": "TZ=utc jest -c ./tests/jest.config.json -u", "cov": "jest -c ./tests/jest.config.json --coverage --no-cache", "coverage:coveralls": "cat ./coverage/lcov.info | coveralls", "lint": "eslint --color '{components,tests,utils,scripts}/**/*.js'", @@ -78,9 +78,10 @@ "eslint-config-travix": "^2.2.0", "eslint-plugin-babel": "^4.0.0", "eslint-plugin-import": "^2.2.0", - "eslint-plugin-react": "^6.6.0", + "eslint-plugin-react": "^6.10.2", "jest": "^19.0.0", "jest-cli": "^19.0.1", + "jest-serializer-enzyme": "^1.0.0", "react-addons-test-utils": "^0.14.8", "react-styleguidist": "^4.6.2", "webpack-hot-middleware": "^2.15.0" diff --git a/packages/travix-ui-kit/tests/jest.config.json b/packages/travix-ui-kit/tests/jest.config.json index e3f0811..fa95bca 100644 --- a/packages/travix-ui-kit/tests/jest.config.json +++ b/packages/travix-ui-kit/tests/jest.config.json @@ -2,6 +2,7 @@ "collectCoverage": true, "collectCoverageFrom": [ "builder/**/*.js", + "!builder/index.js", "!builder/webpack.config.js", "components/**/*.js", "!components/index.js" @@ -10,7 +11,11 @@ "js", "json" ], + "snapshotSerializers": [ + "/node_modules/jest-serializer-enzyme" + ], "transform": { ".*": "/node_modules/babel-jest" - } + }, + "watchman": false } diff --git a/packages/travix-ui-kit/tests/unit/calendar/__snapshots__/calendar.normal.spec.js.snap b/packages/travix-ui-kit/tests/unit/calendar/__snapshots__/calendar.normal.spec.js.snap new file mode 100644 index 0000000..ec8b193 --- /dev/null +++ b/packages/travix-ui-kit/tests/unit/calendar/__snapshots__/calendar.normal.spec.js.snap @@ -0,0 +1,10704 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Calendar (normal mode) #render() should apply the new initialDates on Calendar even when changed in runtime by the parent component 1`] = ` + +
+ +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ + +
+
+`; + +exports[`Calendar (normal mode) #render() should apply the new minDate on Calendar even when changed by the parent component 1`] = ` + +
+ +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ + +
+
+`; + +exports[`Calendar (normal mode) #render() should merge the locale definition with the default one 1`] = ` + +
+ +
+ +
+ +
+
+ Seg +
+
+ Ter +
+
+ Qua +
+
+ Qui +
+
+ Sex +
+
+ Sáb +
+
+ Dom +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should merge the locale definition with the default one 2`] = ` + +
+ +
+ +
+ +
+
+ Sun +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should only be able to select a date that fits the isDaySelectableFn condition, when set 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should only change the renderDate and do nothing else if nav callbacks are not defined 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should only change the selectedDate and do nothing else if selection callbacks are not defined 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should render the Calendar and re-render on change of the props 1`] = ` + +
+ +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+ + +
+
+`; + +exports[`Calendar (normal mode) #render() should render the calendar with selectionType as normal, initialized in the current date 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should reset selectedDates, when at least one of the initialDates are outside min/max limit 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should set maxLimit, with a given "maxDate" attribute 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should set minLimit, with a given "minDate" attribute 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should set renderDate and not minLimit, with a given "initalDates" attribute 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should set renderDate to next/previous months when the next/previous btns are pressed 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should set renderDate to the month of the date pressed when different from current one 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should set selectedDate to the date of the button pressed 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should set the navButtons display values as defined on props, using default aria-labels 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should use be possible to navigate between months 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (normal mode) #render() should use the aria-labels defined on the navButtons attribute 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; diff --git a/packages/travix-ui-kit/tests/unit/calendar/__snapshots__/calendar.range.spec.js.snap b/packages/travix-ui-kit/tests/unit/calendar/__snapshots__/calendar.range.spec.js.snap new file mode 100644 index 0000000..1b679fa --- /dev/null +++ b/packages/travix-ui-kit/tests/unit/calendar/__snapshots__/calendar.range.spec.js.snap @@ -0,0 +1,1644 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Calendar (range mode) #render() should render the calendar with selectionType as range, initialized in the current date 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (range mode) #render() should set the minLimit and dates properly and reset them at 3rd click 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; + +exports[`Calendar (range mode) #render() should set the minLimit to the same date as the first initialDates when provided 1`] = ` + +
+ +
+ +
+ +
+
+ Mon +
+
+ Tue +
+
+ Wed +
+
+ Thu +
+
+ Fri +
+
+ Sat +
+
+ Sun +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+
+`; diff --git a/packages/travix-ui-kit/tests/unit/calendar/calendar.normal.spec.js b/packages/travix-ui-kit/tests/unit/calendar/calendar.normal.spec.js new file mode 100644 index 0000000..23ff46d --- /dev/null +++ b/packages/travix-ui-kit/tests/unit/calendar/calendar.normal.spec.js @@ -0,0 +1,406 @@ +import { mount } from 'enzyme'; +import React from 'react'; +import Calendar from '../../../components/calendar/calendar'; +import CalendarWrapper from './calendarWrapper.mock'; +import { normalizeDate } from '../../../components/_helpers'; + +describe('Calendar (normal mode)', () => { + describe('#render()', () => { + it('should render the calendar with selectionType as normal, initialized in the current date', () => { + const todayDate = normalizeDate(new Date()); + const wrapper = mount( + + ); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.props()).toEqual({ + selectionType: 'normal', + }); + expect(wrapper.state()).toEqual({ + maxLimit: null, + minLimit: null, + renderDate: todayDate, + selectedDates: [null, null], + }); + }); + + it('should set renderDate and not minLimit, with a given "initalDates" attribute', () => { + const initialDate = '2017-03-20'; + const initialDateObject = normalizeDate(new Date(initialDate)); + + const wrapper = mount( + + ); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.props()).toEqual({ + initialDates: [initialDate], + selectionType: 'normal', + }); + expect(wrapper.state()).toEqual({ + maxLimit: null, + minLimit: null, + renderDate: initialDateObject, + selectedDates: [initialDateObject, null], + }); + }); + + it('should set maxLimit, with a given "maxDate" attribute', () => { + const maxDate = '2017-03-20'; + const todayDate = normalizeDate(new Date()); + const maxDateObject = normalizeDate(new Date(maxDate), 23, 59, 59, 999); + + const wrapper = mount( + + ); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.props()).toEqual({ + maxDate, + selectionType: 'normal', + }); + expect(wrapper.state()).toEqual({ + maxLimit: maxDateObject, + minLimit: null, + renderDate: todayDate, + selectedDates: [null, null], + }); + }); + + it('should set minLimit, with a given "minDate" attribute', () => { + const minDate = '2017-03-20'; + const todayDate = normalizeDate(new Date()); + const minDateObject = normalizeDate(new Date(minDate)); + + const wrapper = mount( + + ); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.props()).toEqual({ + minDate, + selectionType: 'normal', + }); + expect(wrapper.state()).toEqual({ + maxLimit: null, + minLimit: minDateObject, + renderDate: todayDate, + selectedDates: [null, null], + }); + }); + + it('should reset selectedDates, when at least one of the initialDates are outside min/max limit', () => { + const initialDates = ['2017-03-23', '2017-03-29']; + const initialDatesObjects = initialDates.map(dateStr => normalizeDate(new Date(dateStr))); + const maxDate = '2017-03-25'; + const maxDateObject = normalizeDate(new Date(maxDate), 23, 59, 59, 999); + const minDate = '2017-03-20'; + const minDateObject = normalizeDate(new Date(minDate)); + + + const wrapper = mount( + + ); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.props()).toEqual({ + initialDates, + maxDate, + minDate, + selectionType: 'normal', + }); + expect(wrapper.state()).toEqual({ + maxLimit: maxDateObject, + minLimit: minDateObject, + renderDate: initialDatesObjects[0], + selectedDates: [null, null], + }); + }); + + it('should set renderDate to next/previous months when the next/previous btns are pressed', () => { + const initialDate = '2017-03-05'; + const initialDateObject = normalizeDate(new Date(initialDate)); + const nextMonthDateObj = normalizeDate(new Date(initialDate)); + + const nextMock = jest.fn(); + const previousMock = jest.fn(); + + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + /** Clicks to go next month */ + wrapper.find('.ui-calendar-days__next-month').simulate('click'); + + nextMonthDateObj.setMonth(nextMonthDateObj.getMonth() + 1); + + expect(wrapper.state().renderDate).toEqual(nextMonthDateObj); + expect(nextMock.mock.calls.length).toEqual(1); + expect(nextMock.mock.calls[0][0]).toEqual(wrapper.state().renderDate); + + /** Clicks to go next month */ + wrapper.find('.ui-calendar-days__previous-month').simulate('click'); + + expect(wrapper.state().renderDate).toEqual(initialDateObject); + expect(previousMock.mock.calls.length).toEqual(1); + expect(previousMock.mock.calls[0][0]).toEqual(wrapper.state().renderDate); + }); + + it('should set selectedDate to the date of the button pressed', () => { + const initialDate = '2017-03-05'; + + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + /** Clicks to go select the day */ + wrapper.find(`[data-date="2017-03-25"]`).simulate('click'); + + expect(wrapper.state().selectedDates[0].getDate()).toEqual(25); + }); + + it('should set renderDate to the month of the date pressed when different from current one', () => { + const initialDate = '2017-03-05'; + const selectDayMock = jest.fn(); + + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + /** Clicks to go select the day */ + expect(wrapper.state().renderDate.getMonth()).toEqual(2); + + wrapper.find(`[data-date="2017-04-01"]`).simulate('click'); + + expect(wrapper.state().selectedDates[0].getMonth()).toEqual(3); + expect(wrapper.state().renderDate.getMonth()).toEqual(3); + expect(selectDayMock.mock.calls.length).toEqual(1); + expect(selectDayMock.mock.calls[0][0]).toEqual(wrapper.state().selectedDates); + }); + + it('should only change the renderDate and do nothing else if nav callbacks are not defined', () => { + const initialDate = '2017-03-05'; + const initialDateObject = normalizeDate(new Date(initialDate)); + const nextMonthDateObj = normalizeDate(new Date(initialDate)); + + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + /** Clicks to go next month */ + wrapper.find('.ui-calendar-days__next-month').simulate('click'); + + nextMonthDateObj.setMonth(nextMonthDateObj.getMonth() + 1); + + expect(wrapper.state().renderDate).toEqual(nextMonthDateObj); + + /** Clicks to go next month */ + wrapper.find('.ui-calendar-days__previous-month').simulate('click'); + + expect(wrapper.state().renderDate).toEqual(initialDateObject); + }); + + it('should only change the selectedDate and do nothing else if selection callbacks are not defined', () => { + const initialDate = '2017-03-05'; + const selectedDate = '2017-03-20'; + const expectedSelectedDate = normalizeDate(new Date(selectedDate)); + + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + /** Clicks to go next month */ + wrapper.find(`[data-date="${selectedDate}"]`).simulate('click'); + + + expect(wrapper.state().selectedDates).toEqual([expectedSelectedDate, null]); + }); + + it('should only be able to select a date that fits the isDaySelectableFn condition, when set', () => { + const initialDate = '2017-03-05'; + const nonSelectableDate = '2017-03-20'; + const selectableDate = '2017-03-21'; + + const isDaySelectableFn = dt => dt.getDate() === 21; + const onSelectDayMock = jest.fn(); + + const initialDateObject = normalizeDate(new Date(initialDate)); + const selectableDateObject = normalizeDate(new Date(selectableDate)); + + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.state().selectedDates).toEqual([initialDateObject, null]); + + wrapper.find(`[data-date="${nonSelectableDate}"]`).simulate('click'); + expect(wrapper.state().selectedDates).toEqual([initialDateObject, null]); + + wrapper.find(`[data-date="${selectableDate}"]`).simulate('click'); + expect(wrapper.state().selectedDates).toEqual([selectableDateObject, null]); + + expect(onSelectDayMock.mock.calls.length).toEqual(1); + expect(onSelectDayMock.mock.calls[0][0]).toBeInstanceOf(Array); + expect(onSelectDayMock.mock.calls[0][0][0]).toBeInstanceOf(Date); + expect(onSelectDayMock.mock.calls[0][0][1]).toEqual(null); + }); + + it('should merge the locale definition with the default one', () => { + const myLocale = { + weekDays: [ + { name: 'Domingo', short: 'Dom' }, + { name: 'Segunda', short: 'Seg' }, + { name: 'Terça', short: 'Ter' }, + { name: 'Quarta', short: 'Qua' }, + { name: 'Quinta', short: 'Qui' }, + { name: 'Sexta', short: 'Sex' }, + { name: 'Sábado', short: 'Sáb' }, + ], + }; + + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.find('.ui-calendar-days__weekday').first().text()).toEqual('Seg'); + }); + + it('should merge the locale definition with the default one', () => { + const myLocale = { startWeekDay: 0 }; + + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.find('.ui-calendar-days__weekday').first().text()).toEqual('Sun'); + }); + + it('should apply the new initialDates on Calendar even when changed in runtime by the parent component', () => { + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.find('Calendar').props().initialDates[0]).toEqual('2017-03-03'); + + wrapper.find('#changeInitialDate').simulate('click'); + + expect(wrapper.find('Calendar').props().initialDates[0]).toEqual('2017-04-03'); + }); + + it('should render the Calendar and re-render on change of the props', () => { + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + wrapper.find('#changeInitialDate').simulate('click'); + + expect(wrapper.find('Calendar').props().initialDates[0]).toEqual('2017-04-03'); + + // Check what happens when the props are the same. + wrapper.find('#changeInitialDate').simulate('click'); + + expect(wrapper.find('Calendar').props().initialDates[0]).toEqual('2017-04-03'); + }); + + it('should apply the new minDate on Calendar even when changed by the parent component', () => { + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + expect(wrapper.find('Calendar').props().minDate).toEqual('2017-03-01'); + + wrapper.find('#changeMinDate').simulate('click'); + + expect(wrapper.find('Calendar').props().minDate).toEqual('2017-04-01'); + }); + + it('should set the navButtons display values as defined on props, using default aria-labels', () => { + const navButtons = { + days: { + next: { + displayValue: '›', + }, + previous: { + displayValue: '‹', + }, + }, + }; + + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + const nextBtn = wrapper.find('.ui-calendar-days__next-month'); + const previousBtn = wrapper.find('.ui-calendar-days__previous-month'); + expect(nextBtn.text()).toEqual(navButtons.days.next.displayValue); + expect(previousBtn.text()).toEqual(navButtons.days.previous.displayValue); + + expect(nextBtn.props()['aria-label']).toEqual('April'); + expect(previousBtn.props()['aria-label']).toEqual('February'); + }); + + it('should use the aria-labels defined on the navButtons attribute', () => { + const navButtons = { + days: { + next: { + ariaLabel: 'Next month', + displayValue: '›', + }, + previous: { + ariaLabel: 'Previous month', + displayValue: '‹', + }, + }, + }; + + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + const nextBtn = wrapper.find('.ui-calendar-days__next-month'); + const previousBtn = wrapper.find('.ui-calendar-days__previous-month'); + expect(nextBtn.text()).toEqual(navButtons.days.next.displayValue); + expect(previousBtn.text()).toEqual(navButtons.days.previous.displayValue); + + expect(nextBtn.props()['aria-label']).toEqual(navButtons.days.next.ariaLabel); + expect(previousBtn.props()['aria-label']).toEqual(navButtons.days.previous.ariaLabel); + }); + + it('should use be possible to navigate between months', () => { + const wrapper = mount( + + ); + expect(wrapper).toMatchSnapshot(); + + const daysView = wrapper.find('Days').at(1); + const nextBtn = wrapper.find('.ui-calendar-days__next-month'); + const previousBtn = wrapper.find('.ui-calendar-days__previous-month'); + + previousBtn.simulate('click'); + expect(daysView.props().renderDate.getMonth()).toEqual(11); + + nextBtn.simulate('click'); + expect(daysView.props().renderDate.getMonth()).toEqual(0); + + nextBtn.simulate('click'); + expect(daysView.props().renderDate.getMonth()).toEqual(1); + }); + }); +}); diff --git a/packages/travix-ui-kit/tests/unit/calendar/calendar.range.spec.js b/packages/travix-ui-kit/tests/unit/calendar/calendar.range.spec.js new file mode 100644 index 0000000..6a18054 --- /dev/null +++ b/packages/travix-ui-kit/tests/unit/calendar/calendar.range.spec.js @@ -0,0 +1,151 @@ +import { mount } from 'enzyme'; +import React from 'react'; +import Calendar from '../../../components/calendar/calendar'; +import { normalizeDate } from '../../../components/_helpers'; + +describe('Calendar (range mode)', () => { + describe('#render()', () => { + it('should render the calendar with selectionType as range, initialized in the current date', () => { + const todayDate = normalizeDate(new Date()); + + const wrapper = mount( + + ); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.props()).toEqual({ + selectionType: 'range', + }); + expect(wrapper.state()).toEqual({ + maxLimit: null, + minLimit: null, + renderDate: todayDate, + selectedDates: [null, null], + }); + }); + + it('should set the minLimit to the same date as the first initialDates when provided', () => { + const initialDate = '2017-03-25'; + const initialDateObj = normalizeDate(new Date(initialDate)); + + const wrapper = mount( + + ); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.props()).toEqual({ + initialDates: [initialDate], + selectionType: 'range', + }); + expect(wrapper.state()).toEqual({ + maxLimit: null, + minLimit: initialDateObj, + renderDate: initialDateObj, + selectedDates: [initialDateObj, null], + }); + }); + + it('should set the minLimit and dates properly and reset them at 3rd click', () => { + const todayDate = normalizeDate(new Date()); + + const wrapper = mount( + + ); + + expect(wrapper).toMatchSnapshot(); + expect(wrapper.props()).toEqual({ + selectionType: 'range', + }); + expect(wrapper.state()).toEqual({ + maxLimit: null, + minLimit: null, + renderDate: todayDate, + selectedDates: [null, null], + }); + + const expectedStartDate = normalizeDate(new Date('2017-03-25')); + + const startRangeOption = wrapper.find('[data-date="2017-03-25"]'); + startRangeOption.simulate('click'); + expect(startRangeOption.props().className.includes('ui-calendar-days-option_selected-start')).toEqual(true); + expect(wrapper.state().minLimit).toEqual(expectedStartDate); + expect(wrapper.state().selectedDates[0]).toEqual(expectedStartDate); + expect(wrapper.find('[data-date="2017-03-24"]').props().disabled).toEqual(true); + + const expectedEndDate = new Date('2017-03-29'); + expectedEndDate.setHours(0); + expectedEndDate.setMinutes(0); + expectedEndDate.setSeconds(0); + expectedEndDate.setMilliseconds(0); + + // Selects the end date on the 2nd click + const endRangeOption = wrapper.find('[data-date="2017-03-29"]'); + endRangeOption.simulate('click'); + + const betweenRangeOption = wrapper.find('[data-date="2017-03-28"]'); + + expect(endRangeOption.props().className.includes('ui-calendar-days-option_selected-end')).toEqual(true); + expect(wrapper.state().selectedDates[1]).toEqual(expectedEndDate); + expect(betweenRangeOption.props().className.includes('ui-calendar-days-option_selected-between')).toEqual(true); + + // On 3rd click resets the calendar, removing the selection + betweenRangeOption.simulate('click'); + expect(wrapper.state().minLimit).toEqual(null); + expect(wrapper.state().selectedDates).toEqual([null, null]); + }); + + it('should render the selection as normal (not range) when start and end date are the same', () => { + const expectedEndDate = normalizeDate(new Date('2017-03-25')); + const expectedStartDate = normalizeDate(new Date('2017-03-25')); + const wrapper = mount( + + ); + + const startRangeOption = wrapper.find('[data-date="2017-03-25"]'); + startRangeOption.simulate('click'); + expect(startRangeOption.props().className.includes('ui-calendar-days-option_selected-start')).toEqual(true); + expect(wrapper.state().minLimit).toEqual(expectedStartDate); + expect(wrapper.state().selectedDates[0]).toEqual(expectedStartDate); + + // Selects the end date on the 2nd click + const endRangeOption = wrapper.find('[data-date="2017-03-25"]'); + endRangeOption.simulate('click'); + + expect(endRangeOption.props().className.includes('ui-calendar-days-option_selected')).toEqual(true); + expect(wrapper.state().minLimit).toEqual(null); + expect(wrapper.state().selectedDates[1]).toEqual(expectedEndDate); + }); + + it('should put the minLimit back to the one passed on props, when resetting it', () => { + const expectedEndDate = normalizeDate(new Date('2017-03-25')); + const expectedStartDate = normalizeDate(new Date('2017-03-25')); + const minDate = '2017-03-05'; + const expectedInitialMinLimit = normalizeDate(new Date(minDate)); + + const wrapper = mount( + + ); + + expect(wrapper.state().minLimit).toEqual(expectedInitialMinLimit); + + const startRangeOption = wrapper.find('[data-date="2017-03-25"]'); + startRangeOption.simulate('click'); + expect(startRangeOption.props().className.includes('ui-calendar-days-option_selected-start')).toEqual(true); + expect(wrapper.state().minLimit).toEqual(expectedStartDate); + expect(wrapper.state().selectedDates[0]).toEqual(expectedStartDate); + + // Selects the end date on the 2nd click + const endRangeOption = wrapper.find('[data-date="2017-03-25"]'); + endRangeOption.simulate('click'); + + expect(endRangeOption.props().className.includes('ui-calendar-days-option_selected')).toEqual(true); + expect(wrapper.state().minLimit).toEqual(expectedInitialMinLimit); + expect(wrapper.state().selectedDates[1]).toEqual(expectedEndDate); + + // And resets the dates with the 3rd click + wrapper.find('[data-date="2017-03-26"]').simulate('click'); + expect(wrapper.state().minLimit).toEqual(expectedInitialMinLimit); + expect(wrapper.state().selectedDates).toEqual([null, null]); + }); + }); +}); diff --git a/packages/travix-ui-kit/tests/unit/calendar/calendarWrapper.mock.js b/packages/travix-ui-kit/tests/unit/calendar/calendarWrapper.mock.js new file mode 100644 index 0000000..fb295b5 --- /dev/null +++ b/packages/travix-ui-kit/tests/unit/calendar/calendarWrapper.mock.js @@ -0,0 +1,44 @@ +const React = require('react'); +const Calendar = require('../../../components/calendar/calendar'); + +export default class CalendarWrapper extends React.Component { + constructor(props) { + super(); + + this.changeInitialDates = this.changeInitialDates.bind(this); + this.changeMinDate = this.changeMinDate.bind(this); + this.state = { + initialDates: props.initialDates ? [].concat(props.initialDates) : undefined, + minDate: props.minDate, + }; + } + + changeInitialDates() { + this.setState((prevState) => { + prevState.initialDates = ['2017-04-03', null]; + return prevState; + }); + } + + changeMinDate() { + this.setState((prevState) => { + prevState.minDate = '2017-04-01'; + return prevState; + }); + } + + render() { + return ( +
+ + + +
+ ); + } +} + +CalendarWrapper.propTypes = { + initialDates: React.PropTypes.arrayOf(String), + minDate: React.PropTypes.string, +}; diff --git a/packages/travix-ui-kit/themes/_default.yaml b/packages/travix-ui-kit/themes/_default.yaml index b907de9..50b6590 100644 --- a/packages/travix-ui-kit/themes/_default.yaml +++ b/packages/travix-ui-kit/themes/_default.yaml @@ -23,8 +23,11 @@ generic: accent-darker: &accent-darker '#0B4848' accent-dark: &accent-dark '#177D7D' accent: &accent '#2A9595' + accent-border: &accent-border '#E6BB00' + accent-bottom: &accent-bottom '#FFD05E' accent-light: &accent-light '#46BCBC' accent-lighter: &accent-lighter '#6EE1E1' + accent-top: &accent-top '#FFD97C' primary-darker: &primary-darker '#162052' primary-dark: &primary-dark '#283A8E' @@ -86,6 +89,89 @@ button: color: "transparent" hover: params: "0 0" +calendar: + height: 300px + nav: + background: *primary-lighter + border-radius: '5px 5px 0 0' + button: + background-color-end: *accent-bottom + background-color-start: *accent-top + border-radius: 50% + box-shadow-color: *accent-border + color: *primary-lighter + font-weight: '700' + height: 27px + margin: '0 5px' + width: 27px + height: 41px + label: + color: *blank + font-weight: '500' + header: + background-color: *primary-lighter + height: 32px + weekday: + color: *secondary + font-size: 14px + font-weight: bold + options: + border-color: *secondary + option: + background-color: *blank + border-bottom: *secondary + border-right: *secondary + color: *primary-darker + disabled: + background-color: *secondary + color: *secondary-darkest + font-size: 14px + hover: + background-color: *active + color: *blank + next-month: + background-color: *secondary-light + previous-month: + background-color: *secondary-light + range: + after: + border-color: transparent + between: + background-color: *secondary-dark + end: + after: + border-color: *active + background-color: *secondary-dark + start: + after: + border-color: *active + background-color: *secondary-dark + width: 300px +list: + bullets: + color: *active +modal: + z-index: '100' + color: + dark: *primary-dark + secondary: *secondary + overlay: + background: + color: *primary-dark + opacity: '0.65' + container: + background: + color: *blank + box-shadow: + color: 'rgba(0,0,0,.3)' + content: + padding-left-right: '24px' + content-devider: + color: *secondary + close-button: + color: *text-dark + hover: + color: *active price: height: underline: 6px @@ -131,28 +217,3 @@ spinner: lighter: *accent dark: *primary darker: *primary-lighter -list: - bullets: - color: *active -modal: - z-index: '100' - color: - dark: *primary-dark - secondary: *secondary - overlay: - background: - color: *primary-dark - opacity: '0.65' - container: - background: - color: *blank - box-shadow: - color: 'rgba(0,0,0,.3)' - content: - padding-left-right: '24px' - content-devider: - color: *secondary - close-button: - color: *text-dark - hover: - color: *active