UNPKG

react-sprucebot

Version:

React components for your Sprucebot Skill 💪🏼

866 lines (743 loc) 20.6 kB
import React, { Component } from 'react' import moment from 'moment' import PropTypes from 'prop-types' import isEqual from 'lodash/isEqual' import { Tween, autoPlay, Easing } from 'es6-tween' import Avatar from '../Avatar/Avatar' import Button from '../Button/Button' import Calendar from './Calendar' import ControlButton from '../ControlButton/ControlButton' import DateSelect from '../DateSelect/DateSelect' import Dialog from '../Dialog/Dialog' import HorizontalWeek from './HorizontalWeek' import Icon from '../Icon/Icon' import Loader from '../Loader/Loader' import Pager from '../Pager/Pager' import { Tabs, TabPane } from '../Tabs/Tabs' autoPlay(true) const getElementWidth = element => { return element && element.offsetWidth } const getElementHeight = element => { return element && element.offsetHeight } export default class BigCalendar extends Component { constructor(props) { super(props) this.state = { currentPage: 0, view: props.defaultView, mode: props.defaultMode, renderFirstCalendar: true, // the first calendar is always the logged in user renderFirstEvents: true, // rendering events is slow, so we may defer loading them until later renderAllCalendars: false, renderAllEvents: true, showAllTeammates: props.defaultMode === 'team', transitioning: false, selectedDate: moment(), earliestTime: null, latestTime: null, teammates: props.teammates ? props.teammates : [], views: props.supportedViews, resized: 0, events: [], // All events for current date range storeSchedule: [], // Hours store is open for selected date range, optionsLoaded: [], isFetchingEvents: true, isSelectingScheduleDate: false } // Expected event structure: // const event = { // title: 'My favorite event', // className: 'shift', // start: new Date(), // end: new Date(), // allDay: true, // userId: id, // payload: { data preserved in callback } // } } componentDidMount = () => { //give things a sec to settle before recording sizes this.refresh() setTimeout(() => {}, 250) window.addEventListener('resize', this.handleWindowResize) } componentWillUnmount = () => { window.removeEventListener('resize', this.handleWindowResize) } setEvents = events => { this.setState({ events }) } triggerRefresh = () => { this.refresh() } events = () => { return this.state.events } setView = view => { this.handleChangeView(0) this.tabs.setSelected(0, '.0') } setMode = mode => { this.setState({ mode }) } setDate = selectedDate => { this.setState({ selectedDate }) } generatePagerTitle = page => { const { auth } = this.props const { view, selectedDate } = this.state let title if (view === 'month') { title = moment(selectedDate).format('MMM YYYY') } else if (view === 'week') { const startOfWeek = moment(selectedDate).startOf('week') const endOfWeek = moment(selectedDate).endOf('week') if (startOfWeek.isSame(endOfWeek, 'month')) { title = `${startOfWeek.format('MMM Do')} - ${endOfWeek.format('Do')}` } else { title = `${startOfWeek.format('MMM Do')} - ${endOfWeek.format( 'MMM Do' )}` } } else if (view === 'day') { const now = moment() .tz(auth.Location.timezone) .startOf('day') const days = moment .tz(selectedDate, auth.Location.timezone) .startOf('day') .diff(now, 'days') switch (days) { case -1: title = 'Yesterday' break case 0: title = 'Today' break case 1: title = 'Tomorrow' break default: title = moment(selectedDate).format('ddd, MMM Do') break } } return ( <ControlButton className={`sub_control`} onClick={this.handleShowScheduleDateDialog} > {title} <Icon>keyboard_arrow_down</Icon> </ControlButton> ) } getDesiredTeammateWrapperWidth = () => { if (!this.calendarWrapper) { return '100%' } const { view, mode } = this.state const { teamDayViewWidth } = this.props const calendarWrapperWidth = getElementWidth(this.calendarWrapper) if (mode === 'team' && view === 'day') { // make it a little thinner than the screen return Math.min(calendarWrapperWidth - 20, teamDayViewWidth) } else if (mode === 'team' && view === 'week') { return '100%' } else if (mode === 'team' && view === 'month') { return '100%' } else if (mode === 'user') { return calendarWrapperWidth } return 'auto' } getDesiredScrollWidth = () => { //act like a normal div until loaded if (!this.calendarWrapper) { return '100%' } const { view, mode, teammates, transitioning } = this.state const calendarWrapperWidth = getElementWidth(this.calendarWrapper) let widthOfAllCalendars = 0 let minWidthOfAllCalendars = this.getDesiredTeammateWrapperWidth() * teammates.length document .querySelectorAll('.teammate_calendar__wrapper') .forEach(wrapper => { widthOfAllCalendars += getElementWidth(wrapper) }) widthOfAllCalendars = Math.max(minWidthOfAllCalendars, widthOfAllCalendars) if (transitioning && view === 'day') { return widthOfAllCalendars } if (mode === 'team' && view == 'day') { return widthOfAllCalendars } else if (view === 'week') { return calendarWrapperWidth } else if (view === 'month') { return calendarWrapperWidth } else if (mode === 'user') { return calendarWrapperWidth } } getDesiredScrollHeight = () => { //act like a normal div until loaded if (!this.calendarWrapper) { return 'auto' } const { mode, view } = this.state if (mode === 'team' && view === 'week') { return 'auto' } else if (view === 'month') { return 'auto' } const firstTeammateWrapper = document.querySelector( '.teammate_calendar__wrapper' ) if (!firstTeammateWrapper) { return 'auto' } return getElementHeight(firstTeammateWrapper) || 'auto' } handleChange = () => { this.refresh() } refresh = async (triggerOnNavigate = false) => { const { mode, view, teammates, selectedDate, optionsLoaded } = this.state const { auth, onNavigate, fetchEvents } = this.props const currentView = view === 'team_week' ? 'week' : view const currentUser = teammates.find( teammate => teammate.User.id === auth.UserId ) const startDate = moment(selectedDate).startOf(currentView) const endDate = moment(selectedDate).endOf(currentView) const options = { mode, startDate, endDate, view: currentView, teammates: mode === 'user' ? currentUser : teammates } // const eventsLoaded = this.checkOptions(options) // if (!eventsLoaded) { this.setState({ optionsLoaded: [...optionsLoaded, options], isFetchingEvents: true }) triggerOnNavigate && onNavigate && onNavigate(options) try { const { storeSchedule, events } = await fetchEvents(options) this.setState({ storeSchedule, events, isFetchingEvents: false }) } catch (err) { console.log(err) this.setState({ isFetchingEvents: false }) } } checkOptions = options => { return this.state.optionsLoaded.find(loaded => isEqual(loaded, options)) } handlePagerChange = async page => { const { view } = this.state const diff = page - this.state.currentPage const stepType = view !== 'month' ? 'days' : 'months' await this.setState(prevState => { return { currentPage: page, selectedDate: prevState.selectedDate.add(diff, stepType) } }) this.handleChange() } handleChangeView = async idx => { const { mode, view } = this.state const newView = this.state.views[idx] const movingToWeek = mode === 'user' && view !== 'week' && newView === 'week' await this.setState({ view: newView, renderFirstCalendar: !movingToWeek }) // because month view does not show all teammates, if we are in team mode jumping OFF month view, lets // re-show team wrappers if (mode === 'team' && view === 'month' && newView !== 'month') { this.toggleShowOnCalendars() } else if (mode === 'user' && view !== 'week' && newView === 'week') { // week view is heavy, give dom a sec to render before rendering calendar this.delayedRenderWeekView() } this.handleChange() //trigger a refresh which causes, sizes to be recalculated. 500 delay for css transitions setTimeout(() => { this.handleWindowResize() }, 500) } delayedRenderWeekView = () => { setTimeout(() => { this.setState({ renderFirstCalendar: true }) }, 100) } //the earliest and latest time of all schedules timeRange = () => { const { selectedDate, storeSchedule, events } = this.state const adjustedEvents = events.filter(event => !event.allDay).map(event => ({ startTime: moment(event.start).format('HH:mm:ss'), endTime: moment(event.end).format('HH:mm:ss') })) const day = selectedDate.format('YYYY-MM-DD') const combinedTimes = [ ...storeSchedule, ...adjustedEvents .filter(event => { if (event.startTime && event.endTime) { return event } }) .map(event => ({ startTime: event.startTime, endTime: event.endTime })) ] let earliest = false let latest = false if (combinedTimes.length !== 0) { combinedTimes.forEach(event => { const start = moment(`${day} ${event.startTime}`) .startOf('hour') .subtract(2, 'hour') const end = moment(`${day} ${event.endTime}`) .endOf('hour') .add(2, 'hour') if (!earliest || earliest.diff(start) > 0) { earliest = start } if (!latest || latest.diff(end) < 0) { latest = end } }) if (!earliest.isSame(day, 'day')) { earliest = moment(`${day} 00:00:00`) } if (!latest.isSame(day, 'day')) { latest = moment(`${day} 23:59:59`) } } else { earliest = moment(selectedDate) .hour(7) .minutes(0) .seconds(0) latest = moment(selectedDate) .hour(18) .minutes(0) .seconds(0) } return [earliest, latest] } toggleShowOnCalendars = () => { // show teammates calendars one at a time const calendars = [ ...document.querySelectorAll('.teammate_calendar__wrapper') ] if (this.props.auth) { calendars.shift() } let delay = 100 const delayBump = 200 calendars.forEach(element => { setTimeout(() => { element.classList.toggle('hide', false) }, delay) delay += delayBump }) } jumpToTeamMode = async () => { if (this.state.transitioning) { return } //first give css transitions a sec to adjust the view await this.setState({ transitioning: true, mode: 'team', showAllTeammates: true, renderAllCalendars: true }) this.toggleShowOnCalendars() setTimeout(() => { this.handleChange() this.setState({ transitioning: false }) }, 1000) } jumpToUserMode = async () => { if (this.state.transitioning) { return } //scroll calendar left new Tween({ y: this.calendarWrapper.scrollLeft }) .to({ y: 0 }, 500) .on('update', ({ y }) => { this.calendarWrapper.scrollLeft = y }) .easing(Easing.Quadratic.Out) .start() // when jumping to week view in user mode, delay render because it's heavy const { view } = this.state //first give css transitions a sec to adjust the view await this.setState({ transitioning: true, mode: 'user', renderFirstCalendar: view !== 'week', showAllTeammates: view !== 'week' }) if (view === 'week') { this.delayedRenderWeekView() } // to hard on the client this.toggleShowOnCalendars() setTimeout(() => { this.handleChange() this.setState({ renderAllCalendars: false, showAllTeammates: false, transitioning: false }) }, 1000) } handleToggleMode = () => { const { mode } = this.state switch (mode) { case 'team': this.jumpToUserMode() break default: this.jumpToTeamMode() break } } handleWindowResize = () => { this.setState({ resized: this.state.resized++ }) } filterEvents = (events, teammate) => { const { view, mode, transitioning } = this.state // make transitions faster? if (transitioning) { return [] } if (mode === 'team' && view === 'month') { return events } const filteredEvents = events.filter( event => event.isUniversalEvent || event.userId === teammate.User.id ) return filteredEvents } applyClassNames = event => { return { className: `${event.className || ''}` } } handleClickEvent = (options, e) => { const { onClickEvent } = this.props onClickEvent && onClickEvent(options, e) } handleClickOpenSlot = (options, e) => { const { onClickOpenSlot } = this.props onClickOpenSlot && onClickOpenSlot(options, e) } handleDropEvent = ({ event, start, end }) => { const { onDropEvent } = this.props onDropEvent && onDropEvent(event, start, end) } handleResizeEvent = (resizeType, { event, start, end }) => { const { onResizeEvent } = this.props onResizeEvent && onResizeEvent(event, start, end) } handleCanDrag = event => { const { canDrag } = this.props if (canDrag) { return canDrag(event) } } handleCanResize = event => { const { canResize } = this.props if (canResize) { return canResize(event) } } /** * DATE SELECT METHODS */ handleShowScheduleDateDialog = () => { this.setState({ isSelectingScheduleDate: true }) } handleHideScheduleDateDialog = () => { this.setState({ isSelectingScheduleDate: false }) } handleScheduleDateSelect = async date => { await this.setState({ isSelectingScheduleDate: false, selectedDate: date }) this.refresh() } handleSelectToday = () => { this.handleScheduleDateSelect(moment()) } render() { const { auth, className, supportedViews, timeslots, step, titleAccessor } = this.props const { selectedDate, view, teammates, mode, transitioning, renderAllCalendars, showAllTeammates, renderFirstCalendar, events, renderAllEvents, isFetchingEvents, isSelectingScheduleDate } = this.state // populate views to take into account team week let selectedView = view const views = {} supportedViews.forEach(view => { views[view] = true }) views.team_week = HorizontalWeek if (mode === 'team' && view === 'week') { selectedView = 'team_week' } const teammateWrapperWidth = this.getDesiredTeammateWrapperWidth() const scrollWidth = this.getDesiredScrollWidth() const scrollHeight = this.getDesiredScrollHeight() // format times const formats = { // format times in left column timeGutterFormat: date => { return moment(date).format('h:mma') } } // setup start and end times const [min, max] = this.timeRange() // configure react-sprucebot calendar const calendarProps = { view: selectedView, formats, toolbar: false, date: selectedDate.toDate(), min: min.toDate(), max: max.toDate() } // Determine selected date in relation to today const today = moment() .tz(auth.Location.timezone) .startOf('day') const selectedDateStart = moment .tz(selectedDate, auth.Location.timezone) .startOf('day') const isToday = today.isSame(selectedDateStart) // Optionally passed calendar props if (timeslots) { calendarProps.timeslots = timeslots } if (step) { calendarProps.step = step } if (titleAccessor) { calendarProps.titleAccessor = titleAccessor } let classNames = `${className || ''} ${mode === 'team' ? 'team' : 'user'} ${ transitioning ? 'transitioning' : '' } ${view}` let team = mode === 'team' ? teammates : [auth] //filter authed user out and prepend if (view === 'month') { team = [auth] } else if (showAllTeammates) { team = team.filter(teammate => { return teammate.User.id !== auth.User.id }) team = [auth, ...team] } let isFetching = isFetchingEvents || transitioning let isLoaderOutside = (view === 'week' && mode === 'user') || view === 'month' return ( <div className={`big_calendar ${classNames}`}> {isSelectingScheduleDate && ( <Dialog title={`Jump To Day`} className={`schedule_calendar_select`} onTapClose={this.handleHideScheduleDateDialog} > <DateSelect defaultDate={selectedDate} initialVisibleMonth={() => selectedDate} onDateSelect={this.handleScheduleDateSelect} allowPastDates /> {!isToday && ( <Button primary onClick={this.handleSelectToday} >{`Jump to Today`}</Button> )} </Dialog> )} <Tabs ref={element => (this.tabs = element)} onChange={this.handleChangeView} > <TabPane title="Day" /> <TabPane title="Week" /> <TabPane title="Month" /> </Tabs> <div className="calendar__controls"> <Pager infinite={true} onChange={this.handlePagerChange} titles={this.generatePagerTitle} jumpAmount={selectedView !== 'month' ? 7 : 1} showStep={selectedView === 'day'} /> <Button className="toggle-mode" onClick={this.handleToggleMode}> {mode === 'team' ? 'show just me' : 'show team'} </Button> </div> <div className={`calendars__wrapper ${isFetching ? 'fetching' : ''}`} ref={ref => { this.calendarWrapper = ref }} > <div className={`calendar__scroll`} style={{ width: scrollWidth, height: scrollHeight }} > {team.map((teammate, idx) => { return ( <div key={`calendar-wrapper-${teammate.User.id}`} className={`teammate_calendar__wrapper ${ idx === 0 ? '' : 'hide' }`} style={{ width: teammateWrapperWidth }} > {!(view === 'month' && mode === 'team') && ( <div className="avatar_wrapper"> <span> <Avatar top user={teammate} /> <span className="calendar__teammate_name"> {teammate.User.casualName} </span> </span> </div> )} {idx === 0 && view === 'month' && mode === 'team' && teammates.map(teammate => ( <div className="avatar_wrapper"> <span> <Avatar top user={teammate} /> <span className="calendar__teammate_name"> {teammate.User.casualName} </span> </span> </div> ))} {((idx === 0 && renderFirstCalendar) || (idx > 0 && renderAllCalendars)) && ( <Calendar className={`${ idx === 0 && !renderFirstCalendar ? 'hide' : '' }`} views={views} events={events ? this.filterEvents(events, teammate) : []} eventPropGetter={event => this.applyClassNames(event)} onSelectEvent={(event, e) => this.handleClickEvent( { event, teammate, view, mode }, e ) } onSelectSlot={({ start, end, action }, e) => this.handleClickOpenSlot( { start, end, action, teammate, view, mode }, e ) } onEventDrop={this.handleDropEvent} onEventResize={this.handleResizeEvent} canDrag={this.handleCanDrag} canResize={this.handleCanResize} popup={selectedView === 'month'} {...calendarProps} /> )} {isFetching && !isLoaderOutside && ( <div className="loader__underlay"> <Loader /> </div> )} </div> ) })} </div> {isFetching && isLoaderOutside && ( <div className="loader__underlay"> <Loader /> </div> )} </div> </div> ) } } BigCalendar.propTypes = { auth: PropTypes.object.isRequired, teammates: PropTypes.array, supportedViews: PropTypes.array.isRequired, //NOT IMPLEMENTED defaultView: PropTypes.string.isRequired, supportedModes: PropTypes.array.isRequired, //NOT IMPLEMENTED defaultMode: PropTypes.string.isRequired, teamDayViewWidth: PropTypes.number, onClickEvent: PropTypes.func, onClickOpenSlot: PropTypes.func, onDropEvent: PropTypes.func, onResizeEvent: PropTypes.func, timeslots: PropTypes.number, step: PropTypes.number } BigCalendar.defaultProps = { supportedViews: ['day', 'week', 'month'], //NOT IMPLEMENTED defaultView: 'day', supportedModes: ['user', 'team'], //NOT IMPLEMENTED defaultMode: 'user', teamDayViewWidth: 250, timeslots: 4, step: 15 }