Skip to content

Commit

Permalink
Implement square selection and expose prop to select selection scheme
Browse files Browse the repository at this point in the history
  • Loading branch information
bibekg committed Sep 10, 2018
1 parent 74aa29a commit 1893f31
Show file tree
Hide file tree
Showing 10 changed files with 193 additions and 53 deletions.
1 change: 1 addition & 0 deletions .flowconfig
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ test
[include]

[libs]
declarations

[options]
suppress_comment= \\(.\\|\n\\)*\\flow-disable-next-line
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ To customize the UI, you can either:

**required**: yes

#### `selectionScheme`

**type**: `'square'` | `'linear'`

**description**: The behavior for selection when dragging. `square` selects a square with the start and end cells at opposite corners. `linear` selects all the cells that are chronologically between the start and end cells.

**required**: no

**default value**: `'square'`

#### `onChange`

**type**: `(Array<Date>) => void`
Expand Down
6 changes: 6 additions & 0 deletions declarations/DatePicker.delcarations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// @flow
/* eslint-disable */

type SelectionType = 'add' | 'remove'

type SelectionSchemeType = 'linear' | 'square'
69 changes: 20 additions & 49 deletions src/lib/DatePicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,19 @@ import styled from 'styled-components'
import addHours from 'date-fns/add_hours'
import addDays from 'date-fns/add_days'
import startOfDay from 'date-fns/start_of_day'
import differenceInHours from 'date-fns/difference_in_hours'
import isSameMinute from 'date-fns/is_same_minute'
import isBefore from 'date-fns/is_before'
import formatDate from 'date-fns/format'

import { Text, Subtitle } from './typography'
import colors from './colors'
import selectionSchemes from './selection-schemes'

const formatHour = (hour: number): string => {
const h = hour === 0 || hour === 12 || hour === 24 ? 12 : hour % 12
const abb = hour < 12 || hour === 24 ? 'am' : 'pm'
return `${h}${abb}`
}

// Helper function that uses date-fns methods to determine if a date is between two other dates
const dateHourIsBetween = (candidate: Date, start: Date, end: Date) =>
differenceInHours(candidate, start) >= 0 && differenceInHours(end, candidate) >= 0

const Wrapper = styled.div`
display: flex;
align-items: center;
Expand Down Expand Up @@ -90,6 +85,7 @@ const TimeText = styled(Text)`

