blocks/timeline/amd/src/event_list.js

  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. * Javascript to load and render the list of calendar events for a
  17. * given day range.
  18. *
  19. * @module block_timeline/event_list
  20. * @copyright 2016 Ryan Wyllie <ryan@moodle.com>
  21. * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  22. */
  23. define(
  24. [
  25. 'jquery',
  26. 'core/notification',
  27. 'core/templates',
  28. 'core/str',
  29. 'core/user_date',
  30. 'block_timeline/calendar_events_repository',
  31. 'core/pending'
  32. ],
  33. function(
  34. $,
  35. Notification,
  36. Templates,
  37. Str,
  38. UserDate,
  39. CalendarEventsRepository,
  40. Pending
  41. ) {
  42. var SECONDS_IN_DAY = 60 * 60 * 24;
  43. var courseview = false;
  44. var SELECTORS = {
  45. EMPTY_MESSAGE: '[data-region="no-events-empty-message"]',
  46. ROOT: '[data-region="event-list-container"]',
  47. EVENT_LIST_CONTENT: '[data-region="event-list-content"]',
  48. EVENT_LIST_WRAPPER: '[data-region="event-list-wrapper"]',
  49. EVENT_LIST_LOADING_PLACEHOLDER: '[data-region="event-list-loading-placeholder"]',
  50. TIMELINE_BLOCK: '[data-region="timeline"]',
  51. TIMELINE_SEARCH: '[data-action="search"]',
  52. MORE_ACTIVITIES_BUTTON: '[data-action="more-events"]',
  53. MORE_ACTIVITIES_BUTTON_CONTAINER: '[data-region="more-events-button-container"]'
  54. };
  55. var TEMPLATES = {
  56. EVENT_LIST_CONTENT: 'block_timeline/event-list-content',
  57. MORE_ACTIVITIES_BUTTON: 'block_timeline/event-list-loadmore',
  58. LOADING_ICON: 'core/loading'
  59. };
  60. /** @property {number} The total items will be shown on the first load. */
  61. const DEFAULT_LAZY_LOADING_ITEMS_FIRST_LOAD = 5;
  62. /** @property {number} The total items will be shown when click on the Show more activities button. */
  63. const DEFAULT_LAZY_LOADING_ITEMS_OTHER_LOAD = 10;
  64. /**
  65. * Hide the content area and display the empty content message.
  66. *
  67. * @param {object} root The container element
  68. */
  69. var hideContent = function(root) {
  70. root.find(SELECTORS.EVENT_LIST_CONTENT).addClass('hidden');
  71. root.find(SELECTORS.EMPTY_MESSAGE).removeClass('hidden');
  72. };
  73. /**
  74. * Show the content area and hide the empty content message.
  75. *
  76. * @param {object} root The container element
  77. */
  78. var showContent = function(root) {
  79. root.find(SELECTORS.EVENT_LIST_CONTENT).removeClass('hidden');
  80. root.find(SELECTORS.EMPTY_MESSAGE).addClass('hidden');
  81. };
  82. /**
  83. * Empty the content area.
  84. *
  85. * @param {object} root The container element
  86. */
  87. var emptyContent = function(root) {
  88. root.find(SELECTORS.EVENT_LIST_CONTENT).empty();
  89. };
  90. /**
  91. * Construct the template context from a list of calendar events. The events
  92. * are grouped by which day they are on. The day is calculated from the user's
  93. * midnight timestamp to ensure that the calculation is timezone agnostic.
  94. *
  95. * The return data structure will look like:
  96. * {
  97. * eventsbyday: [
  98. * {
  99. * dayTimestamp: 1533744000,
  100. * events: [
  101. * { ...event 1 data... },
  102. * { ...event 2 data... }
  103. * ]
  104. * },
  105. * {
  106. * dayTimestamp: 1533830400,
  107. * events: [
  108. * { ...event 3 data... },
  109. * { ...event 4 data... }
  110. * ]
  111. * }
  112. * ]
  113. * }
  114. *
  115. * Each day timestamp is the day's midnight in the user's timezone.
  116. *
  117. * @param {array} calendarEvents List of calendar events
  118. * @return {object}
  119. */
  120. var buildTemplateContext = function(calendarEvents) {
  121. var eventsByDay = {};
  122. var templateContext = {
  123. courseview,
  124. eventsbyday: []
  125. };
  126. calendarEvents.forEach(function(calendarEvent) {
  127. var dayTimestamp = calendarEvent.timeusermidnight;
  128. if (eventsByDay[dayTimestamp]) {
  129. eventsByDay[dayTimestamp].push(calendarEvent);
  130. } else {
  131. eventsByDay[dayTimestamp] = [calendarEvent];
  132. }
  133. });
  134. Object.keys(eventsByDay).forEach(function(dayTimestamp) {
  135. var events = eventsByDay[dayTimestamp];
  136. templateContext.eventsbyday.push({
  137. dayTimestamp: dayTimestamp,
  138. events: events
  139. });
  140. });
  141. return templateContext;
  142. };
  143. /**
  144. * Render the HTML for the given calendar events.
  145. *
  146. * @param {array} calendarEvents A list of calendar events
  147. * @return {promise} Resolved with HTML and JS strings.
  148. */
  149. var render = function(calendarEvents) {
  150. var templateContext = buildTemplateContext(calendarEvents);
  151. var templateName = TEMPLATES.EVENT_LIST_CONTENT;
  152. return Templates.render(templateName, templateContext);
  153. };
  154. /**
  155. * Retrieve a list of calendar events from the server for the given
  156. * constraints.
  157. *
  158. * @param {Number} midnight The user's midnight time in unix timestamp.
  159. * @param {Number} limit Limit the result set to this number of items
  160. * @param {Number} daysOffset How many days (from midnight) to offset the results from
  161. * @param {int|undefined} daysLimit How many dates (from midnight) to limit the result to
  162. * @param {int|false} lastId The ID of the last seen event (if any)
  163. * @param {int|undefined} courseId Course ID to restrict events to
  164. * @param {string|undefined} searchValue Search value
  165. * @return {Promise} A jquery promise
  166. */
  167. var load = function(midnight, limit, daysOffset, daysLimit, lastId, courseId, searchValue) {
  168. var startTime = midnight + (daysOffset * SECONDS_IN_DAY);
  169. var endTime = daysLimit != undefined ? midnight + (daysLimit * SECONDS_IN_DAY) : false;
  170. var args = {
  171. starttime: startTime,
  172. limit: limit,
  173. };
  174. if (lastId) {
  175. args.aftereventid = lastId;
  176. }
  177. if (endTime) {
  178. args.endtime = endTime;
  179. }
  180. if (searchValue) {
  181. args.searchvalue = searchValue;
  182. }
  183. if (courseId) {
  184. // If we have a course id then we only want events from that course.
  185. args.courseid = courseId;
  186. return CalendarEventsRepository.queryByCourse(args);
  187. } else {
  188. // Otherwise we want events from any course.
  189. return CalendarEventsRepository.queryByTime(args);
  190. }
  191. };
  192. /**
  193. * Create a lazy-loading region for the calendar events in the given root element.
  194. *
  195. * @param {object} root The event list container element.
  196. * @param {object} additionalConfig Additional config options to pass to pagedContentFactory.
  197. */
  198. var init = function(root, additionalConfig = {}) {
  199. const pendingPromise = new Pending('block/timeline:event-init');
  200. root = $(root);
  201. courseview = !!additionalConfig.courseview;
  202. // Create a promise that will be resolved once the first set of page
  203. // data has been loaded. This ensures that the loading placeholder isn't
  204. // hidden until we have all of the data back to prevent the page elements
  205. // jumping around.
  206. var firstLoad = $.Deferred();
  207. var eventListContent = root.find(SELECTORS.EVENT_LIST_CONTENT);
  208. var loadingPlaceholder = root.find(SELECTORS.EVENT_LIST_LOADING_PLACEHOLDER);
  209. var courseId = root.attr('data-course-id');
  210. var daysOffset = parseInt(root.attr('data-days-offset'), 10);
  211. var daysLimit = root.attr('data-days-limit');
  212. var midnight = parseInt(root.attr('data-midnight'), 10);
  213. const searchValue = root.closest(SELECTORS.TIMELINE_BLOCK).find(SELECTORS.TIMELINE_SEARCH).val();
  214. // Make sure the content area and loading placeholder is visible.
  215. // This is because the init function can be called to re-initialise
  216. // an existing event list area.
  217. emptyContent(root);
  218. showContent(root);
  219. loadingPlaceholder.removeClass('hidden');
  220. // Days limit isn't mandatory.
  221. if (daysLimit != undefined) {
  222. daysLimit = parseInt(daysLimit, 10);
  223. }
  224. // Create the lazy loading content element.
  225. return createLazyLoadingContent(root, firstLoad,
  226. DEFAULT_LAZY_LOADING_ITEMS_FIRST_LOAD, midnight, 0, courseId, daysOffset, daysLimit, searchValue)
  227. .then(function(html, js) {
  228. firstLoad.then(function(data) {
  229. if (!data.hasContent) {
  230. loadingPlaceholder.addClass('hidden');
  231. // If we didn't get any data then show the empty data message.
  232. return hideContent(root);
  233. }
  234. html = $(html);
  235. // Hide the content for now.
  236. html.addClass('hidden');
  237. // Replace existing elements with the newly created lazy-loading region.
  238. Templates.replaceNodeContents(eventListContent, html, js);
  239. // Prevent changing page elements too much by only showing the content
  240. // once we've loaded some data for the first time. This allows our
  241. // fancy loading placeholder to shine.
  242. html.removeClass('hidden');
  243. loadingPlaceholder.addClass('hidden');
  244. if (!data.loadedAll) {
  245. Templates.render(TEMPLATES.MORE_ACTIVITIES_BUTTON, {courseview}).then(function(html) {
  246. eventListContent.append(html);
  247. setLastTimestamp(root, data.lastTimeStamp);
  248. // Init the event handler.
  249. initEventListener(root);
  250. return html;
  251. }).catch(function() {
  252. return false;
  253. });
  254. }
  255. return data;
  256. })
  257. .catch(function() {
  258. return false;
  259. });
  260. return html;
  261. }).then(() => {
  262. return pendingPromise.resolve();
  263. })
  264. .catch(Notification.exception);
  265. };
  266. /**
  267. * Create a lazy-loading content element for showing the event list for the initial load.
  268. *
  269. * @param {object} root The event list container element.
  270. * @param {object} firstLoad A jQuery promise to be resolved after the first set of data is loaded.
  271. * @param {int} itemLimit Limit the number of items.
  272. * @param {Number} midnight The user's midnight time in unix timestamp.
  273. * @param {int} lastId The last event ID for each loaded page. Page number is key, id is value.
  274. * @param {int|undefined} courseId Course ID to restrict events to.
  275. * @param {Number} daysOffset How many days (from midnight) to offset the results from.
  276. * @param {int|undefined} daysLimit How many dates (from midnight) to limit the result to.
  277. * @param {string|undefined} searchValue Search value.
  278. * @return {object} jQuery promise resolved with calendar events.
  279. */
  280. const createLazyLoadingContent = (root, firstLoad, itemLimit, midnight, lastId,
  281. courseId, daysOffset, daysLimit, searchValue) => {
  282. return loadEventsForLazyLoading(
  283. root,
  284. itemLimit,
  285. midnight,
  286. lastId,
  287. courseId,
  288. daysOffset,
  289. daysLimit,
  290. searchValue
  291. ).then(data => {
  292. if (data.calendarEvents.length) {
  293. const lastEventId = data.calendarEvents.at(-1).id;
  294. const lastTimeStamp = data.calendarEvents.at(-1).timeusermidnight;
  295. firstLoad.resolve({
  296. hasContent: true,
  297. lastId: lastEventId,
  298. lastTimeStamp: lastTimeStamp,
  299. loadedAll: data.loadedAll
  300. });
  301. return render(data.calendarEvents, midnight);
  302. } else {
  303. firstLoad.resolve({
  304. hasContent: false,
  305. lastId: 0,
  306. lastTimeStamp: 0,
  307. loadedAll: true
  308. });
  309. return data.calendarEvents;
  310. }
  311. }).catch(Notification.exception);
  312. };
  313. /**
  314. * Handle the request from the lazy-loading region.
  315. * Uses the given data like course id, offset... to request the events from the server.
  316. *
  317. * @param {object} root The event list container element.
  318. * @param {int} itemLimit Limit the number of items.
  319. * @param {Number} midnight The user's midnight time in unix timestamp.
  320. * @param {int} lastId The last event ID for each loaded page.
  321. * @param {int|undefined} courseId Course ID to restrict events to.
  322. * @param {Number} daysOffset How many days (from midnight) to offset the results from.
  323. * @param {int|undefined} daysLimit How many dates (from midnight) to limit the result to.
  324. * @param {string|undefined} searchValue Search value.
  325. * @return {object} jQuery promise resolved with calendar events.
  326. */
  327. const loadEventsForLazyLoading = (root, itemLimit, midnight, lastId, courseId, daysOffset, daysLimit, searchValue) => {
  328. // Load one more than the given limit so that we can tell if there
  329. // is more content to load after this.
  330. const eventsPromise = load(midnight, itemLimit + 1, daysOffset, daysLimit, lastId, courseId, searchValue);
  331. let calendarEvents = [];
  332. let loadedAll = true;
  333. return eventsPromise.then(result => {
  334. if (!result.events.length) {
  335. return {calendarEvents, loadedAll};
  336. }
  337. // Determine if the overdue filter is applied.
  338. const overdueFilter = document.querySelector("[data-filtername='overdue']");
  339. const filterByOverdue = (overdueFilter && overdueFilter.getAttribute('aria-current'));
  340. calendarEvents = result.events.filter(event => {
  341. if (event.eventtype == 'open' || event.eventtype == 'opensubmission') {
  342. const dayTimestamp = UserDate.getUserMidnightForTimestamp(event.timesort, midnight);
  343. return dayTimestamp > midnight;
  344. }
  345. // When filtering by overdue, we fetch all events due today, in case any have elapsed already and are overdue.
  346. // This means if filtering by overdue, some events fetched might not be required (eg if due later today).
  347. return (!filterByOverdue || event.overdue);
  348. });
  349. loadedAll = calendarEvents.length <= itemLimit;
  350. if (!loadedAll) {
  351. // Remove the last element from the array because it isn't
  352. // needed in this result set.
  353. calendarEvents.pop();
  354. }
  355. if (calendarEvents.length) {
  356. const lastEventId = calendarEvents.at(-1).id;
  357. setOffset(root, lastEventId);
  358. }
  359. return {calendarEvents, loadedAll};
  360. });
  361. };
  362. /**
  363. * Load new events and append to current list.
  364. *
  365. * @param {object} root The event list container element.
  366. */
  367. const loadMoreEvents = root => {
  368. const midnight = parseInt(root.attr('data-midnight'), 10);
  369. const courseId = root.attr('data-course-id');
  370. const daysOffset = parseInt(root.attr('data-days-offset'), 10);
  371. const daysLimit = root.attr('data-days-limit');
  372. const lastId = getOffset(root);
  373. const eventListWrapper = root.find(SELECTORS.EVENT_LIST_WRAPPER);
  374. const searchValue = root.closest(SELECTORS.TIMELINE_BLOCK).find(SELECTORS.TIMELINE_SEARCH).val();
  375. const eventsPromise = loadEventsForLazyLoading(
  376. root,
  377. DEFAULT_LAZY_LOADING_ITEMS_OTHER_LOAD,
  378. midnight,
  379. lastId,
  380. courseId,
  381. daysOffset,
  382. daysLimit,
  383. searchValue
  384. );
  385. eventsPromise.then(data => {
  386. if (data.calendarEvents.length) {
  387. const renderPromise = render(data.calendarEvents);
  388. const lastTimestamp = getLastTimestamp(root);
  389. renderPromise.then((html, js) => {
  390. html = $(html);
  391. // Remove the date heading if it has the same value as the previous one.
  392. html.find(`[data-timestamp="${lastTimestamp}"]`).remove();
  393. Templates.appendNodeContents(eventListWrapper, html.html(), js);
  394. if (!data.loadedAll) {
  395. Templates.render(TEMPLATES.MORE_ACTIVITIES_BUTTON, {}).then(html => {
  396. eventListWrapper.append(html);
  397. setLastTimestamp(root, data.calendarEvents.at(-1).timeusermidnight);
  398. // Init the event handler.
  399. initEventListener(root);
  400. return html;
  401. }).catch(() => {
  402. return false;
  403. });
  404. }
  405. return html;
  406. }).catch(Notification.exception);
  407. }
  408. return data;
  409. }).then(() => {
  410. return disableMoreActivitiesButtonLoading(root);
  411. }).catch(Notification.exception);
  412. };
  413. /**
  414. * Return the offset value for lazy loading fetching.
  415. *
  416. * @param {object} element The event list container element.
  417. * @return {Number} Offset value.
  418. */
  419. const getOffset = element => {
  420. return parseInt(element.attr('data-lazyload-offset'), 10);
  421. };
  422. /**
  423. * Set the offset value for lazy loading fetching.
  424. *
  425. * @param {object} element The event list container element.
  426. * @param {Number} offset Offset value.
  427. */
  428. const setOffset = (element, offset) => {
  429. element.attr('data-lazyload-offset', offset);
  430. };
  431. /**
  432. * Return the timestamp value for lazy loading fetching.
  433. *
  434. * @param {object} element The event list container element.
  435. * @return {Number} Timestamp value.
  436. */
  437. const getLastTimestamp = element => {
  438. return parseInt(element.attr('data-timestamp'), 10);
  439. };
  440. /**
  441. * Set the timestamp value for lazy loading fetching.
  442. *
  443. * @param {object} element The event list container element.
  444. * @param {Number} timestamp Timestamp value.
  445. */
  446. const setLastTimestamp = (element, timestamp) => {
  447. element.attr('data-timestamp', timestamp);
  448. };
  449. /**
  450. * Add the "Show more activities" button and remove and loading spinner.
  451. *
  452. * @param {object} root The event list container element.
  453. */
  454. const enableMoreActivitiesButtonLoading = root => {
  455. const loadMoreButton = root.find(SELECTORS.MORE_ACTIVITIES_BUTTON);
  456. loadMoreButton.prop('disabled', true);
  457. Templates.render(TEMPLATES.LOADING_ICON, {}).then(html => {
  458. loadMoreButton.append(html);
  459. return html;
  460. }).catch(() => {
  461. // It's not important if this false so just do so silently.
  462. return false;
  463. });
  464. };
  465. /**
  466. * Remove the "Show more activities" button and remove and loading spinner.
  467. *
  468. * @param {object} root The event list container element.
  469. */
  470. const disableMoreActivitiesButtonLoading = root => {
  471. const loadMoreButtonContainer = root.find(SELECTORS.MORE_ACTIVITIES_BUTTON_CONTAINER);
  472. loadMoreButtonContainer.remove();
  473. };
  474. /**
  475. * Event initialise.
  476. *
  477. * @param {object} root The event list container element.
  478. */
  479. const initEventListener = root => {
  480. const loadMoreButton = root.find(SELECTORS.MORE_ACTIVITIES_BUTTON);
  481. loadMoreButton.on('click', () => {
  482. enableMoreActivitiesButtonLoading(root);
  483. loadMoreEvents(root);
  484. });
  485. };
  486. return {
  487. init: init,
  488. rootSelector: SELECTORS.ROOT,
  489. };
  490. });