thinkful-ui
Version:
Shared navigation and UI resources for Thinkful.
426 lines (367 loc) • 12.1 kB
JSX
require('./availability_grid.less');
const React = require('react');
const classNames = require('classnames');
const moment = require('moment');
const chunk = require('lodash/array/chunk');
const difference = require('lodash/array/difference');
const fill = require('lodash/array/fill');
const { Icon } = require('../Icon');
const log = require('debug')('ui:AvailabilityGrid');
const DESKTOP_GRID_WIDTH = 620;
const AvailabilityGridSlot = React.createClass({
propTypes: {
dayIndex: React.PropTypes.number.isRequired,
mouseDown: React.PropTypes.number.isRequired,
selectionMode: React.PropTypes.string.isRequired,
data: React.PropTypes.object.isRequired,
onSelectionModeChanged: React.PropTypes.func.isRequired,
onSlotUnselected: React.PropTypes.func.isRequired,
onSlotSelected: React.PropTypes.func.isRequired,
mobile: React.PropTypes.bool,
},
handleMouseDown() {
const {data, dayIndex, onSelectionModeChanged, onSlotUnselected, onSlotSelected} = this.props;
if (this.props.data.selected) {
onSelectionModeChanged('unselecting', dayIndex, data.index);
onSlotUnselected(dayIndex, data.index);
} else {
onSelectionModeChanged('selecting', dayIndex, data.index);
onSlotSelected(dayIndex, data.index);
}
},
handleMouseUp() {
this.props.onSelectionModeChanged('neutral');
},
handleMouseEnter() {
const {data, mouseDown, dayIndex, selectionMode, onSlotSelected, onSlotUnselected} = this.props;
if (mouseDown === 1) {
if (selectionMode === 'selecting') {
onSlotSelected(dayIndex, data.index);
} else if (selectionMode === 'unselecting') {
onSlotUnselected(dayIndex, data.index);
}
}
},
render() {
let classes = classNames(
'availability-grid-slot',
{'selected': this.props.data.selected}
);
return (
<div
className={classes}
onMouseEnter={this.handleMouseEnter}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp} >
{this.props.mobile && this.props.data.name}
</div>
);
}
})
const AvailabilityGridDay = React.createClass({
propTypes: {
minSlot: React.PropTypes.number.isRequired,
maxSlot: React.PropTypes.number.isRequired,
data: React.PropTypes.object.isRequired,
onNavigateDay: React.PropTypes.func,
isMinDay: React.PropTypes.bool,
isMaxDay: React.PropTypes.bool,
},
render() {
let slotNodes = this.props.data.slots.map((slotData) => {
const {data, ...other} = this.props;
return (
<AvailabilityGridSlot
data={slotData}
dayIndex={data.index}
{...other} />
);
})
return (
<div className="availability-grid-day">
<span className="availability-grid-day-name">
{this.props.mobile &&
<Icon
className={classNames(
"navigation navigation__left",
{disabled: this.props.isMinDay})}
name="navigateleft"
onClick={e => this.props.onNavigateDay(-1)}/>}
{this.props.data.name}
{this.props.mobile &&
<Icon
className={classNames(
"navigation navigation__right",
{disabled: this.props.isMaxDay})}
name="navigateright"
onClick={e => this.props.onNavigateDay(1)}/>}
</span>
<div className="availability-grid-items">
{slotNodes.slice(this.props.minSlot, this.props.maxSlot)}
</div>
</div>
);
}
})
const AvailabilityGrid = React.createClass({
propTypes: {
slotsHour: React.PropTypes.number.isRequired,
minHour: React.PropTypes.number.isRequired,
maxHour: React.PropTypes.number.isRequired,
onPost: React.PropTypes.func,
disabled: React.PropTypes.bool,
onChange: React.PropTypes.func,
mobile: React.PropTypes.bool,
},
getDefaultProps() {
return {
onChange: () => {},
}
},
getInitialState() {
let days;
if (this.shouldRenderMobile()) {
days = [
'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'
];
}
else {
days = ['Mon', 'Tues', 'Wed', 'Thurs', 'Fri', 'Sat', 'Sun'];
}
const HOURS_DAY = 24;
const MINUTES_HOUR = 60;
const MINUTES_SLOT = MINUTES_HOUR / this.props.slotsHour;
const SLOTS_DAY = HOURS_DAY * this.props.slotsHour;
let daysData = [];
let slotNames = [];
let selectionMode = 'neutral';
// precalculate the slot names one time
let formatString = 'h a';
if (this.props.slotsHour > 1) {
formatString = 'h:mma';
}
let currentSlot = moment().startOf('day');
for (let i = 0; i < SLOTS_DAY; i++) {
slotNames.push(currentSlot.format(formatString));
currentSlot.add(MINUTES_SLOT, 'm');
}
daysData = days.map((name, index) => {
let slots = slotNames.map((name, index) => ({name, index, selected: false}));
return {index, name, slots};
});
return {
activeDayIdx: 0,
days: daysData,
slotNames: slotNames,
selectionMode: selectionMode,
selectionStartDays: _.cloneDeep(daysData),
selectionStartDay: 0,
selectionStartSlot: 0,
mouseDown: 0
};
},
onMouseDown(e) {
this.setState({mouseDown: this.state.mouseDown + 1});
},
onMouseUp(e) {
this.setState({mouseDown: this.state.mouseDown - 1});
},
componentWillReceiveProps(nextProps) {
this._digestBitmap(nextProps.bitmap);
},
componentDidMount() {
// If the bitmap is present when the component is mounted, render it.
if (this.props.bitmap && this.props.bitmap != '') {
this._digestBitmap(this.props.bitmap);
}
},
/**
* Translates bitmapstring to internal data structures and stores it on
* the state.
*
* Bitmap for availability is a string of 1s and 0s, with 1 representing
* an avalible time block and 0 representing an unavailible time block.
*
* Totally free: 11111111111111111111
* Always busy: 00000000000000000000
*
* If there are 4 slots in every hour (15 minutes per time block), then
* the availability bitmap of someone busy for the first 30m of each hour
* would look like this: 00110011001100110011
*
* This function splits the bitstring into the days variable we store on
* state.
*
*/
_digestBitmap(bitmap='') {
const MAX_SLOTS_HOUR = 4;
const HOURS_DAY = 24;
const DAYS_SLOT = MAX_SLOTS_HOUR * HOURS_DAY;
const BITS_SLOT = MAX_SLOTS_HOUR / this.props.slotsHour;
const AVAILABLE_BITMAP = fill(Array(BITS_SLOT), '1')
bitmap = bitmap.split('');
chunk(bitmap, DAYS_SLOT).
map((day, dayIndex) => chunk(day, BITS_SLOT).
map((slot, slotIndex) => {
this.state.days[dayIndex].slots[slotIndex].selected =
difference(slot, AVAILABLE_BITMAP).length === 0;
})
)
;
this.setState({days: this.state.days});
},
getBitmap() {
// translate state to bitmap and return
return this.state.days.map((dayData) => {
return dayData.slots.map((slot) => {
let value = slot.selected ? '1' : '0'
return new Array(4 / this.props.slotsHour + 1).join(value);
}).join('');
}).join('');
},
getNumDaysSelected() {
return this.state.days.reduce((prev, day) => {
return prev + !!_.find(day.slots, 'selected') * 1;
}, 0);
},
getNumHoursSelected() {
return this.getBitmap().replace(/0/g, '').length / 4;
},
getSlotsAvailable() {
return _.sum(this.state.days.map(dayData => {
return _.sum(dayData.slots.map(slot => {
return slot.selected ? 1 : 0
}));
}));
},
handleMouseEnter() {
if (this.state.mouseDown === 0) {
this.handleSelectionModeChanged('neutral');
}
},
handlePost() {
this.props.onPost(this.getBitmap());
},
handleSelectionModeChanged(newMode, startDay, startSlot) {
if (!this.props.disabled) {
if (newMode == 'selecting' || newMode == 'unselecting') {
let days = this.state.days;
days[startDay].slots[startSlot].selected = (newMode === 'selecting')
this.setState({
days: days,
selectionStartDays: _.cloneDeep(days),
selectionMode: newMode,
selectionStartDay: startDay,
selectionStartSlot: startSlot
});
} else {
this.setState({
selectionMode: newMode
});
}
}
},
handleSlotSelected(dayIndex, slotIndex) {
this.handleSlot(dayIndex, slotIndex, true);
},
handleSlotUnselected(dayIndex, slotIndex) {
this.handleSlot(dayIndex, slotIndex, false);
},
handleSlot(dayIndex, slotIndex, value) {
if (this.state.selectionMode === 'selecting' || this.state.selectionMode === 'unselecting') {
let days = this.state.days;
let startDay = Math.min(this.state.selectionStartDay, dayIndex)
let endDay = Math.max(this.state.selectionStartDay, dayIndex)
let startSlot = Math.min(this.state.selectionStartSlot, slotIndex)
let endSlot = Math.max(this.state.selectionStartSlot, slotIndex)
days.map((day, i) => {
day.slots.map((slot, j) => {
if (i >= startDay && i <= endDay && j >= startSlot && j <= endSlot) {
days[i].slots[j].selected = value;
} else {
days[i].slots[j].selected = this.state.selectionStartDays[i].slots[j].selected;
}
})
});
this.setState({days: days}, this.props.onChange(days));
}
else {
this.props.onChange(this.state.days);
}
},
shouldRenderMobile() {
return window.innerWidth < DESKTOP_GRID_WIDTH;
},
handleNavigateDay(direction) {
let newIdx = this.state.activeDayIdx + direction;
newIdx = Math.max(Math.min(newIdx, this.state.days.length - 1), 0);
this.setState({activeDayIdx: newIdx});
},
render() {
let slotNames = this.state.slotNames.map((slotName) => {
return (
<div className="availability-grid-slot-name">
{slotName}
</div>
);
})
let minSlot = this.props.minHour * this.props.slotsHour;
let maxSlot = this.props.maxHour * this.props.slotsHour;
let days;
if (this.shouldRenderMobile()) {
days = [this.state.days[this.state.activeDayIdx]];
}
else {
days = this.state.days;
}
let dayNodes = days.map((dayData) => {
return (
<AvailabilityGridDay
data={dayData}
isMaxDay={this.state.activeDayIdx >= this.state.days.length - 1}
isMinDay={this.state.activeDayIdx <= 0}
minSlot={minSlot}
maxSlot={maxSlot}
mobile={this.shouldRenderMobile()}
mouseDown={this.state.mouseDown}
onSelectionModeChanged={this.handleSelectionModeChanged}
onSlotSelected={this.handleSlotSelected}
onSlotUnselected={this.handleSlotUnselected}
onNavigateDay={this.handleNavigateDay}
selectionMode={this.state.selectionMode} />
);
});
let classes = classNames(
'availability-grid', {
'availability-grid__disabled': this.props.disabled,
'availability-grid__mobile': this.shouldRenderMobile()
}
);
return (
<div
className={classes}
onMouseEnter={this.handleMouseEnter}
onMouseDown={this.onMouseDown}
onMouseUp={this.onMouseUp} >
{!this.shouldRenderMobile() &&
<div
className="availability-grid-slot-names">
{slotNames.slice(minSlot, maxSlot)}
</div>
}
<div
className="availability-grid-days">
{dayNodes}
</div>
{this.props.onPost && (
<input
className="button"
type="submit"
onClick={this.handlePost}
value="Update Availability"/>
)}
</div>
);
}
})
module.exports = AvailabilityGrid;