react-sprucebot
Version:
React components for your Sprucebot Skill 💪🏼
794 lines (678 loc) • 18.9 kB
JavaScript
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 Pager from "../Pager/Pager";
import Loader from "../Loader/Loader";
import { Tabs, TabPane } from "../Tabs/Tabs";
import HorizontalWeek from "./HorizontalWeek";
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
};
// 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 title;
};
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 day = selectedDate.format("YYYY-MM-DD");
const combinedTimes = [
...storeSchedule,
...events
.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);
}
};
render() {
const {
auth,
className,
supportedViews,
timeslots,
step,
titleAccessor
} = this.props;
const {
selectedDate,
view,
teammates,
mode,
transitioning,
renderAllCalendars,
showAllTeammates,
renderFirstCalendar,
events,
renderAllEvents,
isFetchingEvents
} = 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()
};
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}`}>
<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
};