weekly release 4.1dev
[moodle.git] / calendar / amd / src / view_manager.js
CommitLineData
695c5726
AN
1// This file is part of Moodle - http://moodle.org/
2//
3// Moodle is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, either version 3 of the License, or
6// (at your option) any later version.
7//
8// Moodle is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU General Public License for more details.
12//
13// You should have received a copy of the GNU General Public License
14// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
15
16/**
17 * A javascript module to handler calendar view changes.
18 *
19 * @module core_calendar/view_manager
695c5726
AN
20 * @copyright 2017 Andrew Nicols <andrew@nicols.co.uk>
21 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
22 */
695c5726 23
0ab57f81
SR
24import $ from 'jquery';
25import Templates from 'core/templates';
26import Notification from 'core/notification';
27import * as CalendarRepository from 'core_calendar/repository';
28import CalendarEvents from 'core_calendar/events';
29import * as CalendarSelectors from 'core_calendar/selectors';
30import ModalFactory from 'core/modal_factory';
31import ModalEvents from 'core/modal_events';
32import SummaryModal from 'core_calendar/summary_modal';
33import CustomEvents from 'core/custom_interaction_events';
cffefca1 34import {get_string as getString} from 'core/str';
88f1a81b 35import Pending from 'core/pending';
cffefca1
DC
36import {prefetchStrings} from 'core/prefetch';
37
38/**
39 * Limit number of events per day
40 *
41 */
42const LIMIT_DAY_EVENTS = 5;
43
44/**
45 * Hide day events if more than 5.
46 *
47 */
48export const foldDayEvents = () => {
49 const root = $(CalendarSelectors.elements.monthDetailed);
50 const days = root.find(CalendarSelectors.day);
51 if (days.length === 0) {
52 return;
53 }
54 days.each(function() {
55 const dayContainer = $(this);
56 const eventsSelector = `${CalendarSelectors.elements.dateContent} ul li[data-event-eventtype]`;
57 const filteredEventsSelector = `${CalendarSelectors.elements.dateContent} ul li[data-event-filtered="true"]`;
58 const moreEventsSelector = `${CalendarSelectors.elements.dateContent} [data-action="view-more-events"]`;
59 const events = dayContainer.find(eventsSelector);
60 if (events.length === 0) {
61 return;
62 }
63
64 const filteredEvents = dayContainer.find(filteredEventsSelector);
65 const numberOfFiltered = filteredEvents.length;
66 const numberOfEvents = events.length - numberOfFiltered;
67
68 let count = 1;
69 events.each(function() {
70 const event = $(this);
71 const isNotFiltered = event.attr('data-event-filtered') !== 'true';
72 const offset = (numberOfEvents === LIMIT_DAY_EVENTS) ? 0 : 1;
73 if (isNotFiltered) {
74 if (count > LIMIT_DAY_EVENTS - offset) {
75 event.attr('data-event-folded', 'true');
76 event.hide();
77 } else {
78 event.attr('data-event-folded', 'false');
79 event.show();
80 count++;
81 }
82 } else {
83 // It's being filtered out.
84 event.attr('data-event-folded', 'false');
85 }
86 });
87
88 const moreEventsLink = dayContainer.find(moreEventsSelector);
89 if (numberOfEvents > LIMIT_DAY_EVENTS) {
90 const numberOfHiddenEvents = numberOfEvents - LIMIT_DAY_EVENTS + 1;
91 moreEventsLink.show();
92 getString('moreevents', 'calendar', numberOfHiddenEvents).then(str => {
93 const link = moreEventsLink.find('strong a');
94 moreEventsLink.attr('data-event-folded', 'false');
95 link.text(str);
96 return str;
97 }).fail();
98 } else {
99 moreEventsLink.hide();
100 }
101 });
102};
103
104/**
105 * Register and handle month calendar events.
106 *
107 * @param {string} pendingId pending id.
108 */
109export const registerEventListenersForMonthDetailed = (pendingId) => {
110 const events = `${CalendarEvents.viewUpdated}`;
111 $('body').on(events, function(e) {
112 foldDayEvents(e);
113 });
114 foldDayEvents();
115 $('body').on(CalendarEvents.filterChanged, function(e, data) {
116 const root = $(CalendarSelectors.elements.monthDetailed);
117 const pending = new Pending(pendingId);
118 const target = root.find(CalendarSelectors.eventType[data.type]);
119 const transitionPromise = $.Deferred();
120 if (data.hidden) {
121 transitionPromise.then(function() {
122 target.attr('data-event-filtered', 'true');
123 return target.hide().promise();
124 }).fail();
125 } else {
126 transitionPromise.then(function() {
127 target.attr('data-event-filtered', 'false');
128 return target.show().promise();
129 }).fail();
130 }
131
132 transitionPromise.then(function() {
133 foldDayEvents();
134 return;
135 })
136 .always(pending.resolve)
137 .fail();
138
139 transitionPromise.resolve();
140 });
141};
516e7444 142
0ab57f81
SR
143/**
144 * Register event listeners for the module.
145 *
146 * @param {object} root The root element.
147 */
148const registerEventListeners = (root) => {
149 root = $(root);
150
151 // Bind click events to event links.
152 root.on('click', CalendarSelectors.links.eventLink, (e) => {
153 const target = e.target;
154 let eventLink = null;
155 let eventId = null;
88f1a81b 156 const pendingPromise = new Pending('core_calendar/view_manager:eventLink:click');
0ab57f81
SR
157
158 if (target.matches(CalendarSelectors.actions.viewEvent)) {
159 eventLink = target;
160 } else {
161 eventLink = target.closest(CalendarSelectors.actions.viewEvent);
162 }
163
164 if (eventLink) {
165 eventId = eventLink.dataset.eventId;
166 } else {
167 eventId = target.querySelector(CalendarSelectors.actions.viewEvent).dataset.eventId;
168 }
169
170 if (eventId) {
171 // A link was found. Show the modal.
172
173 e.preventDefault();
174 // We've handled the event so stop it from bubbling
175 // and causing the day click handler to fire.
176 e.stopPropagation();
177
88f1a81b
AN
178 renderEventSummaryModal(eventId)
179 .then(pendingPromise.resolve)
180 .catch();
181 } else {
182 pendingPromise.resolve();
0ab57f81
SR
183 }
184 });
185
186 root.on('click', CalendarSelectors.links.navLink, (e) => {
187 const wrapper = root.find(CalendarSelectors.wrapper);
188 const view = wrapper.data('view');
189 const courseId = wrapper.data('courseid');
190 const categoryId = wrapper.data('categoryid');
191 const link = e.currentTarget;
192
8a2c797c 193 if (view === 'month' || view === 'monthblock') {
0ab57f81
SR
194 changeMonth(root, link.href, link.dataset.year, link.dataset.month, courseId, categoryId, link.dataset.day);
195 e.preventDefault();
196 } else if (view === 'day') {
197 changeDay(root, link.href, link.dataset.year, link.dataset.month, link.dataset.day, courseId, categoryId);
198 e.preventDefault();
199 }
200 });
516e7444 201
0ab57f81
SR
202 const viewSelector = root.find(CalendarSelectors.viewSelector);
203 CustomEvents.define(viewSelector, [CustomEvents.events.activate]);
204 viewSelector.on(
205 CustomEvents.events.activate,
206 (e) => {
207 e.preventDefault();
208
209 const option = e.target;
210 if (option.classList.contains('active')) {
211 return;
afa8c3da 212 }
d0e56d84 213
0ab57f81
SR
214 const view = option.dataset.view,
215 year = option.dataset.year,
216 month = option.dataset.month,
217 day = option.dataset.day,
218 courseId = option.dataset.courseid,
219 categoryId = option.dataset.categoryid;
220
221 if (view == 'month') {
222 refreshMonthContent(root, year, month, courseId, categoryId, root, 'core_calendar/calendar_month', day)
223 .then(() => {
98612827 224 updateUrl('?view=month');
0ab57f81
SR
225 }).fail(Notification.exception);
226 } else if (view == 'day') {
227 refreshDayContent(root, year, month, day, courseId, categoryId, root, 'core_calendar/calendar_day')
228 .then(() => {
98612827 229 updateUrl('?view=day');
0ab57f81
SR
230 }).fail(Notification.exception);
231 } else if (view == 'upcoming') {
232 reloadCurrentUpcoming(root, courseId, categoryId, root, 'core_calendar/calendar_upcoming')
233 .then(() => {
98612827 234 updateUrl('?view=upcoming');
0ab57f81 235 }).fail(Notification.exception);
d0e56d84 236 }
0ab57f81
SR
237 }
238 );
239};
d0e56d84 240
0ab57f81
SR
241/**
242 * Refresh the month content.
243 *
244 * @param {object} root The root element.
245 * @param {number} year Year
246 * @param {number} month Month
247 * @param {number} courseId The id of the course whose events are shown
248 * @param {number} categoryId The id of the category whose events are shown
249 * @param {object} target The element being replaced. If not specified, the calendarwrapper is used.
250 * @param {string} template The template to be rendered.
251 * @param {number} day Day (optional)
252 * @return {promise}
253 */
254export const refreshMonthContent = (root, year, month, courseId, categoryId, target = null, template = '', day = 1) => {
255 startLoading(root);
256
257 target = target || root.find(CalendarSelectors.wrapper);
258 template = template || root.attr('data-template');
259 M.util.js_pending([root.get('id'), year, month, courseId].join('-'));
260 const includenavigation = root.data('includenavigation');
261 const mini = root.data('mini');
8a2c797c
JP
262 const viewMode = target.data('view');
263 return CalendarRepository.getCalendarMonthData(year, month, courseId, categoryId, includenavigation, mini, day, viewMode)
0ab57f81 264 .then(context => {
0ab57f81
SR
265 return Templates.render(template, context);
266 })
267 .then((html, js) => {
268 return Templates.replaceNode(target, html, js);
269 })
270 .then(() => {
3b3269ee 271 document.querySelector('body').dispatchEvent(new CustomEvent(CalendarEvents.viewUpdated));
0ab57f81
SR
272 return;
273 })
274 .always(() => {
275 M.util.js_complete([root.get('id'), year, month, courseId].join('-'));
276 return stopLoading(root);
277 })
278 .fail(Notification.exception);
279};
3ea4f446 280
0ab57f81
SR
281/**
282 * Handle changes to the current calendar view.
283 *
284 * @param {object} root The container element
285 * @param {string} url The calendar url to be shown
286 * @param {number} year Year
287 * @param {number} month Month
288 * @param {number} courseId The id of the course whose events are shown
289 * @param {number} categoryId The id of the category whose events are shown
290 * @param {number} day Day (optional)
291 * @return {promise}
292 */
293export const changeMonth = (root, url, year, month, courseId, categoryId, day = 1) => {
294 return refreshMonthContent(root, year, month, courseId, categoryId, null, '', day)
295 .then((...args) => {
296 if (url.length && url !== '#') {
98612827 297 updateUrl(url);
0ab57f81
SR
298 }
299 return args;
300 })
301 .then((...args) => {
302 $('body').trigger(CalendarEvents.monthChanged, [year, month, courseId, categoryId]);
303 return args;
304 });
305};
3ea4f446 306
0ab57f81
SR
307/**
308 * Reload the current month view data.
309 *
310 * @param {object} root The container element.
311 * @param {number} courseId The course id.
312 * @param {number} categoryId The id of the category whose events are shown
313 * @return {promise}
314 */
315export const reloadCurrentMonth = (root, courseId = 0, categoryId = 0) => {
316 const year = root.find(CalendarSelectors.wrapper).data('year');
317 const month = root.find(CalendarSelectors.wrapper).data('month');
318 const day = root.find(CalendarSelectors.wrapper).data('day');
319
320 courseId = courseId || root.find(CalendarSelectors.wrapper).data('courseid');
321 categoryId = categoryId || root.find(CalendarSelectors.wrapper).data('categoryid');
322
cffefca1
DC
323 return refreshMonthContent(root, year, month, courseId, categoryId, null, '', day).
324 then((...args) => {
325 $('body').trigger(CalendarEvents.courseChanged, [year, month, courseId, categoryId]);
326 return args;
327 });
0ab57f81 328};
3ea4f446 329
3ea4f446 330
0ab57f81
SR
331/**
332 * Refresh the day content.
333 *
334 * @param {object} root The root element.
335 * @param {number} year Year
336 * @param {number} month Month
337 * @param {number} day Day
338 * @param {number} courseId The id of the course whose events are shown
339 * @param {number} categoryId The id of the category whose events are shown
340 * @param {object} target The element being replaced. If not specified, the calendarwrapper is used.
341 * @param {string} template The template to be rendered.
342 *
343 * @return {promise}
344 */
345export const refreshDayContent = (root, year, month, day, courseId, categoryId, target = null, template = '') => {
346 startLoading(root);
347
15e311a1 348 if (!target || target.length == 0){
349 target = root.find(CalendarSelectors.wrapper);
350 }
0ab57f81
SR
351 template = template || root.attr('data-template');
352 M.util.js_pending([root.get('id'), year, month, day, courseId, categoryId].join('-'));
353 const includenavigation = root.data('includenavigation');
354 return CalendarRepository.getCalendarDayData(year, month, day, courseId, categoryId, includenavigation)
355 .then((context) => {
356 context.viewingday = true;
d2f612ab 357 context.showviewselector = true;
0ab57f81
SR
358 return Templates.render(template, context);
359 })
360 .then((html, js) => {
361 return Templates.replaceNode(target, html, js);
362 })
363 .then(() => {
3b3269ee 364 document.querySelector('body').dispatchEvent(new CustomEvent(CalendarEvents.viewUpdated));
0ab57f81
SR
365 return;
366 })
367 .always(() => {
368 M.util.js_complete([root.get('id'), year, month, day, courseId, categoryId].join('-'));
369 return stopLoading(root);
370 })
371 .fail(Notification.exception);
372};
373
374/**
375 * Reload the current day view data.
376 *
377 * @param {object} root The container element.
378 * @param {number} courseId The course id.
379 * @param {number} categoryId The id of the category whose events are shown
380 * @return {promise}
381 */
382export const reloadCurrentDay = (root, courseId = 0, categoryId = 0) => {
383 const wrapper = root.find(CalendarSelectors.wrapper);
384 const year = wrapper.data('year');
385 const month = wrapper.data('month');
386 const day = wrapper.data('day');
387
388 courseId = courseId || root.find(CalendarSelectors.wrapper).data('courseid');
389 categoryId = categoryId || root.find(CalendarSelectors.wrapper).data('categoryid');
390
391 return refreshDayContent(root, year, month, day, courseId, categoryId);
392};
393
394/**
395 * Handle changes to the current calendar view.
396 *
397 * @param {object} root The root element.
398 * @param {String} url The calendar url to be shown
399 * @param {Number} year Year
400 * @param {Number} month Month
401 * @param {Number} day Day
402 * @param {Number} courseId The id of the course whose events are shown
403 * @param {Number} categoryId The id of the category whose events are shown
404 * @return {promise}
405 */
406export const changeDay = (root, url, year, month, day, courseId, categoryId) => {
407 return refreshDayContent(root, year, month, day, courseId, categoryId)
408 .then((...args) => {
409 if (url.length && url !== '#') {
98612827 410 updateUrl(url);
3ea4f446 411 }
0ab57f81
SR
412 return args;
413 })
414 .then((...args) => {
415 $('body').trigger(CalendarEvents.dayChanged, [year, month, courseId, categoryId]);
416 return args;
417 });
418};
3ea4f446 419
98612827
SL
420/**
421 * Update calendar URL.
422 *
423 * @param {String} url The calendar url to be updated.
424 */
425export const updateUrl = (url) => {
426 const viewingFullCalendar = document.getElementById(CalendarSelectors.fullCalendarView);
427
428 // We want to update the url only if the user is viewing the full calendar.
429 if (viewingFullCalendar) {
430 window.history.pushState({}, '', url);
431 }
432};
433
0ab57f81
SR
434/**
435 * Set the element state to loading.
436 *
437 * @param {object} root The container element
438 * @method startLoading
439 */
440const startLoading = (root) => {
441 const loadingIconContainer = root.find(CalendarSelectors.containers.loadingIcon);
3ea4f446 442
0ab57f81
SR
443 loadingIconContainer.removeClass('hidden');
444};
695c5726 445
0ab57f81
SR
446/**
447 * Remove the loading state from the element.
448 *
449 * @param {object} root The container element
450 * @method stopLoading
451 */
452const stopLoading = (root) => {
453 const loadingIconContainer = root.find(CalendarSelectors.containers.loadingIcon);
b31a1695 454
0ab57f81
SR
455 loadingIconContainer.addClass('hidden');
456};
b31a1695 457
0ab57f81
SR
458/**
459 * Reload the current month view data.
460 *
461 * @param {object} root The container element.
462 * @param {number} courseId The course id.
463 * @param {number} categoryId The id of the category whose events are shown
0ab57f81 464 * @param {object} target The element being replaced. If not specified, the calendarwrapper is used.
fd58e6dc 465 * @param {string} template The template to be rendered.
0ab57f81
SR
466 * @return {promise}
467 */
468export const reloadCurrentUpcoming = (root, courseId = 0, categoryId = 0, target = null, template = '') => {
469 startLoading(root);
470
471 target = target || root.find(CalendarSelectors.wrapper);
472 template = template || root.attr('data-template');
473 courseId = courseId || root.find(CalendarSelectors.wrapper).data('courseid');
474 categoryId = categoryId || root.find(CalendarSelectors.wrapper).data('categoryid');
475
476 return CalendarRepository.getCalendarUpcomingData(courseId, categoryId)
477 .then((context) => {
478 context.viewingupcoming = true;
d2f612ab 479 context.showviewselector = true;
0ab57f81
SR
480 return Templates.render(template, context);
481 })
482 .then((html, js) => {
483 return Templates.replaceNode(target, html, js);
484 })
485 .then(() => {
3b3269ee 486 document.querySelector('body').dispatchEvent(new CustomEvent(CalendarEvents.viewUpdated));
0ab57f81
SR
487 return;
488 })
489 .always(function() {
490 return stopLoading(root);
491 })
492 .fail(Notification.exception);
493};
b31a1695 494
0ab57f81
SR
495/**
496 * Get the CSS class to apply for the given event type.
497 *
498 * @param {string} eventType The calendar event type
499 * @return {string}
500 */
501const getEventTypeClassFromType = (eventType) => {
502 return 'calendar_event_' + eventType;
503};
b31a1695 504
0ab57f81
SR
505/**
506 * Render the event summary modal.
507 *
508 * @param {Number} eventId The calendar event id.
88f1a81b 509 * @returns {Promise}
0ab57f81
SR
510 */
511const renderEventSummaryModal = (eventId) => {
88f1a81b 512 const pendingPromise = new Pending('core_calendar/view_manager:renderEventSummaryModal');
0ab57f81
SR
513
514 // Calendar repository promise.
88f1a81b
AN
515 return CalendarRepository.getEventById(eventId)
516 .then((getEventResponse) => {
0ab57f81
SR
517 if (!getEventResponse.event) {
518 throw new Error('Error encountered while trying to fetch calendar event with ID: ' + eventId);
519 }
0ab57f81 520
88f1a81b
AN
521 return getEventResponse.event;
522 })
523 .then(eventData => {
0ab57f81
SR
524 // Build the modal parameters from the event data.
525 const modalParams = {
526 title: eventData.name,
527 type: SummaryModal.TYPE,
528 body: Templates.render('core_calendar/event_summary_body', eventData),
529 templateContext: {
530 canedit: eventData.canedit,
531 candelete: eventData.candelete,
88f1a81b 532 headerclasses: getEventTypeClassFromType(eventData.normalisedeventtype),
0ab57f81 533 isactionevent: eventData.isactionevent,
1a972b06
MG
534 url: eventData.url,
535 action: eventData.action
e00aed51 536 }
0ab57f81 537 };
e00aed51 538
0ab57f81
SR
539 // Create the modal.
540 return ModalFactory.create(modalParams);
88f1a81b
AN
541 })
542 .then(modal => {
0ab57f81
SR
543 // Handle hidden event.
544 modal.getRoot().on(ModalEvents.hidden, function() {
545 // Destroy when hidden.
546 modal.destroy();
547 });
2ca4dc8a 548
0ab57f81
SR
549 // Finally, render the modal!
550 modal.show();
89260305 551
88f1a81b
AN
552 return modal;
553 })
554 .then(modal => {
555 pendingPromise.resolve();
556
557 return modal;
558 })
559 .catch(Notification.exception);
0ab57f81 560};
6c3f463d 561
0ab57f81 562export const init = (root, view) => {
cffefca1
DC
563 prefetchStrings('calendar', ['moreevents']);
564 foldDayEvents();
0ab57f81 565 registerEventListeners(root, view);
cffefca1
DC
566 const calendarTable = root.find(CalendarSelectors.elements.monthDetailed);
567 if (calendarTable.length) {
568 const pendingId = `month-detailed-${calendarTable.id}-filterChanged`;
569 registerEventListenersForMonthDetailed(calendarTable, pendingId);
570 }
0ab57f81 571};