diff --git a/fec/fec/static/js/modules/calendar-helpers.js b/fec/fec/static/js/modules/calendar-helpers.js
index 0920fc6c36..7e443f5aed 100644
--- a/fec/fec/static/js/modules/calendar-helpers.js
+++ b/fec/fec/static/js/modules/calendar-helpers.js
@@ -1,10 +1,160 @@
+/* eslint-disable */
/**
*
*/
import moment from 'moment';
import { default as URI } from 'urijs';
+function dateString(dateToParse, format, adjustmentHours) {
+ const parsed = new Date(Date.parse(dateToParse));
+
+ if (adjustmentHours) parsed.setHours(parsed.getHours() + adjustmentHours);
+
+ let YYYY = parsed.getUTCFullYear();
+ let MM = (parsed.getUTCMonth() + 1).toString().padStart(2, '0'); // Months are 0-indexed
+ let DD = (parsed.getUTCDate()).toString().padStart(2, '0');
+ let hh = (parsed.getUTCHours()).toString().padStart(2, '0');
+ let mm = (parsed.getUTCMinutes()).toString().padStart(2, '0');
+ let ss = (parsed.getUTCSeconds()).toString().padStart(2, '0');
+
+ if (format == 'YYYYMMDD')
+ return `${YYYY}${MM}${DD}`;
+
+ else if (format == 'YYYYMMDD[T]HHmmss')
+ return `${YYYY}${MM}${DD}T${hh}${mm}${ss}`;
+
+ return dateToParse;
+}
+
+/**
+ * @enum {Object} - Returns an object with settings for various datetime formats.
+ * For dates, use .toLocaleDateString('en-US', {}), for time use .toLocaleTimeString('en-US', {});
+ * Converts all dates to U.S. Eastern (i.e. 'America/New_York')
+ * For ISO dates (i.e. 1970-01-01), use only .toISOString();
+ * @see https://moment.github.io/luxon/#/formatting?id=presets
+ */
+export const dateTimeFormatOptions = {
+ /** @example '1/1/1970' (This is the default for en-US) */
+ DATE_SHORT: {},
+
+ /** @example '01/01/1970' */
+ DATE_SHORT_01: { month: '2-digit', day: '2-digit', year: 'numeric' },
+
+ /** @example 'January 1, 1970' */
+ DATE_FULL: { month: 'long', day: 'numeric', year: 'numeric' },
+
+ /** @example 'January 01, 1970' */
+ DATE_FULL_01: { month: 'long', day: '2-digit', year: 'numeric' },
+
+ /** @example 'January 1 */
+ DATE_FULL_MONTH_DAY: { month: 'long', day: '2-digit' },
+
+ /** @example 'Wednesday' */
+ WEEKDAY_FULL: { weekday: 'long' },
+
+ /** @example '1:30 PM' */
+ TIME_SIMPLE: { hour: "numeric", minute: "2-digit" },
+
+ /** @example '13:30' */
+ TIME_24_SIMPLE: {},
+};
+
+/**
+ * @param {Object} event - {start: '', end: '', title: '', summary: ''}
+ * @param {string} event.start
+ * @param {string} [event.end]
+ * @param {string} [event.title]
+ * @param {string} [event.summary]
+ * @returns {string}
+ */
export function getGoogleUrl(event) {
+ console.log('getGoogleUrl(event): ', event);
+ let datesString;
+
+ if (event.end) {
+ datesString = `${convertDateForGoogle(event.start, true)}/${convertDateForGoogle(event.end, true)}`;
+
+ } else {
+ const startDateObj = new Date(event.start);
+ startDateObj.setHours(startDateObj.getHours() + 24);
+ const fakeEndDate = startDateObj.toISOString();
+ const startEndComboString = `${convertDateForGoogle(event.start, false).substring(0,10)}/${convertDateForGoogle(fakeEndDate, false)}`;
+ datesString = startEndComboString;
+ }
+
+ const toReturn =
+ URI('https://calendar.google.com/calendar/render')
+ .addQuery({
+ action: 'TEMPLATE',
+ text: event.title,
+ details: event.summary,
+ dates: datesString
+ })
+ .toString();
+
+ console.log(' toReturn: ', toReturn);
+
+ return toReturn;
+}
+
+export function getMs365Url(event) {
+ console.log('getMs365Url(event): ', event);
+ let datesString;
+
+ if (event.end) {
+ datesString = `${convertDateForGoogle(event.start, true)}/${convertDateForGoogle(event.end, true)}`;
+
+ } else {
+ const startDateObj = new Date(event.start);
+ startDateObj.setHours(startDateObj.getHours() + 24);
+ const fakeEndDate = startDateObj.toISOString();
+ const startEndComboString = `${convertDateForGoogle(event.start, false).substring(0,10)}/${convertDateForGoogle(fakeEndDate, false)}`;
+ datesString = startEndComboString;
+ }
+
+ const toReturn =
+ URI('https://outlook.office.com/calendar/0/deeplink/compose')
+ .addQuery({
+ allday: true,
+ body: event.summary,
+ enddt: '2025-01-31',
+ location: '',
+ // path: '',
+ // rru: '',
+ startdt: '2025-01-30',
+ subject: event.title
+ // action: 'TEMPLATE',
+ // text: event.title,
+ // details: event.summary,
+ // dates: datesString
+ })
+ .toString();
+ console.log(' toReturn: ', toReturn);
+
+ return toReturn;
+}
+
+/**
+ * Takes a Date object, removes the dashes and colon
+ * @param {string} input -sdf
+ * @param {boolean} [includeTime=true] - Whether the returned string should be 200012311945 or 20001231
+ * @returns {string}
+ */
+function convertDateForGoogle(input, includeTime = true) {
+ // const passedDate = new Date(input);
+ const toReturn = input.replaceAll(/[-:.]/g, '');
+ return includeTime ? toReturn : toReturn.substring(0, 7);
+}
+
+/**
+ * @param {Object} event - {start: '', end: '', title: '', summary: ''}
+ * @param {Moment} [event.start]
+ * @param {Moment} [event.end]
+ * @param {string} [event.title]
+ * @param {string} [event.summary]
+ * @returns {string}
+ */
+export function getGoogleUrl_moment(event) {
let fmt, dates;
if (event.end) {
fmt = 'YYYYMMDD[T]HHmmss';
@@ -29,7 +179,14 @@ export function getGoogleUrl(event) {
.toString();
}
-export function calendarDownload(path, params) {
+/**
+ * @param {string} path
+ * @param {Object} params - key-value pairs on an Object
+ * @returns {string} a string like '/v1/path/?param1key=param1val¶m2key=param2val'
+ */
+// export function calendarDownload(path, params) {
+export function getDownloadUrl(path, params) {
+ console.log('getDownloadUrl(path, params): ', path, params);
const url = URI(window.API_LOCATION)
.path(Array.prototype.concat(window.API_VERSION, path || [], '').join('/'))
.addQuery({
@@ -43,6 +200,13 @@ export function calendarDownload(path, params) {
return URI.decode(url);
}
+/**
+ * Builds a URL from the parameters provided plus API_LOCATION and API_VERSION
+ * @param {string[]} path - An array of strings to be combined to build the path. ex: ['data', 'path'] will become 'data/path'
+ * @param {Object} params - Key/value pairs to add after the ? in the full URL
+ * @param {string} [type] - Adds the public key or, if 'sub', uses the calendar public key
+ * @returns {string}
+ */
export function getUrl(path, params, type) {
//if 'type' arg is present and set to 'sub', use API_KEY_PUBLIC_CALENDAR as api_key, otherwise use API_KEY_PUBLIC;
const apiKey =
@@ -57,6 +221,12 @@ export function getUrl(path, params, type) {
.toString();
return URI.decode(url);
}
+
+/**
+ * Returns a class name for multi-date events, i.e. those with valid but different start day and end day
+ * @param {Object} event - Event object, looking at start_date and end_date
+ * @returns {string} Returns a class name for multi-day events, else ''
+ */
export function className(event) {
const start = event.start_date ? moment(event.start_date).format('M D') : null;
const end = event.end_date ? moment(event.end_date).format('M D') : null;
@@ -67,6 +237,11 @@ export function className(event) {
}
}
+/**
+ * Does event.start_date have an hour (vs only yyyy-mm-dd or null)
+ * @param {Object} event
+ * @returns {boolean} true if event.start_date has a legitimate hour included, otherwise false
+ */
export function checkStartTime(event) {
if (event.start_date) {
return moment(event.start_date).hour() ? true : false;
@@ -75,8 +250,12 @@ export function checkStartTime(event) {
}
}
+/**
+ * Matches the category parameter from calendar date API
+ * @param {string} category
+ * @returns {string} String for the tooltip content for category
+ */
export function mapCategoryDescription(category) {
- // matches the category parameter from calendar date API
const tooltipContent = {
'Reporting Deadlines':
'Throughout the year, filers submit regularly scheduled reports about their campaign finance activity. These reporting requirements are outlined in Title 11 of the Code of Federal Regulations (CFR) and vary, depending on the type of filer.',
diff --git a/fec/fec/static/js/modules/calendar-list-view.js b/fec/fec/static/js/modules/calendar-list-view.js
index 56b33630ef..17dc2da6e3 100644
--- a/fec/fec/static/js/modules/calendar-list-view.js
+++ b/fec/fec/static/js/modules/calendar-list-view.js
@@ -1,26 +1,31 @@
+/* eslint-disable */ // TODO: remove eslint-disable
/**
- *
+ * @fileoverview Plugin for fullcalendar.io being used by ./calendar.js
+ * @license CC0-1.0
+ * @owner fec.gov
+ * @version 2.0
*/
-import $ from 'jquery';
-import { default as moment } from 'moment';
-import { default as _chain } from 'underscore/modules/chain.js';
-import { default as _each } from 'underscore/modules/each.js';
-import { default as _pairs } from 'underscore/modules/pairs.js';
-import { default as _reduce } from 'underscore/modules/reduce.js';
-import 'fullcalendar';
-import Dropdown from './dropdowns.js';
-import { default as eventTemplate } from '../templates/calendar/events.hbs';
+import { createPlugin, sliceEvents } from '@fullcalendar/core';
+
+import { dateTimeFormatOptions, getDownloadUrl, getGoogleUrl, getMs365Url } from '../modules/calendar-helpers.js';
-const FC = $.fullCalendar;
-const View = FC.View;
+import Dropdown from './dropdowns.js';
+import { default as template_events } from '../templates/calendar/events.hbs';
+import { default as template_listToggles } from '../templates/calendar/listToggles.hbs';
// 'Sort by: Category' view
// Property name is the category
// Then followed by a list of the types of events under that category
// List items are the first token of the event category parameter from the API
// example: 'ie' for 'IE Periods'
-const categories = {
+
+/** */
+const customClassName = 'fec-list-view';
+const LIST_SORT = ['monthTime', 'monthCategory'];
+
+/** @enum {string[]} */
+const categories_lookup = {
Elections: ['election'],
'Filing deadlines': ['reporting', 'pre'],
'Reporting and compliance periods': ['ie', 'ec', 'fea'],
@@ -30,74 +35,317 @@ const categories = {
Other: ['other']
};
-const categoriesInverse = _reduce(
- _pairs(categories),
- function(memo, pair) {
- const key = pair[0];
- const values = pair[1];
- _each(values, function(value) {
- memo[value] = key;
- });
- return memo;
- },
- {}
-);
+/**
+ * Similar to categories_lookup but flipped so every value there is a key here.
+ * e.g. {Meetings: ['open', 'executive']} in categories_lookup would be {open: 'Meetings', executive: 'Meetings']} here
+ * @enum {string[]}
+ */
+const categories_reverseLookup = Object.getOwnPropertyNames(categories_lookup).reduce((accumulator, key) => {
+ categories_lookup[key].forEach(val => {
+ accumulator[val] = key;
+ })
+ return accumulator;
+}, {});
+/**
+ * Group the events by category,
+ * otherwise events with no recognized category will be group at the top as Unknown.
+ * @param {Object[]} events - Array of Objects with event details
+ * @param {string} [start] - ISO-formatted date string
+ * @param {string} [end] - ISO-formatted date string
+ * @returns {Object[]} An array of objects groups of events like [{title: 'Filing deadlines', datetime_string: '', events: [{}]}, …]
+ */
const categoryGroups = function(events, start, end) {
- return _chain(events)
- .filter(function(event) {
- return start <= event.start && event.start < end;
- })
- .sortBy('start')
- .groupBy(function(event) {
- const category = event.category
- ? event.category.split(/[ -]+/)[0].toLowerCase()
- : null;
- return categoriesInverse[category];
- })
- .map(function(values, key) {
- return {
- title: key,
- events: values
- };
- })
- .sortBy(function(group) {
- return Object.keys(categories).indexOf(group.title);
- })
- .value();
+ const filteredEvents = [...events];//.filter(event => (event.range.start && event.range.start < end));
+
+ filteredEvents.forEach(event => {
+ // When does the event start?
+ const eventStartDate = new Date(event.range.start);
+ // Adjust for the time offset // TODO: this may need to be done elsewhere, too
+ eventStartDate.setMinutes(eventStartDate.getMinutes() + eventStartDate.getTimezoneOffset());
+ event.groupByLabel = categories_reverseLookup[event.def.extendedProps.category];
+ event.datetime = eventStartDate.toISOString().substring(0,10); // used inside
+ });
+
+ // Sort them by date
+ filteredEvents.sort((a, b) => {
+ if (a.range.start == b.range.start) return 0;
+ else return a.range.start < b.range.start ? -1 : 1;
+ });
+
+ const toReturn = [];
+ let nextGroup = {title: '', datetime_string: '', events: []};
+ // For each pretty category name
+ Object.getOwnPropertyNames(categories_lookup).forEach(key => {
+ // The events with this pretty category name
+ const eventsInThisCat = filteredEvents.filter(event => {
+ const lowerFirstWordOfCategory = event.def.extendedProps.category.split(/[ -]+/)[0].toLowerCase();
+ return key == categories_reverseLookup[lowerFirstWordOfCategory];
+ });
+
+ // To account for any events that aren't in designated categories,
+ // let's remove each of these chosen events from filteredEvents
+ eventsInThisCat.forEach(event => {
+ filteredEvents.splice(filteredEvents.indexOf(event), 1);
+ });
+
+ // If there are events, create a new group
+ if (eventsInThisCat.length > 0) {
+ nextGroup = {title: key, datetime_string: '', events: [...eventsInThisCat]};
+ toReturn.push(nextGroup);
+ }
+ });
+
+ // If there are filteredEvents left, they didn't get a category, so…
+ if (filteredEvents.length > 0) {
+ nextGroup = {title: 'Unknown category', datetime_string: '', events: [...filteredEvents]};
+ // …let's put them at the top of the list
+ toReturn.unshift(nextGroup);
+ }
+
+ return toReturn;
};
+/**
+ * Group the events by the formatted value of their start dates,
+ * @param {Object[]} events - Array of Objects with event details
+ * @param {string} [start] - ISO-formatted date string
+ * @param {string} [end] - ISO-formatted date string
+ * @returns {Object[]} An array of objects groups of events like [{title: 'January 1, 1970', datetime_string: '1970-01-01', events: [{}]}, …]
+ */
const chronologicalGroups = function(events, start, end) {
- events = _chain(events)
- .filter(function(event) {
- return start <= event.start && event.start < end;
- })
- .map(function(value) {
- // Group the events by the formatted value of their start dates,
- // otherwise events with a time on their date will be grouped separately
- // from those that just have a date
- value.groupByValue = value.start.format('MMMM D, YYYY');
- return value;
- })
- .sortBy('start')
- .groupBy('groupByValue')
- .map(function(values, key) {
- return {
- title: moment.utc(new Date(key)).format('MMMM D, YYYY'),
- events: values
- };
- })
- .value();
- return events;
+ const filteredEvents = [...events];//events.filter(event => (event.range.start && event.range.start < end));
+
+ // Go through every event and add its eventStartTime, the string like January 1, 1970
+ filteredEvents.forEach(event => {
+ console.log('chronologicalGroups.forEach()');
+ console.log(' event: ', event);
+ const thisEventObj = calendar.getEventById(event.def.publicId);
+ console.log(' thisEventObj: ', thisEventObj);
+ console.log(' .id: ', thisEventObj.id);
+ console.log(' .start: ', thisEventObj.start);
+ console.log(' .end: ', thisEventObj.end);
+ console.log(' .startStr: ', thisEventObj.startStr);
+ console.log(' .endStr: ', thisEventObj.endStr);
+ console.log(' .extendedProps: ', thisEventObj.extendedProps);
+
+ // When does the event start?
+ const eventStartDate = new Date(event.range.start);
+ console.log(' eventStartDate: ', eventStartDate);
+ console.log(' typeof, start, eventStartDate: ', typeof thisEventObj.start, typeof eventStartDate);
+ console.log(' typeof, start, eventStartDate: ', Date.parse(thisEventObj.start), Date.parse(eventStartDate));
+ // Adjust for the time offset // TODO: this may need to be done elsewhere, too
+ eventStartDate.setMinutes(eventStartDate.getMinutes() + eventStartDate.getTimezoneOffset());
+ event.groupByLabel = eventStartDate.toLocaleDateString('en-US', dateTimeFormatOptions.DATE_FULL);
+ event.datetime = eventStartDate.toISOString().substring(0,10); // used inside
+ });
+
+ filteredEvents.sort((a, b) => {
+ if (a.range.start == b.range.start) return 0;
+ else return a.range.start < b.range.start ? -1 : 1;
+ });
+
+ const toReturn = [];
+ let prevGroupTitle = '';
+ let nextGroup = {title: '', datetime_string: '', events: []};
+ filteredEvents.forEach(event => {
+ // If the label has changed, we'll need to start a new group
+ if (event.groupByLabel != prevGroupTitle) {
+ // First, close out the previous group if it's not empty
+ if (nextGroup.events.length > 0) toReturn.push(nextGroup);
+ // Save this label
+ prevGroupTitle = event.groupByLabel;
+ // Start a new group
+ nextGroup = {title: event.groupByLabel, datetime_string: event.datetime, events: []};
+ }
+ nextGroup.events.push(event);
+ });
+ toReturn.push(nextGroup);
+
+ return toReturn;
+};
+
+/**
+ *
+ * @param {*} view
+ */
+const manageListToggles = function(listEventOrder) {
+ const listTogglesSelector = '.cal-list__toggles';
+
+ const headerToolbarCenter = document.querySelector('.fc-header-toolbar .fc-toolbar-center');
+
+ let listToggles = document.querySelector('.cal-list__toggles');
+
+ // If there's no listToggles, create it
+ if (!listToggles) {
+ listToggles = document.createElement('div');
+ listToggles.classList.add(`${listTogglesSelector.substring(1)}`); // drop the period from the selector
+ headerToolbarCenter.appendChild(listToggles);
+ }
+
+ listToggles.innerHTML = template_listToggles({listEventOrder: listEventOrder});
+
+ // Activate the list buttons
+ listToggles.querySelectorAll('.js-toggle-list-sort').forEach(button => {
+ button.addEventListener('click', e => {
+ // If we aren't already sorting like this
+ if (!e.target.classList.contains('is-active')) {
+ calendar.view.custom.listEventOrder = e.target.dataset.triggerSort;
+ calendar.render();
+ }
+ });
+ });
+
+ if (!listEventOrder) listToggles.setAttribute('aria-hidden', true);
+ else listToggles.removeAttribute('aria-hidden');
+
+ // Highlight the "List" button on monthTime
+ // if (view.name === 'monthCategory') {
+ // this.$calendar.find('.fc-monthTime-button').addClass('fc-state-active');
+ // }
};
-const ListView = View.extend({
- setDate: function(date) {
- const intervalUnit = this.options.duration.intervalUnit || this.intervalUnit;
- View.prototype.setDate.call(this, date.startOf(intervalUnit));
+/**
+ * Called from inside `content` after an animation frame.
+ * `didMount` fires first, which doesn't do any good here.
+ * `content` fires next but only returns the html for every calendar entry and the view and
+ * doesn't actually create the elements.
+ * TODO: as fullcalendar adds it for custom views, I'd like to move this to something like eventDidMount
+ */
+const initDropdowns = function() {
+ const dropdownsToInit = document.querySelectorAll(`.${customClassName} .dropdown`);
+ dropdownsToInit.forEach(el => {
+ new Dropdown($(el), { checkboxes: false });
+ });
+};
+
+const fecListViewConfig = {
+ classNames: [customClassName],
+
+ /**
+ * Called when this view is created
+ * @param {Object} d
+ * @param {Object} d.dateProfile
+ * @param {HTMLElement} d.el
+ * @param {Object} d.eventStore
+ * @param {Object} d.eventUiBases
+ * @param {Function} [callback]
+ */
+ didMount: function(props, callback) {
+ calendar.el.dataset.fecView = 'list';
},
+ willUnmount: function(e, e1) {
+ manageListToggles(false);
+ calendar.el.dataset.fecView = 'grid';
+ },
+
+ eventDidMount: function(e) {
+ console.log('CustomViewConfig.eventDidMount(e): ', e);
+ },
+
+ /**
+ * @param {Object} d
+ * @param {(Function)} callback
+ * @returns {Object} {html: ''}
+ */
+ content: function(props) {
+ console.log('CustomViewConfig.content(props): ', props);
+
+ const segs = sliceEvents(props, false);
+ console.log(' segs: ', segs);
+
+ if (!calendar.view.custom) calendar.view.custom = {};
+ if (!calendar.view.custom.settings) calendar.view.custom.settings = {};
+ if (!calendar.view.custom.listEventOrder) calendar.view.custom.listEventOrder = 'monthTime';
+
+ if (LIST_SORT.includes(calendar.view.custom.listEventOrder))
+ manageListToggles(calendar.view.custom.listEventOrder);
+
+ const groups = calendar.view.custom.listEventOrder == 'monthCategory'
+ ? categoryGroups(
+ segs,
+ props.dateProfile.currentRange.start,
+ props.dateProfile.currentRange.end)
+ : chronologicalGroups(
+ segs,
+ props.dateProfile.currentRange.start,
+ props.dateProfile.currentRange.end);
+
+ // Let's convert these events to something the template can use
+ const groupsForTemplate = [];
+ groups.forEach(group => {
+ const newGroup = {title: group.title, datetime_string: group.datetime_string, events: []};
+ //
+ group.events.forEach(event => {
+ // Start with a new event, based on the incoming extendedProps
+ console.log(' customview.forEach(event): ', event);
+ const newEvent = Object.assign({},event.def.extendedProps);
+ // Get the start and end Date objects (GMT)
+ let start_date = new Date(event.range.start);
+ let end_date = event.def.hasEnd ? new Date(event.range.end) : false;
+ newEvent.start_google = 'YAY-start';
+ newEvent.end_google = 'YAY-end';
+
+ start_date.setMinutes(start_date.getMinutes() + start_date.getTimezoneOffset());
+ newEvent.start_iso = start_date.toISOString();
+ newEvent.start_day = start_date.toLocaleDateString('en-US', dateTimeFormatOptions.WEEKDAY_FULL);
+ newEvent.start_date_string = start_date.toLocaleDateString('en-US', dateTimeFormatOptions.DATE_FULL);
+ if (newEvent.hasStartTime)
+ newEvent.start_time_string = start_date.toLocaleTimeString('en-US', dateTimeFormatOptions.TIME_SIMPLE)
+ .toLowerCase().replace(' ', '');
+
+ if (end_date) {
+ end_date.setMinutes(end_date.getMinutes() + end_date.getTimezoneOffset());
+ newEvent.end_iso = end_date.toISOString();
+ newEvent.end_day = end_date.toLocaleDateString('en-US', dateTimeFormatOptions.WEEKDAY_FULL);
+ newEvent.end_date_string = end_date.toLocaleDateString('en-US', dateTimeFormatOptions.DATE_FULL);
+ if (newEvent.hasStartTime)
+ newEvent.end_time_string = end_date.toLocaleTimeString('en-US', dateTimeFormatOptions.TIME_SIMPLE)
+ .toLowerCase().replace(' ', '');
+ }
+ newEvent.download = getDownloadUrl(newEvent);
+ newEvent.google = getGoogleUrl({
+ start: new Date(event.range.start).toISOString(),
+ end: newEvent.hasStartTime && event.range.end ? new Date(event.range.end).toISOString() : false,
+ title: newEvent.title,
+ summary: newEvent.description || newEvent.title
+ });
+ newEvent.ms365 = getMs365Url({
+ start: new Date(event.range.start).toISOString(),
+ end: newEvent.hasStartTime && event.range.end ? new Date(event.range.end).toISOString() : false,
+ title: newEvent.title,
+ summary: newEvent.description || newEvent.title
+ });
+
+ newGroup.events.push(newEvent);
+ });
+ groupsForTemplate.push(newGroup);
+ });
+
+ window.requestAnimationFrame(initDropdowns.bind(this));
+
+ return { html: template_events({
+ groups: groupsForTemplate, settings: {listEventOrder: calendar.view.custom.listEventOrder}
+ }) };
+
+ // TRYING TO ADD MOUSE EVENT LISTENERS TO DROPDOWNS
+ },
+ // dayHeaderContent: function(d, two) {}, // Doesn't do anything here
+ // render: function(d) {} // Not valid here
+
+
+ // Deprecated
+ // setDate: function(date) {
+ // console.log('CustomViewConfig.setDate(props): ', props);
+ // const intervalUnit = this.options.duration.intervalUnit || this.intervalUnit;
+ // ViewRoot.prototype.setDate.call(this, date.startOf(intervalUnit));
+ // },
+
+ // Deprecated
+ /*
renderEvents: function(events) {
+ console.log('CustomViewConfig.renderEvents(events): ', events);
const groups = this.options.categories
? categoryGroups(events, this.start, this.end)
: chronologicalGroups(events, this.start, this.end);
@@ -106,20 +354,33 @@ const ListView = View.extend({
sortBy: this.options.sortBy
};
- this.el.html(eventTemplate({ groups: groups, settings: settings }));
+ this.el.html(template_events({ groups: groups, settings: settings }));
this.dropdowns = $(this.el.html)
.find('.dropdown')
.map(function(idx, elm) {
return new Dropdown($(elm), { checkboxes: false });
});
- },
+ },*/
+ // Deprecated
+ /*
unrenderEvents: function() {
+ console.log('CustomViewConfig.unrenderEvents(props): ', props);
this.dropdowns.each(function(idx, dropdown) {
dropdown.destroy();
});
this.el.html('');
+ }*/
+};
+
+
+
+
+
+
+
+export default createPlugin({
+ views: {
+ fecList: fecListViewConfig
}
});
-
-FC.views.list = ListView;
diff --git a/fec/fec/static/js/modules/calendar-tooltip.js b/fec/fec/static/js/modules/calendar-tooltip.js
index 3b087e968a..634cacb63c 100644
--- a/fec/fec/static/js/modules/calendar-tooltip.js
+++ b/fec/fec/static/js/modules/calendar-tooltip.js
@@ -2,38 +2,94 @@
*
*/
import Dropdown from './dropdowns.js';
-import Listeners from './listeners.js';
-
-export function CalendarTooltip(content, $container) {
- this.$content = $(content);
- this.$container = $container;
- this.$close = this.$content.find('.js-close');
- this.$dropdown = this.$content.find('.dropdown');
- this.exportDropdown = new Dropdown(this.$dropdown, {
- checkboxes: false
- });
-
- this.events = new Listeners();
- this.events.on(this.$close, 'click', this.close.bind(this));
- this.events.on($(document.body), 'click', this.handleClickAway.bind(this));
-
- this.$container.addClass('is-active');
-}
+// import Listeners from './listeners.js';
+
+/**
+ * Activates a new .tooltip element. Doesn't create it, but
+ * - activates listeners
+ * - activates the Dropdown
+ * - destroys the element on click-outside
+ * - handles adding listeners to and then destroying calendar events' tooltips/details pop-up
+ * (doesn't create the element but will remove it)
+ */
+export class CalendarTooltip {
+ /**
+ * @constructor
+ * @param {HTMLElement} el - The HTMLElement that is the tooltip, i.e. the .tooltip element
+ */
+ constructor(el) {
+ CalendarTooltip.instances.push(this);
+
+ el.previousElementSibling.classList.add('visible-tooltip');
-CalendarTooltip.prototype.handleClickAway = function(e) {
- const $target = $(e.target);
- if (
- !this.$content.has($target).length &&
- !this.$container.has($target).length
- ) {
- this.close();
+ this.dropdown = new Dropdown(el.querySelector('.dropdown'), { checkboxes: false });
+
+ // Catch the click on its way in, which happens before the tooltip is created,
+ // otherwise we get into
+ // # 1) click capture phase
+ // # 2) create tooltip and add listeners
+ // # 3) click bubbling is caught by tooltip listeners
+ // # 4) tooltip is removed
+ window.addEventListener('click', CalendarTooltip.handleExternalClicks, { capture: true });
+
+ el.querySelector('.js-close').addEventListener('click', CalendarTooltip.handleCloseClick);
}
-};
-
-CalendarTooltip.prototype.close = function() {
- this.$content.remove();
- this.exportDropdown.destroy();
- this.$container.removeClass('is-active');
- this.$container.focus(); // TODO: jQuery deprecation
- this.events.clear();
-};
+
+ // PUBLIC METHODS
+
+ // GETTERS
+
+ // SETTERS
+
+ // METHODS
+
+ // PUBLIC PROPERTIES
+
+ // STATIC
+ static instances = [];
+
+ /**
+ * @param {PointerEvent} e
+ */
+ static handleCloseClick = function(e) {
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ CalendarTooltip.destroyAll();
+ };
+
+ /**
+ * @param {PointerEvent} e
+ */
+ static handleExternalClicks = function(e) {
+ // If the target isn't inside a .tooltip, close this tooltip and destroy everything
+ if (!e.target.closest('.tooltip')) {
+ CalendarTooltip.destroyAll();
+ }
+ };
+
+ static destroyAll = function() {
+ CalendarTooltip.instances.forEach(instance => {
+ instance.#selfDestruct();
+ });
+ CalendarTooltip.instances = [];
+ let calendarEntriesWithTooltips = document.querySelectorAll('.visible-tooltip');
+ calendarEntriesWithTooltips.forEach(el => {
+ el.classList.remove('visible-tooltip');
+ });
+ calendarEntriesWithTooltips = [];
+ };
+
+ #selfDestruct = function() {
+ window.removeEventListener('click', CalendarTooltip.handleExternalClicks, true);
+ document.querySelector('.tooltip .js-close').removeEventListener('click', CalendarTooltip.handleCloseClick);
+
+ let el = document.querySelector('#calendar-details');
+ el.removeEventListener('mouseleave', CalendarTooltip.handleMouseLeave);
+
+ this.dropdown.destroy();
+ this.dropdown = null;
+ el.remove();
+ el = null;
+ delete this;
+ };
+}
diff --git a/fec/fec/static/js/modules/calendar.js b/fec/fec/static/js/modules/calendar.js
index bdda2ea1cb..f49bd2900c 100644
--- a/fec/fec/static/js/modules/calendar.js
+++ b/fec/fec/static/js/modules/calendar.js
@@ -1,216 +1,358 @@
+/* eslint-disable */ // TODO: remove eslint-disable
/**
- *
+ * @fileoverview Using fullcalendar.io, builds and runs the various views for the fec.gov calendar pages
+ * @license CC0-1.0
+ * @owner fec.gov
+ * @version 2.0
*/
-import { default as Handlebars } from 'hbsfy/runtime.js';
+
+import bootstrap5Plugin from '@fullcalendar/bootstrap5'; // Being included because fullcalendar requires a themesystem and the default is too bossy
+import { Calendar as FullCalendar } from '@fullcalendar/core';
+import dayGridPlugin from '@fullcalendar/daygrid';
+// import { default as Handlebars } from 'hbsfy/runtime.js';
import $ from 'jquery';
-import moment from 'moment';
-import { default as _extend } from 'underscore/modules/extend.js';
import { default as _isEqual } from 'underscore/modules/isEqual.js';
import { default as URI } from 'urijs';
-
-import 'fullcalendar';
-
-import { checkStartTime, className, getGoogleUrl, mapCategoryDescription } from './calendar-helpers.js';
+import { checkStartTime, dateTimeFormatOptions, getGoogleUrl, getDownloadUrl, getMs365Url, mapCategoryDescription } from './calendar-helpers.js';
+import { default as FecListViewPlugin } from './calendar-list-view.js';
import { CalendarTooltip } from './calendar-tooltip.js';
import Dropdown from './dropdowns.js';
-import { LOADING_DELAY, SUCCESS_DELAY, datetime, eq, isLargeScreen, toUpperCase } from './helpers.js';
+import { LOADING_DELAY, SUCCESS_DELAY, isLargeScreen } from './helpers.js';
import { pushQuery, updateQuery } from './urls.js';
-import './calendar-list-view.js';
import { default as template_details } from '../templates/calendar/details.hbs';
import { default as template_download } from '../templates/calendar/download.hbs';
-import { default as template_listToggles } from '../templates/calendar/listToggles.hbs';
import { default as template_subscribe } from '../templates/calendar/subscribe.hbs';
-// TODO: do we need to registerHelper?
-Handlebars.registerHelper('eq', eq);
-Handlebars.registerHelper('datetime', datetime);
-Handlebars.registerHelper('toUpperCase', toUpperCase);
-
+/** @enum {html} */
const templates = {
details: template_details,
download: template_download,
subscribe: template_subscribe,
- listToggles: template_listToggles
};
-const LIST_VIEWS = ['monthTime', 'monthCategory'];
-
-const FC = $.fullCalendar;
-const Grid = FC.Grid;
+// Moved to the plugin
+// const FEC_LIST_SORT = ['monthTime', 'monthCategory'];
// Globally override event sorting to order all-day events last
// TODO: Convince fullcalendar.io support this behavior without monkey-patching
-Grid.prototype.compareEventSegs = function(seg1, seg2) {
- return (
- seg1.event.allDay - seg2.event.allDay || // put all-day events last (booleans cast to 0/1)
- seg1.eventStartMS - seg2.eventStartMS || // tie? earlier events go first
- seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first
- FC.compareByFieldSpecs(seg1.event, seg2.event, this.view.eventOrderSpecs)
- );
-};
+// Grid.prototype.compareEventSegs = function(seg1, seg2) {
+// return (
+// seg1.event.allDay - seg2.event.allDay || // put all-day events last (booleans cast to 0/1)
+// seg1.eventStartMS - seg2.eventStartMS || // tie? earlier events go first
+// seg2.eventDurationMS - seg1.eventDurationMS || // tie? longer events go first
+// FC.compareByFieldSpecs(seg1.event, seg2.event, this.view.eventOrderSpecs)
+// );
+// };
export default function Calendar(opts) {
- this.opts = $.extend({}, this.defaultOpts(), opts);
-
- this.$calendar = $(this.opts.selector);
- this.$head = $('.data-container__head');
- this.$calendar.fullCalendar(this.opts.calendarOpts);
- this.url = URI(this.opts.url);
- this.subscribeUrl = URI(this.opts.subscribeUrl);
- this.exportUrl = URI(this.opts.exportUrl);
+ console.log('new Calendar(opts): ', opts);
+ this.opts = Object.assign({}, this.defaultOpts(), opts);
+
+ this.calendarEl = document.querySelector(this.opts.selector);
+ this.headEl = document.querySelector('.data-container__head');
+ this.calendar = new FullCalendar(this.calendarEl, this.opts.calendarOpts);
+ window.calendar = this.calendar;
+ this.url_api = URI(this.opts.url_api);
+ this.url_subscribe = URI(this.opts.url_subscribe);
+ console.log('this.url_subscribe: ', this.url_subscribe);
+ this.url_export = URI(this.opts.url_export);
this.filterPanel = this.opts.filterPanel;
this.filterSet = this.filterPanel.filterSet;
+ this.tagList = this.opts.tagList;
this.popoverId = 'calendar-popover';
this.detailsId = 'calendar-details';
- this.sources = null;
+ this.eventSources = null;
this.params = null;
- this.$download = $(opts.download);
- this.$subscribe = $(opts.subscribe);
+ // this.$download = $(opts.selector_download);
+ this.downloadEl = document.querySelector(opts.selector_download);
+ // this.$subscribe = $(opts.selector_subscribe);
+ this.subscribeEl = document.querySelector(opts.selector_subscribe);
+
+ // this.$calendar.on('click', '.js-toggle-view', this.toggleListView.bind(this));
+ const toggleView = this.calendarEl.querySelector('.js-toggle-view');
+ // toggleView.addEventListener('click', this.toggleListView.bind(this));
+
+ // this.$calendar.on(
+ // 'keypress',
+ // '.fc-event, .fc-more, .fc-close',
+ // this.simulateClick.bind(this)
+ // );
+ const keypressElements = document.querySelectorAll('.fc-event, .fc-more, .fc-close');
+ keypressElements.forEach(el => {
+ el.addEventListener('keypress', this.simulateClick.bind(this));
+ });
- this.$calendar.on('click', '.js-toggle-view', this.toggleListView.bind(this));
+ // this.$calendar.on('click', '.fc-more', this.managePopoverControl.bind(this));
+ // this.calendarEl.querySelector('.fc-more').addEventListener('click', this.managePopoverControl.bind(this));
- this.$calendar.on(
- 'keypress',
- '.fc-event, .fc-more, .fc-close',
- this.simulateClick.bind(this)
- );
- this.$calendar.on('click', '.fc-more', this.managePopoverControl.bind(this));
+ this.buildTagsForInitialFilters();
+
+ // if (queryParams.search)
+ //
- this.filterPanel.$form.on('change', this.filter.bind(this));
- $(window).on('popstate', this.filter.bind(this));
+ this.filterPanel.$form.on('change', this.handleFilterChange.bind(this));
+ // this.filterPanel.$form.addEventListener('change', this.handleFilterChange.bind(this));
+ $(window).on('popstate', this.handleFilterChange.bind(this));
+ // window.addEventListener('popstate', this.handleFilterChange.bind(this));
updateQuery(this.filterSet.serialize(), this.filterSet.fields);
- this.filter();
- this.styleButtons();
+ this.handleFilterChange();
if (!isLargeScreen()) {
- this.$head.after($('#filters'));
+ // this.$head.after($('#filters'));
+ this.headEl.insertAdjacentElement('afterend', document.querySelector('#filters'));
}
+
+ // Make it exist
+ // this.calendar.render();
+
+ this.styleButtons();
}
Calendar.prototype.toggleListView = function(e) {
- const newView = $(e.target).data('trigger-view');
- this.$calendar.fullCalendar('changeView', newView);
+ // console.log('Calendar.toggleListView(e): ', e);
+ // const newView = $(e.target).data('trigger-view');
+ const newView = e.target.dataset.triggerView;
+ // this.$calendar.fullCalendar('changeView', newView);
+ this.calendar.changeView(newView);
};
+/**
+ * TODO: TEST HOW DATES DISPLAY FOR COMPUTERS IN OTHER TIME ZONES
+ */
Calendar.prototype.defaultOpts = function() {
return {
calendarOpts: {
- header: {
+ timeZone: 'America/New_York', // or 'UTC'? 'GMT'?
+ headerToolbar: {
left: 'prev,next,today',
- center: '',
- right: 'monthTime,month'
+ center: '', // this is where we'll put the list toggles
+ right: 'fecList,dayGridMonth', // fecList is custom, dayGridMonth comes from @fullcalendar/daygrid
},
- buttonIcons: false,
- buttonText: {
+ buttonIcons: false, // The icons fc should put on buttons
+ buttonText: { // The text labels for buttons
today: 'This Month',
- week: 'Week'
+ // next: '',
+ // prev: ''
+ // week: 'Week YAY'
},
- contentHeight: 'auto',
- dayRender: this.handleDayRender.bind(this),
- dayPopoverFormat: 'MMMM D, YYYY',
- defaultView: 'monthTime',
- eventRender: this.handleEventRender.bind(this),
- eventAfterAllRender: this.handleRender.bind(this),
- eventClick: this.handleEventClick.bind(this),
- eventLimit: true,
+ // contentHeight: 'auto',
+ // dayRender: this.handleDayRender.bind(this), // Not valid here
+ // dayPopoverFormat: 'MMMM D, YYYY',
+ initialView: 'fecList',//'listMonth'
+ themeSystem: 'bootstrap5', // of the three choices, bootstrap5 is the lowest impact
+ // eventRender: this.handleEventRender.bind(this), // Not valid here
+ // eventAfterAllRender: this.handleRender.bind(this), // Not valid here
+ // eventClick: this.handleEventClick.bind(this), // TODO: What does this do?
+ // success: this.success.bind(this), // Not valid here
+ // failure: this.failure.bind(this), // Not valid here
+ datesSet: this.handleCalendarDatesSet.bind(this), // TODO: Is this doing anything here?
+ // eventLimit: true, // Not valid here
nowIndicator: true,
+ plugins: [dayGridPlugin, /*timeGridPlugin, listPlugin,*/ bootstrap5Plugin, FecListViewPlugin],
views: {
- agenda: {
- scrollTime: '09:00:00',
- minTime: '08:00:00',
- maxTime: '20:00:00'
- },
+ // dayGridMonth: {},
+ // timeGridWeek: {},
+ // dayGrid: {},
+ // timeGrid: {},
+ // week: {},
+ // day: {}
+ // agenda: {
+ // scrollTime: '09:00:00',
+ // minTime: '08:00:00',
+ // maxTime: '20:00:00'
+ // },
month: {
- eventLimit: 3,
- buttonText: 'Grid'
- },
- monthCategory: {
- type: 'list',
- categories: true,
- sortBy: 'category',
- duration: { months: 1, intervalUnit: 'month' }
+ type: 'grid',
+ dayMaxEvents: 3,
+ buttonText: 'Grid',
+ editable: false,
+ eventClassNames: function(info) { return ['FEC-EVENT']},
+ duration: { months: 1, intervalUnit: 'month' },
+ // render: function(arg) {}, // Not valid here
+ // onRender: function(arg) {}, // Not valid here
+ // noEventsDidMount: function(info) {}, // Not valid here
+ // eventContent: function(info) {
+ // If this exists, it's called instead of Calendar.eventContent()
+ // If this doesn't return anything, events will be put onto the page but will have aria-hidden="true"
+ // return {html: ''};
+ // },
+ // dayHeaderContent: function(d) {
+ // The row of "Sun", "Mon", etc
+ // console.log('views.month.dayHeaderContent(d): ', d);
+ // If this exists, it's called instead of Calendar.dayHeaderContent()
+ // If this doesn't return anything, day headers will be put onto the page but will have aria-hidden="true"
+ // return { html: `
{{summary}}
+{{description}}
+ {{#if location}} + Location: {{location}} + {{/if}} + {{#if url_details}} + Learn more + {{/if}} +{{summary}}
@@ -25,8 +26,8 @@ {{#if location}} Location: {{location}} {{/if}} - {{#if detailUrl}} - Learn more + {{#if url_details}} + Learn more {{/if}}