From 16d77f18846ffad370504713b0956c2cd44c0f88 Mon Sep 17 00:00:00 2001 From: Mathew May Date: Fri, 5 Jun 2020 10:09:37 +0800 Subject: [PATCH 1/2] MDL-67883 core: Make core ready for MoodleNet. --- admin/settings/subsystems.php | 3 - admin/settings/users.php | 1 + course/amd/build/activitychooser.min.js | 2 +- course/amd/build/activitychooser.min.js.map | 2 +- .../local/activitychooser/dialogue.min.js | 2 +- .../local/activitychooser/dialogue.min.js.map | 2 +- .../local/activitychooser/repository.min.js | 2 +- .../activitychooser/repository.min.js.map | 2 +- course/amd/src/activitychooser.js | 22 ++++- .../amd/src/local/activitychooser/dialogue.js | 66 +++++++++++--- .../src/local/activitychooser/repository.js | 19 ++++ .../local/entity/activity_chooser_footer.php | 86 ++++++++++++++++++ course/externallib.php | 70 ++++++++++++++ course/templates/activitychooser.mustache | 2 + .../activitychooser/footer_partial.mustache | 33 +++++++ .../local/activitychooser/help.mustache | 13 +-- course/tests/behat/activity_chooser.feature | 2 + lang/en/admin.php | 2 - lang/en/user.php | 2 + lib/db/install.xml | 1 + lib/db/services.php | 8 ++ lib/db/upgrade.php | 14 +++ lib/myprofilelib.php | 6 ++ mod/url/lib.php | 1 + pix/MoodleNet.png | Bin 0 -> 66122 bytes pix/MoodleNet.svg | 1 + theme/boost/scss/moodle/core.scss | 19 +++- theme/boost/style/moodle.css | 13 ++- theme/classic/style/moodle.css | 13 ++- user/classes/privacy/provider.php | 3 +- user/editlib.php | 3 + version.php | 2 +- 32 files changed, 375 insertions(+), 42 deletions(-) create mode 100644 course/classes/local/entity/activity_chooser_footer.php create mode 100644 course/templates/local/activitychooser/footer_partial.mustache create mode 100644 pix/MoodleNet.png create mode 100644 pix/MoodleNet.svg diff --git a/admin/settings/subsystems.php b/admin/settings/subsystems.php index 14a133126e7..01de61c3e36 100644 --- a/admin/settings/subsystems.php +++ b/admin/settings/subsystems.php @@ -74,7 +74,4 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page new lang_string('configallowemojipickerincompatible', 'admin') )); } - - $optionalsubsystems->add(new admin_setting_configcheckbox('enablemoodlenet', new lang_string('enablemoodlenet', 'admin'), - new lang_string('enablemoodlenet_desc', 'admin'), 1, 1, 0)); } diff --git a/admin/settings/users.php b/admin/settings/users.php index b9612cbe76a..0b7c4a4aa47 100644 --- a/admin/settings/users.php +++ b/admin/settings/users.php @@ -186,6 +186,7 @@ if ($hassiteconfig 'email' => new lang_string('email'), 'city' => new lang_string('city'), 'country' => new lang_string('country'), + 'moodlenetprofile' => new lang_string('moodlenetprofile', 'user'), 'timezone' => new lang_string('timezone'), 'webpage' => new lang_string('webpage'), 'icqnumber' => new lang_string('icqnumber'), diff --git a/course/amd/build/activitychooser.min.js b/course/amd/build/activitychooser.min.js index 82e4295f945..27aebcf5b58 100644 --- a/course/amd/build/activitychooser.min.js +++ b/course/amd/build/activitychooser.min.js @@ -1,2 +1,2 @@ -function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("core_course/activitychooser",["exports","core_course/local/activitychooser/dialogue","core_course/local/activitychooser/repository","core_course/local/activitychooser/selectors","core/custom_interaction_events","core/templates","core/modal_factory","core/str","core/pending"],function(a,b,c,d,e,f,g,h,i){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=l(b);c=l(c);d=j(d);e=j(e);f=l(f);g=l(g);i=j(i);function j(a){return a&&a.__esModule?a:{default:a}}function k(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;k=function(){return a};return a}function l(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=k();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var f=d?Object.getOwnPropertyDescriptor(a,e):null;if(f&&(f.get||f.set)){Object.defineProperty(c,e,f)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function m(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function n(a){return function(){var b=this,c=arguments;return new Promise(function(d,e){var h=a.apply(b,c);function f(a){m(h,d,e,f,g,"next",a)}function g(a){m(h,d,e,f,g,"throw",a)}f(void 0)})}}var o=2,p=function(a,b){var c=new i.default;q(a,b);c.resolve()};a.init=p;var q=function(a,g){var h=["click",e.default.events.activate,e.default.events.keyboardActivate],i=function(){var b=null;return function(){if(!b){b=new Promise(function(b){b(c.activityModules(a))})}return b}}();e.default.define(document,h);h.forEach(function(a){document.addEventListener(a,function(){var a=n(regeneratorRuntime.mark(function a(c){var e,h,j,k,l,m,n,o;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:if(!c.target.closest(d.default.elements.sectionmodchooser)){a.next=16;break}h=c.target.closest(d.default.elements.section);j=c.target.closest(d.default.elements.sectionmodchooser);if(null!==h&&h.hasAttribute("data-sectionid")){e=h}else{e=j}l=new Promise(function(a){k=a});m=t(l);a.next=8;return i();case 8:n=a.sent;o=r(n,e.dataset.sectionid);b.displayChooser(m,o,v(n,e.dataset.sectionid));a.t0=k;a.next=14;return f.render("core_course/activitychooser",s(o,g));case 14:a.t1=a.sent;(0,a.t0)(a.t1);case 16:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}())})},r=function(a,b){var c=JSON.parse(JSON.stringify(a));c.content_items.forEach(function(a){a.link+="§ion="+b});return c.content_items},s=function(a,b){var c=[],d=[],e=!0,f=!1,g=!1,h=parseInt(b.tabmode),i=a.filter(function(a){return!0===a.favourite}),j=a.filter(function(a){return!0===a.recommended});if((h===0||h===o)&&h!==1){c=a.filter(function(a){return a.archetype===0});d=a.filter(function(a){return a.archetype===1});f=!0;g=!0;if(h===o){e=!1}}var k=!!i.length,l=!1===e&&!1===k,m=!0===e&&!1===k;return{default:a,showAll:e,activities:c,showActivities:f,activitiesFirst:l,resources:d,showResources:g,favourites:i,recommended:j,favouritesFirst:k,fallback:m}},t=function(a){return g.create({type:g.types.DEFAULT,title:(0,h.get_string)("addresourceoractivity"),body:a,large:!0,templateContext:{classes:"modchooser"}}).then(function(a){a.show();return a})},u=function(a,b){a.tabIndex=-1;a.classList.add("d-none");if(a.classList.contains("active")){a.classList.remove("active");a.setAttribute("aria-selected","false");var f=b.querySelector(d.default.regions.favouriteTab);f.classList.remove("active");var c=b.querySelector(d.default.regions.defaultTabNav),e=b.querySelector(d.default.regions.activityTabNav);if(!1===c.classList.contains("d-none")){c.classList.add("active");c.setAttribute("aria-selected","true");c.tabIndex=0;c.focus();var g=b.querySelector(d.default.regions.defaultTab);g.classList.add("active")}else{e.classList.add("active");e.setAttribute("aria-selected","true");e.tabIndex=0;e.focus();var h=b.querySelector(d.default.regions.activityTab);h.classList.add("active")}}},v=function(a,b){return function(){var c=n(regeneratorRuntime.mark(function c(e,g,h){var i,j,k,l,m,n,o,p,q,s,t;return regeneratorRuntime.wrap(function(c){while(1){switch(c.prev=c.next){case 0:i=h.querySelector(d.default.render.favourites);j=h.querySelectorAll("[data-internal=\"".concat(e,"\"] ").concat(d.default.actions.optionActions.manageFavourite));k=h.querySelector(d.default.regions.favouriteTabNav);l=a.content_items.find(function(a){var b=a.name;return b===e});m={};if(!l){c.next=27;break}if(!g){c.next=21;break}l.favourite=!0;m.content_items=a.content_items.filter(function(a){return!0===a.favourite});n=r(m,b);c.next=12;return f.renderForPromise("core_course/local/activitychooser/favourites",{favourites:n});case 12:o=c.sent;p=o.html;q=o.js;c.next=17;return f.replaceNodeContents(i,p,q);case 17:Array.from(j).forEach(function(a){a.classList.remove("text-muted");a.classList.add("text-primary");a.dataset.favourited="true";a.setAttribute("aria-pressed",!0);a.firstElementChild.classList.remove("fa-star-o");a.firstElementChild.classList.add("fa-star")});k.classList.remove("d-none");c.next=27;break;case 21:l.favourite=!1;s=i.querySelector("[data-internal=\"".concat(e,"\"]"));s.parentNode.removeChild(s);Array.from(j).forEach(function(a){a.classList.add("text-muted");a.classList.remove("text-primary");a.dataset.favourited="false";a.setAttribute("aria-pressed",!1);a.firstElementChild.classList.remove("fa-star");a.firstElementChild.classList.add("fa-star-o")});t=a.content_items.filter(function(a){return!0===a.favourite});if(0===t.length){u(k,h)}case 27:case"end":return c.stop();}}},c)}));return function(){return c.apply(this,arguments)}}()}}); +function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("core_course/activitychooser",["exports","core_course/local/activitychooser/dialogue","core_course/local/activitychooser/repository","core_course/local/activitychooser/selectors","core/custom_interaction_events","core/templates","core/modal_factory","core/str","core/pending"],function(a,b,c,d,e,f,g,h,i){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=l(b);c=l(c);d=j(d);e=j(e);f=l(f);g=l(g);i=j(i);function j(a){return a&&a.__esModule?a:{default:a}}function k(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;k=function(){return a};return a}function l(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=k();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var f=d?Object.getOwnPropertyDescriptor(a,e):null;if(f&&(f.get||f.set)){Object.defineProperty(c,e,f)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function m(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function n(a){return function(){var b=this,c=arguments;return new Promise(function(d,e){var h=a.apply(b,c);function f(a){m(h,d,e,f,g,"next",a)}function g(a){m(h,d,e,f,g,"throw",a)}f(void 0)})}}var o=2,p=function(a,b){var c=new i.default;q(a,b);c.resolve()};a.init=p;var q=function(a,g){var h=["click",e.default.events.activate,e.default.events.keyboardActivate],i=function(){var b=null;return function(){if(!b){b=new Promise(function(b){b(c.activityModules(a))})}return b}}(),j=function(){var b=null;return function(d){if(!b){b=new Promise(function(b){b(c.fetchFooterData(a,d))})}return b}}();e.default.define(document,h);h.forEach(function(a){document.addEventListener(a,function(){var a=n(regeneratorRuntime.mark(function a(c){var e,h,k,l,m,n,o,p,q;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:if(!c.target.closest(d.default.elements.sectionmodchooser)){a.next=19;break}h=c.target.closest(d.default.elements.section);k=c.target.closest(d.default.elements.sectionmodchooser);if(null!==h&&h.hasAttribute("data-sectionid")){e=h}else{e=k}m=new Promise(function(a){l=a});a.next=7;return j(e.dataset.sectionid);case 7:n=a.sent;o=t(m,n);a.next=11;return i();case 11:p=a.sent;q=r(p,e.dataset.sectionid);b.displayChooser(o,q,v(p,e.dataset.sectionid),n);a.t0=l;a.next=17;return f.render("core_course/activitychooser",s(q,g));case 17:a.t1=a.sent;(0,a.t0)(a.t1);case 19:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}())})},r=function(a,b){var c=JSON.parse(JSON.stringify(a));c.content_items.forEach(function(a){a.link+="§ion="+b});return c.content_items},s=function(a,b){var c=[],d=[],e=!0,f=!1,g=!1,h=parseInt(b.tabmode),i=a.filter(function(a){return!0===a.favourite}),j=a.filter(function(a){return!0===a.recommended});if((h===0||h===o)&&h!==1){c=a.filter(function(a){return a.archetype===0});d=a.filter(function(a){return a.archetype===1});f=!0;g=!0;if(h===o){e=!1}}var k=!!i.length,l=!1===e&&!1===k,m=!0===e&&!1===k;return{default:a,showAll:e,activities:c,showActivities:f,activitiesFirst:l,resources:d,showResources:g,favourites:i,recommended:j,favouritesFirst:k,fallback:m}},t=function(a,b){return g.create({type:g.types.DEFAULT,title:(0,h.get_string)("addresourceoractivity"),body:a,footer:b.customfootertemplate,large:!0,templateContext:{classes:"modchooser"}}).then(function(a){a.show();return a})},u=function(a,b){a.tabIndex=-1;a.classList.add("d-none");if(a.classList.contains("active")){a.classList.remove("active");a.setAttribute("aria-selected","false");var f=b.querySelector(d.default.regions.favouriteTab);f.classList.remove("active");var c=b.querySelector(d.default.regions.defaultTabNav),e=b.querySelector(d.default.regions.activityTabNav);if(!1===c.classList.contains("d-none")){c.classList.add("active");c.setAttribute("aria-selected","true");c.tabIndex=0;c.focus();var g=b.querySelector(d.default.regions.defaultTab);g.classList.add("active")}else{e.classList.add("active");e.setAttribute("aria-selected","true");e.tabIndex=0;e.focus();var h=b.querySelector(d.default.regions.activityTab);h.classList.add("active")}}},v=function(a,b){return function(){var c=n(regeneratorRuntime.mark(function c(e,g,h){var i,j,k,l,m,n,o,p,q,s,t;return regeneratorRuntime.wrap(function(c){while(1){switch(c.prev=c.next){case 0:i=h.querySelector(d.default.render.favourites);j=h.querySelectorAll("[data-internal=\"".concat(e,"\"] ").concat(d.default.actions.optionActions.manageFavourite));k=h.querySelector(d.default.regions.favouriteTabNav);l=a.content_items.find(function(a){var b=a.name;return b===e});m={};if(!l){c.next=27;break}if(!g){c.next=21;break}l.favourite=!0;m.content_items=a.content_items.filter(function(a){return!0===a.favourite});n=r(m,b);c.next=12;return f.renderForPromise("core_course/local/activitychooser/favourites",{favourites:n});case 12:o=c.sent;p=o.html;q=o.js;c.next=17;return f.replaceNodeContents(i,p,q);case 17:Array.from(j).forEach(function(a){a.classList.remove("text-muted");a.classList.add("text-primary");a.dataset.favourited="true";a.setAttribute("aria-pressed",!0);a.firstElementChild.classList.remove("fa-star-o");a.firstElementChild.classList.add("fa-star")});k.classList.remove("d-none");c.next=27;break;case 21:l.favourite=!1;s=i.querySelector("[data-internal=\"".concat(e,"\"]"));s.parentNode.removeChild(s);Array.from(j).forEach(function(a){a.classList.add("text-muted");a.classList.remove("text-primary");a.dataset.favourited="false";a.setAttribute("aria-pressed",!1);a.firstElementChild.classList.remove("fa-star");a.firstElementChild.classList.add("fa-star-o")});t=a.content_items.filter(function(a){return!0===a.favourite});if(0===t.length){u(k,h)}case 27:case"end":return c.stop();}}},c)}));return function(){return c.apply(this,arguments)}}()}}); //# sourceMappingURL=activitychooser.min.js.map diff --git a/course/amd/build/activitychooser.min.js.map b/course/amd/build/activitychooser.min.js.map index 288328b146a..055d5ac8f03 100644 --- a/course/amd/build/activitychooser.min.js.map +++ b/course/amd/build/activitychooser.min.js.map @@ -1 +1 @@ -{"version":3,"sources":["../src/activitychooser.js"],"names":["ACTIVITIESRESOURCES","init","courseId","chooserConfig","pendingPromise","Pending","registerListenerEvents","resolve","events","CustomEvents","activate","keyboardActivate","fetchModuleData","innerPromise","Promise","Repository","activityModules","define","document","forEach","event","addEventListener","e","target","closest","selectors","elements","sectionmodchooser","sectionDiv","section","button","hasAttribute","caller","bodyPromise","bodyPromiseResolver","sectionModal","buildModal","data","builtModuleData","sectionIdMapper","dataset","sectionid","ChooserDialogue","displayChooser","partiallyAppliedFavouriteManager","Templates","render","templateDataBuilder","webServiceData","id","newData","JSON","parse","stringify","content_items","module","link","activities","resources","showAll","showActivities","showResources","tabMode","parseInt","tabmode","favourites","filter","mod","favourite","recommended","archetype","favouritesFirst","length","activitiesFirst","fallback","ModalFactory","create","type","types","DEFAULT","title","body","large","templateContext","classes","then","modal","show","nullFavouriteDomManager","favouriteTabNav","modalBody","tabIndex","classList","add","contains","remove","setAttribute","favouriteTab","querySelector","regions","defaultTabNav","activitiesTabNav","activityTabNav","focus","defaultTab","activitiesTab","activityTab","moduleData","sectionId","internal","favouriteArea","favouriteButtons","querySelectorAll","actions","optionActions","manageFavourite","result","find","name","newFaves","builtFaves","renderForPromise","html","js","replaceNodeContents","Array","from","element","favourited","firstElementChild","nodeToRemove","parentNode","removeChild"],"mappings":"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","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A type of dialogue used as for choosing modules in a course.\n *\n * @module core_course/activitychooser\n * @package core_course\n * @copyright 2020 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as ChooserDialogue from 'core_course/local/activitychooser/dialogue';\nimport * as Repository from 'core_course/local/activitychooser/repository';\nimport selectors from 'core_course/local/activitychooser/selectors';\nimport CustomEvents from 'core/custom_interaction_events';\nimport * as Templates from 'core/templates';\nimport * as ModalFactory from 'core/modal_factory';\nimport {get_string as getString} from 'core/str';\nimport Pending from 'core/pending';\n\n// Set up some JS module wide constants that can be added to in the future.\n\n// Tab config options.\nconst ALLACTIVITIESRESOURCES = 0;\nconst ONLYALL = 1;\nconst ACTIVITIESRESOURCES = 2;\n\n// Module types.\nconst ACTIVITY = 0;\nconst RESOURCE = 1;\n\n/**\n * Set up the activity chooser.\n *\n * @method init\n * @param {Number} courseId Course ID to use later on in fetchModules()\n * @param {Object} chooserConfig Any PHP config settings that we may need to reference\n */\nexport const init = (courseId, chooserConfig) => {\n const pendingPromise = new Pending();\n\n registerListenerEvents(courseId, chooserConfig);\n\n pendingPromise.resolve();\n};\n\n/**\n * Once a selection has been made make the modal & module information and pass it along\n *\n * @method registerListenerEvents\n * @param {Number} courseId\n * @param {Object} chooserConfig Any PHP config settings that we may need to reference\n */\nconst registerListenerEvents = (courseId, chooserConfig) => {\n const events = [\n 'click',\n CustomEvents.events.activate,\n CustomEvents.events.keyboardActivate\n ];\n\n const fetchModuleData = (() => {\n let innerPromise = null;\n\n return () => {\n if (!innerPromise) {\n innerPromise = new Promise((resolve) => {\n resolve(Repository.activityModules(courseId));\n });\n }\n\n return innerPromise;\n };\n })();\n\n CustomEvents.define(document, events);\n\n // Display module chooser event listeners.\n events.forEach((event) => {\n document.addEventListener(event, async(e) => {\n if (e.target.closest(selectors.elements.sectionmodchooser)) {\n let caller;\n // We need to know who called this.\n // Standard courses use the ID in the main section info.\n const sectionDiv = e.target.closest(selectors.elements.section);\n // Front page courses need some special handling.\n const button = e.target.closest(selectors.elements.sectionmodchooser);\n\n // If we don't have a section ID use the fallback ID.\n // We always want the sectionDiv caller first as it keeps track of section ID's after DnD changes.\n // The button attribute is always just a fallback for us as the section div is not always available.\n // A YUI change could be done maybe to only update the button attribute but we are going for minimal change here.\n if (sectionDiv !== null && sectionDiv.hasAttribute('data-sectionid')) {\n // We check for attributes just in case of outdated contrib course formats.\n caller = sectionDiv;\n } else {\n caller = button;\n }\n\n // We want to show the modal instantly but loading whilst waiting for our data.\n let bodyPromiseResolver;\n const bodyPromise = new Promise(resolve => {\n bodyPromiseResolver = resolve;\n });\n\n const sectionModal = buildModal(bodyPromise);\n\n // Now we have a modal we should start fetching data.\n const data = await fetchModuleData();\n\n // Apply the section id to all the module instance links.\n const builtModuleData = sectionIdMapper(data, caller.dataset.sectionid);\n\n ChooserDialogue.displayChooser(\n sectionModal,\n builtModuleData,\n partiallyAppliedFavouriteManager(data, caller.dataset.sectionid),\n );\n\n bodyPromiseResolver(await Templates.render(\n 'core_course/activitychooser',\n templateDataBuilder(builtModuleData, chooserConfig)\n ));\n }\n });\n });\n};\n\n/**\n * Given the web service data and an ID we want to make a deep copy\n * of the WS data then add on the section ID to the addoption URL\n *\n * @method sectionIdMapper\n * @param {Object} webServiceData Our original data from the Web service call\n * @param {Number} id The ID of the section we need to append to the links\n * @return {Array} [modules] with URL's built\n */\nconst sectionIdMapper = (webServiceData, id) => {\n // We need to take a fresh deep copy of the original data as an object is a reference type.\n const newData = JSON.parse(JSON.stringify(webServiceData));\n newData.content_items.forEach((module) => {\n module.link += '§ion=' + id;\n });\n return newData.content_items;\n};\n\n/**\n * Given an array of modules we want to figure out where & how to place them into our template object\n *\n * @method templateDataBuilder\n * @param {Array} data our modules to manipulate into a Templatable object\n * @param {Object} chooserConfig Any PHP config settings that we may need to reference\n * @return {Object} Our built object ready to render out\n */\nconst templateDataBuilder = (data, chooserConfig) => {\n // Setup of various bits and pieces we need to mutate before throwing it to the wolves.\n let activities = [];\n let resources = [];\n let showAll = true;\n let showActivities = false;\n let showResources = false;\n\n // Tab mode can be the following [All, Resources & Activities, All & Activities & Resources].\n const tabMode = parseInt(chooserConfig.tabmode);\n\n // Filter the incoming data to find favourite & recommended modules.\n const favourites = data.filter(mod => mod.favourite === true);\n const recommended = data.filter(mod => mod.recommended === true);\n\n // Both of these modes need Activity & Resource tabs.\n if ((tabMode === ALLACTIVITIESRESOURCES || tabMode === ACTIVITIESRESOURCES) && tabMode !== ONLYALL) {\n // Filter the incoming data to find activities then resources.\n activities = data.filter(mod => mod.archetype === ACTIVITY);\n resources = data.filter(mod => mod.archetype === RESOURCE);\n showActivities = true;\n showResources = true;\n\n // We want all of the previous information but no 'All' tab.\n if (tabMode === ACTIVITIESRESOURCES) {\n showAll = false;\n }\n }\n\n // Given the results of the above filters lets figure out what tab to set active.\n // We have some favourites.\n const favouritesFirst = !!favourites.length;\n // We are in tabMode 2 without any favourites.\n const activitiesFirst = showAll === false && favouritesFirst === false;\n // We have nothing fallback to show all modules.\n const fallback = showAll === true && favouritesFirst === false;\n\n return {\n 'default': data,\n showAll: showAll,\n activities: activities,\n showActivities: showActivities,\n activitiesFirst: activitiesFirst,\n resources: resources,\n showResources: showResources,\n favourites: favourites,\n recommended: recommended,\n favouritesFirst: favouritesFirst,\n fallback: fallback,\n };\n};\n\n/**\n * Given an object we want to build a modal ready to show\n *\n * @method buildModal\n * @param {Promise} bodyPromise\n * @return {Object} The modal ready to display immediately and render body in later.\n */\nconst buildModal = bodyPromise => {\n return ModalFactory.create({\n type: ModalFactory.types.DEFAULT,\n title: getString('addresourceoractivity'),\n body: bodyPromise,\n large: true,\n templateContext: {\n classes: 'modchooser'\n }\n })\n .then(modal => {\n modal.show();\n return modal;\n });\n};\n\n/**\n * A small helper function to handle the case where there are no more favourites\n * and we need to mess a bit with the available tabs in the chooser\n *\n * @method nullFavouriteDomManager\n * @param {HTMLElement} favouriteTabNav Dom node of the favourite tab nav\n * @param {HTMLElement} modalBody Our current modals' body\n */\nconst nullFavouriteDomManager = (favouriteTabNav, modalBody) => {\n favouriteTabNav.tabIndex = -1;\n favouriteTabNav.classList.add('d-none');\n // Need to set active to an available tab.\n if (favouriteTabNav.classList.contains('active')) {\n favouriteTabNav.classList.remove('active');\n favouriteTabNav.setAttribute('aria-selected', 'false');\n const favouriteTab = modalBody.querySelector(selectors.regions.favouriteTab);\n favouriteTab.classList.remove('active');\n const defaultTabNav = modalBody.querySelector(selectors.regions.defaultTabNav);\n const activitiesTabNav = modalBody.querySelector(selectors.regions.activityTabNav);\n if (defaultTabNav.classList.contains('d-none') === false) {\n defaultTabNav.classList.add('active');\n defaultTabNav.setAttribute('aria-selected', 'true');\n defaultTabNav.tabIndex = 0;\n defaultTabNav.focus();\n const defaultTab = modalBody.querySelector(selectors.regions.defaultTab);\n defaultTab.classList.add('active');\n } else {\n activitiesTabNav.classList.add('active');\n activitiesTabNav.setAttribute('aria-selected', 'true');\n activitiesTabNav.tabIndex = 0;\n activitiesTabNav.focus();\n const activitiesTab = modalBody.querySelector(selectors.regions.activityTab);\n activitiesTab.classList.add('active');\n }\n\n }\n};\n\n/**\n * Export a curried function where the builtModules has been applied.\n * We have our array of modules so we can rerender the favourites area and have all of the items sorted.\n *\n * @method partiallyAppliedFavouriteManager\n * @param {Array} moduleData This is our raw WS data that we need to manipulate\n * @param {Number} sectionId We need this to add the sectionID to the URL's in the faves area after rerender\n * @return {Function} partially applied function so we can manipulate DOM nodes easily & update our internal array\n */\nconst partiallyAppliedFavouriteManager = (moduleData, sectionId) => {\n /**\n * Curried function that is being returned.\n *\n * @param {String} internal Internal name of the module to manage\n * @param {Boolean} favourite Is the caller adding a favourite or removing one?\n * @param {HTMLElement} modalBody What we need to update whilst we are here\n */\n return async(internal, favourite, modalBody) => {\n const favouriteArea = modalBody.querySelector(selectors.render.favourites);\n\n // eslint-disable-next-line max-len\n const favouriteButtons = modalBody.querySelectorAll(`[data-internal=\"${internal}\"] ${selectors.actions.optionActions.manageFavourite}`);\n const favouriteTabNav = modalBody.querySelector(selectors.regions.favouriteTabNav);\n const result = moduleData.content_items.find(({name}) => name === internal);\n const newFaves = {};\n if (result) {\n if (favourite) {\n result.favourite = true;\n\n // eslint-disable-next-line camelcase\n newFaves.content_items = moduleData.content_items.filter(mod => mod.favourite === true);\n\n const builtFaves = sectionIdMapper(newFaves, sectionId);\n\n const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/favourites',\n {favourites: builtFaves});\n\n await Templates.replaceNodeContents(favouriteArea, html, js);\n\n Array.from(favouriteButtons).forEach((element) => {\n element.classList.remove('text-muted');\n element.classList.add('text-primary');\n element.dataset.favourited = 'true';\n element.setAttribute('aria-pressed', true);\n element.firstElementChild.classList.remove('fa-star-o');\n element.firstElementChild.classList.add('fa-star');\n });\n\n favouriteTabNav.classList.remove('d-none');\n } else {\n result.favourite = false;\n\n const nodeToRemove = favouriteArea.querySelector(`[data-internal=\"${internal}\"]`);\n\n nodeToRemove.parentNode.removeChild(nodeToRemove);\n\n Array.from(favouriteButtons).forEach((element) => {\n element.classList.add('text-muted');\n element.classList.remove('text-primary');\n element.dataset.favourited = 'false';\n element.setAttribute('aria-pressed', false);\n element.firstElementChild.classList.remove('fa-star');\n element.firstElementChild.classList.add('fa-star-o');\n });\n const newFaves = moduleData.content_items.filter(mod => mod.favourite === true);\n\n if (newFaves.length === 0) {\n nullFavouriteDomManager(favouriteTabNav, modalBody);\n }\n }\n }\n };\n};\n"],"file":"activitychooser.min.js"} \ No newline at end of file +{"version":3,"sources":["../src/activitychooser.js"],"names":["ACTIVITIESRESOURCES","init","courseId","chooserConfig","pendingPromise","Pending","registerListenerEvents","resolve","events","CustomEvents","activate","keyboardActivate","fetchModuleData","innerPromise","Promise","Repository","activityModules","fetchFooterData","footerInnerPromise","sectionId","define","document","forEach","event","addEventListener","e","target","closest","selectors","elements","sectionmodchooser","sectionDiv","section","button","hasAttribute","caller","bodyPromise","bodyPromiseResolver","dataset","sectionid","footerData","sectionModal","buildModal","data","builtModuleData","sectionIdMapper","ChooserDialogue","displayChooser","partiallyAppliedFavouriteManager","Templates","render","templateDataBuilder","webServiceData","id","newData","JSON","parse","stringify","content_items","module","link","activities","resources","showAll","showActivities","showResources","tabMode","parseInt","tabmode","favourites","filter","mod","favourite","recommended","archetype","favouritesFirst","length","activitiesFirst","fallback","footer","ModalFactory","create","type","types","DEFAULT","title","body","customfootertemplate","large","templateContext","classes","then","modal","show","nullFavouriteDomManager","favouriteTabNav","modalBody","tabIndex","classList","add","contains","remove","setAttribute","favouriteTab","querySelector","regions","defaultTabNav","activitiesTabNav","activityTabNav","focus","defaultTab","activitiesTab","activityTab","moduleData","internal","favouriteArea","favouriteButtons","querySelectorAll","actions","optionActions","manageFavourite","result","find","name","newFaves","builtFaves","renderForPromise","html","js","replaceNodeContents","Array","from","element","favourited","firstElementChild","nodeToRemove","parentNode","removeChild"],"mappings":"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","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A type of dialogue used as for choosing modules in a course.\n *\n * @module core_course/activitychooser\n * @package core_course\n * @copyright 2020 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport * as ChooserDialogue from 'core_course/local/activitychooser/dialogue';\nimport * as Repository from 'core_course/local/activitychooser/repository';\nimport selectors from 'core_course/local/activitychooser/selectors';\nimport CustomEvents from 'core/custom_interaction_events';\nimport * as Templates from 'core/templates';\nimport * as ModalFactory from 'core/modal_factory';\nimport {get_string as getString} from 'core/str';\nimport Pending from 'core/pending';\n\n// Set up some JS module wide constants that can be added to in the future.\n\n// Tab config options.\nconst ALLACTIVITIESRESOURCES = 0;\nconst ONLYALL = 1;\nconst ACTIVITIESRESOURCES = 2;\n\n// Module types.\nconst ACTIVITY = 0;\nconst RESOURCE = 1;\n\n/**\n * Set up the activity chooser.\n *\n * @method init\n * @param {Number} courseId Course ID to use later on in fetchModules()\n * @param {Object} chooserConfig Any PHP config settings that we may need to reference\n */\nexport const init = (courseId, chooserConfig) => {\n const pendingPromise = new Pending();\n\n registerListenerEvents(courseId, chooserConfig);\n\n pendingPromise.resolve();\n};\n\n/**\n * Once a selection has been made make the modal & module information and pass it along\n *\n * @method registerListenerEvents\n * @param {Number} courseId\n * @param {Object} chooserConfig Any PHP config settings that we may need to reference\n */\nconst registerListenerEvents = (courseId, chooserConfig) => {\n const events = [\n 'click',\n CustomEvents.events.activate,\n CustomEvents.events.keyboardActivate\n ];\n\n const fetchModuleData = (() => {\n let innerPromise = null;\n\n return () => {\n if (!innerPromise) {\n innerPromise = new Promise((resolve) => {\n resolve(Repository.activityModules(courseId));\n });\n }\n\n return innerPromise;\n };\n })();\n\n const fetchFooterData = (() => {\n let footerInnerPromise = null;\n\n return (sectionId) => {\n if (!footerInnerPromise) {\n footerInnerPromise = new Promise((resolve) => {\n resolve(Repository.fetchFooterData(courseId, sectionId));\n });\n }\n\n return footerInnerPromise;\n };\n })();\n\n CustomEvents.define(document, events);\n\n // Display module chooser event listeners.\n events.forEach((event) => {\n document.addEventListener(event, async(e) => {\n if (e.target.closest(selectors.elements.sectionmodchooser)) {\n let caller;\n // We need to know who called this.\n // Standard courses use the ID in the main section info.\n const sectionDiv = e.target.closest(selectors.elements.section);\n // Front page courses need some special handling.\n const button = e.target.closest(selectors.elements.sectionmodchooser);\n\n // If we don't have a section ID use the fallback ID.\n // We always want the sectionDiv caller first as it keeps track of section ID's after DnD changes.\n // The button attribute is always just a fallback for us as the section div is not always available.\n // A YUI change could be done maybe to only update the button attribute but we are going for minimal change here.\n if (sectionDiv !== null && sectionDiv.hasAttribute('data-sectionid')) {\n // We check for attributes just in case of outdated contrib course formats.\n caller = sectionDiv;\n } else {\n caller = button;\n }\n\n // We want to show the modal instantly but loading whilst waiting for our data.\n let bodyPromiseResolver;\n const bodyPromise = new Promise(resolve => {\n bodyPromiseResolver = resolve;\n });\n\n const footerData = await fetchFooterData(caller.dataset.sectionid);\n const sectionModal = buildModal(bodyPromise, footerData);\n\n // Now we have a modal we should start fetching data.\n const data = await fetchModuleData();\n\n // Apply the section id to all the module instance links.\n const builtModuleData = sectionIdMapper(data, caller.dataset.sectionid);\n\n ChooserDialogue.displayChooser(\n sectionModal,\n builtModuleData,\n partiallyAppliedFavouriteManager(data, caller.dataset.sectionid),\n footerData,\n );\n\n bodyPromiseResolver(await Templates.render(\n 'core_course/activitychooser',\n templateDataBuilder(builtModuleData, chooserConfig)\n ));\n }\n });\n });\n};\n\n/**\n * Given the web service data and an ID we want to make a deep copy\n * of the WS data then add on the section ID to the addoption URL\n *\n * @method sectionIdMapper\n * @param {Object} webServiceData Our original data from the Web service call\n * @param {Number} id The ID of the section we need to append to the links\n * @return {Array} [modules] with URL's built\n */\nconst sectionIdMapper = (webServiceData, id) => {\n // We need to take a fresh deep copy of the original data as an object is a reference type.\n const newData = JSON.parse(JSON.stringify(webServiceData));\n newData.content_items.forEach((module) => {\n module.link += '§ion=' + id;\n });\n return newData.content_items;\n};\n\n/**\n * Given an array of modules we want to figure out where & how to place them into our template object\n *\n * @method templateDataBuilder\n * @param {Array} data our modules to manipulate into a Templatable object\n * @param {Object} chooserConfig Any PHP config settings that we may need to reference\n * @return {Object} Our built object ready to render out\n */\nconst templateDataBuilder = (data, chooserConfig) => {\n // Setup of various bits and pieces we need to mutate before throwing it to the wolves.\n let activities = [];\n let resources = [];\n let showAll = true;\n let showActivities = false;\n let showResources = false;\n\n // Tab mode can be the following [All, Resources & Activities, All & Activities & Resources].\n const tabMode = parseInt(chooserConfig.tabmode);\n\n // Filter the incoming data to find favourite & recommended modules.\n const favourites = data.filter(mod => mod.favourite === true);\n const recommended = data.filter(mod => mod.recommended === true);\n\n // Both of these modes need Activity & Resource tabs.\n if ((tabMode === ALLACTIVITIESRESOURCES || tabMode === ACTIVITIESRESOURCES) && tabMode !== ONLYALL) {\n // Filter the incoming data to find activities then resources.\n activities = data.filter(mod => mod.archetype === ACTIVITY);\n resources = data.filter(mod => mod.archetype === RESOURCE);\n showActivities = true;\n showResources = true;\n\n // We want all of the previous information but no 'All' tab.\n if (tabMode === ACTIVITIESRESOURCES) {\n showAll = false;\n }\n }\n\n // Given the results of the above filters lets figure out what tab to set active.\n // We have some favourites.\n const favouritesFirst = !!favourites.length;\n // We are in tabMode 2 without any favourites.\n const activitiesFirst = showAll === false && favouritesFirst === false;\n // We have nothing fallback to show all modules.\n const fallback = showAll === true && favouritesFirst === false;\n\n return {\n 'default': data,\n showAll: showAll,\n activities: activities,\n showActivities: showActivities,\n activitiesFirst: activitiesFirst,\n resources: resources,\n showResources: showResources,\n favourites: favourites,\n recommended: recommended,\n favouritesFirst: favouritesFirst,\n fallback: fallback,\n };\n};\n\n/**\n * Given an object we want to build a modal ready to show\n *\n * @method buildModal\n * @param {Promise} bodyPromise\n * @param {String|Boolean} footer Either a footer to add or nothing\n * @return {Object} The modal ready to display immediately and render body in later.\n */\nconst buildModal = (bodyPromise, footer) => {\n return ModalFactory.create({\n type: ModalFactory.types.DEFAULT,\n title: getString('addresourceoractivity'),\n body: bodyPromise,\n footer: footer.customfootertemplate,\n large: true,\n templateContext: {\n classes: 'modchooser'\n }\n })\n .then(modal => {\n modal.show();\n return modal;\n });\n};\n\n/**\n * A small helper function to handle the case where there are no more favourites\n * and we need to mess a bit with the available tabs in the chooser\n *\n * @method nullFavouriteDomManager\n * @param {HTMLElement} favouriteTabNav Dom node of the favourite tab nav\n * @param {HTMLElement} modalBody Our current modals' body\n */\nconst nullFavouriteDomManager = (favouriteTabNav, modalBody) => {\n favouriteTabNav.tabIndex = -1;\n favouriteTabNav.classList.add('d-none');\n // Need to set active to an available tab.\n if (favouriteTabNav.classList.contains('active')) {\n favouriteTabNav.classList.remove('active');\n favouriteTabNav.setAttribute('aria-selected', 'false');\n const favouriteTab = modalBody.querySelector(selectors.regions.favouriteTab);\n favouriteTab.classList.remove('active');\n const defaultTabNav = modalBody.querySelector(selectors.regions.defaultTabNav);\n const activitiesTabNav = modalBody.querySelector(selectors.regions.activityTabNav);\n if (defaultTabNav.classList.contains('d-none') === false) {\n defaultTabNav.classList.add('active');\n defaultTabNav.setAttribute('aria-selected', 'true');\n defaultTabNav.tabIndex = 0;\n defaultTabNav.focus();\n const defaultTab = modalBody.querySelector(selectors.regions.defaultTab);\n defaultTab.classList.add('active');\n } else {\n activitiesTabNav.classList.add('active');\n activitiesTabNav.setAttribute('aria-selected', 'true');\n activitiesTabNav.tabIndex = 0;\n activitiesTabNav.focus();\n const activitiesTab = modalBody.querySelector(selectors.regions.activityTab);\n activitiesTab.classList.add('active');\n }\n\n }\n};\n\n/**\n * Export a curried function where the builtModules has been applied.\n * We have our array of modules so we can rerender the favourites area and have all of the items sorted.\n *\n * @method partiallyAppliedFavouriteManager\n * @param {Array} moduleData This is our raw WS data that we need to manipulate\n * @param {Number} sectionId We need this to add the sectionID to the URL's in the faves area after rerender\n * @return {Function} partially applied function so we can manipulate DOM nodes easily & update our internal array\n */\nconst partiallyAppliedFavouriteManager = (moduleData, sectionId) => {\n /**\n * Curried function that is being returned.\n *\n * @param {String} internal Internal name of the module to manage\n * @param {Boolean} favourite Is the caller adding a favourite or removing one?\n * @param {HTMLElement} modalBody What we need to update whilst we are here\n */\n return async(internal, favourite, modalBody) => {\n const favouriteArea = modalBody.querySelector(selectors.render.favourites);\n\n // eslint-disable-next-line max-len\n const favouriteButtons = modalBody.querySelectorAll(`[data-internal=\"${internal}\"] ${selectors.actions.optionActions.manageFavourite}`);\n const favouriteTabNav = modalBody.querySelector(selectors.regions.favouriteTabNav);\n const result = moduleData.content_items.find(({name}) => name === internal);\n const newFaves = {};\n if (result) {\n if (favourite) {\n result.favourite = true;\n\n // eslint-disable-next-line camelcase\n newFaves.content_items = moduleData.content_items.filter(mod => mod.favourite === true);\n\n const builtFaves = sectionIdMapper(newFaves, sectionId);\n\n const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/favourites',\n {favourites: builtFaves});\n\n await Templates.replaceNodeContents(favouriteArea, html, js);\n\n Array.from(favouriteButtons).forEach((element) => {\n element.classList.remove('text-muted');\n element.classList.add('text-primary');\n element.dataset.favourited = 'true';\n element.setAttribute('aria-pressed', true);\n element.firstElementChild.classList.remove('fa-star-o');\n element.firstElementChild.classList.add('fa-star');\n });\n\n favouriteTabNav.classList.remove('d-none');\n } else {\n result.favourite = false;\n\n const nodeToRemove = favouriteArea.querySelector(`[data-internal=\"${internal}\"]`);\n\n nodeToRemove.parentNode.removeChild(nodeToRemove);\n\n Array.from(favouriteButtons).forEach((element) => {\n element.classList.add('text-muted');\n element.classList.remove('text-primary');\n element.dataset.favourited = 'false';\n element.setAttribute('aria-pressed', false);\n element.firstElementChild.classList.remove('fa-star');\n element.firstElementChild.classList.add('fa-star-o');\n });\n const newFaves = moduleData.content_items.filter(mod => mod.favourite === true);\n\n if (newFaves.length === 0) {\n nullFavouriteDomManager(favouriteTabNav, modalBody);\n }\n }\n }\n };\n};\n"],"file":"activitychooser.min.js"} \ No newline at end of file diff --git a/course/amd/build/local/activitychooser/dialogue.min.js b/course/amd/build/local/activitychooser/dialogue.min.js index dde9eec09ea..dbb33f45fbe 100644 --- a/course/amd/build/local/activitychooser/dialogue.min.js +++ b/course/amd/build/local/activitychooser/dialogue.min.js @@ -1,2 +1,2 @@ -function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("core_course/local/activitychooser/dialogue",["exports","jquery","core/modal_events","core_course/local/activitychooser/selectors","core/templates","core/key_codes","core/loadingicon","core_course/local/activitychooser/repository","core/notification","core/utils"],function(a,b,c,d,e,f,g,h,i,j){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.displayChooser=void 0;b=m(b);c=l(c);d=m(d);e=l(e);h=l(h);i=m(i);function k(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;k=function(){return a};return a}function l(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=k();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var f=d?Object.getOwnPropertyDescriptor(a,e):null;if(f&&(f.get||f.set)){Object.defineProperty(c,e,f)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function m(a){return a&&a.__esModule?a:{default:a}}function n(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function o(a){return function(){var b=this,c=arguments;return new Promise(function(d,e){var h=a.apply(b,c);function f(a){n(h,d,e,f,g,"next",a)}function g(a){n(h,d,e,f,g,"throw",a)}f(void 0)})}}function p(a,b){return u(a)||t(a,b)||r(a,b)||q()}function q(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function r(a,b){if(!a)return;if("string"==typeof a)return s(a,b);var c=Object.prototype.toString.call(a).slice(8,-1);if("Object"===c&&a.constructor)c=a.constructor.name;if("Map"===c||"Set"===c)return Array.from(c);if("Arguments"===c||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(c))return s(a,b)}function s(a,b){if(null==b||b>a.length)b=a.length;for(var c=0,d=Array(b);ca.length)b=a.length;for(var c=0,d=Array(b);c.\n\n/**\n * A type of dialogue used as for choosing options.\n *\n * @module core_course/local/chooser/dialogue\n * @package core\n * @copyright 2019 Mihail Geshoski \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport * as ModalEvents from 'core/modal_events';\nimport selectors from 'core_course/local/activitychooser/selectors';\nimport * as Templates from 'core/templates';\nimport {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes';\nimport {addIconToContainer} from 'core/loadingicon';\nimport * as Repository from 'core_course/local/activitychooser/repository';\nimport Notification from 'core/notification';\nimport {debounce} from 'core/utils';\n\n/**\n * Given an event from the main module 'page' navigate to it's help section via a carousel.\n *\n * @method showModuleHelp\n * @param {jQuery} carousel Our initialized carousel to manipulate\n * @param {Object} moduleData Data of the module to carousel to\n */\nconst showModuleHelp = (carousel, moduleData) => {\n const help = carousel.find(selectors.regions.help)[0];\n help.innerHTML = '';\n help.classList.add('m-auto');\n\n // Add a spinner.\n const spinnerPromise = addIconToContainer(help);\n\n // Used later...\n let transitionPromiseResolver = null;\n const transitionPromise = new Promise(resolve => {\n transitionPromiseResolver = resolve;\n });\n\n // Build up the html & js ready to place into the help section.\n const contentPromise = Templates.renderForPromise('core_course/local/activitychooser/help', moduleData);\n\n // Wait for the content to be ready, and for the transition to be complet.\n Promise.all([contentPromise, spinnerPromise, transitionPromise])\n .then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))\n .then(() => {\n help.querySelector(selectors.regions.chooserSummary.header).focus();\n return help;\n })\n .catch(Notification.exception);\n\n // Move to the next slide, and resolve the transition promise when it's done.\n carousel.one('slid.bs.carousel', () => {\n transitionPromiseResolver();\n });\n // Trigger the transition between 'pages'.\n carousel.carousel('next');\n};\n\n/**\n * Given a user wants to change the favourite state of a module we either add or remove the status.\n * We also propergate this change across our map of modals.\n *\n * @method manageFavouriteState\n * @param {HTMLElement} modalBody The DOM node of the modal to manipulate\n * @param {HTMLElement} caller\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n */\nconst manageFavouriteState = async(modalBody, caller, partialFavourite) => {\n const isFavourite = caller.dataset.favourited;\n const id = caller.dataset.id;\n const name = caller.dataset.name;\n const internal = caller.dataset.internal;\n // Switch on fave or not.\n if (isFavourite === 'true') {\n await Repository.unfavouriteModule(name, id);\n\n partialFavourite(internal, false, modalBody);\n } else {\n await Repository.favouriteModule(name, id);\n\n partialFavourite(internal, true, modalBody);\n }\n\n};\n\n/**\n * Register chooser related event listeners.\n *\n * @method registerListenerEvents\n * @param {Promise} modal Our modal that we are working with\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n */\nconst registerListenerEvents = (modal, mappedModules, partialFavourite) => {\n const bodyClickListener = async(e) => {\n if (e.target.closest(selectors.actions.optionActions.showSummary)) {\n const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));\n\n const module = e.target.closest(selectors.regions.chooserOption.container);\n const moduleName = module.dataset.modname;\n const moduleData = mappedModules.get(moduleName);\n showModuleHelp(carousel, moduleData);\n }\n\n if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {\n const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);\n await manageFavouriteState(modal.getBody()[0], caller, partialFavourite);\n const activeSectionId = modal.getBody()[0].querySelector(selectors.elements.activetab).getAttribute(\"href\");\n const sectionChooserOptions = modal.getBody()[0]\n .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = sectionChooserOptions\n .querySelector(selectors.regions.chooserOption.container);\n toggleFocusableChooserOption(firstChooserOption, true);\n initChooserOptionsKeyboardNavigation(modal.getBody()[0], mappedModules, sectionChooserOptions);\n }\n\n // From the help screen go back to the module overview.\n if (e.target.matches(selectors.actions.closeOption)) {\n const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));\n\n // Trigger the transition between 'pages'.\n carousel.carousel('prev');\n carousel.on('slid.bs.carousel', () => {\n const allModules = modal.getBody()[0].querySelector(selectors.regions.modules);\n const caller = allModules.querySelector(selectors.regions.getModuleSelector(e.target.dataset.modname));\n caller.focus();\n });\n }\n\n // The \"clear search\" button is triggered.\n if (e.target.closest(selectors.actions.clearSearch)) {\n // Clear the entered search query in the search bar and hide the search results container.\n const searchInput = modal.getBody()[0].querySelector(selectors.actions.search);\n searchInput.value = \"\";\n searchInput.focus();\n toggleSearchResultsView(modal.getBody()[0], mappedModules, searchInput.value);\n }\n };\n\n modal.getBodyPromise()\n\n // The return value of getBodyPromise is a jquery object containing the body NodeElement.\n .then(body => body[0])\n\n // Set up the carousel.\n .then(body => {\n $(body.querySelector(selectors.regions.carousel))\n .carousel({\n interval: false,\n pause: true,\n keyboard: false\n });\n\n return body;\n })\n\n // Add the listener for clicks on the body.\n .then(body => {\n body.addEventListener('click', bodyClickListener);\n return body;\n })\n\n // Add a listener for an input change in the activity chooser's search bar.\n .then(body => {\n const searchInput = body.querySelector(selectors.actions.search);\n // The search input is triggered.\n searchInput.addEventListener('input', debounce(() => {\n // Display the search results.\n toggleSearchResultsView(body, mappedModules, searchInput.value);\n }, 300));\n return body;\n })\n\n // Register event listeners related to the keyboard navigation controls.\n .then(body => {\n // Get the active chooser options section.\n const activeSectionId = body.querySelector(selectors.elements.activetab).getAttribute(\"href\");\n const sectionChooserOptions = body.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = sectionChooserOptions.querySelector(selectors.regions.chooserOption.container);\n\n toggleFocusableChooserOption(firstChooserOption, true);\n initTabsKeyboardNavigation(body);\n initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions);\n\n return body;\n })\n .catch();\n\n};\n\n/**\n * Initialise the keyboard navigation controls for the tab list items.\n *\n * @method initTabsKeyboardNavigation\n * @param {HTMLElement} body Our modal that we are working with\n */\nconst initTabsKeyboardNavigation = (body) => {\n // Set up the tab handlers.\n const favTabNav = body.querySelector(selectors.regions.favouriteTabNav);\n const recommendedTabNav = body.querySelector(selectors.regions.recommendedTabNav);\n const defaultTabNav = body.querySelector(selectors.regions.defaultTabNav);\n const activityTabNav = body.querySelector(selectors.regions.activityTabNav);\n const resourceTabNav = body.querySelector(selectors.regions.resourceTabNav);\n const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav, activityTabNav, resourceTabNav];\n tabNavArray.forEach((element) => {\n return element.addEventListener('keydown', (e) => {\n // The first visible navigation tab link.\n const firstLink = e.target.parentElement.querySelector(selectors.elements.visibletabs);\n // The last navigation tab link. It would always be the default activities tab link.\n const lastLink = e.target.parentElement.lastElementChild;\n\n if (e.keyCode === arrowRight) {\n const nextLink = e.target.nextElementSibling;\n if (nextLink === null) {\n e.target.tabIndex = -1;\n firstLink.tabIndex = 0;\n firstLink.focus();\n } else if (nextLink.classList.contains('d-none')) {\n e.target.tabIndex = -1;\n lastLink.tabIndex = 0;\n lastLink.focus();\n } else {\n e.target.tabIndex = -1;\n nextLink.tabIndex = 0;\n nextLink.focus();\n }\n }\n if (e.keyCode === arrowLeft) {\n const previousLink = e.target.previousElementSibling;\n if (previousLink === null) {\n e.target.tabIndex = -1;\n lastLink.tabIndex = 0;\n lastLink.focus();\n } else if (previousLink.classList.contains('d-none')) {\n e.target.tabIndex = -1;\n firstLink.tabIndex = 0;\n firstLink.focus();\n } else {\n e.target.tabIndex = -1;\n previousLink.tabIndex = 0;\n previousLink.focus();\n }\n }\n if (e.keyCode === home) {\n e.target.tabIndex = -1;\n firstLink.tabIndex = 0;\n firstLink.focus();\n }\n if (e.keyCode === end) {\n e.target.tabIndex = -1;\n lastLink.tabIndex = 0;\n lastLink.focus();\n }\n if (e.keyCode === space) {\n e.preventDefault();\n e.target.click();\n }\n });\n });\n};\n\n/**\n * Initialise the keyboard navigation controls for the chooser options.\n *\n * @method initChooserOptionsKeyboardNavigation\n * @param {HTMLElement} body Our modal that we are working with\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {HTMLElement} chooserOptionsContainer The section that contains the chooser items\n */\nconst initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOptionsContainer) => {\n const chooserOptions = chooserOptionsContainer.querySelectorAll(selectors.regions.chooserOption.container);\n\n Array.from(chooserOptions).forEach((element) => {\n return element.addEventListener('keydown', (e) => {\n\n // Check for enter/ space triggers for showing the help.\n if (e.keyCode === enter || e.keyCode === space) {\n if (e.target.matches(selectors.actions.optionActions.showSummary)) {\n e.preventDefault();\n const module = e.target.closest(selectors.regions.chooserOption.container);\n const moduleName = module.dataset.modname;\n const moduleData = mappedModules.get(moduleName);\n const carousel = $(body.querySelector(selectors.regions.carousel));\n carousel.carousel({\n interval: false,\n pause: true,\n keyboard: false\n });\n showModuleHelp(carousel, moduleData);\n }\n }\n\n // Next.\n if (e.keyCode === arrowRight) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const nextOption = currentOption.nextElementSibling;\n const firstOption = chooserOptionsContainer.firstElementChild;\n const toFocusOption = clickErrorHandler(nextOption, firstOption);\n focusChooserOption(toFocusOption, currentOption);\n }\n\n // Previous.\n if (e.keyCode === arrowLeft) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const previousOption = currentOption.previousElementSibling;\n const lastOption = chooserOptionsContainer.lastElementChild;\n const toFocusOption = clickErrorHandler(previousOption, lastOption);\n focusChooserOption(toFocusOption, currentOption);\n }\n\n if (e.keyCode === home) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const firstOption = chooserOptionsContainer.firstElementChild;\n focusChooserOption(firstOption, currentOption);\n }\n\n if (e.keyCode === end) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const lastOption = chooserOptionsContainer.lastElementChild;\n focusChooserOption(lastOption, currentOption);\n }\n });\n });\n};\n\n/**\n * Focus on a chooser option element and remove the previous chooser element from the focus order\n *\n * @method focusChooserOption\n * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus\n * @param {HTMLElement|null} previousChooserOption The previous focused option element\n */\nconst focusChooserOption = (currentChooserOption, previousChooserOption = null) => {\n if (previousChooserOption !== null) {\n toggleFocusableChooserOption(previousChooserOption, false);\n }\n\n toggleFocusableChooserOption(currentChooserOption, true);\n currentChooserOption.focus();\n};\n\n/**\n * Add or remove a chooser option from the focus order.\n *\n * @method toggleFocusableChooserOption\n * @param {HTMLElement} chooserOption The chooser option element which should be added or removed from the focus order\n * @param {Boolean} isFocusable Whether the chooser element is focusable or not\n */\nconst toggleFocusableChooserOption = (chooserOption, isFocusable) => {\n const chooserOptionLink = chooserOption.querySelector(selectors.actions.addChooser);\n const chooserOptionHelp = chooserOption.querySelector(selectors.actions.optionActions.showSummary);\n const chooserOptionFavourite = chooserOption.querySelector(selectors.actions.optionActions.manageFavourite);\n\n if (isFocusable) {\n // Set tabindex to 0 to add current chooser option element to the focus order.\n chooserOption.tabIndex = 0;\n chooserOptionLink.tabIndex = 0;\n chooserOptionHelp.tabIndex = 0;\n chooserOptionFavourite.tabIndex = 0;\n } else {\n // Set tabindex to -1 to remove the previous chooser option element from the focus order.\n chooserOption.tabIndex = -1;\n chooserOptionLink.tabIndex = -1;\n chooserOptionHelp.tabIndex = -1;\n chooserOptionFavourite.tabIndex = -1;\n }\n};\n\n/**\n * Small error handling function to make sure the navigated to object exists\n *\n * @method clickErrorHandler\n * @param {HTMLElement} item What we want to check exists\n * @param {HTMLElement} fallback If we dont match anything fallback the focus\n * @return {HTMLElement}\n */\nconst clickErrorHandler = (item, fallback) => {\n if (item !== null) {\n return item;\n } else {\n return fallback;\n }\n};\n\n/**\n * Render the search results in a defined container\n *\n * @method renderSearchResults\n * @param {HTMLElement} searchResultsContainer The container where the data should be rendered\n * @param {Object} searchResultsData Data containing the module items that satisfy the search criteria\n */\nconst renderSearchResults = async(searchResultsContainer, searchResultsData) => {\n const templateData = {\n 'searchresultsnumber': searchResultsData.length,\n 'searchresults': searchResultsData\n };\n // Build up the html & js ready to place into the help section.\n const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/search_results', templateData);\n await Templates.replaceNodeContents(searchResultsContainer, html, js);\n};\n\n/**\n * Toggle (display/hide) the search results depending on the value of the search query\n *\n * @method toggleSearchResultsView\n * @param {HTMLElement} modalBody The body of the created modal for the section\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {String} searchQuery The search query\n */\nconst toggleSearchResultsView = async(modalBody, mappedModules, searchQuery) => {\n const searchResultsContainer = modalBody.querySelector(selectors.regions.searchResults);\n const chooserContainer = modalBody.querySelector(selectors.regions.chooser);\n const clearSearchButton = modalBody.querySelector(selectors.elements.clearsearch);\n const searchIcon = modalBody.querySelector(selectors.elements.searchicon);\n\n if (searchQuery.length > 0) { // Search query is present.\n const searchResultsData = searchModules(mappedModules, searchQuery);\n await renderSearchResults(searchResultsContainer, searchResultsData);\n const searchResultItemsContainer = searchResultsContainer.querySelector(selectors.regions.searchResultItems);\n const firstSearchResultItem = searchResultItemsContainer.querySelector(selectors.regions.chooserOption.container);\n if (firstSearchResultItem) {\n // Set the first result item to be focusable.\n toggleFocusableChooserOption(firstSearchResultItem, true);\n // Register keyboard events on the created search result items.\n initChooserOptionsKeyboardNavigation(modalBody, mappedModules, searchResultItemsContainer);\n }\n // Display the \"clear\" search button in the activity chooser search bar.\n searchIcon.classList.add('d-none');\n clearSearchButton.classList.remove('d-none');\n // Hide the default chooser options container.\n chooserContainer.setAttribute('hidden', 'hidden');\n // Display the search results container.\n searchResultsContainer.removeAttribute('hidden');\n } else { // Search query is not present.\n // Hide the \"clear\" search button in the activity chooser search bar.\n clearSearchButton.classList.add('d-none');\n searchIcon.classList.remove('d-none');\n // Hide the search results container.\n searchResultsContainer.setAttribute('hidden', 'hidden');\n // Display the default chooser options container.\n chooserContainer.removeAttribute('hidden');\n }\n};\n\n/**\n * Return the list of modules which have a name or description that matches the given search term.\n *\n * @method searchModules\n * @param {Array} modules List of available modules\n * @param {String} searchTerm The search term to match\n * @return {Array}\n */\nconst searchModules = (modules, searchTerm) => {\n if (searchTerm === '') {\n return modules;\n }\n searchTerm = searchTerm.toLowerCase();\n const searchResults = [];\n modules.forEach((activity) => {\n const activityName = activity.title.toLowerCase();\n const activityDesc = activity.help.toLowerCase();\n if (activityName.includes(searchTerm) || activityDesc.includes(searchTerm)) {\n searchResults.push(activity);\n }\n });\n\n return searchResults;\n};\n\n/**\n * Set up our tabindex information across the chooser.\n *\n * @method setupKeyboardAccessibility\n * @param {Promise} modal Our created modal for the section\n * @param {Map} mappedModules A map of all of the built module information\n */\nconst setupKeyboardAccessibility = (modal, mappedModules) => {\n modal.getModal()[0].tabIndex = -1;\n\n modal.getBodyPromise().then(body => {\n $(selectors.elements.tab).on('shown.bs.tab', (e) => {\n const activeSectionId = e.target.getAttribute(\"href\");\n const activeSectionChooserOptions = body[0]\n .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = activeSectionChooserOptions\n .querySelector(selectors.regions.chooserOption.container);\n const prevActiveSectionId = e.relatedTarget.getAttribute(\"href\");\n const prevActiveSectionChooserOptions = body[0]\n .querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));\n\n // Disable the focus of every chooser option in the previous active section.\n disableFocusAllChooserOptions(prevActiveSectionChooserOptions);\n // Enable the focus of the first chooser option in the current active section.\n toggleFocusableChooserOption(firstChooserOption, true);\n initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions);\n });\n return;\n }).catch(Notification.exception);\n};\n\n/**\n * Disable the focus of all chooser options in a specific container (section).\n *\n * @method disableFocusAllChooserOptions\n * @param {HTMLElement} sectionChooserOptions The section that contains the chooser items\n */\nconst disableFocusAllChooserOptions = (sectionChooserOptions) => {\n const allChooserOptions = sectionChooserOptions.querySelectorAll(selectors.regions.chooserOption.container);\n allChooserOptions.forEach((chooserOption) => {\n toggleFocusableChooserOption(chooserOption, false);\n });\n};\n\n/**\n * Display the module chooser.\n *\n * @method displayChooser\n * @param {Promise} modalPromise Our created modal for the section\n * @param {Array} sectionModules An array of all of the built module information\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n */\nexport const displayChooser = (modalPromise, sectionModules, partialFavourite) => {\n // Make a map so we can quickly fetch a specific module's object for either rendering or searching.\n const mappedModules = new Map();\n sectionModules.forEach((module) => {\n mappedModules.set(module.componentname + '_' + module.link, module);\n });\n\n // Register event listeners.\n modalPromise.then(modal => {\n registerListenerEvents(modal, mappedModules, partialFavourite);\n\n // We want to focus on the first chooser option element as soon as the modal is opened.\n setupKeyboardAccessibility(modal, mappedModules);\n\n // We want to focus on the action select when the dialog is closed.\n modal.getRoot().on(ModalEvents.hidden, () => {\n modal.destroy();\n });\n\n return modal;\n }).catch();\n};\n"],"file":"dialogue.min.js"} \ No newline at end of file +{"version":3,"sources":["../../../src/local/activitychooser/dialogue.js"],"names":["getPlugin","pluginName","showModuleHelp","carousel","moduleData","modal","showFooter","setFooter","Templates","render","help","find","selectors","regions","innerHTML","classList","add","spinnerPromise","transitionPromiseResolver","transitionPromise","Promise","resolve","contentPromise","renderForPromise","all","then","html","js","replaceNodeContents","querySelector","chooserSummary","header","focus","catch","Notification","exception","one","manageFavouriteState","modalBody","caller","partialFavourite","isFavourite","dataset","favourited","id","name","internal","Repository","unfavouriteModule","favouriteModule","registerListenerEvents","mappedModules","footerData","bodyClickListener","e","target","closest","actions","optionActions","showSummary","getBody","module","chooserOption","container","moduleName","modname","get","hasFooterContent","manageFavourite","activeSectionId","elements","activetab","getAttribute","sectionChooserOptions","getSectionChooserOptions","firstChooserOption","toggleFocusableChooserOption","initChooserOptionsKeyboardNavigation","matches","closeOption","on","allModules","modules","getModuleSelector","clearSearch","searchInput","search","value","toggleSearchResultsView","footerClickListener","footer","customfooterjs","footerjs","getBodyPromise","body","interval","pause","keyboard","addEventListener","initTabsKeyboardNavigation","getFooterPromise","favTabNav","favouriteTabNav","recommendedTabNav","defaultTabNav","activityTabNav","resourceTabNav","forEach","element","firstLink","parentElement","visibletabs","lastLink","lastElementChild","keyCode","arrowRight","nextLink","nextElementSibling","tabIndex","contains","arrowLeft","previousLink","previousElementSibling","home","end","space","preventDefault","click","chooserOptionsContainer","chooserOptions","querySelectorAll","Array","from","enter","currentOption","nextOption","firstOption","firstElementChild","toFocusOption","clickErrorHandler","focusChooserOption","previousOption","lastOption","currentChooserOption","previousChooserOption","isFocusable","chooserOptionLink","addChooser","chooserOptionHelp","chooserOptionFavourite","item","fallback","renderSearchResults","searchResultsContainer","searchResultsData","templateData","length","searchQuery","searchResults","chooserContainer","chooser","clearSearchButton","clearsearch","searchIcon","searchicon","searchModules","searchResultItemsContainer","searchResultItems","firstSearchResultItem","remove","setAttribute","removeAttribute","searchTerm","toLowerCase","activity","activityName","title","activityDesc","includes","push","setupKeyboardAccessibility","getModal","tab","activeSectionChooserOptions","prevActiveSectionId","relatedTarget","prevActiveSectionChooserOptions","disableFocusAllChooserOptions","allChooserOptions","displayChooser","modalPromise","sectionModules","Map","set","componentname","link","getRoot","ModalEvents","hidden","destroy"],"mappings":"wqBAwBA,OACA,OACA,OACA,OAGA,OACA,O,k+DAEMA,CAAAA,CAAS,CAAG,SAAAC,CAAU,uFAAWA,CAAX,mMAAWA,CAAX,sBAAWA,CAAX,G,CAUtBC,CAAc,CAAG,SAACC,CAAD,CAAWC,CAAX,CAAwC,IAAjBC,CAAAA,CAAiB,wDAAT,IAAS,CAE3D,GAAc,IAAV,GAAAA,CAAK,EAAa,KAAAD,CAAU,CAACE,UAAjC,CAAsD,CAClDD,CAAK,CAACE,SAAN,CAAgBC,CAAS,CAACC,MAAV,CAAiB,kDAAjB,CAAqEL,CAArE,CAAhB,CACH,CACD,GAAMM,CAAAA,CAAI,CAAGP,CAAQ,CAACQ,IAAT,CAAcC,UAAUC,OAAV,CAAkBH,IAAhC,EAAsC,CAAtC,CAAb,CACAA,CAAI,CAACI,SAAL,CAAiB,EAAjB,CACAJ,CAAI,CAACK,SAAL,CAAeC,GAAf,CAAmB,QAAnB,EAP2D,GAUrDC,CAAAA,CAAc,CAAG,yBAAmBP,CAAnB,CAVoC,CAavDQ,CAAyB,CAAG,IAb2B,CAcrDC,CAAiB,CAAG,GAAIC,CAAAA,OAAJ,CAAY,SAAAC,CAAO,CAAI,CAC7CH,CAAyB,CAAGG,CAC/B,CAFyB,CAdiC,CAmBrDC,CAAc,CAAGd,CAAS,CAACe,gBAAV,CAA2B,wCAA3B,CAAqEnB,CAArE,CAnBoC,CAsB3DgB,OAAO,CAACI,GAAR,CAAY,CAACF,CAAD,CAAiBL,CAAjB,CAAiCE,CAAjC,CAAZ,EACKM,IADL,CACU,gCAAGC,CAAH,GAAGA,IAAH,CAASC,CAAT,GAASA,EAAT,OAAkBnB,CAAAA,CAAS,CAACoB,mBAAV,CAA8BlB,CAA9B,CAAoCgB,CAApC,CAA0CC,CAA1C,CAAlB,CADV,EAEKF,IAFL,CAEU,UAAM,CACRf,CAAI,CAACmB,aAAL,CAAmBjB,UAAUC,OAAV,CAAkBiB,cAAlB,CAAiCC,MAApD,EAA4DC,KAA5D,GACA,MAAOtB,CAAAA,CACV,CALL,EAMKuB,KANL,CAMWC,UAAaC,SANxB,EASAhC,CAAQ,CAACiC,GAAT,CAAa,kBAAb,CAAiC,UAAM,CACnClB,CAAyB,EAC5B,CAFD,EAIAf,CAAQ,CAACA,QAAT,CAAkB,MAAlB,CACH,C,CAWKkC,CAAoB,4CAAG,WAAMC,CAAN,CAAiBC,CAAjB,CAAyBC,CAAzB,+FACnBC,CADmB,CACLF,CAAM,CAACG,OAAP,CAAeC,UADV,CAEnBC,CAFmB,CAEdL,CAAM,CAACG,OAAP,CAAeE,EAFD,CAGnBC,CAHmB,CAGZN,CAAM,CAACG,OAAP,CAAeG,IAHH,CAInBC,CAJmB,CAIRP,CAAM,CAACG,OAAP,CAAeI,QAJP,MAML,MAAhB,GAAAL,CANqB,kCAOfM,CAAAA,CAAU,CAACC,iBAAX,CAA6BH,CAA7B,CAAmCD,CAAnC,CAPe,QASrBJ,CAAgB,CAACM,CAAD,IAAkBR,CAAlB,CAAhB,CATqB,wCAWfS,CAAAA,CAAU,CAACE,eAAX,CAA2BJ,CAA3B,CAAiCD,CAAjC,CAXe,SAarBJ,CAAgB,CAACM,CAAD,IAAiBR,CAAjB,CAAhB,CAbqB,yCAAH,uD,CA2BpBY,CAAsB,CAAG,SAAC7C,CAAD,CAAQ8C,CAAR,CAAuBX,CAAvB,CAAyCY,CAAzC,CAAwD,IAC7EC,CAAAA,CAAiB,4CAAG,WAAMC,CAAN,2GACtB,GAAIA,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB5C,UAAU6C,OAAV,CAAkBC,aAAlB,CAAgCC,WAAjD,CAAJ,CAAmE,CACzDxD,CADyD,CAC9C,cAAEE,CAAK,CAACuD,OAAN,GAAgB,CAAhB,EAAmB/B,aAAnB,CAAiCjB,UAAUC,OAAV,CAAkBV,QAAnD,CAAF,CAD8C,CAGzD0D,CAHyD,CAGhDP,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB5C,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SAAjD,CAHgD,CAIzDC,CAJyD,CAI5CH,CAAM,CAACnB,OAAP,CAAeuB,OAJ6B,CAKzD7D,CALyD,CAK5C+C,CAAa,CAACe,GAAd,CAAkBF,CAAlB,CAL4C,CAO/D5D,CAAU,CAACE,UAAX,CAAwBD,CAAK,CAAC8D,gBAAN,EAAxB,CACAjE,CAAc,CAACC,CAAD,CAAWC,CAAX,CAAuBC,CAAvB,CACjB,CAVqB,IAYlBiD,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB5C,UAAU6C,OAAV,CAAkBC,aAAlB,CAAgCU,eAAjD,CAZkB,kBAaZ7B,CAbY,CAaHe,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB5C,UAAU6C,OAAV,CAAkBC,aAAlB,CAAgCU,eAAjD,CAbG,gBAcZ/B,CAAAA,CAAoB,CAAChC,CAAK,CAACuD,OAAN,GAAgB,CAAhB,CAAD,CAAqBrB,CAArB,CAA6BC,CAA7B,CAdR,QAeZ6B,CAfY,CAeMhE,CAAK,CAACuD,OAAN,GAAgB,CAAhB,EAAmB/B,aAAnB,CAAiCjB,UAAU0D,QAAV,CAAmBC,SAApD,EAA+DC,YAA/D,CAA4E,MAA5E,CAfN,CAgBZC,CAhBY,CAgBYpE,CAAK,CAACuD,OAAN,GAAgB,CAAhB,EACzB/B,aADyB,CACXjB,UAAUC,OAAV,CAAkB6D,wBAAlB,CAA2CL,CAA3C,CADW,CAhBZ,CAkBZM,CAlBY,CAkBSF,CAAqB,CAC3C5C,aADsB,CACRjB,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SADxB,CAlBT,CAoBlBa,CAA4B,CAACD,CAAD,IAA5B,CACAE,CAAoC,CAACxE,CAAK,CAACuD,OAAN,GAAgB,CAAhB,CAAD,CAAqBT,CAArB,CAAoCsB,CAApC,CAA2DpE,CAA3D,CAApC,CArBkB,QAyBtB,GAAIiD,CAAC,CAACC,MAAF,CAASuB,OAAT,CAAiBlE,UAAU6C,OAAV,CAAkBsB,WAAnC,CAAJ,CAAqD,CAC3C5E,CAD2C,CAChC,cAAEE,CAAK,CAACuD,OAAN,GAAgB,CAAhB,EAAmB/B,aAAnB,CAAiCjB,UAAUC,OAAV,CAAkBV,QAAnD,CAAF,CADgC,CAIjDA,CAAQ,CAACA,QAAT,CAAkB,MAAlB,EACAA,CAAQ,CAAC6E,EAAT,CAAY,kBAAZ,CAAgC,UAAM,IAC5BC,CAAAA,CAAU,CAAG5E,CAAK,CAACuD,OAAN,GAAgB,CAAhB,EAAmB/B,aAAnB,CAAiCjB,UAAUC,OAAV,CAAkBqE,OAAnD,CADe,CAE5B3C,CAAM,CAAG0C,CAAU,CAACpD,aAAX,CAAyBjB,UAAUC,OAAV,CAAkBsE,iBAAlB,CAAoC7B,CAAC,CAACC,MAAF,CAASb,OAAT,CAAiBuB,OAArD,CAAzB,CAFmB,CAGlC1B,CAAM,CAACP,KAAP,EACH,CAJD,CAKH,CAGD,GAAIsB,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB5C,UAAU6C,OAAV,CAAkB2B,WAAnC,CAAJ,CAAqD,CAE3CC,CAF2C,CAE7BhF,CAAK,CAACuD,OAAN,GAAgB,CAAhB,EAAmB/B,aAAnB,CAAiCjB,UAAU6C,OAAV,CAAkB6B,MAAnD,CAF6B,CAGjDD,CAAW,CAACE,KAAZ,CAAoB,EAApB,CACAF,CAAW,CAACrD,KAAZ,GACAwD,CAAuB,CAACnF,CAAD,CAAQ8C,CAAR,CAAuBkC,CAAW,CAACE,KAAnC,CAC1B,CA5CqB,yCAAH,uDAD4D,CAoD7EE,CAAmB,4CAAG,WAAMnC,CAAN,8FACpB,KAAAF,CAAU,CAACsC,MADS,iCAEG1F,CAAAA,CAAS,CAACoD,CAAU,CAACuC,cAAZ,CAFZ,QAEdC,CAFc,uBAGdA,CAAAA,CAAQ,CAACH,mBAAT,CAA6BnC,CAA7B,CAAgCF,CAAhC,CAA4C/C,CAA5C,CAHc,yCAAH,uDApD0D,CA2DnFA,CAAK,CAACwF,cAAN,GAGCpE,IAHD,CAGM,SAAAqE,CAAI,QAAIA,CAAAA,CAAI,CAAC,CAAD,CAAR,CAHV,EAMCrE,IAND,CAMM,SAAAqE,CAAI,CAAI,CACV,cAAEA,CAAI,CAACjE,aAAL,CAAmBjB,UAAUC,OAAV,CAAkBV,QAArC,CAAF,EACKA,QADL,CACc,CACN4F,QAAQ,GADF,CAENC,KAAK,GAFC,CAGNC,QAAQ,GAHF,CADd,EAOA,MAAOH,CAAAA,CACV,CAfD,EAkBCrE,IAlBD,CAkBM,SAAAqE,CAAI,CAAI,CACVA,CAAI,CAACI,gBAAL,CAAsB,OAAtB,CAA+B7C,CAA/B,EACA,MAAOyC,CAAAA,CACV,CArBD,EAwBCrE,IAxBD,CAwBM,SAAAqE,CAAI,CAAI,CACV,GAAMT,CAAAA,CAAW,CAAGS,CAAI,CAACjE,aAAL,CAAmBjB,UAAU6C,OAAV,CAAkB6B,MAArC,CAApB,CAEAD,CAAW,CAACa,gBAAZ,CAA6B,OAA7B,CAAsC,eAAS,UAAM,CAEjDV,CAAuB,CAACnF,CAAD,CAAQ8C,CAAR,CAAuBkC,CAAW,CAACE,KAAnC,CAC1B,CAHqC,CAGnC,GAHmC,CAAtC,EAIA,MAAOO,CAAAA,CACV,CAhCD,EAmCCrE,IAnCD,CAmCM,SAAAqE,CAAI,CAAI,IAEJzB,CAAAA,CAAe,CAAGyB,CAAI,CAACjE,aAAL,CAAmBjB,UAAU0D,QAAV,CAAmBC,SAAtC,EAAiDC,YAAjD,CAA8D,MAA9D,CAFd,CAGJC,CAAqB,CAAGqB,CAAI,CAACjE,aAAL,CAAmBjB,UAAUC,OAAV,CAAkB6D,wBAAlB,CAA2CL,CAA3C,CAAnB,CAHpB,CAIJM,CAAkB,CAAGF,CAAqB,CAAC5C,aAAtB,CAAoCjB,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SAApE,CAJjB,CAMVa,CAA4B,CAACD,CAAD,IAA5B,CACAwB,CAA0B,CAACL,CAAD,CAA1B,CACAjB,CAAoC,CAACiB,CAAD,CAAO3C,CAAP,CAAsBsB,CAAtB,CAA6CpE,CAA7C,CAApC,CAEA,MAAOyF,CAAAA,CACV,CA9CD,EA+CC7D,KA/CD,GAiDA5B,CAAK,CAAC+F,gBAAN,GAGC3E,IAHD,CAGM,SAAAiE,CAAM,QAAIA,CAAAA,CAAM,CAAC,CAAD,CAAV,CAHZ,EAKCjE,IALD,CAKM,SAAAiE,CAAM,CAAI,CACZA,CAAM,CAACQ,gBAAP,CAAwB,OAAxB,CAAiCT,CAAjC,EACA,MAAOC,CAAAA,CACV,CARD,EASCzD,KATD,EAUH,C,CAQKkE,CAA0B,CAAG,SAACL,CAAD,CAAU,IAEnCO,CAAAA,CAAS,CAAGP,CAAI,CAACjE,aAAL,CAAmBjB,UAAUC,OAAV,CAAkByF,eAArC,CAFuB,CAGnCC,CAAiB,CAAGT,CAAI,CAACjE,aAAL,CAAmBjB,UAAUC,OAAV,CAAkB0F,iBAArC,CAHe,CAInCC,CAAa,CAAGV,CAAI,CAACjE,aAAL,CAAmBjB,UAAUC,OAAV,CAAkB2F,aAArC,CAJmB,CAKnCC,CAAc,CAAGX,CAAI,CAACjE,aAAL,CAAmBjB,UAAUC,OAAV,CAAkB4F,cAArC,CALkB,CAMnCC,CAAc,CAAGZ,CAAI,CAACjE,aAAL,CAAmBjB,UAAUC,OAAV,CAAkB6F,cAArC,CANkB,CAOrB,CAACL,CAAD,CAAYE,CAAZ,CAA+BC,CAA/B,CAA8CC,CAA9C,CAA8DC,CAA9D,CACpB,CAAYC,OAAZ,CAAoB,SAACC,CAAD,CAAa,CAC7B,MAAOA,CAAAA,CAAO,CAACV,gBAAR,CAAyB,SAAzB,CAAoC,SAAC5C,CAAD,CAAO,IAExCuD,CAAAA,CAAS,CAAGvD,CAAC,CAACC,MAAF,CAASuD,aAAT,CAAuBjF,aAAvB,CAAqCjB,UAAU0D,QAAV,CAAmByC,WAAxD,CAF4B,CAIxCC,CAAQ,CAAG1D,CAAC,CAACC,MAAF,CAASuD,aAAT,CAAuBG,gBAJM,CAM9C,GAAI3D,CAAC,CAAC4D,OAAF,GAAcC,YAAlB,CAA8B,CAC1B,GAAMC,CAAAA,CAAQ,CAAG9D,CAAC,CAACC,MAAF,CAAS8D,kBAA1B,CACA,GAAiB,IAAb,GAAAD,CAAJ,CAAuB,CACnB9D,CAAC,CAACC,MAAF,CAAS+D,QAAT,CAAoB,CAAC,CAArB,CACAT,CAAS,CAACS,QAAV,CAAqB,CAArB,CACAT,CAAS,CAAC7E,KAAV,EACH,CAJD,IAIO,IAAIoF,CAAQ,CAACrG,SAAT,CAAmBwG,QAAnB,CAA4B,QAA5B,CAAJ,CAA2C,CAC9CjE,CAAC,CAACC,MAAF,CAAS+D,QAAT,CAAoB,CAAC,CAArB,CACAN,CAAQ,CAACM,QAAT,CAAoB,CAApB,CACAN,CAAQ,CAAChF,KAAT,EACH,CAJM,IAIA,CACHsB,CAAC,CAACC,MAAF,CAAS+D,QAAT,CAAoB,CAAC,CAArB,CACAF,CAAQ,CAACE,QAAT,CAAoB,CAApB,CACAF,CAAQ,CAACpF,KAAT,EACH,CACJ,CACD,GAAIsB,CAAC,CAAC4D,OAAF,GAAcM,WAAlB,CAA6B,CACzB,GAAMC,CAAAA,CAAY,CAAGnE,CAAC,CAACC,MAAF,CAASmE,sBAA9B,CACA,GAAqB,IAAjB,GAAAD,CAAJ,CAA2B,CACvBnE,CAAC,CAACC,MAAF,CAAS+D,QAAT,CAAoB,CAAC,CAArB,CACAN,CAAQ,CAACM,QAAT,CAAoB,CAApB,CACAN,CAAQ,CAAChF,KAAT,EACH,CAJD,IAIO,IAAIyF,CAAY,CAAC1G,SAAb,CAAuBwG,QAAvB,CAAgC,QAAhC,CAAJ,CAA+C,CAClDjE,CAAC,CAACC,MAAF,CAAS+D,QAAT,CAAoB,CAAC,CAArB,CACAT,CAAS,CAACS,QAAV,CAAqB,CAArB,CACAT,CAAS,CAAC7E,KAAV,EACH,CAJM,IAIA,CACHsB,CAAC,CAACC,MAAF,CAAS+D,QAAT,CAAoB,CAAC,CAArB,CACAG,CAAY,CAACH,QAAb,CAAwB,CAAxB,CACAG,CAAY,CAACzF,KAAb,EACH,CACJ,CACD,GAAIsB,CAAC,CAAC4D,OAAF,GAAcS,MAAlB,CAAwB,CACpBrE,CAAC,CAACC,MAAF,CAAS+D,QAAT,CAAoB,CAAC,CAArB,CACAT,CAAS,CAACS,QAAV,CAAqB,CAArB,CACAT,CAAS,CAAC7E,KAAV,EACH,CACD,GAAIsB,CAAC,CAAC4D,OAAF,GAAcU,KAAlB,CAAuB,CACnBtE,CAAC,CAACC,MAAF,CAAS+D,QAAT,CAAoB,CAAC,CAArB,CACAN,CAAQ,CAACM,QAAT,CAAoB,CAApB,CACAN,CAAQ,CAAChF,KAAT,EACH,CACD,GAAIsB,CAAC,CAAC4D,OAAF,GAAcW,OAAlB,CAAyB,CACrBvE,CAAC,CAACwE,cAAF,GACAxE,CAAC,CAACC,MAAF,CAASwE,KAAT,EACH,CACJ,CApDM,CAqDV,CAtDD,CAuDH,C,CAWKlD,CAAoC,CAAG,SAACiB,CAAD,CAAO3C,CAAP,CAAsB6E,CAAtB,CAAgE,IAAjB3H,CAAAA,CAAiB,wDAAT,IAAS,CACnG4H,CAAc,CAAGD,CAAuB,CAACE,gBAAxB,CAAyCtH,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SAAzE,CADkF,CAGzGoE,KAAK,CAACC,IAAN,CAAWH,CAAX,EAA2BtB,OAA3B,CAAmC,SAACC,CAAD,CAAa,CAC5C,MAAOA,CAAAA,CAAO,CAACV,gBAAR,CAAyB,SAAzB,CAAoC,SAAC5C,CAAD,CAAO,CAG9C,GAAIA,CAAC,CAAC4D,OAAF,GAAcmB,OAAd,EAAuB/E,CAAC,CAAC4D,OAAF,GAAcW,OAAzC,CAAgD,CAC5C,GAAIvE,CAAC,CAACC,MAAF,CAASuB,OAAT,CAAiBlE,UAAU6C,OAAV,CAAkBC,aAAlB,CAAgCC,WAAjD,CAAJ,CAAmE,CAC/DL,CAAC,CAACwE,cAAF,GAD+D,GAEzDjE,CAAAA,CAAM,CAAGP,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB5C,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SAAjD,CAFgD,CAGzDC,CAAU,CAAGH,CAAM,CAACnB,OAAP,CAAeuB,OAH6B,CAIzD7D,CAAU,CAAG+C,CAAa,CAACe,GAAd,CAAkBF,CAAlB,CAJ4C,CAKzD7D,CAAQ,CAAG,cAAE2F,CAAI,CAACjE,aAAL,CAAmBjB,UAAUC,OAAV,CAAkBV,QAArC,CAAF,CAL8C,CAM/DA,CAAQ,CAACA,QAAT,CAAkB,CACd4F,QAAQ,GADM,CAEdC,KAAK,GAFS,CAGdC,QAAQ,GAHM,CAAlB,EAOA7F,CAAU,CAACE,UAAX,CAAwBD,CAAK,CAAC8D,gBAAN,EAAxB,CACAjE,CAAc,CAACC,CAAD,CAAWC,CAAX,CAAuBC,CAAvB,CACjB,CACJ,CAGD,GAAIiD,CAAC,CAAC4D,OAAF,GAAcC,YAAlB,CAA8B,CAC1B7D,CAAC,CAACwE,cAAF,GAD0B,GAEpBQ,CAAAA,CAAa,CAAGhF,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB5C,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SAAjD,CAFI,CAGpBwE,CAAU,CAAGD,CAAa,CAACjB,kBAHP,CAIpBmB,CAAW,CAAGR,CAAuB,CAACS,iBAJlB,CAKpBC,CAAa,CAAGC,CAAiB,CAACJ,CAAD,CAAaC,CAAb,CALb,CAM1BI,CAAkB,CAACF,CAAD,CAAgBJ,CAAhB,CACrB,CAGD,GAAIhF,CAAC,CAAC4D,OAAF,GAAcM,WAAlB,CAA6B,CACzBlE,CAAC,CAACwE,cAAF,GADyB,GAEnBQ,CAAAA,CAAa,CAAGhF,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB5C,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SAAjD,CAFG,CAGnB8E,CAAc,CAAGP,CAAa,CAACZ,sBAHZ,CAInBoB,CAAU,CAAGd,CAAuB,CAACf,gBAJlB,CAKnByB,CAAa,CAAGC,CAAiB,CAACE,CAAD,CAAiBC,CAAjB,CALd,CAMzBF,CAAkB,CAACF,CAAD,CAAgBJ,CAAhB,CACrB,CAED,GAAIhF,CAAC,CAAC4D,OAAF,GAAcS,MAAlB,CAAwB,CACpBrE,CAAC,CAACwE,cAAF,GADoB,GAEdQ,CAAAA,CAAa,CAAGhF,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB5C,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SAAjD,CAFF,CAGdyE,CAAW,CAAGR,CAAuB,CAACS,iBAHxB,CAIpBG,CAAkB,CAACJ,CAAD,CAAcF,CAAd,CACrB,CAED,GAAIhF,CAAC,CAAC4D,OAAF,GAAcU,KAAlB,CAAuB,CACnBtE,CAAC,CAACwE,cAAF,GADmB,GAEbQ,CAAAA,CAAa,CAAGhF,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiB5C,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SAAjD,CAFH,CAGb+E,CAAU,CAAGd,CAAuB,CAACf,gBAHxB,CAInB2B,CAAkB,CAACE,CAAD,CAAaR,CAAb,CACrB,CACJ,CAvDM,CAwDV,CAzDD,CA0DH,C,CASKM,CAAkB,CAAG,SAACG,CAAD,CAAwD,IAAjCC,CAAAA,CAAiC,wDAAT,IAAS,CAC/E,GAA8B,IAA1B,GAAAA,CAAJ,CAAoC,CAChCpE,CAA4B,CAACoE,CAAD,IAC/B,CAEDpE,CAA4B,CAACmE,CAAD,IAA5B,CACAA,CAAoB,CAAC/G,KAArB,EACH,C,CASK4C,CAA4B,CAAG,SAACd,CAAD,CAAgBmF,CAAhB,CAAgC,IAC3DC,CAAAA,CAAiB,CAAGpF,CAAa,CAACjC,aAAd,CAA4BjB,UAAU6C,OAAV,CAAkB0F,UAA9C,CADuC,CAE3DC,CAAiB,CAAGtF,CAAa,CAACjC,aAAd,CAA4BjB,UAAU6C,OAAV,CAAkBC,aAAlB,CAAgCC,WAA5D,CAFuC,CAG3D0F,CAAsB,CAAGvF,CAAa,CAACjC,aAAd,CAA4BjB,UAAU6C,OAAV,CAAkBC,aAAlB,CAAgCU,eAA5D,CAHkC,CAKjE,GAAI6E,CAAJ,CAAiB,CAEbnF,CAAa,CAACwD,QAAd,CAAyB,CAAzB,CACA4B,CAAiB,CAAC5B,QAAlB,CAA6B,CAA7B,CACA8B,CAAiB,CAAC9B,QAAlB,CAA6B,CAA7B,CACA+B,CAAsB,CAAC/B,QAAvB,CAAkC,CACrC,CAND,IAMO,CAEHxD,CAAa,CAACwD,QAAd,CAAyB,CAAC,CAA1B,CACA4B,CAAiB,CAAC5B,QAAlB,CAA6B,CAAC,CAA9B,CACA8B,CAAiB,CAAC9B,QAAlB,CAA6B,CAAC,CAA9B,CACA+B,CAAsB,CAAC/B,QAAvB,CAAkC,CAAC,CACtC,CACJ,C,CAUKqB,CAAiB,CAAG,SAACW,CAAD,CAAOC,CAAP,CAAoB,CAC1C,GAAa,IAAT,GAAAD,CAAJ,CAAmB,CACf,MAAOA,CAAAA,CACV,CAFD,IAEO,CACH,MAAOC,CAAAA,CACV,CACJ,C,CASKC,CAAmB,4CAAG,WAAMC,CAAN,CAA8BC,CAA9B,+FAClBC,CADkB,CACH,CACjB,oBAAuBD,CAAiB,CAACE,MADxB,CAEjB,cAAiBF,CAFA,CADG,gBAMClJ,CAAAA,CAAS,CAACe,gBAAV,CAA2B,kDAA3B,CAA+EoI,CAA/E,CAND,iBAMjBjI,CANiB,GAMjBA,IANiB,CAMXC,CANW,GAMXA,EANW,gBAOlBnB,CAAAA,CAAS,CAACoB,mBAAV,CAA8B6H,CAA9B,CAAsD/H,CAAtD,CAA4DC,CAA5D,CAPkB,yCAAH,uD,CAkBnB6D,CAAuB,4CAAG,WAAMnF,CAAN,CAAa8C,CAAb,CAA4B0G,CAA5B,uGACtBvH,CADsB,CACVjC,CAAK,CAACuD,OAAN,GAAgB,CAAhB,CADU,CAEtB6F,CAFsB,CAEGnH,CAAS,CAACT,aAAV,CAAwBjB,UAAUC,OAAV,CAAkBiJ,aAA1C,CAFH,CAGtBC,CAHsB,CAGHzH,CAAS,CAACT,aAAV,CAAwBjB,UAAUC,OAAV,CAAkBmJ,OAA1C,CAHG,CAItBC,CAJsB,CAIF3H,CAAS,CAACT,aAAV,CAAwBjB,UAAU0D,QAAV,CAAmB4F,WAA3C,CAJE,CAKtBC,CALsB,CAKT7H,CAAS,CAACT,aAAV,CAAwBjB,UAAU0D,QAAV,CAAmB8F,UAA3C,CALS,MAOH,CAArB,CAAAP,CAAW,CAACD,MAPY,mBAQlBF,CARkB,CAQEW,CAAa,CAAClH,CAAD,CAAgB0G,CAAhB,CARf,gBASlBL,CAAAA,CAAmB,CAACC,CAAD,CAAyBC,CAAzB,CATD,QAUlBY,CAVkB,CAUWb,CAAsB,CAAC5H,aAAvB,CAAqCjB,UAAUC,OAAV,CAAkB0J,iBAAvD,CAVX,CAWlBC,CAXkB,CAWMF,CAA0B,CAACzI,aAA3B,CAAyCjB,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SAAzE,CAXN,CAYxB,GAAIyG,CAAJ,CAA2B,CAEvB5F,CAA4B,CAAC4F,CAAD,IAA5B,CAEA3F,CAAoC,CAACvC,CAAD,CAAYa,CAAZ,CAA2BmH,CAA3B,CAAuDjK,CAAvD,CACvC,CAED8J,CAAU,CAACpJ,SAAX,CAAqBC,GAArB,CAAyB,QAAzB,EACAiJ,CAAiB,CAAClJ,SAAlB,CAA4B0J,MAA5B,CAAmC,QAAnC,EAEAV,CAAgB,CAACW,YAAjB,CAA8B,QAA9B,CAAwC,QAAxC,EAEAjB,CAAsB,CAACkB,eAAvB,CAAuC,QAAvC,EAxBwB,wBA2BxBV,CAAiB,CAAClJ,SAAlB,CAA4BC,GAA5B,CAAgC,QAAhC,EACAmJ,CAAU,CAACpJ,SAAX,CAAqB0J,MAArB,CAA4B,QAA5B,EAEAhB,CAAsB,CAACiB,YAAvB,CAAoC,QAApC,CAA8C,QAA9C,EAEAX,CAAgB,CAACY,eAAjB,CAAiC,QAAjC,EAhCwB,yCAAH,uD,CA4CvBN,CAAa,CAAG,SAACnF,CAAD,CAAU0F,CAAV,CAAyB,CAC3C,GAAmB,EAAf,GAAAA,CAAJ,CAAuB,CACnB,MAAO1F,CAAAA,CACV,CACD0F,CAAU,CAAGA,CAAU,CAACC,WAAX,EAAb,CACA,GAAMf,CAAAA,CAAa,CAAG,EAAtB,CACA5E,CAAO,CAACyB,OAAR,CAAgB,SAACmE,CAAD,CAAc,IACpBC,CAAAA,CAAY,CAAGD,CAAQ,CAACE,KAAT,CAAeH,WAAf,EADK,CAEpBI,CAAY,CAAGH,CAAQ,CAACpK,IAAT,CAAcmK,WAAd,EAFK,CAG1B,GAAIE,CAAY,CAACG,QAAb,CAAsBN,CAAtB,GAAqCK,CAAY,CAACC,QAAb,CAAsBN,CAAtB,CAAzC,CAA4E,CACxEd,CAAa,CAACqB,IAAd,CAAmBL,CAAnB,CACH,CACJ,CAND,EAQA,MAAOhB,CAAAA,CACV,C,CASKsB,CAA0B,CAAG,SAAC/K,CAAD,CAAQ8C,CAAR,CAA0B,CACzD9C,CAAK,CAACgL,QAAN,GAAiB,CAAjB,EAAoB/D,QAApB,CAA+B,CAAC,CAAhC,CAEAjH,CAAK,CAACwF,cAAN,GAAuBpE,IAAvB,CAA4B,SAAAqE,CAAI,CAAI,CAChC,cAAElF,UAAU0D,QAAV,CAAmBgH,GAArB,EAA0BtG,EAA1B,CAA6B,cAA7B,CAA6C,SAAC1B,CAAD,CAAO,IAC1Ce,CAAAA,CAAe,CAAGf,CAAC,CAACC,MAAF,CAASiB,YAAT,CAAsB,MAAtB,CADwB,CAE1C+G,CAA2B,CAAGzF,CAAI,CAAC,CAAD,CAAJ,CAC/BjE,aAD+B,CACjBjB,UAAUC,OAAV,CAAkB6D,wBAAlB,CAA2CL,CAA3C,CADiB,CAFY,CAI1CM,CAAkB,CAAG4G,CAA2B,CACjD1J,aADsB,CACRjB,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SADxB,CAJqB,CAM1CyH,CAAmB,CAAGlI,CAAC,CAACmI,aAAF,CAAgBjH,YAAhB,CAA6B,MAA7B,CANoB,CAO1CkH,CAA+B,CAAG5F,CAAI,CAAC,CAAD,CAAJ,CACnCjE,aADmC,CACrBjB,UAAUC,OAAV,CAAkB6D,wBAAlB,CAA2C8G,CAA3C,CADqB,CAPQ,CAWhDG,CAA6B,CAACD,CAAD,CAA7B,CAEA9G,CAA4B,CAACD,CAAD,IAA5B,CACAE,CAAoC,CAACiB,CAAI,CAAC,CAAD,CAAL,CAAU3C,CAAV,CAAyBoI,CAAzB,CAAsDlL,CAAtD,CACvC,CAfD,CAiBH,CAlBD,EAkBG4B,KAlBH,CAkBSC,UAAaC,SAlBtB,CAmBH,C,CAQKwJ,CAA6B,CAAG,SAAClH,CAAD,CAA2B,CAC7D,GAAMmH,CAAAA,CAAiB,CAAGnH,CAAqB,CAACyD,gBAAtB,CAAuCtH,UAAUC,OAAV,CAAkBiD,aAAlB,CAAgCC,SAAvE,CAA1B,CACA6H,CAAiB,CAACjF,OAAlB,CAA0B,SAAC7C,CAAD,CAAmB,CACzCc,CAA4B,CAACd,CAAD,IAC/B,CAFD,CAGH,C,kBAW6B,QAAjB+H,CAAAA,cAAiB,CAACC,CAAD,CAAeC,CAAf,CAA+BvJ,CAA/B,CAAiDY,CAAjD,CAAgE,CAE1F,GAAMD,CAAAA,CAAa,CAAG,GAAI6I,CAAAA,GAA1B,CACAD,CAAc,CAACpF,OAAf,CAAuB,SAAC9C,CAAD,CAAY,CAC/BV,CAAa,CAAC8I,GAAd,CAAkBpI,CAAM,CAACqI,aAAP,CAAuB,GAAvB,CAA6BrI,CAAM,CAACsI,IAAtD,CAA4DtI,CAA5D,CACH,CAFD,EAKAiI,CAAY,CAACrK,IAAb,CAAkB,SAAApB,CAAK,CAAI,CACvB6C,CAAsB,CAAC7C,CAAD,CAAQ8C,CAAR,CAAuBX,CAAvB,CAAyCY,CAAzC,CAAtB,CAGAgI,CAA0B,CAAC/K,CAAD,CAAQ8C,CAAR,CAA1B,CAGA9C,CAAK,CAAC+L,OAAN,GAAgBpH,EAAhB,CAAmBqH,CAAW,CAACC,MAA/B,CAAuC,UAAM,CACzCjM,CAAK,CAACkM,OAAN,EACH,CAFD,EAIA,MAAOlM,CAAAA,CACV,CAZD,EAYG4B,KAZH,EAaH,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A type of dialogue used as for choosing options.\n *\n * @module core_course/local/chooser/dialogue\n * @package core\n * @copyright 2019 Mihail Geshoski \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport $ from 'jquery';\nimport * as ModalEvents from 'core/modal_events';\nimport selectors from 'core_course/local/activitychooser/selectors';\nimport * as Templates from 'core/templates';\nimport {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes';\nimport {addIconToContainer} from 'core/loadingicon';\nimport * as Repository from 'core_course/local/activitychooser/repository';\nimport Notification from 'core/notification';\nimport {debounce} from 'core/utils';\nconst getPlugin = pluginName => import(pluginName);\n\n/**\n * Given an event from the main module 'page' navigate to it's help section via a carousel.\n *\n * @method showModuleHelp\n * @param {jQuery} carousel Our initialized carousel to manipulate\n * @param {Object} moduleData Data of the module to carousel to\n * @param {jQuery} modal We need to figure out if the current modal has a footer.\n */\nconst showModuleHelp = (carousel, moduleData, modal = null) => {\n // If we have a real footer then we need to change temporarily.\n if (modal !== null && moduleData.showFooter === true) {\n modal.setFooter(Templates.render('core_course/local/activitychooser/footer_partial', moduleData));\n }\n const help = carousel.find(selectors.regions.help)[0];\n help.innerHTML = '';\n help.classList.add('m-auto');\n\n // Add a spinner.\n const spinnerPromise = addIconToContainer(help);\n\n // Used later...\n let transitionPromiseResolver = null;\n const transitionPromise = new Promise(resolve => {\n transitionPromiseResolver = resolve;\n });\n\n // Build up the html & js ready to place into the help section.\n const contentPromise = Templates.renderForPromise('core_course/local/activitychooser/help', moduleData);\n\n // Wait for the content to be ready, and for the transition to be complet.\n Promise.all([contentPromise, spinnerPromise, transitionPromise])\n .then(([{html, js}]) => Templates.replaceNodeContents(help, html, js))\n .then(() => {\n help.querySelector(selectors.regions.chooserSummary.header).focus();\n return help;\n })\n .catch(Notification.exception);\n\n // Move to the next slide, and resolve the transition promise when it's done.\n carousel.one('slid.bs.carousel', () => {\n transitionPromiseResolver();\n });\n // Trigger the transition between 'pages'.\n carousel.carousel('next');\n};\n\n/**\n * Given a user wants to change the favourite state of a module we either add or remove the status.\n * We also propergate this change across our map of modals.\n *\n * @method manageFavouriteState\n * @param {HTMLElement} modalBody The DOM node of the modal to manipulate\n * @param {HTMLElement} caller\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n */\nconst manageFavouriteState = async(modalBody, caller, partialFavourite) => {\n const isFavourite = caller.dataset.favourited;\n const id = caller.dataset.id;\n const name = caller.dataset.name;\n const internal = caller.dataset.internal;\n // Switch on fave or not.\n if (isFavourite === 'true') {\n await Repository.unfavouriteModule(name, id);\n\n partialFavourite(internal, false, modalBody);\n } else {\n await Repository.favouriteModule(name, id);\n\n partialFavourite(internal, true, modalBody);\n }\n\n};\n\n/**\n * Register chooser related event listeners.\n *\n * @method registerListenerEvents\n * @param {Promise} modal Our modal that we are working with\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n * @param {Object} footerData Our base footer object.\n */\nconst registerListenerEvents = (modal, mappedModules, partialFavourite, footerData) => {\n const bodyClickListener = async(e) => {\n if (e.target.closest(selectors.actions.optionActions.showSummary)) {\n const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));\n\n const module = e.target.closest(selectors.regions.chooserOption.container);\n const moduleName = module.dataset.modname;\n const moduleData = mappedModules.get(moduleName);\n // We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.\n moduleData.showFooter = modal.hasFooterContent();\n showModuleHelp(carousel, moduleData, modal);\n }\n\n if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {\n const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);\n await manageFavouriteState(modal.getBody()[0], caller, partialFavourite);\n const activeSectionId = modal.getBody()[0].querySelector(selectors.elements.activetab).getAttribute(\"href\");\n const sectionChooserOptions = modal.getBody()[0]\n .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = sectionChooserOptions\n .querySelector(selectors.regions.chooserOption.container);\n toggleFocusableChooserOption(firstChooserOption, true);\n initChooserOptionsKeyboardNavigation(modal.getBody()[0], mappedModules, sectionChooserOptions, modal);\n }\n\n // From the help screen go back to the module overview.\n if (e.target.matches(selectors.actions.closeOption)) {\n const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));\n\n // Trigger the transition between 'pages'.\n carousel.carousel('prev');\n carousel.on('slid.bs.carousel', () => {\n const allModules = modal.getBody()[0].querySelector(selectors.regions.modules);\n const caller = allModules.querySelector(selectors.regions.getModuleSelector(e.target.dataset.modname));\n caller.focus();\n });\n }\n\n // The \"clear search\" button is triggered.\n if (e.target.closest(selectors.actions.clearSearch)) {\n // Clear the entered search query in the search bar and hide the search results container.\n const searchInput = modal.getBody()[0].querySelector(selectors.actions.search);\n searchInput.value = \"\";\n searchInput.focus();\n toggleSearchResultsView(modal, mappedModules, searchInput.value);\n }\n };\n\n // We essentially have two types of footer.\n // A fake one that is handled within the template for chooser_help and then all of the stuff for\n // modal.footer. We need to ensure we know exactly what type of footer we are using so we know what we\n // need to manage. The below code handles a real footer going to a mnet carousel item.\n const footerClickListener = async(e) => {\n if (footerData.footer === true) {\n const footerjs = await getPlugin(footerData.customfooterjs);\n await footerjs.footerClickListener(e, footerData, modal);\n }\n };\n\n modal.getBodyPromise()\n\n // The return value of getBodyPromise is a jquery object containing the body NodeElement.\n .then(body => body[0])\n\n // Set up the carousel.\n .then(body => {\n $(body.querySelector(selectors.regions.carousel))\n .carousel({\n interval: false,\n pause: true,\n keyboard: false\n });\n\n return body;\n })\n\n // Add the listener for clicks on the body.\n .then(body => {\n body.addEventListener('click', bodyClickListener);\n return body;\n })\n\n // Add a listener for an input change in the activity chooser's search bar.\n .then(body => {\n const searchInput = body.querySelector(selectors.actions.search);\n // The search input is triggered.\n searchInput.addEventListener('input', debounce(() => {\n // Display the search results.\n toggleSearchResultsView(modal, mappedModules, searchInput.value);\n }, 300));\n return body;\n })\n\n // Register event listeners related to the keyboard navigation controls.\n .then(body => {\n // Get the active chooser options section.\n const activeSectionId = body.querySelector(selectors.elements.activetab).getAttribute(\"href\");\n const sectionChooserOptions = body.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = sectionChooserOptions.querySelector(selectors.regions.chooserOption.container);\n\n toggleFocusableChooserOption(firstChooserOption, true);\n initTabsKeyboardNavigation(body);\n initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions, modal);\n\n return body;\n })\n .catch();\n\n modal.getFooterPromise()\n\n // The return value of getBodyPromise is a jquery object containing the body NodeElement.\n .then(footer => footer[0])\n // Add the listener for clicks on the footer.\n .then(footer => {\n footer.addEventListener('click', footerClickListener);\n return footer;\n })\n .catch();\n};\n\n/**\n * Initialise the keyboard navigation controls for the tab list items.\n *\n * @method initTabsKeyboardNavigation\n * @param {HTMLElement} body Our modal that we are working with\n */\nconst initTabsKeyboardNavigation = (body) => {\n // Set up the tab handlers.\n const favTabNav = body.querySelector(selectors.regions.favouriteTabNav);\n const recommendedTabNav = body.querySelector(selectors.regions.recommendedTabNav);\n const defaultTabNav = body.querySelector(selectors.regions.defaultTabNav);\n const activityTabNav = body.querySelector(selectors.regions.activityTabNav);\n const resourceTabNav = body.querySelector(selectors.regions.resourceTabNav);\n const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav, activityTabNav, resourceTabNav];\n tabNavArray.forEach((element) => {\n return element.addEventListener('keydown', (e) => {\n // The first visible navigation tab link.\n const firstLink = e.target.parentElement.querySelector(selectors.elements.visibletabs);\n // The last navigation tab link. It would always be the default activities tab link.\n const lastLink = e.target.parentElement.lastElementChild;\n\n if (e.keyCode === arrowRight) {\n const nextLink = e.target.nextElementSibling;\n if (nextLink === null) {\n e.target.tabIndex = -1;\n firstLink.tabIndex = 0;\n firstLink.focus();\n } else if (nextLink.classList.contains('d-none')) {\n e.target.tabIndex = -1;\n lastLink.tabIndex = 0;\n lastLink.focus();\n } else {\n e.target.tabIndex = -1;\n nextLink.tabIndex = 0;\n nextLink.focus();\n }\n }\n if (e.keyCode === arrowLeft) {\n const previousLink = e.target.previousElementSibling;\n if (previousLink === null) {\n e.target.tabIndex = -1;\n lastLink.tabIndex = 0;\n lastLink.focus();\n } else if (previousLink.classList.contains('d-none')) {\n e.target.tabIndex = -1;\n firstLink.tabIndex = 0;\n firstLink.focus();\n } else {\n e.target.tabIndex = -1;\n previousLink.tabIndex = 0;\n previousLink.focus();\n }\n }\n if (e.keyCode === home) {\n e.target.tabIndex = -1;\n firstLink.tabIndex = 0;\n firstLink.focus();\n }\n if (e.keyCode === end) {\n e.target.tabIndex = -1;\n lastLink.tabIndex = 0;\n lastLink.focus();\n }\n if (e.keyCode === space) {\n e.preventDefault();\n e.target.click();\n }\n });\n });\n};\n\n/**\n * Initialise the keyboard navigation controls for the chooser options.\n *\n * @method initChooserOptionsKeyboardNavigation\n * @param {HTMLElement} body Our modal that we are working with\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {HTMLElement} chooserOptionsContainer The section that contains the chooser items\n * @param {Object} modal Our created modal for the section\n */\nconst initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOptionsContainer, modal = null) => {\n const chooserOptions = chooserOptionsContainer.querySelectorAll(selectors.regions.chooserOption.container);\n\n Array.from(chooserOptions).forEach((element) => {\n return element.addEventListener('keydown', (e) => {\n\n // Check for enter/ space triggers for showing the help.\n if (e.keyCode === enter || e.keyCode === space) {\n if (e.target.matches(selectors.actions.optionActions.showSummary)) {\n e.preventDefault();\n const module = e.target.closest(selectors.regions.chooserOption.container);\n const moduleName = module.dataset.modname;\n const moduleData = mappedModules.get(moduleName);\n const carousel = $(body.querySelector(selectors.regions.carousel));\n carousel.carousel({\n interval: false,\n pause: true,\n keyboard: false\n });\n\n // We need to know if the overall modal has a footer so we know when to show a real / vs fake footer.\n moduleData.showFooter = modal.hasFooterContent();\n showModuleHelp(carousel, moduleData, modal);\n }\n }\n\n // Next.\n if (e.keyCode === arrowRight) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const nextOption = currentOption.nextElementSibling;\n const firstOption = chooserOptionsContainer.firstElementChild;\n const toFocusOption = clickErrorHandler(nextOption, firstOption);\n focusChooserOption(toFocusOption, currentOption);\n }\n\n // Previous.\n if (e.keyCode === arrowLeft) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const previousOption = currentOption.previousElementSibling;\n const lastOption = chooserOptionsContainer.lastElementChild;\n const toFocusOption = clickErrorHandler(previousOption, lastOption);\n focusChooserOption(toFocusOption, currentOption);\n }\n\n if (e.keyCode === home) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const firstOption = chooserOptionsContainer.firstElementChild;\n focusChooserOption(firstOption, currentOption);\n }\n\n if (e.keyCode === end) {\n e.preventDefault();\n const currentOption = e.target.closest(selectors.regions.chooserOption.container);\n const lastOption = chooserOptionsContainer.lastElementChild;\n focusChooserOption(lastOption, currentOption);\n }\n });\n });\n};\n\n/**\n * Focus on a chooser option element and remove the previous chooser element from the focus order\n *\n * @method focusChooserOption\n * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus\n * @param {HTMLElement|null} previousChooserOption The previous focused option element\n */\nconst focusChooserOption = (currentChooserOption, previousChooserOption = null) => {\n if (previousChooserOption !== null) {\n toggleFocusableChooserOption(previousChooserOption, false);\n }\n\n toggleFocusableChooserOption(currentChooserOption, true);\n currentChooserOption.focus();\n};\n\n/**\n * Add or remove a chooser option from the focus order.\n *\n * @method toggleFocusableChooserOption\n * @param {HTMLElement} chooserOption The chooser option element which should be added or removed from the focus order\n * @param {Boolean} isFocusable Whether the chooser element is focusable or not\n */\nconst toggleFocusableChooserOption = (chooserOption, isFocusable) => {\n const chooserOptionLink = chooserOption.querySelector(selectors.actions.addChooser);\n const chooserOptionHelp = chooserOption.querySelector(selectors.actions.optionActions.showSummary);\n const chooserOptionFavourite = chooserOption.querySelector(selectors.actions.optionActions.manageFavourite);\n\n if (isFocusable) {\n // Set tabindex to 0 to add current chooser option element to the focus order.\n chooserOption.tabIndex = 0;\n chooserOptionLink.tabIndex = 0;\n chooserOptionHelp.tabIndex = 0;\n chooserOptionFavourite.tabIndex = 0;\n } else {\n // Set tabindex to -1 to remove the previous chooser option element from the focus order.\n chooserOption.tabIndex = -1;\n chooserOptionLink.tabIndex = -1;\n chooserOptionHelp.tabIndex = -1;\n chooserOptionFavourite.tabIndex = -1;\n }\n};\n\n/**\n * Small error handling function to make sure the navigated to object exists\n *\n * @method clickErrorHandler\n * @param {HTMLElement} item What we want to check exists\n * @param {HTMLElement} fallback If we dont match anything fallback the focus\n * @return {HTMLElement}\n */\nconst clickErrorHandler = (item, fallback) => {\n if (item !== null) {\n return item;\n } else {\n return fallback;\n }\n};\n\n/**\n * Render the search results in a defined container\n *\n * @method renderSearchResults\n * @param {HTMLElement} searchResultsContainer The container where the data should be rendered\n * @param {Object} searchResultsData Data containing the module items that satisfy the search criteria\n */\nconst renderSearchResults = async(searchResultsContainer, searchResultsData) => {\n const templateData = {\n 'searchresultsnumber': searchResultsData.length,\n 'searchresults': searchResultsData\n };\n // Build up the html & js ready to place into the help section.\n const {html, js} = await Templates.renderForPromise('core_course/local/activitychooser/search_results', templateData);\n await Templates.replaceNodeContents(searchResultsContainer, html, js);\n};\n\n/**\n * Toggle (display/hide) the search results depending on the value of the search query\n *\n * @method toggleSearchResultsView\n * @param {Object} modal Our created modal for the section\n * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}\n * @param {String} searchQuery The search query\n */\nconst toggleSearchResultsView = async(modal, mappedModules, searchQuery) => {\n const modalBody = modal.getBody()[0];\n const searchResultsContainer = modalBody.querySelector(selectors.regions.searchResults);\n const chooserContainer = modalBody.querySelector(selectors.regions.chooser);\n const clearSearchButton = modalBody.querySelector(selectors.elements.clearsearch);\n const searchIcon = modalBody.querySelector(selectors.elements.searchicon);\n\n if (searchQuery.length > 0) { // Search query is present.\n const searchResultsData = searchModules(mappedModules, searchQuery);\n await renderSearchResults(searchResultsContainer, searchResultsData);\n const searchResultItemsContainer = searchResultsContainer.querySelector(selectors.regions.searchResultItems);\n const firstSearchResultItem = searchResultItemsContainer.querySelector(selectors.regions.chooserOption.container);\n if (firstSearchResultItem) {\n // Set the first result item to be focusable.\n toggleFocusableChooserOption(firstSearchResultItem, true);\n // Register keyboard events on the created search result items.\n initChooserOptionsKeyboardNavigation(modalBody, mappedModules, searchResultItemsContainer, modal);\n }\n // Display the \"clear\" search button in the activity chooser search bar.\n searchIcon.classList.add('d-none');\n clearSearchButton.classList.remove('d-none');\n // Hide the default chooser options container.\n chooserContainer.setAttribute('hidden', 'hidden');\n // Display the search results container.\n searchResultsContainer.removeAttribute('hidden');\n } else { // Search query is not present.\n // Hide the \"clear\" search button in the activity chooser search bar.\n clearSearchButton.classList.add('d-none');\n searchIcon.classList.remove('d-none');\n // Hide the search results container.\n searchResultsContainer.setAttribute('hidden', 'hidden');\n // Display the default chooser options container.\n chooserContainer.removeAttribute('hidden');\n }\n};\n\n/**\n * Return the list of modules which have a name or description that matches the given search term.\n *\n * @method searchModules\n * @param {Array} modules List of available modules\n * @param {String} searchTerm The search term to match\n * @return {Array}\n */\nconst searchModules = (modules, searchTerm) => {\n if (searchTerm === '') {\n return modules;\n }\n searchTerm = searchTerm.toLowerCase();\n const searchResults = [];\n modules.forEach((activity) => {\n const activityName = activity.title.toLowerCase();\n const activityDesc = activity.help.toLowerCase();\n if (activityName.includes(searchTerm) || activityDesc.includes(searchTerm)) {\n searchResults.push(activity);\n }\n });\n\n return searchResults;\n};\n\n/**\n * Set up our tabindex information across the chooser.\n *\n * @method setupKeyboardAccessibility\n * @param {Promise} modal Our created modal for the section\n * @param {Map} mappedModules A map of all of the built module information\n */\nconst setupKeyboardAccessibility = (modal, mappedModules) => {\n modal.getModal()[0].tabIndex = -1;\n\n modal.getBodyPromise().then(body => {\n $(selectors.elements.tab).on('shown.bs.tab', (e) => {\n const activeSectionId = e.target.getAttribute(\"href\");\n const activeSectionChooserOptions = body[0]\n .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));\n const firstChooserOption = activeSectionChooserOptions\n .querySelector(selectors.regions.chooserOption.container);\n const prevActiveSectionId = e.relatedTarget.getAttribute(\"href\");\n const prevActiveSectionChooserOptions = body[0]\n .querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));\n\n // Disable the focus of every chooser option in the previous active section.\n disableFocusAllChooserOptions(prevActiveSectionChooserOptions);\n // Enable the focus of the first chooser option in the current active section.\n toggleFocusableChooserOption(firstChooserOption, true);\n initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions, modal);\n });\n return;\n }).catch(Notification.exception);\n};\n\n/**\n * Disable the focus of all chooser options in a specific container (section).\n *\n * @method disableFocusAllChooserOptions\n * @param {HTMLElement} sectionChooserOptions The section that contains the chooser items\n */\nconst disableFocusAllChooserOptions = (sectionChooserOptions) => {\n const allChooserOptions = sectionChooserOptions.querySelectorAll(selectors.regions.chooserOption.container);\n allChooserOptions.forEach((chooserOption) => {\n toggleFocusableChooserOption(chooserOption, false);\n });\n};\n\n/**\n * Display the module chooser.\n *\n * @method displayChooser\n * @param {Promise} modalPromise Our created modal for the section\n * @param {Array} sectionModules An array of all of the built module information\n * @param {Function} partialFavourite Partially applied function we need to manage favourite status\n * @param {Object} footerData Our base footer object.\n */\nexport const displayChooser = (modalPromise, sectionModules, partialFavourite, footerData) => {\n // Make a map so we can quickly fetch a specific module's object for either rendering or searching.\n const mappedModules = new Map();\n sectionModules.forEach((module) => {\n mappedModules.set(module.componentname + '_' + module.link, module);\n });\n\n // Register event listeners.\n modalPromise.then(modal => {\n registerListenerEvents(modal, mappedModules, partialFavourite, footerData);\n\n // We want to focus on the first chooser option element as soon as the modal is opened.\n setupKeyboardAccessibility(modal, mappedModules);\n\n // We want to focus on the action select when the dialog is closed.\n modal.getRoot().on(ModalEvents.hidden, () => {\n modal.destroy();\n });\n\n return modal;\n }).catch();\n};\n"],"file":"dialogue.min.js"} \ No newline at end of file diff --git a/course/amd/build/local/activitychooser/repository.min.js b/course/amd/build/local/activitychooser/repository.min.js index e7e7505e824..44e5ee1f5b9 100644 --- a/course/amd/build/local/activitychooser/repository.min.js +++ b/course/amd/build/local/activitychooser/repository.min.js @@ -1,2 +1,2 @@ -define ("core_course/local/activitychooser/repository",["exports","core/ajax"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.unfavouriteModule=a.favouriteModule=a.activityModules=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);var c=function(a){return b.default.call([{methodname:"core_course_get_course_content_items",args:{courseid:a}}])[0]};a.activityModules=c;var d=function(a,c){return b.default.call([{methodname:"core_course_add_content_item_to_user_favourites",args:{componentname:a,contentitemid:c}}])[0]};a.favouriteModule=d;var e=function(a,c){return b.default.call([{methodname:"core_course_remove_content_item_from_user_favourites",args:{componentname:a,contentitemid:c}}])[0]};a.unfavouriteModule=e}); +define ("core_course/local/activitychooser/repository",["exports","core/ajax"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.fetchFooterData=a.unfavouriteModule=a.favouriteModule=a.activityModules=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);var c=function(a){return b.default.call([{methodname:"core_course_get_course_content_items",args:{courseid:a}}])[0]};a.activityModules=c;var d=function(a,c){return b.default.call([{methodname:"core_course_add_content_item_to_user_favourites",args:{componentname:a,contentitemid:c}}])[0]};a.favouriteModule=d;var e=function(a,c){return b.default.call([{methodname:"core_course_remove_content_item_from_user_favourites",args:{componentname:a,contentitemid:c}}])[0]};a.unfavouriteModule=e;var f=function(a,c){return b.default.call([{methodname:"core_course_get_activity_chooser_footer",args:{courseid:a,sectionid:c}}])[0]};a.fetchFooterData=f}); //# sourceMappingURL=repository.min.js.map diff --git a/course/amd/build/local/activitychooser/repository.min.js.map b/course/amd/build/local/activitychooser/repository.min.js.map index e7bb5048953..0469db64b55 100644 --- a/course/amd/build/local/activitychooser/repository.min.js.map +++ b/course/amd/build/local/activitychooser/repository.min.js.map @@ -1 +1 @@ -{"version":3,"sources":["../../../src/local/activitychooser/repository.js"],"names":["activityModules","courseid","ajax","call","methodname","args","favouriteModule","modName","modID","componentname","contentitemid","unfavouriteModule"],"mappings":"0NAsBA,uDASO,GAAMA,CAAAA,CAAe,CAAG,SAACC,CAAD,CAAc,CAOzC,MAAOC,WAAKC,IAAL,CAAU,CAND,CACZC,UAAU,CAAE,sCADA,CAEZC,IAAI,CAAE,CACFJ,QAAQ,CAAEA,CADR,CAFM,CAMC,CAAV,EAAqB,CAArB,CACV,CARM,C,oBAmBA,GAAMK,CAAAA,CAAe,CAAG,SAACC,CAAD,CAAUC,CAAV,CAAoB,CAQ/C,MAAON,WAAKC,IAAL,CAAU,CAPD,CACZC,UAAU,CAAE,iDADA,CAEZC,IAAI,CAAE,CACFI,aAAa,CAAEF,CADb,CAEFG,aAAa,CAAEF,CAFb,CAFM,CAOC,CAAV,EAAqB,CAArB,CACV,CATM,C,oBAoBA,GAAMG,CAAAA,CAAiB,CAAG,SAACJ,CAAD,CAAUC,CAAV,CAAoB,CAQjD,MAAON,WAAKC,IAAL,CAAU,CAPD,CACZC,UAAU,CAAE,sDADA,CAEZC,IAAI,CAAE,CACFI,aAAa,CAAEF,CADb,CAEFG,aAAa,CAAEF,CAFb,CAFM,CAOC,CAAV,EAAqB,CAArB,CACV,CATM,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n *\n * @module core_course/repository\n * @package core_course\n * @copyright 2019 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport ajax from 'core/ajax';\n\n/**\n * Fetch all the information on modules we'll need in the activity chooser.\n *\n * @method activityModules\n * @param {Number} courseid What course to fetch the modules for\n * @return {object} jQuery promise\n */\nexport const activityModules = (courseid) => {\n const request = {\n methodname: 'core_course_get_course_content_items',\n args: {\n courseid: courseid,\n },\n };\n return ajax.call([request])[0];\n};\n\n/**\n * Given a module name, module ID & the current course we want to specify that the module\n * is a users' favourite.\n *\n * @method favouriteModule\n * @param {String} modName Frankenstyle name of the component to add favourite\n * @param {int} modID ID of the module. Mainly for LTI cases where they have same / similar names\n * @return {object} jQuery promise\n */\nexport const favouriteModule = (modName, modID) => {\n const request = {\n methodname: 'core_course_add_content_item_to_user_favourites',\n args: {\n componentname: modName,\n contentitemid: modID,\n },\n };\n return ajax.call([request])[0];\n};\n\n/**\n * Given a module name, module ID & the current course we want to specify that the module\n * is no longer a users' favourite.\n *\n * @method unfavouriteModule\n * @param {String} modName Frankenstyle name of the component to add favourite\n * @param {int} modID ID of the module. Mainly for LTI cases where they have same / similar names\n * @return {object} jQuery promise\n */\nexport const unfavouriteModule = (modName, modID) => {\n const request = {\n methodname: 'core_course_remove_content_item_from_user_favourites',\n args: {\n componentname: modName,\n contentitemid: modID,\n },\n };\n return ajax.call([request])[0];\n};\n"],"file":"repository.min.js"} \ No newline at end of file +{"version":3,"sources":["../../../src/local/activitychooser/repository.js"],"names":["activityModules","courseid","ajax","call","methodname","args","favouriteModule","modName","modID","componentname","contentitemid","unfavouriteModule","fetchFooterData","sectionid"],"mappings":"4OAsBA,uDASO,GAAMA,CAAAA,CAAe,CAAG,SAACC,CAAD,CAAc,CAOzC,MAAOC,WAAKC,IAAL,CAAU,CAND,CACZC,UAAU,CAAE,sCADA,CAEZC,IAAI,CAAE,CACFJ,QAAQ,CAAEA,CADR,CAFM,CAMC,CAAV,EAAqB,CAArB,CACV,CARM,C,oBAmBA,GAAMK,CAAAA,CAAe,CAAG,SAACC,CAAD,CAAUC,CAAV,CAAoB,CAQ/C,MAAON,WAAKC,IAAL,CAAU,CAPD,CACZC,UAAU,CAAE,iDADA,CAEZC,IAAI,CAAE,CACFI,aAAa,CAAEF,CADb,CAEFG,aAAa,CAAEF,CAFb,CAFM,CAOC,CAAV,EAAqB,CAArB,CACV,CATM,C,oBAoBA,GAAMG,CAAAA,CAAiB,CAAG,SAACJ,CAAD,CAAUC,CAAV,CAAoB,CAQjD,MAAON,WAAKC,IAAL,CAAU,CAPD,CACZC,UAAU,CAAE,sDADA,CAEZC,IAAI,CAAE,CACFI,aAAa,CAAEF,CADb,CAEFG,aAAa,CAAEF,CAFb,CAFM,CAOC,CAAV,EAAqB,CAArB,CACV,CATM,C,sBAmBA,GAAMI,CAAAA,CAAe,CAAG,SAACX,CAAD,CAAWY,CAAX,CAAyB,CAQpD,MAAOX,WAAKC,IAAL,CAAU,CAPD,CACZC,UAAU,CAAE,yCADA,CAEZC,IAAI,CAAE,CACFJ,QAAQ,CAAEA,CADR,CAEFY,SAAS,CAAEA,CAFT,CAFM,CAOC,CAAV,EAAqB,CAArB,CACV,CATM,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n *\n * @module core_course/repository\n * @package core_course\n * @copyright 2019 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport ajax from 'core/ajax';\n\n/**\n * Fetch all the information on modules we'll need in the activity chooser.\n *\n * @method activityModules\n * @param {Number} courseid What course to fetch the modules for\n * @return {object} jQuery promise\n */\nexport const activityModules = (courseid) => {\n const request = {\n methodname: 'core_course_get_course_content_items',\n args: {\n courseid: courseid,\n },\n };\n return ajax.call([request])[0];\n};\n\n/**\n * Given a module name, module ID & the current course we want to specify that the module\n * is a users' favourite.\n *\n * @method favouriteModule\n * @param {String} modName Frankenstyle name of the component to add favourite\n * @param {int} modID ID of the module. Mainly for LTI cases where they have same / similar names\n * @return {object} jQuery promise\n */\nexport const favouriteModule = (modName, modID) => {\n const request = {\n methodname: 'core_course_add_content_item_to_user_favourites',\n args: {\n componentname: modName,\n contentitemid: modID,\n },\n };\n return ajax.call([request])[0];\n};\n\n/**\n * Given a module name, module ID & the current course we want to specify that the module\n * is no longer a users' favourite.\n *\n * @method unfavouriteModule\n * @param {String} modName Frankenstyle name of the component to add favourite\n * @param {int} modID ID of the module. Mainly for LTI cases where they have same / similar names\n * @return {object} jQuery promise\n */\nexport const unfavouriteModule = (modName, modID) => {\n const request = {\n methodname: 'core_course_remove_content_item_from_user_favourites',\n args: {\n componentname: modName,\n contentitemid: modID,\n },\n };\n return ajax.call([request])[0];\n};\n\n/**\n * Fetch all the information on modules we'll need in the activity chooser.\n *\n * @method fetchFooterData\n * @param {Number} courseid What course to fetch the data for\n * @param {Number} sectionid What section to fetch the data for\n * @return {object} jQuery promise\n */\nexport const fetchFooterData = (courseid, sectionid) => {\n const request = {\n methodname: 'core_course_get_activity_chooser_footer',\n args: {\n courseid: courseid,\n sectionid: sectionid,\n },\n };\n return ajax.call([request])[0];\n};\n"],"file":"repository.min.js"} \ No newline at end of file diff --git a/course/amd/src/activitychooser.js b/course/amd/src/activitychooser.js index 3704632d84d..176ed9901e3 100644 --- a/course/amd/src/activitychooser.js +++ b/course/amd/src/activitychooser.js @@ -85,6 +85,20 @@ const registerListenerEvents = (courseId, chooserConfig) => { }; })(); + const fetchFooterData = (() => { + let footerInnerPromise = null; + + return (sectionId) => { + if (!footerInnerPromise) { + footerInnerPromise = new Promise((resolve) => { + resolve(Repository.fetchFooterData(courseId, sectionId)); + }); + } + + return footerInnerPromise; + }; + })(); + CustomEvents.define(document, events); // Display module chooser event listeners. @@ -115,7 +129,8 @@ const registerListenerEvents = (courseId, chooserConfig) => { bodyPromiseResolver = resolve; }); - const sectionModal = buildModal(bodyPromise); + const footerData = await fetchFooterData(caller.dataset.sectionid); + const sectionModal = buildModal(bodyPromise, footerData); // Now we have a modal we should start fetching data. const data = await fetchModuleData(); @@ -127,6 +142,7 @@ const registerListenerEvents = (courseId, chooserConfig) => { sectionModal, builtModuleData, partiallyAppliedFavouriteManager(data, caller.dataset.sectionid), + footerData, ); bodyPromiseResolver(await Templates.render( @@ -221,13 +237,15 @@ const templateDataBuilder = (data, chooserConfig) => { * * @method buildModal * @param {Promise} bodyPromise + * @param {String|Boolean} footer Either a footer to add or nothing * @return {Object} The modal ready to display immediately and render body in later. */ -const buildModal = bodyPromise => { +const buildModal = (bodyPromise, footer) => { return ModalFactory.create({ type: ModalFactory.types.DEFAULT, title: getString('addresourceoractivity'), body: bodyPromise, + footer: footer.customfootertemplate, large: true, templateContext: { classes: 'modchooser' diff --git a/course/amd/src/local/activitychooser/dialogue.js b/course/amd/src/local/activitychooser/dialogue.js index 3cbc5e86583..cd045a3de0d 100644 --- a/course/amd/src/local/activitychooser/dialogue.js +++ b/course/amd/src/local/activitychooser/dialogue.js @@ -31,6 +31,7 @@ import {addIconToContainer} from 'core/loadingicon'; import * as Repository from 'core_course/local/activitychooser/repository'; import Notification from 'core/notification'; import {debounce} from 'core/utils'; +const getPlugin = pluginName => import(pluginName); /** * Given an event from the main module 'page' navigate to it's help section via a carousel. @@ -38,8 +39,13 @@ import {debounce} from 'core/utils'; * @method showModuleHelp * @param {jQuery} carousel Our initialized carousel to manipulate * @param {Object} moduleData Data of the module to carousel to + * @param {jQuery} modal We need to figure out if the current modal has a footer. */ -const showModuleHelp = (carousel, moduleData) => { +const showModuleHelp = (carousel, moduleData, modal = null) => { + // If we have a real footer then we need to change temporarily. + if (modal !== null && moduleData.showFooter === true) { + modal.setFooter(Templates.render('core_course/local/activitychooser/footer_partial', moduleData)); + } const help = carousel.find(selectors.regions.help)[0]; help.innerHTML = ''; help.classList.add('m-auto'); @@ -107,8 +113,9 @@ const manageFavouriteState = async(modalBody, caller, partialFavourite) => { * @param {Promise} modal Our modal that we are working with * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object} * @param {Function} partialFavourite Partially applied function we need to manage favourite status + * @param {Object} footerData Our base footer object. */ -const registerListenerEvents = (modal, mappedModules, partialFavourite) => { +const registerListenerEvents = (modal, mappedModules, partialFavourite, footerData) => { const bodyClickListener = async(e) => { if (e.target.closest(selectors.actions.optionActions.showSummary)) { const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel)); @@ -116,7 +123,9 @@ const registerListenerEvents = (modal, mappedModules, partialFavourite) => { const module = e.target.closest(selectors.regions.chooserOption.container); const moduleName = module.dataset.modname; const moduleData = mappedModules.get(moduleName); - showModuleHelp(carousel, moduleData); + // We need to know if the overall modal has a footer so we know when to show a real / vs fake footer. + moduleData.showFooter = modal.hasFooterContent(); + showModuleHelp(carousel, moduleData, modal); } if (e.target.closest(selectors.actions.optionActions.manageFavourite)) { @@ -128,7 +137,7 @@ const registerListenerEvents = (modal, mappedModules, partialFavourite) => { const firstChooserOption = sectionChooserOptions .querySelector(selectors.regions.chooserOption.container); toggleFocusableChooserOption(firstChooserOption, true); - initChooserOptionsKeyboardNavigation(modal.getBody()[0], mappedModules, sectionChooserOptions); + initChooserOptionsKeyboardNavigation(modal.getBody()[0], mappedModules, sectionChooserOptions, modal); } // From the help screen go back to the module overview. @@ -150,7 +159,18 @@ const registerListenerEvents = (modal, mappedModules, partialFavourite) => { const searchInput = modal.getBody()[0].querySelector(selectors.actions.search); searchInput.value = ""; searchInput.focus(); - toggleSearchResultsView(modal.getBody()[0], mappedModules, searchInput.value); + toggleSearchResultsView(modal, mappedModules, searchInput.value); + } + }; + + // We essentially have two types of footer. + // A fake one that is handled within the template for chooser_help and then all of the stuff for + // modal.footer. We need to ensure we know exactly what type of footer we are using so we know what we + // need to manage. The below code handles a real footer going to a mnet carousel item. + const footerClickListener = async(e) => { + if (footerData.footer === true) { + const footerjs = await getPlugin(footerData.customfooterjs); + await footerjs.footerClickListener(e, footerData, modal); } }; @@ -183,7 +203,7 @@ const registerListenerEvents = (modal, mappedModules, partialFavourite) => { // The search input is triggered. searchInput.addEventListener('input', debounce(() => { // Display the search results. - toggleSearchResultsView(body, mappedModules, searchInput.value); + toggleSearchResultsView(modal, mappedModules, searchInput.value); }, 300)); return body; }) @@ -197,12 +217,22 @@ const registerListenerEvents = (modal, mappedModules, partialFavourite) => { toggleFocusableChooserOption(firstChooserOption, true); initTabsKeyboardNavigation(body); - initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions); + initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions, modal); return body; }) .catch(); + modal.getFooterPromise() + + // The return value of getBodyPromise is a jquery object containing the body NodeElement. + .then(footer => footer[0]) + // Add the listener for clicks on the footer. + .then(footer => { + footer.addEventListener('click', footerClickListener); + return footer; + }) + .catch(); }; /** @@ -283,8 +313,9 @@ const initTabsKeyboardNavigation = (body) => { * @param {HTMLElement} body Our modal that we are working with * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object} * @param {HTMLElement} chooserOptionsContainer The section that contains the chooser items + * @param {Object} modal Our created modal for the section */ -const initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOptionsContainer) => { +const initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOptionsContainer, modal = null) => { const chooserOptions = chooserOptionsContainer.querySelectorAll(selectors.regions.chooserOption.container); Array.from(chooserOptions).forEach((element) => { @@ -303,7 +334,10 @@ const initChooserOptionsKeyboardNavigation = (body, mappedModules, chooserOption pause: true, keyboard: false }); - showModuleHelp(carousel, moduleData); + + // We need to know if the overall modal has a footer so we know when to show a real / vs fake footer. + moduleData.showFooter = modal.hasFooterContent(); + showModuleHelp(carousel, moduleData, modal); } } @@ -424,11 +458,12 @@ const renderSearchResults = async(searchResultsContainer, searchResultsData) => * Toggle (display/hide) the search results depending on the value of the search query * * @method toggleSearchResultsView - * @param {HTMLElement} modalBody The body of the created modal for the section + * @param {Object} modal Our created modal for the section * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object} * @param {String} searchQuery The search query */ -const toggleSearchResultsView = async(modalBody, mappedModules, searchQuery) => { +const toggleSearchResultsView = async(modal, mappedModules, searchQuery) => { + const modalBody = modal.getBody()[0]; const searchResultsContainer = modalBody.querySelector(selectors.regions.searchResults); const chooserContainer = modalBody.querySelector(selectors.regions.chooser); const clearSearchButton = modalBody.querySelector(selectors.elements.clearsearch); @@ -443,7 +478,7 @@ const toggleSearchResultsView = async(modalBody, mappedModules, searchQuery) => // Set the first result item to be focusable. toggleFocusableChooserOption(firstSearchResultItem, true); // Register keyboard events on the created search result items. - initChooserOptionsKeyboardNavigation(modalBody, mappedModules, searchResultItemsContainer); + initChooserOptionsKeyboardNavigation(modalBody, mappedModules, searchResultItemsContainer, modal); } // Display the "clear" search button in the activity chooser search bar. searchIcon.classList.add('d-none'); @@ -513,7 +548,7 @@ const setupKeyboardAccessibility = (modal, mappedModules) => { disableFocusAllChooserOptions(prevActiveSectionChooserOptions); // Enable the focus of the first chooser option in the current active section. toggleFocusableChooserOption(firstChooserOption, true); - initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions); + initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions, modal); }); return; }).catch(Notification.exception); @@ -539,8 +574,9 @@ const disableFocusAllChooserOptions = (sectionChooserOptions) => { * @param {Promise} modalPromise Our created modal for the section * @param {Array} sectionModules An array of all of the built module information * @param {Function} partialFavourite Partially applied function we need to manage favourite status + * @param {Object} footerData Our base footer object. */ -export const displayChooser = (modalPromise, sectionModules, partialFavourite) => { +export const displayChooser = (modalPromise, sectionModules, partialFavourite, footerData) => { // Make a map so we can quickly fetch a specific module's object for either rendering or searching. const mappedModules = new Map(); sectionModules.forEach((module) => { @@ -549,7 +585,7 @@ export const displayChooser = (modalPromise, sectionModules, partialFavourite) = // Register event listeners. modalPromise.then(modal => { - registerListenerEvents(modal, mappedModules, partialFavourite); + registerListenerEvents(modal, mappedModules, partialFavourite, footerData); // We want to focus on the first chooser option element as soon as the modal is opened. setupKeyboardAccessibility(modal, mappedModules); diff --git a/course/amd/src/local/activitychooser/repository.js b/course/amd/src/local/activitychooser/repository.js index 52c544440a0..43de8c19681 100644 --- a/course/amd/src/local/activitychooser/repository.js +++ b/course/amd/src/local/activitychooser/repository.js @@ -78,3 +78,22 @@ export const unfavouriteModule = (modName, modID) => { }; return ajax.call([request])[0]; }; + +/** + * Fetch all the information on modules we'll need in the activity chooser. + * + * @method fetchFooterData + * @param {Number} courseid What course to fetch the data for + * @param {Number} sectionid What section to fetch the data for + * @return {object} jQuery promise + */ +export const fetchFooterData = (courseid, sectionid) => { + const request = { + methodname: 'core_course_get_activity_chooser_footer', + args: { + courseid: courseid, + sectionid: sectionid, + }, + }; + return ajax.call([request])[0]; +}; diff --git a/course/classes/local/entity/activity_chooser_footer.php b/course/classes/local/entity/activity_chooser_footer.php new file mode 100644 index 00000000000..2524d24182f --- /dev/null +++ b/course/classes/local/entity/activity_chooser_footer.php @@ -0,0 +1,86 @@ +. + +/** + * Activity Chooser footer data class. + * + * @package core + * @subpackage course + * @copyright 2020 Mathew May + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_course\local\entity; + +/** + * A class to represent the Activity Chooser footer data. + * + * @package core + * @subpackage course + * @copyright 2020 Mathew May + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class activity_chooser_footer { + + /** @var string $footerjspath The path to the plugin JS file to dynamically import later. */ + protected $footerjspath; + + /** @var string $footertemplate The rendered template for the footer. */ + protected $footertemplate; + + /** @var string $carouseltemplate The rendered template for the footer. */ + protected $carouseltemplate; + + /** + * Constructor method. + * + * @param string $footerjspath JS file to dynamically import later. + * @param string $footertemplate Footer template that has been rendered. + * @param string|null $carouseltemplate Carousel template that may have been rendered. + */ + public function __construct(string $footerjspath, string $footertemplate, ?string $carouseltemplate = '') { + $this->footerjspath = $footerjspath; + $this->footertemplate = $footertemplate; + $this->carouseltemplate = $carouseltemplate; + } + + /** + * Get the footer JS file path for this plugin. + * + * @return string The JS file to call functions from. + */ + public function get_footer_js_file(): string { + return $this->footerjspath; + } + + /** + * Get the footer rendered template for this plugin. + * + * @return string The template that has been rendered for the chooser footer. + */ + public function get_footer_template(): string { + return $this->footertemplate; + } + + /** + * Get the carousel rendered template for this plugin. + * + * @return string The template that has been rendered for the chooser carousel. + */ + public function get_carousel_template(): string { + return $this->carouseltemplate; + } +} diff --git a/course/externallib.php b/course/externallib.php index 5ddf89e5e30..fe3e80b290d 100644 --- a/course/externallib.php +++ b/course/externallib.php @@ -4336,4 +4336,74 @@ class core_course_external extends external_api { ] ); } + + /** + * Returns description of method parameters + * + * @return external_function_parameters + */ + public static function get_activity_chooser_footer_parameters() { + return new external_function_parameters([ + 'courseid' => new external_value(PARAM_INT, 'ID of the course', VALUE_REQUIRED), + 'sectionid' => new external_value(PARAM_INT, 'ID of the section', VALUE_REQUIRED), + ]); + } + + /** + * Given a course ID we need to build up a footre for the chooser. + * + * @param int $courseid The course we want to fetch the modules for + * @param int $sectionid The section we want to fetch the modules for + * @return array + */ + public static function get_activity_chooser_footer(int $courseid, int $sectionid) { + [ + 'courseid' => $courseid, + 'sectionid' => $sectionid, + ] = self::validate_parameters(self::get_activity_chooser_footer_parameters(), [ + 'courseid' => $courseid, + 'sectionid' => $sectionid, + ]); + + $coursecontext = context_course::instance($courseid); + self::validate_context($coursecontext); + + $pluginswithfunction = get_plugins_with_function('custom_chooser_footer', 'lib.php'); + if ($pluginswithfunction) { + foreach ($pluginswithfunction as $plugintype => $plugins) { + foreach ($plugins as $pluginfunction) { + $footerdata = $pluginfunction($courseid, $sectionid); + break; // Only a single plugin can modify the footer. + } + break; // Only a single plugin can modify the footer. + } + return [ + 'footer' => true, + 'customfooterjs' => $footerdata->get_footer_js_file(), + 'customfootertemplate' => $footerdata->get_footer_template(), + 'customcarouseltemplate' => $footerdata->get_carousel_template(), + ]; + } else { + return [ + 'footer' => false, + ]; + } + } + + /** + * Returns description of method result value + * + * @return external_description + */ + public static function get_activity_chooser_footer_returns() { + return new external_single_structure( + [ + 'footer' => new external_value(PARAM_BOOL, 'Is a footer being return by this request?', VALUE_REQUIRED), + 'customfooterjs' => new external_value(PARAM_RAW, 'The path to the plugin JS file', VALUE_OPTIONAL), + 'customfootertemplate' => new external_value(PARAM_RAW, 'The prerendered footer', VALUE_OPTIONAL), + 'customcarouseltemplate' => new external_value(PARAM_RAW, 'Either "" or the prerendered carousel page', + VALUE_OPTIONAL), + ] + ); + } } diff --git a/course/templates/activitychooser.mustache b/course/templates/activitychooser.mustache index 4511df99bd4..bf6ba5ec43c 100644 --- a/course/templates/activitychooser.mustache +++ b/course/templates/activitychooser.mustache @@ -147,5 +147,7 @@ + + diff --git a/course/templates/local/activitychooser/footer_partial.mustache b/course/templates/local/activitychooser/footer_partial.mustache new file mode 100644 index 00000000000..898d119a8a1 --- /dev/null +++ b/course/templates/local/activitychooser/footer_partial.mustache @@ -0,0 +1,33 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_course/local/activitychooser/footer_partial + + Chooser favourite template partial. + + Example context (json): + { + } +}} +
+ + + {{#str}} add {{/str}} + +
diff --git a/course/templates/local/activitychooser/help.mustache b/course/templates/local/activitychooser/help.mustache index 68798bcc563..9ddacd44496 100644 --- a/course/templates/local/activitychooser/help.mustache +++ b/course/templates/local/activitychooser/help.mustache @@ -44,12 +44,9 @@ {{{help}}} -
- - - {{#str}} add {{/str}} - -
+ {{^showFooter}} +
+ {{>core_course/local/activitychooser/footer_partial}} +
+ {{/showFooter}} diff --git a/course/tests/behat/activity_chooser.feature b/course/tests/behat/activity_chooser.feature index b71e1e5ac3d..3748d0fe3fa 100644 --- a/course/tests/behat/activity_chooser.feature +++ b/course/tests/behat/activity_chooser.feature @@ -14,6 +14,8 @@ Feature: Display and choose from the available activities in course And the following "course enrolments" exist: | user | course | role | | teacher | C | editingteacher | + And the following config values are set as admin: + | enablemoodlenet | 0 | tool_moodlenet | And I log in as "teacher" And I am on "Course" course homepage with editing mode on diff --git a/lang/en/admin.php b/lang/en/admin.php index d47cd39a47f..b21142e5fbc 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -539,8 +539,6 @@ $string['enableglobalsearch_desc'] = 'If enabled, data will be indexed and synch $string['enablegravatar'] = 'Enable Gravatar'; $string['enablegravatar_help'] = 'When enabled Moodle will attempt to fetch a user profile picture from Gravatar if the user has not uploaded an image.'; $string['enablemobilewebservice'] = 'Enable web services for mobile devices'; -$string['enablemoodlenet'] = 'Enable integration with MoodleNet instances'; -$string['enablemoodlenet_desc'] = 'If enabled, and provided the MoodleNet plugin is installed, users can import content from MoodleNet into this site.'; $string['enablerecordcache'] = 'Enable record cache'; $string['enablerssfeeds'] = 'Enable RSS feeds'; $string['enablesearchareas'] = 'Enable search areas'; diff --git a/lang/en/user.php b/lang/en/user.php index 5eee2cb2b43..ee6a9a81a9a 100644 --- a/lang/en/user.php +++ b/lang/en/user.php @@ -33,6 +33,7 @@ $string['countparticipantsfound'] = '{$a} participants found'; $string['filtersetmatchdescription'] = 'How multiple filters should be combined'; $string['match'] = 'Match'; $string['matchofthefollowing'] = 'of the following:'; +$string['moodlenetprofile'] = 'MoodleNet profile'; $string['placeholdertypeorselect'] = 'Type or select...'; $string['placeholdertype'] = 'Type...'; $string['privacy:courserequestpath'] = 'Requested courses'; @@ -87,6 +88,7 @@ $string['privacy:metadata:maildisplay'] = 'A preference for the user about displ $string['privacy:metadata:middlename'] = 'The middle name of the user'; $string['privacy:metadata:mnethostid'] = 'An identifier for the MNet host if used'; $string['privacy:metadata:model'] = 'The device name, occam or iPhone etc..'; +$string['privacy:metadata:moodlenetprofile'] = 'The MoodleNet profile for the user'; $string['privacy:metadata:msn'] = 'The MSN identifier of the user'; $string['privacy:metadata:my_pages'] = 'User pages - dashboard and profile. This table does not contain personal data and only used to link dashboard blocks to users'; $string['privacy:metadata:my_pages:name'] = 'Page name'; diff --git a/lib/db/install.xml b/lib/db/install.xml index be3ac0058ba..8f91417fa6b 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -872,6 +872,7 @@ + diff --git a/lib/db/services.php b/lib/db/services.php index 1a9ae7fa9e1..b900c1ae329 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -671,6 +671,14 @@ $functions = array( 'type' => 'read', 'ajax' => true, ), + 'core_course_get_activity_chooser_footer' => array( + 'classname' => 'core_course_external', + 'methodname' => 'get_activity_chooser_footer', + 'classpath' => 'course/externallib.php', + 'description' => 'Fetch the data for the activity chooser footer.', + 'type' => 'read', + 'ajax' => true, + ), 'core_course_toggle_activity_recommendation' => array( 'classname' => 'core_course_external', 'methodname' => 'toggle_activity_recommendation', diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index d0d822d82b6..b6348d1cca4 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -2427,5 +2427,19 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2020052200.01); } + if ($oldversion < 2020060500.01) { + // Define field moodlenetprofile to be added to user. + $table = new xmldb_table('user'); + $field = new xmldb_field('moodlenetprofile', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'alternatename'); + + // Conditionally launch add field moodlenetprofile. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2020060500.01); + } + return true; } diff --git a/lib/myprofilelib.php b/lib/myprofilelib.php index 87c792dbcd1..730ef6e352c 100644 --- a/lib/myprofilelib.php +++ b/lib/myprofilelib.php @@ -165,6 +165,12 @@ function core_myprofile_navigation(core_user\output\myprofile\tree $tree, $user, $tree->add_node($node); } + if (!isset($hiddenfields['moodlenetprofile']) && $user->moodlenetprofile) { + $node = new core_user\output\myprofile\node('contact', 'moodlenetprofile', get_string('moodlenetprofile', 'user'), null, + null, $user->moodlenetprofile); + $tree->add_node($node); + } + if (!isset($hiddenfields['country']) && $user->country) { $node = new core_user\output\myprofile\node('contact', 'country', get_string('country'), null, null, get_string($user->country, 'countries')); diff --git a/mod/url/lib.php b/mod/url/lib.php index 23d11a0f4a7..815360d3ae2 100644 --- a/mod/url/lib.php +++ b/mod/url/lib.php @@ -318,6 +318,7 @@ function url_dndupload_handle($uploadinfo) { $data->introformat = FORMAT_HTML; $data->externalurl = clean_param($uploadinfo->content, PARAM_URL); $data->timemodified = time(); + $data->coursemodule = $uploadinfo->coursemodule; // Set the display options to the site defaults. $config = get_config('url'); diff --git a/pix/MoodleNet.png b/pix/MoodleNet.png new file mode 100644 index 0000000000000000000000000000000000000000..872756bda88d1d4153e681393777cfd874770346 GIT binary patch literal 66122 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 literal 0 HcmV?d00001 diff --git a/pix/MoodleNet.svg b/pix/MoodleNet.svg new file mode 100644 index 00000000000..8f191bf0ea9 --- /dev/null +++ b/pix/MoodleNet.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/theme/boost/scss/moodle/core.scss b/theme/boost/scss/moodle/core.scss index 2bfa166876b..411f762413e 100644 --- a/theme/boost/scss/moodle/core.scss +++ b/theme/boost/scss/moodle/core.scss @@ -1637,6 +1637,17 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { } } +.modchooser .modal-footer { + height: 70px; + .moodlenet-logo { + .icon { + height: 2.5rem; + width: 6rem; + margin-bottom: .6rem; + } + } +} + .modchoosercontainer.noscroll { overflow-y: hidden; } @@ -1684,7 +1695,7 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { background-color: $white; overflow-x: hidden; overflow-y: auto; - min-height: 640px; + height: 640px; .content { overflow-y: auto; @@ -2447,6 +2458,10 @@ body.h5p-embed { overflow-wrap: break-word !important; /* stylelint-disable-line declaration-no-important */ } +.z-index-0 { + z-index: 0 !important; /* stylelint-disable-line declaration-no-important */ +} + .z-index-1 { z-index: 1 !important; /* stylelint-disable-line declaration-no-important */ } @@ -2653,4 +2668,4 @@ $picker-emojis-per-row: 7 !default; position: relative; z-index: inherit; } -} \ No newline at end of file +} diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index 5dda75a93fa..b2d48fe4e66 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -11001,6 +11001,13 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { .modchooser .modal-body .carousel-item .loading-icon .icon { margin: 1em auto; } +.modchooser .modal-footer { + height: 70px; } + .modchooser .modal-footer .moodlenet-logo .icon { + height: 2.5rem; + width: 6rem; + margin-bottom: .6rem; } + .modchoosercontainer.noscroll { overflow-y: hidden; } @@ -11038,7 +11045,7 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { background-color: #fff; overflow-x: hidden; overflow-y: auto; - min-height: 640px; } + height: 640px; } .modchooser .modal-body .optionsummary .content { overflow-y: auto; } .modchooser .modal-body .optionsummary .content .heading .icon { @@ -11631,6 +11638,10 @@ body.h5p-embed .h5pmessages { overflow-wrap: break-word !important; /* stylelint-disable-line declaration-no-important */ } +.z-index-0 { + z-index: 0 !important; + /* stylelint-disable-line declaration-no-important */ } + .z-index-1 { z-index: 1 !important; /* stylelint-disable-line declaration-no-important */ } diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index ab07c5f1a5d..e2c098d5e91 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -11208,6 +11208,13 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { .modchooser .modal-body .carousel-item .loading-icon .icon { margin: 1em auto; } +.modchooser .modal-footer { + height: 70px; } + .modchooser .modal-footer .moodlenet-logo .icon { + height: 2.5rem; + width: 6rem; + margin-bottom: .6rem; } + .modchoosercontainer.noscroll { overflow-y: hidden; } @@ -11245,7 +11252,7 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { background-color: #fff; overflow-x: hidden; overflow-y: auto; - min-height: 640px; } + height: 640px; } .modchooser .modal-body .optionsummary .content { overflow-y: auto; } .modchooser .modal-body .optionsummary .content .heading .icon { @@ -11842,6 +11849,10 @@ body.h5p-embed .h5pmessages { overflow-wrap: break-word !important; /* stylelint-disable-line declaration-no-important */ } +.z-index-0 { + z-index: 0 !important; + /* stylelint-disable-line declaration-no-important */ } + .z-index-1 { z-index: 1 !important; /* stylelint-disable-line declaration-no-important */ } diff --git a/user/classes/privacy/provider.php b/user/classes/privacy/provider.php index 839026e044a..74b28a8bc3a 100644 --- a/user/classes/privacy/provider.php +++ b/user/classes/privacy/provider.php @@ -104,7 +104,8 @@ class provider implements 'lastnamephonetic' => 'privacy:metadata:lastnamephonetic', 'firstnamephonetic' => 'privacy:metadata:firstnamephonetic', 'middlename' => 'privacy:metadata:middlename', - 'alternatename' => 'privacy:metadata:alternatename' + 'alternatename' => 'privacy:metadata:alternatename', + 'moodlenetprofile' => 'privacy:metadata:moodlenetprofile' ]; $passwordhistory = [ diff --git a/user/editlib.php b/user/editlib.php index c8d2f68ae0a..b3ec83811a9 100644 --- a/user/editlib.php +++ b/user/editlib.php @@ -300,6 +300,9 @@ function useredit_shared_definition(&$mform, $editoroptions, $filemanageroptions $mform->setDefault('maildisplay', core_user::get_property_default('maildisplay')); $mform->addHelpButton('maildisplay', 'emaildisplay'); + $mform->addElement('text', 'moodlenetprofile', get_string('moodlenetprofile', 'user')); + $mform->setType('moodlenetprofile', PARAM_RAW_TRIMMED); + $mform->addElement('text', 'city', get_string('city'), 'maxlength="120" size="21"'); $mform->setType('city', PARAM_TEXT); if (!empty($CFG->defaultcity)) { diff --git a/version.php b/version.php index f506718cd43..797c89131e9 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2020060200.01; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2020060500.01; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '3.9dev+ (Build: 20200602)'; // Human-friendly version name From b1e6d8b86f80a792f9810b914e818f57525f2cab Mon Sep 17 00:00:00 2001 From: Jake Dallimore Date: Fri, 5 Jun 2020 10:09:37 +0800 Subject: [PATCH 2/2] MDL-67883 tool_moodlenet: Add MoodleNet to core --- .../moodlenet/amd/build/instance_form.min.js | 2 + .../amd/build/instance_form.min.js.map | 1 + .../moodlenet/amd/build/select_page.min.js | 2 + .../amd/build/select_page.min.js.map | 1 + .../tool/moodlenet/amd/build/selectors.min.js | 2 + .../moodlenet/amd/build/selectors.min.js.map | 1 + .../tool/moodlenet/amd/build/validator.min.js | 2 + .../moodlenet/amd/build/validator.min.js.map | 1 + admin/tool/moodlenet/amd/src/instance_form.js | 169 +++++++++ admin/tool/moodlenet/amd/src/select_page.js | 198 ++++++++++ admin/tool/moodlenet/amd/src/selectors.js | 45 +++ admin/tool/moodlenet/amd/src/validator.js | 59 +++ admin/tool/moodlenet/classes/external.php | 189 ++++++++++ .../classes/local/import_backup_helper.php | 194 ++++++++++ .../classes/local/import_handler_info.php | 91 +++++ .../classes/local/import_handler_registry.php | 188 ++++++++++ .../moodlenet/classes/local/import_info.php | 126 +++++++ .../classes/local/import_processor.php | 206 +++++++++++ .../classes/local/import_strategy.php | 69 ++++ .../classes/local/import_strategy_file.php | 170 +++++++++ .../classes/local/import_strategy_link.php | 71 ++++ .../classes/local/remote_resource.php | 169 +++++++++ admin/tool/moodlenet/classes/local/url.php | 84 +++++ .../classes/moodlenet_user_profile.php | 107 ++++++ .../moodlenet/classes/output/renderer.php | 52 +++ .../moodlenet/classes/output/select_page.php | 76 ++++ .../moodlenet/classes/privacy/provider.php | 44 +++ .../moodlenet/classes/profile_manager.php | 350 ++++++++++++++++++ admin/tool/moodlenet/db/services.php | 44 +++ admin/tool/moodlenet/db/upgrade.php | 81 ++++ admin/tool/moodlenet/import.php | 85 +++++ admin/tool/moodlenet/index.php | 136 +++++++ .../tool/moodlenet/lang/en/tool_moodlenet.php | 69 ++++ admin/tool/moodlenet/lib.php | 104 ++++++ admin/tool/moodlenet/options.php | 128 +++++++ .../tool/moodlenet/pix}/MoodleNet.png | Bin .../tool/moodlenet/pix}/MoodleNet.svg | 0 admin/tool/moodlenet/pix/courses.svg | 52 +++ admin/tool/moodlenet/select.php | 53 +++ admin/tool/moodlenet/settings.php | 46 +++ .../templates/chooser_footer.mustache | 46 +++ .../chooser_footer_close_mnet.mustache | 28 ++ .../templates/chooser_moodlenet.mustache | 67 ++++ .../templates/import_confirmation.mustache | 76 ++++ .../templates/import_options_select.mustache | 80 ++++ .../moodlenet/templates/select_page.mustache | 58 +++ .../moodlenet/templates/view-cards.mustache | 54 +++ .../tests/import_backup_helper_test.php | 84 +++++ .../tests/import_handler_info_test.php | 75 ++++ .../tests/import_handler_registry_test.php | 119 ++++++ .../tool/moodlenet/tests/import_info_test.php | 105 ++++++ .../moodlenet/tests/import_processor_test.php | 156 ++++++++ admin/tool/moodlenet/tests/lib_test.php | 80 ++++ .../moodlenet/tests/profile_manager_test.php | 142 +++++++ .../moodlenet/tests/remote_resource_test.php | 115 ++++++ admin/tool/moodlenet/tests/url_test.php | 103 ++++++ admin/tool/moodlenet/version.php | 30 ++ .../local/activitychooser/help.mustache | 2 +- theme/boost/scss/moodle/core.scss | 1 + theme/boost/style/moodle.css | 3 +- theme/classic/style/moodle.css | 3 +- 61 files changed, 4891 insertions(+), 3 deletions(-) create mode 100644 admin/tool/moodlenet/amd/build/instance_form.min.js create mode 100644 admin/tool/moodlenet/amd/build/instance_form.min.js.map create mode 100644 admin/tool/moodlenet/amd/build/select_page.min.js create mode 100644 admin/tool/moodlenet/amd/build/select_page.min.js.map create mode 100644 admin/tool/moodlenet/amd/build/selectors.min.js create mode 100644 admin/tool/moodlenet/amd/build/selectors.min.js.map create mode 100644 admin/tool/moodlenet/amd/build/validator.min.js create mode 100644 admin/tool/moodlenet/amd/build/validator.min.js.map create mode 100644 admin/tool/moodlenet/amd/src/instance_form.js create mode 100644 admin/tool/moodlenet/amd/src/select_page.js create mode 100644 admin/tool/moodlenet/amd/src/selectors.js create mode 100644 admin/tool/moodlenet/amd/src/validator.js create mode 100644 admin/tool/moodlenet/classes/external.php create mode 100644 admin/tool/moodlenet/classes/local/import_backup_helper.php create mode 100644 admin/tool/moodlenet/classes/local/import_handler_info.php create mode 100644 admin/tool/moodlenet/classes/local/import_handler_registry.php create mode 100644 admin/tool/moodlenet/classes/local/import_info.php create mode 100644 admin/tool/moodlenet/classes/local/import_processor.php create mode 100644 admin/tool/moodlenet/classes/local/import_strategy.php create mode 100644 admin/tool/moodlenet/classes/local/import_strategy_file.php create mode 100644 admin/tool/moodlenet/classes/local/import_strategy_link.php create mode 100644 admin/tool/moodlenet/classes/local/remote_resource.php create mode 100644 admin/tool/moodlenet/classes/local/url.php create mode 100644 admin/tool/moodlenet/classes/moodlenet_user_profile.php create mode 100644 admin/tool/moodlenet/classes/output/renderer.php create mode 100644 admin/tool/moodlenet/classes/output/select_page.php create mode 100644 admin/tool/moodlenet/classes/privacy/provider.php create mode 100644 admin/tool/moodlenet/classes/profile_manager.php create mode 100644 admin/tool/moodlenet/db/services.php create mode 100644 admin/tool/moodlenet/db/upgrade.php create mode 100644 admin/tool/moodlenet/import.php create mode 100644 admin/tool/moodlenet/index.php create mode 100644 admin/tool/moodlenet/lang/en/tool_moodlenet.php create mode 100644 admin/tool/moodlenet/lib.php create mode 100644 admin/tool/moodlenet/options.php rename {pix => admin/tool/moodlenet/pix}/MoodleNet.png (100%) rename {pix => admin/tool/moodlenet/pix}/MoodleNet.svg (100%) create mode 100644 admin/tool/moodlenet/pix/courses.svg create mode 100644 admin/tool/moodlenet/select.php create mode 100644 admin/tool/moodlenet/settings.php create mode 100644 admin/tool/moodlenet/templates/chooser_footer.mustache create mode 100644 admin/tool/moodlenet/templates/chooser_footer_close_mnet.mustache create mode 100644 admin/tool/moodlenet/templates/chooser_moodlenet.mustache create mode 100644 admin/tool/moodlenet/templates/import_confirmation.mustache create mode 100644 admin/tool/moodlenet/templates/import_options_select.mustache create mode 100644 admin/tool/moodlenet/templates/select_page.mustache create mode 100644 admin/tool/moodlenet/templates/view-cards.mustache create mode 100644 admin/tool/moodlenet/tests/import_backup_helper_test.php create mode 100644 admin/tool/moodlenet/tests/import_handler_info_test.php create mode 100644 admin/tool/moodlenet/tests/import_handler_registry_test.php create mode 100644 admin/tool/moodlenet/tests/import_info_test.php create mode 100644 admin/tool/moodlenet/tests/import_processor_test.php create mode 100644 admin/tool/moodlenet/tests/lib_test.php create mode 100644 admin/tool/moodlenet/tests/profile_manager_test.php create mode 100644 admin/tool/moodlenet/tests/remote_resource_test.php create mode 100644 admin/tool/moodlenet/tests/url_test.php create mode 100644 admin/tool/moodlenet/version.php diff --git a/admin/tool/moodlenet/amd/build/instance_form.min.js b/admin/tool/moodlenet/amd/build/instance_form.min.js new file mode 100644 index 00000000000..a6f0561f12d --- /dev/null +++ b/admin/tool/moodlenet/amd/build/instance_form.min.js @@ -0,0 +1,2 @@ +define ("tool_moodlenet/instance_form",["tool_moodlenet/validator","tool_moodlenet/selectors","core/loadingicon","core/templates","core/notification","jquery"],function(a,b,c,d,e,f){var g=function(d){d.addEventListener("click",function(f){if(f.target.matches(b.action.submit)){var e=d.querySelector("[data-var=\"mnet-link\"]"),g=d.querySelector(b.region.spinner),h=document.querySelector(b.region.validationArea);g.classList.remove("d-none");var i=c.addIconToContainerWithPromise(g);a.validation(e).then(function(a){i.resolve();g.classList.add("d-none");if(a.result){e.classList.remove("is-invalid");e.classList.add("is-valid");h.innerText=a.message;h.classList.remove("text-error");h.classList.add("text-success");setTimeout(function(){window.location=a.domain},1e3)}else{e.classList.add("is-invalid");h.innerText=a.message;h.classList.add("text-error")}}).catch()}})},h=function(a,b,h,i){a.innerHTML="";var j=c.addIconToContainer(a),k=null,l=new Promise(function(a){k=a});f.when(j,l).then(function(){d.replaceNodeContents(a,b.customcarouseltemplate,"")}).catch(e.exception);g(a);h.one("slid.bs.carousel",function(){k()});h.carousel(2);i.setFooter(d.render("tool_moodlenet/chooser_footer_close_mnet",{}))},i=function(a,b,c){a.carousel(0);b.setFooter(c.customfootertemplate)};return{footerClickListener:function footerClickListener(a,c,d){if(a.target.matches(b.action.showMoodleNet)||a.target.closest(b.action.showMoodleNet)){a.preventDefault();var e=f(d.getBody()[0].querySelector(b.region.carousel)),g=e.find(b.region.moodleNet)[0];h(g,c,e,d)}if(a.target.matches(b.action.closeOption)){var j=f(d.getBody()[0].querySelector(b.region.carousel));i(j,d,c)}}}}); +//# sourceMappingURL=instance_form.min.js.map diff --git a/admin/tool/moodlenet/amd/build/instance_form.min.js.map b/admin/tool/moodlenet/amd/build/instance_form.min.js.map new file mode 100644 index 00000000000..3e2853cc56c --- /dev/null +++ b/admin/tool/moodlenet/amd/build/instance_form.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/instance_form.js"],"names":["define","Validator","Selectors","LoadingIcon","Templates","Notification","$","registerListenerEvents","page","addEventListener","e","target","matches","action","submit","input","querySelector","overlay","region","spinner","validationArea","document","classList","remove","addIconToContainerWithPromise","validation","then","result","resolve","add","innerText","message","setTimeout","window","location","domain","catch","chooserNavigateToMnet","showMoodleNet","footerData","carousel","modal","innerHTML","spinnerPromise","addIconToContainer","transitionPromiseResolver","transitionPromise","Promise","when","replaceNodeContents","customcarouseltemplate","exception","one","setFooter","render","chooserNavigateFromMnet","customfootertemplate","footerClickListener","closest","preventDefault","getBody","find","moodleNet","closeOption"],"mappings":"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","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Our basic form manager for when a user either enters\n * their profile url or just wants to browse.\n *\n * This file is a mishmash of JS functions we need for both the standalone (M3.7, M3.8)\n * plugin & Moodle 3.9 functions. The 3.9 Functions have a base understanding that certain\n * things exist i.e. directory structures for templates. When this feature goes 3.9+ only\n * The goal is that we can quickly gut all AMD modules into bare JS files and use ES6 guidelines.\n * Till then this will have to do.\n *\n * @module tool_moodlenet/instance_form\n * @package tool_moodlenet\n * @copyright 2020 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\ndefine(['tool_moodlenet/validator',\n 'tool_moodlenet/selectors',\n 'core/loadingicon',\n 'core/templates',\n 'core/notification',\n 'jquery'],\n function(Validator,\n Selectors,\n LoadingIcon,\n Templates,\n Notification,\n $) {\n\n /**\n * Add the event listeners to our form.\n *\n * @method registerListenerEvents\n * @param {HTMLElement} page The whole page element for our form area\n */\n var registerListenerEvents = function registerListenerEvents(page) {\n page.addEventListener('click', function(e) {\n\n // Our fake submit button / browse button.\n if (e.target.matches(Selectors.action.submit)) {\n var input = page.querySelector('[data-var=\"mnet-link\"]');\n var overlay = page.querySelector(Selectors.region.spinner);\n var validationArea = document.querySelector(Selectors.region.validationArea);\n\n overlay.classList.remove('d-none');\n var spinner = LoadingIcon.addIconToContainerWithPromise(overlay);\n Validator.validation(input)\n .then(function(result) {\n spinner.resolve();\n overlay.classList.add('d-none');\n if (result.result) {\n input.classList.remove('is-invalid'); // Just in case the class has been applied already.\n input.classList.add('is-valid');\n validationArea.innerText = result.message;\n validationArea.classList.remove('text-error');\n validationArea.classList.add('text-success');\n // Give the user some time to see their input is valid.\n setTimeout(function() {\n window.location = result.domain;\n }, 1000);\n } else {\n input.classList.add('is-invalid');\n validationArea.innerText = result.message;\n validationArea.classList.add('text-error');\n }\n return;\n }).catch();\n }\n });\n };\n\n /**\n * Given a user wishes to see the MoodleNet profile url form transition them there.\n *\n * @method chooserNavigateToMnet\n * @param {HTMLElement} showMoodleNet The chooser's area for ment\n * @param {Object} footerData Our footer object to render out\n * @param {jQuery} carousel Our carousel instance to manage\n * @param {jQuery} modal Our modal instance to manage\n */\n var chooserNavigateToMnet = function(showMoodleNet, footerData, carousel, modal) {\n showMoodleNet.innerHTML = '';\n\n // Add a spinner.\n var spinnerPromise = LoadingIcon.addIconToContainer(showMoodleNet);\n\n // Used later...\n var transitionPromiseResolver = null;\n var transitionPromise = new Promise(resolve => {\n transitionPromiseResolver = resolve;\n });\n\n $.when(\n spinnerPromise,\n transitionPromise\n ).then(function() {\n Templates.replaceNodeContents(showMoodleNet, footerData.customcarouseltemplate, '');\n return;\n }).catch(Notification.exception);\n\n // We apply our handlers in here to minimise plugin dependency in the Chooser.\n registerListenerEvents(showMoodleNet);\n\n // Move to the next slide, and resolve the transition promise when it's done.\n carousel.one('slid.bs.carousel', function() {\n transitionPromiseResolver();\n });\n // Trigger the transition between 'pages'.\n carousel.carousel(2);\n // eslint-disable-next-line max-len\n modal.setFooter(Templates.render('tool_moodlenet/chooser_footer_close_mnet', {}));\n };\n\n /**\n * Given a user no longer wishes to see the MoodleNet profile url form transition them from there.\n *\n * @method chooserNavigateFromMnet\n * @param {jQuery} carousel Our carousel instance to manage\n * @param {jQuery} modal Our modal instance to manage\n * @param {Object} footerData Our footer object to render out\n */\n var chooserNavigateFromMnet = function(carousel, modal, footerData) {\n // Trigger the transition between 'pages'.\n carousel.carousel(0);\n modal.setFooter(footerData.customfootertemplate);\n };\n\n /**\n * Create the custom listener that would handle anything in the footer.\n *\n * @param {Event} e The event being triggered.\n * @param {Object} footerData The data generated from the exporter.\n * @param {Object} modal The chooser modal.\n */\n var footerClickListener = function(e, footerData, modal) {\n if (e.target.matches(Selectors.action.showMoodleNet) || e.target.closest(Selectors.action.showMoodleNet)) {\n e.preventDefault();\n const carousel = $(modal.getBody()[0].querySelector(Selectors.region.carousel));\n const showMoodleNet = carousel.find(Selectors.region.moodleNet)[0];\n\n chooserNavigateToMnet(showMoodleNet, footerData, carousel, modal);\n }\n // From the help screen go back to the module overview.\n if (e.target.matches(Selectors.action.closeOption)) {\n const carousel = $(modal.getBody()[0].querySelector(Selectors.region.carousel));\n\n chooserNavigateFromMnet(carousel, modal, footerData);\n }\n };\n\n return {\n footerClickListener: footerClickListener\n };\n});\n"],"file":"instance_form.min.js"} \ No newline at end of file diff --git a/admin/tool/moodlenet/amd/build/select_page.min.js b/admin/tool/moodlenet/amd/build/select_page.min.js new file mode 100644 index 00000000000..558896069b9 --- /dev/null +++ b/admin/tool/moodlenet/amd/build/select_page.min.js @@ -0,0 +1,2 @@ +define ("tool_moodlenet/select_page",["core/ajax","core/templates","tool_moodlenet/selectors","core/notification"],function(a,b,c,d){var e,f=function(a){return b.renderPix("courses","tool_moodlenet").then(function(a){return a}).then(function(a){var c=document.createElement("div");c.innerHTML=a.trim();return b.render("core_course/no-courses",{nocoursesimg:c.firstChild.src})}).then(function(c,d){b.replaceNodeContents(a,c,d);a.classList.add("mx-auto");a.classList.add("w-25")})},g=function(a,c){return b.render("tool_moodlenet/view-cards",{courses:c}).then(function(c,d){b.replaceNodeContents(a,c,d);a.classList.remove("mx-auto");a.classList.remove("w-25")})},h=function(b,h,i){var j=h.querySelector(c.region.searchIcon),k=h.querySelector(c.region.clearIcon);if(""!==b){j.classList.add("d-none");k.parentElement.classList.remove("d-none")}else{j.classList.remove("d-none");k.parentElement.classList.add("d-none")}a.call([{methodname:"tool_moodlenet_search_courses",args:{searchvalue:b}}])[0].then(function(a){if(0===a.courses.length){return f(i)}else{a.courses.forEach(function(a){a.viewurl+="&id="+e});return g(i,a.courses)}}).catch(d.exception)},i=function(a){var b=a.querySelector(c.region.searchInput),d=a.querySelector(c.region.courses),e=a.querySelector(c.region.clearIcon);e.addEventListener("click",function(){b.value="";h("",a,d)});b.addEventListener("input",k(function(){h(b.value,a,d)},300))},j=function(a){var b=a.querySelector(c.region.courses);h("",a,b)},k=function(a,b,c){var d;return function(){var e=this,f=arguments,g=c&&!d;clearTimeout(d);d=setTimeout(function later(){d=null;if(!c){a.apply(e,f)}},b);if(g){a.apply(e,f)}}};return{init:function init(a){e=a;var b=document.querySelector(c.region.selectPage);i(b);j(b)}}}); +//# sourceMappingURL=select_page.min.js.map diff --git a/admin/tool/moodlenet/amd/build/select_page.min.js.map b/admin/tool/moodlenet/amd/build/select_page.min.js.map new file mode 100644 index 00000000000..49e47572829 --- /dev/null +++ b/admin/tool/moodlenet/amd/build/select_page.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/select_page.js"],"names":["define","Ajax","Templates","Selectors","Notification","importId","renderNoCourses","areaReplace","renderPix","then","img","temp","document","createElement","innerHTML","trim","render","nocoursesimg","firstChild","src","html","js","replaceNodeContents","classList","add","renderCourses","courses","remove","searchCourses","inputValue","page","searchIcon","querySelector","region","clearIcon","parentElement","call","methodname","args","searchvalue","result","length","forEach","course","viewurl","catch","exception","registerListenerEvents","input","searchInput","courseArea","addEventListener","value","debounce","addCourses","func","wait","immediate","timeout","context","arguments","callNow","clearTimeout","setTimeout","later","apply","init","importIdString","selectPage"],"mappings":"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","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * When returning to Moodle let the user select which course to add the resource to.\n *\n * @module tool_moodlenet/select_page\n * @package tool_moodlenet\n * @copyright 2020 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\ndefine([\n 'core/ajax',\n 'core/templates',\n 'tool_moodlenet/selectors',\n 'core/notification'\n], function(\n Ajax,\n Templates,\n Selectors,\n Notification\n) {\n /**\n * @var {string} The id corresponding to the import.\n */\n var importId;\n\n /**\n * Set up the page.\n *\n * @method init\n * @param {string} importIdString the string ID of the import.\n */\n var init = function(importIdString) {\n importId = importIdString;\n var page = document.querySelector(Selectors.region.selectPage);\n registerListenerEvents(page);\n addCourses(page);\n };\n\n /**\n * Renders the 'no-courses' template.\n *\n * @param {HTMLElement} areaReplace the DOM node to replace.\n * @returns {Promise}\n */\n var renderNoCourses = function(areaReplace) {\n return Templates.renderPix('courses', 'tool_moodlenet').then(function(img) {\n return img;\n }).then(function(img) {\n var temp = document.createElement('div');\n temp.innerHTML = img.trim();\n return Templates.render('core_course/no-courses', {\n nocoursesimg: temp.firstChild.src\n });\n }).then(function(html, js) {\n Templates.replaceNodeContents(areaReplace, html, js);\n areaReplace.classList.add('mx-auto');\n areaReplace.classList.add('w-25');\n return;\n });\n };\n\n /**\n * Render the course cards for those supplied courses.\n *\n * @param {HTMLElement} areaReplace the DOM node to replace.\n * @param {Array} courses the courses to render.\n * @returns {Promise}\n */\n var renderCourses = function(areaReplace, courses) {\n return Templates.render('tool_moodlenet/view-cards', {\n courses: courses\n }).then(function(html, js) {\n Templates.replaceNodeContents(areaReplace, html, js);\n areaReplace.classList.remove('mx-auto');\n areaReplace.classList.remove('w-25');\n return;\n });\n };\n\n /**\n * For a given input, the page & what to replace fetch courses and manage icons too.\n *\n * @method searchCourses\n * @param {string} inputValue What to search for\n * @param {HTMLElement} page The whole page element for our page\n * @param {HTMLElement} areaReplace The Element to replace the contents of\n */\n var searchCourses = function(inputValue, page, areaReplace) {\n var searchIcon = page.querySelector(Selectors.region.searchIcon);\n var clearIcon = page.querySelector(Selectors.region.clearIcon);\n\n if (inputValue !== '') {\n searchIcon.classList.add('d-none');\n clearIcon.parentElement.classList.remove('d-none');\n } else {\n searchIcon.classList.remove('d-none');\n clearIcon.parentElement.classList.add('d-none');\n }\n var args = {\n searchvalue: inputValue,\n };\n Ajax.call([{\n methodname: 'tool_moodlenet_search_courses',\n args: args\n }])[0].then(function(result) {\n if (result.courses.length === 0) {\n return renderNoCourses(areaReplace);\n } else {\n // Add the importId to the course link\n result.courses.forEach(function(course) {\n course.viewurl += '&id=' + importId;\n });\n return renderCourses(areaReplace, result.courses);\n }\n }).catch(Notification.exception);\n };\n\n /**\n * Add the event listeners to our page.\n *\n * @method registerListenerEvents\n * @param {HTMLElement} page The whole page element for our page\n */\n var registerListenerEvents = function(page) {\n var input = page.querySelector(Selectors.region.searchInput);\n var courseArea = page.querySelector(Selectors.region.courses);\n var clearIcon = page.querySelector(Selectors.region.clearIcon);\n clearIcon.addEventListener('click', function() {\n input.value = '';\n searchCourses('', page, courseArea);\n });\n\n input.addEventListener('input', debounce(function() {\n searchCourses(input.value, page, courseArea);\n }, 300));\n };\n\n /**\n * Fetch the courses to show the user. We use the same WS structure & template as the search for consistency.\n *\n * @method addCourses\n * @param {HTMLElement} page The whole page element for our course page\n */\n var addCourses = function(page) {\n var courseArea = page.querySelector(Selectors.region.courses);\n searchCourses('', page, courseArea);\n };\n\n /**\n * Define our own debounce function as Moodle 3.7 does not have it.\n *\n * @method debounce\n * @from underscore.js\n * @copyright 2009-2020 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors\n * @licence MIT\n * @param {function} func The function we want to keep calling\n * @param {number} wait Our timeout\n * @param {boolean} immediate Do we want to apply the function immediately\n * @return {function}\n */\n var debounce = function(func, wait, immediate) {\n var timeout;\n return function() {\n var context = this;\n var args = arguments;\n var later = function() {\n timeout = null;\n if (!immediate) {\n func.apply(context, args);\n }\n };\n var callNow = immediate && !timeout;\n clearTimeout(timeout);\n timeout = setTimeout(later, wait);\n if (callNow) {\n func.apply(context, args);\n }\n };\n };\n return {\n init: init,\n };\n});\n"],"file":"select_page.min.js"} \ No newline at end of file diff --git a/admin/tool/moodlenet/amd/build/selectors.min.js b/admin/tool/moodlenet/amd/build/selectors.min.js new file mode 100644 index 00000000000..b1db64f13e2 --- /dev/null +++ b/admin/tool/moodlenet/amd/build/selectors.min.js @@ -0,0 +1,2 @@ +define ("tool_moodlenet/selectors",[],function(){return{action:{browse:"[data-action=\"browse\"]",submit:"[data-action=\"submit\"]",showMoodleNet:"[data-action=\"show-moodlenet\"]",closeOption:"[data-action=\"close-chooser-option-summary\"]"},region:{clearIcon:"[data-region=\"clear-icon\"]",courses:"[data-region=\"mnet-courses\"]",instancePage:"[data-region=\"moodle-net\"]",searchInput:"[data-region=\"search-input\"]",searchIcon:"[data-region=\"search-icon\"]",selectPage:"[data-region=\"moodle-net-select\"]",spinner:"[data-region=\"spinner\"]",validationArea:"[data-region=\"validation-area\"]",carousel:"[data-region=\"carousel\"]",moodleNet:"[data-region=\"pluginCarousel\"]"}}}); +//# sourceMappingURL=selectors.min.js.map diff --git a/admin/tool/moodlenet/amd/build/selectors.min.js.map b/admin/tool/moodlenet/amd/build/selectors.min.js.map new file mode 100644 index 00000000000..8ef6f4bba46 --- /dev/null +++ b/admin/tool/moodlenet/amd/build/selectors.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/selectors.js"],"names":["define","action","browse","submit","showMoodleNet","closeOption","region","clearIcon","courses","instancePage","searchInput","searchIcon","selectPage","spinner","validationArea","carousel","moodleNet"],"mappings":"AAuBAA,OAAM,4BAAC,EAAD,CAAK,UAAW,CAClB,MAAO,CACHC,MAAM,CAAE,CACJC,MAAM,CAAE,0BADJ,CAEJC,MAAM,CAAE,0BAFJ,CAGJC,aAAa,CAAE,kCAHX,CAIJC,WAAW,CAAE,gDAJT,CADL,CAOHC,MAAM,CAAE,CACJC,SAAS,CAAE,8BADP,CAEJC,OAAO,CAAE,gCAFL,CAGJC,YAAY,CAAE,8BAHV,CAIJC,WAAW,CAAE,gCAJT,CAKJC,UAAU,CAAE,+BALR,CAMJC,UAAU,CAAE,qCANR,CAOJC,OAAO,CAAE,2BAPL,CAQJC,cAAc,CAAE,mCARZ,CASJC,QAAQ,CAAE,4BATN,CAUJC,SAAS,CAAE,kCAVP,CAPL,CAoBV,CArBK,CAAN","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Define all of the selectors we will be using within MoodleNet plugin.\n *\n * @module tool_moodlenet/selectors\n * @package tool_moodlenet\n * @copyright 2020 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine([], function() {\n return {\n action: {\n browse: '[data-action=\"browse\"]',\n submit: '[data-action=\"submit\"]',\n showMoodleNet: '[data-action=\"show-moodlenet\"]',\n closeOption: '[data-action=\"close-chooser-option-summary\"]',\n },\n region: {\n clearIcon: '[data-region=\"clear-icon\"]',\n courses: '[data-region=\"mnet-courses\"]',\n instancePage: '[data-region=\"moodle-net\"]',\n searchInput: '[data-region=\"search-input\"]',\n searchIcon: '[data-region=\"search-icon\"]',\n selectPage: '[data-region=\"moodle-net-select\"]',\n spinner: '[data-region=\"spinner\"]',\n validationArea: '[data-region=\"validation-area\"]',\n carousel: '[data-region=\"carousel\"]',\n moodleNet: '[data-region=\"pluginCarousel\"]',\n },\n };\n});\n"],"file":"selectors.min.js"} \ No newline at end of file diff --git a/admin/tool/moodlenet/amd/build/validator.min.js b/admin/tool/moodlenet/amd/build/validator.min.js new file mode 100644 index 00000000000..1c4d02ae503 --- /dev/null +++ b/admin/tool/moodlenet/amd/build/validator.min.js @@ -0,0 +1,2 @@ +define ("tool_moodlenet/validator",["jquery","core/ajax","core/str","core/notification"],function(a,b,c,d){return{validation:function(e){var f=e.value;if(""===f||!f.includes("@")){a.when(c.get_string("profilevalidationerror","tool_moodlenet")).then(function(a){return Promise.reject().catch(function(){return{result:!1,message:a[0]}})}).fail(d.exception)}return b.call([{methodname:"tool_moodlenet_verify_webfinger",args:{profileurl:f,course:e.dataset.courseid,section:e.dataset.sectionid}}])[0].then(function(a){return a}).catch()}}}); +//# sourceMappingURL=validator.min.js.map diff --git a/admin/tool/moodlenet/amd/build/validator.min.js.map b/admin/tool/moodlenet/amd/build/validator.min.js.map new file mode 100644 index 00000000000..34c0c5b8fe0 --- /dev/null +++ b/admin/tool/moodlenet/amd/build/validator.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/validator.js"],"names":["define","$","Ajax","Str","Notification","validation","inputElement","inputValue","value","includes","when","get_string","then","strings","Promise","reject","catch","result","message","fail","exception","call","methodname","args","profileurl","course","dataset","courseid","section","sectionid"],"mappings":"AAuBAA,OAAM,4BAAC,CAAC,QAAD,CAAW,WAAX,CAAwB,UAAxB,CAAoC,mBAApC,CAAD,CAA2D,SAASC,CAAT,CAAYC,CAAZ,CAAkBC,CAAlB,CAAuBC,CAAvB,CAAqC,CAgClG,MAAO,CACHC,UAAU,CAzBG,SAAoBC,CAApB,CAAkC,CAC/C,GAAIC,CAAAA,CAAU,CAAGD,CAAY,CAACE,KAA9B,CAGA,GAAmB,EAAf,GAAAD,CAAU,EAAW,CAACA,CAAU,CAACE,QAAX,CAAoB,GAApB,CAA1B,CAAoD,CAEhDR,CAAC,CAACS,IAAF,CAAOP,CAAG,CAACQ,UAAJ,CAAe,wBAAf,CAAyC,gBAAzC,CAAP,EAAmEC,IAAnE,CAAwE,SAASC,CAAT,CAAkB,CACtF,MAAOC,CAAAA,OAAO,CAACC,MAAR,GAAiBC,KAAjB,CAAuB,UAAW,CACrC,MAAO,CAACC,MAAM,GAAP,CAAgBC,OAAO,CAAEL,CAAO,CAAC,CAAD,CAAhC,CACV,CAFM,CAGV,CAJD,EAIGM,IAJH,CAIQf,CAAY,CAACgB,SAJrB,CAKH,CAED,MAAOlB,CAAAA,CAAI,CAACmB,IAAL,CAAU,CAAC,CACdC,UAAU,CAAE,iCADE,CAEdC,IAAI,CAAE,CACFC,UAAU,CAAEjB,CADV,CAEFkB,MAAM,CAAEnB,CAAY,CAACoB,OAAb,CAAqBC,QAF3B,CAGFC,OAAO,CAAEtB,CAAY,CAACoB,OAAb,CAAqBG,SAH5B,CAFQ,CAAD,CAAV,EAOH,CAPG,EAOAjB,IAPA,CAOK,SAASK,CAAT,CAAiB,CACzB,MAAOA,CAAAA,CACV,CATM,EASJD,KATI,EAUV,CACM,CAGV,CAnCK,CAAN","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Our validator that splits the user's input then fires off to a webservice\n *\n * @module tool_moodlenet/validator\n * @package tool_moodlenet\n * @copyright 2020 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['jquery', 'core/ajax', 'core/str', 'core/notification'], function($, Ajax, Str, Notification) {\n /**\n * Handle form validation\n *\n * @method validation\n * @param {HTMLElement} inputElement The element the user entered text into.\n * @return {Promise} Was the users' entry a valid profile URL?\n */\n var validation = function validation(inputElement) {\n var inputValue = inputElement.value;\n\n // They didn't submit anything or they gave us a simple string that we can't do anything with.\n if (inputValue === \"\" || !inputValue.includes(\"@\")) {\n // Create a promise and immediately reject it.\n $.when(Str.get_string('profilevalidationerror', 'tool_moodlenet')).then(function(strings) {\n return Promise.reject().catch(function() {\n return {result: false, message: strings[0]};\n });\n }).fail(Notification.exception);\n }\n\n return Ajax.call([{\n methodname: 'tool_moodlenet_verify_webfinger',\n args: {\n profileurl: inputValue,\n course: inputElement.dataset.courseid,\n section: inputElement.dataset.sectionid\n }\n }])[0].then(function(result) {\n return result;\n }).catch();\n };\n return {\n validation: validation,\n };\n});\n"],"file":"validator.min.js"} \ No newline at end of file diff --git a/admin/tool/moodlenet/amd/src/instance_form.js b/admin/tool/moodlenet/amd/src/instance_form.js new file mode 100644 index 00000000000..a1443a0d51e --- /dev/null +++ b/admin/tool/moodlenet/amd/src/instance_form.js @@ -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 . + +/** + * Our basic form manager for when a user either enters + * their profile url or just wants to browse. + * + * This file is a mishmash of JS functions we need for both the standalone (M3.7, M3.8) + * plugin & Moodle 3.9 functions. The 3.9 Functions have a base understanding that certain + * things exist i.e. directory structures for templates. When this feature goes 3.9+ only + * The goal is that we can quickly gut all AMD modules into bare JS files and use ES6 guidelines. + * Till then this will have to do. + * + * @module tool_moodlenet/instance_form + * @package tool_moodlenet + * @copyright 2020 Mathew May + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define(['tool_moodlenet/validator', + 'tool_moodlenet/selectors', + 'core/loadingicon', + 'core/templates', + 'core/notification', + 'jquery'], + function(Validator, + Selectors, + LoadingIcon, + Templates, + Notification, + $) { + + /** + * Add the event listeners to our form. + * + * @method registerListenerEvents + * @param {HTMLElement} page The whole page element for our form area + */ + var registerListenerEvents = function registerListenerEvents(page) { + page.addEventListener('click', function(e) { + + // Our fake submit button / browse button. + if (e.target.matches(Selectors.action.submit)) { + var input = page.querySelector('[data-var="mnet-link"]'); + var overlay = page.querySelector(Selectors.region.spinner); + var validationArea = document.querySelector(Selectors.region.validationArea); + + overlay.classList.remove('d-none'); + var spinner = LoadingIcon.addIconToContainerWithPromise(overlay); + Validator.validation(input) + .then(function(result) { + spinner.resolve(); + overlay.classList.add('d-none'); + if (result.result) { + input.classList.remove('is-invalid'); // Just in case the class has been applied already. + input.classList.add('is-valid'); + validationArea.innerText = result.message; + validationArea.classList.remove('text-error'); + validationArea.classList.add('text-success'); + // Give the user some time to see their input is valid. + setTimeout(function() { + window.location = result.domain; + }, 1000); + } else { + input.classList.add('is-invalid'); + validationArea.innerText = result.message; + validationArea.classList.add('text-error'); + } + return; + }).catch(); + } + }); + }; + + /** + * Given a user wishes to see the MoodleNet profile url form transition them there. + * + * @method chooserNavigateToMnet + * @param {HTMLElement} showMoodleNet The chooser's area for ment + * @param {Object} footerData Our footer object to render out + * @param {jQuery} carousel Our carousel instance to manage + * @param {jQuery} modal Our modal instance to manage + */ + var chooserNavigateToMnet = function(showMoodleNet, footerData, carousel, modal) { + showMoodleNet.innerHTML = ''; + + // Add a spinner. + var spinnerPromise = LoadingIcon.addIconToContainer(showMoodleNet); + + // Used later... + var transitionPromiseResolver = null; + var transitionPromise = new Promise(resolve => { + transitionPromiseResolver = resolve; + }); + + $.when( + spinnerPromise, + transitionPromise + ).then(function() { + Templates.replaceNodeContents(showMoodleNet, footerData.customcarouseltemplate, ''); + return; + }).catch(Notification.exception); + + // We apply our handlers in here to minimise plugin dependency in the Chooser. + registerListenerEvents(showMoodleNet); + + // Move to the next slide, and resolve the transition promise when it's done. + carousel.one('slid.bs.carousel', function() { + transitionPromiseResolver(); + }); + // Trigger the transition between 'pages'. + carousel.carousel(2); + // eslint-disable-next-line max-len + modal.setFooter(Templates.render('tool_moodlenet/chooser_footer_close_mnet', {})); + }; + + /** + * Given a user no longer wishes to see the MoodleNet profile url form transition them from there. + * + * @method chooserNavigateFromMnet + * @param {jQuery} carousel Our carousel instance to manage + * @param {jQuery} modal Our modal instance to manage + * @param {Object} footerData Our footer object to render out + */ + var chooserNavigateFromMnet = function(carousel, modal, footerData) { + // Trigger the transition between 'pages'. + carousel.carousel(0); + modal.setFooter(footerData.customfootertemplate); + }; + + /** + * Create the custom listener that would handle anything in the footer. + * + * @param {Event} e The event being triggered. + * @param {Object} footerData The data generated from the exporter. + * @param {Object} modal The chooser modal. + */ + var footerClickListener = function(e, footerData, modal) { + if (e.target.matches(Selectors.action.showMoodleNet) || e.target.closest(Selectors.action.showMoodleNet)) { + e.preventDefault(); + const carousel = $(modal.getBody()[0].querySelector(Selectors.region.carousel)); + const showMoodleNet = carousel.find(Selectors.region.moodleNet)[0]; + + chooserNavigateToMnet(showMoodleNet, footerData, carousel, modal); + } + // From the help screen go back to the module overview. + if (e.target.matches(Selectors.action.closeOption)) { + const carousel = $(modal.getBody()[0].querySelector(Selectors.region.carousel)); + + chooserNavigateFromMnet(carousel, modal, footerData); + } + }; + + return { + footerClickListener: footerClickListener + }; +}); diff --git a/admin/tool/moodlenet/amd/src/select_page.js b/admin/tool/moodlenet/amd/src/select_page.js new file mode 100644 index 00000000000..d9a1dc4ef20 --- /dev/null +++ b/admin/tool/moodlenet/amd/src/select_page.js @@ -0,0 +1,198 @@ +// 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 . + +/** + * When returning to Moodle let the user select which course to add the resource to. + * + * @module tool_moodlenet/select_page + * @package tool_moodlenet + * @copyright 2020 Mathew May + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +define([ + 'core/ajax', + 'core/templates', + 'tool_moodlenet/selectors', + 'core/notification' +], function( + Ajax, + Templates, + Selectors, + Notification +) { + /** + * @var {string} The id corresponding to the import. + */ + var importId; + + /** + * Set up the page. + * + * @method init + * @param {string} importIdString the string ID of the import. + */ + var init = function(importIdString) { + importId = importIdString; + var page = document.querySelector(Selectors.region.selectPage); + registerListenerEvents(page); + addCourses(page); + }; + + /** + * Renders the 'no-courses' template. + * + * @param {HTMLElement} areaReplace the DOM node to replace. + * @returns {Promise} + */ + var renderNoCourses = function(areaReplace) { + return Templates.renderPix('courses', 'tool_moodlenet').then(function(img) { + return img; + }).then(function(img) { + var temp = document.createElement('div'); + temp.innerHTML = img.trim(); + return Templates.render('core_course/no-courses', { + nocoursesimg: temp.firstChild.src + }); + }).then(function(html, js) { + Templates.replaceNodeContents(areaReplace, html, js); + areaReplace.classList.add('mx-auto'); + areaReplace.classList.add('w-25'); + return; + }); + }; + + /** + * Render the course cards for those supplied courses. + * + * @param {HTMLElement} areaReplace the DOM node to replace. + * @param {Array} courses the courses to render. + * @returns {Promise} + */ + var renderCourses = function(areaReplace, courses) { + return Templates.render('tool_moodlenet/view-cards', { + courses: courses + }).then(function(html, js) { + Templates.replaceNodeContents(areaReplace, html, js); + areaReplace.classList.remove('mx-auto'); + areaReplace.classList.remove('w-25'); + return; + }); + }; + + /** + * For a given input, the page & what to replace fetch courses and manage icons too. + * + * @method searchCourses + * @param {string} inputValue What to search for + * @param {HTMLElement} page The whole page element for our page + * @param {HTMLElement} areaReplace The Element to replace the contents of + */ + var searchCourses = function(inputValue, page, areaReplace) { + var searchIcon = page.querySelector(Selectors.region.searchIcon); + var clearIcon = page.querySelector(Selectors.region.clearIcon); + + if (inputValue !== '') { + searchIcon.classList.add('d-none'); + clearIcon.parentElement.classList.remove('d-none'); + } else { + searchIcon.classList.remove('d-none'); + clearIcon.parentElement.classList.add('d-none'); + } + var args = { + searchvalue: inputValue, + }; + Ajax.call([{ + methodname: 'tool_moodlenet_search_courses', + args: args + }])[0].then(function(result) { + if (result.courses.length === 0) { + return renderNoCourses(areaReplace); + } else { + // Add the importId to the course link + result.courses.forEach(function(course) { + course.viewurl += '&id=' + importId; + }); + return renderCourses(areaReplace, result.courses); + } + }).catch(Notification.exception); + }; + + /** + * Add the event listeners to our page. + * + * @method registerListenerEvents + * @param {HTMLElement} page The whole page element for our page + */ + var registerListenerEvents = function(page) { + var input = page.querySelector(Selectors.region.searchInput); + var courseArea = page.querySelector(Selectors.region.courses); + var clearIcon = page.querySelector(Selectors.region.clearIcon); + clearIcon.addEventListener('click', function() { + input.value = ''; + searchCourses('', page, courseArea); + }); + + input.addEventListener('input', debounce(function() { + searchCourses(input.value, page, courseArea); + }, 300)); + }; + + /** + * Fetch the courses to show the user. We use the same WS structure & template as the search for consistency. + * + * @method addCourses + * @param {HTMLElement} page The whole page element for our course page + */ + var addCourses = function(page) { + var courseArea = page.querySelector(Selectors.region.courses); + searchCourses('', page, courseArea); + }; + + /** + * Define our own debounce function as Moodle 3.7 does not have it. + * + * @method debounce + * @from underscore.js + * @copyright 2009-2020 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + * @licence MIT + * @param {function} func The function we want to keep calling + * @param {number} wait Our timeout + * @param {boolean} immediate Do we want to apply the function immediately + * @return {function} + */ + var debounce = function(func, wait, immediate) { + var timeout; + return function() { + var context = this; + var args = arguments; + var later = function() { + timeout = null; + if (!immediate) { + func.apply(context, args); + } + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) { + func.apply(context, args); + } + }; + }; + return { + init: init, + }; +}); diff --git a/admin/tool/moodlenet/amd/src/selectors.js b/admin/tool/moodlenet/amd/src/selectors.js new file mode 100644 index 00000000000..5feb0f39d20 --- /dev/null +++ b/admin/tool/moodlenet/amd/src/selectors.js @@ -0,0 +1,45 @@ +// 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 . + +/** + * Define all of the selectors we will be using within MoodleNet plugin. + * + * @module tool_moodlenet/selectors + * @package tool_moodlenet + * @copyright 2020 Mathew May + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define([], function() { + return { + action: { + browse: '[data-action="browse"]', + submit: '[data-action="submit"]', + showMoodleNet: '[data-action="show-moodlenet"]', + closeOption: '[data-action="close-chooser-option-summary"]', + }, + region: { + clearIcon: '[data-region="clear-icon"]', + courses: '[data-region="mnet-courses"]', + instancePage: '[data-region="moodle-net"]', + searchInput: '[data-region="search-input"]', + searchIcon: '[data-region="search-icon"]', + selectPage: '[data-region="moodle-net-select"]', + spinner: '[data-region="spinner"]', + validationArea: '[data-region="validation-area"]', + carousel: '[data-region="carousel"]', + moodleNet: '[data-region="pluginCarousel"]', + }, + }; +}); diff --git a/admin/tool/moodlenet/amd/src/validator.js b/admin/tool/moodlenet/amd/src/validator.js new file mode 100644 index 00000000000..704bd6710ac --- /dev/null +++ b/admin/tool/moodlenet/amd/src/validator.js @@ -0,0 +1,59 @@ +// 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 . + +/** + * Our validator that splits the user's input then fires off to a webservice + * + * @module tool_moodlenet/validator + * @package tool_moodlenet + * @copyright 2020 Mathew May + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define(['jquery', 'core/ajax', 'core/str', 'core/notification'], function($, Ajax, Str, Notification) { + /** + * Handle form validation + * + * @method validation + * @param {HTMLElement} inputElement The element the user entered text into. + * @return {Promise} Was the users' entry a valid profile URL? + */ + var validation = function validation(inputElement) { + var inputValue = inputElement.value; + + // They didn't submit anything or they gave us a simple string that we can't do anything with. + if (inputValue === "" || !inputValue.includes("@")) { + // Create a promise and immediately reject it. + $.when(Str.get_string('profilevalidationerror', 'tool_moodlenet')).then(function(strings) { + return Promise.reject().catch(function() { + return {result: false, message: strings[0]}; + }); + }).fail(Notification.exception); + } + + return Ajax.call([{ + methodname: 'tool_moodlenet_verify_webfinger', + args: { + profileurl: inputValue, + course: inputElement.dataset.courseid, + section: inputElement.dataset.sectionid + } + }])[0].then(function(result) { + return result; + }).catch(); + }; + return { + validation: validation, + }; +}); diff --git a/admin/tool/moodlenet/classes/external.php b/admin/tool/moodlenet/classes/external.php new file mode 100644 index 00000000000..5fbfd2052d8 --- /dev/null +++ b/admin/tool/moodlenet/classes/external.php @@ -0,0 +1,189 @@ +. + +/** + * This is the external API for this component. + * + * @package tool_moodlenet + * @copyright 2020 Mathew May {@link https://mathew.solutions} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_moodlenet; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->libdir .'/externallib.php'); +require_once($CFG->libdir . '/filelib.php'); +require_once(__DIR__ . '/../lib.php'); + +use core_course\external\course_summary_exporter; +use external_api; +use external_function_parameters; +use external_value; +use external_single_structure; + +/** + * This is the external API for this component. + * + * @copyright 2020 Mathew May {@link https://mathew.solutions} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class external extends external_api { + + /** + * verify_webfinger parameters + * + * @return external_function_parameters + */ + public static function verify_webfinger_parameters() { + return new external_function_parameters( + array( + 'profileurl' => new external_value(PARAM_RAW, 'The profile url that the user has given us', VALUE_REQUIRED), + 'course' => new external_value(PARAM_INT, 'The course we are adding to', VALUE_REQUIRED), + 'section' => new external_value(PARAM_INT, 'The section within the course we are adding to', VALUE_REQUIRED), + ) + ); + } + + /** + * Figure out if the passed content resolves with a WebFinger account. + * + * @param string $profileurl The profile url that the user states exists + * @param int $course The course we are adding to + * @param int $section The section within the course we are adding to + * @return array Contains the result and domain if any + * @throws \invalid_parameter_exception + */ + public static function verify_webfinger(string $profileurl, int $course, int $section) { + global $USER; + + $params = self::validate_parameters(self::verify_webfinger_parameters(), [ + 'profileurl' => $profileurl, + 'section' => $section, + 'course' => $course + ] + ); + try { + $mnetprofile = new moodlenet_user_profile($params['profileurl'], $USER->id); + } catch (\Exception $e) { + return [ + 'result' => false, + 'message' => get_string('profilevalidationfail', 'tool_moodlenet'), + ]; + } + + $userlink = profile_manager::get_moodlenet_profile_link($mnetprofile); + + // There were no problems verifying the account so lets store it. + if ($userlink['result'] === true) { + profile_manager::save_moodlenet_user_profile($mnetprofile); + $userlink['domain'] = generate_mnet_endpoint($mnetprofile->get_profile_name(), $course, $section); + } + + return $userlink; + } + + /** + * verify_webfinger return. + * + * @return \external_description + */ + public static function verify_webfinger_returns() { + return new external_single_structure([ + 'result' => new external_value(PARAM_BOOL, 'Was the passed content a valid WebFinger?'), + 'message' => new external_value(PARAM_TEXT, 'Our message for the user'), + 'domain' => new external_value(PARAM_RAW, 'Domain to redirect the user to', VALUE_OPTIONAL), + ]); + } + + /** + * search_courses_parameters + * + * @return external_function_parameters + */ + public static function search_courses_parameters() { + return new external_function_parameters( + array( + 'searchvalue' => new external_value(PARAM_RAW, 'search value'), + ) + ); + } + + /** + * For some given input find and return any course that matches it. + * + * @param string $searchvalue The profile url that the user states exists + * @return array Contains the result set of courses for the value + */ + public static function search_courses(string $searchvalue) { + global $OUTPUT; + + $params = self::validate_parameters( + self::search_courses_parameters(), + ['searchvalue' => $searchvalue] + ); + self::validate_context(\context_system::instance()); + + $courses = array(); + + if ($arrcourses = \core_course_category::search_courses(array('search' => $params['searchvalue']))) { + foreach ($arrcourses as $course) { + if (has_capability('moodle/course:manageactivities', \context_course::instance($course->id))) { + $data = new \stdClass(); + $data->id = $course->id; + $data->fullname = $course->fullname; + $data->hidden = $course->visible; + $options = [ + 'course' => $course->id, + ]; + $viewurl = new \moodle_url('/admin/tool/moodlenet/options.php', $options); + $data->viewurl = $viewurl->out(false); + $category = \core_course_category::get($course->category); + $data->coursecategory = $category->name; + $courseimage = course_summary_exporter::get_course_image($data); + if (!$courseimage) { + $courseimage = $OUTPUT->get_generated_image_for_id($data->id); + } + $data->courseimage = $courseimage; + $courses[] = $data; + } + } + } + return array( + 'courses' => $courses + ); + } + + /** + * search_courses_returns. + * + * @return \external_description + */ + public static function search_courses_returns() { + return new external_single_structure([ + 'courses' => new \external_multiple_structure( + new external_single_structure([ + 'id' => new external_value(PARAM_INT, 'course id'), + 'fullname' => new external_value(PARAM_TEXT, 'course full name'), + 'hidden' => new external_value(PARAM_INT, 'is the course visible'), + 'viewurl' => new external_value(PARAM_URL, 'Next step of import'), + 'coursecategory' => new external_value(PARAM_TEXT, 'Category name'), + 'courseimage' => new external_value(PARAM_RAW, 'course image'), + ])) + ]); + } +} diff --git a/admin/tool/moodlenet/classes/local/import_backup_helper.php b/admin/tool/moodlenet/classes/local/import_backup_helper.php new file mode 100644 index 00000000000..a2f923afb74 --- /dev/null +++ b/admin/tool/moodlenet/classes/local/import_backup_helper.php @@ -0,0 +1,194 @@ +. +/** + * Contains the import_backup_helper class. + * + * @package tool_moodlenet + * @copyright 2020 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local; + +/** + * The import_backup_helper class. + * + * The import_backup_helper objects provide a means to prepare a backup for for restoration of a course or activity backup file. + * + * @copyright 2020 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class import_backup_helper { + + /** @var remote_resource $remoteresource A file resource to be restored. */ + protected $remoteresource; + + /** @var user $user The user trying to restore a file. */ + protected $user; + + /** @var context $context The context we are trying to restore this file into. */ + protected $context; + + /** @var int $useruploadlimit The size limit that this user can upload in this context. */ + protected $useruploadlimit; + + /** + * Constructor for the import backup helper. + * + * @param remote_resource $remoteresource A remote file resource + * @param \stdClass $user The user importing a file. + * @param \context $context Context to restore into. + */ + public function __construct(remote_resource $remoteresource, \stdClass $user, \context $context) { + $this->remoteresource = $remoteresource; + $this->user = $user; + $this->context = $context; + + $maxbytes = 0; + if ($this->context->contextlevel == CONTEXT_COURSE) { + $course = get_course($this->context->instanceid); + $maxbytes = $course->maxbytes; + } + $this->useruploadlimit = get_user_max_upload_file_size($this->context, get_config('core', 'maxbytes'), + $maxbytes, 0, $this->user); + } + + /** + * Return a stored user draft file for processing. + * + * @return \stored_file The imported file to ultimately be restored. + */ + public function get_stored_file(): \stored_file { + + // Check if the user can upload a backup to this context. + require_capability('moodle/restore:uploadfile', $this->context, $this->user->id); + + // Before starting a potentially lengthy download, try to ensure the file size does not exceed the upload size restrictions + // for the user. This is a time saving measure. + // This is a naive check, that serves only to catch files if they provide the content length header. + // Because of potential content encoding (compression), the stored file will be checked again after download as well. + $size = $this->remoteresource->get_download_size() ?? -1; + if ($this->size_exceeds_upload_limit($size)) { + throw new \moodle_exception('uploadlimitexceeded', 'tool_moodlenet', '', ['filesize' => $size, + 'uploadlimit' => $this->useruploadlimit]); + } + + [$filepath, $filename] = $this->remoteresource->download_to_requestdir(); + \core\antivirus\manager::scan_file($filepath, $filename, true); + + // Check the final size of file against the user upload limits. + $localsize = filesize(sprintf('%s/%s', $filepath, $filename)); + if ($this->size_exceeds_upload_limit($localsize)) { + throw new \moodle_exception('uploadlimitexceeded', 'tool_moodlenet', '', ['filesize' => $localsize, + 'uploadlimit' => $this->useruploadlimit]); + } + + return $this->create_user_draft_stored_file($filename, $filepath); + } + + /** + * Does the size exceed the upload limit for the current import, taking into account user and core settings. + * + * @param int $sizeinbytes + * @return bool true if exceeded, false otherwise. + */ + protected function size_exceeds_upload_limit(int $sizeinbytes): bool { + $maxbytes = 0; + if ($this->context->contextlevel == CONTEXT_COURSE) { + $course = get_course($this->context->instanceid); + $maxbytes = $course->maxbytes; + } + $maxbytes = get_user_max_upload_file_size($this->context, get_config('core', 'maxbytes'), $maxbytes, 0, + $this->user); + if ($maxbytes != USER_CAN_IGNORE_FILE_SIZE_LIMITS && $sizeinbytes > $maxbytes) { + return true; + } + return false; + } + + /** + * Create a file in the user drafts ready for use by plugins implementing dndupload_handle(). + * + * @param string $filename the name of the file on disk + * @param string $path the path where the file is stored on disk + * @return \stored_file + */ + protected function create_user_draft_stored_file(string $filename, string $path): \stored_file { + global $CFG; + + $record = new \stdClass(); + $record->filearea = 'draft'; + $record->component = 'user'; + $record->filepath = '/'; + $record->itemid = file_get_unused_draft_itemid(); + $record->license = $CFG->sitedefaultlicense; + $record->author = ''; + $record->filename = clean_param($filename, PARAM_FILE); + $record->contextid = \context_user::instance($this->user->id)->id; + $record->userid = $this->user->id; + + $fullpathwithname = sprintf('%s/%s', $path, $filename); + + $fs = get_file_storage(); + + return $fs->create_file_from_pathname($record, $fullpathwithname); + } + + /** + * Looks for a context that this user has permission to upload backup files to. + * This gets a list of roles that the user has, checks for the restore:uploadfile capability and then sends back a context + * that has this permission if available. + * + * This starts with the highest context level and moves down i.e. system -> category -> course. + * + * @param int $userid The user ID that we are looking for a working context for. + * @return \context A context that allows the upload of backup files. + */ + public static function get_context_for_user(int $userid): ?\context { + global $DB; + + if (is_siteadmin()) { + return \context_system::instance(); + } + + $sql = "SELECT ctx.id, ctx.contextlevel, ctx.instanceid, ctx.path, ctx.depth, ctx.locked + FROM {context} ctx + JOIN {role_assignments} r ON ctx.id = r.contextid + WHERE r.userid = :userid AND ctx.contextlevel IN (:contextsystem, :contextcategory, :contextcourse) + ORDER BY ctx.contextlevel ASC"; + + $params = [ + 'userid' => $userid, + 'contextsystem' => CONTEXT_SYSTEM, + 'contextcategory' => CONTEXT_COURSECAT, + 'contextcourse' => CONTEXT_COURSE + ]; + $records = $DB->get_records_sql($sql, $params); + foreach ($records as $record) { + \context_helper::preload_from_record($record); + if ($record->contextlevel == CONTEXT_COURSECAT) { + $context = \context_coursecat::instance($record->instanceid); + } else if ($record->contextlevel == CONTEXT_COURSE) { + $context = \context_course::instance($record->instanceid); + } else { + $context = \context_system::instance(); + } + if (has_capability('moodle/restore:uploadfile', $context, $userid)) { + return $context; + } + } + return null; + } +} diff --git a/admin/tool/moodlenet/classes/local/import_handler_info.php b/admin/tool/moodlenet/classes/local/import_handler_info.php new file mode 100644 index 00000000000..8517f376c25 --- /dev/null +++ b/admin/tool/moodlenet/classes/local/import_handler_info.php @@ -0,0 +1,91 @@ +. +/** + * Contains the import_handler_info class. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_moodlenet\local; + +/** + * The import_handler_info class. + * + * An import_handler_info object represent an resource import handler for a particular module. + * + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class import_handler_info { + + /** @var string $modulename the name of the module. */ + protected $modulename; + + /** @var string $description the description. */ + protected $description; + + /** @var import_strategy $importstrategy the strategy which will be used to import resources handled by this handler */ + protected $importstrategy; + + /** + * The import_handler_info constructor. + * + * @param string $modulename the name of the module handling the file extension. E.g. 'label'. + * @param string $description A description of how the module handles files of this extension type. + * @param import_strategy $strategy the strategy which will be used to import the resource. + * @throws \coding_exception + */ + public function __construct(string $modulename, string $description, import_strategy $strategy) { + if (empty($modulename)) { + throw new \coding_exception("Module name cannot be empty."); + } + if (empty($description)) { + throw new \coding_exception("Description cannot be empty."); + } + $this->modulename = $modulename; + $this->description = $description; + $this->importstrategy = $strategy; + } + + /** + * Get the name of the module. + * + * @return string the module name, e.g. 'label'. + */ + public function get_module_name(): string { + return $this->modulename; + } + + /** + * Get a human readable, localised description of how the file is handled by the module. + * + * @return string the localised description. + */ + public function get_description(): string { + return $this->description; + } + + /** + * Get the import strategy used by this handler. + * + * @return import_strategy the import strategy object. + */ + public function get_strategy(): import_strategy { + return $this->importstrategy; + } +} diff --git a/admin/tool/moodlenet/classes/local/import_handler_registry.php b/admin/tool/moodlenet/classes/local/import_handler_registry.php new file mode 100644 index 00000000000..b5860b5dbba --- /dev/null +++ b/admin/tool/moodlenet/classes/local/import_handler_registry.php @@ -0,0 +1,188 @@ +. +/** + * Contains the import_handler_registry class. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local; + +/** + * The import_handler_registry class. + * + * The import_handler_registry objects represent a register of modules handling various file extensions for a given course and user. + * Only modules which are available to the user in the course are included in the register for that user. + * + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class import_handler_registry { + + /** + * @var array array containing the names and messages of all modules handling import of resources as a 'file' type. + */ + protected $filehandlers = []; + + /** + * @var array $typehandlers the array of modules registering as handlers of other, non-file types, indexed by typename. + */ + protected $typehandlers = []; + + /** + * @var array $registry the aggregate of all registrations made by plugins, indexed by 'file' and 'type'. + */ + protected $registry = []; + + /** + * @var \context_course the course context object. + */ + protected $context; + + /** + * @var \stdClass a course object. + */ + protected $course; + + /** + * @var \stdClass a user object. + */ + protected $user; + + /** + * The import_handler_registry constructor. + * + * @param \stdClass $course the course, which impacts available handlers. + * @param \stdClass $user the user, which impacts available handlers. + */ + public function __construct(\stdClass $course, \stdClass $user) { + $this->course = $course; + $this->user = $user; + $this->context = \context_course::instance($course->id); + + // Generate the full list of handlers for all extensions for this user and course. + $this->populate_handlers(); + } + + /** + * Get all handlers for the remote resource, depending on the strategy being used to import the resource. + * + * @param remote_resource $resource the remote resource. + * @param import_strategy $strategy an import_strategy instance. + * @return import_handler_info[] the array of import_handler_info handlers. + */ + public function get_resource_handlers_for_strategy(remote_resource $resource, import_strategy $strategy): array { + return $strategy->get_handlers($this->registry, $resource); + } + + /** + * Get a specific handler for the resource, belonging to a specific module and for a specific strategy. + * + * @param remote_resource $resource the remote resource. + * @param string $modname the name of the module, e.g. 'label'. + * @param import_strategy $strategy a string representing how to treat the resource. e.g. 'file', 'link'. + * @return import_handler_info|null the import_handler_info object, if found, otherwise null. + */ + public function get_resource_handler_for_mod_and_strategy(remote_resource $resource, string $modname, + import_strategy $strategy): ?import_handler_info { + foreach ($strategy->get_handlers($this->registry, $resource) as $handler) { + if ($handler->get_module_name() === $modname) { + return $handler; + } + } + return null; + } + + /** + * Build up a list of extension handlers by leveraging the dndupload_register callbacks. + */ + protected function populate_handlers() { + // Generate a dndupload_handler object, just so we can call ->is_known_type() on the types being registered by plugins. + // We must vet each type which is reported to be handled against the list of known, supported types. + global $CFG; + require_once($CFG->dirroot . '/course/dnduploadlib.php'); + $dndhandlers = new \dndupload_handler($this->course); + + // Get the list of mods enabled at site level first. We need to cross check this. + $pluginman = \core_plugin_manager::instance(); + $sitemods = $pluginman->get_plugins_of_type('mod'); + $sitedisabledmods = array_filter($sitemods, function(\core\plugininfo\mod $modplugininfo){ + return !$modplugininfo->is_enabled(); + }); + $sitedisabledmods = array_map(function($modplugininfo) { + return $modplugininfo->name; + }, $sitedisabledmods); + + // Loop through all modules to find the registered handlers. + $mods = get_plugin_list_with_function('mod', 'dndupload_register'); + foreach ($mods as $component => $funcname) { + list($modtype, $modname) = \core_component::normalize_component($component); + if (!empty($sitedisabledmods) && array_key_exists($modname, $sitedisabledmods)) { + continue; // Module is disabled at the site level. + } + if (!course_allowed_module($this->course, $modname, $this->user)) { + continue; // User does not have permission to add this module to the course. + } + + if (!$resp = component_callback($component, 'dndupload_register')) { + continue; + }; + + if (isset($resp['files'])) { + foreach ($resp['files'] as $file) { + $this->register_file_handler($file['extension'], $modname, $file['message']); + } + } + if (isset($resp['types'])) { + foreach ($resp['types'] as $type) { + if (!$dndhandlers->is_known_type($type['identifier'])) { + throw new \coding_exception("Trying to add handler for unknown type $type"); + } + $this->register_type_handler($type['identifier'], $modname, $type['message']); + } + } + } + $this->registry = [ + 'files' => $this->filehandlers, + 'types' => $this->typehandlers + ]; + } + + /** + * Adds a type handler to the list. + * + * @param string $identifier the name of the type. + * @param string $module the name of the module, e.g. 'label'. + * @param string $message the message describing how the module handles the type. + */ + protected function register_type_handler(string $identifier, string $module, string $message) { + $this->typehandlers[$identifier][] = ['module' => $module, 'message' => $message]; + } + + /** + * Adds a file extension handler to the list. + * + * @param string $extension the extension, e.g. 'png'. + * @param string $module the name of the module handling this extension + * @param string $message the message describing how the module handles the extension. + */ + protected function register_file_handler(string $extension, string $module, string $message) { + $extension = strtolower($extension); + $this->filehandlers[$extension][] = ['module' => $module, 'message' => $message]; + } +} + diff --git a/admin/tool/moodlenet/classes/local/import_info.php b/admin/tool/moodlenet/classes/local/import_info.php new file mode 100644 index 00000000000..818f4c551e2 --- /dev/null +++ b/admin/tool/moodlenet/classes/local/import_info.php @@ -0,0 +1,126 @@ +. +/** + * Contains the import_info class. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local; + +/** + * Class import_info, describing objects which represent a resource being imported by a user. + * + * Objects of this class encapsulate both: + * - information about the resource (remote_resource). + * - config data pertaining to the import process, such as the destination course and section + * and how the resource should be treated (i.e. the type and the name of the module selected as the import handler) + * + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class import_info { + + /** @var int $userid the user conducting this import. */ + protected $userid; + + /** @var remote_resource $resource the resource being imported. */ + protected $resource; + + /** @var \stdClass $config config data pertaining to the import process, e.g. course, section, type. */ + protected $config; + + /** @var string $id string identifier for this object. */ + protected $id; + + /** + * The import_controller constructor. + * + * @param int $userid the id of the user performing the import. + * @param remote_resource $resource the resource being imported. + * @param \stdClass $config import config like 'course', 'section', 'type'. + */ + public function __construct(int $userid, remote_resource $resource, \stdClass $config) { + $this->userid = $userid; + $this->resource = $resource; + $this->config = $config; + $this->id = md5($resource->get_url()->get_value()); + } + + /** + * Get the id of this object. + */ + public function get_id() { + return $this->id; + } + + /** + * Get the remote resource being imported. + * + * @return remote_resource the remote resource being imported. + */ + public function get_resource(): remote_resource { + return $this->resource; + } + + /** + * Get the configuration data pertaining to the import. + * + * @return \stdClass the import configuration data. + */ + public function get_config(): \stdClass { + return $this->config; + } + + /** + * Set the configuration data pertaining to the import. + * + * @param \stdClass $config the configuration data to set. + */ + public function set_config(\stdClass $config): void { + $this->config = $config; + } + + /** + * Get an import_info object by id. + * + * @param string $id the id of the import_info object to load. + * @return mixed an import_info object if found, otherwise null. + */ + public static function load(string $id): ?import_info { + // This currently lives in the session, so we don't need userid. + // It might be useful if we ever move to another storage mechanism however, where we would need it. + global $SESSION; + return isset($SESSION->moodlenetimports[$id]) ? unserialize($SESSION->moodlenetimports[$id]) : null; + } + + /** + * Save this object to a store which is accessible across requests. + */ + public function save(): void { + global $SESSION; + $SESSION->moodlenetimports[$this->id] = serialize($this); + } + + /** + * Remove all information about an import from the store. + */ + public function purge(): void { + global $SESSION; + unset($SESSION->moodlenetimports[$this->id]); + } +} diff --git a/admin/tool/moodlenet/classes/local/import_processor.php b/admin/tool/moodlenet/classes/local/import_processor.php new file mode 100644 index 00000000000..8afd4d3b8cc --- /dev/null +++ b/admin/tool/moodlenet/classes/local/import_processor.php @@ -0,0 +1,206 @@ +. +/** + * Contains the import_processor class. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local; + +/** + * The import_processor class. + * + * The import_processor objects provide a means to import a remote resource into a course section, delegating the handling of + * content to the relevant module, via its dndupload_handler callback. + * + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class import_processor { + + /** @var object The course that we are uploading to */ + protected $course = null; + + /** @var int The section number we are uploading to */ + protected $section = null; + + /** @var import_handler_registry $handlerregistry registry object to use for cross checking the supplied handler.*/ + protected $handlerregistry; + + /** @var import_handler_info $handlerinfo information about the module handling the import.*/ + protected $handlerinfo; + + /** @var \stdClass $user the user conducting the import.*/ + protected $user; + + /** @var remote_resource $remoteresource the remote resource being imported.*/ + protected $remoteresource; + + /** @var string[] $descriptionoverrides list of modules which support having their descriptions updated, post-import. */ + protected $descriptionoverrides = ['folder', 'page', 'resource', 'scorm', 'url']; + + /** + * The import_processor constructor. + * + * @param \stdClass $course the course object. + * @param int $section the section number in the course, starting at 0. + * @param remote_resource $remoteresource the remote resource to import. + * @param import_handler_info $handlerinfo information about which module is handling the import. + * @param import_handler_registry $handlerregistry A registry of import handlers, to use for validation. + * @throws \coding_exception If any of the params are invalid. + */ + public function __construct(\stdClass $course, int $section, remote_resource $remoteresource, import_handler_info $handlerinfo, + import_handler_registry $handlerregistry) { + + global $DB, $USER; + + if ($section < 0) { + throw new \coding_exception("Invalid section number $section. Must be > 0."); + } + if (!$DB->record_exists('modules', array('name' => $handlerinfo->get_module_name()))) { + throw new \coding_exception("Module {$handlerinfo->get_module_name()} does not exist"); + } + + $this->course = $course; + $this->section = $section; + $this->handlerregistry = $handlerregistry; + $this->user = $USER; + $this->remoteresource = $remoteresource; + $this->handlerinfo = $handlerinfo; + + // ALL handlers must have a strategy and ANY strategy can process ANY resource. + // It is therefore NOT POSSIBLE to have a resource that CANNOT be processed by a handler. + // So, there's no need to verify that the remote_resource CAN be handled by the handler. It always can. + } + + /** + * Run the import process, including file download, module creation and cleanup (cache purge, etc). + */ + public function process(): void { + // Allow the strategy to do setup for this file import. + $moduledata = $this->handlerinfo->get_strategy()->import($this->remoteresource, $this->user, $this->course, $this->section); + + // Create the course module, and add that information to the data to be sent to the plugin handling the resource. + $cmdata = $this->create_course_module($this->course, $this->section, $this->handlerinfo->get_module_name()); + $moduledata->coursemodule = $cmdata->id; + + // Now, send the data to the handling plugin to let it set up. + $instanceid = plugin_callback('mod', $this->handlerinfo->get_module_name(), 'dndupload', 'handle', [$moduledata], + 'invalidfunction'); + if ($instanceid == 'invalidfunction') { + $name = $this->handlerinfo->get_module_name(); + throw new \coding_exception("$name does not support drag and drop upload (missing {$name}_dndupload_handle function)"); + } + + // Now, update the module description if the module supports it and only if it's not currently set. + $this->update_module_description($instanceid); + + // Finish setting up the course module. + $this->finish_setup_course_module($instanceid, $cmdata->id); + } + + /** + * Update the module's description (intro), if that feature is supported. + * + * @param int $instanceid the instance id of the module to update. + */ + protected function update_module_description(int $instanceid): void { + global $DB, $CFG; + require_once($CFG->libdir . '/moodlelib.php'); + + if (plugin_supports('mod', $this->handlerinfo->get_module_name(), FEATURE_MOD_INTRO, true)) { + require_once($CFG->libdir . '/editorlib.php'); + require_once($CFG->libdir . '/modinfolib.php'); + + $rec = $DB->get_record($this->handlerinfo->get_module_name(), ['id' => $instanceid]); + + if (empty($rec->intro) || in_array($this->handlerinfo->get_module_name(), $this->descriptionoverrides)) { + $updatedata = (object)[ + 'id' => $instanceid, + 'intro' => clean_param($this->remoteresource->get_description(), PARAM_TEXT), + 'introformat' => editors_get_preferred_format() + ]; + + $DB->update_record($this->handlerinfo->get_module_name(), $updatedata); + + rebuild_course_cache($this->course->id, true); + } + } + } + + /** + * Create the course module to hold the file/content that has been uploaded. + * @param \stdClass $course the course object. + * @param int $section the section. + * @param string $modname the name of the module, e.g. 'label'. + * @return \stdClass the course module data. + */ + protected function create_course_module(\stdClass $course, int $section, string $modname): \stdClass { + global $CFG; + require_once($CFG->dirroot . '/course/modlib.php'); + list($module, $context, $cw, $cm, $data) = prepare_new_moduleinfo_data($course, $modname, $section); + $data->visible = false; // The module is created in a hidden state. + $data->coursemodule = $data->id = add_course_module($data); + return $data; + } + + /** + * Finish off any course module setup, such as adding to the course section and firing events. + * + * @param int $instanceid id returned by the mod when it was created. + * @param int $cmid the course module record id, for removal if something went wrong. + */ + protected function finish_setup_course_module($instanceid, int $cmid): void { + global $DB; + + if (!$instanceid) { + // Something has gone wrong - undo everything we can. + course_delete_module($cmid); + throw new \moodle_exception('errorcreatingactivity', 'moodle', '', $this->handlerinfo->get_module_name()); + } + + // Note the section visibility. + $visible = get_fast_modinfo($this->course)->get_section_info($this->section)->visible; + + $DB->set_field('course_modules', 'instance', $instanceid, array('id' => $cmid)); + + // Rebuild the course cache after update action. + rebuild_course_cache($this->course->id, true); + + course_add_cm_to_section($this->course, $cmid, $this->section); + + set_coursemodule_visible($cmid, $visible); + if (!$visible) { + $DB->set_field('course_modules', 'visibleold', 1, array('id' => $cmid)); + } + + // Retrieve the final info about this module. + $info = get_fast_modinfo($this->course, $this->user->id); + if (!isset($info->cms[$cmid])) { + // The course module has not been properly created in the course - undo everything. + course_delete_module($cmid); + throw new \moodle_exception('errorcreatingactivity', 'moodle', '', $this->handlerinfo->get_module_name()); + } + $mod = $info->get_cm($cmid); + + // Trigger course module created event. + $event = \core\event\course_module_created::create_from_cm($mod); + $event->trigger(); + } +} + diff --git a/admin/tool/moodlenet/classes/local/import_strategy.php b/admin/tool/moodlenet/classes/local/import_strategy.php new file mode 100644 index 00000000000..d647e26b63c --- /dev/null +++ b/admin/tool/moodlenet/classes/local/import_strategy.php @@ -0,0 +1,69 @@ +. +/** + * Contains the import_strategy interface. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local; + +/** + * The import_strategy interface. + * + * This provides a contract allowing different import strategies to be implemented. + * + * An import_strategy encapsulates the logic used to prepare a remote_resource for import into Moodle in some way and is used by the + * import_processor (to perform aforementioned preparations) before it hands control of the import over to a course module plugin. + * + * We may wish to have many strategies because the preparation steps may vary depending on how the resource is to be treated. + * E.g. We may wish to import as a file in which case download steps will be required, or we may simply wish to import the remote + * resource as a link, in which cases setup steps will not require any file download. + * + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +interface import_strategy { + + /** + * Get an array of import_handler_info objects supported by this import strategy, based on the registrydata and resource. + * + * Implementations should check the registry data for any entries which align with their import strategy and should create + * import_handler_info objects to represent each relevant entry. If an entry represents a module, or handling type which does + * not align with the strategy, that item should simply be skipped. + * + * E.g. If one strategy aims to import all remote resources as files (e.g. import_strategy_file), it would only generate a list + * of import_handler_info objects created from those registry entries of type 'file', as those entries represent the modules + * which have said they can handle resources as files. + * + * @param array $registrydata The fully populated handler registry. + * @param remote_resource $resource the remote resource. + * @return import_handler_info[] the array of import_handler_info objects, or an empty array if none were matched. + */ + public function get_handlers(array $registrydata, remote_resource $resource): array; + + /** + * Called during import to perform required import setup steps. + * + * @param remote_resource $resource the resource to import. + * @param \stdClass $user the user to import on behalf of. + * @param \stdClass $course the course into which the remote resource is being imported. + * @param int $section the section into which the remote resource is being imported. + * @return \stdClass the module data which will be passed on to the course module plugin. + */ + public function import(remote_resource $resource, \stdClass $user, \stdClass $course, int $section): \stdClass; +} diff --git a/admin/tool/moodlenet/classes/local/import_strategy_file.php b/admin/tool/moodlenet/classes/local/import_strategy_file.php new file mode 100644 index 00000000000..e34092a62dc --- /dev/null +++ b/admin/tool/moodlenet/classes/local/import_strategy_file.php @@ -0,0 +1,170 @@ +. +/** + * Contains the import_strategy_file class. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local; + +use core\antivirus\manager as avmanager; + +/** + * The import_strategy_file class. + * + * The import_strategy_file objects contains the setup steps needed to prepare a resource for import as a file into Moodle. This + * ensures the remote_resource is first downloaded and put in a draft file area, ready for use as a file by the handling module. + * + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class import_strategy_file implements import_strategy { + + /** + * Get an array of import_handler_info objects representing modules supporting import of this file type. + * + * @param array $registrydata the fully populated registry. + * @param remote_resource $resource the remote resource. + * @return import_handler_info[] the array of import_handler_info objects. + */ + public function get_handlers(array $registrydata, remote_resource $resource): array { + $handlers = []; + foreach ($registrydata['files'] as $index => $items) { + foreach ($items as $item) { + if ($index === $resource->get_extension() || $index === '*') { + $handlers[] = new import_handler_info($item['module'], $item['message'], $this); + } + } + } + return $handlers; + } + + /** + * Import the remote resource according to the rules of this strategy. + * + * @param remote_resource $resource the resource to import. + * @param \stdClass $user the user to import on behalf of. + * @param \stdClass $course the course into which the remote_resource is being imported. + * @param int $section the section into which the remote_resource is being imported. + * @return \stdClass the module data. + * @throws \moodle_exception if the file size means the upload limit is exceeded for the user. + */ + public function import(remote_resource $resource, \stdClass $user, \stdClass $course, int $section): \stdClass { + // Before starting a potentially lengthy download, try to ensure the file size does not exceed the upload size restrictions + // for the user. This is a time saving measure. + // This is a naive check, that serves only to catch files if they provide the content length header. + // Because of potential content encoding (compression), the stored file will be checked again after download as well. + $size = $resource->get_download_size() ?? -1; + $useruploadlimit = $this->get_user_upload_limit($user, $course); + if ($this->size_exceeds_upload_limit($size, $useruploadlimit)) { + throw new \moodle_exception('uploadlimitexceeded', 'tool_moodlenet', '', ['filesize' => $size, + 'uploadlimit' => $useruploadlimit]); + } + + // Download the file into a request directory and scan it. + [$filepath, $filename] = $resource->download_to_requestdir(); + avmanager::scan_file($filepath, $filename, true); + + // Check the final size of file against the user upload limits. + $localsize = filesize(sprintf('%s/%s', $filepath, $filename)); + if ($this->size_exceeds_upload_limit($localsize, $useruploadlimit)) { + throw new \moodle_exception('uploadlimitexceeded', 'tool_moodlenet', '', ['filesize' => $localsize, + 'uploadlimit' => $useruploadlimit]); + } + + // Store in the user draft file area. + $storedfile = $this->create_user_draft_stored_file($user, $filename, $filepath); + + // Prepare the data to be sent to the modules dndupload_handle hook. + return $this->prepare_module_data($course, $resource, $storedfile->get_itemid()); + } + + + /** + * Creates the data to pass to the dndupload_handle() hooks. + * + * @param \stdClass $course the course record. + * @param remote_resource $resource the resource being imported as a file. + * @param int $draftitemid the itemid of the draft file. + * @return \stdClass the data object. + */ + protected function prepare_module_data(\stdClass $course, remote_resource $resource, int $draftitemid): \stdClass { + $data = new \stdClass(); + $data->type = 'Files'; + $data->course = $course; + $data->draftitemid = $draftitemid; + $data->displayname = $resource->get_name(); + return $data; + } + + /** + * Get the max file size limit for the user in the course. + * + * @param \stdClass $user the user to check. + * @param \stdClass $course the course to check in. + * @return int the file size limit, in bytes. + */ + protected function get_user_upload_limit(\stdClass $user, \stdClass $course): int { + return get_user_max_upload_file_size(\context_course::instance($course->id), get_config('core', 'maxbytes'), + $course->maxbytes, 0, $user); + } + + /** + * Does the size exceed the upload limit for the current import, taking into account user and core settings. + * + * @param int $sizeinbytes the size, in bytes. + * @param int $useruploadlimit the upload limit, in bytes. + * @return bool true if exceeded, false otherwise. + * @throws \dml_exception + */ + protected function size_exceeds_upload_limit(int $sizeinbytes, int $useruploadlimit): bool { + if ($useruploadlimit != USER_CAN_IGNORE_FILE_SIZE_LIMITS && $sizeinbytes > $useruploadlimit) { + return true; + } + return false; + } + + /** + * Create a file in the user drafts ready for use by plugins implementing dndupload_handle(). + * + * @param \stdClass $user the user object. + * @param string $filename the name of the file on disk + * @param string $path the path where the file is stored on disk + * @return \stored_file + */ + protected function create_user_draft_stored_file(\stdClass $user, string $filename, string $path): \stored_file { + global $CFG; + + $record = new \stdClass(); + $record->filearea = 'draft'; + $record->component = 'user'; + $record->filepath = '/'; + $record->itemid = file_get_unused_draft_itemid(); + $record->license = $CFG->sitedefaultlicense; + $record->author = ''; + $record->filename = clean_param($filename, PARAM_FILE); + $record->contextid = \context_user::instance($user->id)->id; + $record->userid = $user->id; + + $fullpathwithname = sprintf('%s/%s', $path, $filename); + + $fs = get_file_storage(); + + return $fs->create_file_from_pathname($record, $fullpathwithname); + } +} diff --git a/admin/tool/moodlenet/classes/local/import_strategy_link.php b/admin/tool/moodlenet/classes/local/import_strategy_link.php new file mode 100644 index 00000000000..ca04f061d88 --- /dev/null +++ b/admin/tool/moodlenet/classes/local/import_strategy_link.php @@ -0,0 +1,71 @@ +. +/** + * Contains the import_strategy_link class. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local; + +/** + * The import_strategy_link class. + * + * The import_strategy_link objects contains the setup steps needed to prepare a resource for import as a URL into Moodle. + * + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class import_strategy_link implements import_strategy { + + /** + * Get an array of import_handler_info objects representing modules supporting import of the resource. + * + * @param array $registrydata the fully populated registry. + * @param remote_resource $resource the remote resource. + * @return import_handler_info[] the array of import_handler_info objects. + */ + public function get_handlers(array $registrydata, remote_resource $resource): array { + $handlers = []; + foreach ($registrydata['types'] as $identifier => $items) { + foreach ($items as $item) { + if ($identifier === 'url') { + $handlers[] = new import_handler_info($item['module'], $item['message'], $this); + } + } + } + return $handlers; + } + + /** + * Import the remote resource according to the rules of this strategy. + * + * @param remote_resource $resource the resource to import. + * @param \stdClass $user the user to import on behalf of. + * @param \stdClass $course the course into which the remote_resource is being imported. + * @param int $section the section into which the remote_resource is being imported. + * @return \stdClass the module data. + */ + public function import(remote_resource $resource, \stdClass $user, \stdClass $course, int $section): \stdClass { + $data = new \stdClass(); + $data->type = 'url'; + $data->course = $course; + $data->content = $resource->get_url()->get_value(); + $data->displayname = $resource->get_name(); + return $data; + } +} diff --git a/admin/tool/moodlenet/classes/local/remote_resource.php b/admin/tool/moodlenet/classes/local/remote_resource.php new file mode 100644 index 00000000000..e9526184b8c --- /dev/null +++ b/admin/tool/moodlenet/classes/local/remote_resource.php @@ -0,0 +1,169 @@ +. +/** + * Contains the remote_resource class definition. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local; + +/** + * The remote_resource class. + * + * Objects of type remote_resource provide a means of interacting with resources over HTTP. + * + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class remote_resource { + + /** @var \curl $curl the curl http helper.*/ + protected $curl; + + /** @var url $url the url to the remote resource.*/ + protected $url; + + /** @var string $filename the name of this remote file.*/ + protected $filename; + + /** @var string $extension the file extension of this remote file.*/ + protected $extension; + + /** @var array $headinfo the array of information for the most recent HEAD request.*/ + protected $headinfo = []; + + /** @var \stdClass $metadata information about the resource. */ + protected $metadata; + + /** + * The remote_resource constructor. + * + * @param \curl $curl a curl object for HTTP requests. + * @param url $url the URL of the remote resource. + * @param \stdClass $metadata resource metadata such as name, summary, license, etc. + */ + public function __construct(\curl $curl, url $url, \stdClass $metadata) { + $this->curl = $curl; + $this->url = $url; + $this->filename = pathinfo($this->url->get_path(), PATHINFO_FILENAME); + $this->extension = pathinfo($this->url->get_path(), PATHINFO_EXTENSION); + $this->metadata = $metadata; + } + + /** + * Return the URL for this remote resource. + * + * @return url the url object. + */ + public function get_url(): url { + return $this->url; + } + + /** + * Get the name of the file as taken from the metadata. + */ + public function get_name(): string { + return $this->metadata->name ?? ''; + } + + /** + * Get the resource metadata. + * + * @return \stdClass the metadata. + */ + public function get_metadata(): \stdClass { + return$this->metadata; + } + + /** + * Get the description of the resource as taken from the metadata. + * + * @return string + */ + public function get_description(): string { + return $this->metadata->description ?? ''; + } + + /** + * Return the extension of the file, if found. + * + * @return string the extension of the file, if found. + */ + public function get_extension(): string { + return $this->extension; + } + + /** + * Returns the file size of the remote file, in bytes, or null if it cannot be determined. + * + * @return int|null the content length, if able to be determined, otherwise null. + */ + public function get_download_size(): ?int { + $this->get_resource_info(); + return $this->headinfo['download_content_length'] ?? null; + } + + /** + * Download the remote resource to a local requestdir, returning the path and name of the resulting file. + * + * @return array an array containing filepath adn filename, e.g. [filepath, filename]. + * @throws \moodle_exception if the file cannot be downloaded. + */ + public function download_to_requestdir(): array { + $filename = sprintf('%s.%s', $this->filename, $this->get_extension()); + $path = make_request_directory(); + $fullpathwithname = sprintf('%s/%s', $path, $filename); + + // In future, use a timeout (download and/or connection) controlled by a tool_moodlenet setting. + $downloadtimeout = 30; + + $result = $this->curl->download_one($this->url->get_value(), null, ['filepath' => $fullpathwithname, + 'timeout' => $downloadtimeout]); + if ($result !== true) { + throw new \moodle_exception('errorduringdownload', 'tool_moodlenet', '', $result); + } + + return [$path, $filename]; + } + + /** + * Fetches information about the remote resource via a HEAD request. + * + * @throws \coding_exception if any connection problems occur. + */ + protected function get_resource_info() { + if (!empty($this->headinfo)) { + return; + } + $options['CURLOPT_RETURNTRANSFER'] = 1; + $options['CURLOPT_FOLLOWLOCATION'] = 1; + $options['CURLOPT_MAXREDIRS'] = 5; + $options['CURLOPT_FAILONERROR'] = 1; // We want to consider http error codes as errors to report, not just status codes. + + $this->curl->head($this->url->get_value(), $options); + $errorno = $this->curl->get_errno(); + $this->curl->resetopt(); + + if ($errorno !== 0) { + $message = 'Problem during HEAD request for remote resource \''.$this->url->get_value().'\'. Curl Errno: ' . $errorno; + throw new \coding_exception($message); + } + $this->headinfo = $this->curl->get_info(); + } + +} diff --git a/admin/tool/moodlenet/classes/local/url.php b/admin/tool/moodlenet/classes/local/url.php new file mode 100644 index 00000000000..233b770f56e --- /dev/null +++ b/admin/tool/moodlenet/classes/local/url.php @@ -0,0 +1,84 @@ +. +/** + * Contains the url class, providing a representation of a url and operations on its component parts. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local; + +/** + * The url class, providing a representation of a url and operations on its component parts. + * + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class url { + + /** @var string $url the full URL string.*/ + protected $url; + + /** @var string|null $path the path component of this URL.*/ + protected $path; + + /** @var host|null $host the host component of this URL.*/ + protected $host; + + /** + * The url constructor. + * + * @param string $url the URL string. + * @throws \coding_exception if the URL does not pass syntax validation. + */ + public function __construct(string $url) { + // This object supports URLs as per the spec, so non-ascii chars must be encoded as per IDNA rules. + if (!filter_var($url, FILTER_VALIDATE_URL)) { + throw new \coding_exception('Malformed URL'); + } + $this->url = $url; + $this->path = parse_url($url, PHP_URL_PATH); + $this->host = parse_url($url, PHP_URL_HOST); + } + + /** + * Get the path component of the URL. + * + * @return string|null the path component of the URL. + */ + public function get_path(): ?string { + return $this->path; + } + + /** + * Return the domain component of the URL. + * + * @return string|null the domain component of the URL. + */ + public function get_host(): ?string { + return $this->host; + } + + /** + * Return the full URL string. + * + * @return string the full URL string. + */ + public function get_value() { + return $this->url; + } +} diff --git a/admin/tool/moodlenet/classes/moodlenet_user_profile.php b/admin/tool/moodlenet/classes/moodlenet_user_profile.php new file mode 100644 index 00000000000..51e33f96b80 --- /dev/null +++ b/admin/tool/moodlenet/classes/moodlenet_user_profile.php @@ -0,0 +1,107 @@ +. + +/** + * Moodle net user profile class. + * + * @package tool_moodlenet + * @copyright 2020 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_moodlenet; + +/** + * A class to represent the moodlenet profile. + * + * @package tool_moodlenet + * @copyright 2020 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class moodlenet_user_profile { + + /** @var string $profile The full profile name. */ + protected $profile; + + /** @var int $userid The user ID that this profile belongs to. */ + protected $userid; + + /** @var string $username The username from $userprofile */ + protected $username; + + /** @var string $domain The domain from $domain */ + protected $domain; + + /** + * Constructor method. + * + * @param string $userprofile The moodle net user profile string. + * @param int $userid The user ID that this profile belongs to. + */ + public function __construct(string $userprofile, int $userid) { + $this->profile = $userprofile; + $this->userid = $userid; + + $explodedprofile = explode('@', $this->profile); + if (count($explodedprofile) === 2) { + // It'll either be an email or WebFinger entry. + $this->username = $explodedprofile[0]; + $this->domain = $explodedprofile[1]; + } else if (count($explodedprofile) === 3) { + // We may have a profile link as MoodleNet gives to the user. + $this->username = $explodedprofile[1]; + $this->domain = $explodedprofile[2]; + } else { + throw new \moodle_exception('invalidmoodlenetprofile', 'tool_moodlenet'); + } + } + + /** + * Get the full moodle net profile. + * + * @return string The moodle net profile. + */ + public function get_profile_name(): string { + return $this->profile; + } + + /** + * Get the user ID that this profile belongs to. + * + * @return int The user ID. + */ + public function get_userid(): int { + return $this->userid; + } + + /** + * Get the username for this profile. + * + * @return string The username. + */ + public function get_username(): string { + return $this->username; + } + + /** + * Get the domain for this profile. + * + * @return string The domain. + */ + public function get_domain(): string { + return $this->domain; + } +} diff --git a/admin/tool/moodlenet/classes/output/renderer.php b/admin/tool/moodlenet/classes/output/renderer.php new file mode 100644 index 00000000000..98de3e4ccbf --- /dev/null +++ b/admin/tool/moodlenet/classes/output/renderer.php @@ -0,0 +1,52 @@ +. + +/** + * Renderer. + * + * @package tool_moodlenet + * @copyright 2020 Mathew May {@link https://mathew.solutions} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_moodlenet\output; + +defined('MOODLE_INTERNAL') || die(); + +use plugin_renderer_base; + +/** + * Renderer class. + * + * @package tool_moodlenet + * @copyright 2020 Mathew May {@link https://mathew.solutions} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class renderer extends plugin_renderer_base { + + /** + * Defer to template. + * + * @param select_page $selectpage + * @return string HTML + */ + protected function render_select_page(select_page $selectpage): string { + + $this->page->requires->js_call_amd('tool_moodlenet/select_page', 'init', [$selectpage->get_import_info()->get_id()]); + $data = $selectpage->export_for_template($this); + return parent::render_from_template('tool_moodlenet/select_page', $data); + } +} diff --git a/admin/tool/moodlenet/classes/output/select_page.php b/admin/tool/moodlenet/classes/output/select_page.php new file mode 100644 index 00000000000..60bbde16572 --- /dev/null +++ b/admin/tool/moodlenet/classes/output/select_page.php @@ -0,0 +1,76 @@ +. + +/** + * Select page renderable. + * + * @package tool_moodlenet + * @copyright 2020 Mathew May {@link https://mathew.solutions} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_moodlenet\output; + +defined('MOODLE_INTERNAL') || die; + +use tool_moodlenet\local\import_info; + +/** + * Select page renderable. + * + * @package tool_moodlenet + * @copyright 2020 Mathew May {@link https://mathew.solutions} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class select_page implements \renderable, \templatable { + + /** @var import_info $importinfo resource and config information pertaining to an import. */ + protected $importinfo; + + /** + * Inits the Select page renderable. + * + * @param import_info $importinfo resource and config information pertaining to an import. + */ + public function __construct(import_info $importinfo) { + $this->importinfo = $importinfo; + } + + /** + * Return the import info. + * + * @return import_info the import information. + */ + public function get_import_info(): import_info { + return $this->importinfo; + } + + /** + * Export the data. + * + * @param \renderer_base $output + * @return \stdClass + */ + public function export_for_template(\renderer_base $output): \stdClass { + + // Prepare the context object. + return (object) [ + 'name' => $this->importinfo->get_resource()->get_name(), + 'type' => $this->importinfo->get_config()->type, + 'cancellink' => new \moodle_url('/my'), + ]; + } +} diff --git a/admin/tool/moodlenet/classes/privacy/provider.php b/admin/tool/moodlenet/classes/privacy/provider.php new file mode 100644 index 00000000000..a076f4bc744 --- /dev/null +++ b/admin/tool/moodlenet/classes/privacy/provider.php @@ -0,0 +1,44 @@ +. +/** + * Privacy class for tool_moodlenet. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\privacy; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Privacy class for tool_moodlenet. + * + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class provider implements \core_privacy\local\metadata\null_provider { + + /** + * Get the language string identifier with the component's language + * file to explain why this plugin stores no data. + * + * @return string + */ + public static function get_reason() : string { + return 'privacy:metadata'; + } +} diff --git a/admin/tool/moodlenet/classes/profile_manager.php b/admin/tool/moodlenet/classes/profile_manager.php new file mode 100644 index 00000000000..f1a922adac6 --- /dev/null +++ b/admin/tool/moodlenet/classes/profile_manager.php @@ -0,0 +1,350 @@ +. + +/** + * Profile manager class + * + * @package tool_moodlenet + * @copyright 2020 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace tool_moodlenet; + +/** + * Class for handling interaction with the moodlenet profile. + * + * @package tool_moodlenet + * @copyright 2020 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class profile_manager { + + /** + * Get the mnet profile for a user. + * + * @param int $userid The ID for the user to get the profile form + * @return moodlenet_user_profile or null. + */ + public static function get_moodlenet_user_profile(int $userid): ?moodlenet_user_profile { + global $CFG; + // Check for official profile. + if (self::official_profile_exists()) { + $user = \core_user::get_user($userid, 'moodlenetprofile'); + try { + $userprofile = $user->moodlenetprofile ? $user->moodlenetprofile : ''; + return (isset($user)) ? new moodlenet_user_profile($userprofile, $userid) : null; + } catch (\moodle_exception $e) { + // If an exception is thrown, means there isn't a valid profile set. No need to log exception. + return null; + } + } + // Otherwise get hacked in user profile field. + require_once($CFG->dirroot . '/user/profile/lib.php'); + $profilefields = profile_get_user_fields_with_data($userid); + foreach ($profilefields as $key => $field) { + if ($field->get_category_name() == self::get_category_name() + && $field->inputname == 'profile_field_mnetprofile') { + try { + return new moodlenet_user_profile($field->display_data(), $userid); + } catch (\moodle_exception $e) { + // If an exception is thrown, means there isn't a valid profile set. No need to log exception. + return null; + } + } + } + return null; + } + + /** + * Save the moodlenet profile. + * + * @param moodlenet_user_profile $moodlenetprofile The moodlenet profile to save. + */ + public static function save_moodlenet_user_profile(moodlenet_user_profile $moodlenetprofile): void { + global $CFG, $DB; + // Do some cursory checks first to see if saving is possible. + if (self::official_profile_exists()) { + // All good. Let's save. + $user = \core_user::get_user($moodlenetprofile->get_userid()); + $user->moodlenetprofile = $moodlenetprofile->get_profile_name(); + + require_once($CFG->dirroot . '/user/lib.php'); + + \user_update_user($user, false, true); + return; + } + $fielddata = self::get_user_profile_field(); + $fielddata = self::validate_and_fix_missing_profile_items($fielddata); + // Everything should be back to normal. Let's save. + require_once($CFG->dirroot . '/user/profile/lib.php'); + \profile_save_custom_fields($moodlenetprofile->get_userid(), + [$fielddata->shortname => $moodlenetprofile->get_profile_name()]); + } + + /** + * Checks to see if the required user profile fields and categories are in place. If not it regenerates them. + * + * @param stdClass $fielddata The moodlenet profile field. + * @return stdClass The same moodlenet profile field, with any necessary updates made. + */ + private static function validate_and_fix_missing_profile_items(\stdClass $fielddata): \stdClass { + global $DB; + + if (empty((array) $fielddata)) { + // We need to regenerate the category and field to store this data. + if (!self::check_profile_category()) { + $categoryid = self::create_user_profile_category(); + self::create_user_profile_text_field($categoryid); + } else { + // We need the category id. + $category = $DB->get_record('user_info_category', ['name' => self::get_category_name()]); + self::create_user_profile_text_field($category->id); + } + $fielddata = self::get_user_profile_field(); + } else { + if (!self::check_profile_category($fielddata->categoryid)) { + $categoryid = self::create_user_profile_category(); + // Update the field to put it back into this category. + $fielddata->categoryid = $categoryid; + $DB->update_record('user_info_field', $fielddata); + } + } + return $fielddata; + } + + /** + * Returns the user profile field table object. + * + * @return stdClass the moodlenet profile table object. False if no record found. + */ + private static function get_user_profile_field(): \stdClass { + global $DB; + $fieldname = self::get_profile_field_name(); + $record = $DB->get_record('user_info_field', ['shortname' => $fieldname]); + return ($record) ? $record : (object) []; + } + + /** + * This reports back if the category has been deleted or the config value is different. + * + * @param int $categoryid The category id to check against. + * @return bool True is the category checks out, otherwise false. + */ + private static function check_profile_category(int $categoryid = null): bool { + global $DB; + $categoryname = self::get_category_name(); + $categorydata = $DB->get_record('user_info_category', ['name' => $categoryname]); + if (empty($categorydata)) { + return false; + } + if (isset($categoryid) && $categorydata->id != $categoryid) { + return false; + } + return true; + } + + /** + * Are we using the proper user profile field to hold the mnet profile? + * + * @return bool True if we are using a user table field for the mnet profile. False means we are using costom profile fields. + */ + public static function official_profile_exists(): bool { + global $DB; + + $usertablecolumns = $DB->get_columns('user', false); + if (isset($usertablecolumns['moodlenetprofile'])) { + return true; + } + return false; + } + + /** + * Gets the category name that is set for this site. + * + * @return string The category used to hold the moodle net profile field. + */ + public static function get_category_name(): string { + return get_config('tool_moodlenet', 'profile_category'); + } + + /** + * Sets the a unique category to hold the moodle net user profile. + * + * @param string $categoryname The base category name to use. + * @return string The actual name of the category to use. + */ + private static function set_category_name(string $categoryname): string { + global $DB; + + $attemptname = $categoryname; + + // Check if this category already exists. + $foundcategoryname = false; + $i = 0; + do { + $category = $DB->count_records('user_info_category', ['name' => $attemptname]); + if ($category > 0) { + $i++; + $attemptname = $categoryname . $i; + } else { + set_config('profile_category', $attemptname, 'tool_moodlenet'); + $foundcategoryname = true; + } + } while (!$foundcategoryname); + return $attemptname; + } + + /** + * Create a custom user profile category to hold our custom field. + * + * @return int The id of the created category. + */ + public static function create_user_profile_category(): int { + global $DB; + // No nice API to do this, so direct DB calls it is. + $data = new \stdClass(); + $data->sortorder = $DB->count_records('user_info_category') + 1; + $data->name = self::set_category_name(get_string('pluginname', 'tool_moodlenet')); + $data->id = $DB->insert_record('user_info_category', $data, true); + + $createdcategory = $DB->get_record('user_info_category', array('id' => $data->id)); + \core\event\user_info_category_created::create_from_category($createdcategory)->trigger(); + return $createdcategory->id; + } + + /** + * Sets a unique name to be used for the moodle net profile. + * + * @param string $fieldname The base fieldname to use. + * @return string The actual profile field name. + */ + private static function set_profile_field_name(string $fieldname): string { + global $DB; + + $attemptname = $fieldname; + + // Check if this profilefield already exists. + $foundfieldname = false; + $i = 0; + do { + $profilefield = $DB->count_records('user_info_field', ['shortname' => $attemptname]); + if ($profilefield > 0) { + $i++; + $attemptname = $fieldname . $i; + } else { + set_config('profile_field_name', $attemptname, 'tool_moodlenet'); + $foundfieldname = true; + } + } while (!$foundfieldname); + return $attemptname; + } + + /** + * Gets the unique profile field used to hold the moodle net profile. + * + * @return string The profile field name being used on this site. + */ + public static function get_profile_field_name(): string { + return get_config('tool_moodlenet', 'profile_field_name'); + } + + + /** + * Create a user profile field to hold the moodlenet profile information. + * + * @param int $categoryid The category to put this field into. + */ + public static function create_user_profile_text_field(int $categoryid): void { + global $CFG; + + require_once($CFG->dirroot . '/user/profile/definelib.php'); + require_once($CFG->dirroot . '/user/profile/field/text/define.class.php'); + + // Add our moodlenet profile field. + $profileclass = new \profile_define_text(); + $data = (object) [ + 'shortname' => self::set_profile_field_name('mnetprofile'), + 'name' => get_string('mnetprofile', 'tool_moodlenet'), + 'datatype' => 'text', + 'description' => get_string('mnetprofiledesc', 'tool_moodlenet'), + 'descriptionformat' => 1, + 'categoryid' => $categoryid, + 'signup' => 1, + 'forceunique' => 1, + 'visible' => 2, + 'param1' => 30, + 'param2' => 2048 + ]; + $profileclass->define_save($data); + } + + /** + * Given our $moodlenetprofile let's cURL the domains' WebFinger endpoint + * + * @param moodlenet_user_profile $moodlenetprofile The moodlenet profile to get info from. + * @return array [bool, text, raw] + */ + public static function get_moodlenet_profile_link(moodlenet_user_profile $moodlenetprofile): array { + $domain = $moodlenetprofile->get_domain(); + $username = $moodlenetprofile->get_username(); + + // Assumption: All MoodleNet instance's will contain a WebFinger validation script. + $url = "https://".$domain."/.well-known/webfinger?resource=acct:".$username."@".$domain; + + $curl = new \curl(); + $options = [ + 'CURLOPT_HEADER' => 0, + ]; + $content = $curl->get($url, null, $options); + $errno = $curl->get_errno(); + $info = $curl->get_info(); + + // The base cURL seems fine, let's press on. + if (!$errno) { + // WebFinger gave us a 404 back so the user has no droids here. + if ($info['http_code'] >= 400) { + if ($info['http_code'] === 404) { + // User not found. + return [ + 'result' => false, + 'message' => get_string('profilevalidationfail', 'tool_moodlenet'), + ]; + } else { + // There was some other error that was not a missing account. + return [ + 'result' => false, + 'message' => get_string('profilevalidationerror', 'tool_moodlenet'), + ]; + } + } + + // We must have a valid link so give it back to the user. + $data = json_decode($content); + return [ + 'result' => true, + 'message' => get_string('profilevalidationpass', 'tool_moodlenet'), + 'domain' => $data->aliases[0] + ]; + } else { + // There was some failure in curl so report it back. + return [ + 'result' => false, + 'message' => get_string('profilevalidationerror', 'tool_moodlenet'), + ]; + } + } +} diff --git a/admin/tool/moodlenet/db/services.php b/admin/tool/moodlenet/db/services.php new file mode 100644 index 00000000000..a5b452a0af0 --- /dev/null +++ b/admin/tool/moodlenet/db/services.php @@ -0,0 +1,44 @@ +. + +/** + * Tool Moodle.Net webservice definitions. + * + * @package tool_moodlenet + * @copyright 2020 Mathew May {@link https://mathew.solutions} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$functions = [ + 'tool_moodlenet_verify_webfinger' => [ + 'classname' => 'tool_moodlenet\external', + 'methodname' => 'verify_webfinger', + 'description' => 'Verify if the passed information resolves into a WebFinger profile URL', + 'type' => 'read', + 'ajax' => true, + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] + ], + 'tool_moodlenet_search_courses' => [ + 'classname' => 'tool_moodlenet\external', + 'methodname' => 'search_courses', + 'description' => 'For some given input search for a course that matches', + 'type' => 'read', + 'ajax' => true, + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE] + ], +]; diff --git a/admin/tool/moodlenet/db/upgrade.php b/admin/tool/moodlenet/db/upgrade.php new file mode 100644 index 00000000000..24f6beb74c2 --- /dev/null +++ b/admin/tool/moodlenet/db/upgrade.php @@ -0,0 +1,81 @@ +. + +/** + * Upgrade script for tool_moodlenet. + * + * @package tool_moodlenet + * @copyright 2020 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Upgrade the plugin. + * + * @param int $oldversion + * @return bool always true + */ +function xmldb_tool_moodlenet_upgrade(int $oldversion) { + global $CFG, $DB; + if ($oldversion < 2020060500) { + + // Grab some of the old settings. + $categoryname = get_config('tool_moodlenet', 'profile_category'); + $profilefield = get_config('tool_moodlenet', 'profile_field_name'); + + // Master version only! + + // Find out if we have a custom profile field for moodle.net. + $sql = "SELECT f.* + FROM {user_info_field} f + JOIN {user_info_category} c ON c.id = f.categoryid and c.name = :categoryname + WHERE f.shortname = :name"; + + $params = [ + 'categoryname' => $categoryname, + 'name' => $profilefield + ]; + + $record = $DB->get_record_sql($sql, $params); + + if (!empty($record)) { + $userentries = $DB->get_recordset('user_info_data', ['fieldid' => $record->id]); + $recordstodelete = []; + foreach ($userentries as $userentry) { + $data = (object) [ + 'id' => $userentry->userid, + 'moodlenetprofile' => $userentry->data + ]; + $DB->update_record('user', $data, true); + $recordstodelete[] = $userentry->id; + } + $userentries->close(); + + // Remove the user profile data, fields, and category. + $DB->delete_records_list('user_info_data', 'id', $recordstodelete); + $DB->delete_records('user_info_field', ['id' => $record->id]); + $DB->delete_records('user_info_category', ['name' => $categoryname]); + unset_config('profile_field_name', 'tool_moodlenet'); + unset_config('profile_category', 'tool_moodlenet'); + } + + upgrade_plugin_savepoint(true, 2020060500, 'tool', 'moodlenet'); + } + + return true; +} diff --git a/admin/tool/moodlenet/import.php b/admin/tool/moodlenet/import.php new file mode 100644 index 00000000000..1b57ce9d6de --- /dev/null +++ b/admin/tool/moodlenet/import.php @@ -0,0 +1,85 @@ +. + +/** + * This is the main endpoint which MoodleNet instances POST to. + * + * MoodleNet instances send the user agent to this endpoint via a form POST. + * Then: + * 1. The POSTed resource information is put in a session store for cross-request access. + * 2. This page makes a GET request for admin/tool/moodlenet/index.php (the import confirmation page). + * 3. Then, depending on whether the user is authenticated, the user will either: + * - If not authenticated, they will be asked to login, after which they will see the confirmation page (leveraging $wantsurl). + * - If authenticated, they will see the confirmation page immediately. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use tool_moodlenet\local\import_info; +use tool_moodlenet\local\remote_resource; +use tool_moodlenet\local\url; + +require_once(__DIR__ . '/../../../config.php'); + +// The integration must be enabled for this import endpoint to be active. +if (!get_config('tool_moodlenet', 'enablemoodlenet')) { + print_error('moodlenetnotenabled', 'tool_moodlenet'); +} + +$resourceurl = required_param('resourceurl', PARAM_URL); +$resourceinfo = required_param('resource_info', PARAM_RAW); +$resourceinfo = json_decode($resourceinfo); +$type = optional_param('type', 'link', PARAM_TEXT); +$course = optional_param('course', 0, PARAM_INT); +$section = optional_param('section', 0, PARAM_INT); +// If course isn't provided, course and section are null. +if (empty($course)) { + $course = null; + $section = null; +} +$name = validate_param($resourceinfo->name, PARAM_TEXT); +$description = validate_param($resourceinfo->summary, PARAM_TEXT); + +// Only accept POSTs. +if (!empty($_POST)) { + // Store information about the import of the resource for the current user. + $importconfig = (object) [ + 'course' => $course, + 'section' => $section, + 'type' => $type, + ]; + $metadata = (object) [ + 'name' => $name, + 'description' => $description ?? '' + ]; + + require_once($CFG->libdir . '/filelib.php'); + $importinfo = new import_info( + $USER->id, + new remote_resource(new \curl(), new url($resourceurl), $metadata), + $importconfig + ); + $importinfo->save(); + + // Redirect to the import confirmation page, detouring via the log in page if required. + redirect(new moodle_url('/admin/tool/moodlenet/index.php', ['id' => $importinfo->get_id()])); + +} + +// Invalid or missing POST data. Show an error to the user. +print_error('missinginvalidpostdata', 'tool_moodlenet'); diff --git a/admin/tool/moodlenet/index.php b/admin/tool/moodlenet/index.php new file mode 100644 index 00000000000..5680d19a8dc --- /dev/null +++ b/admin/tool/moodlenet/index.php @@ -0,0 +1,136 @@ +. + +/** + * Landing page for all imports from MoodleNet. + * + * This page asks the user to confirm the import process, and takes them to the relevant next step. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +use tool_moodlenet\local\import_info; +use tool_moodlenet\local\import_backup_helper; + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->dirroot .'/course/lib.php'); + +$cancel = optional_param('cancel', null, PARAM_TEXT); +$continue = optional_param('continue', null, PARAM_TEXT); +$id = required_param('id', PARAM_ALPHANUM); + +if (is_null($importinfo = import_info::load($id))) { + throw new moodle_exception('missinginvalidpostdata', 'tool_moodlenet'); +} + +// Access control. +require_login($importinfo->get_config()->course, false); // Course may be null here - that's ok. +if ($importinfo->get_config()->course) { + require_capability('moodle/course:manageactivities', context_course::instance($importinfo->get_config()->course)); +} +if (!get_config('tool_moodlenet', 'enablemoodlenet')) { + print_error('moodlenetnotenabled', 'tool_moodlenet'); +} + +// Handle the form submits. +// This page POSTs to self to verify the sesskey for the confirm action. +// The next page will either be: +// - 1. The restore process for a course or module, if the file is an mbz file. +// - 2. The 'select a course' tool page, if course and section are not provided. +// - 3. The 'select what to do with the content' tool page, provided course and section are present. +// - 4. The dashboard, if the user decides to cancel and course or section is not found. +// - 5. The course home, if the user decides to cancel but the course and section are found. +if ($cancel) { + if (!empty($importinfo->get_config()->course)) { + $url = new \moodle_url('/course/view.php', ['id' => $importinfo->get_config()->course]); + } else { + $url = new \moodle_url('/'); + } + redirect($url); +} else if ($continue) { + confirm_sesskey(); + + // Handle backups. + if (strtolower($importinfo->get_resource()->get_extension()) == 'mbz') { + if (empty($importinfo->get_config()->course)) { + // Find a course that the user has permission to upload a backup file. + // This is likely to be very slow on larger sites. + $context = import_backup_helper::get_context_for_user($USER->id); + + if (is_null($context)) { + print_error('nopermissions', 'error', '', get_string('restore:uploadfile', 'core_role')); + } + } else { + $context = context_course::instance($importinfo->get_config()->course); + } + + $importbackuphelper = new import_backup_helper($importinfo->get_resource(), $USER, $context); + $storedfile = $importbackuphelper->get_stored_file(); + + $url = new \moodle_url('/backup/restorefile.php', [ + 'component' => $storedfile->get_component(), + 'filearea' => $storedfile->get_filearea(), + 'itemid' => $storedfile->get_itemid(), + 'filepath' => $storedfile->get_filepath(), + 'filename' => $storedfile->get_filename(), + 'filecontextid' => $storedfile->get_contextid(), + 'contextid' => $context->id, + 'action' => 'choosebackupfile' + ]); + redirect($url); + } + + // Handle adding files to a course. + // Course and section data present and confirmed. Redirect to the option select view. + if (!is_null($importinfo->get_config()->course) && !is_null($importinfo->get_config()->section)) { + redirect(new \moodle_url('/admin/tool/moodlenet/options.php', ['id' => $id])); + } + + if (is_null($importinfo->get_config()->course)) { + redirect(new \moodle_url('/admin/tool/moodlenet/select.php', ['id' => $id])); + } +} + +// Display the page. +$PAGE->set_context(context_system::instance()); +$PAGE->set_pagelayout('base'); +$PAGE->set_title(get_string('addingaresource', 'tool_moodlenet')); +$PAGE->set_heading(get_string('addingaresource', 'tool_moodlenet')); +$url = new moodle_url('/admin/tool/moodlenet/index.php'); +$PAGE->set_url($url); +$renderer = $PAGE->get_renderer('core'); + +// Relevant confirmation form. +$context = $context = [ + 'resourceurl' => $importinfo->get_resource()->get_url()->get_value(), + 'resourcename' => $importinfo->get_resource()->get_name(), + 'resourcetype' => $importinfo->get_config()->type, + 'sesskey' => sesskey() +]; +if (!is_null($importinfo->get_config()->course) && !is_null($importinfo->get_config()->section)) { + $course = get_course($importinfo->get_config()->course); + $context = array_merge($context, [ + 'course' => $course->id, + 'coursename' => $course->shortname, + 'section' => $importinfo->get_config()->section + ]); +} + +echo $OUTPUT->header(); +echo $renderer->render_from_template('tool_moodlenet/import_confirmation', $context); +echo $OUTPUT->footer(); diff --git a/admin/tool/moodlenet/lang/en/tool_moodlenet.php b/admin/tool/moodlenet/lang/en/tool_moodlenet.php new file mode 100644 index 00000000000..1ba4e5b6fb2 --- /dev/null +++ b/admin/tool/moodlenet/lang/en/tool_moodlenet.php @@ -0,0 +1,69 @@ +. + +/** + * Strings for the tool_moodlenet component. + * + * @package tool_moodlenet + * @category string + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$string['addingaresource'] = 'Adding content from MoodleNet'; +$string['aria:enterprofile'] = "Enter your MoodleNet profile URL"; +$string['aria:footermessage'] = "Browse for content on MoodleNet"; +$string['browsecontentmoodlenet'] = "Or browse for content on MoodleNet"; +$string['clearsearch'] = "Clear search"; +$string['connectandbrowse'] = "Connect to and browse:"; +$string['defaultmoodlenet'] = "Default MoodleNet URL"; +$string['defaultmoodlenet_desc'] = "The URL to either Moodle HQ's MoodleNet instance, or your preferred instance."; +$string['defaultmoodlenetname'] = "MoodleNet instance name"; +$string['defaultmoodlenetname_desc'] = 'The name of either Moodle HQ\'s MoodleNet instance or your preferred MoodleNet instance to browse on.'; +$string['enablemoodlenet'] = 'Enable MoodleNet integration'; +$string['enablemoodlenet_desc'] = 'Enabling the integration allows users with the \'xx\' capability to browse MoodleNet from the +activity chooser and import MoodleNet resources into their course. It also allows users to push backups from MoodleNet into Moodle. +'; +$string['errorduringdownload'] = 'An error occurred while downloading the file: {$a}'; +$string['forminfo'] = "It will be automatically saved on your moodle profile."; +$string['footermessage'] = "Or browse for content on"; +$string['instancedescription'] = "MoodleNet is an open social media platform for educators, with a focus on the collaborative curation of collections of open resources. "; +$string['instanceplaceholder'] = '@yourprofile@moodle.net'; +$string['inputhelp'] = 'Or if you have a MoodleNet account already, enter your MoodleNet profile:'; +$string['invalidmoodlenetprofile'] = '$userprofile is not correctly formatted'; +$string['importconfirm'] = 'You are about to import the content "{$a->resourcename} ({$a->resourcetype})" into the course "{$a->coursename}". Are you sure you want to continue?'; +$string['importconfirmnocourse'] = 'You are about to import the content "{$a->resourcename} ({$a->resourcetype})" into your site. Are you sure you want to continue?'; +$string['importformatselectguidingtext'] = 'In which format would you like this content "{$a->name} ({$a->type})" to be added to your course?'; +$string['importformatselectheader'] = 'Choose the content display format'; +$string['missinginvalidpostdata'] = 'The resource information from MoodleNet is either missing, or is in an incorrect format. +If this happens repeatedly, please contact the site administrator.'; +$string['mnetprofile'] = 'MoodleNet profile'; +$string['mnetprofiledesc'] = '

Enter in your MoodleNet profile details here to be redirected to your profile while visiting MoodleNet.

'; +$string['moodlenetsettings'] = 'MoodleNet settings'; +$string['moodlenetnotenabled'] = 'The MoodleNet integration must be enabled before resource imports can be processed. +To enable this feature, see the \'enablemoodlenet\' setting.'; +$string['notification'] = 'You are about to import the content "{$a->name} ({$a->type})" into your site. Select the course in which it should be added, or
cancel.'; +$string['searchcourses'] = "Search courses"; +$string['selectpagetitle'] = 'Select page'; +$string['pluginname'] = 'MoodleNet'; +$string['privacy:metadata'] = "The MoodleNet tool only facilitates communication with MoodleNet. It stores no data."; +$string['profilevalidationerror'] = 'There was a problem trying to validate your profile'; +$string['profilevalidationfail'] = 'Please enter a valid MoodleNet profile'; +$string['profilevalidationpass'] = 'Looks good!'; +$string['saveandgo'] = "Save and go"; +$string['uploadlimitexceeded'] = 'The file size {$a->filesize} exceeds the user upload limit of {$a->uploadlimit} bytes.'; diff --git a/admin/tool/moodlenet/lib.php b/admin/tool/moodlenet/lib.php new file mode 100644 index 00000000000..5affcd9de19 --- /dev/null +++ b/admin/tool/moodlenet/lib.php @@ -0,0 +1,104 @@ +. + +/** + * This page lists public api for tool_moodlenet plugin. + * + * @package tool_moodlenet + * @copyright 2020 Peter Dias + * @license http://www.gnu.org/copyleft/gpl.html GNU + */ + +defined('MOODLE_INTERNAL') || die; + +use \core_course\local\entity\activity_chooser_footer; + +/** + * The default endpoint to MoodleNet. + */ +define('MOODLENET_DEFAULT_ENDPOINT', "lms/moodle/search"); + +/** + * Generate the endpoint url to the user's moodlenet site. + * + * @param string $profileurl The user's moodlenet profile page + * @param int $course The moodle course the mnet resource will be added to + * @param int $section The section of the course will be added to. Defaults to the 0th element. + * @return string the resulting endpoint + * @throws moodle_exception + */ +function generate_mnet_endpoint(string $profileurl, int $course, int $section = 0) { + global $CFG; + $urlportions = explode('@', $profileurl); + $domain = end($urlportions); + $parsedurl = parse_url($domain); + $params = [ + 'site' => $CFG->wwwroot, + 'course' => $course, + 'section' => $section + ]; + $endpoint = new moodle_url(MOODLENET_DEFAULT_ENDPOINT, $params); + return (isset($parsedurl['scheme']) ? $domain : "https://$domain")."/{$endpoint->out(false)}"; +} + +/** + * Hooking function to build up the initial Activity Chooser footer information for MoodleNet + * + * @param int $courseid The course the user is currently in and wants to add resources to + * @param int $sectionid The section the user is currently in and wants to add resources to + * @return activity_chooser_footer + * @throws dml_exception + * @throws moodle_exception + */ +function tool_moodlenet_custom_chooser_footer(int $courseid, int $sectionid): activity_chooser_footer { + global $CFG, $USER, $OUTPUT; + $defaultlink = get_config('tool_moodlenet', 'defaultmoodlenet'); + $enabled = get_config('tool_moodlenet', 'enablemoodlenet'); + + $advanced = false; + // We are in the MoodleNet lib. It is safe assume we have our own functions here. + $mnetprofile = \tool_moodlenet\profile_manager::get_moodlenet_user_profile($USER->id); + if ($mnetprofile !== null) { + $advanced = $mnetprofile->get_domain() ?? false; + } + + $defaultlink = generate_mnet_endpoint($defaultlink, $courseid, $sectionid); + if ($advanced !== false) { + $advanced = generate_mnet_endpoint($advanced, $courseid, $sectionid); + } + + $renderedfooter = $OUTPUT->render_from_template('tool_moodlenet/chooser_footer', (object)[ + 'enabled' => (bool)$enabled, + 'generic' => $defaultlink, + 'advanced' => $advanced, + 'courseID' => $courseid, + 'sectionID' => $sectionid, + 'img' => $OUTPUT->image_url('MoodleNet', 'tool_moodlenet')->out(false), + ]); + + $renderedcarousel = $OUTPUT->render_from_template('tool_moodlenet/chooser_moodlenet', (object)[ + 'buttonName' => get_config('tool_moodlenet', 'defaultmoodlenetname'), + 'generic' => $defaultlink, + 'courseID' => $courseid, + 'sectionID' => $sectionid, + 'img' => $OUTPUT->image_url('MoodleNet', 'tool_moodlenet')->out(false), + ]); + return new activity_chooser_footer( + 'tool_moodlenet/instance_form', + $renderedfooter, + $renderedcarousel + ); +} diff --git a/admin/tool/moodlenet/options.php b/admin/tool/moodlenet/options.php new file mode 100644 index 00000000000..a510f53e206 --- /dev/null +++ b/admin/tool/moodlenet/options.php @@ -0,0 +1,128 @@ +. + +/** + * Page to select WHAT to do with a given resource stored on MoodleNet. + * + * This collates and presents the same options as a user would see for a drag and drop upload. + * That is, it leverages the dndupload_register() hooks and delegates the resource handling to the dndupload_handle hooks. + * + * This page requires a course, section an resourceurl to be provided via import_info. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +use tool_moodlenet\local\import_handler_registry; +use tool_moodlenet\local\import_processor; +use tool_moodlenet\local\import_info; +use tool_moodlenet\local\import_strategy_file; +use tool_moodlenet\local\import_strategy_link; + +require_once(__DIR__ . '/../../../config.php'); +require_once($CFG->dirroot . '/course/lib.php'); + +$module = optional_param('module', null, PARAM_PLUGIN); +$import = optional_param('import', null, PARAM_ALPHA); +$cancel = optional_param('cancel', null, PARAM_ALPHA); +$id = required_param('id', PARAM_ALPHANUM); + +if (is_null($importinfo = import_info::load($id))) { + throw new moodle_exception('missinginvalidpostdata', 'tool_moodlenet'); +} + +// Resolve course and section params. +// If course is not already set in the importinfo, we require it in the URL params. +$config = $importinfo->get_config(); +if (!isset($config->course)) { + $course = required_param('course', PARAM_INT); + $config->course = $course; + $config->section = 0; + $importinfo->set_config($config); + $importinfo->save(); +} + +// Access control. +require_login($config->course, false); +require_capability('moodle/course:manageactivities', context_course::instance($config->course)); +if (!get_config('tool_moodlenet', 'enablemoodlenet')) { + print_error('moodlenetnotenabled', 'tool_moodlenet'); +} + +// If the user cancelled, break early. +if ($cancel) { + redirect(new moodle_url('/course/view.php', ['id' => $config->course])); +} + +// Set up required objects. +$course = get_course($config->course); +$handlerregistry = new import_handler_registry($course, $USER); +switch ($config->type) { + case 'file': + $strategy = new import_strategy_file(); + break; + case 'link': + default: + $strategy = new import_strategy_link(); + break; +} + +if ($import && $module) { + confirm_sesskey(); + + $handlerinfo = $handlerregistry->get_resource_handler_for_mod_and_strategy($importinfo->get_resource(), $module, $strategy); + if (is_null($handlerinfo)) { + throw new coding_exception("Invalid handler '$module'. The import handler could not be found."); + } + $importproc = new import_processor($course, $config->section, $importinfo->get_resource(), $handlerinfo, $handlerregistry); + $importproc->process(); + + $importinfo->purge(); // We don't need information about the import any more. + + redirect(new moodle_url('/course/view.php', ['id' => $course->id])); +} + +// Setup the page and display the form. +$PAGE->set_context(context_course::instance($course->id)); +$PAGE->set_pagelayout('base'); +$PAGE->set_title(get_string('coursetitle', 'moodle', array('course' => $course->fullname))); +$PAGE->set_heading($course->fullname); +$PAGE->set_url(new moodle_url('/admin/tool/moodlenet/options.php')); + +// Fetch the handlers supporting this resource. We'll display each of these as an option in the form. +$handlercontext = []; +foreach ($handlerregistry->get_resource_handlers_for_strategy($importinfo->get_resource(), $strategy) as $handler) { + $handlercontext[] = [ + 'module' => $handler->get_module_name(), + 'message' => $handler->get_description(), + ]; +} + +// Template context. +$context = [ + 'resourcename' => $importinfo->get_resource()->get_name(), + 'resourcetype' => $importinfo->get_config()->type, + 'resourceurl' => urlencode($importinfo->get_resource()->get_url()->get_value()), + 'course' => $course->id, + 'section' => $config->section, + 'sesskey' => sesskey(), + 'handlers' => $handlercontext, + 'oneoption' => sizeof($handlercontext) === 1 +]; + +echo $OUTPUT->header(); +echo $PAGE->get_renderer('core')->render_from_template('tool_moodlenet/import_options_select', $context); +echo $OUTPUT->footer(); diff --git a/pix/MoodleNet.png b/admin/tool/moodlenet/pix/MoodleNet.png similarity index 100% rename from pix/MoodleNet.png rename to admin/tool/moodlenet/pix/MoodleNet.png diff --git a/pix/MoodleNet.svg b/admin/tool/moodlenet/pix/MoodleNet.svg similarity index 100% rename from pix/MoodleNet.svg rename to admin/tool/moodlenet/pix/MoodleNet.svg diff --git a/admin/tool/moodlenet/pix/courses.svg b/admin/tool/moodlenet/pix/courses.svg new file mode 100644 index 00000000000..75e59fcf04b --- /dev/null +++ b/admin/tool/moodlenet/pix/courses.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin/tool/moodlenet/select.php b/admin/tool/moodlenet/select.php new file mode 100644 index 00000000000..704de2ff19c --- /dev/null +++ b/admin/tool/moodlenet/select.php @@ -0,0 +1,53 @@ +. + +/** + * Select page. + * + * @package tool_moodlenet + * @copyright 2020 Mathew May {@link https://mathew.solutions} + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +use tool_moodlenet\local\import_info; +use tool_moodlenet\output\select_page; + +require_once(__DIR__ . '/../../../config.php'); + +$id = required_param('id', PARAM_ALPHANUM); + +// Access control. +require_login(); +if (!get_config('tool_moodlenet', 'enablemoodlenet')) { + print_error('moodlenetnotenabled', 'tool_moodlenet'); +} + +if (is_null($importinfo = import_info::load($id))) { + throw new moodle_exception('missinginvalidpostdata', 'tool_moodlenet'); +} + +$PAGE->set_url('/admin/tool/moodlenet/select.php'); +$PAGE->set_context(context_system::instance()); +$PAGE->set_pagelayout('standard'); +$PAGE->set_title(get_string('selectpagetitle', 'tool_moodlenet')); +$PAGE->set_heading(format_string($SITE->fullname)); + +echo $OUTPUT->header(); + +$renderable = new select_page($importinfo); +$renderer = $PAGE->get_renderer('tool_moodlenet'); +echo $renderer->render($renderable); + +echo $OUTPUT->footer(); diff --git a/admin/tool/moodlenet/settings.php b/admin/tool/moodlenet/settings.php new file mode 100644 index 00000000000..4b6fb4f4d31 --- /dev/null +++ b/admin/tool/moodlenet/settings.php @@ -0,0 +1,46 @@ +. + +/** + * Puts the plugin actions into the admin settings tree. + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +if ($hassiteconfig) { + // Create a MoodleNet category. + $ADMIN->add('root', new admin_category('moodlenet', get_string('pluginname', 'tool_moodlenet'))); + // Our settings page. + $settings = new admin_settingpage('tool_moodlenet', get_string('moodlenetsettings', 'tool_moodlenet')); + $ADMIN->add('moodlenet', $settings); + + $temp = new admin_setting_configcheckbox('tool_moodlenet/enablemoodlenet', get_string('enablemoodlenet', 'tool_moodlenet'), + new lang_string('enablemoodlenet_desc', 'tool_moodlenet'), 1, 1, 0); + $settings->add($temp); + + $temp = new admin_setting_configtext('tool_moodlenet/defaultmoodlenetname', + get_string('defaultmoodlenetname', 'tool_moodlenet'), new lang_string('defaultmoodlenetname_desc', 'tool_moodlenet'), + 'Moodle HQ MoodleNet'); + $settings->add($temp); + + $temp = new admin_setting_configtext('tool_moodlenet/defaultmoodlenet', get_string('defaultmoodlenet', 'tool_moodlenet'), + new lang_string('defaultmoodlenet_desc', 'tool_moodlenet'), 'https://home.moodle.net'); + $settings->add($temp); +} diff --git a/admin/tool/moodlenet/templates/chooser_footer.mustache b/admin/tool/moodlenet/templates/chooser_footer.mustache new file mode 100644 index 00000000000..6748023cd37 --- /dev/null +++ b/admin/tool/moodlenet/templates/chooser_footer.mustache @@ -0,0 +1,46 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template tool_moodlenet/chooser_footer + + Chooser favourite template partial. + + Example context (json): + { + } +}} +{{#enabled}} + +{{/enabled}} diff --git a/admin/tool/moodlenet/templates/chooser_footer_close_mnet.mustache b/admin/tool/moodlenet/templates/chooser_footer_close_mnet.mustache new file mode 100644 index 00000000000..2e6f05e3481 --- /dev/null +++ b/admin/tool/moodlenet/templates/chooser_footer_close_mnet.mustache @@ -0,0 +1,28 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template tool_moodlenet/chooser_footer_close_mnet + + Chooser favourite template partial. + + Example context (json): + { + } +}} + diff --git a/admin/tool/moodlenet/templates/chooser_moodlenet.mustache b/admin/tool/moodlenet/templates/chooser_moodlenet.mustache new file mode 100644 index 00000000000..c0c7fc534ad --- /dev/null +++ b/admin/tool/moodlenet/templates/chooser_moodlenet.mustache @@ -0,0 +1,67 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template tool_moodlenet/chooser_moodlenet + + Chooser favourite template partial. + + Example context (json): + { + } +}} +
+
+
+
+ +

{{#str}} instancedescription, tool_moodlenet {{/str}}

+

{{#str}} connectandbrowse, tool_moodlenet {{/str}}

+ + {{{buttonName}}} + +
+ +
+

{{#str}}inputhelp, tool_moodlenet{{/str}}

+
+ +
+ +
+
+

+

{{#str}} forminfo, tool_moodlenet {{/str}}

+
+
+
+
+
diff --git a/admin/tool/moodlenet/templates/import_confirmation.mustache b/admin/tool/moodlenet/templates/import_confirmation.mustache new file mode 100644 index 00000000000..337b22abd5e --- /dev/null +++ b/admin/tool/moodlenet/templates/import_confirmation.mustache @@ -0,0 +1,76 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template tool_moodlenet/import_confirmation + + MoodleNet import confirmation template. + + The purpose of this template is to present the user with a confirm/cancel dialog-like page. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * resourceurl The URL to the remote resource on MoodleNet. + * resourcename The name of the remote resource on MoodleNet. + * sesskey The CSRF token, as per sesskey() + + Example context (json): + { + "course": 33, + "coursename": "Introduction to quantum physics", + "section": 0, + "resourceurl": "http://example.com/test.png", + "resourcename": "test.png", + "sesskey": "abc123" + } +}} + diff --git a/admin/tool/moodlenet/templates/import_options_select.mustache b/admin/tool/moodlenet/templates/import_options_select.mustache new file mode 100644 index 00000000000..9dc42ebdd6a --- /dev/null +++ b/admin/tool/moodlenet/templates/import_options_select.mustache @@ -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 . +}} +{{! + @template tool_moodlenet/import_options_select + + MoodleNet import options template. + + The purpose of this template is to render an list of import options as radio-button-like controls. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * course The course id. + * section The sectio id. + * resourceurl The URL to the remote resource on MoodleNet. + * resourcename The name of the remote resource on MoodleNet. + * sesskey The CSRF token, as per sesskey() + * handlers The array of handler options to present the user with + + Example context (json): + { + "course": 33, + "coursename": "Introduction to quantum physics", + "section": 0, + "resourceurl": "http://example.com/test.png", + "resourcename": "A test image", + "sesskey": "abc123", + "handlers": [ + { + "module": "label", + "message": "Add media to the course page" + } + ] + } +}} + diff --git a/admin/tool/moodlenet/templates/select_page.mustache b/admin/tool/moodlenet/templates/select_page.mustache new file mode 100644 index 00000000000..a49903bd244 --- /dev/null +++ b/admin/tool/moodlenet/templates/select_page.mustache @@ -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 . +}} +{{! + @template tool_moodlenet/select_page + + This template renders the course selection page for the MoodleNet tool. + + Example context (json): + { + "name": "A cat picture", + "cancellink": "https://moodlesite/my" + } +}} +
+ +

{{#str}} selectacourse {{/str}}

+ +
+
diff --git a/admin/tool/moodlenet/templates/view-cards.mustache b/admin/tool/moodlenet/templates/view-cards.mustache new file mode 100644 index 00000000000..da742ffe671 --- /dev/null +++ b/admin/tool/moodlenet/templates/view-cards.mustache @@ -0,0 +1,54 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template tool_moodlenet/view-cards + + This template renders the cards view for the MoodleNet tool. + + Example context (json): + { + "courses": [ + { + "name": "Assignment due 1", + "viewurl": "https://moodlesite/course/view.php?id=2", + "courseimage": "https://moodlesite/pluginfile/123/course/overviewfiles/123.jpg", + "fullname": "course 3", + "coursecategory": "Miscellaneous", + "visible": true + } + ] + } +}} + +{{< core_course/coursecards }} + {{$coursename}} + + {{#shortentext}}50, {{{fullname}}} {{/shortentext}} + + {{/coursename}} + {{$coursecategory}} + + {{#str}}aria:coursecategory, core_course{{/str}} + + + {{{coursecategory}}} + + {{/coursecategory}} + {{$divider}} +
|
+ {{/divider}} +{{/ core_course/coursecards }} diff --git a/admin/tool/moodlenet/tests/import_backup_helper_test.php b/admin/tool/moodlenet/tests/import_backup_helper_test.php new file mode 100644 index 00000000000..81ea96a3fde --- /dev/null +++ b/admin/tool/moodlenet/tests/import_backup_helper_test.php @@ -0,0 +1,84 @@ +. + +/** + * Unit tests for the import_backup_helper + * + * @package tool_moodlenet + * @category test + * @copyright 2020 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +/** + * Class import_backup_helper tests + */ +class tool_moodlenet_import_backup_helper_testcase extends advanced_testcase { + + /** + * Test that the first available context with the capability to upload backup files is returned. + */ + public function test_get_context_for_user() { + global $DB; + + $this->resetAfterTest(); + + $user1 = $this->getDataGenerator()->create_user(); + $user2 = $this->getDataGenerator()->create_user(); + $user3 = $this->getDataGenerator()->create_user(); + $user4 = $this->getDataGenerator()->create_user(); + $user5 = $this->getDataGenerator()->create_user(); + + $course = $this->getDataGenerator()->create_course(); + $coursecontext = context_course::instance($course->id); + + $this->getDataGenerator()->enrol_user($user1->id, $course->id, 'student'); + $this->getDataGenerator()->enrol_user($user2->id, $course->id, 'editingteacher'); + $this->getDataGenerator()->enrol_user($user5->id, $course->id, 'student'); + + $category = $this->getDataGenerator()->create_category(); + $rolerecord = $DB->get_record('role', ['shortname' => 'manager']); + $categorycontext = context_coursecat::instance($category->id); + $this->getDataGenerator()->role_assign($rolerecord->id, $user3->id, $categorycontext->id); + $this->getDataGenerator()->role_assign($rolerecord->id, $user5->id, $categorycontext->id); + + $roleid = $this->getDataGenerator()->create_role(); + $sitecontext = context_system::instance(); + assign_capability('moodle/restore:uploadfile', CAP_ALLOW, $roleid, $sitecontext->id, true); + accesslib_clear_all_caches_for_unit_testing(); + $this->getDataGenerator()->role_assign($roleid, $user4->id, $sitecontext->id); + + $result = \tool_moodlenet\local\import_backup_helper::get_context_for_user($user1->id); + $this->assertNull($result); + $result = \tool_moodlenet\local\import_backup_helper::get_context_for_user($user2->id); + $this->assertEquals($result, $coursecontext); + $this->assertEquals(CONTEXT_COURSE, $result->contextlevel); + $result = \tool_moodlenet\local\import_backup_helper::get_context_for_user($user3->id); + $this->assertEquals($result, $categorycontext); + $this->assertEquals(CONTEXT_COURSECAT, $result->contextlevel); + $result = \tool_moodlenet\local\import_backup_helper::get_context_for_user($user4->id); + $this->assertEquals($result, $sitecontext); + $this->assertEquals(CONTEXT_SYSTEM, $result->contextlevel); + $result = \tool_moodlenet\local\import_backup_helper::get_context_for_user($user5->id); + $this->assertEquals($result, $categorycontext); + $this->assertEquals(CONTEXT_COURSECAT, $result->contextlevel); + } + +} \ No newline at end of file diff --git a/admin/tool/moodlenet/tests/import_handler_info_test.php b/admin/tool/moodlenet/tests/import_handler_info_test.php new file mode 100644 index 00000000000..7f308c040b6 --- /dev/null +++ b/admin/tool/moodlenet/tests/import_handler_info_test.php @@ -0,0 +1,75 @@ +. + +/** + * Unit tests for the import_handler_info class. + * + * @package tool_moodlenet + * @category test + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local\tests; + +use tool_moodlenet\local\import_handler_info; +use tool_moodlenet\local\import_strategy; +use tool_moodlenet\local\import_strategy_file; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class tool_moodlenet_import_handler_info_testcase, providing test cases for the import_handler_info class. + */ +class tool_moodlenet_import_handler_info_testcase extends \advanced_testcase { + + /** + * Test init and the getters. + * + * @dataProvider handler_info_data_provider + * @param string $modname the name of the mod. + * @param string $description description of the mod. + * @param bool $expectexception whether we expect an exception during init or not. + */ + public function test_initialisation($modname, $description, $expectexception) { + $this->resetAfterTest(); + // Skip those cases we cannot init. + if ($expectexception) { + $this->expectException(\coding_exception::class); + $handlerinfo = new import_handler_info($modname, $description, new import_strategy_file()); + } + + $handlerinfo = new import_handler_info($modname, $description, new import_strategy_file()); + + $this->assertEquals($modname, $handlerinfo->get_module_name()); + $this->assertEquals($description, $handlerinfo->get_description()); + $this->assertInstanceOf(import_strategy::class, $handlerinfo->get_strategy()); + } + + + /** + * Data provider for creation of import_handler_info objects. + * + * @return array the data for creation of the info object. + */ + public function handler_info_data_provider() { + return [ + 'All data present' => ['label', 'Add a label to the course', false], + 'Empty module name' => ['', 'Add a file resource to the course', true], + 'Empty description' => ['resource', '', true], + + ]; + } +} diff --git a/admin/tool/moodlenet/tests/import_handler_registry_test.php b/admin/tool/moodlenet/tests/import_handler_registry_test.php new file mode 100644 index 00000000000..df7399c85a2 --- /dev/null +++ b/admin/tool/moodlenet/tests/import_handler_registry_test.php @@ -0,0 +1,119 @@ +. + +/** + * Unit tests for the import_handler_registry class. + * + * @package tool_moodlenet + * @category test + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local\tests; + +use tool_moodlenet\local\import_handler_registry; +use tool_moodlenet\local\import_handler_info; +use tool_moodlenet\local\import_strategy_file; +use tool_moodlenet\local\import_strategy_link; +use tool_moodlenet\local\remote_resource; +use tool_moodlenet\local\url; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class tool_moodlenet_import_handler_registry_testcase, providing test cases for the import_handler_registry class. + */ +class tool_moodlenet_import_handler_registry_testcase extends \advanced_testcase { + + /** + * Test confirming the behaviour of get_resource_handlers_for_strategy with different params. + */ + public function test_get_resource_handlers_for_strategy() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'); + $ihr = new import_handler_registry($course, $teacher); + $resource = new remote_resource( + new \curl(), + new url('http://example.org'), + (object) [ + 'name' => 'Resource name', + 'description' => 'Resource description' + ] + ); + + $handlers = $ihr->get_resource_handlers_for_strategy($resource, new import_strategy_file()); + $this->assertIsArray($handlers); + foreach ($handlers as $handler) { + $this->assertInstanceOf(import_handler_info::class, $handler); + } + } + + /** + * Test confirming that the results are scoped to the provided user. + */ + public function test_get_resource_handlers_for_strategy_user_scoping() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'); + + $studentihr = new import_handler_registry($course, $student); + $teacherihr = new import_handler_registry($course, $teacher); + $resource = new remote_resource( + new \curl(), + new url('http://example.org'), + (object) [ + 'name' => 'Resource name', + 'description' => 'Resource description' + ] + ); + + $this->assertEmpty($studentihr->get_resource_handlers_for_strategy($resource, new import_strategy_file())); + $this->assertNotEmpty($teacherihr->get_resource_handlers_for_strategy($resource, new import_strategy_file())); + } + + /** + * Test confirming that we can find a unique handler based on the module and strategy name. + */ + public function test_get_resource_handler_for_module_and_strategy() { + $this->resetAfterTest(); + + $course = $this->getDataGenerator()->create_course(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'); + $ihr = new import_handler_registry($course, $teacher); + $resource = new remote_resource( + new \curl(), + new url('http://example.org'), + (object) [ + 'name' => 'Resource name', + 'description' => 'Resource description' + ] + ); + + // Resource handles every file type, so we'll always be able to find that unique handler when looking. + $handler = $ihr->get_resource_handler_for_mod_and_strategy($resource, 'resource', new import_strategy_file()); + $this->assertInstanceOf(import_handler_info::class, $handler); + + // URL handles every resource, so we'll always be able to find that unique handler when looking with a link strategy. + $handler = $ihr->get_resource_handler_for_mod_and_strategy($resource, 'url', new import_strategy_link()); + $this->assertInstanceOf(import_handler_info::class, $handler); + $this->assertEquals('url', $handler->get_module_name()); + $this->assertInstanceOf(import_strategy_link::class, $handler->get_strategy()); + } +} diff --git a/admin/tool/moodlenet/tests/import_info_test.php b/admin/tool/moodlenet/tests/import_info_test.php new file mode 100644 index 00000000000..11513710c39 --- /dev/null +++ b/admin/tool/moodlenet/tests/import_info_test.php @@ -0,0 +1,105 @@ +. + +/** + * Unit tests for the import_info class. + * + * @package tool_moodlenet + * @category test + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local\tests; + +use tool_moodlenet\local\import_info; +use tool_moodlenet\local\remote_resource; +use tool_moodlenet\local\url; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class tool_moodlenet_import_info_testcase, providing test cases for the import_info class. + */ +class tool_moodlenet_import_info_testcase extends \advanced_testcase { + + /** + * Create some test objects. + * + * @return array + */ + protected function create_test_info(): array { + $user = $this->getDataGenerator()->create_user(); + $resource = new remote_resource(new \curl(), + new url('http://example.org'), + (object) [ + 'name' => 'Resource name', + 'description' => 'Resource summary' + ] + ); + $importinfo = new import_info($user->id, $resource, (object)[]); + + return [$user, $resource, $importinfo]; + } + + /** + * Test for creation and getters. + */ + public function test_getters() { + $this->resetAfterTest(); + [$user, $resource, $importinfo] = $this->create_test_info(); + + $this->assertEquals($resource, $importinfo->get_resource()); + $this->assertEquals(new \stdClass(), $importinfo->get_config()); + $this->assertNotEmpty($importinfo->get_id()); + } + + /** + * Test for setters. + */ + public function test_set_config() { + $this->resetAfterTest(); + [$user, $resource, $importinfo] = $this->create_test_info(); + + $config = $importinfo->get_config(); + $this->assertEquals(new \stdClass(), $config); + $config->course = 3; + $config->section = 1; + $importinfo->set_config($config); + $this->assertEquals((object) ['course' => 3, 'section' => 1], $importinfo->get_config()); + } + + /** + * Verify the object can be stored and loaded. + */ + public function test_persistence() { + $this->resetAfterTest(); + [$user, $resource, $importinfo] = $this->create_test_info(); + + // Nothing to load initially since nothing has been saved. + $loadedinfo = import_info::load($importinfo->get_id()); + $this->assertNull($loadedinfo); + + // Now, save and confirm we can load the data into a new object. + $importinfo->save(); + $loadedinfo2 = import_info::load($importinfo->get_id()); + $this->assertEquals($importinfo, $loadedinfo2); + + // Purge and confirm the load returns null now. + $importinfo->purge(); + $loadedinfo3 = import_info::load($importinfo->get_id()); + $this->assertNull($loadedinfo3); + } +} diff --git a/admin/tool/moodlenet/tests/import_processor_test.php b/admin/tool/moodlenet/tests/import_processor_test.php new file mode 100644 index 00000000000..ecfcec4ef75 --- /dev/null +++ b/admin/tool/moodlenet/tests/import_processor_test.php @@ -0,0 +1,156 @@ +. + +/** + * Unit tests for the import_processor class. + * + * @package tool_moodlenet + * @category test + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local\tests; + +use tool_moodlenet\local\import_handler_registry; +use tool_moodlenet\local\import_processor; +use tool_moodlenet\local\import_strategy_file; +use tool_moodlenet\local\import_strategy_link; +use tool_moodlenet\local\remote_resource; +use tool_moodlenet\local\url; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class tool_moodlenet_import_processor_testcase, providing test cases for the import_processor class. + */ +class tool_moodlenet_import_processor_testcase extends \advanced_testcase { + + /** + * An integration test, this confirms the ability to construct an import processor and run the import for the current user. + */ + public function test_process_valid_resource() { + $this->resetAfterTest(); + + // Set up a user as a teacher in a course. + $course = $this->getDataGenerator()->create_course(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'); + $section = 0; + $this->setUser($teacher); + + // Set up the import, using a mod_resource handler for the html extension. + $resourceurl = $this->getExternalTestFileUrl('/test.html'); + $remoteresource = new remote_resource( + new \curl(), + new url($resourceurl), + (object) [ + 'name' => 'Resource name', + 'description' => 'Resource description' + ] + ); + $handlerregistry = new import_handler_registry($course, $teacher); + $handlerinfo = $handlerregistry->get_resource_handler_for_mod_and_strategy($remoteresource, 'resource', + new import_strategy_file()); + $importproc = new import_processor($course, $section, $remoteresource, $handlerinfo, $handlerregistry); + + // Import the file. + $importproc->process(); + + // Verify there is a new mod_resource created with correct name, description and containing the test.html file. + $modinfo = get_fast_modinfo($course, $teacher->id); + $cms = $modinfo->get_instances(); + $this->assertArrayHasKey('resource', $cms); + $cminfo = array_shift($cms['resource']); + $this->assertEquals('Resource name', $cminfo->get_formatted_name()); + $cm = get_coursemodule_from_id('', $cminfo->id, 0, false, MUST_EXIST); + list($cm, $context, $module, $data, $cw) = get_moduleinfo_data($cminfo, $course); + $this->assertEquals($remoteresource->get_description(), $data->intro); + $fs = get_file_storage(); + $files = $fs->get_area_files(\context_module::instance($cminfo->id)->id, 'mod_resource', 'content', false, + 'sortorder DESC, id ASC', false); + $file = reset($files); + $this->assertEquals('test.html', $file->get_filename()); + $this->assertEquals('text/html', $file->get_mimetype()); + } + + /** + * Test confirming that an exception is thrown when trying to process a resource which does not exist. + */ + public function test_process_invalid_resource() { + $this->resetAfterTest(); + + // Set up a user as a teacher in a course. + $course = $this->getDataGenerator()->create_course(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'); + $section = 0; + $this->setUser($teacher); + + // Set up the import, using a mod_resource handler for the html extension. + $resourceurl = $this->getExternalTestFileUrl('/test.htmlzz'); + $remoteresource = new remote_resource( + new \curl(), + new url($resourceurl), + (object) [ + 'name' => 'Resource name', + 'description' => 'Resource description' + ] + ); + $handlerregistry = new import_handler_registry($course, $teacher); + $handlerinfo = $handlerregistry->get_resource_handler_for_mod_and_strategy($remoteresource, 'resource', + new import_strategy_file()); + $importproc = new import_processor($course, $section, $remoteresource, $handlerinfo, $handlerregistry); + + // Import the file. + $this->expectException(\coding_exception::class); + $importproc->process(); + } + + /** + * Test confirming that imports can be completed using alternative import strategies. + */ + public function test_process_alternative_import_strategies() { + $this->resetAfterTest(); + + // Set up a user as a teacher in a course. + $course = $this->getDataGenerator()->create_course(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'); + $section = 0; + $this->setUser($teacher); + + // Set up the import, using a mod_url handler and the link import strategy. + $remoteresource = new remote_resource( + new \curl(), + new url('http://example.com/cats.pdf'), + (object) [ + 'name' => 'Resource name', + 'description' => 'Resource description' + ] + ); + $handlerregistry = new import_handler_registry($course, $teacher); + $handlerinfo = $handlerregistry->get_resource_handler_for_mod_and_strategy($remoteresource, 'url', + new import_strategy_link()); + $importproc = new import_processor($course, $section, $remoteresource, $handlerinfo, $handlerregistry); + + // Import the resource as a link. + $importproc->process(); + + // Verify there is a new mod_url created with name 'cats' and containing the URL of the resource. + $modinfo = get_fast_modinfo($course, $teacher->id); + $cms = $modinfo->get_instances(); + $this->assertArrayHasKey('url', $cms); + $cminfo = array_shift($cms['url']); + $this->assertEquals('Resource name', $cminfo->get_formatted_name()); + } +} diff --git a/admin/tool/moodlenet/tests/lib_test.php b/admin/tool/moodlenet/tests/lib_test.php new file mode 100644 index 00000000000..d63afe2eb84 --- /dev/null +++ b/admin/tool/moodlenet/tests/lib_test.php @@ -0,0 +1,80 @@ +. + +/** + * Unit tests for tool_moodlenet lib + * + * @package tool_moodlenet + * @copyright 2020 Peter Dias + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/admin/tool/moodlenet/lib.php'); + +/** + * Test moodlenet functions + */ +class tool_moodlenet_lib_testcase extends advanced_testcase { + + /** + * Test the generate_mnet_endpoint function + * + * @dataProvider get_endpoints_provider + * @param string $profileurl + * @param int $course + * @param int $section + * @param string $expected + */ + public function test_generate_mnet_endpoint($profileurl, $course, $section, $expected) { + $endpoint = generate_mnet_endpoint($profileurl, $course, $section); + $this->assertEquals($expected, $endpoint); + } + + /** + * Dataprovider for test_generate_mnet_endpoint + * + * @return array + */ + public function get_endpoints_provider() { + global $CFG; + return [ + [ + '@name@domain.name', + 1, + 2, + 'https://domain.name/' . MOODLENET_DEFAULT_ENDPOINT . '?site=' . urlencode($CFG->wwwroot) + . '&course=1§ion=2' + ], + [ + '@profile@name@domain.name', + 1, + 2, + 'https://domain.name/' . MOODLENET_DEFAULT_ENDPOINT . '?site=' . urlencode($CFG->wwwroot) + . '&course=1§ion=2' + ], + [ + 'https://domain.name', + 1, + 2, + 'https://domain.name/' . MOODLENET_DEFAULT_ENDPOINT . '?site=' . urlencode($CFG->wwwroot) + . '&course=1§ion=2' + ] + ]; + } +} diff --git a/admin/tool/moodlenet/tests/profile_manager_test.php b/admin/tool/moodlenet/tests/profile_manager_test.php new file mode 100644 index 00000000000..9485fbf90a4 --- /dev/null +++ b/admin/tool/moodlenet/tests/profile_manager_test.php @@ -0,0 +1,142 @@ +. + +/** + * Unit tests for the profile manager + * + * @package tool_moodlenet + * @category test + * @copyright 2020 Adrian Greeve + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; + +/** + * Class profile_manager tests + */ +class tool_moodlenet_profile_manager_testcase extends advanced_testcase { + + /** + * Test that on this site we use the user table to hold moodle net profile information. + */ + public function test_official_profile_exists() { + $this->assertTrue(\tool_moodlenet\profile_manager::official_profile_exists()); + } + + /** + * Test a null is returned when the user's mnet profile field is not set. + */ + public function test_get_moodlenet_user_profile_no_profile_set() { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + + $result = \tool_moodlenet\profile_manager::get_moodlenet_user_profile($user->id); + $this->assertNull($result); + } + + /** + * Test a null is returned when the user's mnet profile field is not set. + */ + public function test_moodlenet_user_profile_creation_no_profile_set() { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage(get_string('invalidmoodlenetprofile', 'tool_moodlenet')); + $result = new \tool_moodlenet\moodlenet_user_profile("", $user->id); + } + + /** + * Test the return of a moodle net profile. + */ + public function test_get_moodlenet_user_profile() { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(['moodlenetprofile' => '@matt@hq.mnet']); + + $result = \tool_moodlenet\profile_manager::get_moodlenet_user_profile($user->id); + $this->assertEquals($user->moodlenetprofile, $result->get_profile_name()); + } + + /** + * Test the creation of a user profile category. + */ + public function test_create_user_profile_category() { + global $DB; + $this->resetAfterTest(); + + $basecategoryname = get_string('pluginname', 'tool_moodlenet'); + + \tool_moodlenet\profile_manager::create_user_profile_category(); + $categoryname = \tool_moodlenet\profile_manager::get_category_name(); + $this->assertEquals($basecategoryname, $categoryname); + \tool_moodlenet\profile_manager::create_user_profile_category(); + + $recordcount = $DB->count_records('user_info_category', ['name' => $basecategoryname]); + $this->assertEquals(1, $recordcount); + + // Test the duplication of categories to ensure a unique name is always used. + $categoryname = \tool_moodlenet\profile_manager::get_category_name(); + $this->assertEquals($basecategoryname . 1, $categoryname); + \tool_moodlenet\profile_manager::create_user_profile_category(); + $categoryname = \tool_moodlenet\profile_manager::get_category_name(); + $this->assertEquals($basecategoryname . 2, $categoryname); + } + + /** + * Test the creating of the custom user profile field to hold the moodle net profile. + */ + public function test_create_user_profile_text_field() { + global $DB; + $this->resetAfterTest(); + + $shortname = 'mnetprofile'; + + $categoryid = \tool_moodlenet\profile_manager::create_user_profile_category(); + \tool_moodlenet\profile_manager::create_user_profile_text_field($categoryid); + + $record = $DB->get_record('user_info_field', ['shortname' => $shortname]); + $this->assertEquals($shortname, $record->shortname); + $this->assertEquals($categoryid, $record->categoryid); + + // Test for a unique name if 'mnetprofile' is already in use. + \tool_moodlenet\profile_manager::create_user_profile_text_field($categoryid); + $profilename = \tool_moodlenet\profile_manager::get_profile_field_name(); + $this->assertEquals($shortname . 1, $profilename); + \tool_moodlenet\profile_manager::create_user_profile_text_field($categoryid); + $profilename = \tool_moodlenet\profile_manager::get_profile_field_name(); + $this->assertEquals($shortname . 2, $profilename); + } + + /** + * Test that the user moodlenet profile is saved. + */ + public function test_save_moodlenet_user_profile() { + $this->resetAfterTest(); + + $user = $this->getDataGenerator()->create_user(); + $profilename = '@matt@hq.mnet'; + + $moodlenetprofile = new \tool_moodlenet\moodlenet_user_profile($profilename, $user->id); + + \tool_moodlenet\profile_manager::save_moodlenet_user_profile($moodlenetprofile); + + $userdata = \core_user::get_user($user->id); + $this->assertEquals($profilename, $userdata->moodlenetprofile); + } +} diff --git a/admin/tool/moodlenet/tests/remote_resource_test.php b/admin/tool/moodlenet/tests/remote_resource_test.php new file mode 100644 index 00000000000..2e108742bf6 --- /dev/null +++ b/admin/tool/moodlenet/tests/remote_resource_test.php @@ -0,0 +1,115 @@ +. + +/** + * Unit tests for the remote_resource class. + * + * @package tool_moodlenet + * @category test + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local\tests; + +use tool_moodlenet\local\remote_resource; +use tool_moodlenet\local\url; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class tool_moodlenet_remote_resource_testcase, providing test cases for the remote_resource class. + */ +class tool_moodlenet_remote_resource_testcase extends \advanced_testcase { + + /** + * Test getters. + * + * @dataProvider remote_resource_data_provider + * @param string $url the url of the resource. + * @param string $metadata the resource metadata like name, description, etc. + * @param string $expectedextension the extension we expect to find when querying the remote resource. + */ + public function test_getters($url, $metadata, $expectedextension) { + $this->resetAfterTest(); + + $remoteres = new remote_resource(new \curl(), new url($url), $metadata); + + $this->assertEquals(new url($url), $remoteres->get_url()); + $this->assertEquals($metadata->name, $remoteres->get_name()); + $this->assertEquals($metadata->description, $remoteres->get_description()); + $this->assertEquals($expectedextension, $remoteres->get_extension()); + } + + /** + * Data provider generating remote urls. + * + * @return array + */ + public function remote_resource_data_provider() { + return [ + 'With filename and extension' => [ + $this->getExternalTestFileUrl('/test.html'), + (object) [ + 'name' => 'Test html file', + 'description' => 'Full description of the html file' + ], + 'html' + ], + 'With filename only' => [ + 'http://example.com/path/file', + (object) [ + 'name' => 'Test html file', + 'description' => 'Full description of the html file' + ], + '' + ] + ]; + } + + /** + * Test confirming the network based operations of a remote_resource. + */ + public function test_network_features() { + $url = $this->getExternalTestFileUrl('/test.html'); + $nonexistenturl = $this->getExternalTestFileUrl('/test.htmlzz'); + + $remoteres = new remote_resource( + new \curl(), + new url($url), + (object) [ + 'name' => 'Test html file', + 'description' => 'Some description' + ] + ); + $nonexistentremoteres = new remote_resource( + new \curl(), + new url($nonexistenturl), + (object) [ + 'name' => 'Test html file', + 'description' => 'Some description' + ] + ); + + $this->assertGreaterThan(0, $remoteres->get_download_size()); + [$path, $name] = $remoteres->download_to_requestdir(); + $this->assertIsString($path); + $this->assertEquals('test.html', $name); + $this->assertFileExists($path . '/' . $name); + + $this->expectException(\coding_exception::class); + $nonexistentremoteres->get_download_size(); + } +} diff --git a/admin/tool/moodlenet/tests/url_test.php b/admin/tool/moodlenet/tests/url_test.php new file mode 100644 index 00000000000..74a7349bec4 --- /dev/null +++ b/admin/tool/moodlenet/tests/url_test.php @@ -0,0 +1,103 @@ +. + +/** + * Unit tests for the url class. + * + * @package tool_moodlenet + * @category test + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +namespace tool_moodlenet\local\tests; + +use tool_moodlenet\local\url; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Class tool_moodlenet_url_testcase, providing test cases for the url class. + */ +class tool_moodlenet_url_testcase extends \advanced_testcase { + + /** + * Test the parsing to host + path components. + * + * @dataProvider url_provider + * @param string $urlstring The full URL string + * @param string $host the expected host component of the URL. + * @param string $path the expected path component of the URL. + * @param bool $exception whether or not an exception is expected during construction. + */ + public function test_parsing($urlstring, $host, $path, $exception) { + if ($exception) { + $this->expectException(\coding_exception::class); + $url = new url($urlstring); + return; + } + + $url = new url($urlstring); + $this->assertEquals($urlstring, $url->get_value()); + $this->assertEquals($host, $url->get_host()); + $this->assertEquals($path, $url->get_path()); + } + + /** + * Data provider. + * + * @return array + */ + public function url_provider() { + return [ + 'No path' => [ + 'url' => 'https://example.moodle.net', + 'host' => 'example.moodle.net', + 'path' => null, + 'exception' => false, + ], + 'Slash path' => [ + 'url' => 'https://example.moodle.net/', + 'host' => 'example.moodle.net', + 'path' => '/', + 'exception' => false, + ], + 'Path includes file and extension' => [ + 'url' => 'https://example.moodle.net/uploads/123456789/pic.png', + 'host' => 'example.moodle.net', + 'path' => '/uploads/123456789/pic.png', + 'exception' => false, + ], + 'Path includes file, extension and params' => [ + 'url' => 'https://example.moodle.net/uploads/123456789/pic.png?option=1&option2=test', + 'host' => 'example.moodle.net', + 'path' => '/uploads/123456789/pic.png', + 'exception' => false, + ], + 'Malformed - invalid' => [ + 'url' => 'invalid', + 'host' => null, + 'path' => null, + 'exception' => true, + ], + 'Direct, non-encoded utf8 - invalid' => [ + 'url' => 'http://москва.рф/services/', + 'host' => 'москва.рф', + 'path' => '/services/', + 'exception' => true, + ], + ]; + } +} diff --git a/admin/tool/moodlenet/version.php b/admin/tool/moodlenet/version.php new file mode 100644 index 00000000000..d6a7b6e7daf --- /dev/null +++ b/admin/tool/moodlenet/version.php @@ -0,0 +1,30 @@ +. + +/** + * Version file for tool_moodlenet + * + * @package tool_moodlenet + * @copyright 2020 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->component = 'tool_moodlenet'; +$plugin->version = 2020060500; +$plugin->requires = 2020022800.01; +$plugin->maturity = MATURITY_ALPHA; diff --git a/course/templates/local/activitychooser/help.mustache b/course/templates/local/activitychooser/help.mustache index 9ddacd44496..471dffc380f 100644 --- a/course/templates/local/activitychooser/help.mustache +++ b/course/templates/local/activitychooser/help.mustache @@ -45,7 +45,7 @@ {{^showFooter}} -
+ {{/showFooter}} diff --git a/theme/boost/scss/moodle/core.scss b/theme/boost/scss/moodle/core.scss index 411f762413e..32a17d6717f 100644 --- a/theme/boost/scss/moodle/core.scss +++ b/theme/boost/scss/moodle/core.scss @@ -1639,6 +1639,7 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { .modchooser .modal-footer { height: 70px; + background: $modal-content-bg; .moodlenet-logo { .icon { height: 2.5rem; diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index b2d48fe4e66..f0ec2361252 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -11002,7 +11002,8 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { margin: 1em auto; } .modchooser .modal-footer { - height: 70px; } + height: 70px; + background: #fff; } .modchooser .modal-footer .moodlenet-logo .icon { height: 2.5rem; width: 6rem; diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index e2c098d5e91..316f966a9ac 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -11209,7 +11209,8 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { margin: 1em auto; } .modchooser .modal-footer { - height: 70px; } + height: 70px; + background: #fff; } .modchooser .modal-footer .moodlenet-logo .icon { height: 2.5rem; width: 6rem;