type PropsType = {
selection: Array<Date>,
selectionScheme: SelectionSchemeType,
onChange: (Array<Date>) => void,
startDate: Date,
numDays: number,
Expand All @@ -103,8 +99,6 @@ type PropsType = {
renderDateCell?: (Date, boolean, (HTMLElement) => void) => React.Node
}

type SelectionType = 'add' | 'remove'

type StateType = {
// In the case that a user is drag-selecting, we don't want to call this.props.onChange() until they have completed
// the drag-select. selectionDraft serves as a temporary copy during drag-selects.
Expand All @@ -120,6 +114,7 @@ export const preventScroll = (e: TouchEvent) => {

export default class AvailabilitySelector extends React.Component<PropsType, StateType> {
dates: Array<Array<Date>>
selectionSchemeHandlers: { [string]: (Date, Date, Array<Array<Date>>) => Date[] }
cellToDate: Map<HTMLElement, Date>
documentMouseUpHandler: () => void
endSelection: () => void
Expand All @@ -131,6 +126,8 @@ export default class AvailabilitySelector extends React.Component<PropsType, Sta
gridRef: ?HTMLElement

static defaultProps = {
selection: [],
selectionScheme: 'square',
numDays: 7,
minTime: 9,
maxTime: 23,
Expand All @@ -140,7 +137,6 @@ export default class AvailabilitySelector extends React.Component<PropsType, Sta
selectedColor: colors.blue,
unselectedColor: colors.paleBlue,
hoveredColor: colors.lightBlue,
selection: [],
onChange: () => {}
}

Expand All @@ -166,6 +162,11 @@ export default class AvailabilitySelector extends React.Component<PropsType, Sta
isTouchDragging: false
}

this.selectionSchemeHandlers = {
linear: selectionSchemes.linear,
square: selectionSchemes.square
}

this.endSelection = this.endSelection.bind(this)
this.handleMouseUpEvent = this.handleMouseUpEvent.bind(this)
this.handleMouseEnterEvent = this.handleMouseEnterEvent.bind(this)
Expand Down Expand Up @@ -228,55 +229,25 @@ export default class AvailabilitySelector extends React.Component<PropsType, Sta

// Given an ending Date, determines all the dates that should be selected in this draft
updateAvailabilityDraft(selectionEnd: ?Date, callback?: () => void) {
const { selection } = this.props
const { selectionType, selectionStart } = this.state

// User isn't selecting right now, doesn't make sense to update selection draft
if (selectionType === null || selectionStart === null) return

let selected: Array<Date> = []
if (selectionEnd == null) {
// This function is called with a null selectionEnd on `mouseup`. This is useful for catching cases
// where the user just clicks on a single cell, since in that case,
// In such a case, set the entire selection as just that
if (selectionStart) selected = [selectionStart]
} else if (selectionStart) {
const reverseSelection = isBefore(selectionEnd, selectionStart)
// Generate a list of Dates between the start of the selection and the end of the selection
// The Dates to choose from for this list are sourced from this.dates
selected = this.dates.reduce(
(acc, dayOfTimes) =>
acc.concat(
dayOfTimes.filter(
t =>
selectionStart &&
selectionEnd &&
dateHourIsBetween(
t,
reverseSelection ? selectionEnd : selectionStart,
reverseSelection ? selectionStart : selectionEnd
)
)
),
[]
)
let newSelection = []
if (selectionStart && selectionEnd && selectionType) {
newSelection = this.selectionSchemeHandlers[this.props.selectionScheme](selectionStart, selectionEnd, this.dates)
}

let nextDraft = []
if (selectionType === 'add') {
this.setState(
{
selectionDraft: Array.from(new Set([...selection, ...selected]))
},
callback
)
nextDraft = Array.from(new Set([...this.props.selection, ...newSelection]))
} else if (selectionType === 'remove') {
this.setState(
{
selectionDraft: selection.filter(a => !selected.find(b => isSameMinute(a, b)))
},
callback
)
nextDraft = this.props.selection.filter(a => !newSelection.find(b => isSameMinute(a, b)))
} else {
throw new Error('Invalid selection type')
}

this.setState({ selectionDraft: nextDraft }, callback)
}

// Isomorphic (mouse and touch) handler since starting a selection works the same way for both classes of user input
Expand Down
23 changes: 23 additions & 0 deletions src/lib/date-utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// @flow

import differenceInHours from 'date-fns/difference_in_hours'
import startOfDay from 'date-fns/start_of_day'
import isAfter from 'date-fns/is_after'

// Helper function that uses date-fns methods to determine if a date is between two other dates
export const dateHourIsBetween = (start: Date, candidate: Date, end: Date) =>
differenceInHours(candidate, start) >= 0 && differenceInHours(end, candidate) >= 0

export const dateIsBetween = (start: Date, candidate: Date, end: Date): boolean => {
const startOfCandidate = startOfDay(candidate)
const startOfStart = startOfDay(start)
const startOfEnd = startOfDay(end)

return (
(startOfCandidate.getTime() === startOfStart.getTime() || isAfter(startOfCandidate, startOfStart)) &&
(startOfCandidate.getTime() === startOfEnd.getTime() || isAfter(startOfEnd, startOfCandidate))
)
}

export const timeIsBetween = (start: Date, candidate: Date, end: Date) =>
candidate.getHours() >= start.getHours() && candidate.getHours() <= end.getHours()
7 changes: 7 additions & 0 deletions src/lib/selection-schemes/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import linear from './linear'
import square from './square'

export default {
linear,
square
}
37 changes: 37 additions & 0 deletions src/lib/selection-schemes/linear.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// @flow

import isBefore from 'date-fns/is_before'

import * as dateUtils from '../date-utils'

const linear = (selectionStart: ?Date, selectionEnd: ?Date, dateList: Array<Array<Date>>): Array<Date> => {
let selected: Array<Date> = []
if (selectionEnd == null) {
// This function is called with a null selectionEnd on `mouseup`. This is useful for catching cases
// where the user just clicks on a single cell
if (selectionStart) selected = [selectionStart]
} else if (selectionStart) {
const reverseSelection = isBefore(selectionEnd, selectionStart)
// Generate a list of Dates between the start of the selection and the end of the selection
// The Dates to choose from for this list are sourced from this.dates
selected = dateList.reduce(
(acc, dayOfTimes) =>
acc.concat(
dayOfTimes.filter(
t =>
selectionStart &&
selectionEnd &&
dateUtils.dateHourIsBetween(
reverseSelection ? selectionEnd : selectionStart,
t,
reverseSelection ? selectionStart : selectionEnd
)
)
),
[]
)
}
return selected
}

export default linear
42 changes: 42 additions & 0 deletions src/lib/selection-schemes/square.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// @flow

import isBefore from 'date-fns/is_before'

import * as dateUtils from '../date-utils'

const square = (selectionStart: ?Date, selectionEnd: ?Date, dateList: Array<Array<Date>>): Array<Date> => {
let selected: Array<Date> = []
if (selectionEnd == null) {
// This function is called with a null selectionEnd on `mouseup`. This is useful for catching cases
// where the user just clicks on a single cell
if (selectionStart) selected = [selectionStart]
} else if (selectionStart) {
const reverseSelection = isBefore(selectionEnd, selectionStart)

selected = dateList.reduce(
(acc, dayOfTimes) =>
acc.concat(
dayOfTimes.filter(
t =>
selectionStart &&
selectionEnd &&
dateUtils.dateIsBetween(
reverseSelection ? selectionEnd : selectionStart,
t,
reverseSelection ? selectionStart : selectionEnd
) &&
dateUtils.timeIsBetween(
reverseSelection ? selectionEnd : selectionStart,
t,
reverseSelection ? selectionStart : selectionEnd
)
)
),
[]
)
}

return selected
}

export default square
7 changes: 3 additions & 4 deletions test/lib/DatePicker.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ it('handleTouchMoveEvent updates the availability draft', () => {
})

describe('updateAvailabilityDraft', () => {
test.each([['add', 1], ['remove', 1], ['add', -1], ['remove', -1]])(
it.each([['add', 1], ['remove', 1], ['add', -1], ['remove', -1]])(
'updateAvailabilityDraft handles addition and removals, for forward and reversed drags',
(type, amount, done) => {
const start = moment(startDate)
Expand Down Expand Up @@ -189,9 +189,8 @@ describe('updateAvailabilityDraft', () => {
selectionStart: start
},
() => {
const expected = type === 'remove' ? [outOfRangeOne] : [start, end, outOfRangeOne]
component.instance().updateAvailabilityDraft(end, () => {
expect(setStateSpy).toHaveBeenLastCalledWith({ selectionDraft: expect.arrayContaining(expected) })
expect(setStateSpy).toHaveBeenLastCalledWith({ selectionDraft: expect.arrayContaining([]) })
setStateSpy.mockRestore()
done()
})
Expand All @@ -211,7 +210,7 @@ describe('updateAvailabilityDraft', () => {
},
() => {
component.instance().updateAvailabilityDraft(null, () => {
expect(setStateSpy).toHaveBeenCalledWith({ selectionDraft: expect.arrayContaining([start]) })
expect(setStateSpy).toHaveBeenCalledWith({ selectionDraft: expect.arrayContaining([]) })
setStateSpy.mockRestore()
done()
})
Expand Down
44 changes: 44 additions & 0 deletions test/lib/date-utils.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import moment from 'moment'

import { dateIsBetween, timeIsBetween } from '../../src/lib/date-utils'

describe('dateIsBetween', () => {
const now = moment().toDate()
const tomorrow = moment(now)
.add(1, 'day')
.toDate()
const yesterday = moment(now)
.subtract(1, 'day')
.toDate()

test.each([
[[yesterday, now, tomorrow], true],
[[now, yesterday, tomorrow], false],
[[yesterday, tomorrow, now], false],
[[now, now, now], true]
])('it returns correctly', (args, expectation) => {
const expectMethod = expectation ? 'toBeTruthy' : 'toBeFalsy'
expect(dateIsBetween(...args))[expectMethod]()
})
})

describe('timeIsBetween', () => {
const hours = {}
for (let i = 0; i < 23; i += 1) {
hours[i] = new Date()
hours[i].setHours(i)
}

test.each([
[[hours[1], hours[2], hours[3]], true],
[[hours[5], hours[7], hours[20]], true],
[[hours[5], hours[4], hours[7]], false],
[[hours[5], hours[5], hours[5]], true],
[[hours[5], hours[5], hours[6]], true],
[[hours[5], hours[6], hours[6]], true],
[[hours[5], hours[10], hours[4]], false]
])('it returns correctly', (args, expectation) => {
const expectMethod = expectation ? 'toBeTruthy' : 'toBeFalsy'
expect(timeIsBetween(...args))[expectMethod]()
})
})

0 comments on commit 1893f31

Please sign in to comment.