Skip to content

Commit

Permalink
Merge pull request #29 from hernansartorio/min-max
Browse files Browse the repository at this point in the history
Add minimumLength and maximumLength props to date range picker
  • Loading branch information
hernansartorio authored May 8, 2020
2 parents dc2e010 + f9cc320 commit ccca2b2
Show file tree
Hide file tree
Showing 8 changed files with 159 additions and 44 deletions.
4 changes: 4 additions & 0 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ declare module "react-nice-dates" {
children: JSX.Element;
startDate?: Date | undefined;
endDate?: Date | undefined;
minimumLength?: number | undefined;
maximumLength?: number | undefined;
onStartDateChange?: (date: Date | undefined) => void;
onEndDateChange?: (date: Date | undefined) => void;
format?: string;
Expand All @@ -46,6 +48,8 @@ declare module "react-nice-dates" {
endDate?: Date | undefined;
focus?: "startDate, endDate";
month?: Date | undefined;
minimumLength?: number | undefined;
maximumLength?: number | undefined;
onFocusChange: (focus: "startDate" | "endDate") => void;
onStartDateChange: (date: Date | undefined) => void;
onEndDateChange: (date: Date | undefined) => void;
Expand Down
15 changes: 10 additions & 5 deletions src/DateRangePicker.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React, { useState } from 'react'
import { func, instanceOf, object, objectOf, string } from 'prop-types'
import { addDays, subDays } from 'date-fns'
import { isSelectable } from './utils'
import { func, instanceOf, number, object, objectOf, string } from 'prop-types'
import { isRangeLengthValid } from './utils'
import { START_DATE, END_DATE } from './constants'
import useDateInput from './useDateInput'
import useOutsideClickHandler from './useOutsideClickHandler'
Expand All @@ -19,6 +18,8 @@ export default function DateRangePicker({
format,
minimumDate,
maximumDate,
minimumLength,
maximumLength,
modifiers,
modifiersClassNames,
weekdayFormat
Expand All @@ -41,7 +42,7 @@ export default function DateRangePicker({
onStartDateChange(date)
date && setMonth(date)
},
validate: date => isSelectable(date, { maximumDate: subDays(endDate, 1) })
validate: date => !endDate || isRangeLengthValid({ startDate: date, endDate }, { minimumLength, maximumLength })
})

const endDateInputProps = useDateInput({
Expand All @@ -54,7 +55,7 @@ export default function DateRangePicker({
onEndDateChange(date)
date && setMonth(date)
},
validate: date => isSelectable(date, { minimumDate: addDays(startDate, 1) })
validate: date => !startDate || isRangeLengthValid({ startDate, endDate: date }, { minimumLength, maximumLength })
})

return (
Expand Down Expand Up @@ -102,6 +103,8 @@ export default function DateRangePicker({
onMonthChange={setMonth}
minimumDate={minimumDate}
maximumDate={maximumDate}
minimumLength={minimumLength}
maximumLength={maximumLength}
modifiers={modifiers}
modifiersClassNames={modifiersClassNames}
weekdayFormat={weekdayFormat}
Expand All @@ -121,6 +124,8 @@ DateRangePicker.propTypes = {
format: string,
minimumDate: instanceOf(Date),
maximumDate: instanceOf(Date),
minimumLength: number,
maximumLength: number,
modifiers: objectOf(func),
modifiersClassNames: objectOf(string),
weekdayFormat: string
Expand Down
49 changes: 33 additions & 16 deletions src/DateRangePickerCalendar.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React, { useState } from 'react'
import { func, instanceOf, object, objectOf, oneOf, string } from 'prop-types'
import { isSameDay, isAfter, isBefore, startOfMonth, startOfDay } from 'date-fns'
import { isSelectable, mergeModifiers, setTime } from './utils'
import { func, instanceOf, number, object, objectOf, oneOf, string } from 'prop-types'
import { differenceInDays, isSameDay, isAfter, isBefore, startOfMonth, startOfDay } from 'date-fns'
import { isRangeLengthValid, isSelectable, mergeModifiers, setTime } from './utils'
import { START_DATE, END_DATE } from './constants'
import useControllableState from './useControllableState'
import Calendar from './Calendar'
Expand All @@ -18,12 +18,18 @@ export default function DateRangePickerCalendar({
onMonthChange,
minimumDate,
maximumDate,
minimumLength,
maximumLength,
modifiers: receivedModifiers,
modifiersClassNames,
weekdayFormat
}) {
const [hoveredDate, setHoveredDate] = useState()
const [month, setMonth] = useControllableState(receivedMonth, onMonthChange, startOfMonth(startDate || endDate || new Date()))
const [month, setMonth] = useControllableState(
receivedMonth,
onMonthChange,
startOfMonth(startDate || endDate || new Date())
)

const displayedStartDate =
focus === START_DATE && !startDate && endDate && hoveredDate && !isSameDay(hoveredDate, endDate)
Expand All @@ -47,46 +53,53 @@ export default function DateRangePickerCalendar({
isMiddleDate(date) ||
isEndDate(date) ||
isSameDay(date, startDate) ||
isSameDay(date, endDate)
),
isSameDay(date, endDate)),
selectedStart: isStartDate,
selectedMiddle: isMiddleDate,
selectedEnd: isEndDate,
disabled: date => (focus === START_DATE && isEndDate(date)) || (focus === END_DATE && isStartDate(date))
disabled: date =>
(focus === START_DATE &&
endDate &&
((differenceInDays(endDate, date) < minimumLength && (!startDate || !isAfter(date, endDate))) ||
(!startDate && maximumLength && differenceInDays(endDate, date) > maximumLength))) ||
(focus === END_DATE &&
startDate &&
((differenceInDays(date, startDate) < minimumLength && (!endDate || !isBefore(date, startDate))) ||
(!endDate && maximumLength && differenceInDays(date, startDate) > maximumLength)))
},
receivedModifiers
)

const handleSelectDate = date => {
if (focus === START_DATE) {
if (endDate && !isAfter(endDate, date)) {
const invalidEndDate =
endDate && !isRangeLengthValid({ startDate: date, endDate }, { minimumLength, maximumLength })

if (invalidEndDate) {
onEndDateChange(null)
}

onStartDateChange(startDate ? setTime(date, startDate) : date)
onFocusChange(END_DATE)
} else if (focus === END_DATE) {
const invalidStartDate = startDate && !isBefore(startDate, date)
const invalidStartDate =
startDate && !isRangeLengthValid({ startDate, endDate: date }, { minimumLength, maximumLength })

if (invalidStartDate) {
onStartDateChange(null)
}

onEndDateChange(endDate ? setTime(date, endDate) : date)
onFocusChange(invalidStartDate ? START_DATE : null)
onFocusChange(invalidStartDate || !startDate ? START_DATE : null)
}
}

const handleHoverDate = date => {
setHoveredDate(date)
}

return (
<Calendar
locale={locale}
month={month}
onMonthChange={setMonth}
onDayHover={handleHoverDate}
onDayHover={setHoveredDate}
onDayClick={handleSelectDate}
minimumDate={minimumDate}
maximumDate={maximumDate}
Expand All @@ -109,6 +122,8 @@ DateRangePickerCalendar.propTypes = {
onMonthChange: func,
minimumDate: instanceOf(Date),
maximumDate: instanceOf(Date),
minimumLength: number,
maximumLength: number,
modifiers: objectOf(func),
modifiersClassNames: objectOf(string),
weekdayFormat: string
Expand All @@ -117,5 +132,7 @@ DateRangePickerCalendar.propTypes = {
DateRangePickerCalendar.defaultProps = {
onStartDateChange: () => {},
onEndDateChange: () => {},
onFocusChange: () => {}
onFocusChange: () => {},
minimumLength: 0,
maximumLength: null
}
6 changes: 5 additions & 1 deletion src/utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isAfter, isBefore, startOfDay, set } from 'date-fns'
import { differenceInDays, isAfter, isBefore, startOfDay, set } from 'date-fns'

export const isSelectable = (date, { minimumDate, maximumDate }) =>
!isBefore(date, startOfDay(minimumDate)) && !isAfter(date, maximumDate)
Expand All @@ -21,3 +21,7 @@ export const mergeModifiers = (baseModifiers, newModifiers) => {

export const setTime = (date, dateWithTime) =>
set(date, { hours: dateWithTime.getHours(), minutes: dateWithTime.getMinutes(), seconds: dateWithTime.getSeconds() })

export const isRangeLengthValid = ({ startDate, endDate }, { minimumLength, maximumLength }) =>
differenceInDays(endDate, startDate) >= minimumLength &&
(!maximumLength || differenceInDays(endDate, startDate) <= maximumLength)
39 changes: 18 additions & 21 deletions test/DateRangePicker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,16 @@ describe('DateRangePicker', () => {
<DateRangePicker locale={locale}>
{({ startDateInputProps, endDateInputProps, focus }) => (
<div className='date-range'>
<input aria-label={START_DATE} className={classNames({ '-focused': focus === START_DATE })} {...startDateInputProps} />
<input aria-label={END_DATE} className={classNames({ '-focused': focus === END_DATE })} {...endDateInputProps} />
<input
aria-label={START_DATE}
className={classNames({ '-focused': focus === START_DATE })}
{...startDateInputProps}
/>
<input
aria-label={END_DATE}
className={classNames({ '-focused': focus === END_DATE })}
{...endDateInputProps}
/>
</div>
)}
</DateRangePicker>
Expand All @@ -23,20 +31,23 @@ describe('DateRangePicker', () => {
expect(getAllByText('1').length).toBeGreaterThan(0)
})

it('should open and close', () => {
const { container, getAllByText, getByLabelText } = render(
it('should open and close popup', () => {
const { container, getByLabelText } = render(
<DateRangePicker locale={locale}>
{({ startDateInputProps, endDateInputProps, focus }) => (
<div className='date-range'>
<input aria-label={START_DATE} className={classNames({ '-focused': focus === START_DATE })} {...startDateInputProps} />
<input aria-label={END_DATE} className={classNames({ '-focused': focus === END_DATE })} {...endDateInputProps} />
<input
aria-label={START_DATE}
className={classNames({ '-focused': focus === START_DATE })}
{...startDateInputProps}
/>
<input className={classNames({ '-focused': focus === END_DATE })} {...endDateInputProps} />
</div>
)}
</DateRangePicker>
)

const startDateInput = getByLabelText(START_DATE)
const endDateInput = getByLabelText(END_DATE)
const popover = container.querySelector('.nice-dates-popover')

expect(popover).not.toHaveClass('-open')
Expand All @@ -53,20 +64,6 @@ describe('DateRangePicker', () => {

expect(popover).not.toHaveClass('-open')
expect(startDateInput).not.toHaveClass('-focused')

// Should close on date range selection
fireEvent.focus(startDateInput)

expect(popover).toHaveClass('-open')

fireEvent.click(getAllByText('1')[0])

expect(popover).toHaveClass('-open')
expect(endDateInput).toHaveClass('-focused')

fireEvent.click(getAllByText('2')[0])

expect(popover).not.toHaveClass('-open')
})

it('should display pre-selected start date’s month on initial render', () => {
Expand Down
84 changes: 83 additions & 1 deletion test/DateRangePickerCalendar.test.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import React from 'react'
import { render, fireEvent } from '@testing-library/react'
import '@testing-library/jest-dom/extend-expect'
import { addDays, format, startOfMonth, subMonths } from 'date-fns'
import { addDays, format, startOfMonth, set, startOfDay, subMonths } from 'date-fns'
import { enGB as locale } from 'date-fns/locale'
import { START_DATE, END_DATE } from '../src/constants'
import DateRangePickerCalendar from '../src/DateRangePickerCalendar'
Expand Down Expand Up @@ -130,4 +130,86 @@ describe('DateRangePickerCalendar', () => {

expect(handleEndDateChange).toHaveBeenCalledWith(new Date(2020, 1, 25, 18, 30))
})

it('should allow same day selection by default (when minimumLength is 0)', () => {
const startDate = startOfDay(set(new Date(), { date: 13 }))

const { getByText } = render(<DateRangePickerCalendar locale={locale} focus={END_DATE} startDate={startDate} />)

expect(getByText('13').parentElement).not.toHaveClass('-disabled')
})

it('should disable dates before the start date when selecting an end date with no existing end date selected', () => {
const startDate = startOfDay(set(new Date(), { date: 13 }))

const { getByText } = render(<DateRangePickerCalendar locale={locale} focus={END_DATE} startDate={startDate} />)

expect(getByText('11').parentElement).toHaveClass('-disabled')
expect(getByText('12').parentElement).toHaveClass('-disabled')
expect(getByText('13').parentElement).not.toHaveClass('-disabled')
})

it('should disable dates after the end date when selecting a start date with no existing start date selected', () => {
const endDate = startOfDay(set(new Date(), { date: 13 }))

const { getByText } = render(<DateRangePickerCalendar locale={locale} focus={START_DATE} endDate={endDate} />)

expect(getByText('13').parentElement).not.toHaveClass('-disabled')
expect(getByText('14').parentElement).toHaveClass('-disabled')
expect(getByText('15').parentElement).toHaveClass('-disabled')
})

it('should disable in-between dates when minimumLength is set', () => {
const startDate = startOfDay(set(new Date(), { date: 13 }))

const { getByText } = render(
<DateRangePickerCalendar locale={locale} focus={END_DATE} startDate={startDate} minimumLength={3} />
)

expect(getByText('13').parentElement).toHaveClass('-disabled')
expect(getByText('14').parentElement).toHaveClass('-disabled')
expect(getByText('15').parentElement).toHaveClass('-disabled')
expect(getByText('16').parentElement).not.toHaveClass('-disabled')
})

it('should disable in-between dates when selecting start date and minimumLength is set', () => {
const endDate = startOfDay(set(new Date(), { date: 13 }))

const { getByText } = render(
<DateRangePickerCalendar locale={locale} focus={START_DATE} endDate={endDate} minimumLength={3} />
)

expect(getByText('13').parentElement).toHaveClass('-disabled')
expect(getByText('12').parentElement).toHaveClass('-disabled')
expect(getByText('11').parentElement).toHaveClass('-disabled')
expect(getByText('10').parentElement).not.toHaveClass('-disabled')
})

it('should disable later dates when maximumLength is set', () => {
const startDate = startOfDay(set(new Date(), { date: 13 }))

const { getByText } = render(
<DateRangePickerCalendar locale={locale} focus={END_DATE} startDate={startDate} maximumLength={3} />
)

expect(getByText('13').parentElement).not.toHaveClass('-disabled')
expect(getByText('14').parentElement).not.toHaveClass('-disabled')
expect(getByText('15').parentElement).not.toHaveClass('-disabled')
expect(getByText('16').parentElement).not.toHaveClass('-disabled')
expect(getByText('17').parentElement).toHaveClass('-disabled')
})

it('should disable earlier dates when selecting start date and maximumLength is set', () => {
const endDate = startOfDay(set(new Date(), { date: 13 }))

const { getByText } = render(
<DateRangePickerCalendar locale={locale} focus={START_DATE} endDate={endDate} maximumLength={3} />
)

expect(getByText('13').parentElement).not.toHaveClass('-disabled')
expect(getByText('12').parentElement).not.toHaveClass('-disabled')
expect(getByText('11').parentElement).not.toHaveClass('-disabled')
expect(getByText('10').parentElement).not.toHaveClass('-disabled')
expect(getByText('9').parentElement).toHaveClass('-disabled')
})
})
2 changes: 2 additions & 0 deletions website/examples/DateRangePickerExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ function DateRangePickerExample() {
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
minimumDate={new Date()}
minimumLength={1}
format='dd MMM yyyy'
locale={enGB}
>
Expand Down Expand Up @@ -55,6 +56,7 @@ export default function DateRangePickerExample() {
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
minimumDate={new Date()}
minimumLength={1}
format='dd MMM yyyy'
locale={enGB}
>
Expand Down
4 changes: 4 additions & 0 deletions website/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ onEndDateChange: func,
format: string, // Default: locale.formatLong.date({ width: 'short' })
minimumDate: instanceOf(Date), // See Calendar props
maximumDate: instanceOf(Date), // See Calendar props
minimumLength: number, // See DateRangePickerCalendar props
maximumLength: number, // See DateRangePickerCalendar props
modifiers: objectOf(func),
modifiersClassNames: objectOf(string),
weekdayFormat: string // See Calendar props`}
Expand Down Expand Up @@ -324,6 +326,8 @@ onFocusChange: func.isRequired,
onMonthChange: func, // See Calendar props
minimumDate: instanceOf(Date), // See Calendar props
maximumDate: instanceOf(Date), // See Calendar props
minimumLength: number, // Minimum range selection length, defaults to 0
maximumLength: number, // Maximum range selection length, defaults to null
modifiers: objectOf(func),
modifiersClassNames: objectOf(string),
weekdayFormat: string // See Calendar props`}
Expand Down

0 comments on commit ccca2b2

Please sign in to comment.