Merge branch 'dashboard-split-overview-block' of https://github.com/ryanwyllie/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 27 Sep 2018 06:08:55 +0000 (14:08 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Thu, 27 Sep 2018 06:08:55 +0000 (14:08 +0800)
137 files changed:
admin/tool/usertours/tests/behat/create_tour.feature
blocks/myoverview/amd/build/event_list.min.js [deleted file]
blocks/myoverview/amd/build/event_list_by_course.min.js [deleted file]
blocks/myoverview/amd/build/tab_preferences.min.js [deleted file]
blocks/myoverview/amd/src/event_list.js [deleted file]
blocks/myoverview/amd/src/event_list_by_course.js [deleted file]
blocks/myoverview/amd/src/tab_preferences.js [deleted file]
blocks/myoverview/block_myoverview.php
blocks/myoverview/classes/output/main.php
blocks/myoverview/classes/privacy/provider.php
blocks/myoverview/db/upgrade.php [new file with mode: 0644]
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/templates/course-event-list-item.mustache [deleted file]
blocks/myoverview/templates/course-event-list.mustache [deleted file]
blocks/myoverview/templates/course-summary.mustache [deleted file]
blocks/myoverview/templates/courses-view-nav-grouping-display-filter.mustache [new file with mode: 0644]
blocks/myoverview/templates/courses-view.mustache
blocks/myoverview/templates/event-list-group.mustache [deleted file]
blocks/myoverview/templates/event-list-item.mustache [deleted file]
blocks/myoverview/templates/event-list.mustache [deleted file]
blocks/myoverview/templates/main.mustache
blocks/myoverview/templates/timeline-view-courses.mustache [deleted file]
blocks/myoverview/templates/timeline-view.mustache [deleted file]
blocks/myoverview/tests/behat/block_myoverview_dashboard.feature
blocks/myoverview/tests/behat/block_myoverview_progress.feature
blocks/myoverview/tests/privacy_test.php [deleted file]
blocks/myoverview/version.php
blocks/timeline/amd/build/calendar_events_repository.min.js [moved from blocks/myoverview/amd/build/calendar_events_repository.min.js with 100% similarity]
blocks/timeline/amd/build/event_list.min.js [new file with mode: 0644]
blocks/timeline/amd/build/main.min.js [new file with mode: 0644]
blocks/timeline/amd/build/paging_bar.min.js [new file with mode: 0644]
blocks/timeline/amd/build/paging_content.min.js [new file with mode: 0644]
blocks/timeline/amd/build/view.min.js [new file with mode: 0644]
blocks/timeline/amd/build/view_courses.min.js [new file with mode: 0644]
blocks/timeline/amd/build/view_dates.min.js [new file with mode: 0644]
blocks/timeline/amd/build/view_nav.min.js [new file with mode: 0644]
blocks/timeline/amd/src/calendar_events_repository.js [moved from blocks/myoverview/amd/src/calendar_events_repository.js with 96% similarity]
blocks/timeline/amd/src/event_list.js [new file with mode: 0644]
blocks/timeline/amd/src/main.js [new file with mode: 0644]
blocks/timeline/amd/src/view.js [new file with mode: 0644]
blocks/timeline/amd/src/view_courses.js [new file with mode: 0644]
blocks/timeline/amd/src/view_dates.js [new file with mode: 0644]
blocks/timeline/amd/src/view_nav.js [new file with mode: 0644]
blocks/timeline/block_timeline.php [new file with mode: 0644]
blocks/timeline/classes/output/main.php [new file with mode: 0644]
blocks/timeline/classes/output/renderer.php [new file with mode: 0644]
blocks/timeline/classes/privacy/provider.php [moved from blocks/myoverview/lib.php with 52% similarity]
blocks/timeline/db/access.php [new file with mode: 0644]
blocks/timeline/db/install.php [new file with mode: 0644]
blocks/timeline/lang/en/block_timeline.php [new file with mode: 0644]
blocks/timeline/pix/activities.svg [moved from blocks/myoverview/pix/activities.svg with 100% similarity]
blocks/timeline/pix/courses.svg [new file with mode: 0644]
blocks/timeline/templates/course-item-loading-placeholder.mustache [new file with mode: 0644]
blocks/timeline/templates/course-item.mustache [moved from blocks/myoverview/templates/course-item.mustache with 64% similarity]
blocks/timeline/templates/course-items.mustache [moved from theme/bootstrapbase/templates/block_myoverview/course-item.mustache with 63% similarity]
blocks/timeline/templates/event-list-content.mustache [moved from theme/bootstrapbase/templates/block_myoverview/event-list-group.mustache with 66% similarity]
blocks/timeline/templates/event-list-item.mustache [new file with mode: 0644]
blocks/timeline/templates/event-list-items.mustache [moved from blocks/myoverview/templates/course-event-list-items.mustache with 82% similarity]
blocks/timeline/templates/event-list.mustache [new file with mode: 0644]
blocks/timeline/templates/main.mustache [new file with mode: 0644]
blocks/timeline/templates/nav-day-filter.mustache [new file with mode: 0644]
blocks/timeline/templates/nav-view-selector.mustache [new file with mode: 0644]
blocks/timeline/templates/placeholder-event-list-item.mustache [new file with mode: 0644]
blocks/timeline/templates/view-courses.mustache [new file with mode: 0644]
blocks/timeline/templates/view-dates.mustache [moved from blocks/myoverview/templates/timeline-view-dates.mustache with 57% similarity]
blocks/timeline/templates/view.mustache [new file with mode: 0644]
blocks/timeline/tests/behat/block_timeline_courses.feature [new file with mode: 0644]
blocks/timeline/tests/behat/block_timeline_dates.feature [new file with mode: 0644]
blocks/timeline/version.php [moved from blocks/myoverview/settings.php with 55% similarity]
blocks/upgrade.txt
course/amd/build/repository.min.js [new file with mode: 0644]
course/amd/src/repository.js [new file with mode: 0644]
course/externallib.php
course/lib.php
course/tests/courselib_test.php
course/tests/externallib_test.php
lang/en/moodle.php
lib/amd/build/page_global.min.js [new file with mode: 0644]
lib/amd/build/paged_content.min.js [new file with mode: 0644]
lib/amd/build/paged_content_events.min.js
lib/amd/build/paged_content_factory.min.js
lib/amd/build/paged_content_pages.min.js
lib/amd/build/paged_content_paging_bar.min.js
lib/amd/build/paged_content_paging_bar_limit_selector.min.js [new file with mode: 0644]
lib/amd/build/paged_content_paging_dropdown.min.js
lib/amd/build/pubsub.min.js [new file with mode: 0644]
lib/amd/build/user_date.min.js
lib/amd/src/page_global.js [new file with mode: 0644]
lib/amd/src/paged_content.js [new file with mode: 0644]
lib/amd/src/paged_content_events.js
lib/amd/src/paged_content_factory.js
lib/amd/src/paged_content_pages.js
lib/amd/src/paged_content_paging_bar.js
lib/amd/src/paged_content_paging_bar_limit_selector.js [new file with mode: 0644]
lib/amd/src/paged_content_paging_dropdown.js
lib/amd/src/pubsub.js [new file with mode: 0644]
lib/amd/src/user_date.js
lib/blocklib.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/plugin_manager.php
lib/db/services.php
lib/enrollib.php
lib/navigationlib.php
lib/outputrequirementslib.php
lib/templates/paged_content.mustache
lib/templates/paged_content_pages.mustache
lib/templates/paged_content_paging_bar.mustache
lib/templates/paged_content_paging_dropdown.mustache
message/tests/privacy_provider_test.php
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/icons.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/bootstrapbase/less/moodle/blocks.less
theme/bootstrapbase/less/moodle/core.less
theme/bootstrapbase/style/moodle.css
theme/bootstrapbase/templates/block_myoverview/course-event-list-item.mustache [deleted file]
theme/bootstrapbase/templates/block_myoverview/course-event-list.mustache [deleted file]
theme/bootstrapbase/templates/block_myoverview/course-summary.mustache [deleted file]
theme/bootstrapbase/templates/block_myoverview/courses-view-nav-grouping-display-filter.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/block_myoverview/courses-view.mustache
theme/bootstrapbase/templates/block_myoverview/event-list-item.mustache [deleted file]
theme/bootstrapbase/templates/block_myoverview/main.mustache
theme/bootstrapbase/templates/block_myoverview/timeline-view.mustache [deleted file]
theme/bootstrapbase/templates/block_timeline/course-item-loading-placeholder.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/block_timeline/event-list-item.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/block_timeline/event-list-items.mustache [moved from blocks/myoverview/templates/event-list-items.mustache with 91% similarity]
theme/bootstrapbase/templates/block_timeline/event-list.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/block_timeline/main.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/block_timeline/nav-day-filter.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/block_timeline/nav-view-selector.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/block_timeline/placeholder-event-list-item.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/block_timeline/view.mustache [new file with mode: 0644]
theme/bootstrapbase/templates/core/paged_content_paging_bar.mustache
theme/bootstrapbase/templates/core/paged_content_paging_dropdown.mustache
version.php

