Created
October 24, 2019 17:22
-
-
Save justingrant/a72ea880b886bd1fe75aa62c5c69e05b to your computer and use it in GitHub Desktop.
react wrapper for FullCalendar.io that calls gotoDate() when props.defaultDate is changed
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// adapted from https://github.com/fullcalendar/fullcalendar-react/blob/b23f8cbc67b4b75429bc3192eb8301ab1259a21c/src/FullCalendar.tsx | |
import React, { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; | |
import { Calendar, OptionsInput } from '@fullcalendar/core'; | |
import deepEqual from 'fast-deep-equal'; | |
// type-safe equivalent to Object.keys | |
function keysOf<T>(o: T) { | |
return Object.keys(o) as (keyof T)[]; | |
} | |
// type-safe way to filter an object's properties (like Array.filter, but for objects) | |
function filterObject<T>(o: T, predicate: (propName: keyof T) => boolean) { | |
return keysOf(o).reduce( | |
(acc, propName) => { | |
if (predicate(propName)) { | |
acc[propName] = o[propName]; | |
} | |
return acc; | |
}, | |
{} as Partial<T> | |
); | |
} | |
// See https://fullcalendar.io/docs/timeZone for why we use UTC here | |
export const dateOnly = (dateInLocal: Date) => | |
new Date(dateInLocal.getFullYear(), dateInLocal.getMonth(), dateInLocal.getDate()); | |
export const dateOnlyUTC = (dateInUtc: Date) => | |
new Date(dateInUtc.getUTCFullYear(), dateInUtc.getUTCMonth(), dateInUtc.getUTCDate()); | |
export interface FullCalendarRefHandles { | |
getApi: () => Calendar; | |
} | |
export default forwardRef((props: OptionsInput, componentRef: React.Ref<FullCalendarRefHandles>) => { | |
const elRef = useRef<HTMLDivElement>(null); | |
const calRef = useRef<Calendar>(); | |
const oldDate = useRef<Date>(); | |
const oldPropsRef = useRef<OptionsInput>(props); | |
// TODO: Need to figure out how to deal with timezones here. | |
// Not sure if it's browser or server date boundaries being used here. | |
const getNewDate = () => { | |
const calApi = getApi(); | |
const datetime = calApi.dateEnv.createMarker(props.defaultDate ? props.defaultDate : calApi.getNow()); | |
const utc = dateOnlyUTC(datetime); | |
return utc; | |
}; | |
function getApi(): Calendar { | |
return calRef.current!; | |
} | |
function init() { | |
const cal = new Calendar(elRef.current!, props); | |
calRef.current = cal; | |
cal.render(); | |
oldDate.current = getNewDate(); | |
// Cleanup callback automatically called before unmounting | |
return () => cal.destroy(); | |
} | |
/* | |
here's what's going on: | |
props are local time, e.g. Thu Jun 06 2019 00:00:00 GMT-0700 (Pacific Daylight Time) {} | |
we convert that to a marker that's in UTC, e.g. Wed Jun 05 2019 17:00:00 GMT-0700 (Pacific Daylight Time) | |
that marker is stored and compared to a new date. | |
when the user clicks on a new date, we gotoDate() with one day later, in local time: Thu Jun 06 2019 00:00:00 GMT-0700 (Pacific Daylight Time) | |
where does that new date come from? why does it differ from the old date? | |
*/ | |
function updateOptions() { | |
const cal = calRef.current!; | |
const newDate = getNewDate(); | |
// First, update the selected date if defaultDate prop changes | |
// IMPORTANT: changing the date must come before changing other props. | |
// Otherwise we can end up in infinite loops in Calendar rendering, because | |
// of the following sequence that would happen if we set the date after | |
// other props: | |
// 1. caller sets the new defaultDate prop programmatically in response to | |
// user action, which triggers this method | |
// 2. we call Calendar.mutateOptions with any other props that changed | |
// (typically handled functions and the date) | |
// 3. this in turn causes the FullCalendar instance to re-render with the | |
// old selected date (because we haven't changed the date yet) | |
// 4. this triggers a dayRender event | |
// 5. the dayRender event compares the current rendered date to the current | |
// defaultDate prop, and because they won't match (remember, we haven't | |
// changed the date yet!) my dayRender handler assumes that the rendered | |
// date is a new date (e.g. triggered by the user clicking the "next" | |
// button on the toolbar) and sets the defaultDate prop back to the old | |
// date. | |
// 6. Changing the defaultDate prop queues up a call to this method (more | |
// on this later) | |
// 7. Meanwhile, the first execution of this method isn't done yet... it | |
// still has to call gotoDate() to set the date on the FullCalendar | |
// instance. It does this. | |
// 8. Calling gotoDate triggers a re-render of the FullCalendar instance, | |
// which triggers the dayRender handler, which (correctly) sees that the | |
// selected date has changed (to the new date). It changes the | |
// defaultDate prop to the new date. | |
// 9. But remember the erroneous prop update (to the old date) from step #6 | |
// above? That prop update causes this "on props changed" method to fire | |
// again, with the "old" date as a prop. | |
// 10. This triggers a Calendar.mutateOptions() call, which triggers another | |
// render with the selected date being the "new" date. But remember we | |
// haven't called gotoDate yet with the "old" date. (more on this in | |
// step 12) | |
// 11. This triggers the dayRender handler, which compares the current | |
// selectedDate prop (which was set to the "old" date in step #5) to the | |
// "new" date set by step #7. They don't match. The dayRender handler | |
// assumes that the user clicked a toolbar navigation button again, and | |
// sets the defaultDate prop to the "new" date. | |
// 12. At the end of step 11, we're in a similar state as the end of step 1: | |
// changing the defaultDate prop to the "new" date. But there's still a | |
// pending gotoDate call from step 10 that will cause this whole process | |
// to repeat itself over and over. FAIL! However, if you set the date | |
// first before mutating other options, then the dayRender handler will | |
// see the "new" date (which will match the "new" date in the | |
// defaultDate prop) and it won't try to reset that prop to another | |
// value. No infinite loop. Yay! | |
// console.log(`new: ${newDate}, old: ${oldDate.current}`); | |
if (newDate != null && (oldDate.current == null || oldDate.current.getTime() !== newDate.getTime())) { | |
cal.gotoDate(newDate); | |
oldDate.current = newDate; | |
} | |
const oldProps = oldPropsRef.current; | |
if (oldProps !== props) { | |
// manually check to see which props are different from last time | |
const removals = oldProps ? keysOf(oldProps).filter(propName => !(propName in props)) : []; | |
const updates: Partial<OptionsInput> = oldProps | |
? filterObject(props, propName => !deepEqual(props[propName], oldProps[propName])) | |
: props; | |
// Current fullcalendar-react uses false for isDynamic, but we are making dynamic updates. Right? | |
// Assuming yes, I'm setting it to true while investigating further. | |
cal.mutateOptions(updates, removals, true, deepEqual); | |
oldPropsRef.current = props; | |
} | |
} | |
// Calendar initialization | |
useEffect(init, []); | |
// Calendar options update when props change | |
useEffect(updateOptions, [calRef, props]); | |
// Allow Calendar API to be exposed to parent component when using ref | |
// eslint-disable-next-line react-hooks/exhaustive-deps | |
useImperativeHandle(componentRef, () => ({ getApi }), [calRef]); | |
return <div ref={elRef} />; | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
@awaism551 yep, this is 3-year-old code, so I wouldn't be surprised if it doesn't work the same way now.