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" - } - } -}} -
  • -
    -
    -
    - {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}} -
    -
    - {{{name}}} -

    - {{#userdate}} {{timesort}}, {{#str}} strftimerecent {{/str}} {{/userdate}} -

    -
    -
    -
    - {{#action.actionable}} - {{action.name}} - {{#action.itemcount}} - {{#action.showitemcount}} - {{.}} - {{/action.showitemcount}} - {{/action.itemcount}} - {{/action.actionable}} - {{^action.actionable}} -
    {{action.name}}
    - {{/action.actionable}} -
    -
    -
  • 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": "#" - } - } -}} -
    - -
    - {{< block_myoverview/event-list-group }} - {{$title}}{{#str}} recentlyoverdue, block_myoverview {{/str}}{{/title}} - {{$extratitleclasses}}text-danger{{/extratitleclasses}} - {{$startday}}-14{{/startday}} - {{$endday}}0{{/endday}} - {{$eventlistitems}} - {{> block_myoverview/course-event-list-items }} - {{/eventlistitems}} - {{/ block_myoverview/event-list-group }} - {{< block_myoverview/event-list-group }} - {{$title}}{{#str}} today {{/str}}{{/title}} - {{$extratitleclasses}}{{/extratitleclasses}} - {{$startday}}0{{/startday}} - {{$endday}}1{{/endday}} - {{$eventlistitems}} - {{> block_myoverview/course-event-list-items }} - {{/eventlistitems}} - {{/ block_myoverview/event-list-group }} - {{< block_myoverview/event-list-group }} - {{$title}}{{#str}} next7days, block_myoverview {{/str}}{{/title}} - {{$extratitleclasses}}{{/extratitleclasses}} - {{$startday}}1{{/startday}} - {{$endday}}7{{/endday}} - {{$eventlistitems}} - {{> block_myoverview/course-event-list-items }} - {{/eventlistitems}} - {{/ block_myoverview/event-list-group }} - {{< block_myoverview/event-list-group }} - {{$title}}{{#str}} next30days, block_myoverview {{/str}}{{/title}} - {{$extratitleclasses}}{{/extratitleclasses}} - {{$startday}}7{{/startday}} - {{$endday}}30{{/endday}} - {{$eventlistitems}} - {{> block_myoverview/course-event-list-items }} - {{/eventlistitems}} - {{/ block_myoverview/event-list-group }} - {{< block_myoverview/event-list-group }} - {{$title}}{{#str}} future, block_myoverview {{/str}}{{/title}} - {{$extratitleclasses}}{{/extratitleclasses}} - {{$startday}}30{{/startday}} - {{$endday}}{{/endday}} - {{$eventlistitems}} - {{> block_myoverview/course-event-list-items }} - {{/eventlistitems}} - {{/ block_myoverview/event-list-group }} - -
    - -
    -
    - -
    -{{#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." - } -}} -
    -
    - {{> block_myoverview/progress-chart}} -

    {{{fullnamedisplay}}}

    -
    -
    -
    -
    -
    - {{> block_myoverview/progress-chart}} -
    -
    - -
    -
    -

    - {{#shortentext}} 140, {{{summary}}}{{/shortentext}} -

    -
    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): + {} +}} + diff --git a/blocks/myoverview/templates/courses-view.mustache b/blocks/myoverview/templates/courses-view.mustache index 14ffa493f7315..cb5b9261a5f18 100644 --- a/blocks/myoverview/templates/courses-view.mustache +++ b/blocks/myoverview/templates/courses-view.mustache @@ -24,25 +24,6 @@ }}
    {{#hascourses}} -
    {{#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" - } - } -}} -
  • -
    -
    -
    - {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}} -
    -
    - {{{name}}} -

    {{{course.fullnamedisplay}}}

    -
    -
    -
    -
    -
    - {{#userdate}} {{timesort}}, {{#str}} strftimerecent {{/str}} {{/userdate}} -
    -
    - {{#action.actionable}} - {{action.name}} - {{#action.itemcount}} - {{#action.showitemcount}} - {{.}} - {{/action.showitemcount}} - {{/action.itemcount}} - {{/action.actionable}} - {{^action.actionable}} -
    {{action.name}}
    - {{/action.actionable}} -
    -
    -
    -
    -
  • 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): - { - } -}} -
    - -
    - {{< block_myoverview/event-list-group }} - {{$title}}{{#str}} recentlyoverdue, block_myoverview {{/str}}{{/title}} - {{$extratitleclasses}}text-danger{{/extratitleclasses}} - {{$startday}}-14{{/startday}} - {{$endday}}0{{/endday}} - {{/ block_myoverview/event-list-group }} - {{< block_myoverview/event-list-group }} - {{$title}}{{#str}} today {{/str}}{{/title}} - {{$extratitleclasses}}{{/extratitleclasses}} - {{$startday}}0{{/startday}} - {{$endday}}1{{/endday}} - {{/ block_myoverview/event-list-group }} - {{< block_myoverview/event-list-group }} - {{$title}}{{#str}} next7days, block_myoverview {{/str}}{{/title}} - {{$extratitleclasses}}{{/extratitleclasses}} - {{$startday}}1{{/startday}} - {{$endday}}7{{/endday}} - {{/ block_myoverview/event-list-group }} - {{< block_myoverview/event-list-group }} - {{$title}}{{#str}} next30days, block_myoverview {{/str}}{{/title}} - {{$extratitleclasses}}{{/extratitleclasses}} - {{$startday}}7{{/startday}} - {{$endday}}30{{/endday}} - {{/ block_myoverview/event-list-group }} - {{< block_myoverview/event-list-group }} - {{$title}}{{#str}} future, block_myoverview {{/str}}{{/title}} - {{$extratitleclasses}}{{/extratitleclasses}} - {{$startday}}30{{/startday}} - {{$endday}}{{/endday}} - {{/ block_myoverview/event-list-group }} - -
    - -
    -
    - -
    -{{#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/main.mustache b/blocks/myoverview/templates/main.mustache index e9b21bd69ae61..fef8e9f3db9b4 100644 --- a/blocks/myoverview/templates/main.mustache +++ b/blocks/myoverview/templates/main.mustache @@ -24,32 +24,20 @@ }}
    - -
    -
    - {{> block_myoverview/timeline-view }} -
    -
    +
    +
    {{#coursesview}} - {{> block_myoverview/courses-view }} + {{#hascourses}} +
    + {{> block_myoverview/courses-view-nav-grouping-display-filter }} +
    + {{/hascourses}} {{/coursesview}}
    +
    + {{#coursesview}} + {{> block_myoverview/courses-view }} + {{/coursesview}} +
    -{{#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): - {} -}} -
    - {{#coursesview}} - {{#inprogress}} - {{#haspages}} - {{#pages}} - - {{/pages}} -
    - -
    - {{/haspages}} - {{^haspages}} -
    - {{#str}} nocoursesinprogress, block_myoverview {{/str}} -

    {{#str}} nocoursesinprogress, block_myoverview {{/str}}

    -
    - {{/haspages}} - {{/inprogress}} - {{^inprogress}} -
    - {{#str}} nocoursesinprogress, block_myoverview {{/str}} -

    {{#str}} nocoursesinprogress, block_myoverview {{/str}}

    -
    - {{/inprogress}} - {{/coursesview}} -
    -{{#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): - {} -}} -
    - - -
    -
    - {{> block_myoverview/timeline-view-dates }} -
    -
    - {{> block_myoverview/timeline-view-courses }} -
    -
    -
    \ 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): + {} +}} +
  • +
    +
    +
      + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} +
    +
    +
    +
    +
    +
    +
  • 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." } }} -
  • -
    -
    -
    - {{> block_myoverview/course-summary }} -
    -
    - {{< block_myoverview/course-event-list }} - {{$limit}}10{{/limit}} - {{$offset}}0{{/offset}} - {{$courseid}}{{id}}{{/courseid}} - {{/ block_myoverview/course-event-list }} -
    +
  • +
    +

    {{{fullnamedisplay}}}

    + {{< block_timeline/event-list }} + {{$courseid}}{{id}}{{/courseid}} + {{/ block_timeline/event-list }}
    -
  • 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." } }} -
  • -
    -
    -
    - {{> block_myoverview/course-summary }} -
    -
    - {{< block_myoverview/course-event-list }} - {{$limit}}10{{/limit}} - {{$offset}}0{{/offset}} - {{$courseid}}{{id}}{{/courseid}} - {{/ block_myoverview/course-event-list }} -
    -
    -
    -
  • +{{#courses}} + {{> block_timeline/course-item }} +{{/courses}} diff --git a/theme/bootstrapbase/templates/block_myoverview/event-list-group.mustache b/blocks/timeline/templates/event-list-content.mustache similarity index 66% rename from theme/bootstrapbase/templates/block_myoverview/event-list-group.mustache rename to blocks/timeline/templates/event-list-content.mustache index 2fd6bfbae8721..b8df729d6d86a 100644 --- a/theme/bootstrapbase/templates/block_myoverview/event-list-group.mustache +++ b/blocks/timeline/templates/event-list-content.mustache @@ -15,24 +15,25 @@ along with Moodle. If not, see . }} {{! - @template block_myoverview/event-list-group + @template block_timeline/event-list-content - This template renders a list of events for the myoverview block. + This template renders a group of event list items for the timeline block. Example context (json): { "events": [ { - "enddate": "Nov 4th, 10am", "name": "Assignment due 1", "url": "https://www.google.com", + "timesort": 1490320388, "course": { - "fullname": "Course 1" + "fullnamedisplay": "Course 1" }, "action": { "name": "Submit assignment", "url": "https://www.google.com", - "itemcount": 1 + "itemcount": 1, + "actionable": true }, "icon": { "key": "icon", @@ -41,16 +42,17 @@ } }, { - "enddate": "Nov 4th, 10am", "name": "Assignment due 2", "url": "https://www.google.com", + "timesort": 1490320388, "course": { - "fullname": "Course 1" + "fullnamedisplay": "Course 1" }, "action": { "name": "Submit assignment", "url": "https://www.google.com", - "itemcount": 1 + "itemcount": 1, + "actionable": true }, "icon": { "key": "icon", @@ -61,15 +63,9 @@ ] } }} - +
    + {{#eventsbyday}} +
    {{#userdate}} {{dayTimestamp}}, {{#str}} strftimedayshort, core_langconfig {{/str}} {{/userdate}}
    + {{> block_timeline/event-list-items }} + {{/eventsbyday}} +
    \ 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" + } + } +}} + +
    +
    + {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}} +
    +
    +
    {{{name}}}
    + {{{course.fullnamedisplay}}} +
    + + {{#userdate}} {{timesort}}, {{#str}} strftimetime24, core_langconfig {{/str}} {{/userdate}} + +
    +
    diff --git a/blocks/myoverview/templates/course-event-list-items.mustache b/blocks/timeline/templates/event-list-items.mustache similarity index 82% rename from blocks/myoverview/templates/course-event-list-items.mustache rename to blocks/timeline/templates/event-list-items.mustache index 10a1c435dc0df..27f6b424a62a0 100644 --- a/blocks/myoverview/templates/course-event-list-items.mustache +++ b/blocks/timeline/templates/event-list-items.mustache @@ -15,10 +15,9 @@ along with Moodle. If not, see . }} {{! - @template block_myoverview/course-event-list-items + @template block_timeline/event-list-items - This template renders a group of event list items for the myoverview block - sort by courses view. + This template renders a group of event list items for the timeline block. Example context (json): { @@ -27,6 +26,9 @@ "name": "Assignment due 1", "url": "https://www.google.com", "timesort": 1490320388, + "course": { + "fullnamedisplay": "Course 1" + }, "action": { "name": "Submit assignment", "url": "https://www.google.com", @@ -43,6 +45,9 @@ "name": "Assignment due 2", "url": "https://www.google.com", "timesort": 1490320388, + "course": { + "fullnamedisplay": "Course 1" + }, "action": { "name": "Submit assignment", "url": "https://www.google.com", @@ -58,6 +63,8 @@ ] } }} +
    {{#events}} - {{> block_myoverview/course-event-list-item }} + {{> block_timeline/event-list-item }} {{/events}} +
    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): + { + } +}} +
    +
    +
      + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} +
    +
    +
    +
    +
    +
    +
    + +
    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): + {} +}} +
    +
      + {{> block_timeline/course-item-loading-placeholder }} + {{> block_timeline/course-item-loading-placeholder }} +
    +
    +
    +
      + + 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): {} }} -
      - {{< block_myoverview/event-list }} - {{$limit}}20{{/limit}} - {{/ block_myoverview/event-list }} +
      + {{> block_timeline/event-list }}
      -{{#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 @@ } }}
      - {{#pagingbar}} - {{> core/paged_content_paging_bar }} - {{/pagingbar}} - {{#pagingdropdown}} - {{> core/paged_content_paging_dropdown }} - {{/pagingdropdown}} + {{^controlplacementbottom}} +
      + {{#pagingbar}} + {{> core/paged_content_paging_bar }} + {{/pagingbar}} + {{#pagingdropdown}} + {{> core/paged_content_paging_dropdown }} + {{/pagingdropdown}} +
      + {{/controlplacementbottom}} {{> core/paged_content_pages }} + {{#controlplacementbottom}} +
      + {{#pagingbar}} + {{> core/paged_content_paging_bar }} + {{/pagingbar}} + {{#pagingdropdown}} + {{> core/paged_content_paging_dropdown }} + {{/pagingdropdown}} +
      + {{/controlplacementbottom}}
      {{^skipjs}} {{#js}} require( [ 'jquery', - 'core/paged_content_pages' + 'core/paged_content' ], function( $, PagedContent ) { var container = $("#paged-content-container-{{uniqid}}"); - var pagingContent = container.find(PagedContent.rootSelector); - - PagedContent.init(pagingContent, container); + PagedContent.init(container); }); {{/js}} {{/skipjs}} diff --git a/lib/templates/paged_content_pages.mustache b/lib/templates/paged_content_pages.mustache index a93b33757b085..bd53214d2a881 100644 --- a/lib/templates/paged_content_pages.mustache +++ b/lib/templates/paged_content_pages.mustache @@ -35,7 +35,12 @@ ] } }} -
      +
      {{#pages}} {{$paged-content-page}} {{> core/paged_content_page }} diff --git a/lib/templates/paged_content_paging_bar.mustache b/lib/templates/paged_content_paging_bar.mustache index 1147f6b691f0e..15fe22349388e 100644 --- a/lib/templates/paged_content_paging_bar.mustache +++ b/lib/templates/paged_content_paging_bar.mustache @@ -27,6 +27,7 @@ "next": true, "first": true, "last": true, + "activepagenumber": 1, "pages": [ { "url": "#", @@ -40,58 +41,148 @@ ] } }} - -{{#js}} -require(['jquery', 'core/paged_content_paging_bar'], function($, PagingControl) { - var root = $('#{{$pagingbarid}}paging-bar-{{uniqid}}{{/pagingbarid}}'); - PagingControl.init(root); -}); -{{/js}} + +
      diff --git a/lib/templates/paged_content_paging_dropdown.mustache b/lib/templates/paged_content_paging_dropdown.mustache index d065d39745e86..47cb9b3237f31 100644 --- a/lib/templates/paged_content_paging_dropdown.mustache +++ b/lib/templates/paged_content_paging_dropdown.mustache @@ -44,8 +44,8 @@ data-region="paging-dropdown-container"> -
      -
      - -
      -{{#js}} -require(['jquery', 'block_myoverview/event_list'], function($, EventList) { - var root = $("#event-list-container-{{$courseid}}{{/courseid}}"); - EventList.registerEventListeners(root); -}); -{{/js}} diff --git a/theme/bootstrapbase/templates/block_myoverview/course-summary.mustache b/theme/bootstrapbase/templates/block_myoverview/course-summary.mustache deleted file mode 100644 index 4141d61f1929f..0000000000000 --- a/theme/bootstrapbase/templates/block_myoverview/course-summary.mustache +++ /dev/null @@ -1,51 +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." - } -}} -
      -
      - {{> block_myoverview/progress-chart}} -

      {{{fullnamedisplay}}}

      -
      -
      -
      -
      -
      - {{> block_myoverview/progress-chart}} -
      -
      - -
      -
      -

      - {{#shortentext}} 140, {{{summary}}}{{/shortentext}} -

      -
      diff --git a/theme/bootstrapbase/templates/block_myoverview/courses-view-nav-grouping-display-filter.mustache b/theme/bootstrapbase/templates/block_myoverview/courses-view-nav-grouping-display-filter.mustache new file mode 100644 index 0000000000000..0cd929cd0ff8a --- /dev/null +++ b/theme/bootstrapbase/templates/block_myoverview/courses-view-nav-grouping-display-filter.mustache @@ -0,0 +1,41 @@ +{{! + 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): + {} +}} + diff --git a/theme/bootstrapbase/templates/block_myoverview/courses-view.mustache b/theme/bootstrapbase/templates/block_myoverview/courses-view.mustache index 950ab5a15c401..5bfc78dea4960 100644 --- a/theme/bootstrapbase/templates/block_myoverview/courses-view.mustache +++ b/theme/bootstrapbase/templates/block_myoverview/courses-view.mustache @@ -24,19 +24,6 @@ }}
      {{#hascourses}} -
      {{#inprogress}} diff --git a/theme/bootstrapbase/templates/block_myoverview/event-list-item.mustache b/theme/bootstrapbase/templates/block_myoverview/event-list-item.mustache deleted file mode 100644 index 16174f32b0c81..0000000000000 --- a/theme/bootstrapbase/templates/block_myoverview/event-list-item.mustache +++ /dev/null @@ -1,104 +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" - } - } -}} -
    • -
      -
      -
      - {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}} -
      -
      - {{{name}}} -
      {{{course.fullnamedisplay}}}
      -
      -
      -
      -
      -
      - {{#userdate}} {{timesort}}, {{#str}} strftimerecent {{/str}} {{/userdate}} -
      -
      - {{#action.actionable}} - {{action.name}} - {{#action.itemcount}} - {{#action.showitemcount}} - {{.}} - {{/action.showitemcount}} - {{/action.itemcount}} - {{/action.actionable}} - {{^action.actionable}} -
      {{action.name}}
      - {{/action.actionable}} -
      -
      -
      -
      -
      -
      -
      - {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}} -
      -
      - {{name}} -
      {{{course.fullnamedisplay}}}
      -
      -
      -
      - {{#userdate}} {{timesort}}, {{#str}} strftimerecent {{/str}} {{/userdate}} -
      -
      -
      -
      -
      - {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}} -
      -
      - {{{name}}} -
      {{{course.fullnamedisplay}}}
      -
      -
      - {{#userdate}} {{timesort}}, {{#str}} strftimerecent {{/str}} {{/userdate}} -
      -
      -
      -
    • diff --git a/theme/bootstrapbase/templates/block_myoverview/main.mustache b/theme/bootstrapbase/templates/block_myoverview/main.mustache index 42577fed2eb33..3a8a005509de2 100644 --- a/theme/bootstrapbase/templates/block_myoverview/main.mustache +++ b/theme/bootstrapbase/templates/block_myoverview/main.mustache @@ -24,33 +24,20 @@ }}
      - - -
      -
      - {{> block_myoverview/timeline-view }} -
      -
      +
      +
      {{#coursesview}} - {{> block_myoverview/courses-view }} + {{#hascourses}} +
      + {{> block_myoverview/courses-view-nav-grouping-display-filter }} +
      + {{/hascourses}} {{/coursesview}}
      +
      + {{#coursesview}} + {{> block_myoverview/courses-view }} + {{/coursesview}} +
      -{{#js}} -require(['jquery', 'block_myoverview/tab_preferences'], function($, TabPreferences) { - var root = $('#block-myoverview-view-choices-{{uniqid}}'); - TabPreferences.registerEventListeners(root); -}); -{{/js}} diff --git a/theme/bootstrapbase/templates/block_myoverview/timeline-view.mustache b/theme/bootstrapbase/templates/block_myoverview/timeline-view.mustache deleted file mode 100644 index 9ccdb7d4325f0..0000000000000 --- a/theme/bootstrapbase/templates/block_myoverview/timeline-view.mustache +++ /dev/null @@ -1,55 +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): - {} -}} -
      - - -
      -
      - {{> block_myoverview/timeline-view-dates }} -
      -
      - {{> block_myoverview/timeline-view-courses }} -
      -
      -
      -{{#js}} -require(['jquery', 'core/custom_interaction_events'], function($, customEvents) { - var root = $('#timeline-view-{{uniqid}}'); - customEvents.define(root, [customEvents.events.activate]); - root.on(customEvents.events.activate, '[data-toggle="btns"] > .btn', function() { - root.find('.btn.active').removeClass('active'); - $(this).addClass('active'); - }); -}); -{{/js}} diff --git a/theme/bootstrapbase/templates/block_timeline/course-item-loading-placeholder.mustache b/theme/bootstrapbase/templates/block_timeline/course-item-loading-placeholder.mustache new file mode 100644 index 0000000000000..096ec11a5c5af --- /dev/null +++ b/theme/bootstrapbase/templates/block_timeline/course-item-loading-placeholder.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/course-item-loading-placeholder + + This template renders the each course block containing a summary and calendar events. + + Example context (json): + { + "shortname": "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." + } +}} +
    • +
      +
      +
        + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} +
      +
      +
      +
      +
      +
      +
    • diff --git a/theme/bootstrapbase/templates/block_timeline/event-list-item.mustache b/theme/bootstrapbase/templates/block_timeline/event-list-item.mustache new file mode 100644 index 0000000000000..af008b1791909 --- /dev/null +++ b/theme/bootstrapbase/templates/block_timeline/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" + } + } +}} +
    • + +
      + {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}} +
      + + {{#userdate}} {{timesort}}, {{#str}} strftimetime24, core_langconfig {{/str}} {{/userdate}} + +
      +
      {{{name}}}
      + {{{course.fullnamedisplay}}} +
      +
      +
    • diff --git a/blocks/myoverview/templates/event-list-items.mustache b/theme/bootstrapbase/templates/block_timeline/event-list-items.mustache similarity index 91% rename from blocks/myoverview/templates/event-list-items.mustache rename to theme/bootstrapbase/templates/block_timeline/event-list-items.mustache index 2dc770bfc5e5f..59da36e7dcb83 100644 --- a/blocks/myoverview/templates/event-list-items.mustache +++ b/theme/bootstrapbase/templates/block_timeline/event-list-items.mustache @@ -15,9 +15,9 @@ along with Moodle. If not, see . }} {{! - @template block_myoverview/event-list-items + @template block_timeline/event-list-items - This template renders a group of event list items for the myoverview block. + This template renders a group of event list items for the timeline block. Example context (json): { @@ -63,6 +63,8 @@ ] } }} +
        {{#events}} - {{> block_myoverview/event-list-item }} + {{> block_timeline/event-list-item }} {{/events}} +
      diff --git a/theme/bootstrapbase/templates/block_timeline/event-list.mustache b/theme/bootstrapbase/templates/block_timeline/event-list.mustache new file mode 100644 index 0000000000000..12801c555268c --- /dev/null +++ b/theme/bootstrapbase/templates/block_timeline/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): + { + } +}} +
      +
      +
        + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} + {{> block_timeline/placeholder-event-list-item }} +
      +
      +
      +
      +
      +
      +
      + +
      diff --git a/theme/bootstrapbase/templates/block_timeline/main.mustache b/theme/bootstrapbase/templates/block_timeline/main.mustache new file mode 100644 index 0000000000000..7e82e6fe66796 --- /dev/null +++ b/theme/bootstrapbase/templates/block_timeline/main.mustache @@ -0,0 +1,50 @@ +{{! + 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/theme/bootstrapbase/templates/block_timeline/nav-day-filter.mustache b/theme/bootstrapbase/templates/block_timeline/nav-day-filter.mustache new file mode 100644 index 0000000000000..85a1183b95281 --- /dev/null +++ b/theme/bootstrapbase/templates/block_timeline/nav-day-filter.mustache @@ -0,0 +1,67 @@ +{{! + 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/theme/bootstrapbase/templates/block_timeline/nav-view-selector.mustache b/theme/bootstrapbase/templates/block_timeline/nav-view-selector.mustache new file mode 100644 index 0000000000000..4caeda98a395d --- /dev/null +++ b/theme/bootstrapbase/templates/block_timeline/nav-view-selector.mustache @@ -0,0 +1,46 @@ +{{! + 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/theme/bootstrapbase/templates/block_timeline/placeholder-event-list-item.mustache b/theme/bootstrapbase/templates/block_timeline/placeholder-event-list-item.mustache new file mode 100644 index 0000000000000..3e98db150783c --- /dev/null +++ b/theme/bootstrapbase/templates/block_timeline/placeholder-event-list-item.mustache @@ -0,0 +1,31 @@ +{{! + 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/theme/bootstrapbase/templates/block_timeline/view.mustache b/theme/bootstrapbase/templates/block_timeline/view.mustache new file mode 100644 index 0000000000000..7d67b034e8ff4 --- /dev/null +++ b/theme/bootstrapbase/templates/block_timeline/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/theme/bootstrapbase/templates/core/paged_content_paging_bar.mustache b/theme/bootstrapbase/templates/core/paged_content_paging_bar.mustache index b4bfd4fac8100..12c64bbb53114 100644 --- a/theme/bootstrapbase/templates/core/paged_content_paging_bar.mustache +++ b/theme/bootstrapbase/templates/core/paged_content_paging_bar.mustache @@ -27,6 +27,7 @@ "next": true, "first": true, "last": true, + "activepagenumber": 1, "pages": [ { "url": "#", @@ -40,59 +41,145 @@ ] } }} - -{{#js}} -require(['jquery', 'core/paged_content_paging_dropdown'], function($, PagingControl) { - var root = $('#paging-dropdown-{{uniqid}}'); - PagingControl.init(root); -}); -{{/js}} diff --git a/version.php b/version.php index a6e8909f8b4e7..635d9e6c56ef3 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2018092100.04; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2018092700.00; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes.