From 05b27f211840b2ce54c140d53f0f3f53e318aae7 Mon Sep 17 00:00:00 2001 From: Mathew May Date: Mon, 3 Feb 2020 09:35:11 +0800 Subject: [PATCH] MDL-67264 core_course: Activity chooser new feature Roll in the base for the new activity chooser It renders all modules into a modal Gives the user to add from either the base or help screens All checked by accessability tools with great coverage Adds minimal overhead to the course edit setup time Co-authored-by: Mathew May Co-authored-by: Mihail Geshoski --- course/amd/build/activitychooser.min.js | 2 + course/amd/build/activitychooser.min.js.map | 1 + .../local/activitychooser/dialogue.min.js | 2 + .../local/activitychooser/dialogue.min.js.map | 1 + .../local/activitychooser/repository.min.js | 2 + .../activitychooser/repository.min.js.map | 1 + .../local/activitychooser/selectors.min.js | 2 + .../activitychooser/selectors.min.js.map | 1 + course/amd/src/activitychooser.js | 148 +++++++++ .../amd/src/local/activitychooser/dialogue.js | 287 ++++++++++++++++++ .../src/local/activitychooser/repository.js | 40 +++ .../src/local/activitychooser/selectors.js | 70 +++++ course/externallib.php | 82 +++++ course/renderer.php | 10 +- course/templates/chooser.mustache | 48 +++ course/templates/chooser_help.mustache | 54 ++++ course/templates/chooser_item.mustache | 49 +++ course/templates/modchooser.mustache | 38 --- course/tests/externallib_test.php | 35 +++ .../moodle-course-modchooser-debug.js | 169 ----------- .../moodle-course-modchooser-min.js | 1 - .../moodle-course-modchooser.js | 169 ----------- course/yui/src/modchooser/build.json | 10 - course/yui/src/modchooser/js/modchooser.js | 164 ---------- .../yui/src/modchooser/meta/modchooser.json | 8 - lang/en/moodle.php | 2 + lib/db/services.php | 8 + theme/boost/scss/moodle/core.scss | 123 +++++++- theme/boost/scss/moodle/course.scss | 5 - theme/boost/style/moodle.css | 92 +++++- theme/classic/style/moodle.css | 92 +++++- version.php | 2 +- 32 files changed, 1121 insertions(+), 597 deletions(-) create mode 100644 course/amd/build/activitychooser.min.js create mode 100644 course/amd/build/activitychooser.min.js.map create mode 100644 course/amd/build/local/activitychooser/dialogue.min.js create mode 100644 course/amd/build/local/activitychooser/dialogue.min.js.map create mode 100644 course/amd/build/local/activitychooser/repository.min.js create mode 100644 course/amd/build/local/activitychooser/repository.min.js.map create mode 100644 course/amd/build/local/activitychooser/selectors.min.js create mode 100644 course/amd/build/local/activitychooser/selectors.min.js.map create mode 100644 course/amd/src/activitychooser.js create mode 100644 course/amd/src/local/activitychooser/dialogue.js create mode 100644 course/amd/src/local/activitychooser/repository.js create mode 100644 course/amd/src/local/activitychooser/selectors.js create mode 100644 course/templates/chooser.mustache create mode 100644 course/templates/chooser_help.mustache create mode 100644 course/templates/chooser_item.mustache delete mode 100644 course/templates/modchooser.mustache delete mode 100644 course/yui/build/moodle-course-modchooser/moodle-course-modchooser-debug.js delete mode 100644 course/yui/build/moodle-course-modchooser/moodle-course-modchooser-min.js delete mode 100644 course/yui/build/moodle-course-modchooser/moodle-course-modchooser.js delete mode 100644 course/yui/src/modchooser/build.json delete mode 100644 course/yui/src/modchooser/js/modchooser.js delete mode 100644 course/yui/src/modchooser/meta/modchooser.json diff --git a/course/amd/build/activitychooser.min.js b/course/amd/build/activitychooser.min.js new file mode 100644 index 0000000000000..39ab5cf434d9f --- /dev/null +++ b/course/amd/build/activitychooser.min.js @@ -0,0 +1,2 @@ +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=k(b);c=k(c);d=j(d);e=j(e);f=k(f);g=k(g);i=j(i);function j(a){return a&&a.__esModule?a:{default:a}}function k(a){if(a&&a.__esModule){return a}else{var b={};if(null!=a){for(var c in a){if(Object.prototype.hasOwnProperty.call(a,c)){var d=Object.defineProperty&&Object.getOwnPropertyDescriptor?Object.getOwnPropertyDescriptor(a,c):{};if(d.get||d.set){Object.defineProperty(b,c,d)}else{b[c]=a[c]}}}}b.default=a;return b}}function l(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 m(a){return function(){var b=this,c=arguments;return new Promise(function(d,e){var h=a.apply(b,c);function f(a){l(h,d,e,f,g,"next",a)}function g(a){l(h,d,e,f,g,"throw",a)}f(void 0)})}}var n=function(a){var b=new i.default;o(a);b.resolve()};a.init=n;var o=function(a){var f=["click",e.default.events.activate,e.default.events.keyboardActivate],g=function(){var b=null;return function(){if(!b){b=new Promise(function(b){b(c.activityModules(a))})}return b}}();e.default.define(document,f);f.forEach(function(a){document.addEventListener(a,function(){var a=m(regeneratorRuntime.mark(function a(c){var e,f,h;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:if(!c.target.closest(d.default.elements.sectionmodchooser)){a.next=12;break}e=c.target.closest(d.default.elements.sectionmodchooser);a.t0=p;a.next=5;return g();case 5:a.t1=a.sent;a.t2=e.dataset.sectionid;f=(0,a.t0)(a.t1,a.t2);a.next=10;return q(f);case 10:h=a.sent;b.displayChooser(e,h,f);case 12:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}())})},p=function(a,b){var c=JSON.parse(JSON.stringify(a));c.allmodules.forEach(function(a){a.urls.addoption+="§ion="+b});return c.allmodules},q=function(a){return s(r(a))},r=function(a){return{default:a}},s=function(a){return g.create({type:g.types.DEFAULT,title:(0,h.get_string)("addresourceoractivity"),body:f.render("core_course/chooser",a),large:!0,templateContext:{classes:"modchooser"}})}}); +//# sourceMappingURL=activitychooser.min.js.map diff --git a/course/amd/build/activitychooser.min.js.map b/course/amd/build/activitychooser.min.js.map new file mode 100644 index 0000000000000..77cf4e6fa3029 --- /dev/null +++ b/course/amd/build/activitychooser.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/activitychooser.js"],"names":["init","courseId","pendingPromise","Pending","registerListenerEvents","resolve","events","CustomEvents","activate","keyboardActivate","fetchModuleData","innerPromise","Promise","Repository","activityModules","define","document","forEach","event","addEventListener","e","target","closest","selectors","elements","sectionmodchooser","caller","sectionIdMapper","dataset","sectionid","builtModuleData","modalBuilder","sectionModal","ChooserDialogue","displayChooser","webServiceData","id","newData","JSON","parse","stringify","allmodules","module","urls","addoption","data","buildModal","templateDataBuilder","ModalFactory","create","type","types","DEFAULT","title","body","Templates","render","large","templateContext","classes"],"mappings":"qYAwBA,OACA,OACA,OACA,OACA,OACA,OAEA,O,grBAQO,GAAMA,CAAAA,CAAI,CAAG,SAAAC,CAAQ,CAAI,CAC5B,GAAMC,CAAAA,CAAc,CAAG,GAAIC,UAA3B,CAEAC,CAAsB,CAACH,CAAD,CAAtB,CAEAC,CAAc,CAACG,OAAf,EACH,CANM,C,YAcDD,CAAAA,CAAsB,CAAG,SAACH,CAAD,CAAc,IACnCK,CAAAA,CAAM,CAAG,CACX,OADW,CAEXC,UAAaD,MAAb,CAAoBE,QAFT,CAGXD,UAAaD,MAAb,CAAoBG,gBAHT,CAD0B,CAOnCC,CAAe,CAAI,UAAM,CAC3B,GAAIC,CAAAA,CAAY,CAAG,IAAnB,CAEA,MAAO,WAAM,CACT,GAAI,CAACA,CAAL,CAAmB,CACfA,CAAY,CAAG,GAAIC,CAAAA,OAAJ,CAAY,SAACP,CAAD,CAAa,CACpCA,CAAO,CAACQ,CAAU,CAACC,eAAX,CAA2Bb,CAA3B,CAAD,CACV,CAFc,CAGlB,CAED,MAAOU,CAAAA,CACV,CACJ,CAZuB,EAPiB,CAqBzCJ,UAAaQ,MAAb,CAAoBC,QAApB,CAA8BV,CAA9B,EAGAA,CAAM,CAACW,OAAP,CAAe,SAACC,CAAD,CAAW,CACtBF,QAAQ,CAACG,gBAAT,CAA0BD,CAA1B,4CAAiC,WAAME,CAAN,iGACzBA,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiBC,UAAUC,QAAV,CAAmBC,iBAApC,CADyB,kBAEnBC,CAFmB,CAEVN,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiBC,UAAUC,QAAV,CAAmBC,iBAApC,CAFU,MAGDE,CAHC,gBAGqBjB,CAAAA,CAAe,EAHpC,yBAGwCgB,CAAM,CAACE,OAAP,CAAeC,SAHvD,CAGnBC,CAHmB,qCAIEC,CAAAA,CAAY,CAACD,CAAD,CAJd,SAInBE,CAJmB,QAMzBC,CAAe,CAACC,cAAhB,CAA+BR,CAA/B,CAAuCM,CAAvC,CAAqDF,CAArD,EANyB,yCAAjC,wDASH,CAVD,CAWH,C,CAWKH,CAAe,CAAG,SAACQ,CAAD,CAAiBC,CAAjB,CAAwB,CAE5C,GAAMC,CAAAA,CAAO,CAAGC,IAAI,CAACC,KAAL,CAAWD,IAAI,CAACE,SAAL,CAAeL,CAAf,CAAX,CAAhB,CACAE,CAAO,CAACI,UAAR,CAAmBxB,OAAnB,CAA2B,SAACyB,CAAD,CAAY,CACnCA,CAAM,CAACC,IAAP,CAAYC,SAAZ,EAAyB,YAAcR,CAC1C,CAFD,EAGA,MAAOC,CAAAA,CAAO,CAACI,UAClB,C,CASKV,CAAY,CAAG,SAAAc,CAAI,QAAIC,CAAAA,CAAU,CAACC,CAAmB,CAACF,CAAD,CAApB,CAAd,C,CASnBE,CAAmB,CAAG,SAACF,CAAD,CAAU,CAClC,MAAO,CACH,QAAWA,CADR,CAGV,C,CASKC,CAAU,CAAG,SAAAD,CAAI,CAAI,CACvB,MAAOG,CAAAA,CAAY,CAACC,MAAb,CAAoB,CACvBC,IAAI,CAAEF,CAAY,CAACG,KAAb,CAAmBC,OADF,CAEvBC,KAAK,CAAE,iBAAU,uBAAV,CAFgB,CAGvBC,IAAI,CAAEC,CAAS,CAACC,MAAV,CAAiB,qBAAjB,CAAwCX,CAAxC,CAHiB,CAIvBY,KAAK,GAJkB,CAKvBC,eAAe,CAAE,CACbC,OAAO,CAAE,YADI,CALM,CAApB,CASV,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 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/**\n * Set up the activity chooser.\n *\n * @method init\n * @param {Number} courseId Course ID to use later on in fetchModules()\n */\nexport const init = courseId => {\n const pendingPromise = new Pending();\n\n registerListenerEvents(courseId);\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 */\nconst registerListenerEvents = (courseId) => {\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 const caller = e.target.closest(selectors.elements.sectionmodchooser);\n const builtModuleData = sectionIdMapper(await fetchModuleData(), caller.dataset.sectionid);\n const sectionModal = await modalBuilder(builtModuleData);\n\n ChooserDialogue.displayChooser(caller, sectionModal, builtModuleData);\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 {Array} 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.allmodules.forEach((module) => {\n module.urls.addoption += '§ion=' + id;\n });\n return newData.allmodules;\n};\n\n/**\n * Build a modal for each section ID and store it into a map for quick access\n *\n * @method modalBuilder\n * @param {Map} data our map of section ID's & modules to generate modals for\n * @return {Object} TODO\n */\nconst modalBuilder = data => buildModal(templateDataBuilder(data));\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 * @return {Object} Our built object ready to render out\n */\nconst templateDataBuilder = (data) => {\n return {\n 'default': data,\n };\n};\n\n/**\n * Given an object we want to prebuild a modal ready to store into a map\n *\n * @method buildModal\n * @param {Object} data The template data which contains arrays of modules\n * @return {Object} The modal for the calling section with everything already set up\n */\nconst buildModal = data => {\n return ModalFactory.create({\n type: ModalFactory.types.DEFAULT,\n title: getString('addresourceoractivity'),\n body: Templates.render('core_course/chooser', data),\n large: true,\n templateContext: {\n classes: 'modchooser'\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 new file mode 100644 index 0000000000000..e1d3ec57776c9 --- /dev/null +++ b/course/amd/build/local/activitychooser/dialogue.min.js @@ -0,0 +1,2 @@ +define ("core_course/local/activitychooser/dialogue",["exports","jquery","core/modal_events","core_course/local/activitychooser/selectors","core/templates","core/key_codes","core/loadingicon"],function(a,b,c,d,e,f,g){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.displayChooser=void 0;b=i(b);c=h(c);d=i(d);e=h(e);function h(a){if(a&&a.__esModule){return a}else{var b={};if(null!=a){for(var c in a){if(Object.prototype.hasOwnProperty.call(a,c)){var d=Object.defineProperty&&Object.getOwnPropertyDescriptor?Object.getOwnPropertyDescriptor(a,c):{};if(d.get||d.set){Object.defineProperty(b,c,d)}else{b[c]=a[c]}}}}b.default=a;return b}}function i(a){return a&&a.__esModule?a:{default:a}}function j(a,b){return m(a)||l(a,b)||k()}function k(){throw new TypeError("Invalid attempt to destructure non-iterable instance")}function l(a,b){var c=[],d=!0,e=!1,f=void 0;try{for(var g=a[Symbol.iterator](),h;!(d=(h=g.next()).done);d=!0){c.push(h.value);if(b&&c.length===b)break}}catch(a){e=!0;f=a}finally{try{if(!d&&null!=g["return"])g["return"]()}finally{if(e)throw f}}return c}function m(a){if(Array.isArray(a))return a}var n=function(a,b){var c=a.find(d.default.regions.help)[0];c.innerHTML="";var f=(0,g.addIconToContainer)(c),h=null,i=new Promise(function(a){h=a}),k=e.renderForPromise("core_course/chooser_help",b);Promise.all([k,f,i]).then(function(a){var b=j(a,1),d=b[0],f=d.html,g=d.js;return e.replaceNodeContents(c,f,g)}).then(function(){c.querySelector(d.default.regions.chooserSummary.description).focus();return c}).catch(Notification.exception);a.one("slid.bs.carousel",function(){h()});a.carousel("next")},o=function(a,c){var e=function(f){if(f.target.closest(d.default.actions.optionActions.showSummary)){var e=(0,b.default)(a.getBody()[0].querySelector(d.default.regions.carousel)),g=f.target.closest(d.default.regions.chooserOption.container),h=g.dataset.modname,i=c.get(h);n(e,i)}if(f.target.matches(d.default.actions.closeOption)){var j=(0,b.default)(a.getBody()[0].querySelector(d.default.regions.carousel));j.carousel("prev");j.on("slid.bs.carousel",function(){var b=a.getBody()[0].querySelector(d.default.regions.modules),c=b.querySelector(d.default.regions.getModuleSelector(f.target.dataset.modname));c.focus()})}};a.getBodyPromise().then(function(a){return a[0]}).then(function(a){(0,b.default)(a.querySelector(d.default.regions.carousel)).carousel({interval:!1,pause:!0,keyboard:!1});return a}).then(function(a){a.addEventListener("click",e);return a}).then(function(a){p(a,c);return a}).catch()},p=function(a,c){var e=a.querySelectorAll(d.default.regions.chooserOption.container);Array.from(e).forEach(function(e){return e.addEventListener("keyup",function(g){var e=document.querySelector(d.default.regions.chooserOptions);if(g.keyCode===f.enter||g.keyCode===f.space){if(g.target.matches(d.default.actions.optionActions.showSummary)){g.preventDefault();var h=g.target.closest(d.default.regions.chooserOption.container),i=h.dataset.modname,j=c.get(i),k=(0,b.default)(a.querySelector(d.default.regions.carousel));k.carousel({interval:!1,pause:!0,keyboard:!1});n(k,j)}}if(g.keyCode===f.arrowRight){g.preventDefault();var l=g.target.closest(d.default.regions.chooserOption.container),m=l.nextElementSibling,o=e.firstElementChild,p=r(m,o);q(p,l)}if(g.keyCode===f.arrowLeft){g.preventDefault();var s=g.target.closest(d.default.regions.chooserOption.container),t=s.previousElementSibling,u=e.lastElementChild,v=r(t,u);q(v,s)}if(g.keyCode===f.home){g.preventDefault();var w=g.target.closest(d.default.regions.chooserOption.container),x=e.firstElementChild;q(x,w)}if(g.keyCode===f.end){g.preventDefault();var y=g.target.closest(d.default.regions.chooserOption.container),z=e.lastElementChild;q(z,y)}})})},q=function(a){var b=1.\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';\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\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/chooser_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.description).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 * 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 */\nconst registerListenerEvents = (modal, mappedModules) => {\n const bodyClickListener = 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 // 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\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 // Register event listeners related to the keyboard navigation controls.\n .then(body => {\n initKeyboardNavigation(body, mappedModules);\n return body;\n })\n .catch();\n\n};\n\n/**\n * Initialise the keyboard navigation controls for the chooser.\n *\n * @method initKeyboardNavigation\n * @param {NodeElement} 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 */\nconst initKeyboardNavigation = (body, mappedModules) => {\n\n const chooserOptions = body.querySelectorAll(selectors.regions.chooserOption.container);\n\n Array.from(chooserOptions).forEach((element) => {\n return element.addEventListener('keyup', (e) => {\n const chooserOptions = document.querySelector(selectors.regions.chooserOptions);\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 = chooserOptions.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 = chooserOptions.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 = chooserOptions.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 = chooserOptions.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} previousChooserOption The previous focused option element\n */\nconst focusChooserOption = (currentChooserOption, previousChooserOption = false) => {\n if (previousChooserOption !== false) {\n const previousChooserOptionLink = previousChooserOption.querySelector(selectors.actions.addChooser);\n const previousChooserOptionHelp = previousChooserOption.querySelector(selectors.actions.optionActions.showSummary);\n // Set tabindex to -1 to remove the previous chooser option element from the focus order.\n previousChooserOption.tabIndex = -1;\n previousChooserOptionLink.tabIndex = -1;\n previousChooserOptionHelp.tabIndex = -1;\n }\n\n const currentChooserOptionLink = currentChooserOption.querySelector(selectors.actions.addChooser);\n const currentChooserOptionHelp = currentChooserOption.querySelector(selectors.actions.optionActions.showSummary);\n // Set tabindex to 0 to add current chooser option element to the focus order.\n currentChooserOption.tabIndex = 0;\n currentChooserOptionLink.tabIndex = 0;\n currentChooserOptionHelp.tabIndex = 0;\n // Focus the current chooser option element.\n currentChooserOption.focus();\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 {String}\n */\nconst clickErrorHandler = (item, fallback) => {\n if (item !== null) {\n return item;\n } else {\n return fallback;\n }\n};\n\n/**\n * Display the module chooser.\n *\n * @method displayChooser\n * @param {HTMLElement} origin The calling button\n * @param {Object} modal Our created modal for the section\n * @param {Array} sectionModules An array of all of the built module information\n */\nexport const displayChooser = (origin, modal, sectionModules) => {\n\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.modulename, module);\n });\n\n // Register event listeners.\n registerListenerEvents(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 // We want to focus on the first chooser option element as soon as the modal is opened.\n modal.getRoot().on(ModalEvents.shown, () => {\n modal.getModal()[0].tabIndex = -1;\n\n modal.getBodyPromise()\n .then(body => {\n const firstChooserOption = body[0].querySelector(selectors.regions.chooserOption.container);\n focusChooserOption(firstChooserOption);\n\n return;\n })\n .catch(Notification.exception);\n });\n\n modal.show();\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 new file mode 100644 index 0000000000000..da5b3c7c69466 --- /dev/null +++ b/course/amd/build/local/activitychooser/repository.min.js @@ -0,0 +1,2 @@ +define ("core_course/local/activitychooser/repository",["exports","core/ajax"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});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_activity_picker_info",args:{courseid:a}}])[0]};a.activityModules=c}); +//# 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 new file mode 100644 index 0000000000000..67b15907c1039 --- /dev/null +++ b/course/amd/build/local/activitychooser/repository.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/local/activitychooser/repository.js"],"names":["activityModules","courseid","ajax","call","methodname","args"],"mappings":"oLAsBA,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","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_activity_picker_info',\n args: {\n courseid: courseid,\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/build/local/activitychooser/selectors.min.js b/course/amd/build/local/activitychooser/selectors.min.js new file mode 100644 index 0000000000000..d709bb4edf0c9 --- /dev/null +++ b/course/amd/build/local/activitychooser/selectors.min.js @@ -0,0 +1,2 @@ +define ("core_course/local/activitychooser/selectors",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;var b=function(a,b){return"[data-".concat(a,"=\"").concat(b,"\"]")},c={regions:{chooser:b("region","chooser-container"),chooserOptions:b("region","chooser-options-container"),chooserOption:{container:b("region","chooser-option-container"),actions:b("region","chooser-option-actions-container"),info:b("region","chooser-option-info-container")},chooserSummary:{container:b("region","chooser-option-summary-container"),content:b("region","chooser-option-summary-content-container"),description:b("region","summary-description"),actions:b("region","chooser-option-summary-actions-container")},carousel:b("region","carousel"),help:b("region","help"),modules:b("region","modules"),getModuleSelector:function getModuleSelector(a){return"[role=\"menuitem\"][data-modname=\"".concat(a,"\"]")}},actions:{optionActions:{showSummary:b("action","show-option-summary")},addChooser:b("action","add-chooser-option"),closeOption:b("action","close-chooser-option-summary"),hide:b("action","hide")},elements:{section:".section",sectionmodchooser:"button.section-modchooser-link",sitemenu:".block_site_main_menu",sitetopic:"div.sitetopic"}};a.default=c;return a.default}); +//# sourceMappingURL=selectors.min.js.map diff --git a/course/amd/build/local/activitychooser/selectors.min.js.map b/course/amd/build/local/activitychooser/selectors.min.js.map new file mode 100644 index 0000000000000..98e336764ff50 --- /dev/null +++ b/course/amd/build/local/activitychooser/selectors.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../../../src/local/activitychooser/selectors.js"],"names":["getDataSelector","name","value","regions","chooser","chooserOptions","chooserOption","container","actions","info","chooserSummary","content","description","carousel","help","modules","getModuleSelector","modname","optionActions","showSummary","addChooser","closeOption","hide","elements","section","sectionmodchooser","sitemenu","sitetopic"],"mappings":"gKA+BMA,CAAAA,CAAe,CAAG,SAACC,CAAD,CAAOC,CAAP,CAAiB,CACrC,sBAAgBD,CAAhB,eAAyBC,CAAzB,OACH,C,GAEc,CACXC,OAAO,CAAE,CACLC,OAAO,CAAEJ,CAAe,CAAC,QAAD,CAAW,mBAAX,CADnB,CAELK,cAAc,CAAEL,CAAe,CAAC,QAAD,CAAW,2BAAX,CAF1B,CAGLM,aAAa,CAAE,CACXC,SAAS,CAAEP,CAAe,CAAC,QAAD,CAAW,0BAAX,CADf,CAEXQ,OAAO,CAAER,CAAe,CAAC,QAAD,CAAW,kCAAX,CAFb,CAGXS,IAAI,CAAET,CAAe,CAAC,QAAD,CAAW,+BAAX,CAHV,CAHV,CAQLU,cAAc,CAAE,CACZH,SAAS,CAAEP,CAAe,CAAC,QAAD,CAAW,kCAAX,CADd,CAEZW,OAAO,CAAEX,CAAe,CAAC,QAAD,CAAW,0CAAX,CAFZ,CAGZY,WAAW,CAAEZ,CAAe,CAAC,QAAD,CAAW,qBAAX,CAHhB,CAIZQ,OAAO,CAAER,CAAe,CAAC,QAAD,CAAW,0CAAX,CAJZ,CARX,CAcLa,QAAQ,CAAEb,CAAe,CAAC,QAAD,CAAW,UAAX,CAdpB,CAeLc,IAAI,CAAEd,CAAe,CAAC,QAAD,CAAW,MAAX,CAfhB,CAgBLe,OAAO,CAAEf,CAAe,CAAC,QAAD,CAAW,SAAX,CAhBnB,CAiBLgB,iBAAiB,CAAE,2BAAAC,CAAO,qDAAuCA,CAAvC,QAjBrB,CADE,CAoBXT,OAAO,CAAE,CACLU,aAAa,CAAE,CACXC,WAAW,CAAEnB,CAAe,CAAC,QAAD,CAAW,qBAAX,CADjB,CADV,CAILoB,UAAU,CAAEpB,CAAe,CAAC,QAAD,CAAW,oBAAX,CAJtB,CAKLqB,WAAW,CAAErB,CAAe,CAAC,QAAD,CAAW,8BAAX,CALvB,CAMLsB,IAAI,CAAEtB,CAAe,CAAC,QAAD,CAAW,MAAX,CANhB,CApBE,CA4BXuB,QAAQ,CAAE,CACNC,OAAO,CAAE,UADH,CAENC,iBAAiB,CAAE,gCAFb,CAGNC,QAAQ,CAAE,uBAHJ,CAINC,SAAS,CAAE,eAJL,CA5BC,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 * Define all of the selectors we will be using on the grading interface.\n *\n * @module core_course/local/chooser/selectors\n * @package core_course\n * @copyright 2019 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/**\n * A small helper function to build queryable data selectors.\n * @method getDataSelector\n * @param {String} name\n * @param {String} value\n * @return {string}\n */\nconst getDataSelector = (name, value) => {\n return `[data-${name}=\"${value}\"]`;\n};\n\nexport default {\n regions: {\n chooser: getDataSelector('region', 'chooser-container'),\n chooserOptions: getDataSelector('region', 'chooser-options-container'),\n chooserOption: {\n container: getDataSelector('region', 'chooser-option-container'),\n actions: getDataSelector('region', 'chooser-option-actions-container'),\n info: getDataSelector('region', 'chooser-option-info-container'),\n },\n chooserSummary: {\n container: getDataSelector('region', 'chooser-option-summary-container'),\n content: getDataSelector('region', 'chooser-option-summary-content-container'),\n description: getDataSelector('region', 'summary-description'),\n actions: getDataSelector('region', 'chooser-option-summary-actions-container'),\n },\n carousel: getDataSelector('region', 'carousel'),\n help: getDataSelector('region', 'help'),\n modules: getDataSelector('region', 'modules'),\n getModuleSelector: modname => `[role=\"menuitem\"][data-modname=\"${modname}\"]`\n },\n actions: {\n optionActions: {\n showSummary: getDataSelector('action', 'show-option-summary'),\n },\n addChooser: getDataSelector('action', 'add-chooser-option'),\n closeOption: getDataSelector('action', 'close-chooser-option-summary'),\n hide: getDataSelector('action', 'hide')\n },\n elements: {\n section: '.section',\n sectionmodchooser: 'button.section-modchooser-link',\n sitemenu: '.block_site_main_menu',\n sitetopic: 'div.sitetopic',\n },\n};\n"],"file":"selectors.min.js"} \ No newline at end of file diff --git a/course/amd/src/activitychooser.js b/course/amd/src/activitychooser.js new file mode 100644 index 0000000000000..6f6613bc9d2ab --- /dev/null +++ b/course/amd/src/activitychooser.js @@ -0,0 +1,148 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * A type of dialogue used as for choosing modules in a course. + * + * @module core_course/activitychooser + * @package core_course + * @copyright 2020 Mathew May + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import * as ChooserDialogue from 'core_course/local/activitychooser/dialogue'; +import * as Repository from 'core_course/local/activitychooser/repository'; +import selectors from 'core_course/local/activitychooser/selectors'; +import CustomEvents from 'core/custom_interaction_events'; +import * as Templates from 'core/templates'; +import * as ModalFactory from 'core/modal_factory'; +import {get_string as getString} from 'core/str'; +import Pending from 'core/pending'; + +/** + * Set up the activity chooser. + * + * @method init + * @param {Number} courseId Course ID to use later on in fetchModules() + */ +export const init = courseId => { + const pendingPromise = new Pending(); + + registerListenerEvents(courseId); + + pendingPromise.resolve(); +}; + +/** + * Once a selection has been made make the modal & module information and pass it along + * + * @method registerListenerEvents + * @param {Number} courseId + */ +const registerListenerEvents = (courseId) => { + const events = [ + 'click', + CustomEvents.events.activate, + CustomEvents.events.keyboardActivate + ]; + + const fetchModuleData = (() => { + let innerPromise = null; + + return () => { + if (!innerPromise) { + innerPromise = new Promise((resolve) => { + resolve(Repository.activityModules(courseId)); + }); + } + + return innerPromise; + }; + })(); + + CustomEvents.define(document, events); + + // Display module chooser event listeners. + events.forEach((event) => { + document.addEventListener(event, async(e) => { + if (e.target.closest(selectors.elements.sectionmodchooser)) { + const caller = e.target.closest(selectors.elements.sectionmodchooser); + const builtModuleData = sectionIdMapper(await fetchModuleData(), caller.dataset.sectionid); + const sectionModal = await modalBuilder(builtModuleData); + + ChooserDialogue.displayChooser(caller, sectionModal, builtModuleData); + } + }); + }); +}; + +/** + * Given the web service data and an ID we want to make a deep copy + * of the WS data then add on the section ID to the addoption URL + * + * @method sectionIdMapper + * @param {Object} webServiceData Our original data from the Web service call + * @param {Array} id The ID of the section we need to append to the links + * @return {Array} [modules] with URL's built + */ +const sectionIdMapper = (webServiceData, id) => { + // We need to take a fresh deep copy of the original data as an object is a reference type. + const newData = JSON.parse(JSON.stringify(webServiceData)); + newData.allmodules.forEach((module) => { + module.urls.addoption += '§ion=' + id; + }); + return newData.allmodules; +}; + +/** + * Build a modal for each section ID and store it into a map for quick access + * + * @method modalBuilder + * @param {Map} data our map of section ID's & modules to generate modals for + * @return {Object} TODO + */ +const modalBuilder = data => buildModal(templateDataBuilder(data)); + +/** + * Given an array of modules we want to figure out where & how to place them into our template object + * + * @method templateDataBuilder + * @param {Array} data our modules to manipulate into a Templatable object + * @return {Object} Our built object ready to render out + */ +const templateDataBuilder = (data) => { + return { + 'default': data, + }; +}; + +/** + * Given an object we want to prebuild a modal ready to store into a map + * + * @method buildModal + * @param {Object} data The template data which contains arrays of modules + * @return {Object} The modal for the calling section with everything already set up + */ +const buildModal = data => { + return ModalFactory.create({ + type: ModalFactory.types.DEFAULT, + title: getString('addresourceoractivity'), + body: Templates.render('core_course/chooser', data), + large: true, + templateContext: { + classes: 'modchooser' + } + }); +}; diff --git a/course/amd/src/local/activitychooser/dialogue.js b/course/amd/src/local/activitychooser/dialogue.js new file mode 100644 index 0000000000000..4be43417dd4ee --- /dev/null +++ b/course/amd/src/local/activitychooser/dialogue.js @@ -0,0 +1,287 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * A type of dialogue used as for choosing options. + * + * @module core_course/local/chooser/dialogue + * @package core + * @copyright 2019 Mihail Geshoski + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import $ from 'jquery'; +import * as ModalEvents from 'core/modal_events'; +import selectors from 'core_course/local/activitychooser/selectors'; +import * as Templates from 'core/templates'; +import {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes'; +import {addIconToContainer} from 'core/loadingicon'; + +/** + * Given an event from the main module 'page' navigate to it's help section via a carousel. + * + * @method showModuleHelp + * @param {jQuery} carousel Our initialized carousel to manipulate + * @param {Object} moduleData Data of the module to carousel to + */ +const showModuleHelp = (carousel, moduleData) => { + const help = carousel.find(selectors.regions.help)[0]; + help.innerHTML = ''; + + // Add a spinner. + const spinnerPromise = addIconToContainer(help); + + // Used later... + let transitionPromiseResolver = null; + const transitionPromise = new Promise(resolve => { + transitionPromiseResolver = resolve; + }); + + // Build up the html & js ready to place into the help section. + const contentPromise = Templates.renderForPromise('core_course/chooser_help', moduleData); + + // Wait for the content to be ready, and for the transition to be complet. + Promise.all([contentPromise, spinnerPromise, transitionPromise]) + .then(([{html, js}]) => Templates.replaceNodeContents(help, html, js)) + .then(() => { + help.querySelector(selectors.regions.chooserSummary.description).focus(); + return help; + }) + .catch(Notification.exception); + + // Move to the next slide, and resolve the transition promise when it's done. + carousel.one('slid.bs.carousel', () => { + transitionPromiseResolver(); + }); + // Trigger the transition between 'pages'. + carousel.carousel('next'); +}; + +/** + * Register chooser related event listeners. + * + * @method registerListenerEvents + * @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} + */ +const registerListenerEvents = (modal, mappedModules) => { + const bodyClickListener = e => { + if (e.target.closest(selectors.actions.optionActions.showSummary)) { + const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel)); + + const module = e.target.closest(selectors.regions.chooserOption.container); + const moduleName = module.dataset.modname; + const moduleData = mappedModules.get(moduleName); + showModuleHelp(carousel, moduleData); + } + + // From the help screen go back to the module overview. + if (e.target.matches(selectors.actions.closeOption)) { + const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel)); + + // Trigger the transition between 'pages'. + carousel.carousel('prev'); + carousel.on('slid.bs.carousel', () => { + const allModules = modal.getBody()[0].querySelector(selectors.regions.modules); + const caller = allModules.querySelector(selectors.regions.getModuleSelector(e.target.dataset.modname)); + caller.focus(); + }); + } + }; + + modal.getBodyPromise() + + // The return value of getBodyPromise is a jquery object containing the body NodeElement. + .then(body => body[0]) + + // Set up the carousel. + .then(body => { + $(body.querySelector(selectors.regions.carousel)) + .carousel({ + interval: false, + pause: true, + keyboard: false + }); + + return body; + }) + + // Add the listener for clicks on the body. + .then(body => { + body.addEventListener('click', bodyClickListener); + return body; + }) + + // Register event listeners related to the keyboard navigation controls. + .then(body => { + initKeyboardNavigation(body, mappedModules); + return body; + }) + .catch(); + +}; + +/** + * Initialise the keyboard navigation controls for the chooser. + * + * @method initKeyboardNavigation + * @param {NodeElement} 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} + */ +const initKeyboardNavigation = (body, mappedModules) => { + + const chooserOptions = body.querySelectorAll(selectors.regions.chooserOption.container); + + Array.from(chooserOptions).forEach((element) => { + return element.addEventListener('keyup', (e) => { + const chooserOptions = document.querySelector(selectors.regions.chooserOptions); + + // Check for enter/ space triggers for showing the help. + if (e.keyCode === enter || e.keyCode === space) { + if (e.target.matches(selectors.actions.optionActions.showSummary)) { + e.preventDefault(); + const module = e.target.closest(selectors.regions.chooserOption.container); + const moduleName = module.dataset.modname; + const moduleData = mappedModules.get(moduleName); + const carousel = $(body.querySelector(selectors.regions.carousel)); + carousel.carousel({ + interval: false, + pause: true, + keyboard: false + }); + showModuleHelp(carousel, moduleData); + } + } + + // Next. + if (e.keyCode === arrowRight) { + e.preventDefault(); + const currentOption = e.target.closest(selectors.regions.chooserOption.container); + const nextOption = currentOption.nextElementSibling; + const firstOption = chooserOptions.firstElementChild; + const toFocusOption = clickErrorHandler(nextOption, firstOption); + focusChooserOption(toFocusOption, currentOption); + } + + // Previous. + if (e.keyCode === arrowLeft) { + e.preventDefault(); + const currentOption = e.target.closest(selectors.regions.chooserOption.container); + const previousOption = currentOption.previousElementSibling; + const lastOption = chooserOptions.lastElementChild; + const toFocusOption = clickErrorHandler(previousOption, lastOption); + focusChooserOption(toFocusOption, currentOption); + } + + if (e.keyCode === home) { + e.preventDefault(); + const currentOption = e.target.closest(selectors.regions.chooserOption.container); + const firstOption = chooserOptions.firstElementChild; + focusChooserOption(firstOption, currentOption); + } + + if (e.keyCode === end) { + e.preventDefault(); + const currentOption = e.target.closest(selectors.regions.chooserOption.container); + const lastOption = chooserOptions.lastElementChild; + focusChooserOption(lastOption, currentOption); + } + }); + }); +}; + +/** + * Focus on a chooser option element and remove the previous chooser element from the focus order + * + * @method focusChooserOption + * @param {HTMLElement} currentChooserOption The current chooser option element that we want to focus + * @param {HTMLElement} previousChooserOption The previous focused option element + */ +const focusChooserOption = (currentChooserOption, previousChooserOption = false) => { + if (previousChooserOption !== false) { + const previousChooserOptionLink = previousChooserOption.querySelector(selectors.actions.addChooser); + const previousChooserOptionHelp = previousChooserOption.querySelector(selectors.actions.optionActions.showSummary); + // Set tabindex to -1 to remove the previous chooser option element from the focus order. + previousChooserOption.tabIndex = -1; + previousChooserOptionLink.tabIndex = -1; + previousChooserOptionHelp.tabIndex = -1; + } + + const currentChooserOptionLink = currentChooserOption.querySelector(selectors.actions.addChooser); + const currentChooserOptionHelp = currentChooserOption.querySelector(selectors.actions.optionActions.showSummary); + // Set tabindex to 0 to add current chooser option element to the focus order. + currentChooserOption.tabIndex = 0; + currentChooserOptionLink.tabIndex = 0; + currentChooserOptionHelp.tabIndex = 0; + // Focus the current chooser option element. + currentChooserOption.focus(); +}; + +/** + * Small error handling function to make sure the navigated to object exists + * + * @method clickErrorHandler + * @param {HTMLElement} item What we want to check exists + * @param {HTMLElement} fallback If we dont match anything fallback the focus + * @return {String} + */ +const clickErrorHandler = (item, fallback) => { + if (item !== null) { + return item; + } else { + return fallback; + } +}; + +/** + * Display the module chooser. + * + * @method displayChooser + * @param {HTMLElement} origin The calling button + * @param {Object} modal Our created modal for the section + * @param {Array} sectionModules An array of all of the built module information + */ +export const displayChooser = (origin, modal, sectionModules) => { + + // 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) => { + mappedModules.set(module.modulename, module); + }); + + // Register event listeners. + registerListenerEvents(modal, mappedModules); + + // We want to focus on the action select when the dialog is closed. + modal.getRoot().on(ModalEvents.hidden, () => { + modal.destroy(); + }); + + // We want to focus on the first chooser option element as soon as the modal is opened. + modal.getRoot().on(ModalEvents.shown, () => { + modal.getModal()[0].tabIndex = -1; + + modal.getBodyPromise() + .then(body => { + const firstChooserOption = body[0].querySelector(selectors.regions.chooserOption.container); + focusChooserOption(firstChooserOption); + + return; + }) + .catch(Notification.exception); + }); + + modal.show(); +}; diff --git a/course/amd/src/local/activitychooser/repository.js b/course/amd/src/local/activitychooser/repository.js new file mode 100644 index 0000000000000..1e6f1a501879c --- /dev/null +++ b/course/amd/src/local/activitychooser/repository.js @@ -0,0 +1,40 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * + * @module core_course/repository + * @package core_course + * @copyright 2019 Mathew May + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +import ajax from 'core/ajax'; + +/** + * Fetch all the information on modules we'll need in the activity chooser. + * + * @method activityModules + * @param {Number} courseid What course to fetch the modules for + * @return {object} jQuery promise + */ +export const activityModules = (courseid) => { + const request = { + methodname: 'core_course_get_activity_picker_info', + args: { + courseid: courseid, + }, + }; + return ajax.call([request])[0]; +}; diff --git a/course/amd/src/local/activitychooser/selectors.js b/course/amd/src/local/activitychooser/selectors.js new file mode 100644 index 0000000000000..adeb07f2961fa --- /dev/null +++ b/course/amd/src/local/activitychooser/selectors.js @@ -0,0 +1,70 @@ +// 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 on the grading interface. + * + * @module core_course/local/chooser/selectors + * @package core_course + * @copyright 2019 Mathew May + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +/** + * A small helper function to build queryable data selectors. + * @method getDataSelector + * @param {String} name + * @param {String} value + * @return {string} + */ +const getDataSelector = (name, value) => { + return `[data-${name}="${value}"]`; +}; + +export default { + regions: { + chooser: getDataSelector('region', 'chooser-container'), + chooserOptions: getDataSelector('region', 'chooser-options-container'), + chooserOption: { + container: getDataSelector('region', 'chooser-option-container'), + actions: getDataSelector('region', 'chooser-option-actions-container'), + info: getDataSelector('region', 'chooser-option-info-container'), + }, + chooserSummary: { + container: getDataSelector('region', 'chooser-option-summary-container'), + content: getDataSelector('region', 'chooser-option-summary-content-container'), + description: getDataSelector('region', 'summary-description'), + actions: getDataSelector('region', 'chooser-option-summary-actions-container'), + }, + carousel: getDataSelector('region', 'carousel'), + help: getDataSelector('region', 'help'), + modules: getDataSelector('region', 'modules'), + getModuleSelector: modname => `[role="menuitem"][data-modname="${modname}"]` + }, + actions: { + optionActions: { + showSummary: getDataSelector('action', 'show-option-summary'), + }, + addChooser: getDataSelector('action', 'add-chooser-option'), + closeOption: getDataSelector('action', 'close-chooser-option-summary'), + hide: getDataSelector('action', 'hide') + }, + elements: { + section: '.section', + sectionmodchooser: 'button.section-modchooser-link', + sitemenu: '.block_site_main_menu', + sitetopic: 'div.sitetopic', + }, +}; diff --git a/course/externallib.php b/course/externallib.php index b39acfe3a0f17..c92ad974e13e3 100644 --- a/course/externallib.php +++ b/course/externallib.php @@ -4140,4 +4140,86 @@ public static function user_description() { ); return new external_single_structure($userfields); } + + /** + * Returns description of method result value + * + * @return external_description + */ + public static function fetch_modules_activity_chooser_returns() { + return new external_single_structure([ + 'allmodules' => new external_multiple_structure( + new external_single_structure([ + 'label' => new external_value(PARAM_TEXT, 'Human readable module name', VALUE_OPTIONAL), + 'modulename' => new external_value(PARAM_TEXT, 'Module name', VALUE_OPTIONAL), + 'description' => new external_value(PARAM_RAW, 'Help panel information', VALUE_OPTIONAL), + 'urls' => new external_single_structure([ + 'addoption' => new external_value(PARAM_URL, 'The edit link for the module', VALUE_OPTIONAL), + ]), + 'icon' => new external_single_structure([ + 'attributes' => new external_multiple_structure( + new external_single_structure([ + 'name' => new external_value(PARAM_RAW, 'HTML attr', VALUE_OPTIONAL), + 'value' => new external_value(PARAM_RAW, 'Value of the HTML attr', VALUE_OPTIONAL), + ]) + ), + 'extraclasses' => new external_value(PARAM_RAW, 'Anything extra the module defines', VALUE_OPTIONAL), + ]), + ]) + ), + 'warnings' => new external_warnings() + ]); + } + + /** + * Returns description of method parameters + * + * @return external_function_parameters + */ + public static function fetch_modules_activity_chooser_parameters() { + return new external_function_parameters([ + 'courseid' => new external_value(PARAM_INT, 'ID of the course', VALUE_REQUIRED), + ]); + } + + /** + * Given a course ID fetch all accessible modules for that course + * + * @param int $courseid The course we want to fetch the modules for + * @return array Contains array of modules and their metadata + * @throws moodle_exception + */ + public static function fetch_modules_activity_chooser(int $courseid) { + global $DB, $OUTPUT; + [ + 'courseid' => $courseid, + ] = self::validate_parameters(self::fetch_modules_activity_chooser_parameters(), [ + 'courseid' => $courseid, + ]); + $warnings = array(); + + // Validate the course context. + $coursecontext = context_course::instance($courseid); + self::validate_context($coursecontext); + // Check to see if user can add menus and there are modules to add. + if (!has_capability('moodle/course:manageactivities', $coursecontext) + || !($modnames = get_module_types_names()) || empty($modnames)) { + return ''; + } + + $course = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST); + // Retrieve all modules with associated metadata. + $modules = get_module_metadata($course, $modnames, null); + $related = [ + 'context' => $coursecontext + ]; + // Export the module chooser data. + $modchooserdata = new \core_course\external\course_module_chooser_exporter($modules, $related); + + $result = []; + $result['allmodules'] = $modchooserdata->export($OUTPUT)->options; + $result['warnings'] = $warnings; + return $result; + } + } diff --git a/course/renderer.php b/course/renderer.php index b02df933065f5..83a329aaf3080 100644 --- a/course/renderer.php +++ b/course/renderer.php @@ -142,11 +142,8 @@ public function render_modchooser(renderable $modchooser) { */ public function course_modchooser($modules, $course) { debugging('course_modchooser() is deprecated. Please use course_activitychooser() instead.', DEBUG_DEVELOPER); - if (!$this->page->requires->should_create_one_time_item_now('core_course_modchooser')) { - return ''; - } - $modchooser = new \core_course\output\modchooser($course, $modules); - return $this->render($modchooser); + + return $this->course_activitychooser($course->id); } /** @@ -161,7 +158,7 @@ public function course_activitychooser($courseid) { return ''; } - $this->page->requires->js_call_amd('core_course/modchooser', 'init', [$courseid]); + $this->page->requires->js_call_amd('core_course/activitychooser', 'init', [$courseid]); return ''; } @@ -345,7 +342,6 @@ function course_section_add_cm_control($course, $section, $sectionreturn = null, 'class' => 'section-modchooser-link btn btn-link', 'data-action' => 'open-chooser', 'data-sectionid' => $section, - 'disabled' => true ) ); $modchooser.= html_writer::end_tag('div'); diff --git a/course/templates/chooser.mustache b/course/templates/chooser.mustache new file mode 100644 index 0000000000000..c503c9bb06ebc --- /dev/null +++ b/course/templates/chooser.mustache @@ -0,0 +1,48 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_course/chooser + + Chooser dialog template. + + Example context (json): + { + "title": "Chooser title", + "options": { + "label": "Option name", + "description": "Option description", + "urls": { + "addoption": "http://addoptionurl.com" + }, + "icon": "" + } + } +}} + diff --git a/course/templates/chooser_help.mustache b/course/templates/chooser_help.mustache new file mode 100644 index 0000000000000..b63502b843463 --- /dev/null +++ b/course/templates/chooser_help.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 core_course/chooser_help + + Chooser help / more information template. + + Example context (json): + { + "label": "Option name", + "description": "Option description", + "urls": { + "addoption": "http://addoptionurl.com" + }, + "icon": "" + } +}} +
+
+
+
+ {{#icon}} + {{>core/pix_icon}} + {{/icon}} + {{label}} +
+
+
+ {{{description}}} +
+
+
+ + + {{#str}} add {{/str}} + +
+
diff --git a/course/templates/chooser_item.mustache b/course/templates/chooser_item.mustache new file mode 100644 index 0000000000000..f8507561689b8 --- /dev/null +++ b/course/templates/chooser_item.mustache @@ -0,0 +1,49 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_course/chooser_item + + Chooser item template. + + Example context (json): + { + "label": "Option name", + "description": "Option description", + "urls": { + "addoption": "http://addoptionurl.com" + }, + "icon": "" + } +}} + diff --git a/course/templates/modchooser.mustache b/course/templates/modchooser.mustache deleted file mode 100644 index 88cf99c4f6803..0000000000000 --- a/course/templates/modchooser.mustache +++ /dev/null @@ -1,38 +0,0 @@ -{{! - This file is part of Moodle - http://moodle.org/ - - Moodle is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Moodle is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Moodle. If not, see . -}} -{{! - Course module chooser. -}} -{{> core/chooser }} -{{#js}} -require([ - 'core/yui', - 'core/str' -], function(Y, Str) { - Str.get_strings([ - { key: 'addresourceoractivity', component: 'moodle' }, - { key: 'close', component: 'editor' }, - ]).then(function(add, close) { - Y.use('moodle-course-modchooser', function() { - M.course.init_chooser({ - courseid: {{courseid}}, - closeButtonTitle: close - }); - }); - }); -}); -{{/js}} diff --git a/course/tests/externallib_test.php b/course/tests/externallib_test.php index 67611e5978650..b2f1671c7ff72 100644 --- a/course/tests/externallib_test.php +++ b/course/tests/externallib_test.php @@ -3049,4 +3049,39 @@ public function test_get_enrolled_users_by_cmid() { $this->assertEquals(2, count($users['users'])); $this->assertEquals($expectedusers, $users); } + + /** + * Test fetch_modules_activity_chooser + */ + public function test_fetch_modules_activity_chooser() { + global $OUTPUT; + + $this->resetAfterTest(true); + + // Log in as Admin. + $this->setAdminUser(); + + $course1 = self::getDataGenerator()->create_course(); + + // Fetch course modules. + $result = core_course_external::fetch_modules_activity_chooser($course1->id); + $result = external_api::clean_returnvalue(core_course_external::fetch_modules_activity_chooser_returns(), $result); + // Check for 0 warnings. + $this->assertEquals(0, count($result['warnings'])); + // Check we have the right number of standard modules. + $this->assertEquals(21, count($result['allmodules'])); + + $coursecontext = context_course::instance($course1->id); + $modnames = get_module_types_names(); + $modules = get_module_metadata($course1, $modnames, null); + $related = [ + 'context' => $coursecontext + ]; + // Export the module chooser data. + $modchooserdata = new \core_course\external\course_module_chooser_exporter($modules, $related); + $formatteddata = $modchooserdata->export($OUTPUT)->options; + + // Check if the webservice returns exactly what the exporter defines. + $this->assertEquals($formatteddata, $result['allmodules']); + } } diff --git a/course/yui/build/moodle-course-modchooser/moodle-course-modchooser-debug.js b/course/yui/build/moodle-course-modchooser/moodle-course-modchooser-debug.js deleted file mode 100644 index ad86243e7b5c0..0000000000000 --- a/course/yui/build/moodle-course-modchooser/moodle-course-modchooser-debug.js +++ /dev/null @@ -1,169 +0,0 @@ -YUI.add('moodle-course-modchooser', function (Y, NAME) { - -/** - * The activity chooser dialogue for courses. - * - * @module moodle-course-modchooser - */ - -var CSS = { - PAGECONTENT: 'body', - SECTION: null, - SECTIONMODCHOOSER: 'button.section-modchooser-link', - SITEMENU: '.block_site_main_menu', - SITETOPIC: 'div.sitetopic' -}; - -var MODCHOOSERNAME = 'course-modchooser'; - -/** - * The activity chooser dialogue for courses. - * - * @constructor - * @class M.course.modchooser - * @extends M.core.chooserdialogue - */ -var MODCHOOSER = function() { - MODCHOOSER.superclass.constructor.apply(this, arguments); -}; - -Y.extend(MODCHOOSER, M.core.chooserdialogue, { - /** - * The current section ID. - * - * @property sectionid - * @private - * @type Number - * @default null - */ - sectionid: null, - - /** - * Set up the activity chooser. - * - * @method initializer - */ - initializer: function() { - var sectionclass = M.course.format.get_sectionwrapperclass(); - if (sectionclass) { - CSS.SECTION = '.' + sectionclass; - } - var dialogue = Y.one('.chooserdialoguebody'); - var header = Y.one('.choosertitle'); - var params = {}; - this.setup_chooser_dialogue(dialogue, header, params); - - // Initialize existing sections and register for dynamically created sections - this.setup_for_section(); - M.course.coursebase.register_module(this); - }, - - /** - * Update any section areas within the scope of the specified - * selector with AJAX equivalents - * - * @method setup_for_section - * @param baseselector The selector to limit scope to - */ - setup_for_section: function(baseselector) { - if (!baseselector) { - baseselector = CSS.PAGECONTENT; - } - - // Setup for site topics - Y.one(baseselector).all(CSS.SITETOPIC).each(function(section) { - this._setup_for_section(section); - }, this); - - // Setup for standard course topics - if (CSS.SECTION) { - Y.one(baseselector).all(CSS.SECTION).each(function(section) { - this._setup_for_section(section); - }, this); - } - - // Setup for the block site menu - Y.one(baseselector).all(CSS.SITEMENU).each(function(section) { - this._setup_for_section(section); - }, this); - }, - - /** - * Update any section areas within the scope of the specified - * selector with AJAX equivalents - * - * @method _setup_for_section - * @private - * @param baseselector The selector to limit scope to - */ - _setup_for_section: function(section) { - var chooserspan = section.one(CSS.SECTIONMODCHOOSER); - if (!chooserspan) { - return; - } - var chooserlink = Y.Node.create(""); - chooserspan.get('children').each(function(node) { - chooserlink.appendChild(node); - }); - chooserspan.insertBefore(chooserlink); - chooserlink.on('click', this.display_mod_chooser, this); - }, - /** - * Display the module chooser - * - * @method display_mod_chooser - * @param {EventFacade} e Triggering Event - */ - display_mod_chooser: function(e) { - // Set the section for this version of the dialogue - if (e.target.ancestor(CSS.SITETOPIC)) { - // The site topic has a sectionid of 1 - this.sectionid = 1; - } else if (e.target.ancestor(CSS.SECTION)) { - var section = e.target.ancestor(CSS.SECTION); - this.sectionid = section.get('id').replace('section-', ''); - } else if (e.target.ancestor(CSS.SITEMENU)) { - // The block site menu has a sectionid of 0 - this.sectionid = 0; - } - this.display_chooser(e); - }, - - /** - * Helper function to set the value of a hidden radio button when a - * selection is made. - * - * @method option_selected - * @param {String} thisoption The selected option value - * @private - */ - option_selected: function(thisoption) { - // Add the sectionid to the URL. - this.hiddenRadioValue.setAttrs({ - name: 'jump', - value: thisoption.get('value') + '§ion=' + this.sectionid - }); - } -}, -{ - NAME: MODCHOOSERNAME, - ATTRS: { - /** - * The maximum height (in pixels) of the activity chooser. - * - * @attribute maxheight - * @type Number - * @default 800 - */ - maxheight: { - value: 800 - } - } -}); -M.course = M.course || {}; -M.course.init_chooser = function(config) { - return new MODCHOOSER(config); -}; - - -}, '@VERSION@', {"requires": ["moodle-core-chooserdialogue", "moodle-course-coursebase"]}); diff --git a/course/yui/build/moodle-course-modchooser/moodle-course-modchooser-min.js b/course/yui/build/moodle-course-modchooser/moodle-course-modchooser-min.js deleted file mode 100644 index 785cf357db22d..0000000000000 --- a/course/yui/build/moodle-course-modchooser/moodle-course-modchooser-min.js +++ /dev/null @@ -1 +0,0 @@ -YUI.add("moodle-course-modchooser",function(e,t){var n={PAGECONTENT:"body",SECTION:null,SECTIONMODCHOOSER:"button.section-modchooser-link",SITEMENU:".block_site_main_menu",SITETOPIC:"div.sitetopic"},r="course-modchooser",i=function(){i.superclass.constructor.apply(this,arguments)};e.extend(i,M.core.chooserdialogue,{sectionid:null,initializer:function(){var t=M.course.format.get_sectionwrapperclass();t&&(n.SECTION="."+t);var r=e.one(".chooserdialoguebody"),i=e.one(".choosertitle"),s={};this.setup_chooser_dialogue(r,i,s),this.setup_for_section(),M.course.coursebase.register_module(this)},setup_for_section:function(t){t||(t=n.PAGECONTENT),e.one(t).all(n.SITETOPIC).each(function(e){this._setup_for_section(e)},this),n.SECTION&&e.one(t).all(n.SECTION).each(function(e){this._setup_for_section(e)},this),e.one(t).all(n.SITEMENU).each(function(e){this._setup_for_section(e)},this)},_setup_for_section:function(t){var r=t.one(n.SECTIONMODCHOOSER);if(!r)return;var i=e.Node.create("");r.get("children").each(function(e){i.appendChild(e)}),r.insertBefore(i),i.on("click",this.display_mod_chooser,this)},display_mod_chooser:function(e){if(e.target.ancestor(n.SITETOPIC))this.sectionid=1;else if(e.target.ancestor(n.SECTION)){var t=e.target.ancestor(n.SECTION);this.sectionid=t.get("id").replace("section-","")}else e.target.ancestor(n.SITEMENU)&&(this.sectionid=0);this.display_chooser(e)},option_selected:function(e){this.hiddenRadioValue.setAttrs({name:"jump",value:e.get("value")+"§ion="+this.sectionid})}},{NAME:r,ATTRS:{maxheight:{value:800}}}),M.course=M.course||{},M.course.init_chooser=function(e){return new i(e)}},"@VERSION@",{requires:["moodle-core-chooserdialogue","moodle-course-coursebase"]}); diff --git a/course/yui/build/moodle-course-modchooser/moodle-course-modchooser.js b/course/yui/build/moodle-course-modchooser/moodle-course-modchooser.js deleted file mode 100644 index ad86243e7b5c0..0000000000000 --- a/course/yui/build/moodle-course-modchooser/moodle-course-modchooser.js +++ /dev/null @@ -1,169 +0,0 @@ -YUI.add('moodle-course-modchooser', function (Y, NAME) { - -/** - * The activity chooser dialogue for courses. - * - * @module moodle-course-modchooser - */ - -var CSS = { - PAGECONTENT: 'body', - SECTION: null, - SECTIONMODCHOOSER: 'button.section-modchooser-link', - SITEMENU: '.block_site_main_menu', - SITETOPIC: 'div.sitetopic' -}; - -var MODCHOOSERNAME = 'course-modchooser'; - -/** - * The activity chooser dialogue for courses. - * - * @constructor - * @class M.course.modchooser - * @extends M.core.chooserdialogue - */ -var MODCHOOSER = function() { - MODCHOOSER.superclass.constructor.apply(this, arguments); -}; - -Y.extend(MODCHOOSER, M.core.chooserdialogue, { - /** - * The current section ID. - * - * @property sectionid - * @private - * @type Number - * @default null - */ - sectionid: null, - - /** - * Set up the activity chooser. - * - * @method initializer - */ - initializer: function() { - var sectionclass = M.course.format.get_sectionwrapperclass(); - if (sectionclass) { - CSS.SECTION = '.' + sectionclass; - } - var dialogue = Y.one('.chooserdialoguebody'); - var header = Y.one('.choosertitle'); - var params = {}; - this.setup_chooser_dialogue(dialogue, header, params); - - // Initialize existing sections and register for dynamically created sections - this.setup_for_section(); - M.course.coursebase.register_module(this); - }, - - /** - * Update any section areas within the scope of the specified - * selector with AJAX equivalents - * - * @method setup_for_section - * @param baseselector The selector to limit scope to - */ - setup_for_section: function(baseselector) { - if (!baseselector) { - baseselector = CSS.PAGECONTENT; - } - - // Setup for site topics - Y.one(baseselector).all(CSS.SITETOPIC).each(function(section) { - this._setup_for_section(section); - }, this); - - // Setup for standard course topics - if (CSS.SECTION) { - Y.one(baseselector).all(CSS.SECTION).each(function(section) { - this._setup_for_section(section); - }, this); - } - - // Setup for the block site menu - Y.one(baseselector).all(CSS.SITEMENU).each(function(section) { - this._setup_for_section(section); - }, this); - }, - - /** - * Update any section areas within the scope of the specified - * selector with AJAX equivalents - * - * @method _setup_for_section - * @private - * @param baseselector The selector to limit scope to - */ - _setup_for_section: function(section) { - var chooserspan = section.one(CSS.SECTIONMODCHOOSER); - if (!chooserspan) { - return; - } - var chooserlink = Y.Node.create(""); - chooserspan.get('children').each(function(node) { - chooserlink.appendChild(node); - }); - chooserspan.insertBefore(chooserlink); - chooserlink.on('click', this.display_mod_chooser, this); - }, - /** - * Display the module chooser - * - * @method display_mod_chooser - * @param {EventFacade} e Triggering Event - */ - display_mod_chooser: function(e) { - // Set the section for this version of the dialogue - if (e.target.ancestor(CSS.SITETOPIC)) { - // The site topic has a sectionid of 1 - this.sectionid = 1; - } else if (e.target.ancestor(CSS.SECTION)) { - var section = e.target.ancestor(CSS.SECTION); - this.sectionid = section.get('id').replace('section-', ''); - } else if (e.target.ancestor(CSS.SITEMENU)) { - // The block site menu has a sectionid of 0 - this.sectionid = 0; - } - this.display_chooser(e); - }, - - /** - * Helper function to set the value of a hidden radio button when a - * selection is made. - * - * @method option_selected - * @param {String} thisoption The selected option value - * @private - */ - option_selected: function(thisoption) { - // Add the sectionid to the URL. - this.hiddenRadioValue.setAttrs({ - name: 'jump', - value: thisoption.get('value') + '§ion=' + this.sectionid - }); - } -}, -{ - NAME: MODCHOOSERNAME, - ATTRS: { - /** - * The maximum height (in pixels) of the activity chooser. - * - * @attribute maxheight - * @type Number - * @default 800 - */ - maxheight: { - value: 800 - } - } -}); -M.course = M.course || {}; -M.course.init_chooser = function(config) { - return new MODCHOOSER(config); -}; - - -}, '@VERSION@', {"requires": ["moodle-core-chooserdialogue", "moodle-course-coursebase"]}); diff --git a/course/yui/src/modchooser/build.json b/course/yui/src/modchooser/build.json deleted file mode 100644 index 569ab7b0b60ba..0000000000000 --- a/course/yui/src/modchooser/build.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "moodle-course-modchooser", - "builds": { - "moodle-course-modchooser": { - "jsfiles": [ - "modchooser.js" - ] - } - } -} diff --git a/course/yui/src/modchooser/js/modchooser.js b/course/yui/src/modchooser/js/modchooser.js deleted file mode 100644 index 989adf700f310..0000000000000 --- a/course/yui/src/modchooser/js/modchooser.js +++ /dev/null @@ -1,164 +0,0 @@ -/** - * The activity chooser dialogue for courses. - * - * @module moodle-course-modchooser - */ - -var CSS = { - PAGECONTENT: 'body', - SECTION: null, - SECTIONMODCHOOSER: 'button.section-modchooser-link', - SITEMENU: '.block_site_main_menu', - SITETOPIC: 'div.sitetopic' -}; - -var MODCHOOSERNAME = 'course-modchooser'; - -/** - * The activity chooser dialogue for courses. - * - * @constructor - * @class M.course.modchooser - * @extends M.core.chooserdialogue - */ -var MODCHOOSER = function() { - MODCHOOSER.superclass.constructor.apply(this, arguments); -}; - -Y.extend(MODCHOOSER, M.core.chooserdialogue, { - /** - * The current section ID. - * - * @property sectionid - * @private - * @type Number - * @default null - */ - sectionid: null, - - /** - * Set up the activity chooser. - * - * @method initializer - */ - initializer: function() { - var sectionclass = M.course.format.get_sectionwrapperclass(); - if (sectionclass) { - CSS.SECTION = '.' + sectionclass; - } - var dialogue = Y.one('.chooserdialoguebody'); - var header = Y.one('.choosertitle'); - var params = {}; - this.setup_chooser_dialogue(dialogue, header, params); - - // Initialize existing sections and register for dynamically created sections - this.setup_for_section(); - M.course.coursebase.register_module(this); - }, - - /** - * Update any section areas within the scope of the specified - * selector with AJAX equivalents - * - * @method setup_for_section - * @param baseselector The selector to limit scope to - */ - setup_for_section: function(baseselector) { - if (!baseselector) { - baseselector = CSS.PAGECONTENT; - } - - // Setup for site topics - Y.one(baseselector).all(CSS.SITETOPIC).each(function(section) { - this._setup_for_section(section); - }, this); - - // Setup for standard course topics - if (CSS.SECTION) { - Y.one(baseselector).all(CSS.SECTION).each(function(section) { - this._setup_for_section(section); - }, this); - } - - // Setup for the block site menu - Y.one(baseselector).all(CSS.SITEMENU).each(function(section) { - this._setup_for_section(section); - }, this); - }, - - /** - * Update any section areas within the scope of the specified - * selector with AJAX equivalents - * - * @method _setup_for_section - * @private - * @param baseselector The selector to limit scope to - */ - _setup_for_section: function(section) { - var chooserspan = section.one(CSS.SECTIONMODCHOOSER); - if (!chooserspan) { - return; - } - var chooserlink = Y.Node.create(""); - chooserspan.get('children').each(function(node) { - chooserlink.appendChild(node); - }); - chooserspan.insertBefore(chooserlink); - chooserlink.on('click', this.display_mod_chooser, this); - }, - /** - * Display the module chooser - * - * @method display_mod_chooser - * @param {EventFacade} e Triggering Event - */ - display_mod_chooser: function(e) { - // Set the section for this version of the dialogue - if (e.target.ancestor(CSS.SITETOPIC)) { - // The site topic has a sectionid of 1 - this.sectionid = 1; - } else if (e.target.ancestor(CSS.SECTION)) { - var section = e.target.ancestor(CSS.SECTION); - this.sectionid = section.get('id').replace('section-', ''); - } else if (e.target.ancestor(CSS.SITEMENU)) { - // The block site menu has a sectionid of 0 - this.sectionid = 0; - } - this.display_chooser(e); - }, - - /** - * Helper function to set the value of a hidden radio button when a - * selection is made. - * - * @method option_selected - * @param {String} thisoption The selected option value - * @private - */ - option_selected: function(thisoption) { - // Add the sectionid to the URL. - this.hiddenRadioValue.setAttrs({ - name: 'jump', - value: thisoption.get('value') + '§ion=' + this.sectionid - }); - } -}, -{ - NAME: MODCHOOSERNAME, - ATTRS: { - /** - * The maximum height (in pixels) of the activity chooser. - * - * @attribute maxheight - * @type Number - * @default 800 - */ - maxheight: { - value: 800 - } - } -}); -M.course = M.course || {}; -M.course.init_chooser = function(config) { - return new MODCHOOSER(config); -}; diff --git a/course/yui/src/modchooser/meta/modchooser.json b/course/yui/src/modchooser/meta/modchooser.json deleted file mode 100644 index 2ef461b2662c9..0000000000000 --- a/course/yui/src/modchooser/meta/modchooser.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "moodle-course-modchooser": { - "requires": [ - "moodle-core-chooserdialogue", - "moodle-course-coursebase" - ] - } -} diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 6c17f58459d83..5010450bcb573 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -43,6 +43,7 @@ $string['activitysince'] = 'Activity since {$a}'; $string['activitytypetitle'] = '{$a->activity} - {$a->type}'; $string['activityweighted'] = 'Activity per user'; +$string['actionsfor'] = 'Actions for {$a}'; $string['add'] = 'Add'; $string['addactivity'] = 'Add an activity...'; $string['addactivitytosection'] = 'Add an activity to section \'{$a}\''; @@ -58,6 +59,7 @@ $string['addedtogroupnotenrolled'] = 'Not added to group "{$a}", because not enrolled in course'; $string['addfilehere'] = 'Add file(s) here'; $string['addinganew'] = 'Adding a new {$a}'; +$string['addnew'] = 'Add a new {$a}'; $string['addinganewto'] = 'Adding a new {$a->what} to {$a->to}'; $string['addingdatatoexisting'] = 'Adding data to existing'; $string['additionalnames'] = 'Additional names'; diff --git a/lib/db/services.php b/lib/db/services.php index de2a20039167a..50422b27bbcf1 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -629,6 +629,14 @@ 'type' => 'read', 'ajax' => true, ), + 'core_course_get_activity_picker_info' => array( + 'classname' => 'core_course_external', + 'methodname' => 'fetch_modules_activity_chooser', + 'classpath' => 'course/externallib.php', + 'description' => 'Fetch all the module information for the activity picker', + 'type' => 'read', + 'ajax' => true, + ), 'core_enrol_get_course_enrolment_methods' => array( 'classname' => 'core_enrol_external', 'methodname' => 'get_course_enrolment_methods', diff --git a/theme/boost/scss/moodle/core.scss b/theme/boost/scss/moodle/core.scss index 2c3ad22db47a3..8fd7052ab0c67 100644 --- a/theme/boost/scss/moodle/core.scss +++ b/theme/boost/scss/moodle/core.scss @@ -1489,11 +1489,6 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { padding-top: 1px; } -.chooserdialogue-course-modchooser .modicon .icon { - width: 24px; - height: 24px; - font-size: 24px; -} @include media-breakpoint-down(xs) { .jsenabled .choosercontainer #chooseform .alloptions { @@ -1506,6 +1501,124 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { } } +/** + * Module chooser dialogue (moodle-core-chooserdialogue) + * + * This CSS belong to the chooser dialogue which should work both with, and + * without javascript enabled + */ +.modchooser .modal-body { + padding: 0; + height: 590px; + overflow-y: auto; + + .loading-icon { + opacity: 1; + .icon { + display: block; + font-size: 3em; + height: 1em; + width: 1em; + margin: 5em auto; + } + } +} + +.modchoosercontainer.noscroll { + overflow-y: hidden; +} + +.modchoosercontainer .optionscontainer { + overflow-x: hidden; + .option { + // Six items per line. + flex-basis: 16%; + .optionactions { + .optionaction { + cursor: pointer; + margin: 0.2rem; + color: $gray-600; + i { + margin: 0; + } + } + } + .optioninfo { + a { + color: $gray-700; + &:hover { + text-decoration: none; + } + .optionname { + margin-top: 0.5em; + } + .optionicon { + .icon { + margin: 0; + padding: 0; + width: 32px; + height: 32px; + font-size: 32px; + } + } + } + } + } +} + +.modchooser .modal-body .optionsummary { + background-color: $white; + overflow-x: hidden; + overflow-y: auto; + line-height: 2em; + height: 590px; + + .content { + overflow-y: auto; + .heading { + .icon { + height: 32px; + width: 32px; + font-size: 32px; + padding: 0; + } + } + } + + .actions { + border-top: 1px solid $gray-300; + background: $white; + } +} + +@include media-breakpoint-down(lg) { + .modchoosercontainer .optionscontainer .option { + // Five items per line. + flex-basis: 20%; + } +} + +@include media-breakpoint-down(xs) { + .path-course-view .modal-dialog.modal-lg, + .path-course-view .modal-content, + .modchooser .modal-body, + .modchooser .modal-body .carousel, + .modchooser .modal-body .carousel-inner, + .modchooser .modal-body .carousel-item, + .modchooser .modal-body .optionsummary, + .modchoosercontainer, + .optionscontainer { + height: 100%; + } + .path-course-view .modal-dialog.modal-lg { + margin: 0; + } + .modchoosercontainer .optionscontainer .option { + // Four items per line. + flex-basis: 25%; + } +} + /* Form element: listing */ .formlistingradio { padding-bottom: 25px; diff --git a/theme/boost/scss/moodle/course.scss b/theme/boost/scss/moodle/course.scss index d068d112def38..57c0b1922e6b5 100644 --- a/theme/boost/scss/moodle/course.scss +++ b/theme/boost/scss/moodle/course.scss @@ -1,10 +1,5 @@ /* course.less */ /* COURSE CONTENT */ -.section-modchooser-link img { - margin-right: 0.5rem; - width: 16px; - height: 16px; -} .section_add_menus { text-align: right; diff --git a/theme/boost/style/moodle.css b/theme/boost/style/moodle.css index bc138fa075cb2..330360e41f1aa 100644 --- a/theme/boost/style/moodle.css +++ b/theme/boost/style/moodle.css @@ -10637,11 +10637,6 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { margin-top: -1px; padding-top: 1px; } -.chooserdialogue-course-modchooser .modicon .icon { - width: 24px; - height: 24px; - font-size: 24px; } - @media (max-width: 575.98px) { .jsenabled .choosercontainer #chooseform .alloptions { max-width: 100%; } @@ -10649,6 +10644,88 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { .jsenabled .choosercontainer #chooseform .typesummary { position: static; } } +/** + * Module chooser dialogue (moodle-core-chooserdialogue) + * + * This CSS belong to the chooser dialogue which should work both with, and + * without javascript enabled + */ +.modchooser .modal-body { + padding: 0; + height: 590px; + overflow-y: auto; } + .modchooser .modal-body .loading-icon { + opacity: 1; } + .modchooser .modal-body .loading-icon .icon { + display: block; + font-size: 3em; + height: 1em; + width: 1em; + margin: 5em auto; } + +.modchoosercontainer.noscroll { + overflow-y: hidden; } + +.modchoosercontainer .optionscontainer { + overflow-x: hidden; } + .modchoosercontainer .optionscontainer .option { + flex-basis: 16%; } + .modchoosercontainer .optionscontainer .option .optionactions .optionaction { + cursor: pointer; + margin: 0.2rem; + color: #868e96; } + .modchoosercontainer .optionscontainer .option .optionactions .optionaction i { + margin: 0; } + .modchoosercontainer .optionscontainer .option .optioninfo a { + color: #495057; } + .modchoosercontainer .optionscontainer .option .optioninfo a:hover { + text-decoration: none; } + .modchoosercontainer .optionscontainer .option .optioninfo a .optionname { + margin-top: 0.5em; } + .modchoosercontainer .optionscontainer .option .optioninfo a .optionicon .icon { + margin: 0; + padding: 0; + width: 32px; + height: 32px; + font-size: 32px; } + +.modchooser .modal-body .optionsummary { + background-color: #fff; + overflow-x: hidden; + overflow-y: auto; + line-height: 2em; + height: 590px; } + .modchooser .modal-body .optionsummary .content { + overflow-y: auto; } + .modchooser .modal-body .optionsummary .content .heading .icon { + height: 32px; + width: 32px; + font-size: 32px; + padding: 0; } + .modchooser .modal-body .optionsummary .actions { + border-top: 1px solid #dee2e6; + background: #fff; } + +@media (max-width: 1199.98px) { + .modchoosercontainer .optionscontainer .option { + flex-basis: 20%; } } + +@media (max-width: 575.98px) { + .path-course-view .modal-dialog.modal-lg, + .path-course-view .modal-content, + .modchooser .modal-body, + .modchooser .modal-body .carousel, + .modchooser .modal-body .carousel-inner, + .modchooser .modal-body .carousel-item, + .modchooser .modal-body .optionsummary, + .modchoosercontainer, + .optionscontainer { + height: 100%; } + .path-course-view .modal-dialog.modal-lg { + margin: 0; } + .modchoosercontainer .optionscontainer .option { + flex-basis: 25%; } } + /* Form element: listing */ .formlistingradio { padding-bottom: 25px; @@ -12421,11 +12498,6 @@ table.calendartable caption { /* course.less */ /* COURSE CONTENT */ -.section-modchooser-link img { - margin-right: 0.5rem; - width: 16px; - height: 16px; } - .section_add_menus { text-align: right; clear: both; } diff --git a/theme/classic/style/moodle.css b/theme/classic/style/moodle.css index fd31a4ed14728..4802c1437a993 100644 --- a/theme/classic/style/moodle.css +++ b/theme/classic/style/moodle.css @@ -10844,11 +10844,6 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { margin-top: -1px; padding-top: 1px; } -.chooserdialogue-course-modchooser .modicon .icon { - width: 24px; - height: 24px; - font-size: 24px; } - @media (max-width: 575.98px) { .jsenabled .choosercontainer #chooseform .alloptions { max-width: 100%; } @@ -10856,6 +10851,88 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview { .jsenabled .choosercontainer #chooseform .typesummary { position: static; } } +/** + * Module chooser dialogue (moodle-core-chooserdialogue) + * + * This CSS belong to the chooser dialogue which should work both with, and + * without javascript enabled + */ +.modchooser .modal-body { + padding: 0; + height: 590px; + overflow-y: auto; } + .modchooser .modal-body .loading-icon { + opacity: 1; } + .modchooser .modal-body .loading-icon .icon { + display: block; + font-size: 3em; + height: 1em; + width: 1em; + margin: 5em auto; } + +.modchoosercontainer.noscroll { + overflow-y: hidden; } + +.modchoosercontainer .optionscontainer { + overflow-x: hidden; } + .modchoosercontainer .optionscontainer .option { + flex-basis: 16%; } + .modchoosercontainer .optionscontainer .option .optionactions .optionaction { + cursor: pointer; + margin: 0.2rem; + color: #868e96; } + .modchoosercontainer .optionscontainer .option .optionactions .optionaction i { + margin: 0; } + .modchoosercontainer .optionscontainer .option .optioninfo a { + color: #495057; } + .modchoosercontainer .optionscontainer .option .optioninfo a:hover { + text-decoration: none; } + .modchoosercontainer .optionscontainer .option .optioninfo a .optionname { + margin-top: 0.5em; } + .modchoosercontainer .optionscontainer .option .optioninfo a .optionicon .icon { + margin: 0; + padding: 0; + width: 32px; + height: 32px; + font-size: 32px; } + +.modchooser .modal-body .optionsummary { + background-color: #fff; + overflow-x: hidden; + overflow-y: auto; + line-height: 2em; + height: 590px; } + .modchooser .modal-body .optionsummary .content { + overflow-y: auto; } + .modchooser .modal-body .optionsummary .content .heading .icon { + height: 32px; + width: 32px; + font-size: 32px; + padding: 0; } + .modchooser .modal-body .optionsummary .actions { + border-top: 1px solid #dee2e6; + background: #fff; } + +@media (max-width: 1199.98px) { + .modchoosercontainer .optionscontainer .option { + flex-basis: 20%; } } + +@media (max-width: 575.98px) { + .path-course-view .modal-dialog.modal-lg, + .path-course-view .modal-content, + .modchooser .modal-body, + .modchooser .modal-body .carousel, + .modchooser .modal-body .carousel-inner, + .modchooser .modal-body .carousel-item, + .modchooser .modal-body .optionsummary, + .modchoosercontainer, + .optionscontainer { + height: 100%; } + .path-course-view .modal-dialog.modal-lg { + margin: 0; } + .modchoosercontainer .optionscontainer .option { + flex-basis: 25%; } } + /* Form element: listing */ .formlistingradio { padding-bottom: 25px; @@ -12633,11 +12710,6 @@ table.calendartable caption { /* course.less */ /* COURSE CONTENT */ -.section-modchooser-link img { - margin-right: 0.5rem; - width: 16px; - height: 16px; } - .section_add_menus { text-align: right; clear: both; } diff --git a/version.php b/version.php index 8f043162503c1..fa4b3f0e69dfd 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2020020700.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2020020700.02; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '3.9dev (Build: 20200207)'; // Human-friendly version name