AutorÃa | Ultima modificación | Ver Log |
// 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.
*
* @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/templates',
'block_timeline/event_list',
'core_course/repository',
'block_timeline/calendar_events_repository',
'core/pending'
],
function(
$,
Notification,
CustomEvents,
Templates,
EventList,
CourseRepository,
EventsRepository,
Pending
) {
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"]',
NO_COURSES_WITH_EVENTS_MESSAGE: '[data-region="no-events-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',
TIMELINE_BLOCK: '[data-region="timeline"]',
TIMELINE_SEARCH: '[data-action="search"]'
};
var TEMPLATES = {
COURSE_ITEMS: 'block_timeline/course-items',
LOADING_ICON: 'core/loading'
};
var COURSE_CLASSIFICATION = 'all';
var COURSE_SORT = 'fullname asc';
var COURSE_EVENT_LIMIT = 5;
var COURSE_LIMIT = 2;
var SECONDS_IN_DAY = 60 * 60 * 24;
const additionalConfig = {courseview: true};
/**
* Hide the loading placeholder elements.
*
* @param {object} root The rool element.
*/
var hideLoadingPlaceholder = function(root) {
root.find(SELECTORS.COURSE_ITEMS_LOADING_PLACEHOLDER).addClass('hidden');
};
/**
* Show the loading placeholder elements.
*
* @param {object} root The rool element.
*/
const showLoadingPlaceholder = function(root) {
root.find(SELECTORS.COURSE_ITEMS_LOADING_PLACEHOLDER).removeClass('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 courses have no events available (within the current filtering).
*
* @param {object} root The rool element.
*/
const showNoCoursesWithEventsMessage = function(root) {
// Remove any course list contents, since we will display the no events message.
const container = root.find(SELECTORS.COURSES_LIST);
Templates.replaceNodeContents(container, '', '');
root.find(SELECTORS.NO_COURSES_WITH_EVENTS_MESSAGE).removeClass('hidden');
};
/**
* Hide the message for when courses have no events available (within the current filtering).
*
* @param {object} root The rool element.
*/
const hideNoCoursesWithEventsMessage = function(root) {
root.find(SELECTORS.NO_COURSES_WITH_EVENTS_MESSAGE).addClass('hidden');
};
/**
* Render the course items HTML to the page.
*
* @param {object} root The rool element.
* @param {string} html The course items HTML to render.
* @param {boolean} append Whether the HTML should be appended (eg pressed "show more courses").
* Defaults to false - replaces the existing content (eg when modifying filter values).
*/
var renderCourseItemsHTML = function(root, html, append = false) {
var container = root.find(SELECTORS.COURSES_LIST);
if (append) {
Templates.appendNodeContents(container, html, '');
} else {
Templates.replaceNodeContents(container, html, '');
}
};
/**
* 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, unless filtering by overdue, where the current UNIX timestamp is used.
*
* @param {object} root The rool element.
* @return {Number}
*/
var getEndTime = function(root) {
let endTime = null;
if (root.attr('data-filter-overdue')) {
// If filtering by overdue, end time will be the current timestamp in seconds.
endTime = Math.floor(Date.now() / 1000);
} else {
const midnight = getMidnight(root);
const daysLimit = getDaysLimit(root);
if (daysLimit != undefined) {
endTime = midnight + (daysLimit * SECONDS_IN_DAY);
}
}
return endTime;
};
/**
* 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.
* @param {string|undefined} searchValue Search value
* @return {object} jQuery promise.
*/
var getEventsForCourseIds = function(courseIds, startTime, limit, endTime, searchValue) {
var args = {
courseids: courseIds,
starttime: startTime,
limit: limit
};
if (endTime) {
args.endtime = endTime;
}
if (searchValue) {
args.searchvalue = searchValue;
}
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.
* @param {string|undefined} searchValue Search value
* @return {object} jQuery promise resolved with the events.
*/
var loadEventsForCourses = function(courses, startTime, endTime, searchValue) {
var courseIds = courses.map(function(course) {
return course.id;
});
return getEventsForCourseIds(courseIds, startTime, COURSE_EVENT_LIMIT + 1, endTime, searchValue);
};
/**
* 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 {boolean} append Whether new content should be appended instead of replaced (eg "show more courses").
* @return {object} jQuery promise resolved after rendering is complete.
*/
var updateDisplayFromCourses = function(courses, root, midnight, daysOffset, daysLimit, append) {
// 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,
courseview: true,
hascourses: true
}).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, append);
}
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.
* @param {boolean} append Whether content should be appended instead of replaced (eg "show more courses"). False by default.
* @return {object} jQuery promise resolved with courses and events.
*/
var loadMoreCourses = function(root, append = false) {
const pendingPromise = new Pending('block/timeline:load-more-courses');
var offset = getOffset(root);
var limit = getLimit(root);
const startTime = getStartTime(root);
const endTime = getEndTime(root);
const searchValue = root.closest(SELECTORS.TIMELINE_BLOCK).find(SELECTORS.TIMELINE_SEARCH).val();
// Start loading the next set of courses.
// Fetch up to limit number of courses with at least one action event in the time filtering specified.
// Courses without events will also be fetched, but hidden in case they have events in other timespans.
return CourseRepository.getEnrolledCoursesWithEventsByTimelineClassification(
COURSE_CLASSIFICATION,
limit,
offset,
COURSE_SORT,
searchValue,
startTime,
endTime
).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);
const moreCoursesAvailable = result.morecoursesavailable;
// 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, searchValue);
// Render the courses in the DOM.
var renderPromise = updateDisplayFromCourses(courses, root, midnight, daysOffset, daysLimit, append);
return $.when(eventsPromise, renderPromise)
.then(function(eventsByCourse) {
if (hasReloadedEventsSince(root, startEventLoadingTime)) {
// All of the events are being reloaded so ignore our results.
return eventsByCourse;
}
if (courses.length > 0) {
// Render the events in the correct course event list.
courses.forEach(function(course) {
const courseId = course.id;
const containerSelector = '[data-region="course-events-container"][data-course-id="' + courseId + '"]';
const courseEventsContainer = root.find(containerSelector);
const eventListRoot = courseEventsContainer.find(EventList.rootSelector);
EventList.init(eventListRoot, additionalConfig);
});
if (!moreCoursesAvailable) {
// If no more courses with events matching the current filtering exist, hide the more courses button.
hideMoreCoursesButton(root);
} else {
// If more courses exist with events matching the current filtering, show the more courses button.
showMoreCoursesButton(root);
}
} else {
// No more courses to load, hide the more courses button.
hideMoreCoursesButton(root);
// A zero offset means this was not loading "more courses", so we need to display the no results message.
if (offset == 0) {
showNoCoursesWithEventsMessage(root);
}
}
return eventsByCourse;
});
}).then(() => {
return pendingPromise.resolve();
}).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, true)
.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);
// Only need to handle course loading if the user is actively enrolled in a course.
if (!root.find(SELECTORS.NO_COURSES_EMPTY_MESSAGE).length) {
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) {
setOffset(root, 0);
showLoadingPlaceholder(root);
hideNoCoursesWithEventsMessage(root);
root.removeAttr('data-seen');
if (root.hasClass('active')) {
shown(root);
}
};
/**
* Begin loading the events unless we know there are no actively enrolled courses.
*
* @param {object} root The root element for the timeline courses view.
*/
var shown = function(root) {
if (!root.attr('data-seen') && !root.find(SELECTORS.NO_COURSES_EMPTY_MESSAGE).length) {
loadMoreCourses(root);
root.attr('data-seen', true);
}
};
return {
init: init,
reset: reset,
shown: shown
};
});