index 33c7db7..09f99ff 100644 (file)
@@ -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 (file)
index 343b46e..0000000
Binary files a/blocks/myoverview/amd/build/event_list.min.js and /dev/null differ
diff --git a/blocks/myoverview/amd/build/event_list_by_course.min.js b/blocks/myoverview/amd/build/event_list_by_course.min.js
deleted file mode 100644 (file)
index 055a8b3..0000000
Binary files a/blocks/myoverview/amd/build/event_list_by_course.min.js and /dev/null differ
diff --git a/blocks/myoverview/amd/build/tab_preferences.min.js b/blocks/myoverview/amd/build/tab_preferences.min.js
deleted file mode 100644 (file)
index da5bd97..0000000
Binary files a/blocks/myoverview/amd/build/tab_preferences.min.js and /dev/null differ
diff --git a/blocks/myoverview/amd/src/event_list.js b/blocks/myoverview/amd/src/event_list.js
deleted file mode 100644 (file)
index 8a1bd91..0000000
+++ /dev/null
@@ -1,416 +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 <http://www.gnu.org/licenses/>.
-
-/**
- * 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 <ryan@moodle.com>
- * @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 (file)
index 32d52cc..0000000
+++ /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 <http://www.gnu.org/licenses/>.
-
-/**
- * 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 <simey@moodle.com>
- * @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 (file)
index 25ac2ee..0000000
+++ /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 <http://www.gnu.org/licenses/>.
-
-/**
- * Javascript used to save the user's tab preference.
- *
- * @package    block_myoverview
- * @copyright  2017 Mark Nelson <markn@moodle.com>
- * @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
-    };
-});
index 8afd4a1..f22ce15 100644 (file)
@@ -50,16 +50,7 @@ class block_myoverview extends block_base {
             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 @@ class block_myoverview extends block_base {
     public function applicable_formats() {
         return array('my' => true);
     }
-
-    /**
-     * This block does contain a configuration settings.
-     *
-     * @return boolean
-     */
-    public function has_config() {
-        return true;
-    }
 }
index 2850637..46834f3 100644 (file)
@@ -29,7 +29,6 @@ use renderer_base;
 use templatable;
 use core_completion\progress;
 
-require_once($CFG->dirroot . '/blocks/myoverview/lib.php');
 require_once($CFG->libdir . '/completionlib.php');
 
 /**
@@ -39,21 +38,6 @@ require_once($CFG->libdir . '/completionlib.php');
  * @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 @@ class main implements renderable, templatable {
 
         $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
         ];
     }
 }
index d0ee9e8..b3cf042 100644 (file)
@@ -32,30 +32,15 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright  2018 Zig Tan <zig@moodle.com>
  * @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 (file)
index 0000000..b91cb96
--- /dev/null
@@ -0,0 +1,41 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * This file keeps track of upgrades to the myoverview block
+ *
+ * @package block_myoverview
+ * @copyright 2018 Ryan Wyllie <ryan@moodle.com>
+ * @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;
+}
index a3ca64e..df3ae66 100644 (file)
@@ -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['nocourses'] = 'No courses';
 $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 (file)
index 55c0e46..0000000
+++ /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 <http://www.gnu.org/licenses/>.
-}}
-{{!
-    @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"
-        }
-    }
-}}
-<li class="list-group-item event-list-item" data-region="event-list-item">
-    <div class="row">
-        <div class="col-lg-7 col-xl-8">
-            <div class="d-inline-block icon-large event-icon">
-                {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}}
-            </div>
-            <div class="d-inline-block event-name-container">
-                <a class="event-name text-truncate" href="{{{url}}}">{{{name}}}</a>
-                <p class="small text-muted text-truncate m-b-0">
-                    {{#userdate}} {{timesort}}, {{#str}} strftimerecent {{/str}} {{/userdate}}
-                </p>
-            </div>
-        </div>
-        <div class="hidden-md-down d-none d-md-block col-lg-5 col-xl-4 text-truncate">
-            {{#action.actionable}}
-                <a href="{{{action.url}}}">{{action.name}}</a>
-                {{#action.itemcount}}
-                    {{#action.showitemcount}}
-                        <span class="tag tag-pill tag-default">{{.}}</span>
-                    {{/action.showitemcount}}
-                {{/action.itemcount}}
-            {{/action.actionable}}
-            {{^action.actionable}}
-                <div class="text-muted">{{action.name}}</div>
-            {{/action.actionable}}
-        </div>
-    </div>
-</li>
diff --git a/blocks/myoverview/templates/course-event-list.mustache b/blocks/myoverview/templates/course-event-list.mustache
deleted file mode 100644 (file)
index d7f9fb2..0000000
+++ /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 <http://www.gnu.org/licenses/>.
-}}
-{{!
-    @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": "#"
-        }
-    }
-}}
-<div data-region="event-list-container"
-     data-limit="{{$limit}}20{{/limit}}"
-     data-course-id="{{$courseid}}{{/courseid}}"
-     data-last-id="{{$lastid}}{{/lastid}}"
-     data-midnight="{{midnight}}"
-     id="event-list-container-{{$courseid}}{{/courseid}}">
-
-    <div data-region="event-list-content">
-        {{< 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 }}
-
-        <div class="text-xs-center text-center m-y-2">
-            <button type="button" class="btn btn-secondary" data-action="view-more">
-                {{#str}} viewmore {{/str}}
-                <span class="hidden" data-region="loading-icon-container">
-                    {{> core/loading }}
-                </span>
-            </button>
-        </div>
-    </div>
-    <div class="hidden text-xs-center text-center m-y-3" data-region="empty-message">
-        <img class="empty-placeholder-image-sm"
-             src="{{urls.noevents}}"
-             alt="{{#str}} noevents, block_myoverview {{/str}}"
-             role="presentation">
-        <p class="text-muted m-t-1">{{#str}} noevents, block_myoverview {{/str}}</p>
-        <a href="{{viewurl}}" class="btn btn-secondary {{#visible}}text-primary{{/visible}}"
-           aria-label="{{#str}} viewcoursename, block_myoverview, {{{fullnamedisplay}}} {{/str}}">
-            {{#str}} viewcourse, block_myoverview {{/str}}
-        </a>
-    </div>
-</div>
-{{#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 (file)
index 53f40a0..0000000
+++ /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 <http://www.gnu.org/licenses/>.
-}}
-{{!
-    @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."
-    }
-}}
-<div class="course-info-container" id="course-info-container-{{id}}">
-    <div class="d-sm-none d-lg-block">
-        {{> block_myoverview/progress-chart}}
-        <h4 class="h5"><a href="{{viewurl}}" class="{{^visible}}dimmed{{/visible}}">{{{fullnamedisplay}}}</a></h4>
-    </div>
-    <div class="d-none d-sm-block d-lg-none visible-tablet">
-        <div class="media">
-            <div class="media-left pr-3">
-                <div class="media-object">
-                    {{> block_myoverview/progress-chart}}
-                </div>
-            </div>
-            <div class="media-body">
-                <h4 class="h5"><a href="{{viewurl}}" class="{{^visible}}dimmed{{/visible}}">{{{fullnamedisplay}}}</a></h4>
-            </div>
-        </div>
-    </div>
-    <p class="text-muted">
-        {{#shortentext}} 140, {{{summary}}}{{/shortentext}}
-    </p>
-</div>
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 (file)
index 0000000..f7aa275
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_myoverview/courses-view-nav-grouping-display-filter
+
+    This template renders the main content area for the myoverview block.
+
+    Example context (json):
+    {}
+}}
+<div data-region="courses-grouping-display-filter" class="btn-group">
+    <button type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+        {{#str}} inprogress, block_myoverview {{/str}}
+    </button>
+    <div class="dropdown-menu list-group hidden" data-show-active-item data-skip-active-class="true">
+        <a class="dropdown-item active" href="#myoverview_courses_view_in_progress" data-toggle="tab">
+            {{#str}} inprogress, block_myoverview {{/str}}
+        </a>
+        <a class="dropdown-item" href="#myoverview_courses_view_future" data-toggle="tab">
+            {{#str}} future, block_myoverview {{/str}}
+        </a>
+        <a class="dropdown-item" href="#myoverview_courses_view_past" data-toggle="tab">
+            {{#str}} past, block_myoverview {{/str}}
+        </a>
+    </div>
+</div>
index 14ffa49..cb5b926 100644 (file)
 }}
 <div id="courses-view-{{uniqid}}" data-region="courses-view">
     {{#hascourses}}
-    <div class="d-flex justify-content-center">
-        <ul class="nav nav-pills my-5">
-            <li class="nav-item">
-                <a class="nav-link active" href="#myoverview_courses_view_in_progress" data-toggle="tab">
-                    {{#str}} inprogress, block_myoverview {{/str}}
-                </a>
-            </li>
-            <li class="nav-item">
-                <a class="nav-link" href="#myoverview_courses_view_future" data-toggle="tab">
-                    {{#str}} future, block_myoverview {{/str}}
-                </a>
-            </li>
-            <li class="nav-item">
-                <a class="nav-link" href="#myoverview_courses_view_past" data-toggle="tab">
-                    {{#str}} past, block_myoverview {{/str}}
-                </a>
-            </li>
-        </ul>
-    </div>
     <div class="tab-content">
         <div class="tab-pane active fade show" id="myoverview_courses_view_in_progress">
             {{#inprogress}}
diff --git a/blocks/myoverview/templates/event-list-group.mustache b/blocks/myoverview/templates/event-list-group.mustache
deleted file mode 100644 (file)
index 340fdcb..0000000
+++ /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 <http://www.gnu.org/licenses/>.
-}}
-{{!
-    @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"
-                }
-            }
-        ]
-    }
-}}
-<div data-region="event-list-group-container"
-     data-start-day="{{$startday}}0{{/startday}}"
-     data-end-day="{{$endday}}{{/endday}}"
-     class="hidden">
-
-    <h5 class="h6 m-t-1 {{$extratitleclasses}}{{/extratitleclasses}}" id="event-list-title-{{uniqid}}"><strong>{{$title}}{{/title}}</strong></h5>
-    <ul class="list-group unstyled" data-region="event-list" aria-describedby="event-list-title-{{uniqid}}">
-        {{$eventlistitems}}
-            {{> block_myoverview/event-list-items }}
-        {{/eventlistitems}}
-    </ul>
-</div>
diff --git a/blocks/myoverview/templates/event-list-item.mustache b/blocks/myoverview/templates/event-list-item.mustache
deleted file mode 100644 (file)
index a269b5c..0000000
+++ /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 <http://www.gnu.org/licenses/>.
-}}
-{{!
-    @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"
-        }
-    }
-}}
-<li class="list-group-item event-list-item" data-region="event-list-item">
-    <div class="row">
-        <div class="col-sm-8 col-lg-6 col-xl-7">
-            <div class="d-inline-block icon-large event-icon">
-                {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}}
-            </div>
-            <div class="d-inline-block event-name-container">
-                <a class="event-name text-truncate" href="{{{url}}}">{{{name}}}</a>
-                <p class="small text-muted text-truncate m-b-0">{{{course.fullnamedisplay}}}</p>
-            </div>
-        </div>
-        <div class="col-sm-4 col-lg-6 col-xl-5">
-            <div class="row">
-                <div class="col-lg-5 text-xs-right text-lg-left text-truncate">
-                    {{#userdate}} {{timesort}}, {{#str}} strftimerecent {{/str}} {{/userdate}}
-                </div>
-                <div class="hidden-md-down d-none d-md-block col-lg-7 text-truncate">
-                    {{#action.actionable}}
-                        <a href="{{{action.url}}}">{{action.name}}</a>
-                        {{#action.itemcount}}
-                            {{#action.showitemcount}}
-                                <span class="tag tag-pill tag-default">{{.}}</span>
-                            {{/action.showitemcount}}
-                        {{/action.itemcount}}
-                    {{/action.actionable}}
-                    {{^action.actionable}}
-                        <div class="text-muted">{{action.name}}</div>
-                    {{/action.actionable}}
-                </div>
-            </div>
-        </div>
-    </div>
-</li>
diff --git a/blocks/myoverview/templates/event-list.mustache b/blocks/myoverview/templates/event-list.mustache
deleted file mode 100644 (file)
index dbe3d25..0000000
+++ /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 <http://www.gnu.org/licenses/>.
-}}
-{{!
-    @template block_myoverview/event-list
-
-    This template renders a list of events for the myoverview block.
-
-    Example context (json):
-    {
-    }
-}}
-<div data-region="event-list-container"
-     data-limit="{{$limit}}20{{/limit}}"
-     data-course-id="{{$courseid}}{{/courseid}}"
-     data-last-id="{{$lastid}}{{/lastid}}"
-     data-midnight="{{midnight}}"
-     id="event-list-container-{{$courseid}}{{/courseid}}">
-
-    <div data-region="event-list-content">
-        {{< 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 }}
-
-        <div class="text-xs-center text-center m-y-2">
-            <button type="button" class="btn btn-secondary" data-action="view-more">
-                {{#str}} viewmore {{/str}}
-                <span class="hidden" data-region="loading-icon-container">
-                    {{> core/loading }}
-                </span>
-            </button>
-        </div>
-    </div>
-    <div class="hidden text-xs-center text-center m-t-3" data-region="empty-message">
-        <img class="empty-placeholder-image-lg"
-             src="{{urls.noevents}}"
-             alt="{{#str}} noevents, block_myoverview {{/str}}"
-             role="presentation">
-        <p class="text-muted m-t-1">{{#str}} noevents, block_myoverview {{/str}}</p>
-    </div>
-</div>
-{{#js}}
-require(['jquery', 'block_myoverview/event_list'], function($, EventList) {
-    var root = $("#event-list-container-{{$courseid}}{{/courseid}}");
-    EventList.registerEventListeners(root);
-});
-{{/js}}
index e9b21bd..fef8e9f 100644 (file)
 }}
 
 <div id="block-myoverview-{{uniqid}}" class="block-myoverview" data-region="myoverview">
-    <ul id="block-myoverview-view-choices-{{uniqid}}" class="nav nav-tabs" role="tablist">
-        <li class="nav-item">
-            <a class="nav-link {{#viewingtimeline}}active{{/viewingtimeline}}" href="#myoverview_timeline_view" role="tab" data-toggle="tab" data-tabname="timeline">
-                {{#str}} timeline, block_myoverview {{/str}}
-            </a>
-        </li>
-        <li class="nav-item">
-            <a class="nav-link {{#viewingcourses}}active{{/viewingcourses}}" href="#myoverview_courses_view" role="tab" data-toggle="tab" data-tabname="courses">
-                {{#str}} courses {{/str}}
-            </a>
-        </li>
-    </ul>
-    <div class="tab-content content-centred">
-        <div role="tabpanel" class="tab-pane fade {{#viewingtimeline}}in active{{/viewingtimeline}}" id="myoverview_timeline_view">
-            {{> block_myoverview/timeline-view }}
-        </div>
-        <div role="tabpanel" class="tab-pane fade {{#viewingcourses}}in active{{/viewingcourses}}" id="myoverview_courses_view">
+    <div class="container p-0 m-b-1">
+        <div class="row no-gutters">
             {{#coursesview}}
-                {{> block_myoverview/courses-view }}
+                {{#hascourses}}
+                    <div class="{{#viewingtimeline}}d-none{{/viewingtimeline}}" data-tab-content="courses">
+                        {{> block_myoverview/courses-view-nav-grouping-display-filter }}
+                    </div>
+                {{/hascourses}}
             {{/coursesview}}
         </div>
     </div>
+    <div class="container p-0">
+        {{#coursesview}}
+        {{> block_myoverview/courses-view }}
+        {{/coursesview}}
+    </div>
 </div>
-{{#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 (file)
index 6f35022..0000000
+++ /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 <http://www.gnu.org/licenses/>.
-}}
-{{!
-    @template block_myoverview/timeline-view-courses
-
-    This template renders the timeline view by courses for the myoverview block.
-
-    Example context (json):
-    {}
-}}
-<div id="sort-by-courses-view-{{uniqid}}">
-    {{#coursesview}}
-        {{#inprogress}}
-            {{#haspages}}
-                {{#pages}}
-                    <ul class="list-group unstyled hidden" data-region="course-block">
-                        {{#courses}} {{> block_myoverview/course-item }} {{/courses}}
-                    </ul>
-                {{/pages}}
-                <div class="text-xs-center text-center m-t-1">
-                    <button type="button" class="btn btn-secondary" data-action="more-courses">
-                        {{#str}} morecourses, block_myoverview {{/str}}
-                        <span class="hidden" data-region="loading-icon-container">
-                            {{> core/loading }}
-                        </span>
-                    </button>
-                </div>
-            {{/haspages}}
-            {{^haspages}}
-                <div class="text-xs-center text-center m-t-3">
-                    <img class="empty-placeholder-image-lg"
-                         src="{{urls.noevents}}"
-                         alt="{{#str}} nocoursesinprogress, block_myoverview {{/str}}"
-                         role="presentation">
-                    <p class="text-muted m-t-1">{{#str}} nocoursesinprogress, block_myoverview {{/str}}</p>
-                </div>
-            {{/haspages}}
-        {{/inprogress}}
-        {{^inprogress}}
-            <div class="text-xs-center text-center m-t-3">
-                <img class="empty-placeholder-image-lg"
-                     src="{{urls.noevents}}"
-                     alt="{{#str}} nocoursesinprogress, block_myoverview {{/str}}"
-                     role="presentation">
-                <p class="text-muted m-t-1">{{#str}} nocoursesinprogress, block_myoverview {{/str}}</p>
-            </div>
-        {{/inprogress}}
-    {{/coursesview}}
-</div>
-{{#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 (file)
index 9d57cd2..0000000
+++ /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 <http://www.gnu.org/licenses/>.
-}}
-{{!
-    @template block_myoverview/timeline-view
-
-    This template renders the timeline view for the myoverview block.
-
-    Example context (json):
-    {}
-}}
-<div id="timeline-view-{{uniqid}}" data-region="timeline-view">
-    <div class="d-flex justify-content-center">
-        <ul class="nav nav-pills my-5">
-            <li class="nav-item">
-            <a class="nav-link active" href="#myoverview_timeline_dates" data-toggle="tab">
-                {{#str}} sortbydates, block_myoverview {{/str}}
-            </a>
-            </li>
-            <li class="nav-item">
-            <a class="nav-link" href="#myoverview_timeline_courses" data-toggle="tab">
-                {{#str}} sortbycourses, block_myoverview {{/str}}
-            </a>
-            </li>
-        </ul>
-    </div>
-
-    <div class="tab-content">
-        <div class="tab-pane active fade show" id="myoverview_timeline_dates">
-            {{> block_myoverview/timeline-view-dates }}
-        </div>
-        <div class="tab-pane fade" id="myoverview_timeline_courses">
-            {{> block_myoverview/timeline-view-courses }}
-        </div>
-    </div>
-</div>
\ No newline at end of file
index bf6f356..de37359 100644 (file)
@@ -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
index 44cd539..5ed5656 100644 (file)
@@ -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 (file)
index 875dd03..0000000
+++ /dev/null
@@ -1,80 +0,0 @@
-<?php
-// 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 <http://www.gnu.org/licenses/>.
-
-/**
- * Unit tests for the block_myoverview implementation of the privacy API.
- *
- * @package    block_myoverview
- * @category   test
- * @copyright  2018 Adrian Greeve <adriangreeve.com>
- * @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 <adriangreeve.com>
- * @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);
-    }
-}
index 26ccc6d..6b8960c 100644 (file)
@@ -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/timeline/amd/build/event_list.min.js b/blocks/timeline/amd/build/event_list.min.js
new file mode 100644 (file)
index 0000000..cd209c5
Binary files /dev/null and b/blocks/timeline/amd/build/event_list.min.js differ
diff --git a/blocks/timeline/amd/build/main.min.js b/blocks/timeline/amd/build/main.min.js
new file mode 100644 (file)
index 0000000..4b8ded9
Binary files /dev/null and b/blocks/timeline/amd/build/main.min.js differ
diff --git a/blocks/timeline/amd/build/paging_bar.min.js b/blocks/timeline/amd/build/paging_bar.min.js
new file mode 100644 (file)
index 0000000..40e8b6d
Binary files /dev/null and b/blocks/timeline/amd/build/paging_bar.min.js differ
diff --git a/blocks/timeline/amd/build/paging_content.min.js b/blocks/timeline/amd/build/paging_content.min.js
new file mode 100644 (file)
index 0000000..97da2ca
Binary files /dev/null and b/blocks/timeline/amd/build/paging_content.min.js differ
diff --git a/blocks/timeline/amd/build/view.min.js b/blocks/timeline/amd/build/view.min.js
new file mode 100644 (file)
index 0000000..e374dc3
Binary files /dev/null and b/blocks/timeline/amd/build/view.min.js differ
diff --git a/blocks/timeline/amd/build/view_courses.min.js b/blocks/timeline/amd/build/view_courses.min.js
new file mode 100644 (file)
index 0000000..918fdd1
Binary files /dev/null and b/blocks/timeline/amd/build/view_courses.min.js differ
diff --git a/blocks/timeline/amd/build/view_dates.min.js b/blocks/timeline/amd/build/view_dates.min.js
new file mode 100644 (file)
index 0000000..b29cb60
Binary files /dev/null and b/blocks/timeline/amd/build/view_dates.min.js differ
diff --git a/blocks/timeline/amd/build/view_nav.min.js b/blocks/timeline/amd/build/view_nav.min.js
new file mode 100644 (file)
index 0000000..c66ad2f
Binary files /dev/null and b/blocks/timeline/amd/build/view_nav.min.js differ
 /**
  * A javascript module to retrieve calendar events from the server.
  *
- * @module     block_myoverview/calendar_events_repository
- * @class      repository
- * @package    block_myoverview
- * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
+ * @module     block_timeline/calendar_events_repository
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
  * @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 (file)
index 0000000..dfd1517
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * 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 <ryan@moodle.com>
+ * @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 (file)
index 0000000..334248b
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript to initialise the timeline block.
+ *
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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 (file)
index 0000000..89430a7
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Manage the timeline view for the timeline block.
+ *
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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 (file)
index 0000000..4749c92
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Manage the timeline courses view for the timeline block.
+ *
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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 (file)
index 0000000..b18d21a
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Manage the timeline dates view for the timeline block.
+ *
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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 (file)
index 0000000..092dcbd
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Manage the timeline view navigation for the timeline block.
+ *
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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 (file)
index 0000000..5a24f9c
--- /dev/null
@@ -0,0 +1,72 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains the class for the Timeline block.
+ *
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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 <ryan@moodle.com>
+ * @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 (file)
index 0000000..b5e9609
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Class containing data for timeline block.
+ *
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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 <ryan@moodle.com>
+ * @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 (file)
index 0000000..dbe6bb7
--- /dev/null
@@ -0,0 +1,48 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Timeline block rendrer.
+ *
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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 <ryan@moodle.com>
+ * @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));
+    }
+}
similarity index 52%
rename from blocks/myoverview/lib.php
rename to blocks/timeline/classes/privacy/provider.php
index a73db25..02ae526 100644 (file)
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Contains functions called by core.
+ * Privacy Subsystem implementation for block_timeline.
  *
- * @package    block_myoverview
- * @copyright  2017 Mark Nelson <markn@moodle.com>
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
  * @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 <ryan@moodle.com>
+ * @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 (file)
index 0000000..6bd70a2
--- /dev/null
@@ -0,0 +1,50 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Capabilities for the timeline block.
+ *
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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 (file)
index 0000000..c5a3d65
--- /dev/null
@@ -0,0 +1,108 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Timeline block installation.
+ *
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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 (file)
index 0000000..70f961c
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Lang strings for the timeline block.
+ *
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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/timeline/pix/courses.svg b/blocks/timeline/pix/courses.svg
new file mode 100644 (file)
index 0000000..75e59fc
--- /dev/null
@@ -0,0 +1,52 @@
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="157 -1305 148 125" preserveAspectRatio="xMinYMid meet">
+  <defs>
+    <style>
+      .cls-1 {
+        clip-path: url(#clip-Courses);
+      }
+
+      .cls-2 {
+        fill: #eee;
+      }
+
+      .cls-3 {
+        fill: #c4c8cc;
+      }
+
+      .cls-4 {
+        fill: #fff;
+      }
+    </style>
+    <clipPath id="clip-Courses">
+      <rect x="157" y="-1305" width="148" height="125"/>
+    </clipPath>
+  </defs>
+  <g id="Courses" class="cls-1">
+    <g id="Group_44" data-name="Group 44" transform="translate(-268 -1781)">
+      <ellipse id="Ellipse_41" data-name="Ellipse 41" class="cls-2" cx="74" cy="14.785" rx="74" ry="14.785" transform="translate(425 571.43)"/>
+      <rect id="Rectangle_87" data-name="Rectangle 87" class="cls-3" width="95.097" height="110.215" transform="translate(451.909 476)"/>
+      <g id="Group_43" data-name="Group 43" transform="translate(464.04 494)">
+        <rect id="Rectangle_88" data-name="Rectangle 88" class="cls-4" width="31.043" height="34" transform="translate(0)"/>
+        <rect id="Rectangle_89" data-name="Rectangle 89" class="cls-4" width="31.043" height="34" transform="translate(0 42)"/>
+        <rect id="Rectangle_90" data-name="Rectangle 90" class="cls-4" width="31.067" height="34" transform="translate(39.005)"/>
+        <rect id="Rectangle_91" data-name="Rectangle 91" class="cls-4" width="31.067" height="34" transform="translate(39.005 42)"/>
+        <rect id="Rectangle_92" data-name="Rectangle 92" class="cls-3" width="23.023" height="3.18" transform="translate(3.081 16.549)"/>
+        <rect id="Rectangle_93" data-name="Rectangle 93" class="cls-3" width="23.023" height="3.18" transform="translate(3.081 58.549)"/>
+        <rect id="Rectangle_94" data-name="Rectangle 94" class="cls-3" width="23.023" height="3.18" transform="translate(43.122 16.549)"/>
+        <rect id="Rectangle_95" data-name="Rectangle 95" class="cls-3" width="23.023" height="3.18" transform="translate(43.122 58.549)"/>
+        <rect id="Rectangle_96" data-name="Rectangle 96" class="cls-3" width="14.014" height="3.18" transform="translate(3.081 21.825)"/>
+        <rect id="Rectangle_97" data-name="Rectangle 97" class="cls-3" width="18.845" height="3.18" transform="translate(3.081 26.825)"/>
+        <rect id="Rectangle_98" data-name="Rectangle 98" class="cls-3" width="14.014" height="3.18" transform="translate(3.081 63.825)"/>
+        <rect id="Rectangle_99" data-name="Rectangle 99" class="cls-3" width="18.845" height="3.18" transform="translate(3.081 68.825)"/>
+        <rect id="Rectangle_100" data-name="Rectangle 100" class="cls-3" width="14.014" height="3.18" transform="translate(43.122 21.825)"/>
+        <rect id="Rectangle_101" data-name="Rectangle 101" class="cls-3" width="18.845" height="3.18" transform="translate(43.122 26.825)"/>
+        <rect id="Rectangle_102" data-name="Rectangle 102" class="cls-3" width="14.014" height="3.18" transform="translate(43.122 63.825)"/>
+        <rect id="Rectangle_103" data-name="Rectangle 103" class="cls-3" width="18.845" height="3.18" transform="translate(43.122 68.825)"/>
+        <ellipse id="Ellipse_42" data-name="Ellipse 42" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(3.003 3.55)"/>
+        <ellipse id="Ellipse_43" data-name="Ellipse 43" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(3.003 45.55)"/>
+        <ellipse id="Ellipse_44" data-name="Ellipse 44" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(43.044 3.55)"/>
+        <ellipse id="Ellipse_45" data-name="Ellipse 45" class="cls-3" cx="5.658" cy="5.652" rx="5.658" ry="5.652" transform="translate(43.044 45.55)"/>
+      </g>
+    </g>
+  </g>
+</svg>
diff --git a/blocks/timeline/templates/course-item-loading-placeholder.mustache b/blocks/timeline/templates/course-item-loading-placeholder.mustache
new file mode 100644 (file)
index 0000000..d2c8168
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_timeline/course-item-loading-placeholder
+
+    This template renders the each course block containing a summary and calendar events.
+
+    Example context (json):
+    {}
+}}
+<li class="list-group-item mt-3 p-0 border-0">
+    <div class="w-50 bg-pulse-grey mb-2" style="height: 20px"></div>
+    <div>
+        <ul class="pl-0 list-group list-group-flush">
+            {{> 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 }}
+        </ul>
+        <div class="pt-3 d-flex justify-content-between">
+            <div class="w-25 bg-pulse-grey" style="height: 35px"></div>
+            <div class="w-25 bg-pulse-grey" style="height: 35px"></div>
+        </div>
+    </div>
+</li>
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template block_myoverview/course-item
+    @template block_timeline/course-item
 
     This template renders the each course block containing a summary and calendar events.
 
         "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."
     }
 }}
-<li class="list-group-item m-y-1">
-<div data-region="course-events-container" id="course-events-container-{{id}}" data-course-id="{{id}}">
-    <div class="row">
-        <div class="col-lg-3">
-            {{> block_myoverview/course-summary }}
-        </div>
-        <div class="col-lg-9">
-            {{< block_myoverview/course-event-list }}
-                {{$limit}}10{{/limit}}
-                {{$offset}}0{{/offset}}
-                {{$courseid}}{{id}}{{/courseid}}
-            {{/ block_myoverview/course-event-list }}
-        </div>
+<li class="list-group-item mt-3 p-0 border-0">
+    <div data-region="course-events-container" id="course-events-container-{{id}}" data-course-id="{{id}}">
+        <h4 class="h5"><a href="{{viewurl}}" data-region="course-name">{{{fullnamedisplay}}}</a></h4>
+        {{< block_timeline/event-list }}
+            {{$courseid}}{{id}}{{/courseid}}
+        {{/ block_timeline/event-list }}
     </div>
-</div>
 </li>
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template block_myoverview/course-item
+    @template block_timeline/course-items
 
     This template renders the each course block containing a summary and calendar events.
 
         "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."
     }
 }}
-<li class="list-group-item well well-small">
-<div data-region="course-events-container" id="course-events-container-{{id}}" data-course-id="{{id}}">
-    <div class="row-fluid">
-        <div class="span3">
-            {{> block_myoverview/course-summary }}
-        </div>
-        <div class="span9">
-            {{< block_myoverview/course-event-list }}
-                {{$limit}}10{{/limit}}
-                {{$offset}}0{{/offset}}
-                {{$courseid}}{{id}}{{/courseid}}
-            {{/ block_myoverview/course-event-list }}
-        </div>
-    </div>
-</div>
-</li>
+{{#courses}}
+    {{> block_timeline/course-item }}
+{{/courses}}
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @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",
                 }
             },
             {
-                "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",
         ]
     }
 }}
-<div data-region="event-list-group-container"
-     data-start-day="{{$startday}}0{{/startday}}"
-     data-end-day="{{$endday}}{{/endday}}"
-     class="hidden">
-
-     <h5 class="{{$extratitleclasses}}{{/extratitleclasses}}" id="event-list-title-{{uniqid}}"><strong>{{$title}}{{/title}}</strong></h5>
-    <ul class="unstyled well well-small" data-region="event-list" aria-describedby="event-list-title-{{uniqid}}">
-        {{$eventlistitems}}
-            {{> block_myoverview/event-list-items }}
-        {{/eventlistitems}}
-    </ul>
-</div>
+<div class="border-bottom pb-2">
+    {{#eventsbyday}}
+        <h5 class="h6 mt-3 mb-0 {{#past}}text-danger{{/past}}">{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedayshort, core_langconfig {{/str}}  {{/userdate}}</h5>
+        {{> block_timeline/event-list-items }}
+    {{/eventsbyday}}
+</div>
\ 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 (file)
index 0000000..40fe0dc
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @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"
+        }
+    }
+}}
+<a
+    class="list-group-item list-group-item-action flex-column py-2 pl-0 pr-0 border-0"
+    href="{{{action.url}}}"
+    title="{{name}}"
+    data-region="event-list-item"
+    aria-label='{{#str}} ariaeventlistitem, block_timeline, { "name": "{{name}}", "course": "{{course.fullnamedisplay}}", "date": "{{#userdate}} {{timesort}}, {{#str}} strftimedatetime, core_langconfig {{/str}} {{/userdate}}" } {{/str}}'
+>
+    <div class="d-flex">
+        <div class="icon-size-4 d-flex align-self-center">
+            {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}}
+        </div>
+        <div class="w-100 event-name-container text-truncate line-height-3">
+            <h6 class="event-name text-truncate mb-0">{{{name}}}</h6>
+            <small class="text-muted text-truncate m-b-0">{{{course.fullnamedisplay}}}</small>
+        </div>
+        <small class="text-right text-nowrap ml-1">
+            {{#userdate}} {{timesort}}, {{#str}} strftimetime24, core_langconfig {{/str}} {{/userdate}}
+        </small>
+    </div>
+</a>
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @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 @@
         ]
     }
 }}
+<div class="pl-0 list-group list-group-flush">
 {{#events}}
-    {{> block_myoverview/course-event-list-item }}
+    {{> block_timeline/event-list-item }}
 {{/events}}
+</div>
diff --git a/blocks/timeline/templates/event-list.mustache b/blocks/timeline/templates/event-list.mustache
new file mode 100644 (file)
index 0000000..9a640c4
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_timeline/event-list
+
+    This template renders a list of events for the timeline block.
+
+    Example context (json):
+    {
+    }
+}}
+<div data-region="event-list-container"
+     data-days-offset="{{$daysoffset}}{{#hasdaysoffset}}{{daysoffset}}{{/hasdaysoffset}}{{^hasdaysoffset}}0{{/hasdaysoffset}}{{/daysoffset}}"
+     {{^nodayslimit}}data-days-limit="{{$dayslimit}}{{#hasdayslimit}}{{dayslimit}}{{/hasdayslimit}}{{^hasdayslimit}}30{{/hasdayslimit}}{{/dayslimit}}"{{/nodayslimit}}
+     data-course-id="{{$courseid}}{{/courseid}}"
+     data-midnight="{{midnight}}"
+>
+    <div data-region="event-list-loading-placeholder">
+        <ul class="pl-0 list-group list-group-flush">
+            {{> 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 }}
+        </ul>
+        <div class="pt-3 d-flex justify-content-between">
+            <div class="w-25 bg-pulse-grey" style="height: 35px"></div>
+            <div class="w-25 bg-pulse-grey" style="height: 35px"></div>
+        </div>
+    </div>
+    <div data-region="event-list-content"></div>
+    <div class="hidden text-xs-center text-center mt-3" data-region="empty-message">
+        <img
+            src="{{urls.noevents}}"
+            alt="{{#str}} noevents, block_timeline {{/str}}"
+            role="presentation"
+            style="height: 70px; width: 70px"
+        >
+        <p class="text-muted m-t-1">{{#str}} noevents, block_timeline {{/str}}</p>
+    </div>
+</div>
diff --git a/blocks/timeline/templates/main.mustache b/blocks/timeline/templates/main.mustache
new file mode 100644 (file)
index 0000000..021cf47
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_timeline/main
+
+    This template renders the main content area for the timeline block.
+
+    Example context (json):
+    {}
+}}
+
+<div id="block-timeline-{{uniqid}}" class="block-timeline" data-region="timeline">
+    <div class="container p-0 pb-3 border-bottom">
+        <div class="row no-gutters">
+            <div class="col-sm d-flex justify-content-start">
+                {{> block_timeline/nav-day-filter }}
+            </div>
+            <div class="col-sm d-flex justify-content-end">
+                {{> block_timeline/nav-view-selector }}
+            </div>
+        </div>
+    </div>
+    <div class="container p-0">
+        {{> block_timeline/view }}
+    </div>
+</div>
+{{#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 (file)
index 0000000..90afd62
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_timeline/nav-day-filter
+
+    This template renders the day range selector for the timeline view.
+
+    Example context (json):
+    {}
+}}
+<div data-region="day-filter" class="dropdown">
+    <button type="button" class="btn btn-outline-secondary dropdown-toggle icon-no-margin" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+        {{#pix}} i/duration {{/pix}}
+        <span class="sr-only">
+            {{#str}} ariadayfilter, block_timeline {{/str}}
+            <span data-active-item-text>{{#str}} next30days, block_timeline {{/str}}</span>
+        </span>
+    </button>
+    <div role="menu" class="dropdown-menu" data-show-active-item>
+        <a
+            class="dropdown-item"
+            href="#"
+            data-from="-14"
+            aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} all, core {{/str}}{{/str}}"
+        >
+            {{#str}} all, core {{/str}}
+        </a>
+        <a
+            class="dropdown-item"
+            href="#"
+            data-from="-14"
+            data-to="0"
+            aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} overdue, block_timeline {{/str}}{{/str}}"
+        >
+            {{#str}} overdue, block_timeline {{/str}}
+        </a>
+        <div class="dropdown-divider"></div>
+        <h6 class="dropdown-header">{{#str}} duedate, block_timeline {{/str}}</h6>
+        <a
+            class="dropdown-item"
+            href="#"
+            data-from="0"
+            data-to="7"
+            aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} next7days, block_timeline {{/str}}{{/str}}"
+        >
+            {{#str}} next7days, block_timeline {{/str}}
+        </a>
+        <a
+            class="dropdown-item active"
+            href="#"
+            data-from="0"
+            data-to="30"
+            aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} next30days, block_timeline {{/str}}{{/str}}"
+        >
+            {{#str}} next30days, block_timeline {{/str}}
+        </a>
+        <a
+            class="dropdown-item"
+            href="#"
+            data-from="0"
+            data-to="90"
+            aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} next3months, block_timeline {{/str}}{{/str}}"
+        >
+            {{#str}} next3months, block_timeline {{/str}}
+        </a>
+        <a
+            class="dropdown-item"
+            href="#"
+            data-from="0"
+            data-to="180"
+            aria-label="{{#str}} ariadayfilteroption, block_timeline, {{#str}} next6months, block_timeline {{/str}}{{/str}}"
+        >
+            {{#str}} next6months, block_timeline {{/str}}
+        </a>
+    </div>
+</div>
diff --git a/blocks/timeline/templates/nav-view-selector.mustache b/blocks/timeline/templates/nav-view-selector.mustache
new file mode 100644 (file)
index 0000000..50c2add
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_timeline/nav-view-selector
+
+    This template renders the timeline sort selector.
+
+    Example context (json):
+    {}
+}}
+<div data-region="view-selector" class="btn-group">
+    <button type="button" class="btn btn-outline-secondary dropdown-toggle icon-no-margin" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
+        {{#pix}} i/filter {{/pix}}
+        <span class="sr-only">
+            {{#str}} ariaviewselector, block_timeline{{/str}}
+            <span data-active-item-text>{{#str}} sortbydates, block_timeline {{/str}}</span>
+        </span>
+    </button>
+    <div role="menu" class="dropdown-menu dropdown-menu-right list-group hidden" data-show-active-item data-skip-active-class="true">
+        <a
+            class="dropdown-item active"
+            href="#view_dates_{{uniqid}}"
+            data-toggle="tab"
+            aria-label="{{#str}} ariaviewselectoroption, block_timeline, {{#str}} sortbydates, block_timeline {{/str}}{{/str}}"
+        >
+            {{#str}} sortbydates, block_timeline {{/str}}
+        </a>
+        <a
+            class="dropdown-item"
+            href="#view_courses_{{uniqid}}"
+            data-toggle="tab"
+            aria-label="{{#str}} ariaviewselectoroption, block_timeline, {{#str}} sortbycourses, block_timeline {{/str}}{{/str}}"
+        >
+            {{#str}} sortbycourses, block_timeline {{/str}}
+        </a>
+    </div>
+</div>
diff --git a/blocks/timeline/templates/placeholder-event-list-item.mustache b/blocks/timeline/templates/placeholder-event-list-item.mustache
new file mode 100644 (file)
index 0000000..ad01fc0
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_timeline/event-list-item
+
+    This template renders an event list item loading placeholder for the timeline block.
+
+    Example context (json):
+    {}
+}}
+<li class="list-group-item pl-0 pr-0">
+    <div class="row">
+        <div class="col-8 pr-0">
+            <div class="d-flex flex-row align-items-center" style="height: 32px">
+                <div class="bg-pulse-grey rounded-circle" style="height: 32px; width: 32px;"></div>
+                <div style="flex: 1" class="pl-2">
+                    <div class="bg-pulse-grey w-100" style="height: 15px;"></div>
+                    <div class="bg-pulse-grey w-75 mt-1" style="height: 10px;"></div>
+                </div>
+            </div>
+        </div>
+        <div class="col-4 pr-3">
+            <div class="d-flex flex-row justify-content-end" style="height: 32px; padding-top: 2px">
+                <div class="bg-pulse-grey w-75" style="height: 15px;"></div>
+            </div>
+        </div>
+    </div>
+</li>
diff --git a/blocks/timeline/templates/view-courses.mustache b/blocks/timeline/templates/view-courses.mustache
new file mode 100644 (file)
index 0000000..d4d1799
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_timeline/view-courses
+
+    This template renders the timeline view by courses for the timeline block.
+
+    Example context (json):
+    {}
+}}
+<div data-region="course-items-loading-placeholder">
+    <ul class="list-group unstyled">
+        {{> block_timeline/course-item-loading-placeholder }}
+        {{> block_timeline/course-item-loading-placeholder }}
+    </ul>
+    <div class="bg-pulse-grey m-t-1" style="width: 100px; height: 30px; margin-left: auto; margin-right: auto"></div>
+</div>
+<ul class="list-group unstyled" data-region="courses-list"></ul>
+<div class="hidden text-xs-center text-center pt-3" data-region="more-courses-button-container">
+    <button type="button" class="btn btn-secondary" data-action="more-courses">
+        {{#str}} morecourses, block_timeline {{/str}}
+        <span class="hidden" data-region="loading-icon-container">
+            {{> core/loading }}
+        </span>
+    </button>
+</div>
+<div class="hidden text-xs-center text-center mt-3" data-region="no-courses-empty-message">
+    <img
+        src="{{urls.noevents}}"
+        alt="{{#str}} nocoursesinprogress, block_timeline {{/str}}"
+        role="presentation"
+        style="height: 70px; width: 70px"
+    >
+    <p class="text-muted m-t-1">{{#str}} nocoursesinprogress, block_timeline {{/str}}</p>
+</div>
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @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):
     {}
 }}
-<div data-region="timeline-view-dates" id="timeline-view-dates-{{uniqid}}">
-    {{< block_myoverview/event-list }}
-        {{$limit}}20{{/limit}}
-    {{/ block_myoverview/event-list }}
+<div data-region="timeline-view-dates">
+    {{> block_timeline/event-list }}
 </div>
-{{#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 (file)
index 0000000..73decee
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template block_timeline/view
+
+    This template renders the timeline view for the timeline block.
+
+    Example context (json):
+    {}
+}}
+<div data-region="timeline-view">
+    <div class="tab-content">
+        <div class="tab-pane active fade show" data-region="view-dates" id="view_dates_{{uniqid}}">
+            {{> block_timeline/view-dates }}
+        </div>
+        <div
+            class="tab-pane fade"
+            data-region="view-courses"
+            data-midnight="{{midnight}}"
+            data-limit="2"
+            data-offset="0"
+            data-days-limit="30"
+            data-days-offset="0"
+            data-no-events-url="{{urls.noevents}}"
+            id="view_courses_{{uniqid}}"
+        >
+            {{> block_timeline/view-courses }}
+        </div>
+    </div>
+</div>
\ 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 (file)
index 0000000..f937684
--- /dev/null
@@ -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 (file)
index 0000000..6aaf269
--- /dev/null
@@ -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"
similarity index 55%
rename from blocks/myoverview/settings.php
rename to blocks/timeline/version.php
index 10f084d..64de7f3 100644 (file)
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Settings for the overview block.
+ * Version details for the timeline block.
  *
- * @package    block_myoverview
- * @copyright  2017 Mark Nelson <markn@moodle.com>
+ * @package    block_timeline
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
  * @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).
index 7ef8f0a..1234de4 100644 (file)
@@ -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 (file)
index 0000000..59fa590
Binary files /dev/null and b/course/amd/build/repository.min.js differ
diff --git a/course/amd/src/repository.js b/course/amd/src/repository.js
new file mode 100644 (file)
index 0000000..a0c7a40
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * A javascript module to handle course ajax actions.
+ *
+ * @module     core_course/repository
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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
+    };
+});
index 25621da..b7cabe7 100644 (file)
@@ -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 @@ class core_course_external extends external_api {
     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')
+            )
+        );
+    }
 }
index 1e0322f..c255e55 100644 (file)
@@ -58,6 +58,7 @@ define('MOD_CLASS_RESOURCE', 1);
 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.
index 463c824..297e54b 100644 (file)
@@ -4227,4 +4227,494 @@ class core_course_courselib_testcase extends advanced_testcase {
         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);
+    }
 }
index 19cb9b2..c476088 100644 (file)
@@ -2341,4 +2341,218 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $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']);
+    }
 }
index 1680fc1..eb72e18 100644 (file)
@@ -1471,6 +1471,10 @@ $string['outline'] = 'Outline';
 $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 (file)
index 0000000..838ab91
Binary files /dev/null and b/lib/amd/build/page_global.min.js differ
diff --git a/lib/amd/build/paged_content.min.js b/lib/amd/build/paged_content.min.js
new file mode 100644 (file)
index 0000000..860f755
Binary files /dev/null and b/lib/amd/build/paged_content.min.js differ
index 27e1fa8..7cd9b32 100644 (file)
Binary files a/lib/amd/build/paged_content_events.min.js and b/lib/amd/build/paged_content_events.min.js differ
index c0fd46e..62c3166 100644 (file)
Binary files a/lib/amd/build/paged_content_factory.min.js and b/lib/amd/build/paged_content_factory.min.js differ
index b59f52d..ae2015f 100644 (file)
Binary files a/lib/amd/build/paged_content_pages.min.js and b/lib/amd/build/paged_content_pages.min.js differ
index fcf9759..0c5dc70 100644 (file)
Binary files a/lib/amd/build/paged_content_paging_bar.min.js and b/lib/amd/build/paged_content_paging_bar.min.js differ
diff --git a/lib/amd/build/paged_content_paging_bar_limit_selector.min.js b/lib/amd/build/paged_content_paging_bar_limit_selector.min.js
new file mode 100644 (file)
index 0000000..77df6ca
Binary files /dev/null and b/lib/amd/build/paged_content_paging_bar_limit_selector.min.js differ
index 381853b..de7ac54 100644 (file)
Binary files a/lib/amd/build/paged_content_paging_dropdown.min.js and b/lib/amd/build/paged_content_paging_dropdown.min.js differ
diff --git a/lib/amd/build/pubsub.min.js b/lib/amd/build/pubsub.min.js
new file mode 100644 (file)
index 0000000..871bb59
Binary files /dev/null and b/lib/amd/build/pubsub.min.js differ
index 61a07a3..43482c2 100644 (file)
Binary files a/lib/amd/build/user_date.min.js and b/lib/amd/build/user_date.min.js differ
diff --git a/lib/amd/src/page_global.js b/lib/amd/src/page_global.js
new file mode 100644 (file)
index 0000000..88360b8
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Provide global helper code to enhance page elements.
+ *
+ * @module     core/page_global
+ * @package    core
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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 (file)
index 0000000..9067ca5
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Javascript to load and render a paged content section.
+ *
+ * @module     core/paged_content
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @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"]'
+    };
+});
index 1181cb1..b7a0b1e 100644 (file)
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * 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 <ryan@moodle.com>
  * @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'
     };
 });
index 709dd8e..696eeeb 100644 (file)
@@ -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,