diff --git a/admin/tool/usertours/tests/behat/create_tour.feature b/admin/tool/usertours/tests/behat/create_tour.feature
index 33c7db7cc77d4..09f99ff9cc005 100644
--- a/admin/tool/usertours/tests/behat/create_tour.feature
+++ b/admin/tool/usertours/tests/behat/create_tour.feature
@@ -27,16 +27,16 @@ Feature: Add a new user tour
| Selector | .usermenu | User menu | This is your personal user menu. You'll find your personal preferences and your user profile here. |
When I am on homepage
Then I should see "Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful"
- And I press "Next"
+ And I click on "Next" "button" in the "[data-role='flexitour-step']" "css_element"
And I should see "This area shows you what's happening in some of your courses"
And I should not see "This is the Calendar. All of your assignments and due dates can be found here"
- And I press "Next"
+ And I click on "Next" "button" in the "[data-role='flexitour-step']" "css_element"
And I should see "This is the Calendar. All of your assignments and due dates can be found here"
And I should not see "This area shows you what's happening in some of your courses"
- And I press "Prev"
+ And I click on "Prev" "button" in the "[data-role='flexitour-step']" "css_element"
And I should not see "This is the Calendar. All of your assignments and due dates can be found here"
And I should see "This area shows you what's happening in some of your courses"
- And I press "End tour"
+ And I click on "End tour" "button" in the "[data-role='flexitour-step']" "css_element"
And I should not see "This area shows you what's happening in some of your courses"
And I am on homepage
And I should not see "Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful"
diff --git a/blocks/myoverview/amd/build/event_list.min.js b/blocks/myoverview/amd/build/event_list.min.js
deleted file mode 100644
index 343b46ee841a0..0000000000000
--- a/blocks/myoverview/amd/build/event_list.min.js
+++ /dev/null
@@ -1 +0,0 @@
-define(["jquery","core/notification","core/templates","core/custom_interaction_events","block_myoverview/calendar_events_repository"],function(a,b,c,d,e){var f=86400,g={EMPTY_MESSAGE:'[data-region="empty-message"]',ROOT:'[data-region="event-list-container"]',EVENT_LIST:'[data-region="event-list"]',EVENT_LIST_CONTENT:'[data-region="event-list-content"]',EVENT_LIST_GROUP_CONTAINER:'[data-region="event-list-group-container"]',LOADING_ICON_CONTAINER:'[data-region="loading-icon-container"]',VIEW_MORE_BUTTON:'[data-action="view-more"]'},h={EVENT_LIST_ITEMS:"block_myoverview/event-list-items",COURSE_EVENT_LIST_ITEMS:"block_myoverview/course-event-list-items"},i=function(a){a.attr("data-loaded-all",!0)},j=function(a){return!!a.attr("data-loaded-all")},k=function(a){var b=a.find(g.LOADING_ICON_CONTAINER),c=a.find(g.VIEW_MORE_BUTTON);a.addClass("loading"),b.removeClass("hidden"),c.prop("disabled",!0)},l=function(a){var b=a.find(g.LOADING_ICON_CONTAINER),c=a.find(g.VIEW_MORE_BUTTON);a.removeClass("loading"),b.addClass("hidden"),j(a)?c.addClass("hidden"):c.prop("disabled",!1)},m=function(a){return a.hasClass("loading")},n=function(a){a.attr("data-has-events",!0)},o=function(a){return!!a.attr("data-has-events")},p=function(a,b){b?n(a):o(a)||q(a)},q=function(a){a.find(g.EVENT_LIST_CONTENT).addClass("hidden"),a.find(g.EMPTY_MESSAGE).removeClass("hidden")},r=function(a,b,d){return a.removeClass("hidden"),c.render(d,{events:b}).done(function(b,d){c.appendNodeContents(a.find(g.EVENT_LIST),b,d)})},s=function(a,b){var c=b.timesort||0;return c-a},t=function(a,b,c){var d=a.attr("data-midnight"),e=+c.attr("data-start-day")*f,g=+c.attr("data-end-day")*f,h=s(d,b);return""===c.attr("data-end-day")?e<=h:e<=h&&h.
-
-/**
- * Javascript to load and render the list of calendar events for a
- * given day range.
- *
- * @module block_myoverview/event_list
- * @package block_myoverview
- * @copyright 2016 Ryan Wyllie
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-define(['jquery', 'core/notification', 'core/templates',
- 'core/custom_interaction_events',
- 'block_myoverview/calendar_events_repository'],
- function($, Notification, Templates, CustomEvents, CalendarEventsRepository) {
-
- var SECONDS_IN_DAY = 60 * 60 * 24;
-
- var SELECTORS = {
- EMPTY_MESSAGE: '[data-region="empty-message"]',
- ROOT: '[data-region="event-list-container"]',
- EVENT_LIST: '[data-region="event-list"]',
- EVENT_LIST_CONTENT: '[data-region="event-list-content"]',
- EVENT_LIST_GROUP_CONTAINER: '[data-region="event-list-group-container"]',
- LOADING_ICON_CONTAINER: '[data-region="loading-icon-container"]',
- VIEW_MORE_BUTTON: '[data-action="view-more"]'
- };
-
- var TEMPLATES = {
- EVENT_LIST_ITEMS: 'block_myoverview/event-list-items',
- COURSE_EVENT_LIST_ITEMS: 'block_myoverview/course-event-list-items'
- };
-
- /**
- * Set a flag on the element to indicate that it has completed
- * loading all event data.
- *
- * @method setLoadedAll
- * @private
- * @param {object} root The container element
- */
- var setLoadedAll = function(root) {
- root.attr('data-loaded-all', true);
- };
-
- /**
- * Check if all event data has finished loading.
- *
- * @method hasLoadedAll
- * @private
- * @param {object} root The container element
- * @return {bool} if the element has completed all loading
- */
- var hasLoadedAll = function(root) {
- return !!root.attr('data-loaded-all');
- };
-
- /**
- * Set the element state to loading.
- *
- * @method startLoading
- * @private
- * @param {object} root The container element
- */
- var startLoading = function(root) {
- var loadingIcon = root.find(SELECTORS.LOADING_ICON_CONTAINER),
- viewMoreButton = root.find(SELECTORS.VIEW_MORE_BUTTON);
-
- root.addClass('loading');
- loadingIcon.removeClass('hidden');
- viewMoreButton.prop('disabled', true);
- };
-
- /**
- * Remove the loading state from the element.
- *
- * @method stopLoading
- * @private
- * @param {object} root The container element
- */
- var stopLoading = function(root) {
- var loadingIcon = root.find(SELECTORS.LOADING_ICON_CONTAINER),
- viewMoreButton = root.find(SELECTORS.VIEW_MORE_BUTTON);
-
- root.removeClass('loading');
- loadingIcon.addClass('hidden');
-
- if (!hasLoadedAll(root)) {
- // Only enable the button if we've got more events to load.
- viewMoreButton.prop('disabled', false);
- } else {
- viewMoreButton.addClass('hidden');
- }
- };
-
- /**
- * Check if the element is currently loading some event data.
- *
- * @method isLoading
- * @private
- * @param {object} root The container element
- * @returns {Boolean}
- */
- var isLoading = function(root) {
- return root.hasClass('loading');
- };
-
- /**
- * Flag the root element to remember that it contains events.
- *
- * @method setHasContent
- * @private
- * @param {object} root The container element
- */
- var setHasContent = function(root) {
- root.attr('data-has-events', true);
- };
-
- /**
- * Check if the root element has had events loaded.
- *
- * @method hasContent
- * @private
- * @param {object} root The container element
- * @return {bool}
- */
- var hasContent = function(root) {
- return root.attr('data-has-events') ? true : false;
- };
-
- /**
- * Update the visibility of the content area. The content area
- * is hidden if we have no events.
- *
- * @method updateContentVisibility
- * @private
- * @param {object} root The container element
- * @param {int} eventCount A count of the events we just received.
- */
- var updateContentVisibility = function(root, eventCount) {
- if (eventCount) {
- // We've rendered some events, let's remember that.
- setHasContent(root);
- } else {
- // If this is the first time trying to load events and
- // we don't have any then there isn't any so let's show
- // the empty message.
- if (!hasContent(root)) {
- hideContent(root);
- }
- }
- };
-
- /**
- * Hide the content area and display the empty content message.
- *
- * @method hideContent
- * @private
- * @param {object} root The container element
- */
- var hideContent = function(root) {
- root.find(SELECTORS.EVENT_LIST_CONTENT).addClass('hidden');
- root.find(SELECTORS.EMPTY_MESSAGE).removeClass('hidden');
- };
-
- /**
- * Render a group of calendar events and add them to the event
- * list.
- *
- * @method renderGroup
- * @private
- * @param {object} group The group container element
- * @param {array} calendarEvents The list of calendar events
- * @param {string} templateName The template name
- * @return {promise} Resolved when the elements are attached to the DOM
- */
- var renderGroup = function(group, calendarEvents, templateName) {
-
- group.removeClass('hidden');
-
- return Templates.render(
- templateName,
- {events: calendarEvents}
- ).done(function(html, js) {
- Templates.appendNodeContents(group.find(SELECTORS.EVENT_LIST), html, js);
- });
- };
-
- /**
- * Determine the time (in seconds) from the given timestamp until the calendar
- * event will need actioning.
- *
- * @method timeUntilEvent
- * @private
- * @param {int} timestamp The time to compare with
- * @param {object} event The calendar event
- * @return {int}
- */
- var timeUntilEvent = function(timestamp, event) {
- var orderTime = event.timesort || 0;
- return orderTime - timestamp;
- };
-
- /**
- * Check if the given calendar event should be added to the given event
- * list group container. The event list group container will specify a
- * day range for the time boundary it is interested in.
- *
- * If only a start day is specified for the container then it will be treated
- * as an open catchment for all events that begin after that time.
- *
- * @method eventBelongsInContainer
- * @private
- * @param {object} root The root element
- * @param {object} event The calendar event
- * @param {object} container The group event list container
- * @return {bool}
- */
- var eventBelongsInContainer = function(root, event, container) {
- var todayTime = root.attr('data-midnight'),
- timeUntilContainerStart = +container.attr('data-start-day') * SECONDS_IN_DAY,
- timeUntilContainerEnd = +container.attr('data-end-day') * SECONDS_IN_DAY,
- timeUntilEventNeedsAction = timeUntilEvent(todayTime, event);
-
- if (container.attr('data-end-day') === '') {
- return timeUntilContainerStart <= timeUntilEventNeedsAction;
- } else {
- return timeUntilContainerStart <= timeUntilEventNeedsAction &&
- timeUntilEventNeedsAction < timeUntilContainerEnd;
- }
- };
-
- /**
- * Return a function that can be used to filter a list of events based on the day
- * range specified on the given event list group container.
- *
- * @method getFilterCallbackForContainer
- * @private
- * @param {object} root The root element
- * @param {object} container Event list group container
- * @return {function}
- */
- var getFilterCallbackForContainer = function(root, container) {
- return function(event) {
- return eventBelongsInContainer(root, event, $(container));
- };
- };
-
- /**
- * Render the given calendar events in the container element. The container
- * elements must have a day range defined using data attributes that will be
- * used to group the calendar events according to their order time.
- *
- * @method render
- * @private
- * @param {object} root The container element
- * @param {array} calendarEvents A list of calendar events
- * @return {promise} Resolved with a count of the number of rendered events
- */
- var render = function(root, calendarEvents) {
- var renderCount = 0;
- var templateName = TEMPLATES.EVENT_LIST_ITEMS;
-
- if (root.attr('data-course-id')) {
- templateName = TEMPLATES.COURSE_EVENT_LIST_ITEMS;
- }
-
- // Loop over each of the element list groups and find the set of calendar events
- // that belong to that group (as defined by the group's day range). The matching
- // list of calendar events are rendered and added to the DOM within that group.
- return $.when.apply($, $.map(root.find(SELECTORS.EVENT_LIST_GROUP_CONTAINER), function(container) {
- var events = calendarEvents.filter(getFilterCallbackForContainer(root, container));
-
- if (events.length) {
- renderCount += events.length;
- return renderGroup($(container), events, templateName);
- } else {
- return null;
- }
- })).then(function() {
- return renderCount;
- });
- };
-
- /**
- * Retrieve a list of calendar events, render and append them to the end of the
- * existing list. The events will be loaded based on the set of data attributes
- * on the root element.
- *
- * This function can be provided with a jQuery promise. If it is then it won't
- * attempt to load data by itself, instead it will use the given promise.
- *
- * The provided promise must resolve with an an object that has an events key
- * and value is an array of calendar events.
- * E.g.
- * { events: ['event 1', 'event 2'] }
- *
- * @method load
- * @param {object} root The root element of the event list
- * @param {object} promise A jQuery promise resolved with events
- * @return {promise} A jquery promise
- */
- var load = function(root, promise) {
- root = $(root);
- var limit = +root.attr('data-limit'),
- courseId = +root.attr('data-course-id'),
- lastId = root.attr('data-last-id'),
- midnight = root.attr('data-midnight'),
- startTime = midnight - (14 * SECONDS_IN_DAY);
-
- // Don't load twice.
- if (isLoading(root)) {
- return $.Deferred().resolve();
- }
-
- startLoading(root);
-
- // If we haven't been provided a promise to resolve the
- // data then we will load our own.
- if (typeof promise == 'undefined') {
- var args = {
- starttime: startTime,
- limit: limit,
- };
-
- if (lastId) {
- args.aftereventid = lastId;
- }
-
- // If we have a course id then we only want events from that course.
- if (courseId) {
- args.courseid = courseId;
- promise = CalendarEventsRepository.queryByCourse(args);
- } else {
- // Otherwise we want events from any course.
- promise = CalendarEventsRepository.queryByTime(args);
- }
- }
-
- // Request data from the server.
- return promise.then(function(result) {
- if (!result.events.length) {
- // No events, nothing to do.
- setLoadedAll(root);
- return 0;
- }
-
- var calendarEvents = result.events;
-
- // Remember the last id we've seen.
- root.attr('data-last-id', calendarEvents[calendarEvents.length - 1].id);
-
- if (calendarEvents.length < limit) {
- // No more events to load, disable loading button.
- setLoadedAll(root);
- }
-
- // Render the events.
- return render(root, calendarEvents).then(function(renderCount) {
- if (renderCount < calendarEvents.length) {
- // If the number of events that was rendered is less than
- // the number we sent for rendering we can assume that there
- // are no groups to add them in. Since the ordering of the
- // events is guaranteed it means that any future requests will
- // also yield events that can't be rendered, so let's not bother
- // sending any more requests.
- setLoadedAll(root);
- }
- return calendarEvents.length;
- });
- }).then(function(eventCount) {
- return updateContentVisibility(root, eventCount);
- }).fail(
- Notification.exception
- ).always(function() {
- stopLoading(root);
- });
- };
-
- /**
- * Register the event listeners for the container element.
- *
- * @method registerEventListeners
- * @param {object} root The root element of the event list
- */
- var registerEventListeners = function(root) {
- CustomEvents.define(root, [CustomEvents.events.activate]);
- root.on(CustomEvents.events.activate, SELECTORS.VIEW_MORE_BUTTON, function() {
- load(root);
- });
- };
-
- return {
- init: function(root) {
- root = $(root);
- load(root);
- registerEventListeners(root);
- },
- registerEventListeners: registerEventListeners,
- load: load,
- rootSelector: SELECTORS.ROOT,
- };
-});
diff --git a/blocks/myoverview/amd/src/event_list_by_course.js b/blocks/myoverview/amd/src/event_list_by_course.js
deleted file mode 100644
index 32d52cc53ea52..0000000000000
--- a/blocks/myoverview/amd/src/event_list_by_course.js
+++ /dev/null
@@ -1,108 +0,0 @@
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle. If not, see .
-
-/**
- * Javascript to load and render the list of calendar events grouping by course.
- *
- * @module block_myoverview/events_by_course_list
- * @package block_myoverview
- * @copyright 2016 Simey Lameze
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-define(
-[
- 'jquery',
- 'block_myoverview/event_list',
- 'block_myoverview/calendar_events_repository'
-],
-function($, EventList, EventsRepository) {
-
- var SECONDS_IN_DAY = 60 * 60 * 24;
-
- var SELECTORS = {
- EVENTS_BY_COURSE_CONTAINER: '[data-region="course-events-container"]',
- EVENT_LIST_CONTAINER: '[data-region="event-list-container"]',
- };
-
- /**
- * Loop through course events containers and load calendar events for that course.
- *
- * @method load
- * @param {Object} root The root element of sort by course list.
- */
- var load = function(root) {
- var courseBlocks = root.find(SELECTORS.EVENTS_BY_COURSE_CONTAINER);
-
- if (!courseBlocks.length) {
- return;
- }
-
- var eventList = courseBlocks.find(SELECTORS.EVENT_LIST_CONTAINER).first();
- var midnight = eventList.attr('data-midnight');
- var startTime = midnight - (14 * SECONDS_IN_DAY);
- var limit = eventList.attr('data-limit');
- var courseIds = courseBlocks.map(function() {
- return $(this).attr('data-course-id');
- }).get();
-
- // Load the first set of events for each course in a single request.
- // We want to avoid sending an individual request for each course because
- // there could be lots of them.
- var coursesPromise = EventsRepository.queryByCourses({
- courseids: courseIds,
- starttime: startTime,
- limit: limit
- });
-
- // Load the events into each course block.
- courseBlocks.each(function(index, container) {
- container = $(container);
- var courseId = container.attr('data-course-id');
- var eventListContainer = container.find(EventList.rootSelector);
- var promise = $.Deferred();
-
- // Once all of the course events have been loaded then we need
- // to extract just the ones relevant to this course block and
- // hand them to the event list to render.
- coursesPromise.done(function(result) {
- var events = [];
- // Get this course block's events from the collection returned
- // from the server.
- var courseGroup = result.groupedbycourse.filter(function(group) {
- return group.courseid == courseId;
- });
-
- if (courseGroup.length) {
- events = courseGroup[0].events;
- }
-
- promise.resolve({events: events});
- }).fail(function(e) {
- promise.reject(e);
- });
-
- // Provide the event list with a promise that will be resolved
- // when we have received the events from the server.
- EventList.load(eventListContainer, promise);
- });
- };
-
- return {
- init: function(root) {
- root = $(root);
- load(root);
- }
- };
-});
diff --git a/blocks/myoverview/amd/src/tab_preferences.js b/blocks/myoverview/amd/src/tab_preferences.js
deleted file mode 100644
index 25ac2eefa3de2..0000000000000
--- a/blocks/myoverview/amd/src/tab_preferences.js
+++ /dev/null
@@ -1,61 +0,0 @@
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle. If not, see .
-
-/**
- * Javascript used to save the user's tab preference.
- *
- * @package block_myoverview
- * @copyright 2017 Mark Nelson
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-define(['jquery', 'core/ajax', 'core/custom_interaction_events',
- 'core/notification'], function($, Ajax, CustomEvents, Notification) {
-
- /**
- * Registers an event that saves the user's tab preference when switching between them.
- *
- * @param {object} root The container element
- */
- var registerEventListeners = function(root) {
- CustomEvents.define(root, [CustomEvents.events.activate]);
- root.on(CustomEvents.events.activate, "[data-toggle='tab']", function(e) {
- var tabname = $(e.currentTarget).data('tabname');
- // Bootstrap does not change the URL when using BS tabs, so need to do this here.
- // Also check to make sure the browser supports the history API.
- if (typeof window.history.pushState === "function") {
- window.history.pushState(null, null, '?myoverviewtab=' + tabname);
- }
- var request = {
- methodname: 'core_user_update_user_preferences',
- args: {
- preferences: [
- {
- type: 'block_myoverview_last_tab',
- value: tabname
- }
- ]
- }
- };
-
- Ajax.call([request])[0]
- .fail(Notification.exception);
- });
- };
-
- return {
- registerEventListeners: registerEventListeners
- };
-});
diff --git a/blocks/myoverview/block_myoverview.php b/blocks/myoverview/block_myoverview.php
index 8afd4a10471b6..f22ce15decb6b 100644
--- a/blocks/myoverview/block_myoverview.php
+++ b/blocks/myoverview/block_myoverview.php
@@ -50,16 +50,7 @@ public function get_content() {
return $this->content;
}
- // Check if the tab to select wasn't passed in the URL, if so see if the user has any preference.
- if (!$tab = optional_param('myoverviewtab', null, PARAM_ALPHA)) {
- // Check if the user has no preference, if so get the site setting.
- if (!$tab = get_user_preferences('block_myoverview_last_tab')) {
- $config = get_config('block_myoverview');
- $tab = $config->defaulttab;
- }
- }
-
- $renderable = new \block_myoverview\output\main($tab);
+ $renderable = new \block_myoverview\output\main();
$renderer = $this->page->get_renderer('block_myoverview');
$this->content = new stdClass();
@@ -77,13 +68,4 @@ public function get_content() {
public function applicable_formats() {
return array('my' => true);
}
-
- /**
- * This block does contain a configuration settings.
- *
- * @return boolean
- */
- public function has_config() {
- return true;
- }
}
diff --git a/blocks/myoverview/classes/output/main.php b/blocks/myoverview/classes/output/main.php
index 28506375fb58e..46834f318b363 100644
--- a/blocks/myoverview/classes/output/main.php
+++ b/blocks/myoverview/classes/output/main.php
@@ -29,7 +29,6 @@
use templatable;
use core_completion\progress;
-require_once($CFG->dirroot . '/blocks/myoverview/lib.php');
require_once($CFG->libdir . '/completionlib.php');
/**
@@ -39,21 +38,6 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class main implements renderable, templatable {
-
- /**
- * @var string The tab to display.
- */
- public $tab;
-
- /**
- * Constructor.
- *
- * @param string $tab The tab to display.
- */
- public function __construct($tab) {
- $this->tab = $tab;
- }
-
/**
* Export this data so it can be used as the context for a mustache template.
*
@@ -86,26 +70,13 @@ public function export_for_template(renderer_base $output) {
$coursesview = new courses_view($courses, $coursesprogress);
$nocoursesurl = $output->image_url('courses', 'block_myoverview')->out();
- $noeventsurl = $output->image_url('activities', 'block_myoverview')->out();
-
- // Now, set the tab we are going to be viewing.
- $viewingtimeline = false;
- $viewingcourses = false;
- if ($this->tab == BLOCK_MYOVERVIEW_TIMELINE_VIEW) {
- $viewingtimeline = true;
- } else {
- $viewingcourses = true;
- }
return [
'midnight' => usergetmidnight(time()),
'coursesview' => $coursesview->export_for_template($output),
'urls' => [
'nocourses' => $nocoursesurl,
- 'noevents' => $noeventsurl
],
- 'viewingtimeline' => $viewingtimeline,
- 'viewingcourses' => $viewingcourses
];
}
}
diff --git a/blocks/myoverview/classes/privacy/provider.php b/blocks/myoverview/classes/privacy/provider.php
index d0ee9e8b30b52..b3cf042afef93 100644
--- a/blocks/myoverview/classes/privacy/provider.php
+++ b/blocks/myoverview/classes/privacy/provider.php
@@ -32,30 +32,15 @@
* @copyright 2018 Zig Tan
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class provider implements \core_privacy\local\metadata\provider, \core_privacy\local\request\user_preference_provider {
+class provider implements \core_privacy\local\metadata\null_provider {
/**
- * Returns meta-data information about the myoverview block.
+ * Get the language string identifier with the component's language
+ * file to explain why this plugin stores no data.
*
- * @param \core_privacy\local\metadata\collection $collection A collection of meta-data.
- * @return \core_privacy\local\metadata\collection Return the collection of meta-data.
+ * @return string
*/
- public static function get_metadata(\core_privacy\local\metadata\collection $collection) :
- \core_privacy\local\metadata\collection {
- $collection->add_user_preference('block_myoverview_last_tab', 'privacy:metadata:overviewlasttab');
- return $collection;
- }
-
- /**
- * Export all user preferences for the myoverview block
- *
- * @param int $userid The userid of the user whose data is to be exported.
- */
- public static function export_user_preferences(int $userid) {
- $preference = get_user_preferences('block_myoverview_last_tab', null, $userid);
- if (isset($preference)) {
- \core_privacy\local\request\writer::export_user_preference('block_myoverview', 'block_myoverview_last_tab',
- $preference, get_string('privacy:metadata:overviewlasttab', 'block_myoverview'));
- }
+ public static function get_reason() : string {
+ return 'privacy:metadata';
}
}
diff --git a/blocks/myoverview/db/upgrade.php b/blocks/myoverview/db/upgrade.php
new file mode 100644
index 0000000000000..b91cb9613a3d7
--- /dev/null
+++ b/blocks/myoverview/db/upgrade.php
@@ -0,0 +1,41 @@
+.
+
+/**
+ * This file keeps track of upgrades to the myoverview block
+ *
+ * @package block_myoverview
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Upgrade code for the myoverview block.
+ *
+ * @param int $oldversion
+ */
+function xmldb_block_myoverview_upgrade($oldversion) {
+ global $DB;
+
+ if ($oldversion < 2018092700) {
+ $DB->delete_records('user_preferences', ['name' => 'block_myoverview_last_tab']);
+ upgrade_block_savepoint(true, 2018092700, 'myoverview');
+ }
+
+ return true;
+}
diff --git a/blocks/myoverview/lang/en/block_myoverview.php b/blocks/myoverview/lang/en/block_myoverview.php
index a3ca64e2625db..df3ae6652b4cf 100644
--- a/blocks/myoverview/lang/en/block_myoverview.php
+++ b/blocks/myoverview/lang/en/block_myoverview.php
@@ -22,8 +22,6 @@
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-$string['defaulttab'] = 'Default tab';
-$string['defaulttab_desc'] = 'The tab that will be displayed when a user first views their course overview. When returning to their course overview, the user\'s active tab is remembered.';
$string['future'] = 'Future';
$string['inprogress'] = 'In progress';
$string['morecourses'] = 'More courses';
@@ -33,15 +31,8 @@
$string['nocoursesinprogress'] = 'No in progress courses';
$string['nocoursesfuture'] = 'No future courses';
$string['nocoursespast'] = 'No past courses';
-$string['noevents'] = 'No upcoming activities due';
-$string['next30days'] = 'Next 30 days';
-$string['next7days'] = 'Next 7 days';
$string['past'] = 'Past';
$string['pluginname'] = 'Course overview';
-$string['recentlyoverdue'] = 'Recently overdue';
-$string['sortbycourses'] = 'Sort by courses';
-$string['sortbydates'] = 'Sort by dates';
-$string['timeline'] = 'Timeline';
$string['viewcourse'] = 'View course';
$string['viewcoursename'] = 'View course {$a}';
-$string['privacy:metadata:overviewlasttab'] = 'This stores the last tab selected by the user on the overview block.';
+$string['privacy:metadata'] = 'The myoverview block does not store any personal data.';
diff --git a/blocks/myoverview/templates/course-event-list-item.mustache b/blocks/myoverview/templates/course-event-list-item.mustache
deleted file mode 100644
index 55c0e46c883d9..0000000000000
--- a/blocks/myoverview/templates/course-event-list-item.mustache
+++ /dev/null
@@ -1,69 +0,0 @@
-{{!
- This file is part of Moodle - http://moodle.org/
-
- Moodle is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- Moodle is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with Moodle. If not, see .
-}}
-{{!
- @template block_myoverview/course-event-list-item
-
- This template renders an event list item for the myoverview block
- in the courses view.
-
- Example context (json):
- {
- "name": "Assignment due 1",
- "url": "https://www.google.com",
- "timesort": 1490320388,
- "action": {
- "name": "Submit assignment",
- "url": "https://www.google.com",
- "itemcount": 1,
- "showitemcount": true,
- "actionable": true
- },
- "icon": {
- "key": "icon",
- "component": "mod_assign",
- "alttext": "Assignment icon"
- }
- }
-}}
-
diff --git a/blocks/myoverview/templates/course-event-list.mustache b/blocks/myoverview/templates/course-event-list.mustache
deleted file mode 100644
index d7f9fb283c7ef..0000000000000
--- a/blocks/myoverview/templates/course-event-list.mustache
+++ /dev/null
@@ -1,110 +0,0 @@
-{{!
- This file is part of Moodle - http://moodle.org/
-
- Moodle is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- Moodle is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with Moodle. If not, see .
-}}
-{{!
- @template block_myoverview/course-event-list
-
- This template renders a list of events for the myoverview block
- sort by courses view.
-
- Example context (json):
- {
- "urls": {
- "noevents": "#"
- }
- }
-}}
-
-{{#js}}
-require(['jquery', 'block_myoverview/event_list'], function($, EventList) {
- var root = $("#event-list-container-{{$courseid}}{{/courseid}}");
- EventList.registerEventListeners(root);
-});
-{{/js}}
diff --git a/blocks/myoverview/templates/course-summary.mustache b/blocks/myoverview/templates/course-summary.mustache
deleted file mode 100644
index 53f40a018ca44..0000000000000
--- a/blocks/myoverview/templates/course-summary.mustache
+++ /dev/null
@@ -1,49 +0,0 @@
-{{!
- This file is part of Moodle - http://moodle.org/
-
- Moodle is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- Moodle is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with Moodle. If not, see .
-}}
-{{!
- @template block_myoverview/course-summary
-
- This template renders the course summary (view by courses) for the myoverview block.
-
- Example context (json):
- {
- "fullnamedisplay": "course 3",
- "viewurl": "https://www.google.com",
- "summary": "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout."
- }
-}}
-
diff --git a/blocks/myoverview/templates/courses-view-nav-grouping-display-filter.mustache b/blocks/myoverview/templates/courses-view-nav-grouping-display-filter.mustache
new file mode 100644
index 0000000000000..f7aa27534b42a
--- /dev/null
+++ b/blocks/myoverview/templates/courses-view-nav-grouping-display-filter.mustache
@@ -0,0 +1,40 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template block_myoverview/courses-view-nav-grouping-display-filter
+
+ This template renders the main content area for the myoverview block.
+
+ Example context (json):
+ {}
+}}
+
{{#inprogress}}
diff --git a/blocks/myoverview/templates/event-list-group.mustache b/blocks/myoverview/templates/event-list-group.mustache
deleted file mode 100644
index 340fdcbb4a6ec..0000000000000
--- a/blocks/myoverview/templates/event-list-group.mustache
+++ /dev/null
@@ -1,75 +0,0 @@
-{{!
- This file is part of Moodle - http://moodle.org/
-
- Moodle is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- Moodle is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with Moodle. If not, see .
-}}
-{{!
- @template block_myoverview/event-list-group
-
- This template renders a list of events for the myoverview block.
-
- Example context (json):
- {
- "events": [
- {
- "enddate": "Nov 4th, 10am",
- "name": "Assignment due 1",
- "url": "https://www.google.com",
- "course": {
- "fullname": "Course 1"
- },
- "action": {
- "name": "Submit assignment",
- "url": "https://www.google.com",
- "itemcount": 1
- },
- "icon": {
- "key": "icon",
- "component": "mod_assign",
- "alttext": "Assignment icon"
- }
- },
- {
- "enddate": "Nov 4th, 10am",
- "name": "Assignment due 2",
- "url": "https://www.google.com",
- "course": {
- "fullname": "Course 1"
- },
- "action": {
- "name": "Submit assignment",
- "url": "https://www.google.com",
- "itemcount": 1
- },
- "icon": {
- "key": "icon",
- "component": "mod_assign",
- "alttext": "Assignment icon"
- }
- }
- ]
- }
-}}
-
diff --git a/blocks/myoverview/templates/event-list-item.mustache b/blocks/myoverview/templates/event-list-item.mustache
deleted file mode 100644
index a269b5cb10cfa..0000000000000
--- a/blocks/myoverview/templates/event-list-item.mustache
+++ /dev/null
@@ -1,76 +0,0 @@
-{{!
- This file is part of Moodle - http://moodle.org/
-
- Moodle is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- Moodle is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with Moodle. If not, see .
-}}
-{{!
- @template block_myoverview/event-list-item
-
- This template renders an event list item for the myoverview block.
-
- Example context (json):
- {
- "name": "Assignment due 1",
- "url": "https://www.google.com",
- "timesort": 1490320388,
- "course": {
- "fullnamedisplay": "Course 1"
- },
- "action": {
- "name": "Submit assignment",
- "url": "https://www.google.com",
- "itemcount": 1,
- "showitemcount": true,
- "actionable": true
- },
- "icon": {
- "key": "icon",
- "component": "mod_assign",
- "alttext": "Assignment icon"
- }
- }
-}}
-
diff --git a/blocks/myoverview/templates/event-list.mustache b/blocks/myoverview/templates/event-list.mustache
deleted file mode 100644
index dbe3d25da7ba8..0000000000000
--- a/blocks/myoverview/templates/event-list.mustache
+++ /dev/null
@@ -1,87 +0,0 @@
-{{!
- This file is part of Moodle - http://moodle.org/
-
- Moodle is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- Moodle is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with Moodle. If not, see .
-}}
-{{!
- @template block_myoverview/event-list
-
- This template renders a list of events for the myoverview block.
-
- Example context (json):
- {
- }
-}}
-
-{{#js}}
-require(['jquery', 'block_myoverview/tab_preferences'], function($, TabPreferences) {
- var root = $('#block-myoverview-view-choices-{{uniqid}}');
- TabPreferences.registerEventListeners(root);
-});
-{{/js}}
diff --git a/blocks/myoverview/templates/timeline-view-courses.mustache b/blocks/myoverview/templates/timeline-view-courses.mustache
deleted file mode 100644
index 6f350227b8fe8..0000000000000
--- a/blocks/myoverview/templates/timeline-view-courses.mustache
+++ /dev/null
@@ -1,121 +0,0 @@
-{{!
- This file is part of Moodle - http://moodle.org/
-
- Moodle is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- Moodle is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with Moodle. If not, see .
-}}
-{{!
- @template block_myoverview/timeline-view-courses
-
- This template renders the timeline view by courses for the myoverview block.
-
- Example context (json):
- {}
-}}
-
-{{#js}}
- require(['jquery', 'core/custom_interaction_events', 'block_myoverview/event_list_by_course'],
- function($, CustomEvents, EventListByCourse) {
-
- var root = $("#sort-by-courses-view-{{uniqid}}");
- // This flag is used so that we can delay the loading of the events until the tab
- // is toggled by the user.
- var seen = false;
-
- CustomEvents.define(root, [CustomEvents.events.activate]);
- // Show more courses and load their events when the user clicks the "more courses"
- // button.
- root.on(CustomEvents.events.activate, '[data-action="more-courses"]', function(e, data) {
- var button = $(e.target);
- var blocks = root.find('[data-region="course-block"].hidden');
-
- if (blocks && blocks.length) {
- var block = blocks.first();
- EventListByCourse.init(block);
- block.removeClass('hidden');
- }
-
- // If there was only one hidden block then we have no more to show now
- // so we can disable the button.
- if (blocks && blocks.length == 1) {
- button.addClass('hidden');
- }
-
- if (data) {
- data.originalEvent.preventDefault();
- data.originalEvent.stopPropagation();
- }
- e.stopPropagation();
- });
-
- // Listen for when the user changes tab so that we can show the first set of courses
- // and load their events when they request the sort by courses view for the first time.
- root.closest('[data-region="timeline-view"]').on('shown shown.bs.tab', function(e) {
- if (seen) {
- return;
- }
-
- var tab = $(e.target);
- var tabTarget = $(tab.attr('href'));
-
- if (!tabTarget || !tabTarget.length) {
- return;
- }
-
- var viewCourses = tabTarget.find('#sort-by-courses-view-{{uniqid}}');
-
- if (viewCourses && viewCourses.length && !seen) {
- seen = true;
- viewCourses.find('[data-action="more-courses"]').trigger(CustomEvents.events.activate);
- }
- });
- });
-{{/js}}
diff --git a/blocks/myoverview/templates/timeline-view.mustache b/blocks/myoverview/templates/timeline-view.mustache
deleted file mode 100644
index 9d57cd26b59cd..0000000000000
--- a/blocks/myoverview/templates/timeline-view.mustache
+++ /dev/null
@@ -1,49 +0,0 @@
-{{!
- This file is part of Moodle - http://moodle.org/
-
- Moodle is free software: you can redistribute it and/or modify
- it under the terms of the GNU General Public License as published by
- the Free Software Foundation, either version 3 of the License, or
- (at your option) any later version.
-
- Moodle is distributed in the hope that it will be useful,
- but WITHOUT ANY WARRANTY; without even the implied warranty of
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License
- along with Moodle. If not, see .
-}}
-{{!
- @template block_myoverview/timeline-view
-
- This template renders the timeline view for the myoverview block.
-
- Example context (json):
- {}
-}}
-
\ No newline at end of file
diff --git a/blocks/myoverview/tests/behat/block_myoverview_dashboard.feature b/blocks/myoverview/tests/behat/block_myoverview_dashboard.feature
index bf6f356adda4b..de3735901bb2c 100644
--- a/blocks/myoverview/tests/behat/block_myoverview_dashboard.feature
+++ b/blocks/myoverview/tests/behat/block_myoverview_dashboard.feature
@@ -1,5 +1,5 @@
@block @block_myoverview @javascript
-Feature: The my overview block allows users to easily access their courses and see upcoming activities
+Feature: The my overview block allows users to easily access their courses
In order to enable the my overview block in a course
As a student
I can add the my overview block to my dashboard
@@ -14,59 +14,23 @@ Feature: The my overview block allows users to easily access their courses and s
| Course 1 | C1 | 0 | ##1 month ago## | ##15 days ago## |
| Course 2 | C2 | 0 | ##yesterday## | ##tomorrow## |
| Course 3 | C3 | 0 | ##first day of next month## | ##last day of next month## |
- And the following "activities" exist:
- | activity | course | idnumber | name | intro | timeopen | timeclose |
- | choice | C2 | choice1 | Test choice 1 | Test choice description | ##yesterday## | ##tomorrow## |
- | choice | C1 | choice2 | Test choice 2 | Test choice description | ##1 month ago## | ##15 days ago## |
- | choice | C3 | choice3 | Test choice 3 | Test choice description | ##first day of +5 months## | ##last day of +5 months## |
- | feedback | C2 | feedback1 | Test feedback 1 | Test feedback description | ##yesterday## | ##tomorrow## |
- | feedback | C3 | feedback3 | Test feedback 3 | Test feedback description | ##first day of +5 months## | ##last day of +5 months## |
And the following "course enrolments" exist:
| user | course | role |
| student1 | C1 | student |
| student1 | C2 | student |
| student1 | C3 | student |
- Scenario: View courses and upcoming activities on timeline view
- Given I log in as "student1"
- And I click on "Timeline" "link" in the "Course overview" "block"
- When I click on "Sort by dates" "link" in the "Course overview" "block"
- Then I should see "Next 7 days" in the "Course overview" "block"
- And I should see "Test choice 1 closes" in the "Course overview" "block"
- And I should see "View choices" in the "Course overview" "block"
- And I should see "Test feedback 1 closes" in the "Course overview" "block"
- And I should see "Answer the questions" in the "Course overview" "block"
- And I should see "Future" in the "Course overview" "block"
- And I should see "Test choice 3 closes" in the "Course overview" "block"
- And I should see "Test feedback 3 closes" in the "Course overview" "block"
- And I log out
-
- Scenario: Past activities should not be displayed on the timeline view
- Given I log in as "student1"
- And I click on "Timeline" "link" in the "Course overview" "block"
- When I click on "Sort by dates" "link" in the "Course overview" "block"
- And I should not see "Test choice 2 closes" in the "Course overview" "block"
- And I log out
-
Scenario: See the courses I am enrolled by their status on courses view
Given I log in as "student1"
- And I click on "Courses" "link" in the "Course overview" "block"
- And I click on "In progress" "link" in the "Course overview" "block"
And I should see "Course 2" in the "Course overview" "block"
And I should not see "Course 1" in the "Course overview" "block"
+ And I click on "In progress" "button" in the "Course overview" "block"
And I click on "Future" "link" in the "Course overview" "block"
And I should see "Course 3" in the "Course overview" "block"
And I should not see "Course 1" in the "Course overview" "block"
+ And I click on "Future" "button" in the "Course overview" "block"
When I click on "Past" "link" in the "Course overview" "block"
Then I should see "Course 1" in the "Course overview" "block"
And I should not see "Course 2" in the "Course overview" "block"
And I should not see "Course 3" in the "Course overview" "block"
And I log out
-
- Scenario: No activities should be displayed if the user is not enrolled
- Given I log in as "student2"
- And I click on "Timeline" "link" in the "Course overview" "block"
- And I should see "No upcoming activities" in the "Course overview" "block"
- When I click on "Courses" "link" in the "Course overview" "block"
- Then I should see "No courses" in the "Course overview" "block"
- And I log out
diff --git a/blocks/myoverview/tests/behat/block_myoverview_progress.feature b/blocks/myoverview/tests/behat/block_myoverview_progress.feature
index 44cd5398341e9..5ed56564e8095 100644
--- a/blocks/myoverview/tests/behat/block_myoverview_progress.feature
+++ b/blocks/myoverview/tests/behat/block_myoverview_progress.feature
@@ -20,18 +20,6 @@ Feature: Course overview block show users their progress on courses
| teacher1 | C1 | editingteacher |
| student1 | C1 | student |
- Scenario: Course progress percentage should not be displayed if completion is not enabled
- Given I log in as "student1"
- And I click on "Timeline" "link" in the "Course overview" "block"
- When I click on "Sort by courses" "link" in the "Course overview" "block"
- Then I should see "Test choice 1 closes" in the "#myoverview_timeline_courses" "css_element"
- And I should not see "0%" in the "Course overview" "block"
- And I click on "Courses" "link" in the "Course overview" "block"
- And I click on "In progress" "link" in the "Course overview" "block"
- And I should see "Course 1" in the "Course overview" "block"
- And I should not see "0%" in the "Course overview" "block"
- And I log out
-
Scenario: User complete activity and verify his progress
Given I log in as "teacher1"
And I am on "Course 1" course homepage with editing mode on
@@ -43,21 +31,11 @@ Feature: Course overview block show users their progress on courses
And I press "Save and return to course"
And I log out
And I log in as "student1"
- And I click on "Sort by courses" "link" in the "Course overview" "block"
- And I should see "Test choice 1 closes" in the "#myoverview_timeline_courses" "css_element"
- And I should see "0%" in the "Course overview" "block"
- And I click on "Courses" "link" in the "Course overview" "block"
- When I click on "In progress" "link" in the "Course overview" "block"
Then I should see "Course 1" in the "Course overview" "block"
And I should see "0%" in the "Course overview" "block"
And I am on "Course 1" course homepage
And I follow "Test choice 1"
And I follow "Dashboard" in the user menu
- And I click on "Timeline" "link" in the "Course overview" "block"
- And I click on "Sort by courses" "link" in the "Course overview" "block"
- And I should see "100%" in the "Course overview" "block"
- And I click on "Courses" "link" in the "Course overview" "block"
- And I click on "In progress" "link" in the "Course overview" "block"
And I should see "Course 1" in the "Course overview" "block"
And I should see "100%" in the "Course overview" "block"
And I log out
diff --git a/blocks/myoverview/tests/privacy_test.php b/blocks/myoverview/tests/privacy_test.php
deleted file mode 100644
index 875dd033abe45..0000000000000
--- a/blocks/myoverview/tests/privacy_test.php
+++ /dev/null
@@ -1,80 +0,0 @@
-.
-
-/**
- * Unit tests for the block_myoverview implementation of the privacy API.
- *
- * @package block_myoverview
- * @category test
- * @copyright 2018 Adrian Greeve
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-use \core_privacy\local\request\writer;
-use \block_myoverview\privacy\provider;
-
-/**
- * Unit tests for the block_myoverview implementation of the privacy API.
- *
- * @copyright 2018 Adrian Greeve
- * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class block_myoverview_privacy_testcase extends \core_privacy\tests\provider_testcase {
-
- /**
- * Ensure that export_user_preferences returns no data if the user has not visited the myoverview block.
- */
- public function test_export_user_preferences_no_pref() {
- $this->resetAfterTest();
-
- $user = $this->getDataGenerator()->create_user();
- provider::export_user_preferences($user->id);
- $writer = writer::with_context(\context_system::instance());
- $this->assertFalse($writer->has_any_data());
- }
-
- /**
- * Test that the preference courses is exported properly.
- */
- public function test_export_user_preferences_course_preference() {
- $this->resetAfterTest();
-
- $user = $this->getDataGenerator()->create_user();
- set_user_preference('block_myoverview_last_tab', 'courses', $user);
-
- provider::export_user_preferences($user->id);
- $writer = writer::with_context(\context_system::instance());
- $blockpreferences = $writer->get_user_preferences('block_myoverview');
- $this->assertEquals('courses', $blockpreferences->block_myoverview_last_tab->value);
- }
-
- /**
- * Test that the preference timeline is exported properly.
- */
- public function test_export_user_preferences_timeline_preference() {
- $this->resetAfterTest();
-
- $user = $this->getDataGenerator()->create_user();
- set_user_preference('block_myoverview_last_tab', 'timeline', $user);
-
- provider::export_user_preferences($user->id);
- $writer = writer::with_context(\context_system::instance());
- $blockpreferences = $writer->get_user_preferences('block_myoverview');
- $this->assertEquals('timeline', $blockpreferences->block_myoverview_last_tab->value);
- }
-}
diff --git a/blocks/myoverview/version.php b/blocks/myoverview/version.php
index 26ccc6dffa432..6b8960ce85a3a 100644
--- a/blocks/myoverview/version.php
+++ b/blocks/myoverview/version.php
@@ -24,6 +24,6 @@
defined('MOODLE_INTERNAL') || die();
-$plugin->version = 2018051400; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version = 2018092700; // The current plugin version (Date: YYYYMMDDXX).
$plugin->requires = 2018050800; // Requires this Moodle version.
$plugin->component = 'block_myoverview'; // Full name of the plugin (used for diagnostics).
diff --git a/blocks/myoverview/amd/build/calendar_events_repository.min.js b/blocks/timeline/amd/build/calendar_events_repository.min.js
similarity index 100%
rename from blocks/myoverview/amd/build/calendar_events_repository.min.js
rename to blocks/timeline/amd/build/calendar_events_repository.min.js
diff --git a/blocks/timeline/amd/build/event_list.min.js b/blocks/timeline/amd/build/event_list.min.js
new file mode 100644
index 0000000000000..cd209c581a08f
--- /dev/null
+++ b/blocks/timeline/amd/build/event_list.min.js
@@ -0,0 +1 @@
+define(["jquery","core/notification","core/templates","core/paged_content_factory","core/str","core/user_date","block_timeline/calendar_events_repository"],function(a,b,c,d,e,f,g){var h=86400,i={EMPTY_MESSAGE:'[data-region="empty-message"]',ROOT:'[data-region="event-list-container"]',EVENT_LIST_CONTENT:'[data-region="event-list-content"]',EVENT_LIST_LOADING_PLACEHOLDER:'[data-region="event-list-loading-placeholder"]'},j={EVENT_LIST_CONTENT:"block_timeline/event-list-content"},k={ignoreControlWhileLoading:!0,controlPlacementBottom:!0,ariaLabels:{itemsperpagecomponents:"ariaeventlistpagelimit, block_timeline"}},l=function(a){a.find(i.EVENT_LIST_CONTENT).addClass("hidden"),a.find(i.EMPTY_MESSAGE).removeClass("hidden")},m=function(a){a.find(i.EVENT_LIST_CONTENT).removeClass("hidden"),a.find(i.EMPTY_MESSAGE).addClass("hidden")},n=function(a){a.find(i.EVENT_LIST_CONTENT).empty()},o=function(a,b){var c={},d={eventsbyday:[]};return a.forEach(function(a){var d=f.getUserMidnightForTimestamp(a.timesort,b);c[d]?c[d].push(a):c[d]=[a]}),Object.keys(c).forEach(function(a){var e=c[a];d.eventsbyday.push({past:a0},x=function(a){return parseInt(a.attr("data-offset"),10)},y=function(a,b){a.attr("data-offset",b)},z=function(a){return parseInt(a.attr("data-limit"),10)},A=function(a){return parseInt(a.attr("data-days-offset"),10)},B=function(a){var b=a.attr("data-days-limit");return void 0!=b?parseInt(b,10):void 0},C=function(a){return parseInt(a.attr("data-midnight"),10)},D=function(a){var b=C(a),c=A(a);return b+c*o},E=function(a){var b=C(a),c=B(a);return void 0!=c&&b+c*o},F=function(a,b,c,d){var e={courseids:a,starttime:b,limit:c};return d&&(e.endtime=d),h.queryByCourses(e)},G=function(a){return a.data("last-event-load-time")},H=function(a,b){a.data("last-event-load-time",b)},I=function(a,b){return G(a)>b},J=function(a,b,c){var d=a.map(function(a){return a.id});return F(d,b,m+1,c)},K=function(a,b,c,d,f,g){return e.render(j.COURSE_ITEMS,{courses:a,midnight:c,hasdaysoffset:!0,hasdayslimit:void 0!=f,daysoffset:d,dayslimit:f,nodayslimit:void 0==f,urls:{noevents:g}}).then(function(a){return p(b),a?v(b,a):w(b)||u(b),a}).then(function(c){return a.length
+ * @module block_timeline/calendar_events_repository
+ * @copyright 2018 Ryan Wyllie
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define(['jquery', 'core/ajax', 'core/notification'], function($, Ajax, Notification) {
diff --git a/blocks/timeline/amd/src/event_list.js b/blocks/timeline/amd/src/event_list.js
new file mode 100644
index 0000000000000..dfd1517a5613d
--- /dev/null
+++ b/blocks/timeline/amd/src/event_list.js
@@ -0,0 +1,465 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Javascript to load and render the list of calendar events for a
+ * given day range.
+ *
+ * @module block_timeline/event_list
+ * @package block_timeline
+ * @copyright 2016 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(
+[
+ 'jquery',
+ 'core/notification',
+ 'core/templates',
+ 'core/paged_content_factory',
+ 'core/str',
+ 'core/user_date',
+ 'block_timeline/calendar_events_repository'
+],
+function(
+ $,
+ Notification,
+ Templates,
+ PagedContentFactory,
+ Str,
+ UserDate,
+ CalendarEventsRepository
+) {
+
+ var SECONDS_IN_DAY = 60 * 60 * 24;
+
+ var SELECTORS = {
+ EMPTY_MESSAGE: '[data-region="empty-message"]',
+ ROOT: '[data-region="event-list-container"]',
+ EVENT_LIST_CONTENT: '[data-region="event-list-content"]',
+ EVENT_LIST_LOADING_PLACEHOLDER: '[data-region="event-list-loading-placeholder"]',
+ };
+
+ var TEMPLATES = {
+ EVENT_LIST_CONTENT: 'block_timeline/event-list-content'
+ };
+
+ // We want the paged content controls below the paged content area
+ // and the controls should be ignored while data is loading.
+ var DEFAULT_PAGED_CONTENT_CONFIG = {
+ ignoreControlWhileLoading: true,
+ controlPlacementBottom: true,
+ ariaLabels: {
+ itemsperpagecomponents: 'ariaeventlistpagelimit, block_timeline',
+ }
+ };
+
+ /**
+ * Hide the content area and display the empty content message.
+ *
+ * @param {object} root The container element
+ */
+ var hideContent = function(root) {
+ root.find(SELECTORS.EVENT_LIST_CONTENT).addClass('hidden');
+ root.find(SELECTORS.EMPTY_MESSAGE).removeClass('hidden');
+ };
+
+ /**
+ * Show the content area and hide the empty content message.
+ *
+ * @param {object} root The container element
+ */
+ var showContent = function(root) {
+ root.find(SELECTORS.EVENT_LIST_CONTENT).removeClass('hidden');
+ root.find(SELECTORS.EMPTY_MESSAGE).addClass('hidden');
+ };
+
+ /**
+ * Empty the content area.
+ *
+ * @param {object} root The container element
+ */
+ var emptyContent = function(root) {
+ root.find(SELECTORS.EVENT_LIST_CONTENT).empty();
+ };
+
+ /**
+ * Construct the template context from a list of calendar events. The events
+ * are grouped by which day they are on. The day is calculated from the user's
+ * midnight timestamp to ensure that the calculation is timezone agnostic.
+ *
+ * The return data structure will look like:
+ * {
+ * eventsbyday: [
+ * {
+ * dayTimestamp: 1533744000,
+ * events: [
+ * { ...event 1 data... },
+ * { ...event 2 data... }
+ * ]
+ * },
+ * {
+ * dayTimestamp: 1533830400,
+ * events: [
+ * { ...event 3 data... },
+ * { ...event 4 data... }
+ * ]
+ * }
+ * ]
+ * }
+ *
+ * Each day timestamp is the day's midnight in the user's timezone.
+ *
+ * @param {array} calendarEvents List of calendar events
+ * @param {Number} midnight A timestamp representing midnight in the user's timezone
+ * @return {object}
+ */
+ var buildTemplateContext = function(calendarEvents, midnight) {
+ var eventsByDay = {};
+ var templateContext = {
+ eventsbyday: []
+ };
+
+ calendarEvents.forEach(function(calendarEvent) {
+ var dayTimestamp = UserDate.getUserMidnightForTimestamp(calendarEvent.timesort, midnight);
+ if (eventsByDay[dayTimestamp]) {
+ eventsByDay[dayTimestamp].push(calendarEvent);
+ } else {
+ eventsByDay[dayTimestamp] = [calendarEvent];
+ }
+ });
+
+ Object.keys(eventsByDay).forEach(function(dayTimestamp) {
+ var events = eventsByDay[dayTimestamp];
+ templateContext.eventsbyday.push({
+ past: dayTimestamp < midnight,
+ dayTimestamp: dayTimestamp,
+ events: events
+ });
+ });
+
+ return templateContext;
+ };
+
+ /**
+ * Render the HTML for the given calendar events.
+ *
+ * @param {array} calendarEvents A list of calendar events
+ * @param {Number} midnight A timestamp representing midnight for the user
+ * @return {promise} Resolved with HTML and JS strings.
+ */
+ var render = function(calendarEvents, midnight) {
+ var templateContext = buildTemplateContext(calendarEvents, midnight);
+ var templateName = TEMPLATES.EVENT_LIST_CONTENT;
+
+ return Templates.render(templateName, templateContext);
+ };
+
+ /**
+ * Retrieve a list of calendar events from the server for the given
+ * constraints.
+ *
+ * @param {Number} midnight The user's midnight time in unix timestamp.
+ * @param {Number} limit Limit the result set to this number of items
+ * @param {Number} daysOffset How many days (from midnight) to offset the results from
+ * @param {int|undefined} daysLimit How many dates (from midnight) to limit the result to
+ * @param {int|falsey} lastId The ID of the last seen event (if any)
+ * @param {int|undefined} courseId Course ID to restrict events to
+ * @return {promise} A jquery promise
+ */
+ var load = function(midnight, limit, daysOffset, daysLimit, lastId, courseId) {
+ var startTime = midnight + (daysOffset * SECONDS_IN_DAY);
+ var endTime = daysLimit != undefined ? midnight + (daysLimit * SECONDS_IN_DAY) : false;
+
+ var args = {
+ starttime: startTime,
+ limit: limit,
+ };
+
+ if (lastId) {
+ args.aftereventid = lastId;
+ }
+
+ if (endTime) {
+ args.endtime = endTime;
+ }
+
+ if (courseId) {
+ // If we have a course id then we only want events from that course.
+ args.courseid = courseId;
+ return CalendarEventsRepository.queryByCourse(args);
+ } else {
+ // Otherwise we want events from any course.
+ return CalendarEventsRepository.queryByTime(args);
+ }
+ };
+
+ /**
+ * Handle a single page request from the paged content. Uses the given page data to request
+ * the events from the server.
+ *
+ * Checks the given preloadedPages before sending a request to the server to make sure we
+ * don't load data unnecessarily.
+ *
+ * @param {object} pageData A single page data (see core/paged_content_pages for more info).
+ * @param {object} actions Paged content actions (see core/paged_content_pages for more info).
+ * @param {Number} midnight The user's midnight time in unix timestamp.
+ * @param {object} lastIds The last event ID for each loaded page. Page number is key, id is value.
+ * @param {object} preloadedPages An object of preloaded page data. Page number as key, data promise as value.
+ * @param {int|undefined} courseId Course ID to restrict events to
+ * @param {Number} daysOffset How many days (from midnight) to offset the results from
+ * @param {int|undefined} daysLimit How many dates (from midnight) to limit the result to
+ * @return {object} jQuery promise resolved with calendar events.
+ */
+ var loadEventsFromPageData = function(
+ pageData,
+ actions,
+ midnight,
+ lastIds,
+ preloadedPages,
+ courseId,
+ daysOffset,
+ daysLimit
+ ) {
+ var pageNumber = pageData.pageNumber;
+ var limit = pageData.limit;
+ var lastPageNumber = pageNumber;
+
+ // This is here to protect us if, for some reason, the pages
+ // are loaded out of order somehow and we don't have a reference
+ // to the previous page. In that case, scan back to find the most
+ // recent page we've seen.
+ while (!lastIds.hasOwnProperty(lastPageNumber)) {
+ lastPageNumber--;
+ }
+ // Use the last id of the most recent page.
+ var lastId = lastIds[lastPageNumber];
+ var eventsPromise = null;
+
+ if (preloadedPages && preloadedPages.hasOwnProperty(pageNumber)) {
+ // This page has been preloaded so use that rather than load the values
+ // again.
+ eventsPromise = preloadedPages[pageNumber];
+ } else {
+ // Load one more than the given limit so that we can tell if there
+ // is more content to load after this.
+ eventsPromise = load(midnight, limit + 1, daysOffset, daysLimit, lastId, courseId);
+ }
+
+ return eventsPromise.then(function(result) {
+ if (!result.events.length) {
+ // If we didn't get any events back then tell the paged content
+ // that we're done loading.
+ actions.allItemsLoaded(pageNumber);
+ return [];
+ }
+
+ var calendarEvents = result.events;
+ // We expect to receive limit + 1 events back from the server.
+ // Any less means there are no more events to load.
+ var loadedAll = calendarEvents.length <= limit;
+
+ if (loadedAll) {
+ // Tell the pagination that everything is loaded.
+ actions.allItemsLoaded(pageNumber);
+ } else {
+ // Remove the last element from the array because it isn't
+ // needed in this result set.
+ calendarEvents.pop();
+ }
+
+ return calendarEvents;
+ });
+ };
+
+ /**
+ * Use the paged content factory to create a paged content element for showing
+ * the event list. We only provide a page limit to the factory because we don't
+ * know exactly how many pages we'll need. This creates a paging bar with just
+ * next/previous buttons.
+ *
+ * This function specifies the callback for loading the event data that the user
+ * is requesting.
+ *
+ * @param {int|array} pageLimit A single limit or list of limits as options for the paged content
+ * @param {object} preloadedPages An object of preloaded page data. Page number as key, data promise as value.
+ * @param {Number} midnight The user's midnight time in unix timestamp.
+ * @param {object} firstLoad A jQuery promise to be resolved after the first set of data is loaded.
+ * @param {int|undefined} courseId Course ID to restrict events to
+ * @param {Number} daysOffset How many days (from midnight) to offset the results from
+ * @param {int|undefined} daysLimit How many dates (from midnight) to limit the result to
+ * @param {string} paginationAriaLabel String to set as the aria label for the pagination bar.
+ * @return {object} jQuery promise.
+ */
+ var createPagedContent = function(
+ pageLimit,
+ preloadedPages,
+ midnight,
+ firstLoad,
+ courseId,
+ daysOffset,
+ daysLimit,
+ paginationAriaLabel
+ ) {
+ // Remember the last event id we loaded on each page because we can't
+ // use the offset value since the backend can skip events if the user doesn't
+ // have the capability to see them. Instead we load the next page of events
+ // based on the last seen event id.
+ var lastIds = {'1': 0};
+ var hasContent = false;
+ var config = $.extend({}, DEFAULT_PAGED_CONTENT_CONFIG);
+
+ return Str.get_string(
+ 'ariaeventlistpagelimit',
+ 'block_timeline',
+ $.isArray(pageLimit) ? pageLimit[0] : pageLimit
+ )
+ .then(function(string) {
+ config.ariaLabels.itemsperpage = string;
+ config.ariaLabels.paginationnav = paginationAriaLabel;
+ return string;
+ })
+ .then(function() {
+ return PagedContentFactory.createWithLimit(
+ pageLimit,
+ function(pagesData, actions) {
+ var promises = [];
+
+ pagesData.forEach(function(pageData) {
+ var pageNumber = pageData.pageNumber;
+ // Load the page data.
+ var pagePromise = loadEventsFromPageData(
+ pageData,
+ actions,
+ midnight,
+ lastIds,
+ preloadedPages,
+ courseId,
+ daysOffset,
+ daysLimit
+ ).then(function(calendarEvents) {
+ if (calendarEvents.length) {
+ // Remember that we've loaded content.
+ hasContent = true;
+ // Remember the last id we've seen.
+ var lastEventId = calendarEvents[calendarEvents.length - 1].id;
+ // Record the id that the next page will need to start from.
+ lastIds[pageNumber + 1] = lastEventId;
+ // Get the HTML and JS for these calendar events.
+ return render(calendarEvents, midnight);
+ } else {
+ return calendarEvents;
+ }
+ })
+ .catch(Notification.exception);
+
+ promises.push(pagePromise);
+ });
+
+ $.when.apply($, promises).then(function() {
+ // Tell the calling code that the first page has been loaded
+ // and whether it contains any content.
+ firstLoad.resolve(hasContent);
+ return;
+ })
+ .catch(function() {
+ firstLoad.resolve(hasContent);
+ });
+
+ return promises;
+ },
+ config
+ );
+ });
+ };
+
+ /**
+ * Create a paged content region for the calendar events in the given root element.
+ * The content of the root element are replaced with a new paged content section
+ * each time this function is called.
+ *
+ * This function will be called each time the offset or limit values are changed to
+ * reload the event list region.
+ *
+ * @param {object} root The event list container element
+ * @param {int|array} pageLimit A single limit or list of limits as options for the paged content
+ * @param {object} preloadedPages An object of preloaded page data. Page number as key, data promise as value.
+ * @param {string} paginationAriaLabel String to set as the aria label for the pagination bar.
+ */
+ var init = function(root, pageLimit, preloadedPages, paginationAriaLabel) {
+ root = $(root);
+
+ // Create a promise that will be resolved once the first set of page
+ // data has been loaded. This ensures that the loading placeholder isn't
+ // hidden until we have all of the data back to prevent the page elements
+ // jumping around.
+ var firstLoad = $.Deferred();
+ var eventListContent = root.find(SELECTORS.EVENT_LIST_CONTENT);
+ var loadingPlaceholder = root.find(SELECTORS.EVENT_LIST_LOADING_PLACEHOLDER);
+ var courseId = root.attr('data-course-id');
+ var daysOffset = parseInt(root.attr('data-days-offset'), 10);
+ var daysLimit = root.attr('data-days-limit');
+ var midnight = parseInt(root.attr('data-midnight'), 10);
+
+ // Make sure the content area and loading placeholder is visible.
+ // This is because the init function can be called to re-initialise
+ // an existing event list area.
+ emptyContent(root);
+ showContent(root);
+ loadingPlaceholder.removeClass('hidden');
+
+ // Days limit isn't mandatory.
+ if (daysLimit != undefined) {
+ daysLimit = parseInt(daysLimit, 10);
+ }
+
+ // Created the paged content element.
+ createPagedContent(pageLimit, preloadedPages, midnight, firstLoad, courseId, daysOffset, daysLimit, paginationAriaLabel)
+ .then(function(html, js) {
+ html = $(html);
+ // Hide the content for now.
+ html.addClass('hidden');
+ // Replace existing elements with the newly created paged content.
+ // If we're reinitialising an existing event list this will replace
+ // the old event list (including removing any event handlers).
+ Templates.replaceNodeContents(eventListContent, html, js);
+
+ firstLoad.then(function(hasContent) {
+ // Prevent changing page elements too much by only showing the content
+ // once we've loaded some data for the first time. This allows our
+ // fancy loading placeholder to shine.
+ html.removeClass('hidden');
+ loadingPlaceholder.addClass('hidden');
+
+ if (!hasContent) {
+ // If we didn't get any data then show the empty data message.
+ hideContent(root);
+ }
+
+ return hasContent;
+ })
+ .catch(function() {
+ return false;
+ });
+
+ return html;
+ })
+ .catch(Notification.exception);
+ };
+
+ return {
+ init: init,
+ rootSelector: SELECTORS.ROOT,
+ };
+});
diff --git a/blocks/timeline/amd/src/main.js b/blocks/timeline/amd/src/main.js
new file mode 100644
index 0000000000000..334248b7f491a
--- /dev/null
+++ b/blocks/timeline/amd/src/main.js
@@ -0,0 +1,57 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Javascript to initialise the timeline block.
+ *
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(
+[
+ 'jquery',
+ 'block_timeline/view_nav',
+ 'block_timeline/view'
+],
+function(
+ $,
+ ViewNav,
+ View
+) {
+
+ var SELECTORS = {
+ TIMELINE_VIEW: '[data-region="timeline-view"]'
+ };
+
+ /**
+ * Initialise all of the modules for the timeline block.
+ *
+ * @param {object} root The root element for the timeline block.
+ */
+ var init = function(root) {
+ root = $(root);
+ var viewRoot = root.find(SELECTORS.TIMELINE_VIEW);
+
+ // Initialise the timeline navigation elements.
+ ViewNav.init(root, viewRoot);
+ // Initialise the timeline view modules.
+ View.init(viewRoot);
+ };
+
+ return {
+ init: init
+ };
+});
diff --git a/blocks/timeline/amd/src/view.js b/blocks/timeline/amd/src/view.js
new file mode 100644
index 0000000000000..89430a77fe5de
--- /dev/null
+++ b/blocks/timeline/amd/src/view.js
@@ -0,0 +1,97 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Manage the timeline view for the timeline block.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(
+[
+ 'jquery',
+ 'block_timeline/view_dates',
+ 'block_timeline/view_courses',
+],
+function(
+ $,
+ ViewDates,
+ ViewCourses
+) {
+
+ var SELECTORS = {
+ TIMELINE_DATES_VIEW: '[data-region="view-dates"]',
+ TIMELINE_COURSES_VIEW: '[data-region="view-courses"]',
+ };
+
+ /**
+ * Intialise the timeline dates and courses views on page load.
+ * This function should only be called once per page load because
+ * it can cause event listeners to be added to the page.
+ *
+ * @param {object} root The root element for the timeline view.
+ */
+ var init = function(root) {
+ root = $(root);
+ var datesViewRoot = root.find(SELECTORS.TIMELINE_DATES_VIEW);
+ var coursesViewRoot = root.find(SELECTORS.TIMELINE_COURSES_VIEW);
+
+ ViewDates.init(datesViewRoot);
+ ViewCourses.init(coursesViewRoot);
+ };
+
+ /**
+ * Reset the timeline dates and courses views to their original
+ * state on first page load.
+ *
+ * This is called when configuration has changed for the event lists
+ * to cause them to reload their data.
+ *
+ * @param {object} root The root element for the timeline view.
+ */
+ var reset = function(root) {
+ var datesViewRoot = root.find(SELECTORS.TIMELINE_DATES_VIEW);
+ var coursesViewRoot = root.find(SELECTORS.TIMELINE_COURSES_VIEW);
+ ViewDates.reset(datesViewRoot);
+ ViewCourses.reset(coursesViewRoot);
+ };
+
+ /**
+ * Tell the timeline dates or courses view that it has been displayed.
+ *
+ * This is called each time one of the views is displayed and is used to
+ * lazy load the data within it on first load.
+ *
+ * @param {object} root The root element for the timeline view.
+ */
+ var shown = function(root) {
+ var datesViewRoot = root.find(SELECTORS.TIMELINE_DATES_VIEW);
+ var coursesViewRoot = root.find(SELECTORS.TIMELINE_COURSES_VIEW);
+
+ if (datesViewRoot.hasClass('active')) {
+ ViewDates.shown(datesViewRoot);
+ } else {
+ ViewCourses.shown(coursesViewRoot);
+ }
+ };
+
+ return {
+ init: init,
+ reset: reset,
+ shown: shown,
+ };
+});
diff --git a/blocks/timeline/amd/src/view_courses.js b/blocks/timeline/amd/src/view_courses.js
new file mode 100644
index 0000000000000..4749c920351dd
--- /dev/null
+++ b/blocks/timeline/amd/src/view_courses.js
@@ -0,0 +1,606 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Manage the timeline courses view for the timeline block.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(
+[
+ 'jquery',
+ 'core/notification',
+ 'core/custom_interaction_events',
+ 'core/str',
+ 'core/templates',
+ 'block_timeline/event_list',
+ 'core_course/repository',
+ 'block_timeline/calendar_events_repository'
+],
+function(
+ $,
+ Notification,
+ CustomEvents,
+ Str,
+ Templates,
+ EventList,
+ CourseRepository,
+ EventsRepository
+) {
+
+ var SELECTORS = {
+ MORE_COURSES_BUTTON: '[data-action="more-courses"]',
+ MORE_COURSES_BUTTON_CONTAINER: '[data-region="more-courses-button-container"]',
+ NO_COURSES_EMPTY_MESSAGE: '[data-region="no-courses-empty-message"]',
+ COURSES_LIST: '[data-region="courses-list"]',
+ COURSE_ITEMS_LOADING_PLACEHOLDER: '[data-region="course-items-loading-placeholder"]',
+ COURSE_EVENTS_CONTAINER: '[data-region="course-events-container"]',
+ COURSE_NAME: '[data-region="course-name"]',
+ LOADING_ICON: '.loading-icon'
+ };
+
+ var TEMPLATES = {
+ COURSE_ITEMS: 'block_timeline/course-items',
+ LOADING_ICON: 'core/loading'
+ };
+
+ var COURSE_CLASSIFICATION = 'inprogress';
+ var COURSE_SORT = 'fullname asc';
+ var COURSE_EVENT_LIMIT = 5;
+ var COURSE_LIMIT = 2;
+ var SECONDS_IN_DAY = 60 * 60 * 24;
+
+ /**
+ * Hide the loading placeholder elements.
+ *
+ * @param {object} root The rool element.
+ */
+ var hideLoadingPlaceholder = function(root) {
+ root.find(SELECTORS.COURSE_ITEMS_LOADING_PLACEHOLDER).addClass('hidden');
+ };
+
+ /**
+ * Hide the "more courses" button.
+ *
+ * @param {object} root The rool element.
+ */
+ var hideMoreCoursesButton = function(root) {
+ root.find(SELECTORS.MORE_COURSES_BUTTON_CONTAINER).addClass('hidden');
+ };
+
+ /**
+ * Show the "more courses" button.
+ *
+ * @param {object} root The rool element.
+ */
+ var showMoreCoursesButton = function(root) {
+ root.find(SELECTORS.MORE_COURSES_BUTTON_CONTAINER).removeClass('hidden');
+ };
+
+ /**
+ * Disable the "more courses" button and show the loading spinner.
+ *
+ * @param {object} root The rool element.
+ */
+ var enableMoreCoursesButtonLoading = function(root) {
+ var button = root.find(SELECTORS.MORE_COURSES_BUTTON);
+ button.prop('disabled', true);
+ Templates.render(TEMPLATES.LOADING_ICON, {})
+ .then(function(html) {
+ button.append(html);
+ return html;
+ })
+ .catch(function() {
+ // It's not important if this false so just do so silently.
+ return false;
+ });
+ };
+
+ /**
+ * Enable the "more courses" button and remove the loading spinner.
+ *
+ * @param {object} root The rool element.
+ */
+ var disableMoreCoursesButtonLoading = function(root) {
+ var button = root.find(SELECTORS.MORE_COURSES_BUTTON);
+ button.prop('disabled', false);
+ button.find(SELECTORS.LOADING_ICON).remove();
+ };
+
+ /**
+ * Display the message for when there are no courses available.
+ *
+ * @param {object} root The rool element.
+ */
+ var showNoCoursesEmptyMessage = function(root) {
+ root.find(SELECTORS.NO_COURSES_EMPTY_MESSAGE).removeClass('hidden');
+ };
+
+ /**
+ * Render the course items HTML to the page.
+ *
+ * @param {object} root The rool element.
+ * @param {string} html The course items HTML to render.
+ */
+ var renderCourseItemsHTML = function(root, html) {
+ var container = root.find(SELECTORS.COURSES_LIST);
+ Templates.appendNodeContents(container, html, '');
+ };
+
+ /**
+ * Check if any courses have been loaded.
+ *
+ * @param {object} root The rool element.
+ * @return {bool}
+ */
+ var hasLoadedCourses = function(root) {
+ return root.find(SELECTORS.COURSE_EVENTS_CONTAINER).length > 0;
+ };
+
+ /**
+ * Return the offset value for fetching courses.
+ *
+ * @param {object} root The rool element.
+ * @return {Number}
+ */
+ var getOffset = function(root) {
+ return parseInt(root.attr('data-offset'), 10);
+ };
+
+ /**
+ * Set the offset value for fetching courses.
+ *
+ * @param {object} root The rool element.
+ * @param {Number} offset Offset value.
+ */
+ var setOffset = function(root, offset) {
+ root.attr('data-offset', offset);
+ };
+
+ /**
+ * Return the limit value for fetching courses.
+ *
+ * @param {object} root The rool element.
+ * @return {Number}
+ */
+ var getLimit = function(root) {
+ return parseInt(root.attr('data-limit'), 10);
+ };
+
+ /**
+ * Return the days offset value for fetching events.
+ *
+ * @param {object} root The rool element.
+ * @return {Number}
+ */
+ var getDaysOffset = function(root) {
+ return parseInt(root.attr('data-days-offset'), 10);
+ };
+
+ /**
+ * Return the days limit value for fetching events. The days
+ * limit is optional so undefined will be returned if it isn't
+ * set.
+ *
+ * @param {object} root The rool element.
+ * @return {int|undefined}
+ */
+ var getDaysLimit = function(root) {
+ var daysLimit = root.attr('data-days-limit');
+ return daysLimit != undefined ? parseInt(daysLimit, 10) : undefined;
+ };
+
+ /**
+ * Return the timestamp for the user's midnight.
+ *
+ * @param {object} root The rool element.
+ * @return {Number}
+ */
+ var getMidnight = function(root) {
+ return parseInt(root.attr('data-midnight'), 10);
+ };
+
+ /**
+ * Return the start time for fetching events. This is calculated
+ * based on the user's midnight value so that timezones are
+ * preserved.
+ *
+ * @param {object} root The rool element.
+ * @return {Number}
+ */
+ var getStartTime = function(root) {
+ var midnight = getMidnight(root);
+ var daysOffset = getDaysOffset(root);
+ return midnight + (daysOffset * SECONDS_IN_DAY);
+ };
+
+ /**
+ * Return the end time for fetching events. This is calculated
+ * based on the user's midnight value so that timezones are
+ * preserved.
+ *
+ * @param {object} root The rool element.
+ * @return {Number}
+ */
+ var getEndTime = function(root) {
+ var midnight = getMidnight(root);
+ var daysLimit = getDaysLimit(root);
+ return daysLimit != undefined ? midnight + (daysLimit * SECONDS_IN_DAY) : false;
+ };
+
+ /**
+ * Get a list of events for the given course ids. Returns a promise that will
+ * be resolved with the events.
+ *
+ * @param {array} courseIds The list of course ids to fetch events for.
+ * @param {Number} startTime Timestamp to fetch events from.
+ * @param {Number} limit Limit to the number of events (this applies per course, not total)
+ * @param {Number} endTime Timestamp to fetch events to.
+ * @return {object} jQuery promise.
+ */
+ var getEventsForCourseIds = function(courseIds, startTime, limit, endTime) {
+ var args = {
+ courseids: courseIds,
+ starttime: startTime,
+ limit: limit
+ };
+
+ if (endTime) {
+ args.endtime = endTime;
+ }
+
+ return EventsRepository.queryByCourses(args);
+ };
+
+ /**
+ * Get the last time the events were reloaded.
+ *
+ * @param {object} root The rool element.
+ * @return {Number}
+ */
+ var getEventReloadTime = function(root) {
+ return root.data('last-event-load-time');
+ };
+
+ /**
+ * Set the last time the events were reloaded.
+ *
+ * @param {object} root The rool element.
+ * @param {Number} time Timestamp in milliseconds.
+ */
+ var setEventReloadTime = function(root, time) {
+ root.data('last-event-load-time', time);
+ };
+
+ /**
+ * Check if events have begun reloading since the given
+ * time.
+ *
+ * @param {object} root The rool element.
+ * @param {Number} time Timestamp in milliseconds.
+ * @return {bool}
+ */
+ var hasReloadedEventsSince = function(root, time) {
+ return getEventReloadTime(root) > time;
+ };
+
+ /**
+ * Send a request to the server to load the events for the courses.
+ *
+ * @param {array} courses List of course objects.
+ * @param {Number} startTime Timestamp to load events after.
+ * @param {int|undefined} endTime Timestamp to load events up until.
+ * @return {object} jQuery promise resolved with the events.
+ */
+ var loadEventsForCourses = function(courses, startTime, endTime) {
+ var courseIds = courses.map(function(course) {
+ return course.id;
+ });
+
+ return getEventsForCourseIds(courseIds, startTime, COURSE_EVENT_LIMIT + 1, endTime);
+ };
+
+ /**
+ * Render the courses in the DOM once the server has returned the courses.
+ *
+ * @param {array} courses List of course objects.
+ * @param {object} root The root element
+ * @param {Number} midnight The midnight timestamp in the user's timezone.
+ * @param {Number} daysOffset Number of days from today to offset the events.
+ * @param {Number} daysLimit Number of days from today to limit the events to.
+ * @param {string} noEventsURL URL for the image to display for no events.
+ * @return {object} jQuery promise resolved after rendering is complete.
+ */
+ var updateDisplayFromCourses = function(courses, root, midnight, daysOffset, daysLimit, noEventsURL) {
+ // Render the courses template.
+ return Templates.render(TEMPLATES.COURSE_ITEMS, {
+ courses: courses,
+ midnight: midnight,
+ hasdaysoffset: true,
+ hasdayslimit: daysLimit != undefined,
+ daysoffset: daysOffset,
+ dayslimit: daysLimit,
+ nodayslimit: daysLimit == undefined,
+ urls: {
+ noevents: noEventsURL
+ }
+ }).then(function(html) {
+ hideLoadingPlaceholder(root);
+
+ if (html) {
+ // Template rendering is complete and we have the HTML so we can
+ // add it to the DOM.
+ renderCourseItemsHTML(root, html);
+ } else {
+ if (!hasLoadedCourses(root)) {
+ // There were no courses to render so show the empty placeholder
+ // message for the user to tell them.
+ showNoCoursesEmptyMessage(root);
+ }
+ }
+
+ return html;
+ })
+ .then(function(html) {
+ if (courses.length < COURSE_LIMIT) {
+ // We know there aren't any more courses because we got back less
+ // than we asked for so hide the button to request more.
+ hideMoreCoursesButton(root);
+ } else {
+ // Make sure the button is visible if there are more courses to load.
+ showMoreCoursesButton(root);
+ }
+
+ return html;
+ })
+ .catch(function() {
+ hideLoadingPlaceholder(root);
+ });
+ };
+
+ /**
+ * Find all of the visible course blocks and initialise the event
+ * list module to being loading the events for the course block.
+ *
+ * @param {object} root The root element for the timeline courses view.
+ * @return {object} jQuery promise resolved with courses and events.
+ */
+ var loadMoreCourses = function(root) {
+ var offset = getOffset(root);
+ var limit = getLimit(root);
+
+ // Start loading the next set of courses.
+ return CourseRepository.getEnrolledCoursesByTimelineClassification(
+ COURSE_CLASSIFICATION,
+ limit,
+ offset,
+ COURSE_SORT
+ ).then(function(result) {
+ var startEventLoadingTime = Date.now();
+ var courses = result.courses;
+ var nextOffset = result.nextoffset;
+ var daysOffset = getDaysOffset(root);
+ var daysLimit = getDaysLimit(root);
+ var midnight = getMidnight(root);
+ var startTime = getStartTime(root);
+ var endTime = getEndTime(root);
+ var noEventsURL = root.attr('data-no-events-url');
+ // Record the next offset if we want to request more courses.
+ setOffset(root, nextOffset);
+ // Load the events for these courses.
+ var eventsPromise = loadEventsForCourses(courses, startTime, endTime);
+ // Render the courses in the DOM.
+ var renderPromise = updateDisplayFromCourses(courses, root, midnight, daysOffset, daysLimit, noEventsURL);
+
+ return $.when(eventsPromise, renderPromise)
+ .then(function(eventsByCourse) {
+ if (hasReloadedEventsSince(root, startEventLoadingTime)) {
+ // All of the events are being reloaded so ignore our results.
+ return eventsByCourse;
+ }
+
+ // When we've got all of the courses and events we can render the events in the
+ // correct course event list.
+ courses.forEach(function(course) {
+ var courseId = course.id;
+ var events = [];
+ var containerSelector = '[data-region="course-events-container"][data-course-id="' + courseId + '"]';
+ var courseEventsContainer = root.find(containerSelector);
+ var eventListRoot = courseEventsContainer.find(EventList.rootSelector);
+ var courseGroups = eventsByCourse.groupedbycourse.filter(function(group) {
+ return group.courseid == courseId;
+ });
+
+ if (courseGroups.length) {
+ // Get the events for this course.
+ events = courseGroups[0].events;
+ }
+
+ // Create a preloaded page to pass to the event list because we've already
+ // loaded the first page of events.
+ var pageOnePreload = $.Deferred().resolve({events: events}).promise();
+ // Initialise the event list pagination area for this course.
+ Str.get_string('ariaeventlistpaginationnavcourses', 'block_timeline', course.fullnamedisplay)
+ .then(function(string) {
+ EventList.init(eventListRoot, COURSE_EVENT_LIMIT, {'1': pageOnePreload}, string);
+ return string;
+ })
+ .catch(function() {
+ // An error is ok, just render with the default string.
+ EventList.init(eventListRoot, COURSE_EVENT_LIMIT, {'1': pageOnePreload});
+ });
+ });
+
+ return eventsByCourse;
+ });
+ }).catch(Notification.exception);
+ };
+
+ /**
+ * Reload the events for all of the visible courses. These events will be loaded
+ * in a single request to the server.
+ *
+ * @param {object} root The root element.
+ * @return {object} jQuery promise resolved with courses and events.
+ */
+ var reloadCourseEvents = function(root) {
+ var startReloadTime = Date.now();
+ var startTime = getStartTime(root);
+ var endTime = getEndTime(root);
+ var courseEventsContainers = root.find(SELECTORS.COURSE_EVENTS_CONTAINER);
+ var courseIds = courseEventsContainers.map(function() {
+ return $(this).attr('data-course-id');
+ }).get();
+
+ // Record when we started our request.
+ setEventReloadTime(root, startReloadTime);
+
+ // Load all of the events for the given courses.
+ return getEventsForCourseIds(courseIds, startTime, COURSE_EVENT_LIMIT + 1, endTime)
+ .then(function(eventsByCourse) {
+ if (hasReloadedEventsSince(root, startReloadTime)) {
+ // A new reload has begun so ignore our results.
+ return eventsByCourse;
+ }
+
+ courseEventsContainers.each(function(index, container) {
+ container = $(container);
+ var courseId = container.attr('data-course-id');
+ var courseName = container.find(SELECTORS.COURSE_NAME).text();
+ var eventListContainer = container.find(EventList.rootSelector);
+ var pageDeferred = $.Deferred();
+ var events = [];
+ var courseGroups = eventsByCourse.groupedbycourse.filter(function(group) {
+ return group.courseid == courseId;
+ });
+
+ if (courseGroups.length) {
+ // Get the events just for this course.
+ events = courseGroups[0].events;
+ }
+
+ pageDeferred.resolve({events: events});
+
+ // Re-initialise the events list with the preloaded events we just got from
+ // the server.
+ Str.get_string('ariaeventlistpaginationnavcourses', 'block_timeline', courseName)
+ .then(function(string) {
+ EventList.init(eventListContainer, COURSE_EVENT_LIMIT, {'1': pageDeferred.promise()}, string);
+ return string;
+ })
+ .catch(function() {
+ // Ignore a failure to load the string. Just render with the default string.
+ EventList.init(eventListContainer, COURSE_EVENT_LIMIT, {'1': pageDeferred.promise()});
+ });
+ });
+
+ return eventsByCourse;
+ }).catch(Notification.exception);
+ };
+
+ /**
+ * Add event listeners to load more courses for the courses view.
+ *
+ * @param {object} root The root element for the timeline courses view.
+ */
+ var registerEventListeners = function(root) {
+ CustomEvents.define(root, [CustomEvents.events.activate]);
+ // Show more courses and load their events when the user clicks the "more courses"
+ // button.
+ root.on(CustomEvents.events.activate, SELECTORS.MORE_COURSES_BUTTON, function(e, data) {
+ enableMoreCoursesButtonLoading(root);
+ loadMoreCourses(root)
+ .then(function() {
+ disableMoreCoursesButtonLoading(root);
+ return;
+ })
+ .catch(function() {
+ disableMoreCoursesButtonLoading(root);
+ });
+
+ if (data) {
+ data.originalEvent.preventDefault();
+ data.originalEvent.stopPropagation();
+ }
+ e.stopPropagation();
+ });
+ };
+
+ /**
+ * Initialise the timeline courses view. Begin loading the events
+ * if this view is active. Add the relevant event listeners.
+ *
+ * This function should only be called once per page load because it
+ * is adding event listeners to the page.
+ *
+ * @param {object} root The root element for the timeline courses view.
+ */
+ var init = function(root) {
+ root = $(root);
+
+ setEventReloadTime(root, Date.now());
+
+ if (root.hasClass('active')) {
+ // Only load if this is active otherwise it will be lazy loaded later.
+ loadMoreCourses(root);
+ root.attr('data-seen', true);
+ }
+
+ registerEventListeners(root);
+ };
+
+ /**
+ * Reset the element back to it's initial state. Begin loading the events again
+ * if this view is active.
+ *
+ * @param {object} root The root element for the timeline courses view.
+ */
+ var reset = function(root) {
+ root.removeAttr('data-seen');
+ if (root.hasClass('active')) {
+ shown(root);
+ }
+ };
+
+ /**
+ * If this is the first time this view has been displayed then begin loading
+ * the events.
+ *
+ * @param {object} root The root element for the timeline courses view.
+ */
+ var shown = function(root) {
+ if (!root.attr('data-seen')) {
+ if (hasLoadedCourses(root)) {
+ // This isn't the first time this view is shown so just reload the
+ // events for the courses we've already loaded.
+ reloadCourseEvents(root);
+ } else {
+ // We haven't loaded any courses yet so do that now.
+ loadMoreCourses(root);
+ }
+
+ root.attr('data-seen', true);
+ }
+ };
+
+ return {
+ init: init,
+ reset: reset,
+ shown: shown
+ };
+});
diff --git a/blocks/timeline/amd/src/view_dates.js b/blocks/timeline/amd/src/view_dates.js
new file mode 100644
index 0000000000000..b18d21a3fd3b4
--- /dev/null
+++ b/blocks/timeline/amd/src/view_dates.js
@@ -0,0 +1,103 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Manage the timeline dates view for the timeline block.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(
+[
+ 'jquery',
+ 'core/str',
+ 'block_timeline/event_list'
+],
+function(
+ $,
+ Str,
+ EventList
+) {
+
+ var SELECTORS = {
+ EVENT_LIST_CONTAINER: '[data-region="event-list-container"]',
+ };
+
+ /**
+ * Initialise the event list and being loading the events.
+ *
+ * @param {object} root The root element for the timeline dates view.
+ */
+ var load = function(root) {
+ var eventListContainer = root.find(SELECTORS.EVENT_LIST_CONTAINER);
+ Str.get_string('ariaeventlistpaginationnavdates', 'block_timeline')
+ .then(function(string) {
+ EventList.init(eventListContainer, [5, 10, 25], {}, string);
+ return string;
+ })
+ .catch(function() {
+ // Ignore if we can't load the string. Still init the event list.
+ EventList.init(eventListContainer, [5, 10, 25]);
+ });
+ };
+
+ /**
+ * Initialise the timeline dates view. Begin loading the events
+ * if this view is active.
+ *
+ * @param {object} root The root element for the timeline courses view.
+ */
+ var init = function(root) {
+ root = $(root);
+ if (root.hasClass('active')) {
+ load(root);
+ root.attr('data-seen', true);
+ }
+ };
+
+ /**
+ * Reset the view back to it's initial state. If this view is active then
+ * beging loading the events.
+ *
+ * @param {object} root The root element for the timeline courses view.
+ */
+ var reset = function(root) {
+ root.removeAttr('data-seen');
+ if (root.hasClass('active')) {
+ load(root);
+ root.attr('data-seen', true);
+ }
+ };
+
+ /**
+ * Load the events if this is the first time the view is displayed.
+ *
+ * @param {object} root The root element for the timeline courses view.
+ */
+ var shown = function(root) {
+ if (!root.attr('data-seen')) {
+ load(root);
+ root.attr('data-seen', true);
+ }
+ };
+
+ return {
+ init: init,
+ reset: reset,
+ shown: shown
+ };
+});
diff --git a/blocks/timeline/amd/src/view_nav.js b/blocks/timeline/amd/src/view_nav.js
new file mode 100644
index 0000000000000..092dcbd93fe4f
--- /dev/null
+++ b/blocks/timeline/amd/src/view_nav.js
@@ -0,0 +1,120 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Manage the timeline view navigation for the timeline block.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define(
+[
+ 'jquery',
+ 'core/custom_interaction_events',
+ 'block_timeline/view'
+],
+function(
+ $,
+ CustomEvents,
+ View
+) {
+
+ var SELECTORS = {
+ TIMELINE_DAY_FILTER: '[data-region="day-filter"]',
+ TIMELINE_DAY_FILTER_OPTION: '[data-from]',
+ TIMELINE_VIEW_SELECTOR: '[data-region="view-selector"]',
+ DATA_DAYS_OFFSET: '[data-days-offset]',
+ DATA_DAYS_LIMIT: '[data-days-limit]',
+ };
+
+ /**
+ * Event listener for the day selector ("Next 7 days", "Next 30 days", etc).
+ *
+ * @param {object} root The root element for the timeline block
+ * @param {object} timelineViewRoot The root element for the timeline view
+ */
+ var registerTimelineDaySelector = function(root, timelineViewRoot) {
+ var timelineDaySelectorContainer = root.find(SELECTORS.TIMELINE_DAY_FILTER);
+
+ CustomEvents.define(timelineDaySelectorContainer, [CustomEvents.events.activate]);
+ timelineDaySelectorContainer.on(
+ CustomEvents.events.activate,
+ SELECTORS.TIMELINE_DAY_FILTER_OPTION,
+ function(e, data) {
+ var option = $(e.target).closest(SELECTORS.TIMELINE_DAY_FILTER_OPTION);
+
+ if (option.hasClass('active')) {
+ // If it's already active then we don't need to do anything.
+ return;
+ }
+
+ var daysOffset = option.attr('data-from');
+ var daysLimit = option.attr('data-to');
+ var elementsWithDaysOffset = root.find(SELECTORS.DATA_DAYS_OFFSET);
+
+ elementsWithDaysOffset.attr('data-days-offset', daysOffset);
+
+ if (daysLimit != undefined) {
+ elementsWithDaysOffset.attr('data-days-limit', daysLimit);
+ } else {
+ elementsWithDaysOffset.removeAttr('data-days-limit');
+ }
+
+ // Reset the views to reinitialise the event lists now that we've
+ // updated the day limits.
+ View.reset(timelineViewRoot);
+
+ data.originalEvent.preventDefault();
+ }
+ );
+ };
+
+ /**
+ * Event listener for the "sort" button in the timeline navigation that allows for
+ * changing between the timeline dates and courses views.
+ *
+ * On a view change we tell the timeline view module that the view has been shown
+ * so that it can handle how to display the appropriate view.
+ *
+ * @param {object} root The root element for the timeline block
+ * @param {object} timelineViewRoot The root element for the timeline view
+ */
+ var registerViewSelector = function(root, timelineViewRoot) {
+ // Listen for when the user changes tab so that we can show the first set of courses
+ // and load their events when they request the sort by courses view for the first time.
+ root.find(SELECTORS.TIMELINE_VIEW_SELECTOR).on('shown shown.bs.tab', function() {
+ View.shown(timelineViewRoot);
+ });
+ };
+
+ /**
+ * Initialise the timeline view navigation by adding event listeners to
+ * the navigation elements.
+ *
+ * @param {object} root The root element for the timeline block
+ * @param {object} timelineViewRoot The root element for the timeline view
+ */
+ var init = function(root, timelineViewRoot) {
+ root = $(root);
+ registerTimelineDaySelector(root, timelineViewRoot);
+ registerViewSelector(root, timelineViewRoot);
+ };
+
+ return {
+ init: init
+ };
+});
diff --git a/blocks/timeline/block_timeline.php b/blocks/timeline/block_timeline.php
new file mode 100644
index 0000000000000..5a24f9ca98f7a
--- /dev/null
+++ b/blocks/timeline/block_timeline.php
@@ -0,0 +1,72 @@
+.
+
+/**
+ * Contains the class for the Timeline block.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Timeline block class.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class block_timeline extends block_base {
+
+ /**
+ * Init.
+ */
+ public function init() {
+ $this->title = get_string('pluginname', 'block_timeline');
+ }
+
+ /**
+ * Returns the contents.
+ *
+ * @return stdClass contents of block
+ */
+ public function get_content() {
+ if (isset($this->content)) {
+ return $this->content;
+ }
+
+ $renderable = new \block_timeline\output\main();
+ $renderer = $this->page->get_renderer('block_timeline');
+
+ $this->content = (object) [
+ 'text' => $renderer->render($renderable),
+ 'footer' => ''
+ ];
+
+ return $this->content;
+ }
+
+ /**
+ * Locations where block can be displayed.
+ *
+ * @return array
+ */
+ public function applicable_formats() {
+ return array('my' => true);
+ }
+}
diff --git a/blocks/timeline/classes/output/main.php b/blocks/timeline/classes/output/main.php
new file mode 100644
index 0000000000000..b5e96097b2232
--- /dev/null
+++ b/blocks/timeline/classes/output/main.php
@@ -0,0 +1,81 @@
+.
+
+/**
+ * Class containing data for timeline block.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace block_timeline\output;
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use renderer_base;
+use templatable;
+use core_course\external\course_summary_exporter;
+
+require_once($CFG->dirroot . '/course/lib.php');
+require_once($CFG->libdir . '/completionlib.php');
+
+/**
+ * Class containing data for timeline block.
+ *
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class main implements renderable, templatable {
+
+ /** Number of courses to load per page */
+ const COURSES_PER_PAGE = 2;
+
+ /**
+ * Export this data so it can be used as the context for a mustache template.
+ *
+ * @param \renderer_base $output
+ * @return stdClass
+ */
+ public function export_for_template(renderer_base $output) {
+
+ $nocoursesurl = $output->image_url('courses', 'block_timeline')->out();
+ $noeventsurl = $output->image_url('activities', 'block_timeline')->out();
+
+ $requiredproperties = course_summary_exporter::define_properties();
+ $fields = join(',', array_keys($requiredproperties));
+ $courses = course_get_enrolled_courses_for_logged_in_user(0, 0, null, $fields);
+ list($inprogresscourses, $processedcount) = course_filter_courses_by_timeline_classification(
+ $courses,
+ COURSE_TIMELINE_INPROGRESS,
+ self::COURSES_PER_PAGE
+ );
+ $formattedcourses = array_map(function($course) use ($output) {
+ \context_helper::preload_from_record($course);
+ $context = \context_course::instance($course->id);
+ $exporter = new course_summary_exporter($course, ['context' => $context]);
+ return $exporter->export($output);
+ }, $inprogresscourses);
+
+ return [
+ 'midnight' => usergetmidnight(time()),
+ 'coursepages' => [$formattedcourses],
+ 'urls' => [
+ 'nocourses' => $nocoursesurl,
+ 'noevents' => $noeventsurl
+ ]
+ ];
+ }
+}
diff --git a/blocks/timeline/classes/output/renderer.php b/blocks/timeline/classes/output/renderer.php
new file mode 100644
index 0000000000000..dbe6bb73c732b
--- /dev/null
+++ b/blocks/timeline/classes/output/renderer.php
@@ -0,0 +1,48 @@
+.
+
+/**
+ * Timeline block rendrer.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace block_timeline\output;
+defined('MOODLE_INTERNAL') || die;
+
+use plugin_renderer_base;
+use renderable;
+
+/**
+ * Timeline block renderer.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends plugin_renderer_base {
+
+ /**
+ * Return the main content for the block timeline.
+ *
+ * @param main $main The main renderable
+ * @return string HTML string
+ */
+ public function render_main(main $main) {
+ return $this->render_from_template('block_timeline/main', $main->export_for_template($this));
+ }
+}
diff --git a/blocks/myoverview/lib.php b/blocks/timeline/classes/privacy/provider.php
similarity index 52%
rename from blocks/myoverview/lib.php
rename to blocks/timeline/classes/privacy/provider.php
index a73db2566ac15..02ae526ba7410 100644
--- a/blocks/myoverview/lib.php
+++ b/blocks/timeline/classes/privacy/provider.php
@@ -15,38 +15,32 @@
// along with Moodle. If not, see .
/**
- * Contains functions called by core.
+ * Privacy Subsystem implementation for block_timeline.
*
- * @package block_myoverview
- * @copyright 2017 Mark Nelson
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-defined('MOODLE_INTERNAL') || die();
-
-/**
- * The timeline view.
- */
-define('BLOCK_MYOVERVIEW_TIMELINE_VIEW', 'timeline');
+namespace block_timeline\privacy;
-/**
- * The courses view.
- */
-define('BLOCK_MYOVERVIEW_COURSES_VIEW', 'courses');
+defined('MOODLE_INTERNAL') || die();
/**
- * Returns the name of the user preferences as well as the details this plugin uses.
+ * Privacy Subsystem for block_timeline.
*
- * @return array
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-function block_myoverview_user_preferences() {
- $preferences = array();
- $preferences['block_myoverview_last_tab'] = array(
- 'type' => PARAM_ALPHA,
- 'null' => NULL_NOT_ALLOWED,
- 'default' => BLOCK_MYOVERVIEW_TIMELINE_VIEW,
- 'choices' => array(BLOCK_MYOVERVIEW_TIMELINE_VIEW, BLOCK_MYOVERVIEW_COURSES_VIEW)
- );
+class provider implements \core_privacy\local\metadata\null_provider {
- return $preferences;
+ /**
+ * Get the language string identifier with the component's language
+ * file to explain why this plugin stores no data.
+ *
+ * @return string
+ */
+ public static function get_reason() : string {
+ return 'privacy:metadata';
+ }
}
diff --git a/blocks/timeline/db/access.php b/blocks/timeline/db/access.php
new file mode 100644
index 0000000000000..6bd70a287cff0
--- /dev/null
+++ b/blocks/timeline/db/access.php
@@ -0,0 +1,50 @@
+.
+
+/**
+ * Capabilities for the timeline block.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$capabilities = array(
+
+ 'block/timeline:myaddinstance' => array(
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_SYSTEM,
+ 'archetypes' => array(
+ 'user' => CAP_ALLOW
+ ),
+
+ 'clonepermissionsfrom' => 'moodle/my:manageblocks'
+ ),
+
+ 'block/timeline:addinstance' => array(
+ 'riskbitmask' => RISK_SPAM | RISK_XSS,
+
+ 'captype' => 'write',
+ 'contextlevel' => CONTEXT_BLOCK,
+ 'archetypes' => array(
+ 'manager' => CAP_ALLOW
+ ),
+
+ 'clonepermissionsfrom' => 'moodle/site:manageblocks'
+ )
+);
diff --git a/blocks/timeline/db/install.php b/blocks/timeline/db/install.php
new file mode 100644
index 0000000000000..c5a3d658de3c2
--- /dev/null
+++ b/blocks/timeline/db/install.php
@@ -0,0 +1,108 @@
+.
+
+/**
+ * Timeline block installation.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+ /**
+ * Add the timeline block to the dashboard for all users by default
+ * when it is installed.
+ */
+function xmldb_block_timeline_install() {
+ global $DB;
+
+ if ($DB->count_records('block_instances') < 1) {
+ // Only add the timeline block if it's being installed on an existing site.
+ // For new sites it will be added by blocks_add_default_system_blocks().
+ return;
+ }
+
+ if ($defaultmypage = $DB->get_record('my_pages', array('userid' => null, 'name' => '__default', 'private' => 1))) {
+ $subpagepattern = $defaultmypage->id;
+ } else {
+ $subpagepattern = null;
+ }
+
+ $page = new moodle_page();
+ $systemcontext = context_system::instance();
+ $page->set_context($systemcontext);
+ // Add the block to the default /my.
+ $page->blocks->add_region(BLOCK_POS_RIGHT);
+ $page->blocks->add_block('timeline', BLOCK_POS_RIGHT, 0, false, 'my-index', $subpagepattern);
+
+ // Now we need to find all users that have viewed their dashboard because it'll have
+ // made duplicates of the default block_instances for them so they won't see the new
+ // timeline block without the admin resetting all of the dashboards.
+ //
+ // Instead we'll just add the timeline block to their dashboards here. We will only
+ // add the timeline block if they still have the myoverview block.
+ $sql = "SELECT parentcontextid, subpagepattern
+ FROM {block_instances}
+ WHERE pagetypepattern = 'my-index'
+ AND blockname = 'myoverview'
+ AND parentcontextid != ?";
+ $params = [$systemcontext->id];
+ $existingrecords = $DB->get_recordset_sql($sql, $params);
+ $blockinstances = [];
+ $seencontexts = [];
+ $now = time();
+
+ foreach ($existingrecords as $existingrecord) {
+ $parentcontextid = $existingrecord->parentcontextid;
+ if (isset($seencontexts[$parentcontextid])) {
+ // If we've seen this context already then skip it because we don't want
+ // to add duplicate timeline blocks to the same context. This happens
+ // if something funny is going on with the subpagepattern.
+ continue;
+ } else {
+ $seencontexts[$parentcontextid] = true;
+ }
+
+ $blockinstances[] = [
+ 'blockname' => 'timeline',
+ 'parentcontextid' => $parentcontextid,
+ 'showinsubcontexts' => false,
+ 'pagetypepattern' => 'my-index',
+ 'subpagepattern' => $existingrecord->subpagepattern,
+ 'defaultregion' => BLOCK_POS_RIGHT,
+ 'defaultweight' => 0,
+ 'configdata' => '',
+ 'timecreated' => $now,
+ 'timemodified' => $now,
+ ];
+
+ if (count($blockinstances) >= 1000) {
+ // Insert after every 1000 records so that the memory usage doesn't
+ // get out of control.
+ $DB->insert_records('block_instances', $blockinstances);
+ $blockinstances = [];
+ }
+ }
+
+ $existingrecords->close();
+
+ if (!empty($blockinstances)) {
+ // Insert what ever is left over.
+ $DB->insert_records('block_instances', $blockinstances);
+ }
+}
diff --git a/blocks/timeline/lang/en/block_timeline.php b/blocks/timeline/lang/en/block_timeline.php
new file mode 100644
index 0000000000000..70f961ce2030c
--- /dev/null
+++ b/blocks/timeline/lang/en/block_timeline.php
@@ -0,0 +1,49 @@
+.
+
+/**
+ * Lang strings for the timeline block.
+ *
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['ariadayfilter'] = 'Filter timeline items';
+$string['ariadayfilteroption'] = '{$a} filter option';
+$string['ariaeventlistitem'] = '{$a->name} activity in {$a->course} is due on {$a->date}';
+$string['ariaeventlistpagelimit'] = 'Show {$a} activities per page';
+$string['ariaeventlistpaginationnavdates'] = 'Timeline activities pagination';
+$string['ariaeventlistpaginationnavcourses'] = 'Timeline activities for course {$a} pagination';
+$string['ariaviewselector'] = 'Sort timeline items';
+$string['ariaviewselectoroption'] = '{$a} sort option';
+$string['duedate'] = 'Due date';
+$string['morecourses'] = 'More courses';
+$string['timeline:addinstance'] = 'Add a new timeline block';
+$string['timeline:myaddinstance'] = 'Add a new timeline block to Dashboard';
+$string['nocoursesinprogress'] = 'No in progress courses';
+$string['noevents'] = 'No upcoming activities due';
+$string['next30days'] = 'Next 30 days';
+$string['next7days'] = 'Next 7 days';
+$string['next3months'] = 'Next 3 months';
+$string['next6months'] = 'Next 6 months';
+$string['overdue'] = 'Overdue';
+$string['pluginname'] = 'Timeline';
+$string['sortbycourses'] = 'Sort by courses';
+$string['sortbydates'] = 'Sort by dates';
+$string['timeline'] = 'Timeline';
+$string['viewcourse'] = 'View course';
+$string['privacy:metadata'] = 'The timeline block does not store any personal data.';
diff --git a/blocks/myoverview/pix/activities.svg b/blocks/timeline/pix/activities.svg
similarity index 100%
rename from blocks/myoverview/pix/activities.svg
rename to blocks/timeline/pix/activities.svg
diff --git a/blocks/timeline/pix/courses.svg b/blocks/timeline/pix/courses.svg
new file mode 100644
index 0000000000000..75e59fcf04b0a
--- /dev/null
+++ b/blocks/timeline/pix/courses.svg
@@ -0,0 +1,52 @@
+
diff --git a/blocks/timeline/templates/course-item-loading-placeholder.mustache b/blocks/timeline/templates/course-item-loading-placeholder.mustache
new file mode 100644
index 0000000000000..d2c8168125b6a
--- /dev/null
+++ b/blocks/timeline/templates/course-item-loading-placeholder.mustache
@@ -0,0 +1,40 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template block_timeline/course-item-loading-placeholder
+
+ This template renders the each course block containing a summary and calendar events.
+
+ Example context (json):
+ {}
+}}
+
diff --git a/blocks/myoverview/templates/course-item.mustache b/blocks/timeline/templates/course-item.mustache
similarity index 64%
rename from blocks/myoverview/templates/course-item.mustache
rename to blocks/timeline/templates/course-item.mustache
index c7ce9d8868290..dafb42a691e97 100644
--- a/blocks/myoverview/templates/course-item.mustache
+++ b/blocks/timeline/templates/course-item.mustache
@@ -15,7 +15,7 @@
along with Moodle. If not, see .
}}
{{!
- @template block_myoverview/course-item
+ @template block_timeline/course-item
This template renders the each course block containing a summary and calendar events.
@@ -26,19 +26,11 @@
"summary": "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout."
}
}}
-
diff --git a/theme/bootstrapbase/templates/block_myoverview/course-item.mustache b/blocks/timeline/templates/course-items.mustache
similarity index 63%
rename from theme/bootstrapbase/templates/block_myoverview/course-item.mustache
rename to blocks/timeline/templates/course-items.mustache
index d3f093a6cee7e..444a13019e4b8 100644
--- a/theme/bootstrapbase/templates/block_myoverview/course-item.mustache
+++ b/blocks/timeline/templates/course-items.mustache
@@ -15,7 +15,7 @@
along with Moodle. If not, see .
}}
{{!
- @template block_myoverview/course-item
+ @template block_timeline/course-items
This template renders the each course block containing a summary and calendar events.
@@ -26,19 +26,6 @@
"summary": "It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout."
}
}}
-
\ No newline at end of file
diff --git a/blocks/timeline/templates/event-list-item.mustache b/blocks/timeline/templates/event-list-item.mustache
new file mode 100644
index 0000000000000..40fe0dccf4e28
--- /dev/null
+++ b/blocks/timeline/templates/event-list-item.mustache
@@ -0,0 +1,63 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template block_timeline/event-list-item
+
+ This template renders an event list item for the timeline block.
+
+ Example context (json):
+ {
+ "name": "Assignment due 1",
+ "url": "https://www.google.com",
+ "timesort": 1490320388,
+ "course": {
+ "fullnamedisplay": "Course 1"
+ },
+ "action": {
+ "name": "Submit assignment",
+ "url": "https://www.google.com",
+ "itemcount": 1,
+ "showitemcount": true,
+ "actionable": true
+ },
+ "icon": {
+ "key": "icon",
+ "component": "mod_assign",
+ "alttext": "Assignment icon"
+ }
+ }
+}}
+
+
diff --git a/blocks/timeline/templates/event-list.mustache b/blocks/timeline/templates/event-list.mustache
new file mode 100644
index 0000000000000..9a640c496ac62
--- /dev/null
+++ b/blocks/timeline/templates/event-list.mustache
@@ -0,0 +1,55 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template block_timeline/event-list
+
+ This template renders a list of events for the timeline block.
+
+ Example context (json):
+ {
+ }
+}}
+
diff --git a/blocks/timeline/templates/main.mustache b/blocks/timeline/templates/main.mustache
new file mode 100644
index 0000000000000..021cf47971715
--- /dev/null
+++ b/blocks/timeline/templates/main.mustache
@@ -0,0 +1,54 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template block_timeline/main
+
+ This template renders the main content area for the timeline block.
+
+ Example context (json):
+ {}
+}}
+
+
+
+
+
+ {{> block_timeline/nav-day-filter }}
+
+
+ {{> block_timeline/nav-view-selector }}
+
+
+
+
+ {{> block_timeline/view }}
+
+
+{{#js}}
+require(
+[
+ 'jquery',
+ 'block_timeline/main',
+],
+function(
+ $,
+ Main
+) {
+ var root = $('#block-timeline-{{uniqid}}');
+ Main.init(root);
+});
+{{/js}}
diff --git a/blocks/timeline/templates/nav-day-filter.mustache b/blocks/timeline/templates/nav-day-filter.mustache
new file mode 100644
index 0000000000000..90afd62717304
--- /dev/null
+++ b/blocks/timeline/templates/nav-day-filter.mustache
@@ -0,0 +1,90 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template block_timeline/nav-day-filter
+
+ This template renders the day range selector for the timeline view.
+
+ Example context (json):
+ {}
+}}
+
diff --git a/blocks/timeline/templates/nav-view-selector.mustache b/blocks/timeline/templates/nav-view-selector.mustache
new file mode 100644
index 0000000000000..50c2add83d779
--- /dev/null
+++ b/blocks/timeline/templates/nav-view-selector.mustache
@@ -0,0 +1,51 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template block_timeline/nav-view-selector
+
+ This template renders the timeline sort selector.
+
+ Example context (json):
+ {}
+}}
+
diff --git a/blocks/timeline/templates/placeholder-event-list-item.mustache b/blocks/timeline/templates/placeholder-event-list-item.mustache
new file mode 100644
index 0000000000000..ad01fc0a015e5
--- /dev/null
+++ b/blocks/timeline/templates/placeholder-event-list-item.mustache
@@ -0,0 +1,42 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template block_timeline/event-list-item
+
+ This template renders an event list item loading placeholder for the timeline block.
+
+ Example context (json):
+ {}
+}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/blocks/timeline/templates/view-courses.mustache b/blocks/timeline/templates/view-courses.mustache
new file mode 100644
index 0000000000000..d4d1799da11e1
--- /dev/null
+++ b/blocks/timeline/templates/view-courses.mustache
@@ -0,0 +1,49 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template block_timeline/view-courses
+
+ This template renders the timeline view by courses for the timeline block.
+
+ Example context (json):
+ {}
+}}
+
diff --git a/blocks/myoverview/templates/timeline-view-dates.mustache b/blocks/timeline/templates/view-dates.mustache
similarity index 57%
rename from blocks/myoverview/templates/timeline-view-dates.mustache
rename to blocks/timeline/templates/view-dates.mustache
index 66cb8ea7b27b5..0b571f0c0e494 100644
--- a/blocks/myoverview/templates/timeline-view-dates.mustache
+++ b/blocks/timeline/templates/view-dates.mustache
@@ -15,21 +15,13 @@
along with Moodle. If not, see .
}}
{{!
- @template block_myoverview/timeline-view-dates
+ @template block_timeline/view-dates
- This template renders the timeline view by dates for the myoverview block.
+ This template renders the timeline view by dates for the timeline block.
Example context (json):
{}
}}
-
-{{#js}}
- require(['jquery', 'block_myoverview/event_list'], function($, EventList) {
- var root = $("#timeline-view-dates-{{uniqid}}").find('[data-region="event-list-container"]');
- EventList.load(root);
- });
-{{/js}}
diff --git a/blocks/timeline/templates/view.mustache b/blocks/timeline/templates/view.mustache
new file mode 100644
index 0000000000000..73deceeb91594
--- /dev/null
+++ b/blocks/timeline/templates/view.mustache
@@ -0,0 +1,44 @@
+{{!
+ This file is part of Moodle - http://moodle.org/
+
+ Moodle is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Moodle is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with Moodle. If not, see .
+}}
+{{!
+ @template block_timeline/view
+
+ This template renders the timeline view for the timeline block.
+
+ Example context (json):
+ {}
+}}
+
+
+
+ {{> block_timeline/view-dates }}
+
+
+ {{> block_timeline/view-courses }}
+
+
+
\ No newline at end of file
diff --git a/blocks/timeline/tests/behat/block_timeline_courses.feature b/blocks/timeline/tests/behat/block_timeline_courses.feature
new file mode 100644
index 0000000000000..f9376847a4c63
--- /dev/null
+++ b/blocks/timeline/tests/behat/block_timeline_courses.feature
@@ -0,0 +1,72 @@
+@block @block_timeline @javascript
+Feature: The timeline block allows users to see upcoming activities
+ In order to enable the timeline block
+ As a student
+ I can add the timeline block to my dashboard
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email | idnumber |
+ | student1 | Student | 1 | student1@example.com | S1 |
+ | student2 | Student | 2 | student2@example.com | S2 |
+ And the following "courses" exist:
+ | fullname | shortname | category | startdate | enddate |
+ | Course 1 | C1 | 0 | ##yesterday## | ##tomorrow## |
+ | Course 2 | C2 | 0 | ##yesterday## | ##tomorrow## |
+ | Course 3 | C3 | 0 | ##yesterday## | ##tomorrow## |
+ | Course 4 | C4 | 0 | ##first day of next month## | ##last day of next month## |
+ And the following "activities" exist:
+ | activity | course | idnumber | name | intro | timeopen | timeclose |
+ | choice | C2 | choice1 | Test choice 1 | Test choice description | ##yesterday## | ##tomorrow## |
+ | choice | C1 | choice2 | Test choice 2 | Test choice description | ##1 month ago## | ##15 days ago## |
+ | choice | C3 | choice3 | Test choice 3 | Test choice description | ##first day of +5 months## | ##last day of +5 months## |
+ | feedback | C2 | feedback1 | Test feedback 1 | Test feedback description | ##yesterday## | ##tomorrow## |
+ | feedback | C1 | feedback2 | Test feedback 2 | Test feedback description | ##first day of +10 months## | ##last day of +10 months## |
+ | feedback | C3 | feedback3 | Test feedback 3 | Test feedback description | ##first day of +5 months## | ##last day of +5 months## |
+ | feedback | C4 | feedback4 | Test feedback 4 | Test feedback description | ##yesterday## | ##tomorrow## |
+ And the following "activities" exist:
+ | activity | course | idnumber | name | intro | timeopen | duedate |
+ | assign | C1 | assign1 | Test assign 1 | Test assign description | ##1 month ago## | ##yesterday## |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | student1 | C1 | student |
+ | student1 | C2 | student |
+ | student1 | C3 | student |
+ | student1 | C4 | student |
+
+ Scenario: Next 30 days in course view
+ Given I log in as "student1"
+ And I click on "Sort" "button" in the "Timeline" "block"
+ When I click on "Sort by courses" "link" in the "Timeline" "block"
+ Then I should see "Course 1" in the "Timeline" "block"
+ And I should see "Course 2" in the "Timeline" "block"
+ And I should see "More courses" in the "Timeline" "block"
+ And I should see "Test choice 1 closes" in the "Timeline" "block"
+ And I should see "Test feedback 1 closes" in the "Timeline" "block"
+ And I should not see "Course 3" in the "Timeline" "block"
+ And I should not see "Test choice 2 closes" in the "Timeline" "block"
+ And I should not see "Test choice 3 closes" in the "Timeline" "block"
+ And I should not see "Test feedback 2 closes" in the "Timeline" "block"
+ And I should not see "Test feedback 3 closes" in the "Timeline" "block"
+ And I should not see "Test assign 1 is due" in the "Timeline" "block"
+
+ Scenario: All in course view
+ Given I log in as "student1"
+ And I click on "Next 30 days" "button" in the "Timeline" "block"
+ And I click on "All" "link" in the "Timeline" "block"
+ And I click on "Sort" "button" in the "Timeline" "block"
+ And I click on "Sort by courses" "link" in the "Timeline" "block"
+ When I click on "More courses" "button" in the "Timeline" "block"
+ Then I should see "Course 3" in the "Timeline" "block"
+ And I should see "Course 2" in the "Timeline" "block"
+ And I should see "Course 1" in the "Timeline" "block"
+ And I should see "Test choice 1 closes" in the "Timeline" "block"
+ And I should see "Test choice 3 closes" in the "Timeline" "block"
+ And I should see "Test feedback 1 closes" in the "Timeline" "block"
+ And I should see "Test feedback 2 closes" in the "Timeline" "block"
+ And I should see "Test feedback 3 closes" in the "Timeline" "block"
+ And I should see "Test assign 1 is due" in the "Timeline" "block"
+ And I should not see "More courses" in the "Timeline" "block"
+ And I should not see "Course 4" in the "Timeline" "block"
+ And I should not see "Test choice 2 closes" in the "Timeline" "block"
+ And I should not see "Test feedback 4 closes" in the "Timeline" "block"
diff --git a/blocks/timeline/tests/behat/block_timeline_dates.feature b/blocks/timeline/tests/behat/block_timeline_dates.feature
new file mode 100644
index 0000000000000..6aaf269f6b0b4
--- /dev/null
+++ b/blocks/timeline/tests/behat/block_timeline_dates.feature
@@ -0,0 +1,88 @@
+@block @block_timeline @javascript
+Feature: The timeline block allows users to see upcoming activities
+ In order to enable the timeline block
+ As a student
+ I can add the timeline block to my dashboard
+
+ Background:
+ Given the following "users" exist:
+ | username | firstname | lastname | email | idnumber |
+ | student1 | Student | 1 | student1@example.com | S1 |
+ | student2 | Student | 2 | student2@example.com | S2 |
+ And the following "courses" exist:
+ | fullname | shortname | category | startdate | enddate |
+ | Course 1 | C1 | 0 | ##1 month ago## | ##15 days ago## |
+ | Course 2 | C2 | 0 | ##yesterday## | ##tomorrow## |
+ | Course 3 | C3 | 0 | ##first day of next month## | ##last day of next month## |
+ And the following "activities" exist:
+ | activity | course | idnumber | name | intro | timeopen | timeclose |
+ | choice | C2 | choice1 | Test choice 1 | Test choice description | ##yesterday## | ##tomorrow## |
+ | choice | C1 | choice2 | Test choice 2 | Test choice description | ##1 month ago## | ##15 days ago## |
+ | choice | C3 | choice3 | Test choice 3 | Test choice description | ##first day of +5 months## | ##last day of +5 months## |
+ | feedback | C2 | feedback1 | Test feedback 1 | Test feedback description | ##yesterday## | ##tomorrow## |
+ | feedback | C1 | feedback2 | Test feedback 2 | Test feedback description | ##first day of +10 months## | ##last day of +10 months## |
+ | feedback | C3 | feedback3 | Test feedback 3 | Test feedback description | ##first day of +5 months## | ##last day of +5 months## |
+ And the following "activities" exist:
+ | activity | course | idnumber | name | intro | timeopen | duedate |
+ | assign | C1 | assign1 | Test assign 1 | Test assign description | ##1 month ago## | ##yesterday## |
+ And the following "course enrolments" exist:
+ | user | course | role |
+ | student1 | C1 | student |
+ | student1 | C2 | student |
+ | student1 | C3 | student |
+
+ Scenario: Next 7 days in date view
+ Given I log in as "student1"
+ And I click on "Next 30 days" "button" in the "Timeline" "block"
+ When I click on "Next 7 days" "link" in the "Timeline" "block"
+ Then I should see "Test choice 1 closes" in the "Timeline" "block"
+ And I should see "Test feedback 1 closes" in the "Timeline" "block"
+ And I should not see "Test choice 2 closes" in the "Timeline" "block"
+ And I should not see "Test choice 3 closes" in the "Timeline" "block"
+ And I should not see "Test feedback 3 closes" in the "Timeline" "block"
+ And I should not see "Test assign 1 is due" in the "Timeline" "block"
+
+ Scenario: Overdue in date view
+ Given I log in as "student1"
+ And I click on "Next 30 days" "button" in the "Timeline" "block"
+ When I click on "Overdue" "link" in the "Timeline" "block"
+ Then I should see "Test assign 1 is due" in the "Timeline" "block"
+ And I should not see "Test choice 2 closes" in the "Timeline" "block"
+ And I should not see "Test feedback 1 closes" in the "Timeline" "block"
+ And I should not see "Test choice 1 closes" in the "Timeline" "block"
+ And I should not see "Test choice 3 closes" in the "Timeline" "block"
+ And I should not see "Test feedback 3 closes" in the "Timeline" "block"
+
+ Scenario: All in date view
+ Given I log in as "student1"
+ And I click on "Next 30 days" "button" in the "Timeline" "block"
+ When I click on "All" "link" in the "Timeline" "block"
+ Then I should see "Test assign 1 is due" in the "Timeline" "block"
+ And I should see "Test feedback 1 closes" in the "Timeline" "block"
+ And I should see "Test choice 1 closes" in the "Timeline" "block"
+ And I should see "Test choice 3 closes" in the "Timeline" "block"
+ And I should see "Test feedback 3 closes" in the "Timeline" "block"
+ And I should not see "Test choice 2 closes" in the "Timeline" "block"
+ And I should not see "Test feedback 2 closes" in the "Timeline" "block"
+ And I click on "[data-region='paging-bar'] [data-control='next']" "css_element" in the "Timeline" "block"
+ And I should see "Test feedback 2 closes" in the "Timeline" "block"
+ And I should not see "Test assign 1 is due" in the "Timeline" "block"
+ And I should not see "Test feedback 1 closes" in the "Timeline" "block"
+ And I should not see "Test choice 1 closes" in the "Timeline" "block"
+ And I should not see "Test choice 3 closes" in the "Timeline" "block"
+ And I should not see "Test feedback 3 closes" in the "Timeline" "block"
+ And I should not see "Test choice 2 closes" in the "Timeline" "block"
+
+ Scenario: All in date view no next
+ Given I log in as "student1"
+ And I click on "Next 30 days" "button" in the "Timeline" "block"
+ And I click on "All" "link" in the "Timeline" "block"
+ And I click on "5" "button" in the "Timeline" "block"
+ When I click on "25" "link" in the "Timeline" "block"
+ Then I should see "Test assign 1 is due" in the "Timeline" "block"
+ And I should see "Test feedback 1 closes" in the "Timeline" "block"
+ And I should see "Test choice 1 closes" in the "Timeline" "block"
+ And I should see "Test choice 3 closes" in the "Timeline" "block"
+ And I should see "Test feedback 3 closes" in the "Timeline" "block"
+ And I should see "Test feedback 2 closes" in the "Timeline" "block"
+ And I should not see "Test choice 2 closes" in the "Timeline" "block"
diff --git a/blocks/myoverview/settings.php b/blocks/timeline/version.php
similarity index 55%
rename from blocks/myoverview/settings.php
rename to blocks/timeline/version.php
index 10f084d6a1fd3..64de7f35a1f8c 100644
--- a/blocks/myoverview/settings.php
+++ b/blocks/timeline/version.php
@@ -15,25 +15,15 @@
// along with Moodle. If not, see .
/**
- * Settings for the overview block.
+ * Version details for the timeline block.
*
- * @package block_myoverview
- * @copyright 2017 Mark Nelson
+ * @package block_timeline
+ * @copyright 2018 Ryan Wyllie
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-defined('MOODLE_INTERNAL') || die;
+defined('MOODLE_INTERNAL') || die();
-require_once($CFG->dirroot . '/blocks/myoverview/lib.php');
-
-if ($ADMIN->fulltree) {
-
- $options = [
- BLOCK_MYOVERVIEW_TIMELINE_VIEW => get_string('timeline', 'block_myoverview'),
- BLOCK_MYOVERVIEW_COURSES_VIEW => get_string('courses')
- ];
-
- $settings->add(new admin_setting_configselect('block_myoverview/defaulttab',
- get_string('defaulttab', 'block_myoverview'),
- get_string('defaulttab_desc', 'block_myoverview'), 'timeline', $options));
-}
+$plugin->version = 2018083100; // The current plugin version (Date: YYYYMMDDXX).
+$plugin->requires = 2018082400; // Requires this Moodle version.
+$plugin->component = 'block_timeline'; // Full name of the plugin (used for diagnostics).
diff --git a/blocks/upgrade.txt b/blocks/upgrade.txt
index 7ef8f0abd0c56..1234de4f59174 100644
--- a/blocks/upgrade.txt
+++ b/blocks/upgrade.txt
@@ -1,6 +1,10 @@
This files describes API changes in /blocks/* - activity modules,
information provided here is intended especially for developers.
+=== 3.6 ===
+
+* The timeline view from block_myoverview has been split out into block_timeline.
+
=== 3.4 ===
* The block_instances table now contains fields timecreated and timemodified. If third-party code
diff --git a/course/amd/build/repository.min.js b/course/amd/build/repository.min.js
new file mode 100644
index 0000000000000..59fa5904e6bb3
--- /dev/null
+++ b/course/amd/build/repository.min.js
@@ -0,0 +1 @@
+define(["jquery","core/ajax"],function(a,b){var c=function(a,c,d,e){var f={classification:a};"undefined"!=typeof c&&(f.limit=c),"undefined"!=typeof d&&(f.offset=d),"undefined"!=typeof e&&(f.sort=e);var g={methodname:"core_course_get_enrolled_courses_by_timeline_classification",args:f};return b.call([g])[0]};return{getEnrolledCoursesByTimelineClassification:c}});
\ No newline at end of file
diff --git a/course/amd/src/repository.js b/course/amd/src/repository.js
new file mode 100644
index 0000000000000..a0c7a40f297d4
--- /dev/null
+++ b/course/amd/src/repository.js
@@ -0,0 +1,63 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * A javascript module to handle course ajax actions.
+ *
+ * @module core_course/repository
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/ajax'], function($, Ajax) {
+
+ /**
+ * Get the list of courses that the logged in user is enrolled in for a given
+ * timeline classification.
+ *
+ * @param {string} classification past, inprogress, or future
+ * @param {int} limit Only return this many results
+ * @param {int} offset Skip this many results from the start of the result set
+ * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'
+ * @return {object} jQuery promise resolved with courses.
+ */
+ var getEnrolledCoursesByTimelineClassification = function(classification, limit, offset, sort) {
+ var args = {
+ classification: classification
+ };
+
+ if (typeof limit !== 'undefined') {
+ args.limit = limit;
+ }
+
+ if (typeof offset !== 'undefined') {
+ args.offset = offset;
+ }
+
+ if (typeof sort !== 'undefined') {
+ args.sort = sort;
+ }
+
+ var request = {
+ methodname: 'core_course_get_enrolled_courses_by_timeline_classification',
+ args: args
+ };
+
+ return Ajax.call([request])[0];
+ };
+
+ return {
+ getEnrolledCoursesByTimelineClassification: getEnrolledCoursesByTimelineClassification
+ };
+});
diff --git a/course/externallib.php b/course/externallib.php
index 25621da7bcda3..b7cabe7799fc5 100644
--- a/course/externallib.php
+++ b/course/externallib.php
@@ -26,6 +26,8 @@
defined('MOODLE_INTERNAL') || die;
+use core_course\external\course_summary_exporter;
+
require_once("$CFG->libdir/externallib.php");
/**
@@ -3553,4 +3555,114 @@ public static function edit_section($action, $id, $sectionreturn) {
public static function edit_section_returns() {
return new external_value(PARAM_RAW, 'Additional data for javascript (JSON-encoded string)');
}
+
+ /**
+ * Returns description of method parameters
+ *
+ * @return external_function_parameters
+ */
+ public static function get_enrolled_courses_by_timeline_classification_parameters() {
+ return new external_function_parameters(
+ array(
+ 'classification' => new external_value(PARAM_ALPHA, 'future, inprogress, or past'),
+ 'limit' => new external_value(PARAM_INT, 'Result set limit', VALUE_DEFAULT, 0),
+ 'offset' => new external_value(PARAM_INT, 'Result set offset', VALUE_DEFAULT, 0),
+ 'sort' => new external_value(PARAM_TEXT, 'Sort string', VALUE_DEFAULT, null)
+ )
+ );
+ }
+
+ /**
+ * Get courses matching the given timeline classification.
+ *
+ * NOTE: The offset applies to the unfiltered full set of courses before the classification
+ * filtering is done.
+ * E.g.
+ * If the user is enrolled in 5 courses:
+ * c1, c2, c3, c4, and c5
+ * And c4 and c5 are 'future' courses
+ *
+ * If a request comes in for future courses with an offset of 1 it will mean that
+ * c1 is skipped (because the offset applies *before* the classification filtering)
+ * and c4 and c5 will be return.
+ *
+ * @param string $classification past, inprogress, or future
+ * @param int $limit Result set limit
+ * @param int $offset Offset the full course set before timeline classification is applied
+ * @param string $sort SQL sort string for results
+ * @return array list of courses and warnings
+ * @throws invalid_parameter_exception
+ */
+ public static function get_enrolled_courses_by_timeline_classification(
+ string $classification,
+ int $limit = 0,
+ int $offset = 0,
+ string $sort = null
+ ) {
+ global $CFG, $PAGE, $USER;
+ require_once($CFG->dirroot . '/course/lib.php');
+
+ $params = self::validate_parameters(self::get_enrolled_courses_by_timeline_classification_parameters(),
+ array(
+ 'classification' => $classification,
+ 'limit' => $limit,
+ 'offset' => $offset,
+ 'sort' => $sort,
+ )
+ );
+
+ $classification = $params['classification'];
+ $limit = $params['limit'];
+ $offset = $params['offset'];
+ $sort = $params['sort'];
+
+ switch($classification) {
+ case COURSE_TIMELINE_PAST:
+ break;
+ case COURSE_TIMELINE_INPROGRESS:
+ break;
+ case COURSE_TIMELINE_FUTURE:
+ break;
+ default:
+ throw new invalid_parameter_exception('Invalid classification');
+ }
+
+ self::validate_context(context_user::instance($USER->id));
+
+ $requiredproperties = course_summary_exporter::define_properties();
+ $fields = join(',', array_keys($requiredproperties));
+ $courses = course_get_enrolled_courses_for_logged_in_user(0, $offset, $sort, $fields);
+ list($filteredcourses, $processedcount) = course_filter_courses_by_timeline_classification(
+ $courses,
+ $classification,
+ $limit
+ );
+
+ $renderer = $PAGE->get_renderer('core');
+ $formattedcourses = array_map(function($course) use ($renderer) {
+ context_helper::preload_from_record($course);
+ $context = context_course::instance($course->id);
+ $exporter = new course_summary_exporter($course, ['context' => $context]);
+ return $exporter->export($renderer);
+ }, $filteredcourses);
+
+ return [
+ 'courses' => $formattedcourses,
+ 'nextoffset' => $offset + $processedcount
+ ];
+ }
+
+ /**
+ * Returns description of method result value
+ *
+ * @return external_description
+ */
+ public static function get_enrolled_courses_by_timeline_classification_returns() {
+ return new external_single_structure(
+ array(
+ 'courses' => new external_multiple_structure(course_summary_exporter::get_read_structure(), 'Course'),
+ 'nextoffset' => new external_value(PARAM_INT, 'Offset for the next request')
+ )
+ );
+ }
}
diff --git a/course/lib.php b/course/lib.php
index 1e0322f4057ff..c255e555347ec 100644
--- a/course/lib.php
+++ b/course/lib.php
@@ -58,6 +58,7 @@
define('COURSE_TIMELINE_PAST', 'past');
define('COURSE_TIMELINE_INPROGRESS', 'inprogress');
define('COURSE_TIMELINE_FUTURE', 'future');
+define('COURSE_DB_QUERY_LIMIT', 1000);
function make_log_url($module, $url) {
switch ($module) {
@@ -4113,6 +4114,126 @@ function course_classify_start_date($course) {
return $startdate->getTimestamp();
}
+/**
+ * Group a list of courses into either past, future, or in progress.
+ *
+ * The return value will be an array indexed by the COURSE_TIMELINE_* constants
+ * with each value being an array of courses in that group.
+ * E.g.
+ * [
+ * COURSE_TIMELINE_PAST => [... list of past courses ...],
+ * COURSE_TIMELINE_FUTURE => [],
+ * COURSE_TIMELINE_INPROGRESS => []
+ * ]
+ *
+ * @param array $courses List of courses to be grouped.
+ * @return array
+ */
+function course_classify_courses_for_timeline(array $courses) {
+ return array_reduce($courses, function($carry, $course) {
+ $classification = course_classify_for_timeline($course);
+ array_push($carry[$classification], $course);
+
+ return $carry;
+ }, [
+ COURSE_TIMELINE_PAST => [],
+ COURSE_TIMELINE_FUTURE => [],
+ COURSE_TIMELINE_INPROGRESS => []
+ ]);
+}
+
+/**
+ * Get the list of enrolled courses for the current user.
+ *
+ * This function returns a Generator. The courses will be loaded from the database
+ * in chunks rather than a single query.
+ *
+ * @param int $limit Restrict result set to this amount
+ * @param int $offset Skip this number of records from the start of the result set
+ * @param string|null $sort SQL string for sorting
+ * @param string|null $fields SQL string for fields to be returned
+ * @param int $dbquerylimit The number of records to load per DB request
+ * @return Generator
+ */
+function course_get_enrolled_courses_for_logged_in_user(
+ int $limit = 0,
+ int $offset = 0,
+ string $sort = null,
+ string $fields = null,
+ int $dbquerylimit = COURSE_DB_QUERY_LIMIT
+) : Generator {
+
+ $haslimit = !empty($limit);
+ $recordsloaded = 0;
+ $querylimit = (!$haslimit || $limit > $dbquerylimit) ? $dbquerylimit : $limit;
+
+ while ($courses = enrol_get_my_courses($fields, $sort, $querylimit, [], false, $offset)) {
+ yield from $courses;
+
+ $recordsloaded += $querylimit;
+
+ if (count($courses) < $querylimit) {
+ break;
+ }
+ if ($haslimit && $recordsloaded >= $limit) {
+ break;
+ }
+
+ $offset += $querylimit;
+ }
+}
+
+/**
+ * Search the given $courses for any that match the given $classification up to the specified
+ * $limit.
+ *
+ * This function will return the subset of courses that match the classification as well as the
+ * number of courses it had to process to build that subset.
+ *
+ * It is recommended that for larger sets of courses this function is given a Generator that loads
+ * the courses from the database in chunks.
+ *
+ * @param array|Traversable $courses List of courses to process
+ * @param string $classification One of the COURSE_TIMELINE_* constants
+ * @param int $limit Limit the number of results to this amount
+ * @return array First value is the filtered courses, second value is the number of courses processed
+ */
+function course_filter_courses_by_timeline_classification(
+ $courses,
+ string $classification,
+ int $limit = 0
+) : array {
+
+ if (!in_array($classification, [COURSE_TIMELINE_PAST, COURSE_TIMELINE_INPROGRESS, COURSE_TIMELINE_FUTURE])) {
+ $message = 'Classification must be one of COURSE_TIMELINE_PAST, '
+ . 'COURSE_TIMELINE_INPROGRESS or COURSE_TIMELINE_FUTURE';
+ throw new moodle_exception($message);
+ }
+
+ $filteredcourses = [];
+ $numberofcoursesprocessed = 0;
+ $filtermatches = 0;
+
+ foreach ($courses as $course) {
+ $numberofcoursesprocessed++;
+
+ if ($classification == course_classify_for_timeline($course)) {
+ $filteredcourses[] = $course;
+ $filtermatches++;
+ }
+
+ if ($limit && $filtermatches >= $limit) {
+ // We've found the number of requested courses. No need to continue searching.
+ break;
+ }
+ }
+
+ // Return the number of filtered courses as well as the number of courses that were searched
+ // in order to find the matching courses. This allows the calling code to do some kind of
+ // pagination.
+ return [$filteredcourses, $numberofcoursesprocessed];
+}
+
/**
* Check module updates since a given time.
* This function checks for updates in the module config, file areas, completion, grades, comments and ratings.
diff --git a/course/tests/courselib_test.php b/course/tests/courselib_test.php
index 463c8243980dd..297e54b4c852c 100644
--- a/course/tests/courselib_test.php
+++ b/course/tests/courselib_test.php
@@ -4227,4 +4227,494 @@ public function test_can_download_from_backup_filearea() {
assign_capability('moodle/backup:downloadfile', CAP_ALLOW, $teacherrole->id, $context);
$this->assertFalse(can_download_from_backup_filearea('testing', $context, $user));
}
+
+ /**
+ * Test cases for the course_classify_courses_for_timeline test.
+ */
+ public function get_course_classify_courses_for_timeline_test_cases() {
+ $now = time();
+ $day = 86400;
+
+ return [
+ 'no courses' => [
+ 'coursesdata' => [],
+ 'expected' => [
+ COURSE_TIMELINE_PAST => [],
+ COURSE_TIMELINE_FUTURE => [],
+ COURSE_TIMELINE_INPROGRESS => []
+ ]
+ ],
+ 'only past' => [
+ 'coursesdata' => [
+ [
+ 'shortname' => 'past1',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'past2',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ]
+ ],
+ 'expected' => [
+ COURSE_TIMELINE_PAST => ['past1', 'past2'],
+ COURSE_TIMELINE_FUTURE => [],
+ COURSE_TIMELINE_INPROGRESS => []
+ ]
+ ],
+ 'only in progress' => [
+ 'coursesdata' => [
+ [
+ 'shortname' => 'inprogress1',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'inprogress2',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ]
+ ],
+ 'expected' => [
+ COURSE_TIMELINE_PAST => [],
+ COURSE_TIMELINE_FUTURE => [],
+ COURSE_TIMELINE_INPROGRESS => ['inprogress1', 'inprogress2']
+ ]
+ ],
+ 'only future' => [
+ 'coursesdata' => [
+ [
+ 'shortname' => 'future1',
+ 'startdate' => $now + $day
+ ],
+ [
+ 'shortname' => 'future2',
+ 'startdate' => $now + $day
+ ]
+ ],
+ 'expected' => [
+ COURSE_TIMELINE_PAST => [],
+ COURSE_TIMELINE_FUTURE => ['future1', 'future2'],
+ COURSE_TIMELINE_INPROGRESS => []
+ ]
+ ],
+ 'combination' => [
+ 'coursesdata' => [
+ [
+ 'shortname' => 'past1',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'past2',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'inprogress1',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'inprogress2',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'future1',
+ 'startdate' => $now + $day
+ ],
+ [
+ 'shortname' => 'future2',
+ 'startdate' => $now + $day
+ ]
+ ],
+ 'expected' => [
+ COURSE_TIMELINE_PAST => ['past1', 'past2'],
+ COURSE_TIMELINE_FUTURE => ['future1', 'future2'],
+ COURSE_TIMELINE_INPROGRESS => ['inprogress1', 'inprogress2']
+ ]
+ ],
+ ];
+ }
+
+ /**
+ * Test the course_classify_courses_for_timeline function.
+ *
+ * @dataProvider get_course_classify_courses_for_timeline_test_cases()
+ * @param array $coursesdata Courses to create
+ * @param array $expected Expected test results.
+ */
+ public function test_course_classify_courses_for_timeline($coursesdata, $expected) {
+ $this->resetAfterTest();
+ $generator = $this->getDataGenerator();
+
+ $courses = array_map(function($coursedata) use ($generator) {
+ return $generator->create_course($coursedata);
+ }, $coursesdata);
+
+ sort($expected[COURSE_TIMELINE_PAST]);
+ sort($expected[COURSE_TIMELINE_FUTURE]);
+ sort($expected[COURSE_TIMELINE_INPROGRESS]);
+
+ $results = course_classify_courses_for_timeline($courses);
+
+ $actualpast = array_map(function($result) {
+ return $result->shortname;
+ }, $results[COURSE_TIMELINE_PAST]);
+
+ $actualfuture = array_map(function($result) {
+ return $result->shortname;
+ }, $results[COURSE_TIMELINE_FUTURE]);
+
+ $actualinprogress = array_map(function($result) {
+ return $result->shortname;
+ }, $results[COURSE_TIMELINE_INPROGRESS]);
+
+ sort($actualpast);
+ sort($actualfuture);
+ sort($actualinprogress);
+
+ $this->assertEquals($expected[COURSE_TIMELINE_PAST], $actualpast);
+ $this->assertEquals($expected[COURSE_TIMELINE_FUTURE], $actualfuture);
+ $this->assertEquals($expected[COURSE_TIMELINE_INPROGRESS], $actualinprogress);
+ }
+
+ /**
+ * Test cases for the course_get_enrolled_courses_for_logged_in_user tests.
+ */
+ public function get_course_get_enrolled_courses_for_logged_in_user_test_cases() {
+ $buildexpectedresult = function($limit, $offset) {
+ $result = [];
+ for ($i = $offset; $i < $offset + $limit; $i++) {
+ $result[] = "testcourse{$i}";
+ }
+ return $result;
+ };
+
+ return [
+ 'zero records' => [
+ 'dbquerylimit' => 3,
+ 'totalcourses' => 0,
+ 'limit' => 0,
+ 'offset' => 0,
+ 'expecteddbqueries' => 1,
+ 'expectedresult' => $buildexpectedresult(0, 0)
+ ],
+ 'less than query limit' => [
+ 'dbquerylimit' => 3,
+ 'totalcourses' => 2,
+ 'limit' => 0,
+ 'offset' => 0,
+ 'expecteddbqueries' => 1,
+ 'expectedresult' => $buildexpectedresult(2, 0)
+ ],
+ 'more than query limit' => [
+ 'dbquerylimit' => 3,
+ 'totalcourses' => 7,
+ 'limit' => 0,
+ 'offset' => 0,
+ 'expecteddbqueries' => 3,
+ 'expectedresult' => $buildexpectedresult(7, 0)
+ ],
+ 'limit less than query limit' => [
+ 'dbquerylimit' => 3,
+ 'totalcourses' => 7,
+ 'limit' => 2,
+ 'offset' => 0,
+ 'expecteddbqueries' => 1,
+ 'expectedresult' => $buildexpectedresult(2, 0)
+ ],
+ 'limit less than query limit with offset' => [
+ 'dbquerylimit' => 3,
+ 'totalcourses' => 7,
+ 'limit' => 2,
+ 'offset' => 2,
+ 'expecteddbqueries' => 1,
+ 'expectedresult' => $buildexpectedresult(2, 2)
+ ],
+ 'limit less than total' => [
+ 'dbquerylimit' => 3,
+ 'totalcourses' => 9,
+ 'limit' => 6,
+ 'offset' => 0,
+ 'expecteddbqueries' => 2,
+ 'expectedresult' => $buildexpectedresult(6, 0)
+ ],
+ 'less results than limit' => [
+ 'dbquerylimit' => 4,
+ 'totalcourses' => 9,
+ 'limit' => 20,
+ 'offset' => 0,
+ 'expecteddbqueries' => 3,
+ 'expectedresult' => $buildexpectedresult(9, 0)
+ ],
+ 'less results than limit exact divisible' => [
+ 'dbquerylimit' => 3,
+ 'totalcourses' => 9,
+ 'limit' => 20,
+ 'offset' => 0,
+ 'expecteddbqueries' => 4,
+ 'expectedresult' => $buildexpectedresult(9, 0)
+ ],
+ 'less results than limit with offset' => [
+ 'dbquerylimit' => 3,
+ 'totalcourses' => 9,
+ 'limit' => 10,
+ 'offset' => 5,
+ 'expecteddbqueries' => 2,
+ 'expectedresult' => $buildexpectedresult(4, 5)
+ ],
+ ];
+ }
+
+ /**
+ * Test the course_get_enrolled_courses_for_logged_in_user function.
+ *
+ * @dataProvider get_course_get_enrolled_courses_for_logged_in_user_test_cases()
+ * @param int $dbquerylimit Number of records to load per DB request
+ * @param int $totalcourses Number of courses to create
+ * @param int $limit Maximum number of results to get.
+ * @param int $offset Skip this number of results from the start of the result set.
+ * @param int $expecteddbqueries The number of DB queries expected during the test.
+ * @param array $expectedresult Expected test results.
+ */
+ public function test_course_get_enrolled_courses_for_logged_in_user(
+ $dbquerylimit,
+ $totalcourses,
+ $limit,
+ $offset,
+ $expecteddbqueries,
+ $expectedresult
+ ) {
+ global $DB;
+
+ $this->resetAfterTest();
+ $generator = $this->getDataGenerator();
+ $student = $generator->create_user();
+
+ for ($i = 0; $i < $totalcourses; $i++) {
+ $shortname = "testcourse{$i}";
+ $course = $generator->create_course(['shortname' => $shortname]);
+ $generator->enrol_user($student->id, $course->id, 'student');
+ }
+
+ $this->setUser($student);
+
+ $initialquerycount = $DB->perf_get_queries();
+ $courses = course_get_enrolled_courses_for_logged_in_user($limit, $offset, 'shortname ASC', 'shortname', $dbquerylimit);
+
+ // Loop over the result set to force the lazy loading to kick in so that we can check the
+ // number of DB queries.
+ $actualresult = array_map(function($course) {
+ return $course->shortname;
+ }, iterator_to_array($courses, false));
+
+ sort($expectedresult);
+
+ $this->assertEquals($expectedresult, $actualresult);
+ $this->assertEquals($expecteddbqueries, $DB->perf_get_queries() - $initialquerycount);
+ }
+
+ /**
+ * Test cases for the course_filter_courses_by_timeline_classification tests.
+ */
+ public function get_course_filter_courses_by_timeline_classification_test_cases() {
+ $now = time();
+ $day = 86400;
+
+ $coursedata = [
+ [
+ 'shortname' => 'apast',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'bpast',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'cpast',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'dpast',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'epast',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'ainprogress',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'binprogress',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'cinprogress',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'dinprogress',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'einprogress',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'afuture',
+ 'startdate' => $now + $day
+ ],
+ [
+ 'shortname' => 'bfuture',
+ 'startdate' => $now + $day
+ ],
+ [
+ 'shortname' => 'cfuture',
+ 'startdate' => $now + $day
+ ],
+ [
+ 'shortname' => 'dfuture',
+ 'startdate' => $now + $day
+ ],
+ [
+ 'shortname' => 'efuture',
+ 'startdate' => $now + $day
+ ]
+ ];
+
+ // Raw enrolled courses result set should be returned in this order:
+ // afuture, ainprogress, apast, bfuture, binprogress, bpast, cfuture, cinprogress, cpast,
+ // dfuture, dinprogress, dpast, efuture, einprogress, epast
+ //
+ // By classification the offset values for each record should be:
+ // COURSE_TIMELINE_FUTURE
+ // 0 (afuture), 3 (bfuture), 6 (cfuture), 9 (dfuture), 12 (efuture)
+ // COURSE_TIMELINE_INPROGRESS
+ // 1 (ainprogress), 4 (binprogress), 7 (cinprogress), 10 (dinprogress), 13 (einprogress)
+ // COURSE_TIMELINE_PAST
+ // 2 (apast), 5 (bpast), 8 (cpast), 11 (dpast), 14 (epast).
+ return [
+ 'empty set' => [
+ 'coursedata' => [],
+ 'classification' => COURSE_TIMELINE_FUTURE,
+ 'limit' => 2,
+ 'offset' => 0,
+ 'expectedcourses' => [],
+ 'expectedprocessedcount' => 0
+ ],
+ // COURSE_TIMELINE_FUTURE.
+ 'future not limit no offset' => [
+ 'coursedata' => $coursedata,
+ 'classification' => COURSE_TIMELINE_FUTURE,
+ 'limit' => 0,
+ 'offset' => 0,
+ 'expectedcourses' => ['afuture', 'bfuture', 'cfuture', 'dfuture', 'efuture'],
+ 'expectedprocessedcount' => 15
+ ],
+ 'future no offset' => [
+ 'coursedata' => $coursedata,
+ 'classification' => COURSE_TIMELINE_FUTURE,
+ 'limit' => 2,
+ 'offset' => 0,
+ 'expectedcourses' => ['afuture', 'bfuture'],
+ 'expectedprocessedcount' => 4
+ ],
+ 'future offset' => [
+ 'coursedata' => $coursedata,
+ 'classification' => COURSE_TIMELINE_FUTURE,
+ 'limit' => 2,
+ 'offset' => 2,
+ 'expectedcourses' => ['bfuture', 'cfuture'],
+ 'expectedprocessedcount' => 5
+ ],
+ 'future exact limit' => [
+ 'coursedata' => $coursedata,
+ 'classification' => COURSE_TIMELINE_FUTURE,
+ 'limit' => 5,
+ 'offset' => 0,
+ 'expectedcourses' => ['afuture', 'bfuture', 'cfuture', 'dfuture', 'efuture'],
+ 'expectedprocessedcount' => 13
+ ],
+ 'future limit less results' => [
+ 'coursedata' => $coursedata,
+ 'classification' => COURSE_TIMELINE_FUTURE,
+ 'limit' => 10,
+ 'offset' => 0,
+ 'expectedcourses' => ['afuture', 'bfuture', 'cfuture', 'dfuture', 'efuture'],
+ 'expectedprocessedcount' => 15
+ ],
+ 'future limit less results with offset' => [
+ 'coursedata' => $coursedata,
+ 'classification' => COURSE_TIMELINE_FUTURE,
+ 'limit' => 10,
+ 'offset' => 5,
+ 'expectedcourses' => ['cfuture', 'dfuture', 'efuture'],
+ 'expectedprocessedcount' => 10
+ ],
+ ];
+ }
+
+ /**
+ * Test the course_filter_courses_by_timeline_classification function.
+ *
+ * @dataProvider get_course_filter_courses_by_timeline_classification_test_cases()
+ * @param array $coursedata Course test data to create.
+ * @param string $classification Timeline classification.
+ * @param int $limit Maximum number of results to return.
+ * @param int $offset Results to skip at the start of the result set.
+ * @param string[] $expectedcourses Expected courses in results.
+ * @param int $expectedprocessedcount Expected number of course records to be processed.
+ */
+ public function test_course_filter_courses_by_timeline_classification(
+ $coursedata,
+ $classification,
+ $limit,
+ $offset,
+ $expectedcourses,
+ $expectedprocessedcount
+ ) {
+ $this->resetAfterTest();
+ $generator = $this->getDataGenerator();
+
+ $courses = array_map(function($coursedata) use ($generator) {
+ return $generator->create_course($coursedata);
+ }, $coursedata);
+
+ $student = $generator->create_user();
+
+ foreach ($courses as $course) {
+ $generator->enrol_user($student->id, $course->id, 'student');
+ }
+
+ $this->setUser($student);
+
+ $coursesgenerator = course_get_enrolled_courses_for_logged_in_user(0, $offset, 'shortname ASC', 'shortname');
+ list($result, $processedcount) = course_filter_courses_by_timeline_classification(
+ $coursesgenerator,
+ $classification,
+ $limit
+ );
+
+ $actual = array_map(function($course) {
+ return $course->shortname;
+ }, $result);
+
+ $this->assertEquals($expectedcourses, $actual);
+ $this->assertEquals($expectedprocessedcount, $processedcount);
+ }
}
diff --git a/course/tests/externallib_test.php b/course/tests/externallib_test.php
index 19cb9b2e33c33..c47608827d8f0 100644
--- a/course/tests/externallib_test.php
+++ b/course/tests/externallib_test.php
@@ -2341,4 +2341,218 @@ public function test_check_updates() {
$this->assertCount(1, $result['warnings']);
$this->assertEquals(-2, $result['warnings'][0]['itemid']);
}
+
+ /**
+ * Test cases for the get_enrolled_courses_by_timeline_classification test.
+ */
+ public function get_get_enrolled_courses_by_timeline_classification_test_cases() {
+ $now = time();
+ $day = 86400;
+
+ $coursedata = [
+ [
+ 'shortname' => 'apast',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'bpast',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'cpast',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'dpast',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'epast',
+ 'startdate' => $now - ($day * 2),
+ 'enddate' => $now - $day
+ ],
+ [
+ 'shortname' => 'ainprogress',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'binprogress',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'cinprogress',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'dinprogress',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'einprogress',
+ 'startdate' => $now - $day,
+ 'enddate' => $now + $day
+ ],
+ [
+ 'shortname' => 'afuture',
+ 'startdate' => $now + $day
+ ],
+ [
+ 'shortname' => 'bfuture',
+ 'startdate' => $now + $day
+ ],
+ [
+ 'shortname' => 'cfuture',
+ 'startdate' => $now + $day
+ ],
+ [
+ 'shortname' => 'dfuture',
+ 'startdate' => $now + $day
+ ],
+ [
+ 'shortname' => 'efuture',
+ 'startdate' => $now + $day
+ ]
+ ];
+
+ // Raw enrolled courses result set should be returned in this order:
+ // afuture, ainprogress, apast, bfuture, binprogress, bpast, cfuture, cinprogress, cpast,
+ // dfuture, dinprogress, dpast, efuture, einprogress, epast
+ //
+ // By classification the offset values for each record should be:
+ // COURSE_TIMELINE_FUTURE
+ // 0 (afuture), 3 (bfuture), 6 (cfuture), 9 (dfuture), 12 (efuture)
+ // COURSE_TIMELINE_INPROGRESS
+ // 1 (ainprogress), 4 (binprogress), 7 (cinprogress), 10 (dinprogress), 13 (einprogress)
+ // COURSE_TIMELINE_PAST
+ // 2 (apast), 5 (bpast), 8 (cpast), 11 (dpast), 14 (epast).
+ //
+ // NOTE: The offset applies to the unfiltered full set of courses before the classification
+ // filtering is done.
+ // E.g. In our example if an offset of 2 is given then it would mean the first
+ // two courses (afuture, ainprogress) are ignored.
+ return [
+ 'empty set' => [
+ 'coursedata' => [],
+ 'classification' => 'future',
+ 'limit' => 2,
+ 'offset' => 0,
+ 'expectedcourses' => [],
+ 'expectednextoffset' => 0
+ ],
+ // COURSE_TIMELINE_FUTURE.
+ 'future not limit no offset' => [
+ 'coursedata' => $coursedata,
+ 'classification' => 'future',
+ 'limit' => 0,
+ 'offset' => 0,
+ 'expectedcourses' => ['afuture', 'bfuture', 'cfuture', 'dfuture', 'efuture'],
+ 'expectednextoffset' => 15
+ ],
+ 'future no offset' => [
+ 'coursedata' => $coursedata,
+ 'classification' => 'future',
+ 'limit' => 2,
+ 'offset' => 0,
+ 'expectedcourses' => ['afuture', 'bfuture'],
+ 'expectednextoffset' => 4
+ ],
+ 'future offset' => [
+ 'coursedata' => $coursedata,
+ 'classification' => 'future',
+ 'limit' => 2,
+ 'offset' => 2,
+ 'expectedcourses' => ['bfuture', 'cfuture'],
+ 'expectednextoffset' => 7
+ ],
+ 'future exact limit' => [
+ 'coursedata' => $coursedata,
+ 'classification' => 'future',
+ 'limit' => 5,
+ 'offset' => 0,
+ 'expectedcourses' => ['afuture', 'bfuture', 'cfuture', 'dfuture', 'efuture'],
+ 'expectednextoffset' => 13
+ ],
+ 'future limit less results' => [
+ 'coursedata' => $coursedata,
+ 'classification' => 'future',
+ 'limit' => 10,
+ 'offset' => 0,
+ 'expectedcourses' => ['afuture', 'bfuture', 'cfuture', 'dfuture', 'efuture'],
+ 'expectednextoffset' => 15
+ ],
+ 'future limit less results with offset' => [
+ 'coursedata' => $coursedata,
+ 'classification' => 'future',
+ 'limit' => 10,
+ 'offset' => 5,
+ 'expectedcourses' => ['cfuture', 'dfuture', 'efuture'],
+ 'expectednextoffset' => 15
+ ],
+ ];
+ }
+
+ /**
+ * Test the get_enrolled_courses_by_timeline_classification function.
+ *
+ * @dataProvider get_get_enrolled_courses_by_timeline_classification_test_cases()
+ * @param array $coursedata Courses to create
+ * @param string $classification Timeline classification
+ * @param int $limit Maximum number of results
+ * @param int $offset Offset the unfiltered courses result set by this amount
+ * @param array $expectedcourses Expected courses in result
+ * @param int $expectednextoffset Expected next offset value in result
+ */
+ public function test_get_enrolled_courses_by_timeline_classification(
+ $coursedata,
+ $classification,
+ $limit,
+ $offset,
+ $expectedcourses,
+ $expectednextoffset
+ ) {
+ $this->resetAfterTest();
+ $generator = $this->getDataGenerator();
+
+ $courses = array_map(function($coursedata) use ($generator) {
+ return $generator->create_course($coursedata);
+ }, $coursedata);
+
+ $student = $generator->create_user();
+
+ foreach ($courses as $course) {
+ $generator->enrol_user($student->id, $course->id, 'student');
+ }
+
+ $this->setUser($student);
+
+ // NOTE: The offset applies to the unfiltered full set of courses before the classification
+ // filtering is done.
+ // E.g. In our example if an offset of 2 is given then it would mean the first
+ // two courses (afuture, ainprogress) are ignored.
+ $result = core_course_external::get_enrolled_courses_by_timeline_classification(
+ $classification,
+ $limit,
+ $offset,
+ 'shortname ASC'
+ );
+ $result = external_api::clean_returnvalue(
+ core_course_external::get_enrolled_courses_by_timeline_classification_returns(),
+ $result
+ );
+
+ $actual = array_map(function($course) {
+ return $course['shortname'];
+ }, $result['courses']);
+
+ $this->assertEquals($expectedcourses, $actual);
+ $this->assertEquals($expectednextoffset, $result['nextoffset']);
+ }
}
diff --git a/lang/en/moodle.php b/lang/en/moodle.php
index 1680fc196bff9..eb72e18b41fb2 100644
--- a/lang/en/moodle.php
+++ b/lang/en/moodle.php
@@ -1471,6 +1471,10 @@
$string['outlinereport'] = 'Outline report';
$string['page'] = 'Page';
$string['pagea'] = 'Page {$a}';
+$string['pagedcontentnavigation'] = 'Pagination navigation';
+$string['pagedcontentnavigationitem'] = 'Go to page {$a}';
+$string['pagedcontentnavigationactiveitem'] = 'Current page, page {$a}';
+$string['pagedcontentpagingbaritemsperpage'] = 'Show {$a} items per page';
$string['pageheaderconfigablock'] = 'Configuring a block in {$a->fullname}';
$string['pagepath'] = 'Page path';
$string['pageshouldredirect'] = 'This page should automatically redirect. If nothing is happening please use the continue link below.';
diff --git a/lib/amd/build/page_global.min.js b/lib/amd/build/page_global.min.js
new file mode 100644
index 0000000000000..838ab91a5f531
--- /dev/null
+++ b/lib/amd/build/page_global.min.js
@@ -0,0 +1 @@
+define(["jquery","core/custom_interaction_events","core/str"],function(a,b,c){var d=function(){var d=a("body");b.define(d,[b.events.activate]),d.on(b.events.activate,"[data-show-active-item]",function(b){var d=a(b.target).closest(".dropdown-item"),e=d.closest("[data-show-active-item]");if(d.hasClass("dropdown-item")&&!d.hasClass("active")){var f=e.find(".dropdown-item");f.removeClass("active"),f.removeAttr("aria-current"),e.attr("data-skip-active-class")||d.addClass("active"),d.attr("aria-current",!0);var g=d.text(),h=e.parent().find('[data-toggle="dropdown"]'),i=h.find("[data-active-item-text]");i.length?i.html(g):h.html(g);var j=e.attr("data-active-item-button-aria-label-components");if(j){var k=j.split(",");k.push(g),c.get_string(k[0].trim(),k[1].trim(),k[2].trim()).then(function(a){return h.attr("aria-label",a),a})["catch"](function(){return!1})}}})},e=function(){d()};return{init:e}});
\ No newline at end of file
diff --git a/lib/amd/build/paged_content.min.js b/lib/amd/build/paged_content.min.js
new file mode 100644
index 0000000000000..860f755172374
--- /dev/null
+++ b/lib/amd/build/paged_content.min.js
@@ -0,0 +1 @@
+define(["jquery","core/paged_content_pages","core/paged_content_paging_bar","core/paged_content_paging_bar_limit_selector","core/paged_content_paging_dropdown"],function(a,b,c,d,e){var f=function(f,g){f=a(f);var h=f.find(b.rootSelector),i=f.find(c.rootSelector),j=f.find(e.rootSelector),k=f.find(d.rootSelector),l=f.attr("id");b.init(h,l,g),i.length&&c.init(i,l),k.length&&d.init(k,l),j.length&&e.init(j,l)};return{init:f,rootSelector:'[data-region="paged-content-container"]'}});
\ No newline at end of file
diff --git a/lib/amd/build/paged_content_events.min.js b/lib/amd/build/paged_content_events.min.js
index 27e1fa814149a..7cd9b320b0c97 100644
--- a/lib/amd/build/paged_content_events.min.js
+++ b/lib/amd/build/paged_content_events.min.js
@@ -1 +1 @@
-define([],function(){return{SHOW_PAGES:"core-paged-content-show-pages"}});
\ No newline at end of file
+define([],function(){return{SHOW_PAGES:"core-paged-content-show-pages",PAGES_SHOWN:"core-paged-content-pages-shown",ALL_ITEMS_LOADED:"core-paged-content-all-items-loaded",SET_ITEMS_PER_PAGE_LIMIT:"core-paged-content-set-items-per-page-limit"}});
\ No newline at end of file
diff --git a/lib/amd/build/paged_content_factory.min.js b/lib/amd/build/paged_content_factory.min.js
index c0fd46e8fbc58..62c3166e4ceb7 100644
--- a/lib/amd/build/paged_content_factory.min.js
+++ b/lib/amd/build/paged_content_factory.min.js
@@ -1 +1 @@
-define(["jquery","core/templates","core/notification","core/paged_content_pages"],function(a,b,c,d){var e={PAGED_CONTENT:"core/paged_content"},f=function(a,b){for(var c={itemsperpage:b,previous:{},next:{},pages:[]},d=1;d<=a;d++){var e={number:d,page:""+d};1===d&&(e.active=!0),c.pages.push(e)}return c},g=function(a,b,c){var d={options:[]},e=0,f=0,g=a;c.hasOwnProperty("maxPages")&&(g=c.maxPages);for(var h=1;h<=g;h++){var i=0;h<=2?(i=b,f=b):(f=2*f,i=f),e+=i;var j={itemcount:i,content:e};1===h&&(j.active=!0),d.options.push(j)}return d},h=function(a,b,c){var d={pagingbar:!1,pagingdropdown:!1,skipjs:!0};return c.hasOwnProperty("dropdown")&&c.dropdown?d.pagingdropdown=g(a,b,c):d.pagingbar=f(a,b),d},i=function(a,b){var c=1;if(a>0){var d=a%b;d?(a-=d,c=a/b+1):c=a/b}return c},j=function(f,g,j,k){"undefined"==typeof k&&(k={});var l=a.Deferred(),m=i(f,g),n=h(m,g,k);return b.render(e.PAGED_CONTENT,n).then(function(b,c){b=a(b);var e=b,f=b.find(d.rootSelector);d.init(f,e,j),l.resolve(b,c)}).fail(function(a){l.reject(a)}).fail(c.exception),l},k=function(a,b,c,d){"undefined"==typeof d&&(d={});var e=a.length;return j(e,b,function(b){var d=[];return b.forEach(function(b){var c=b.offset,f=b.limit?c+b.limit:e,g=a.slice(c,f);d.push(g)}),c(d)},d)};return{createFromAjax:j,createFromStaticList:k}});
\ No newline at end of file
+define(["jquery","core/templates","core/notification","core/paged_content"],function(a,b,c,d){var e={PAGED_CONTENT:"core/paged_content"},f={ITEMS_PER_PAGE_SINGLE:25,ITEMS_PER_PAGE_ARRAY:[25,50,100,0],MAX_PAGES:3},g=function(){return{pagingbar:!1,pagingdropdown:!1,skipjs:!0,ignorecontrolwhileloading:!0,controlplacementbottom:!1}},h=function(){return{showitemsperpageselector:!1,itemsperpage:35,previous:!0,next:!0,activepagenumber:1,hidecontrolonsinglepage:!0,pages:[]}},i=function(a,b){var c=1;if(a>0){var d=a%b;d?(a-=d,c=a/b+1):c=a/b}return c},j=function(b,c){null===c&&(c=f.ITEMS_PER_PAGE_SINGLE),a.isArray(c)&&(c=c[0]);var d=h();d.itemsperpage=c;for(var e=i(b,c),g=1;g<=e;g++){var j={number:g,page:""+g};1===g&&(j.active=!0),d.pages.push(j)}return d},k=function(b){if(a.isArray(b)){var c=b.map(function(a){return"number"==typeof a?{value:a,active:!1}:a}),d=c.filter(function(a){return a.active});return d.length||(c[0].active=!0),c}return b},l=function(b){null===b&&(b=f.ITEMS_PER_PAGE_ARRAY);var c=h();return c.itemsperpage=k(b),c.showitemsperpageselector=a.isArray(b),c},m=function(a,b){return a?j(a,b):l(b)},n=function(b,c){if(null===b&&(b=f.ITEMS_PER_PAGE_SINGLE),a.isArray(b))return{options:b};var d={options:[]},e=0,g=0,h=f.MAX_PAGES;c.hasOwnProperty("maxPages")&&(h=c.maxPages);for(var i=1;i<=h;i++){var j=0;i<=2?(j=b,g=b):(g=2*g,j=g),e+=j;var k={itemcount:j,content:e};1===i&&(k.active=!0),d.options.push(k)}return d},o=function(a,b,c){var d=g();return c.hasOwnProperty("ignoreControlWhileLoading")&&(d.ignorecontrolwhileloading=c.ignoreControlWhileLoading),c.hasOwnProperty("controlPlacementBottom")&&(d.controlplacementbottom=c.controlPlacementBottom),c.hasOwnProperty("hideControlOnSinglePage")&&(d.hidecontrolonsinglepage=c.hideControlOnSinglePage),c.hasOwnProperty("ariaLabels")&&(d.arialabels=c.ariaLabels),c.hasOwnProperty("dropdown")&&c.dropdown?d.pagingdropdown=n(b,c):d.pagingbar=m(a,b),d},p=function(a,b){return r(null,null,a,b)},q=function(a,b,c){return r(null,a,b,c)},r=function(f,g,h,i){i=i||{};var j=a.Deferred(),k=o(f,g,i);return b.render(e.PAGED_CONTENT,k).then(function(b,c){b=a(b);var e=b;d.init(e,h),j.resolve(b,c)}).fail(function(a){j.reject(a)}).fail(c.exception),j.promise()},s=function(a,b,c,d){"undefined"==typeof d&&(d={});var e=a.length;return r(e,b,function(b){var d=[];return b.forEach(function(b){var c=b.offset,f=b.limit?c+b.limit:e,g=a.slice(c,f);d.push(g)}),c(d)},d)};return{create:p,createWithLimit:q,createWithTotalAndLimit:r,createFromStaticList:s,createFromAjax:r}});
\ No newline at end of file
diff --git a/lib/amd/build/paged_content_pages.min.js b/lib/amd/build/paged_content_pages.min.js
index b59f52d4601fe..ae2015fdea7d9 100644
--- a/lib/amd/build/paged_content_pages.min.js
+++ b/lib/amd/build/paged_content_pages.min.js
@@ -1 +1 @@
-define(["jquery","core/templates","core/notification","core/paged_content_events"],function(a,b,c,d){var e={ROOT:'[data-region="page-container"]',PAGE_REGION:'[data-region="paged-content-page"]',ACTIVE_PAGE_REGION:'[data-region="paged-content-page"].active'},f={PAGING_CONTENT_ITEM:"core/paged_content_page",LOADING:"core/overlay_loading"},g=function(a,b){return a.find('[data-page="'+b+'"]')},h=function(d){var e=a.Deferred();return b.render(f.LOADING,{visible:!0}).then(function(b){var c=a(b),f=setTimeout(function(){d.css("position","relative"),c.appendTo(d)},100);e.always(function(){clearTimeout(f),c.remove(),d.css("position","")})}).fail(c.exception),e},i=function(d,e,h){var i=a.Deferred();return e.then(function(a,e){b.render(f.PAGING_CONTENT_ITEM,{page:h,content:a}).then(function(a){b.appendNodeContents(d,a,e);var c=g(d,h);i.resolve(c)}).fail(function(a){i.reject(a)}).fail(c.exception)}).fail(function(a){i.reject(a)}).fail(c.exception),i},j=function(b,d,f){var j=[],k=[],l=a.Deferred();if(d.forEach(function(a){var c=a.pageNumber,d=g(b,c);d.length?j.push(d):k.push(a)}),k.length&&"function"==typeof f){var m=f(k),n=m.map(function(a,c){return i(b,a,k[c].pageNumber)});a.when.apply(a,n).then(function(){var a=Array.prototype.slice.call(arguments);l.resolve(a)}).fail(function(a){l.reject(a)}).fail(c.exception)}else l.resolve([]);var o=h(b);l.then(function(a){var c=j.concat(a);b.find(e.PAGE_REGION).addClass("hidden"),c.forEach(function(a){a.removeClass("hidden")})}).fail(c.exception).always(function(){o.resolve()})},k=function(b,c,e){b=a(b),c=a(c),c.on(d.SHOW_PAGES,function(a,c){j(b,c,e)})};return{init:k,rootSelector:e.ROOT}});
\ No newline at end of file
+define(["jquery","core/templates","core/notification","core/pubsub","core/paged_content_events"],function(a,b,c,d,e){var f={ROOT:'[data-region="page-container"]',PAGE_REGION:'[data-region="paged-content-page"]',ACTIVE_PAGE_REGION:'[data-region="paged-content-page"].active'},g={PAGING_CONTENT_ITEM:"core/paged_content_page",LOADING:"core/overlay_loading"},h=300,i=function(a,b){return a.find('[data-page="'+b+'"]')},j=function(d){var e=a.Deferred();return d.attr("aria-busy",!0),b.render(g.LOADING,{visible:!0}).then(function(b){var c=a(b),f=setTimeout(function(){d.css("position","relative"),c.appendTo(d)},h);e.always(function(){clearTimeout(f),c.remove(),d.css("position",""),d.removeAttr("aria-busy")})}).fail(c.exception),e},k=function(d,e,f){var h=a.Deferred();return e.then(function(a,e){e=e||"",b.render(g.PAGING_CONTENT_ITEM,{page:f,content:a}).then(function(a){b.appendNodeContents(d,a,e);var c=i(d,f);h.resolve(c)}).fail(function(a){h.reject(a)}).fail(c.exception)}).fail(function(a){h.reject(a)}).fail(c.exception),h.promise()},l=function(b,g,h,l){var m=[],n=[],o=a.Deferred();if(g.forEach(function(a){var c=a.pageNumber,d=i(b,c);d.length?m.push(d):n.push(a)}),n.length&&"function"==typeof l){var p=l(n,{allItemsLoaded:function(a){d.publish(h+e.ALL_ITEMS_LOADED,a)}}),q=p.map(function(a,c){return k(b,a,n[c].pageNumber)});a.when.apply(a,q).then(function(){var a=Array.prototype.slice.call(arguments);o.resolve(a)}).fail(function(a){o.reject(a)}).fail(c.exception)}else o.resolve([]);var r=j(b);o.then(function(a){var c=m.concat(a);b.find(f.PAGE_REGION).addClass("hidden"),c.forEach(function(a){a.removeClass("hidden")})}).then(function(){d.publish(h+e.PAGES_SHOWN,g)}).fail(c.exception).always(function(){r.resolve()})},m=function(b,c,f){b=a(b),d.subscribe(c+e.SHOW_PAGES,function(a){l(b,a,c,f)}),d.subscribe(c+e.SET_ITEMS_PER_PAGE_LIMIT,function(){b.empty()})};return{init:m,rootSelector:f.ROOT}});
\ No newline at end of file
diff --git a/lib/amd/build/paged_content_paging_bar.min.js b/lib/amd/build/paged_content_paging_bar.min.js
index fcf975938cc79..0c5dc70e8f47a 100644
--- a/lib/amd/build/paged_content_paging_bar.min.js
+++ b/lib/amd/build/paged_content_paging_bar.min.js
@@ -1 +1 @@
-define(["jquery","core/custom_interaction_events","core/paged_content_events"],function(a,b,c){var d={ROOT:'[data-region="paging-bar"]',PAGE:"[data-page]",PAGE_ITEM:'[data-region="page-item"]',ACTIVE_PAGE_ITEM:'[data-region="page-item"].active'},e=function(a,b){return a.find(d.PAGE_ITEM+'[data-page-number="'+b+'"]')},f=function(a){var b=a.find(d.PAGE).last();return b?parseInt(b.attr("data-page-number"),10):null},g=function(a){var b=a.find(d.ACTIVE_PAGE_ITEM);return b.length?h(a,b):null},h=function(a,b){if(void 0!=b.attr("data-page"))return parseInt(b.attr("data-page-number"),10);var c=1,d=null;switch(b.attr("data-control")){case"first":c=1;break;case"last":c=f(a);break;case"next":d=g(a);var e=f(a);c=d&&d1?d-1:1;break;default:c=1}return parseInt(c,10)},i=function(a){return parseInt(a.attr("data-items-per-page"),10)},j=function(b){b.each(function(b,c){c=a(c),c.attr("data-page-number",b+1)})},k=function(a,b){var f=b==g(a),h=i(a),j=(b-1)*h;if(!f){a.find(d.PAGE_ITEM).removeClass("active");var k=e(a,b);k.addClass("active")}a.trigger(c.SHOW_PAGES,[[{pageNumber:b,limit:h,offset:j}]])},l=function(c){c=a(c);var e=c.find(d.PAGE);j(e);var f=g(c);f&&k(c,f),b.define(c,[b.events.activate]),c.on(b.events.activate,d.PAGE_ITEM,function(b,e){var f=a(b.target).closest(d.PAGE_ITEM),g=h(c,f);k(c,g),e.originalEvent.preventDefault(),e.originalEvent.stopPropagation()})};return{init:l,rootSelector:d.ROOT}});
\ No newline at end of file
+define(["jquery","core/custom_interaction_events","core/paged_content_events","core/str","core/pubsub"],function(a,b,c,d,e){var f={ROOT:'[data-region="paging-bar"]',PAGE:"[data-page]",PAGE_ITEM:'[data-region="page-item"]',PAGE_LINK:'[data-region="page-link"]',FIRST_BUTTON:'[data-control="first"]',LAST_BUTTON:'[data-control="last"]',NEXT_BUTTON:'[data-control="next"]',PREVIOUS_BUTTON:'[data-control="previous"]'},g=function(a,b){return a.find(f.PAGE_ITEM+'[data-page-number="'+b+'"]')},h=function(a){return a.find(f.NEXT_BUTTON)},i=function(a,b){a.attr("data-last-page-number",b)},j=function(a){return parseInt(a.attr("data-last-page-number"),10)},k=function(a){return parseInt(a.attr("data-active-page-number"),10)},l=function(a,b){a.attr("data-active-page-number",b)},m=function(a){var b=k(a);return!isNaN(b)&&0!=b},n=function(a,b){if(void 0!=b.attr("data-page"))return parseInt(b.attr("data-page-number"),10);var c=1,d=null;switch(b.attr("data-control")){case"first":c=1;break;case"last":c=j(a);break;case"next":d=k(a);var e=j(a);c=e?d&&d1?d-1:1;break;default:c=1}return parseInt(c,10)},o=function(a){return parseInt(a.attr("data-items-per-page"),10)},p=function(a,b){a.attr("data-items-per-page",b)},q=function(a){a.removeClass("hidden")},r=function(a){a.addClass("hidden")},s=function(a){var b=a.find(f.NEXT_BUTTON),c=a.find(f.LAST_BUTTON);b.addClass("disabled"),b.attr("aria-disabled",!0),c.addClass("disabled"),c.attr("aria-disabled",!0)},t=function(a){var b=a.find(f.NEXT_BUTTON),c=a.find(f.LAST_BUTTON);b.removeClass("disabled"),b.removeAttr("aria-disabled"),c.removeClass("disabled"),c.removeAttr("aria-disabled")},u=function(a){var b=a.find(f.PREVIOUS_BUTTON),c=a.find(f.FIRST_BUTTON);b.addClass("disabled"),b.attr("aria-disabled",!0),c.addClass("disabled"),c.attr("aria-disabled",!0)},v=function(a){var b=a.find(f.PREVIOUS_BUTTON),c=a.find(f.FIRST_BUTTON);b.removeClass("disabled"),b.removeAttr("aria-disabled"),c.removeClass("disabled"),c.removeAttr("aria-disabled")},w=function(a){var b=a.attr("data-aria-label-components-pagination-item"),c=b.split(",").map(function(a){return a.trim()});return c},x=function(a){var b=a.attr("data-aria-label-components-pagination-active-item"),c=b.split(",").map(function(a){return a.trim()});return c},y=function(b,c){var d=0;l(b,0),c.each(function(c,e){var f=c+1;e=a(e),e.attr("data-page-number",f),d++,e.hasClass("active")&&l(b,f)}),i(b,d)},z=function(b){var c=w(b),e=x(b),g=k(b),h=b.find(f.PAGE_ITEM),i=h.map(function(d,f){f=a(f);var h=n(b,f);return h===g?{key:e[0],component:e[1],param:h}:{key:c[0],component:c[1],param:h}});d.get_strings(i).then(function(b){return h.each(function(c,d){d=a(d);var e=b[c];d.attr("aria-label",e),d.find(f.PAGE_LINK).attr("aria-label",e)}),b})["catch"](function(){})},A=function(a,b,d){var h=j(a),i=b==k(a),m=o(a),n=(b-1)*m;if(!i){a.find(f.PAGE_ITEM).removeClass("active").removeAttr("aria-current");var p=g(a,b);p.addClass("active"),p.attr("aria-current",!0),l(a,b)}h&&b>=h?s(a):t(a),b>1?v(a):u(a),z(a),e.publish(d+c.SHOW_PAGES,[{pageNumber:b,limit:m,offset:n}])},B=function(d,g){var h=d.attr("data-ignore-control-while-loading"),k=!1;""==h&&(h=!0),b.define(d,[b.events.activate]),d.on(b.events.activate,f.PAGE_ITEM,function(b,c){if(c.originalEvent.preventDefault(),c.originalEvent.stopPropagation(),!h||!k){var e=a(b.target).closest(f.PAGE_ITEM);if(!e.hasClass("disabled")){var i=n(d,e);A(d,i,g),k=!0}}}),e.subscribe(g+c.ALL_ITEMS_LOADED,function(a){k=!1;var b=j(d);(!b||ab,d=Math.abs(a-b),f=c?Math.floor(d/e):Math.ceil(d/e),g=f*e,h=c?b+g:b-g;return h};return{get:n,getUserMidnightForTimestamp:o}});
\ No newline at end of file
diff --git a/lib/amd/src/page_global.js b/lib/amd/src/page_global.js
new file mode 100644
index 0000000000000..88360b8bf294e
--- /dev/null
+++ b/lib/amd/src/page_global.js
@@ -0,0 +1,135 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Provide global helper code to enhance page elements.
+ *
+ * @module core/page_global
+ * @package core
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(
+[
+ 'jquery',
+ 'core/custom_interaction_events',
+ 'core/str',
+],
+function(
+ $,
+ CustomEvents,
+ Str
+) {
+
+ /**
+ * Add an event handler for dropdown menus that wish to show their active item
+ * in the dropdown toggle element.
+ *
+ * By default the handler will add the "active" class to the selected dropdown
+ * item and set it's text as the HTML for the dropdown toggle.
+ *
+ * The behaviour of this handler is controlled by adding data attributes to
+ * the HTML and requires the typically Bootstrap dropdown markup.
+ *
+ * data-show-active-item - Add to the .dropdown-menu element to enable default
+ * functionality.
+ * data-skip-active-class - Add to the .dropdown-menu to prevent this code from
+ * adding the active class to the dropdown items
+ * data-active-item-text - Add to an element within the data-toggle="dropdown" element
+ * to use it as the active option text placeholder otherwise the
+ * data-toggle="dropdown" element itself will be used.
+ * data-active-item-button-aria-label-components - String components to set the aria
+ * lable on the dropdown button. The string will be given the
+ * active item text.
+ */
+ var initActionOptionDropdownHandler = function() {
+ var body = $('body');
+
+ CustomEvents.define(body, [CustomEvents.events.activate]);
+ body.on(CustomEvents.events.activate, '[data-show-active-item]', function(e) {
+ // The dropdown item that the user clicked on.
+ var option = $(e.target).closest('.dropdown-item');
+ // The dropdown menu element.
+ var menuContainer = option.closest('[data-show-active-item]');
+
+ if (!option.hasClass('dropdown-item')) {
+ // Ignore non Bootstrap dropdowns.
+ return;
+ }
+
+ if (option.hasClass('active')) {
+ // If it's already active then we don't need to do anything.
+ return;
+ }
+
+ // Clear the active class from all other options.
+ var dropdownItems = menuContainer.find('.dropdown-item');
+ dropdownItems.removeClass('active');
+ dropdownItems.removeAttr('aria-current');
+
+ if (!menuContainer.attr('data-skip-active-class')) {
+ // Make this option active unless configured to ignore it.
+ // Some code, for example the Bootstrap tabs, may want to handle
+ // adding the active class itself.
+ option.addClass('active');
+ }
+
+ // Update aria attribute for active item.
+ option.attr('aria-current', true);
+
+ var activeOptionText = option.text();
+ var dropdownToggle = menuContainer.parent().find('[data-toggle="dropdown"]');
+ var dropdownToggleText = dropdownToggle.find('[data-active-item-text]');
+
+ if (dropdownToggleText.length) {
+ // We have a specific placeholder for the active item text so
+ // use that.
+ dropdownToggleText.html(activeOptionText);
+ } else {
+ // Otherwise just replace all of the toggle text with the active item.
+ dropdownToggle.html(activeOptionText);
+ }
+
+ var activeItemAriaLabelComponent = menuContainer.attr('data-active-item-button-aria-label-components');
+ if (activeItemAriaLabelComponent) {
+ // If we have string components for the aria label then load the string
+ // and set the label on the dropdown toggle.
+ var strParams = activeItemAriaLabelComponent.split(',');
+ strParams.push(activeOptionText);
+
+ Str.get_string(strParams[0].trim(), strParams[1].trim(), strParams[2].trim())
+ .then(function(string) {
+ dropdownToggle.attr('aria-label', string);
+ return string;
+ })
+ .catch(function() {
+ // Silently ignore that we couldn't load the string.
+ return false;
+ });
+ }
+ });
+ };
+
+ /**
+ * Initialise the global helper functions.
+ */
+ var init = function() {
+ initActionOptionDropdownHandler();
+ };
+
+ return {
+ init: init
+ };
+});
diff --git a/lib/amd/src/paged_content.js b/lib/amd/src/paged_content.js
new file mode 100644
index 0000000000000..9067ca5d10d2b
--- /dev/null
+++ b/lib/amd/src/paged_content.js
@@ -0,0 +1,75 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Javascript to load and render a paged content section.
+ *
+ * @module core/paged_content
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(
+[
+ 'jquery',
+ 'core/paged_content_pages',
+ 'core/paged_content_paging_bar',
+ 'core/paged_content_paging_bar_limit_selector',
+ 'core/paged_content_paging_dropdown'
+],
+function(
+ $,
+ Pages,
+ PagingBar,
+ PagingBarLimitSelector,
+ Dropdown
+) {
+
+ /**
+ * Initialise the paged content region by running the pages
+ * module and initialising any paging controls in the DOM.
+ *
+ * @param {object} root The paged content container element
+ * @param {function} renderPagesContentCallback (optional) A callback function to render a
+ * content page. See core/paged_content_pages for
+ * more defails.
+ */
+ var init = function(root, renderPagesContentCallback) {
+ root = $(root);
+ var pagesContainer = root.find(Pages.rootSelector);
+ var pagingBarContainer = root.find(PagingBar.rootSelector);
+ var dropdownContainer = root.find(Dropdown.rootSelector);
+ var pagingBarLimitSelectorContainer = root.find(PagingBarLimitSelector.rootSelector);
+ var id = root.attr('id');
+
+ Pages.init(pagesContainer, id, renderPagesContentCallback);
+
+ if (pagingBarContainer.length) {
+ PagingBar.init(pagingBarContainer, id);
+ }
+
+ if (pagingBarLimitSelectorContainer.length) {
+ PagingBarLimitSelector.init(pagingBarLimitSelectorContainer, id);
+ }
+
+ if (dropdownContainer.length) {
+ Dropdown.init(dropdownContainer, id);
+ }
+ };
+
+ return {
+ init: init,
+ rootSelector: '[data-region="paged-content-container"]'
+ };
+});
diff --git a/lib/amd/src/paged_content_events.js b/lib/amd/src/paged_content_events.js
index 1181cb1657b8c..b7a0b1e347591 100644
--- a/lib/amd/src/paged_content_events.js
+++ b/lib/amd/src/paged_content_events.js
@@ -14,14 +14,17 @@
// along with Moodle. If not, see .
/**
- * Javascript to load and render the paging bar.
+ * Events for the paged content element.
*
- * @module core/paging_bar
+ * @module core/paged_content_events
* @copyright 2018 Ryan Wyllie
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define([], function() {
return {
SHOW_PAGES: 'core-paged-content-show-pages',
+ PAGES_SHOWN: 'core-paged-content-pages-shown',
+ ALL_ITEMS_LOADED: 'core-paged-content-all-items-loaded',
+ SET_ITEMS_PER_PAGE_LIMIT: 'core-paged-content-set-items-per-page-limit'
};
});
diff --git a/lib/amd/src/paged_content_factory.js b/lib/amd/src/paged_content_factory.js
index 709dd8ebaa169..696eeeb5180d9 100644
--- a/lib/amd/src/paged_content_factory.js
+++ b/lib/amd/src/paged_content_factory.js
@@ -25,7 +25,7 @@ define(
'jquery',
'core/templates',
'core/notification',
- 'core/paged_content_pages'
+ 'core/paged_content'
],
function(
$,
@@ -37,21 +37,92 @@ function(
PAGED_CONTENT: 'core/paged_content'
};
+ var DEFAULT = {
+ ITEMS_PER_PAGE_SINGLE: 25,
+ ITEMS_PER_PAGE_ARRAY: [25, 50, 100, 0],
+ MAX_PAGES: 3
+ };
+
/**
- * Build the context to render the paging bar template with based on the number
- * of pages to show.
+ * Get the default context to render the paged content mustache
+ * template.
*
- * @param {int} numberOfPages How many pages to have in the paging bar.
- * @param {int} itemsPerPage How many items will be shown per page.
- * @return {object} The template context.
+ * @return {object}
*/
- var buildPagingBarTemplateContext = function(numberOfPages, itemsPerPage) {
- var context = {
- "itemsperpage": itemsPerPage,
- "previous": {},
- "next": {},
- "pages": []
+ var getDefaultTemplateContext = function() {
+ return {
+ pagingbar: false,
+ pagingdropdown: false,
+ skipjs: true,
+ ignorecontrolwhileloading: true,
+ controlplacementbottom: false
};
+ };
+
+ /**
+ * Get the default context to render the paging bar mustache template.
+ *
+ * @return {object}
+ */
+ var getDefaultPagingBarTemplateContext = function() {
+ return {
+ showitemsperpageselector: false,
+ itemsperpage: 35,
+ previous: true,
+ next: true,
+ activepagenumber: 1,
+ hidecontrolonsinglepage: true,
+ pages: []
+ };
+ };
+
+ /**
+ * Calculate the number of pages required for the given number of items and
+ * how many of each item should appear on a page.
+ *
+ * @param {Number} numberOfItems How many items in total.
+ * @param {Number} itemsPerPage How many items will be shown per page.
+ * @return {Number} The number of pages required.
+ */
+ var calculateNumberOfPages = function(numberOfItems, itemsPerPage) {
+ var numberOfPages = 1;
+
+ if (numberOfItems > 0) {
+ var partial = numberOfItems % itemsPerPage;
+
+ if (partial) {
+ numberOfItems -= partial;
+ numberOfPages = (numberOfItems / itemsPerPage) + 1;
+ } else {
+ numberOfPages = numberOfItems / itemsPerPage;
+ }
+ }
+
+ return numberOfPages;
+ };
+
+ /**
+ * Build the context for the paging bar template when we have a known number
+ * of items.
+ *
+ * @param {Number} numberOfItems How many items in total.
+ * @param {Number} itemsPerPage How many items will be shown per page.
+ * @return {object} Mustache template
+ */
+ var buildPagingBarTemplateContextKnownLength = function(numberOfItems, itemsPerPage) {
+ if (itemsPerPage === null) {
+ itemsPerPage = DEFAULT.ITEMS_PER_PAGE_SINGLE;
+ }
+
+ if ($.isArray(itemsPerPage)) {
+ // If we're given a total number of pages then we don't support a variable
+ // set of items per page so just use the first one.
+ itemsPerPage = itemsPerPage[0];
+ }
+
+ var context = getDefaultPagingBarTemplateContext();
+ context.itemsperpage = itemsPerPage;
+ var numberOfPages = calculateNumberOfPages(numberOfItems, itemsPerPage);
for (var i = 1; i <= numberOfPages; i++) {
var page = {
@@ -71,15 +142,101 @@ function(
};
/**
- * Build the context to render the paging dropdown template with based on the number
+ * Convert the itemsPerPage value into a format applicable for the mustache template.
+ * The given value can be either a single integer or an array of integers / objects.
+ *
+ * E.g.
+ * In: [5, 10]
+ * out: [{value: 5, active: true}, {value: 10, active: false}]
+ *
+ * In: [5, {value: 10, active: true}]
+ * Out: [{value: 5, active: false}, {value: 10, active: true}]
+ *
+ * In: [{value: 5, active: false}, {value: 10, active: true}]
+ * Out: [{value: 5, active: false}, {value: 10, active: true}]
+ *
+ * @param {int|int[]} itemsPerPage Options for number of items per page.
+ * @return {int|array}
+ */
+ var buildItemsPerPagePagingBarContext = function(itemsPerPage) {
+ if ($.isArray(itemsPerPage)) {
+ // Convert the array into a format accepted by the template.
+ var context = itemsPerPage.map(function(num) {
+ if (typeof num === 'number') {
+ // If the item is just a plain number then convert it into
+ // an object with value and active keys.
+ return {
+ value: num,
+ active: false
+ };
+ } else {
+ // Otherwise we assume the caller has specified things correctly.
+ return num;
+ }
+ });
+
+ var activeItems = context.filter(function(item) {
+ return item.active;
+ });
+
+ // Default the first item to active if one hasn't been specified.
+ if (!activeItems.length) {
+ context[0].active = true;
+ }
+
+ return context;
+ } else {
+ return itemsPerPage;
+ }
+ };
+
+ /**
+ * Build the context for the paging bar template when we have an unknown
+ * number of items.
+ *
+ * @param {Number} itemsPerPage How many items will be shown per page.
+ * @return {object} Mustache template
+ */
+ var buildPagingBarTemplateContextUnknownLength = function(itemsPerPage) {
+ if (itemsPerPage === null) {
+ itemsPerPage = DEFAULT.ITEMS_PER_PAGE_ARRAY;
+ }
+
+ var context = getDefaultPagingBarTemplateContext();
+ context.itemsperpage = buildItemsPerPagePagingBarContext(itemsPerPage);
+ context.showitemsperpageselector = $.isArray(itemsPerPage);
+
+ return context;
+ };
+
+ /**
+ * Build the context to render the paging bar template with based on the number
+ * of pages to show.
+ *
+ * @param {int|null} numberOfItems How many items are there total.
+ * @param {int|null} itemsPerPage How many items will be shown per page.
+ * @return {object} The template context.
+ */
+ var buildPagingBarTemplateContext = function(numberOfItems, itemsPerPage) {
+ if (numberOfItems) {
+ return buildPagingBarTemplateContextKnownLength(numberOfItems, itemsPerPage);
+ } else {
+ return buildPagingBarTemplateContextUnknownLength(itemsPerPage);
+ }
+ };
+
+ /**
+ * Build the context to render the paging dropdown template based on the number
* of pages to show and items per page.
*
* This control is rendered with a gradual increase of the items per page to
* limit the number of pages in the dropdown. Each page will show twice as much
* as the previous page (except for the first two pages).
*
+ * By default there will only be 4 pages shown (including the "All" option) unless
+ * a different number of pages is defined using the maxPages config value.
+ *
* For example:
- * Number of pages = 3
* Items per page = 25
* Would render a dropdown will 4 options:
* 25
@@ -87,19 +244,30 @@ function(
* 100
* All
*
- * @param {int} numberOfPages How many options to have in the dropdown.
- * @param {int} itemsPerPage How many items will be shown per page.
+ * @param {Number} itemsPerPage How many items will be shown per page.
* @param {object} config Configuration options provided by the client.
* @return {object} The template context.
*/
- var buildPagingDropdownTemplateContext = function(numberOfPages, itemsPerPage, config) {
+ var buildPagingDropdownTemplateContext = function(itemsPerPage, config) {
+ if (itemsPerPage === null) {
+ itemsPerPage = DEFAULT.ITEMS_PER_PAGE_SINGLE;
+ }
+
+ if ($.isArray(itemsPerPage)) {
+ // If we're given an array for the items per page, rather than a number,
+ // then just use that as the options for the dropdown.
+ return {
+ options: itemsPerPage
+ };
+ }
+
var context = {
options: []
};
var totalItems = 0;
var lastIncrease = 0;
- var maxPages = numberOfPages;
+ var maxPages = DEFAULT.MAX_PAGES;
if (config.hasOwnProperty('maxPages')) {
maxPages = config.maxPages;
@@ -140,50 +308,86 @@ function(
* By default the code will render a paging bar for the paging controls unless
* otherwise specified in the provided config.
*
- * @param {int} numberOfPages How many pages to have.
- * @param {int} itemsPerPage How many items will be shown per page.
+ * @param {int|null} numberOfItems Total number of items.
+ * @param {int|null|array} itemsPerPage How many items will be shown per page.
* @param {object} config Configuration options provided by the client.
* @return {object} The template context.
*/
- var buildTemplateContext = function(numberOfPages, itemsPerPage, config) {
- var context = {
- pagingbar: false,
- pagingdropdown: false,
- skipjs: true
- };
+ var buildTemplateContext = function(numberOfItems, itemsPerPage, config) {
+ var context = getDefaultTemplateContext();
+
+ if (config.hasOwnProperty('ignoreControlWhileLoading')) {
+ context.ignorecontrolwhileloading = config.ignoreControlWhileLoading;
+ }
+
+ if (config.hasOwnProperty('controlPlacementBottom')) {
+ context.controlplacementbottom = config.controlPlacementBottom;
+ }
+
+ if (config.hasOwnProperty('hideControlOnSinglePage')) {
+ context.hidecontrolonsinglepage = config.hideControlOnSinglePage;
+ }
+
+ if (config.hasOwnProperty('ariaLabels')) {
+ context.arialabels = config.ariaLabels;
+ }
if (config.hasOwnProperty('dropdown') && config.dropdown) {
- context.pagingdropdown = buildPagingDropdownTemplateContext(numberOfPages, itemsPerPage, config);
+ context.pagingdropdown = buildPagingDropdownTemplateContext(itemsPerPage, config);
} else {
- context.pagingbar = buildPagingBarTemplateContext(numberOfPages, itemsPerPage);
+ context.pagingbar = buildPagingBarTemplateContext(numberOfItems, itemsPerPage);
}
return context;
};
/**
- * Calculate the number of pages required for the given number of items and
- * how many of each item should appear on a page.
+ * Create a paged content widget where the complete list of items is not loaded
+ * up front but will instead be loaded by an ajax request (or similar).
*
- * @param {int} numberOfItems How many items in total.
- * @param {int} itemsPerPage How many items will be shown per page.
- * @return {int} The number of pages required.
+ * The client code must provide a callback function which loads and renders the
+ * items for each page. See PagedContent.init for more details.
+ *
+ * The function will return a deferred that is resolved with a jQuery object
+ * for the HTML content and a string for the JavaScript.
+ *
+ * The current list of configuration options available are:
+ * dropdown {bool} True to render the page control as a dropdown (paging bar is default).
+ * maxPages {Number} The maximum number of pages to show in the dropdown (only works with dropdown option)
+ * ignoreControlWhileLoading {bool} Disable the pagination controls while loading a page (default to true)
+ * controlPlacementBottom {bool} Render controls under paged content (default to false)
+ *
+ * @param {function} renderPagesContentCallback Callback for loading and rendering the items.
+ * @param {object} config Configuration options provided by the client.
+ * @return {promise} Resolved with jQuery HTML and string JS.
*/
- var calculateNumberOfPages = function(numberOfItems, itemsPerPage) {
- var numberOfPages = 1;
-
- if (numberOfItems > 0) {
- var partial = numberOfItems % itemsPerPage;
-
- if (partial) {
- numberOfItems -= partial;
- numberOfPages = (numberOfItems / itemsPerPage) + 1;
- } else {
- numberOfPages = numberOfItems / itemsPerPage;
- }
- }
+ var create = function(renderPagesContentCallback, config) {
+ return createWithTotalAndLimit(null, null, renderPagesContentCallback, config);
+ };
- return numberOfPages;
+ /**
+ * Create a paged content widget where the complete list of items is not loaded
+ * up front but will instead be loaded by an ajax request (or similar).
+ *
+ * The client code must provide a callback function which loads and renders the
+ * items for each page. See PagedContent.init for more details.
+ *
+ * The function will return a deferred that is resolved with a jQuery object
+ * for the HTML content and a string for the JavaScript.
+ *
+ * The current list of configuration options available are:
+ * dropdown {bool} True to render the page control as a dropdown (paging bar is default).
+ * maxPages {Number} The maximum number of pages to show in the dropdown (only works with dropdown option)
+ * ignoreControlWhileLoading {bool} Disable the pagination controls while loading a page (default to true)
+ * controlPlacementBottom {bool} Render controls under paged content (default to false)
+ *
+ * @param {int|array|null} itemsPerPage How many items will be shown per page.
+ * @param {function} renderPagesContentCallback Callback for loading and rendering the items.
+ * @param {object} config Configuration options provided by the client.
+ * @return {promise} Resolved with jQuery HTML and string JS.
+ */
+ var createWithLimit = function(itemsPerPage, renderPagesContentCallback, config) {
+ return createWithTotalAndLimit(null, itemsPerPage, renderPagesContentCallback, config);
};
/**
@@ -198,30 +402,29 @@ function(
*
* The current list of configuration options available are:
* dropdown {bool} True to render the page control as a dropdown (paging bar is default).
+ * maxPages {Number} The maximum number of pages to show in the dropdown (only works with dropdown option)
+ * ignoreControlWhileLoading {bool} Disable the pagination controls while loading a page (default to true)
+ * controlPlacementBottom {bool} Render controls under paged content (default to false)
*
- * @param {int} numberOfItems How many items are there in total.
- * @param {int} itemsPerPage How many items will be shown per page.
+ * @param {int|null} numberOfItems How many items are there in total.
+ * @param {int|array|null} itemsPerPage How many items will be shown per page.
* @param {function} renderPagesContentCallback Callback for loading and rendering the items.
* @param {object} config Configuration options provided by the client.
* @return {promise} Resolved with jQuery HTML and string JS.
*/
- var createFromAjax = function(numberOfItems, itemsPerPage, renderPagesContentCallback, config) {
- if (typeof config == 'undefined') {
- config = {};
- }
+ var createWithTotalAndLimit = function(numberOfItems, itemsPerPage, renderPagesContentCallback, config) {
+ config = config || {};
var deferred = $.Deferred();
- var numberOfPages = calculateNumberOfPages(numberOfItems, itemsPerPage);
- var templateContext = buildTemplateContext(numberOfPages, itemsPerPage, config);
+ var templateContext = buildTemplateContext(numberOfItems, itemsPerPage, config);
Templates.render(TEMPLATES.PAGED_CONTENT, templateContext)
.then(function(html, js) {
html = $(html);
var container = html;
- var pagedContent = html.find(PagedContent.rootSelector);
- PagedContent.init(pagedContent, container, renderPagesContentCallback);
+ PagedContent.init(container, renderPagesContentCallback);
deferred.resolve(html, js);
return;
@@ -231,7 +434,7 @@ function(
})
.fail(Notification.exception);
- return deferred;
+ return deferred.promise();
};
/**
@@ -247,9 +450,12 @@ function(
*
* The current list of configuration options available are:
* dropdown {bool} True to render the page control as a dropdown (paging bar is default).
+ * maxPages {Number} The maximum number of pages to show in the dropdown (only works with dropdown option)
+ * ignoreControlWhileLoading {bool} Disable the pagination controls while loading a page (default to true)
+ * controlPlacementBottom {bool} Render controls under paged content (default to false)
*
* @param {array} contentItems The list of items to paginate.
- * @param {int} itemsPerPage How many items will be shown per page.
+ * @param {Number} itemsPerPage How many items will be shown per page.
* @param {function} renderContentCallback Callback for rendering the items for the page.
* @param {object} config Configuration options provided by the client.
* @return {promise} Resolved with jQuery HTML and string JS.
@@ -260,7 +466,7 @@ function(
}
var numberOfItems = contentItems.length;
- return createFromAjax(numberOfItems, itemsPerPage, function(pagesData) {
+ return createWithTotalAndLimit(numberOfItems, itemsPerPage, function(pagesData) {
var contentToRender = [];
pagesData.forEach(function(pageData) {
var begin = pageData.offset;
@@ -274,7 +480,11 @@ function(
};
return {
- createFromAjax: createFromAjax,
- createFromStaticList: createFromStaticList
+ create: create,
+ createWithLimit: createWithLimit,
+ createWithTotalAndLimit: createWithTotalAndLimit,
+ createFromStaticList: createFromStaticList,
+ // Backwards compatibility just in case anyone was using this.
+ createFromAjax: createWithTotalAndLimit
};
});
diff --git a/lib/amd/src/paged_content_pages.js b/lib/amd/src/paged_content_pages.js
index 4fbbb104622b6..e76069ac6e879 100644
--- a/lib/amd/src/paged_content_pages.js
+++ b/lib/amd/src/paged_content_pages.js
@@ -25,12 +25,14 @@ define(
'jquery',
'core/templates',
'core/notification',
+ 'core/pubsub',
'core/paged_content_events'
],
function(
$,
Templates,
Notification,
+ PubSub,
PagedContentEvents
) {
@@ -45,6 +47,8 @@ define(
LOADING: 'core/overlay_loading'
};
+ var PRELOADING_GRACE_PERIOD = 300;
+
/**
* Find a page by the number.
*
@@ -60,23 +64,27 @@ define(
* Show the loading spinner until the returned deferred is resolved by the
* calling code.
*
+ * The loading spinner is only rendered after a short grace period to avoid
+ * having it flash up briefly in the interface.
+ *
* @param {object} root The root element.
* @returns {promise} The page.
*/
var startLoading = function(root) {
var deferred = $.Deferred();
+ root.attr('aria-busy', true);
Templates.render(TEMPLATES.LOADING, {visible: true})
.then(function(html) {
var loadingSpinner = $(html);
- // Put this in a timer to give the calling code 100 milliseconds
+ // Put this in a timer to give the calling code 300 milliseconds
// to render the content before we show the loading spinner. This
// helps prevent a loading icon flicker on close to instant
// rendering.
var timerId = setTimeout(function() {
root.css('position', 'relative');
loadingSpinner.appendTo(root);
- }, 100);
+ }, PRELOADING_GRACE_PERIOD);
deferred.always(function() {
clearTimeout(timerId);
@@ -84,6 +92,7 @@ define(
// by the calling code.
loadingSpinner.remove();
root.css('position', '');
+ root.removeAttr('aria-busy');
return;
});
@@ -102,12 +111,13 @@ define(
*
* @param {object} root The root element.
* @param {promise} pagePromise The promise resolved with HTML and JS to render in the page.
- * @param {int} pageNumber The page number.
+ * @param {Number} pageNumber The page number.
* @returns {promise} The page.
*/
var renderPagePromise = function(root, pagePromise, pageNumber) {
var deferred = $.Deferred();
pagePromise.then(function(html, pageJS) {
+ pageJS = pageJS || '';
// When we get the contents to be rendered we can pass it in as the
// content for a new page.
Templates.render(TEMPLATES.PAGING_CONTENT_ITEM, {
@@ -135,7 +145,7 @@ define(
})
.fail(Notification.exception);
- return deferred;
+ return deferred.promise();
};
/**
@@ -164,11 +174,14 @@ define(
* If the renderPagesContentCallback is not provided then it is assumed that
* all pages have been rendered prior to initialising this module.
*
+ * This function triggers the PAGES_SHOWN event after the pages have been rendered.
+ *
* @param {object} root The root element.
* @param {Number} pagesData The data for which pages need to be visible.
+ * @param {string} id A unique id for this instance.
* @param {function} renderPagesContentCallback Render pages content.
*/
- var showPages = function(root, pagesData, renderPagesContentCallback) {
+ var showPages = function(root, pagesData, id, renderPagesContentCallback) {
var existingPages = [];
var newPageData = [];
var newPagesPromise = $.Deferred();
@@ -188,7 +201,11 @@ define(
if (newPageData.length && typeof renderPagesContentCallback === 'function') {
// If we have pages we haven't previously seen then ask the client code
// to render them for us by calling the callback.
- var promises = renderPagesContentCallback(newPageData);
+ var promises = renderPagesContentCallback(newPageData, {
+ allItemsLoaded: function(lastPageNumber) {
+ PubSub.publish(id + PagedContentEvents.ALL_ITEMS_LOADED, lastPageNumber);
+ }
+ });
// After the client has finished rendering each of the pages being asked
// for then begin our rendering process to put that content into paged
// content pages.
@@ -229,6 +246,11 @@ define(
return;
})
+ .then(function() {
+ // Let everything else know we've displayed the pages.
+ PubSub.publish(id + PagedContentEvents.PAGES_SHOWN, pagesData);
+ return;
+ })
.fail(Notification.exception)
.always(function() {
loadingPromise.resolve();
@@ -264,15 +286,20 @@ define(
* The event element is the element to listen for the paged content events on.
*
* @param {object} root The root element.
- * @param {object} eventElement The element to listen for events on.
+ * @param {string} id A unique id for this instance.
* @param {function} renderPagesContentCallback Render pages content.
*/
- var init = function(root, eventElement, renderPagesContentCallback) {
+ var init = function(root, id, renderPagesContentCallback) {
root = $(root);
- eventElement = $(eventElement);
- eventElement.on(PagedContentEvents.SHOW_PAGES, function(e, pagesData) {
- showPages(root, pagesData, renderPagesContentCallback);
+ PubSub.subscribe(id + PagedContentEvents.SHOW_PAGES, function(pagesData) {
+ showPages(root, pagesData, id, renderPagesContentCallback);
+ });
+
+ PubSub.subscribe(id + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT, function() {
+ // If the items per page limit was changed then we need to clear our content
+ // the load new values based on the new limit.
+ root.empty();
});
};
diff --git a/lib/amd/src/paged_content_paging_bar.js b/lib/amd/src/paged_content_paging_bar.js
index c1d26b4993c7c..456ee39410d0b 100644
--- a/lib/amd/src/paged_content_paging_bar.js
+++ b/lib/amd/src/paged_content_paging_bar.js
@@ -24,19 +24,27 @@ define(
[
'jquery',
'core/custom_interaction_events',
- 'core/paged_content_events'
+ 'core/paged_content_events',
+ 'core/str',
+ 'core/pubsub'
],
function(
$,
CustomEvents,
- PagedContentEvents
+ PagedContentEvents,
+ Str,
+ PubSub
) {
var SELECTORS = {
ROOT: '[data-region="paging-bar"]',
PAGE: '[data-page]',
PAGE_ITEM: '[data-region="page-item"]',
- ACTIVE_PAGE_ITEM: '[data-region="page-item"].active'
+ PAGE_LINK: '[data-region="page-link"]',
+ FIRST_BUTTON: '[data-control="first"]',
+ LAST_BUTTON: '[data-control="last"]',
+ NEXT_BUTTON: '[data-control="next"]',
+ PREVIOUS_BUTTON: '[data-control="previous"]'
};
/**
@@ -50,43 +58,74 @@ define(
return root.find(SELECTORS.PAGE_ITEM + '[data-page-number="' + pageNumber + '"]');
};
+ /**
+ * Get the next button element.
+ *
+ * @param {object} root The root element.
+ * @return {jQuery}
+ */
+ var getNextButton = function(root) {
+ return root.find(SELECTORS.NEXT_BUTTON);
+ };
+
+ /**
+ * Set the last page number after which no more pages
+ * should be loaded.
+ *
+ * @param {object} root The root element.
+ * @param {Number} number Page number.
+ */
+ var setLastPageNumber = function(root, number) {
+ root.attr('data-last-page-number', number);
+ };
+
/**
* Get the last page number.
*
* @param {object} root The root element.
- * @return {int}
+ * @return {Number}
*/
var getLastPageNumber = function(root) {
- var lastPage = root.find(SELECTORS.PAGE).last();
- if (lastPage) {
- return parseInt(lastPage.attr('data-page-number'), 10);
- } else {
- return null;
- }
+ return parseInt(root.attr('data-last-page-number'), 10);
};
/**
* Get the active page number.
*
* @param {object} root The root element.
- * @returns {int} The page number
+ * @returns {Number} The page number
*/
var getActivePageNumber = function(root) {
- var activePage = root.find(SELECTORS.ACTIVE_PAGE_ITEM);
+ return parseInt(root.attr('data-active-page-number'), 10);
+ };
- if (activePage.length) {
- return getPageNumber(root, activePage);
- } else {
- return null;
- }
+ /**
+ * Set the active page number.
+ *
+ * @param {object} root The root element.
+ * @param {Number} number Page number.
+ */
+ var setActivePageNumber = function(root, number) {
+ root.attr('data-active-page-number', number);
+ };
+
+ /**
+ * Check if there is an active page number.
+ *
+ * @param {object} root The root element.
+ * @returns {bool}
+ */
+ var hasActivePageNumber = function(root) {
+ var number = getActivePageNumber(root);
+ return !isNaN(number) && number != 0;
};
/**
- * Get the page number.
+ * Get the page number for a given page.
*
* @param {object} root The root element.
- * @param {object} page The page.
- * @returns {int} The page number
+ * @param {object} page The page element.
+ * @returns {Number} The page number
*/
var getPageNumber = function(root, page) {
if (page.attr('data-page') != undefined) {
@@ -110,7 +149,9 @@ define(
case 'next':
activePageNumber = getActivePageNumber(root);
var lastPage = getLastPageNumber(root);
- if (activePageNumber && activePageNumber < lastPage) {
+ if (!lastPage) {
+ pageNumber = activePageNumber + 1;
+ } else if (activePageNumber && activePageNumber < lastPage) {
pageNumber = activePageNumber + 1;
} else {
pageNumber = lastPage;
@@ -139,22 +180,207 @@ define(
* Get the limit of items for each page.
*
* @param {object} root The root element.
- * @returns {int}
+ * @returns {Number}
*/
var getLimit = function(root) {
return parseInt(root.attr('data-items-per-page'), 10);
};
+ /**
+ * Set the limit of items for each page.
+ *
+ * @param {object} root The root element.
+ * @param {Number} limit Items per page limit.
+ */
+ var setLimit = function(root, limit) {
+ root.attr('data-items-per-page', limit);
+ };
+
+ /**
+ * Show the paging bar.
+ *
+ * @param {object} root The root element.
+ */
+ var show = function(root) {
+ root.removeClass('hidden');
+ };
+
+ /**
+ * Hide the paging bar.
+ *
+ * @param {object} root The root element.
+ */
+ var hide = function(root) {
+ root.addClass('hidden');
+ };
+
+ /**
+ * Disable the next and last buttons in the paging bar.
+ *
+ * @param {object} root The root element.
+ */
+ var disableNextControlButtons = function(root) {
+ var nextButton = root.find(SELECTORS.NEXT_BUTTON);
+ var lastButton = root.find(SELECTORS.LAST_BUTTON);
+
+ nextButton.addClass('disabled');
+ nextButton.attr('aria-disabled', true);
+ lastButton.addClass('disabled');
+ lastButton.attr('aria-disabled', true);
+ };
+
+ /**
+ * Enable the next and last buttons in the paging bar.
+ *
+ * @param {object} root The root element.
+ */
+ var enableNextControlButtons = function(root) {
+ var nextButton = root.find(SELECTORS.NEXT_BUTTON);
+ var lastButton = root.find(SELECTORS.LAST_BUTTON);
+
+ nextButton.removeClass('disabled');
+ nextButton.removeAttr('aria-disabled');
+ lastButton.removeClass('disabled');
+ lastButton.removeAttr('aria-disabled');
+ };
+
+ /**
+ * Disable the previous and first buttons in the paging bar.
+ *
+ * @param {object} root The root element.
+ */
+ var disablePreviousControlButtons = function(root) {
+ var previousButton = root.find(SELECTORS.PREVIOUS_BUTTON);
+ var firstButton = root.find(SELECTORS.FIRST_BUTTON);
+
+ previousButton.addClass('disabled');
+ previousButton.attr('aria-disabled', true);
+ firstButton.addClass('disabled');
+ firstButton.attr('aria-disabled', true);
+ };
+
+ /**
+ * Enable the previous and first buttons in the paging bar.
+ *
+ * @param {object} root The root element.
+ */
+ var enablePreviousControlButtons = function(root) {
+ var previousButton = root.find(SELECTORS.PREVIOUS_BUTTON);
+ var firstButton = root.find(SELECTORS.FIRST_BUTTON);
+
+ previousButton.removeClass('disabled');
+ previousButton.removeAttr('aria-disabled');
+ firstButton.removeClass('disabled');
+ firstButton.removeAttr('aria-disabled');
+ };
+
+ /**
+ * Get the components for a get_string request for the aria-label
+ * on a page. The value is a comma separated string of key and
+ * component.
+ *
+ * @param {object} root The root element.
+ * @return {array} First element is the key, second is the component.
+ */
+ var getPageAriaLabelComponents = function(root) {
+ var componentString = root.attr('data-aria-label-components-pagination-item');
+ var components = componentString.split(',').map(function(component) {
+ return component.trim();
+ });
+ return components;
+ };
+
+ /**
+ * Get the components for a get_string request for the aria-label
+ * on an active page. The value is a comma separated string of key and
+ * component.
+ *
+ * @param {object} root The root element.
+ * @return {array} First element is the key, second is the component.
+ */
+ var getActivePageAriaLabelComponents = function(root) {
+ var componentString = root.attr('data-aria-label-components-pagination-active-item');
+ var components = componentString.split(',').map(function(component) {
+ return component.trim();
+ });
+ return components;
+ };
+
/**
* Set page numbers on each of the given items. Page numbers are set
* from 1..n (where n is the number of items).
*
+ * Sets the active page number to be the last page found with
+ * an "active" class (if any).
+ *
+ * Sets the last page number.
+ *
+ * @param {object} root The root element.
* @param {jQuery} items A jQuery list of items.
*/
- var generatePageNumbers = function(items) {
+ var generatePageNumbers = function(root, items) {
+ var lastPageNumber = 0;
+ setActivePageNumber(root, 0);
+
items.each(function(index, item) {
+ var pageNumber = index + 1;
item = $(item);
- item.attr('data-page-number', index + 1);
+ item.attr('data-page-number', pageNumber);
+ lastPageNumber++;
+
+ if (item.hasClass('active')) {
+ setActivePageNumber(root, pageNumber);
+ }
+ });
+
+ setLastPageNumber(root, lastPageNumber);
+ };
+
+ /**
+ * Set the aria-labels on each of the page items in the paging bar.
+ * This includes the next, previous, first, and last items.
+ *
+ * @param {object} root The root element.
+ */
+ var generateAriaLabels = function(root) {
+ var pageAriaLabelComponents = getPageAriaLabelComponents(root);
+ var activePageAriaLabelComponents = getActivePageAriaLabelComponents(root);
+ var activePageNumber = getActivePageNumber(root);
+ var pageItems = root.find(SELECTORS.PAGE_ITEM);
+ // We want to request all of the strings at once rather than
+ // one at a time.
+ var stringRequests = pageItems.map(function(index, page) {
+ page = $(page);
+ var pageNumber = getPageNumber(root, page);
+
+ if (pageNumber === activePageNumber) {
+ return {
+ key: activePageAriaLabelComponents[0],
+ component: activePageAriaLabelComponents[1],
+ param: pageNumber
+ };
+ } else {
+ return {
+ key: pageAriaLabelComponents[0],
+ component: pageAriaLabelComponents[1],
+ param: pageNumber
+ };
+ }
+ });
+
+ Str.get_strings(stringRequests).then(function(strings) {
+ pageItems.each(function(index, page) {
+ page = $(page);
+ var string = strings[index];
+ page.attr('aria-label', string);
+ page.find(SELECTORS.PAGE_LINK).attr('aria-label', string);
+ });
+
+ return strings;
+ })
+ .catch(function() {
+ // No need to interrupt the page if we can't load the aria lang strings.
+ return;
});
};
@@ -164,10 +390,11 @@ define(
* update.
*
* @param {object} root The root element.
- * @param {int} pageNumber The number for the page to show.
- * @param {object} page The page.
+ * @param {Number} pageNumber The number for the page to show.
+ * @param {string} id A uniqie id for this instance.
*/
- var showPage = function(root, pageNumber) {
+ var showPage = function(root, pageNumber, id) {
+ var lastPageNumber = getLastPageNumber(root);
var isSamePage = pageNumber == getActivePageNumber(root);
var limit = getLimit(root);
var offset = (pageNumber - 1) * limit;
@@ -175,36 +402,56 @@ define(
if (!isSamePage) {
// We only need to toggle the active class if the user didn't click
// on the already active page.
- root.find(SELECTORS.PAGE_ITEM).removeClass('active');
+ root.find(SELECTORS.PAGE_ITEM).removeClass('active').removeAttr('aria-current');
var page = getPageByNumber(root, pageNumber);
page.addClass('active');
+ page.attr('aria-current', true);
+ setActivePageNumber(root, pageNumber);
+ }
+
+ // Make sure the control buttons are disabled as the user navigates
+ // to either end of the limits.
+ if (lastPageNumber && pageNumber >= lastPageNumber) {
+ disableNextControlButtons(root);
+ } else {
+ enableNextControlButtons(root);
}
+ if (pageNumber > 1) {
+ enablePreviousControlButtons(root);
+ } else {
+ disablePreviousControlButtons(root);
+ }
+
+ generateAriaLabels(root);
+
// This event requires a payload that contains a list of all pages that
// were activated. In the case of the paging bar we only show one page at
// a time.
- root.trigger(PagedContentEvents.SHOW_PAGES, [[{
+ PubSub.publish(id + PagedContentEvents.SHOW_PAGES, [{
pageNumber: pageNumber,
limit: limit,
offset: offset
- }]]);
+ }]);
};
/**
- * Initialise the paging bar.
+ * Add event listeners for interactions with the paging bar as well as listening
+ * for custom paged content events.
+ *
+ * Each event will trigger different logic to update parts of the paging bar's
+ * display.
+ *
* @param {object} root The root element.
+ * @param {string} id A uniqie id for this instance.
*/
- var init = function(root) {
- root = $(root);
- var pages = root.find(SELECTORS.PAGE);
- generatePageNumbers(pages);
+ var registerEventListeners = function(root, id) {
+ var ignoreControlWhileLoading = root.attr('data-ignore-control-while-loading');
+ var loading = false;
- var activePageNumber = getActivePageNumber(root);
- if (activePageNumber) {
- // If the the paging bar was rendered with an active page selected
- // then make sure we fired off the event to tell the content page to
- // show.
- showPage(root, activePageNumber);
+ if (ignoreControlWhileLoading == "") {
+ // Default to ignoring control while loading if not specified.
+ ignoreControlWhileLoading = true;
}
CustomEvents.define(root, [
@@ -212,17 +459,98 @@ define(
]);
root.on(CustomEvents.events.activate, SELECTORS.PAGE_ITEM, function(e, data) {
- var page = $(e.target).closest(SELECTORS.PAGE_ITEM);
- var pageNumber = getPageNumber(root, page);
- showPage(root, pageNumber);
-
data.originalEvent.preventDefault();
data.originalEvent.stopPropagation();
+
+ if (ignoreControlWhileLoading && loading) {
+ // Do nothing if configured to ignore control while loading.
+ return;
+ }
+
+ var page = $(e.target).closest(SELECTORS.PAGE_ITEM);
+
+ if (!page.hasClass('disabled')) {
+ var pageNumber = getPageNumber(root, page);
+ showPage(root, pageNumber, id);
+ loading = true;
+ }
+ });
+
+ // This event is fired when all of the items have been loaded. Typically used
+ // in an "infinite" pages context when we don't know the exact number of pages
+ // ahead of time.
+ PubSub.subscribe(id + PagedContentEvents.ALL_ITEMS_LOADED, function(pageNumber) {
+ loading = false;
+ var currentLastPage = getLastPageNumber(root);
+
+ if (!currentLastPage || pageNumber < currentLastPage) {
+ // Somehow the value we've got saved is higher than the new
+ // value we just received. Perhaps events came out of order.
+ // In any case, save the lowest value.
+ setLastPageNumber(root, pageNumber);
+ }
+
+ if (pageNumber === 1 && root.attr('data-hide-control-on-single-page')) {
+ // If all items were loaded on the first page then we can hide
+ // the paging bar because there are no other pages to load.
+ hide(root);
+ disableNextControlButtons(root);
+ disablePreviousControlButtons(root);
+ } else {
+ show(root);
+ disableNextControlButtons(root);
+ }
+ });
+
+ // This event is fired after all of the requested pages have been rendered.
+ PubSub.subscribe(id + PagedContentEvents.PAGES_SHOWN, function() {
+ // All pages have been shown so turn off the loading flag.
+ loading = false;
+ });
+
+ // This is triggered when the paging limit is modified.
+ PubSub.subscribe(id + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT, function(limit) {
+ // Update the limit.
+ setLimit(root, limit);
+ setLastPageNumber(root, 0);
+ setActivePageNumber(root, 0);
+ show(root);
+ // Reload the data from page 1 again.
+ showPage(root, 1, id);
});
};
+ /**
+ * Initialise the paging bar.
+ * @param {object} root The root element.
+ * @param {string} id A uniqie id for this instance.
+ */
+ var init = function(root, id) {
+ root = $(root);
+ var pages = root.find(SELECTORS.PAGE);
+ generatePageNumbers(root, pages);
+ registerEventListeners(root, id);
+
+ if (hasActivePageNumber(root)) {
+ var activePageNumber = getActivePageNumber(root);
+ // If the the paging bar was rendered with an active page selected
+ // then make sure we fired off the event to tell the content page to
+ // show.
+ getPageByNumber(root, activePageNumber).click();
+ if (activePageNumber == 1) {
+ // If the first page is active then disable the previous buttons.
+ disablePreviousControlButtons(root);
+ }
+ } else {
+ // There was no active page number so load the first page using
+ // the next button. This allows the infinite pagination to work.
+ getNextButton(root).click();
+ }
+ };
+
return {
init: init,
+ showPage: showPage,
rootSelector: SELECTORS.ROOT,
};
});
diff --git a/lib/amd/src/paged_content_paging_bar_limit_selector.js b/lib/amd/src/paged_content_paging_bar_limit_selector.js
new file mode 100644
index 0000000000000..ebc327e767c3d
--- /dev/null
+++ b/lib/amd/src/paged_content_paging_bar_limit_selector.js
@@ -0,0 +1,77 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * Javascript for dynamically changing the page limits.
+ *
+ * @module core/paged_content_paging_bar_limit_selector
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(
+[
+ 'jquery',
+ 'core/custom_interaction_events',
+ 'core/paged_content_events',
+ 'core/pubsub'
+],
+function(
+ $,
+ CustomEvents,
+ PagedContentEvents,
+ PubSub
+) {
+
+ var SELECTORS = {
+ ROOT: '[data-region="paging-control-limit-container"]',
+ LIMIT_OPTION: '[data-limit]',
+ LIMIT_TOGGLE: '[data-action="limit-toggle"]',
+ };
+
+ /**
+ * Trigger the SET_ITEMS_PER_PAGE_LIMIT event when the page limit option
+ * is modified.
+ *
+ * @param {object} root The root element.
+ * @param {string} id A unique id for this instance.
+ */
+ var init = function(root, id) {
+ root = $(root);
+
+ CustomEvents.define(root, [
+ CustomEvents.events.activate
+ ]);
+
+ root.on(CustomEvents.events.activate, SELECTORS.LIMIT_OPTION, function(e, data) {
+ var optionElement = $(e.target).closest(SELECTORS.LIMIT_OPTION);
+
+ if (optionElement.hasClass('active')) {
+ // Don't do anything if it was the active option selected.
+ return;
+ }
+
+ var limit = parseInt(optionElement.attr('data-limit'), 10);
+ // Tell the rest of the pagination components that the limit has changed.
+ PubSub.publish(id + PagedContentEvents.SET_ITEMS_PER_PAGE_LIMIT, limit);
+
+ data.originalEvent.preventDefault();
+ });
+ };
+
+ return {
+ init: init,
+ rootSelector: SELECTORS.ROOT
+ };
+});
diff --git a/lib/amd/src/paged_content_paging_dropdown.js b/lib/amd/src/paged_content_paging_dropdown.js
index 59d75b59cb18d..5fe58707bc205 100644
--- a/lib/amd/src/paged_content_paging_dropdown.js
+++ b/lib/amd/src/paged_content_paging_dropdown.js
@@ -24,12 +24,14 @@ define(
[
'jquery',
'core/custom_interaction_events',
- 'core/paged_content_events'
+ 'core/paged_content_events',
+ 'core/pubsub'
],
function(
$,
CustomEvents,
- PagedContentEvents
+ PagedContentEvents,
+ PubSub
) {
var SELECTORS = {
@@ -44,7 +46,7 @@ define(
* Get the page number.
*
* @param {jquery} item The dropdown item.
- * @returns {int}
+ * @returns {Number}
*/
var getPageNumber = function(item) {
return parseInt(item.attr('data-page-number'), 10);
@@ -79,7 +81,7 @@ define(
* Get the number of items to be loaded for the dropdown item.
*
* @param {jquery} item The dropdown item.
- * @returns {int}
+ * @returns {Number}
*/
var getLimit = function(item) {
return parseInt(item.attr('data-item-count'), 10);
@@ -91,7 +93,7 @@ define(
*
* @param {jquery} root The root element.
* @param {jquery} item The dropdown item.
- * @returns {int}
+ * @returns {Number}
*/
var getOffset = function(root, item) {
if (item.attr('data-offset') != undefined) {
@@ -181,8 +183,9 @@ define(
*
* @param {jquery} root The root element.
* @param {jquery} item The dropdown item.
+ * @param {string} id A unique id for this instance.
*/
- var setActiveItem = function(root, item) {
+ var setActiveItem = function(root, item, id) {
var prevItems = getPreviousItems(root, item);
var allItems = prevItems.add(item);
var eventPayload = generateEventPayload(root, allItems);
@@ -197,7 +200,7 @@ define(
// Bootstrap 2 compatibility.
toggle.append(caret);
// Fire the event to tell the content to update.
- root.trigger(PagedContentEvents.SHOW_PAGES, [eventPayload]);
+ PubSub.publish(id + PagedContentEvents.SHOW_PAGES, eventPayload);
};
/**
@@ -206,8 +209,9 @@ define(
* new pages.
*
* @param {object} root The root element.
+ * @param {string} id A unique id for this instance.
*/
- var init = function(root) {
+ var init = function(root, id) {
root = $(root);
var items = getAllItems(root);
generatePageNumbers(items);
@@ -215,7 +219,7 @@ define(
var activeItem = getActiveItem(root);
if (activeItem.length) {
// Fire the first event for the content to make sure it's visible.
- setActiveItem(root, activeItem);
+ setActiveItem(root, activeItem, id);
}
CustomEvents.define(root, [
@@ -224,7 +228,7 @@ define(
root.on(CustomEvents.events.activate, SELECTORS.DROPDOWN_ITEM, function(e, data) {
var item = $(e.target).closest(SELECTORS.DROPDOWN_ITEM);
- setActiveItem(root, item);
+ setActiveItem(root, item, id);
data.originalEvent.preventDefault();
});
diff --git a/lib/amd/src/pubsub.js b/lib/amd/src/pubsub.js
new file mode 100644
index 0000000000000..7160c56397cb7
--- /dev/null
+++ b/lib/amd/src/pubsub.js
@@ -0,0 +1,74 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see .
+
+/**
+ * A simple Javascript PubSub implementation.
+ *
+ * @module core/pubsub
+ * @copyright 2018 Ryan Wyllie
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([], function() {
+
+ var events = {};
+
+ /**
+ * Subscribe to an event.
+ *
+ * @param {string} eventName The name of the event to subscribe to.
+ * @param {function} callback The callback function to run when eventName occurs.
+ */
+ var subscribe = function(eventName, callback) {
+ events[eventName] = events[eventName] || [];
+ events[eventName].push(callback);
+ };
+
+ /**
+ * Unsubscribe from an event.
+ *
+ * @param {string} eventName The name of the event to unsubscribe from.
+ * @param {function} callback The callback to unsubscribe.
+ */
+ var unsubscribe = function(eventName, callback) {
+ if (events[eventName]) {
+ for (var i = 0; i < events[eventName].length; i++) {
+ if (events[eventName][i] === callback) {
+ events[eventName].splice(i, 1);
+ break;
+ }
+ }
+ }
+ };
+
+ /**
+ * Publish an event to all subscribers.
+ *
+ * @param {string} eventName The name of the event to publish.
+ * @param {any} data The data to provide to the subscribed callbacks.
+ */
+ var publish = function(eventName, data) {
+ if (events[eventName]) {
+ events[eventName].forEach(function(callback) {
+ callback(data);
+ });
+ }
+ };
+
+ return {
+ subscribe: subscribe,
+ unsubscribe: unsubscribe,
+ publish: publish
+ };
+});
diff --git a/lib/amd/src/user_date.js b/lib/amd/src/user_date.js
index 826ce41628cac..20ea44cf38f96 100644
--- a/lib/amd/src/user_date.js
+++ b/lib/amd/src/user_date.js
@@ -24,6 +24,8 @@
define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
function($, Ajax, Storage, Config) {
+ var SECONDS_IN_DAY = 86400;
+
/** @var {object} promisesCache Store all promises we've seen so far. */
var promisesCache = {};
@@ -228,7 +230,42 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
});
};
+
+ /**
+ * For a given timestamp get the midnight value in the user's timezone.
+ *
+ * The calculation is performed relative to the user's midnight timestamp
+ * for today to ensure that timezones are preserved.
+ *
+ * E.g.
+ * Input:
+ * timestamp: 1514836800 (01/01/2018 8pm GMT)(02/01/2018 4am GMT+8)
+ * midnight: 1514851200 (02/01/2018 midnight GMT)
+ * Output:
+ * 1514764800 (01/01/2018 midnight GMT)
+ *
+ * Input:
+ * timestamp: 1514836800 (01/01/2018 8pm GMT)(02/01/2018 4am GMT+8)
+ * midnight: 1514822400 (02/01/2018 midnight GMT+8)
+ * Output:
+ * 1514822400 (02/01/2018 midnight GMT+8)
+ *
+ * @param {Number} timestamp The timestamp to calculate from
+ * @param {Number} todayMidnight The user's midnight timestamp
+ * @return {Number} The midnight value of the user's timestamp
+ */
+ var getUserMidnightForTimestamp = function(timestamp, todayMidnight) {
+ var future = timestamp > todayMidnight;
+ var diffSeconds = Math.abs(timestamp - todayMidnight);
+ var diffDays = future ? Math.floor(diffSeconds / SECONDS_IN_DAY) : Math.ceil(diffSeconds / SECONDS_IN_DAY);
+ var diffDaysInSeconds = diffDays * SECONDS_IN_DAY;
+ // Is the timestamp in the future or past?
+ var dayTimestamp = future ? todayMidnight + diffDaysInSeconds : todayMidnight - diffDaysInSeconds;
+ return dayTimestamp;
+ };
+
return {
- get: get
+ get: get,
+ getUserMidnightForTimestamp: getUserMidnightForTimestamp
};
});
diff --git a/lib/blocklib.php b/lib/blocklib.php
index c8fa2455a08b4..bf42e6c06b6c8 100644
--- a/lib/blocklib.php
+++ b/lib/blocklib.php
@@ -2583,7 +2583,7 @@ function blocks_add_default_system_blocks() {
$subpagepattern = null;
}
- $newblocks = array('private_files', 'online_users', 'badges', 'calendar_month', 'calendar_upcoming');
+ $newblocks = array('timeline', 'private_files', 'online_users', 'badges', 'calendar_month', 'calendar_upcoming');
$newcontent = array('lp', 'myoverview');
$page->blocks->add_blocks(array(BLOCK_POS_RIGHT => $newblocks, 'content' => $newcontent), 'my-index', $subpagepattern);
}
diff --git a/lib/classes/output/icon_system_fontawesome.php b/lib/classes/output/icon_system_fontawesome.php
index 75abeda9b8721..c81580324f095 100644
--- a/lib/classes/output/icon_system_fontawesome.php
+++ b/lib/classes/output/icon_system_fontawesome.php
@@ -260,6 +260,7 @@ public function get_core_icon_map() {
'core:i/ne_red_mark' => 'fa-remove',
'core:i/new' => 'fa-bolt',
'core:i/news' => 'fa-newspaper-o',
+ 'core:i/next' => 'fa-chevron-right',
'core:i/nosubcat' => 'fa-plus-square-o',
'core:i/notifications' => 'fa-bell',
'core:i/open' => 'fa-folder-open',
@@ -270,6 +271,7 @@ public function get_core_icon_map() {
'core:i/persona_sign_in_black' => 'fa-male',
'core:i/portfolio' => 'fa-id-badge',
'core:i/preview' => 'fa-search-plus',
+ 'core:i/previous' => 'fa-chevron-left',
'core:i/privatefiles' => 'fa-file-o',
'core:i/progressbar' => 'fa-spinner fa-spin',
'core:i/publish' => 'fa-share',
diff --git a/lib/classes/plugin_manager.php b/lib/classes/plugin_manager.php
index e319dc3613e6d..ca16fd4b8f87b 100644
--- a/lib/classes/plugin_manager.php
+++ b/lib/classes/plugin_manager.php
@@ -1721,7 +1721,7 @@ public static function standard_plugins_list($type) {
'private_files', 'quiz_results', 'recent_activity',
'rss_client', 'search_forums', 'section_links',
'selfcompletion', 'settings', 'site_main_menu',
- 'social_activities', 'tag_flickr', 'tag_youtube', 'tags'
+ 'social_activities', 'tag_flickr', 'tag_youtube', 'tags', 'timeline'
),
'booktool' => array(
diff --git a/lib/db/services.php b/lib/db/services.php
index e8f44f4aec64c..08195cc6bd1c0 100644
--- a/lib/db/services.php
+++ b/lib/db/services.php
@@ -510,6 +510,14 @@
'ajax' => true,
'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
),
+ 'core_course_get_enrolled_courses_by_timeline_classification' => array(
+ 'classname' => 'core_course_external',
+ 'methodname' => 'get_enrolled_courses_by_timeline_classification',
+ 'classpath' => 'course/externallib.php',
+ 'description' => 'List of enrolled courses for the given timeline classification (past, inprogress, or future).',
+ 'type' => 'read',
+ 'ajax' => true
+ ),
'core_enrol_get_course_enrolment_methods' => array(
'classname' => 'core_enrol_external',
'methodname' => 'get_course_enrolment_methods',
diff --git a/lib/enrollib.php b/lib/enrollib.php
index 18ea77e9b0746..fa20f6535f4f3 100644
--- a/lib/enrollib.php
+++ b/lib/enrollib.php
@@ -557,9 +557,10 @@ function enrol_add_course_navigation(navigation_node $coursenode, $course) {
* @param int $limit max number of courses
* @param array $courseids the list of course ids to filter by
* @param bool $allaccessible Include courses user is not enrolled in, but can access
+ * @param int $offset Offset the result set by this number
* @return array
*/
-function enrol_get_my_courses($fields = null, $sort = null, $limit = 0, $courseids = [], $allaccessible = false) {
+function enrol_get_my_courses($fields = null, $sort = null, $limit = 0, $courseids = [], $allaccessible = false, $offset = 0) {
global $DB, $USER, $CFG;
if ($sort === null) {
@@ -714,7 +715,7 @@ function enrol_get_my_courses($fields = null, $sort = null, $limit = 0, $coursei
WHERE $wheres
$orderby";
- $courses = $DB->get_records_sql($sql, $params, 0, $limit);
+ $courses = $DB->get_records_sql($sql, $params, $offset, $limit);
// preload contexts and check visibility
foreach ($courses as $id=>$course) {
diff --git a/lib/navigationlib.php b/lib/navigationlib.php
index b30c1d1d8dc14..94f995d26fe25 100644
--- a/lib/navigationlib.php
+++ b/lib/navigationlib.php
@@ -3045,7 +3045,7 @@ protected function load_courses_enrolled() {
// Show a link to the course page if there are more courses the user is enrolled in.
if ($showmorelinkinnav || $showmorelinkinflatnav) {
// Adding hash to URL so the link is not highlighted in the navigation when clicked.
- $url = new moodle_url('/my/?myoverviewtab=courses');
+ $url = new moodle_url('/my/');
$parent = $this->rootnodes['mycourses'];
$coursenode = $parent->add(get_string('morenavigationlinks'), $url, self::TYPE_CUSTOM, null, self::COURSE_INDEX_PAGE);
diff --git a/lib/outputrequirementslib.php b/lib/outputrequirementslib.php
index ba6e09fa5284c..ded44aad26c97 100644
--- a/lib/outputrequirementslib.php
+++ b/lib/outputrequirementslib.php
@@ -1590,6 +1590,8 @@ public function get_end_code() {
$logconfig->level = 'trace';
}
$this->js_call_amd('core/log', 'setConfig', array($logconfig));
+ // Add any global JS that needs to run on all pages.
+ $this->js_call_amd('core/page_global', 'init');
// Call amd init functions.
$output .= $this->get_amd_footercode();
diff --git a/lib/templates/paged_content.mustache b/lib/templates/paged_content.mustache
index 47b5fbb4f5afc..953cbccc3e961 100644
--- a/lib/templates/paged_content.mustache
+++ b/lib/templates/paged_content.mustache
@@ -53,29 +53,41 @@
}
}}