Skip to content

Instantly share code, notes, and snippets.

@justingrant
Created October 24, 2019 17:22
Show Gist options
  • Save justingrant/a72ea880b886bd1fe75aa62c5c69e05b to your computer and use it in GitHub Desktop.
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
// 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} />;
});
@awaism551
Copy link

fullCalendar version is not mentioned, as per my knowledge gotoDate function is not present in version 5, changeView function can be used instead

@justingrant
Copy link
Author

@awaism551 yep, this is 3-year-old code, so I wouldn't be surprised if it doesn't work the same way now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment