first commit

This commit is contained in:
CHIEFSOFT\ameye
2024-09-30 18:11:26 -04:00
commit e592ca6823
27270 changed files with 5002257 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
// 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/>.
/**
* Show an add block modal instead of doing it on a separate page.
*
* @module core/addblockmodal
* @deprecated since Moodle 4.2 - please use core_block/add_modal instead.
* @copyright 2016 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import CancelModal from './modal_cancel';
import Templates from 'core/templates';
import {getString} from 'core/str';
import Ajax from 'core/ajax';
const SELECTORS = {
ADD_BLOCK: '[data-key="addblock"]'
};
// Ensure we only add our listeners once.
let listenerEventsRegistered = false;
/**
* Register related event listeners.
*
* @method registerListenerEvents
* @param {String} pageType The type of the page
* @param {String} pageLayout The layout of the page
* @param {String|null} addBlockUrl The add block URL
* @param {String} subPage The subpage identifier
*/
const registerListenerEvents = (pageType, pageLayout, addBlockUrl, subPage) => {
document.addEventListener('click', e => {
const addBlock = e.target.closest(SELECTORS.ADD_BLOCK);
if (addBlock) {
e.preventDefault();
let addBlockModal = null;
let addBlockModalUrl = addBlockUrl ?? addBlock.dataset.url;
buildAddBlockModal()
.then(modal => {
addBlockModal = modal;
const modalBody = renderBlocks(addBlockModalUrl, pageType, pageLayout, subPage);
modal.setBody(modalBody);
modal.show();
return modalBody;
})
.catch(() => {
addBlockModal.destroy();
});
}
});
};
/**
* Method that creates the 'add block' modal.
*
* @method buildAddBlockModal
* @returns {Promise} The modal promise (modal's body will be rendered later).
*/
const buildAddBlockModal = () => {
return CancelModal.create({
title: getString('addblock')
});
};
/**
* Method that renders the list of available blocks.
*
* @method renderBlocks
* @param {String} addBlockUrl The add block URL
* @param {String} pageType The type of the page
* @param {String} pageLayout The layout of the page
* @param {String} subPage The subpage identifier
* @return {Promise}
*/
const renderBlocks = async(addBlockUrl, pageType, pageLayout, subPage) => {
// Fetch all addable blocks in the given page.
const blocks = await getAddableBlocks(pageType, pageLayout, subPage);
return Templates.render('core/add_block_body', {
blocks: blocks,
url: addBlockUrl
});
};
/**
* Method that fetches all addable blocks in a given page.
*
* @method getAddableBlocks
* @param {String} pageType The type of the page
* @param {String} pageLayout The layout of the page
* @param {String} subPage The subpage identifier
* @return {Promise}
*/
const getAddableBlocks = async(pageType, pageLayout, subPage) => {
const request = {
methodname: 'core_block_fetch_addable_blocks',
args: {
pagecontextid: M.cfg.contextid,
pagetype: pageType,
pagelayout: pageLayout,
subpage: subPage,
},
};
return Ajax.call([request])[0];
};
/**
* Set up the actions.
*
* @method init
* @param {String} pageType The type of the page
* @param {String} pageLayout The layout of the page
* @param {String|null} addBlockUrl The add block URL
* @param {String} subPage The subpage identifier
*/
export const init = (pageType, pageLayout, addBlockUrl = null, subPage = '') => {
if (!listenerEventsRegistered) {
registerListenerEvents(pageType, pageLayout, addBlockUrl, subPage);
listenerEventsRegistered = true;
}
};
+311
View File
@@ -0,0 +1,311 @@
// 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/>.
/**
* Standard Ajax wrapper for Moodle. It calls the central Ajax script,
* which can call any existing webservice using the current session.
* In addition, it can batch multiple requests and return multiple responses.
*
* @module core/ajax
* @copyright 2015 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 2.9
*/
define(['jquery', 'core/config', 'core/log', 'core/url'], function($, config, Log, URL) {
/**
* A request to be performed.
*
* @typedef {object} request
* @property {string} methodname The remote method to be called
* @property {object} args The arguments to pass when fetching the remote content
*/
// Keeps track of when the user leaves the page so we know not to show an error.
var unloading = false;
/**
* Success handler. Called when the ajax call succeeds. Checks each response and
* resolves or rejects the deferred from that request.
*
* @method requestSuccess
* @private
* @param {Object[]} responses Array of responses containing error, exception and data attributes.
*/
var requestSuccess = function(responses) {
// Call each of the success handlers.
var requests = this,
exception = null,
i = 0,
request,
response,
nosessionupdate;
if (responses.error) {
// There was an error with the request as a whole.
// We need to reject each promise.
// Unfortunately this may lead to duplicate dialogues, but each Promise must be rejected.
for (; i < requests.length; i++) {
request = requests[i];
request.deferred.reject(responses);
}
return;
}
for (i = 0; i < requests.length; i++) {
request = requests[i];
response = responses[i];
// We may not have responses for all the requests.
if (typeof response !== "undefined") {
if (response.error === false) {
// Call the done handler if it was provided.
request.deferred.resolve(response.data);
} else {
exception = response.exception;
nosessionupdate = requests[i].nosessionupdate;
break;
}
} else {
// This is not an expected case.
exception = new Error('missing response');
break;
}
}
// Something failed, reject the remaining promises.
if (exception !== null) {
// Redirect to the login page.
if (exception.errorcode === "servicerequireslogin" && !nosessionupdate) {
window.location = URL.relativeUrl("/login/index.php");
} else {
requests.forEach(function(request) {
request.deferred.reject(exception);
});
}
}
};
/**
* Fail handler. Called when the ajax call fails. Rejects all deferreds.
*
* @method requestFail
* @private
* @param {jqXHR} jqXHR The ajax object.
* @param {string} textStatus The status string.
* @param {Error|Object} exception The error thrown.
*/
var requestFail = function(jqXHR, textStatus, exception) {
// Reject all the promises.
var requests = this;
var i = 0;
for (i = 0; i < requests.length; i++) {
var request = requests[i];
if (unloading) {
// No need to trigger an error because we are already navigating.
Log.error("Page unloaded.");
Log.error(exception);
} else {
request.deferred.reject(exception);
}
}
};
return /** @alias module:core/ajax */ {
// Public variables and functions.
/**
* Make a series of ajax requests and return all the responses.
*
* @method call
* @param {request[]} requests Array of requests with each containing methodname and args properties.
* done and fail callbacks can be set for each element in the array, or the
* can be attached to the promises returned by this function.
* @param {Boolean} [async=true] If false this function will not return until the promises are resolved.
* @param {Boolean} [loginrequired=true] When false this function calls an endpoint which does not use the
* session.
* Note: This may only be used with external functions which have been marked as
* `'loginrequired' => false`
* @param {Boolean} [nosessionupdate=false] If true, the timemodified for the session will not be updated.
* @param {Number} [timeout] number of milliseconds to wait for a response. Defaults to no limit.
* @param {Number} [cachekey] A cache key used to improve browser-side caching.
* Typically the same `cachekey` is used for all function calls.
* When the key changes, this causes the URL used to perform the fetch to change, which
* prevents the existing browser cache from being used.
* Note: This option is only availbale when `loginrequired` is `false`.
* See {@link https://tracker.moodle.org/browser/MDL-65794} for more information.
* @return {Promise[]} The Promises for each of the supplied requests.
* The order of the Promise matches the order of requests exactly.
*
* @example <caption>A simple example that you might find in a repository module</caption>
*
* import {call as fetchMany} from 'core/ajax';
*
* export const fetchMessages = timeSince => fetchMany([{methodname: 'core_message_get_messages', args: {timeSince}}])[0];
*
* export const fetchNotifications = timeSince => fetchMany([{
* methodname: 'core_message_get_notifications',
* args: {
* timeSince,
* }
* }])[0];
*
* export const fetchSomethingElse = (some, params, here) => fetchMany([{
* methodname: 'core_get_something_else',
* args: {
* some,
* params,
* gohere: here,
* },
* }])[0];
*
* @example <caption>An example of fetching a string using the cachekey parameter</caption>
* import {call as fetchMany} from 'core/ajax';
* import * as Notification from 'core/notification';
*
* export const performAction = (some, args) => {
* Promises.all(fetchMany([{methodname: 'core_get_string', args: {
* stringid: 'do_not_copy',
* component: 'core',
* lang: 'en',
* stringparams: [],
* }}], true, false, false, undefined, M.cfg.langrev))
* .then(([doNotCopyString]) => {
* window.console.log(doNotCopyString);
* })
* .catch(Notification.exception);
* };
*
*/
call: function(requests, async, loginrequired, nosessionupdate, timeout, cachekey) {
$(window).bind('beforeunload', function() {
unloading = true;
});
var ajaxRequestData = [],
i,
promises = [],
methodInfo = [],
requestInfo = '';
var maxUrlLength = 2000;
if (typeof loginrequired === "undefined") {
loginrequired = true;
}
if (typeof async === "undefined") {
async = true;
}
if (typeof timeout === 'undefined') {
timeout = 0;
}
if (typeof cachekey === 'undefined') {
cachekey = null;
} else {
cachekey = parseInt(cachekey);
if (cachekey <= 0) {
cachekey = null;
} else if (!cachekey) {
cachekey = null;
}
}
if (typeof nosessionupdate === "undefined") {
nosessionupdate = false;
}
for (i = 0; i < requests.length; i++) {
var request = requests[i];
ajaxRequestData.push({
index: i,
methodname: request.methodname,
args: request.args
});
request.nosessionupdate = nosessionupdate;
request.deferred = $.Deferred();
promises.push(request.deferred.promise());
// Allow setting done and fail handlers as arguments.
// This is just a shortcut for the calling code.
if (typeof request.done !== "undefined") {
request.deferred.done(request.done);
}
if (typeof request.fail !== "undefined") {
request.deferred.fail(request.fail);
}
request.index = i;
methodInfo.push(request.methodname);
}
if (methodInfo.length <= 5) {
requestInfo = methodInfo.sort().join();
} else {
requestInfo = methodInfo.length + '-method-calls';
}
ajaxRequestData = JSON.stringify(ajaxRequestData);
var settings = {
type: 'POST',
context: requests,
dataType: 'json',
processData: false,
async: async,
contentType: "application/json",
timeout: timeout
};
var script = 'service.php';
var url = config.wwwroot + '/lib/ajax/';
if (!loginrequired) {
script = 'service-nologin.php';
url += script + '?info=' + requestInfo;
if (cachekey) {
url += '&cachekey=' + cachekey;
settings.type = 'GET';
}
} else {
url += script + '?sesskey=' + config.sesskey + '&info=' + requestInfo;
}
if (nosessionupdate) {
url += '&nosessionupdate=true';
}
if (settings.type === 'POST') {
settings.data = ajaxRequestData;
} else {
var urlUseGet = url + '&args=' + encodeURIComponent(ajaxRequestData);
if (urlUseGet.length > maxUrlLength) {
settings.type = 'POST';
settings.data = ajaxRequestData;
} else {
url = urlUseGet;
}
}
// Jquery deprecated done and fail with async=false so we need to do this 2 ways.
if (async) {
$.ajax(url, settings)
.done(requestSuccess)
.fail(requestFail);
} else {
settings.success = requestSuccess;
settings.error = requestFail;
$.ajax(url, settings);
}
return promises;
}
};
});
+29
View File
@@ -0,0 +1,29 @@
// 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/>.
/**
* Helpers to perform ARIA compliance changes to the DOM.
*
* @module core/aria
* @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export {
hide,
unhide,
hideSiblings,
unhideSiblings,
} from './local/aria/aria-hidden';
+112
View File
@@ -0,0 +1,112 @@
// 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/>.
/**
* Enhance a textarea with auto growing rows to fit the content.
*
* @module core/auto_rows
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.2
*/
define(['jquery'], function($) {
var SELECTORS = {
ELEMENT: '[data-auto-rows]'
};
var EVENTS = {
ROW_CHANGE: 'autorows:rowchange',
};
/**
* Determine how many rows should be set for the given element.
*
* @method calculateRows
* @param {jQuery} element The textarea element
* @return {int} The number of rows for the element
* @private
*/
var calculateRows = function(element) {
var currentRows = element.attr('rows');
var minRows = element.data('min-rows');
var maxRows = element.attr('data-max-rows');
var height = element.height();
var innerHeight = element.innerHeight();
var padding = innerHeight - height;
var scrollHeight = element[0].scrollHeight;
var rows = (scrollHeight - padding) / (height / currentRows);
// Remove the height styling to let the height be calculated automatically
// based on the row attribute.
element.css('height', '');
if (rows < minRows) {
return minRows;
} else if (maxRows && rows >= maxRows) {
return maxRows;
} else {
return rows;
}
};
/**
* Listener for change events to trigger resizing of the element.
*
* @method changeListener
* @param {Event} e The triggered event.
* @private
*/
var changeListener = function(e) {
var element = $(e.target);
var minRows = element.data('min-rows');
var currentRows = element.attr('rows');
if (typeof minRows === "undefined") {
element.data('min-rows', currentRows);
}
// Reset element to single row so that the scroll height of the
// element is correctly calculated each time.
element.attr('rows', 1);
var rows = calculateRows(element);
element.attr('rows', rows);
if (rows != currentRows) {
element.trigger(EVENTS.ROW_CHANGE);
}
};
/**
* Add the event listeners for all text areas within the given element.
*
* @method init
* @param {jQuery|selector} root The container element of all enhanced text areas
* @public
*/
var init = function(root) {
if ($(root).data('auto-rows')) {
$(root).on('input propertychange', changeListener.bind(this));
} else {
$(root).on('input propertychange', SELECTORS.ELEMENT, changeListener.bind(this));
}
};
return /** @module core/auto_rows */ {
init: init,
events: EVENTS,
};
});
+207
View File
@@ -0,0 +1,207 @@
// 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 provide automatic scrolling, e.g. during a drag operation.
*
* Note: this module is defined statically. It is a singleton. You
* can only have one use of it active at any time. However, since this
* is usually used in relation to drag-drop, and since you only ever
* drag one thing at a time, this is not a problem in practice.
*
* @module core/autoscroll
* @copyright 2016 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.6
*/
define(['jquery'], function($) {
/**
* @alias module:core/autoscroll
*/
var autoscroll = {
/**
* Size of area near edge of screen that triggers scrolling.
* @private
*/
SCROLL_THRESHOLD: 30,
/**
* How frequently to scroll window.
* @private
*/
SCROLL_FREQUENCY: 1000 / 60,
/**
* How many pixels to scroll per unit (1 = max scroll 30).
* @private
*/
SCROLL_SPEED: 0.5,
/**
* Set if currently scrolling up/down.
* @private
*/
scrollingId: null,
/**
* Speed we are supposed to scroll (range 1 to SCROLL_THRESHOLD).
* @private
*/
scrollAmount: 0,
/**
* Optional callback called when it scrolls
* @private
*/
callback: null,
/**
* Starts automatically scrolling if user moves near edge of window.
* This should be called in response to mouse down or touch start.
*
* @public
* @param {Function} callback Optional callback that is called every time it scrolls
*/
start: function(callback) {
$(window).on('mousemove', autoscroll.mouseMove);
$(window).on('touchmove', autoscroll.touchMove);
autoscroll.callback = callback;
},
/**
* Stops automatically scrolling. This should be called in response to mouse up or touch end.
*
* @public
*/
stop: function() {
$(window).off('mousemove', autoscroll.mouseMove);
$(window).off('touchmove', autoscroll.touchMove);
if (autoscroll.scrollingId !== null) {
autoscroll.stopScrolling();
}
},
/**
* Event handler for touch move.
*
* @private
* @param {Object} e Event
*/
touchMove: function(e) {
for (var i = 0; i < e.changedTouches.length; i++) {
autoscroll.handleMove(e.changedTouches[i].clientX, e.changedTouches[i].clientY);
}
},
/**
* Event handler for mouse move.
*
* @private
* @param {Object} e Event
*/
mouseMove: function(e) {
autoscroll.handleMove(e.clientX, e.clientY);
},
/**
* Handles user moving.
*
* @private
* @param {number} clientX X
* @param {number} clientY Y
*/
handleMove: function(clientX, clientY) {
// If near the bottom or top, start auto-scrolling.
if (clientY < autoscroll.SCROLL_THRESHOLD) {
autoscroll.scrollAmount = -Math.min(autoscroll.SCROLL_THRESHOLD - clientY, autoscroll.SCROLL_THRESHOLD);
} else if (clientY > $(window).height() - autoscroll.SCROLL_THRESHOLD) {
autoscroll.scrollAmount = Math.min(clientY - ($(window).height() - autoscroll.SCROLL_THRESHOLD),
autoscroll.SCROLL_THRESHOLD);
} else {
autoscroll.scrollAmount = 0;
}
if (autoscroll.scrollAmount && autoscroll.scrollingId === null) {
autoscroll.startScrolling();
} else if (!autoscroll.scrollAmount && autoscroll.scrollingId !== null) {
autoscroll.stopScrolling();
}
},
/**
* Starts automatic scrolling.
*
* @private
*/
startScrolling: function() {
var maxScroll = $(document).height() - $(window).height();
autoscroll.scrollingId = window.setInterval(function() {
// Work out how much to scroll.
var y = $(window).scrollTop();
var offset = Math.round(autoscroll.scrollAmount * autoscroll.SCROLL_SPEED);
if (y + offset < 0) {
offset = -y;
}
if (y + offset > maxScroll) {
offset = maxScroll - y;
}
if (offset === 0) {
return;
}
// Scroll.
$(window).scrollTop(y + offset);
var realOffset = $(window).scrollTop() - y;
if (realOffset === 0) {
return;
}
// Inform callback
if (autoscroll.callback) {
autoscroll.callback(realOffset);
}
}, autoscroll.SCROLL_FREQUENCY);
},
/**
* Stops the automatic scrolling.
*
* @private
*/
stopScrolling: function() {
window.clearInterval(autoscroll.scrollingId);
autoscroll.scrollingId = null;
}
};
return {
/**
* Starts automatic scrolling if user moves near edge of window.
* This should be called in response to mouse down or touch start.
*
* @public
* @param {Function} callback Optional callback that is called every time it scrolls
*/
start: autoscroll.start,
/**
* Stops automatic scrolling. This should be called in response to mouse up or touch end.
*
* @public
*/
stop: autoscroll.stop
};
});
+169
View File
@@ -0,0 +1,169 @@
// 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 timer that will execute a callback with decreasing frequency. Useful for
* doing polling on the server without overwhelming it with requests.
*
* @module core/backoff_timer
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define(function() {
/**
* Constructor for the back off timer.
*
* @class
* @param {function} callback The function to execute after each tick
* @param {function} backoffFunction The function to determine what the next timeout value should be
*/
var BackoffTimer = function(callback, backoffFunction) {
this.callback = callback;
this.backOffFunction = backoffFunction;
};
/**
* @property {function} callback The function to execute after each tick
*/
BackoffTimer.prototype.callback = null;
/**
* @property {function} backoffFunction The function to determine what the next timeout value should be
*/
BackoffTimer.prototype.backOffFunction = null;
/**
* @property {int} time The timeout value to use
*/
BackoffTimer.prototype.time = null;
/**
* @property {numeric} timeout The timeout identifier
*/
BackoffTimer.prototype.timeout = null;
/**
* Generate the next timeout in the back off time sequence
* for the timer.
*
* The back off function is called to calculate the next value.
* It is given the current value and an array of all previous values.
*
* @return {int} The new timeout value (in milliseconds)
*/
BackoffTimer.prototype.generateNextTime = function() {
var newTime = this.backOffFunction(this.time);
this.time = newTime;
return newTime;
};
/**
* Stop the current timer and clear the previous time values
*
* @return {object} this
*/
BackoffTimer.prototype.reset = function() {
this.time = null;
this.stop();
return this;
};
/**
* Clear the current timeout, if one is set.
*
* @return {object} this
*/
BackoffTimer.prototype.stop = function() {
if (this.timeout) {
window.clearTimeout(this.timeout);
this.timeout = null;
}
return this;
};
/**
* Start the current timer by generating the new timeout value and
* starting the ticks.
*
* This function recurses after each tick with a new timeout value
* generated each time.
*
* The callback function is called after each tick.
*
* @return {object} this
*/
BackoffTimer.prototype.start = function() {
// If we haven't already started.
if (!this.timeout) {
var time = this.generateNextTime();
this.timeout = window.setTimeout(function() {
this.callback();
// Clear the existing timer.
this.stop();
// Start the next timer.
this.start();
}.bind(this), time);
}
return this;
};
/**
* Reset the timer and start it again from the initial timeout
* values
*
* @return {object} this
*/
BackoffTimer.prototype.restart = function() {
return this.reset().start();
};
/**
* Returns an incremental function for the timer.
*
* @param {int} minamount The minimum amount of time we wait before checking
* @param {int} incrementamount The amount to increment the timer by
* @param {int} maxamount The max amount to ever increment to
* @param {int} timeoutamount The timeout to use once we reach the max amount
* @return {function}
*/
BackoffTimer.getIncrementalCallback = function(minamount, incrementamount, maxamount, timeoutamount) {
/**
* An incremental function for the timer.
*
* @param {(int|null)} time The current timeout value or null if none set
* @return {int} The new timeout value
*/
return function(time) {
if (!time) {
return minamount;
}
// Don't go over the max amount.
if (time + incrementamount > maxamount) {
return timeoutamount;
}
return time + incrementamount;
};
};
return BackoffTimer;
});
+85
View File
@@ -0,0 +1,85 @@
// 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/>.
/**
* Base class for defining a bulk action.
*
* @module core/bulkactions/bulk_action
* @copyright 2023 Mihail Geshoski <mihail@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default class BulkAction {
/** @property {array} selectedItems The array of selected item elements. */
selectedItems = [];
/**
* Registers the listener events for the bulk actions.
*
* @method registerListenerEvents
* @param {HTMLElement} containerElement The container element for the bulk actions.
* @returns {void}
*/
registerListenerEvents(containerElement) {
// Listen for the click event on the bulk action trigger element.
containerElement.addEventListener('click', (e) => {
if (e.target.closest(this.getBulkActionTriggerSelector())) {
e.preventDefault();
this.triggerBulkAction();
}
});
}
/**
* Setter method for the selectedItems property.
*
* @method setSelectedItems
* @param {Array} selectedItems The array of selected item elements..
* @returns {void}
*/
setSelectedItems(selectedItems) {
this.selectedItems = selectedItems;
}
/**
* Defines the selector of the element that triggers the bulk action.
*
* @method getBulkActionTriggerSelector
* @returns {string}
*/
getBulkActionTriggerSelector() {
throw new Error(`getBulkActionTriggerSelector() must be implemented in ${this.constructor.name}`);
}
/**
* Defines the behavior once the bulk action is triggered.
*
* @method triggerBulkAction
*/
triggerBulkAction() {
throw new Error(`triggerBulkAction() must be implemented in ${this.constructor.name}`);
}
/**
* Renders the bulk action trigger element.
*
* @method renderBulkActionTrigger
* @returns {Promise}
*/
renderBulkActionTrigger() {
throw new Error(`renderBulkActionTrigger() must be implemented in ${this.constructor.name}`);
}
}
+196
View File
@@ -0,0 +1,196 @@
// 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/>.
import Templates from 'core/templates';
import {get_string as getString} from 'core/str';
import {disableStickyFooter, enableStickyFooter} from 'core/sticky-footer';
/**
* Base class for defining a bulk actions area within a page.
*
* @module core/bulkactions/bulk_actions
* @copyright 2023 Mihail Geshoski <mihail@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/** @constant {Object} The object containing the relevant selectors. */
const Selectors = {
stickyFooterContainer: '#sticky-footer',
selectedItemsCountContainer: '[data-type="bulkactions"] [data-for="bulkcount"]',
cancelBulkActionModeElement: '[data-type="bulkactions"] [data-action="bulkcancel"]',
bulkModeContainer: '[data-type="bulkactions"]',
bulkActionsContainer: '[data-type="bulkactions"] [data-for="bulktools"]'
};
export default class BulkActions {
/** @property {string|null} initialStickyFooterContent The initial content of the sticky footer. */
initialStickyFooterContent = null;
/** @property {Array} selectedItems The array of selected item elements. */
selectedItems = [];
/** @property {boolean} isBulkActionsModeEnabled Whether the bulk actions mode is enabled. */
isBulkActionsModeEnabled = false;
/**
* The class constructor.
*
* @returns {void}
*/
constructor() {
if (!this.getStickyFooterContainer()) {
throw new Error('Sticky footer not found.');
}
// Store any pre-existing content in the sticky footer. When bulk actions mode is enabled, this content will be
// replaced with the bulk actions content and restored when bulk actions mode is disabled.
this.initialStickyFooterContent = this.getStickyFooterContainer().innerHTML;
// Register and handle the item select change event.
this.registerItemSelectChangeEvent(async() => {
this.selectedItems = this.getSelectedItems();
if (this.selectedItems.length > 0) { // At least one item is selected.
// If the bulk actions mode is already enabled only update the selected items count.
if (this.isBulkActionsModeEnabled) {
await this.updateBulkItemSelection();
} else { // Otherwise, enable the bulk action mode.
await this.enableBulkActionsMode();
}
} else { // No items are selected, disable the bulk action mode.
this.disableBulkActionsMode();
}
});
}
/**
* Returns the array of the relevant bulk action objects.
*
* @method getBulkActions
* @returns {Array}
*/
getBulkActions() {
throw new Error(`getBulkActions() must be implemented in ${this.constructor.name}`);
}
/**
* Returns the array of selected items.
*
* @method getSelectedItems
* @returns {Array}
*/
getSelectedItems() {
throw new Error(`getSelectedItems() must be implemented in ${this.constructor.name}`);
}
/**
* Adds the listener for the item select change event.
* The event handler function that is passed as a parameter should be called right after the event is triggered.
*
* @method registerItemSelectChangeEvent
* @param {function} eventHandler The event handler function.
* @returns {void}
*/
registerItemSelectChangeEvent(eventHandler) {
throw new Error(`registerItemSelectChangeEvent(${eventHandler}) must be implemented in ${this.constructor.name}`);
}
/**
* Returns the sticky footer container.
*
* @method getStickyFooterContainer
* @returns {HTMLElement}
*/
getStickyFooterContainer() {
return document.querySelector(Selectors.stickyFooterContainer);
}
/**
* Enables the bulk action mode.
*
* @method enableBulkActionsMode
* @returns {Promise}
*/
async enableBulkActionsMode() {
// Make sure that the sticky footer is enabled.
enableStickyFooter();
// Render the bulk actions content in the sticky footer container.
this.getStickyFooterContainer().innerHTML = await this.renderBulkActions();
const bulkModeContainer = this.getStickyFooterContainer().querySelector(Selectors.bulkModeContainer);
const bulkActionsContainer = bulkModeContainer.querySelector(Selectors.bulkActionsContainer);
this.getBulkActions().forEach((bulkAction) => {
// Register the listener events for each available bulk action.
bulkAction.registerListenerEvents(bulkActionsContainer);
// Set the selected items for each available bulk action.
bulkAction.setSelectedItems(this.selectedItems);
});
// Register the click listener event for the cancel bulk mode button.
bulkModeContainer.addEventListener('click', (e) => {
if (e.target.closest(Selectors.cancelBulkActionModeElement)) {
// Uncheck all selected items.
this.selectedItems.forEach((item) => {
item.checked = false;
});
// Disable the bulk action mode.
this.disableBulkActionsMode();
}
});
this.isBulkActionsModeEnabled = true;
}
/**
* Disables the bulk action mode.
*
* @method disableBulkActionsMode
* @returns {void}
*/
disableBulkActionsMode() {
// If there was any previous (initial) content in the sticky footer, restore it.
if (this.initialStickyFooterContent.length > 0) {
this.getStickyFooterContainer().innerHTML = this.initialStickyFooterContent;
} else { // No previous content to restore, disable the sticky footer.
disableStickyFooter();
}
this.isBulkActionsModeEnabled = false;
}
/**
* Renders the bulk actions content.
*
* @method renderBulkActions
* @returns {Promise}
*/
async renderBulkActions() {
let data = {
'bulkselectioncount': this.selectedItems.length,
'actions': []
};
// Render the bulk actions trigger element for each available bulk action.
await Promise.all(this.getBulkActions().map(async(bulkAction) => {
data.actions.push({'actiontrigger': await bulkAction.renderBulkActionTrigger()});
}));
return Templates.render('core/bulkactions/bulk_actions', data);
}
/**
* Updates the selected items count in the bulk actions content.
*
* @method updateBulkItemSelection
* @returns {void}
*/
async updateBulkItemSelection() {
const bulkSelection = await getString('bulkselection', 'core', this.selectedItems.length);
document.querySelector(Selectors.selectedItemsCountContainer).innerHTML = bulkSelection;
}
}
+297
View File
@@ -0,0 +1,297 @@
// 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/>.
/**
* Chart axis.
*
* @module core/chart_axis
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define([], function() {
/**
* Chart axis class.
*
* This is used to represent an axis, whether X or Y.
*
* @class core/chart_axis
*/
function Axis() {
// Please eslint no-empty-function.
}
/**
* Default axis position.
* @const {Null}
*/
Axis.prototype.POS_DEFAULT = null;
/**
* Bottom axis position.
* @const {String}
*/
Axis.prototype.POS_BOTTOM = 'bottom';
/**
* Left axis position.
* @const {String}
*/
Axis.prototype.POS_LEFT = 'left';
/**
* Right axis position.
* @const {String}
*/
Axis.prototype.POS_RIGHT = 'right';
/**
* Top axis position.
* @const {String}
*/
Axis.prototype.POS_TOP = 'top';
/**
* Label of the axis.
* @type {String}
* @protected
*/
Axis.prototype._label = null;
/**
* Labels of the ticks.
* @type {String[]}
* @protected
*/
Axis.prototype._labels = null;
/**
* Maximum value of the axis.
* @type {Number}
* @protected
*/
Axis.prototype._max = null;
/**
* Minimum value of the axis.
* @type {Number}
* @protected
*/
Axis.prototype._min = null;
/**
* Position of the axis.
* @type {String}
* @protected
*/
Axis.prototype._position = null;
/**
* Steps on the axis.
* @type {Number}
* @protected
*/
Axis.prototype._stepSize = null;
/**
* Create a new instance of an axis from serialised data.
*
* @static
* @method create
* @param {Object} obj The data of the axis.
* @return {module:core/chart_axis}
*/
Axis.prototype.create = function(obj) {
var s = new Axis();
s.setPosition(obj.position);
s.setLabel(obj.label);
s.setStepSize(obj.stepSize);
s.setMax(obj.max);
s.setMin(obj.min);
s.setLabels(obj.labels);
return s;
};
/**
* Get the label of the axis.
*
* @method getLabel
* @return {String}
*/
Axis.prototype.getLabel = function() {
return this._label;
};
/**
* Get the labels of the ticks of the axis.
*
* @method getLabels
* @return {String[]}
*/
Axis.prototype.getLabels = function() {
return this._labels;
};
/**
* Get the maximum value of the axis.
*
* @method getMax
* @return {Number}
*/
Axis.prototype.getMax = function() {
return this._max;
};
/**
* Get the minimum value of the axis.
*
* @method getMin
* @return {Number}
*/
Axis.prototype.getMin = function() {
return this._min;
};
/**
* Get the position of the axis.
*
* @method getPosition
* @return {String}
*/
Axis.prototype.getPosition = function() {
return this._position;
};
/**
* Get the step size of the axis.
*
* @method getStepSize
* @return {Number}
*/
Axis.prototype.getStepSize = function() {
return this._stepSize;
};
/**
* Set the label of the axis.
*
* @method setLabel
* @param {String} label The label.
*/
Axis.prototype.setLabel = function(label) {
this._label = label || null;
};
/**
* Set the labels of the values on the axis.
*
* This automatically sets the [_stepSize]{@link module:core/chart_axis#_stepSize},
* [_min]{@link module:core/chart_axis#_min} and [_max]{@link module:core/chart_axis#_max}
* to define a scale from 0 to the number of labels when none of the previously
* mentioned values have been modified.
*
* You can use other values so long that your values in a series are mapped
* to the values represented by your _min, _max and _stepSize.
*
* @method setLabels
* @param {String[]} labels The labels.
*/
Axis.prototype.setLabels = function(labels) {
this._labels = labels || null;
// By default we set the grid according to the labels.
if (this._labels !== null
&& this._stepSize === null
&& (this._min === null || this._min === 0)
&& this._max === null) {
this.setStepSize(1);
this.setMin(0);
this.setMax(labels.length - 1);
}
};
/**
* Set the maximum value on the axis.
*
* When this is not set (or set to null) it is left for the output
* library to best guess what should be used.
*
* @method setMax
* @param {Number} max The value.
*/
Axis.prototype.setMax = function(max) {
this._max = typeof max !== 'undefined' ? max : null;
};
/**
* Set the minimum value on the axis.
*
* When this is not set (or set to null) it is left for the output
* library to best guess what should be used.
*
* @method setMin
* @param {Number} min The value.
*/
Axis.prototype.setMin = function(min) {
this._min = typeof min !== 'undefined' ? min : null;
};
/**
* Set the position of the axis.
*
* This does not validate whether or not the constant used is valid
* as the axis itself is not aware whether it represents the X or Y axis.
*
* The output library has to have a fallback in case the values are incorrect.
* When this is not set to {@link module:core/chart_axis#POS_DEFAULT} it is up
* to the output library to choose what position fits best.
*
* @method setPosition
* @param {String} position The value.
*/
Axis.prototype.setPosition = function(position) {
if (position != this.POS_DEFAULT
&& position != this.POS_BOTTOM
&& position != this.POS_LEFT
&& position != this.POS_RIGHT
&& position != this.POS_TOP) {
throw new Error('Invalid axis position.');
}
this._position = position;
};
/**
* Set the stepSize on the axis.
*
* This is used to determine where ticks are displayed on the axis between min and max.
*
* @method setStepSize
* @param {Number} stepSize The value.
*/
Axis.prototype.setStepSize = function(stepSize) {
if (typeof stepSize === 'undefined' || stepSize === null) {
stepSize = null;
} else if (isNaN(Number(stepSize))) {
throw new Error('Value for stepSize is not a number.');
} else {
stepSize = Number(stepSize);
}
this._stepSize = stepSize;
};
return Axis;
});
+115
View File
@@ -0,0 +1,115 @@
// 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/>.
/**
* Chart bar.
*
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @module core/chart_bar
*/
define(['core/chart_base'], function(Base) {
/**
* Bar chart.
*
* @extends {module:core/chart_base}
* @class
*/
function Bar() {
Base.prototype.constructor.apply(this, arguments);
}
Bar.prototype = Object.create(Base.prototype);
/**
* Whether the bars should be displayed horizontally or not.
*
* @type {Bool}
* @protected
*/
Bar.prototype._horizontal = false;
/**
* Whether the bars should be stacked or not.
*
* @type {Bool}
* @protected
*/
Bar.prototype._stacked = false;
/** @override */
Bar.prototype.TYPE = 'bar';
/** @override */
Bar.prototype.create = function(Klass, data) {
var chart = Base.prototype.create.apply(this, arguments);
chart.setHorizontal(data.horizontal);
chart.setStacked(data.stacked);
return chart;
};
/** @override */
Bar.prototype._setDefaults = function() {
Base.prototype._setDefaults.apply(this, arguments);
var axis = this.getYAxis(0, true);
axis.setMin(0);
};
/**
* Get whether the bars should be displayed horizontally or not.
*
* @returns {Bool}
*/
Bar.prototype.getHorizontal = function() {
return this._horizontal;
};
/**
* Get whether the bars should be stacked or not.
*
* @returns {Bool}
*/
Bar.prototype.getStacked = function() {
return this._stacked;
};
/**
* Set whether the bars should be displayed horizontally or not.
*
* It sets the X Axis to zero if the min value is null.
*
* @param {Bool} horizontal True if the bars should be displayed horizontally, false otherwise.
*/
Bar.prototype.setHorizontal = function(horizontal) {
var axis = this.getXAxis(0, true);
if (axis.getMin() === null) {
axis.setMin(0);
}
this._horizontal = Boolean(horizontal);
};
/**
* Set whether the bars should be stacked or not.
*
* @method setStacked
* @param {Bool} stacked True if the chart should be stacked or false otherwise.
*/
Bar.prototype.setStacked = function(stacked) {
this._stacked = Boolean(stacked);
};
return Bar;
});
+417
View File
@@ -0,0 +1,417 @@
// 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/>.
/**
* Chart base.
*
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @module core/chart_base
*/
define(['core/chart_series', 'core/chart_axis'], function(Series, Axis) {
/**
* Chart base.
*
* The constructor of a chart must never take any argument.
*
* {@link module:core/chart_base#_setDefault} to set the defaults on instantiation.
*
* @class
*/
function Base() {
this._series = [];
this._labels = [];
this._xaxes = [];
this._yaxes = [];
this._setDefaults();
}
/**
* The series constituting this chart.
*
* @protected
* @type {module:core/chart_series[]}
*/
Base.prototype._series = null;
/**
* The labels of the X axis when categorised.
*
* @protected
* @type {String[]}
*/
Base.prototype._labels = null;
/**
* Options for chart legend display.
*
* @protected
* @type {Object}
*/
Base.prototype._legendOptions = null;
/**
* The title of the chart.
*
* @protected
* @type {String}
*/
Base.prototype._title = null;
/**
* The X axes.
*
* @protected
* @type {module:core/chart_axis[]}
*/
Base.prototype._xaxes = null;
/**
* The Y axes.
*
* @protected
* @type {module:core/chart_axis[]}
*/
Base.prototype._yaxes = null;
/**
* Colours to pick from when automatically assigning them.
*
* @const
* @type {String[]}
*/
Base.prototype.COLORSET = ['#f3c300', '#875692', '#f38400', '#a1caf1', '#be0032', '#c2b280', '#7f180d', '#008856',
'#e68fac', '#0067a5'];
/**
* Set of colours defined by setting $CFG->chart_colorset to be picked when automatically assigning them.
*
* @type {String[]}
* @protected
*/
Base.prototype._configColorSet = null;
/**
* The type of chart.
*
* @abstract
* @type {String}
* @const
*/
Base.prototype.TYPE = null;
/**
* Add a series to the chart.
*
* This will automatically assign a color to the series if it does not have one.
*
* @param {module:core/chart_series} series The series to add.
*/
Base.prototype.addSeries = function(series) {
this._validateSeries(series);
this._series.push(series);
// Give a default color from the set.
if (series.getColor() === null) {
var configColorSet = this.getConfigColorSet() || Base.prototype.COLORSET;
series.setColor(configColorSet[this._series.length % configColorSet.length]);
}
};
/**
* Create a new instance of a chart from serialised data.
*
* the serialised attributes they offer and support.
*
* @static
* @method create
* @param {module:core/chart_base} Klass The class oject representing the type of chart to instantiate.
* @param {Object} data The data of the chart.
* @return {module:core/chart_base}
*/
Base.prototype.create = function(Klass, data) {
// TODO Not convinced about the usage of Klass here but I can't figure out a way
// to have a reference to the class in the sub classes, in PHP I'd do new self().
var Chart = new Klass();
Chart.setConfigColorSet(data.config_colorset);
Chart.setLabels(data.labels);
Chart.setTitle(data.title);
if (data.legend_options) {
Chart.setLegendOptions(data.legend_options);
}
data.series.forEach(function(seriesData) {
Chart.addSeries(Series.prototype.create(seriesData));
});
data.axes.x.forEach(function(axisData, i) {
Chart.setXAxis(Axis.prototype.create(axisData), i);
});
data.axes.y.forEach(function(axisData, i) {
Chart.setYAxis(Axis.prototype.create(axisData), i);
});
return Chart;
};
/**
* Get an axis.
*
* @private
* @param {String} xy Accepts the values 'x' or 'y'.
* @param {Number} [index=0] The index of the axis of its type.
* @param {Bool} [createIfNotExists=false] When true, create an instance if it does not exist.
* @return {module:core/chart_axis}
*/
Base.prototype.__getAxis = function(xy, index, createIfNotExists) {
var axes = xy === 'x' ? this._xaxes : this._yaxes,
setAxis = (xy === 'x' ? this.setXAxis : this.setYAxis).bind(this),
axis;
index = typeof index === 'undefined' ? 0 : index;
createIfNotExists = typeof createIfNotExists === 'undefined' ? false : createIfNotExists;
axis = axes[index];
if (typeof axis === 'undefined') {
if (!createIfNotExists) {
throw new Error('Unknown axis.');
}
axis = new Axis();
setAxis(axis, index);
}
return axis;
};
/**
* Get colours defined by setting.
*
* @return {String[]}
*/
Base.prototype.getConfigColorSet = function() {
return this._configColorSet;
};
/**
* Get the labels of the X axis.
*
* @return {String[]}
*/
Base.prototype.getLabels = function() {
return this._labels;
};
/**
* Get whether to display the chart legend.
*
* @return {Bool}
*/
Base.prototype.getLegendOptions = function() {
return this._legendOptions;
};
/**
* Get the series.
*
* @return {module:core/chart_series[]}
*/
Base.prototype.getSeries = function() {
return this._series;
};
/**
* Get the title of the chart.
*
* @return {String}
*/
Base.prototype.getTitle = function() {
return this._title;
};
/**
* Get the type of chart.
*
* @see module:core/chart_base#TYPE
* @return {String}
*/
Base.prototype.getType = function() {
if (!this.TYPE) {
throw new Error('The TYPE property has not been set.');
}
return this.TYPE;
};
/**
* Get the X axes.
*
* @return {module:core/chart_axis[]}
*/
Base.prototype.getXAxes = function() {
return this._xaxes;
};
/**
* Get an X axis.
*
* @param {Number} [index=0] The index of the axis.
* @param {Bool} [createIfNotExists=false] Create the instance of it does not exist at index.
* @return {module:core/chart_axis}
*/
Base.prototype.getXAxis = function(index, createIfNotExists) {
return this.__getAxis('x', index, createIfNotExists);
};
/**
* Get the Y axes.
*
* @return {module:core/chart_axis[]}
*/
Base.prototype.getYAxes = function() {
return this._yaxes;
};
/**
* Get an Y axis.
*
* @param {Number} [index=0] The index of the axis.
* @param {Bool} [createIfNotExists=false] Create the instance of it does not exist at index.
* @return {module:core/chart_axis}
*/
Base.prototype.getYAxis = function(index, createIfNotExists) {
return this.__getAxis('y', index, createIfNotExists);
};
/**
* Set colours defined by setting.
*
* @param {String[]} colorset An array of css colours.
* @protected
*/
Base.prototype.setConfigColorSet = function(colorset) {
this._configColorSet = colorset;
};
/**
* Set the defaults for this chart type.
*
* Child classes can extend this to set defaults values on instantiation.
*
* emphasize and self-document the defaults values set by the chart type.
*
* @protected
*/
Base.prototype._setDefaults = function() {
// For the children to extend.
};
/**
* Set the labels of the X axis.
*
* This requires for each series to contain strictly as many values as there
* are labels.
*
* @param {String[]} labels The labels.
*/
Base.prototype.setLabels = function(labels) {
if (labels.length && this._series.length && this._series[0].length != labels.length) {
throw new Error('Series must match label values.');
}
this._labels = labels;
};
/**
* Set options for chart legend display.
*
* @param {Object} legendOptions
*/
Base.prototype.setLegendOptions = function(legendOptions) {
if (typeof legendOptions !== 'object') {
throw new Error('Setting legend with non-object value:' + legendOptions);
}
this._legendOptions = legendOptions;
};
/**
* Set the title of the chart.
*
* @param {String} title The title.
*/
Base.prototype.setTitle = function(title) {
this._title = title;
};
/**
* Set an X axis.
*
* Note that this will override any predefined axis without warning.
*
* @param {module:core/chart_axis} axis The axis.
* @param {Number} [index=0] The index of the axis.
*/
Base.prototype.setXAxis = function(axis, index) {
index = typeof index === 'undefined' ? 0 : index;
this._validateAxis('x', axis, index);
this._xaxes[index] = axis;
};
/**
* Set a Y axis.
*
* Note that this will override any predefined axis without warning.
*
* @param {module:core/chart_axis} axis The axis.
* @param {Number} [index=0] The index of the axis.
*/
Base.prototype.setYAxis = function(axis, index) {
index = typeof index === 'undefined' ? 0 : index;
this._validateAxis('y', axis, index);
this._yaxes[index] = axis;
};
/**
* Validate an axis.
*
* @protected
* @param {String} xy X or Y axis.
* @param {module:core/chart_axis} axis The axis to validate.
* @param {Number} [index=0] The index of the axis.
*/
Base.prototype._validateAxis = function(xy, axis, index) {
index = typeof index === 'undefined' ? 0 : index;
if (index > 0) {
var axes = xy == 'x' ? this._xaxes : this._yaxes;
if (typeof axes[index - 1] === 'undefined') {
throw new Error('Missing ' + xy + ' axis at index lower than ' + index);
}
}
};
/**
* Validate a series.
*
* @protected
* @param {module:core/chart_series} series The series to validate.
*/
Base.prototype._validateSeries = function(series) {
if (this._series.length && this._series[0].getCount() != series.getCount()) {
throw new Error('Series do not have an equal number of values.');
} else if (this._labels.length && this._labels.length != series.getCount()) {
throw new Error('Series must match label values.');
}
};
return Base;
});
+52
View File
@@ -0,0 +1,52 @@
// 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/>.
/**
* Chart builder.
*
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define(['jquery'], function($) {
/**
* Chart builder.
*
* @exports core/chart_builder
*/
var module = {
/**
* Make a chart instance.
*
* This takes data, most likely generated in PHP, and creates a chart instance from it
* deferring most of the logic to {@link module:core/chart_base.create}.
*
* @param {Object} data The data.
* @return {Promise} A promise resolved with the chart instance.
*/
make: function(data) {
var deferred = $.Deferred();
require(['core/chart_' + data.type], function(Klass) {
var instance = Klass.prototype.create(Klass, data);
deferred.resolve(instance);
});
return deferred.promise();
}
};
return module;
});
+78
View File
@@ -0,0 +1,78 @@
// 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/>.
/**
* Chart line.
*
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @module core/chart_line
*/
define(['core/chart_base'], function(Base) {
/**
* Line chart.
*
* @extends {module:core/chart_base}
* @class
*/
function Line() {
Base.prototype.constructor.apply(this, arguments);
}
Line.prototype = Object.create(Base.prototype);
/** @override */
Line.prototype.TYPE = 'line';
/**
* Whether the line should be smooth or not.
*
* By default the chart lines are not smooth.
*
* @type {Bool}
* @protected
*/
Line.prototype._smooth = false;
/** @override */
Line.prototype.create = function(Klass, data) {
var chart = Base.prototype.create.apply(this, arguments);
chart.setSmooth(data.smooth);
return chart;
};
/**
* Get whether the line should be smooth or not.
*
* @method getSmooth
* @returns {Bool}
*/
Line.prototype.getSmooth = function() {
return this._smooth;
};
/**
* Set whether the line should be smooth or not.
*
* @method setSmooth
* @param {Bool} smooth True if the line chart should be smooth, false otherwise.
*/
Line.prototype.setSmooth = function(smooth) {
this._smooth = Boolean(smooth);
};
return Line;
});
+34
View File
@@ -0,0 +1,34 @@
// 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/>.
/**
* Chart output.
*
* Proxy to the default output module.
*
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define(['core/chart_output_chartjs'], function(Output) {
/**
* @exports module:core/chart_output
* @extends {module:core/chart_output_chartjs}
*/
var defaultModule = Output;
return defaultModule;
});
+64
View File
@@ -0,0 +1,64 @@
// 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/>.
/**
* Chart output base.
*
* This takes a chart object and draws it.
*
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @module core/chart_output_base
*/
define(['jquery'], function($) {
/**
* Chart output base.
*
* The constructor of an output class must instantly generate and display the
* chart. It is also the responsability of the output module to check that
* the node received is of the appropriate type, if not a new node can be
* added within.
*
* The output module has total control over the content of the node and can
* clear it or output anything to it at will. A node should not be shared by
* two simultaneous output modules.
*
* @class
* @param {Node} node The node to output with/in.
* @param {Chart} chart A chart object.
*/
function Base(node, chart) {
this._node = $(node);
this._chart = chart;
}
/**
* Update method.
*
* This is the public method through which an output instance in informed
* that the chart instance has been updated and they need to update the
* chart rendering.
*
* @abstract
* @return {Void}
*/
Base.prototype.update = function() {
throw new Error('Not supported.');
};
return Base;
});
+366
View File
@@ -0,0 +1,366 @@
// 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/>.
/**
* Chart output for chart.js.
*
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @module core/chart_output_chartjs
*/
define([
'jquery',
'core/chartjs',
'core/chart_axis',
'core/chart_bar',
'core/chart_output_base',
'core/chart_line',
'core/chart_pie',
'core/chart_series'
], function($, Chartjs, Axis, Bar, Base, Line, Pie, Series) {
/**
* Makes an axis ID.
*
* @param {String} xy Accepts 'x' and 'y'.
* @param {Number} index The axis index.
* @return {String}
*/
var makeAxisId = function(xy, index) {
return 'axis-' + xy + '-' + index;
};
/**
* Chart output for Chart.js.
*
* @class
* @extends {module:core/chart_output_base}
*/
function Output() {
Base.prototype.constructor.apply(this, arguments);
// Make sure that we've got a canvas tag.
this._canvas = this._node;
if (this._canvas.prop('tagName') != 'CANVAS') {
this._canvas = $('<canvas>');
this._node.append(this._canvas);
}
this._build();
}
Output.prototype = Object.create(Base.prototype);
/**
* Reference to the chart config object.
*
* @type {Object}
* @protected
*/
Output.prototype._config = null;
/**
* Reference to the instance of chart.js.
*
* @type {Object}
* @protected
*/
Output.prototype._chartjs = null;
/**
* Reference to the canvas node.
*
* @type {Jquery}
* @protected
*/
Output.prototype._canvas = null;
/**
* Builds the config and the chart.
*
* @protected
*/
Output.prototype._build = function() {
this._config = this._makeConfig();
this._chartjs = new Chartjs(this._canvas[0], this._config);
};
/**
* Clean data.
*
* @param {(String|String[])} data A single string or an array of strings.
* @returns {(String|String[])}
* @protected
*/
Output.prototype._cleanData = function(data) {
if (data instanceof Array) {
return data.map(function(value) {
return $('<span>').html(value).text();
});
} else {
return $('<span>').html(data).text();
}
};
/**
* Get the chart type and handles the Chart.js specific chart types.
*
* By default returns the current chart TYPE value. Also does the handling of specific chart types, for example
* check if the bar chart should be horizontal and the pie chart should be displayed as a doughnut.
*
* @method getChartType
* @returns {String} the chart type.
* @protected
*/
Output.prototype._getChartType = function() {
var type = this._chart.getType();
// Bars can be displayed vertically and horizontally, defining horizontalBar type.
if (this._chart.getType() === Bar.prototype.TYPE && this._chart.getHorizontal() === true) {
type = 'horizontalBar';
} else if (this._chart.getType() === Pie.prototype.TYPE && this._chart.getDoughnut() === true) {
// Pie chart can be displayed as doughnut.
type = 'doughnut';
}
return type;
};
/**
* Make the axis config.
*
* @protected
* @param {module:core/chart_axis} axis The axis.
* @param {String} xy Accepts 'x' or 'y'.
* @param {Number} index The axis index.
* @return {Object} The axis config.
*/
Output.prototype._makeAxisConfig = function(axis, xy, index) {
var scaleData = {
id: makeAxisId(xy, index)
};
if (axis.getPosition() !== Axis.prototype.POS_DEFAULT) {
scaleData.position = axis.getPosition();
}
if (axis.getLabel() !== null) {
scaleData.title = {
display: true,
text: this._cleanData(axis.getLabel())
};
}
if (axis.getStepSize() !== null) {
scaleData.ticks = scaleData.ticks || {};
scaleData.ticks.stepSize = axis.getStepSize();
}
if (axis.getMax() !== null) {
scaleData.ticks = scaleData.ticks || {};
scaleData.ticks.max = axis.getMax();
}
if (axis.getMin() !== null) {
scaleData.ticks = scaleData.ticks || {};
scaleData.ticks.min = axis.getMin();
}
return scaleData;
};
/**
* Make the config config.
*
* @protected
* @return {Object} The axis config.
*/
Output.prototype._makeConfig = function() {
var charType = this._getChartType();
var config = {
type: charType,
data: {
labels: this._cleanData(this._chart.getLabels()),
datasets: this._makeDatasetsConfig()
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
title: {
display: this._chart.getTitle() !== null,
text: this._cleanData(this._chart.getTitle())
}
}
}
};
if (charType === 'horizontalBar') {
config.type = 'bar';
config.options.indexAxis = 'y';
}
var legendOptions = this._chart.getLegendOptions();
if (legendOptions) {
config.options.plugins.legend = legendOptions;
}
this._chart.getXAxes().forEach(function(axis, i) {
var axisLabels = axis.getLabels();
config.options.scales = config.options.scales || {};
config.options.scales.x = config.options.scales.x || {};
config.options.scales.x[i] = this._makeAxisConfig(axis, 'x', i);
if (axisLabels !== null) {
config.options.scales.x[i].ticks.callback = function(value, index) {
return axisLabels[index] || '';
};
}
config.options.scales.x.stacked = this._isStacked();
}.bind(this));
this._chart.getYAxes().forEach(function(axis, i) {
var axisLabels = axis.getLabels();
config.options.scales = config.options.scales || {};
config.options.scales.y = config.options.scales.yAxes || {};
config.options.scales.y[i] = this._makeAxisConfig(axis, 'y', i);
if (axisLabels !== null) {
config.options.scales.y[i].ticks.callback = function(value) {
return axisLabels[parseInt(value, 10)] || '';
};
}
config.options.scales.y.stacked = this._isStacked();
}.bind(this));
config.options.plugins.tooltip = {
callbacks: {
label: this._makeTooltip.bind(this)
}
};
return config;
};
/**
* Get the datasets configurations.
*
* @protected
* @return {Object[]}
*/
Output.prototype._makeDatasetsConfig = function() {
var sets = this._chart.getSeries().map(function(series) {
var colors = series.hasColoredValues() ? series.getColors() : series.getColor();
var dataset = {
label: this._cleanData(series.getLabel()),
data: series.getValues(),
type: series.getType(),
fill: series.getFill(),
backgroundColor: colors,
// Pie charts look better without borders.
borderColor: this._chart.getType() == Pie.prototype.TYPE ? '#fff' : colors,
tension: this._isSmooth(series) ? 0.3 : 0
};
if (series.getXAxis() !== null) {
dataset.xAxisID = makeAxisId('x', series.getXAxis());
}
if (series.getYAxis() !== null) {
dataset.yAxisID = makeAxisId('y', series.getYAxis());
}
return dataset;
}.bind(this));
return sets;
};
/**
* Get the chart data, add labels and rebuild the tooltip.
*
* @param {Object[]} tooltipItem The tooltip item object.
* @returns {Array}
* @protected
*/
Output.prototype._makeTooltip = function(tooltipItem) {
// Get series and chart data to rebuild the tooltip and add labels.
var series = this._chart.getSeries()[tooltipItem.datasetIndex];
var serieLabel = series.getLabel();
var chartData = tooltipItem.dataset.data;
var tooltipData = chartData[tooltipItem.dataIndex];
// Build default tooltip.
var tooltip = [];
// Pie and doughnut charts tooltip are different.
if (this._chart.getType() === Pie.prototype.TYPE) {
var chartLabels = this._cleanData(this._chart.getLabels());
tooltip.push(chartLabels[tooltipItem.dataIndex] + ' - ' + this._cleanData(serieLabel) + ': ' + tooltipData);
} else {
tooltip.push(this._cleanData(serieLabel) + ': ' + tooltipData);
}
return tooltip;
};
/**
* Verify if the chart line is smooth or not.
*
* @protected
* @param {module:core/chart_series} series The series.
* @returns {Bool}
*/
Output.prototype._isSmooth = function(series) {
var smooth = false;
if (this._chart.getType() === Line.prototype.TYPE) {
smooth = series.getSmooth();
if (smooth === null) {
smooth = this._chart.getSmooth();
}
} else if (series.getType() === Series.prototype.TYPE_LINE) {
smooth = series.getSmooth();
}
return smooth;
};
/**
* Verify if the bar chart is stacked or not.
*
* @protected
* @returns {Bool}
*/
Output.prototype._isStacked = function() {
var stacked = false;
// Stacking is (currently) only supported for bar charts.
if (this._chart.getType() === Bar.prototype.TYPE) {
stacked = this._chart.getStacked();
}
return stacked;
};
/** @override */
Output.prototype.update = function() {
$.extend(true, this._config, this._makeConfig());
this._chartjs.update();
};
return Output;
});
+120
View File
@@ -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/>.
/**
* Chart output for HTML table.
*
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @module core/chart_output_htmltable
*/
define([
'jquery',
'core/chart_output_base',
], function($, Base) {
/**
* Render a chart as an HTML table.
*
* @class
* @extends {module:core/chart_output_base}
*/
function Output() {
Base.prototype.constructor.apply(this, arguments);
this._build();
}
Output.prototype = Object.create(Base.prototype);
/**
* Attach the table to the document.
*
* @protected
*/
Output.prototype._build = function() {
this._node.empty();
this._node.append(this._makeTable());
};
/**
* Builds the table node.
*
* @protected
* @return {Jquery}
*/
Output.prototype._makeTable = function() {
var tbl = $('<table>'),
c = this._chart,
node,
value,
labels = c.getLabels(),
hasLabel = labels.length > 0,
series = c.getSeries(),
seriesLabels,
rowCount = series[0].getCount();
// Identify the table.
tbl.addClass('chart-output-htmltable generaltable');
// Set the caption.
if (c.getTitle() !== null) {
tbl.append($('<caption>').text(c.getTitle()));
}
// Write the column headers.
node = $('<tr>');
if (hasLabel) {
node.append($('<td>'));
}
series.forEach(function(serie) {
node.append(
$('<th>')
.text(serie.getLabel())
.attr('scope', 'col')
);
});
tbl.append(node);
// Write rows.
for (var rowId = 0; rowId < rowCount; rowId++) {
node = $('<tr>');
if (labels.length > 0) {
node.append(
$('<th>')
.text(labels[rowId])
.attr('scope', 'row')
);
}
for (var serieId = 0; serieId < series.length; serieId++) {
value = series[serieId].getValues()[rowId];
seriesLabels = series[serieId].getLabels();
if (seriesLabels !== null) {
value = series[serieId].getLabels()[rowId];
}
node.append($('<td>').text(value));
}
tbl.append(node);
}
return tbl;
};
/** @override */
Output.prototype.update = function() {
this._build();
};
return Output;
});
+108
View File
@@ -0,0 +1,108 @@
// 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/>.
/**
* Chart pie.
*
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @module core/chart_pie
*/
define(['core/chart_base'], function(Base) {
/**
* Pie chart.
*
* @class
* @extends {module:core/chart_base}
*/
function Pie() {
Base.prototype.constructor.apply(this, arguments);
}
Pie.prototype = Object.create(Base.prototype);
/** @override */
Pie.prototype.TYPE = 'pie';
/**
* Whether the chart should be displayed as doughnut or not.
*
* @type {Bool}
* @protected
*/
Pie.prototype._doughnut = null;
/** @override */
Pie.prototype.create = function(Klass, data) {
var chart = Base.prototype.create.apply(this, arguments);
chart.setDoughnut(data.doughnut);
return chart;
};
/**
* Overridden to add appropriate colors to the series.
*
* @override
*/
Pie.prototype.addSeries = function(series) {
if (series.getColor() === null) {
var colors = [];
var configColorSet = this.getConfigColorSet() || Base.prototype.COLORSET;
for (var i = 0; i < series.getCount(); i++) {
colors.push(configColorSet[i % configColorSet.length]);
}
series.setColors(colors);
}
return Base.prototype.addSeries.apply(this, arguments);
};
/**
* Get whether the chart should be displayed as doughnut or not.
*
* @method getDoughnut
* @returns {Bool}
*/
Pie.prototype.getDoughnut = function() {
return this._doughnut;
};
/**
* Set whether the chart should be displayed as doughnut or not.
*
* @method setDoughnut
* @param {Bool} doughnut True for doughnut type, false for pie.
*/
Pie.prototype.setDoughnut = function(doughnut) {
this._doughnut = Boolean(doughnut);
};
/**
* Validate a series.
*
* Overrides parent implementation to validate that there is only
* one series per chart instance.
*
* @override
*/
Pie.prototype._validateSeries = function() {
if (this._series.length >= 1) {
throw new Error('Pie charts only support one serie.');
}
return Base.prototype._validateSeries.apply(this, arguments);
};
return Pie;
});
+369
View File
@@ -0,0 +1,369 @@
// 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/>.
/**
* Chart series.
*
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @module core/chart_series
*/
define([], function() {
/**
* Chart data series.
*
* @class
* @param {String} label The series label.
* @param {Number[]} values The values.
*/
function Series(label, values) {
if (typeof label !== 'string') {
throw new Error('Invalid label for series.');
} else if (typeof values !== 'object') {
throw new Error('Values for a series must be an array.');
} else if (values.length < 1) {
throw new Error('Invalid values received for series.');
}
this._colors = [];
this._label = label;
this._values = values;
}
/**
* The default type of series.
*
* @type {Null}
* @const
*/
Series.prototype.TYPE_DEFAULT = null;
/**
* Type of series 'line'.
*
* @type {String}
* @const
*/
Series.prototype.TYPE_LINE = 'line';
/**
* The colors of the series.
*
* @type {String[]}
* @protected
*/
Series.prototype._colors = null;
/**
* The fill mode of the series.
*
* @type {Object}
* @protected
*/
Series.prototype._fill = false;
/**
* The label of the series.
*
* @type {String}
* @protected
*/
Series.prototype._label = null;
/**
* The labels for the values of the series.
*
* @type {String[]}
* @protected
*/
Series.prototype._labels = null;
/**
* Whether the line of the serie should be smooth or not.
*
* @type {Bool}
* @protected
*/
Series.prototype._smooth = false;
/**
* The type of the series.
*
* @type {String}
* @protected
*/
Series.prototype._type = Series.prototype.TYPE_DEFAULT;
/**
* The values in the series.
*
* @type {Number[]}
* @protected
*/
Series.prototype._values = null;
/**
* The index of the X axis.
*
* @type {Number[]}
* @protected
*/
Series.prototype._xaxis = null;
/**
* The index of the Y axis.
*
* @type {Number[]}
* @protected
*/
Series.prototype._yaxis = null;
/**
* Create a new instance of a series from serialised data.
*
* @static
* @method create
* @param {Object} obj The data of the series.
* @return {module:core/chart_series}
*/
Series.prototype.create = function(obj) {
var s = new Series(obj.label, obj.values);
s.setType(obj.type);
s.setXAxis(obj.axes.x);
s.setYAxis(obj.axes.y);
s.setLabels(obj.labels);
// Colors are exported as an array with 1, or n values.
if (obj.colors && obj.colors.length > 1) {
s.setColors(obj.colors);
} else {
s.setColor(obj.colors[0]);
}
s.setFill(obj.fill);
s.setSmooth(obj.smooth);
return s;
};
/**
* Get the color.
*
* @return {String}
*/
Series.prototype.getColor = function() {
return this._colors[0] || null;
};
/**
* Get the colors for each value in the series.
*
* @return {String[]}
*/
Series.prototype.getColors = function() {
return this._colors;
};
/**
* Get the number of values in the series.
*
* @return {Number}
*/
Series.prototype.getCount = function() {
return this._values.length;
};
/**
* Get the fill mode of the series.
*
* @return {Object}
*/
Series.prototype.getFill = function() {
return this._fill;
};
/**
* Get the series label.
*
* @return {String}
*/
Series.prototype.getLabel = function() {
return this._label;
};
/**
* Get labels for the values of the series.
*
* @return {String[]}
*/
Series.prototype.getLabels = function() {
return this._labels;
};
/**
* Get whether the line of the serie should be smooth or not.
*
* @returns {Bool}
*/
Series.prototype.getSmooth = function() {
return this._smooth;
};
/**
* Get the series type.
*
* @return {String}
*/
Series.prototype.getType = function() {
return this._type;
};
/**
* Get the series values.
*
* @return {Number[]}
*/
Series.prototype.getValues = function() {
return this._values;
};
/**
* Get the index of the X axis.
*
* @return {Number}
*/
Series.prototype.getXAxis = function() {
return this._xaxis;
};
/**
* Get the index of the Y axis.
*
* @return {Number}
*/
Series.prototype.getYAxis = function() {
return this._yaxis;
};
/**
* Whether there is a color per value.
*
* @return {Bool}
*/
Series.prototype.hasColoredValues = function() {
return this._colors.length == this.getCount();
};
/**
* Set the series color.
*
* @param {String} color A CSS-compatible color.
*/
Series.prototype.setColor = function(color) {
this._colors = [color];
};
/**
* Set a color for each value in the series.
*
* @param {String[]} colors CSS-compatible colors.
*/
Series.prototype.setColors = function(colors) {
if (colors && colors.length != this.getCount()) {
throw new Error('When setting multiple colors there must be one per value.');
}
this._colors = colors || [];
};
/**
* Set the fill mode for the series.
*
* @param {Object} fill
*/
Series.prototype.setFill = function(fill) {
this._fill = typeof fill === 'undefined' ? null : fill;
};
/**
* Set the labels for the values of the series.
*
* @param {String[]} labels the labels of the series values.
*/
Series.prototype.setLabels = function(labels) {
this._validateLabels(labels);
labels = typeof labels === 'undefined' ? null : labels;
this._labels = labels;
};
/**
* Set Whether the line of the serie should be smooth or not.
*
* Only applicable for line chart or a line series, if null it assumes the chart default (not smooth).
*
* @param {Bool} smooth True if the lines should be smooth, false for tensioned lines.
*/
Series.prototype.setSmooth = function(smooth) {
smooth = typeof smooth === 'undefined' ? null : smooth;
this._smooth = smooth;
};
/**
* Set the type of the series.
*
* @param {String} type A type constant value.
*/
Series.prototype.setType = function(type) {
if (type != this.TYPE_DEFAULT && type != this.TYPE_LINE) {
throw new Error('Invalid serie type.');
}
this._type = type || null;
};
/**
* Set the index of the X axis.
*
* @param {Number} index The index.
*/
Series.prototype.setXAxis = function(index) {
this._xaxis = index || null;
};
/**
* Set the index of the Y axis.
*
* @param {Number} index The index.
*/
Series.prototype.setYAxis = function(index) {
this._yaxis = index || null;
};
/**
* Validate series labels.
*
* @protected
* @param {String[]} labels The labels of the serie.
*/
Series.prototype._validateLabels = function(labels) {
if (labels && labels.length > 0 && labels.length != this.getCount()) {
throw new Error('Series labels must match series values.');
}
};
return Series;
});
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
// 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/>.
/**
* Chart.js loader.
*
* @copyright 2016 Frédéric Massart - FMCorz.net
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define(['core/chartjs-lazy'], function(ChartJS) {
return ChartJS;
});
+63
View File
@@ -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/>.
/**
* Check API result functions
*
* @module core/check
* @author Matthew Hilton <matthewhilton@catalyst-au.net>
* @copyright Catalyst IT, 2023
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {getCheckResult} from './repository';
import {getString} from 'core/str';
import * as Templates from 'core/templates';
/**
* Get the result of a check and replace a given DOM element with the result.
*
* @method getAndRender
* @param {String} domSelector A CSS selector for a dom element to replace the the HTML for.
* @param {String} adminTreeId Id of the admin_setting that called this webservice. Used to retrieve the check registered to it.
* @param {String} settingName Name of setting (used to find the parent node in the admin tree)
* @param {Boolean} includeDetails If true, details will be included in the check.
* By default only the status and the summary is returned.
*/
export async function getAndRender(domSelector, adminTreeId, settingName, includeDetails) {
const element = document.querySelector(domSelector);
if (!element) {
window.console.error('Check selector not found');
return;
}
try {
const result = await getCheckResult(adminTreeId, settingName, includeDetails);
const decoded = new DOMParser().parseFromString(result.html, "text/html").documentElement.textContent;
element.innerHTML = decoded;
} catch (e) {
window.console.error(e);
// Render error as a red notification.
element.innerHTML = await Templates.render('core/notification', {
iserror: true,
closebutton: false,
announce: 0,
extraclasses: '',
message: await getString('checkerror', 'core', adminTreeId)
});
}
}
+42
View File
@@ -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/>.
/**
* Check API webservice repository
*
* @module core/check
* @author Matthew Hilton <matthewhilton@catalyst-au.net>
* @copyright Catalyst IT, 2023
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {call as fetchMany} from 'core/ajax';
/**
* Call check_get_result webservice function
*
* @param {String} adminTreeId Id of the admin_setting that called this webservice. Used to retrieve the check registered to it.
* @param {String} settingName Setting name (used to find it's parent)
* @param {Boolean} includeDetails If details should be included in the response
*/
export const getCheckResult = (adminTreeId, settingName, includeDetails) => fetchMany([{
methodname: 'core_check_get_result_admintree',
args: {
admintreeid: adminTreeId,
settingname: settingName,
includedetails: includeDetails,
},
}])[0];
+338
View File
@@ -0,0 +1,338 @@
// 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 module to help with toggle select/deselect all.
*
* @module core/checkbox-toggleall
* @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define(['jquery', 'core/pubsub'], function($, PubSub) {
/**
* Whether event listeners have already been registered.
*
* @private
* @type {boolean}
*/
var registered = false;
/**
* List of custom events that this module publishes.
*
* @private
* @type {{checkboxToggled: string}}
*/
var events = {
checkboxToggled: 'core/checkbox-toggleall:checkboxToggled',
};
/**
* Fetches elements that are member of a given toggle group.
*
* @private
* @param {jQuery} root The root jQuery element.
* @param {string} toggleGroup The toggle group name that we're searching form.
* @param {boolean} exactMatch Whether we want an exact match we just want to match toggle groups that start with the given
* toggle group name.
* @returns {jQuery} The elements matching the given toggle group.
*/
var getToggleGroupElements = function(root, toggleGroup, exactMatch) {
if (exactMatch) {
return root.find('[data-action="toggle"][data-togglegroup="' + toggleGroup + '"]');
} else {
return root.find('[data-action="toggle"][data-togglegroup^="' + toggleGroup + '"]');
}
};
/**
* Fetches the slave checkboxes for a given toggle group.
*
* @private
* @param {jQuery} root The root jQuery element.
* @param {string} toggleGroup The toggle group name.
* @returns {jQuery} The slave checkboxes belonging to the toggle group.
*/
var getAllSlaveCheckboxes = function(root, toggleGroup) {
return getToggleGroupElements(root, toggleGroup, false).filter('[data-toggle="slave"]');
};
/**
* Fetches the master elements (checkboxes or buttons) that control the slave checkboxes in a given toggle group.
*
* @private
* @param {jQuery} root The root jQuery element.
* @param {string} toggleGroup The toggle group name.
* @param {boolean} exactMatch
* @returns {jQuery} The control elements belonging to the toggle group.
*/
var getControlCheckboxes = function(root, toggleGroup, exactMatch) {
return getToggleGroupElements(root, toggleGroup, exactMatch).filter('[data-toggle="master"]');
};
/**
* Fetches the action elements that perform actions on the selected checkboxes in a given toggle group.
*
* @private
* @param {jQuery} root The root jQuery element.
* @param {string} toggleGroup The toggle group name.
* @returns {jQuery} The action elements belonging to the toggle group.
*/
var getActionElements = function(root, toggleGroup) {
return getToggleGroupElements(root, toggleGroup, true).filter('[data-toggle="action"]');
};
/**
* Toggles the slave checkboxes in a given toggle group when a master element in that toggle group is toggled.
*
* @private
* @param {Object} e The event object.
*/
var toggleSlavesFromMasters = function(e) {
var root = e.data.root;
var target = $(e.target);
var toggleGroupName = target.data('togglegroup');
var targetState;
if (target.is(':checkbox')) {
targetState = target.is(':checked');
} else {
targetState = target.data('checkall') === 1;
}
toggleSlavesToState(root, toggleGroupName, targetState);
};
/**
* Toggles the slave checkboxes from the masters.
*
* @param {HTMLElement} root
* @param {String} toggleGroupName
*/
var updateSlavesFromMasterState = function(root, toggleGroupName) {
// Normalise to jQuery Object.
root = $(root);
var target = getControlCheckboxes(root, toggleGroupName, false);
var targetState;
if (target.is(':checkbox')) {
targetState = target.is(':checked');
} else {
targetState = target.data('checkall') === 1;
}
toggleSlavesToState(root, toggleGroupName, targetState);
};
/**
* Toggles the master checkboxes and action elements in a given toggle group.
*
* @param {jQuery} root The root jQuery element.
* @param {String} toggleGroupName The name of the toggle group
*/
var toggleMastersAndActionElements = function(root, toggleGroupName) {
var toggleGroupSlaves = getAllSlaveCheckboxes(root, toggleGroupName);
if (toggleGroupSlaves.length > 0) {
var toggleGroupCheckedSlaves = toggleGroupSlaves.filter(':checked');
var targetState = toggleGroupSlaves.length === toggleGroupCheckedSlaves.length;
// Make sure to toggle the exact master checkbox in the given toggle group.
setMasterStates(root, toggleGroupName, targetState, true);
// Enable the action elements if there's at least one checkbox checked in the given toggle group.
// Disable otherwise.
setActionElementStates(root, toggleGroupName, !toggleGroupCheckedSlaves.length);
}
};
/**
* Returns an array containing every toggle group level of a given toggle group.
*
* @param {String} toggleGroupName The name of the toggle group
* @return {Array} toggleGroupLevels Array that contains every toggle group level of a given toggle group
*/
var getToggleGroupLevels = function(toggleGroupName) {
var toggleGroups = toggleGroupName.split(' ');
var toggleGroupLevels = [];
var toggleGroupLevel = '';
toggleGroups.forEach(function(toggleGroupName) {
toggleGroupLevel += ' ' + toggleGroupName;
toggleGroupLevels.push(toggleGroupLevel.trim());
});
return toggleGroupLevels;
};
/**
* Toggles the slave checkboxes to a specific state.
*
* @param {HTMLElement} root
* @param {String} toggleGroupName
* @param {Bool} targetState
*/
var toggleSlavesToState = function(root, toggleGroupName, targetState) {
var slaves = getAllSlaveCheckboxes(root, toggleGroupName);
// Set the slave checkboxes from the masters and manually trigger the native 'change' event.
slaves.prop('checked', targetState).trigger('change');
// Get all checked slaves after the change of state.
var checkedSlaves = slaves.filter(':checked');
// Toggle the master checkbox in the given toggle group.
setMasterStates(root, toggleGroupName, targetState, false);
// Enable the action elements if there's at least one checkbox checked in the given toggle group. Disable otherwise.
setActionElementStates(root, toggleGroupName, !checkedSlaves.length);
// Get all toggle group levels and toggle accordingly all parent master checkboxes and action elements from each
// level. Exclude the given toggle group (toggleGroupName) as the master checkboxes and action elements from this
// level have been already toggled.
var toggleGroupLevels = getToggleGroupLevels(toggleGroupName)
.filter(toggleGroupLevel => toggleGroupLevel !== toggleGroupName);
toggleGroupLevels.forEach(function(toggleGroupLevel) {
// Toggle the master checkboxes action elements in the given toggle group level.
toggleMastersAndActionElements(root, toggleGroupLevel);
});
PubSub.publish(events.checkboxToggled, {
root: root,
toggleGroupName: toggleGroupName,
slaves: slaves,
checkedSlaves: checkedSlaves,
anyChecked: targetState,
});
};
/**
* Set the state for an entire group of checkboxes.
*
* @param {HTMLElement} root
* @param {String} toggleGroupName
* @param {Bool} targetState
*/
var setGroupState = function(root, toggleGroupName, targetState) {
// Normalise to jQuery Object.
root = $(root);
// Set the master and slaves.
setMasterStates(root, toggleGroupName, targetState, true);
toggleSlavesToState(root, toggleGroupName, targetState);
};
/**
* Toggles the master checkboxes in a given toggle group when all or none of the slave checkboxes in the same toggle group
* have been selected.
*
* @private
* @param {Object} e The event object.
*/
var toggleMastersFromSlaves = function(e) {
var root = e.data.root;
var target = $(e.target);
var toggleGroupName = target.data('togglegroup');
var slaves = getAllSlaveCheckboxes(root, toggleGroupName);
var checkedSlaves = slaves.filter(':checked');
// Get all toggle group levels for the given toggle group and toggle accordingly all master checkboxes
// and action elements from each level.
var toggleGroupLevels = getToggleGroupLevels(toggleGroupName);
toggleGroupLevels.forEach(function(toggleGroupLevel) {
// Toggle the master checkboxes action elements in the given toggle group level.
toggleMastersAndActionElements(root, toggleGroupLevel);
});
PubSub.publish(events.checkboxToggled, {
root: root,
toggleGroupName: toggleGroupName,
slaves: slaves,
checkedSlaves: checkedSlaves,
anyChecked: !!checkedSlaves.length,
});
};
/**
* Enables or disables the action elements.
*
* @private
* @param {jQuery} root The root jQuery element.
* @param {string} toggleGroupName The toggle group name of the action element(s).
* @param {boolean} disableActionElements Whether to disable or to enable the action elements.
*/
var setActionElementStates = function(root, toggleGroupName, disableActionElements) {
getActionElements(root, toggleGroupName).prop('disabled', disableActionElements);
};
/**
* Selects or deselects the master elements.
*
* @private
* @param {jQuery} root The root jQuery element.
* @param {string} toggleGroupName The toggle group name of the master element(s).
* @param {boolean} targetState Whether to select (true) or deselect (false).
* @param {boolean} exactMatch Whether to do an exact match for the toggle group name or not.
*/
var setMasterStates = function(root, toggleGroupName, targetState, exactMatch) {
// Set the master checkboxes value and ARIA labels..
var masters = getControlCheckboxes(root, toggleGroupName, exactMatch);
masters.prop('checked', targetState);
masters.each(function(i, masterElement) {
masterElement = $(masterElement);
var targetString;
if (targetState) {
targetString = masterElement.data('toggle-deselectall');
} else {
targetString = masterElement.data('toggle-selectall');
}
if (masterElement.is(':checkbox')) {
var masterLabel = root.find('[for="' + masterElement.attr('id') + '"]');
if (masterLabel.length) {
if (masterLabel.html() !== targetString) {
masterLabel.html(targetString);
}
}
} else {
masterElement.text(targetString);
// Set the checkall data attribute.
masterElement.data('checkall', targetState ? 0 : 1);
}
});
};
/**
* Registers the event listeners.
*
* @private
*/
var registerListeners = function() {
if (!registered) {
registered = true;
var root = $(document.body);
root.on('click', '[data-action="toggle"][data-toggle="master"]', {root: root}, toggleSlavesFromMasters);
root.on('click', '[data-action="toggle"][data-toggle="slave"]', {root: root}, toggleMastersFromSlaves);
}
};
return {
init: function() {
registerListeners();
},
events: events,
setGroupState: setGroupState,
updateSlavesFromMasterState: updateSlavesFromMasterState,
};
});
@@ -0,0 +1,383 @@
// 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/>.
import $ from 'jquery';
import {debounce} from 'core/utils';
import Pending from 'core/pending';
/**
* The class that manages the state of the search within a combobox.
*
* @module core/comboboxsearch/search_combobox
* @copyright 2023 Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default class {
// Define our standard lookups.
selectors = {
component: this.componentSelector(),
toggle: '[data-toggle="dropdown"]',
instance: '[data-region="instance"]',
input: '[data-action="search"]',
clearSearch: '[data-action="clearsearch"]',
dropdown: this.dropdownSelector(),
resultitems: '[role="option"]',
viewall: '#select-all',
combobox: '[role="combobox"]',
};
// The results from the called filter function.
matchedResults = [];
// What did the user search for?
searchTerm = '';
// What the user searched for as a lowercase.
preppedSearchTerm = null;
// The DOM nodes after the dropdown render.
resultNodes = [];
// Where does the user currently have focus?
currentNode = null;
// The current node for the view all link.
currentViewAll = null;
dataset = null;
datasetSize = 0;
// DOM nodes that persist.
component = document.querySelector(this.selectors.component);
instance = this.component.dataset.instance;
toggle = this.component.querySelector(this.selectors.toggle);
searchInput = this.component.querySelector(this.selectors.input);
searchDropdown = this.component.querySelector(this.selectors.dropdown);
clearSearchButton = this.component.querySelector(this.selectors.clearSearch);
combobox = this.component.querySelector(this.selectors.combobox);
$component = $(this.component);
constructor() {
// If we have a search input, try to get the value otherwise fallback.
this.setSearchTerms(this.searchInput?.value ?? '');
// Begin handling the base search component.
this.registerClickHandlers();
// Conditionally set up the input handler since we don't know exactly how we were called.
// If the combobox is rendered later, then you'll need to call this.registerInputHandlers() manually.
// An example of this is the collapse columns in the gradebook.
if (this.searchInput !== null) {
this.registerInputHandlers();
this.registerChangeHandlers();
}
// If we have a search term, show the clear button.
if (this.getSearchTerm() !== '') {
this.clearSearchButton.classList.remove('d-none');
}
}
/**
* Stub out a required function.
*/
fetchDataset() {
throw new Error(`fetchDataset() must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
* @param {Array} dataset
*/
filterDataset(dataset) {
throw new Error(`filterDataset(${dataset}) must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
*/
filterMatchDataset() {
throw new Error(`filterMatchDataset() must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
*/
renderDropdown() {
throw new Error(`renderDropdown() must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
*/
componentSelector() {
throw new Error(`componentSelector() must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
*/
dropdownSelector() {
throw new Error(`dropdownSelector() must be implemented in ${this.constructor.name}`);
}
/**
* Stub out a required function.
* @deprecated since Moodle 4.4
*/
triggerSelector() {
window.console.warning('triggerSelector() is deprecated. Consider using this.selectors.toggle');
}
/**
* Return the dataset that we will be searching upon.
*
* @returns {Promise<null>}
*/
async getDataset() {
if (!this.dataset) {
this.dataset = await this.fetchDataset();
}
this.datasetSize = this.dataset.length;
return this.dataset;
}
/**
* Return the size of the dataset.
*
* @returns {number}
*/
getDatasetSize() {
return this.datasetSize;
}
/**
* Return the results of the filter upon the dataset.
*
* @returns {Array}
*/
getMatchedResults() {
return this.matchedResults;
}
/**
* Given a filter has been run across the dataset, store the matched results.
*
* @param {Array} result
*/
setMatchedResults(result) {
this.matchedResults = result;
}
/**
* Get the value that the user entered.
*
* @returns {string}
*/
getSearchTerm() {
return this.searchTerm;
}
/**
* Get the transformed search value.
*
* @returns {string}
*/
getPreppedSearchTerm() {
return this.preppedSearchTerm;
}
/**
* When a user searches for something, set our variable to manage it.
*
* @param {string} result
*/
setSearchTerms(result) {
this.searchTerm = result;
this.preppedSearchTerm = result.toLowerCase();
}
/**
* Return an object containing a handfull of dom nodes that we sometimes need the value of.
*
* @returns {object}
*/
getHTMLElements() {
this.updateNodes();
return {
searchDropdown: this.searchDropdown,
currentViewAll: this.currentViewAll,
searchInput: this.searchInput,
clearSearchButton: this.clearSearchButton,
trigger: this.component.querySelector(this.selectors.trigger),
};
}
/**
* When called, close the dropdown and reset the input field attributes.
*
* @param {Boolean} clear Conditionality clear the input box.
*/
closeSearch(clear = false) {
this.toggleDropdown();
if (clear) {
// Hide the "clear" search button search bar.
this.clearSearchButton.classList.add('d-none');
// Clear the entered search query in the search bar and hide the search results container.
this.setSearchTerms('');
this.searchInput.value = "";
}
}
/**
* Check whether search results are currently visible.
*
* @returns {Boolean}
*/
searchResultsVisible() {
const {searchDropdown} = this.getHTMLElements();
// If a Node is not visible, then the offsetParent is null.
return searchDropdown.offsetParent !== null;
}
/**
* When called, update the dropdown fields.
*
* @param {Boolean} on Flag to toggle hiding or showing values.
*/
toggleDropdown(on = false) {
if (on) {
$(this.toggle).dropdown('show');
} else {
$(this.toggle).dropdown('hide');
}
}
/**
* These class members change when a new result set is rendered. So update for fresh data.
*/
updateNodes() {
this.resultNodes = [...this.component.querySelectorAll(this.selectors.resultitems)];
this.currentNode = this.resultNodes.find(r => r.id === document.activeElement.id);
this.currentViewAll = this.component.querySelector(this.selectors.viewall);
this.clearSearchButton = this.component.querySelector(this.selectors.clearSearch);
this.searchInput = this.component.querySelector(this.selectors.input);
this.searchDropdown = this.component.querySelector(this.selectors.dropdown);
}
/**
* Register clickable event listeners.
*/
registerClickHandlers() {
// Register click events within the component.
this.component.addEventListener('click', this.clickHandler.bind(this));
}
/**
* Register change event listeners.
*/
registerChangeHandlers() {
const valueElement = this.component.querySelector(`#${this.combobox.dataset.inputElement}`);
valueElement.addEventListener('change', this.changeHandler.bind(this));
}
/**
* Register input event listener for the text input area.
*/
registerInputHandlers() {
// Register & handle the text input.
this.searchInput.addEventListener('input', debounce(async() => {
if (this.getSearchTerm() === this.searchInput.value && this.searchResultsVisible()) {
window.console.warn(`Search term matches input value - skipping`);
// The debounce canhappen multiple times quickly. GRrargh
return;
}
this.setSearchTerms(this.searchInput.value);
const pendingPromise = new Pending();
if (this.getSearchTerm() === '') {
this.toggleDropdown();
this.clearSearchButton.classList.add('d-none');
await this.filterrenderpipe();
} else {
this.clearSearchButton.classList.remove('d-none');
await this.renderAndShow();
}
pendingPromise.resolve();
}, 300, {pending: true}));
}
/**
* Update any changeable nodes, filter and then render the result.
*
* @returns {Promise<void>}
*/
async filterrenderpipe() {
this.updateNodes();
this.setMatchedResults(await this.filterDataset(await this.getDataset()));
this.filterMatchDataset();
await this.renderDropdown();
}
/**
* A combo method to take the matching fields and render out the results.
*
* @returns {Promise<void>}
*/
async renderAndShow() {
// User has given something for us to filter against.
this.setMatchedResults(await this.filterDataset(await this.getDataset()));
await this.filterMatchDataset();
// Replace the dropdown node contents and show the results.
await this.renderDropdown();
// Set the dropdown to open.
this.toggleDropdown(true);
}
/**
* The handler for when a user interacts with the component.
*
* @param {MouseEvent} e The triggering event that we are working with.
*/
async clickHandler(e) {
this.updateNodes();
// The "clear search" button is triggered.
if (e.target.closest(this.selectors.clearSearch)) {
this.closeSearch(true);
this.searchInput.focus();
// Remove aria-activedescendant when the available options change.
this.searchInput.removeAttribute('aria-activedescendant');
}
// User may have accidentally clicked off the dropdown and wants to reopen it.
if (
this.getSearchTerm() !== ''
&& !this.getHTMLElements().searchDropdown.classList.contains('show')
&& e.target.closest(this.selectors.input)
) {
await this.renderAndShow();
}
}
/**
* The handler for when a user changes the value of the component (selects an option from the dropdown).
*
* @param {Event} e The change event.
*/
// eslint-disable-next-line no-unused-vars
changeHandler(e) {
// Components may override this method to do something.
}
}
+25
View File
@@ -0,0 +1,25 @@
// 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/>.
/**
* Expose the M.cfg global variable.
*
* @module core/config
* @copyright 2015 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 2.9
*/
// This module exposes only the raw data from M.cfg;
export default M.cfg;
+213
View File
@@ -0,0 +1,213 @@
// 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 that enhances a button and text container to support copy-to-clipboard functionality.
*
* This module needs to be loaded by pages/templates/modules that require this functionality.
*
* To enable copy-to-clipboard functionality, we need a trigger element (usually a button) and a copy target element
* (e.g. a div, span, text input, or text area).
*
* In the trigger element, we need to declare the <code>data-action="copytoclipboard"</code> attribute and set the
* <code>data-clipboard-target</code> attribute which is the CSS selector that points to the target element that contains the text
* to be copied.
*
* When the text is successfully copied to the clipboard, a toast message that indicates that the copy operation was a success
* will be shown. This success message can be customised by setting the <code>data-clipboard-success-message</code> attribute in the
* trigger element.
*
* @module core/copy_to_clipboard
* @copyright 2021 Jun Pataleta
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*
* @example <caption>Markup for the trigger and target elements</caption>
* <input type="text" id="textinputtocopy" class="form-control" value="Copy me!" readonly />
* <button id="copybutton" data-action="copytoclipboard" data-clipboard-target="#textinputtocopy"
* data-clipboard-success-message="Success!" class="btn btn-secondary">
* Copy to clipboard
* </button>
*/
import {getString} from 'core/str';
import {add as addToast} from 'core/toast';
import {prefetchStrings} from 'core/prefetch';
/**
* Add event listeners to trigger elements through event delegation.
*
* @private
*/
const addEventListeners = () => {
document.addEventListener('click', e => {
const copyButton = e.target.closest('[data-action="copytoclipboard"]');
if (!copyButton) {
return;
}
if (!copyButton.dataset.clipboardTarget) {
return;
}
const copyTarget = document.querySelector(copyButton.dataset.clipboardTarget);
if (!copyTarget) {
return;
}
// This is a copy target and there is content.
// Prevent the default action.
e.preventDefault();
// We have a copy target - great. Let's copy its content.
const textToCopy = getTextFromContainer(copyTarget);
if (!textToCopy) {
displayFailureToast();
return;
}
if (navigator.clipboard) {
navigator.clipboard.writeText(textToCopy)
.then(() => displaySuccessToast(copyButton)).catch();
return;
}
// The clipboard API is not available.
// This may happen when the page is not served over SSL.
// Try to fall back to document.execCommand() approach of copying the text.
// WARNING: This is deprecated functionality that may get dropped at anytime by browsers.
if (copyTarget instanceof HTMLInputElement || copyTarget instanceof HTMLTextAreaElement) {
// Focus and select the text in the target element.
// If the execCommand fails, at least the user can readily copy the text.
copyTarget.focus();
if (copyNodeContentToClipboard(copyButton, copyTarget)) {
// If the copy was successful then focus back on the copy button.
copyButton.focus();
}
} else {
// This copyTarget is not an input, or text area so cannot be used with the execCommand('copy') command.
// To work around this we create a new textarea and copy that.
// This textarea must be part of the DOM and must be visible.
// We (ab)use the sr-only tag to ensure that it is considered visible to the browser, whilst being
// hidden from view by the user.
const copyRegion = document.createElement('textarea');
copyRegion.value = textToCopy;
copyRegion.classList.add('sr-only');
document.body.appendChild(copyRegion);
copyNodeContentToClipboard(copyButton, copyRegion);
// After copying, remove the temporary element and move focus back to the triggering button.
copyRegion.remove();
copyButton.focus();
}
});
};
/**
* Copy the content of the selected element to the clipboard, and display a notifiction if successful.
*
* @param {HTMLElement} copyButton
* @param {HTMLElement} copyTarget
* @returns {boolean}
* @private
*/
const copyNodeContentToClipboard = (copyButton, copyTarget) => {
copyTarget.select();
// Try to copy the text from the target element.
if (document.execCommand('copy')) {
displaySuccessToast(copyButton);
return true;
}
displayFailureToast();
return false;
};
/**
* Displays a toast containing the success message.
*
* @param {HTMLElement} copyButton The element that copies the text from the container.
* @returns {Promise<void>}
* @private
*/
const displaySuccessToast = copyButton => getSuccessText(copyButton)
.then(successMessage => addToast(successMessage, {}));
/**
* Displays a toast containing the failure message.
*
* @returns {Promise<void>}
* @private
*/
const displayFailureToast = () => getFailureText()
.then(message => addToast(message, {type: 'warning'}));
/**
* Fetches the failure message to show to the user.
*
* @returns {Promise}
* @private
*/
const getFailureText = () => getString('unabletocopytoclipboard', 'core');
/**
* Fetches the success message to show to the user.
*
* @param {HTMLElement} copyButton The element that copies the text from the container. This may contain the custom success message
* via its data-clipboard-success-message attribute.
* @returns {Promise|*}
* @private
*/
const getSuccessText = copyButton => {
if (copyButton.dataset.clipboardSuccessMessage) {
return Promise.resolve(copyButton.dataset.clipboardSuccessMessage);
}
return getString('textcopiedtoclipboard', 'core');
};
/**
* Fetches the text to be copied from the container.
*
* @param {HTMLElement} container The element containing the text to be copied.
* @returns {null|string}
* @private
*/
const getTextFromContainer = container => {
if (container.value) {
// For containers which are form elements (e.g. text area, text input), get the element's value.
return container.value;
} else if (container.innerText) {
// For other elements, try to use the innerText attribute.
return container.innerText;
}
return null;
};
let loaded = false;
if (!loaded) {
prefetchStrings('core', [
'textcopiedtoclipboard',
'unabletocopytoclipboard',
]);
// Add event listeners.
addEventListeners();
loaded = true;
}
+579
View File
@@ -0,0 +1,579 @@
// 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 module provides a wrapper to encapsulate a lot of the common combinations of
* user interaction we use in Moodle.
*
* @module core/custom_interaction_events
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.2
*/
define(['jquery', 'core/key_codes'], function($, keyCodes) {
// The list of events provided by this module. Namespaced to avoid clashes.
var events = {
activate: 'cie:activate',
keyboardActivate: 'cie:keyboardactivate',
escape: 'cie:escape',
down: 'cie:down',
up: 'cie:up',
home: 'cie:home',
end: 'cie:end',
next: 'cie:next',
previous: 'cie:previous',
asterix: 'cie:asterix',
scrollLock: 'cie:scrollLock',
scrollTop: 'cie:scrollTop',
scrollBottom: 'cie:scrollBottom',
ctrlPageUp: 'cie:ctrlPageUp',
ctrlPageDown: 'cie:ctrlPageDown',
enter: 'cie:enter',
accessibleChange: 'cie:accessibleChange',
};
// Static cache of jQuery events that have been handled. This should
// only be populated by JavaScript generated events (which will keep it
// fairly small).
var triggeredEvents = {};
/**
* Check if the caller has asked for the given event type to be
* registered.
*
* @method shouldAddEvent
* @private
* @param {string} eventType name of the event (see events above)
* @param {array} include the list of events to be added
* @return {bool} true if the event should be added, false otherwise.
*/
var shouldAddEvent = function(eventType, include) {
include = include || [];
if (include.length && include.indexOf(eventType) !== -1) {
return true;
}
return false;
};
/**
* Check if any of the modifier keys have been pressed on the event.
*
* @method isModifierPressed
* @private
* @param {event} e jQuery event
* @return {bool} true if shift, meta (command on Mac), alt or ctrl are pressed
*/
var isModifierPressed = function(e) {
return (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey);
};
/**
* Trigger the custom event for the given jQuery event.
*
* This function will only fire the custom event if one hasn't already been
* fired for the jQuery event.
*
* This is to prevent multiple custom event handlers triggering multiple
* custom events for a single jQuery event as it bubbles up the stack.
*
* @param {string} eventName The name of the custom event
* @param {event} e The jQuery event
* @return {void}
*/
var triggerEvent = function(eventName, e) {
var eventTypeKey = "";
if (!e.hasOwnProperty('originalEvent')) {
// This is a jQuery event generated from JavaScript not a browser event so
// we need to build the cache key for the event.
eventTypeKey = "" + eventName + e.type + e.timeStamp;
if (!triggeredEvents.hasOwnProperty(eventTypeKey)) {
// If we haven't seen this jQuery event before then fire a custom
// event for it and remember the event for later.
triggeredEvents[eventTypeKey] = true;
$(e.target).trigger(eventName, [{originalEvent: e}]);
}
return;
}
eventTypeKey = "triggeredCustom_" + eventName;
if (!e.originalEvent.hasOwnProperty(eventTypeKey)) {
// If this is a jQuery event generated by the browser then set a
// property on the original event to track that we've seen it before.
// The property is set on the original event because it's the only part
// of the jQuery event that is maintained through multiple event handlers.
e.originalEvent[eventTypeKey] = true;
$(e.target).trigger(eventName, [{originalEvent: e}]);
return;
}
};
/**
* Register a keyboard event that ignores modifier keys.
*
* @method addKeyboardEvent
* @private
* @param {object} element A jQuery object of the element to bind events to
* @param {string} event The custom interaction event name
* @param {int} keyCode The key code.
*/
var addKeyboardEvent = function(element, event, keyCode) {
element.off('keydown.' + event).on('keydown.' + event, function(e) {
if (!isModifierPressed(e)) {
if (e.keyCode == keyCode) {
triggerEvent(event, e);
}
}
});
};
/**
* Trigger the activate event on the given element if it is clicked or the enter
* or space key are pressed without a modifier key.
*
* @method addActivateListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addActivateListener = function(element) {
element.off('click.cie.activate').on('click.cie.activate', function(e) {
triggerEvent(events.activate, e);
});
element.off('keydown.cie.activate').on('keydown.cie.activate', function(e) {
if (!isModifierPressed(e)) {
if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
triggerEvent(events.activate, e);
}
}
});
};
/**
* Trigger the keyboard activate event on the given element if the enter
* or space key are pressed without a modifier key.
*
* @method addKeyboardActivateListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addKeyboardActivateListener = function(element) {
element.off('keydown.cie.keyboardactivate').on('keydown.cie.keyboardactivate', function(e) {
if (!isModifierPressed(e)) {
if (e.keyCode == keyCodes.enter || e.keyCode == keyCodes.space) {
triggerEvent(events.keyboardActivate, e);
}
}
});
};
/**
* Trigger the escape event on the given element if the escape key is pressed
* without a modifier key.
*
* @method addEscapeListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addEscapeListener = function(element) {
addKeyboardEvent(element, events.escape, keyCodes.escape);
};
/**
* Trigger the down event on the given element if the down arrow key is pressed
* without a modifier key.
*
* @method addDownListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addDownListener = function(element) {
addKeyboardEvent(element, events.down, keyCodes.arrowDown);
};
/**
* Trigger the up event on the given element if the up arrow key is pressed
* without a modifier key.
*
* @method addUpListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addUpListener = function(element) {
addKeyboardEvent(element, events.up, keyCodes.arrowUp);
};
/**
* Trigger the home event on the given element if the home key is pressed
* without a modifier key.
*
* @method addHomeListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addHomeListener = function(element) {
addKeyboardEvent(element, events.home, keyCodes.home);
};
/**
* Trigger the end event on the given element if the end key is pressed
* without a modifier key.
*
* @method addEndListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addEndListener = function(element) {
addKeyboardEvent(element, events.end, keyCodes.end);
};
/**
* Trigger the next event on the given element if the right arrow key is pressed
* without a modifier key in LTR mode or left arrow key in RTL mode.
*
* @method addNextListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addNextListener = function(element) {
// Left and right are flipped in RTL mode.
var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowLeft : keyCodes.arrowRight;
addKeyboardEvent(element, events.next, keyCode);
};
/**
* Trigger the previous event on the given element if the left arrow key is pressed
* without a modifier key in LTR mode or right arrow key in RTL mode.
*
* @method addPreviousListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addPreviousListener = function(element) {
// Left and right are flipped in RTL mode.
var keyCode = $('html').attr('dir') == "rtl" ? keyCodes.arrowRight : keyCodes.arrowLeft;
addKeyboardEvent(element, events.previous, keyCode);
};
/**
* Trigger the asterix event on the given element if the asterix key is pressed
* without a modifier key.
*
* @method addAsterixListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addAsterixListener = function(element) {
addKeyboardEvent(element, events.asterix, keyCodes.asterix);
};
/**
* Trigger the scrollTop event on the given element if the user scrolls to
* the top of the given element.
*
* @method addScrollTopListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addScrollTopListener = function(element) {
element.off('scroll.cie.scrollTop').on('scroll.cie.scrollTop', function(e) {
var scrollTop = element.scrollTop();
if (scrollTop === 0) {
triggerEvent(events.scrollTop, e);
}
});
};
/**
* Trigger the scrollBottom event on the given element if the user scrolls to
* the bottom of the given element.
*
* @method addScrollBottomListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addScrollBottomListener = function(element) {
element.off('scroll.cie.scrollBottom').on('scroll.cie.scrollBottom', function(e) {
var scrollTop = element.scrollTop();
var innerHeight = element.innerHeight();
var scrollHeight = element[0].scrollHeight;
if (scrollTop + innerHeight >= scrollHeight) {
triggerEvent(events.scrollBottom, e);
}
});
};
/**
* Trigger the scrollLock event on the given element if the user scrolls to
* the bottom or top of the given element.
*
* @method addScrollLockListener
* @private
* @param {jQuery} element jQuery object to add event listeners to
*/
var addScrollLockListener = function(element) {
// Lock mousewheel scrolling within the element to stop the annoying window scroll.
element.off('DOMMouseScroll.cie.DOMMouseScrollLock mousewheel.cie.mousewheelLock')
.on('DOMMouseScroll.cie.DOMMouseScrollLock mousewheel.cie.mousewheelLock', function(e) {
var scrollTop = element.scrollTop();
var scrollHeight = element[0].scrollHeight;
var height = element.height();
var delta = (e.type == 'DOMMouseScroll' ?
e.originalEvent.detail * -40 :
e.originalEvent.wheelDelta);
var up = delta > 0;
if (!up && -delta > scrollHeight - height - scrollTop) {
// Scrolling down past the bottom.
element.scrollTop(scrollHeight);
e.stopPropagation();
e.preventDefault();
e.returnValue = false;
// Fire the scroll lock event.
triggerEvent(events.scrollLock, e);
return false;
} else if (up && delta > scrollTop) {
// Scrolling up past the top.
element.scrollTop(0);
e.stopPropagation();
e.preventDefault();
e.returnValue = false;
// Fire the scroll lock event.
triggerEvent(events.scrollLock, e);
return false;
}
return true;
});
};
/**
* Trigger the ctrlPageUp event on the given element if the user presses the
* control and page up key.
*
* @method addCtrlPageUpListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addCtrlPageUpListener = function(element) {
element.off('keydown.cie.ctrlpageup').on('keydown.cie.ctrlpageup', function(e) {
if (e.ctrlKey) {
if (e.keyCode == keyCodes.pageUp) {
triggerEvent(events.ctrlPageUp, e);
}
}
});
};
/**
* Trigger the ctrlPageDown event on the given element if the user presses the
* control and page down key.
*
* @method addCtrlPageDownListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addCtrlPageDownListener = function(element) {
element.off('keydown.cie.ctrlpagedown').on('keydown.cie.ctrlpagedown', function(e) {
if (e.ctrlKey) {
if (e.keyCode == keyCodes.pageDown) {
triggerEvent(events.ctrlPageDown, e);
}
}
});
};
/**
* Trigger the enter event on the given element if the enter key is pressed
* without a modifier key.
*
* @method addEnterListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addEnterListener = function(element) {
addKeyboardEvent(element, events.enter, keyCodes.enter);
};
/**
* Trigger the AccessibleChange event on the given element if the value of the element is changed.
*
* @method addAccessibleChangeListener
* @private
* @param {object} element jQuery object to add event listeners to
*/
var addAccessibleChangeListener = function(element) {
var onMac = navigator.userAgent.indexOf('Macintosh') !== -1;
var touchEnabled = ('ontouchstart' in window) || (('msMaxTouchPoints' in navigator) && (navigator.msMaxTouchPoints > 0));
if (onMac || touchEnabled) {
// On Mac devices, and touch-enabled devices, the change event seems to be handled correctly and
// consistently at this time.
element.on('change', function(e) {
triggerEvent(events.accessibleChange, e);
});
} else {
// Some browsers have non-normalised behaviour for handling the selection of values in a <select> element.
// When using Chrome on Linux (and possibly others), a 'change' event is fired when pressing the Escape key.
// When using Firefox on Linux (and possibly others), a 'change' event is fired when navigating through the
// list with a keyboard.
//
// To normalise these behaviours:
// - the initial value is stored in a data attribute when focusing the element
// - the current value is checked against the stored initial value when and the accessibleChange event fired when:
// --- blurring the element
// --- the 'Enter' key is pressed
// --- the element is clicked
// --- the 'change' event is fired, except where it is from a keyboard interaction
//
// To facilitate the change event keyboard interaction check, the 'keyDown' handler sets a flag to ignore
// the change event handler which is unset on the 'keyUp' event.
//
// Unfortunately we cannot control this entirely as some browsers (Chrome) trigger a change event when
// pressign the Escape key, and this is considered to be the correct behaviour.
// Chrome https://bugs.chromium.org/p/chromium/issues/detail?id=839717
//
// Our longer-term solution to this should be to switch away from using <select> boxes as a single-select,
// and make use of a dropdown of action links like the Bootstrap Dropdown menu.
var setInitialValue = function(target) {
target.dataset.initValue = target.value;
};
var resetToInitialValue = function(target) {
if ('initValue' in target.dataset) {
target.value = target.dataset.initValue;
}
};
var checkAndTriggerAccessibleChange = function(e) {
if (!('initValue' in e.target.dataset)) {
// Some browsers trigger click before focus, therefore it is possible that initValue is undefined.
// In this case it's likely that it's being focused for the first time and we should therefore not submit.
return;
}
if (e.target.value !== e.target.dataset.initValue) {
// Update the initValue when the event is triggered.
// This means that if the click handler fires before the focus handler on a subsequent interaction
// with the element, the currently dispalyed value will be the best guess current value.
e.target.dataset.initValue = e.target.value;
triggerEvent(events.accessibleChange, e);
}
};
var nativeElement = element.get()[0];
// The `focus` and `blur` events do not support bubbling. Use Event Capture instead.
nativeElement.addEventListener('focus', function(e) {
setInitialValue(e.target);
}, true);
nativeElement.addEventListener('blur', function(e) {
checkAndTriggerAccessibleChange(e);
}, true);
element.on('keydown', function(e) {
if ((e.which === keyCodes.enter)) {
checkAndTriggerAccessibleChange(e);
} else if (e.which === keyCodes.escape) {
resetToInitialValue(e.target);
e.target.dataset.ignoreChange = true;
} else {
// Firefox triggers a change event when using the keyboard to scroll through the selection.
// Set a data- attribute that the change listener can use to ignore the change event where it was
// generated from a keyboard change such as typing to complete a value, or using arrow keys.
e.target.dataset.ignoreChange = true;
}
});
element.on('change', function(e) {
if (e.target.dataset.ignoreChange) {
// This change event was triggered from a keyboard change which is not yet complete.
// Do not trigger the accessibleChange event until the selection is completed using the [return]
// key.
return;
}
checkAndTriggerAccessibleChange(e);
});
element.on('keyup', function(e) {
// The key has been lifted. Stop ignoring the change event.
delete e.target.dataset.ignoreChange;
});
element.on('click', function(e) {
checkAndTriggerAccessibleChange(e);
});
}
};
/**
* Get the list of events and their handlers.
*
* @method getHandlers
* @private
* @return {object} object key of event names and value of handler functions
*/
var getHandlers = function() {
var handlers = {};
handlers[events.activate] = addActivateListener;
handlers[events.keyboardActivate] = addKeyboardActivateListener;
handlers[events.escape] = addEscapeListener;
handlers[events.down] = addDownListener;
handlers[events.up] = addUpListener;
handlers[events.home] = addHomeListener;
handlers[events.end] = addEndListener;
handlers[events.next] = addNextListener;
handlers[events.previous] = addPreviousListener;
handlers[events.asterix] = addAsterixListener;
handlers[events.scrollLock] = addScrollLockListener;
handlers[events.scrollTop] = addScrollTopListener;
handlers[events.scrollBottom] = addScrollBottomListener;
handlers[events.ctrlPageUp] = addCtrlPageUpListener;
handlers[events.ctrlPageDown] = addCtrlPageDownListener;
handlers[events.enter] = addEnterListener;
handlers[events.accessibleChange] = addAccessibleChangeListener;
return handlers;
};
/**
* Add all of the listeners on the given element for the requested events.
*
* @method define
* @public
* @param {object} element the DOM element to register event listeners on
* @param {array} include the array of events to be triggered
*/
var define = function(element, include) {
element = $(element);
include = include || [];
if (!element.length || !include.length) {
return;
}
$.each(getHandlers(), function(eventType, handler) {
if (shouldAddEvent(eventType, include)) {
handler(element);
}
});
};
return {
define: define,
events: events,
};
});
+491
View File
@@ -0,0 +1,491 @@
// 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/>.
/**
* Data filter management.
*
* @module core/datafilter
* @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import CourseFilter from 'core/datafilter/filtertypes/courseid';
import GenericFilter from 'core/datafilter/filtertype';
import {getStrings} from 'core/str';
import Notification from 'core/notification';
import Pending from 'core/pending';
import Selectors from 'core/datafilter/selectors';
import Templates from 'core/templates';
import CustomEvents from 'core/custom_interaction_events';
import jQuery from 'jquery';
export default class {
/**
* Initialise the filter on the element with the given filterSet and callback.
*
* @param {HTMLElement} filterSet The filter element.
* @param {Function} applyCallback Callback function when updateTableFromFilter
*/
constructor(filterSet, applyCallback) {
this.filterSet = filterSet;
this.applyCallback = applyCallback;
// Keep a reference to all of the active filters.
this.activeFilters = {
courseid: new CourseFilter('courseid', filterSet),
};
}
/**
* Initialise event listeners to the filter.
*/
init() {
// Add listeners for the main actions.
this.filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
if (e.target.closest(Selectors.filterset.actions.addRow)) {
e.preventDefault();
this.addFilterRow();
}
if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
e.preventDefault();
this.updateTableFromFilter();
}
if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
e.preventDefault();
this.removeAllFilters();
}
});
// Add the listener to remove a single filter.
this.filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
if (e.target.closest(Selectors.filter.actions.remove)) {
e.preventDefault();
this.removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region), true);
}
});
// Add listeners for the filter type selection.
let filterRegion = jQuery(this.getFilterRegion());
CustomEvents.define(filterRegion, [CustomEvents.events.accessibleChange]);
filterRegion.on(CustomEvents.events.accessibleChange, e => {
const typeField = e.target.closest(Selectors.filter.fields.type);
if (typeField && typeField.value) {
const filter = e.target.closest(Selectors.filter.region);
this.addFilter(filter, typeField.value);
}
});
this.filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
this.filterSet.dataset.filterverb = e.target.value;
});
}
/**
* Get the filter list region.
*
* @return {HTMLElement}
*/
getFilterRegion() {
return this.filterSet.querySelector(Selectors.filterset.regions.filterlist);
}
/**
* Add a filter row.
*
* @param {Object} filterdata Optional, data for adding for row with an existing filter.
* @return {Promise}
*/
addFilterRow(filterdata = {}) {
const pendingPromise = new Pending('core/datafilter:addFilterRow');
const rownum = filterdata.rownum ?? 1 + this.getFilterRegion().querySelectorAll(Selectors.filter.region).length;
return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rownum})
.then(({html, js}) => {
const newContentNodes = Templates.appendNodeContents(this.getFilterRegion(), html, js);
return newContentNodes;
})
.then(filterRow => {
// Note: This is a nasty hack.
// We should try to find a better way of doing this.
// We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
// it in place.
const typeList = this.filterSet.querySelector(Selectors.data.typeList);
filterRow.forEach(contentNode => {
const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
if (contentTypeList) {
contentTypeList.innerHTML = typeList.innerHTML;
}
});
return filterRow;
})
.then(filterRow => {
this.updateFiltersOptions();
return filterRow;
})
.then(result => {
pendingPromise.resolve();
// If an existing filter is passed in, add it. Otherwise, leave the row empty.
if (filterdata.filtertype) {
result.forEach(filter => {
this.addFilter(filter, filterdata.filtertype, filterdata.values,
filterdata.jointype, filterdata.filteroptions);
});
}
return result;
})
.catch(Notification.exception);
}
/**
* Get the filter data source node fro the specified filter type.
*
* @param {String} filterType
* @return {HTMLElement}
*/
getFilterDataSource(filterType) {
const filterDataNode = this.filterSet.querySelector(Selectors.filterset.regions.datasource);
return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
}
/**
* Add a filter to the list of active filters, performing any necessary setup.
*
* @param {HTMLElement} filterRow
* @param {String} filterType
* @param {Array} initialFilterValues The initially selected values for the filter
* @param {String} filterJoin
* @param {Object} filterOptions
* @returns {Filter}
*/
async addFilter(filterRow, filterType, initialFilterValues, filterJoin, filterOptions) {
// Name the filter on the filter row.
filterRow.dataset.filterType = filterType;
const filterDataNode = this.getFilterDataSource(filterType);
// Instantiate the Filter class.
let Filter = GenericFilter;
if (filterDataNode.dataset.filterTypeClass) {
Filter = await import(filterDataNode.dataset.filterTypeClass);
}
this.activeFilters[filterType] = new Filter(filterType, this.filterSet, initialFilterValues, filterOptions);
// Disable the select.
const typeField = filterRow.querySelector(Selectors.filter.fields.type);
typeField.value = filterType;
typeField.disabled = 'disabled';
// Update the join list.
this.updateJoinList(JSON.parse(filterDataNode.dataset.joinList), filterRow);
const joinField = filterRow.querySelector(Selectors.filter.fields.join);
if (!isNaN(filterJoin)) {
joinField.value = filterJoin;
}
// Update the list of available filter types.
this.updateFiltersOptions();
return this.activeFilters[filterType];
}
/**
* Get the registered filter class for the named filter.
*
* @param {String} name
* @return {Object} See the Filter class.
*/
getFilterObject(name) {
return this.activeFilters[name];
}
/**
* Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row,
* that it is replaced instead of being removed.
*
* @param {HTMLElement} filterRow
* @param {Bool} refreshContent Whether to refresh the table content when removing
*/
removeOrReplaceFilterRow(filterRow, refreshContent) {
const filterCount = this.getFilterRegion().querySelectorAll(Selectors.filter.region).length;
if (filterCount === 1) {
this.replaceFilterRow(filterRow, refreshContent);
} else {
this.removeFilterRow(filterRow, refreshContent);
}
}
/**
* Remove the specified filter row and associated class.
*
* @param {HTMLElement} filterRow
* @param {Bool} refreshContent Whether to refresh the table content when removing
*/
async removeFilterRow(filterRow, refreshContent = true) {
if (filterRow.querySelector(Selectors.data.required)) {
return;
}
const filterType = filterRow.querySelector(Selectors.filter.fields.type);
const hasFilterValue = !!filterType.value;
// Remove the filter object.
this.removeFilterObject(filterRow.dataset.filterType);
// Remove the actual filter HTML.
filterRow.remove();
// Update the list of available filter types.
this.updateFiltersOptions();
if (hasFilterValue && refreshContent) {
// Refresh the table if there was any content in this row.
this.updateTableFromFilter();
}
// Update filter fieldset legends.
const filterLegends = await this.getAvailableFilterLegends();
this.getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
filterRow.querySelector('legend').innerText = filterLegends[index];
});
}
/**
* Replace the specified filter row with a new one.
*
* @param {HTMLElement} filterRow
* @param {Bool} refreshContent Whether to refresh the table content when removing
* @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
* @return {Promise}
*/
replaceFilterRow(filterRow, refreshContent = true, rowNum = 1) {
if (filterRow.querySelector(Selectors.data.required)) {
return;
}
// Remove the filter object.
this.removeFilterObject(filterRow.dataset.filterType);
return Templates.renderForPromise('core/datafilter/filter_row', {"rownumber": rowNum})
.then(({html, js}) => {
const newContentNodes = Templates.replaceNode(filterRow, html, js);
return newContentNodes;
})
.then(filterRow => {
// Note: This is a nasty hack.
// We should try to find a better way of doing this.
// We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
// it in place.
const typeList = this.filterSet.querySelector(Selectors.data.typeList);
filterRow.forEach(contentNode => {
const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
if (contentTypeList) {
contentTypeList.innerHTML = typeList.innerHTML;
}
});
return filterRow;
})
.then(filterRow => {
this.updateFiltersOptions();
return filterRow;
})
.then(filterRow => {
// Refresh the table.
if (refreshContent) {
return this.updateTableFromFilter();
} else {
return filterRow;
}
})
.catch(Notification.exception);
}
/**
* Remove the Filter Object from the register.
*
* @param {string} filterName The name of the filter to be removed
*/
removeFilterObject(filterName) {
if (filterName) {
const filter = this.getFilterObject(filterName);
if (filter) {
filter.tearDown();
// Remove from the list of active filters.
delete this.activeFilters[filterName];
}
}
}
/**
* Remove all filters.
*
* @returns {Promise}
*/
removeAllFilters() {
const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
filters.forEach(filterRow => this.removeOrReplaceFilterRow(filterRow, false));
// Refresh the table.
return this.updateTableFromFilter();
}
/**
* Remove any empty filters.
*/
removeEmptyFilters() {
const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
filters.forEach(filterRow => {
const filterType = filterRow.querySelector(Selectors.filter.fields.type);
if (!filterType.value) {
this.removeOrReplaceFilterRow(filterRow, false);
}
});
}
/**
* Update the list of filter types to filter out those already selected.
*/
updateFiltersOptions() {
const filters = this.getFilterRegion().querySelectorAll(Selectors.filter.region);
filters.forEach(filterRow => {
const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option');
options.forEach(option => {
if (option.value === filterRow.dataset.filterType) {
option.classList.remove('hidden');
option.disabled = false;
} else if (this.activeFilters[option.value]) {
option.classList.add('hidden');
option.disabled = true;
} else {
option.classList.remove('hidden');
option.disabled = false;
}
});
});
// Configure the state of the "Add row" button.
// This button is disabled when there is a filter row available for each condition.
const addRowButton = this.filterSet.querySelector(Selectors.filterset.actions.addRow);
const filterDataNode = this.filterSet.querySelectorAll(Selectors.data.fields.all);
if (filterDataNode.length <= filters.length) {
addRowButton.setAttribute('disabled', 'disabled');
} else {
addRowButton.removeAttribute('disabled');
}
if (filters.length === 1) {
this.filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.add('hidden');
this.filterSet.querySelector(Selectors.filterset.fields.join).value = 2;
this.filterSet.dataset.filterverb = 2;
} else {
this.filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden');
}
}
/**
* Update the Dynamic table based upon the current filter.
*/
updateTableFromFilter() {
const pendingPromise = new Pending('core/datafilter:updateTableFromFilter');
const filters = {};
Object.values(this.activeFilters).forEach(filter => {
filters[filter.filterValue.name] = filter.filterValue;
});
if (this.applyCallback) {
this.applyCallback(filters, pendingPromise);
}
}
/**
* Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
*
* @return {array}
*/
async getAvailableFilterLegends() {
const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
let requests = [];
[...Array(maxFilters)].forEach((_, rowIndex) => {
requests.push({
"key": "filterrowlegend",
"component": "core",
// Add 1 since rows begin at 1 (index begins at zero).
"param": rowIndex + 1
});
});
const legendStrings = await getStrings(requests)
.then(fetchedStrings => {
return fetchedStrings;
})
.catch(Notification.exception);
return legendStrings;
}
/**
* Update the list of join types for a filter.
*
* This will update the list of join types based on the allowed types defined for a filter.
* If only one type is allowed, the list will be hidden.
*
* @param {Array} filterJoinList Array of join types, a subset of the regularJoinList array in this function.
* @param {Element} filterRow The row being updated.
*/
updateJoinList(filterJoinList, filterRow) {
const regularJoinList = [0, 1, 2];
// If a join list was specified for this filter, find the default join list and disable the options that are not allowed
// for this filter.
if (filterJoinList.length !== 0) {
const joinField = filterRow.querySelector(Selectors.filter.fields.join);
// Check each option from the default list, and disable the option in this filter row if it is not allowed
// for this filter.
regularJoinList.forEach((join) => {
if (!filterJoinList.includes(join)) {
joinField.options[join].classList.add('hidden');
joinField.options[join].disabled = true;
}
});
// Now remove the disabled options, and hide the select list of there is only one option left.
joinField.options.forEach((element, index) => {
if (element.disabled) {
joinField.options[index] = null;
}
});
if (joinField.options.length === 1) {
joinField.hidden = true;
}
}
}
}
+257
View File
@@ -0,0 +1,257 @@
// 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/>.
/**
* Base Filter class for a filter type in the filter UI.
*
* @module core/datafilter/filtertype
* @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Autocomplete from 'core/form-autocomplete';
import Selectors from 'core/datafilter/selectors';
import {getString} from 'core/str';
import Notification from 'core/notification';
/**
* Fetch all checked options in the select.
*
* This is a poor-man's polyfill for select.selectedOptions, which is not available in IE11.
*
* @param {HTMLSelectElement} select
* @returns {HTMLOptionElement[]} All selected options
*/
const getOptionsForSelect = select => {
return select.querySelectorAll(':checked');
};
export default class {
/**
* Constructor for a new filter.
*
* @param {String} filterType The type of filter that this relates to
* @param {HTMLElement} rootNode The root node for the participants filterset
* @param {Array} initialValues The initial values for the selector
*/
constructor(filterType, rootNode, initialValues) {
this.filterType = filterType;
this.rootNode = rootNode;
this.addValueSelector(initialValues).then(() => {
const filterRoot = this.filterRoot;
if (filterRoot && filterRoot.querySelector(Selectors.data.required)) {
filterRoot.querySelector(Selectors.filter.actions.remove).remove();
}
return filterRoot;
}).catch(Notification.exception);
}
/**
* Perform any tear-down for this filter type.
*/
tearDown() {
// eslint-disable-line no-empty-function
}
/**
* Get the placeholder to use when showing the value selector.
*
* @return {Promise} Resolving to a String
*/
get placeholder() {
return getString('placeholdertypeorselect', 'core');
}
/**
* Whether to show suggestions in the autocomplete.
*
* @return {Boolean}
*/
get showSuggestions() {
return true;
}
/**
* Add the value selector to the filter row.
*
* @param {Array} initialValues
* @return {Promise}
*/
async addValueSelector(initialValues = []) {
const filterValueNode = this.getFilterValueNode();
// Copy the data in place.
const sourceDataNode = this.getSourceDataForFilter();
if (!sourceDataNode) {
throw new Error('No source data for filter.');
}
filterValueNode.innerHTML = sourceDataNode.outerHTML;
const dataSource = filterValueNode.querySelector('select');
// Set an ID for this filter value element.
dataSource.id = 'filter-value-' + dataSource.getAttribute('data-field-name');
// Create a hidden label for the filter value.
const filterValueLabel = document.createElement('label');
filterValueLabel.setAttribute('for', dataSource.id);
filterValueLabel.classList.add('sr-only');
filterValueLabel.innerText = dataSource.getAttribute('data-field-title');
// Append this label to the filter value container.
filterValueNode.appendChild(filterValueLabel);
// If there are any initial values then attempt to apply them.
initialValues.forEach(filterValue => {
let selectedOption = dataSource.querySelector(`option[value="${filterValue}"]`);
if (selectedOption) {
selectedOption.selected = true;
} else if (!this.showSuggestions) {
selectedOption = document.createElement('option');
selectedOption.value = filterValue;
selectedOption.innerHTML = filterValue;
selectedOption.selected = true;
dataSource.append(selectedOption);
}
});
return Autocomplete.enhance(
// The source select element.
dataSource,
// Whether to allow 'tags' (custom entries).
dataSource.dataset.allowCustom == "1",
// We do not require AJAX at all as standard.
null,
// The string to use as a placeholder.
await this.placeholder,
// Disable case sensitivity on searches.
false,
// Show suggestions.
this.showSuggestions,
// Do not override the 'no suggestions' string.
null,
// Close the suggestions if this is not a multi-select.
!dataSource.multiple,
// Template overrides.
{
items: 'core/datafilter/autocomplete_selection_items',
layout: 'core/datafilter/autocomplete_layout',
selection: 'core/datafilter/autocomplete_selection',
}
);
}
/**
* Get the root node for this filter.
*
* @returns {HTMLElement}
*/
get filterRoot() {
return this.rootNode.querySelector(Selectors.filter.byName(this.filterType));
}
/**
* Get the possible data for this filter type.
*
* @returns {Array}
*/
getSourceDataForFilter() {
const filterDataNode = this.rootNode.querySelector(Selectors.filterset.regions.datasource);
return filterDataNode.querySelector(Selectors.data.fields.byName(this.filterType));
}
/**
* Get the HTMLElement which contains the value selector.
*
* @returns {HTMLElement}
*/
getFilterValueNode() {
return this.filterRoot.querySelector(Selectors.filter.regions.values);
}
/**
* Get the name of this filter.
*
* @returns {String}
*/
get name() {
return this.filterType;
}
/**
* Get the type of join specified.
*
* @returns {Number}
*/
get jointype() {
return parseInt(this.filterRoot.querySelector(Selectors.filter.fields.join).value, 10);
}
/**
* Get the list of raw values for this filter type.
*
* @returns {Array}
*/
get rawValues() {
const filterValueNode = this.getFilterValueNode();
const filterValueSelect = filterValueNode.querySelector('select');
return Object.values(getOptionsForSelect(filterValueSelect)).map(option => option.value);
}
/**
* Get the list of values for this filter type.
*
* @returns {Array}
*/
get values() {
return this.rawValues.map(option => parseInt(option, 10));
}
/**
* Get options specific to this filter type.
*
* @returns {Array} of {name:, value:} objects
*/
get filterOptions() {
return [];
}
/**
* Get the composed value for this filter.
*
* @returns {Object}
*/
get filterValue() {
return {
name: this.name,
jointype: this.jointype,
values: this.values,
filteroptions: this.filterOptions,
};
}
}
@@ -0,0 +1,111 @@
// 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/>.
/**
* Base filter for binary selector ie: (Yes / No).
*
* @module core/datafilter/filtertypes/binary
* @author 2022 Ghaly Marc-Alexandre <marc-alexandreghaly@catalyst-ca.net>
* @copyright 2022 Catalyst IT Australia Pty Ltd
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Filter from 'core/datafilter/filtertype';
import Selectors from 'core/datafilter/selectors';
import Templates from 'core/templates';
import {get_strings as getStrings} from 'core/str';
export default class extends Filter {
/**
* Text string for the first binary option.
*
* This option (and {@see optionTwo}) are set by {@see getTextValues()}. The base class will set default values,
* a subclass can override the method to define its own option.
*
* @type {String}
*/
optionOne;
/**
* Text string for the second binary option.
*
* @type {String}
*/
optionTwo;
/**
* Add the value selector to the filter row.
*
* @param {Array} initialValues The default value for the filter.
*/
async addValueSelector(initialValues) {
[this.optionOne, this.optionTwo] = await this.getTextValues();
return this.displayBinarySelection(initialValues[0]);
}
/**
* Fetch text values for select options.
*
* Subclasses should override this method to set their own options.
*
* @returns {Promise}
*/
getTextValues() {
return getStrings([{key: 'no'}, {key: 'yes'}]);
}
/**
* Renders yes/no select input with proper selection.
*
* @param {Number} initialValue The default value for the filter.
*/
async displayBinarySelection(initialValue = 0) {
// We specify a specific filterset in case there are multiple filtering condition - avoiding glitches.
const specificFilterSet = this.rootNode.querySelector(Selectors.filter.byName(this.filterType));
const sourceDataNode = this.getSourceDataForFilter();
const context = {
filtertype: this.filterType,
title: sourceDataNode.getAttribute('data-field-title'),
required: sourceDataNode.dataset.required,
options: [
{
text: this.optionOne,
value: 0,
selected: initialValue === 0,
},
{
text: this.optionTwo,
value: 1,
selected: initialValue === 1,
},
]
};
return Templates.render('core/datafilter/filtertypes/binary_selector', context)
.then((binaryUi, js) => {
return Templates.replaceNodeContents(specificFilterSet.querySelector(Selectors.filter.regions.values), binaryUi, js);
});
}
/**
* Get the list of raw values for this filter type.
*
* @returns {Array}
*/
get values() {
return [parseInt(this.filterRoot.querySelector(`[data-filterfield="${this.name}"]`).value)];
}
}
@@ -0,0 +1,36 @@
// 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/>.
/**
* Country filter
*
* @module core/datafilter/filtertypes/country
* @copyright 2021 Paul Holden <paulh@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Filter from 'core/datafilter/filtertype';
export default class extends Filter {
/**
* For country the final value is an array of country code strings
*
* @return {Object}
*/
get values() {
return this.rawValues;
}
}
@@ -0,0 +1,46 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Course ID filter.
*
* @module core/datafilter/filtertypes/courseid
* @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Filter from 'core/datafilter/filtertype';
export default class extends Filter {
constructor(filterType, filterSet) {
super(filterType, filterSet);
}
async addValueSelector() {
// eslint-disable-line no-empty-function
}
/**
* Get the composed value for this filter.
*
* @returns {Object}
*/
get filterValue() {
return {
name: this.name,
jointype: 1,
values: [parseInt(this.rootNode.dataset.tableCourseId, 10)],
};
}
}
@@ -0,0 +1,53 @@
// 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/>.
/**
* Keyword filter.
*
* @module core/datafilter/filtertypes/keyword
* @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Filter from 'core/datafilter/filtertype';
import {getString} from 'core/str';
export default class extends Filter {
/**
* For keywords the final value is an Array of strings.
*
* @returns {Object}
*/
get values() {
return this.rawValues;
}
/**
* Get the placeholder to use when showing the value selector.
*
* @return {Promise} Resolving to a String
*/
get placeholder() {
return getString('placeholdertype', 'core_user');
}
/**
* Whether to show suggestions in the autocomplete.
*
* @return {Boolean}
*/
get showSuggestions() {
return false;
}
}
+69
View File
@@ -0,0 +1,69 @@
// 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/>.
/**
* Module containing the selectors for user filters.
*
* @module core/datafilter/selectors
* @copyright 2020 Michael Hawkins <michaelh@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
const getFilterRegion = region => `[data-filterregion="${region}"]`;
const getFilterAction = action => `[data-filteraction="${action}"]`;
const getFilterField = field => `[data-filterfield="${field}"]`;
export default {
filter: {
region: getFilterRegion('filter'),
actions: {
remove: getFilterAction('remove'),
},
fields: {
join: getFilterField('join'),
type: getFilterField('type'),
},
regions: {
values: getFilterRegion('value'),
},
byName: name => `${getFilterRegion('filter')}[data-filter-type="${name}"]`,
},
filterset: {
region: getFilterRegion('actions'),
actions: {
addRow: getFilterAction('add'),
applyFilters: getFilterAction('apply'),
resetFilters: getFilterAction('reset'),
},
regions: {
filtermatch: getFilterRegion('filtermatch'),
filterlist: getFilterRegion('filters'),
datasource: getFilterRegion('filtertypedata'),
emptyFilterRow: `${getFilterRegion('filter')}[data-filter-type=""]`,
},
fields: {
join: `${getFilterRegion('filtermatch')} ${getFilterField('join')}`,
},
},
data: {
fields: {
byName: name => `[data-field-name="${name}"]`,
all: `${getFilterRegion('filtertypedata')} [data-field-name]`,
},
typeList: getFilterRegion('filtertypelist'),
typeListSelect: `select${getFilterRegion('filtertypelist')}`,
required: `${getFilterRegion('value')} > [data-required="1"]`,
},
};
+335
View File
@@ -0,0 +1,335 @@
// 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 handle drag operations, including automatic scrolling.
*
* Note: this module is defined statically. It is a singleton. You
* can only have one use of it active at any time. However, you
* can only drag one thing at a time, this is not a problem in practice.
*
* @module core/dragdrop
* @copyright 2016 The Open University
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.6
*/
define(['jquery', 'core/autoscroll'], function($, autoScroll) {
var dragdrop = {
/**
* A boolean or options argument depending on whether browser supports passive events.
* @private
*/
eventCaptureOptions: {passive: false, capture: true},
/**
* Drag proxy if any.
* @private
*/
dragProxy: null,
/**
* Function called on move.
* @private
*/
onMove: null,
/**
* Function called on drop.
* @private
*/
onDrop: null,
/**
* Initial position of proxy at drag start.
*/
initialPosition: null,
/**
* Initial page X of cursor at drag start.
*/
initialX: null,
/**
* Initial page Y of cursor at drag start.
*/
initialY: null,
/**
* If touch event is in progress, this will be the id, otherwise null
*/
touching: null,
/**
* Prepares to begin a drag operation - call with a mousedown or touchstart event.
*
* If the returned object has 'start' true, then you can set up a drag proxy, and call
* start. This function will call preventDefault automatically regardless of whether
* starting or not.
*
* @public
* @param {Object} event Event (should be either mousedown or touchstart)
* @return {Object} Object with start (boolean flag) and x, y (only if flag true) values
*/
prepare: function(event) {
event.preventDefault();
var start;
if (event.type === 'touchstart') {
// For touch, start if there's at least one touch and we are not currently doing
// a touch event.
start = (dragdrop.touching === null) && event.changedTouches.length > 0;
} else {
// For mousedown, start if it's the left button.
start = event.which === 1;
}
if (start) {
var details = dragdrop.getEventXY(event);
details.start = true;
return details;
} else {
return {start: false};
}
},
/**
* Call to start a drag operation, in response to a mouse down or touch start event.
* Normally call this after calling prepare and receiving start true (you can probably
* skip prepare if only supporting drag not touch).
*
* Note: The caller is responsible for creating a 'drag proxy' which is the
* thing that actually gets dragged. At present, this doesn't really work
* properly unless it is added directly within the body tag.
*
* You also need to ensure that there is CSS so the proxy is absolutely positioned,
* and styled to look like it is floating.
*
* You also need to absolutely position the proxy where you want it to start.
*
* @public
* @param {Object} event Event (should be either mousedown or touchstart)
* @param {jQuery} dragProxy An absolute-positioned element for dragging
* @param {Object} onMove Function that receives X and Y page locations for a move
* @param {Object} onDrop Function that receives X and Y page locations when dropped
*/
start: function(event, dragProxy, onMove, onDrop) {
var xy = dragdrop.getEventXY(event);
dragdrop.initialX = xy.x;
dragdrop.initialY = xy.y;
dragdrop.initialPosition = dragProxy.offset();
dragdrop.dragProxy = dragProxy;
dragdrop.onMove = onMove;
dragdrop.onDrop = onDrop;
switch (event.type) {
case 'mousedown':
// Cannot use jQuery 'on' because events need to not be passive.
dragdrop.addEventSpecial('mousemove', dragdrop.mouseMove);
dragdrop.addEventSpecial('mouseup', dragdrop.mouseUp);
break;
case 'touchstart':
dragdrop.addEventSpecial('touchend', dragdrop.touchEnd);
dragdrop.addEventSpecial('touchcancel', dragdrop.touchEnd);
dragdrop.addEventSpecial('touchmove', dragdrop.touchMove);
dragdrop.touching = event.changedTouches[0].identifier;
break;
default:
throw new Error('Unexpected event type: ' + event.type);
}
autoScroll.start(dragdrop.scroll);
},
/**
* Adds an event listener with special event capture options (capture, not passive). If the
* browser does not support passive events, it will fall back to the boolean for capture.
*
* @private
* @param {Object} event Event type string
* @param {Object} handler Handler function
*/
addEventSpecial: function(event, handler) {
try {
window.addEventListener(event, handler, dragdrop.eventCaptureOptions);
} catch (ex) {
dragdrop.eventCaptureOptions = true;
window.addEventListener(event, handler, dragdrop.eventCaptureOptions);
}
},
/**
* Gets X/Y co-ordinates of an event, which can be either touchstart or mousedown.
*
* @private
* @param {Object} event Event (should be either mousedown or touchstart)
* @return {Object} X/Y co-ordinates
*/
getEventXY: function(event) {
switch (event.type) {
case 'touchstart':
return {x: event.changedTouches[0].pageX,
y: event.changedTouches[0].pageY};
case 'mousedown':
return {x: event.pageX, y: event.pageY};
default:
throw new Error('Unexpected event type: ' + event.type);
}
},
/**
* Event handler for touch move.
*
* @private
* @param {Object} e Event
*/
touchMove: function(e) {
e.preventDefault();
for (var i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === dragdrop.touching) {
dragdrop.handleMove(e.changedTouches[i].pageX, e.changedTouches[i].pageY);
}
}
},
/**
* Event handler for mouse move.
*
* @private
* @param {Object} e Event
*/
mouseMove: function(e) {
dragdrop.handleMove(e.pageX, e.pageY);
},
/**
* Shared handler for move event (mouse or touch).
*
* @private
* @param {number} pageX X co-ordinate
* @param {number} pageY Y co-ordinate
*/
handleMove: function(pageX, pageY) {
// Move the drag proxy, not letting you move it out of screen or window bounds.
var current = dragdrop.dragProxy.offset();
var topOffset = current.top - parseInt(dragdrop.dragProxy.css('top'));
var leftOffset = current.left - parseInt(dragdrop.dragProxy.css('left'));
var maxY = $(document).height() - dragdrop.dragProxy.outerHeight() - topOffset;
var maxX = $(document).width() - dragdrop.dragProxy.outerWidth() - leftOffset;
var minY = -topOffset;
var minX = -leftOffset;
var initial = dragdrop.initialPosition;
var position = {
top: Math.max(minY, Math.min(maxY, initial.top + (pageY - dragdrop.initialY) - topOffset)),
left: Math.max(minX, Math.min(maxX, initial.left + (pageX - dragdrop.initialX) - leftOffset))
};
dragdrop.dragProxy.css(position);
// Trigger move handler.
dragdrop.onMove(pageX, pageY, dragdrop.dragProxy);
},
/**
* Event handler for touch end.
*
* @private
* @param {Object} e Event
*/
touchEnd: function(e) {
e.preventDefault();
for (var i = 0; i < e.changedTouches.length; i++) {
if (e.changedTouches[i].identifier === dragdrop.touching) {
dragdrop.handleEnd(e.changedTouches[i].pageX, e.changedTouches[i].pageY);
}
}
},
/**
* Event handler for mouse up.
*
* @private
* @param {Object} e Event
*/
mouseUp: function(e) {
dragdrop.handleEnd(e.pageX, e.pageY);
},
/**
* Shared handler for end drag (mouse or touch).
*
* @private
* @param {number} pageX X
* @param {number} pageY Y
*/
handleEnd: function(pageX, pageY) {
if (dragdrop.touching !== null) {
window.removeEventListener('touchend', dragdrop.touchEnd, dragdrop.eventCaptureOptions);
window.removeEventListener('touchcancel', dragdrop.touchEnd, dragdrop.eventCaptureOptions);
window.removeEventListener('touchmove', dragdrop.touchMove, dragdrop.eventCaptureOptions);
dragdrop.touching = null;
} else {
window.removeEventListener('mousemove', dragdrop.mouseMove, dragdrop.eventCaptureOptions);
window.removeEventListener('mouseup', dragdrop.mouseUp, dragdrop.eventCaptureOptions);
}
autoScroll.stop();
dragdrop.onDrop(pageX, pageY, dragdrop.dragProxy);
},
/**
* Called when the page scrolls.
*
* @private
* @param {number} offset Amount of scroll
*/
scroll: function(offset) {
// Move the proxy to match.
var maxY = $(document).height() - dragdrop.dragProxy.outerHeight();
var currentPosition = dragdrop.dragProxy.offset();
currentPosition.top = Math.min(maxY, currentPosition.top + offset);
dragdrop.dragProxy.css(currentPosition);
}
};
return {
/**
* Prepares to begin a drag operation - call with a mousedown or touchstart event.
*
* If the returned object has 'start' true, then you can set up a drag proxy, and call
* start. This function will call preventDefault automatically regardless of whether
* starting or not.
*
* @param {Object} event Event (should be either mousedown or touchstart)
* @return {Object} Object with start (boolean flag) and x, y (only if flag true) values
*/
prepare: dragdrop.prepare,
/**
* Call to start a drag operation, in response to a mouse down or touch start event.
* Normally call this after calling prepare and receiving start true (you can probably
* skip prepare if only supporting drag not touch).
*
* Note: The caller is responsible for creating a 'drag proxy' which is the
* thing that actually gets dragged. At present, this doesn't really work
* properly unless it is added directly within the body tag.
*
* You also need to ensure that there is CSS so the proxy is absolutely positioned,
* and styled to look like it is floating.
*
* You also need to absolutely position the proxy where you want it to start.
*
* @param {Object} event Event (should be either mousedown or touchstart)
* @param {jQuery} dragProxy An absolute-positioned element for dragging
* @param {Object} onMove Function that receives X and Y page locations for a move
* @param {Object} onDrop Function that receives X and Y page locations when dropped
*/
start: dragdrop.start
};
});
+133
View File
@@ -0,0 +1,133 @@
// 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/>.
/**
* Controls the drawer.
*
* @module core/drawer
* @copyright 2019 Jun Pataleta <jun@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import $ from 'jquery';
import * as PubSub from 'core/pubsub';
import * as Aria from 'core/aria';
import DrawerEvents from 'core/drawer_events';
/**
* Show the drawer.
*
* @param {Object} root The drawer container.
*/
const show = root => {
// Ensure that it is a jQuery.
root = $(root);
Aria.unhide(root.get());
root.removeClass('hidden');
root.attr('aria-expanded', true);
root.focus();
PubSub.publish(DrawerEvents.DRAWER_SHOWN, root);
};
/**
* Hide the drawer.
*
* @param {Object} root The drawer container.
*/
const hide = root => {
// Ensure that it is a jQuery.
root = $(root);
root.addClass('hidden');
root.attr('aria-expanded', false);
Aria.hide(root.get());
PubSub.publish(DrawerEvents.DRAWER_HIDDEN, root);
};
/**
* Check if the drawer is visible.
*
* @param {Object} root The drawer container.
* @return {boolean}
*/
const isVisible = (root) => {
let isHidden = root.hasClass('hidden');
return !isHidden;
};
/**
* Toggle the drawer visibility.
*
* @param {Object} root The drawer container.
*/
const toggle = (root) => {
if (isVisible(root)) {
hide(root);
} else {
show(root);
}
};
/**
* Add event listeners to toggle the drawer.
*
* @param {Object} root The drawer container.
* @param {Object} toggleElements The toggle elements.
*/
const registerToggles = (root, toggleElements) => {
let openTrigger = null;
toggleElements.attr('aria-expanded', isVisible(root));
toggleElements.on('click', (e) => {
e.preventDefault();
const wasVisible = isVisible(root);
toggle(root);
toggleElements.attr('aria-expanded', !wasVisible);
if (!wasVisible) {
// Remember which trigger element opened the drawer.
openTrigger = toggleElements.filter((index, element) => {
return element == e.target || element.contains(e.target);
});
} else if (openTrigger) {
// The drawer has gone from open to close so we need to set the focus back
// to the element that openend it.
openTrigger.focus();
openTrigger = null;
}
});
};
/**
* Find the root element of the drawer based on the using the drawer content root's ID.
*
* @param {Object} contentRoot The drawer content's root element.
* @returns {*|jQuery}
*/
const getDrawerRoot = (contentRoot) => {
contentRoot = $(contentRoot);
return contentRoot.closest('[data-region="right-hand-drawer"]');
};
export default {
hide: hide,
show: show,
isVisible: isVisible,
toggle: toggle,
registerToggles: registerToggles,
getDrawerRoot: getDrawerRoot
};
+26
View File
@@ -0,0 +1,26 @@
// 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/>.
/**
* Events for the drawer.
*
* @module core/drawer_events
* @copyright 2019 Jun Pataleta <jun@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default {
DRAWER_SHOWN: 'drawer-shown',
DRAWER_HIDDEN: 'drawer-hidden',
};
+200
View File
@@ -0,0 +1,200 @@
// 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 handle dropzone.
*
* @module core/dropzone
* @copyright 2024 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.4
*/
import {getString} from 'core/str';
import Log from 'core/log';
import {prefetchString} from 'core/prefetch';
import Templates from 'core/templates';
/**
* A dropzone.
*
* @class core/dropzone
*/
const DropZone = class {
/**
* The element to render the dropzone.
* @type {Element}
*/
dropZoneElement;
/**
* The file types that are allowed to be uploaded.
* @type {String}
*/
fileTypes;
/**
* The function to call when a file is dropped.
* @type {CallableFunction}
*/
callback;
/**
* The label to display in the dropzone.
* @type {string}
*/
dropZoneLabel = '';
/**
* Constructor.
*
* @param {Element} dropZoneElement The element to render the dropzone.
* @param {String} fileTypes The file types that are allowed to be uploaded. Example: image/*
* @param {CallableFunction} callback The function to call when a file is dropped.
*/
constructor(dropZoneElement, fileTypes, callback) {
prefetchString('core', 'addfilesdrop');
this.dropZoneElement = dropZoneElement;
this.fileTypes = fileTypes;
this.callback = callback;
}
/**
* Initialise the dropzone.
*
* @returns {DropZone}
*/
init() {
this.dropZoneElement.addEventListener('dragover', (e) => {
const dropZone = this.getDropZoneFromEvent(e);
if (!dropZone) {
return;
}
e.preventDefault();
dropZone.classList.add('dragover');
});
this.dropZoneElement.addEventListener('dragleave', (e) => {
const dropZone = this.getDropZoneFromEvent(e);
if (!dropZone) {
return;
}
e.preventDefault();
dropZone.classList.remove('dragover');
});
this.dropZoneElement.addEventListener('drop', (e) => {
const dropZone = this.getDropZoneFromEvent(e);
if (!dropZone) {
return;
}
e.preventDefault();
dropZone.classList.remove('dragover');
this.callback(e.dataTransfer.files);
});
this.dropZoneElement.addEventListener('click', (e) => {
const dropZoneContainer = this.getDropZoneContainerFromEvent(e);
if (!dropZoneContainer) {
return;
}
this.getFileElementFromEvent(e).click();
});
this.dropZoneElement.addEventListener('click', (e) => {
const dropZoneLabel = e.target.closest('.dropzone-sr-only-focusable');
if (!dropZoneLabel) {
return;
}
this.getFileElementFromEvent(e).click();
});
this.dropZoneElement.addEventListener('change', (e) => {
const fileInput = this.getFileElementFromEvent(e);
if (fileInput) {
e.preventDefault();
this.callback(fileInput.files);
}
});
this.renderDropZone(this.dropZoneElement, this.fileTypes);
Log.info('Dropzone has been initialized!');
return this;
}
/**
* Get the dropzone.
*
* @param {Event} e The event.
* @returns {HTMLElement|bool}
*/
getDropZoneFromEvent(e) {
return e.target.closest('.dropzone');
}
/**
* Get the dropzone container.
*
* @param {Event} e The event.
* @returns {HTMLElement|bool}
*/
getDropZoneContainerFromEvent(e) {
return e.target.closest('.dropzone-container');
}
/**
* Get the file element.
*
* @param {Event} e The event.
* @returns {HTMLElement|bool}
*/
getFileElementFromEvent(e) {
return e.target.closest('.dropzone-container').querySelector('.drop-zone-fileinput');
}
/**
* Set the label to display in the dropzone.
*
* @param {String} label The label to display in the dropzone.
*/
setLabel(label) {
this.dropZoneLabel = label;
}
/**
* Get the label to display in the dropzone.
*
* @return {String} The label to display in the dropzone.
*/
getLabel() {
return this.dropZoneLabel;
}
/**
* Render the dropzone.
*
* @param {Element} dropZoneElement The element to render the dropzone.
* @param {String} fileTypes The file types that are allowed to be uploaded.
* @returns {Promise}
*/
async renderDropZone(dropZoneElement, fileTypes) {
if (!this.getLabel()) {
// Use the default one.
this.setLabel(await getString('addfilesdrop', 'core'));
}
const dropZoneLabel = this.getLabel();
dropZoneElement.innerHTML = await Templates.render('core/dropzone', {
label: dropZoneLabel,
filetypes: fileTypes,
});
}
};
export default DropZone;
+208
View File
@@ -0,0 +1,208 @@
// 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/>.
/**
* Dynamic Tabs UI element with AJAX loading of tabs content
*
* @module core/dynamic_tabs
* @copyright 2021 David Matamoros <davidmc@moodle.com> based on code from Marina Glancy
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import $ from 'jquery';
import Templates from 'core/templates';
import {addIconToContainer} from 'core/loadingicon';
import Notification from 'core/notification';
import Pending from 'core/pending';
import {getStrings} from 'core/str';
import {getContent} from 'core/local/repository/dynamic_tabs';
import {isAnyWatchedFormDirty, resetAllFormDirtyStates} from 'core_form/changechecker';
const SELECTORS = {
dynamicTabs: '.dynamictabs',
activeTab: '.dynamictabs .nav-link.active',
allActiveTabs: '.dynamictabs .nav-link[data-toggle="tab"]:not(.disabled)',
tabContent: '.dynamictabs .tab-pane [data-tab-content]',
tabToggle: 'a[data-toggle="tab"]',
tabPane: '.dynamictabs .tab-pane',
};
SELECTORS.forTabName = tabName => `.dynamictabs [data-tab-content="${tabName}"]`;
SELECTORS.forTabId = tabName => `.dynamictabs [data-toggle="tab"][href="#${tabName}"]`;
/**
* Initialises the tabs view on the page (only one tabs view per page is supported)
*/
export const init = () => {
const tabToggle = $(SELECTORS.tabToggle);
// Listen to click, warn user if they are navigating away with unsaved form changes.
tabToggle.on('click', (event) => {
if (!isAnyWatchedFormDirty()) {
return;
}
event.preventDefault();
event.stopPropagation();
getStrings([
{key: 'changesmade', component: 'moodle'},
{key: 'changesmadereallygoaway', component: 'moodle'},
{key: 'confirm', component: 'moodle'},
]).then(([strChangesMade, strChangesMadeReally, strConfirm]) =>
// Reset form dirty state on confirmation, re-trigger the event.
Notification.confirm(strChangesMade, strChangesMadeReally, strConfirm, null, () => {
resetAllFormDirtyStates();
$(event.target).trigger(event.type);
})
).catch(Notification.exception);
});
// This code listens to Bootstrap events 'show.bs.tab' and 'shown.bs.tab' which is triggered using JQuery and
// can not be converted yet to native events.
tabToggle
.on('show.bs.tab', function() {
// Clean content from previous tab.
const previousTabName = getActiveTabName();
if (previousTabName) {
const previousTab = document.querySelector(SELECTORS.forTabName(previousTabName));
previousTab.textContent = '';
}
})
.on('shown.bs.tab', function() {
const tab = $($(this).attr('href'));
if (tab.length !== 1) {
return;
}
loadTab(tab.attr('id'));
});
if (!openTabFromHash()) {
const tabs = document.querySelector(SELECTORS.allActiveTabs);
if (tabs) {
openTab(tabs.getAttribute('aria-controls'));
} else {
// We may hide tabs if there is only one available, just load the contents of the first tab.
const tabPane = document.querySelector(SELECTORS.tabPane);
if (tabPane) {
tabPane.classList.add('active', 'show');
loadTab(tabPane.getAttribute('id'));
}
}
}
};
/**
* Returns id/name of the currently active tab
*
* @return {String|null}
*/
const getActiveTabName = () => {
const element = document.querySelector(SELECTORS.activeTab);
return element?.getAttribute('aria-controls') || null;
};
/**
* Returns the id/name of the first tab
*
* @return {String|null}
*/
const getFirstTabName = () => {
const element = document.querySelector(SELECTORS.tabContent);
return element?.dataset.tabContent || null;
};
/**
* Loads contents of a tab using an AJAX request
*
* @param {String} tabName
*/
const loadTab = (tabName) => {
// If tabName is not specified find the active tab, or if is not defined, the first available tab.
tabName = tabName ?? getActiveTabName() ?? getFirstTabName();
const tab = document.querySelector(SELECTORS.forTabName(tabName));
if (!tab) {
return;
}
const pendingPromise = new Pending('core/dynamic_tabs:loadTab:' + tabName);
addIconToContainer(tab)
.then(() => {
let tabArgs = {...tab.dataset};
delete tabArgs.tabClass;
delete tabArgs.tabContent;
return getContent(tab.dataset.tabClass, JSON.stringify(tabArgs));
})
.then(response => Promise.all([
$.parseHTML(response.javascript, null, true).map(node => node.innerHTML).join("\n"),
Templates.renderForPromise(response.template, JSON.parse(response.content)),
]))
.then(([responseJs, {html, js}]) => Templates.replaceNodeContents(tab, html, js + responseJs))
.then(() => pendingPromise.resolve())
.catch(Notification.exception);
};
/**
* Return the tab given the tab name
*
* @param {String} tabName
* @return {HTMLElement}
*/
const getTab = (tabName) => {
return document.querySelector(SELECTORS.forTabId(tabName));
};
/**
* Return the tab pane given the tab name
*
* @param {String} tabName
* @return {HTMLElement}
*/
const getTabPane = (tabName) => {
return document.getElementById(tabName);
};
/**
* Open the tab on page load. If this script loads before theme_boost/tab we need to open tab ourselves
*
* @param {String} tabName
* @return {Boolean}
*/
const openTab = (tabName) => {
const tab = getTab(tabName);
if (!tab) {
return false;
}
loadTab(tabName);
tab.classList.add('active');
getTabPane(tabName).classList.add('active', 'show');
return true;
};
/**
* If there is a location hash that is the same as the tab name - open this tab.
*
* @return {Boolean}
*/
const openTabFromHash = () => {
const hash = document.location.hash;
if (hash.match(/^#\w+$/g)) {
return openTab(hash.replace(/^#/g, ''));
}
return false;
};
+122
View File
@@ -0,0 +1,122 @@
// 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/>.
/**
* Controls the edit switch.
*
* @module core/edit_switch
* @copyright 2021 Bas Brands <bas@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {call as fetchMany} from 'core/ajax';
import {dispatchEvent} from 'core/event_dispatcher';
import {exception as displayException} from 'core/notification';
/**
* Change the Edit mode.
*
* @param {number} context The contextid that editing is being set for
* @param {bool} setmode Whether editing is set or not
* @return {Promise} Resolved with an array file the stored file url.
*/
const setEditMode = (context, setmode) => fetchMany([{
methodname: 'core_change_editmode',
args: {
context,
setmode,
},
}])[0];
/**
* Toggle the edit switch
*
* @method
* @protected
* @param {HTMLElement} editSwitch
*/
const toggleEditSwitch = editSwitch => {
if (editSwitch.checked) {
editSwitch.setAttribute('aria-checked', true);
} else {
editSwitch.setAttribute('aria-checked', false);
}
const event = notifyEditModeSet(editSwitch, editSwitch.checked);
if (!event.defaultPrevented) {
editSwitch.setAttribute('disabled', true);
window.location = editSwitch.dataset.pageurl;
}
};
/**
* Names of events for core/edit_switch.
*
* @static
* @property {String} editModeSet See {@link event:core/edit_switch/editModeSet}
*/
export const eventTypes = {
/**
* An event triggered when the edit mode toggled.
*
* @event core/edit_switch/editModeSet
* @type {CustomEvent}
* @property {HTMLElement} target The switch used to toggle the edit mode
* @property {object} detail
* @property {bool} detail.editMode
*/
editModeSet: 'core/edit_switch/editModeSet',
};
/**
* Dispatch the editModeSet event after changing the edit mode.
*
* This event is cancelable.
*
* The default action is to reload the page after toggling the edit mode.
*
* @method
* @protected
* @param {HTMLElement} container
* @param {bool} editMode
* @returns {CustomEvent}
*/
const notifyEditModeSet = (container, editMode) => dispatchEvent(
eventTypes.editModeSet,
{editMode},
container,
{cancelable: true}
);
/**
* Add the eventlistener for the editswitch.
*
* @param {string} editingSwitchId The id of the editing switch to listen for
*/
export const init = editingSwitchId => {
const editSwitch = document.getElementById(editingSwitchId);
editSwitch.addEventListener('change', () => {
setEditMode(editSwitch.dataset.context, editSwitch.checked)
.then(result => {
if (result.success) {
toggleEditSwitch(editSwitch);
} else {
editSwitch.checked = false;
}
return;
})
.catch(displayException);
});
};
+328
View File
@@ -0,0 +1,328 @@
// 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/>.
/**
* Emoji auto complete.
*
* @module core/emoji/auto_complete
* @copyright 2019 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import * as EmojiData from 'core/emoji/data';
import {render as renderTemplate} from 'core/templates';
import {debounce} from 'core/utils';
import LocalStorage from 'core/localstorage';
import KeyCodes from 'core/key_codes';
const INPUT_DEBOUNCE_TIMER = 200;
const SUGGESTION_LIMIT = 50;
const MAX_RECENT_COUNT = 27;
const RECENT_EMOJIS_STORAGE_KEY = 'moodle-recent-emojis';
const SELECTORS = {
EMOJI_BUTTON: '[data-region="emoji-button"]',
ACTIVE_EMOJI_BUTTON: '[data-region="emoji-button"].active',
};
/**
* Get the list of recent emojis data from local storage.
*
* @return {Array}
*/
const getRecentEmojis = () => {
const storedData = LocalStorage.get(RECENT_EMOJIS_STORAGE_KEY);
return storedData ? JSON.parse(storedData) : [];
};
/**
* Add an emoji data to the set of recent emojis. The new set of recent emojis are
* saved in local storage.
*
* @param {String} unified The char chodes for the emoji
* @param {String} shortName The emoji short name
*/
const addRecentEmoji = (unified, shortName) => {
const newEmoji = {
unified,
shortnames: [shortName]
};
const recentEmojis = getRecentEmojis();
// Add the new emoji to the start of the list of recent emojis.
let newRecentEmojis = [newEmoji, ...recentEmojis.filter(emoji => emoji.unified != newEmoji.unified)];
// Limit the number of recent emojis.
newRecentEmojis = newRecentEmojis.slice(0, MAX_RECENT_COUNT);
LocalStorage.set(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(newRecentEmojis));
};
/**
* Get the actual emoji string from the short name.
*
* @param {String} shortName Emoji short name
* @return {String|null}
*/
const getEmojiTextFromShortName = (shortName) => {
const unified = EmojiData.byShortName[shortName];
if (unified) {
const charCodes = unified.split('-').map(code => `0x${code}`);
return String.fromCodePoint.apply(null, charCodes);
} else {
return null;
}
};
/**
* Render the auto complete list for the given short names.
*
* @param {Element} root The root container for the emoji auto complete
* @param {Array} shortNames The list of short names for emoji suggestions to show
*/
const render = async(root, shortNames) => {
const renderContext = {
emojis: shortNames.map((shortName, index) => {
return {
active: index === 0,
emojitext: getEmojiTextFromShortName(shortName),
displayshortname: `:${shortName}:`,
shortname: shortName,
unified: EmojiData.byShortName[shortName]
};
})
};
const html = await renderTemplate('core/emoji/auto_complete', renderContext);
root.innerHTML = html;
};
/**
* Get the list of emoji short names that include the given search term. If
* the search term is an empty string then the list of recently used emojis
* will be returned.
*
* @param {String} searchTerm Text to match on
* @param {Number} limit Maximum number of results to return
* @return {Array}
*/
const searchEmojis = (searchTerm, limit) => {
if (searchTerm === '') {
return getRecentEmojis().map(data => data.shortnames[0]).slice(0, limit);
} else {
searchTerm = searchTerm.toLowerCase();
return Object.keys(EmojiData.byShortName)
.filter(shortName => shortName.includes(searchTerm))
.slice(0, limit);
}
};
/**
* Get the current word at the given position (index) within the text.
*
* @param {String} text The text to process
* @param {Number} position The position (index) within the text to match the word
* @return {String}
*/
const getWordFromPosition = (text, position) => {
const startMatches = text.slice(0, position).match(/(\S*)$/);
const endMatches = text.slice(position).match(/^(\S*)/);
let startText = '';
let endText = '';
if (startMatches) {
startText = startMatches[startMatches.length - 1];
}
if (endMatches) {
endText = endMatches[endMatches.length - 1];
}
return `${startText}${endText}`;
};
/**
* Check if the given text is a full short name, i.e. has leading and trialing colon
* characters.
*
* @param {String} text The text to process
* @return {Bool}
*/
const isCompleteShortName = text => /^:[^:\s]+:$/.test(text);
/**
* Check if the given text is a partial short name, i.e. has a leading colon but no
* trailing colon.
*
* @param {String} text The text to process
* @return {Bool}
*/
const isPartialShortName = text => /^:[^:\s]*$/.test(text);
/**
* Remove the colon characters from the given text.
*
* @param {String} text The text to process
* @return {String}
*/
const getShortNameFromText = text => text.replace(/:/g, '');
/**
* Get the currently active emoji button element in the list of suggestions.
*
* @param {Element} root The emoji auto complete container element
* @return {Element|null}
*/
const getActiveEmojiSuggestion = (root) => {
return root.querySelector(SELECTORS.ACTIVE_EMOJI_BUTTON);
};
/**
* Make the previous sibling of the current active emoji active.
*
* @param {Element} root The emoji auto complete container element
*/
const selectPreviousEmojiSuggestion = (root) => {
const activeEmojiSuggestion = getActiveEmojiSuggestion(root);
const previousSuggestion = activeEmojiSuggestion.previousElementSibling;
if (previousSuggestion) {
activeEmojiSuggestion.classList.remove('active');
previousSuggestion.classList.add('active');
previousSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'});
}
};
/**
* Make the next sibling to the current active emoji active.
*
* @param {Element} root The emoji auto complete container element
*/
const selectNextEmojiSuggestion = (root) => {
const activeEmojiSuggestion = getActiveEmojiSuggestion(root);
const nextSuggestion = activeEmojiSuggestion.nextElementSibling;
if (nextSuggestion) {
activeEmojiSuggestion.classList.remove('active');
nextSuggestion.classList.add('active');
nextSuggestion.scrollIntoView({behaviour: 'smooth', inline: 'center'});
}
};
/**
* Trigger the select callback for the given emoji button element.
*
* @param {Element} element The emoji button element
* @param {Function} selectCallback The callback for when the user selects an emoji
*/
const selectEmojiElement = (element, selectCallback) => {
const shortName = element.getAttribute('data-short-name');
const unified = element.getAttribute('data-unified');
addRecentEmoji(unified, shortName);
selectCallback(element.innerHTML.trim());
};
/**
* Initialise the emoji auto complete.
*
* @method
* @param {Element} root The root container element for the auto complete
* @param {Element} textArea The text area element to monitor for auto complete
* @param {Function} hasSuggestionCallback Callback for when there are auto-complete suggestions
* @param {Function} selectCallback Callback for when the user selects an emoji
*/
export default (root, textArea, hasSuggestionCallback, selectCallback) => {
let hasSuggestions = false;
let previousSearchText = '';
// Debounce the listener so that each keypress delays the execution of the handler. The
// handler should only run 200 milliseconds after the last keypress.
textArea.addEventListener('keyup', debounce(() => {
// This is a "keyup" listener so that it only executes after the text area value
// has been updated.
const text = textArea.value;
const cursorPos = textArea.selectionStart;
const searchText = getWordFromPosition(text, cursorPos);
if (searchText === previousSearchText) {
// Nothing has changed so no need to take any action.
return;
} else {
previousSearchText = searchText;
}
if (isCompleteShortName(searchText)) {
// If the user has entered a full short name (with leading and trialing colons)
// then see if we can find a match for it and auto complete it.
const shortName = getShortNameFromText(searchText);
const emojiText = getEmojiTextFromShortName(shortName);
hasSuggestions = false;
if (emojiText) {
addRecentEmoji(EmojiData.byShortName[shortName], shortName);
selectCallback(emojiText);
}
} else if (isPartialShortName(searchText)) {
// If the user has entered a partial short name (leading colon but no trailing) then
// search on the text to see if we can find some suggestions for them.
const suggestions = searchEmojis(getShortNameFromText(searchText), SUGGESTION_LIMIT);
if (suggestions.length) {
render(root, suggestions);
hasSuggestions = true;
} else {
hasSuggestions = false;
}
} else {
hasSuggestions = false;
}
hasSuggestionCallback(hasSuggestions);
}, INPUT_DEBOUNCE_TIMER));
textArea.addEventListener('keydown', (e) => {
if (hasSuggestions) {
const isModifierPressed = (e.shiftKey || e.metaKey || e.altKey || e.ctrlKey);
if (!isModifierPressed) {
switch (e.which) {
case KeyCodes.escape:
// Escape key closes the auto complete.
hasSuggestions = false;
hasSuggestionCallback(false);
break;
case KeyCodes.arrowLeft:
// Arrow keys navigate through the list of suggetions.
selectPreviousEmojiSuggestion(root);
e.preventDefault();
break;
case KeyCodes.arrowRight:
// Arrow keys navigate through the list of suggetions.
selectNextEmojiSuggestion(root);
e.preventDefault();
break;
case KeyCodes.enter:
// Enter key selects the current suggestion.
selectEmojiElement(getActiveEmojiSuggestion(root), selectCallback);
e.preventDefault();
e.stopPropagation();
break;
}
}
}
});
root.addEventListener('click', (e) => {
const target = e.target;
if (target.matches(SELECTORS.EMOJI_BUTTON)) {
selectEmojiElement(target, selectCallback);
}
});
};
File diff suppressed because it is too large Load Diff
+892
View File
@@ -0,0 +1,892 @@
// 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/>.
/**
* Emoji picker.
*
* @module core/emoji/picker
* @copyright 2019 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import LocalStorage from 'core/localstorage';
import * as EmojiData from 'core/emoji/data';
import {throttle, debounce} from 'core/utils';
import {getString} from 'core/str';
import {render as renderTemplate} from 'core/templates';
const VISIBLE_ROW_COUNT = 10;
const ROW_RENDER_BUFFER_COUNT = 5;
const RECENT_EMOJIS_STORAGE_KEY = 'moodle-recent-emojis';
const ROW_HEIGHT_RAW = 40;
const EMOJIS_PER_ROW = 7;
const MAX_RECENT_COUNT = EMOJIS_PER_ROW * 3;
const ROW_TYPE = {
EMOJI: 0,
HEADER: 1
};
const SELECTORS = {
CATEGORY_SELECTOR: '[data-action="show-category"]',
EMOJIS_CONTAINER: '[data-region="emojis-container"]',
EMOJI_PREVIEW: '[data-region="emoji-preview"]',
EMOJI_SHORT_NAME: '[data-region="emoji-short-name"]',
ROW_CONTAINER: '[data-region="row-container"]',
SEARCH_INPUT: '[data-region="search-input"]',
SEARCH_RESULTS_CONTAINER: '[data-region="search-results-container"]'
};
/**
* Create the row data for a category.
*
* @method
* @param {String} categoryName The category name
* @param {String} categoryDisplayName The category display name
* @param {Array} emojis The emoji data
* @param {Number} totalRowCount The total number of rows generated so far
* @return {Array}
*/
const createRowDataForCategory = (categoryName, categoryDisplayName, emojis, totalRowCount) => {
const rowData = [];
rowData.push({
index: totalRowCount + rowData.length,
type: ROW_TYPE.HEADER,
data: {
name: categoryName,
displayName: categoryDisplayName
}
});
for (let i = 0; i < emojis.length; i += EMOJIS_PER_ROW) {
const rowEmojis = emojis.slice(i, i + EMOJIS_PER_ROW);
rowData.push({
index: totalRowCount + rowData.length,
type: ROW_TYPE.EMOJI,
data: rowEmojis
});
}
return rowData;
};
/**
* Add each row's index to it's value in the row data.
*
* @method
* @param {Array} rowData List of emoji row data
* @return {Array}
*/
const addIndexesToRowData = (rowData) => {
return rowData.map((data, index) => {
return {...data, index};
});
};
/**
* Calculate the scroll position for the beginning of each category from
* the row data.
*
* @method
* @param {Array} rowData List of emoji row data
* @return {Object}
*/
const getCategoryScrollPositionsFromRowData = (rowData) => {
return rowData.reduce((carry, row, index) => {
if (row.type === ROW_TYPE.HEADER) {
carry[row.data.name] = index * ROW_HEIGHT_RAW;
}
return carry;
}, {});
};
/**
* Create a header row element for the category name.
*
* @method
* @param {Number} rowIndex Index of the row in the row data
* @param {String} name The category display name
* @return {Element}
*/
const createHeaderRow = async(rowIndex, name) => {
const context = {
index: rowIndex,
text: name
};
const html = await renderTemplate('core/emoji/header_row', context);
const temp = document.createElement('div');
temp.innerHTML = html;
return temp.firstChild;
};
/**
* Create an emoji row element.
*
* @method
* @param {Number} rowIndex Index of the row in the row data
* @param {Array} emojis The list of emoji data for the row
* @return {Element}
*/
const createEmojiRow = async(rowIndex, emojis) => {
const context = {
index: rowIndex,
emojis: emojis.map(emojiData => {
const charCodes = emojiData.unified.split('-').map(code => `0x${code}`);
const emojiText = String.fromCodePoint.apply(null, charCodes);
return {
shortnames: `:${emojiData.shortnames.join(': :')}:`,
unified: emojiData.unified,
text: emojiText,
spacer: false
};
}),
spacers: Array(EMOJIS_PER_ROW - emojis.length).fill(true)
};
const html = await renderTemplate('core/emoji/emoji_row', context);
const temp = document.createElement('div');
temp.innerHTML = html;
return temp.firstChild;
};
/**
* Check if the element is an emoji element.
*
* @method
* @param {Element} element Element to check
* @return {Bool}
*/
const isEmojiElement = element => element.getAttribute('data-short-names') !== null;
/**
* Search from an element and up through it's ancestors to fine the category
* selector element and return it.
*
* @method
* @param {Element} element Element to begin searching from
* @return {Element|null}
*/
const findCategorySelectorFromElement = element => {
if (!element) {
return null;
}
if (element.getAttribute('data-action') === 'show-category') {
return element;
} else {
return findCategorySelectorFromElement(element.parentElement);
}
};
const getCategorySelectorByCategoryName = (root, name) => {
return root.querySelector(`[data-category="${name}"]`);
};
/**
* Sets the given category selector element as active.
*
* @method
* @param {Element} root The root picker element
* @param {Element} element The category selector element to make active
*/
const setCategorySelectorActive = (root, element) => {
const allCategorySelectors = root.querySelectorAll(SELECTORS.CATEGORY_SELECTOR);
for (let i = 0; i < allCategorySelectors.length; i++) {
const selector = allCategorySelectors[i];
selector.classList.remove('selected');
}
element.classList.add('selected');
};
/**
* Get the category selector element and the scroll positions for the previous and
* next categories for the given scroll position.
*
* @method
* @param {Element} root The picker root element
* @param {Number} position The position to get the category for
* @param {Object} categoryScrollPositions Set of scroll positions for all categories
* @return {Array}
*/
const getCategoryByScrollPosition = (root, position, categoryScrollPositions) => {
let positions = [];
if (position < 0) {
position = 0;
}
// Get all of the category positions.
for (const categoryName in categoryScrollPositions) {
const categoryPosition = categoryScrollPositions[categoryName];
positions.push([categoryPosition, categoryName]);
}
// Sort the positions in ascending order.
positions.sort(([a], [b]) => {
if (a < b) {
return -1;
} else if (a > b) {
return 1;
} else {
return 0;
}
});
// Get the current category name as well as the previous and next category
// positions from the sorted list of positions.
const {categoryName, previousPosition, nextPosition} = positions.reduce(
(carry, candidate) => {
const [categoryPosition, categoryName] = candidate;
if (categoryPosition <= position) {
carry.categoryName = categoryName;
carry.previousPosition = carry.currentPosition;
carry.currentPosition = position;
} else if (carry.nextPosition === null) {
carry.nextPosition = categoryPosition;
}
return carry;
},
{
categoryName: null,
currentPosition: null,
previousPosition: null,
nextPosition: null
}
);
return [getCategorySelectorByCategoryName(root, categoryName), previousPosition, nextPosition];
};
/**
* Get the list of recent emojis data from local storage.
*
* @method
* @return {Array}
*/
const getRecentEmojis = () => {
const storedData = LocalStorage.get(RECENT_EMOJIS_STORAGE_KEY);
return storedData ? JSON.parse(storedData) : [];
};
/**
* Save the list of recent emojis in local storage.
*
* @method
* @param {Array} recentEmojis List of emoji data to save
*/
const saveRecentEmoji = (recentEmojis) => {
LocalStorage.set(RECENT_EMOJIS_STORAGE_KEY, JSON.stringify(recentEmojis));
};
/**
* Add an emoji data to the set of recent emojis. This function will update the row
* data to ensure that the recent emoji rows are correct and all of the rows are
* re-indexed.
*
* The new set of recent emojis are saved in local storage and the full set of updated
* row data and new emoji row count are returned.
*
* @method
* @param {Array} rowData The emoji rows data
* @param {Number} recentEmojiRowCount Count of the recent emoji rows
* @param {Object} newEmoji The emoji data for the emoji to add to the recent emoji list
* @return {Array}
*/
const addRecentEmoji = (rowData, recentEmojiRowCount, newEmoji) => {
// The first set of rows is always the recent emojis.
const categoryName = rowData[0].data.name;
const categoryDisplayName = rowData[0].data.displayName;
const recentEmojis = getRecentEmojis();
// Add the new emoji to the start of the list of recent emojis.
let newRecentEmojis = [newEmoji, ...recentEmojis.filter(emoji => emoji.unified != newEmoji.unified)];
// Limit the number of recent emojis.
newRecentEmojis = newRecentEmojis.slice(0, MAX_RECENT_COUNT);
const newRecentEmojiRowData = createRowDataForCategory(categoryName, categoryDisplayName, newRecentEmojis);
// Save the new list in local storage.
saveRecentEmoji(newRecentEmojis);
return [
// Return the new rowData and re-index it to make sure it's all correct.
addIndexesToRowData(newRecentEmojiRowData.concat(rowData.slice(recentEmojiRowCount))),
newRecentEmojiRowData.length
];
};
/**
* Calculate which rows should be visible based on the given scroll position. Adds a
* buffer to amount to either side of the total number of requested rows so that
* scrolling the emoji rows container is smooth.
*
* @method
* @param {Number} scrollPosition Scroll position within the emoji container
* @param {Number} visibleRowCount How many rows should be visible
* @param {Array} rowData The emoji rows data
* @return {Array}
*/
const getRowsToRender = (scrollPosition, visibleRowCount, rowData) => {
const minVisibleRow = scrollPosition > ROW_HEIGHT_RAW ? Math.floor(scrollPosition / ROW_HEIGHT_RAW) : 0;
const start = minVisibleRow >= ROW_RENDER_BUFFER_COUNT ? minVisibleRow - ROW_RENDER_BUFFER_COUNT : minVisibleRow;
const end = minVisibleRow + visibleRowCount + ROW_RENDER_BUFFER_COUNT;
const rows = rowData.slice(start, end);
return rows;
};
/**
* Create a row element from the row data.
*
* @method
* @param {Object} rowData The emoji row data
* @return {Element}
*/
const createRowElement = async(rowData) => {
let row = null;
if (rowData.type === ROW_TYPE.HEADER) {
row = await createHeaderRow(rowData.index, rowData.data.displayName);
} else {
row = await createEmojiRow(rowData.index, rowData.data);
}
row.style.position = 'absolute';
row.style.left = 0;
row.style.right = 0;
row.style.top = `${rowData.index * ROW_HEIGHT_RAW}px`;
return row;
};
/**
* Check if the given rows match.
*
* @method
* @param {Object} a The first row
* @param {Object} b The second row
* @return {Bool}
*/
const doRowsMatch = (a, b) => {
if (a.index !== b.index) {
return false;
}
if (a.type !== b.type) {
return false;
}
if (typeof a.data != typeof b.data) {
return false;
}
if (a.type === ROW_TYPE.HEADER) {
return a.data.name === b.data.name;
} else {
if (a.data.length !== b.data.length) {
return false;
}
for (let i = 0; i < a.data.length; i++) {
if (a.data[i].unified != b.data[i].unified) {
return false;
}
}
}
return true;
};
/**
* Update the visible rows. Deletes any row elements that should no longer
* be visible and creates the newly visible row elements. Any rows that haven't
* changed visibility will be left untouched.
*
* @method
* @param {Element} rowContainer The container element for the emoji rows
* @param {Array} currentRows List of row data that matches the currently visible rows
* @param {Array} nextRows List of row data containing the new list of rows to be made visible
*/
const renderRows = async(rowContainer, currentRows, nextRows) => {
// We need to add any rows that are in nextRows but not in currentRows.
const toAdd = nextRows.filter(nextRow => !currentRows.some(currentRow => doRowsMatch(currentRow, nextRow)));
// Remember which rows will still be visible so that we can insert our element in the correct place in the DOM.
let toKeep = currentRows.filter(currentRow => nextRows.some(nextRow => doRowsMatch(currentRow, nextRow)));
// We need to remove any rows that are in currentRows but not in nextRows.
const toRemove = currentRows.filter(currentRow => !nextRows.some(nextRow => doRowsMatch(currentRow, nextRow)));
const toRemoveElements = toRemove.map(rowData => rowContainer.querySelectorAll(`[data-row="${rowData.index}"]`));
// Render all of the templates first.
const rows = await Promise.all(toAdd.map(rowData => createRowElement(rowData)));
rows.forEach((row, index) => {
const rowData = toAdd[index];
let nextRowIndex = null;
for (let i = 0; i < toKeep.length; i++) {
const candidate = toKeep[i];
if (candidate.index > rowData.index) {
nextRowIndex = i;
break;
}
}
// Make sure the elements get added to the DOM in the correct order (ascending by row data index)
// so that they appear naturally in the tab order.
if (nextRowIndex !== null) {
const nextRowData = toKeep[nextRowIndex];
const nextRowNode = rowContainer.querySelector(`[data-row="${nextRowData.index}"]`);
rowContainer.insertBefore(row, nextRowNode);
toKeep.splice(nextRowIndex, 0, toKeep);
} else {
toKeep.push(rowData);
rowContainer.appendChild(row);
}
});
toRemoveElements.forEach(rows => {
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
rowContainer.removeChild(row);
}
});
};
/**
* Build a function to render the visible emoji rows for a given scroll
* position.
*
* @method
* @param {Element} rowContainer The container element for the emoji rows
* @return {Function}
*/
const generateRenderRowsAtPositionFunction = (rowContainer) => {
let currentRows = [];
let nextRows = [];
let rowCount = 0;
let isRendering = false;
const renderNextRows = async() => {
if (!nextRows.length) {
return;
}
if (isRendering) {
return;
}
isRendering = true;
const nextRowsToRender = nextRows.slice();
nextRows = [];
await renderRows(rowContainer, currentRows, nextRowsToRender);
currentRows = nextRowsToRender;
isRendering = false;
renderNextRows();
};
return (scrollPosition, rowData, rowLimit = VISIBLE_ROW_COUNT) => {
nextRows = getRowsToRender(scrollPosition, rowLimit, rowData);
renderNextRows();
if (rowCount !== rowData.length) {
// Adjust the height of the container to match the number of rows.
rowContainer.style.height = `${rowData.length * ROW_HEIGHT_RAW}px`;
}
rowCount = rowData.length;
};
};
/**
* Show the search results container and hide the emoji container.
*
* @method
* @param {Element} emojiContainer The emojis container
* @param {Element} searchResultsContainer The search results container
*/
const showSearchResults = (emojiContainer, searchResultsContainer) => {
searchResultsContainer.classList.remove('hidden');
emojiContainer.classList.add('hidden');
};
/**
* Hide the search result container and show the emojis container.
*
* @method
* @param {Element} emojiContainer The emojis container
* @param {Element} searchResultsContainer The search results container
* @param {Element} searchInput The search input
*/
const clearSearch = (emojiContainer, searchResultsContainer, searchInput) => {
searchResultsContainer.classList.add('hidden');
emojiContainer.classList.remove('hidden');
searchInput.value = '';
};
/**
* Build function to handle mouse hovering an emoji. Shows the preview.
*
* @method
* @param {Element} emojiPreview The emoji preview element
* @param {Element} emojiShortName The emoji short name element
* @return {Function}
*/
const getHandleMouseEnter = (emojiPreview, emojiShortName) => {
return (e) => {
const target = e.target;
if (isEmojiElement(target)) {
emojiShortName.textContent = target.getAttribute('data-short-names');
emojiPreview.textContent = target.textContent;
}
};
};
/**
* Build function to handle mouse leaving an emoji. Removes the preview.
*
* @method
* @param {Element} emojiPreview The emoji preview element
* @param {Element} emojiShortName The emoji short name element
* @return {Function}
*/
const getHandleMouseLeave = (emojiPreview, emojiShortName) => {
return (e) => {
const target = e.target;
if (isEmojiElement(target)) {
emojiShortName.textContent = '';
emojiPreview.textContent = '';
}
};
};
/**
* Build the function to handle a user clicking something in the picker.
*
* The function currently handles clicking on the category selector or selecting
* a specific emoji.
*
* @method
* @param {Number} recentEmojiRowCount Number of rows of recent emojis
* @param {Element} emojiContainer Container element for the visible of emojis
* @param {Element} searchResultsContainer Contaienr element for the search results
* @param {Element} searchInput Search input element
* @param {Function} selectCallback Callback function to execute when a user selects an emoji
* @param {Function} renderAtPosition Render function to display current visible emojis
* @return {Function}
*/
const getHandleClick = (
recentEmojiRowCount,
emojiContainer,
searchResultsContainer,
searchInput,
selectCallback,
renderAtPosition
) => {
return (e, rowData, categoryScrollPositions) => {
const target = e.target;
let newRowData = rowData;
let newCategoryScrollPositions = categoryScrollPositions;
// Hide the search results if they are visible.
clearSearch(emojiContainer, searchResultsContainer, searchInput);
if (isEmojiElement(target)) {
// Emoji selected.
const unified = target.getAttribute('data-unified');
const shortnames = target.getAttribute('data-short-names').replace(/:/g, '').split(' ');
// Build the emoji data from the selected element.
const emojiData = {unified, shortnames};
const currentScrollTop = emojiContainer.scrollTop;
const isRecentEmojiRowVisible = emojiContainer.querySelector(`[data-row="${recentEmojiRowCount - 1}"]`) !== null;
// Save the selected emoji in the recent emojis list.
[newRowData, recentEmojiRowCount] = addRecentEmoji(rowData, recentEmojiRowCount, emojiData);
// Re-index the category scroll positions because the additional recent emoji may have
// changed their positions.
newCategoryScrollPositions = getCategoryScrollPositionsFromRowData(newRowData);
if (isRecentEmojiRowVisible) {
// If the list of recent emojis is currently visible then we need to re-render the emojis
// to update the display and show the newly selected recent emoji.
renderAtPosition(currentScrollTop, newRowData);
}
// Call the client's callback function with the selected emoji.
selectCallback(target.textContent);
// Return the newly calculated row data and scroll positions.
return [newRowData, newCategoryScrollPositions];
}
const categorySelector = findCategorySelectorFromElement(target);
if (categorySelector) {
// Category selector.
const selectedCategory = categorySelector.getAttribute('data-category');
const position = categoryScrollPositions[selectedCategory];
// Scroll the container to the selected category. This will trigger the
// on scroll handler to re-render the visibile emojis.
emojiContainer.scrollTop = position;
}
return [newRowData, newCategoryScrollPositions];
};
};
/**
* Build the function that handles scrolling of the emoji container to display the
* correct emojis.
*
* We render the emoji rows as they are needed rather than all up front so that we
* can avoid adding tends of thousands of elements to the DOM unnecessarily which
* would bog down performance.
*
* @method
* @param {Element} root The picker root element
* @param {Number} currentVisibleRowScrollPosition The current scroll position of the container
* @param {Element} emojiContainer The emojis container element
* @param {Object} initialCategoryScrollPositions Scroll positions for each category
* @param {Function} renderAtPosition Function to render the appropriate emojis for a scroll position
* @return {Function}
*/
const getHandleScroll = (
root,
currentVisibleRowScrollPosition,
emojiContainer,
initialCategoryScrollPositions,
renderAtPosition
) => {
// Scope some local variables to track the scroll positions of the categories. We need to
// recalculate these because adding recent emojis can change those positions by adding
// additional rows.
let [
currentCategoryElement,
previousCategoryPosition,
nextCategoryPosition
] = getCategoryByScrollPosition(root, emojiContainer.scrollTop, initialCategoryScrollPositions);
return (categoryScrollPositions, rowData) => {
const newScrollPosition = emojiContainer.scrollTop;
const upperScrollBound = currentVisibleRowScrollPosition + ROW_HEIGHT_RAW;
const lowerScrollBound = currentVisibleRowScrollPosition - ROW_HEIGHT_RAW;
// We only need to update the active category indicator if the user has scrolled into a
// new category scroll position.
const updateActiveCategory = (newScrollPosition >= nextCategoryPosition) ||
(newScrollPosition < previousCategoryPosition);
// We only need to render new emoji rows if the user has scrolled far enough that a new row
// would be visible (i.e. they've scrolled up or down more than 40px - the height of a row).
const updateRenderRows = (newScrollPosition < lowerScrollBound) || (newScrollPosition > upperScrollBound);
if (updateActiveCategory) {
// New category is visible so update the active category selector and re-index the
// positions incase anything has changed.
[
currentCategoryElement,
previousCategoryPosition,
nextCategoryPosition
] = getCategoryByScrollPosition(root, newScrollPosition, categoryScrollPositions);
setCategorySelectorActive(root, currentCategoryElement);
}
if (updateRenderRows) {
// A new row should be visible so re-render the visible emojis at this new position.
// We request an animation frame from the browser so that we're not blocking anything.
// The animation only needs to occur as soon as the browser is ready not immediately.
requestAnimationFrame(() => {
renderAtPosition(newScrollPosition, rowData);
// Remember the updated position.
currentVisibleRowScrollPosition = newScrollPosition;
});
}
};
};
/**
* Build the function that handles search input from the user.
*
* @method
* @param {Element} searchInput The search input element
* @param {Element} searchResultsContainer Container element to display the search results
* @param {Element} emojiContainer Container element for the emoji rows
* @return {Function}
*/
const getHandleSearch = (searchInput, searchResultsContainer, emojiContainer) => {
const rowContainer = searchResultsContainer.querySelector(SELECTORS.ROW_CONTAINER);
// Build a render function for the search results.
const renderSearchResultsAtPosition = generateRenderRowsAtPositionFunction(rowContainer);
searchResultsContainer.appendChild(rowContainer);
return async() => {
const searchTerm = searchInput.value.toLowerCase();
if (searchTerm) {
// Display the search results container and hide the emojis container.
showSearchResults(emojiContainer, searchResultsContainer);
// Find which emojis match the user's search input.
const matchingEmojis = Object.keys(EmojiData.byShortName).reduce((carry, shortName) => {
if (shortName.includes(searchTerm)) {
carry.push({
shortnames: [shortName],
unified: EmojiData.byShortName[shortName]
});
}
return carry;
}, []);
const searchResultsString = await getString('searchresults', 'core');
const rowData = createRowDataForCategory(searchResultsString, searchResultsString, matchingEmojis, 0);
// Show the emoji rows for the search results.
renderSearchResultsAtPosition(0, rowData, rowData.length);
} else {
// Hide the search container and show the emojis container.
clearSearch(emojiContainer, searchResultsContainer, searchInput);
}
};
};
/**
* Register the emoji picker event listeners.
*
* @method
* @param {Element} root The picker root element
* @param {Element} emojiContainer Root element containing the list of visible emojis
* @param {Function} renderAtPosition Function to render the visible emojis at a given scroll position
* @param {Number} currentVisibleRowScrollPosition What is the current scroll position
* @param {Function} selectCallback Function to execute when the user picks an emoji
* @param {Object} categoryScrollPositions Scroll positions for where each of the emoji categories begin
* @param {Array} rowData Data representing each of the display rows for hte emoji container
* @param {Number} recentEmojiRowCount Number of rows of recent emojis
*/
const registerEventListeners = (
root,
emojiContainer,
renderAtPosition,
currentVisibleRowScrollPosition,
selectCallback,
categoryScrollPositions,
rowData,
recentEmojiRowCount
) => {
const searchInput = root.querySelector(SELECTORS.SEARCH_INPUT);
const searchResultsContainer = root.querySelector(SELECTORS.SEARCH_RESULTS_CONTAINER);
const emojiPreview = root.querySelector(SELECTORS.EMOJI_PREVIEW);
const emojiShortName = root.querySelector(SELECTORS.EMOJI_SHORT_NAME);
// Build the click handler function.
const clickHandler = getHandleClick(
recentEmojiRowCount,
emojiContainer,
searchResultsContainer,
searchInput,
selectCallback,
renderAtPosition
);
// Build the scroll handler function.
const scrollHandler = getHandleScroll(
root,
currentVisibleRowScrollPosition,
emojiContainer,
categoryScrollPositions,
renderAtPosition
);
const searchHandler = getHandleSearch(searchInput, searchResultsContainer, emojiContainer);
// Mouse enter/leave events to show the emoji preview on hover or focus.
root.addEventListener('focus', getHandleMouseEnter(emojiPreview, emojiShortName), true);
root.addEventListener('blur', getHandleMouseLeave(emojiPreview, emojiShortName), true);
root.addEventListener('mouseenter', getHandleMouseEnter(emojiPreview, emojiShortName), true);
root.addEventListener('mouseleave', getHandleMouseLeave(emojiPreview, emojiShortName), true);
// User selects an emoji or clicks on one of the emoji category selectors.
root.addEventListener('click', e => {
// Update the row data and category scroll positions because they may have changes if the
// user selects an emoji which updates the recent emojis list.
[rowData, categoryScrollPositions] = clickHandler(e, rowData, categoryScrollPositions);
});
// Throttle the scroll event to only execute once every 50 milliseconds to prevent performance issues
// in the browser when re-rendering the picker emojis. The scroll event fires a lot otherwise.
emojiContainer.addEventListener('scroll', throttle(() => scrollHandler(categoryScrollPositions, rowData), 50));
// Debounce the search input so that it only executes 200 milliseconds after the user has finished typing.
searchInput.addEventListener('input', debounce(searchHandler, 200));
};
/**
* Initialise the emoji picker.
*
* @method
* @param {Element} root The root element for the picker
* @param {Function} selectCallback Callback for when the user selects an emoji
*/
export default (root, selectCallback) => {
const emojiContainer = root.querySelector(SELECTORS.EMOJIS_CONTAINER);
const rowContainer = emojiContainer.querySelector(SELECTORS.ROW_CONTAINER);
const recentEmojis = getRecentEmojis();
// Add the recent emojis category to the list of standard categories.
const allData = [{
name: 'Recent',
emojis: recentEmojis
}, ...EmojiData.byCategory];
let rowData = [];
let recentEmojiRowCount = 0;
/**
* Split categories data into rows which represent how they will be displayed in the
* picker. Each category will add a row containing the display name for the category
* and a row for every 9 emojis in the category. The row data will be used to calculate
* which emojis should be visible in the picker at any given time.
*
* E.g.
* input = [
* {name: 'example1', emojis: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]},
* {name: 'example2', emojis: [13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]},
* ]
* output = [
* {type: 'categoryName': data: 'Example 1'},
* {type: 'emojiRow': data: [1, 2, 3, 4, 5, 6, 7, 8, 9]},
* {type: 'emojiRow': data: [10, 11, 12]},
* {type: 'categoryName': data: 'Example 2'},
* {type: 'emojiRow': data: [13, 14, 15, 16, 17, 18, 19, 20, 21]},
* {type: 'emojiRow': data: [22, 23]},
* ]
*/
allData.forEach(category => {
const categorySelector = getCategorySelectorByCategoryName(root, category.name);
// Get the display name from the category selector button so that we don't need to
// send an ajax request for the string.
const categoryDisplayName = categorySelector.title;
const categoryRowData = createRowDataForCategory(category.name, categoryDisplayName, category.emojis, rowData.length);
if (category.name === 'Recent') {
// Remember how many recent emoji rows there are because it needs to be used to
// re-index the row data later when we're adding more recent emojis.
recentEmojiRowCount = categoryRowData.length;
}
rowData = rowData.concat(categoryRowData);
});
// Index the row data so that we can calculate which rows should be visible.
rowData = addIndexesToRowData(rowData);
// Calculate the scroll positions for each of the categories within the emoji container.
// These are used to know where to jump to when the user selects a specific category.
const categoryScrollPositions = getCategoryScrollPositionsFromRowData(rowData);
const renderAtPosition = generateRenderRowsAtPositionFunction(rowContainer);
// Display the initial set of emojis.
renderAtPosition(0, rowData);
registerEventListeners(
root,
emojiContainer,
renderAtPosition,
0,
selectCallback,
categoryScrollPositions,
rowData,
recentEmojiRowCount
);
};
+98
View File
@@ -0,0 +1,98 @@
// 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/>.
/**
* Global registry of core events that can be triggered/listened for.
*
* @module core/event
* @copyright 2015 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.0
*/
import {notifyEditorContentRestored} from 'core_editor/events';
import {notifyFilterContentUpdated} from 'core_filters/events';
import {notifyFormSubmittedByJavascript} from 'core_form/events';
// These are only imported for legacy.
import $ from 'jquery';
import Y from 'core/yui';
// These are AMD only events - no backwards compatibility for new things.
// Note: No new events should be created here.
const Events = {
FORM_FIELD_VALIDATION: "core_form-field-validation"
};
/**
* Load the legacy YUI module which defines events in M.core.event and return it.
*
* @method getLegacyEvents
* @return {Promise}
* @deprecated
*/
const getLegacyEvents = () => {
const result = $.Deferred();
window.console.warn("The getLegacyEvents function has been deprecated. Please update your code to use native events.");
Y.use('event', 'moodle-core-event', function() {
result.resolve(window.M.core.event);
});
return result.promise();
};
/**
* Get a curried function to warn that a function has been moved and renamed
*
* @param {String} oldFunctionName
* @param {String} newModule
* @param {String} newFunctionName
* @param {Function} newFunctionRef
* @returns {Function}
*/
const getRenamedLegacyFunction = (oldFunctionName, newModule, newFunctionName, newFunctionRef) => (...args) => {
window.console.warn(
`The core/event::${oldFunctionName}() function has been moved to ${newModule}::${newFunctionName}. ` +
`Please update your code to use the new module.`
);
return newFunctionRef(...args);
};
export default {
Events,
getLegacyEvents,
notifyEditorContentRestored: getRenamedLegacyFunction(
'notifyEditorContentRestored',
'core_editor/events',
'notifyEditorContentRestored',
notifyEditorContentRestored
),
notifyFilterContentUpdated: getRenamedLegacyFunction(
'notifyFilterContentUpdated',
'core_filters/events',
'notifyFilterContentUpdated',
notifyFilterContentUpdated
),
notifyFormSubmitAjax: getRenamedLegacyFunction(
'notifyFormSubmitAjax',
'core_form/events',
'notifyFormSubmittedByJavascript',
notifyFormSubmittedByJavascript
),
};
+82
View File
@@ -0,0 +1,82 @@
// 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/>.
/**
* An Event dispatcher used to dispatch Native JS CustomEvent objects with custom default properties.
*
* @module core/event_dispatcher
* @copyright 2021 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.0
*/
/**
* Dispatch an event as a CustomEvent on the specified container.
* By default events are bubbled, and cancelable.
*
* The eventName should typically by sourced using a constant. See the supplied examples.
*
* Note: This function uses native events. Any additional details are passed to the function in event.detail.
*
* This function mimics the behaviour of EventTarget.dispatchEvent but bubbles by default.
*
* @method dispatchEvent
* @param {String} eventName The name of the event
* @param {Object} detail Any additional details to pass into the eveent
* @param {HTMLElement} container The point at which to dispatch the event
* @param {Object} options
* @param {Boolean} options.bubbles Whether to bubble up the DOM
* @param {Boolean} options.cancelable Whether preventDefault() can be called
* @param {Boolean} options.composed Whether the event can bubble across the ShadowDOM bounadry
* @returns {CustomEvent}
*
* @example <caption>Using a native CustomEvent to indicate that some example data was displayed.</caption>
* // mod/example/amd/src/events.js
*
* import {dispatchEvent} from 'core/event_dispatcher';
*
* export const eventTypes = {
* exampleDataDisplayed: 'mod_example/exampleDataDisplayed',
* };
*
* export const notifyExampleDisplayed = someArgument => dispatchEvent(eventTypes.exampleDataDisplayed, {
* someArgument,
* }, document, {
* cancelable: false,
* });
*/
export const dispatchEvent = (
eventName,
detail = {},
container = document,
{
bubbles = true,
cancelable = false,
composed = false,
} = {}
) => {
const customEvent = new CustomEvent(
eventName,
{
bubbles,
cancelable,
composed,
detail,
}
);
container.dispatchEvent(customEvent);
return customEvent;
};
+38
View File
@@ -0,0 +1,38 @@
// 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 is an empty module, that is required before all other modules.
* Because every module is returned from a request for any other module, this
* forces the loading of all modules with a single request.
*
* This function also sets up the listeners for ajax requests so we can tell
* if any requests are still in progress.
*
* @module core/first
* @copyright 2015 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 2.9
*/
import $ from 'jquery';
$(document)
.bind("ajaxStart", function() {
M.util.js_pending('jq');
})
.bind("ajaxStop", function() {
M.util.js_complete('jq');
});
File diff suppressed because it is too large Load Diff
+74
View File
@@ -0,0 +1,74 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* Course selector adaptor for auto-complete form element.
*
* @module core/form-cohort-selector
* @copyright 2016 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.1
*/
define(['core/ajax', 'jquery'], function(ajax, $) {
return {
// Public variables and functions.
processResults: function(selector, data) {
// Mangle the results into an array of objects.
var results = [];
var i = 0;
var excludelist = String($(selector).data('exclude')).split(',');
for (i = 0; i < data.cohorts.length; i++) {
if (excludelist.indexOf(String(data.cohorts[i].id)) === -1) {
results.push({value: data.cohorts[i].id, label: data.cohorts[i].name});
}
}
return results;
},
transport: function(selector, query, success, failure) {
var el = $(selector);
// Parse some data-attributes from the form element.
// Build the query.
var promises = null;
if (typeof query === "undefined") {
query = '';
}
var contextid = el.data('contextid');
var searchargs = {
query: query,
includes: 'parents',
limitfrom: 0,
limitnum: 100,
context: {contextid: contextid}
};
var calls = [{
methodname: 'core_cohort_search_cohorts', args: searchargs
}];
// Go go go!
promises = ajax.call(calls);
$.when.apply($.when, promises).done(function(data) {
success(data);
}).fail(failure);
}
};
});
+104
View File
@@ -0,0 +1,104 @@
// 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/>.
/**
* Course selector adaptor for auto-complete form element.
*
* @module core/form-course-selector
* @copyright 2016 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.1
*/
define(['core/ajax', 'jquery'], function(ajax, $) {
return {
// Public variables and functions.
processResults: function(selector, data) {
// Mangle the results into an array of objects.
var results = [];
var i = 0;
var excludelist = String($(selector).data('exclude')).split(',');
for (i = 0; i < data.courses.length; i++) {
if (excludelist.indexOf(String(data.courses[i].id)) === -1) {
results.push({value: data.courses[i].id, label: data.courses[i].displayname});
}
}
return results;
},
transport: function(selector, query, success, failure) {
var el = $(selector);
// Parse some data-attributes from the form element.
var requiredcapabilities = el.data('requiredcapabilities');
if (requiredcapabilities.trim() !== "") {
requiredcapabilities = requiredcapabilities.split(',');
} else {
requiredcapabilities = [];
}
var limittoenrolled = el.data('limittoenrolled');
var includefrontpage = el.data('includefrontpage');
var onlywithcompletion = el.data('onlywithcompletion');
// Build the query.
var promises = null;
if (typeof query === "undefined") {
query = '';
}
var searchargs = {
criterianame: 'search',
criteriavalue: query,
page: 0,
perpage: 100,
requiredcapabilities: requiredcapabilities,
limittoenrolled: limittoenrolled,
onlywithcompletion: onlywithcompletion
};
var calls = [{
methodname: 'core_course_search_courses', args: searchargs
}];
if (includefrontpage) {
calls.push({
methodname: 'core_course_get_courses',
args: {
options: {
ids: [includefrontpage]
}
}
});
}
// Go go go!
promises = ajax.call(calls);
$.when.apply($.when, promises).done(function(data, site) {
if (site && site.length == 1) {
var frontpage = site.pop();
var matches = query === ''
|| frontpage.fullname.toUpperCase().indexOf(query.toUpperCase()) > -1
|| frontpage.shortname.toUpperCase().indexOf(query.toUpperCase()) > -1;
if (matches) {
data.courses.splice(0, 0, frontpage);
}
}
success(data);
}).fail(failure);
}
};
});
+128
View File
@@ -0,0 +1,128 @@
// 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 way to call HTML fragments to be inserted as required via JavaScript.
*
* @module core/fragment
* @copyright 2016 Adrian Greeve <adrian@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.1
*/
define(['jquery', 'core/ajax'], function($, ajax) {
/**
* Loads an HTML fragment through a callback.
*
* @method loadFragment
* @param {string} component Component where callback is located.
* @param {string} callback Callback function name.
* @param {integer} contextid Context ID of the fragment.
* @param {object} params Parameters for the callback.
* @return {Promise} JQuery promise object resolved when the fragment has been loaded.
*/
var loadFragment = function(component, callback, contextid, params) {
// Change params into required webservice format.
var formattedparams = [];
for (var index in params) {
formattedparams.push({
name: index,
value: params[index]
});
}
return ajax.call([{
methodname: 'core_get_fragment',
args: {
component: component,
callback: callback,
contextid: contextid,
args: formattedparams
}
}])[0];
};
/**
* Converts the JS that was received from collecting JS requirements on the $PAGE so it can be added to the existing page
*
* @param {string} js
* @return {string}
*/
var processCollectedJavascript = function(js) {
var jsNodes = $(js);
var allScript = '';
jsNodes.each(function(index, scriptNode) {
scriptNode = $(scriptNode);
var tagName = scriptNode.prop('tagName');
if (tagName && (tagName.toLowerCase() == 'script')) {
if (scriptNode.attr('src')) {
// We only reload the script if it was not loaded already.
var exists = false;
$('script').each(function(index, s) {
if ($(s).attr('src') == scriptNode.attr('src')) {
exists = true;
}
return !exists;
});
if (!exists) {
allScript += ' { ';
allScript += ' node = document.createElement("script"); ';
allScript += ' node.type = "text/javascript"; ';
allScript += ' node.src = decodeURI("' + encodeURI(scriptNode.attr('src')) + '"); ';
allScript += ' document.getElementsByTagName("head")[0].appendChild(node); ';
allScript += ' } ';
}
} else {
allScript += ' ' + scriptNode.text();
}
}
});
return allScript;
};
return {
/**
* Appends HTML and JavaScript fragments to specified nodes.
* Callbacks called by this AMD module are responsible for doing the appropriate security checks
* to access the information that is returned. This only does minimal validation on the context.
*
* @method fragmentAppend
* @param {string} component Component where callback is located.
* @param {string} callback Callback function name.
* @param {integer} contextid Context ID of the fragment.
* @param {object} params Parameters for the callback.
* @return {Deferred} new promise that is resolved with the html and js.
*/
loadFragment: function(component, callback, contextid, params) {
var promise = $.Deferred();
loadFragment(component, callback, contextid, params).then(function(data) {
promise.resolve(data.html, processCollectedJavascript(data.javascript));
}).fail(function(ex) {
promise.reject(ex);
});
return promise.promise();
},
/**
* Converts the JS that was received from collecting JS requirements on the $PAGE so it can be added to the existing page
*
* @param {string} js
* @return {string}
*/
processCollectedJavascript: function(js) {
return processCollectedJavascript(js);
}
};
});
+47
View File
@@ -0,0 +1,47 @@
// 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/>.
/**
* Detects if an element is fullscreen.
*
* @module core/fullscreen
* @copyright 2020 University of Nottingham
* @author Neill Magill <neill.magill@nottingham.ac.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Gets the element that is fullscreen or null if no element is fullscreen.
*
* @method
* @returns {HTMLElement}
*/
export const getElement = () => {
let element = null;
if (document.fullscreenElement) {
element = document.fullscreenElement;
} else if (document.mozFullscreenElement) {
// Fallback for older Firefox.
element = document.mozFullscreenElement;
} else if (document.msFullscreenElement) {
// Fallback for Edge and IE.
element = document.msFullscreenElement;
} else if (document.webkitFullscreenElement) {
// Fallback for Chrome, Edge and Safari.
element = document.webkitFullscreenElement;
}
return element;
};
+106
View File
@@ -0,0 +1,106 @@
// 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/>.
/**
* Icon System base module.
*
* @module core/icon_system
* @copyright 2017 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import config from 'core/config';
/**
* Icon System abstract class.
*
* Any icon system needs to define a module extending this one and return this module name from the php icon_system class.
*
* @class core/icon_system
*/
export default class IconSystem {
/**
* A Promise which resolves to the Icon System instance.
*
* @private
* @var {Promise<IconSystem>}
*/
static iconSystemInstance = null;
/**
* Factory method to fetch the Icon System currently in use.
*
* @returns {Promise<IconSystem>}
*/
static async instance() {
if (this.iconSystemInstance) {
return await this.iconSystemInstance;
}
this.iconSystemInstance = (async () => {
const SystemClass = await import(config.iconsystemmodule);
const instance = new SystemClass();
if (!(instance instanceof IconSystem)) {
window.console.error('Class is not an IconSystem', SystemClass);
throw Error(`Invalid icon system specified ${config.iconsystemmodule}. Class is not an IconSystem.`);
}
return await instance.init();
})();
return await this.iconSystemInstance;
}
/**
* Initialise the icon system.
*
* @return {Promise<IconSystem>}
*/
init() {
return Promise.resolve(this);
}
/**
* Render an icon.
*
* The key, component and title come from either the pix mustache helper tag, or the call to templates.renderIcon.
* The template is the pre-loaded template string matching the template from getTemplateName() in this class.
* This function must return a string (not a promise) because it is used during the internal rendering of the mustache
* template (which is unfortunately synchronous). To render the mustache template in this function call
* core/mustache.render() directly and do not use any partials, blocks or helper functions in the template.
*
* @param {string} key
* @param {string} component
* @param {string} title
* @param {string} template
* @returns {string}
* @method renderIcon
*/
// eslint-disable-next-line no-unused-vars
renderIcon(key, component, title, template) {
throw new Error('Abstract function not implemented.');
}
/**
* Get the name of the template.
*
* @returns {string}
* @method getTemplateName
*/
// eslint-disable-next-line no-unused-vars
getTemplateName() {
throw new Error('Abstract function not implemented.');
}
}
+196
View File
@@ -0,0 +1,196 @@
// 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/>.
/**
* An Icon System implementation for FontAwesome.
*
* @module core/icon_system_fontawesome
* @copyright 2017 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {call as fetchMany} from './ajax';
import LocalStorage from './localstorage';
import IconSystem from './icon_system';
import * as Mustache from './mustache';
import * as Config from './config';
import * as Url from './url';
/**
* An set of properties for an icon.
* @typedef {object} IconProperties
* @property {array} attributes
* @private
*/
/**
* The FontAwesome icon system.
*/
export default class IconSystemFontawesome extends IconSystem {
/**
* @var {Map} staticMap A map of icon names to FA Icon.
* @private
*/
static staticMap = null;
/**
* @var {Promise} fetchPromise The promise used when fetching the result
* @private
*/
static fetchPromise = null;
/**
* @var {string} cacheKey The key used to store the icon map in LocalStorage.
* @private
*/
static cacheKey = `core_iconsystem/theme/${Config.theme}/core/iconmap-fontawesome`;
/**
* Prefetch resources so later calls to renderIcon can be resolved synchronously.
*
* @returns {Promise<IconSystemFontawesome>}
*/
init() {
if (IconSystemFontawesome.staticMap) {
return Promise.resolve(this);
}
if (this.getMapFromCache()) {
return Promise.resolve(this);
}
if (IconSystemFontawesome.fetchPromise) {
return IconSystemFontawesome.fetchPromise;
}
return this.fetchMapFromServer();
}
/**
* Get the icon map from LocalStorage.
*
* @private
* @returns {Map}
*/
getMapFromCache() {
const map = LocalStorage.get(IconSystemFontawesome.cacheKey);
if (map) {
IconSystemFontawesome.staticMap = new Map(JSON.parse(map));
}
return IconSystemFontawesome.staticMap;
}
/**
* Fetch the map data from the server.
*
* @private
* @returns {Promise}
*/
_fetchMapFromServer() {
return fetchMany([{
methodname: 'core_output_load_fontawesome_icon_system_map',
args: {
themename: Config.theme,
},
}], true, false, false, 0, Config.themerev)[0];
}
/**
* Fetch the map data from the server.
*
* @returns {Promise<IconSystemFontawesome>}
* @private
*/
async fetchMapFromServer() {
IconSystemFontawesome.fetchPromise = (async () => {
const mapData = await this._fetchMapFromServer();
IconSystemFontawesome.staticMap = new Map(Object.entries(mapData).map(([, value]) => ([
`${value.component}/${value.pix}`,
value.to,
])));
LocalStorage.set(
IconSystemFontawesome.cacheKey,
JSON.stringify(Array.from(IconSystemFontawesome.staticMap.entries())),
);
return this;
})();
return IconSystemFontawesome.fetchPromise;
}
/**
* Render an icon.
*
* @param {string} key
* @param {string} component
* @param {string} title
* @param {string} template
* @return {string} The rendered HTML content
*/
renderIcon(key, component, title, template) {
const iconKey = `${component}/${key}`;
const mappedIcon = IconSystemFontawesome.staticMap.get(iconKey);
const unmappedIcon = this.getUnmappedIcon(mappedIcon, key, component, title);
const context = {
title,
unmappedIcon,
alt: title,
key: mappedIcon,
};
if (typeof title === "undefined" || title === '') {
context['aria-hidden'] = true;
}
return Mustache.render(template, context).trim();
}
/**
* Get the unmapped icon content, if the icon is not mapped.
*
* @param {IconProperties} mappedIcon
* @param {string} key
* @param {string} component
* @param {string} title
* @returns {IconProperties|null}
* @private
*/
getUnmappedIcon(mappedIcon, key, component, title) {
if (mappedIcon) {
return null;
}
return {
attributes: [
{name: 'src', value: Url.imageUrl(key, component)},
{name: 'alt', value: title},
{name: 'title', value: title}
],
};
}
/**
* Get the name of the template to pre-cache for this icon system.
*
* @return {string}
* @method getTemplateName
*/
getTemplateName() {
return 'core/pix_icon_fontawesome';
}
}
+67
View File
@@ -0,0 +1,67 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* The Standard icon system.
*
* @module core/icon_system_standard
* @copyright 2017 Damyon Wiese
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import IconSystem from './icon_system';
import * as CoreUrl from './url';
import * as Mustache from './mustache';
/**
* The Standard icon system.
*/
export default class IconSystemStandard extends IconSystem {
/**
* Render an icon.
*
* @param {string} key
* @param {string} component
* @param {string} title
* @param {string} template
* @return {string}
*/
renderIcon(key, component, title, template) {
const url = CoreUrl.imageUrl(key, component);
const templatecontext = {
attributes: [
{name: 'src', value: url},
{name: 'alt', value: title},
{name: 'title', value: title},
]
};
if (typeof title === "undefined" || title == "") {
templatecontext.attributes.push({name: 'aria-hidden', value: 'true'});
}
return Mustache.render(template, templatecontext).trim();
}
/**
* Get the name of the template to pre-cache for this icon system.
*
* @return {string}
*/
getTemplateName() {
return 'core/pix_icon';
}
}
+394
View File
@@ -0,0 +1,394 @@
// 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/>.
/**
* AJAX helper for the inline editing a value.
*
* This script is automatically included from template core/inplace_editable
* It registers a click-listener on [data-inplaceeditablelink] link (the "inplace edit" icon),
* then replaces the displayed value with an input field. On "Enter" it sends a request
* to web service core_update_inplace_editable, which invokes the specified callback.
* Any exception thrown by the web service (or callback) is displayed as an error popup.
*
* @module core/inplace_editable
* @copyright 2016 Marina Glancy
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.1
*/
define(
['jquery',
'core/ajax',
'core/templates',
'core/notification',
'core/str',
'core/config',
'core/url',
'core/form-autocomplete',
'core/loadingicon',
'core/pending',
'core/local/inplace_editable/events',
],
function($, ajax, templates, notification, str, cfg, url, autocomplete, LoadingIcon, Pending, Events) {
const removeSpinner = function(element) {
element.find('.loading-icon').hide();
};
/**
* Update an inplace editable value.
*
* @param {Jquery} mainelement the element to update
* @param {string} value the new value
* @param {bool} silent if true the change won't alter the current page focus
* @fires event:core/inplace_editable:updated
* @fires event:core/inplace_editable:updateFailed
*/
const updateValue = function(mainelement, value, silent) {
var pendingId = [
mainelement.attr('data-itemid'),
mainelement.attr('data-component'),
mainelement.attr('data-itemtype'),
].join('-');
var pendingPromise = new Pending(pendingId);
LoadingIcon.addIconToContainerRemoveOnCompletion(mainelement, pendingPromise);
ajax.call([{
methodname: 'core_update_inplace_editable',
args: {
itemid: mainelement.attr('data-itemid'),
component: mainelement.attr('data-component'),
itemtype: mainelement.attr('data-itemtype'),
value: value,
},
}])[0]
.then(function(data) {
return templates.render('core/inplace_editable', data)
.then(function(html, js) {
var oldvalue = mainelement.attr('data-value');
var newelement = $(html);
templates.replaceNode(mainelement, newelement, js);
if (!silent) {
newelement.find('[data-inplaceeditablelink]').focus();
}
// Trigger updated event on the DOM element.
Events.notifyElementUpdated(newelement.get(0), data, oldvalue);
return;
});
})
.then(function() {
return pendingPromise.resolve();
})
.fail(function(ex) {
removeSpinner(mainelement);
M.util.js_complete(pendingId);
// Trigger update failed event on the DOM element.
let updateFailedEvent = Events.notifyElementUpdateFailed(mainelement.get(0), ex, value);
if (!updateFailedEvent.defaultPrevented) {
notification.exception(ex);
}
});
};
$('body').on('click keypress', '[data-inplaceeditable] [data-inplaceeditablelink]', function(e) {
if (e.type === 'keypress' && e.keyCode !== 13) {
return;
}
var editingEnabledPromise = new Pending('autocomplete-start-editing');
e.stopImmediatePropagation();
e.preventDefault();
var target = $(this),
mainelement = target.closest('[data-inplaceeditable]');
var turnEditingOff = function(el) {
el.find('input').off();
el.find('select').off();
el.html(el.attr('data-oldcontent'));
el.removeAttr('data-oldcontent');
el.removeClass('inplaceeditingon');
el.find('[data-inplaceeditablelink]').focus();
// Re-enable any parent draggable attribute.
el.parents(`[data-inplace-in-draggable="true"]`)
.attr('draggable', true)
.attr('data-inplace-in-draggable', false);
};
var turnEditingOffEverywhere = function() {
// Re-enable any disabled draggable attribute.
$(`[data-inplace-in-draggable="true"]`)
.attr('draggable', true)
.attr('data-inplace-in-draggable', false);
$('span.inplaceeditable.inplaceeditingon').each(function() {
turnEditingOff($(this));
});
};
var uniqueId = function(prefix, idlength) {
var uniqid = prefix,
i;
for (i = 0; i < idlength; i++) {
uniqid += String(Math.floor(Math.random() * 10));
}
// Make sure this ID is not already taken by an existing element.
if ($("#" + uniqid).length === 0) {
return uniqid;
}
return uniqueId(prefix, idlength);
};
var turnEditingOnText = function(el) {
str.get_string('edittitleinstructions').done(function(s) {
var instr = $('<span class="editinstructions">' + s + '</span>').
attr('id', uniqueId('id_editinstructions_', 20)),
inputelement = $('<input type="text"/>').
attr('id', uniqueId('id_inplacevalue_', 20)).
attr('value', el.attr('data-value')).
attr('aria-describedby', instr.attr('id')).
addClass('ignoredirty').
addClass('form-control'),
lbl = $('<label class="accesshide">' + mainelement.attr('data-editlabel') + '</label>').
attr('for', inputelement.attr('id'));
el.html('').append(instr).append(lbl).append(inputelement);
inputelement.focus();
inputelement.select();
inputelement.on('keyup keypress focusout', function(e) {
if (cfg.behatsiterunning && e.type === 'focusout') {
// Behat triggers focusout too often.
return;
}
if (e.type === 'keypress' && e.keyCode === 13) {
// We need 'keypress' event for Enter because keyup/keydown would catch Enter that was
// pressed in other fields.
var val = inputelement.val();
turnEditingOff(el);
updateValue(el, val);
}
if ((e.type === 'keyup' && e.keyCode === 27) || e.type === 'focusout') {
// We need 'keyup' event for Escape because keypress does not work with Escape.
turnEditingOff(el);
}
});
});
};
var turnEditingOnToggle = function(el, newvalue) {
turnEditingOff(el);
updateValue(el, newvalue);
};
var turnEditingOnSelect = function(el, options) {
var i,
inputelement = $('<select></select>').
attr('id', uniqueId('id_inplacevalue_', 20)).
addClass('custom-select'),
lbl = $('<label class="accesshide">' + mainelement.attr('data-editlabel') + '</label>')
.attr('for', inputelement.attr('id'));
for (i in options) {
inputelement
.append($('<option>')
.attr('value', options[i].key)
.html(options[i].value));
}
inputelement.val(el.attr('data-value'));
el.html('')
.append(lbl)
.append(inputelement);
inputelement.focus();
inputelement.select();
inputelement.on('keyup change focusout', function(e) {
if (cfg.behatsiterunning && e.type === 'focusout') {
// Behat triggers focusout too often.
return;
}
if (e.type === 'change') {
var val = inputelement.val();
turnEditingOff(el);
updateValue(el, val);
}
if ((e.type === 'keyup' && e.keyCode === 27) || e.type === 'focusout') {
// We need 'keyup' event for Escape because keypress does not work with Escape.
turnEditingOff(el);
}
});
};
var turnEditingOnAutocomplete = function(el, args) {
var i,
inputelement = $('<select></select>').
attr('id', uniqueId('id_inplacevalue_', 20)).
addClass('form-autocomplete-original-select').
addClass('custom-select'),
lbl = $('<label class="accesshide">' + mainelement.attr('data-editlabel') + '</label>')
.attr('for', inputelement.attr('id')),
options = args.options,
attributes = args.attributes,
saveelement = $('<a href="#"></a>'),
cancelelement = $('<a href="#"></a>');
for (i in options) {
inputelement
.append($('<option>')
.attr('value', options[i].key)
.html(options[i].value));
}
if (attributes.multiple) {
inputelement.attr('multiple', 'true');
}
inputelement.val(JSON.parse(el.attr('data-value')));
str.get_string('savechanges', 'core').then(function(s) {
return templates.renderPix('e/save', 'core', s);
}).then(function(html) {
saveelement.append(html);
return;
}).fail(notification.exception);
str.get_string('cancel', 'core').then(function(s) {
return templates.renderPix('e/cancel', 'core', s);
}).then(function(html) {
cancelelement.append(html);
return;
}).fail(notification.exception);
el.html('')
.append(lbl)
.append(inputelement)
.append(saveelement)
.append(cancelelement);
inputelement.focus();
inputelement.select();
autocomplete.enhance(inputelement,
attributes.tags,
attributes.ajax,
attributes.placeholder,
attributes.caseSensitive,
attributes.showSuggestions,
attributes.noSelectionString)
.then(function() {
// Focus on the enhanced combobox.
el.find('[role=combobox]').focus();
// Stop eslint nagging.
return;
}).fail(notification.exception);
inputelement.on('keyup', function(e) {
if ((e.type === 'keyup' && e.keyCode === 27) || e.type === 'focusout') {
// We need 'keyup' event for Escape because keypress does not work with Escape.
turnEditingOff(el);
}
});
saveelement.on('click', function(e) {
var val = JSON.stringify(inputelement.val());
// We need to empty the node to destroy all event handlers etc.
inputelement.empty();
turnEditingOff(el);
updateValue(el, val);
e.preventDefault();
});
cancelelement.on('click', function(e) {
// We need to empty the node to destroy all event handlers etc.
inputelement.empty();
turnEditingOff(el);
e.preventDefault();
});
};
var turnEditingOn = function(el) {
el.addClass('inplaceeditingon');
el.attr('data-oldcontent', el.html());
var type = el.attr('data-type');
var options = el.attr('data-options');
// Input text inside draggable elements disable text selection in some browsers.
// To prevent this we temporally disable any parent draggables.
el.parents('[draggable="true"]')
.attr('data-inplace-in-draggable', true)
.attr('draggable', false);
if (type === 'toggle') {
turnEditingOnToggle(el, options);
} else if (type === 'select') {
turnEditingOnSelect(el, $.parseJSON(options));
} else if (type === 'autocomplete') {
turnEditingOnAutocomplete(el, $.parseJSON(options));
} else {
turnEditingOnText(el);
}
};
// Turn editing on for the current element and register handler for Enter/Esc keys.
turnEditingOffEverywhere();
turnEditingOn(mainelement);
editingEnabledPromise.resolve();
});
return {
/**
* Return an object to interact with the current inplace editables at a frontend level.
*
* @param {Element} parent the parent element containing a inplace editable
* @returns {Object|undefined} an object to interact with the inplace element, or undefined
* if no inplace editable is found.
*/
getInplaceEditable: function(parent) {
const element = parent.querySelector(`[data-inplaceeditable]`);
if (!element) {
return undefined;
}
// Return an object to interact with the inplace editable.
return {
element,
/**
* Get the value from the inplace editable.
*
* @returns {string} the current inplace value
*/
getValue: function() {
return this.element.dataset.value;
},
/**
* Force a value change.
*
* @param {string} newvalue the new value
* @fires event:core/inplace_editable:updated
* @fires event:core/inplace_editable:updateFailed
*/
setValue: function(newvalue) {
updateValue($(this.element), newvalue, true);
},
/**
* Return the inplace editable itemid.
*
* @returns {string} the current itemid
*/
getItemId: function() {
return this.element.dataset.itemid;
},
};
}
};
});
+48
View File
@@ -0,0 +1,48 @@
// 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 list of human readable names for the keycodes.
*
* @module core/key_codes
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 3.2
*/
define(function() {
/**
* @type {object}
*/
return {
'tab': 9,
'enter': 13,
'shift': 16,
'ctrl': 17,
'alt': 18,
'escape': 27,
'space': 32,
'end': 35,
'home': 36,
'arrowLeft': 37,
'arrowUp': 38,
'arrowRight': 39,
'arrowDown': 40,
'8': 56,
'asterix': 106,
'pageUp': 33,
'pageDown': 34,
};
});
+108
View File
@@ -0,0 +1,108 @@
// 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/>.
/**
* Contain the logic for the loading icon.
*
* @module core/loadingicon
* @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define(['jquery', 'core/templates'], function($, Templates) {
var TEMPLATES = {
LOADING: 'core/loading',
};
var getIcon = function() {
return Templates.render(TEMPLATES.LOADING, {});
};
/**
* Add a loading icon to the end of the specified container and return an unresolved promise.
*
* Resolution of the returned promise causes the icon to be faded out and removed.
*
* @method addIconToContainer
* @param {jQuery|HTMLElement} container The element to add the spinner to
* @return {Promise} The Promise used to create the icon.
*/
var addIconToContainer = function(container) {
return getIcon()
.then(function(html) {
var loadingIcon = $(html).hide();
$(container).append(loadingIcon);
loadingIcon.fadeIn(150);
return loadingIcon;
});
};
/**
* Add a loading icon to the end of the specified container and return an unresolved promise.
*
* Resolution of the returned promise causes the icon to be faded out and removed.
*
* @method addIconToContainerWithPromise
* @param {jQuery|HTMLElement} container The element to add the spinner to
* @param {Promise} loadingIconPromise The jQuery Promise which determines the removal of the icon
* @return {jQuery} The Promise used to create and then remove the icon.
*/
var addIconToContainerRemoveOnCompletion = function(container, loadingIconPromise) {
return getIcon()
.then(function(html) {
var loadingIcon = $(html).hide();
$(container).append(loadingIcon);
loadingIcon.fadeIn(150);
return $.when(loadingIcon.promise(), loadingIconPromise);
})
.then(function(loadingIcon) {
// Once the content has finished loading and
// the loading icon has been shown then we can
// fade the icon away to reveal the content.
return loadingIcon.fadeOut(100).promise();
})
.then(function(loadingIcon) {
loadingIcon.remove();
return;
});
};
/**
* Add a loading icon to the end of the specified container and return an unresolved promise.
*
* Resolution of the returned promise causes the icon to be faded out and removed.
*
* @method addIconToContainerWithPromise
* @param {jQuery|HTMLElement} container The element to add the spinner to
* @return {Promise} A jQuery Promise to resolve when ready
*/
var addIconToContainerWithPromise = function(container) {
var loadingIconPromise = $.Deferred();
addIconToContainerRemoveOnCompletion(container, loadingIconPromise);
return loadingIconPromise;
};
return {
getIcon: getIcon,
addIconToContainer: addIconToContainer,
addIconToContainerWithPromise: addIconToContainerWithPromise,
addIconToContainerRemoveOnCompletion: addIconToContainerRemoveOnCompletion,
};
});
+410
View File
@@ -0,0 +1,410 @@
// 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/>.
/**
* Action menu subpanel JS controls.
*
* @module core/local/action_menu/subpanel
* @copyright 2023 Mikel Martín <mikel@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import jQuery from 'jquery';
import {debounce} from 'core/utils';
import {
isBehatSite,
isExtraSmall,
firstFocusableElement,
lastFocusableElement,
previousFocusableElement,
nextFocusableElement,
} from 'core/pagehelpers';
import Pending from 'core/pending';
import {
hide,
unhide,
} from 'core/aria';
const Selectors = {
mainMenu: '[role="menu"]',
dropdownRight: '.dropdown-menu-right',
subPanel: '.dropdown-subpanel',
subPanelMenuItem: '.dropdown-subpanel > .dropdown-item',
subPanelContent: '.dropdown-subpanel > .dropdown-menu',
// Drawer selector.
drawer: '[data-region="fixed-drawer"]',
// Lateral blocks columns selectors.
blockColumn: '.blockcolumn',
columnLeft: '.columnleft',
};
const Classes = {
dropRight: 'dropright',
dropLeft: 'dropleft',
dropDown: 'dropdown',
forceLeft: 'downleft',
contentDisplayed: 'content-displayed',
};
const BootstrapEvents = {
hideDropdown: 'hidden.bs.dropdown',
};
let initialized = false;
/**
* Initialize all delegated events into the page.
*/
const initPageEvents = () => {
if (initialized) {
return;
}
// Hide all subpanels when hidind a dropdown.
// This is using JQuery because of BS4 events. JQuery won't be needed with BS5.
jQuery(document).on(BootstrapEvents.hideDropdown, () => {
document.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {
const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);
const subPanel = new SubPanel(dropdownSubPanel);
subPanel.setVisibility(false);
});
});
window.addEventListener('resize', debounce(updateAllPanelsPosition, 400));
initialized = true;
};
/**
* Update all the panels position.
*/
const updateAllPanelsPosition = () => {
document.querySelectorAll(Selectors.subPanel).forEach(dropdown => {
const subpanel = new SubPanel(dropdown);
subpanel.updatePosition();
});
};
/**
* Subpanel class.
* @private
*/
class SubPanel {
/**
* Constructor.
* @param {HTMLElement} element The element to initialize.
*/
constructor(element) {
this.element = element;
this.menuItem = element.querySelector(Selectors.subPanelMenuItem);
this.panelContent = element.querySelector(Selectors.subPanelContent);
/**
* Enable preview when the menu item has focus.
*
* This is disabled when the user press ESC or shift+TAB to force closing
*
* @type {Boolean}
* @private
*/
this.showPreviewOnFocus = true;
}
/**
* Initialize the subpanel element.
*
* This method adds the event listeners to the subpanel and the position classes.
*/
init() {
if (this.element.dataset.subPanelInitialized) {
return;
}
this.updatePosition();
// Full element events.
this.element.addEventListener('focusin', this._mainElementFocusInHandler.bind(this));
// Menu Item events.
this.menuItem.addEventListener('click', this._menuItemClickHandler.bind(this));
this.menuItem.addEventListener('keydown', this._menuItemKeyHandler.bind(this));
if (!isBehatSite()) {
// Behat in Chrome usually move the mouse over the page when trying clicking a subpanel element.
// If the menu has more than one subpanel this could cause closing the subpanel by mistake.
this.menuItem.addEventListener('mouseover', this._menuItemHoverHandler.bind(this));
this.menuItem.addEventListener('mouseout', this._menuItemHoverOutHandler.bind(this));
}
// Subpanel content events.
this.panelContent.addEventListener('keydown', this._panelContentKeyHandler.bind(this));
this.element.dataset.subPanelInitialized = true;
}
/**
* Checks if the subpanel has enough space.
*
* In general there are two scenarios were the subpanel must be interacted differently:
* - Extra small screens: The subpanel is displayed below the menu item.
* - Drawer: The subpanel is displayed one of the drawers.
* - Block columns: for classic based themes.
*
* @returns {Boolean} true if the subpanel should be displayed in small screens.
*/
_needSmallSpaceBehaviour() {
return isExtraSmall() ||
this.element.closest(Selectors.drawer) !== null ||
this.element.closest(Selectors.blockColumn) !== null;
}
/**
* Check if the subpanel should be displayed on the right.
*
* This is defined by the drop right boostrap class. However, if the menu is
* displayed in a block column on the right, the subpanel should be forced
* to the right.
*
* @returns {Boolean} true if the subpanel should be displayed on the right.
*/
_needDropdownRight() {
if (this.element.closest(Selectors.columnLeft) !== null) {
return false;
}
return this.element.closest(Selectors.dropdownRight) !== null;
}
/**
* Main element focus in handler.
*/
_mainElementFocusInHandler() {
if (this._needSmallSpaceBehaviour() || !this.showPreviewOnFocus) {
// Preview is disabled when the user press ESC or shift+TAB to force closing
// but if the continue navigating with keyboard the preview is enabled again.
this.showPreviewOnFocus = true;
return;
}
this.setVisibility(true);
}
/**
* Menu item click handler.
* @param {Event} event
*/
_menuItemClickHandler(event) {
// Avoid dropdowns being closed after clicking a subemnu.
// This won't be needed with BS5 (data-bs-auto-close handles it).
event.stopPropagation();
event.preventDefault();
if (this._needSmallSpaceBehaviour()) {
this.setVisibility(!this.getVisibility());
}
}
/**
* Menu item hover handler.
* @private
*/
_menuItemHoverHandler() {
if (this._needSmallSpaceBehaviour()) {
return;
}
this.setVisibility(true);
}
/**
* Menu item hover out handler.
* @private
*/
_menuItemHoverOutHandler() {
if (this._needSmallSpaceBehaviour()) {
return;
}
this._hideOtherSubPanels();
}
/**
* Menu item key handler.
* @param {Event} event
* @private
*/
_menuItemKeyHandler(event) {
// In small sizes te down key will focus on the panel.
if (event.key === 'ArrowUp' || (event.key === 'ArrowDown' && !this._needSmallSpaceBehaviour())) {
this.setVisibility(false);
return;
}
// Keys to move focus to the panel.
let focusPanel = false;
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft' || (event.key === 'Tab' && !event.shiftKey)) {
focusPanel = true;
}
if ((event.key === 'Enter' || event.key === ' ')) {
focusPanel = true;
}
// In extra small screen the panel is shown below the item.
if (event.key === 'ArrowDown' && this._needSmallSpaceBehaviour() && this.getVisibility()) {
focusPanel = true;
}
if (focusPanel) {
event.stopPropagation();
event.preventDefault();
this.setVisibility(true);
this._focusPanelContent();
}
}
/**
* Sub panel content key handler.
* @param {Event} event
* @private
*/
_panelContentKeyHandler(event) {
// In extra small devices the panel is displayed under the menu item
// so the arrow up/down switch between subpanel and the menu item.
const canLoop = !this._needSmallSpaceBehaviour();
let isBrowsingSubPanel = false;
let newFocus = null;
if (event.key === 'ArrowRight' || event.key === 'ArrowLeft') {
newFocus = this.menuItem;
}
// Acording to WCAG Esc and Tab are similar to arrow navigation but they
// force the subpanel to be closed.
if (event.key === 'Escape' || (event.key === 'Tab' && event.shiftKey)) {
newFocus = this.menuItem;
this.setVisibility(false);
this.showPreviewOnFocus = false;
}
if (event.key === 'ArrowUp') {
newFocus = previousFocusableElement(this.panelContent, canLoop);
isBrowsingSubPanel = true;
}
if (event.key === 'ArrowDown') {
newFocus = nextFocusableElement(this.panelContent, canLoop);
isBrowsingSubPanel = true;
}
if (event.key === 'Home') {
newFocus = firstFocusableElement(this.panelContent);
isBrowsingSubPanel = true;
}
if (event.key === 'End') {
newFocus = lastFocusableElement(this.panelContent);
isBrowsingSubPanel = true;
}
// If the user cannot loop and arrive to the start/end of the subpanel
// we focus on the menu item.
if (newFocus === null && isBrowsingSubPanel && !canLoop) {
newFocus = this.menuItem;
}
if (newFocus !== null) {
event.stopPropagation();
event.preventDefault();
newFocus.focus();
}
}
/**
* Focus on the first focusable element of the subpanel.
* @private
*/
_focusPanelContent() {
const pendingPromise = new Pending('core/action_menu/subpanel:focuscontent');
// Some Bootstrap events are triggered after the click event.
// To prevent this from affecting the focus we wait a bit.
setTimeout(() => {
const firstFocusable = firstFocusableElement(this.panelContent);
if (firstFocusable) {
firstFocusable.focus();
}
pendingPromise.resolve();
}, 100);
}
/**
* Set the visibility of a subpanel.
* @param {Boolean} visible true if the subpanel should be visible.
*/
setVisibility(visible) {
if (visible) {
this._hideOtherSubPanels();
}
// Aria hidden/unhidden can alter the focus, we only want to do it when needed.
if (!visible && this.getVisibility) {
hide(this.panelContent);
}
if (visible && !this.getVisibility) {
unhide(this.panelContent);
}
this.menuItem.setAttribute('aria-expanded', visible ? 'true' : 'false');
this.panelContent.classList.toggle('show', visible);
this.element.classList.toggle(Classes.contentDisplayed, visible);
}
/**
* Hide all other subpanels in the parent menu.
* @private
*/
_hideOtherSubPanels() {
const dropdown = this.element.closest(Selectors.mainMenu);
dropdown.querySelectorAll(`${Selectors.subPanelContent}.show`).forEach(visibleSubPanel => {
const dropdownSubPanel = visibleSubPanel.closest(Selectors.subPanel);
if (dropdownSubPanel === this.element) {
return;
}
const subPanel = new SubPanel(dropdownSubPanel);
subPanel.setVisibility(false);
});
}
/**
* Get the visibility of a subpanel.
* @returns {Boolean} true if the subpanel is visible.
*/
getVisibility() {
return this.menuItem.getAttribute('aria-expanded') === 'true';
}
/**
* Update the panels position depending on the screen size and panel position.
*/
updatePosition() {
const dropdownRight = this._needDropdownRight();
if (this._needSmallSpaceBehaviour()) {
this.element.classList.remove(Classes.dropRight);
this.element.classList.remove(Classes.dropLeft);
this.element.classList.add(Classes.dropDown);
this.element.classList.toggle(Classes.forceLeft, dropdownRight);
return;
}
this.element.classList.remove(Classes.dropDown);
this.element.classList.remove(Classes.forceLeft);
this.element.classList.toggle(Classes.dropRight, !dropdownRight);
this.element.classList.toggle(Classes.dropLeft, dropdownRight);
}
}
/**
* Initialise module for given report
*
* @method
* @param {string} selector The query selector to init.
*/
export const init = (selector) => {
initPageEvents();
const subMenu = document.querySelector(selector);
if (!subMenu) {
throw new Error(`Sub panel element not found: ${selector}`);
}
const subPanel = new SubPanel(subMenu);
subPanel.init();
};
+296
View File
@@ -0,0 +1,296 @@
// 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/>.
/**
* ARIA helpers related to the aria-hidden attribute.
*
* @module core/local/aria/aria-hidden.
* @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {getList} from 'core/normalise';
import Selectors from './selectors';
// The map of MutationObserver objects for an object.
const childObserverMap = new Map();
const siblingObserverMap = new Map();
/**
* Determine whether the browser supports the MutationObserver system.
*
* @method
* @returns {Bool}
*/
const supportsMutationObservers = () => (MutationObserver && typeof MutationObserver === 'function');
/**
* Disable element focusability, disabling the tabindex for child elements which are normally focusable.
*
* @method
* @param {HTMLElement} target
*/
const disableElementFocusability = target => {
if (!(target instanceof HTMLElement)) {
// This element is not an HTMLElement.
// This can happen for Text Nodes.
return;
}
if (target.matches(Selectors.elements.focusable)) {
disableAndStoreTabIndex(target);
}
target.querySelectorAll(Selectors.elements.focusable).forEach(disableAndStoreTabIndex);
};
/**
* Remove the current tab-index and store it for later restoration.
*
* @method
* @param {HTMLElement} element
*/
const disableAndStoreTabIndex = element => {
if (typeof element.dataset.ariaHiddenTabIndex !== 'undefined') {
// This child already has a hidden attribute.
// Do not modify it as the original value will be lost.
return;
}
// Store the old tabindex in a data attribute.
if (element.getAttribute('tabindex')) {
element.dataset.ariaHiddenTabIndex = element.getAttribute('tabindex');
} else {
element.dataset.ariaHiddenTabIndex = '';
}
element.setAttribute('tabindex', -1);
};
/**
* Re-enable element focusability, restoring any tabindex.
*
* @method
* @param {HTMLElement} target
*/
const enableElementFocusability = target => {
if (!(target instanceof HTMLElement)) {
// This element is not an HTMLElement.
// This can happen for Text Nodes.
return;
}
if (target.matches(Selectors.elements.focusableToUnhide)) {
restoreTabIndex(target);
}
target.querySelectorAll(Selectors.elements.focusableToUnhide).forEach(restoreTabIndex);
};
/**
* Restore the tab-index of the supplied element.
*
* When disabling focusability the current tab-index is stored in the ariaHiddenTabIndex data attribute.
* This is used to restore the tab-index, but only whilst the parent nodes remain unhidden.
*
* @method
* @param {HTMLElement} element
*/
const restoreTabIndex = element => {
if (element.closest(Selectors.aria.hidden)) {
// This item still has a hidden parent, or is hidden itself. Do not unhide it.
return;
}
const oldTabIndex = element.dataset.ariaHiddenTabIndex;
if (oldTabIndex === '') {
element.removeAttribute('tabindex');
} else {
element.setAttribute('tabindex', oldTabIndex);
}
delete element.dataset.ariaHiddenTabIndex;
};
/**
* Update the supplied DOM Module to be hidden.
*
* @method
* @param {HTMLElement} target
* @returns {Array}
*/
export const hide = target => getList(target).forEach(_hide);
const _hide = target => {
if (!(target instanceof HTMLElement)) {
// This element is not an HTMLElement.
// This can happen for Text Nodes.
return;
}
if (target.closest(Selectors.aria.hidden)) {
// This Element, or a parent Element, is already hidden.
// Stop processing.
return;
}
// Set the aria-hidden attribute to true.
target.setAttribute('aria-hidden', true);
// Based on advice from https://dequeuniversity.com/rules/axe/3.3/aria-hidden-focus, upon setting the aria-hidden
// attribute, all focusable elements underneath that element should be modified such that they are not focusable.
disableElementFocusability(target);
if (supportsMutationObservers()) {
// Add a MutationObserver to check for new children to the tree.
const mutationObserver = new MutationObserver(mutationList => {
mutationList.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(disableElementFocusability);
} else if (mutation.type === 'attributes') {
// The tabindex has been updated on a hidden attribute.
// Ensure that it is stored, ad set to -1 to prevent breakage.
const element = mutation.target;
const proposedTabIndex = element.getAttribute('tabindex');
if (proposedTabIndex !== "-1") {
element.dataset.ariaHiddenTabIndex = proposedTabIndex;
element.setAttribute('tabindex', -1);
}
}
});
});
mutationObserver.observe(target, {
// Watch for changes to the entire subtree.
subtree: true,
// Watch for new nodes.
childList: true,
// Watch for attribute changes to the tabindex.
attributes: true,
attributeFilter: ['tabindex'],
});
childObserverMap.set(target, mutationObserver);
}
};
/**
* Reverse the effect of the hide action.
*
* @method
* @param {HTMLElement} target
* @returns {Array}
*/
export const unhide = target => getList(target).forEach(_unhide);
const _unhide = target => {
if (!(target instanceof HTMLElement)) {
return;
}
// Note: The aria-hidden attribute should be removed, and not set to false.
// The presence of the attribute is sufficient for some browsers to treat it as being true, regardless of its value.
target.removeAttribute('aria-hidden');
// Restore the tabindex across all child nodes of the target.
enableElementFocusability(target);
// Remove the focusability MutationObserver watching this tree.
if (childObserverMap.has(target)) {
childObserverMap.get(target).disconnect();
childObserverMap.delete(target);
}
};
/**
* Correctly mark all siblings of the supplied target Element as hidden.
*
* @method
* @param {HTMLElement} target
* @returns {Array}
*/
export const hideSiblings = target => getList(target).forEach(_hideSiblings);
const _hideSiblings = target => {
if (!(target instanceof HTMLElement)) {
return;
}
if (!target.parentElement) {
return;
}
target.parentElement.childNodes.forEach(node => {
if (node === target) {
// Skip self;
return;
}
hide(node);
});
if (supportsMutationObservers()) {
// Add a MutationObserver to check for new children to the tree.
const newNodeObserver = new MutationObserver(mutationList => {
mutationList.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (target.contains(node)) {
// Skip self, and children of self.
return;
}
hide(node);
});
});
});
newNodeObserver.observe(target.parentElement, {childList: true, subtree: true});
siblingObserverMap.set(target.parentElement, newNodeObserver);
}
};
/**
* Correctly reverse the hide action of all children of the supplied target Element.
*
* @method
* @param {HTMLElement} target
* @returns {Array}
*/
export const unhideSiblings = target => getList(target).forEach(_unhideSiblings);
const _unhideSiblings = target => {
if (!(target instanceof HTMLElement)) {
return;
}
if (!target.parentElement) {
return;
}
target.parentElement.childNodes.forEach(node => {
if (node === target) {
// Skip self;
return;
}
unhide(node);
});
// Remove the sibling MutationObserver watching this tree.
if (siblingObserverMap.has(target.parentElement)) {
siblingObserverMap.get(target.parentElement).disconnect();
siblingObserverMap.delete(target.parentElement);
}
};
+319
View File
@@ -0,0 +1,319 @@
// 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/>.
/**
* Tab locking system.
*
* This is based on code and examples provided in the ARIA specification.
* https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html
*
* @module core/local/aria/focuslock
* @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Selectors from './selectors';
const lockRegionStack = [];
const initialFocusElementStack = [];
const finalFocusElementStack = [];
let lastFocus = null;
let ignoreFocusChanges = false;
let isLocked = false;
/**
* The lock handler.
*
* This is the item that does a majority of the work.
* The overall logic from this comes from the examles in the WCAG guidelines.
*
* The general idea is that if the focus is not held within by an Element within the lock region, then we replace focus
* on the first element in the lock region. If the first element is the element previously selected prior to the
* user-initiated focus change, then instead jump to the last element in the lock region.
*
* This gives us a solution which supports focus locking of any kind, which loops in both directions, and which
* prevents the lock from escaping the modal entirely.
*
* @method
* @param {Event} event The event from the focus change
*/
const lockHandler = event => {
if (ignoreFocusChanges) {
// The focus change was made by an internal call to set focus.
return;
}
// Find the current lock region.
let lockRegion = getCurrentLockRegion();
while (lockRegion) {
if (document.contains(lockRegion)) {
break;
}
// The lock region does not exist.
// Perhaps it was removed without being untrapped.
untrapFocus();
lockRegion = getCurrentLockRegion();
}
if (!lockRegion) {
return;
}
if (lockRegion.contains(event.target)) {
lastFocus = event.target;
} else {
focusFirstDescendant();
if (lastFocus == document.activeElement) {
focusLastDescendant();
}
lastFocus = document.activeElement;
}
};
/**
* Focus the first descendant of the current lock region.
*
* @method
* @returns {Bool} Whether a node was focused
*/
const focusFirstDescendant = () => {
const lockRegion = getCurrentLockRegion();
// Grab all elements in the lock region and attempt to focus each element until one is focused.
// We can capture most of this in the query selector, but some cases may still reject focus.
// For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector
// to capture this.
// The use of Array.some just ensures that we stop as soon as we have a successful focus.
const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable));
// The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.
// We must include it in the calculation of descendants to ensure that looping works correctly.
focusableElements.unshift(lockRegion);
return focusableElements.some(focusableElement => attemptFocus(focusableElement));
};
/**
* Focus the last descendant of the current lock region.
*
* @method
* @returns {Bool} Whether a node was focused
*/
const focusLastDescendant = () => {
const lockRegion = getCurrentLockRegion();
// Grab all elements in the lock region, reverse them, and attempt to focus each element until one is focused.
// We can capture most of this in the query selector, but some cases may still reject focus.
// For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector
// to capture this.
// The use of Array.some just ensures that we stop as soon as we have a successful focus.
const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable)).reverse();
// The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.
// We must include it in the calculation of descendants to ensure that looping works correctly.
focusableElements.push(lockRegion);
return focusableElements.some(focusableElement => attemptFocus(focusableElement));
};
/**
* Check whether the supplied focusTarget is actually focusable.
* There are cases where a normally focusable element can reject focus.
*
* Note: This example is a wholesale copy of the WCAG example.
*
* @method
* @param {HTMLElement} focusTarget
* @returns {Bool}
*/
const isFocusable = focusTarget => {
if (focusTarget.tabIndex > 0 || (focusTarget.tabIndex === 0 && focusTarget.getAttribute('tabIndex') !== null)) {
return true;
}
if (focusTarget.disabled) {
return false;
}
switch (focusTarget.nodeName) {
case 'A':
return !!focusTarget.href && focusTarget.rel != 'ignore';
case 'INPUT':
return focusTarget.type != 'hidden' && focusTarget.type != 'file';
case 'BUTTON':
case 'SELECT':
case 'TEXTAREA':
return true;
default:
return false;
}
};
/**
* Attempt to focus the supplied focusTarget.
*
* Note: This example is a heavily inspired by the WCAG example.
*
* @method
* @param {HTMLElement} focusTarget
* @returns {Bool} Whether focus was successful o rnot.
*/
const attemptFocus = focusTarget => {
if (!isFocusable(focusTarget)) {
return false;
}
// The ignoreFocusChanges variable prevents the focus event handler from interfering and entering a fight with itself.
ignoreFocusChanges = true;
try {
focusTarget.focus();
} catch (e) {
// Ignore failures. We will just try to focus the next element in the list.
}
ignoreFocusChanges = false;
// If focus was successful the activeElement will be the one we focused.
return (document.activeElement === focusTarget);
};
/**
* Get the current lock region from the top of the stack.
*
* @method
* @returns {HTMLElement}
*/
const getCurrentLockRegion = () => {
return lockRegionStack[lockRegionStack.length - 1];
};
/**
* Add a new lock region to the stack.
*
* @method
* @param {HTMLElement} newLockRegion
*/
const addLockRegionToStack = newLockRegion => {
if (newLockRegion === getCurrentLockRegion()) {
return;
}
lockRegionStack.push(newLockRegion);
const currentLockRegion = getCurrentLockRegion();
// Append an empty div which can be focused just outside of the item locked.
// This locks tab focus to within the tab region, and does not allow it to extend back into the window by
// guaranteeing the existence of a tabable item after the lock region which can be focused but which will be caught
// by the handler.
const element = document.createElement('div');
element.tabIndex = 0;
element.style.position = 'fixed';
element.style.top = 0;
element.style.left = 0;
const initialNode = element.cloneNode();
currentLockRegion.parentNode.insertBefore(initialNode, currentLockRegion);
initialFocusElementStack.push(initialNode);
const finalNode = element.cloneNode();
currentLockRegion.parentNode.insertBefore(finalNode, currentLockRegion.nextSibling);
finalFocusElementStack.push(finalNode);
};
/**
* Remove the top lock region from the stack.
*
* @method
*/
const removeLastLockRegionFromStack = () => {
// Take the top element off the stack, and replce the current lockRegion value.
lockRegionStack.pop();
const finalNode = finalFocusElementStack.pop();
if (finalNode) {
// The final focus element may have been removed if it was part of a parent item.
finalNode.remove();
}
const initialNode = initialFocusElementStack.pop();
if (initialNode) {
// The initial focus element may have been removed if it was part of a parent item.
initialNode.remove();
}
};
/**
* Whether any region is left in the stack.
*
* @return {Bool}
*/
const hasTrappedRegionsInStack = () => {
return !!lockRegionStack.length;
};
/**
* Start trapping the focus and lock it to the specified newLockRegion.
*
* @method
* @param {HTMLElement} newLockRegion The container to lock focus to
*/
export const trapFocus = newLockRegion => {
// Update the lock region stack.
// This allows us to support nesting.
addLockRegionToStack(newLockRegion);
if (!isLocked) {
// Add the focus handler.
document.addEventListener('focus', lockHandler, true);
}
// Attempt to focus on the first item in the lock region.
if (!focusFirstDescendant()) {
const currentLockRegion = getCurrentLockRegion();
// No focusable descendants found in the region yet.
// This can happen when the region is locked before content is generated.
// Focus on the region itself for now.
const originalRegionTabIndex = currentLockRegion.tabIndex;
currentLockRegion.tabIndex = 0;
attemptFocus(currentLockRegion);
currentLockRegion.tabIndex = originalRegionTabIndex;
}
// Keep track of the last item focused.
lastFocus = document.activeElement;
isLocked = true;
};
/**
* Stop trapping the focus.
*
* @method
*/
export const untrapFocus = () => {
// Remove the top region from the stack.
removeLastLockRegionFromStack();
if (hasTrappedRegionsInStack()) {
// The focus manager still has items in the stack.
return;
}
document.removeEventListener('focus', lockHandler, true);
lastFocus = null;
ignoreFocusChanges = false;
isLocked = false;
};
+36
View File
@@ -0,0 +1,36 @@
// 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/>.
/**
* Selectors used for ARIA.
*
* @module core/local/aria/selectors
* @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default {
aria: {
hidden: '[aria-hidden]',
},
elements: {
focusable: 'input:not([type="hidden"]):not([disabled]):not([tabindex^="-"]),' +
'a[href]:not([disabled]):not([tabindex^="-"]),' +
'button:not([disabled]):not([tabindex^="-"]),' +
'textarea:not([disabled]):not([tabindex^="-"]),' +
'select:not([disabled]):not([tabindex^="-"]),' +
'[tabindex]:not([disabled]):not([tabindex^="-"])',
focusableToUnhide: '[data-aria-hidden-tab-index]',
},
};
+214
View File
@@ -0,0 +1,214 @@
// 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/>.
/**
* Dropdown status JS controls.
*
* @module core/local/dropdown/dialog
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
// The jQuery module is only used for interacting with Bootstrap 4. It can be removed when MDL-71979 is integrated.
import jQuery from 'jquery';
import {
firstFocusableElement,
lastFocusableElement,
previousFocusableElement,
nextFocusableElement,
} from 'core/pagehelpers';
import Pending from 'core/pending';
const Selectors = {
dropdownButton: '[data-for="dropdowndialog_button"]',
dropdownDialog: '[data-for="dropdowndialog_dialog"]',
};
/**
* Dropdown dialog class.
* @private
*/
export class DropdownDialog {
/**
* Constructor.
* @param {HTMLElement} element The element to initialize.
*/
constructor(element) {
this.element = element;
this.button = element.querySelector(Selectors.dropdownButton);
this.panel = element.querySelector(Selectors.dropdownDialog);
}
/**
* Initialize the subpanel element.
*
* This method adds the event listeners to the subpanel and the position classes.
*/
init() {
if (this.element.dataset.dropdownDialogInitialized) {
return;
}
// Menu Item events.
this.button.addEventListener('keydown', this._buttonKeyHandler.bind(this));
// Subpanel content events.
this.panel.addEventListener('keydown', this._contentKeyHandler.bind(this));
this.element.dataset.dropdownDialogInitialized = true;
}
/**
* Dropdown button key handler.
* @param {Event} event
* @private
*/
_buttonKeyHandler(event) {
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
event.stopPropagation();
event.preventDefault();
this.setVisible(false);
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
event.stopPropagation();
event.preventDefault();
this.setVisible(true);
this._focusPanelContent();
}
}
/**
* Sub panel content key handler.
* @param {Event} event
* @private
*/
_contentKeyHandler(event) {
let newFocus = null;
if (event.key === 'End') {
newFocus = lastFocusableElement(this.panel);
}
if (event.key === 'Home') {
newFocus = firstFocusableElement(this.panel);
}
if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') {
newFocus = previousFocusableElement(this.panel, false);
if (!newFocus) {
newFocus = this.button;
}
}
if (event.key === 'ArrowDown' || event.key === 'ArrowRight') {
newFocus = nextFocusableElement(this.panel, false);
}
if (newFocus !== null) {
event.stopPropagation();
event.preventDefault();
newFocus.focus();
}
}
/**
* Focus on the first focusable element of the subpanel.
* @private
*/
_focusPanelContent() {
const pendingPromise = new Pending('core/dropdown/dialog:focuscontent');
// Some Bootstrap events are triggered after the click event.
// To prevent this from affecting the focus we wait a bit.
setTimeout(() => {
const firstFocusable = firstFocusableElement(this.panel);
if (firstFocusable) {
firstFocusable.focus();
}
pendingPromise.resolve();
}, 100);
}
/**
* Set the visibility of a subpanel.
* @param {Boolean} visible true if the subpanel should be visible.
*/
setVisible(visible) {
if (visible === this.isVisible()) {
return;
}
// All jQuery in this code can be replaced when MDL-71979 is integrated.
jQuery(this.button).dropdown('toggle');
}
/**
* Get the visibility of a subpanel.
* @returns {Boolean} true if the subpanel is visible.
*/
isVisible() {
return this.button.getAttribute('aria-expanded') === 'true';
}
/**
* Set the content of the button.
* @param {String} content
*/
setButtonContent(content) {
this.button.innerHTML = content;
}
/**
* Set the disabled state of the button.
* @param {Boolean} disabled
*/
setButtonDisabled(disabled) {
if (disabled) {
this.button.setAttribute('disabled', 'disabled');
} else {
this.button.removeAttribute('disabled');
}
}
/**
* Return the main dropdown HTML element.
* @returns {HTMLElement} The element.
*/
getElement() {
return this.element;
}
}
/**
* Get the dropdown dialog instance from a selector.
* @param {string} selector The query selector to init.
* @returns {DropdownDialog|null} The dropdown dialog instance if any.
*/
export const getDropdownDialog = (selector) => {
const dropdownElement = document.querySelector(selector);
if (!dropdownElement) {
return null;
}
return new DropdownDialog(dropdownElement);
};
/**
* Initialize module.
*
* @method
* @param {string} selector The query selector to init.
*/
export const init = (selector) => {
const dropdown = getDropdownDialog(selector);
if (!dropdown) {
throw new Error(`Dopdown dialog element not found: ${selector}`);
}
dropdown.init();
};
+287
View File
@@ -0,0 +1,287 @@
// 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/>.
/**
* Dropdown status JS controls.
*
* The status controls enable extra configurarions for the dropdown like:
* - Sync the button text with the selected option.
* - Update the status of the button when the selected option changes. This will
* trigger a "change" event when the status changes.
*
* @module core/local/dropdown/status
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {DropdownDialog} from 'core/local/dropdown/dialog';
const Selectors = {
checkedIcon: '[data-for="checkedIcon"]',
option: '[role="option"]',
optionItem: '[data-optionnumber]',
optionIcon: '.option-icon',
selectedOption: '[role="option"][aria-selected="true"]',
uncheckedIcon: '[data-for="uncheckedIcon"]',
};
const Classes = {
selected: 'selected',
disabled: 'disabled',
hidden: 'd-none',
};
/**
* Dropdown dialog class.
* @private
*/
export class DropdownStatus extends DropdownDialog {
/**
* Constructor.
* @param {HTMLElement} element The element to initialize.
*/
constructor(element) {
super(element);
this.buttonSync = element.dataset.buttonSync == 'true';
this.updateStatus = element.dataset.updateStatus == 'true';
}
/**
* Initialize the subpanel element.
*
* This method adds the event listeners to the subpanel and the position classes.
* @private
*/
init() {
super.init();
if (this.element.dataset.dropdownStatusInitialized) {
return;
}
this.panel.addEventListener('click', this._contentClickHandler.bind(this));
if (this.element.dataset.buttonSync == 'true') {
this.setButtonSyncEnabled(true);
}
if (this.element.dataset.updateStatus == 'true') {
this.setUpdateStatusEnabled(true);
}
this.element.dataset.dropdownStatusInitialized = true;
}
/**
* Handle click events on the status content.
* @param {Event} event The event.
* @private
*/
_contentClickHandler(event) {
const option = event.target.closest(Selectors.option);
if (!option) {
return;
}
if (option.getAttribute('aria-disabled') === 'true') {
return;
}
if (option.getAttribute('aria-selected') === 'true') {
return;
}
if (this.isUpdateStatusEnabled()) {
this.setSelectedValue(option.dataset.value);
}
}
/**
* Sets the selected value.
* @param {string} value The value to set.
*/
setSelectedValue(value) {
const selected = this.panel.querySelector(Selectors.selectedOption);
if (selected && selected.dataset.value === value) {
return;
}
if (selected) {
this._updateOptionChecked(selected, false);
}
const option = this.panel.querySelector(`${Selectors.option}[data-value="${value}"]`);
if (option) {
this._updateOptionChecked(option, true);
}
if (this.isButtonSyncEnabled()) {
this.syncButtonText();
}
// Emit standard radio button event with the selected option.
this.element.dispatchEvent(new Event('change'));
}
/**
* Update the option checked content.
* @private
* @param {HTMLElement} option the option element to set
* @param {Boolean} checked the new checked value
*/
_updateOptionChecked(option, checked) {
option.setAttribute('aria-selected', checked.toString());
option.classList.toggle(Classes.selected, checked);
option.classList.toggle(Classes.disabled, checked);
const optionItem = option.closest(Selectors.optionItem);
if (optionItem) {
this._updateOptionItemChecked(optionItem, checked);
}
if (checked) {
this.element.dataset.value = option.dataset.value;
} else if (this.element.dataset.value === option.dataset.value) {
delete this.element.dataset.value;
}
}
/**
* Update the option item checked content.
* @private
* @param {HTMLElement} optionItem
* @param {Boolean} checked
*/
_updateOptionItemChecked(optionItem, checked) {
const selectedClasses = optionItem.dataset.selectedClasses ?? Classes.selected;
for (const selectedClass of selectedClasses.split(' ')) {
optionItem.classList.toggle(selectedClass, checked);
}
if (checked) {
optionItem.dataset.selected = checked;
} else {
delete optionItem?.dataset.selected;
}
const checkedIcon = optionItem.querySelector(Selectors.checkedIcon);
if (checkedIcon) {
checkedIcon.classList.toggle(Classes.hidden, !checked);
}
const uncheckedIcon = optionItem.querySelector(Selectors.uncheckedIcon);
if (uncheckedIcon) {
uncheckedIcon.classList.toggle(Classes.hidden, checked);
}
}
/**
* Return the selected value.
* @returns {string|null} The selected value.
*/
getSelectedValue() {
const selected = this.panel.querySelector(Selectors.selectedOption);
return selected?.dataset.value ?? null;
}
/**
* Set the button sync value.
*
* If the sync is enabled, the button text will show the selected option.
*
* @param {Boolean} value The value to set.
*/
setButtonSyncEnabled(value) {
if (value) {
this.element.dataset.buttonSync = 'true';
} else {
delete this.element.dataset.buttonSync;
}
if (value) {
this.syncButtonText();
}
}
/**
* Return if the button sync is enabled.
* @returns {Boolean} The button sync value.
*/
isButtonSyncEnabled() {
return this.element.dataset.buttonSync == 'true';
}
/**
* Sync the button text with the selected option.
*/
syncButtonText() {
const selected = this.panel.querySelector(Selectors.selectedOption);
if (!selected) {
return;
}
let newText = selected.textContent;
const optionIcon = this._getOptionIcon(selected);
if (optionIcon) {
newText = optionIcon.innerHTML + newText;
}
this.button.innerHTML = newText;
}
/**
* Set the update status value.
*
* @param {Boolean} value The value to set.
*/
setUpdateStatusEnabled(value) {
if (value) {
this.element.dataset.updateStatus = 'true';
} else {
delete this.element.dataset.updateStatus;
}
}
/**
* Return if the update status is enabled.
* @returns {Boolean} The update status value.
*/
isUpdateStatusEnabled() {
return this.element.dataset.updateStatus == 'true';
}
_getOptionIcon(option) {
const optionItem = option.closest(Selectors.optionItem);
if (!optionItem) {
return null;
}
return optionItem.querySelector(Selectors.optionIcon);
}
}
/**
* Get the dropdown dialog instance form a selector.
* @param {string} selector The query selector to init.
* @returns {DropdownStatus|null} The dropdown dialog instance if any.
*/
export const getDropdownStatus = (selector) => {
const dropdownElement = document.querySelector(selector);
if (!dropdownElement) {
return null;
}
return new DropdownStatus(dropdownElement);
};
/**
* Initialize module.
*
* @method
* @param {string} selector The query selector to init.
*/
export const init = (selector) => {
const dropdown = getDropdownStatus(selector);
if (!dropdown) {
throw new Error(`Dopdown status element not found: ${selector}`);
}
dropdown.init();
};
@@ -0,0 +1,126 @@
// 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/>.
/**
* Inplace editable module events
*
* @module core/local/inplace_editable/events
* @copyright 2021 Paul Holden <paulh@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import $ from 'jquery';
import {dispatchEvent} from 'core/event_dispatcher';
/**
* Module events
*
* @constant
* @property {String} elementUpdated See {@link event:core/inplace_editable:updated}
* @property {String} elementUpdateFailed See {@link event:core/inplace_editable:updateFailed}
*/
export const eventTypes = {
/**
* Event triggered when an element has been updated
*
* @event core/inplace_editable:updated
* @type {CustomEvent}
* @property {HTMLElement} target The element that was updated
* @property {Object} detail
* @property {Object} detail.ajaxreturn The data returned from the update AJAX request
* @property {String} detail.oldvalue The previous value of the element
*/
elementUpdated: 'core/inplace_editable:updated',
/**
* Event triggered when an element update has failed
*
* @event core/inplace_editable:updateFailed
* @type {CustomEvent}
* @property {HTMLElement} target The element that failed to update
* @property {Object} detail
* @property {Object} detail.exception The raised exception
* @property {String} detail.newvalue The intended value of the element
*/
elementUpdateFailed: 'core/inplace_editable:updateFailed',
};
/**
* Notify element of successful update
*
* @method
* @param {HTMLElement} element The element that was updated
* @param {Object} ajaxreturn The data returned from the update AJAX request
* @param {String} oldvalue The previous value of the element
* @returns {CustomEvent}
* @fires event:core/inplace_editable:updated
*/
export const notifyElementUpdated = (element, ajaxreturn, oldvalue) => dispatchEvent(
eventTypes.elementUpdated,
{
ajaxreturn,
oldvalue,
},
element
);
/**
* Notify element of failed update
*
* @method
* @param {HTMLElement} element The element that failed to update
* @param {Object} exception The raised exception
* @param {String} newvalue The intended value of the element
* @returns {CustomEvent}
* @fires event:core/inplace_editable:updateFailed
*/
export const notifyElementUpdateFailed = (element, exception, newvalue) => dispatchEvent(
eventTypes.elementUpdateFailed,
{
exception,
newvalue,
},
element,
{
cancelable: true
}
);
let legacyEventsRegistered = false;
if (!legacyEventsRegistered) {
// The following event triggers are legacy and will be removed in the future.
// The following approach provides a backwards-compatability layer for the new events.
// Code should be updated to make use of native events.
// Listen for the new native elementUpdated event, and trigger the legacy jQuery event.
document.addEventListener(eventTypes.elementUpdated, event => {
const legacyEvent = $.Event('updated', event.detail);
$(event.target).trigger(legacyEvent);
});
// Listen for the new native elementUpdateFailed event, and trigger the legacy jQuery event.
document.addEventListener(eventTypes.elementUpdateFailed, event => {
const legacyEvent = $.Event('updatefailed', event.detail);
$(event.target).trigger(legacyEvent);
// If the legacy event is cancelled, so should the native event.
if (legacyEvent.isDefaultPrevented()) {
event.preventDefault();
}
});
legacyEventsRegistered = true;
}
+48
View File
@@ -0,0 +1,48 @@
// 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/>.
/**
* Alert modal.
*
* @module core/local/modal/alert
* @copyright 2020 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Modal from 'core/modal';
/**
* The Alert Modal
*
* @class
* @extends Modal
*/
export default class ModalAlert extends Modal {
static TYPE = 'ALERT';
static TEMPLATE = 'core/local/modal/alert';
/**
* Register all event listeners.
*/
registerEventListeners() {
// Call the parent registration.
super.registerEventListeners();
// Register to close on cancel.
this.registerCloseOnCancel();
}
}
ModalAlert.registerModalType();
@@ -0,0 +1,58 @@
// 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 events for the `process_monitor` module.
*
* @module core/local/process_monitor/events
* @copyright 2022 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.2
*/
/**
* Events for the `core_editor` subsystem.
*
* @constant
* @property {String} processMonitorStateChange See {@link event:processMonitorStateChange}
*/
export const eventTypes = {
/**
* An event triggered when the monitor state has changed.
*
* @event processMonitorStateChange
*/
processMonitorStateChange: 'core_editor/contentRestored',
};
/**
* Trigger a state changed event.
*
* @method dispatchStateChangedEvent
* @param {Object} detail the full state
* @param {Object} target the custom event target (document if none provided)
* @param {Function} target.dispatchEvent the component dispatch event method.
*/
export function dispatchStateChangedEvent(detail, target) {
if (target === undefined) {
target = document;
}
target.dispatchEvent(new CustomEvent(
eventTypes.processMonitorStateChange,
{
bubbles: true,
detail: detail,
}
));
}
@@ -0,0 +1,211 @@
// 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/>.
/**
* The process wrapper class.
*
* This module is used to update a process in the process monitor.
*
* @module core/local/process_monitor/loadingprocess
* @class LoadingProcess
* @copyright 2022 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import log from 'core/log';
export class LoadingProcess {
/** @var {Map} editorUpdates the courses pending to be updated. */
processData = null;
/** @var {Object} extraData any extra process information to store. */
extraData = null;
/** @var {ProcessMonitorManager} manager the page monitor. */
manager = null;
/** @var {Function} finishedCallback the finished callback if any. */
finishedCallback = null;
/** @var {Function} removedCallback the removed callback if any. */
removedCallback = null;
/** @var {Function} errorCallback the error callback if any. */
errorCallback = null;
/**
* Class constructor
* @param {ProcessMonitorManager} manager the monitor manager
* @param {Object} definition the process definition data
*/
constructor(manager, definition) {
this.manager = manager;
// Add defaults.
this.processData = {
id: manager.generateProcessId(),
name: '',
percentage: 0,
url: null,
error: null,
finished: false,
...definition,
};
// Create a new entry.
this._dispatch('addProcess', this.processData);
}
/**
* Execute a monitor manager mutation when the state is ready.
*
* @private
* @param {String} action the mutation to dispatch
* @param {*} params the mutaiton params
*/
_dispatch(action, params) {
this.manager.getInitialStatePromise().then(() => {
this.manager.dispatch(action, params);
return;
}).catch(() => {
log.error(`Cannot update process monitor.`);
});
}
/**
* Define a finished process callback function.
* @param {Function} callback the callback function
*/
onFinish(callback) {
this.finishedCallback = callback;
}
/**
* Define a removed from monitor process callback function.
* @param {Function} callback the callback function
*/
onRemove(callback) {
this.removedCallback = callback;
}
/**
* Define a error process callback function.
* @param {Function} callback the callback function
*/
onError(callback) {
this.errorCallback = callback;
}
/**
* Set the process percentage.
* @param {Number} percentage
*/
setPercentage(percentage) {
this.processData.percentage = percentage;
this._dispatch('updateProcess', this.processData);
}
/**
* Stores extra information to the process.
*
* This method is used to add information like the course, the user
* or any other needed information.
*
* @param {Object} extraData any extra process information to store
*/
setExtraData(extraData) {
this.extraData = extraData;
}
/**
* Set the process error string.
*
* Note: set the error message will mark the process as finished.
*
* @param {String} error the string message
*/
setError(error) {
this.processData.error = error;
if (this.errorCallback !== null) {
this.errorCallback(this);
}
this.processData.finished = true;
if (this.finishedCallback !== null) {
this.finishedCallback(this);
}
this._dispatch('updateProcess', this.processData);
}
/**
* Rename the process
* @param {String} name the new process name
*/
setName(name) {
this.processData.name = name;
this._dispatch('updateProcess', this.processData);
}
/**
* Mark the process as finished.
*/
finish() {
this.processData.finished = true;
if (this.finishedCallback !== null) {
this.finishedCallback(this);
}
this._dispatch('updateProcess', this.processData);
}
/**
* Remove the process from the monitor.
*/
remove() {
if (this.removedCallback !== null) {
this.removedCallback(this);
}
this._dispatch('removeProcess', this.processData.id);
}
/**
* Returns the current rpocess data.
* @returns {Object} the process data
*/
getData() {
return {...this.processData};
}
/**
* Return the process name
* @return {String}
*/
get name() {
return this.processData.name;
}
/**
* Return the process internal id
* @return {Number}
*/
get id() {
return this.processData.id;
}
/**
* Return the process extra data.
* @return {*} whatever is in extra data
*/
get data() {
return this.extraData;
}
}
@@ -0,0 +1,182 @@
// 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/>.
/**
* The course file uploader.
*
* This module is used to upload files directly into the course.
*
* @module core/local/process_monitor/manager
* @copyright 2022 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {Reactive} from 'core/reactive';
import {eventTypes, dispatchStateChangedEvent} from 'core/local/process_monitor/events';
const initialState = {
display: {
show: false,
},
queue: [],
};
/**
* The reactive file uploader class.
*
* As all the upload queues are reactive, any plugin can implement its own upload monitor.
*
* @module core/local/process_monitor/manager
* @class ProcessMonitorManager
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class ProcessMonitorManager extends Reactive {
/**
* The next process id to use.
*
* @attribute nextId
* @type number
* @default 1
* @package
*/
nextId = 1;
/**
* Generate a unique process id.
* @return {number} a generated process Id
*/
generateProcessId() {
return this.nextId++;
}
}
/**
* @var {Object} mutations the monitor mutations.
*/
const mutations = {
/**
* Add a new process to the queue.
*
* @param {StateManager} stateManager the current state manager
* @param {Object} processData the upload id to finish
*/
addProcess: function(stateManager, processData) {
const state = stateManager.state;
stateManager.setReadOnly(false);
state.queue.add({...processData});
state.display.show = true;
stateManager.setReadOnly(true);
},
/**
* Remove a process from the queue.
*
* @param {StateManager} stateManager the current state manager
* @param {Number} processId the process id
*/
removeProcess: function(stateManager, processId) {
const state = stateManager.state;
stateManager.setReadOnly(false);
state.queue.delete(processId);
if (state.queue.size === 0) {
state.display.show = false;
}
stateManager.setReadOnly(true);
},
/**
* Update a process process to the queue.
*
* @param {StateManager} stateManager the current state manager
* @param {Object} processData the upload id to finish
* @param {Number} processData.id the process id
*/
updateProcess: function(stateManager, processData) {
if (processData.id === undefined) {
throw Error(`Missing process ID in process data`);
}
const state = stateManager.state;
stateManager.setReadOnly(false);
const queueItem = state.queue.get(processData.id);
if (!queueItem) {
throw Error(`Unkown process with id ${processData.id}`);
}
for (const [prop, propValue] of Object.entries(processData)) {
queueItem[prop] = propValue;
}
stateManager.setReadOnly(true);
},
/**
* Set the monitor show attribute.
*
* @param {StateManager} stateManager the current state manager
* @param {Boolean} show the show value
*/
setShow: function(stateManager, show) {
const state = stateManager.state;
stateManager.setReadOnly(false);
state.display.show = show;
if (!show) {
this.cleanFinishedProcesses(stateManager);
}
stateManager.setReadOnly(true);
},
/**
* Remove a processes from the queue.
*
* @param {StateManager} stateManager the current state manager
*/
removeAllProcesses: function(stateManager) {
const state = stateManager.state;
stateManager.setReadOnly(false);
state.queue.forEach((element) => {
state.queue.delete(element.id);
});
state.display.show = false;
stateManager.setReadOnly(true);
},
/**
* Clean all finished processes.
*
* @param {StateManager} stateManager the current state manager
*/
cleanFinishedProcesses: function(stateManager) {
const state = stateManager.state;
stateManager.setReadOnly(false);
state.queue.forEach((element) => {
if (element.finished && !element.error) {
state.queue.delete(element.id);
}
});
if (state.queue.size === 0) {
state.display.show = false;
}
stateManager.setReadOnly(true);
},
};
const manager = new ProcessMonitorManager({
name: `ProcessMonitor`,
eventName: eventTypes.processMonitorStateChange,
eventDispatch: dispatchStateChangedEvent,
mutations: mutations,
state: initialState,
});
export {manager};
@@ -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/>.
/**
* The file upload monitor component.
*
* @module core/local/process_monitor/monitor
* @class core/local/process_monitor/monitor
* @copyright 2022 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Templates from 'core/templates';
import {BaseComponent} from 'core/reactive';
import {manager} from 'core/local/process_monitor/manager';
export default class extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'process_monitor';
// Default query selectors.
this.selectors = {
QUEUELIST: `[data-for="process-list"]`,
CLOSE: `[data-action="hide"]`,
};
// Default classes to toggle on refresh.
this.classes = {
HIDE: `d-none`,
};
}
/**
* Static method to create a component instance form the mustache template.
*
* @param {string} query the DOM main element query selector
* @param {object} selectors optional css selector overrides
* @return {this}
*/
static init(query, selectors) {
return new this({
element: document.querySelector(query),
reactive: manager,
selectors,
});
}
/**
* Initial state ready method.
*
* @param {Object} state the initial state
*/
stateReady(state) {
this._updateMonitor({state, element: state.display});
this.addEventListener(this.getElement(this.selectors.CLOSE), 'click', this._closeMonitor);
state.queue.forEach((element) => {
this._createListItem({state, element});
});
}
/**
* Return the component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
// State changes that require to reload some course modules.
{watch: `queue:created`, handler: this._createListItem},
{watch: `display:updated`, handler: this._updateMonitor},
];
}
/**
* Create a monitor item.
*
* @param {object} args the watcher arguments
* @param {object} args.element the item state data
*/
async _createListItem({element}) {
const {html, js} = await Templates.renderForPromise(
'core/local/process_monitor/process',
{...element}
);
const target = this.getElement(this.selectors.QUEUELIST);
Templates.appendNodeContents(target, html, js);
}
/**
* Create a monitor item.
*
* @param {object} args the watcher arguments
* @param {object} args.element the display state data
*/
_updateMonitor({element}) {
this.element.classList.toggle(this.classes.HIDE, element.show !== true);
}
/**
* Close the monitor.
*/
_closeMonitor() {
this.reactive.dispatch('setShow', false);
}
}
@@ -0,0 +1,115 @@
// 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/>.
/**
* The process motnitor's process reactive component.
*
* @module core/local/process_monitor/process
* @class core/local/process_monitor/process
* @copyright 2022 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent} from 'core/reactive';
import {manager} from 'core/local/process_monitor/manager';
export default class extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'process_monitor_process';
// Default query selectors.
this.selectors = {
CLOSE: `[data-action="closeProcess"]`,
ERROR: `[data-for="error"]`,
PROGRESSBAR: `progress`,
NAME: `[data-for="name"]`,
};
// Default classes to toggle on refresh.
this.classes = {
HIDE: `d-none`,
};
this.id = this.element.dataset.id;
}
/**
* Static method to create a component instance form the mustache template.
*
* @param {string} query the DOM main element query selector
* @param {object} selectors optional css selector overrides
* @return {this}
*/
static init(query, selectors) {
return new this({
element: document.querySelector(query),
reactive: manager,
selectors,
});
}
/**
* Initial state ready method.
*
* @param {Object} state the initial state
*/
stateReady(state) {
this._refreshItem({state, element: state.queue.get(this.id)});
this.addEventListener(this.getElement(this.selectors.CLOSE), 'click', this._removeProcess);
}
/**
* Return the component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
{watch: `queue[${this.id}]:updated`, handler: this._refreshItem},
{watch: `queue[${this.id}]:deleted`, handler: this.remove},
];
}
/**
* Create a monitor item.
*
* @param {object} args the watcher arguments
* @param {object} args.element the item state data
*/
async _refreshItem({element}) {
const name = this.getElement(this.selectors.NAME);
name.innerHTML = element.name;
const progressbar = this.getElement(this.selectors.PROGRESSBAR);
progressbar.classList.toggle(this.classes.HIDE, element.finished);
progressbar.value = element.percentage;
const close = this.getElement(this.selectors.CLOSE);
close.classList.toggle(this.classes.HIDE, !element.error);
const error = this.getElement(this.selectors.ERROR);
error.innerHTML = element.error;
error.classList.toggle(this.classes.HIDE, !element.error);
}
/**
* Close the process.
*/
_removeProcess() {
this.reactive.dispatch('removeProcess', this.id);
}
}
@@ -0,0 +1,116 @@
// 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/>.
import {debounce} from 'core/utils';
import {LoadingProcess} from 'core/local/process_monitor/loadingprocess';
import log from 'core/log';
const TOASTSTIMER = 3000;
/**
* A process queue manager.
*
* Adding process to the queue will guarante process are executed in sequence.
*
* @module core/local/process_monitor/processqueue
* @class ProcessQueue
* @copyright 2022 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export class ProcessQueue {
/** @var {Array} pending the pending queue. */
pending = [];
/** @var {LoadingProcess} current the current uploading process. */
currentProcess = null;
/**
* Class constructor.
* @param {ProcessMonitorManager} manager the monitor manager
*/
constructor(manager) {
this.manager = manager;
this.cleanFinishedProcesses = debounce(
() => manager.dispatch('cleanFinishedProcesses'),
TOASTSTIMER
);
}
/**
* Adds a new pending upload to the queue.
* @param {String} processName the process name
* @param {Function} processor the execution function
*/
addPending(processName, processor) {
const process = new LoadingProcess(this.manager, {name: processName});
process.setExtraData({
processor,
});
process.onFinish((uploadedFile) => {
if (this.currentProcess?.id !== uploadedFile.id) {
return;
}
this._discardCurrent();
});
this.pending.push(process);
this._continueProcessing();
}
/**
* Adds a new pending upload to the queue.
* @param {String} processName the file info
* @param {String} errorMessage the file processor
*/
addError(processName, errorMessage) {
const process = new LoadingProcess(this.manager, {name: processName});
process.setError(errorMessage);
}
/**
* Discard the current process and execute the next one if any.
*/
_discardCurrent() {
if (this.currentProcess) {
this.currentProcess = null;
}
this.cleanFinishedProcesses();
this._continueProcessing();
}
/**
* Return the current file uploader.
* @return {FileUploader}
*/
_currentProcessor() {
return this.currentProcess.data.processor;
}
/**
* Continue the queue processing if no current process is defined.
*/
async _continueProcessing() {
if (this.currentProcess !== null || this.pending.length === 0) {
return;
}
this.currentProcess = this.pending.shift();
try {
const processor = this._currentProcessor();
await processor(this.currentProcess);
} catch (error) {
this.currentProcess.setError(error.message);
log.error(error);
}
}
}
+531
View File
@@ -0,0 +1,531 @@
// 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/>.
import Templates from 'core/templates';
import {addOverlay, removeOverlay, removeAllOverlays} from 'core/local/reactive/overlay';
/**
* Reactive UI component base class.
*
* Each UI reactive component should extend this class to interact with a reactive state.
*
* @module core/local/reactive/basecomponent
* @class core/local/reactive/basecomponent
* @copyright 2020 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default class {
/**
* The component descriptor data structure.
*
* This structure is used by any component and init method to define the way the component will interact
* with the interface and whith reactive instance operates. The logic behind this object is to avoid
* unnecessary dependancies between the final interface and the state logic.
*
* Any component interacts with a single main DOM element (description.element) but it can use internal
* selector to select elements within this main element (descriptor.selectors). By default each component
* will provide it's own default selectors, but those can be overridden by the "descriptor.selectors"
* property in case the mustache wants to reuse the same component logic but with a different interface.
*
* @typedef {object} descriptor
* @property {Reactive} reactive an optional reactive module to register in
* @property {DOMElement} element all components needs an element to anchor events
* @property {object} [selectors] an optional object to override query selectors
*/
/**
* The class constructor.
*
* The only param this method gets is a constructor with all the mandatory
* and optional component data. Component will receive the same descriptor
* as create method param.
*
* This method will call the "create" method before registering the component into
* the reactive module. This way any component can add default selectors and events.
*
* @param {descriptor} descriptor data to create the object.
*/
constructor(descriptor) {
if (descriptor.element === undefined || !(descriptor.element instanceof HTMLElement)) {
throw Error(`Reactive components needs a main DOM element to dispatch events`);
}
this.element = descriptor.element;
// Variable to track event listeners.
this.eventHandlers = new Map([]);
this.eventListeners = [];
// Empty default component selectors.
this.selectors = {};
// Empty default event list from the static method.
this.events = this.constructor.getEvents();
// Call create function to get the component defaults.
this.create(descriptor);
// Overwrite the components selectors if necessary.
if (descriptor.selectors !== undefined) {
this.addSelectors(descriptor.selectors);
}
// Register into a reactive instance.
if (descriptor.reactive === undefined) {
// Ask parent components for registration.
this.element.dispatchEvent(new CustomEvent(
'core/reactive:requestRegistration',
{
bubbles: true,
detail: {component: this},
}
));
} else {
this.reactive = descriptor.reactive;
this.reactive.registerComponent(this);
// Add a listener to register child components.
this.addEventListener(
this.element,
'core/reactive:requestRegistration',
(event) => {
if (event?.detail?.component) {
event.stopPropagation();
this.registerChildComponent(event.detail.component);
}
}
);
}
}
/**
* Return the component custom event names.
*
* Components may override this method to provide their own events.
*
* Component custom events is an important part of component reusability. This function
* is static because is part of the component definition and should be accessible from
* outsite the instances. However, values will be available at instance level in the
* this.events object.
*
* @returns {Object} the component events.
*/
static getEvents() {
return {};
}
/**
* Component create function.
*
* Default init method will call "create" when all internal attributes are set
* but before the component is not yet registered in the reactive module.
*
* In this method any component can define its own defaults such as:
* - this.selectors {object} the default query selectors of this component.
* - this.events {object} a list of event names this component dispatch
* - extract any data from the main dom element (this.element)
* - set any other data the component uses
*
* @param {descriptor} descriptor the component descriptor
*/
// eslint-disable-next-line no-unused-vars
create(descriptor) {
// Components may override this method to initialize selects, events or other data.
}
/**
* Component destroy hook.
*
* BaseComponent call this method when a component is unregistered or removed.
*
* Components may override this method to clean the HTML or do some action when the
* component is unregistered or removed.
*/
destroy() {
// Components can override this method.
}
/**
* Return the list of watchers that component has.
*
* Each watcher is represented by an object with two attributes:
* - watch (string) the specific state event to watch. Example 'section.visible:updated'
* - handler (function) the function to call when the watching state change happens
*
* Any component shoudl override this method to define their state watchers.
*
* @returns {array} array of watchers.
*/
getWatchers() {
return [];
}
/**
* Reactive module will call this method when the state is ready.
*
* Component can override this method to update/load the component HTML or to bind
* listeners to HTML entities.
*/
stateReady() {
// Components can override this method.
}
/**
* Get the main DOM element of this component or a subelement.
*
* @param {string|undefined} query optional subelement query
* @param {string|undefined} dataId optional data-id value
* @returns {element|undefined} the DOM element (if any)
*/
getElement(query, dataId) {
if (query === undefined && dataId === undefined) {
return this.element;
}
const dataSelector = (dataId) ? `[data-id='${dataId}']` : '';
const selector = `${query ?? ''}${dataSelector}`;
return this.element.querySelector(selector);
}
/**
* Get the all subelement that match a query selector.
*
* @param {string|undefined} query optional subelement query
* @param {string|undefined} dataId optional data-id value
* @returns {NodeList} the DOM elements
*/
getElements(query, dataId) {
const dataSelector = (dataId) ? `[data-id='${dataId}']` : '';
const selector = `${query ?? ''}${dataSelector}`;
return this.element.querySelectorAll(selector);
}
/**
* Add or update the component selectors.
*
* @param {Object} newSelectors an object of new selectors.
*/
addSelectors(newSelectors) {
for (const [selectorName, selector] of Object.entries(newSelectors)) {
this.selectors[selectorName] = selector;
}
}
/**
* Return a component selector.
*
* @param {string} selectorName the selector name
* @return {string|undefined} the query selector
*/
getSelector(selectorName) {
return this.selectors[selectorName];
}
/**
* Dispatch a custom event on this.element.
*
* This is just a convenient method to dispatch custom events from within a component.
* Components are free to use an alternative function to dispatch custom
* events. The only restriction is that it should be dispatched on this.element
* and specify "bubbles:true" to alert any component listeners.
*
* @param {string} eventName the event name
* @param {*} detail event detail data
*/
dispatchEvent(eventName, detail) {
this.element.dispatchEvent(new CustomEvent(eventName, {
bubbles: true,
detail: detail,
}));
}
/**
* Render a new Component using a mustache file.
*
* It is important to note that this method should NOT be used for loading regular mustache files
* as it returns a Promise that will only be resolved if the mustache registers a component instance.
*
* @param {element} target the DOM element that contains the component
* @param {string} file the component mustache file to render
* @param {*} data the mustache data
* @return {Promise} a promise of the resulting component instance
*/
renderComponent(target, file, data) {
return new Promise((resolve, reject) => {
target.addEventListener('ComponentRegistration:Success', ({detail}) => {
resolve(detail.component);
});
target.addEventListener('ComponentRegistration:Fail', () => {
reject(`Registration of ${file} fails.`);
});
Templates.renderForPromise(
file,
data
).then(({html, js}) => {
Templates.replaceNodeContents(target, html, js);
return true;
}).catch(error => {
reject(`Rendering of ${file} throws an error.`);
throw error;
});
});
}
/**
* Add and bind an event listener to a target and keep track of all event listeners.
*
* The native element.addEventListener method is not object oriented friently as the
* "this" represents the element that triggers the event and not the listener class.
* As components can be unregister and removed at any time, the BaseComponent provides
* this method to keep track of all component listeners and do all of the bind stuff.
*
* @param {Element} target the event target
* @param {string} type the event name
* @param {function} listener the class method that recieve the event
*/
addEventListener(target, type, listener) {
// Check if we have the bind version of that listener.
let bindListener = this.eventHandlers.get(listener);
if (bindListener === undefined) {
bindListener = listener.bind(this);
this.eventHandlers.set(listener, bindListener);
}
target.addEventListener(type, bindListener);
// Keep track of all component event listeners in case we need to remove them.
this.eventListeners.push({
target,
type,
bindListener,
});
}
/**
* Remove an event listener from a component.
*
* This method allows components to remove listeners without keeping track of the
* listeners bind versions of the method. Both addEventListener and removeEventListener
* keeps internally the relation between the original class method and the bind one.
*
* @param {Element} target the event target
* @param {string} type the event name
* @param {function} listener the class method that recieve the event
*/
removeEventListener(target, type, listener) {
// Check if we have the bind version of that listener.
let bindListener = this.eventHandlers.get(listener);
if (bindListener === undefined) {
// This listener has not been added.
return;
}
target.removeEventListener(type, bindListener);
}
/**
* Remove all event listeners from this component.
*
* This method is called also when the component is unregistered or removed.
*
* Note that only listeners registered with the addEventListener method
* will be removed. Other manual listeners will keep active.
*/
removeAllEventListeners() {
this.eventListeners.forEach(({target, type, bindListener}) => {
target.removeEventListener(type, bindListener);
});
this.eventListeners = [];
}
/**
* Remove a previously rendered component instance.
*
* This method will remove the component HTML and unregister it from the
* reactive module.
*/
remove() {
this.unregister();
this.element.remove();
}
/**
* Unregister the component from the reactive module.
*
* This method will disable the component logic, event listeners and watchers
* but it won't remove any HTML created by the component. However, it will trigger
* the destroy hook to allow the component to clean parts of the interface.
*/
unregister() {
this.reactive.unregisterComponent(this);
this.removeAllEventListeners();
this.destroy();
}
/**
* Dispatch a component registration event to inform the parent node.
*
* The registration event is different from the rest of the component events because
* is the only way in which components can communicate its existence to a possible parent.
* Most components will be created by including a mustache file, child components
* must emit a registration event to the parent DOM element to alert about the registration.
*/
dispatchRegistrationSuccess() {
// The registration event does not bubble because we just want to comunicate with the parentNode.
// Otherwise, any component can get multiple registrations events and could not differentiate
// between child components and grand child components.
if (this.element.parentNode === undefined) {
return;
}
// This custom element is captured by renderComponent method.
this.element.parentNode.dispatchEvent(new CustomEvent(
'ComponentRegistration:Success',
{
bubbles: false,
detail: {component: this},
}
));
}
/**
* Dispatch a component registration fail event to inform the parent node.
*
* As dispatchRegistrationSuccess, this method will communicate the registration fail to the
* parent node to inform the possible parent component.
*/
dispatchRegistrationFail() {
if (this.element.parentNode === undefined) {
return;
}
// This custom element is captured only by renderComponent method.
this.element.parentNode.dispatchEvent(new CustomEvent(
'ComponentRegistration:Fail',
{
bubbles: false,
detail: {component: this},
}
));
}
/**
* Register a child component into the reactive instance.
*
* @param {self} component the component to register.
*/
registerChildComponent(component) {
component.reactive = this.reactive;
this.reactive.registerComponent(component);
}
/**
* Set the lock value and locks or unlocks the element.
*
* @param {boolean} locked the new locked value
*/
set locked(locked) {
this.setElementLocked(this.element, locked);
}
/**
* Get the current locked value from the element.
*
* @return {boolean}
*/
get locked() {
return this.getElementLocked(this.element);
}
/**
* Lock/unlock an element.
*
* @param {Element} target the event target
* @param {boolean} locked the new locked value
*/
setElementLocked(target, locked) {
target.dataset.locked = locked ?? false;
if (locked) {
// Disable interactions.
target.style.pointerEvents = 'none';
target.style.userSelect = 'none';
// Check if it is draggable.
if (target.hasAttribute('draggable')) {
target.setAttribute('draggable', false);
}
target.setAttribute('aria-busy', true);
} else {
// Enable interactions.
target.style.pointerEvents = null;
target.style.userSelect = null;
// Check if it was draggable.
if (target.hasAttribute('draggable')) {
target.setAttribute('draggable', true);
}
target.setAttribute('aria-busy', false);
}
}
/**
* Get the current locked value from the element.
*
* @param {Element} target the event target
* @return {boolean}
*/
getElementLocked(target) {
return target.dataset.locked ?? false;
}
/**
* Adds an overlay to a specific page element.
*
* @param {Object} definition the overlay definition.
* @param {String} definition.content an optional overlay content.
* @param {String} definition.classes an optional CSS classes
* @param {Element} target optional parent object (this.element will be used if none provided)
*/
async addOverlay(definition, target) {
if (this._overlay) {
this.removeOverlay();
}
this._overlay = await addOverlay(
{
content: definition.content,
css: definition.classes ?? 'file-drop-zone',
},
target ?? this.element
);
}
/**
* Remove the current overlay.
*/
removeOverlay() {
if (!this._overlay) {
return;
}
removeOverlay(this._overlay);
this._overlay = null;
}
/**
* Remove all page overlais.
*/
removeAllOverlays() {
removeAllOverlays();
}
}
+371
View File
@@ -0,0 +1,371 @@
// 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/>.
/**
* Reactive module debug tools.
*
* @module core/local/reactive/debug
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Reactive from 'core/local/reactive/reactive';
import log from 'core/log';
// The list of reactives instances.
const reactiveInstances = {};
// The reactive debugging objects.
const reactiveDebuggers = {};
/**
* Reactive module debug tools.
*
* If debug is enabled, this reactive module will spy all the reactive instances and keep a record
* of the changes and components they have.
*
* It is important to note that the Debug class is also a Reactive module. The debug instance keeps
* the reactive instances data as its own state. This way it is possible to implement development tools
* that whatches this data.
*
* @class core/reactive/local/reactive/debug/Debug
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class Debug extends Reactive {
/**
* Set the initial state.
*
* @param {object} stateData the initial state data.
*/
setInitialState(stateData) {
super.setInitialState(stateData);
log.debug(`Debug module "M.reactive" loaded.`);
}
/**
* List the currents page reactives instances.
*/
get list() {
return JSON.parse(JSON.stringify(this.state.reactives));
}
/**
* Register a new Reactive instance.
*
* This method is called every time a "new Reactive" is executed.
*
* @param {Reactive} instance the reactive instance
*/
registerNewInstance(instance) {
// Generate a valid variable name for that instance.
let name = instance.name ?? `instance${this.state.reactives.length}`;
name = name.replace(/\W/g, '');
log.debug(`Registering new reactive instance "M.reactive.${name}"`);
reactiveInstances[name] = instance;
reactiveDebuggers[name] = new DebugInstance(reactiveInstances[name]);
// Register also in the state.
this.dispatch('putInstance', name, instance);
// Add debug watchers to instance.
const refreshMethod = () => {
this.dispatch('putInstance', name, instance);
};
instance.target.addEventListener('readmode:on', refreshMethod);
instance.target.addEventListener('readmode:off', refreshMethod);
instance.target.addEventListener('registerComponent:success', refreshMethod);
instance.target.addEventListener('transaction:end', refreshMethod);
// We store the last transaction into the state.
const storeTransaction = ({detail}) => {
const changes = detail?.changes;
this.dispatch('lastTransaction', name, changes);
};
instance.target.addEventListener('transaction:start', storeTransaction);
}
/**
* Returns a debugging object for a specific Reactive instance.
*
* A debugging object is a class that wraps a Reactive instance to quick access some of the
* reactive methods using the browser JS console.
*
* @param {string} name the Reactive instance name
* @returns {DebugInstance} a debug object wrapping the Reactive instance
*/
debug(name) {
return reactiveDebuggers[name];
}
}
/**
* The debug state mutations class.
*
* @class core/reactive/local/reactive/debug/Mutations
*/
class Mutations {
/**
* Insert or update a new instance into the debug state.
*
* @param {StateManager} stateManager the debug state manager
* @param {string} name the instance name
* @param {Reactive} instance the reactive instance
*/
putInstance(stateManager, name, instance) {
const state = stateManager.state;
stateManager.setReadOnly(false);
if (state.reactives.has(name)) {
state.reactives.get(name).countcomponents = instance.components.length;
state.reactives.get(name).readOnly = instance.stateManager.readonly;
state.reactives.get(name).modified = new Date().getTime();
} else {
state.reactives.add({
id: name,
countcomponents: instance.components.length,
readOnly: instance.stateManager.readonly,
lastChanges: [],
modified: new Date().getTime(),
});
}
stateManager.setReadOnly(true);
}
/**
* Update the lastChanges attribute with a list of changes
*
* @param {StateManager} stateManager the debug reactive state
* @param {string} name tje instance name
* @param {array} changes the list of changes
*/
lastTransaction(stateManager, name, changes) {
if (!changes || changes.length === 0) {
return;
}
const state = stateManager.state;
const lastChanges = ['transaction:start'];
changes.forEach(change => {
lastChanges.push(change.eventName);
});
lastChanges.push('transaction:end');
stateManager.setReadOnly(false);
state.reactives.get(name).lastChanges = lastChanges;
stateManager.setReadOnly(true);
}
}
/**
* Class used to debug a specific instance and manipulate the state from the JS console.
*
* @class core/reactive/local/reactive/debug/DebugInstance
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class DebugInstance {
/**
* Constructor.
*
* @param {Reactive} instance the reactive instance
*/
constructor(instance) {
this.instance = instance;
// Add some debug data directly into the instance. This way we avoid having attributes
// that will confuse the console aoutocomplete.
if (instance._reactiveDebugData === undefined) {
instance._reactiveDebugData = {
highlighted: false,
};
}
}
/**
* Set the read only mode.
*
* Quick access to the instance setReadOnly method.
*
* @param {bool} value the new read only value
*/
set readOnly(value) {
this.instance.stateManager.setReadOnly(value);
}
/**
* Get the read only value
*
* @returns {bool}
*/
get readOnly() {
return this.instance.stateManager.readonly;
}
/**
* Return the current state object.
*
* @returns {object}
*/
get state() {
return this.instance.state;
}
/**
* Tooggle the reactive HTML element highlight registered in this reactive instance.
*
* @param {bool} value the highlight value
*/
set highlight(value) {
this.instance._reactiveDebugData.highlighted = value;
this.instance.components.forEach(({element}) => {
const border = (value) ? `thick solid #0000FF` : '';
element.style.border = border;
});
}
/**
* Get the current highligh value.
*
* @returns {bool}
*/
get highlight() {
return this.instance._reactiveDebugData.highlighted;
}
/**
* List all the components registered in this instance.
*
* @returns {array}
*/
get components() {
return [...this.instance.components];
}
/**
* List all the state changes evenet pending to dispatch.
*
* @returns {array}
*/
get changes() {
const result = [];
this.instance.stateManager.eventsToPublish.forEach(
(element) => {
result.push(element.eventName);
}
);
return result;
}
/**
* Dispatch a change in the state.
*
* Usually reactive modules throw an error directly to the components when something
* goes wrong. However, course editor can directly display a notification.
*
* @method dispatch
* @param {*} args
*/
async dispatch(...args) {
this.instance.dispatch(...args);
}
/**
* Return all the HTML elements registered in the instance components.
*
* @returns {array}
*/
get elements() {
const result = [];
this.instance.components.forEach(({element}) => {
result.push(element);
});
return result;
}
/**
* Return a plain copy of the state data.
*
* @returns {object}
*/
get stateData() {
return JSON.parse(JSON.stringify(this.state));
}
/**
* Process an update state array.
*
* @param {array} updates an array of update state messages
*/
processUpdates(updates) {
this.instance.stateManager.processUpdates(updates);
}
}
const stateChangedEventName = 'core_reactive_debug:stateChanged';
/**
* Internal state changed event.
*
* @method dispatchStateChangedEvent
* @param {object} detail the full state
* @param {object} target the custom event target (document if none provided)
*/
function dispatchStateChangedEvent(detail, target) {
if (target === undefined) {
target = document;
}
target.dispatchEvent(
new CustomEvent(
stateChangedEventName,
{
bubbles: true,
detail: detail,
}
)
);
}
/**
* The main init method to initialize the reactive debug.
* @returns {object}
*/
export const initDebug = () => {
const debug = new Debug({
name: 'CoreReactiveDebug',
eventName: stateChangedEventName,
eventDispatch: dispatchStateChangedEvent,
mutations: new Mutations(),
state: {
reactives: [],
},
});
// The reactiveDebuggers will be used as a way of access the debug instances but also to register every new
// instance. To ensure this will update the reactive debug state we add the registerNewInstance method to it.
reactiveDebuggers.registerNewInstance = debug.registerNewInstance.bind(debug);
return {
debug,
debuggers: reactiveDebuggers,
};
};
+595
View File
@@ -0,0 +1,595 @@
// 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/>.
/**
* Reactive module debug panel.
*
* This module contains all the UI components for the reactive debug tools.
* Those tools are only available if the debug is enables and could be used
* from the footer.
*
* @module core/local/reactive/debugpanel
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import {BaseComponent, DragDrop, debug} from 'core/reactive';
import log from 'core/log';
import {debounce} from 'core/utils';
/**
* Init the main reactive panel.
*
* @param {element|string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
*/
export const init = (target, selectors) => {
const element = document.getElementById(target);
// Check if the debug reactive module is available.
if (debug === undefined) {
element.remove();
return;
}
// Create the main component.
new GlobalDebugPanel({
element,
reactive: debug,
selectors,
});
};
/**
* Init an instance reactive subpanel.
*
* @param {element|string} target the DOM main element or its ID
* @param {object} selectors optional css selector overrides
*/
export const initsubpanel = (target, selectors) => {
const element = document.getElementById(target);
// Check if the debug reactive module is available.
if (debug === undefined) {
element.remove();
return;
}
// Create the main component.
new DebugInstanceSubpanel({
element,
reactive: debug,
selectors,
});
};
/**
* Component for the main reactive dev panel.
*
* This component shows the list of reactive instances and handle the buttons
* to open a specific instance panel.
*/
class GlobalDebugPanel extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'GlobalDebugPanel';
// Default query selectors.
this.selectors = {
LOADERS: `[data-for='loaders']`,
SUBPANEL: `[data-for='subpanel']`,
NOINSTANCES: `[data-for='noinstances']`,
LOG: `[data-for='log']`,
};
this.classes = {
HIDE: `d-none`,
};
// The list of loaded debuggers.
this.subPanels = new Set();
}
/**
* Initial state ready method.
*
* @param {object} state the initial state
*/
stateReady(state) {
this._updateReactivesPanels({state});
// Remove loading wheel.
this.getElement(this.selectors.SUBPANEL).innerHTML = '';
}
/**
* Component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
{watch: `reactives:created`, handler: this._updateReactivesPanels},
];
}
/**
* Update the list of reactive instances.
* @param {Object} args
* @param {Object} args.state the current state
*/
_updateReactivesPanels({state}) {
this.getElement(this.selectors.NOINSTANCES)?.classList?.toggle(
this.classes.HIDE,
state.reactives.size > 0
);
// Generate loading buttons.
state.reactives.forEach(
instance => {
this._createLoader(instance);
}
);
}
/**
* Create a debug panel button for a specific reactive instance.
*
* @param {object} instance hte instance data
*/
_createLoader(instance) {
if (this.subPanels.has(instance.id)) {
return;
}
this.subPanels.add(instance.id);
const loaders = this.getElement(this.selectors.LOADERS);
const btn = document.createElement("button");
btn.innerHTML = instance.id;
btn.dataset.id = instance.id;
loaders.appendChild(btn);
// Add click event.
this.addEventListener(btn, 'click', () => this._openPanel(btn, instance));
}
/**
* Open a debug panel.
*
* @param {Element} btn the button element
* @param {object} instance the instance data
*/
async _openPanel(btn, instance) {
try {
const target = this.getElement(this.selectors.SUBPANEL);
const data = {...instance};
await this.renderComponent(target, 'core/local/reactive/debuginstancepanel', data);
} catch (error) {
log.error('Cannot load reactive debug subpanel');
throw error;
}
}
}
/**
* Component for the main reactive dev panel.
*
* This component shows the list of reactive instances and handle the buttons
* to open a specific instance panel.
*/
class DebugInstanceSubpanel extends BaseComponent {
/**
* Constructor hook.
*/
create() {
// Optional component name for debugging.
this.name = 'DebugInstanceSubpanel';
// Default query selectors.
this.selectors = {
NAME: `[data-for='name']`,
CLOSE: `[data-for='close']`,
READMODE: `[data-for='readmode']`,
HIGHLIGHT: `[data-for='highlight']`,
LOG: `[data-for='log']`,
STATE: `[data-for='state']`,
CLEAN: `[data-for='clean']`,
PIN: `[data-for='pin']`,
SAVE: `[data-for='save']`,
INVALID: `[data-for='invalid']`,
};
this.id = this.element.dataset.id;
this.controller = M.reactive[this.id];
// The component is created always pinned.
this.draggable = false;
// We want the element to be dragged like modal.
this.relativeDrag = true;
// Save warning (will be loaded when state is ready.
this.strings = {
savewarning: '',
};
}
/**
* Initial state ready method.
*
*/
stateReady() {
// Enable drag and drop.
this.dragdrop = new DragDrop(this);
// Close button.
this.addEventListener(
this.getElement(this.selectors.CLOSE),
'click',
this.remove
);
// Highlight button.
if (this.controller.highlight) {
this._toggleButtonText(this.getElement(this.selectors.HIGHLIGHT));
}
this.addEventListener(
this.getElement(this.selectors.HIGHLIGHT),
'click',
() => {
this.controller.highlight = !this.controller.highlight;
this._toggleButtonText(this.getElement(this.selectors.HIGHLIGHT));
}
);
// Edit mode button.
this.addEventListener(
this.getElement(this.selectors.READMODE),
'click',
this._toggleEditMode
);
// Clean log and state.
this.addEventListener(
this.getElement(this.selectors.CLEAN),
'click',
this._cleanAreas
);
// Unpin panel butotn.
this.addEventListener(
this.getElement(this.selectors.PIN),
'click',
this._togglePin
);
// Save button, state format error message and state textarea.
this.getElement(this.selectors.SAVE).disabled = true;
this.addEventListener(
this.getElement(this.selectors.STATE),
'keyup',
debounce(this._checkJSON.bind(this), 500)
);
this.addEventListener(
this.getElement(this.selectors.SAVE),
'click',
this._saveState
);
// Save the default save warning message.
this.strings.savewarning = this.getElement(this.selectors.INVALID)?.innerHTML ?? '';
// Add current state.
this._refreshState();
}
/**
* Remove all subcomponents dependencies.
*/
destroy() {
if (this.dragdrop !== undefined) {
this.dragdrop.unregister();
}
}
/**
* Component watchers.
*
* @returns {Array} of watchers
*/
getWatchers() {
return [
{watch: `reactives[${this.id}].lastChanges:updated`, handler: this._refreshLog},
{watch: `reactives[${this.id}].modified:updated`, handler: this._refreshState},
{watch: `reactives[${this.id}].readOnly:updated`, handler: this._refreshReadOnly},
];
}
/**
* Wtacher method to refresh the log panel.
*
* @param {object} args
* @param {HTMLElement} args.element
*/
_refreshLog({element}) {
const list = element?.lastChanges ?? [];
const logContent = list.join("\n");
// Append last log.
const target = this.getElement(this.selectors.LOG);
target.value += `\n\n= Transaction =\n ${logContent}`;
target.scrollTop = target.scrollHeight;
}
/**
* Listener method to clean the log area.
*/
_cleanAreas() {
let target = this.getElement(this.selectors.LOG);
target.value = '';
this._refreshState();
}
/**
* Watcher to refresh the state information.
*/
_refreshState() {
const target = this.getElement(this.selectors.STATE);
target.value = JSON.stringify(this.controller.state, null, 4);
}
/**
* Watcher to update the read only information.
*/
_refreshReadOnly() {
// Toggle the read mode button.
const target = this.getElement(this.selectors.READMODE);
if (target.dataset.readonly === undefined) {
target.dataset.readonly = target.innerHTML;
}
if (this.controller.readOnly) {
target.innerHTML = target.dataset.readonly;
} else {
target.innerHTML = target.dataset.alt;
}
}
/**
* Listener to toggle the edit mode of the component.
*/
_toggleEditMode() {
this.controller.readOnly = !this.controller.readOnly;
}
/**
* Check that the edited state JSON is valid.
*
* Not all valid JSON are suitable for transforming the state. For example,
* the first level attributes cannot change the type.
*
* @return {undefined|array} Array of state updates.
*/
_checkJSON() {
const invalid = this.getElement(this.selectors.INVALID);
const save = this.getElement(this.selectors.SAVE);
const edited = this.getElement(this.selectors.STATE).value;
const currentStateData = this.controller.stateData;
// Check if the json is tha same as state.
if (edited == JSON.stringify(this.controller.state, null, 4)) {
invalid.style.color = '';
invalid.innerHTML = '';
save.disabled = true;
return undefined;
}
// Check if the json format is valid.
try {
const newState = JSON.parse(edited);
// Check the first level did not change types.
const result = this._generateStateUpdates(currentStateData, newState);
// Enable save button.
invalid.style.color = '';
invalid.innerHTML = this.strings.savewarning;
save.disabled = false;
return result;
} catch (error) {
invalid.style.color = 'red';
invalid.innerHTML = error.message ?? 'Invalid JSON sctructure';
save.disabled = true;
return undefined;
}
}
/**
* Listener to save the current edited state into the real state.
*/
_saveState() {
const updates = this._checkJSON();
if (!updates) {
return;
}
// Sent the updates to the state manager.
this.controller.processUpdates(updates);
}
/**
* Check that the edited state JSON is valid.
*
* Not all valid JSON are suitable for transforming the state. For example,
* the first level attributes cannot change the type. This method do a two
* steps comparison between the current state data and the new state data.
*
* A reactive state cannot be overridden like any other variable. To keep
* the watchers updated is necessary to transform the current state into
* the new one. As a result, this method generates all the necessary state
* updates to convert the state into the new state.
*
* @param {object} currentStateData
* @param {object} newStateData
* @return {array} Array of state updates.
* @throws {Error} is the structure is not compatible
*/
_generateStateUpdates(currentStateData, newStateData) {
const updates = [];
const ids = {};
// Step 1: Add all overrides newStateData.
for (const [key, newValue] of Object.entries(newStateData)) {
// Check is it is new.
if (Array.isArray(newValue)) {
ids[key] = {};
newValue.forEach(element => {
if (element.id === undefined) {
throw Error(`Array ${key} element without id attribute`);
}
updates.push({
name: key,
action: 'override',
fields: element,
});
const index = String(element.id).valueOf();
ids[key][index] = true;
});
} else {
updates.push({
name: key,
action: 'override',
fields: newValue,
});
}
}
// Step 2: delete unnecesary data from currentStateData.
for (const [key, oldValue] of Object.entries(currentStateData)) {
let deleteField = false;
// Check if the attribute is still there.
if (newStateData[key] === undefined) {
deleteField = true;
}
if (Array.isArray(oldValue)) {
if (!deleteField && ids[key] === undefined) {
throw Error(`Array ${key} cannot change to object.`);
}
oldValue.forEach(element => {
const index = String(element.id).valueOf();
let deleteEntry = deleteField;
// Check if the id is there.
if (!deleteEntry && ids[key][index] === undefined) {
deleteEntry = true;
}
if (deleteEntry) {
updates.push({
name: key,
action: 'delete',
fields: element,
});
}
});
} else {
if (!deleteField && ids[key] !== undefined) {
throw Error(`Object ${key} cannot change to array.`);
}
if (deleteField) {
updates.push({
name: key,
action: 'delete',
fields: oldValue,
});
}
}
}
// Delete all elements without action.
return updates;
}
// Drag and drop methods.
/**
* Get the draggable data of this component.
*
* @returns {Object} exported course module drop data
*/
getDraggableData() {
return this.draggable;
}
/**
* The element drop end hook.
*
* @param {Object} dropdata the dropdata
* @param {Event} event the dropdata
*/
dragEnd(dropdata, event) {
this.element.style.top = `${event.newFixedTop}px`;
this.element.style.left = `${event.newFixedLeft}px`;
}
/**
* Pin and unpin the panel.
*/
_togglePin() {
this.draggable = !this.draggable;
this.dragdrop.setDraggable(this.draggable);
if (this.draggable) {
this._unpin();
} else {
this._pin();
}
}
/**
* Unpin the panel form the footer.
*/
_unpin() {
// Find the initial spot.
const pageCenterY = window.innerHeight / 2;
const pageCenterX = window.innerWidth / 2;
// Put the element in the middle of the screen
const style = {
position: 'fixed',
resize: 'both',
overflow: 'auto',
height: '400px',
width: '400px',
top: `${pageCenterY - 200}px`,
left: `${pageCenterX - 200}px`,
};
Object.assign(this.element.style, style);
// Small also the text areas.
this.getElement(this.selectors.STATE).style.height = '50px';
this.getElement(this.selectors.LOG).style.height = '50px';
this._toggleButtonText(this.getElement(this.selectors.PIN));
}
/**
* Pin the panel into the footer.
*/
_pin() {
const props = [
'position',
'resize',
'overflow',
'top',
'left',
'height',
'width',
];
props.forEach(
prop => this.element.style.removeProperty(prop)
);
this._toggleButtonText(this.getElement(this.selectors.PIN));
}
/**
* Toogle the button text with the data-alt value.
*
* @param {Element} element the button element
*/
_toggleButtonText(element) {
[element.innerHTML, element.dataset.alt] = [element.dataset.alt, element.innerHTML];
}
}
+510
View File
@@ -0,0 +1,510 @@
// 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/>.
/**
* Drag and drop helper component.
*
* This component is used to delegate drag and drop handling.
*
* To delegate the logic to this particular element the component should create a new instance
* passing "this" as param. The component will use all the necessary callbacks and add all the
* necessary listeners to the component element.
*
* Component attributes used by dragdrop module:
* - element: the draggable or dropzone element.
* - (optional) classes: object with alternative CSS classes
* - (optional) fullregion: page element affeted by the elementy dragging. Use this attribute if
* the draggable element affects a bigger region (for example a draggable
* title).
* - (optional) autoconfigDraggable: by default, the component will be draggable if it has a
* getDraggableData method. If this value is false draggable
* property must be defined using setDraggable method.
* - (optional) relativeDrag: by default the drag image is located at point (0,0) relative to the
* mouse position to prevent the mouse from covering it. If this attribute
* is true the drag image will be located at the click offset.
*
* Methods the parent component should have for making it draggable:
*
* - getDraggableData(): Object|data
* Return the data that will be passed to any valid dropzone while it is dragged.
* If the component has this method, the dragdrop module will enable the dragging,
* this is the only required method for dragging.
* If at the dragging moment this method returns a false|null|undefined, the dragging
* actions won't be captured.
*
* - (optional) dragStart(Object dropdata, Event event): void
* - (optional) dragEnd(Object dropdata, Event event): void
* Callbacks dragdrop will call when the element is dragged and getDraggableData
* return some data.
*
* Methods the parent component should have for enabling it as a dropzone:
*
* - validateDropData(Object dropdata): boolean
* If that method exists, the dragdrop module will automathically configure the element as dropzone.
* This method will return true if the dropdata is accepted. In case it returns false, no drag and
* drop event will be listened for this specific dragged dropdata.
*
* - (Optional) showDropZone(Object dropdata, Event event): void
* - (Optional) hideDropZone(Object dropdata, Event event): void
* Methods called when a valid dragged data pass over the element.
*
* - (Optional) drop(Object dropdata, Event event): void
* Called when a valid dragged element is dropped over the element.
*
* Note that none of this methods will be called if validateDropData
* returns a false value.
*
* This module will also add or remove several CSS classes from both dragged elements and dropzones.
* See the "this.classes" in the create method for more details. In case the parent component wants
* to use the same classes, it can use the getClasses method. On the other hand, if the parent
* component has an alternative "classes" attribute, this will override the default drag and drop
* classes.
*
* @module core/local/reactive/dragdrop
* @class core/local/reactive/dragdrop
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import BaseComponent from 'core/local/reactive/basecomponent';
// Map with the dragged element generate by an specific reactive applications.
// Potentially, any component can generate a draggable element to interact with other
// page elements. However, the dragged data is specific and could only interact with
// components of the same reactive instance.
let activeDropData = new Map();
// Drag & Drop API provides the final drop point and incremental movements but we can
// provide also starting points and displacements. Absolute displacements simplifies
// moving components with aboslute position around the page.
let dragStartPoint = {};
export default class extends BaseComponent {
/**
* Constructor hook.
*
* @param {BaseComponent} parent the parent component.
*/
create(parent) {
// Optional component name for debugging.
this.name = `${parent.name ?? 'unkown'}_dragdrop`;
// Default drag and drop classes.
this.classes = Object.assign(
{
// This class indicate a dragging action is active at a page level.
BODYDRAGGING: 'dragging',
// Added when draggable and drop are ready.
DRAGGABLEREADY: 'draggable',
DROPREADY: 'dropready',
// When a valid drag element is over the element.
DRAGOVER: 'dragover',
// When a the component is dragged.
DRAGGING: 'dragging',
// Dropzones classes names.
DROPUP: 'drop-up',
DROPDOWN: 'drop-down',
DROPZONE: 'drop-zone',
// Drag icon class.
DRAGICON: 'dragicon',
},
parent?.classes ?? {}
);
// Add the affected region if any.
this.fullregion = parent.fullregion;
// Keep parent to execute drap and drop handlers.
this.parent = parent;
// Check if parent handle draggable manually.
this.autoconfigDraggable = this.parent.draggable ?? true;
// Drag image relative position.
this.relativeDrag = this.parent.relativeDrag ?? false;
// Sub HTML elements will trigger extra dragEnter and dragOver all the time.
// To prevent that from affecting dropzones, we need to count the enters and leaves.
this.entercount = 0;
// Stores if the droparea is shown or not.
this.dropzonevisible = false;
// Stores if the mouse is over the element or not.
this.ismouseover = false;
}
/**
* Return the component drag and drop CSS classes.
*
* @returns {Object} the dragdrop css classes
*/
getClasses() {
return this.classes;
}
/**
* Return the current drop-zone visible of the element.
*
* @returns {boolean} if the dropzone should be visible or not
*/
isDropzoneVisible() {
return this.dropzonevisible;
}
/**
* Initial state ready method.
*
* This method will add all the necessary event listeners to the component depending on the
* parent methods.
* - Add drop events to the element if the parent component has validateDropData method.
* - Configure the elements draggable if the parent component has getDraggableData method.
*/
stateReady() {
// Add drop events to the element if the parent component has dropable types.
if (typeof this.parent.validateDropData === 'function') {
this.element.classList.add(this.classes.DROPREADY);
this.addEventListener(this.element, 'dragenter', this._dragEnter);
this.addEventListener(this.element, 'dragleave', this._dragLeave);
this.addEventListener(this.element, 'dragover', this._dragOver);
this.addEventListener(this.element, 'drop', this._drop);
this.addEventListener(this.element, 'mouseover', this._mouseOver);
this.addEventListener(this.element, 'mouseleave', this._mouseLeave);
}
// Configure the elements draggable if the parent component has dragable data.
if (this.autoconfigDraggable && typeof this.parent.getDraggableData === 'function') {
this.setDraggable(true);
}
}
/**
* Enable or disable the draggable property.
*
* @param {bool} value the new draggable value
*/
setDraggable(value) {
if (typeof this.parent.getDraggableData !== 'function') {
throw new Error(`Draggable components must have a getDraggableData method`);
}
this.element.setAttribute('draggable', value);
if (value) {
this.addEventListener(this.element, 'dragstart', this._dragStart);
this.addEventListener(this.element, 'dragend', this._dragEnd);
this.element.classList.add(this.classes.DRAGGABLEREADY);
} else {
this.removeEventListener(this.element, 'dragstart', this._dragStart);
this.removeEventListener(this.element, 'dragend', this._dragEnd);
this.element.classList.remove(this.classes.DRAGGABLEREADY);
}
}
/**
* Mouse over handle.
*/
_mouseOver() {
this.ismouseover = true;
}
/**
* Mouse leave handler.
*/
_mouseLeave() {
this.ismouseover = false;
}
/**
* Drag start event handler.
*
* This method will generate the current dropable data. This data is the one used to determine
* if a droparea accepts the dropping or not.
*
* @param {Event} event the event.
*/
_dragStart(event) {
// Cancel dragging if any editable form element is focussed.
if (document.activeElement.matches(`textarea, input`)) {
event.preventDefault();
return;
}
const dropdata = this.parent.getDraggableData();
if (!dropdata) {
return;
}
// Save the starting point.
dragStartPoint = {
pageX: event.pageX,
pageY: event.pageY,
};
// If the drag event is accepted we prevent any other draggable element from interfiering.
event.stopPropagation();
// Save the drop data of the current reactive intance.
activeDropData.set(this.reactive, dropdata);
// Add some CSS classes to indicate the state.
document.body.classList.add(this.classes.BODYDRAGGING);
this.element.classList.add(this.classes.DRAGGING);
this.fullregion?.classList.add(this.classes.DRAGGING);
// Force the drag image. This makes the UX more consistent in case the
// user dragged an internal element like a link or some other element.
let dragImage = this.element;
if (this.parent.setDragImage !== undefined) {
const customImage = this.parent.setDragImage(dropdata, event);
if (customImage) {
dragImage = customImage;
}
}
// Define the image position relative to the mouse.
const position = {x: 0, y: 0};
if (this.relativeDrag) {
position.x = event.offsetX;
position.y = event.offsetY;
}
event.dataTransfer.setDragImage(dragImage, position.x, position.y);
event.dataTransfer.effectAllowed = 'copyMove';
this._callParentMethod('dragStart', dropdata, event);
}
/**
* Drag end event handler.
*
* @param {Event} event the event.
*/
_dragEnd(event) {
const dropdata = activeDropData.get(this.reactive);
if (!dropdata) {
return;
}
// Remove the current dropdata.
activeDropData.delete(this.reactive);
// Remove the dragging classes.
document.body.classList.remove(this.classes.BODYDRAGGING);
this.element.classList.remove(this.classes.DRAGGING);
this.fullregion?.classList.remove(this.classes.DRAGGING);
this.removeAllOverlays();
// We add the total movement to the event in case the component
// wants to move its absolute position.
this._addEventTotalMovement(event);
this._callParentMethod('dragEnd', dropdata, event);
}
/**
* Drag enter event handler.
*
* The JS drag&drop API triggers several dragenter events on the same element because it bubbles the
* child events as well. To prevent this form affecting the dropzones display, this methods use
* "entercount" to determine if it's one extra child event or a valid one.
*
* @param {Event} event the event.
*/
_dragEnter(event) {
const dropdata = this._processEvent(event);
if (dropdata) {
this.entercount++;
this.element.classList.add(this.classes.DRAGOVER);
if (this.entercount == 1 && !this.dropzonevisible) {
this.dropzonevisible = true;
this.element.classList.add(this.classes.DRAGOVER);
this._callParentMethod('showDropZone', dropdata, event);
}
}
}
/**
* Drag over event handler.
*
* We only use dragover event when a draggable action starts inside a valid dropzone. In those cases
* the API won't trigger any dragEnter because the dragged alement was already there. We use the
* dropzonevisible to determine if the component needs to display the dropzones or not.
*
* @param {Event} event the event.
*/
_dragOver(event) {
const dropdata = this._processEvent(event);
event.dataTransfer.dropEffect = (event.altKey) ? 'copy' : 'move';
if (dropdata && !this.dropzonevisible) {
this.dropzonevisible = true;
this.element.classList.add(this.classes.DRAGOVER);
this._callParentMethod('showDropZone', dropdata, event);
}
}
/**
* Drag over leave handler.
*
* The JS drag&drop API triggers several dragleave events on the same element because it bubbles the
* child events as well. To prevent this form affecting the dropzones display, this methods use
* "entercount" to determine if it's one extra child event or a valid one.
*
* @param {Event} event the event.
*/
_dragLeave(event) {
const dropdata = this._processEvent(event);
if (dropdata) {
this.entercount--;
if (this.entercount <= 0 && this.dropzonevisible) {
this.dropzonevisible = false;
this.element.classList.remove(this.classes.DRAGOVER);
this._callParentMethod('hideDropZone', dropdata, event);
}
}
}
/**
* Drop event handler.
*
* This method will call both hideDropZones and drop methods on the parent component.
*
* @param {Event} event the event.
*/
_drop(event) {
const dropdata = this._processEvent(event);
if (dropdata) {
this.entercount = 0;
if (this.dropzonevisible) {
this.dropzonevisible = false;
this._callParentMethod('hideDropZone', dropdata, event);
}
this.element.classList.remove(this.classes.DRAGOVER);
this.removeAllOverlays();
this._callParentMethod('drop', dropdata, event);
// An accepted drop resets the initial position.
// Save the starting point.
dragStartPoint = {};
}
}
/**
* Process a drag and drop event and delegate logic to the parent component.
*
* @param {Event} event the drag and drop event
* @return {Object|false} the dropdata or null if the event should not be processed
*/
_processEvent(event) {
const dropdata = this._getDropData(event);
if (!dropdata) {
return null;
}
if (this.parent.validateDropData(dropdata)) {
// All accepted drag&drop event must prevent bubbling and defaults, otherwise
// parent dragdrop instances could capture it by mistake.
event.preventDefault();
event.stopPropagation();
this._addEventTotalMovement(event);
return dropdata;
}
return null;
}
/**
* Add the total amout of movement to a mouse event.
*
* @param {MouseEvent} event
*/
_addEventTotalMovement(event) {
if (dragStartPoint.pageX === undefined || event.pageX === undefined) {
return;
}
event.fixedMovementX = event.pageX - dragStartPoint.pageX;
event.fixedMovementY = event.pageY - dragStartPoint.pageY;
event.initialPageX = dragStartPoint.pageX;
event.initialPageY = dragStartPoint.pageY;
// The element possible new top.
const current = this.element.getBoundingClientRect();
// Add the new position fixed position.
event.newFixedTop = current.top + event.fixedMovementY;
event.newFixedLeft = current.left + event.fixedMovementX;
// The affected region possible new top.
if (this.fullregion !== undefined) {
const current = this.fullregion.getBoundingClientRect();
event.newRegionFixedxTop = current.top + event.fixedMovementY;
event.newRegionFixedxLeft = current.left + event.fixedMovementX;
}
}
/**
* Convenient method for calling parent component functions if present.
*
* @param {string} methodname the name of the method
* @param {Object} dropdata the current drop data object
* @param {Event} event the original event
*/
_callParentMethod(methodname, dropdata, event) {
if (typeof this.parent[methodname] === 'function') {
this.parent[methodname](dropdata, event);
}
}
/**
* Get the current dropdata for a specific event.
*
* The browser can generate drag&drop events related to several user interactions:
* - Drag a page elements: this case is registered in the activeDropData map
* - Drag some HTML selections: ignored for now
* - Drag a file over the browser: file drag may appear in the future but for now they are ignored.
*
* @param {Event} event the original event.
* @returns {Object|undefined} with the dragged data (or undefined if none)
*/
_getDropData(event) {
this._isOnlyFilesDragging = this._containsOnlyFiles(event);
if (this._isOnlyFilesDragging) {
// Check if the reactive instance can provide a files draggable data.
if (this.reactive.getFilesDraggableData !== undefined && typeof this.reactive.getFilesDraggableData === 'function') {
return this.reactive.getFilesDraggableData(event.dataTransfer);
}
return undefined;
}
return activeDropData.get(this.reactive);
}
/**
* Check if the dragged event contains only files.
*
* Files dragging does not generate drop data because they came from outside the page and the component
* must check it before validating the event.
*
* Some browsers like Firefox add extra types to file dragging. To discard the false positives
* a double check is necessary.
*
* @param {Event} event the original event.
* @returns {boolean} if the drag dataTransfers contains files.
*/
_containsOnlyFiles(event) {
if (!event.dataTransfer.types.includes('Files')) {
return false;
}
return event.dataTransfer.types.every((type) => {
return (type.toLowerCase() != 'text/uri-list'
&& type.toLowerCase() != 'text/html'
&& type.toLowerCase() != 'text/plain'
);
});
}
}
+60
View File
@@ -0,0 +1,60 @@
// 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/>.
/**
* Default reactive mutations logger class.
*
* This logger is used by default by the StateManager to log mutation feedbacks
* and actions. By default, feedbacks will be displayed as a toast. However, the
* reactive instance can provide alternative loggers to provide advanced logging
* capabilities.
*
* @module core/local/reactive/logger
* @class Logger
* @copyright 2023 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
/**
* Logger entry structure.
*
* @typedef {object} LoggerEntry
* @property {string} feedbackMessage Feedback message.
*/
import {add as addToast} from 'core/toast';
/**
* Default reactive mutations logger class.
* @class Logger
*/
export default class Logger {
/**
* Constructor.
*/
constructor() {
this._debug = false;
}
/**
* Add a log entry.
* @param {LoggerEntry} entry Log entry.
*/
add(entry) {
if (entry.feedbackMessage) {
addToast(entry.feedbackMessage);
}
}
}
+166
View File
@@ -0,0 +1,166 @@
// 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/>.
/**
* Element overlay methods.
*
* This module is used to create overlay information on components. For example
* to generate or destroy file drop-zones.
*
* @module core/local/reactive/overlay
* @copyright 2022 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Templates from 'core/templates';
import Prefetch from 'core/prefetch';
// Prefetch the overlay html.
const overlayTemplate = 'core/local/reactive/overlay';
Prefetch.prefetchTemplate(overlayTemplate);
/**
* @var {boolean} isInitialized if the module is capturing the proper page events.
*/
let isInitialized = false;
/**
* @var {Object} isInitialized if the module is capturing the proper page events.
*/
const selectors = {
OVERLAY: "[data-overlay]",
REPOSITION: "[data-overlay-dynamic]",
NAVBAR: "nav.navbar.fixed-top",
};
/**
* Adds an overlay to a specific page element.
*
* @param {Object} definition the overlay definition.
* @param {String|Promise} definition.content an optional overlay content.
* @param {String|Promise} definition.icon an optional icon content.
* @param {String} definition.classes an optional CSS classes
* @param {HTMLElement} parent the parent object
* @return {HTMLElement|undefined} the new page element.
*/
export const addOverlay = async(definition, parent) => {
// Validate non of the passed params is a promise.
if (definition.content && typeof definition.content !== 'string') {
definition.content = await definition.content;
}
if (definition.icon && typeof definition.icon !== 'string') {
definition.icon = await definition.icon;
}
const data = {
content: definition.content,
css: definition.classes ?? 'file-drop-zone',
};
const {html, js} = await Templates.renderForPromise(overlayTemplate, data);
Templates.appendNodeContents(parent, html, js);
const overlay = parent.querySelector(selectors.OVERLAY);
rePositionPreviewInfoElement(overlay);
init();
return overlay;
};
/**
* Adds an overlay to a specific page element.
*
* @param {HTMLElement} overlay the parent object
*/
export const removeOverlay = (overlay) => {
if (!overlay || !overlay.parentNode) {
return;
}
// Remove any forced parentNode position.
if (overlay.dataset?.overlayPosition) {
delete overlay.parentNode.style.position;
}
overlay.parentNode.removeChild(overlay);
};
export const removeAllOverlays = () => {
document.querySelectorAll(selectors.OVERLAY).forEach(
(overlay) => {
removeOverlay(overlay);
}
);
};
/**
* Re-position the preview information element by calculating the section position.
*
* @param {Object} overlay the overlay element.
*/
const rePositionPreviewInfoElement = function(overlay) {
if (!overlay) {
throw new Error('Inexistent overlay element');
}
// Add relative position to the parent object.
if (!overlay.parentNode?.style?.position) {
overlay.parentNode.style.position = 'relative';
overlay.dataset.overlayPosition = "true";
}
// Get the element to reposition.
const target = overlay.querySelector(selectors.REPOSITION);
if (!target) {
return;
}
// Get the new bounds.
const rect = overlay.getBoundingClientRect();
const sectionHeight = parseInt(window.getComputedStyle(overlay).height, 10);
const sectionOffset = rect.top;
const previewHeight = parseInt(window.getComputedStyle(target).height, 10) +
(2 * parseInt(window.getComputedStyle(target).padding, 10));
// Calculate the new target position.
let top, bottom;
if (sectionOffset < 0) {
if (sectionHeight + sectionOffset >= previewHeight) {
// We have enough space here, just stick the preview to the top.
let offSetTop = 0 - sectionOffset;
const navBar = document.querySelector(selectors.NAVBAR);
if (navBar) {
offSetTop = offSetTop + navBar.offsetHeight;
}
top = offSetTop + 'px';
bottom = 'unset';
} else {
// We do not have enough space here, just stick the preview to the bottom.
top = 'unset';
bottom = 0;
}
} else {
top = 0;
bottom = 'unset';
}
target.style.top = top;
target.style.bottom = bottom;
};
// Update overlays when the page scrolls.
const init = () => {
if (isInitialized) {
return;
}
// Add scroll events.
document.addEventListener('scroll', () => {
document.querySelectorAll(selectors.OVERLAY).forEach(
(overlay) => {
rePositionPreviewInfoElement(overlay);
}
);
}, true);
};
+406
View File
@@ -0,0 +1,406 @@
// 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 generic single state reactive module.
*
* @module core/local/reactive/reactive
* @class core/local/reactive/reactive
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import log from 'core/log';
import StateManager from 'core/local/reactive/statemanager';
import Pending from 'core/pending';
// Count the number of pending operations done to ensure we have a unique id for each one.
let pendingCount = 0;
/**
* Set up general reactive class to create a single state application with components.
*
* The reactive class is used for registering new UI components and manage the access to the state values
* and mutations.
*
* When a new reactive instance is created, it will contain an empty state and and empty mutations
* lists. When the state data is ready, the initial state can be loaded using the "setInitialState"
* method. This will protect the state from writing and will trigger all the components "stateReady"
* methods.
*
* State can only be altered by mutations. To replace all the mutations with a specific class,
* use "setMutations" method. If you need to just add some new mutation methods, use "addMutations".
*
* To register new components into a reactive instance, use "registerComponent".
*
* Inside a component, use "dispatch" to invoke a mutation on the state (components can only access
* the state in read only mode).
*/
export default class {
/**
* The component descriptor data structure.
*
* @typedef {object} description
* @property {string} eventName the custom event name used for state changed events
* @property {Function} eventDispatch the state update event dispatch function
* @property {Element} [target] the target of the event dispatch. If not passed a fake element will be created
* @property {Object} [mutations] an object with state mutations functions
* @property {Object} [state] an object to initialize the state.
*/
/**
* Create a basic reactive manager.
*
* Note that if your state is not async loaded, you can pass directly on creation by using the
* description.state attribute. However, this will initialize the state, this means
* setInitialState will throw an exception because the state is already defined.
*
* @param {description} description reactive manager description.
*/
constructor(description) {
if (description.eventName === undefined || description.eventDispatch === undefined) {
throw new Error(`Reactivity event required`);
}
if (description.name !== undefined) {
this.name = description.name;
}
// Each reactive instance has its own element anchor to propagate state changes internally.
// By default the module will create a fake DOM element to target custom events but
// if all reactive components is constrait to a single element, this can be passed as
// target in the description.
this.target = description.target ?? document.createTextNode(null);
this.eventName = description.eventName;
this.eventDispatch = description.eventDispatch;
// State manager is responsible for dispatch state change events when a mutation happens.
this.stateManager = new StateManager(this.eventDispatch, this.target);
// An internal registry of watchers and components.
this.watchers = new Map([]);
this.components = new Set([]);
// Mutations can be overridden later using setMutations method.
this.mutations = description.mutations ?? {};
// Register the event to alert watchers when specific state change happens.
this.target.addEventListener(this.eventName, this.callWatchersHandler.bind(this));
// Add a pending operation waiting for the initial state.
this.pendingState = new Pending(`core/reactive:registerInstance${pendingCount++}`);
// Set initial state if we already have it.
if (description.state !== undefined) {
this.setInitialState(description.state);
}
// Check if we have a debug instance to register the instance.
if (M.reactive !== undefined) {
M.reactive.registerNewInstance(this);
}
}
/**
* State changed listener.
*
* This function take any state change and send it to the proper watchers.
*
* To prevent internal state changes from colliding with other reactive instances, only the
* general "state changed" is triggered at document level. All the internal changes are
* triggered at private target level without bubbling. This way any reactive instance can alert
* only its own watchers.
*
* @param {CustomEvent} event
*/
callWatchersHandler(event) {
// Execute any registered component watchers.
this.target.dispatchEvent(new CustomEvent(event.detail.action, {
bubbles: false,
detail: event.detail,
}));
}
/**
* Set the initial state.
*
* @param {object} stateData the initial state data.
*/
setInitialState(stateData) {
this.pendingState.resolve();
this.stateManager.setInitialState(stateData);
}
/**
* Add individual functions to the mutations.
*
* Note new mutations will be added to the existing ones. To replace the full mutation
* object with a new one, use setMutations method.
*
* @method addMutations
* @param {Object} newFunctions an object with new mutation functions.
*/
addMutations(newFunctions) {
// Mutations can provide an init method to do some setup in the statemanager.
if (newFunctions.init !== undefined) {
newFunctions.init(this.stateManager);
}
// Save all mutations.
for (const [mutation, mutationFunction] of Object.entries(newFunctions)) {
this.mutations[mutation] = mutationFunction.bind(newFunctions);
}
}
/**
* Replace the current mutations with a new object.
*
* This method is designed to override the full mutations class, for example by extending
* the original one. To add some individual mutations, use addMutations instead.
*
* @param {object} manager the new mutations intance
*/
setMutations(manager) {
this.mutations = manager;
// Mutations can provide an init method to do some setup in the statemanager.
if (manager.init !== undefined) {
manager.init(this.stateManager);
}
}
/**
* Return the current state.
*
* @return {object}
*/
get state() {
return this.stateManager.state;
}
/**
* Get state data.
*
* Components access the state frequently. This convenience method is a shortcut to
* this.reactive.state.stateManager.get() method.
*
* @param {String} name the state object name
* @param {*} id an optional object id for state maps.
* @return {Object|undefined} the state object found
*/
get(name, id) {
return this.stateManager.get(name, id);
}
/**
* Return the initial state promise.
*
* Typically, components do not require to use this promise because registerComponent
* will trigger their stateReady method automatically. But it could be useful for complex
* components that require to combine state, template and string loadings.
*
* @method getState
* @return {Promise}
*/
getInitialStatePromise() {
return this.stateManager.getInitialPromise();
}
/**
* Register a new component.
*
* Component can provide some optional functions to the reactive module:
* - getWatchers: returns an array of watchers
* - stateReady: a method to call when the initial state is loaded
*
* It can also provide some optional attributes:
* - name: the component name (default value: "Unkown component") to customize debug messages.
*
* The method will also use dispatchRegistrationSuccess and dispatchRegistrationFail. Those
* are BaseComponent methods to inform parent components of the registration status.
* Components should not override those methods.
*
* @method registerComponent
* @param {object} component the new component
* @param {string} [component.name] the component name to display in warnings and errors.
* @param {Function} [component.dispatchRegistrationSuccess] method to notify registration success
* @param {Function} [component.dispatchRegistrationFail] method to notify registration fail
* @param {Function} [component.getWatchers] getter of the component watchers
* @param {Function} [component.stateReady] method to call when the state is ready
* @return {object} the registered component
*/
registerComponent(component) {
// Component name is an optional attribute to customize debug messages.
const componentName = component.name ?? 'Unkown component';
// Components can provide special methods to communicate registration to parent components.
let dispatchSuccess = () => {
return;
};
let dispatchFail = dispatchSuccess;
if (component.dispatchRegistrationSuccess !== undefined) {
dispatchSuccess = component.dispatchRegistrationSuccess.bind(component);
}
if (component.dispatchRegistrationFail !== undefined) {
dispatchFail = component.dispatchRegistrationFail.bind(component);
}
// Components can be registered only one time.
if (this.components.has(component)) {
dispatchSuccess();
return component;
}
// Components are fully registered only when the state ready promise is resolved.
const pendingPromise = new Pending(`core/reactive:registerComponent${pendingCount++}`);
// Keep track of the event listeners.
let listeners = [];
// Register watchers.
let handlers = [];
if (component.getWatchers !== undefined) {
handlers = component.getWatchers();
}
handlers.forEach(({watch, handler}) => {
if (watch === undefined) {
dispatchFail();
throw new Error(`Missing watch attribute in ${componentName} watcher`);
}
if (handler === undefined) {
dispatchFail();
throw new Error(`Missing handler for watcher ${watch} in ${componentName}`);
}
const listener = (event) => {
// Prevent any watcher from losing the page focus.
const currentFocus = document.activeElement;
// Execute watcher.
handler.apply(component, [event.detail]);
// Restore focus in case it is lost.
if (document.activeElement === document.body && document.body.contains(currentFocus)) {
currentFocus.focus();
}
};
// Save the listener information in case the component must be unregistered later.
listeners.push({target: this.target, watch, listener});
// The state manager triggers a general "state changed" event at a document level. However,
// for the internal watchers, each component can listen to specific state changed custom events
// in the target element. This way we can use the native event loop without colliding with other
// reactive instances.
this.target.addEventListener(watch, listener);
});
// Register state ready function. There's the possibility a component is registered after the initial state
// is loaded. For those cases we have a state promise to handle this specific state change.
if (component.stateReady !== undefined) {
this.getInitialStatePromise()
.then(state => {
component.stateReady(state);
pendingPromise.resolve();
return true;
})
.catch(reason => {
pendingPromise.resolve();
log.error(`Initial state in ${componentName} rejected due to: ${reason}`);
log.error(reason);
});
}
// Save unregister data.
this.watchers.set(component, listeners);
this.components.add(component);
// Dispatch an event to communicate the registration to the debug module.
this.target.dispatchEvent(new CustomEvent('registerComponent:success', {
bubbles: false,
detail: {component},
}));
dispatchSuccess();
return component;
}
/**
* Unregister a component and its watchers.
*
* @param {object} component the object instance to unregister
* @returns {object} the deleted component
*/
unregisterComponent(component) {
if (!this.components.has(component)) {
return component;
}
this.components.delete(component);
// Remove event listeners.
const listeners = this.watchers.get(component);
if (listeners === undefined) {
return component;
}
listeners.forEach(({target, watch, listener}) => {
target.removeEventListener(watch, listener);
});
this.watchers.delete(component);
return component;
}
/**
* Dispatch a change in the state.
*
* This method is the only way for components to alter the state. Watchers will receive a
* read only state to prevent illegal changes. If some user action require a state change, the
* component should dispatch a mutation to trigger all the necessary logic to alter the state.
*
* @method dispatch
* @param {string} actionName the action name (usually the mutation name)
* @param {mixed} params any number of params the mutation needs.
*/
async dispatch(actionName, ...params) {
if (typeof actionName !== 'string') {
throw new Error(`Dispatch action name must be a string`);
}
// JS does not have private methods yet. However, we prevent any component from calling
// a method starting with "_" because the most accepted convention for private methods.
if (actionName.charAt(0) === '_') {
throw new Error(`Illegal Private ${actionName} mutation method dispatch`);
}
if (this.mutations[actionName] === undefined) {
throw new Error(`Unkown ${actionName} mutation`);
}
const pendingPromise = new Pending(`core/reactive:${actionName}${pendingCount++}`);
const mutationFunction = this.mutations[actionName];
try {
await mutationFunction.apply(this.mutations, [this.stateManager, ...params]);
pendingPromise.resolve();
} catch (error) {
// Ensure the state is locked.
this.stateManager.setReadOnly(true);
pendingPromise.resolve();
throw error;
}
}
}
+75
View File
@@ -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/>.
/**
* Screen reader-only (sr-only) reactive mutations logger class.
*
* This logger can be used by the StateManager to log mutation feedbacks and actions.
* The feedback messages logged by this logger will be rendered in a sr-only, ARIA live region.
*
* @module core/local/reactive/srlogger
* @class SRLogger
* @copyright 2023 Jun Pataleta <jun@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Logger from 'core/local/reactive/logger';
/**
* Logger entry structure.
*
* @typedef {object} LoggerEntry
* @property {string} feedbackMessage Feedback message.
*/
/**
* Screen reader-only (sr-only) reactive mutations logger class.
*
* @class SRLogger
*/
export default class SRLogger extends Logger {
/**
* The element ID of the ARIA live region where the logger feedback will be rendered.
*
* @type {string}
*/
static liveRegionId = 'sr-logger-feedback-container';
/**
* Add a log entry.
* @param {LoggerEntry} entry Log entry.
*/
add(entry) {
if (entry.feedbackMessage) {
// Fetch or create an ARIA live region that will serve as the container for the logger feedback.
let loggerFeedback = document.getElementById(SRLogger.liveRegionId);
if (!loggerFeedback) {
loggerFeedback = document.createElement('div');
loggerFeedback.id = SRLogger.liveRegionId;
loggerFeedback.classList.add('sr-only');
loggerFeedback.setAttribute('aria-live', 'polite');
document.body.append(loggerFeedback);
}
// Set the ARIA live region's contents with the feedback.
loggerFeedback.innerHTML = entry.feedbackMessage;
// Clear the feedback message after 4 seconds to avoid the contents from being read out in case the user navigates
// to this region. This is similar to the default timeout of toast messages before disappearing from view.
setTimeout(() => {
loggerFeedback.innerHTML = '';
}, 4000);
}
}
}
+925
View File
@@ -0,0 +1,925 @@
// 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/>.
/**
* Reactive simple state manager.
*
* The state manager contains the state data, trigger update events and
* can lock and unlock the state data.
*
* This file contains the three main elements of the state manager:
* - State manager: the public class to alter the state, dispatch events and process update messages.
* - Proxy handler: a private class to keep track of the state object changes.
* - StateMap class: a private class extending Map class that triggers event when a state list is modifed.
*
* @module core/local/reactive/statemanager
* @class StateManager
* @copyright 2021 Ferran Recio <ferran@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Logger from 'core/local/reactive/logger';
/**
* State manager class.
*
* This class handle the reactive state and ensure only valid mutations can modify the state.
* It also provide methods to apply batch state update messages (see processUpdates function doc
* for more details on update messages).
*
* Implementing a deep state manager is complex and will require many frontend resources. To keep
* the state fast and simple, the state can ONLY store two kind of data:
* - Object with attributes
* - Sets of objects with id attributes.
*
* This is an example of a valid state:
*
* {
* course: {
* name: 'course name',
* shortname: 'courseshort',
* sectionlist: [21, 34]
* },
* sections: [
* {id: 21, name: 'Topic 1', visible: true},
* {id: 34, name: 'Topic 2', visible: false,
* ],
* }
*
* The following cases are NOT allowed at a state ROOT level (throws an exception if they are assigned):
* - Simple values (strings, boolean...).
* - Arrays of simple values.
* - Array of objects without ID attribute (all arrays will be converted to maps and requires an ID).
*
* Thanks to those limitations it can simplify the state update messages and the event names. If You
* need to store simple data, just group them in an object.
*
* To grant any state change triggers the proper events, the class uses two private structures:
* - proxy handler: any object stored in the state is proxied using this class.
* - StateMap class: any object set in the state will be converted to StateMap using the
* objects id attribute.
*/
export default class StateManager {
/**
* Create a basic reactive state store.
*
* The state manager is meant to work with native JS events. To ensure each reactive module can use
* it in its own way, the parent element must provide a valid event dispatcher function and an optional
* DOM element to anchor the event.
*
* @param {function} dispatchEvent the function to dispatch the custom event when the state changes.
* @param {element} target the state changed custom event target (document if none provided)
*/
constructor(dispatchEvent, target) {
// The dispatch event function.
/** @package */
this.dispatchEvent = dispatchEvent;
// The DOM container to trigger events.
/** @package */
this.target = target ?? document;
// State can be altered freely until initial state is set.
/** @package */
this.readonly = false;
// List of state changes pending to be published as events.
/** @package */
this.eventsToPublish = [];
// The update state types functions.
/** @package */
this.updateTypes = {
"create": this.defaultCreate.bind(this),
"update": this.defaultUpdate.bind(this),
"delete": this.defaultDelete.bind(this),
"put": this.defaultPut.bind(this),
"override": this.defaultOverride.bind(this),
"remove": this.defaultRemove.bind(this),
"prepareFields": this.defaultPrepareFields.bind(this),
};
// The state_loaded event is special because it only happens one but all components
// may react to that state, even if they are registered after the setIinitialState.
// For these reason we use a promise for that event.
this.initialPromise = new Promise((resolve) => {
const initialStateDone = (event) => {
resolve(event.detail.state);
};
this.target.addEventListener('state:loaded', initialStateDone);
});
this.logger = new Logger();
}
/**
* Loads the initial state.
*
* Note this method will trigger a state changed event with "state:loaded" actionname.
*
* The state mode will be set to read only when the initial state is loaded.
*
* @param {object} initialState
*/
setInitialState(initialState) {
if (this.state !== undefined) {
throw Error('Initial state can only be initialized ones');
}
// Create the state object.
const state = new Proxy({}, new Handler('state', this, true));
for (const [prop, propValue] of Object.entries(initialState)) {
state[prop] = propValue;
}
this.state = state;
// When the state is loaded we can lock it to prevent illegal changes.
this.readonly = true;
this.dispatchEvent({
action: 'state:loaded',
state: this.state,
}, this.target);
}
/**
* Generate a promise that will be resolved when the initial state is loaded.
*
* In most cases the final state will be loaded using an ajax call. This is the reason
* why states manager are created unlocked and won't be reactive until the initial state is set.
*
* @return {Promise} the resulting promise
*/
getInitialPromise() {
return this.initialPromise;
}
/**
* Locks or unlocks the state to prevent illegal updates.
*
* Mutations use this method to modify the state. Once the state is updated, they must
* block again the state.
*
* All changes done while the state is writable will be registered using registerStateAction.
* When the state is set again to read only the method will trigger _publishEvents to communicate
* changes to all watchers.
*
* @param {bool} readonly if the state is in read only mode enabled
*/
setReadOnly(readonly) {
this.readonly = readonly;
let mode = 'off';
// When the state is in readonly again is time to publish all pending events.
if (this.readonly) {
mode = 'on';
this._publishEvents();
}
// Dispatch a read only event.
this.dispatchEvent({
action: `readmode:${mode}`,
state: this.state,
element: null,
}, this.target);
}
/**
* Add methods to process update state messages.
*
* The state manager provide a default update, create and delete methods. However,
* some applications may require to override the default methods or even add new ones
* like "refresh" or "error".
*
* @param {Object} newFunctions the new update types functions.
*/
addUpdateTypes(newFunctions) {
for (const [updateType, updateFunction] of Object.entries(newFunctions)) {
if (typeof updateFunction === 'function') {
this.updateTypes[updateType] = updateFunction.bind(newFunctions);
}
}
}
/**
* Process a state updates array and do all the necessary changes.
*
* Note this method unlocks the state while it is executing and relocks it
* when finishes.
*
* @param {array} updates
* @param {Object} updateTypes optional functions to override the default update types.
*/
processUpdates(updates, updateTypes) {
if (!Array.isArray(updates)) {
throw Error('State updates must be an array');
}
this.setReadOnly(false);
updates.forEach((update) => {
if (update.name === undefined) {
throw Error('Missing state update name');
}
this.processUpdate(
update.name,
update.action,
update.fields,
updateTypes
);
});
this.setReadOnly(true);
}
/**
* Process a single state update.
*
* Note this method will not lock or unlock the state by itself.
*
* @param {string} updateName the state element to update
* @param {string} action to action to perform
* @param {object} fields the new data
* @param {Object} updateTypes optional functions to override the default update types.
*/
processUpdate(updateName, action, fields, updateTypes) {
if (!fields) {
throw Error('Missing state update fields');
}
if (updateTypes === undefined) {
updateTypes = {};
}
action = action ?? 'update';
const method = updateTypes[action] ?? this.updateTypes[action];
if (method === undefined) {
throw Error(`Unkown update action ${action}`);
}
// Some state data may require some cooking before sending to the
// state. Reactive instances can overrdide the default fieldDefaults
// method to add extra logic to all updates.
const prepareFields = updateTypes.prepareFields ?? this.updateTypes.prepareFields;
method(this, updateName, prepareFields(this, updateName, fields));
}
/**
* Prepare fields for processing.
*
* This method is used to add default values or calculations from the frontend side.
*
* @param {Object} stateManager the state manager
* @param {String} updateName the state element to update
* @param {Object} fields the new data
* @returns {Object} final fields data
*/
defaultPrepareFields(stateManager, updateName, fields) {
return fields;
}
/**
* Process a create state message.
*
* @param {Object} stateManager the state manager
* @param {String} updateName the state element to update
* @param {Object} fields the new data
*/
defaultCreate(stateManager, updateName, fields) {
let state = stateManager.state;
// Create can be applied only to lists, not to objects.
if (state[updateName] instanceof StateMap) {
state[updateName].add(fields);
return;
}
state[updateName] = fields;
}
/**
* Process a delete state message.
*
* @param {Object} stateManager the state manager
* @param {String} updateName the state element to update
* @param {Object} fields the new data
*/
defaultDelete(stateManager, updateName, fields) {
// Get the current value.
let current = stateManager.get(updateName, fields.id);
if (!current) {
throw Error(`Inexistent ${updateName} ${fields.id}`);
}
// Process deletion.
let state = stateManager.state;
if (state[updateName] instanceof StateMap) {
state[updateName].delete(fields.id);
return;
}
delete state[updateName];
}
/**
* Process a remove state message.
*
* @param {Object} stateManager the state manager
* @param {String} updateName the state element to update
* @param {Object} fields the new data
*/
defaultRemove(stateManager, updateName, fields) {
// Get the current value.
let current = stateManager.get(updateName, fields.id);
if (!current) {
return;
}
// Process deletion.
let state = stateManager.state;
if (state[updateName] instanceof StateMap) {
state[updateName].delete(fields.id);
return;
}
delete state[updateName];
}
/**
* Process a update state message.
*
* @param {Object} stateManager the state manager
* @param {String} updateName the state element to update
* @param {Object} fields the new data
*/
defaultUpdate(stateManager, updateName, fields) {
// Get the current value.
let current = stateManager.get(updateName, fields.id);
if (!current) {
throw Error(`Inexistent ${updateName} ${fields.id}`);
}
// Execute updates.
for (const [fieldName, fieldValue] of Object.entries(fields)) {
current[fieldName] = fieldValue;
}
}
/**
* Process a put state message.
*
* @param {Object} stateManager the state manager
* @param {String} updateName the state element to update
* @param {Object} fields the new data
*/
defaultPut(stateManager, updateName, fields) {
// Get the current value.
let current = stateManager.get(updateName, fields.id);
if (current) {
// Update attributes.
for (const [fieldName, fieldValue] of Object.entries(fields)) {
current[fieldName] = fieldValue;
}
} else {
// Create new object.
let state = stateManager.state;
if (state[updateName] instanceof StateMap) {
state[updateName].add(fields);
return;
}
state[updateName] = fields;
}
}
/**
* Process an override state message.
*
* @param {Object} stateManager the state manager
* @param {String} updateName the state element to update
* @param {Object} fields the new data
*/
defaultOverride(stateManager, updateName, fields) {
// Get the current value.
let current = stateManager.get(updateName, fields.id);
if (current) {
// Remove any unnecessary fields.
for (const [fieldName] of Object.entries(current)) {
if (fields[fieldName] === undefined) {
delete current[fieldName];
}
}
// Update field.
for (const [fieldName, fieldValue] of Object.entries(fields)) {
current[fieldName] = fieldValue;
}
} else {
// Create the element if not exists.
let state = stateManager.state;
if (state[updateName] instanceof StateMap) {
state[updateName].add(fields);
return;
}
state[updateName] = fields;
}
}
/**
* Set the logger class instance.
*
* Reactive instances can provide alternative loggers to provide advanced logging.
* @param {Logger} logger
*/
setLogger(logger) {
this.logger = logger;
}
/**
* Add a new log entry into the reactive logger.
* @param {LoggerEntry} entry
*/
addLoggerEntry(entry) {
this.logger.add(entry);
}
/**
* Get an element from the state or form an alternative state object.
*
* The altstate param is used by external update functions that gets the current
* state as param.
*
* @param {String} name the state object name
* @param {*} id and object id for state maps.
* @return {Object|undefined} the state object found
*/
get(name, id) {
const state = this.state;
let current = state[name];
if (current instanceof StateMap) {
if (id === undefined) {
throw Error(`Missing id for ${name} state update`);
}
current = state[name].get(id);
}
return current;
}
/**
* Get all element ids from the given state.
*
* @param {String} name the state object name
* @return {Array} the element ids.
*/
getIds(name) {
const state = this.state;
const current = state[name];
if (!(current instanceof StateMap)) {
throw Error(`${name} is not an instance of StateMap`);
}
return [...state[name].keys()];
}
/**
* Register a state modification and generate the necessary events.
*
* This method is used mainly by proxy helpers to dispatch state change event.
* However, mutations can use it to inform components about non reactive changes
* in the state (only the two first levels of the state are reactive).
*
* Each action can produce several events:
* - The specific attribute updated, created or deleter (example: "cm.visible:updated")
* - The general state object updated, created or deleted (example: "cm:updated")
* - If the element has an ID attribute, the specific event with id (example: "cm[42].visible:updated")
* - If the element has an ID attribute, the general event with id (example: "cm[42]:updated")
* - A generic state update event "state:update"
*
* @param {string} field the affected state field name
* @param {string|null} prop the affecter field property (null if affect the full object)
* @param {string} action the action done (created/updated/deleted)
* @param {*} data the affected data
*/
registerStateAction(field, prop, action, data) {
let parentAction = 'updated';
if (prop !== null) {
this.eventsToPublish.push({
eventName: `${field}.${prop}:${action}`,
eventData: data,
action,
});
} else {
parentAction = action;
}
// Trigger extra events if the element has an ID attribute.
if (data.id !== undefined) {
if (prop !== null) {
this.eventsToPublish.push({
eventName: `${field}[${data.id}].${prop}:${action}`,
eventData: data,
action,
});
}
this.eventsToPublish.push({
eventName: `${field}[${data.id}]:${parentAction}`,
eventData: data,
action: parentAction,
});
}
// Register the general change.
this.eventsToPublish.push({
eventName: `${field}:${parentAction}`,
eventData: data,
action: parentAction,
});
// Register state updated event.
this.eventsToPublish.push({
eventName: `state:updated`,
eventData: data,
action: 'updated',
});
}
/**
* Internal method to publish events.
*
* This is a private method, it will be invoked when the state is set back to read only mode.
*/
_publishEvents() {
const fieldChanges = this.eventsToPublish;
this.eventsToPublish = [];
// Dispatch a transaction start event.
this.dispatchEvent({
action: 'transaction:start',
state: this.state,
element: null,
changes: fieldChanges,
}, this.target);
// State changes can be registered in any order. However it will avoid many
// components errors if they are sorted to have creations-updates-deletes in case
// some component needs to create or destroy DOM elements before updating them.
fieldChanges.sort((a, b) => {
const weights = {
created: 0,
updated: 1,
deleted: 2,
};
const aweight = weights[a.action] ?? 0;
const bweight = weights[b.action] ?? 0;
// In case both have the same weight, the eventName length decide.
if (aweight === bweight) {
return a.eventName.length - b.eventName.length;
}
return aweight - bweight;
});
// List of the published events to prevent redundancies.
let publishedEvents = new Set();
fieldChanges.forEach((event) => {
const eventkey = `${event.eventName}.${event.eventData.id ?? 0}`;
if (!publishedEvents.has(eventkey)) {
this.dispatchEvent({
action: event.eventName,
state: this.state,
element: event.eventData
}, this.target);
publishedEvents.add(eventkey);
}
});
// Dispatch a transaction end event.
this.dispatchEvent({
action: 'transaction:end',
state: this.state,
element: null,
}, this.target);
}
}
// Proxy helpers.
/**
* The proxy handler.
*
* This class will inform any value change directly to the state manager.
*
* The proxied variable will throw an error if it is altered when the state manager is
* in read only mode.
*/
class Handler {
/**
* Class constructor.
*
* @param {string} name the variable name used for identify triggered actions
* @param {StateManager} stateManager the state manager object
* @param {boolean} proxyValues if new values must be proxied (used only at state root level)
*/
constructor(name, stateManager, proxyValues) {
this.name = name;
this.stateManager = stateManager;
this.proxyValues = proxyValues ?? false;
}
/**
* Set trap to trigger events when the state changes.
*
* @param {object} obj the source object (not proxied)
* @param {string} prop the attribute to set
* @param {*} value the value to save
* @param {*} receiver the proxied element to be attached to events
* @returns {boolean} if the value is set
*/
set(obj, prop, value, receiver) {
// Only mutations should be able to set state values.
if (this.stateManager.readonly) {
throw new Error(`State locked. Use mutations to change ${prop} value in ${this.name}.`);
}
// Check any data change.
if (JSON.stringify(obj[prop]) === JSON.stringify(value)) {
return true;
}
const action = (obj[prop] !== undefined) ? 'updated' : 'created';
// Proxy value if necessary (used at state root level).
if (this.proxyValues) {
if (Array.isArray(value)) {
obj[prop] = new StateMap(prop, this.stateManager).loadValues(value);
} else {
obj[prop] = new Proxy(value, new Handler(prop, this.stateManager));
}
} else {
obj[prop] = value;
}
// If the state is not ready yet means the initial state is not yet loaded.
if (this.stateManager.state === undefined) {
return true;
}
this.stateManager.registerStateAction(this.name, prop, action, receiver);
return true;
}
/**
* Delete property trap to trigger state change events.
*
* @param {*} obj the affected object (not proxied)
* @param {*} prop the prop to delete
* @returns {boolean} if prop is deleted
*/
deleteProperty(obj, prop) {
// Only mutations should be able to set state values.
if (this.stateManager.readonly) {
throw new Error(`State locked. Use mutations to delete ${prop} in ${this.name}.`);
}
if (prop in obj) {
delete obj[prop];
this.stateManager.registerStateAction(this.name, prop, 'deleted', obj);
}
return true;
}
}
/**
* Class to add events dispatching to the JS Map class.
*
* When the state has a list of objects (with IDs) it will be converted into a StateMap.
* StateMap is used almost in the same way as a regular JS map. Because all elements have an
* id attribute, it has some specific methods:
* - add: a convenient method to add an element without specifying the key ("id" attribute will be used as a key).
* - loadValues: to add many elements at once wihout specifying keys ("id" attribute will be used).
*
* Apart, the main difference between regular Map and MapState is that this one will inform any change to the
* state manager.
*/
class StateMap extends Map {
/**
* Create a reactive Map.
*
* @param {string} name the property name
* @param {StateManager} stateManager the state manager
* @param {iterable} iterable an iterable object to create the Map
*/
constructor(name, stateManager, iterable) {
// We don't have any "this" until be call super.
super(iterable);
this.name = name;
this.stateManager = stateManager;
}
/**
* Set an element into the map.
*
* Each value needs it's own id attribute. Objects without id will be rejected.
* The function will throw an error if the value id and the key are not the same.
*
* @param {*} key the key to store
* @param {*} value the value to store
* @returns {Map} the resulting Map object
*/
set(key, value) {
// Only mutations should be able to set state values.
if (this.stateManager.readonly) {
throw new Error(`State locked. Use mutations to change ${key} value in ${this.name}.`);
}
// Normalize keys as string to prevent json decoding errors.
key = this.normalizeKey(key);
this.checkValue(value);
if (key === undefined || key === null) {
throw Error('State lists keys cannot be null or undefined');
}
// ID is mandatory and should be the same as the key.
if (this.normalizeKey(value.id) !== key) {
throw new Error(`State error: ${this.name} list element ID (${value.id}) and key (${key}) mismatch`);
}
const action = (super.has(key)) ? 'updated' : 'created';
// Save proxied data into the list.
const result = super.set(key, new Proxy(value, new Handler(this.name, this.stateManager)));
// If the state is not ready yet means the initial state is not yet loaded.
if (this.stateManager.state === undefined) {
return result;
}
this.stateManager.registerStateAction(this.name, null, action, super.get(key));
return result;
}
/**
* Check if a value is valid to be stored in a a State List.
*
* Only objects with id attribute can be stored in State lists.
*
* This method throws an error if the value is not valid.
*
* @param {object} value (with ID)
*/
checkValue(value) {
if (!typeof value === 'object' && value !== null) {
throw Error('State lists can contain objects only');
}
if (value.id === undefined) {
throw Error('State lists elements must contain at least an id attribute');
}
}
/**
* Return a normalized key value for state map.
*
* Regular maps uses strict key comparissons but state maps are indexed by ID.JSON conversions
* and webservices sometimes do unexpected types conversions so we convert any integer key to string.
*
* @param {*} key the provided key
* @returns {string}
*/
normalizeKey(key) {
return String(key).valueOf();
}
/**
* Insert a new element int a list.
*
* Each value needs it's own id attribute. Objects withouts id will be rejected.
*
* @param {object} value the value to add (needs an id attribute)
* @returns {Map} the resulting Map object
*/
add(value) {
this.checkValue(value);
return this.set(value.id, value);
}
/**
* Return a state map element.
*
* @param {*} key the element id
* @return {Object}
*/
get(key) {
return super.get(this.normalizeKey(key));
}
/**
* Check whether an element with the specified key exists or not.
*
* @param {*} key the key to find
* @return {boolean}
*/
has(key) {
return super.has(this.normalizeKey(key));
}
/**
* Delete an element from the map.
*
* @param {*} key
* @returns {boolean}
*/
delete(key) {
// State maps uses only string keys to avoid strict comparisons.
key = this.normalizeKey(key);
// Only mutations should be able to set state values.
if (this.stateManager.readonly) {
throw new Error(`State locked. Use mutations to change ${key} value in ${this.name}.`);
}
const previous = super.get(key);
const result = super.delete(key);
if (!result) {
return result;
}
this.stateManager.registerStateAction(this.name, null, 'deleted', previous);
return result;
}
/**
* Return a suitable structure for JSON conversion.
*
* This function is needed because new values are compared in JSON. StateMap has Private
* attributes which cannot be stringified (like this.stateManager which will produce an
* infinite recursivity).
*
* @returns {array}
*/
toJSON() {
let result = [];
this.forEach((value) => {
result.push(value);
});
return result;
}
/**
* Insert a full list of values using the id attributes as keys.
*
* This method is used mainly to initialize the list. Note each element is indexed by its "id" attribute.
* This is a basic restriction of StateMap. All elements need an id attribute, otherwise it won't be saved.
*
* @param {iterable} values the values to load
* @returns {StateMap} return the this value
*/
loadValues(values) {
values.forEach((data) => {
this.checkValue(data);
let key = data.id;
let newvalue = new Proxy(data, new Handler(this.name, this.stateManager));
this.set(key, newvalue);
});
return this;
}
}
@@ -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/>.
/**
* Module to handle dynamic tabs AJAX requests
*
* @module core/local/repository/dynamic_tabs
* @copyright 2021 David Matamoros <davidmc@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Ajax from 'core/ajax';
/**
* Return tab content
*
* @param {String} tab
* @param {String} jsondata
* @return {Promise}
*/
export const getContent = (tab, jsondata) => {
const request = {
methodname: 'core_dynamic_tabs_get_content',
args: {tab: tab, jsondata: jsondata}
};
return Ajax.call([request])[0];
};
+489
View File
@@ -0,0 +1,489 @@
// 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/>.
import $ from 'jquery';
import ajax from 'core/ajax';
import * as str from 'core/str';
import * as config from 'core/config';
import mustache from 'core/mustache';
import storage from 'core/localstorage';
import {getNormalisedComponent} from 'core/utils';
/**
* Template this.
*
* @module core/local/templates/loader
* @copyright 2023 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.3
*/
export default class Loader {
/** @var {String} themeName for the current render */
currentThemeName = '';
/** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */
static loadTemplateBuffer = [];
/** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
static isLoadingTemplates = false;
/** @var {Map} templateCache - Cache of already loaded template strings */
static templateCache = new Map();
/** @var {Promise[]} templatePromises - Cache of already loaded template promises */
static templatePromises = {};
/** @var {Promise[]} cachePartialPromises - Cache of already loaded template partial promises */
static cachePartialPromises = [];
/**
* A helper to get the search key
*
* @param {string} theme
* @param {string} templateName
* @returns {string}
*/
static getSearchKey(theme, templateName) {
return `${theme}/${templateName}`;
}
/**
* Load a template.
*
* @method getTemplate
* @param {string} templateName - should consist of the component and the name of the template like this:
* core/menu (lib/templates/menu.mustache) or
* tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
* @param {string} [themeName=config.theme] - The theme to load the template from
* @return {Promise} JQuery promise object resolved when the template has been fetched.
*/
static getTemplate(templateName, themeName = config.theme) {
const searchKey = this.getSearchKey(themeName, templateName);
// If we haven't already seen this template then buffer it.
const cachedPromise = this.getTemplatePromiseFromCache(searchKey);
if (cachedPromise) {
return cachedPromise;
}
// Check the buffer to see if this template has already been added.
const existingBufferRecords = this.loadTemplateBuffer.filter((record) => record.searchKey === searchKey);
if (existingBufferRecords.length) {
// This template is already in the buffer so just return the existing
// promise. No need to add it to the buffer again.
return existingBufferRecords[0].deferred.promise();
}
// This is the first time this has been requested so let's add it to the buffer
// to be loaded.
const parts = templateName.split('/');
const component = getNormalisedComponent(parts.shift());
const name = parts.join('/');
const deferred = $.Deferred();
// Add this template to the buffer to be loaded.
this.loadTemplateBuffer.push({
component,
name,
theme: themeName,
searchKey,
deferred,
});
// We know there is at least one thing in the buffer so kick off a processing run.
this.processLoadTemplateBuffer();
return deferred.promise();
}
/**
* Store a template in the cache.
*
* @param {string} searchKey
* @param {string} templateSource
*/
static setTemplateInCache(searchKey, templateSource) {
// Cache all of the dependent templates because we'll need them to render
// the requested template.
this.templateCache.set(searchKey, templateSource);
}
/**
* Fetch a template from the cache.
*
* @param {string} searchKey
* @returns {string}
*/
static getTemplateFromCache(searchKey) {
return this.templateCache.get(searchKey);
}
/**
* Check whether a template is in the cache.
*
* @param {string} searchKey
* @returns {bool}
*/
static hasTemplateInCache(searchKey) {
return this.templateCache.has(searchKey);
}
/**
* Prefetch a set of templates without rendering them.
*
* @param {Array} templateNames The list of templates to fetch
* @param {string} themeName
*/
static prefetchTemplates(templateNames, themeName) {
templateNames.forEach((templateName) => this.prefetchTemplate(templateName, themeName));
}
/**
* Prefetech a sginle template without rendering it.
*
* @param {string} templateName
* @param {string} themeName
*/
static prefetchTemplate(templateName, themeName) {
const searchKey = this.getSearchKey(themeName, templateName);
// If we haven't already seen this template then buffer it.
if (this.hasTemplateInCache(searchKey)) {
return;
}
// Check the buffer to see if this template has already been added.
const existingBufferRecords = this.loadTemplateBuffer.filter((record) => record.searchKey === searchKey);
if (existingBufferRecords.length) {
// This template is already in the buffer so just return the existing promise.
// No need to add it to the buffer again.
return;
}
// This is the first time this has been requested so let's add it to the buffer to be loaded.
const parts = templateName.split('/');
const component = getNormalisedComponent(parts.shift());
const name = parts.join('/');
// Add this template to the buffer to be loaded.
this.loadTemplateBuffer.push({
component,
name,
theme: themeName,
searchKey,
deferred: $.Deferred(),
});
this.processLoadTemplateBuffer();
}
/**
* Load a partial from the cache or ajax.
*
* @method partialHelper
* @param {string} name The partial name to load.
* @param {string} [themeName = config.theme] The theme to load the partial from.
* @return {string}
*/
static partialHelper(name, themeName = config.theme) {
const searchKey = this.getSearchKey(themeName, name);
if (!this.hasTemplateInCache(searchKey)) {
new Error(`Failed to pre-fetch the template: ${name}`);
}
return this.getTemplateFromCache(searchKey);
}
/**
* Scan a template source for partial tags and return a list of the found partials.
*
* @method scanForPartials
* @param {string} templateSource - source template to scan.
* @return {Array} List of partials.
*/
static scanForPartials(templateSource) {
const tokens = mustache.parse(templateSource);
const partials = [];
const findPartial = (tokens, partials) => {
let i;
for (i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token[0] == '>' || token[0] == '<') {
partials.push(token[1]);
}
if (token.length > 4) {
findPartial(token[4], partials);
}
}
};
findPartial(tokens, partials);
return partials;
}
/**
* Load a template and scan it for partials. Recursively fetch the partials.
*
* @method cachePartials
* @param {string} templateName - should consist of the component and the name of the template like this:
* core/menu (lib/templates/menu.mustache) or
* tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
* @param {string} [themeName=config.theme]
* @param {Array} parentage - A list of requested partials in this render chain.
* @return {Promise} JQuery promise object resolved when all partials are in the cache.
*/
static cachePartials(templateName, themeName = config.theme, parentage = []) {
const searchKey = this.getSearchKey(themeName, templateName);
if (searchKey in this.cachePartialPromises) {
return this.cachePartialPromises[searchKey];
}
// This promise will not be resolved until all child partials are also resolved and ready.
// We create it here to allow us to check for recursive inclusion of templates.
// Keep track of the requested partials in this chain.
if (!parentage.length) {
parentage.push(searchKey);
}
this.cachePartialPromises[searchKey] = $.Deferred();
this._cachePartials(templateName, themeName, parentage).catch((error) => {
this.cachePartialPromises[searchKey].reject(error);
});
return this.cachePartialPromises[searchKey];
}
/**
* Cache the template partials for the specified template.
*
* @param {string} templateName
* @param {string} themeName
* @param {array} parentage
* @returns {promise<string>}
*/
static async _cachePartials(templateName, themeName, parentage) {
const searchKey = this.getSearchKey(themeName, templateName);
const templateSource = await this.getTemplate(templateName, themeName);
const partials = this.scanForPartials(templateSource);
const uniquePartials = partials.filter((partialName) => {
// Check for recursion.
if (parentage.indexOf(`${themeName}/${partialName}`) >= 0) {
// Ignore templates which include a parent template already requested in the current chain.
return false;
}
// Ignore templates that include themselves.
return partialName !== templateName;
});
// Fetch any partial which has not already been fetched.
const fetchThemAll = uniquePartials.map((partialName) => {
parentage.push(`${themeName}/${partialName}`);
return this.cachePartials(partialName, themeName, parentage);
});
await Promise.all(fetchThemAll);
return this.cachePartialPromises[searchKey].resolve(templateSource);
}
/**
* Take all of the templates waiting in the buffer and load them from the server
* or from the cache.
*
* All of the templates that need to be loaded from the server will be batched up
* and sent in a single network request.
*/
static processLoadTemplateBuffer() {
if (!this.loadTemplateBuffer.length) {
return;
}
if (this.isLoadingTemplates) {
return;
}
this.isLoadingTemplates = true;
// Grab any templates waiting in the buffer.
const templatesToLoad = this.loadTemplateBuffer.slice();
// This will be resolved with the list of promises for the server request.
const serverRequestsDeferred = $.Deferred();
const requests = [];
// Get a list of promises for each of the templates we need to load.
const templatePromises = templatesToLoad.map((templateData) => {
const component = getNormalisedComponent(templateData.component);
const name = templateData.name;
const searchKey = templateData.searchKey;
const theme = templateData.theme;
const templateDeferred = templateData.deferred;
let promise = null;
// Double check to see if this template happened to have landed in the
// cache as a dependency of an earlier template.
if (this.hasTemplateInCache(searchKey)) {
// We've seen this template so immediately resolve the existing promise.
promise = this.getTemplatePromiseFromCache(searchKey);
} else {
// We haven't seen this template yet so we need to request it from
// the server.
requests.push({
methodname: 'core_output_load_template_with_dependencies',
args: {
component,
template: name,
themename: theme,
lang: config.language,
}
});
// Remember the index in the requests list for this template so that
// we can get the appropriate promise back.
const index = requests.length - 1;
// The server deferred will be resolved with a list of all of the promises
// that were sent in the order that they were added to the requests array.
promise = serverRequestsDeferred.promise()
.then((promises) => {
// The promise for this template will be the one that matches the index
// for it's entry in the requests array.
//
// Make sure the promise is added to the promises cache for this template
// search key so that we don't request it again.
templatePromises[searchKey] = promises[index].then((response) => {
// Process all of the template dependencies for this template and add
// them to the caches so that we don't request them again later.
response.templates.forEach((data) => {
data.component = getNormalisedComponent(data.component);
const tempSearchKey = this.getSearchKey(
theme,
[data.component, data.name].join('/'),
);
// Cache all of the dependent templates because we'll need them to render
// the requested template.
this.setTemplateInCache(tempSearchKey, data.value);
if (config.templaterev > 0) {
// The template cache is enabled - set the value there.
storage.set(`core_template/${config.templaterev}:${tempSearchKey}`, data.value);
}
});
if (response.strings.length) {
// If we have strings that the template needs then warm the string cache
// with them now so that we don't need to re-fetch them.
str.cache_strings(response.strings.map(({component, name, value}) => ({
component: getNormalisedComponent(component),
key: name,
value,
})));
}
// Return the original template source that the user requested.
if (this.hasTemplateInCache(searchKey)) {
return this.getTemplateFromCache(searchKey);
}
return null;
});
return templatePromises[searchKey];
});
}
return promise
// When we've successfully loaded the template then resolve the deferred
// in the buffer so that all of the calling code can proceed.
.then((source) => templateDeferred.resolve(source))
.catch((error) => {
// If there was an error loading the template then reject the deferred
// in the buffer so that all of the calling code can proceed.
templateDeferred.reject(error);
// Rethrow for anyone else listening.
throw error;
});
});
if (requests.length) {
// We have requests to send so resolve the deferred with the promises.
serverRequestsDeferred.resolve(ajax.call(requests, true, false, false, 0, config.templaterev));
} else {
// Nothing to load so we can resolve our deferred.
serverRequestsDeferred.resolve();
}
// Once we've finished loading all of the templates then recurse to process
// any templates that may have been added to the buffer in the time that we
// were fetching.
$.when.apply(null, templatePromises)
.then(() => {
// Remove the templates we've loaded from the buffer.
this.loadTemplateBuffer.splice(0, templatesToLoad.length);
this.isLoadingTemplates = false;
this.processLoadTemplateBuffer();
return;
})
.catch(() => {
// Remove the templates we've loaded from the buffer.
this.loadTemplateBuffer.splice(0, templatesToLoad.length);
this.isLoadingTemplates = false;
this.processLoadTemplateBuffer();
});
}
/**
* Search the various caches for a template promise for the given search key.
* The search key should be in the format <theme>/<component>/<template> e.g. boost/core/modal.
*
* If the template is found in any of the caches it will populate the other caches with
* the same data as well.
*
* @param {String} searchKey The template search key in the format <theme>/<component>/<template> e.g. boost/core/modal
* @returns {Object|null} jQuery promise resolved with the template source
*/
static getTemplatePromiseFromCache(searchKey) {
// First try the cache of promises.
if (searchKey in this.templatePromises) {
return this.templatePromises[searchKey];
}
// Check the module cache.
if (this.hasTemplateInCache(searchKey)) {
const templateSource = this.getTemplateFromCache(searchKey);
// Add this to the promises cache for future.
this.templatePromises[searchKey] = $.Deferred().resolve(templateSource).promise();
return this.templatePromises[searchKey];
}
if (config.templaterev <= 0) {
// Template caching is disabled. Do not store in persistent storage.
return null;
}
// Now try local storage.
const cached = storage.get(`core_template/${config.templaterev}:${searchKey}`);
if (cached) {
// Add this to the module cache for future.
this.setTemplateInCache(searchKey, cached);
// Add to the promises cache for future.
this.templatePromises[searchKey] = $.Deferred().resolve(cached).promise();
return this.templatePromises[searchKey];
}
return null;
}
}
+642
View File
@@ -0,0 +1,642 @@
// 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/>.
import * as Log from 'core/log';
import * as Truncate from 'core/truncate';
import * as UserDate from 'core/user_date';
import Pending from 'core/pending';
import {getStrings} from 'core/str';
import IconSystem from 'core/icon_system';
import config from 'core/config';
import mustache from 'core/mustache';
import Loader from './loader';
import {getNormalisedComponent} from 'core/utils';
/** @var {string} The placeholder character used for standard strings (unclean) */
const placeholderString = 's';
/** @var {string} The placeholder character used for cleaned strings */
const placeholderCleanedString = 'c';
/**
* Template Renderer Class.
*
* Note: This class is not intended to be instantiated directly. Instead, use the core/templates module.
*
* @module core/local/templates/renderer
* @copyright 2023 Andrew Lyons <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.3
*/
export default class Renderer {
/** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */
requiredStrings = null;
/** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */
requiredDates = [];
/** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */
requiredJS = null;
/** @var {String} themeName for the current render */
currentThemeName = '';
/** @var {Number} uniqInstances Count of times this constructor has been called. */
static uniqInstances = 0;
/** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */
static loadTemplateBuffer = [];
/** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */
static isLoadingTemplates = false;
/** @var {Object} iconSystem - Object extending core/iconsystem */
iconSystem = null;
/** @var {Array} disallowedNestedHelpers - List of helpers that can't be called within other helpers */
static disallowedNestedHelpers = [
'js',
];
/** @var {String[]} templateCache - Cache of already loaded template strings */
static templateCache = {};
/**
* Cache of already loaded template promises.
*
* @type {Promise[]}
* @static
* @private
*/
static templatePromises = {};
/**
* The loader used to fetch templates.
* @type {Loader}
* @static
* @private
*/
static loader = Loader;
/**
* Constructor
*
* Each call to templates.render gets it's own instance of this class.
*/
constructor() {
this.requiredStrings = [];
this.requiredJS = [];
this.requiredDates = [];
this.currentThemeName = '';
}
/**
* Set the template loader to use for all Template renderers.
*
* @param {Loader} loader
*/
static setLoader(loader) {
this.loader = loader;
}
/**
* Get the Loader used to fetch templates.
*
* @returns {Loader}
*/
static getLoader() {
return this.loader;
}
/**
* Render a single image icon.
*
* @method renderIcon
* @private
* @param {string} key The icon key.
* @param {string} component The component name.
* @param {string} title The icon title
* @returns {Promise}
*/
async renderIcon(key, component, title) {
// Preload the module to do the icon rendering based on the theme iconsystem.
component = getNormalisedComponent(component);
await this.setupIconSystem();
const template = await Renderer.getLoader().getTemplate(
this.iconSystem.getTemplateName(),
this.currentThemeName,
);
return this.iconSystem.renderIcon(
key,
component,
title,
template
);
}
/**
* Helper to set up the icon system.
*/
async setupIconSystem() {
if (!this.iconSystem) {
this.iconSystem = await IconSystem.instance();
}
return this.iconSystem;
}
/**
* Render image icons.
*
* @method pixHelper
* @private
* @param {object} context The mustache context
* @param {string} sectionText The text to parse arguments from.
* @param {function} helper Used to render the alt attribute of the text.
* @returns {string}
*/
pixHelper(context, sectionText, helper) {
const parts = sectionText.split(',');
let key = '';
let component = '';
let text = '';
if (parts.length > 0) {
key = helper(parts.shift().trim(), context);
}
if (parts.length > 0) {
component = helper(parts.shift().trim(), context);
}
if (parts.length > 0) {
text = helper(parts.join(',').trim(), context);
}
// Note: We cannot use Promises in Mustache helpers.
// We must fetch straight from the Loader cache.
// The Loader cache is statically defined on the Loader class and should be used by all children.
const Loader = Renderer.getLoader();
const templateName = this.iconSystem.getTemplateName();
const searchKey = Loader.getSearchKey(this.currentThemeName, templateName);
const template = Loader.getTemplateFromCache(searchKey);
component = getNormalisedComponent(component);
// The key might have been escaped by the JS Mustache engine which
// converts forward slashes to HTML entities. Let us undo that here.
key = key.replace(/&#x2F;/gi, '/');
return this.iconSystem.renderIcon(
key,
component,
text,
template
);
}
/**
* Render blocks of javascript and save them in an array.
*
* @method jsHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to save as a js block.
* @param {function} helper Used to render the block.
* @returns {string}
*/
jsHelper(context, sectionText, helper) {
this.requiredJS.push(helper(sectionText, context));
return '';
}
/**
* String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}
* into a get_string call.
*
* @method stringHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to parse the arguments from.
* @param {function} helper Used to render subsections of the text.
* @returns {string}
*/
stringHelper(context, sectionText, helper) {
// A string instruction is in the format:
// key, component, params.
let parts = sectionText.split(',');
const key = parts.length > 0 ? parts.shift().trim() : '';
const component = parts.length > 0 ? getNormalisedComponent(parts.shift().trim()) : '';
let param = parts.length > 0 ? parts.join(',').trim() : '';
if (param !== '') {
// Allow variable expansion in the param part only.
param = helper(param, context);
}
if (param.match(/^{\s*"/gm)) {
// If it can't be parsed then the string is not a JSON format.
try {
const parsedParam = JSON.parse(param);
// Handle non-exception-throwing cases, e.g. null, integer, boolean.
if (parsedParam && typeof parsedParam === "object") {
param = parsedParam;
}
} catch (err) {
// This was probably not JSON.
// Keep the error message visible but do not promote it because it may not be an error.
window.console.warn(err.message);
}
}
const index = this.requiredStrings.length;
this.requiredStrings.push({
key,
component,
param,
});
// The placeholder must not use {{}} as those can be misinterpreted by the engine.
return `[[_s${index}]]`;
}
/**
* String helper to render {{#cleanstr}}abd component { a : 'fish'}{{/cleanstr}}
* into a get_string following by an HTML escape.
*
* @method cleanStringHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to parse the arguments from.
* @param {function} helper Used to render subsections of the text.
* @returns {string}
*/
cleanStringHelper(context, sectionText, helper) {
// We're going to use [[_cx]] format for clean strings, where x is a number.
// Hence, replacing 's' with 'c' in the placeholder that stringHelper returns.
return this
.stringHelper(context, sectionText, helper)
.replace(placeholderString, placeholderCleanedString);
}
/**
* Quote helper used to wrap content in quotes, and escape all special JSON characters present in the content.
*
* @method quoteHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to parse the arguments from.
* @param {function} helper Used to render subsections of the text.
* @returns {string}
*/
quoteHelper(context, sectionText, helper) {
let content = helper(sectionText.trim(), context);
// Escape the {{ and JSON encode.
// This involves wrapping {{, and }} in change delimeter tags.
content = JSON.stringify(content);
content = content.replace(/([{}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>');
return content;
}
/**
* Shorten text helper to truncate text and append a trailing ellipsis.
*
* @method shortenTextHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to parse the arguments from.
* @param {function} helper Used to render subsections of the text.
* @returns {string}
*/
shortenTextHelper(context, sectionText, helper) {
// Non-greedy split on comma to grab section text into the length and
// text parts.
const parts = sectionText.match(/(.*?),(.*)/);
// The length is the part matched in the first set of parethesis.
const length = parts[1].trim();
// The length is the part matched in the second set of parethesis.
const text = parts[2].trim();
const content = helper(text, context);
return Truncate.truncate(content, {
length,
words: true,
ellipsis: '...'
});
}
/**
* User date helper to render user dates from timestamps.
*
* @method userDateHelper
* @private
* @param {object} context The current mustache context.
* @param {string} sectionText The text to parse the arguments from.
* @param {function} helper Used to render subsections of the text.
* @returns {string}
*/
userDateHelper(context, sectionText, helper) {
// Non-greedy split on comma to grab the timestamp and format.
const parts = sectionText.match(/(.*?),(.*)/);
const timestamp = helper(parts[1].trim(), context);
const format = helper(parts[2].trim(), context);
const index = this.requiredDates.length;
this.requiredDates.push({
timestamp: timestamp,
format: format
});
return `[[_t_${index}]]`;
}
/**
* Return a helper function to be added to the context for rendering the a
* template.
*
* This will parse the provided text before giving it to the helper function
* in order to remove any disallowed nested helpers to prevent one helper
* from calling another.
*
* In particular to prevent the JS helper from being called from within another
* helper because it can lead to security issues when the JS portion is user
* provided.
*
* @param {function} helperFunction The helper function to add
* @param {object} context The template context for the helper function
* @returns {Function} To be set in the context
*/
addHelperFunction(helperFunction, context) {
return function() {
return function(sectionText, helper) {
// Override the disallowed helpers in the template context with
// a function that returns an empty string for use when executing
// other helpers. This is to prevent these helpers from being
// executed as part of the rendering of another helper in order to
// prevent any potential security issues.
const originalHelpers = Renderer.disallowedNestedHelpers.reduce((carry, name) => {
if (context.hasOwnProperty(name)) {
carry[name] = context[name];
}
return carry;
}, {});
Renderer.disallowedNestedHelpers.forEach((helperName) => {
context[helperName] = () => '';
});
// Execute the helper with the modified context that doesn't include
// the disallowed nested helpers. This prevents the disallowed
// helpers from being called from within other helpers.
const result = helperFunction.apply(this, [context, sectionText, helper]);
// Restore the original helper implementation in the context so that
// any further rendering has access to them again.
for (const name in originalHelpers) {
context[name] = originalHelpers[name];
}
return result;
}.bind(this);
}.bind(this);
}
/**
* Add some common helper functions to all context objects passed to templates.
* These helpers match exactly the helpers available in php.
*
* @method addHelpers
* @private
* @param {Object} context Simple types used as the context for the template.
* @param {String} themeName We set this multiple times, because there are async calls.
*/
addHelpers(context, themeName) {
this.currentThemeName = themeName;
this.requiredStrings = [];
this.requiredJS = [];
context.uniqid = (Renderer.uniqInstances++);
// Please note that these helpers _must_ not return a Promise.
context.str = this.addHelperFunction(this.stringHelper, context);
context.cleanstr = this.addHelperFunction(this.cleanStringHelper, context);
context.pix = this.addHelperFunction(this.pixHelper, context);
context.js = this.addHelperFunction(this.jsHelper, context);
context.quote = this.addHelperFunction(this.quoteHelper, context);
context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);
context.userdate = this.addHelperFunction(this.userDateHelper, context);
context.globals = {config: config};
context.currentTheme = themeName;
}
/**
* Get all the JS blocks from the last rendered template.
*
* @method getJS
* @private
* @returns {string}
*/
getJS() {
return this.requiredJS.join(";\n");
}
/**
* Treat strings in content.
*
* The purpose of this method is to replace the placeholders found in a string
* with the their respective translated strings.
*
* Previously we were relying on String.replace() but the complexity increased with
* the numbers of strings to replace. Now we manually walk the string and stop at each
* placeholder we find, only then we replace it. Most of the time we will
* replace all the placeholders in a single run, at times we will need a few
* more runs when placeholders are replaced with strings that contain placeholders
* themselves.
*
* @param {String} content The content in which string placeholders are to be found.
* @param {Map} stringMap The strings to replace with.
* @returns {String} The treated content.
*/
treatStringsInContent(content, stringMap) {
// Placeholders are in the for [[_sX]] or [[_cX]] where X is the string index.
const stringPattern = /(?<placeholder>\[\[_(?<stringType>[cs])(?<stringIndex>\d+)\]\])/g;
// A helpre to fetch the string for a given placeholder.
const getUpdatedString = ({placeholder, stringType, stringIndex}) => {
if (stringMap.has(placeholder)) {
return stringMap.get(placeholder);
}
if (stringType === placeholderCleanedString) {
// Attempt to find the unclean string and clean it. Store it for later use.
const uncleanString = stringMap.get(`[[_s${stringIndex}]]`);
if (uncleanString) {
stringMap.set(placeholder, mustache.escape(uncleanString));
return stringMap.get(placeholder);
}
}
Log.debug(`Could not find string for pattern ${placeholder}`);
return '';
};
// Find all placeholders in the content and replace them with their respective strings.
let match;
while ((match = stringPattern.exec(content)) !== null) {
let updatedContent = content.slice(0, match.index);
updatedContent += getUpdatedString(match.groups);
updatedContent += content.slice(match.index + match.groups.placeholder.length);
content = updatedContent;
}
return content;
}
/**
* Treat strings in content.
*
* The purpose of this method is to replace the date placeholders found in the
* content with the their respective translated dates.
*
* @param {String} content The content in which string placeholders are to be found.
* @param {Array} dates The dates to replace with.
* @returns {String} The treated content.
*/
treatDatesInContent(content, dates) {
dates.forEach((date, index) => {
content = content.replace(
new RegExp(`\\[\\[_t_${index}\\]\\]`, 'g'),
date,
);
});
return content;
}
/**
* Render a template and then call the callback with the result.
*
* @method doRender
* @private
* @param {string|Promise} templateSourcePromise The mustache template to render.
* @param {Object} context Simple types used as the context for the template.
* @param {String} themeName Name of the current theme.
* @returns {Promise<object<string, string>>} The rendered HTML and JS.
*/
async doRender(templateSourcePromise, context, themeName) {
this.currentThemeName = themeName;
const iconTemplate = this.iconSystem.getTemplateName();
const pendingPromise = new Pending('core/templates:doRender');
const [templateSource] = await Promise.all([
templateSourcePromise,
Renderer.getLoader().getTemplate(iconTemplate, themeName),
]);
this.addHelpers(context, themeName);
// Render the template.
const renderedContent = await mustache.render(
templateSource,
context,
// Note: The third parameter is a function that will be called to process partials.
(partialName) => Renderer.getLoader().partialHelper(partialName, themeName),
);
const {html, js} = await this.processRenderedContent(renderedContent);
pendingPromise.resolve();
return {html, js};
}
/**
* Process the rendered content, treating any strings and applying and helper strings, dates, etc.
* @param {string} renderedContent
* @returns {Promise<object<string, string>>} The rendered HTML and JS.
*/
async processRenderedContent(renderedContent) {
let html = renderedContent.trim();
let js = this.getJS();
if (this.requiredStrings.length > 0) {
// Fetch the strings into a new Map using the placeholder as an index.
// Note: We only fetch the unclean version. Cleaning of strings happens lazily in treatStringsInContent.
const stringMap = new Map(
(await getStrings(this.requiredStrings)).map((string, index) => (
[`[[_s${index}]]`, string]
))
);
// Make sure string substitutions are done for the userdate
// values as well.
this.requiredDates = this.requiredDates.map(function(date) {
return {
timestamp: this.treatStringsInContent(date.timestamp, stringMap),
format: this.treatStringsInContent(date.format, stringMap)
};
}.bind(this));
// Why do we not do another call the render here?
//
// Because that would expose DOS holes. E.g.
// I create an assignment called "{{fish" which
// would get inserted in the template in the first pass
// and cause the template to die on the second pass (unbalanced).
html = this.treatStringsInContent(html, stringMap);
js = this.treatStringsInContent(js, stringMap);
}
// This has to happen after the strings replacement because you can
// use the string helper in content for the user date helper.
if (this.requiredDates.length > 0) {
const dates = await UserDate.get(this.requiredDates);
html = this.treatDatesInContent(html, dates);
js = this.treatDatesInContent(js, dates);
}
return {html, js};
}
/**
* Load a template and call doRender on it.
*
* @method render
* @private
* @param {string} templateName - should consist of the component and the name of the template like this:
* core/menu (lib/templates/menu.mustache) or
* tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)
* @param {Object} [context={}] - Could be array, string or simple value for the context of the template.
* @param {string} [themeName] - Name of the current theme.
* @returns {Promise<object>} Native promise object resolved when the template has been rendered.}
*/
async render(
templateName,
context = {},
themeName = config.theme,
) {
this.currentThemeName = themeName;
// Preload the module to do the icon rendering based on the theme iconsystem.
await this.setupIconSystem();
const templateSource = Renderer.getLoader().cachePartials(templateName, themeName);
return this.doRender(templateSource, context, themeName);
}
}
+65
View File
@@ -0,0 +1,65 @@
// 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/>.
/**
* Simple API for set/get to localstorage, with cacherev expiration.
*
* @module core/localstorage
* @class localstorage
* @copyright 2015 Damyon Wiese <damyon@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 2.9
*/
define(['core/config', 'core/storagewrapper'], function(config, StorageWrapper) {
// Private functions and variables.
/** @var {Object} StorageWrapper - Wraps browsers localStorage object */
var storage = new StorageWrapper(window.localStorage);
return /** @alias module:core/localstorage */ {
/**
* Get a value from local storage. Remember - all values must be strings.
*
* @method get
* @param {string} key The cache key to check.
* @return {boolean|string} False if the value is not in the cache, or some other error - a string otherwise.
*/
get: function(key) {
return storage.get(key);
},
/**
* Set a value to local storage. Remember - all values must be strings.
*
* @method set
* @param {string} key The cache key to set.
* @param {string} value The value to set.
* @return {boolean} False if the value can't be saved in the cache, or some other error - true otherwise.
*/
set: function(key, value) {
return storage.set(key, value);
},
/**
* Clean local storage
*
* @method clean
*/
clean: function() {
return storage.clean();
}
};
});
+52
View File
@@ -0,0 +1,52 @@
// 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 is an empty module, that is required before all other modules.
* Because every module is returned from a request for any other module, this
* forces the loading of all modules with a single request.
*
* @module core/log
* @copyright 2015 Andrew Nicols <andrew@nicols.co.uk>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
define(['core/loglevel'], function(log) {
var originalFactory = log.methodFactory;
log.methodFactory = function(methodName, logLevel) {
var rawMethod = originalFactory(methodName, logLevel);
return function(message, source) {
if (source) {
rawMethod(source + ": " + message);
} else {
rawMethod(message);
}
};
};
/**
* Set default config settings.
*
* @param {Object} config including the level to use.
* @method setConfig
*/
log.setConfig = function(config) {
if (typeof config.level !== "undefined") {
log.setLevel(config.level);
}
};
return log;
});
+320
View File
@@ -0,0 +1,320 @@
// Copyright (c) 2013 Tim Perry
//
// Permission is hereby granted, free of charge, to any person
// obtaining a copy of this software and associated documentation
// files (the "Software"), to deal in the Software without
// restriction, including without limitation the rights to use,
// copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the
// Software is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
// OTHER DEALINGS IN THE SOFTWARE.
// Description of import into Moodle:
// Download from https://github.com/pimterry/loglevel/tree/master/dist
// Copy loglevel.js into lib/amd/src/ in Moodle folder.
// Add the license as a comment to the file and these instructions.
/*! loglevel - v1.8.1 - https://github.com/pimterry/loglevel - (c) 2022 Tim Perry - licensed MIT */
(function (root, definition) {
"use strict";
if (typeof define === 'function' && define.amd) {
define(definition);
} else if (typeof module === 'object' && module.exports) {
module.exports = definition();
} else {
root.log = definition();
}
}(this, function () {
"use strict";
// Slightly dubious tricks to cut down minimized file size
var noop = function() {};
var undefinedType = "undefined";
var isIE = (typeof window !== undefinedType) && (typeof window.navigator !== undefinedType) && (
/Trident\/|MSIE /.test(window.navigator.userAgent)
);
var logMethods = [
"trace",
"debug",
"info",
"warn",
"error"
];
// Cross-browser bind equivalent that works at least back to IE6
function bindMethod(obj, methodName) {
var method = obj[methodName];
if (typeof method.bind === 'function') {
return method.bind(obj);
} else {
try {
return Function.prototype.bind.call(method, obj);
} catch (e) {
// Missing bind shim or IE8 + Modernizr, fallback to wrapping
return function() {
return Function.prototype.apply.apply(method, [obj, arguments]);
};
}
}
}
// Trace() doesn't print the message in IE, so for that case we need to wrap it
function traceForIE() {
if (console.log) {
if (console.log.apply) {
console.log.apply(console, arguments);
} else {
// In old IE, native console methods themselves don't have apply().
Function.prototype.apply.apply(console.log, [console, arguments]);
}
}
if (console.trace) console.trace();
}
// Build the best logging method possible for this env
// Wherever possible we want to bind, not wrap, to preserve stack traces
function realMethod(methodName) {
if (methodName === 'debug') {
methodName = 'log';
}
if (typeof console === undefinedType) {
return false; // No method possible, for now - fixed later by enableLoggingWhenConsoleArrives
} else if (methodName === 'trace' && isIE) {
return traceForIE;
} else if (console[methodName] !== undefined) {
return bindMethod(console, methodName);
} else if (console.log !== undefined) {
return bindMethod(console, 'log');
} else {
return noop;
}
}
// These private functions always need `this` to be set properly
function replaceLoggingMethods(level, loggerName) {
/*jshint validthis:true */
for (var i = 0; i < logMethods.length; i++) {
var methodName = logMethods[i];
this[methodName] = (i < level) ?
noop :
this.methodFactory(methodName, level, loggerName);
}
// Define log.log as an alias for log.debug
this.log = this.debug;
}
// In old IE versions, the console isn't present until you first open it.
// We build realMethod() replacements here that regenerate logging methods
function enableLoggingWhenConsoleArrives(methodName, level, loggerName) {
return function () {
if (typeof console !== undefinedType) {
replaceLoggingMethods.call(this, level, loggerName);
this[methodName].apply(this, arguments);
}
};
}
// By default, we use closely bound real methods wherever possible, and
// otherwise we wait for a console to appear, and then try again.
function defaultMethodFactory(methodName, level, loggerName) {
/*jshint validthis:true */
return realMethod(methodName) ||
enableLoggingWhenConsoleArrives.apply(this, arguments);
}
function Logger(name, defaultLevel, factory) {
var self = this;
var currentLevel;
defaultLevel = defaultLevel == null ? "WARN" : defaultLevel;
var storageKey = "loglevel";
if (typeof name === "string") {
storageKey += ":" + name;
} else if (typeof name === "symbol") {
storageKey = undefined;
}
function persistLevelIfPossible(levelNum) {
var levelName = (logMethods[levelNum] || 'silent').toUpperCase();
if (typeof window === undefinedType || !storageKey) return;
// Use localStorage if available
try {
window.localStorage[storageKey] = levelName;
return;
} catch (ignore) {}
// Use session cookie as fallback
try {
window.document.cookie =
encodeURIComponent(storageKey) + "=" + levelName + ";";
} catch (ignore) {}
}
function getPersistedLevel() {
var storedLevel;
if (typeof window === undefinedType || !storageKey) return;
try {
storedLevel = window.localStorage[storageKey];
} catch (ignore) {}
// Fallback to cookies if local storage gives us nothing
if (typeof storedLevel === undefinedType) {
try {
var cookie = window.document.cookie;
var location = cookie.indexOf(
encodeURIComponent(storageKey) + "=");
if (location !== -1) {
storedLevel = /^([^;]+)/.exec(cookie.slice(location))[1];
}
} catch (ignore) {}
}
// If the stored level is not valid, treat it as if nothing was stored.
if (self.levels[storedLevel] === undefined) {
storedLevel = undefined;
}
return storedLevel;
}
function clearPersistedLevel() {
if (typeof window === undefinedType || !storageKey) return;
// Use localStorage if available
try {
window.localStorage.removeItem(storageKey);
return;
} catch (ignore) {}
// Use session cookie as fallback
try {
window.document.cookie =
encodeURIComponent(storageKey) + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC";
} catch (ignore) {}
}
/*
*
* Public logger API - see https://github.com/pimterry/loglevel for details
*
*/
self.name = name;
self.levels = { "TRACE": 0, "DEBUG": 1, "INFO": 2, "WARN": 3,
"ERROR": 4, "SILENT": 5};
self.methodFactory = factory || defaultMethodFactory;
self.getLevel = function () {
return currentLevel;
};
self.setLevel = function (level, persist) {
if (typeof level === "string" && self.levels[level.toUpperCase()] !== undefined) {
level = self.levels[level.toUpperCase()];
}
if (typeof level === "number" && level >= 0 && level <= self.levels.SILENT) {
currentLevel = level;
if (persist !== false) { // defaults to true
persistLevelIfPossible(level);
}
replaceLoggingMethods.call(self, level, name);
if (typeof console === undefinedType && level < self.levels.SILENT) {
return "No console available for logging";
}
} else {
throw "log.setLevel() called with invalid level: " + level;
}
};
self.setDefaultLevel = function (level) {
defaultLevel = level;
if (!getPersistedLevel()) {
self.setLevel(level, false);
}
};
self.resetLevel = function () {
self.setLevel(defaultLevel, false);
clearPersistedLevel();
};
self.enableAll = function(persist) {
self.setLevel(self.levels.TRACE, persist);
};
self.disableAll = function(persist) {
self.setLevel(self.levels.SILENT, persist);
};
// Initialize with the right level
var initialLevel = getPersistedLevel();
if (initialLevel == null) {
initialLevel = defaultLevel;
}
self.setLevel(initialLevel, false);
}
/*
*
* Top-level API
*
*/
var defaultLogger = new Logger();
var _loggersByName = {};
defaultLogger.getLogger = function getLogger(name) {
if ((typeof name !== "symbol" && typeof name !== "string") || name === "") {
throw new TypeError("You must supply a name when creating a logger.");
}
var logger = _loggersByName[name];
if (!logger) {
logger = _loggersByName[name] = new Logger(
name, defaultLogger.getLevel(), defaultLogger.methodFactory);
}
return logger;
};
// Grab the current global log variable in case of overwrite
var _log = (typeof window !== undefinedType) ? window.log : undefined;
defaultLogger.noConflict = function() {
if (typeof window !== undefinedType &&
window.log === defaultLogger) {
window.log = _log;
}
return defaultLogger;
};
defaultLogger.getLoggers = function getLoggers() {
return _loggersByName;
};
// ES6 default export, for compatibility
defaultLogger['default'] = defaultLogger;
return defaultLogger;
}));
+268
View File
@@ -0,0 +1,268 @@
// 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/>.
/**
* Keyboard initialization for a given html node.
*
* @module core/menu_navigation
* @copyright 2021 Moodle
* @author Mathew May <mathew.solutions>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
const SELECTORS = {
'menuitem': '[role="menuitem"]',
'tab': '[role="tab"]',
'dropdowntoggle': '[data-toggle="dropdown"]',
};
let openDropdownNode = null;
/**
* Small helper function to check if a given node is null or not.
*
* @param {HTMLElement|null} item The node that we want to compare.
* @param {HTMLElement} fallback Either the first node or final node that can be focused on.
* @return {HTMLElement}
*/
const clickErrorHandler = (item, fallback) => {
if (item !== null) {
return item;
} else {
return fallback;
}
};
/**
* Control classes etc of the selected dropdown item and its' parent <a>
*
* @param {HTMLElement} src The node within the dropdown the user selected.
*/
const menuItemHelper = src => {
let parent;
// Do not apply any actions if the selected dropdown item is explicitly instructing to not display an active state.
if (src.dataset.disableactive) {
return;
}
// Handling for dropdown escapes.
// A bulk of the handling is already done by aria.js just add polish.
if (src.classList.contains('dropdown-item')) {
parent = src.closest('.dropdown-menu');
const dropDownToggle = document.getElementById(parent.getAttribute('aria-labelledby'));
dropDownToggle.classList.add('active');
dropDownToggle.setAttribute('tabindex', 0);
} else if (src.matches(`${SELECTORS.tab},${SELECTORS.menuitem}`) && !src.matches(SELECTORS.dropdowntoggle)) {
parent = src.parentElement.parentElement.querySelector('.dropdown-menu');
} else {
return;
}
// Remove active class from any other dropdown elements.
Array.prototype.forEach.call(parent.children, node => {
const menuItem = node.querySelector(SELECTORS.menuitem);
if (menuItem !== null) {
menuItem.classList.remove('active');
// Remove aria selection state.
menuItem.removeAttribute('aria-current');
}
});
// Set the applicable element's selection state.
if (src.getAttribute('role') === 'menuitem') {
src.setAttribute('aria-current', 'true');
}
};
/**
* Defined keyboard event handling so we can remove listeners on nodes on resize etc.
*
* @param {event} e The triggering element and key presses etc.
*/
const keyboardListenerEvents = e => {
const src = e.srcElement;
const firstNode = e.currentTarget.firstElementChild;
const lastNode = findUsableLastNode(e.currentTarget);
// Handling for dropdown escapes.
// A bulk of the handling is already done by aria.js just add polish.
if (src.classList.contains('dropdown-item')) {
if (e.key == 'ArrowRight' ||
e.key == 'ArrowLeft') {
e.preventDefault();
if (openDropdownNode !== null) {
openDropdownNode.parentElement.click();
}
}
if (e.key == ' ' ||
e.key == 'Enter') {
e.preventDefault();
menuItemHelper(src);
if (!src.parentElement.classList.contains('dropdown')) {
src.click();
}
}
} else {
const rtl = window.right_to_left();
const arrowNext = rtl ? 'ArrowLeft' : 'ArrowRight';
const arrowPrevious = rtl ? 'ArrowRight' : 'ArrowLeft';
if (src.getAttribute('role') === 'menuitem') {
// When not rendered within a dropdown menu, handle keyboard navigation if the element is rendered as a menu item.
if (e.key == arrowNext) {
e.preventDefault();
setFocusNext(src, firstNode);
}
if (e.key == arrowPrevious) {
e.preventDefault();
setFocusPrev(src, lastNode);
}
// Let aria.js handle the dropdowns.
if (e.key == 'ArrowUp' ||
e.key == 'ArrowDown') {
openDropdownNode = src;
e.preventDefault();
}
if (e.key == 'Home') {
e.preventDefault();
setFocusHomeEnd(firstNode);
}
if (e.key == 'End') {
e.preventDefault();
setFocusHomeEnd(lastNode);
}
}
if (e.key == ' ' ||
e.key == 'Enter') {
e.preventDefault();
// Aria.js handles dropdowns etc.
if (!src.parentElement.classList.contains('dropdown')) {
src.click();
}
}
}
};
/**
* Defined click event handling so we can remove listeners on nodes on resize etc.
*
* @param {event} e The triggering element and key presses etc.
*/
const clickListenerEvents = e => {
const src = e.srcElement;
menuItemHelper(src);
};
/**
* The initial entry point that a given module can pass a HTMLElement.
*
* @param {HTMLElement} elementRoot The menu to add handlers upon.
*/
export default elementRoot => {
// Remove any and all instances of old listeners on the passed element.
elementRoot.removeEventListener('keydown', keyboardListenerEvents);
elementRoot.removeEventListener('click', clickListenerEvents);
// (Re)apply our event listeners to the passed element.
elementRoot.addEventListener('keydown', keyboardListenerEvents);
elementRoot.addEventListener('click', clickListenerEvents);
};
/**
* Handle the focusing to the next element in the dropdown.
*
* @param {HTMLElement|null} currentNode The node that we want to take action on.
* @param {HTMLElement} firstNode The backup node to focus as a last resort.
*/
const setFocusNext = (currentNode, firstNode) => {
const listElement = currentNode.parentElement;
const nextListItem = ((el) => {
do {
el = el.nextElementSibling;
} while (el && !el.offsetHeight); // We only work with the visible tabs.
return el;
})(listElement);
const nodeToSelect = clickErrorHandler(nextListItem, firstNode);
const parent = listElement.parentElement;
const isTabList = parent.getAttribute('role') === 'tablist';
const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;
const menuItem = nodeToSelect.querySelector(itemSelector);
menuItem.focus();
};
/**
* Handle the focusing to the previous element in the dropdown.
*
* @param {HTMLElement|null} currentNode The node that we want to take action on.
* @param {HTMLElement} lastNode The backup node to focus as a last resort.
*/
const setFocusPrev = (currentNode, lastNode) => {
const listElement = currentNode.parentElement;
const nextListItem = ((el) => {
do {
el = el.previousElementSibling;
} while (el && !el.offsetHeight); // We only work with the visible tabs.
return el;
})(listElement);
const nodeToSelect = clickErrorHandler(nextListItem, lastNode);
const parent = listElement.parentElement;
const isTabList = parent.getAttribute('role') === 'tablist';
const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;
const menuItem = nodeToSelect.querySelector(itemSelector);
menuItem.focus();
};
/**
* Focus on either the start or end of a nav list.
*
* @param {HTMLElement} node The element to focus on.
*/
const setFocusHomeEnd = node => {
node.querySelector(SELECTORS.menuitem).focus();
};
/**
* We need to look within the menu to find a last node we can add focus to.
*
* @param {HTMLElement} elementRoot Menu to find a final child node within.
* @return {HTMLElement}
*/
const findUsableLastNode = elementRoot => {
const lastNode = elementRoot.lastElementChild;
// An example is the more menu existing but hidden on the page for the time being.
if (!lastNode.classList.contains('d-none')) {
return elementRoot.lastElementChild;
} else {
// Cast the HTMLCollection & reverse it.
const extractedNodes = Array.prototype.map.call(elementRoot.children, node => {
return node;
}).reverse();
// Get rid of any nodes we can not set focus on.
const nodesToUse = extractedNodes.filter((node => {
if (!node.classList.contains('d-none')) {
return node;
}
}));
// If we find no elements we can set focus on, fall back to the absolute first element.
if (nodesToUse.length !== 0) {
return nodesToUse[0];
} else {
return elementRoot.firstElementChild;
}
}
};
+1215
View File
File diff suppressed because it is too large Load Diff
+166
View File
@@ -0,0 +1,166 @@
// 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/>.
/**
* Contain the logic for modal backdrops.
*
* @module core/modal_backdrop
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import $ from 'jquery';
import * as Notification from './notification';
import * as Fullscreen from './fullscreen';
const SELECTORS = {
ROOT: '[data-region="modal-backdrop"]',
};
export default class ModalBackdrop {
root = null;
isAttached = false;
attachmentPoint = null;
/**
* Constructor for ModalBackdrop.
*
* @class core/modal_backdrop
* @param {HTMLElement|jQuery} root The root element for the modal backdrop
*/
constructor(root) {
this.root = $(root);
this.isAttached = false;
this.attachmentPoint = document.createElement('div');
document.body.append(this.attachmentPoint);
if (!this.root.is(SELECTORS.ROOT)) {
Notification.exception({message: 'Element is not a modal backdrop'});
}
}
/**
* Get the root element of this modal backdrop.
*
* @method getRoot
* @return {object} jQuery object
*/
getRoot() {
return this.root;
}
/**
* Gets the jQuery wrapped node that the Modal should be attached to.
*
* @returns {jQuery}
*/
getAttachmentPoint() {
return $(Fullscreen.getElement() || this.attachmentPoint);
}
/**
* Add the modal backdrop to the page, if it hasn't already been added.
*
* @method attachToDOM
*/
attachToDOM() {
this.getAttachmentPoint().append(this.root);
if (this.isAttached) {
return;
}
this.isAttached = true;
}
/**
* Set the z-index value for this backdrop.
*
* @method setZIndex
* @param {int} value The z-index value
*/
setZIndex(value) {
this.root.css('z-index', value);
}
/**
* Check if this backdrop is visible.
*
* @method isVisible
* @return {bool}
*/
isVisible() {
return this.root.hasClass('show');
}
/**
* Check if this backdrop has CSS transitions applied.
*
* @method hasTransitions
* @return {bool}
*/
hasTransitions() {
return this.getRoot().hasClass('fade');
}
/**
* Display this backdrop. The backdrop will be attached to the DOM if it hasn't
* already been.
*
* @method show
*/
show() {
if (this.isVisible()) {
return;
}
this.attachToDOM();
this.root.removeClass('hide').addClass('show');
}
/**
* Hide this backdrop.
*
* @method hide
*/
hide() {
if (!this.isVisible()) {
return;
}
if (this.hasTransitions()) {
// Wait for CSS transitions to complete before hiding the element.
this.getRoot().one('transitionend webkitTransitionEnd oTransitionEnd', () => {
this.getRoot().removeClass('show').addClass('hide');
});
} else {
this.getRoot().removeClass('show').addClass('hide');
}
// Ensure the modal is moved onto the body node if it is still attached to the DOM.
if ($(document.body).find(this.getRoot()).length) {
$(document.body).append(this.getRoot());
}
}
/**
* Remove this backdrop from the DOM.
*
* @method destroy
*/
destroy() {
this.root.remove();
this.attachmentPoint.remove();
}
}
+55
View File
@@ -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/>.
/**
* Contain the logic for the cancel modal.
*
* @module core/modal_cancel
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Modal from 'core/modal';
import Notification from 'core/notification';
/**
* @class
* @extends module:core/modal
*/
export default class ModalCancel extends Modal {
static TYPE = 'CANCEL';
static TEMPLATE = 'core/modal_cancel';
constructor(root) {
super(root);
if (!this.getFooter().find(this.getActionSelector('cancel')).length) {
Notification.exception({message: 'No cancel button found'});
}
}
/**
* Register all event listeners.
*/
registerEventListeners() {
// Call the parent registration.
super.registerEventListeners();
// Register to close on cancel.
this.registerCloseOnCancel();
}
}
ModalCancel.registerModalType();
+107
View File
@@ -0,0 +1,107 @@
// 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/>.
/**
* Contain the logic for the copy to clipboard modal, i.e. the modal contains a
* readonly input text field, that contains a value. Clicking on the single
* button "Copy to clipboard" in the footer, puts the content of the input
* text field into the clipboard and closes the modal.
*
* Usage:
* ModalCopyToClipboard.create(string:<stringToCopy>, string:<modalTitle>|null);
*
* @module core/modal_copy_to_clipboard
* @copyright 2023 Stephan Robotta <stephan.robotta@bfh.ch>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Modal from 'core/modal';
import 'core/copy_to_clipboard';
export default class CopyToClipboardModal extends Modal {
static TYPE = 'core/copytoclipboard';
static TEMPLATE = 'core/modal_copytoclipboard';
constructor(...config) {
// Override the constructor to set the removeOnClose property, and show the modal.
super(...config);
this.setRemoveOnClose(true);
}
/**
* Set up all the event handling for the modal.
* This is an override of the parent method, adding an event listener to close upon the action.
*
* @param {array} args
*/
registerEventListeners(...args) {
super.registerEventListeners(...args);
this.getRoot().get(0).addEventListener('click', (e) => {
if (!e.target.closest('[data-action="copytoclipboard"]')) {
return;
}
if (!this.getRoot().get(0).contains(e.target)) {
return;
}
// Note: We must call destroy() here, because the copy-to-clipboard action listens on the document,
// which will be processed after this event listener has been processed.
// By placing this in a setTimeout we move its processing to after the event loop has finished.
setTimeout(this.destroy.bind(this));
});
}
/**
* Create a new instance of the Modal. Set the text that is being copied. By default, the text is put into the
* value of an input readonly field. If useTextArea is set to true, the text is rendered in a textarea element.
* The optional title argument is for the modal title. If not set, the generic string "copy to clipboard" is used.
*
* @param {Object} data used in the template
* @param {string} data.text the text to copy to the clipboard
* @param {boolean} data.useTextArea when the text to copy is displayed in a textarea, default is input
* @param {string|null} title
* @returns {Promise<void>}
*/
static async create(
{
text,
useTextArea = false,
} = {},
title,
) {
const modalConfig = {
templateContext: {
text,
useTextArea,
},
};
if (title) {
modalConfig.title = title;
}
return super.create(modalConfig);
}
configure(modalConfig) {
modalConfig.show = true;
modalConfig.removeOnClose = true;
super.configure(modalConfig);
}
}
CopyToClipboardModal.registerModalType();
+78
View File
@@ -0,0 +1,78 @@
// 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/>.
/**
* Contain the logic for the delete/cancel modal.
*
* @module core/modal_delete_cancel
* @copyright 2022 Laurent David <laurent.david@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Modal from 'core/modal';
import Notification from 'core/notification';
/**
* The Delete/Cancel Modal.
*
* @class
* @extends module:core/modal
*/
export default class ModalDeleteCancel extends Modal {
static TYPE = 'DELETE_CANCEL';
static TEMPLATE = 'core/modal_delete_cancel';
constructor(root) {
super(root);
if (!this.getFooter().find(this.getActionSelector('delete')).length) {
Notification.exception({message: 'No delete button found'});
}
if (!this.getFooter().find(this.getActionSelector('cancel')).length) {
Notification.exception({message: 'No cancel button found'});
}
}
/**
* Register all event listeners.
*/
registerEventListeners() {
// Call the parent registration.
super.registerEventListeners();
// Register to close on delete/cancel.
this.registerCloseOnDelete();
this.registerCloseOnCancel();
}
/**
* Override parent implementation to prevent changing the footer content.
*/
setFooter() {
Notification.exception({message: 'Can not change the footer of a delete cancel modal'});
}
/**
* Set the title of the delete button.
*
* @param {String|Promise} value The button text, or a Promise which will resolve it
* @returns{Promise}
*/
setDeleteButtonText(value) {
return this.setButtonText('delete', value);
}
}
ModalDeleteCancel.registerModalType();
+35
View File
@@ -0,0 +1,35 @@
// 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/>.
/**
* Contain the events a modal can fire.
*
* @module core/modal_events
* @class modal_events
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
export default {
// Default events.
shown: 'modal:shown',
hidden: 'modal:hidden',
destroyed: 'modal:destroyed',
bodyRendered: 'modal:bodyRendered',
outsideClick: 'modal:outsideClick',
// ModalSaveCancel events.
save: 'modal-save-cancel:save',
'delete': 'modal-delete-cancel:delete', // Delete is a reserved word.
cancel: 'modal-save-cancel:cancel',
};
+163
View File
@@ -0,0 +1,163 @@
// 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/>.
/**
* Create a modal.
*
* @module core/modal_factory
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @deprecated since Moodle 4.3
* @todo Final deprecation in Moodle 4.7/5.2. See MDL-79128/
*/
import $ from 'jquery';
import ModalEvents from 'core/modal_events';
import * as ModalRegistry from 'core/modal_registry';
import Modal from 'core/modal';
import ModalSaveCancel from 'core/modal_save_cancel';
import ModalDeleteCancel from 'core/modal_delete_cancel';
import ModalCancel from 'core/modal_cancel';
import ModalAlert from 'core/local/modal/alert';
import * as Notification from 'core/notification';
import * as CustomEvents from 'core/custom_interaction_events';
import Pending from 'core/pending';
/**
* The available standard modals.
*
* @property {String} DEFAULT The default modal
* @property {String} SAVE_CANCEL A modal which can be used to either save, or cancel.
* @property {String} DELETE_CANCEL A modal which can be used to either delete, or cancel.
* @property {String} CANCEL A modal which displayed a cancel button
* @property {String} ALERT An information modal which only displays information.
*/
export const types = {
DEFAULT: 'DEFAULT',
SAVE_CANCEL: ModalSaveCancel.TYPE,
DELETE_CANCEL: ModalDeleteCancel.TYPE,
CANCEL: ModalCancel.TYPE,
ALERT: ModalAlert.TYPE,
};
// Most modals are self-registering.
// We do not self-register the base Modal because we do not want to define a default TYPE
// on the class that every other modal extends.
ModalRegistry.register(types.DEFAULT, Modal, Modal.TEMPLATE);
/**
* Set up the events required to show the modal and return focus when the modal
* is closed.
*
* @method setUpTrigger
* @private
* @param {Promise} modalPromise The modal instance
* @param {object} triggerElement The jQuery element to open the modal
* @param {object} modalConfig The modal configuration given to the factory
*/
const setUpTrigger = (modalPromise, triggerElement, modalConfig) => {
// The element that actually shows the modal.
let actualTriggerElement = null;
// Check if the client has provided a callback function to be called
// before the modal is displayed.
const hasPreShowCallback = (typeof modalConfig.preShowCallback == 'function');
// Function to handle the trigger element being activated.
const triggeredCallback = (e, data) => {
const pendingPromise = new Pending('core/modal_factory:setUpTrigger:triggeredCallback');
actualTriggerElement = $(e.currentTarget);
// eslint-disable-next-line promise/catch-or-return
modalPromise.then(function(modal) {
if (hasPreShowCallback) {
// If the client provided a pre-show callback then execute
// it now before showing the modal.
modalConfig.preShowCallback(actualTriggerElement, modal);
}
modal.show();
return modal;
})
.then(pendingPromise.resolve);
data.originalEvent.preventDefault();
};
// The trigger element can either be a single element or it can be an
// element + selector pair to create a delegated event handler to trigger
// the modal.
if (Array.isArray(triggerElement)) {
const selector = triggerElement[1];
triggerElement = triggerElement[0];
CustomEvents.define(triggerElement, [CustomEvents.events.activate]);
triggerElement.on(CustomEvents.events.activate, selector, triggeredCallback);
} else {
CustomEvents.define(triggerElement, [CustomEvents.events.activate]);
triggerElement.on(CustomEvents.events.activate, triggeredCallback);
}
// eslint-disable-next-line promise/catch-or-return
modalPromise.then(function(modal) {
modal.getRoot().on(ModalEvents.hidden, function() {
// Focus on the trigger element that actually launched the modal.
if (actualTriggerElement !== null) {
actualTriggerElement.focus();
}
});
return modal;
});
};
/**
* Create a Modal instance.
*
* @method create
* @param {object} modalConfig The configuration to create the modal instance
* @param {object} triggerElement The trigger HTML jQuery object
* @return {promise} Resolved with a Modal instance
*/
export const create = (modalConfig, triggerElement) => {
window.console.warn(
'The modal_factory has been deprecated since Moodle 4.3. Please use the create method on your target modal type instead.',
);
// Use of the triggerElement has been deprecated.
const type = modalConfig.type || types.DEFAULT;
const registryConf = ModalRegistry.get(type);
if (!registryConf) {
Notification.exception({message: `Unable to find modal of type: ${type}`});
}
const modal = registryConf.module.create(modalConfig);
if (triggerElement) {
window.console.warn(
'The triggerElement feature of the modal_factory has been deprecated. Please use event listeners instead.',
);
setUpTrigger(modal, triggerElement, modalConfig);
}
return $.when(new Promise((resolve, reject) => {
modal
.then(resolve)
.catch(reject);
}));
};
export default {
create,
types,
};
+73
View File
@@ -0,0 +1,73 @@
// 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 registry for the different types of modal.
*
* @module core/modal_registry
* @class modal_registry
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import * as Notification from 'core/notification';
import * as Prefetch from 'core/prefetch';
// A singleton registry for all modules to access. Allows types to be
// added at runtime.
const registry = new Map();
/**
* Get a registered type of modal.
*
* @method get
* @param {string} type The type of modal to get
* @return {object} The registered config for the modal
*/
export const get = (type) => registry.get(type);
/**
* Register a modal with the registry.
*
* @method register
* @param {string} type The type of modal (must be unique)
* @param {function} module The modal module (must be a constructor function of type core/modal)
* @param {string} template The template name of the modal
*/
export const register = (type, module, template) => {
const existing = get(type);
if (existing && existing.module !== module) {
Notification.exception({
message: `Modal of type '${type}' is already registered`,
});
}
if (!module || typeof module !== 'function') {
Notification.exception({message: "You must provide a modal module"});
}
if (!template) {
Notification.exception({message: "You must provide a modal template"});
}
registry.set(type, {module, template});
// Prefetch the template.
Prefetch.prefetchTemplate(template);
};
export default {
register,
get,
};
+80
View File
@@ -0,0 +1,80 @@
// 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/>.
/**
* Contain the logic for the save/cancel modal.
*
* @module core/modal_save_cancel
* @copyright 2016 Ryan Wyllie <ryan@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
import Modal from 'core/modal';
import Notification from 'core/notification';
/**
* The Save/Cancel Modal.
*
* @class
* @extends module:core/modal
*/
export default class ModalSaveCancel extends Modal {
static TYPE = 'SAVE_CANCEL';
static TEMPLATE = 'core/modal_save_cancel';
constructor(root) {
super(root);
if (!this.getFooter().find(this.getActionSelector('save')).length) {
Notification.exception({message: 'No save button found'});
}
if (!this.getFooter().find(this.getActionSelector('cancel')).length) {
Notification.exception({message: 'No cancel button found'});
}
}
/**
* Register all event listeners.
*/
registerEventListeners() {
// Call the parent registration.
super.registerEventListeners();
// Register to close on save/cancel.
this.registerCloseOnSave();
this.registerCloseOnCancel();
}
/**
* Override parent implementation to prevent changing the footer content.
*/
setFooter() {
Notification.exception({message: 'Can not change the footer of a save cancel modal'});
return;
}
/**
* Set the title of the save button.
*
* @param {String|Promise} value The button text, or a Promise which will resolve it
* @returns{Promise}
*/
setSaveButtonText(value) {
return this.setButtonText('save', value);
}
}
ModalSaveCancel.registerModalType();
+67
View File
@@ -0,0 +1,67 @@
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
* MoodleNet authorization.
*
* @module core/moodlenet/authorize
* @copyright 2023 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.3
*/
import {alert as displayAlert, exception as displayException} from 'core/notification';
import * as MoodleNetService from 'core/moodlenet/service';
import {sendToMoodleNet} from 'core/moodlenet/send_resource';
/**
* Handle authorization with MoodleNet server.
*
* @param {int} issuerId The OAuth 2 issuer ID.
* @param {int} courseId Course id.
* @param {int} resourceId Resource id.
* @param {int} shareFormat Share format.
* @return {promise}
*/
export const handleAuthorization = (issuerId, courseId, resourceId, shareFormat) => {
const windowSizeWidth = 550;
const windowSizeHeight = 550;
// Check if the user is authorized with MoodleNet or not.
return MoodleNetService.authorizationCheck(issuerId, courseId).then(async(data) => {
if (!data.status) {
// Not yet authorized.
// Declare moodleNetAuthorize variable, so we can call it later in the callback.
window.moodleNetAuthorize = (error, errorDescription) => {
// This will be called by the callback after the authorization is successful.
if (error === '') {
handleAuthorization(issuerId, courseId, resourceId, shareFormat);
} else if (error !== 'access_denied') {
displayAlert(
'Authorization error',
'Error: ' + error + '<br><br>Error description: ' + errorDescription,
'Cancel'
);
}
};
// Open the login url of the OAuth 2 issuer for user to login into MoodleNet and authorize.
return window.open(data.loginurl, 'moodlenet_auth',
`location=0,status=0,width=${windowSizeWidth},height=${windowSizeHeight},scrollbars=yes`);
} else {
// Already authorized.
return sendToMoodleNet(issuerId, resourceId, shareFormat);
}
}).catch(displayException);
};
+27
View File
@@ -0,0 +1,27 @@
// 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/>.
/**
* MoodleNet events.
*
* @module core/moodlenet/events
* @copyright 2023 Huong Nguyen <huongnv13@gmail.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
* @since 4.3
*/
export default {
MOODLENET_SHARE_STARTED: 'moodlenet-share-started',
};

Some files were not shown because too many files have changed in this diff Show More