thinkful-ui
Version:
Shared UI resources for Thinkful.
497 lines (433 loc) • 13.3 kB
JSX
const _ = require('lodash');
const cx = require('classnames');
const moment = require('moment');
const PropTypes = require('prop-types');
const React = require('react');
const Icon = require('../Icon');
const log = require('debug')('ui:AvailabilityGrid');
const DESKTOP_GRID_WIDTH = 620;
class AvailabilityGridSlot extends React.Component {
constructor(props) {
super(props);
this.handleMouseDown = this.handleMouseDown.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
}
handleMouseDown() {
const {
data,
dayIndex,
onSelectionModeChanged,
onSlotUnselected,
onSlotSelected
} = this.props;
if (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,
dayIndex,
mouseDown,
onSlotSelected,
onSlotUnselected,
selectionMode
} = this.props;
if (mouseDown === 1) {
if (selectionMode === 'selecting') {
onSlotSelected(dayIndex, data.index);
} else if (selectionMode === 'unselecting') {
onSlotUnselected(dayIndex, data.index);
}
}
}
render() {
return (
<div
className={cx('availability-grid-slot', {
selected: this.props.data.selected
})}
onMouseEnter={this.handleMouseEnter}
onMouseDown={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
>
{this.props.mobile && this.props.data.name}
</div>
);
}
}
AvailabilityGridSlot.propTypes = {
data: PropTypes.object.isRequired,
dayIndex: PropTypes.number.isRequired,
mobile: PropTypes.bool,
mouseDown: PropTypes.number.isRequired,
onSelectionModeChanged: PropTypes.func.isRequired,
onSlotSelected: PropTypes.func.isRequired,
onSlotUnselected: PropTypes.func.isRequired,
selectionMode: PropTypes.string.isRequired
};
const AvailabilityGridDay = ({ data, ...otherProps }) => {
const {
isMaxDay,
isMinDay,
maxSlot,
minSlot,
mobile,
onNavigateDay
} = otherProps;
const slotNodes = data.slots
.slice(minSlot, maxSlot)
.map((slotData, idx) => (
<AvailabilityGridSlot
data={slotData}
dayIndex={data.index}
key={idx}
{...otherProps}
/>
));
return (
<div className="availability-grid-day">
<span className="availability-grid-day-name">
{mobile && (
<Icon
className={cx('navigation navigation__left', {
disabled: isMinDay
})}
name="navigateleft"
onClick={e => onNavigateDay(-1)}
/>
)}
{data.name}
{mobile && (
<Icon
className={cx('navigation navigation__right', {
disabled: isMaxDay
})}
name="navigateright"
onClick={e => onNavigateDay(1)}
/>
)}
</span>
<div className="availability-grid-items">{slotNodes}</div>
</div>
);
};
AvailabilityGridDay.propTypes = {
data: PropTypes.shape({
index: PropTypes.number.isRequired,
slots: PropTypes.arrayOf(PropTypes.object).isRequired
}).isRequired,
isMinDay: PropTypes.bool,
isMaxDay: PropTypes.bool,
maxSlot: PropTypes.number.isRequired,
minSlot: PropTypes.number.isRequired,
onNavigateDay: PropTypes.func
};
class AvailabilityGrid extends React.Component {
constructor(props) {
super(props);
this._digestBitmap = this._digestBitmap.bind(this);
this.getBitmap = this.getBitmap.bind(this);
this.getInitialState = this.getInitialState.bind(this);
this.getNumDaysSelected = this.getNumDaysSelected.bind(this);
this.getNumHoursSelected = this.getNumHoursSelected.bind(this);
this.getSlotsAvailable = this.getSlotsAvailable.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleNavigateDay = this.handleNavigateDay.bind(this);
this.handlePost = this.handlePost.bind(this);
this.handleSelectionModeChanged = this.handleSelectionModeChanged.bind(
this
);
this.handleSlot = this.handleSlot.bind(this);
this.handleSlotSelected = this.handleSlotSelected.bind(this);
this.handleSlotUnselected = this.handleSlotUnselected.bind(this);
this.onMouseDown = this.onMouseDown.bind(this);
this.onMouseUp = this.onMouseUp.bind(this);
this.shouldRenderMobile = this.shouldRenderMobile.bind(this);
this.state = this.getInitialState();
}
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() {
const { bitmap } = this.props;
// If the bitmap is present when the component is mounted, render it.
if (bitmap) {
this._digestBitmap(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 = '') {
let { days } = this.state;
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) => {
days[dayIndex].slots[slotIndex].selected =
_.difference(slot, AVAILABLE_BITMAP).length === 0;
})
);
this.setState({ days: 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, idx) => {
return (
<div className="availability-grid-slot-name" key={idx}>
{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, idx) => (
<AvailabilityGridDay
data={dayData}
isMaxDay={this.state.activeDayIdx >= this.state.days.length - 1}
isMinDay={this.state.activeDayIdx <= 0}
key={idx}
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}
/>
));
const classes = cx('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>
);
}
}
AvailabilityGrid.propTypes = {
slotsHour: PropTypes.number.isRequired,
minHour: PropTypes.number.isRequired,
maxHour: PropTypes.number.isRequired,
onPost: PropTypes.func,
disabled: PropTypes.bool,
onChange: PropTypes.func,
mobile: PropTypes.bool
};
AvailabilityGrid.defaultProps = {
minHour: 0,
maxHour: 24,
onChange: () => {}
};
module.exports = AvailabilityGrid;