Merge branch 'MDL-68348-master-6' of git://github.com/mickhawkins/moodle
authorAdrian Greeve <abgreeve@gmail.com>
Mon, 25 May 2020 23:48:53 +0000 (07:48 +0800)
committerAdrian Greeve <abgreeve@gmail.com>
Mon, 25 May 2020 23:48:53 +0000 (07:48 +0800)
224 files changed:
.eslintignore
.stylelintignore
admin/tool/usertours/lang/en/tool_usertours.php
badges/amd/build/backpackactions.min.js [new file with mode: 0644]
badges/amd/build/backpackactions.min.js.map [new file with mode: 0644]
badges/amd/build/selectors.min.js [new file with mode: 0644]
badges/amd/build/selectors.min.js.map [new file with mode: 0644]
badges/amd/src/backpackactions.js [new file with mode: 0644]
badges/amd/src/selectors.js [new file with mode: 0644]
badges/backpacks.php
badges/classes/form/external_backpack.php
badges/classes/helper.php [new file with mode: 0644]
badges/classes/output/external_backpacks_page.php
badges/templates/external_backpacks_page.mustache
badges/tests/badgeslib_test.php
badges/tests/behat/backpack.feature
badges/tests/privacy_test.php
blocks/myoverview/classes/output/main.php
blocks/myoverview/lang/en/block_myoverview.php
blocks/myoverview/lib.php
blocks/myoverview/templates/nav-sort-selector.mustache
blocks/myoverview/tests/behat/block_myoverview_dashboard.feature
blocks/myoverview/tests/privacy_test.php
calendar/templates/minicalendar_day_link.mustache
contentbank/index.php
course/renderer.php
course/upgrade.txt
enrol/manual/tests/behat/quickenrolment.feature
h5p/classes/api.php
h5p/classes/helper.php
h5p/classes/player.php
h5p/tests/api_test.php
h5p/tests/external_test.php
h5p/tests/generator/lib.php
h5p/tests/helper_test.php
lang/en/badges.php
lang/en/deprecated.txt
lib/badgeslib.php
lib/form/templates/element-group-inline.mustache
lib/templates/local/toast/message.mustache
lib/tests/rsslib_test.php
mod/choice/report.php
mod/h5pactivity/classes/external/get_h5pactivities_by_courses.php [new file with mode: 0644]
mod/h5pactivity/classes/external/get_results.php [new file with mode: 0644]
mod/h5pactivity/classes/external/h5pactivity_summary_exporter.php [new file with mode: 0644]
mod/h5pactivity/classes/local/manager.php
mod/h5pactivity/classes/local/report/results.php
mod/h5pactivity/db/services.php
mod/h5pactivity/tests/external/get_h5pactivities_by_courses_test.php [new file with mode: 0644]
mod/h5pactivity/tests/external/get_results_test.php [new file with mode: 0644]
mod/h5pactivity/tests/generator/lib.php
mod/h5pactivity/tests/local/manager_test.php
mod/h5pactivity/version.php
privacy/classes/local/request/moodle_content_writer.php
privacy/tests/moodle_content_writer_test.php
question/engine/bank.php
question/type/ddwtos/amd/build/ddwtos.min.js
question/type/ddwtos/amd/build/ddwtos.min.js.map
question/type/ddwtos/amd/src/ddwtos.js
question/type/ddwtos/tests/helper.php
question/type/ddwtos/tests/questiontype_test.php
question/type/gapselect/questiontypebase.php
question/type/gapselect/tests/helper.php
question/type/gapselect/tests/question_test.php
question/type/gapselect/tests/questiontype_test.php
question/type/gapselect/tests/walkthrough_test.php
question/type/questiontypebase.php
theme/boost/amd/build/alert.min.js [deleted file]
theme/boost/amd/build/alert.min.js.map [deleted file]
theme/boost/amd/build/bootstrap/alert.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/alert.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/button.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/button.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/carousel.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/carousel.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/collapse.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/collapse.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/dropdown.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/dropdown.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/index.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/index.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/modal.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/modal.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/popover.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/popover.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/scrollspy.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/scrollspy.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/tab.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/tab.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/toast.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/toast.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/tools/sanitizer.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/tools/sanitizer.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/tooltip.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/tooltip.min.js.map [new file with mode: 0644]
theme/boost/amd/build/bootstrap/util.min.js [new file with mode: 0644]
theme/boost/amd/build/bootstrap/util.min.js.map [new file with mode: 0644]
theme/boost/amd/build/button.min.js [deleted file]
theme/boost/amd/build/button.min.js.map [deleted file]
theme/boost/amd/build/carousel.min.js [deleted file]
theme/boost/amd/build/carousel.min.js.map [deleted file]
theme/boost/amd/build/collapse.min.js [deleted file]
theme/boost/amd/build/collapse.min.js.map [deleted file]
theme/boost/amd/build/dropdown.min.js [deleted file]
theme/boost/amd/build/dropdown.min.js.map [deleted file]
theme/boost/amd/build/index.min.js [deleted file]
theme/boost/amd/build/index.min.js.map [deleted file]
theme/boost/amd/build/loader.min.js
theme/boost/amd/build/loader.min.js.map
theme/boost/amd/build/modal.min.js [deleted file]
theme/boost/amd/build/modal.min.js.map [deleted file]
theme/boost/amd/build/popover.min.js
theme/boost/amd/build/popover.min.js.map
theme/boost/amd/build/sanitizer.min.js [deleted file]
theme/boost/amd/build/sanitizer.min.js.map [deleted file]
theme/boost/amd/build/scrollspy.min.js [deleted file]
theme/boost/amd/build/scrollspy.min.js.map [deleted file]
theme/boost/amd/build/tab.min.js [deleted file]
theme/boost/amd/build/tab.min.js.map [deleted file]
theme/boost/amd/build/tether.min.js [deleted file]
theme/boost/amd/build/tether.min.js.map [deleted file]
theme/boost/amd/build/toast.min.js
theme/boost/amd/build/toast.min.js.map
theme/boost/amd/build/tooltip.min.js [deleted file]
theme/boost/amd/build/tooltip.min.js.map [deleted file]
theme/boost/amd/build/util.min.js [deleted file]
theme/boost/amd/build/util.min.js.map [deleted file]
theme/boost/amd/src/alert.js [deleted file]
theme/boost/amd/src/bootstrap/alert.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/button.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/carousel.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/collapse.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/dropdown.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/index.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/modal.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/popover.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/scrollspy.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/tab.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/toast.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/tools/sanitizer.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/tooltip.js [new file with mode: 0644]
theme/boost/amd/src/bootstrap/util.js [new file with mode: 0644]
theme/boost/amd/src/button.js [deleted file]
theme/boost/amd/src/carousel.js [deleted file]
theme/boost/amd/src/collapse.js [deleted file]
theme/boost/amd/src/dropdown.js [deleted file]
theme/boost/amd/src/index.js [deleted file]
theme/boost/amd/src/loader.js
theme/boost/amd/src/modal.js [deleted file]
theme/boost/amd/src/popover.js
theme/boost/amd/src/sanitizer.js [deleted file]
theme/boost/amd/src/scrollspy.js [deleted file]
theme/boost/amd/src/tab.js [deleted file]
theme/boost/amd/src/tether.js [deleted file]
theme/boost/amd/src/toast.js
theme/boost/amd/src/tooltip.js [deleted file]
theme/boost/amd/src/util.js [deleted file]
theme/boost/readme_moodle.txt
theme/boost/scss/bootstrap/_badge.scss
theme/boost/scss/bootstrap/_breadcrumb.scss
theme/boost/scss/bootstrap/_button-group.scss
theme/boost/scss/bootstrap/_buttons.scss
theme/boost/scss/bootstrap/_card.scss
theme/boost/scss/bootstrap/_carousel.scss
theme/boost/scss/bootstrap/_close.scss
theme/boost/scss/bootstrap/_code.scss
theme/boost/scss/bootstrap/_custom-forms.scss
theme/boost/scss/bootstrap/_dropdown.scss
theme/boost/scss/bootstrap/_forms.scss
theme/boost/scss/bootstrap/_functions.scss
theme/boost/scss/bootstrap/_grid.scss
theme/boost/scss/bootstrap/_images.scss
theme/boost/scss/bootstrap/_input-group.scss
theme/boost/scss/bootstrap/_list-group.scss
theme/boost/scss/bootstrap/_mixins.scss
theme/boost/scss/bootstrap/_modal.scss
theme/boost/scss/bootstrap/_nav.scss
theme/boost/scss/bootstrap/_navbar.scss
theme/boost/scss/bootstrap/_pagination.scss
theme/boost/scss/bootstrap/_popover.scss
theme/boost/scss/bootstrap/_print.scss
theme/boost/scss/bootstrap/_progress.scss
theme/boost/scss/bootstrap/_reboot.scss
theme/boost/scss/bootstrap/_root.scss
theme/boost/scss/bootstrap/_spinners.scss
theme/boost/scss/bootstrap/_tables.scss
theme/boost/scss/bootstrap/_type.scss
theme/boost/scss/bootstrap/_utilities.scss
theme/boost/scss/bootstrap/_variables.scss
theme/boost/scss/bootstrap/bootstrap-grid.scss
theme/boost/scss/bootstrap/bootstrap-reboot.scss
theme/boost/scss/bootstrap/bootstrap.scss
theme/boost/scss/bootstrap/mixins/_background-variant.scss
theme/boost/scss/bootstrap/mixins/_badge.scss
theme/boost/scss/bootstrap/mixins/_border-radius.scss
theme/boost/scss/bootstrap/mixins/_buttons.scss
theme/boost/scss/bootstrap/mixins/_caret.scss
theme/boost/scss/bootstrap/mixins/_float.scss
theme/boost/scss/bootstrap/mixins/_forms.scss
theme/boost/scss/bootstrap/mixins/_grid-framework.scss
theme/boost/scss/bootstrap/mixins/_grid.scss
theme/boost/scss/bootstrap/mixins/_hover.scss
theme/boost/scss/bootstrap/mixins/_image.scss
theme/boost/scss/bootstrap/mixins/_list-group.scss
theme/boost/scss/bootstrap/mixins/_lists.scss
theme/boost/scss/bootstrap/mixins/_nav-divider.scss
theme/boost/scss/bootstrap/mixins/_reset-text.scss
theme/boost/scss/bootstrap/mixins/_screen-reader.scss
theme/boost/scss/bootstrap/mixins/_table-row.scss
theme/boost/scss/bootstrap/mixins/_text-emphasis.scss
theme/boost/scss/bootstrap/mixins/_transition.scss
theme/boost/scss/bootstrap/utilities/_background.scss
theme/boost/scss/bootstrap/utilities/_interactions.scss [new file with mode: 0644]
theme/boost/scss/bootstrap/utilities/_text.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/boost/templates/columns1.mustache
theme/boost/templates/columns2.mustache
theme/boost/templates/secure.mustache
theme/boost/thirdpartylibs.xml
theme/classic/scss/classic/post.scss
theme/classic/style/moodle.css
theme/classic/templates/columns.mustache
theme/classic/templates/secure.mustache

index b9c0b6a..1bca50c 100644 (file)
@@ -74,19 +74,19 @@ media/player/videojs/videojs/video-js.swf
 mod/assign/feedback/editpdf/fpdi/
 repository/s3/S3.php
 theme/boost/scss/bootstrap/
-theme/boost/amd/src/alert.js
-theme/boost/amd/src/button.js
-theme/boost/amd/src/carousel.js
-theme/boost/amd/src/collapse.js
-theme/boost/amd/src/dropdown.js
-theme/boost/amd/src/index.js
-theme/boost/amd/src/modal.js
-theme/boost/amd/src/popover.js
-theme/boost/amd/src/sanitizer.js
-theme/boost/amd/src/scrollspy.js
-theme/boost/amd/src/tab.js
-theme/boost/amd/src/toast.js
-theme/boost/amd/src/tooltip.js
-theme/boost/amd/src/util.js
+theme/boost/amd/src/bootstrap/alert.js
+theme/boost/amd/src/bootstrap/button.js
+theme/boost/amd/src/bootstrap/carousel.js
+theme/boost/amd/src/bootstrap/collapse.js
+theme/boost/amd/src/bootstrap/dropdown.js
+theme/boost/amd/src/bootstrap/index.js
+theme/boost/amd/src/bootstrap/modal.js
+theme/boost/amd/src/bootstrap/popover.js
+theme/boost/amd/src/bootstrap/tools/sanitizer.js
+theme/boost/amd/src/bootstrap/scrollspy.js
+theme/boost/amd/src/bootstrap/tab.js
+theme/boost/amd/src/bootstrap/toast.js
+theme/boost/amd/src/bootstrap/tooltip.js
+theme/boost/amd/src/bootstrap/util.js
 theme/boost/amd/src/tether.js
 theme/boost/scss/fontawesome/
\ No newline at end of file
index 5d9e5c1..c0de22f 100644 (file)
@@ -75,19 +75,19 @@ media/player/videojs/videojs/video-js.swf
 mod/assign/feedback/editpdf/fpdi/
 repository/s3/S3.php
 theme/boost/scss/bootstrap/
-theme/boost/amd/src/alert.js
-theme/boost/amd/src/button.js
-theme/boost/amd/src/carousel.js
-theme/boost/amd/src/collapse.js
-theme/boost/amd/src/dropdown.js
-theme/boost/amd/src/index.js
-theme/boost/amd/src/modal.js
-theme/boost/amd/src/popover.js
-theme/boost/amd/src/sanitizer.js
-theme/boost/amd/src/scrollspy.js
-theme/boost/amd/src/tab.js
-theme/boost/amd/src/toast.js
-theme/boost/amd/src/tooltip.js
-theme/boost/amd/src/util.js
+theme/boost/amd/src/bootstrap/alert.js
+theme/boost/amd/src/bootstrap/button.js
+theme/boost/amd/src/bootstrap/carousel.js
+theme/boost/amd/src/bootstrap/collapse.js
+theme/boost/amd/src/bootstrap/dropdown.js
+theme/boost/amd/src/bootstrap/index.js
+theme/boost/amd/src/bootstrap/modal.js
+theme/boost/amd/src/bootstrap/popover.js
+theme/boost/amd/src/bootstrap/tools/sanitizer.js
+theme/boost/amd/src/bootstrap/scrollspy.js
+theme/boost/amd/src/bootstrap/tab.js
+theme/boost/amd/src/bootstrap/toast.js
+theme/boost/amd/src/bootstrap/tooltip.js
+theme/boost/amd/src/bootstrap/util.js
 theme/boost/amd/src/tether.js
 theme/boost/scss/fontawesome/
\ No newline at end of file
index 2ce495b..3d86e56 100644 (file)
@@ -210,7 +210,7 @@ These actions only affect your view.
 
 You can also choose to display the courses in a list, or with summary information, or the default \'card\' view.';
 $string['tour3_title_displayoptions'] = 'Display options';
-$string['tour3_content_displayoptions'] = 'Courses may be sorted by course name or by last access date.
+$string['tour3_content_displayoptions'] = 'Courses may be sorted by course name, course short name or last access date.
 
 You can also choose to display the courses in a list, with summary information, or the default \'card\' view.';
 
diff --git a/badges/amd/build/backpackactions.min.js b/badges/amd/build/backpackactions.min.js
new file mode 100644 (file)
index 0000000..659e07c
Binary files /dev/null and b/badges/amd/build/backpackactions.min.js differ
diff --git a/badges/amd/build/backpackactions.min.js.map b/badges/amd/build/backpackactions.min.js.map
new file mode 100644 (file)
index 0000000..52bdc23
Binary files /dev/null and b/badges/amd/build/backpackactions.min.js.map differ
diff --git a/badges/amd/build/selectors.min.js b/badges/amd/build/selectors.min.js
new file mode 100644 (file)
index 0000000..dbe7b36
Binary files /dev/null and b/badges/amd/build/selectors.min.js differ
diff --git a/badges/amd/build/selectors.min.js.map b/badges/amd/build/selectors.min.js.map
new file mode 100644 (file)
index 0000000..856297c
Binary files /dev/null and b/badges/amd/build/selectors.min.js.map differ
diff --git a/badges/amd/src/backpackactions.js b/badges/amd/src/backpackactions.js
new file mode 100644 (file)
index 0000000..5730b84
--- /dev/null
@@ -0,0 +1,89 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Action methods related to backpacks.
+ *
+ * @module     core_badges/backpackactions
+ * @package    core_badges
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import $ from 'jquery';
+import selectors from 'core_badges/selectors';
+import {get_string as getString} from 'core/str';
+import Pending from 'core/pending';
+import ModalFactory from 'core/modal_factory';
+import ModalEvents from 'core/modal_events';
+import Config from 'core/config';
+
+/**
+ * Set up the actions.
+ *
+ * @method init
+ */
+export const init = () => {
+    const pendingPromise = new Pending();
+
+    const root = $(selectors.elements.main);
+    registerListenerEvents(root);
+
+    pendingPromise.resolve();
+};
+
+/**
+ * Register backpack related event listeners.
+ *
+ * @method registerListenerEvents
+ * @param {Object} root The root element.
+ */
+const registerListenerEvents = (root) => {
+
+    root.on('click', selectors.actions.deletebackpack, async(e) => {
+        e.preventDefault();
+
+        const link = $(e.currentTarget);
+        const modal = await buildModal(link);
+
+        displayModal(modal, link);
+    });
+};
+
+const buildModal = async(link) => {
+
+    const backpackurl = link.closest(selectors.elements.backpackurl).attr('data-backpackurl');
+
+    return ModalFactory.create({
+        title: await getString('delexternalbackpack', 'core_badges'),
+        body: await getString('delexternalbackpackconfirm', 'core_badges', backpackurl),
+        type: ModalFactory.types.SAVE_CANCEL,
+    });
+
+};
+
+const displayModal = async(modal, link) => {
+    modal.setSaveButtonText(await getString('delete', 'core'));
+
+    modal.getRoot().on(ModalEvents.save, function() {
+        window.location.href = link.attr('href') + '&sesskey=' + Config.sesskey + '&confirm=1';
+    });
+
+    modal.getRoot().on(ModalEvents.hidden, function() {
+        modal.destroy();
+    });
+
+    modal.show();
+};
diff --git a/badges/amd/src/selectors.js b/badges/amd/src/selectors.js
new file mode 100644 (file)
index 0000000..fea6dfb
--- /dev/null
@@ -0,0 +1,46 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Define all of the selectors we will be using on the backpack interface.
+ *
+ * @module     core_badges/selectors
+ * @package    core_badges
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @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 {
+    actions: {
+        deletebackpack: getDataSelector('action', 'deletebackpack'),
+    },
+    elements: {
+        clearsearch: '.input-group-append .clear-icon',
+        main: '#backpacklist',
+        backpackurl: '[data-backpackurl]',
+    },
+};
index 341d511..ae63fd6 100644 (file)
@@ -34,6 +34,7 @@ $output = $PAGE->get_renderer('core', 'badges');
 
 $id = optional_param('id', 0, PARAM_INT);
 $action = optional_param('action', '', PARAM_ALPHA);
+$confirm = optional_param('confirm', 1, PARAM_BOOL);
 
 $PAGE->set_pagelayout('admin');
 $url = new moodle_url('/badges/backpacks.php');
@@ -45,6 +46,18 @@ if (empty($CFG->badges_allowexternalbackpack)) {
 $PAGE->set_url($url);
 $PAGE->set_title(get_string('managebackpacks', 'badges'));
 $PAGE->set_heading($SITE->fullname);
+
+$msg = '';
+$msgtype = 'error';
+if ($action == 'delete' && $confirm && confirm_sesskey()) {
+    if (badges_delete_site_backpack($id)) {
+        $msg = get_string('sitebackpackdeleted', 'badges');
+        $msgtype = 'notifysuccess';
+    } else {
+        $msg = get_string('sitebackpacknotdeleted', 'badges');
+    }
+}
+
 if ($action == 'edit') {
     $backpack = null;
     if (!empty($id)) {
@@ -71,6 +84,9 @@ if ($action == 'edit') {
     echo $OUTPUT->header();
     echo $output->heading(get_string('managebackpacks', 'badges'));
 
+    if ($msg) {
+        echo $OUTPUT->notification($msg, $msgtype);
+    }
     $page = new \core_badges\output\external_backpacks_page($url);
     echo $output->render($page);
 }
index 1bb90c5..f92febd 100644 (file)
@@ -48,40 +48,39 @@ class external_backpack extends \moodleform {
 
         if (isset($this->_customdata['externalbackpack'])) {
             $backpack = $this->_customdata['externalbackpack'];
-        } else {
-            throw new \coding_exception('backpack is required.');
         }
 
-        $url = $backpack->backpackapiurl;
+        $mform->addElement('hidden', 'action', 'edit');
+        $mform->setType('action', PARAM_ALPHA);
 
-        $mform->addElement('static', 'backpackapiurlinfo', get_string('backpackapiurl', 'core_badges'), $url);
+        if ($backpack) {
+            $mform->addElement('hidden', 'id', $backpack->id);
+            $mform->setType('id', PARAM_INTEGER);
+        }
 
-        $mform->addElement('hidden', 'backpackapiurl', $url);
+        $mform->addElement('text', 'backpackapiurl',  get_string('backpackapiurl', 'core_badges'));
         $mform->setType('backpackapiurl', PARAM_URL);
+        $mform->addRule('backpackapiurl', null, 'required', null, 'client');
+        $mform->addRule('backpackapiurl', get_string('maximumchars', '', 255), 'maxlength', 50, 'client');
 
-        $url = $backpack->backpackweburl;
-        $mform->addElement('static', 'backpackweburlinfo', get_string('backpackweburl', 'core_badges'), $url);
-        $mform->addElement('hidden', 'backpackweburl', $url);
+        $mform->addElement('text', 'backpackweburl', get_string('backpackweburl', 'core_badges'));
         $mform->setType('backpackweburl', PARAM_URL);
+        $mform->addRule('backpackweburl', null, 'required', null, 'client');
+        $mform->addRule('backpackweburl', get_string('maximumchars', '', 255), 'maxlength', 50, 'client');
 
-        $options = badges_get_badge_api_versions();
-        $label = $options[$backpack->apiversion];
-        $mform->addElement('static', 'apiversioninfo', get_string('apiversion', 'core_badges'), $label);
-        $mform->addElement('hidden', 'apiversion', $backpack->apiversion);
+        $apiversions = badges_get_badge_api_versions();
+        $mform->addElement('select', 'apiversion', get_string('apiversion', 'core_badges'), $apiversions);
         $mform->setType('apiversion', PARAM_RAW);
-
-        $mform->addElement('hidden', 'id', $backpack->id);
-        $mform->setType('id', PARAM_INTEGER);
-
-        $mform->addElement('hidden', 'action', 'edit');
-        $mform->setType('action', PARAM_ALPHA);
+        $mform->setDefault('apiversion', OPEN_BADGES_V2P1);
+        $mform->addRule('apiversion', null, 'required', null, 'client');
 
         $issuername = $CFG->badges_defaultissuername;
         $mform->addElement('static', 'issuerinfo', get_string('defaultissuername', 'core_badges'), $issuername);
 
         $issuercontact = $CFG->badges_defaultissuercontact;
         $mform->addElement('static', 'issuerinfo', get_string('defaultissuercontact', 'core_badges'), $issuercontact);
-        if ($backpack->apiversion != OPEN_BADGES_V2P1) {
+
+        if ($backpack && $backpack->apiversion != OPEN_BADGES_V2P1) {
             $mform->addElement('passwordunmask', 'password', get_string('defaultissuerpassword', 'core_badges'));
             $mform->setType('password', PARAM_RAW);
             $mform->addHelpButton('password', 'defaultissuerpassword', 'badges');
@@ -91,7 +90,9 @@ class external_backpack extends \moodleform {
             $mform->addElement('select', 'oauth2_issuerid', get_string('oauth2issuer', 'core_badges'), $oauth2options);
             $mform->setType('oauth2_issuerid', PARAM_INT);
         }
-        $this->set_data($backpack);
+        if ($backpack) {
+            $this->set_data($backpack);
+        }
 
         // Disable short forms.
         $mform->setDisableShortforms();
@@ -99,4 +100,24 @@ class external_backpack extends \moodleform {
         $this->add_action_buttons();
     }
 
+    /**
+     * Validate the data from the form.
+     *
+     * @param  array $data form data
+     * @param  array $files form files
+     * @return array An array of error messages.
+     */
+    public function validation($data, $files) {
+        $errors = parent::validation($data, $files);
+
+        // Ensure backpackapiurl and  are valid URLs.
+        if (!empty($data['backpackapiurl']) && !preg_match('@^https?://.+@', $data['backpackapiurl'])) {
+            $errors['backpackapiurl'] = get_string('invalidurl', 'badges');
+        }
+        if (!empty($data['backpackweburl']) && !preg_match('@^https?://.+@', $data['backpackweburl'])) {
+            $errors['backpackweburl'] = get_string('invalidurl', 'badges');
+        }
+
+        return $errors;
+    }
 }
diff --git a/badges/classes/helper.php b/badges/classes/helper.php
new file mode 100644 (file)
index 0000000..b744c1e
--- /dev/null
@@ -0,0 +1,75 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Badge helper library.
+ *
+ * @package    core
+ * @subpackage badges
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_badges;
+
+/**
+ * Badge helper library.
+ *
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class helper {
+
+    /**
+     * Create a backpack.
+     *
+     * @param array $params Parameters.
+     * @return object
+     */
+    public static function create_fake_backpack(array $params = []) {
+        global $DB;
+
+        $record = (object) array_merge([
+            'userid' => null,
+            'email' => 'test@example.com',
+            'backpackuid' => -1,
+            'autosync' => 0,
+            'password' => '',
+            'externalbackpackid' => 12345,
+        ], $params);
+        $record->id = $DB->insert_record('badge_backpack', $record);
+
+        return $record;
+    }
+
+    /**
+     * Create a user backpack collection.
+     *
+     * @param array $params Parameters.
+     * @return object
+     */
+    public static function create_fake_backpack_collection(array $params = []) {
+        global $DB;
+
+        $record = (object) array_merge([
+            'backpackid' => 12345,
+            'collectionid' => -1,
+            'entityid' => random_string(20),
+        ], $params);
+        $record->id = $DB->insert_record('badge_external', $record);
+
+        return $record;
+    }
+}
index 839a731..f9258b3 100644 (file)
@@ -56,6 +56,10 @@ class external_backpacks_page implements \renderable {
      * @return stdClass
      */
     public function export_for_template(\renderer_base $output) {
+        global $CFG, $PAGE;
+
+        $PAGE->requires->js_call_amd('core_badges/backpackactions', 'init');
+
         $data = new \stdClass();
         $data->baseurl = $this->url;
         $data->backpacks = array();
@@ -68,6 +72,8 @@ class external_backpacks_page implements \renderable {
             } else {
                 $backpack->canedit = false;
             }
+            $backpack->iscurrent = ($backpack->id == $CFG->badges_site_backpack);
+
             $data->backpacks[] = $backpack;
         }
         $data->warning = badges_verify_site_backpack();
index 2d84733..aab0f2f 100644 (file)
         "warning": "<span class='text-warning'>Could not login</span>"
     }
 }}
-<table class="generaltable fullwidth">
+
+<form action="{{baseurl}}" method="get" id="createbackpack">
+   <input type="hidden" name="action" value="edit"/>
+   <button type="submit" class="btn btn-secondary">{{#str}}newbackpack, core_badges{{/str}}</button>
+</form>
+
+<table class="generaltable fullwidth" id="backpacklist">
     <caption>{{#str}}listbackpacks, core_badges{{/str}}</caption>
     <thead>
         <tr>
     </thead>
     <tbody>
         {{#backpacks}}
-        <tr>
+        <tr data-backpackurl="{{{backpackweburl}}}">
             <td> {{{backpackweburl}}} </td>
             <td> {{#sitebackpack}}Yes{{/sitebackpack}} </td>
             <td>
             {{#canedit}}
-                <a href="{{baseurl}}?id={{id}}&action=edit">
-                    {{#str}}editsettings, core_badges{{/str}}
-                </a>
+                <a href="{{baseurl}}?id={{id}}&action=edit">{{#pix}}t/edit, core,{{#str}}editsettings{{/str}}{{/pix}}</a>
             {{/canedit}}
+            {{^iscurrent}}
+                <a href="{{baseurl}}?id={{id}}&action=delete" role="button" data-action="deletebackpack">
+                    {{#pix}}t/delete, core,{{#str}}delete{{/str}}{{/pix}}
+                </a>
+            {{/iscurrent}}
             </td>
         </tr>
         {{/backpacks}}
index d3d3541..596c67e 100644 (file)
@@ -30,6 +30,8 @@ global $CFG;
 require_once($CFG->libdir . '/badgeslib.php');
 require_once($CFG->dirroot . '/badges/lib.php');
 
+use core_badges\helper;
+
 class core_badges_badgeslib_testcase extends advanced_testcase {
     protected $badgeid;
     protected $course;
@@ -855,4 +857,58 @@ class core_badges_badgeslib_testcase extends advanced_testcase {
         $badge->delete_alignment($alignments1[$newid2]->id);
         $this->assertCount(1, $badge->get_alignments());
     }
+
+    /**
+     * Test badges_delete_site_backpack().
+     *
+     */
+    public function test_badges_delete_site_backpack(): void {
+        global $DB;
+
+        $this->setAdminUser();
+
+        // Create one backpack.
+        $total = $DB->count_records('badge_external_backpack');
+        $this->assertEquals(1, $total);
+
+        $data = new \stdClass();
+        $data->apiversion = OPEN_BADGES_V2P1;
+        $data->backpackapiurl = 'https://dc.imsglobal.org/obchost/ims/ob/v2p1';
+        $data->backpackweburl = 'https://dc.imsglobal.org';
+        badges_create_site_backpack($data);
+        $backpack = $DB->get_record('badge_external_backpack', ['backpackweburl' => $data->backpackweburl]);
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        // User1 is connected to the backpack to be removed and has 2 collections.
+        $backpackuser1 = helper::create_fake_backpack(['userid' => $user1->id, 'externalbackpackid' => $backpack->id]);
+        helper::create_fake_backpack_collection(['backpackid' => $backpackuser1->id]);
+        helper::create_fake_backpack_collection(['backpackid' => $backpackuser1->id]);
+        // User2 is connected to a different backpack and has 1 collection.
+        $backpackuser2 = helper::create_fake_backpack(['userid' => $user2->id]);
+        helper::create_fake_backpack_collection(['backpackid' => $backpackuser2->id]);
+
+        $total = $DB->count_records('badge_external_backpack');
+        $this->assertEquals(2, $total);
+        $total = $DB->count_records('badge_backpack');
+        $this->assertEquals(2, $total);
+        $total = $DB->count_records('badge_external');
+        $this->assertEquals(3, $total);
+
+        // Remove the backpack created previously.
+        $result = badges_delete_site_backpack($backpack->id);
+        $this->assertTrue($result);
+
+        $total = $DB->count_records('badge_external_backpack');
+        $this->assertEquals(1, $total);
+
+        $total = $DB->count_records('badge_backpack');
+        $this->assertEquals(1, $total);
+
+        $total = $DB->count_records('badge_external');
+        $this->assertEquals(1, $total);
+
+        // Try to remove an non-existent backpack.
+        $result = badges_delete_site_backpack($backpack->id);
+        $this->assertFalse($result);
+    }
 }
index 7c66ac7..3cb8e78 100644 (file)
@@ -92,3 +92,30 @@ Feature: Backpack badges
     And I follow "Manage badges"
     And I should see "Test badge verify backpack"
     And "Add to backpack" "link" should exist
+
+  @javascript
+  Scenario: Add a new site backpack
+    Given I am on homepage
+    And I log in as "admin"
+    And I navigate to "Badges > Manage backpacks" in site administration
+    When I press "Add a new backpack"
+    And I set the field "backpackapiurl" to "http://backpackapiurl.cat"
+    And I set the field "backpackweburl" to "aaa"
+    And I press "Save changes"
+    And I should see "Invalid URL"
+    And I set the field "backpackweburl" to "http://backpackweburl.cat"
+    And I press "Save changes"
+    Then I should see "http://backpackweburl.cat"
+    And "Delete" "button" should exist
+
+  @javascript
+  Scenario: Remove a site backpack
+    Given I am on homepage
+    And I log in as "admin"
+    And I navigate to "Badges > Manage backpacks" in site administration
+    When I click on "Delete" "link" in the "https://dc.imsglobal.org" "table_row"
+    And I should see "Delete site backpack 'https://dc.imsglobal.org'?"
+    And I click on "Delete" "button" in the "Delete site backpack" "dialogue"
+    Then I should see "The site backpack has been deleted."
+    And I should not see "https://dc.imsglobal.org"
+    And "Delete" "button" should not exist
index fb75770..8067b4e 100644 (file)
@@ -33,6 +33,7 @@ use core_privacy\local\request\transform;
 use core_privacy\local\request\writer;
 use core_badges\privacy\provider;
 use core_privacy\local\request\approved_userlist;
+use core_badges\helper;
 
 require_once($CFG->libdir . '/badgeslib.php');
 
@@ -142,7 +143,7 @@ class core_badges_privacy_testcase extends provider_testcase {
         $b1 = $this->create_badge();
         $b2 = $this->create_badge(['type' => BADGE_TYPE_COURSE, 'courseid' => $c1->id]);
 
-        $this->create_backpack(['userid' => $u1->id]);
+        helper::create_fake_backpack(['userid' => $u1->id]);
         $this->create_manual_award(['recipientid' => $u2->id, 'badgeid' => $b1->id]);
         $this->create_issued(['badgeid' => $b2->id, 'userid' => $u3->id]);
 
@@ -182,8 +183,8 @@ class core_badges_privacy_testcase extends provider_testcase {
         $b2 = $this->create_badge(['usercreated' => $u2->id, 'usermodified' => $u1->id,
             'type' => BADGE_TYPE_COURSE, 'courseid' => $c1->id]);
 
-        $this->create_backpack(['userid' => $u1->id]);
-        $this->create_backpack(['userid' => $u2->id]);
+        helper::create_fake_backpack(['userid' => $u1->id]);
+        helper::create_fake_backpack(['userid' => $u2->id]);
         $this->create_manual_award(['recipientid' => $u1->id, 'badgeid' => $b1->id]);
         $this->create_manual_award(['recipientid' => $u2->id, 'badgeid' => $b1->id, 'issuerid' => $u1->id]);
         $this->create_issued(['badgeid' => $b2->id, 'userid' => $u1->id]);
@@ -240,8 +241,8 @@ class core_badges_privacy_testcase extends provider_testcase {
         $b2 = $this->create_badge(['usercreated' => $u2->id, 'usermodified' => $u1->id,
             'type' => BADGE_TYPE_COURSE, 'courseid' => $c1->id]);
 
-        $this->create_backpack(['userid' => $u1->id]);
-        $this->create_backpack(['userid' => $u2->id]);
+        helper::create_fake_backpack(['userid' => $u1->id]);
+        helper::create_fake_backpack(['userid' => $u2->id]);
         $this->create_manual_award(['recipientid' => $u1->id, 'badgeid' => $b1->id]);
         $this->create_manual_award(['recipientid' => $u2->id, 'badgeid' => $b1->id, 'issuerid' => $u1->id]);
         $this->create_issued(['badgeid' => $b2->id, 'userid' => $u1->id]);
@@ -317,14 +318,14 @@ class core_badges_privacy_testcase extends provider_testcase {
 
         // Create things for user 2, to check it's not exported it.
         $this->create_issued(['badgeid' => $b4->id, 'userid' => $u2->id]);
-        $this->create_backpack(['userid' => $u2->id, 'email' => $u2->email]);
+        helper::create_fake_backpack(['userid' => $u2->id, 'email' => $u2->email]);
         $this->create_manual_award(['badgeid' => $b1->id, 'recipientid' => $u2->id, 'issuerid' => $u3->id]);
 
         // Create a set of stuff for u1.
         $this->create_issued(['badgeid' => $b1->id, 'userid' => $u1->id, 'uniquehash' => 'yoohoo']);
         $this->create_manual_award(['badgeid' => $b2->id, 'recipientid' => $u1->id, 'issuerid' => $u3->id]);
         $b3crit->mark_complete($u1->id);
-        $this->create_backpack(['userid' => $u1->id, 'email' => $u1->email]);
+        helper::create_fake_backpack(['userid' => $u1->id, 'email' => $u1->email]);
 
         // Check u1.
         writer::reset();
@@ -482,7 +483,7 @@ class core_badges_privacy_testcase extends provider_testcase {
         $this->create_manual_award(['recipientid' => $user3->id, 'issuerid' => $user2->id, 'badgeid' => $badge1->id]);
         $this->create_manual_award(['recipientid' => $user1->id, 'issuerid' => $user2->id, 'badgeid' => $badge2->id]);
 
-        $this->create_backpack(['userid' => $user2->id]);
+        helper::create_fake_backpack(['userid' => $user2->id]);
         $this->create_issued(['badgeid' => $badge2->id, 'userid' => $user3->id]);
 
         $crit = $this->create_criteria_manual($badge1->id);
@@ -538,7 +539,7 @@ class core_badges_privacy_testcase extends provider_testcase {
         $this->create_manual_award(['recipientid' => $user3->id, 'issuerid' => $user2->id, 'badgeid' => $badge1->id]);
         $this->create_manual_award(['recipientid' => $user1->id, 'issuerid' => $user2->id, 'badgeid' => $badge2->id]);
 
-        $this->create_backpack(['userid' => $user2->id]);
+        helper::create_fake_backpack(['userid' => $user2->id]);
         $this->create_issued(['badgeid' => $badge2->id, 'userid' => $user3->id]);
 
         $crit = $this->create_criteria_manual($badge1->id);
@@ -708,26 +709,6 @@ class core_badges_privacy_testcase extends provider_testcase {
         return $record;
     }
 
-    /**
-     * Create a backpack.
-     *
-     * @param array $params Parameters.
-     * @return object
-     */
-    protected function create_backpack(array $params = []) {
-        global $DB;
-        $record = (object) array_merge([
-            'userid' => null,
-            'email' => 'test@example.com',
-            'backpackurl' => "http://here.there.com",
-            'backpackuid' => "12345",
-            'autosync' => 0,
-            'password' => '',
-        ], $params);
-        $record->id = $DB->insert_record('badge_backpack', $record);
-        return $record;
-    }
-
     /**
      * Create a criteria of type badge.
      *
index 96e18fb..93478ab 100644 (file)
@@ -164,6 +164,7 @@ class main implements renderable, templatable {
      * @throws \dml_exception
      */
     public function __construct($grouping, $sort, $view, $paging, $customfieldvalue = null) {
+        global $CFG;
         // Get plugin config.
         $config = get_config('block_myoverview');
 
@@ -185,7 +186,18 @@ class main implements renderable, templatable {
         $this->customfieldvalue = $customfieldvalue;
 
         // Check and remember the given sorting.
-        $this->sort = $sort ? $sort : BLOCK_MYOVERVIEW_SORTING_TITLE;
+        if ($sort) {
+            $this->sort = $sort;
+        } else if ($CFG->courselistshortnames) {
+            $this->sort = BLOCK_MYOVERVIEW_SORTING_SHORTNAME;
+        } else {
+            $this->sort = BLOCK_MYOVERVIEW_SORTING_TITLE;
+        }
+        // In case sorting remembered is shortname and display extended course names not checked,
+        // we should revert sorting to title.
+        if (!$CFG->courselistshortnames && $sort == BLOCK_MYOVERVIEW_SORTING_SHORTNAME) {
+            $this->sort = BLOCK_MYOVERVIEW_SORTING_TITLE;
+        }
 
         // Check and remember the given view.
         $this->view = $view ? $view : BLOCK_MYOVERVIEW_VIEW_CARD;
@@ -236,7 +248,6 @@ class main implements renderable, templatable {
         }
         unset ($displaygroupingselectors, $displaygroupingselectorscount);
     }
-
     /**
      * Determine the most sensible fallback grouping to use (in cases where the stored selection
      * is no longer available).
@@ -393,7 +404,7 @@ class main implements renderable, templatable {
      *
      */
     public function export_for_template(renderer_base $output) {
-        global $USER;
+        global $CFG, $USER;
 
         $nocoursesurl = $output->image_url('courses', 'block_myoverview')->out();
 
@@ -422,12 +433,18 @@ class main implements renderable, templatable {
         }
         $preferences = $this->get_preferences_as_booleans();
         $availablelayouts = $this->get_formatted_available_layouts_for_export();
+        $sort = '';
+        if ($this->sort == BLOCK_MYOVERVIEW_SORTING_SHORTNAME) {
+            $sort = 'shortname';
+        } else {
+            $sort = $this->sort == BLOCK_MYOVERVIEW_SORTING_TITLE ? 'fullname' : 'ul.timeaccess desc';
+        }
 
         $defaultvariables = [
             'totalcoursecount' => count(enrol_get_all_users_courses($USER->id, true)),
             'nocoursesimg' => $nocoursesurl,
             'grouping' => $this->grouping,
-            'sort' => $this->sort == BLOCK_MYOVERVIEW_SORTING_TITLE ? 'fullname' : 'ul.timeaccess desc',
+            'sort' => $sort,
             // If the user preference display option is not available, default to first available layout.
             'view' => in_array($this->view, $this->layouts) ? $this->view : reset($this->layouts),
             'paging' => $this->paging,
@@ -447,6 +464,7 @@ class main implements renderable, templatable {
             'customfieldvalue' => $this->customfieldvalue,
             'customfieldvalues' => $customfieldvalues,
             'selectedcustomfield' => $selectedcustomfield,
+            'showsortbyshortname' => $CFG->courselistshortnames,
         ];
         return array_merge($defaultvariables, $preferences);
 
index 76f83fb..9763156 100644 (file)
@@ -44,6 +44,7 @@ $string['aria:list'] = 'Switch to list view';
 $string['aria:title'] = 'Sort courses by course name';
 $string['aria:past'] = 'Show past courses';
 $string['aria:removefromfavourites'] = 'Remove star for';
+$string['aria:shortname'] = 'Sort courses by course short name';
 $string['aria:summary'] = 'Switch to summary view';
 $string['aria:sortingdropdown'] = 'Sorting drop-down menu';
 $string['availablegroupings'] = 'Available filters';
@@ -73,6 +74,7 @@ $string['privacy:metadata:overviewviewpreference'] = 'The Course overview block
 $string['privacy:metadata:overviewgroupingpreference'] = 'The Course overview block grouping preference.';
 $string['privacy:metadata:overviewpagingpreference'] = 'The Course overview block paging preference.';
 $string['removefromfavourites'] = 'Unstar this course';
+$string['shortname'] = 'Short name';
 $string['summary'] = 'Summary';
 $string['title'] = 'Course name';
 $string['aria:hidecourse'] = 'Remove {$a} from view';
index 7af8cbd..7080d73 100644 (file)
@@ -47,6 +47,7 @@ define('BLOCK_MYOVERVIEW_CUSTOMFIELD_EMPTY', -1);
  */
 define('BLOCK_MYOVERVIEW_SORTING_TITLE', 'title');
 define('BLOCK_MYOVERVIEW_SORTING_LASTACCESSED', 'lastaccessed');
+define('BLOCK_MYOVERVIEW_SORTING_SHORTNAME', 'shortname');
 
 /**
  * Constants for the user preferences view options
@@ -104,7 +105,8 @@ function block_myoverview_user_preferences() {
         'type' => PARAM_ALPHA,
         'choices' => array(
             BLOCK_MYOVERVIEW_SORTING_TITLE,
-            BLOCK_MYOVERVIEW_SORTING_LASTACCESSED
+            BLOCK_MYOVERVIEW_SORTING_LASTACCESSED,
+            BLOCK_MYOVERVIEW_SORTING_SHORTNAME
         )
     );
     $preferences['block_myoverview_user_view_preference'] = array(
index 3289ea5..55a425d 100644 (file)
@@ -33,6 +33,7 @@
             <span class="d-sm-inline-block" data-active-item-text>
                 {{#title}}{{#str}} title, block_myoverview {{/str}}{{/title}}
                 {{#lastaccessed}}{{#str}} lastaccessed, block_myoverview {{/str}}{{/lastaccessed}}
+                {{#shortname}}{{#str}} shortname, block_myoverview {{/str}}{{/shortname}}
             </span>
         </button>
         <ul class="dropdown-menu" data-show-active-item aria-labelledby="sortingdropdown">
                     {{#str}} title, block_myoverview {{/str}}
                 </a>
             </li>
+            {{#showsortbyshortname}}
+            <li>
+                <a class="dropdown-item {{#shortname}}active{{/shortname}}" href="#" data-filter="sort" data-pref="shortname" data-value="shortname" aria-label="{{#str}} aria:shortname, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
+                    {{#str}} shortname, block_myoverview {{/str}}
+                </a>
+            </li>
+             {{/showsortbyshortname}}
             <li>
                 <a class="dropdown-item {{#lastaccessed}}active{{/lastaccessed}}" href="#" data-filter="sort" data-pref="lastaccessed" data-value="ul.timeaccess desc" aria-label="{{#str}} aria:lastaccessed, block_myoverview {{/str}}" aria-controls="courses-view-{{uniqid}}">
                     {{#str}} lastaccessed, block_myoverview {{/str}}
@@ -48,4 +56,4 @@
             </li>
         </ul>
     </div>
-</div>
\ No newline at end of file
+</div>
index 72d2f2c..10a32fd 100644 (file)
@@ -182,6 +182,20 @@ Feature: The my overview block allows users to easily access their courses
     Then I should see "Last accessed" in the "Course overview" "block"
     And "[data-sort='ul.timeaccess desc']" "css_element" in the "Course overview" "block" should be visible
 
+  Scenario: Short name sort persistence
+    Given I log in as "student1"
+    When I click on "sortingdropdown" "button" in the "Course overview" "block"
+    Then I should not see "Short name" in the "Course overview" "block"
+    When the following config values are set as admin:
+      | config               | value |
+      | courselistshortnames | 1     |
+    And I reload the page
+    And I click on "sortingdropdown" "button" in the "Course overview" "block"
+    And I click on "Short name" "link" in the "Course overview" "block"
+    And I reload the page
+    Then I should see "Short name" in the "Course overview" "block"
+    And "[data-sort='shortname']" "css_element" in the "Course overview" "block" should be visible
+
   Scenario: View inprogress courses with hide persistent functionality
     Given I log in as "student1"
     And I click on "All (except removed from view)" "button" in the "Course overview" "block"
index 6dc9b94..90c549e 100644 (file)
@@ -72,6 +72,7 @@ class block_myoverview_privacy_testcase extends \core_privacy\tests\provider_tes
         return array(
             array('block_myoverview_user_sort_preference', 'lastaccessed', ''),
             array('block_myoverview_user_sort_preference', 'title', ''),
+            array('block_myoverview_user_sort_preference', 'shortname', ''),
             array('block_myoverview_user_grouping_preference', 'allincludinghidden', ''),
             array('block_myoverview_user_grouping_preference', 'all', ''),
             array('block_myoverview_user_grouping_preference', 'inprogress', ''),
@@ -104,4 +105,4 @@ class block_myoverview_privacy_testcase extends \core_privacy\tests\provider_tes
             $blockpreferences->{$name}->description
         );
     }
-}
\ No newline at end of file
+}
index 8772356..13ac37b 100644 (file)
@@ -49,7 +49,7 @@
 </div>
 {{#js}}
 require(['jquery'], function($) {
-    require(['theme_boost/popover'], function() {
+    require(['theme_boost/bootstrap/popover'], function() {
         var target = $("#calendar-day-popover-link-{{courseid}}-{{year}}-{{yday}}-{{uniqid}}");
         target.popover({
             content: function() {
index f4f101b..3b15b1e 100644 (file)
@@ -67,7 +67,7 @@ if (has_capability('moodle/contentbank:upload', $context)) {
     $accepted = $cb->get_supported_extensions_as_string($context);
     if (!empty($accepted)) {
         $importurl = new moodle_url('/contentbank/upload.php', ['contextid' => $contextid]);
-        $toolbar[] = array('name' => 'Upload', 'link' => $importurl, 'icon' => 'i/upload');
+        $toolbar[] = array('name' => get_string('upload', 'contentbank'), 'link' => $importurl, 'icon' => 'i/upload');
     }
 }
 
index af5d289..b5260c4 100644 (file)
@@ -1126,6 +1126,58 @@ class core_course_renderer extends plugin_renderer_base {
         return $content;
     }
 
+    /**
+     * Returns HTML to display course name.
+     *
+     * @param coursecat_helper $chelper
+     * @param core_course_list_element $course
+     * @return string
+     */
+    protected function course_name(coursecat_helper $chelper, core_course_list_element $course): string {
+        $content = '';
+        if ($chelper->get_show_courses() >= self::COURSECAT_SHOW_COURSES_EXPANDED) {
+            $nametag = 'h3';
+        } else {
+            $nametag = 'div';
+        }
+        $coursename = $chelper->get_course_formatted_name($course);
+        $coursenamelink = html_writer::link(new moodle_url('/course/view.php', ['id' => $course->id]),
+            $coursename, ['class' => $course->visible ? '' : 'dimmed']);
+        $content .= html_writer::tag($nametag, $coursenamelink, ['class' => 'coursename']);
+        // If we display course in collapsed form but the course has summary or course contacts, display the link to the info page.
+        $content .= html_writer::start_tag('div', ['class' => 'moreinfo']);
+        if ($chelper->get_show_courses() < self::COURSECAT_SHOW_COURSES_EXPANDED) {
+            if ($course->has_summary() || $course->has_course_contacts() || $course->has_course_overviewfiles()
+                || $course->has_custom_fields()) {
+                $url = new moodle_url('/course/info.php', ['id' => $course->id]);
+                $image = $this->output->pix_icon('i/info', $this->strings->summary);
+                $content .= html_writer::link($url, $image, ['title' => $this->strings->summary]);
+                // Make sure JS file to expand course content is included.
+                $this->coursecat_include_js();
+            }
+        }
+        $content .= html_writer::end_tag('div');
+        return $content;
+    }
+
+    /**
+     * Returns HTML to display course enrolment icons.
+     *
+     * @param core_course_list_element $course
+     * @return string
+     */
+    protected function course_enrolment_icons(core_course_list_element $course): string {
+        $content = '';
+        if ($icons = enrol_get_course_info_icons($course)) {
+            $content .= html_writer::start_tag('div', ['class' => 'enrolmenticons']);
+            foreach ($icons as $icon) {
+                $content .= $this->render($icon);
+            }
+            $content .= html_writer::end_tag('div');
+        }
+        return $content;
+    }
+
     /**
      * Displays one course in the list of courses.
      *
@@ -1150,11 +1202,8 @@ class core_course_renderer extends plugin_renderer_base {
         }
         $content = '';
         $classes = trim('coursebox clearfix '. $additionalclasses);
-        if ($chelper->get_show_courses() >= self::COURSECAT_SHOW_COURSES_EXPANDED) {
-            $nametag = 'h3';
-        } else {
+        if ($chelper->get_show_courses() < self::COURSECAT_SHOW_COURSES_EXPANDED) {
             $classes .= ' collapsed';
-            $nametag = 'div';
         }
 
         // .coursebox
@@ -1165,128 +1214,151 @@ class core_course_renderer extends plugin_renderer_base {
         ));
 
         $content .= html_writer::start_tag('div', array('class' => 'info'));
-
-        // course name
-        $coursename = $chelper->get_course_formatted_name($course);
-        $coursenamelink = html_writer::link(new moodle_url('/course/view.php', array('id' => $course->id)),
-                                            $coursename, array('class' => $course->visible ? '' : 'dimmed'));
-        $content .= html_writer::tag($nametag, $coursenamelink, array('class' => 'coursename'));
-        // If we display course in collapsed form but the course has summary or course contacts, display the link to the info page.
-        $content .= html_writer::start_tag('div', array('class' => 'moreinfo'));
-        if ($chelper->get_show_courses() < self::COURSECAT_SHOW_COURSES_EXPANDED) {
-            if ($course->has_summary() || $course->has_course_contacts() || $course->has_course_overviewfiles()
-                    || $course->has_custom_fields()) {
-                $url = new moodle_url('/course/info.php', array('id' => $course->id));
-                $image = $this->output->pix_icon('i/info', $this->strings->summary);
-                $content .= html_writer::link($url, $image, array('title' => $this->strings->summary));
-                // Make sure JS file to expand course content is included.
-                $this->coursecat_include_js();
-            }
-        }
-        $content .= html_writer::end_tag('div'); // .moreinfo
-
-        // print enrolmenticons
-        if ($icons = enrol_get_course_info_icons($course)) {
-            $content .= html_writer::start_tag('div', array('class' => 'enrolmenticons'));
-            foreach ($icons as $pix_icon) {
-                $content .= $this->render($pix_icon);
-            }
-            $content .= html_writer::end_tag('div'); // .enrolmenticons
-        }
-
-        $content .= html_writer::end_tag('div'); // .info
+        $content .= $this->course_name($chelper, $course);
+        $content .= $this->course_enrolment_icons($course);
+        $content .= html_writer::end_tag('div');
 
         $content .= html_writer::start_tag('div', array('class' => 'content'));
         $content .= $this->coursecat_coursebox_content($chelper, $course);
-        $content .= html_writer::end_tag('div'); // .content
+        $content .= html_writer::end_tag('div');
 
         $content .= html_writer::end_tag('div'); // .coursebox
         return $content;
     }
 
     /**
-     * Returns HTML to display course content (summary, course contacts and optionally category name)
-     *
-     * This method is called from coursecat_coursebox() and may be re-used in AJAX
+     * Returns HTML to display course summary.
      *
-     * @param coursecat_helper $chelper various display options
-     * @param stdClass|core_course_list_element $course
+     * @param coursecat_helper $chelper
+     * @param core_course_list_element $course
      * @return string
      */
-    protected function coursecat_coursebox_content(coursecat_helper $chelper, $course) {
-        global $CFG;
-        if ($chelper->get_show_courses() < self::COURSECAT_SHOW_COURSES_EXPANDED) {
-            return '';
-        }
-        if ($course instanceof stdClass) {
-            $course = new core_course_list_element($course);
-        }
+    protected function course_summary(coursecat_helper $chelper, core_course_list_element $course): string {
         $content = '';
-
-        // display course summary
         if ($course->has_summary()) {
-            $content .= html_writer::start_tag('div', array('class' => 'summary'));
+            $content .= html_writer::start_tag('div', ['class' => 'summary']);
             $content .= $chelper->get_course_formatted_summary($course,
-                    array('overflowdiv' => true, 'noclean' => true, 'para' => false));
-            $content .= html_writer::end_tag('div'); // .summary
+                array('overflowdiv' => true, 'noclean' => true, 'para' => false));
+            $content .= html_writer::end_tag('div');
         }
+        return $content;
+    }
+
+    /**
+     * Returns HTML to display course contacts.
+     *
+     * @param core_course_list_element $course
+     * @return string
+     */
+    protected function course_contacts(core_course_list_element $course) {
+        $content = '';
+        if ($course->has_course_contacts()) {
+            $content .= html_writer::start_tag('ul', ['class' => 'teachers']);
+            foreach ($course->get_course_contacts() as $coursecontact) {
+                $rolenames = array_map(function ($role) {
+                    return $role->displayname;
+                }, $coursecontact['roles']);
+                $name = implode(", ", $rolenames).': '.
+                    html_writer::link(new moodle_url('/user/view.php',
+                        ['id' => $coursecontact['user']->id, 'course' => SITEID]),
+                        $coursecontact['username']);
+                $content .= html_writer::tag('li', $name);
+            }
+            $content .= html_writer::end_tag('ul');
+        }
+        return $content;
+    }
+
+    /**
+     * Returns HTML to display course overview files.
+     *
+     * @param core_course_list_element $course
+     * @return string
+     */
+    protected function course_overview_files(core_course_list_element $course): string {
+        global $CFG;
 
-        // display course overview files
         $contentimages = $contentfiles = '';
         foreach ($course->get_course_overviewfiles() as $file) {
             $isimage = $file->is_valid_image();
-            $url = file_encode_url("$CFG->wwwroot/pluginfile.php",
-                    '/'. $file->get_contextid(). '/'. $file->get_component(). '/'.
-                    $file->get_filearea(). $file->get_filepath(). $file->get_filename(), !$isimage);
+            $url = moodle_url::make_file_url("$CFG->wwwroot/pluginfile.php",
+                '/' . $file->get_contextid() . '/' . $file->get_component() . '/' .
+                $file->get_filearea() . $file->get_filepath() . $file->get_filename(), !$isimage);
             if ($isimage) {
                 $contentimages .= html_writer::tag('div',
-                        html_writer::empty_tag('img', array('src' => $url)),
-                        array('class' => 'courseimage'));
+                    html_writer::empty_tag('img', ['src' => $url]),
+                    ['class' => 'courseimage']);
             } else {
                 $image = $this->output->pix_icon(file_file_icon($file, 24), $file->get_filename(), 'moodle');
-                $filename = html_writer::tag('span', $image, array('class' => 'fp-icon')).
-                        html_writer::tag('span', $file->get_filename(), array('class' => 'fp-filename'));
+                $filename = html_writer::tag('span', $image, ['class' => 'fp-icon']).
+                    html_writer::tag('span', $file->get_filename(), ['class' => 'fp-filename']);
                 $contentfiles .= html_writer::tag('span',
-                        html_writer::link($url, $filename),
-                        array('class' => 'coursefile fp-filename-icon'));
+                    html_writer::link($url, $filename),
+                    ['class' => 'coursefile fp-filename-icon']);
             }
         }
-        $content .= $contentimages. $contentfiles;
-
-        // Display course contacts. See core_course_list_element::get_course_contacts().
-        if ($course->has_course_contacts()) {
-            $content .= html_writer::start_tag('ul', array('class' => 'teachers'));
-            foreach ($course->get_course_contacts() as $coursecontact) {
-                $rolenames = array_map(function ($role) {
-                    return $role->displayname;
-                }, $coursecontact['roles']);
-                $name = implode(", ", $rolenames).': '.
-                        html_writer::link(new moodle_url('/user/view.php',
-                                array('id' => $coursecontact['user']->id, 'course' => SITEID)),
-                            $coursecontact['username']);
-                $content .= html_writer::tag('li', $name);
-            }
-            $content .= html_writer::end_tag('ul'); // .teachers
-        }
+        return $contentimages . $contentfiles;
+    }
 
-        // display course category if necessary (for example in search results)
+    /**
+     * Returns HTML to display course category name.
+     *
+     * @param coursecat_helper $chelper
+     * @param core_course_list_element $course
+     * @return string
+     */
+    protected function course_category_name(coursecat_helper $chelper, core_course_list_element $course): string {
+        $content = '';
+        // Display course category if necessary (for example in search results).
         if ($chelper->get_show_courses() == self::COURSECAT_SHOW_COURSES_EXPANDED_WITH_CAT) {
             if ($cat = core_course_category::get($course->category, IGNORE_MISSING)) {
-                $content .= html_writer::start_tag('div', array('class' => 'coursecat'));
+                $content .= html_writer::start_tag('div', ['class' => 'coursecat']);
                 $content .= get_string('category').': '.
-                        html_writer::link(new moodle_url('/course/index.php', array('categoryid' => $cat->id)),
-                                $cat->get_formatted_name(), array('class' => $cat->visible ? '' : 'dimmed'));
-                $content .= html_writer::end_tag('div'); // .coursecat
+                    html_writer::link(new moodle_url('/course/index.php', ['categoryid' => $cat->id]),
+                        $cat->get_formatted_name(), ['class' => $cat->visible ? '' : 'dimmed']);
+                $content .= html_writer::end_tag('div');
             }
         }
+        return $content;
+    }
 
-        // Display custom fields.
+    /**
+     * Returns HTML to display course custom fields.
+     *
+     * @param core_course_list_element $course
+     * @return string
+     */
+    protected function course_custom_fields(core_course_list_element $course): string {
+        $content = '';
         if ($course->has_custom_fields()) {
             $handler = core_course\customfield\course_handler::create();
             $customfields = $handler->display_custom_fields_data($course->get_custom_fields());
             $content .= \html_writer::tag('div', $customfields, ['class' => 'customfields-container']);
         }
+        return $content;
+    }
 
+    /**
+     * Returns HTML to display course content (summary, course contacts and optionally category name)
+     *
+     * This method is called from coursecat_coursebox() and may be re-used in AJAX
+     *
+     * @param coursecat_helper $chelper various display options
+     * @param stdClass|core_course_list_element $course
+     * @return string
+     */
+    protected function coursecat_coursebox_content(coursecat_helper $chelper, $course) {
+        if ($chelper->get_show_courses() < self::COURSECAT_SHOW_COURSES_EXPANDED) {
+            return '';
+        }
+        if ($course instanceof stdClass) {
+            $course = new core_course_list_element($course);
+        }
+        $content = $this->course_summary($chelper, $course);
+        $content .= $this->course_overview_files($course);
+        $content .= $this->course_contacts($course);
+        $content .= $this->course_category_name($chelper, $course);
+        $content .= $this->course_custom_fields($course);
         return $content;
     }
 
index 195a6eb..45dc3a1 100644 (file)
@@ -5,6 +5,15 @@ information provided here is intended especially for developers.
 
 * The function get_module_metadata is now deprecated. Please use \core_course\local\service\content_item_service instead.
 * Activity module names are now PARAM_ALPHANUM instead of PARAM_ALPHA so integers can be used in activity module names
+* The following functions have been added to core_course_renderer class to have more granularity. They can be overriden in
+  extending classes:
+  - course_name
+  - course_enrolment_icons
+  - course_summary
+  - course_contacts
+  - course_overview_files
+  - course_category_name
+  - course_custom_fields
 
 === 3.8 ===
 
index 49e51ba..cd3c979 100644 (file)
@@ -124,9 +124,7 @@ Feature: Teacher can search and enrol users one by one into the course
     And I should see "Student 001"
     And I click on "Enrol users" "button" in the "Enrol users" "dialogue"
     Then I should see "Active" in the "Student 001" "table_row"
-    # The following line is commented out as auto-hidden toasts fire events in the wrong place.
-    # TODO Uncomment this when we upgrade Bootstrap. This issue is fixed in v4.4.0 - see MDL-67386.
-    #And I should see "1 enrolled users"
+    And I should see "1 enrolled users"
 
   @javascript
   Scenario: Searching for a non-existing user
index 81c7625..6cd3484 100644 (file)
@@ -502,4 +502,43 @@ class api {
 
         return ($h5p) ? $h5p : null;
     }
+
+    /**
+     * Return the H5P export information file when the file has been deployed.
+     * Otherwise, return null if H5P file:
+     * i) has not been deployed.
+     * ii) has changed the content.
+     *
+     * The information returned will be:
+     * - filename, filepath, mimetype, filesize, timemodified and fileurl.
+     *
+     * @param int $contextid ContextId of the H5P activity.
+     * @param factory $factory The \core_h5p\factory object.
+     * @param string $component component
+     * @param string $filearea file area
+     * @return array|null Return file info otherwise null.
+     */
+    public static function get_export_info_from_context_id(int $contextid,
+        factory $factory,
+        string $component,
+        string $filearea): ?array {
+
+        $core = $factory->get_core();
+        $fs = get_file_storage();
+        $files = $fs->get_area_files($contextid, $component, $filearea, 0, 'id', false);
+        $file = reset($files);
+
+        if ($h5p = self::get_content_from_pathnamehash($file->get_pathnamehash())) {
+            if ($h5p->contenthash == $file->get_contenthash()) {
+                $content = $core->loadContent($h5p->id);
+                $slug = $content['slug'] ? $content['slug'] . '-' : '';
+                $filename = "{$slug}{$content['id']}.h5p";
+                $deployedfile = helper::get_export_info($filename, null, $factory);
+
+                return $deployedfile;
+            }
+        }
+
+        return null;
+    }
 }
index 93780fa..8e85ffc 100644 (file)
@@ -429,4 +429,50 @@ class helper {
 
         return $strings;
     }
+
+    /**
+     * Get the information related to the H5P export file.
+     * The information returned will be:
+     * - filename, filepath, mimetype, filesize, timemodified and fileurl.
+     *
+     * @param  string $exportfilename The H5P export filename (with slug).
+     * @param  \moodle_url $url The URL of the exported file.
+     * @param  factory $factory The \core_h5p\factory object
+     * @return array|null The information export file otherwise null.
+     */
+    public static function get_export_info(string $exportfilename, \moodle_url $url = null, ?factory $factory = null): ?array {
+
+        if (!$factory) {
+            $factory = new factory();
+        }
+        $core = $factory->get_core();
+
+        // Get export file.
+        if (!$fileh5p = $core->fs->get_export_file($exportfilename)) {
+            return null;
+        }
+
+        // Build the export info array.
+        $file = [];
+        $file['filename'] = $fileh5p->get_filename();
+        $file['filepath'] = $fileh5p->get_filepath();
+        $file['mimetype'] = $fileh5p->get_mimetype();
+        $file['filesize'] = $fileh5p->get_filesize();
+        $file['timemodified'] = $fileh5p->get_timemodified();
+
+        if (!$url) {
+            $url  = \moodle_url::make_webservice_pluginfile_url(
+                $fileh5p->get_contextid(),
+                $fileh5p->get_component(),
+                $fileh5p->get_filearea(),
+                '',
+                '',
+                $fileh5p->get_filename()
+            );
+        }
+
+        $file['fileurl'] = $url->out(false);
+
+        return $file;
+    }
 }
index 23c5420..9cbc121 100644 (file)
@@ -456,7 +456,7 @@ class player {
     }
 
     /**
-     * Return the export file for Mobile App.
+     * Return the info export file for Mobile App.
      *
      * @return array
      */
@@ -467,23 +467,8 @@ class player {
         $path = $exporturl->out_as_local_url();
         $parts = explode('/', $path);
         $filename = array_pop($parts);
-        // Get the the export file.
-        $systemcontext = \context_system::instance();
-        $fs = get_file_storage();
-        $fileh5p = $fs->get_file($systemcontext->id,
-            \core_h5p\file_storage::COMPONENT,
-            \core_h5p\file_storage::EXPORT_FILEAREA,
-            0,
-            '/',
-            $filename);
-        // Get the options that the Mobile App needs.
-        $file = [];
-        $file['filename'] = $fileh5p->get_filename();
-        $file['filepath'] = $fileh5p->get_filepath();
-        $file['mimetype'] = $fileh5p->get_mimetype();
-        $file['filesize'] = $fileh5p->get_filesize();
-        $file['timemodified'] = $fileh5p->get_timemodified();
-        $file['fileurl'] = $exporturl->out(false);
+        // Get the required info from the export file to be able to get the export file by third apps.
+        $file = helper::get_export_info($filename, $exporturl);
 
         return $file;
     }
index f7f01b8..e1ec76e 100644 (file)
@@ -451,4 +451,56 @@ class api_testcase extends \advanced_testcase {
         api::delete_content_from_pluginfile_url($url->out(), $factory);
         $this->assertEquals(0, $DB->count_records('h5p'));
     }
+
+    /**
+     * Test the behaviour of get_export_info_from_context_id().
+     */
+    public function test_get_export_info_from_context_id(): void {
+        global $DB;
+
+        $this->setRunTestInSeparateProcess(true);
+        $this->resetAfterTest();
+        $factory = new factory();
+
+        // Create the H5P data.
+        $filename = 'find-the-words.h5p';
+        $syscontext = \context_system::instance();
+
+        // Test scenario 1: H5P exists and deployed.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $fakeexportfile = $generator->create_export_file($filename,
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            \core_h5p\file_storage::EXPORT_FILEAREA);
+
+        $exportfile = api::get_export_info_from_context_id($syscontext->id,
+            $factory,
+            \core_h5p\file_storage::COMPONENT,
+            \core_h5p\file_storage::EXPORT_FILEAREA);
+        $this->assertEquals($fakeexportfile['filename'], $exportfile['filename']);
+        $this->assertEquals($fakeexportfile['filepath'], $exportfile['filepath']);
+        $this->assertEquals($fakeexportfile['filesize'], $exportfile['filesize']);
+        $this->assertEquals($fakeexportfile['timemodified'], $exportfile['timemodified']);
+        $this->assertEquals($fakeexportfile['fileurl'], $exportfile['fileurl']);
+
+        // Test scenario 2: H5P exist, deployed but the content has changed.
+        // We need to change the contenthash to simulate the H5P file was changed.
+        $h5pfile = $DB->get_record('h5p', []);
+        $h5pfile->contenthash = sha1('testedit');
+        $DB->update_record('h5p', $h5pfile);
+        $exportfile = api::get_export_info_from_context_id($syscontext->id,
+            $factory,
+            \core_h5p\file_storage::COMPONENT,
+            \core_h5p\file_storage::EXPORT_FILEAREA);
+        $this->assertNull($exportfile);
+
+        // Tests scenario 3: H5P is not deployed.
+        // We need to delete the H5P record to simulate the H5P was not deployed.
+        $DB->delete_records('h5p', ['id' => $h5pfile->id]);
+        $exportfile = api::get_export_info_from_context_id($syscontext->id,
+            $factory,
+            \core_h5p\file_storage::COMPONENT,
+            \core_h5p\file_storage::EXPORT_FILEAREA);
+        $this->assertNull($exportfile);
+    }
 }
index 737c7cc..42f1aea 100644 (file)
@@ -55,56 +55,45 @@ class core_h5p_external_testcase extends externallib_advanced_testcase {
      * test_get_trusted_h5p_file description
      */
     public function test_get_trusted_h5p_file() {
-        global $DB;
         $this->resetAfterTest(true);
         $this->setAdminUser();
 
         // This is a valid .H5P file.
         $filename = 'find-the-words.h5p';
-        $path = __DIR__ . '/fixtures/'.$filename;
         $syscontext = \context_system::instance();
-        $filerecord = [
-            'contextid' => $syscontext->id,
-            'component' => \core_h5p\file_storage::COMPONENT,
-            'filearea'  => 'unittest',
-            'itemid'    => 0,
-            'filepath'  => '/',
-            'filename'  => $filename,
-        ];
-        // Load the h5p file into DB.
-        $fs = get_file_storage();
-        $file = $fs->create_file_from_pathname($filerecord, $path);
+
+        // Create a fake export H5P file with normal pluginfile call.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $deployedfile = $generator->create_export_file($filename,
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            \core_h5p\file_storage::EXPORT_FILEAREA,
+            $generator::PLUGINFILE);
+
         // Make the URL to pass to the WS.
         $url  = \moodle_url::make_pluginfile_url(
             $syscontext->id,
             \core_h5p\file_storage::COMPONENT,
-            'unittest',
+            \core_h5p\file_storage::EXPORT_FILEAREA,
             0,
             '/',
             $filename
         );
+
         // Call the WS.
-        $result = external::get_trusted_h5p_file($url->out(), 0, 0, 0, 0);
+        $result = external::get_trusted_h5p_file($url->out(false), 0, 0, 0, 0);
         $result = external_api::clean_returnvalue(external::get_trusted_h5p_file_returns(), $result);
         // Expected result: Just 1 record on files and none on warnings.
         $this->assertCount(1, $result['files']);
         $this->assertCount(0, $result['warnings']);
-        // Get the export file in the DB to compare with the ws's results.
-        $fileh5p = $this->get_export_file($filename, $file->get_pathnamehash());
-        $fileh5purl  = \moodle_url::make_pluginfile_url(
-            $syscontext->id,
-            \core_h5p\file_storage::COMPONENT,
-            \core_h5p\file_storage::EXPORT_FILEAREA,
-            '',
-            '',
-            $fileh5p->get_filename()
-        );
-        $this->assertEquals($fileh5p->get_filepath(), $result['files'][0]['filepath']);
-        $this->assertEquals($fileh5p->get_mimetype(), $result['files'][0]['mimetype']);
-        $this->assertEquals($fileh5p->get_filesize(), $result['files'][0]['filesize']);
-        $this->assertEquals($fileh5p->get_timemodified(), $result['files'][0]['timemodified']);
-        $this->assertEquals($fileh5p->get_filename(), $result['files'][0]['filename']);
-        $this->assertEquals($fileh5purl->out(), $result['files'][0]['fileurl']);
+
+        // Check info export file to compare with the ws's results.
+        $this->assertEquals($deployedfile['filepath'], $result['files'][0]['filepath']);
+        $this->assertEquals($deployedfile['mimetype'], $result['files'][0]['mimetype']);
+        $this->assertEquals($deployedfile['filesize'], $result['files'][0]['filesize']);
+        $this->assertEquals($deployedfile['timemodified'], $result['files'][0]['timemodified']);
+        $this->assertEquals($deployedfile['filename'], $result['files'][0]['filename']);
+        $this->assertEquals($deployedfile['fileurl'], $result['files'][0]['fileurl']);
     }
 
     /**
@@ -170,56 +159,41 @@ class core_h5p_external_testcase extends externallib_advanced_testcase {
      * using webservice/pluginfile.php as url param.
      */
     public function test_allow_webservice_pluginfile_in_url_param() {
-        global $DB;
         $this->resetAfterTest(true);
         $this->setAdminUser();
 
         // This is a valid .H5P file.
         $filename = 'find-the-words.h5p';
-        $path = __DIR__ . '/fixtures/'.$filename;
         $syscontext = \context_system::instance();
-        $filerecord = [
-            'contextid' => $syscontext->id,
-            'component' => \core_h5p\file_storage::COMPONENT,
-            'filearea'  => 'unittest',
-            'itemid'    => 0,
-            'filepath'  => '/',
-            'filename'  => $filename,
-        ];
-        // Load the h5p file into DB.
-        $fs = get_file_storage();
-        $file = $fs->create_file_from_pathname($filerecord, $path);
+
+        // Create a fake export H5P file with webservice call.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $deployedfile = $generator->create_export_file($filename,
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            \core_h5p\file_storage::EXPORT_FILEAREA);
+
         // Make the URL to pass to the WS.
         $url  = \moodle_url::make_webservice_pluginfile_url(
             $syscontext->id,
             \core_h5p\file_storage::COMPONENT,
-            'unittest',
+            \core_h5p\file_storage::EXPORT_FILEAREA,
             0,
             '/',
             $filename
         );
+
         // Call the WS.
         $result = external::get_trusted_h5p_file($url->out(), 0, 0, 0, 0);
         $result = external_api::clean_returnvalue(external::get_trusted_h5p_file_returns(), $result);
-        // Expected result: Just 1 record on files and none on warnings.
-        $this->assertCount(1, $result['files']);
-        $this->assertCount(0, $result['warnings']);
-        // Get the export file in the DB to compare with the ws's results.
-        $fileh5p = $this->get_export_file($filename, $file->get_pathnamehash());
-        $fileh5purl  = \moodle_url::make_webservice_pluginfile_url(
-            $syscontext->id,
-            \core_h5p\file_storage::COMPONENT,
-            \core_h5p\file_storage::EXPORT_FILEAREA,
-            '',
-            '',
-            $fileh5p->get_filename()
-        );
-        $this->assertEquals($fileh5p->get_filepath(), $result['files'][0]['filepath']);
-        $this->assertEquals($fileh5p->get_mimetype(), $result['files'][0]['mimetype']);
-        $this->assertEquals($fileh5p->get_filesize(), $result['files'][0]['filesize']);
-        $this->assertEquals($fileh5p->get_timemodified(), $result['files'][0]['timemodified']);
-        $this->assertEquals($fileh5p->get_filename(), $result['files'][0]['filename']);
-        $this->assertEquals($fileh5purl->out(), $result['files'][0]['fileurl']);
+
+        // Check info export file to compare with the ws's results.
+        $this->assertEquals($deployedfile['filepath'], $result['files'][0]['filepath']);
+        $this->assertEquals($deployedfile['mimetype'], $result['files'][0]['mimetype']);
+        $this->assertEquals($deployedfile['filesize'], $result['files'][0]['filesize']);
+        $this->assertEquals($deployedfile['timemodified'], $result['files'][0]['timemodified']);
+        $this->assertEquals($deployedfile['filename'], $result['files'][0]['filename']);
+        $this->assertEquals($deployedfile['fileurl'], $result['files'][0]['fileurl']);
     }
 
     /**
@@ -227,83 +201,46 @@ class core_h5p_external_testcase extends externallib_advanced_testcase {
      * using tokenpluginfile.php as url param.
      */
     public function test_allow_tokenluginfile_in_url_param() {
-        global $DB;
         $this->resetAfterTest(true);
         $this->setAdminUser();
 
         // This is a valid .H5P file.
         $filename = 'find-the-words.h5p';
-        $path = __DIR__ . '/fixtures/'.$filename;
         $syscontext = \context_system::instance();
-        $filerecord = [
-            'contextid' => $syscontext->id,
-            'component' => \core_h5p\file_storage::COMPONENT,
-            'filearea'  => 'unittest',
-            'itemid'    => 0,
-            'filepath'  => '/',
-            'filename'  => $filename,
-        ];
-        // Load the h5p file into DB.
-        $fs = get_file_storage();
-        $file = $fs->create_file_from_pathname($filerecord, $path);
+
+        // Create a fake export H5P file with tokenfile call.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $deployedfile = $generator->create_export_file($filename,
+            $syscontext->id,
+            \core_h5p\file_storage::COMPONENT,
+            \core_h5p\file_storage::EXPORT_FILEAREA,
+            $generator::TOKENPLUGINFILE);
+
         // Make the URL to pass to the WS.
         $url  = \moodle_url::make_pluginfile_url(
             $syscontext->id,
             \core_h5p\file_storage::COMPONENT,
-            'unittest',
+            \core_h5p\file_storage::EXPORT_FILEAREA,
             0,
             '/',
             $filename,
             false,
             true
         );
+
         // Call the WS.
-        $result = external::get_trusted_h5p_file($url->out(), 0, 0, 0, 0);
+        $result = external::get_trusted_h5p_file($url->out(false), 0, 0, 0, 0);
         $result = external_api::clean_returnvalue(external::get_trusted_h5p_file_returns(), $result);
         // Expected result: Just 1 record on files and none on warnings.
         $this->assertCount(1, $result['files']);
         $this->assertCount(0, $result['warnings']);
-        // Get the export file in the DB to compare with the ws's results.
-        $fileh5p = $this->get_export_file($filename, $file->get_pathnamehash());
-        $fileh5purl  = \moodle_url::make_pluginfile_url(
-            $syscontext->id,
-            \core_h5p\file_storage::COMPONENT,
-            \core_h5p\file_storage::EXPORT_FILEAREA,
-            '',
-            '',
-            $fileh5p->get_filename(),
-            false,
-            true
-        );
-        $this->assertEquals($fileh5p->get_filepath(), $result['files'][0]['filepath']);
-        $this->assertEquals($fileh5p->get_mimetype(), $result['files'][0]['mimetype']);
-        $this->assertEquals($fileh5p->get_filesize(), $result['files'][0]['filesize']);
-        $this->assertEquals($fileh5p->get_timemodified(), $result['files'][0]['timemodified']);
-        $this->assertEquals($fileh5p->get_filename(), $result['files'][0]['filename']);
-        $this->assertEquals($fileh5purl->out(), $result['files'][0]['fileurl']);
-    }
 
-    /**
-     * Get the H5P export file.
-     *
-     * @param string $filename
-     * @param string $pathnamehash
-     * @return stored_file
-     */
-    protected function get_export_file($filename, $pathnamehash) {
-        global $DB;
-
-        // Simulate the filenameexport using slug as H5P does.
-        $id = $DB->get_field('h5p', 'id', ['pathnamehash' => $pathnamehash]);
-        $filenameexport = basename($filename, '.h5p').'-'.$id.'-'.$id.'.h5p';
-        $syscontext = \context_system::instance();
-        $fs = get_file_storage();
-        $fileh5p = $fs->get_file($syscontext->id,
-            \core_h5p\file_storage::COMPONENT,
-            \core_h5p\file_storage::EXPORT_FILEAREA,
-            0,
-            '/',
-            $filenameexport);
-        return $fileh5p;
+        // Check info export file to compare with the ws's results.
+        $this->assertEquals($deployedfile['filepath'], $result['files'][0]['filepath']);
+        $this->assertEquals($deployedfile['mimetype'], $result['files'][0]['mimetype']);
+        $this->assertEquals($deployedfile['filesize'], $result['files'][0]['filesize']);
+        $this->assertEquals($deployedfile['timemodified'], $result['files'][0]['timemodified']);
+        $this->assertEquals($deployedfile['filename'], $result['files'][0]['filename']);
+        $this->assertEquals($deployedfile['fileurl'], $result['files'][0]['fileurl']);
     }
 }
index 9cf4313..88c229d 100644 (file)
@@ -25,6 +25,8 @@
 
 use core_h5p\local\library\autoloader;
 use core_h5p\core;
+use core_h5p\player;
+use core_h5p\factory;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -38,6 +40,13 @@ defined('MOODLE_INTERNAL') || die();
  */
 class core_h5p_generator extends \component_generator_base {
 
+    /** Url pointing to webservice plugin file. */
+    public const WSPLUGINFILE = 0;
+    /** Url pointing to token plugin file. */
+    public const TOKENPLUGINFILE = 1;
+    /** Url pointing to plugin file. */
+    public const PLUGINFILE = 2;
+
     /**
      * Convenience function to create a file.
      *
@@ -428,4 +437,121 @@ class core_h5p_generator extends \component_generator_base {
         $fs = new file_storage();
         return $fs->create_file_from_string($filerecord, $content);
     }
+
+    /**
+     * Create a fake export H5P deployed file.
+     *
+     * @param string $filename Name of the H5P file to deploy.
+     * @param int $contextid Context id of the H5P activity.
+     * @param string $component component.
+     * @param string $filearea file area.
+     * @param int $typeurl Type of url to create the export url plugin file.
+     * @return array return deployed file information.
+     */
+    public function create_export_file(string $filename, int $contextid,
+        string $component,
+        string $filearea,
+        int $typeurl = self::WSPLUGINFILE): array {
+        global $CFG;
+
+        // We need the autoloader for H5P player.
+        autoloader::register();
+
+        $path = $CFG->dirroot.'/h5p/tests/fixtures/'.$filename;
+        $filerecord = [
+            'contextid' => $contextid,
+            'component' => $component,
+            'filearea'  => $filearea,
+            'itemid'    => 0,
+            'filepath'  => '/',
+            'filename'  => $filename,
+        ];
+        // Load the h5p file into DB.
+        $fs = get_file_storage();
+        if (!$fs->get_file($contextid, $component, $filearea, $filerecord['itemid'], $filerecord['filepath'], $filename)) {
+            $fs->create_file_from_pathname($filerecord, $path);
+        }
+
+        // Make the URL to pass to the player.
+        if ($typeurl == self::WSPLUGINFILE) {
+            $url = \moodle_url::make_webservice_pluginfile_url(
+                $filerecord['contextid'],
+                $filerecord['component'],
+                $filerecord['filearea'],
+                $filerecord['itemid'],
+                $filerecord['filepath'],
+                $filerecord['filename']
+            );
+        } else {
+            $includetoken = false;
+            if ($typeurl == self::TOKENPLUGINFILE) {
+                $includetoken = true;
+            }
+            $url = \moodle_url::make_pluginfile_url(
+                $filerecord['contextid'],
+                $filerecord['component'],
+                $filerecord['filearea'],
+                $filerecord['itemid'],
+                $filerecord['filepath'],
+                $filerecord['filename'],
+                false,
+                $includetoken
+            );
+        }
+
+        $config = new stdClass();
+        $h5pplayer = new player($url->out(false), $config);
+        // We need to add assets to page to create the export file.
+        $h5pplayer->add_assets_to_page();
+
+        // Call the method. We need the id of the new H5P content.
+        $rc = new \ReflectionClass(player::class);
+        $rcp = $rc->getProperty('h5pid');
+        $rcp->setAccessible(true);
+        $h5pid = $rcp->getValue($h5pplayer);
+
+        // Get the info export file.
+        $factory = new factory();
+        $core = $factory->get_core();
+        $content = $core->loadContent($h5pid);
+        $slug = $content['slug'] ? $content['slug'] . '-' : '';
+        $exportfilename = "{$slug}{$h5pid}.h5p";
+        $fileh5p = $core->fs->get_export_file($exportfilename);
+        $deployedfile = [];
+        $deployedfile['filename'] = $fileh5p->get_filename();
+        $deployedfile['filepath'] = $fileh5p->get_filepath();
+        $deployedfile['mimetype'] = $fileh5p->get_mimetype();
+        $deployedfile['filesize'] = $fileh5p->get_filesize();
+        $deployedfile['timemodified'] = $fileh5p->get_timemodified();
+
+        // Create the url depending the request was made through typeurl.
+        if ($typeurl == self::WSPLUGINFILE) {
+            $url  = \moodle_url::make_webservice_pluginfile_url(
+                $fileh5p->get_contextid(),
+                $fileh5p->get_component(),
+                $fileh5p->get_filearea(),
+                '',
+                '',
+                $fileh5p->get_filename()
+            );
+        } else {
+            $includetoken = false;
+            if ($typeurl == self::TOKENPLUGINFILE) {
+                $includetoken = true;
+            }
+            $url = \moodle_url::make_pluginfile_url(
+                $fileh5p->get_contextid(),
+                $fileh5p->get_component(),
+                $fileh5p->get_filearea(),
+                '',
+                '',
+                $fileh5p->get_filename(),
+                false,
+                $includetoken
+            );
+        }
+        $deployedfile['fileurl'] = $url->out(false);
+
+        return $deployedfile;
+    }
 }
index 734bd08..a58f61a 100644 (file)
@@ -328,4 +328,55 @@ class helper_testcase extends \advanced_testcase {
         $this->assertCount(7, $messages->error);
         $this->assertCount(2, $messages->info);
     }
+
+    /**
+     * Test the behaviour of get_export_info().
+     */
+    public function test_get_export_info(): void {
+         $this->resetAfterTest();
+
+        $filename = 'guess-the-answer.h5p';
+        $syscontext = \context_system::instance();
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $deployedfile = $generator->create_export_file($filename,
+            $syscontext->id,
+            file_storage::COMPONENT,
+            file_storage::EXPORT_FILEAREA);
+
+        // Test scenario 1: Get export information from correct filename.
+        $helperfile = helper::get_export_info($deployedfile['filename']);
+        $this->assertEquals($deployedfile['filename'], $helperfile['filename']);
+        $this->assertEquals($deployedfile['filepath'], $helperfile['filepath']);
+        $this->assertEquals($deployedfile['filesize'], $helperfile['filesize']);
+        $this->assertEquals($deployedfile['timemodified'], $helperfile['timemodified']);
+        $this->assertEquals($deployedfile['fileurl'], $helperfile['fileurl']);
+
+        // Test scenario 2: Get export information from correct filename and url.
+        $url = \moodle_url::make_pluginfile_url(
+            $syscontext->id,
+            file_storage::COMPONENT,
+            'unittest',
+            0,
+            '/',
+            $deployedfile['filename'],
+            false,
+            true
+        );
+        $helperfile = helper::get_export_info($deployedfile['filename'], $url);
+        $this->assertEquals($url, $helperfile['fileurl']);
+
+        // Test scenario 3: Get export information from correct filename and factory.
+        $factory = new \core_h5p\factory();
+        $helperfile = helper::get_export_info($deployedfile['filename'], null, $factory);
+        $this->assertEquals($deployedfile['filename'], $helperfile['filename']);
+        $this->assertEquals($deployedfile['filepath'], $helperfile['filepath']);
+        $this->assertEquals($deployedfile['filesize'], $helperfile['filesize']);
+        $this->assertEquals($deployedfile['timemodified'], $helperfile['timemodified']);
+        $this->assertEquals($deployedfile['fileurl'], $helperfile['fileurl']);
+
+        // Test scenario 4: Get export information from wrong filename.
+        $helperfile = helper::get_export_info('nofileexist.h5p', $url);
+        $this->assertNull($helperfile);
+    }
 }
index fa6690d..11bbe84 100644 (file)
@@ -270,6 +270,7 @@ $string['defaultissuerpassword_help'] = 'An account is required on the backpack
 $string['defaultissuername'] = 'Badge issuer name';
 $string['defaultissuername_desc'] = 'Name of the issuing agent or authority.';
 $string['delbadge'] = 'Would you like to delete badge \'{$a}\' and remove all existing issued badges?';
+$string['delexternalbackpack'] = 'Delete site backpack';
 $string['delexternalbackpackconfirm'] = 'Delete site backpack \'{$a}\'?';
 $string['delconfirm'] = 'Delete and remove existing issued badges';
 $string['deletehelp'] = '<p>Fully deleting a badge means that all its information and criteria records will be permanently removed. Users who have earned this badge will no longer be able to access it and display it on their profile pages.</p>
@@ -279,7 +280,6 @@ $string['delparamconfirm'] = 'Are you sure that you want to delete this paramete
 $string['description'] = 'Description';
 $string['disconnect'] = 'Disconnect';
 $string['donotaward'] = 'Currently, this badge is not active, so it cannot be awarded to users. If you would like to award this badge, please set its status to active.';
-$string['editsettings'] = 'Edit settings';
 $string['enablebadges'] = 'Enable badges';
 $string['endorsement'] = 'Endorsement';
 $string['error:backpackdatainvalid'] = 'The data return from the backpack was invalid.';
@@ -402,6 +402,7 @@ $string['month'] = 'Month(s)';
 $string['mybadges'] = 'My badges';
 $string['mybackpack'] = 'My backpack settings';
 $string['never'] = 'Never';
+$string['newbackpack'] = 'Add a new backpack';
 $string['newbadge'] = 'Add a new badge';
 $string['newimage'] = 'New image';
 $string['noalignment'] = 'This badge does not have any external skills or standards specified.';
@@ -513,6 +514,8 @@ $string['selecting'] = 'With selected badges...';
 $string['setup'] = 'Set up connection';
 $string['sitebackpack'] = 'Active external backpack';
 $string['sitebackpack_help'] = 'The external backpack that users can connect to from this site. Note that changing this setting after users have connected their backpacks will require each user to go to their backpack settings page and disconnect then reconnect.';
+$string['sitebackpackdeleted'] = 'The site backpack has been deleted.';
+$string['sitebackpacknotdeleted'] = 'This backpack couldn\'t be deleted because it\'s currently the site default.';
 $string['sitebackpackverify'] = 'Backpack connection';
 $string['sitebackpackwarning'] = 'Could not connect to backpack. <br/><br/>Check that the "Badge issuer email address" admin setting is the valid email for an account on the backpack website. <br/><br/>Check that the "Badge issuer password" on the <a href="{$a->url}">site backpack settings page</a>, is the correct password for the account on the backpack website. <br/><br/>The backpack returned: "{$a->warning}"';
 $string['sitebadges'] = 'Site badges';
@@ -572,3 +575,6 @@ $string['backpackbadges'] = 'You have {$a->totalbadges} badge(s) displayed from
 $string['error:nogroups'] = '<p>There are no public collections of badges available in your backpack. </p> <p>Only public collections are shown. <a href="https://backpack.openbadges.org">Visit your backpack</a> to create some public collections.</p>';
 $string['nobackpackbadges'] = 'There are no badges in the collections you have selected. <a href="mybackpack.php">Add more collections</a>.';
 $string['nobackpackcollections'] = 'No badge collections have been selected. <a href="mybackpack.php">Add collections</a>.';
+
+// Deprecated since Moodle 3.9.
+$string['editsettings'] = 'Edit settings';
index 280b3bd..0164c28 100644 (file)
@@ -141,3 +141,4 @@ europe/belfast,core_timezones
 pacific/ponape,core_timezones
 pacific/truk,core_timezones
 pacific/yap,core_timezones
+editsettings,core_badges
\ No newline at end of file
index 6790227..c1d2d0f 100644 (file)
@@ -809,6 +809,47 @@ function badges_update_site_backpack($id, $data) {
     return false;
 }
 
+
+/**
+ * Delete the backpack with this id.
+ *
+ * @param integer $id The backpack to delete.
+ * @return boolean
+ */
+function badges_delete_site_backpack($id) {
+    global $DB, $CFG;
+
+    $context = context_system::instance();
+    require_capability('moodle/badges:manageglobalsettings', $context);
+
+    // Only remove site backpack if it's not the default one.
+    if ($CFG->badges_site_backpack != $id && $DB->record_exists('badge_external_backpack', ['id' => $id])) {
+        $transaction = $DB->start_delegated_transaction();
+
+        // Remove connections for users to this backpack.
+        $sql = "SELECT DISTINCT bb.id
+                  FROM {badge_backpack} bb
+                 WHERE bb.externalbackpackid = :backpackid";
+        $params = ['backpackid' => $id];
+        $userbackpacks = $DB->get_fieldset_sql($sql, $params);
+        if ($userbackpacks) {
+            // Delete user external collections references to this backpack.
+            list($insql, $params) = $DB->get_in_or_equal($userbackpacks);
+            $DB->delete_records_select('badge_external', "backpackid $insql", $params);
+        }
+        $DB->delete_records('badge_backpack', ['externalbackpackid' => $id]);
+
+        // Delete backpack entry.
+        $result = $DB->delete_records('badge_external_backpack', ['id' => $id]);
+
+        $transaction->allow_commit();
+
+        return $result;
+    }
+
+    return false;
+}
+
 /**
  * Is any backpack enabled that supports open badges V1?
  * @return boolean
index 504ff4b..2d3022e 100644 (file)
@@ -1,8 +1,10 @@
 {{< core_form/element-template-inline }}
     {{$element}}
+    <div class="d-flex flex-wrap">
         {{#element.elements}}
             {{{separator}}}
             {{{html}}}
         {{/element.elements}}
+    </div>
     {{/element}}
 {{/ core_form/element-template-inline }}
index 4e00ed1..ae5cdac 100644 (file)
@@ -44,7 +44,7 @@
   </div>
 </div>
 {{#js}}
-require(['jquery', 'theme_boost/toast'], function(jQuery) {
+require(['jquery', 'theme_boost/bootstrap/toast'], function(jQuery) {
     // Show the toast.
     // Bootstrap toast components are not shown automatically.
     jQuery('#toast-{{uniqid}}').toast('show');
index 51648c9..c724f6a 100644 (file)
@@ -118,11 +118,15 @@ EOD;
         $oldproxy = $CFG->proxyhost;
         $CFG->proxyhost = 'xxxxxxxxxxxxxxx.moodle.org';
 
+        $oldproxybypass = $CFG->proxybypass; // Ensure we don't get locally served extests bypassing the proxy.
+        $CFG->proxybypass = '';
+
         $feed = new moodle_simplepie($this->getExternalTestFileUrl('/rsstest.xml'));
 
         $this->assertNotEmpty($feed->error());
         $this->assertEmpty($feed->get_title());
         $CFG->proxyhost = $oldproxy;
+        $CFG->proxybypass = $oldproxybypass;
     }
 
     /*
index 7810b33..9c9ea4c 100644 (file)
         require_once("$CFG->libdir/odslib.class.php");
 
     /// Calculate file name
-        $filename = clean_filename("$course->shortname ".strip_tags(format_string($choice->name,true))).'.ods';
+        $shortname = format_string($course->shortname, true, array('context' => $context));
+        $choicename = format_string($choice->name, true, array('context' => $context));
+        $filename = clean_filename("$shortname " . strip_tags($choicename)) . '.ods';
     /// Creating a workbook
         $workbook = new MoodleODSWorkbook("-");
     /// Send HTTP headers
         require_once("$CFG->libdir/excellib.class.php");
 
     /// Calculate file name
-        $filename = clean_filename("$course->shortname ".strip_tags(format_string($choice->name,true))).'.xls';
+        $shortname = format_string($course->shortname, true, array('context' => $context));
+        $choicename = format_string($choice->name, true, array('context' => $context));
+        $filename = clean_filename("$shortname " . strip_tags($choicename)) . '.xls';
     /// Creating a workbook
         $workbook = new MoodleExcelWorkbook("-");
     /// Send HTTP headers
 
     // print text file
     if ($download == "txt" && has_capability('mod/choice:downloadresponses', $context)) {
-        $filename = clean_filename("$course->shortname ".strip_tags(format_string($choice->name,true))).'.txt';
+        $shortname = format_string($course->shortname, true, array('context' => $context));
+        $choicename = format_string($choice->name, true, array('context' => $context));
+        $filename = clean_filename("$shortname " . strip_tags($choicename)) . '.txt';
 
         header("Content-Type: application/download\n");
         header("Content-Disposition: attachment; filename=\"$filename\"");
diff --git a/mod/h5pactivity/classes/external/get_h5pactivities_by_courses.php b/mod/h5pactivity/classes/external/get_h5pactivities_by_courses.php
new file mode 100644 (file)
index 0000000..e1546dd
--- /dev/null
@@ -0,0 +1,136 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This is the external method for returning a list of h5p activities.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/externallib.php');
+
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_single_structure;
+use external_multiple_structure;
+use external_util;
+use external_warnings;
+use context_module;
+use core_h5p\factory;
+
+/**
+ * This is the external method for returning a list of h5p activities.
+ *
+ * @copyright  2020 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class get_h5pactivities_by_courses extends external_api {
+    /**
+     * Parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function execute_parameters(): external_function_parameters {
+        return new external_function_parameters (
+            [
+                'courseids' => new external_multiple_structure(
+                    new external_value(PARAM_INT, 'Course id'), 'Array of course ids', VALUE_DEFAULT, []
+                ),
+            ]
+        );
+    }
+
+    /**
+     * Returns a list of h5p activities in a provided list of courses.
+     * If no list is provided all h5p activities that the user can view will be returned.
+     *
+     * @param  array $courseids course ids
+     * @return array of h5p activities and warnings
+     * @since Moodle 3.9
+     */
+    public static function execute(array $courseids): array {
+        global $PAGE;
+
+        $warnings = [];
+        $returnedh5pactivities = [];
+
+        $params = external_api::validate_parameters(self::execute_parameters(), [
+            'courseids' => $courseids
+        ]);
+
+        $mycourses = [];
+        if (empty($params['courseids'])) {
+            $mycourses = enrol_get_my_courses();
+            $params['courseids'] = array_keys($mycourses);
+        }
+
+        // Ensure there are courseids to loop through.
+        if (!empty($params['courseids'])) {
+
+            $factory = new factory();
+
+            list($courses, $warnings) = external_util::validate_courses($params['courseids'], $mycourses);
+            $output = $PAGE->get_renderer('core');
+
+            // Get the h5p activities in this course, this function checks users visibility permissions.
+            // We can avoid then additional validate_context calls.
+            $h5pactivities = get_all_instances_in_courses('h5pactivity', $courses);
+            foreach ($h5pactivities as $h5pactivity) {
+                $context = context_module::instance($h5pactivity->coursemodule);
+                // Remove fields that are not from the h5p activity (added by get_all_instances_in_courses).
+                unset($h5pactivity->coursemodule, $h5pactivity->context,
+                    $h5pactivity->visible, $h5pactivity->section,
+                    $h5pactivity->groupmode, $h5pactivity->groupingid);
+
+                $exporter = new h5pactivity_summary_exporter($h5pactivity,
+                    ['context' => $context, 'factory' => $factory]);
+                $summary = $exporter->export($output);
+                $returnedh5pactivities[] = $summary;
+            }
+        }
+
+        $result = [
+            'h5pactivities' => $returnedh5pactivities,
+            'warnings' => $warnings
+        ];
+        return $result;
+    }
+
+    /**
+     * Describes the get_h5pactivities_by_courses return value.
+     *
+     * @return external_single_structure
+     * @since Moodle 3.9
+     */
+    public static function execute_returns() {
+        return new external_single_structure(
+            [
+                'h5pactivities' => new external_multiple_structure(
+                    h5pactivity_summary_exporter::get_read_structure()
+                ),
+                'warnings' => new external_warnings(),
+            ]
+        );
+    }
+}
\ No newline at end of file
diff --git a/mod/h5pactivity/classes/external/get_results.php b/mod/h5pactivity/classes/external/get_results.php
new file mode 100644 (file)
index 0000000..a37ab62
--- /dev/null
@@ -0,0 +1,303 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This is the external method for getting the information needed to present a results report.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/externallib.php');
+
+use mod_h5pactivity\local\manager;
+use mod_h5pactivity\local\report\results as report_results;
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_multiple_structure;
+use external_single_structure;
+use external_warnings;
+use moodle_exception;
+use context_module;
+use stdClass;
+
+/**
+ * This is the external method for getting the information needed to present a results report.
+ *
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class get_results extends external_api {
+
+    /**
+     * Webservice parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function execute_parameters(): external_function_parameters {
+        return new external_function_parameters(
+            [
+                'h5pactivityid' => new external_value(PARAM_INT, 'h5p activity instance id'),
+                'attemptids' => new external_multiple_structure(
+                    new external_value(PARAM_INT, 'The attempt id'),
+                    'Attempt ids', VALUE_DEFAULT, []
+                ),
+            ]
+        );
+    }
+
+    /**
+     * Return user attempts results information in a h5p activity.
+     *
+     * In case an empty array of attempt ids is passed, the method will load all
+     * activity attempts from the current user.
+     *
+     * @throws  moodle_exception if the user cannot see the report
+     * @param  int $h5pactivityid The h5p activity id
+     * @param  int[] $attemptids The attempt ids
+     * @return stdClass report data
+     */
+    public static function execute(int $h5pactivityid, array $attemptids = []): stdClass {
+        global $USER;
+
+        $params = external_api::validate_parameters(self::execute_parameters(), [
+            'h5pactivityid' => $h5pactivityid,
+            'attemptids' => $attemptids,
+        ]);
+        $h5pactivityid = $params['h5pactivityid'];
+        $attemptids = $params['attemptids'];
+
+        $warnings = [];
+
+        // Request and permission validation.
+        list ($course, $cm) = get_course_and_cm_from_instance($h5pactivityid, 'h5pactivity');
+
+        $context = context_module::instance($cm->id);
+        self::validate_context($context);
+
+        $manager = manager::create_from_coursemodule($cm);
+
+        if (empty($attemptids)) {
+            $attemptids = [];
+            foreach ($manager->get_user_attempts($USER->id) as $attempt) {
+                $attemptids[] = $attempt->get_id();
+            }
+        }
+
+        $attempts = [];
+        foreach ($attemptids as $attemptid) {
+            $report = $manager->get_report(null, $attemptid);
+
+            if ($report && $report instanceof report_results) {
+                $attempts[] = self::export_attempt($report);
+            } else {
+                $warnings[] = [
+                    'item' => 'h5pactivity_attempts',
+                    'itemid' => $attemptid,
+                    'warningcode' => '1',
+                    'message' => "Cannot access attempt",
+                ];
+            }
+        }
+
+        $result = (object)[
+            'activityid' => $h5pactivityid,
+            'attempts' => $attempts,
+            'warnings' => $warnings,
+        ];
+
+        return $result;
+    }
+
+    /**
+     * Return a data object from an attempt.
+     *
+     * @param report_results $report the attempt data
+     * @return stdClass a WS compatible version of the attempt
+     */
+    private static function export_attempt(report_results $report): stdClass {
+
+        $data = $report->export_data_for_external();
+
+        $attemptdata = $data->attempt;
+
+        $attempt = (object)[
+            'id' => $attemptdata->id,
+            'h5pactivityid' => $attemptdata->h5pactivityid,
+            'userid' => $attemptdata->userid,
+            'timecreated' => $attemptdata->timecreated,
+            'timemodified' => $attemptdata->timemodified,
+            'attempt' => $attemptdata->attempt,
+            'rawscore' => $attemptdata->rawscore,
+            'maxscore' => $attemptdata->maxscore,
+            'duration' => (empty($attemptdata->duration)) ? 0 : $attemptdata->duration,
+            'scaled' => (empty($attemptdata->scaled)) ? 0 : $attemptdata->scaled,
+            'results' => [],
+        ];
+        if (isset($attemptdata->completion) && $attemptdata->completion !== null) {
+            $attempt->completion = $attemptdata->completion;
+        }
+        if (isset($attemptdata->success) && $attemptdata->success !== null) {
+            $attempt->success = $attemptdata->success;
+        }
+        foreach ($data->results as $result) {
+            $attempt->results[] = self::export_result($result);
+        }
+        return $attempt;
+    }
+
+    /**
+     * Return a data object from a result.
+     *
+     * @param stdClass $data the result data
+     * @return stdClass a WS compatible version of the result
+     */
+    private static function export_result(stdClass $data): stdClass {
+        $result = (object)[
+            'id' => $data->id,
+            'attemptid' => $data->attemptid,
+            'subcontent' => $data->subcontent,
+            'timecreated' => $data->timecreated,
+            'interactiontype' => $data->interactiontype,
+            'description' => $data->description,
+            'rawscore' => $data->rawscore,
+            'maxscore' => $data->maxscore,
+            'duration' => $data->duration,
+            'optionslabel' => $data->optionslabel ?? get_string('choice', 'mod_h5pactivity'),
+            'correctlabel' => $data->correctlabel ?? get_string('correct_answer', 'mod_h5pactivity'),
+            'answerlabel' => $data->answerlabel ?? get_string('attempt_answer', 'mod_h5pactivity'),
+            'track' => $data->track ?? false,
+        ];
+        if (isset($data->completion) && $data->completion !== null) {
+            $result->completion = $data->completion;
+        }
+        if (isset($data->success) && $data->success !== null) {
+            $result->success = $data->success;
+        }
+        if (isset($data->options)) {
+            $result->options = $data->options;
+        }
+        if (isset($data->content)) {
+            $result->content = $data->content;
+        }
+        return $result;
+    }
+
+    /**
+     * Describes the get_h5pactivity_access_information return value.
+     *
+     * @return external_single_structure
+     */
+    public static function execute_returns(): external_single_structure {
+        return new external_single_structure([
+            'activityid' => new external_value(PARAM_INT, 'Activity course module ID'),
+            'attempts' => new external_multiple_structure(
+                self::get_attempt_returns(), 'The complete attempts list'
+            ),
+            'warnings' => new external_warnings(),
+        ], 'Activity attempts results data');
+    }
+
+    /**
+     * Return the external structure of an attempt
+     * @return external_single_structure
+     */
+    private static function get_attempt_returns(): external_single_structure {
+
+        $result = new external_single_structure([
+            'id'    => new external_value(PARAM_INT, 'ID of the context'),
+            'h5pactivityid' => new external_value(PARAM_INT, 'ID of the H5P activity'),
+            'userid' => new external_value(PARAM_INT, 'ID of the user'),
+            'timecreated' => new external_value(PARAM_INT, 'Attempt creation'),
+            'timemodified' => new external_value(PARAM_INT, 'Attempt modified'),
+            'attempt' => new external_value(PARAM_INT, 'Attempt number'),
+            'rawscore' => new external_value(PARAM_INT, 'Attempt score value'),
+            'maxscore' => new external_value(PARAM_INT, 'Attempt max score'),
+            'duration' => new external_value(PARAM_INT, 'Attempt duration in seconds'),
+            'completion' => new external_value(PARAM_INT, 'Attempt completion', VALUE_OPTIONAL),
+            'success' => new external_value(PARAM_INT, 'Attempt success', VALUE_OPTIONAL),
+            'scaled' => new external_value(PARAM_FLOAT, 'Attempt scaled'),
+            'results' => new external_multiple_structure(
+                self::get_result_returns(),
+                'The results of the attempt', VALUE_OPTIONAL
+            ),
+        ], 'The attempt general information');
+        return $result;
+    }
+
+    /**
+     * Return the external structure of a result
+     * @return external_single_structure
+     */
+    private static function get_result_returns(): external_single_structure {
+
+        $result = new external_single_structure([
+            'id'    => new external_value(PARAM_INT, 'ID of the context'),
+            'attemptid' => new external_value(PARAM_INT, 'ID of the H5P attempt'),
+            'subcontent' => new external_value(PARAM_NOTAGS, 'Subcontent identifier'),
+            'timecreated' => new external_value(PARAM_INT, 'Result creation'),
+            'interactiontype' => new external_value(PARAM_NOTAGS, 'Interaction type'),
+            'description' => new external_value(PARAM_TEXT, 'Result description'),
+            'rawscore' => new external_value(PARAM_INT, 'Result score value'),
+            'maxscore' => new external_value(PARAM_INT, 'Result max score'),
+            'duration' => new external_value(PARAM_INT, 'Result duration in seconds', VALUE_OPTIONAL, 0),
+            'completion' => new external_value(PARAM_INT, 'Result completion', VALUE_OPTIONAL),
+            'success' => new external_value(PARAM_INT, 'Result success', VALUE_OPTIONAL),
+            'optionslabel' => new external_value(PARAM_NOTAGS, 'Label used for result options', VALUE_OPTIONAL),
+            'correctlabel' => new external_value(PARAM_NOTAGS, 'Label used for correct answers', VALUE_OPTIONAL),
+            'answerlabel' => new external_value(PARAM_NOTAGS, 'Label used for user answers', VALUE_OPTIONAL),
+            'track' => new external_value(PARAM_BOOL, 'If the result has valid track information', VALUE_OPTIONAL),
+            'options' => new external_multiple_structure(
+                new external_single_structure([
+                    'description'    => new external_value(PARAM_TEXT, 'Option description'),
+                    'id' => new external_value(PARAM_INT, 'Option identifier'),
+                    'correctanswer' => self::get_answer_returns('The option correct answer'),
+                    'useranswer' => self::get_answer_returns('The option user answer'),
+                ]),
+                'The statement options', VALUE_OPTIONAL
+            ),
+        ], 'A single result statement tracking information');
+        return $result;
+    }
+
+    /**
+     * Return the external structure of an answer or correctanswer
+     *
+     * @param string $description the return description
+     * @return external_single_structure
+     */
+    private static function get_answer_returns(string $description): external_single_structure {
+
+        $result = new external_single_structure([
+            'answer' => new external_value(PARAM_NOTAGS, 'Option text value', VALUE_OPTIONAL),
+            'correct' => new external_value(PARAM_BOOL, 'If has to be displayed as correct', VALUE_OPTIONAL),
+            'incorrect' => new external_value(PARAM_BOOL, 'If has to be displayed as incorrect', VALUE_OPTIONAL),
+            'text' => new external_value(PARAM_BOOL, 'If has to be displayed as simple text', VALUE_OPTIONAL),
+            'checked' => new external_value(PARAM_BOOL, 'If has to be displayed as a checked option', VALUE_OPTIONAL),
+            'unchecked' => new external_value(PARAM_BOOL, 'If has to be displayed as a unchecked option', VALUE_OPTIONAL),
+            'pass' => new external_value(PARAM_BOOL, 'If has to be displayed as passed', VALUE_OPTIONAL),
+            'fail' => new external_value(PARAM_BOOL, 'If has to be displayed as failed', VALUE_OPTIONAL),
+        ], $description);
+        return $result;
+    }
+}
diff --git a/mod/h5pactivity/classes/external/h5pactivity_summary_exporter.php b/mod/h5pactivity/classes/external/h5pactivity_summary_exporter.php
new file mode 100644 (file)
index 0000000..0088715
--- /dev/null
@@ -0,0 +1,241 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class for exporting h5p activity data.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace mod_h5pactivity\external;
+
+use core\external\exporter;
+use renderer_base;
+use external_util;
+use external_files;
+use core_h5p\factory;
+use core_h5p\api;
+
+/**
+ * Class for exporting h5p activity data.
+ *
+ * @copyright  2020 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class h5pactivity_summary_exporter extends exporter {
+
+    /**
+     * Properties definition.
+     *
+     * @return array
+     */
+    protected static function define_properties() {
+
+        return [
+            'id' => [
+                'type' => PARAM_INT,
+                'description' => 'The primary key of the record.',
+            ],
+            'course' => [
+                'type' => PARAM_INT,
+                'description' => 'Course id this h5p activity is part of.',
+            ],
+            'name' => [
+                'type' => PARAM_TEXT,
+                'description' => 'The name of the activity module instance.',
+            ],
+            'timecreated' => [
+                'type' => PARAM_INT,
+                'description' => 'Timestamp of when the instance was added to the course.',
+                'optional' => true,
+            ],
+            'timemodified' => [
+                'type' => PARAM_INT,
+                'description' => 'Timestamp of when the instance was last modified.',
+                'optional' => true,
+            ],
+            'intro' => [
+                'default' => '',
+                'type' => PARAM_RAW,
+                'description' => 'H5P activity description.',
+                'null' => NULL_ALLOWED,
+            ],
+            'introformat' => [
+                'choices' => [FORMAT_HTML, FORMAT_MOODLE, FORMAT_PLAIN, FORMAT_MARKDOWN],
+                'type' => PARAM_INT,
+                'default' => FORMAT_MOODLE,
+                'description' => 'The format of the intro field.',
+            ],
+            'grade' => [
+                'type' => PARAM_INT,
+                'default' => 0,
+                'description' => 'The maximum grade for submission.',
+                'optional' => true,
+            ],
+            'displayoptions' => [
+                'type' => PARAM_INT,
+                'default' => 0,
+                'description' => 'H5P Button display options.',
+            ],
+            'enabletracking' => [
+                'type' => PARAM_INT,
+                'default' => 1,
+                'description' => 'Enable xAPI tracking.',
+            ],
+            'grademethod' => [
+                'type' => PARAM_INT,
+                'default' => 1,
+                'description' => 'Which H5P attempt is used for grading.',
+            ],
+            'contenthash' => [
+                'type' => PARAM_ALPHANUM,
+                'description' => 'Sha1 hash of file content.',
+                'optional' => true,
+            ],
+        ];
+    }
+
+    /**
+     * Related objects definition.
+     *
+     * @return array
+     */
+    protected static function define_related() {
+        return [
+            'context' => 'context',
+            'factory' => 'core_h5p\\factory'
+        ];
+    }
+
+    /**
+     * Other properties definition.
+     *
+     * @return array
+     */
+    protected static function define_other_properties() {
+        return [
+            'coursemodule' => [
+                'type' => PARAM_INT
+            ],
+            'introfiles' => [
+                'type' => external_files::get_properties_for_exporter(),
+                'multiple' => true
+            ],
+            'package' => [
+                'type' => external_files::get_properties_for_exporter(),
+                'multiple' => true
+            ],
+            'deployedfile' => [
+                'optional' => true,
+                'description' => 'H5P file deployed.',
+                'type' => [
+                    'filename' => array(
+                        'type' => PARAM_FILE,
+                        'description' => 'File name.',
+                        'optional' => true,
+                        'null' => NULL_NOT_ALLOWED,
+                    ),
+                    'filepath' => array(
+                        'type' => PARAM_PATH,
+                        'description' => 'File path.',
+                        'optional' => true,
+                        'null' => NULL_NOT_ALLOWED,
+                    ),
+                    'filesize' => array(
+                        'type' => PARAM_INT,
+                        'description' => 'File size.',
+                        'optional' => true,
+                        'null' => NULL_NOT_ALLOWED,
+                    ),
+                    'fileurl' => array(
+                        'type' => PARAM_URL,
+                        'description' => 'Downloadable file url.',
+                        'optional' => true,
+                        'null' => NULL_NOT_ALLOWED,
+                    ),
+                    'timemodified' => array(
+                        'type' => PARAM_INT,
+                        'description' => 'Time modified.',
+                        'optional' => true,
+                        'null' => NULL_NOT_ALLOWED,
+                    ),
+                    'mimetype' => array(
+                        'type' => PARAM_RAW,
+                        'description' => 'File mime type.',
+                        'optional' => true,
+                        'null' => NULL_NOT_ALLOWED,
+                    )
+                ]
+            ],
+        ];
+    }
+
+    /**
+     * Assign values to the defined other properties.
+     *
+     * @param renderer_base $output The output renderer object.
+     * @return array
+     */
+    protected function get_other_values(renderer_base $output) {
+        $context = $this->related['context'];
+        $factory = $this->related['factory'];
+
+        $values = [
+            'coursemodule' => $context->instanceid,
+        ];
+
+        $values['introfiles'] = external_util::get_area_files($context->id, 'mod_h5pactivity', 'intro', false, false);
+
+        $values['package'] = external_util::get_area_files($context->id, 'mod_h5pactivity', 'package', false, false);
+
+        // Only if this H5P activity has been deployed, return the exported file.
+        $fileh5p = api::get_export_info_from_context_id($context->id, $factory, 'mod_h5pactivity', 'package');
+        if ($fileh5p) {
+            $values['deployedfile'] = $fileh5p;
+        }
+
+        return $values;
+    }
+
+    /**
+     * Get the formatting parameters for the intro.
+     *
+     * @return array with the formatting parameters
+     */
+    protected function get_format_parameters_for_intro() {
+        return [
+            'component' => 'mod_h5pactivity',
+            'filearea' => 'intro',
+            'options' => ['noclean' => true],
+        ];
+    }
+
+    /**
+     * Get the formatting parameters for the package.
+     *
+     * @return array with the formatting parameters
+     */
+    protected function get_format_parameters_for_package() {
+        return [
+            'component' => 'mod_h5pactivity',
+            'filearea' => 'package',
+            'itemid' => 0,
+            'options' => ['noclean' => true],
+        ];
+    }
+}
index 4c471bf..840de03 100644 (file)
@@ -417,7 +417,10 @@ class manager {
      */
     public function get_attempt(int $attemptid): ?attempt {
         global $DB;
-        $record = $DB->get_record('h5pactivity_attempts', ['id' => $attemptid]);
+        $record = $DB->get_record('h5pactivity_attempts', [
+            'id' => $attemptid,
+            'h5pactivityid' => $this->instance->id,
+        ]);
         if (!$record) {
             return null;
         }
index c5690f9..61fbb8b 100644 (file)
@@ -95,4 +95,20 @@ class results implements report {
         $widget = new reportresults($attempt, $this->user, $cm->course);
         echo $OUTPUT->render($widget);
     }
+
+    /**
+     * Get the export data form this report.
+     *
+     * This method is used to render the report in mobile.
+     */
+    public function export_data_for_external(): stdClass {
+        global $PAGE;
+
+        $manager = $this->manager;
+        $attempt = $this->attempt;
+        $cm = $manager->get_coursemodule();
+
+        $widget = new reportresults($attempt, $this->user, $cm->course);
+        return $widget->export_for_template($PAGE->get_renderer('core'));
+    }
 }
index db62e5e..10c91cc 100644 (file)
@@ -53,4 +53,24 @@ $functions = [
         'capabilities'  => 'mod/h5pactivity:view',
         'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE],
     ],
+    'mod_h5pactivity_get_results' => [
+        'classname'     => 'mod_h5pactivity\external\get_results',
+        'methodname'    => 'execute',
+        'classpath'     => '',
+        'description'   => 'Return the information needed to list a user attempt results.',
+        'type'          => 'read',
+        'capabilities'  => 'mod/h5pactivity:view',
+        'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE],
+    ],
+    'mod_h5pactivity_get_h5pactivities_by_courses' => [
+        'classname'     => 'mod_h5pactivity\external\get_h5pactivities_by_courses',
+        'methodname'    => 'execute',
+        'classpath'     => '',
+        'description'   => 'Returns a list of h5p activities in a list of
+            provided courses, if no list is provided all h5p activities
+            that the user can view will be returned.',
+        'type'          => 'read',
+        'capabilities'  => 'mod/h5pactivity:view',
+        'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE],
+    ],
 ];
diff --git a/mod/h5pactivity/tests/external/get_h5pactivities_by_courses_test.php b/mod/h5pactivity/tests/external/get_h5pactivities_by_courses_test.php
new file mode 100644 (file)
index 0000000..62050d2
--- /dev/null
@@ -0,0 +1,182 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * External function test for get_h5pactivities_by_courses.
+ *
+ * @package    mod_h5pactivity
+ * @category   external
+ * @since      Moodle 3.9
+ * @copyright  2020 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+use external_api;
+use externallib_advanced_testcase;
+use stdClass;
+use context_module;
+
+/**
+ * External function test for get_h5pactivities_by_courses.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2020 Carlos Escobedo <carlos@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class get_h5pactivities_by_courses_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Test test_get_h5pactivities_by_courses user student.
+     */
+    public function test_get_h5pactivities_by_courses() {
+        global $CFG, $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Create 2 courses.
+        // Course 1 -> 2 activities with H5P files package without deploy.
+        // Course 2 -> 1 activity with H5P file package deployed.
+        $course1 = $this->getDataGenerator()->create_course();
+        $params = [
+            'course' => $course1->id,
+            'packagefilepath' => $CFG->dirroot.'/h5p/tests/fixtures/filltheblanks.h5p',
+            'introformat' => 1
+        ];
+        $activities[] = $this->getDataGenerator()->create_module('h5pactivity', $params);
+        // Add filename to make easier the asserts.
+        $activities[0]->filename = 'filltheblanks.h5p';
+        $params = [
+            'course' => $course1->id,
+            'packagefilepath' => $CFG->dirroot.'/h5p/tests/fixtures/greeting-card-887.h5p',
+            'introformat' => 1
+        ];
+        $activities[] = $this->getDataGenerator()->create_module('h5pactivity', $params);
+        // Add filename to make easier the asserts.
+        $activities[1]->filename = 'greeting-card-887.h5p';
+
+        $course2 = $this->getDataGenerator()->create_course();
+        $params = [
+            'course' => $course2->id,
+            'packagefilepath' => $CFG->dirroot.'/h5p/tests/fixtures/guess-the-answer.h5p',
+            'introformat' => 1
+        ];
+        $activities[] = $this->getDataGenerator()->create_module('h5pactivity', $params);
+        $activities[2]->filename = 'guess-the-answer.h5p';
+
+        $context = context_module::instance($activities[2]->cmid);
+        // Create a fake deploy H5P file.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_h5p');
+        $deployedfile = $generator->create_export_file($activities[2]->filename, $context->id, 'mod_h5pactivity', 'package');
+
+        // Create a user and enrol as student in both courses.
+        $user = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        $maninstance1 = $DB->get_record('enrol', ['courseid' => $course1->id, 'enrol' => 'manual'], '*', MUST_EXIST);
+        $maninstance2 = $DB->get_record('enrol', ['courseid' => $course2->id, 'enrol' => 'manual'], '*', MUST_EXIST);
+        $manual = enrol_get_plugin('manual');
+        $manual->enrol_user($maninstance1, $user->id, $studentrole->id);
+        $manual->enrol_user($maninstance2, $user->id, $studentrole->id);
+
+        // Check the activities returned by the first course.
+        $this->setUser($user);
+        $courseids = [$course1->id];
+        $result = get_h5pactivities_by_courses::execute($courseids);
+        $result = external_api::clean_returnvalue(get_h5pactivities_by_courses::execute_returns(), $result);
+        $this->assertCount(0, $result['warnings']);
+        $this->assertCount(2, $result['h5pactivities']);
+        $this->assert_activities($activities, $result);
+        $this->assertNotContains('deployedfile', $result['h5pactivities'][0]);
+        $this->assertNotContains('deployedfile', $result['h5pactivities'][1]);
+
+        // Call the external function without passing course id.
+        // Expected result, all the courses, course1 and course2.
+        $result = get_h5pactivities_by_courses::execute([]);
+        $result = external_api::clean_returnvalue(get_h5pactivities_by_courses::execute_returns(), $result);
+        $this->assertCount(0, $result['warnings']);
+        $this->assertCount(3, $result['h5pactivities']);
+        // We need to sort the $result by id.
+        // Because we are not sure how it is ordered with more than one course.
+        array_multisort(array_map(function($element) {
+            return $element['id'];
+        }, $result['h5pactivities']), SORT_ASC, $result['h5pactivities']);
+        $this->assert_activities($activities, $result);
+        $this->assertNotContains('deployedfile', $result['h5pactivities'][0]);
+        $this->assertNotContains('deployedfile', $result['h5pactivities'][1]);
+        // Only the activity from the second course has been deployed.
+        $this->assertEquals($deployedfile['filename'], $result['h5pactivities'][2]['deployedfile']['filename']);
+        $this->assertEquals($deployedfile['filepath'], $result['h5pactivities'][2]['deployedfile']['filepath']);
+        $this->assertEquals($deployedfile['filesize'], $result['h5pactivities'][2]['deployedfile']['filesize']);
+        $this->assertEquals($deployedfile['timemodified'], $result['h5pactivities'][2]['deployedfile']['timemodified']);
+        $this->assertEquals($deployedfile['mimetype'], $result['h5pactivities'][2]['deployedfile']['mimetype']);
+        $this->assertEquals($deployedfile['fileurl'], $result['h5pactivities'][2]['deployedfile']['fileurl']);
+
+        // Unenrol user from second course.
+        $manual->unenrol_user($maninstance2, $user->id);
+        // Remove the last activity from the array.
+        array_pop($activities);
+
+        // Call the external function without passing course id.
+        $result = get_h5pactivities_by_courses::execute([]);
+        $result = external_api::clean_returnvalue(get_h5pactivities_by_courses::execute_returns(), $result);
+        $this->assertCount(0, $result['warnings']);
+        $this->assertCount(2, $result['h5pactivities']);
+        $this->assert_activities($activities, $result);
+
+        // Call for the second course we unenrolled the user from, expected warning.
+        $result = get_h5pactivities_by_courses::execute([$course2->id]);
+        $result = external_api::clean_returnvalue(get_h5pactivities_by_courses::execute_returns(), $result);
+        $this->assertCount(1, $result['warnings']);
+        $this->assertEquals('1', $result['warnings'][0]['warningcode']);
+        $this->assertEquals($course2->id, $result['warnings'][0]['itemid']);
+    }
+
+    /**
+     * Create a scenario to use into the tests.
+     *
+     * @param  array $activities list of H5P activities.
+     * @param  array $result list of H5P activities by WS.
+     * @return void
+     */
+    protected function assert_activities(array $activities, array $result): void {
+
+        $total = count($result);
+        for ($i = 0; $i < $total; $i++) {
+            $this->assertEquals($activities[$i]->id, $result['h5pactivities'][$i]['id']);
+            $this->assertEquals($activities[$i]->course, $result['h5pactivities'][$i]['course']);
+            $this->assertEquals($activities[$i]->name, $result['h5pactivities'][$i]['name']);
+            $this->assertEquals($activities[$i]->timecreated, $result['h5pactivities'][$i]['timecreated']);
+            $this->assertEquals($activities[$i]->timemodified, $result['h5pactivities'][$i]['timemodified']);
+            $this->assertEquals($activities[$i]->intro, $result['h5pactivities'][$i]['intro']);
+            $this->assertEquals($activities[$i]->introformat, $result['h5pactivities'][$i]['introformat']);
+            $this->assertEquals([], $result['h5pactivities'][$i]['introfiles']);
+            $this->assertEquals($activities[$i]->grade, $result['h5pactivities'][$i]['grade']);
+            $this->assertEquals($activities[$i]->displayoptions, $result['h5pactivities'][$i]['displayoptions']);
+            $this->assertEquals($activities[$i]->enabletracking, $result['h5pactivities'][$i]['enabletracking']);
+            $this->assertEquals($activities[$i]->grademethod, $result['h5pactivities'][$i]['grademethod']);
+            $this->assertEquals($activities[$i]->cmid, $result['h5pactivities'][$i]['coursemodule']);
+            $this->assertEquals($activities[$i]->filename, $result['h5pactivities'][$i]['package'][0]['filename']);
+        }
+    }
+}
diff --git a/mod/h5pactivity/tests/external/get_results_test.php b/mod/h5pactivity/tests/external/get_results_test.php
new file mode 100644 (file)
index 0000000..0a76597
--- /dev/null
@@ -0,0 +1,428 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * External function test for get_results.
+ *
+ * @package    mod_h5pactivity
+ * @category   external
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+use mod_h5pactivity\local\manager;
+use external_api;
+use externallib_advanced_testcase;
+use dml_missing_record_exception;
+
+/**
+ * External function test for get_results.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class get_results_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Test the behaviour of get_results.
+     *
+     * @dataProvider execute_data
+     * @param int $enabletracking the activity tracking enable
+     * @param int $reviewmode the activity review mode
+     * @param string $loginuser the user which calls the webservice
+     * @param string|null $participant the user to get the data
+     * @param bool $createattempts if the student user has attempts created
+     * @param int|null $count the expected number of attempts returned (null for exception)
+     */
+    public function test_execute(int $enabletracking, int $reviewmode, string $loginuser,
+            ?string $participant, bool $createattempts, ?int $count): void {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity',
+                ['course' => $course, 'enabletracking' => $enabletracking, 'reviewmode' => $reviewmode]);
+
+        $manager = manager::create_from_instance($activity);
+        $cm = $manager->get_coursemodule();
+
+        // Prepare users: 1 teacher, 1 student and 1 unenroled user.
+        $users = [
+            'editingteacher' => $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'),
+            'student' => $this->getDataGenerator()->create_and_enrol($course, 'student'),
+            'other' => $this->getDataGenerator()->create_and_enrol($course, 'student'),
+        ];
+
+        $attempts = [];
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity');
+
+        if ($createattempts) {
+            $user = $users['student'];
+            $params = ['cmid' => $cm->id, 'userid' => $user->id];
+            $attempts['student'] = $generator->create_content($activity, $params);
+        }
+
+        // Create another 2 attempts for the user "other" to validate no cross attempts are returned.
+        $user = $users['other'];
+        $params = ['cmid' => $cm->id, 'userid' => $user->id];
+        $attempts['other'] = $generator->create_content($activity, $params);
+
+        // Execute external method.
+        $this->setUser($users[$loginuser]);
+
+        $attemptid = $attempts[$participant]->id ?? 0;
+
+        $result = get_results::execute($activity->id, [$attemptid]);
+        $result = external_api::clean_returnvalue(
+            get_results::execute_returns(),
+            $result
+        );
+
+        // Validate general structure.
+        $this->assertArrayHasKey('activityid', $result);
+        $this->assertArrayHasKey('attempts', $result);
+        $this->assertArrayHasKey('warnings', $result);
+
+        $this->assertEquals($activity->id, $result['activityid']);
+
+        if ($count === null) {
+            $this->assertCount(1, $result['warnings']);
+            $this->assertCount(0, $result['attempts']);
+            return;
+        }
+
+        $this->assertCount(0, $result['warnings']);
+        $this->assertCount(1, $result['attempts']);
+
+        // Validate attempt.
+        $attempt = $result['attempts'][0];
+        $this->assertEquals($attemptid, $attempt['id']);
+
+        // Validate results.
+        $this->assertArrayHasKey('results', $attempt);
+        $this->assertCount($count, $attempt['results']);
+        foreach ($attempt['results'] as $value) {
+            $this->assertEquals($attemptid, $value['attemptid']);
+            $this->assertArrayHasKey('subcontent', $value);
+            $this->assertArrayHasKey('rawscore', $value);
+            $this->assertArrayHasKey('maxscore', $value);
+            $this->assertArrayHasKey('duration', $value);
+            $this->assertArrayHasKey('track', $value);
+            if (isset($value['options'])) {
+                foreach ($value['options'] as $option) {
+                    $this->assertArrayHasKey('description', $option);
+                    $this->assertArrayHasKey('id', $option);
+                }
+            }
+        }
+    }
+
+    /**
+     * Data provider for the test_execute tests.
+     *
+     * @return  array
+     */
+    public function execute_data(): array {
+        return [
+            'Teacher reviewing an attempt' => [
+                1, manager::REVIEWCOMPLETION, 'editingteacher', 'student', true, 1
+            ],
+            'Teacher try to review an inexistent attempt' => [
+                1, manager::REVIEWCOMPLETION, 'editingteacher', 'student', false, null
+            ],
+            'Teacher reviewing attempt with student review mode off' => [
+                1, manager::REVIEWNONE, 'editingteacher', 'student', true, 1
+            ],
+            'Student reviewing own attempt' => [
+                1, manager::REVIEWCOMPLETION, 'student', 'student', true, 1
+            ],
+            'Student reviewing an inexistent attempt' => [
+                1, manager::REVIEWCOMPLETION, 'student', 'student', false, null
+            ],
+            'Student reviewing own attempt with review mode off' => [
+                1, manager::REVIEWNONE, 'student', 'student', true, null
+            ],
+            'Student try to stalk other student attempt' => [
+                1, manager::REVIEWCOMPLETION, 'student', 'other', false, null
+            ],
+            'Teacher trying to review an attempt without tracking enabled' => [
+                0, manager::REVIEWNONE, 'editingteacher', 'student', true, null
+            ],
+            'Student trying to review an attempt without tracking enabled' => [
+                0, manager::REVIEWNONE, 'editingteacher', 'student', true, null
+            ],
+            'Student trying to stalk another student attempt without tracking enabled' => [
+                0, manager::REVIEWNONE, 'editingteacher', 'student', true, null
+            ],
+        ];
+    }
+
+    /**
+     * Test the behaviour of get_results.
+     *
+     * @dataProvider execute_multipleattempts_data
+     * @param string $loginuser the user which calls the webservice
+     * @param array $getattempts the attempts to get the data
+     * @param array $warnings warnigns expected
+     * @param array $reports data expected
+     *
+     */
+    public function test_execute_multipleattempts(string $loginuser,
+            array $getattempts, array $warnings, array $reports): void {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+
+        $manager = manager::create_from_instance($activity);
+        $cm = $manager->get_coursemodule();
+
+        // Prepare users: 1 teacher, 2 student.
+        $users = [
+            'editingteacher' => $this->getDataGenerator()->create_and_enrol($course, 'editingteacher'),
+            'student1' => $this->getDataGenerator()->create_and_enrol($course, 'student'),
+            'student2' => $this->getDataGenerator()->create_and_enrol($course, 'student'),
+        ];
+
+        $attempts = [];
+
+        // Generate attempts for student 1 and 2.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity');
+
+        $user = $users['student1'];
+        $params = ['cmid' => $cm->id, 'userid' => $user->id];
+        $attempts['student1_1'] = $generator->create_content($activity, $params);
+        $attempts['student1_2'] = $generator->create_content($activity, $params);
+
+        $user = $users['student2'];
+        $params = ['cmid' => $cm->id, 'userid' => $user->id];
+        $attempts['student2_1'] = $generator->create_content($activity, $params);
+        $attempts['student2_2'] = $generator->create_content($activity, $params);
+
+        // Execute external method.
+        $this->setUser($users[$loginuser]);
+
+        $attemptids = [];
+        foreach ($getattempts as $getattempt) {
+            $attemptids[] = $attempts[$getattempt]->id ?? 0;
+        }
+
+        $result = get_results::execute($activity->id, $attemptids);
+        $result = external_api::clean_returnvalue(
+            get_results::execute_returns(),
+            $result
+        );
+
+        // Validate general structure.
+        $this->assertArrayHasKey('activityid', $result);
+        $this->assertArrayHasKey('attempts', $result);
+        $this->assertArrayHasKey('warnings', $result);
+
+        $this->assertEquals($activity->id, $result['activityid']);
+
+        $this->assertCount(count($warnings), $result['warnings']);
+        $this->assertCount(count($reports), $result['attempts']);
+
+        // Validate warnings.
+        $expectedwarnings = [];
+        foreach ($warnings as $warningattempt) {
+            $id = $attempts[$warningattempt]->id ?? 0;
+            $expectedwarnings[$id] = $warningattempt;
+        }
+        foreach ($result['warnings'] as $warning) {
+            $this->assertEquals('h5pactivity_attempts', $warning['item']);
+            $this->assertEquals(1, $warning['warningcode']);
+            $this->assertArrayHasKey($warning['itemid'], $expectedwarnings);
+        }
+
+        // Validate attempts.
+        $expectedattempts = [];
+        foreach ($reports as $expectedattempt) {
+            $id = $attempts[$expectedattempt]->id;
+            $expectedattempts[$id] = $expectedattempt;
+        }
+        foreach ($result['attempts'] as $value) {
+            $this->assertArrayHasKey($value['id'], $expectedattempts);
+        }
+    }
+
+    /**
+     * Data provider for the test_execute_multipleattempts tests.
+     *
+     * @return  array
+     */
+    public function execute_multipleattempts_data(): array {
+        return [
+            // Teacher cases.
+            'Teacher reviewing students attempts' => [
+                'editingteacher', ['student1_1', 'student2_1'], [], ['student1_1', 'student2_1']
+            ],
+            'Teacher reviewing invalid attempt' => [
+                'editingteacher', ['student1_1', 'invalid'], ['invalid'], ['student1_1']
+            ],
+            'Teacher reviewing empty attempts list' => [
+                'editingteacher', [], [], []
+            ],
+            // Student cases.
+            'Student reviewing own students attempts' => [
+                'student1', ['student1_1', 'student1_2'], [], ['student1_1', 'student1_2']
+            ],
+            'Student reviewing invalid attempt' => [
+                'student1', ['student1_1', 'invalid'], ['invalid'], ['student1_1']
+            ],
+            'Student reviewing trying to access another user attempts' => [
+                'student1', ['student1_1', 'student2_1'], ['student2_1'], ['student1_1']
+            ],
+            'Student reviewing empty attempts list' => [
+                'student1', [], [], ['student1_1', 'student1_2']
+            ],
+        ];
+    }
+
+    /**
+     * Test the behaviour of get_results using mixed activityid.
+     *
+     * @dataProvider execute_mixactivities_data
+     * @param string $activityname the activity name to use
+     * @param string $attemptname the attempt name to use
+     * @param string $expectedwarnings expected warning attempt
+     * @param string $expectedattempt expected result attempt
+     *
+     */
+    public function test_execute_mixactivities(string $activityname, string $attemptname,
+            string $expectedwarnings, string $expectedattempt): void {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        // Create 2 courses.
+        $course1 = $this->getDataGenerator()->create_course();
+        $course2 = $this->getDataGenerator()->create_course();
+
+        // Prepare users: 1 teacher, 1 student.
+        $user = $this->getDataGenerator()->create_and_enrol($course1, 'student');
+        $this->getDataGenerator()->enrol_user($user->id, $course2->id, 'student');
+
+        // Create our base activity.
+        $activity11 = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course1]);
+        $manager11 = manager::create_from_instance($activity11);
+        $cm11 = $manager11->get_coursemodule();
+
+        // Create a second activity in the same course to check if the retuned attempt is the correct one.
+        $activity12 = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course1]);
+        $manager12 = manager::create_from_instance($activity12);
+        $cm12 = $manager12->get_coursemodule();
+
+        // Create a second activity on a different course.
+        $activity21 = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course2]);
+        $manager21 = manager::create_from_instance($activity21);
+        $cm21 = $manager21->get_coursemodule();
+
+        $activities = [
+            '11' => $activity11->id,
+            '12' => $activity12->id,
+            '21' => $activity21->id,
+            'inexistent' => 0,
+        ];
+
+        // Generate attempts.
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_h5pactivity');
+
+        $params = ['cmid' => $cm11->id, 'userid' => $user->id];
+        $attempt11 = $generator->create_content($activity11, $params);
+        $params = ['cmid' => $cm12->id, 'userid' => $user->id];
+        $attempt12 = $generator->create_content($activity12, $params);
+        $params = ['cmid' => $cm21->id, 'userid' => $user->id];
+        $attempt21 = $generator->create_content($activity21, $params);
+
+        $attempts = [
+            '11' => $attempt11->id,
+            '12' => $attempt12->id,
+            '21' => $attempt21->id,
+            'inexistent' => 0,
+        ];
+
+        if ($activityname == 'inexistent') {
+            $this->expectException(dml_missing_record_exception::class);
+        }
+
+        // Execute external method.
+        $this->setUser($user);
+
+        $attemptid = $attempts[$attemptname];
+
+        $result = get_results::execute($activities[$activityname], [$attemptid]);
+        $result = external_api::clean_returnvalue(
+            get_results::execute_returns(),
+            $result
+        );
+
+        // Validate general structure.
+        $this->assertArrayHasKey('activityid', $result);
+        $this->assertArrayHasKey('attempts', $result);
+        $this->assertArrayHasKey('warnings', $result);
+
+        if (empty($expectedwarnings)) {
+            $this->assertEmpty($result['warnings']);
+        } else {
+            $this->assertEquals('h5pactivity_attempts', $result['warnings'][0]['item']);
+            $this->assertEquals(1, $result['warnings'][0]['warningcode']);
+            $this->assertEquals($attempts[$expectedwarnings], $result['warnings'][0]['itemid']);
+        }
+
+        if (empty($expectedattempt)) {
+            $this->assertEmpty($result['attempts']);
+        } else {
+            $this->assertEquals($attempts[$expectedattempt], $result['attempts'][0]['id']);
+        }
+    }
+
+    /**
+     * Data provider for the test_execute_multipleattempts tests.
+     *
+     * @return  array
+     */
+    public function execute_mixactivities_data(): array {
+        return [
+            // Teacher cases.
+            'Correct activity id' => [
+                '11', '11', '', '11'
+            ],
+            'Wrong activity id' => [
+                '21', '11', '11', ''
+            ],
+            'Inexistent activity id' => [
+                'inexistent', '11', '', ''
+            ],
+            'Inexistent attempt id' => [
+                '11', 'inexistent', 'inexistent', ''
+            ],
+        ];
+    }
+}
index cabec2f..c551632 100644 (file)
@@ -165,6 +165,7 @@ class mod_h5pactivity_generator extends testing_module_generator {
 
         $result->subcontent = '14fcc986-728b-47f3-915b-'.$userid;
         $result->interactiontype = 'matching';
+        $result->correctpattern = '["0[.]1[,]1[.]0[,]2[.]2"]';
         $result->response = '1[.]0[,]0[.]1[,]2[.]2';
         $result->additionals = '{"source":[{"id":"0","description":{"en-US":"A berry"}}'.
                 ',{"id":"1","description":{"en-US":"An orange berry"}},'.
index ff2e8b6..bbbf9ea 100644 (file)
@@ -738,6 +738,70 @@ class manager_testcase extends \advanced_testcase {
         ];
     }
 
+    /**
+     * Test get_attempt method.
+     *
+     * @dataProvider get_attempt_data
+     * @param string $attemptname the attempt to use
+     * @param string|null $result the expected attempt ID or null for none
+     */
+    public function test_get_attempt(string $attemptname, ?string $result): void {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $cm = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST);
+
+        $otheractivity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $othercm = get_coursemodule_from_id('h5pactivity', $otheractivity->cmid, 0, false, MUST_EXIST);
+
+        $manager = manager::create_from_instance($activity);
+
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $attempts = ['inexistent' => 0];
+
+        $this->generate_fake_attempts($activity, $user, 1);
+        $attempt = attempt::last_attempt($user, $cm);
+        $attempts['current'] = $attempt->get_id();
+
+        $this->generate_fake_attempts($otheractivity, $user, 1);
+        $attempt = attempt::last_attempt($user, $othercm);
+        $attempts['other'] = $attempt->get_id();
+
+        $attempt = $manager->get_attempt($attempts[$attemptname]);
+        if ($result === null) {
+            $this->assertNull($attempt);
+        } else {
+            $this->assertEquals($attempts[$attemptname], $attempt->get_id());
+            $this->assertEquals($activity->id, $attempt->get_h5pactivityid());
+            $this->assertEquals($user->id, $attempt->get_userid());
+            $this->assertEquals(4, $attempt->get_attempt());
+        }
+    }
+
+    /**
+     * Data provider for test_get_attempt.
+     *
+     * @return array
+     */
+    public function get_attempt_data(): array {
+        return [
+            'Get the current activity attempt' => [
+                'current', 'current'
+            ],
+            'Try to get another activity attempt' => [
+                'other', null
+            ],
+            'Try to get an inexistent attempt' => [
+                'inexistent', null
+            ],
+        ];
+    }
+
     /**
      * Insert fake attempt data into h5pactiviyt_attempts.
      *
index 91081ce..6bfe3b6 100644 (file)
@@ -25,5 +25,5 @@
 defined('MOODLE_INTERNAL') || die();
 
 $plugin->component = 'mod_h5pactivity';
-$plugin->version = 2020052000;
+$plugin->version = 2020052100;
 $plugin->requires = 2020013000;
index 5f367cd..fd0340d 100644 (file)
@@ -139,11 +139,8 @@ class moodle_content_writer implements content_writer {
      * @return  content_writer
      */
     public function export_related_data(array $subcontext, $name, $data) : content_writer {
-        $path = $this->get_path($subcontext, "{$name}.json");
-
-        $this->write_data($path, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
-
-        return $this;
+        return $this->export_custom_file($subcontext, "{$name}.json",
+            json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT));
     }
 
     /**
@@ -289,6 +286,7 @@ class moodle_content_writer implements content_writer {
         // This weird code is to look for a subcontext that contains a number and append an '_' to the front.
         // This is because there seems to be some weird problem with array_merge_recursive used in finalise_content().
         $subcontext = array_map(function($data) {
+            $data = clean_param($data, PARAM_PATH);
             if (stripos($data, DIRECTORY_SEPARATOR) !== false) {
                 $newpath = explode(DIRECTORY_SEPARATOR, $data);
                 $newpath = array_map(function($value) {
index 57ada55..c3ebc3c 100644 (file)
@@ -963,6 +963,34 @@ class moodle_content_writer_test extends advanced_testcase {
         $this->assertEquals($data, $expanded);
     }
 
+    /**
+     * Test that exported related data name is properly cleaned
+     *
+     * @covers ::export_related_data
+     */
+    public function test_export_related_data_clean_name() {
+        $context = \context_system::instance();
+        $subcontext = [];
+        $data = (object) ['foo' => 'bar'];
+
+        $name = 'Bad/chars:>';
+
+        $writer = $this->get_writer_instance()
+            ->set_context($context)
+            ->export_related_data($subcontext, $name, $data);
+
+        $nameclean = clean_param($name, PARAM_FILE);
+
+        $contextpath = $this->get_context_path($context, $subcontext, "{$nameclean}.json");
+        $expectedpath = "System _.{$context->id}/Badchars.json";
+        $this->assertEquals($expectedpath, $contextpath);
+
+        $fileroot = $this->fetch_exported_content($writer);
+        $json = $fileroot->getChild($contextpath)->getContent();
+
+        $this->assertEquals($data, json_decode($json));
+    }
+
     /**
      * Test that exported user preference is human readable.
      *
@@ -1002,6 +1030,30 @@ class moodle_content_writer_test extends advanced_testcase {
         ];
     }
 
+    /**
+     * Test that exported data subcontext is properly cleaned
+     *
+     * @covers ::export_data
+     */
+    public function test_export_data_clean_subcontext() {
+        $context = \context_system::instance();
+        $subcontext = ['Something/weird', 'More/bad:>', 'Bad&chars:>'];
+        $data = (object) ['foo' => 'bar'];
+
+        $writer = $this->get_writer_instance()
+            ->set_context($context)
+            ->export_data($subcontext, $data);
+
+        $contextpath = $this->get_context_path($context, $subcontext, 'data.json');
+        $expectedpath = "System _.{$context->id}/Something/weird/More/bad/Badchars/data.json";
+        $this->assertEquals($expectedpath, $contextpath);
+
+        $fileroot = $this->fetch_exported_content($writer);
+        $json = $fileroot->getChild($contextpath)->getContent();
+
+        $this->assertEquals($data, json_decode($json));
+    }
+
     /**
      * Test that exported data is shortened when exceeds the limit.
      *
index c239388..5f01a2e 100644 (file)
@@ -264,7 +264,6 @@ abstract class question_bank {
      * @return question_definition loaded from the database.
      */
     public static function load_question($questionid, $allowshuffle = true) {
-        global $DB;
 
         if (self::$testmode) {
             // Evil, test code in production, but no way round it.
index e212c72..d526782 100644 (file)
Binary files a/question/type/ddwtos/amd/build/ddwtos.min.js and b/question/type/ddwtos/amd/build/ddwtos.min.js differ
index 8ec5db4..6e2a847 100644 (file)
Binary files a/question/type/ddwtos/amd/build/ddwtos.min.js.map and b/question/type/ddwtos/amd/build/ddwtos.min.js.map differ
index 3369db2..2fb2eec 100644 (file)
@@ -387,11 +387,33 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
                 break;
 
             default:
+                questionManager.isKeyboardNavigation = false;
                 return; // To avoid the preventDefault below.
         }
 
         if (nextDrag.length) {
             nextDrag.data('isfocus', true);
+            nextDrag.addClass('beingdragged');
+            var hiddenDrag = this.getDragClone(nextDrag);
+            if (hiddenDrag.length) {
+                if (nextDrag.hasClass('infinite')) {
+                    var noOfDrags = this.noOfDropsInGroup(this.getGroup(nextDrag));
+                    var cloneDrags = this.getInfiniteDragClones(nextDrag, false);
+                    if (cloneDrags.length < noOfDrags) {
+                        var cloneDrag = nextDrag.clone();
+                        cloneDrag.removeClass('beingdragged');
+                        cloneDrag.removeAttr('tabindex');
+                        hiddenDrag.after(cloneDrag);
+                        nextDrag.offset(cloneDrag.offset());
+                    } else {
+                        hiddenDrag.addClass('active');
+                        nextDrag.offset(hiddenDrag.offset());
+                    }
+                } else {
+                    hiddenDrag.addClass('active');
+                    nextDrag.offset(hiddenDrag.offset());
+                }
+            }
         } else {
             drop.data('isfocus', true);
         }
@@ -688,6 +710,11 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
          */
         eventHandlersInitialised: false,
 
+        /**
+         * {boolean} is keyboard navigation or not.
+         */
+        isKeyboardNavigation: false,
+
         /**
          * {DragDropToTextQuestion[]} all the questions on this page, indexed by containerId (id on the .que div).
          */
@@ -740,6 +767,10 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
          * @param {KeyboardEvent} e
          */
         handleKeyPress: function(e) {
+            if (questionManager.isKeyboardNavigation) {
+                return;
+            }
+            questionManager.isKeyboardNavigation = true;
             var question = questionManager.getQuestionForEvent(e);
             if (question) {
                 question.handleKeyPress(e);
@@ -775,35 +806,19 @@ define(['jquery', 'core/dragdrop', 'core/key_codes'], function($, dragDrop, keys
                 drag.removeAttr('tabindex');
                 drag.removeData('unplaced');
                 if (drag.hasClass('infinite') && thisQ.getInfiniteDragClones(drag, true).length > 1) {
-                    setTimeout(function() {
-                        thisQ.getInfiniteDragClones(drag, true).first().remove();
-                    });
+                    thisQ.getInfiniteDragClones(drag, true).first().remove();
                 }
             }
             if (typeof drag.data('isfocus') !== 'undefined' && drag.data('isfocus') === true) {
-                var hiddenDrag = thisQ.getDragClone(drag);
-                if (hiddenDrag.length) {
-                    if (drag.hasClass('infinite')) {
-                        var noOfDrags = thisQ.noOfDropsInGroup(thisQ.getGroup(drag));
-                        var cloneDrags = thisQ.getInfiniteDragClones(drag, false);
-                        if (cloneDrags.length < noOfDrags) {
-                            var cloneDrag = drag.clone();
-                            cloneDrag.removeClass('beingdragged');
-                            cloneDrag.removeAttr('tabindex');
-                            hiddenDrag.after(cloneDrag);
-                        } else {
-                            hiddenDrag.addClass('active');
-                        }
-                    } else {
-                        hiddenDrag.addClass('active');
-                    }
-                }
                 drag.focus();
                 drag.removeData('isfocus');
             }
             if (typeof target.data('isfocus') !== 'undefined' && target.data('isfocus') === true) {
                 target.removeData('isfocus');
             }
+            if (questionManager.isKeyboardNavigation) {
+                questionManager.isKeyboardNavigation = false;
+            }
         }
     };
 
index ade0570..3ccd35b 100644 (file)
@@ -34,7 +34,7 @@ defined('MOODLE_INTERNAL') || die();
  */
 class qtype_ddwtos_test_helper extends question_test_helper {
     public function get_test_questions() {
-        return array('fox', 'maths', 'oddgroups');
+        return array('fox', 'maths', 'oddgroups', 'missingchoiceno');
     }
 
     /**
@@ -128,6 +128,31 @@ class qtype_ddwtos_test_helper extends question_test_helper {
         return $fromform;
     }
 
+    /**
+     * Get data required to save a drag-drop into text question where the author
+     * missed out one of the group numbers.
+     *
+     * @return stdClass data to create a ddwtos question.
+     */
+    public function get_ddwtos_question_form_data_missingchoiceno() {
+        $fromform = new stdClass();
+
+        $fromform->name = 'Drag-drop into text question with one index missing';
+        $fromform->questiontext = ['text' => 'The [[1]] sat on the [[3]].', 'format' => FORMAT_HTML];
+        $fromform->defaultmark = 1.0;
+        $fromform->generalfeedback = array('text' => 'The right answer is: "The cat sat on the mat."', 'format' => FORMAT_HTML);
+        $fromform->choices = array(
+                array('answer' => 'cat', 'choicegroup' => '1'),
+                array('answer' => '',    'choicegroup' => '1'),
+                array('answer' => 'mat', 'choicegroup' => '1'),
+        );
+        test_question_maker::set_standard_combined_feedback_form_data($fromform);
+        $fromform->shownumcorrect = 0;
+        $fromform->penalty = 0.3333333;
+
+        return $fromform;
+    }
+
     /**
      * @return qtype_ddwtos_question
      */
index ded8ebe..ca7f562 100644 (file)
@@ -117,6 +117,31 @@ class qtype_ddwtos_test extends question_testcase {
         $this->assertTrue($this->qtype->can_analyse_responses());
     }
 
+    public function test_save_question() {
+        $this->resetAfterTest();
+
+        $syscontext = context_system::instance();
+        /** @var core_question_generator $generator */
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $category = $generator->create_question_category(['contextid' => $syscontext->id]);
+
+        $fromform = test_question_maker::get_question_form_data('ddwtos', 'missingchoiceno');
+        $fromform->category = $category->id . ',' . $syscontext->id;
+
+        $question = new stdClass();
+        $question->category = $category->id;
+        $question->qtype = 'ddwtos';
+        $question->createdby = 0;
+
+        $this->qtype->save_question($question, $fromform);
+        $q = question_bank::load_question($question->id);
+        // We just want to verify that this does not cause errors,
+        // but also verify some of the outcome.
+        $this->assertEquals('The [[1]] sat on the [[2]].', $q->questiontext);
+        $this->assertEquals([1 => 1, 2 => 1], $q->places);
+        $this->assertEquals([1 => 1, 2 => 2], $q->rightchoices);
+    }
+
     public function test_initialise_question_instance() {
         $qdata = $this->get_test_question_data();
 
index de9f149..86f4a3d 100644 (file)
@@ -49,7 +49,35 @@ abstract class qtype_gapselect_base extends question_type {
     public function save_question_options($question) {
         global $DB;
         $context = $question->context;
-        $result = new stdClass();
+
+        // This question type needs the choices to be consecutively numbered, but
+        // there is no reason why the question author should have done that,
+        // so renumber if necessary.
+        // Insert all the new answers.
+        $nonblankchoices = [];
+        $questiontext = $question->questiontext;
+        $newkey = 0;
+        foreach ($question->choices as $key => $choice) {
+            if (trim($choice['answer']) == '') {
+                continue;
+            }
+
+            $nonblankchoices[] = $choice;
+            if ($newkey != $key) {
+                // Safe to do this in this order, because we will always be replacing
+                // a bigger number with a smaller number that is not present.
+                // Numbers in the question text always one bigger than the array index.
+                $questiontext = str_replace('[[' . ($key + 1) . ']]', '[[' . ($newkey + 1) . ']]',
+                        $questiontext);
+            }
+            $newkey += 1;
+        }
+        $question->choices = $nonblankchoices;
+        if ($questiontext !== $question->questiontext) {
+            $DB->set_field('question', 'questiontext', $questiontext,
+                    ['id' => $question->id]);
+            $question->questiontext = $questiontext;
+        }
 
         $oldanswers = $DB->get_records('question_answers',
                 array('question' => $question->id), 'id ASC');
@@ -57,14 +85,12 @@ abstract class qtype_gapselect_base extends question_type {
         // Insert all the new answers.
         foreach ($question->choices as $key => $choice) {
 
-            if (trim($choice['answer']) == '') {
-                continue;
-            }
+            // Answer guaranteed to be non-blank. See above.
 
             $feedback = $this->choice_options_to_feedback($choice);
 
             if ($answer = array_shift($oldanswers)) {
-                $answer->answer = $choice['answer'];
+                $answer->answer = trim($choice['answer']);
                 $answer->feedback = $feedback;
                 $DB->update_record('question_answers', $answer);
 
index faa98eb..5907cc1 100644 (file)
@@ -32,12 +32,91 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright 2011 The Open University
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class qtype_gapselect_test_helper {
+class qtype_gapselect_test_helper extends question_test_helper {
+
+    public function get_test_questions() {
+        return array('fox', 'maths', 'currency', 'multilang', 'missingchoiceno');
+    }
+
+    /**
+     * Get data you would get by loading a typical select missing words question.
+     *
+     * @return stdClass as returned by question_bank::load_question_data for this qtype.
+     */
+    public static function get_gapselect_question_data_fox() {
+        global $USER;
+
+        $gapselect = new stdClass();
+        $gapselect->id = 0;
+        $gapselect->category = 0;
+        $gapselect->contextid = 0;
+        $gapselect->parent = 0;
+        $gapselect->questiontextformat = FORMAT_HTML;
+        $gapselect->generalfeedbackformat = FORMAT_HTML;
+        $gapselect->defaultmark = 1;
+        $gapselect->penalty = 0.3333333;
+        $gapselect->length = 1;
+        $gapselect->stamp = make_unique_id_code();
+        $gapselect->version = make_unique_id_code();
+        $gapselect->hidden = 0;
+        $gapselect->idnumber = null;
+        $gapselect->timecreated = time();
+        $gapselect->timemodified = time();
+        $gapselect->createdby = $USER->id;
+        $gapselect->modifiedby = $USER->id;
+
+        $gapselect->name = 'Selection from drop down list question';
+        $gapselect->questiontext = 'The [[1]] brown [[2]] jumped over the [[3]] dog.';
+        $gapselect->generalfeedback = 'This sentence uses each letter of the alphabet.';
+        $gapselect->qtype = 'gapselect';
+
+        $gapselect->options = new stdClass();
+        $gapselect->options->shuffleanswers = true;
+
+        test_question_maker::set_standard_combined_feedback_fields($gapselect->options);
+
+        $gapselect->options->answers = array(
+            (object) array('answer' => 'quick', 'feedback' => '1'),
+            (object) array('answer' => 'fox', 'feedback' => '2'),
+            (object) array('answer' => 'lazy', 'feedback' => '3'),
+            (object) array('answer' => 'assiduous', 'feedback' => '3'),
+            (object) array('answer' => 'dog', 'feedback' => '2'),
+            (object) array('answer' => 'slow', 'feedback' => '1'),
+        );
+
+        return $gapselect;
+    }
+
+    /**
+     * Get data required to save a select missing words question where
+     * the author missed out one of the group numbers.
+     *
+     * @return stdClass data to create a gapselect question.
+     */
+    public function get_gapselect_question_form_data_missingchoiceno() {
+        $fromform = new stdClass();
+
+        $fromform->name = 'Select missing words question';
+        $fromform->questiontext = ['text' => 'The [[1]] sat on the [[3]].', 'format' => FORMAT_HTML];
+        $fromform->defaultmark = 1.0;
+        $fromform->generalfeedback = ['text' => 'The right answer is: "The cat sat on the mat."', 'format' => FORMAT_HTML];
+        $fromform->choices = [
+                ['answer' => 'cat', 'choicegroup' => '1'],
+                ['answer' => '',    'choicegroup' => '1'],
+                ['answer' => 'mat', 'choicegroup' => '1'],
+        ];
+        test_question_maker::set_standard_combined_feedback_form_data($fromform);
+        $fromform->shownumcorrect = 0;
+        $fromform->penalty = 0.3333333;
+
+        return $fromform;
+    }
+
     /**
      * Get an example gapselect question to use for testing. This examples has one of each item.
      * @return qtype_gapselect_question
      */
-    public static function make_a_gapselect_question() {
+    public static function make_gapselect_question_fox() {
         question_bank::load_question_definition_classes('gapselect');
         $gapselect = new qtype_gapselect_question();
 
@@ -75,7 +154,7 @@ class qtype_gapselect_test_helper {
      * Get an example gapselect question to use for testing. This exmples had unlimited items.
      * @return qtype_gapselect_question
      */
-    public static function make_a_maths_gapselect_question() {
+    public static function make_gapselect_question_maths() {
         question_bank::load_question_definition_classes('gapselect');
         $gapselect = new qtype_gapselect_question();
 
@@ -93,10 +172,10 @@ class qtype_gapselect_test_helper {
 
         $gapselect->choices = array(
             1 => array(
-                1 => new qtype_gapselect_choice('+', 1, true),
-                2 => new qtype_gapselect_choice('-', 1, true),
-                3 => new qtype_gapselect_choice('*', 1, true),
-                4 => new qtype_gapselect_choice('/', 1, true),
+                1 => new qtype_gapselect_choice('+', 1),
+                2 => new qtype_gapselect_choice('-', 1),
+                3 => new qtype_gapselect_choice('*', 1),
+                4 => new qtype_gapselect_choice('/', 1),
             ));
 
         $gapselect->places = array(1 => 1, 2 => 1, 3 => 1, 4 => 1);
@@ -110,7 +189,7 @@ class qtype_gapselect_test_helper {
      * Get an example gapselect question with multilang entries to use for testing.
      * @return qtype_gapselect_question
      */
-    public static function make_a_multilang_gapselect_question() {
+    public static function make_gapselect_question_multilang() {
         question_bank::load_question_definition_classes('gapselect');
         $gapselect = new qtype_gapselect_question();
 
@@ -129,14 +208,14 @@ class qtype_gapselect_test_helper {
         $gapselect->choices = array(
                 1 => array(
                     1 => new qtype_gapselect_choice('<span lang="en" class="multilang">cat</span><span lang="ru" ' .
-                        'class="multilang">кошка</span>', 1, true),
+                        'class="multilang">кошка</span>', 1),
                     2 => new qtype_gapselect_choice('<span lang="en" class="multilang">dog</span><span lang="ru" ' .
-                        'class="multilang">пес</span>', 1, true)),
+                        'class="multilang">пес</span>', 1)),
                 2 => array(
                     1 => new qtype_gapselect_choice('<span lang="en" class="multilang">mat</span><span lang="ru" ' .
-                        'class="multilang">коврике</span>', 2, true),
+                        'class="multilang">коврике</span>', 2),
                     2 => new qtype_gapselect_choice('<span lang="en" class="multilang">bat</span><span lang="ru" ' .
-                        'class="multilang">бита</span>', 2, true))
+                        'class="multilang">бита</span>', 2))
                 );
 
         $gapselect->places = array(1 => 1, 2 => 2);
@@ -151,7 +230,7 @@ class qtype_gapselect_test_helper {
      * This examples includes choices with currency like options.
      * @return qtype_gapselect_question
      */
-    public static function make_a_currency_gapselect_question() {
+    public static function make_gapselect_question_currency() {
         question_bank::load_question_definition_classes('gapselect');
         $gapselect = new qtype_gapselect_question();
 
@@ -181,4 +260,48 @@ class qtype_gapselect_test_helper {
 
         return $gapselect;
     }
+
+    /**
+     * Just for backwards compatibility.
+     *
+     * @return qtype_gapselect_question
+     */
+    public static function make_a_gapselect_question() {
+        debugging('qtype_gapselect_test_helper::make_a_gapselect_question is deprecated. ' .
+                "Please use test_question_maker::make_question('gapselect') instead.");
+        return self::make_gapselect_question_fox();
+    }
+
+    /**
+     * Just for backwards compatibility.
+     *
+     * @return qtype_gapselect_question
+     */
+    public static function make_a_maths_gapselect_question() {
+        debugging('qtype_gapselect_test_helper::make_a_maths_gapselect_question is deprecated. ' .
+                "Please use test_question_maker::make_question('gapselect', 'maths') instead.");
+        return self::make_gapselect_question_maths();
+    }
+
+    /**
+     * Just for backwards compatibility.
+     *
+     * @return qtype_gapselect_question
+     */
+    public static function make_a_currency_gapselect_question() {
+        debugging('qtype_gapselect_test_helper::make_a_currency_gapselect_question is deprecated. ' .
+                "Please use test_question_maker::make_question('gapselect', 'currency') instead.");
+        return self::make_gapselect_question_currency();
+    }
+
+    /**
+     * Just for backwards compatibility.
+     *
+     * @return qtype_gapselect_question
+     */
+    public static function make_a_multilang_gapselect_question() {
+        debugging('qtype_gapselect_test_helper::make_a_multilang_gapselect_question is deprecated. ' .
+                "Please use test_question_maker::make_question('gapselect', 'multilang') instead.");
+        return self::make_gapselect_question_multilang();
+    }
 }
index a437782..4bfdfd6 100644 (file)
@@ -39,21 +39,21 @@ require_once($CFG->dirroot . '/question/type/gapselect/tests/helper.php');
 class qtype_gapselect_question_test extends basic_testcase {
 
     public function test_get_question_summary() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $this->assertEquals('The [[1]] brown [[2]] jumped over the [[3]] dog.; ' .
                 '[[1]] -> {quick / slow}; [[2]] -> {fox / dog}; [[3]] -> {lazy / assiduous}',
                 $gapselect->get_question_summary());
     }
 
     public function test_get_question_summary_maths() {
-        $gapselect = qtype_gapselect_test_helper::make_a_maths_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect', 'maths');
         $this->assertEquals('Fill in the operators to make this equation work: ' .
                 '7 [[1]] 11 [[2]] 13 [[1]] 17 [[2]] 19 = 3; [[1]] -> {+ / - / * / /}',
                 $gapselect->get_question_summary());
     }
 
     public function test_summarise_response() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
@@ -62,7 +62,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_summarise_response_maths() {
-        $gapselect = qtype_gapselect_test_helper::make_a_maths_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect', 'maths');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
@@ -71,17 +71,17 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_get_random_guess_score() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $this->assertEquals(0.5, $gapselect->get_random_guess_score());
     }
 
     public function test_get_random_guess_score_maths() {
-        $gapselect = qtype_gapselect_test_helper::make_a_maths_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect', 'maths');
         $this->assertEquals(0.25, $gapselect->get_random_guess_score());
     }
 
     public function test_get_right_choice_for() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
@@ -90,7 +90,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_get_right_choice_for_maths() {
-        $gapselect = qtype_gapselect_test_helper::make_a_maths_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect', 'maths');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
@@ -99,7 +99,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_clear_wrong_from_response() {
-        $gapselect = qtype_gapselect_test_helper::make_a_maths_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect', 'maths');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
@@ -109,7 +109,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_get_num_parts_right() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
@@ -120,7 +120,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_get_num_parts_right_maths() {
-        $gapselect = qtype_gapselect_test_helper::make_a_maths_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect', 'maths');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
@@ -129,7 +129,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_get_expected_data() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
         $this->assertEquals(array('p1' => PARAM_INT, 'p2' => PARAM_INT, 'p3' => PARAM_INT),
@@ -137,7 +137,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_get_correct_response() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
@@ -146,7 +146,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_get_correct_response_maths() {
-        $gapselect = qtype_gapselect_test_helper::make_a_maths_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect', 'maths');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
@@ -155,7 +155,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_is_same_response() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
         $this->assertTrue($gapselect->is_same_response(
@@ -179,7 +179,7 @@ class qtype_gapselect_question_test extends basic_testcase {
                 array('p1' => '1', 'p2' => '2', 'p3' => '2')));
     }
     public function test_is_complete_response() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
         $this->assertFalse($gapselect->is_complete_response(array()));
@@ -191,7 +191,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_is_gradable_response() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
         $this->assertFalse($gapselect->is_gradable_response(array()));
@@ -205,7 +205,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_grading() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
@@ -218,7 +218,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_grading_maths() {
-        $gapselect = qtype_gapselect_test_helper::make_a_maths_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect', 'maths');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
@@ -231,7 +231,7 @@ class qtype_gapselect_question_test extends basic_testcase {
     }
 
     public function test_classify_response() {
-        $gapselect = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $gapselect = test_question_maker::make_question('gapselect');
         $gapselect->shufflechoices = false;
         $gapselect->start_attempt(new question_attempt_step(), 1);
 
index 6eea6cb..88ccadf 100644 (file)
@@ -27,7 +27,6 @@ defined('MOODLE_INTERNAL') || die();
 global $CFG;
 
 require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
-require_once($CFG->dirroot . '/question/type/gapselect/tests/helper.php');
 
 
 /**
@@ -41,7 +40,7 @@ class qtype_gapselect_test extends question_testcase {
     protected $qtype;
 
     protected function setUp() {
-        $this->qtype = question_bank::get_qtype('gapselect');;
+        $this->qtype = question_bank::get_qtype('gapselect');
     }
 
     protected function tearDown() {
@@ -50,61 +49,47 @@ class qtype_gapselect_test extends question_testcase {
 
     /**
      * Asserts that two strings containing XML are the same ignoring the line-endings.
-     * @param unknown $expectedxml
-     * @param unknown $xml
+     *
+     * @param string $expectedxml
+     * @param string $xml
      */
     public function assert_same_xml($expectedxml, $xml) {
         $this->assertEquals(str_replace("\r\n", "\n", $expectedxml),
                 str_replace("\r\n", "\n", $xml));
     }
 
+    public function test_save_question() {
+        $this->resetAfterTest();
+
+        $syscontext = context_system::instance();
+        /** @var core_question_generator $generator */
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $category = $generator->create_question_category(['contextid' => $syscontext->id]);
+
+        $fromform = test_question_maker::get_question_form_data('gapselect', 'missingchoiceno');
+        $fromform->category = $category->id . ',' . $syscontext->id;
+
+        $question = new stdClass();
+        $question->category = $category->id;
+        $question->qtype = 'gapselect';
+        $question->createdby = 0;
+
+        $this->qtype->save_question($question, $fromform);
+        $q = question_bank::load_question($question->id);
+        // We just want to verify that this does not cause errors,
+        // but also verify some of the outcome.
+        $this->assertEquals('The [[1]] sat on the [[2]].', $q->questiontext);
+        $this->assertEquals([1 => 1, 2 => 1], $q->places);
+        $this->assertEquals([1 => 1, 2 => 2], $q->rightchoices);
+    }
+
     /**
      * Get some test question data.
      * @return object the data to construct a question like
-     * {@link qtype_gapselect_test_helper::make_a_gapselect_question()}.
+     * {@link test_question_maker::make_question('gapselect')}.
      */
     protected function get_test_question_data() {
-        global $USER;
-
-        $gapselect = new stdClass();
-        $gapselect->id = 0;
-        $gapselect->category = 0;
-        $gapselect->contextid = 0;
-        $gapselect->parent = 0;
-        $gapselect->questiontextformat = FORMAT_HTML;
-        $gapselect->generalfeedbackformat = FORMAT_HTML;
-        $gapselect->defaultmark = 1;
-        $gapselect->penalty = 0.3333333;
-        $gapselect->length = 1;
-        $gapselect->stamp = make_unique_id_code();
-        $gapselect->version = make_unique_id_code();
-        $gapselect->hidden = 0;
-        $gapselect->idnumber = null;
-        $gapselect->timecreated = time();
-        $gapselect->timemodified = time();
-        $gapselect->createdby = $USER->id;
-        $gapselect->modifiedby = $USER->id;
-
-        $gapselect->name = 'Selection from drop down list question';
-        $gapselect->questiontext = 'The [[1]] brown [[2]] jumped over the [[3]] dog.';
-        $gapselect->generalfeedback = 'This sentence uses each letter of the alphabet.';
-        $gapselect->qtype = 'gapselect';
-
-        $gapselect->options = new stdClass();
-        $gapselect->options->shuffleanswers = true;
-
-        test_question_maker::set_standard_combined_feedback_fields($gapselect->options);
-
-        $gapselect->options->answers = array(
-            (object) array('answer' => 'quick', 'feedback' => '1'),
-            (object) array('answer' => 'fox', 'feedback' => '2'),
-            (object) array('answer' => 'lazy', 'feedback' => '3'),
-            (object) array('answer' => 'assiduous', 'feedback' => '3'),
-            (object) array('answer' => 'dog', 'feedback' => '2'),
-            (object) array('answer' => 'slow', 'feedback' => '1'),
-        );
-
-        return $gapselect;
+        return test_question_maker::get_question_data('gapselect');
     }
 
     public function test_name() {
@@ -118,7 +103,7 @@ class qtype_gapselect_test extends question_testcase {
     public function test_initialise_question_instance() {
         $qdata = $this->get_test_question_data();
 
-        $expected = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $expected = test_question_maker::make_question('gapselect');
         $expected->stamp = $qdata->stamp;
         $expected->version = $qdata->version;
 
index ba9ea8e..89e2fa8 100644 (file)
@@ -40,7 +40,7 @@ class qtype_gapselect_walkthrough_test extends qbehaviour_walkthrough_test_base
     public function test_interactive_behaviour() {
 
         // Create a gapselect question.
-        $q = qtype_gapselect_test_helper::make_a_gapselect_question();
+        $q = test_question_maker::make_question('gapselect');
         $q->hints = array(
             new question_hint_with_parts(1, 'This is the first hint.', FORMAT_HTML, false, false),
             new question_hint_with_parts(2, 'This is the second hint.', FORMAT_HTML, true, true),
@@ -160,7 +160,7 @@ class qtype_gapselect_walkthrough_test extends qbehaviour_walkthrough_test_base
         $filtermanager->reset_caches();
 
         // Create a multilang gapselect question.
-        $q = qtype_gapselect_test_helper::make_a_multilang_gapselect_question();
+        $q = test_question_maker::make_question('gapselect', 'multilang');
         $q->shufflechoices = false;
         $this->start_attempt_at_question($q, 'interactive', 3);
 
@@ -177,7 +177,7 @@ class qtype_gapselect_walkthrough_test extends qbehaviour_walkthrough_test_base
     public function test_choices_containing_dollars() {
 
         // Choices with a currency like entry (e.g. $3) should display.
-        $q = qtype_gapselect_test_helper::make_a_currency_gapselect_question();
+        $q = test_question_maker::make_question('gapselect', 'currency');
         $q->shufflechoices = false;
         $this->start_attempt_at_question($q, 'interactive', 1);
 
index 5c353f8..21c00ec 100644 (file)
@@ -323,9 +323,9 @@ class question_type {
      *       is accurate any more.)
      */
     public function save_question($question, $form) {
-        global $USER, $DB, $OUTPUT;
+        global $USER, $DB;
 
-        // The actuall update/insert done with multiple DB access, so we do it in a transaction.
+        // The actual update/insert done with multiple DB access, so we do it in a transaction.
         $transaction = $DB->start_delegated_transaction ();
 
         list($question->category) = explode(',', $form->category);
diff --git a/theme/boost/amd/build/alert.min.js b/theme/boost/amd/build/alert.min.js
deleted file mode 100644 (file)
index 64e09a6..0000000
Binary files a/theme/boost/amd/build/alert.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/alert.min.js.map b/theme/boost/amd/build/alert.min.js.map
deleted file mode 100644 (file)
index f1fde5a..0000000
Binary files a/theme/boost/amd/build/alert.min.js.map and /dev/null differ
diff --git a/theme/boost/amd/build/bootstrap/alert.min.js b/theme/boost/amd/build/bootstrap/alert.min.js
new file mode 100644 (file)
index 0000000..dcb51aa
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/alert.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/alert.min.js.map b/theme/boost/amd/build/bootstrap/alert.min.js.map
new file mode 100644 (file)
index 0000000..b26833c
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/alert.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/button.min.js b/theme/boost/amd/build/bootstrap/button.min.js
new file mode 100644 (file)
index 0000000..bdc9b93
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/button.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/button.min.js.map b/theme/boost/amd/build/bootstrap/button.min.js.map
new file mode 100644 (file)
index 0000000..dc25c52
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/button.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/carousel.min.js b/theme/boost/amd/build/bootstrap/carousel.min.js
new file mode 100644 (file)
index 0000000..477f01b
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/carousel.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/carousel.min.js.map b/theme/boost/amd/build/bootstrap/carousel.min.js.map
new file mode 100644 (file)
index 0000000..6093ef4
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/carousel.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/collapse.min.js b/theme/boost/amd/build/bootstrap/collapse.min.js
new file mode 100644 (file)
index 0000000..365a018
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/collapse.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/collapse.min.js.map b/theme/boost/amd/build/bootstrap/collapse.min.js.map
new file mode 100644 (file)
index 0000000..964a939
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/collapse.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/dropdown.min.js b/theme/boost/amd/build/bootstrap/dropdown.min.js
new file mode 100644 (file)
index 0000000..279ced3
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/dropdown.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/dropdown.min.js.map b/theme/boost/amd/build/bootstrap/dropdown.min.js.map
new file mode 100644 (file)
index 0000000..f6df14f
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/dropdown.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/index.min.js b/theme/boost/amd/build/bootstrap/index.min.js
new file mode 100644 (file)
index 0000000..9bcd5b1
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/index.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/index.min.js.map b/theme/boost/amd/build/bootstrap/index.min.js.map
new file mode 100644 (file)
index 0000000..b366651
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/index.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/modal.min.js b/theme/boost/amd/build/bootstrap/modal.min.js
new file mode 100644 (file)
index 0000000..b1cb596
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/modal.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/modal.min.js.map b/theme/boost/amd/build/bootstrap/modal.min.js.map
new file mode 100644 (file)
index 0000000..140720f
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/modal.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/popover.min.js b/theme/boost/amd/build/bootstrap/popover.min.js
new file mode 100644 (file)
index 0000000..61c387f
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/popover.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/popover.min.js.map b/theme/boost/amd/build/bootstrap/popover.min.js.map
new file mode 100644 (file)
index 0000000..465e1a4
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/popover.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/scrollspy.min.js b/theme/boost/amd/build/bootstrap/scrollspy.min.js
new file mode 100644 (file)
index 0000000..2fa1426
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/scrollspy.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/scrollspy.min.js.map b/theme/boost/amd/build/bootstrap/scrollspy.min.js.map
new file mode 100644 (file)
index 0000000..8d96336
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/scrollspy.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/tab.min.js b/theme/boost/amd/build/bootstrap/tab.min.js
new file mode 100644 (file)
index 0000000..2421fb0
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/tab.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/tab.min.js.map b/theme/boost/amd/build/bootstrap/tab.min.js.map
new file mode 100644 (file)
index 0000000..989a659
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/tab.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/toast.min.js b/theme/boost/amd/build/bootstrap/toast.min.js
new file mode 100644 (file)
index 0000000..de29992
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/toast.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/toast.min.js.map b/theme/boost/amd/build/bootstrap/toast.min.js.map
new file mode 100644 (file)
index 0000000..3b9179a
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/toast.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/tools/sanitizer.min.js b/theme/boost/amd/build/bootstrap/tools/sanitizer.min.js
new file mode 100644 (file)
index 0000000..d2c359d
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/tools/sanitizer.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/tools/sanitizer.min.js.map b/theme/boost/amd/build/bootstrap/tools/sanitizer.min.js.map
new file mode 100644 (file)
index 0000000..61311c0
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/tools/sanitizer.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/tooltip.min.js b/theme/boost/amd/build/bootstrap/tooltip.min.js
new file mode 100644 (file)
index 0000000..6991e78
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/tooltip.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/tooltip.min.js.map b/theme/boost/amd/build/bootstrap/tooltip.min.js.map
new file mode 100644 (file)
index 0000000..65f7255
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/tooltip.min.js.map differ
diff --git a/theme/boost/amd/build/bootstrap/util.min.js b/theme/boost/amd/build/bootstrap/util.min.js
new file mode 100644 (file)
index 0000000..611cada
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/util.min.js differ
diff --git a/theme/boost/amd/build/bootstrap/util.min.js.map b/theme/boost/amd/build/bootstrap/util.min.js.map
new file mode 100644 (file)
index 0000000..ed17e6f
Binary files /dev/null and b/theme/boost/amd/build/bootstrap/util.min.js.map differ
diff --git a/theme/boost/amd/build/button.min.js b/theme/boost/amd/build/button.min.js
deleted file mode 100644 (file)
index 46bc89d..0000000
Binary files a/theme/boost/amd/build/button.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/button.min.js.map b/theme/boost/amd/build/button.min.js.map
deleted file mode 100644 (file)
index 6f26ee8..0000000
Binary files a/theme/boost/amd/build/button.min.js.map and /dev/null differ
diff --git a/theme/boost/amd/build/carousel.min.js b/theme/boost/amd/build/carousel.min.js
deleted file mode 100644 (file)
index 5871f5c..0000000
Binary files a/theme/boost/amd/build/carousel.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/carousel.min.js.map b/theme/boost/amd/build/carousel.min.js.map
deleted file mode 100644 (file)
index f2055a0..0000000
Binary files a/theme/boost/amd/build/carousel.min.js.map and /dev/null differ
diff --git a/theme/boost/amd/build/collapse.min.js b/theme/boost/amd/build/collapse.min.js
deleted file mode 100644 (file)
index 3185b0f..0000000
Binary files a/theme/boost/amd/build/collapse.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/collapse.min.js.map b/theme/boost/amd/build/collapse.min.js.map
deleted file mode 100644 (file)
index 19bcfa2..0000000
Binary files a/theme/boost/amd/build/collapse.min.js.map and /dev/null differ
diff --git a/theme/boost/amd/build/dropdown.min.js b/theme/boost/amd/build/dropdown.min.js
deleted file mode 100644 (file)
index 645a8b4..0000000
Binary files a/theme/boost/amd/build/dropdown.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/dropdown.min.js.map b/theme/boost/amd/build/dropdown.min.js.map
deleted file mode 100644 (file)
index e6c8d71..0000000
Binary files a/theme/boost/amd/build/dropdown.min.js.map and /dev/null differ
diff --git a/theme/boost/amd/build/index.min.js b/theme/boost/amd/build/index.min.js
deleted file mode 100644 (file)
index f814773..0000000
Binary files a/theme/boost/amd/build/index.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/index.min.js.map b/theme/boost/amd/build/index.min.js.map
deleted file mode 100644 (file)
index ecb4a7d..0000000
Binary files a/theme/boost/amd/build/index.min.js.map and /dev/null differ
index 91e6786..ca24dd7 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js and b/theme/boost/amd/build/loader.min.js differ
index eb80756..bf9bf8d 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js.map and b/theme/boost/amd/build/loader.min.js.map differ
diff --git a/theme/boost/amd/build/modal.min.js b/theme/boost/amd/build/modal.min.js
deleted file mode 100644 (file)
index 8156564..0000000
Binary files a/theme/boost/amd/build/modal.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/modal.min.js.map b/theme/boost/amd/build/modal.min.js.map
deleted file mode 100644 (file)
index cc8ee03..0000000
Binary files a/theme/boost/amd/build/modal.min.js.map and /dev/null differ
index 809d6e7..d3e3bad 100644 (file)
Binary files a/theme/boost/amd/build/popover.min.js and b/theme/boost/amd/build/popover.min.js differ
index 93e3919..a7b2c0e 100644 (file)
Binary files a/theme/boost/amd/build/popover.min.js.map and b/theme/boost/amd/build/popover.min.js.map differ
diff --git a/theme/boost/amd/build/sanitizer.min.js b/theme/boost/amd/build/sanitizer.min.js
deleted file mode 100644 (file)
index d534905..0000000
Binary files a/theme/boost/amd/build/sanitizer.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/sanitizer.min.js.map b/theme/boost/amd/build/sanitizer.min.js.map
deleted file mode 100644 (file)
index 4ea0d01..0000000
Binary files a/theme/boost/amd/build/sanitizer.min.js.map and /dev/null differ
diff --git a/theme/boost/amd/build/scrollspy.min.js b/theme/boost/amd/build/scrollspy.min.js
deleted file mode 100644 (file)
index f3bde01..0000000
Binary files a/theme/boost/amd/build/scrollspy.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/scrollspy.min.js.map b/theme/boost/amd/build/scrollspy.min.js.map
deleted file mode 100644 (file)
index b2d98e5..0000000
Binary files a/theme/boost/amd/build/scrollspy.min.js.map and /dev/null differ
diff --git a/theme/boost/amd/build/tab.min.js b/theme/boost/amd/build/tab.min.js
deleted file mode 100644 (file)
index 099bb3a..0000000
Binary files a/theme/boost/amd/build/tab.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/tab.min.js.map b/theme/boost/amd/build/tab.min.js.map
deleted file mode 100644 (file)
index 2f508e9..0000000
Binary files a/theme/boost/amd/build/tab.min.js.map and /dev/null differ
diff --git a/theme/boost/amd/build/tether.min.js b/theme/boost/amd/build/tether.min.js
deleted file mode 100644 (file)
index 9e10be9..0000000
Binary files a/theme/boost/amd/build/tether.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/tether.min.js.map b/theme/boost/amd/build/tether.min.js.map
deleted file mode 100644 (file)
index fcb74d0..0000000
Binary files a/theme/boost/amd/build/tether.min.js.map and /dev/null differ
index dc2b834..d055d3f 100644 (file)
Binary files a/theme/boost/amd/build/toast.min.js and b/theme/boost/amd/build/toast.min.js differ
index 22be02b..7f2acb0 100644 (file)
Binary files a/theme/boost/amd/build/toast.min.js.map and b/theme/boost/amd/build/toast.min.js.map differ
diff --git a/theme/boost/amd/build/tooltip.min.js b/theme/boost/amd/build/tooltip.min.js
deleted file mode 100644 (file)
index 9137cbc..0000000
Binary files a/theme/boost/amd/build/tooltip.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/tooltip.min.js.map b/theme/boost/amd/build/tooltip.min.js.map
deleted file mode 100644 (file)
index bfa5185..0000000
Binary files a/theme/boost/amd/build/tooltip.min.js.map and /dev/null differ
diff --git a/theme/boost/amd/build/util.min.js b/theme/boost/amd/build/util.min.js
deleted file mode 100644 (file)
index 37e7acf..0000000
Binary files a/theme/boost/amd/build/util.min.js and /dev/null differ
diff --git a/theme/boost/amd/build/util.min.js.map b/theme/boost/amd/build/util.min.js.map
deleted file mode 100644 (file)
index 75d109e..0000000
Binary files a/theme/boost/amd/build/util.min.js.map and /dev/null differ
diff --git a/theme/boost/amd/src/alert.js b/theme/boost/amd/src/alert.js
deleted file mode 100644 (file)
index a1f927f..0000000
+++ /dev/null
@@ -1,213 +0,0 @@
-"use strict";
-
-define(["exports", "jquery", "./util"], function (exports, _jquery, _util) {
-  "use strict";
-
-  Object.defineProperty(exports, "__esModule", {
-    value: true
-  });
-
-  var _jquery2 = _interopRequireDefault(_jquery);
-
-  var _util2 = _interopRequireDefault(_util);
-
-  function _interopRequireDefault(obj) {
-    return obj && obj.__esModule ? obj : {
-      default: obj
-    };
-  }
-
-  function _classCallCheck(instance, Constructor) {
-    if (!(instance instanceof Constructor)) {
-      throw new TypeError("Cannot call a class as a function");
-    }
-  }
-
-  function _defineProperties(target, props) {
-    for (var i = 0; i < props.length; i++) {
-      var descriptor = props[i];
-      descriptor.enumerable = descriptor.enumerable || false;
-      descriptor.configurable = true;
-      if ("value" in descriptor) descriptor.writable = true;
-      Object.defineProperty(target, descriptor.key, descriptor);
-    }
-  }
-
-  function _createClass(Constructor, protoProps, staticProps) {
-    if (protoProps) _defineProperties(Constructor.prototype, protoProps);
-    if (staticProps) _defineProperties(Constructor, staticProps);
-    return Constructor;
-  }
-
-  /**
-   * ------------------------------------------------------------------------
-   * Constants
-   * ------------------------------------------------------------------------
-   */
-  var NAME = 'alert';
-  var VERSION = '4.3.1';
-  var DATA_KEY = 'bs.alert';
-  var EVENT_KEY = ".".concat(DATA_KEY);
-  var DATA_API_KEY = '.data-api';
-  var JQUERY_NO_CONFLICT = _jquery2.default.fn[NAME];
-  var Selector = {
-    DISMISS: '[data-dismiss="alert"]'
-  };
-  var Event = {
-    CLOSE: "close".concat(EVENT_KEY),
-    CLOSED: "closed".concat(EVENT_KEY),
-    CLICK_DATA_API: "click".concat(EVENT_KEY).concat(DATA_API_KEY)
-  };
-  var ClassName = {
-    ALERT: 'alert',
-    FADE: 'fade',
-    SHOW: 'show'
-    /**
-     * ------------------------------------------------------------------------
-     * Class Definition
-     * ------------------------------------------------------------------------
-     */
-
-  };
-
-  var Alert = function () {
-    function Alert(element) {
-      _classCallCheck(this, Alert);
-
-      this._element = element;
-    } // Getters
-
-
-    _createClass(Alert, [{
-      key: "close",
-      value: function close(element) {
-        var rootElement = this._element;
-
-        if (element) {
-          rootElement = this._getRootElement(element);
-        }
-
-        var customEvent = this._triggerCloseEvent(rootElement);
-
-        if (customEvent.isDefaultPrevented()) {
-          return;
-        }
-
-        this._removeElement(rootElement);
-      }
-    }, {
-      key: "dispose",
-      value: function dispose() {
-        _jquery2.default.removeData(this._element, DATA_KEY);
-
-        this._element = null;
-      }
-    }, {
-      key: "_getRootElement",
-      value: function _getRootElement(element) {
-        var selector = _util2.default.getSelectorFromElement(element);
-
-        var parent = false;
-
-        if (selector) {
-          parent = document.querySelector(selector);
-        }
-
-        if (!parent) {
-          parent = (0, _jquery2.default)(element).closest(".".concat(ClassName.ALERT))[0];
-        }
-
-        return parent;
-      }
-    }, {
-      key: "_triggerCloseEvent",
-      value: function _triggerCloseEvent(element) {
-        var closeEvent = _jquery2.default.Event(Event.CLOSE);
-
-        (0, _jquery2.default)(element).trigger(closeEvent);
-        return closeEvent;
-      }
-    }, {
-      key: "_removeElement",
-      value: function _removeElement(element) {
-        var _this = this;
-
-        (0, _jquery2.default)(element).removeClass(ClassName.SHOW);
-
-        if (!(0, _jquery2.default)(element).hasClass(ClassName.FADE)) {
-          this._destroyElement(element);
-
-          return;
-        }
-
-        var transitionDuration = _util2.default.getTransitionDurationFromElement(element);
-
-        (0, _jquery2.default)(element).one(_util2.default.TRANSITION_END, function (event) {
-          return _this._destroyElement(element, event);
-        }).emulateTransitionEnd(transitionDuration);
-      }
-    }, {
-      key: "_destroyElement",
-      value: function _destroyElement(element) {
-        (0, _jquery2.default)(element).detach().trigger(Event.CLOSED).remove();
-      }
-    }], [{
-      key: "_jQueryInterface",
-      value: function _jQueryInterface(config) {
-        return this.each(function () {
-          var $element = (0, _jquery2.default)(this);
-          var data = $element.data(DATA_KEY);
-
-          if (!data) {
-            data = new Alert(this);
-            $element.data(DATA_KEY, data);
-          }
-
-          if (config === 'close') {
-            data[config](this);
-          }
-        });
-      }
-    }, {
-      key: "_handleDismiss",
-      value: function _handleDismiss(alertInstance) {
-        return function (event) {
-          if (event) {
-            event.preventDefault();
-          }
-
-          alertInstance.close(this);
-        };
-      }
-    }, {
-      key: "VERSION",
-      get: function get() {
-        return VERSION;
-      }
-    }]);
-
-    return Alert;
-  }();
-
-  /**
-   * ------------------------------------------------------------------------
-   * Data Api implementation
-   * ------------------------------------------------------------------------
-   */
-  (0, _jquery2.default)(document).on(Event.CLICK_DATA_API, Selector.DISMISS, Alert._handleDismiss(new Alert()));
-  /**
-   * ------------------------------------------------------------------------
-   * jQuery
-   * ------------------------------------------------------------------------
-   */
-
-  _jquery2.default.fn[NAME] = Alert._jQueryInterface;
-  _jquery2.default.fn[NAME].Constructor = Alert;
-
-  _jquery2.default.fn[NAME].noConflict = function () {
-    _jquery2.default.fn[NAME] = JQUERY_NO_CONFLICT;
-    return Alert._jQueryInterface;
-  };
-
-  exports.default = Alert;
-});
\ No newline at end of file
diff --git a/theme/boost/amd/src/bootstrap/alert.js b/theme/boost/amd/src/bootstrap/alert.js
new file mode 100644 (file)
index 0000000..c15460a
--- /dev/null
@@ -0,0 +1,173 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v4.5.0): alert.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import $ from 'jquery'
+import Util from './util'
+
+/**
+ * ------------------------------------------------------------------------
+ * Constants
+ * ------------------------------------------------------------------------
+ */
+
+const NAME                = 'alert'
+const VERSION             = '4.5.0'
+const DATA_KEY            = 'bs.alert'
+const EVENT_KEY           = `.${DATA_KEY}`
+const DATA_API_KEY        = '.data-api'
+const JQUERY_NO_CONFLICT  = $.fn[NAME]
+
+const SELECTOR_DISMISS = '[data-dismiss="alert"]'
+
+const EVENT_CLOSE          = `close${EVENT_KEY}`
+const EVENT_CLOSED         = `closed${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_ALERT = 'alert'
+const CLASS_NAME_FADE  = 'fade'
+const CLASS_NAME_SHOW  = 'show'
+
+/**
+ * ------------------------------------------------------------------------
+ * Class Definition
+ * ------------------------------------------------------------------------
+ */
+
+class Alert {
+  constructor(element) {
+    this._element = element
+  }
+
+  // Getters
+
+  static get VERSION() {
+    return VERSION
+  }
+
+  // Public
+
+  close(element) {
+    let rootElement = this._element
+    if (element) {
+      rootElement = this._getRootElement(element)
+    }
+
+    const customEvent = this._triggerCloseEvent(rootElement)
+
+    if (customEvent.isDefaultPrevented()) {
+      return
+    }
+
+    this._removeElement(rootElement)
+  }
+
+  dispose() {
+    $.removeData(this._element, DATA_KEY)
+    this._element = null
+  }
+
+  // Private
+
+  _getRootElement(element) {
+    const selector = Util.getSelectorFromElement(element)
+    let parent     = false
+
+    if (selector) {
+      parent = document.querySelector(selector)
+    }
+
+    if (!parent) {
+      parent = $(element).closest(`.${CLASS_NAME_ALERT}`)[0]
+    }
+
+    return parent
+  }
+
+  _triggerCloseEvent(element) {
+    const closeEvent = $.Event(EVENT_CLOSE)
+
+    $(element).trigger(closeEvent)
+    return closeEvent
+  }
+
+  _removeElement(element) {
+    $(element).removeClass(CLASS_NAME_SHOW)
+
+    if (!$(element).hasClass(CLASS_NAME_FADE)) {
+      this._destroyElement(element)
+      return
+    }
+
+    const transitionDuration = Util.getTransitionDurationFromElement(element)
+
+    $(element)
+      .one(Util.TRANSITION_END, (event) => this._destroyElement(element, event))
+      .emulateTransitionEnd(transitionDuration)
+  }
+
+  _destroyElement(element) {
+    $(element)
+      .detach()
+      .trigger(EVENT_CLOSED)
+      .remove()
+  }
+
+  // Static
+
+  static _jQueryInterface(config) {
+    return this.each(function () {
+      const $element = $(this)
+      let data       = $element.data(DATA_KEY)
+
+      if (!data) {
+        data = new Alert(this)
+        $element.data(DATA_KEY, data)
+      }
+
+      if (config === 'close') {
+        data[config](this)
+      }
+    })
+  }
+
+  static _handleDismiss(alertInstance) {
+    return function (event) {
+      if (event) {
+        event.preventDefault()
+      }
+
+      alertInstance.close(this)
+    }
+  }
+}
+
+/**
+ * ------------------------------------------------------------------------
+ * Data Api implementation
+ * ------------------------------------------------------------------------
+ */
+
+$(document).on(
+  EVENT_CLICK_DATA_API,
+  SELECTOR_DISMISS,
+  Alert._handleDismiss(new Alert())
+)
+
+/**
+ * ------------------------------------------------------------------------
+ * jQuery
+ * ------------------------------------------------------------------------
+ */
+
+$.fn[NAME]             = Alert._jQueryInterface
+$.fn[NAME].Constructor = Alert
+$.fn[NAME].noConflict  = () => {
+  $.fn[NAME] = JQUERY_NO_CONFLICT
+  return Alert._jQueryInterface
+}
+
+export default Alert
diff --git a/theme/boost/amd/src/bootstrap/button.js b/theme/boost/amd/src/bootstrap/button.js
new file mode 100644 (file)
index 0000000..0da2545
--- /dev/null
@@ -0,0 +1,207 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v4.5.0): button.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import $ from 'jquery'
+
+/**
+ * ------------------------------------------------------------------------
+ * Constants
+ * ------------------------------------------------------------------------
+ */
+
+const NAME                = 'button'
+const VERSION             = '4.5.0'
+const DATA_KEY            = 'bs.button'
+const EVENT_KEY           = `.${DATA_KEY}`
+const DATA_API_KEY        = '.data-api'
+const JQUERY_NO_CONFLICT  = $.fn[NAME]
+
+const CLASS_NAME_ACTIVE = 'active'
+const CLASS_NAME_BUTTON = 'btn'
+const CLASS_NAME_FOCUS  = 'focus'
+
+const SELECTOR_DATA_TOGGLE_CARROT   = '[data-toggle^="button"]'
+const SELECTOR_DATA_TOGGLES         = '[data-toggle="buttons"]'
+const SELECTOR_DATA_TOGGLE          = '[data-toggle="button"]'
+const SELECTOR_DATA_TOGGLES_BUTTONS = '[data-toggle="buttons"] .btn'
+const SELECTOR_INPUT                = 'input:not([type="hidden"])'
+const SELECTOR_ACTIVE               = '.active'
+const SELECTOR_BUTTON               = '.btn'
+
+const EVENT_CLICK_DATA_API      = `click${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_FOCUS_BLUR_DATA_API = `focus${EVENT_KEY}${DATA_API_KEY} ` +
+                          `blur${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_LOAD_DATA_API       = `load${EVENT_KEY}${DATA_API_KEY}`
+
+/**
+ * ------------------------------------------------------------------------
+ * Class Definition
+ * ------------------------------------------------------------------------
+ */
+
+class Button {
+  constructor(element) {
+    this._element = element
+  }
+
+  // Getters
+
+  static get VERSION() {
+    return VERSION
+  }
+
+  // Public
+
+  toggle() {
+    let triggerChangeEvent = true
+    let addAriaPressed = true
+    const rootElement = $(this._element).closest(
+      SELECTOR_DATA_TOGGLES
+    )[0]
+
+    if (rootElement) {
+      const input = this._element.querySelector(SELECTOR_INPUT)
+
+      if (input) {
+        if (input.type === 'radio') {
+          if (input.checked &&
+            this._element.classList.contains(CLASS_NAME_ACTIVE)) {
+            triggerChangeEvent = false
+          } else {
+            const activeElement = rootElement.querySelector(SELECTOR_ACTIVE)
+
+            if (activeElement) {
+              $(activeElement).removeClass(CLASS_NAME_ACTIVE)
+            }
+          }
+        }
+
+        if (triggerChangeEvent) {
+          // if it's not a radio button or checkbox don't add a pointless/invalid checked property to the input
+          if (input.type === 'checkbox' || input.type === 'radio') {
+            input.checked = !this._element.classList.contains(CLASS_NAME_ACTIVE)
+          }
+          $(input).trigger('change')
+        }
+
+        input.focus()
+        addAriaPressed = false
+      }
+    }
+
+    if (!(this._element.hasAttribute('disabled') || this._element.classList.contains('disabled'))) {
+      if (addAriaPressed) {
+        this._element.setAttribute('aria-pressed',
+          !this._element.classList.contains(CLASS_NAME_ACTIVE))
+      }
+
+      if (triggerChangeEvent) {
+        $(this._element).toggleClass(CLASS_NAME_ACTIVE)
+      }
+    }
+  }
+
+  dispose() {
+    $.removeData(this._element, DATA_KEY)
+    this._element = null
+  }
+
+  // Static
+
+  static _jQueryInterface(config) {
+    return this.each(function () {
+      let data = $(this).data(DATA_KEY)
+
+      if (!data) {
+        data = new Button(this)
+        $(this).data(DATA_KEY, data)
+      }
+
+      if (config === 'toggle') {
+        data[config]()
+      }
+    })
+  }
+}
+
+/**
+ * ------------------------------------------------------------------------
+ * Data Api implementation
+ * ------------------------------------------------------------------------
+ */
+
+$(document)
+  .on(EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE_CARROT, (event) => {
+    let button = event.target
+    const initialButton = button
+
+    if (!$(button).hasClass(CLASS_NAME_BUTTON)) {
+      button = $(button).closest(SELECTOR_BUTTON)[0]
+    }
+
+    if (!button || button.hasAttribute('disabled') || button.classList.contains('disabled')) {
+      event.preventDefault() // work around Firefox bug #1540995
+    } else {
+      const inputBtn = button.querySelector(SELECTOR_INPUT)
+
+      if (inputBtn && (inputBtn.hasAttribute('disabled') || inputBtn.classList.contains('disabled'))) {
+        event.preventDefault() // work around Firefox bug #1540995
+        return
+      }
+
+      if (initialButton.tagName === 'LABEL' && inputBtn && inputBtn.type === 'checkbox') {
+        event.preventDefault() // work around event sent to label and input
+      }
+      Button._jQueryInterface.call($(button), 'toggle')
+    }
+  })
+  .on(EVENT_FOCUS_BLUR_DATA_API, SELECTOR_DATA_TOGGLE_CARROT, (event) => {
+    const button = $(event.target).closest(SELECTOR_BUTTON)[0]
+    $(button).toggleClass(CLASS_NAME_FOCUS, /^focus(in)?$/.test(event.type))
+  })
+
+$(window).on(EVENT_LOAD_DATA_API, () => {
+  // ensure correct active class is set to match the controls' actual values/states
+
+  // find all checkboxes/readio buttons inside data-toggle groups
+  let buttons = [].slice.call(document.querySelectorAll(SELECTOR_DATA_TOGGLES_BUTTONS))
+  for (let i = 0, len = buttons.length; i < len; i++) {
+    const button = buttons[i]
+    const input = button.querySelector(SELECTOR_INPUT)
+    if (input.checked || input.hasAttribute('checked')) {
+      button.classList.add(CLASS_NAME_ACTIVE)
+    } else {
+      button.classList.remove(CLASS_NAME_ACTIVE)
+    }
+  }
+
+  // find all button toggles
+  buttons = [].slice.call(document.querySelectorAll(SELECTOR_DATA_TOGGLE))
+  for (let i = 0, len = buttons.length; i < len; i++) {
+    const button = buttons[i]
+    if (button.getAttribute('aria-pressed') === 'true') {
+      button.classList.add(CLASS_NAME_ACTIVE)
+    } else {
+      button.classList.remove(CLASS_NAME_ACTIVE)
+    }
+  }
+})
+
+/**
+ * ------------------------------------------------------------------------
+ * jQuery
+ * ------------------------------------------------------------------------
+ */
+
+$.fn[NAME] = Button._jQueryInterface
+$.fn[NAME].Constructor = Button
+$.fn[NAME].noConflict = () => {
+  $.fn[NAME] = JQUERY_NO_CONFLICT
+  return Button._jQueryInterface
+}
+
+export default Button
diff --git a/theme/boost/amd/src/bootstrap/carousel.js b/theme/boost/amd/src/bootstrap/carousel.js
new file mode 100644 (file)
index 0000000..4efea10
--- /dev/null
@@ -0,0 +1,598 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v4.5.0): carousel.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import $ from 'jquery'
+import Util from './util'
+
+/**
+ * ------------------------------------------------------------------------
+ * Constants
+ * ------------------------------------------------------------------------
+ */
+
+const NAME                   = 'carousel'
+const VERSION                = '4.5.0'
+const DATA_KEY               = 'bs.carousel'
+const EVENT_KEY              = `.${DATA_KEY}`
+const DATA_API_KEY           = '.data-api'
+const JQUERY_NO_CONFLICT     = $.fn[NAME]
+const ARROW_LEFT_KEYCODE     = 37 // KeyboardEvent.which value for left arrow key
+const ARROW_RIGHT_KEYCODE    = 39 // KeyboardEvent.which value for right arrow key
+const TOUCHEVENT_COMPAT_WAIT = 500 // Time for mouse compat events to fire after touch
+const SWIPE_THRESHOLD        = 40
+
+const Default = {
+  interval : 5000,
+  keyboard : true,
+  slide    : false,
+  pause    : 'hover',
+  wrap     : true,
+  touch    : true
+}
+
+const DefaultType = {
+  interval : '(number|boolean)',
+  keyboard : 'boolean',
+  slide    : '(boolean|string)',
+  pause    : '(string|boolean)',
+  wrap     : 'boolean',
+  touch    : 'boolean'
+}
+
+const DIRECTION_NEXT     = 'next'
+const DIRECTION_PREV     = 'prev'
+const DIRECTION_LEFT     = 'left'
+const DIRECTION_RIGHT    = 'right'
+
+const EVENT_SLIDE          = `slide${EVENT_KEY}`
+const EVENT_SLID           = `slid${EVENT_KEY}`
+const EVENT_KEYDOWN        = `keydown${EVENT_KEY}`
+const EVENT_MOUSEENTER     = `mouseenter${EVENT_KEY}`
+const EVENT_MOUSELEAVE     = `mouseleave${EVENT_KEY}`
+const EVENT_TOUCHSTART     = `touchstart${EVENT_KEY}`
+const EVENT_TOUCHMOVE      = `touchmove${EVENT_KEY}`
+const EVENT_TOUCHEND       = `touchend${EVENT_KEY}`
+const EVENT_POINTERDOWN    = `pointerdown${EVENT_KEY}`
+const EVENT_POINTERUP      = `pointerup${EVENT_KEY}`
+const EVENT_DRAG_START     = `dragstart${EVENT_KEY}`
+const EVENT_LOAD_DATA_API  = `load${EVENT_KEY}${DATA_API_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_CAROUSEL      = 'carousel'
+const CLASS_NAME_ACTIVE        = 'active'
+const CLASS_NAME_SLIDE         = 'slide'
+const CLASS_NAME_RIGHT         = 'carousel-item-right'
+const CLASS_NAME_LEFT          = 'carousel-item-left'
+const CLASS_NAME_NEXT          = 'carousel-item-next'
+const CLASS_NAME_PREV          = 'carousel-item-prev'
+const CLASS_NAME_POINTER_EVENT = 'pointer-event'
+
+const SELECTOR_ACTIVE      = '.active'
+const SELECTOR_ACTIVE_ITEM = '.active.carousel-item'
+const SELECTOR_ITEM        = '.carousel-item'
+const SELECTOR_ITEM_IMG    = '.carousel-item img'
+const SELECTOR_NEXT_PREV   = '.carousel-item-next, .carousel-item-prev'
+const SELECTOR_INDICATORS  = '.carousel-indicators'
+const SELECTOR_DATA_SLIDE  = '[data-slide], [data-slide-to]'
+const SELECTOR_DATA_RIDE   = '[data-ride="carousel"]'
+
+const PointerType = {
+  TOUCH : 'touch',
+  PEN   : 'pen'
+}
+
+/**
+ * ------------------------------------------------------------------------
+ * Class Definition
+ * ------------------------------------------------------------------------
+ */
+class Carousel {
+  constructor(element, config) {
+    this._items         = null
+    this._interval      = null
+    this._activeElement = null
+    this._isPaused      = false
+    this._isSliding     = false
+    this.touchTimeout   = null
+    this.touchStartX    = 0
+    this.touchDeltaX    = 0
+
+    this._config            = this._getConfig(config)
+    this._element           = element
+    this._indicatorsElement = this._element.querySelector(SELECTOR_INDICATORS)
+    this._touchSupported    = 'ontouchstart' in document.documentElement || navigator.maxTouchPoints > 0
+    this._pointerEvent      = Boolean(window.PointerEvent || window.MSPointerEvent)
+
+    this._addEventListeners()
+  }
+
+  // Getters
+
+  static get VERSION() {
+    return VERSION
+  }
+
+  static get Default() {
+    return Default
+  }
+
+  // Public
+
+  next() {
+    if (!this._isSliding) {
+      this._slide(DIRECTION_NEXT)
+    }
+  }
+
+  nextWhenVisible() {
+    // Don't call next when the page isn't visible
+    // or the carousel or its parent isn't visible
+    if (!document.hidden &&
+      ($(this._element).is(':visible') && $(this._element).css('visibility') !== 'hidden')) {
+      this.next()
+    }
+  }
+
+  prev() {
+    if (!this._isSliding) {
+      this._slide(DIRECTION_PREV)
+    }
+  }
+
+  pause(event) {
+    if (!event) {
+      this._isPaused = true
+    }
+
+    if (this._element.querySelector(SELECTOR_NEXT_PREV)) {
+      Util.triggerTransitionEnd(this._element)
+      this.cycle(true)
+    }
+
+    clearInterval(this._interval)
+    this._interval = null
+  }
+
+  cycle(event) {
+    if (!event) {
+      this._isPaused = false
+    }
+
+    if (this._interval) {
+      clearInterval(this._interval)
+      this._interval = null
+    }
+
+    if (this._config.interval && !this._isPaused) {
+      this._interval = setInterval(
+        (document.visibilityState ? this.nextWhenVisible : this.next).bind(this),
+        this._config.interval
+      )
+    }
+  }
+
+  to(index) {
+    this._activeElement = this._element.querySelector(SELECTOR_ACTIVE_ITEM)
+
+    const activeIndex = this._getItemIndex(this._activeElement)
+
+    if (index > this._items.length - 1 || index < 0) {
+      return
+    }
+
+    if (this._isSliding) {
+      $(this._element).one(EVENT_SLID, () => this.to(index))
+      return
+    }
+
+    if (activeIndex === index) {
+      this.pause()
+      this.cycle()
+      return
+    }
+
+    const direction = index > activeIndex
+      ? DIRECTION_NEXT
+      : DIRECTION_PREV
+
+    this._slide(direction, this._items[index])
+  }
+
+  dispose() {
+    $(this._element).off(EVENT_KEY)
+    $.removeData(this._element, DATA_KEY)
+
+    this._items             = null
+    this._config            = null
+    this._element           = null
+    this._interval          = null
+    this._isPaused          = null
+    this._isSliding         = null
+    this._activeElement     = null
+    this._indicatorsElement = null
+  }
+
+  // Private
+
+  _getConfig(config) {
+    config = {
+      ...Default,
+      ...config
+    }
+    Util.typeCheckConfig(NAME, config, DefaultType)
+    return config
+  }
+
+  _handleSwipe() {
+    const absDeltax = Math.abs(this.touchDeltaX)
+
+    if (absDeltax <= SWIPE_THRESHOLD) {
+      return
+    }
+
+    const direction = absDeltax / this.touchDeltaX
+
+    this.touchDeltaX = 0
+
+    // swipe left
+    if (direction > 0) {
+      this.prev()
+    }
+
+    // swipe right
+    if (direction < 0) {
+      this.next()
+    }
+  }
+
+  _addEventListeners() {
+    if (this._config.keyboard) {
+      $(this._element).on(EVENT_KEYDOWN, (event) => this._keydown(event))
+    }
+
+    if (this._config.pause === 'hover') {
+      $(this._element)
+        .on(EVENT_MOUSEENTER, (event) => this.pause(event))
+        .on(EVENT_MOUSELEAVE, (event) => this.cycle(event))
+    }
+
+    if (this._config.touch) {
+      this._addTouchEventListeners()
+    }
+  }
+
+  _addTouchEventListeners() {
+    if (!this._touchSupported) {
+      return
+    }
+
+    const start = (event) => {
+      if (this._pointerEvent && PointerType[event.originalEvent.pointerType.toUpperCase()]) {
+        this.touchStartX = event.originalEvent.clientX
+      } else if (!this._pointerEvent) {
+        this.touchStartX = event.originalEvent.touches[0].clientX
+      }
+    }
+
+    const move = (event) => {
+      // ensure swiping with one touch and not pinching
+      if (event.originalEvent.touches && event.originalEvent.touches.length > 1) {
+        this.touchDeltaX = 0
+      } else {
+        this.touchDeltaX = event.originalEvent.touches[0].clientX - this.touchStartX
+      }
+    }
+
+    const end = (event) => {
+      if (this._pointerEvent && PointerType[event.originalEvent.pointerType.toUpperCase()]) {
+        this.touchDeltaX = event.originalEvent.clientX - this.touchStartX
+      }
+
+      this._handleSwipe()
+      if (this._config.pause === 'hover') {
+        // If it's a touch-enabled device, mouseenter/leave are fired as
+        // part of the mouse compatibility events on first tap - the carousel
+        // would stop cycling until user tapped out of it;
+        // here, we listen for touchend, explicitly pause the carousel
+        // (as if it's the second time we tap on it, mouseenter compat event
+        // is NOT fired) and after a timeout (to allow for mouse compatibility
+        // events to fire) we explicitly restart cycling
+
+        this.pause()
+        if (this.touchTimeout) {
+          clearTimeout(this.touchTimeout)
+        }
+        this.touchTimeout = setTimeout((event) => this.cycle(event), TOUCHEVENT_COMPAT_WAIT + this._config.interval)
+      }
+    }
+
+    $(this._element.querySelectorAll(SELECTOR_ITEM_IMG))
+      .on(EVENT_DRAG_START, (e) => e.preventDefault())
+
+    if (this._pointerEvent) {
+      $(this._element).on(EVENT_POINTERDOWN, (event) => start(event))
+      $(this._element).on(EVENT_POINTERUP, (event) => end(event))
+
+      this._element.classList.add(CLASS_NAME_POINTER_EVENT)
+    } else {
+      $(this._element).on(EVENT_TOUCHSTART, (event) => start(event))
+      $(this._element).on(EVENT_TOUCHMOVE, (event) => move(event))
+      $(this._element).on(EVENT_TOUCHEND, (event) => end(event))
+    }
+  }
+
+  _keydown(event) {
+    if (/input|textarea/i.test(event.target.tagName)) {
+      return
+    }
+
+    switch (event.which) {
+      case ARROW_LEFT_KEYCODE:
+        event.preventDefault()
+        this.prev()
+        break
+      case ARROW_RIGHT_KEYCODE:
+        event.preventDefault()
+        this.next()
+        break
+      default:
+    }
+  }
+
+  _getItemIndex(element) {
+    this._items = element && element.parentNode
+      ? [].slice.call(element.parentNode.querySelectorAll(SELECTOR_ITEM))
+      : []
+    return this._items.indexOf(element)
+  }
+
+  _getItemByDirection(direction, activeElement) {
+    const isNextDirection = direction === DIRECTION_NEXT
+    const isPrevDirection = direction === DIRECTION_PREV
+    const activeIndex     = this._getItemIndex(activeElement)
+    const lastItemIndex   = this._items.length - 1
+    const isGoingToWrap   = isPrevDirection && activeIndex === 0 ||
+                            isNextDirection && activeIndex === lastItemIndex
+
+    if (isGoingToWrap && !this._config.wrap) {
+      return activeElement
+    }
+
+    const delta     = direction === DIRECTION_PREV ? -1 : 1
+    const itemIndex = (activeIndex + delta) % this._items.length
+
+    return itemIndex === -1
+      ? this._items[this._items.length - 1] : this._items[itemIndex]
+  }
+
+  _triggerSlideEvent(relatedTarget, eventDirectionName) {
+    const targetIndex = this._getItemIndex(relatedTarget)
+    const fromIndex = this._getItemIndex(this._element.querySelector(SELECTOR_ACTIVE_ITEM))
+    const slideEvent = $.Event(EVENT_SLIDE, {
+      relatedTarget,
+      direction: eventDirectionName,
+      from: fromIndex,
+      to: targetIndex
+    })
+
+    $(this._element).trigger(slideEvent)
+
+    return slideEvent
+  }
+
+  _setActiveIndicatorElement(element) {
+    if (this._indicatorsElement) {
+      const indicators = [].slice.call(this._indicatorsElement.querySelectorAll(SELECTOR_ACTIVE))
+      $(indicators).removeClass(CLASS_NAME_ACTIVE)
+
+      const nextIndicator = this._indicatorsElement.children[
+        this._getItemIndex(element)
+      ]
+
+      if (nextIndicator) {
+        $(nextIndicator).addClass(CLASS_NAME_ACTIVE)
+      }
+    }
+  }
+
+  _slide(direction, element) {
+    const activeElement = this._element.querySelector(SELECTOR_ACTIVE_ITEM)
+    const activeElementIndex = this._getItemIndex(activeElement)
+    const nextElement   = element || activeElement &&
+      this._getItemByDirection(direction, activeElement)
+    const nextElementIndex = this._getItemIndex(nextElement)
+    const isCycling = Boolean(this._interval)
+
+    let directionalClassName
+    let orderClassName
+    let eventDirectionName
+
+    if (direction === DIRECTION_NEXT) {
+      directionalClassName = CLASS_NAME_LEFT
+      orderClassName = CLASS_NAME_NEXT
+      eventDirectionName = DIRECTION_LEFT
+    } else {
+      directionalClassName = CLASS_NAME_RIGHT
+      orderClassName = CLASS_NAME_PREV
+      eventDirectionName = DIRECTION_RIGHT
+    }
+
+    if (nextElement && $(nextElement).hasClass(CLASS_NAME_ACTIVE)) {
+      this._isSliding = false
+      return
+    }
+
+    const slideEvent = this._triggerSlideEvent(nextElement, eventDirectionName)
+    if (slideEvent.isDefaultPrevented()) {
+      return
+    }
+
+    if (!activeElement || !nextElement) {
+      // Some weirdness is happening, so we bail
+      return
+    }
+
+    this._isSliding = true
+
+    if (isCycling) {
+      this.pause()
+    }
+
+    this._setActiveIndicatorElement(nextElement)
+
+    const slidEvent = $.Event(EVENT_SLID, {
+      relatedTarget: nextElement,
+      direction: eventDirectionName,
+      from: activeElementIndex,
+      to: nextElementIndex
+    })
+
+    if ($(this._element).hasClass(CLASS_NAME_SLIDE)) {
+      $(nextElement).addClass(orderClassName)
+
+      Util.reflow(nextElement)
+
+      $(activeElement).addClass(directionalClassName)
+      $(nextElement).addClass(directionalClassName)
+
+      const nextElementInterval = parseInt(nextElement.getAttribute('data-interval'), 10)
+      if (nextElementInterval) {
+        this._config.defaultInterval = this._config.defaultInterval || this._config.interval
+        this._config.interval = nextElementInterval
+      } else {
+        this._config.interval = this._config.defaultInterval || this._config.interval
+      }
+
+      const transitionDuration = Util.getTransitionDurationFromElement(activeElement)
+
+      $(activeElement)
+        .one(Util.TRANSITION_END, () => {
+          $(nextElement)
+            .removeClass(`${directionalClassName} ${orderClassName}`)
+            .addClass(CLASS_NAME_ACTIVE)
+
+          $(activeElement).removeClass(`${CLASS_NAME_ACTIVE} ${orderClassName} ${directionalClassName}`)
+
+          this._isSliding = false
+
+          setTimeout(() => $(this._element).trigger(slidEvent), 0)
+        })
+        .emulateTransitionEnd(transitionDuration)
+    } else {
+      $(activeElement).removeClass(CLASS_NAME_ACTIVE)
+      $(nextElement).addClass(CLASS_NAME_ACTIVE)
+
+      this._isSliding = false
+      $(this._element).trigger(slidEvent)
+    }
+
+    if (isCycling) {
+      this.cycle()
+    }
+  }
+
+  // Static
+
+  static _jQueryInterface(config) {
+    return this.each(function () {
+      let data = $(this).data(DATA_KEY)
+      let _config = {
+        ...Default,
+        ...$(this).data()
+      }
+
+      if (typeof config === 'object') {
+        _config = {
+          ..._config,
+          ...config
+        }
+      }
+
+      const action = typeof config === 'string' ? config : _config.slide
+
+      if (!data) {
+        data = new Carousel(this, _config)
+        $(this).data(DATA_KEY, data)
+      }
+
+      if (typeof config === 'number') {
+        data.to(config)
+      } else if (typeof action === 'string') {
+        if (typeof data[action] === 'undefined') {
+          throw new TypeError(`No method named "${action}"`)
+        }
+        data[action]()
+      } else if (_config.interval && _config.ride) {
+        data.pause()
+        data.cycle()
+      }
+    })
+  }
+
+  static _dataApiClickHandler(event) {
+    const selector = Util.getSelectorFromElement(this)
+
+    if (!selector) {
+      return
+    }
+
+    const target = $(selector)[0]
+
+    if (!target || !$(target).hasClass(CLASS_NAME_CAROUSEL)) {
+      return
+    }
+
+    const config = {
+      ...$(target).data(),
+      ...$(this).data()
+    }
+    const slideIndex = this.getAttribute('data-slide-to')
+
+    if (slideIndex) {
+      config.interval = false
+    }
+
+    Carousel._jQueryInterface.call($(target), config)
+
+    if (slideIndex) {
+      $(target).data(DATA_KEY).to(slideIndex)
+    }
+
+    event.preventDefault()
+  }
+}
+
+/**
+ * ------------------------------------------------------------------------
+ * Data Api implementation
+ * ------------------------------------------------------------------------
+ */
+
+$(document).on(EVENT_CLICK_DATA_API, SELECTOR_DATA_SLIDE, Carousel._dataApiClickHandler)
+
+$(window).on(EVENT_LOAD_DATA_API, () => {
+  const carousels = [].slice.call(document.querySelectorAll(SELECTOR_DATA_RIDE))
+  for (let i = 0, len = carousels.length; i < len; i++) {
+    const $carousel = $(carousels[i])
+    Carousel._jQueryInterface.call($carousel, $carousel.data())
+  }
+})
+
+/**
+ * ------------------------------------------------------------------------
+ * jQuery
+ * ------------------------------------------------------------------------
+ */
+
+$.fn[NAME] = Carousel._jQueryInterface
+$.fn[NAME].Constructor = Carousel
+$.fn[NAME].noConflict = () => {
+  $.fn[NAME] = JQUERY_NO_CONFLICT
+  return Carousel._jQueryInterface
+}
+
+export default Carousel
diff --git a/theme/boost/amd/src/bootstrap/collapse.js b/theme/boost/amd/src/bootstrap/collapse.js
new file mode 100644 (file)
index 0000000..270eb85
--- /dev/null
@@ -0,0 +1,391 @@
+/**
+ * --------------------------------------------------------------------------
+ * Bootstrap (v4.5.0): collapse.js
+ * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE)
+ * --------------------------------------------------------------------------
+ */
+
+import $ from 'jquery'
+import Util from './util'
+
+/**
+ * ------------------------------------------------------------------------
+ * Constants
+ * ------------------------------------------------------------------------
+ */
+
+const NAME                = 'collapse'
+const VERSION             = '4.5.0'
+const DATA_KEY            = 'bs.collapse'
+const EVENT_KEY           = `.${DATA_KEY}`
+const DATA_API_KEY        = '.data-api'
+const JQUERY_NO_CONFLICT  = $.fn[NAME]
+
+const Default = {
+  toggle : true,
+  parent : ''
+}
+
+const DefaultType = {
+  toggle : 'boolean',
+  parent : '(string|element)'
+}
+
+const EVENT_SHOW           = `show${EVENT_KEY}`
+const EVENT_SHOWN          = `shown${EVENT_KEY}`
+const EVENT_HIDE           = `hide${EVENT_KEY}`
+const EVENT_HIDDEN         = `hidden${EVENT_KEY}`
+const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
+
+const CLASS_NAME_SHOW       = 'show'
+const CLASS_NAME_COLLAPSE   = 'collapse'
+const CLASS_NAME_COLLAPSING = 'collapsing'
+const CLASS_NAME_COLLAPSED  = 'collapsed'
+
+const DIMENSION_WIDTH  = 'width'
+const DIMENSION_HEIGHT = 'height'
+
+const SELECTOR_ACTIVES     = '.show, .collapsing'
+const SELECTOR_DATA_TOGGLE = '[data-toggle="collapse"]'
+
+/**
+ * ------------------------------------------------------------------------
+ * Class Definition
+ * ------------------------------------------------------------------------
+ */
+
+class Collapse {
+  constructor(element, config) {
+    this._isTransitioning = false
+    this._element         = element
+    this._config          = this._getConfig(config)
+    this._triggerArray    = [].slice.call(document.querySelectorAll(
+      `[data-toggle="collapse"][href="#${element.id}"],` +
+      `[data-toggle="collapse"][data-target="#${element.id}"]`
+    ))
+
+    const toggleList = [].slice.call(document.querySelectorAll(SELECTOR_DATA_TOGGLE))
+    for (let i = 0, len = toggleList.length; i < len; i++) {
+      const elem = toggleList[i]
+      const selector = Util.getSelectorFromElement(elem)
+      const filterElement = [].slice.call(document.querySelectorAll(selector))
+        .filter((foundElem) => foundElem === element)
+
+      if (selector !== null && filterElement.length > 0) {
+        this._selector = selector
+        this._triggerArray.push(elem)
+      }
+    }
+
+    this._parent = this._config.parent ? this._getParent() : null
+
+    if (!this._config.parent) {
+      this._addAriaAndCollapsedClass(this._element, this._triggerArray)
+    }
+
+    if (this._config.toggle) {
+      this.toggle()
+    }
+  }
+
+  // Getters
+
+  static get VERSION() {
+    return VERSION
+  }
+
+  static get Default() {
+    return Default
+  }
+
+  // Public
+
+  toggle() {
+    if ($(this._element).hasClass(CLASS_NAME_SHOW)) {
+      this.hide()
+    } else {
+      this.show()
+    }
+  }
+
+  show() {
+    if (this._isTransitioning ||
+      $(this._element).hasClass(CLASS_NAME_SHOW)) {
+      return
+    }
+
+    let actives
+    let activesData
+
+    if (this._parent) {
+      actives = [].slice.call(this._parent.querySelectorAll(SELECTOR_ACTIVES))
+        .filter((elem) => {
+          if (typeof this._config.parent === 'string') {
+            return elem.getAttribute('data-parent') === this._config.parent
+          }
+
+          return elem.classList.contains(CLASS_NAME_COLLAPSE)
+        })
+
+      if (actives.length === 0) {
+        actives = null
+      }
+    }
+
+    if (actives) {
+      activesData = $(actives).not(this._selector).data(DATA_KEY)
+      if (activesData && activesData._isTransitioning) {
+        return
+      }
+    }
+
+    const startEvent = $.Event(EVENT_SHOW)
+    $(this._element).trigger(startEvent)
+    if (startEvent.isDefaultPrevented()) {
+      return
+    }
+
+    if (actives) {
+      Collapse._jQueryInterface.call($(actives).not(this._selector), 'hide')
+      if (!activesData) {
+        $(actives).data(DATA_KEY, null)
+      }
+    }
+
+    const dimension = this._getDimension()
+
+    $(this._element)
+      .removeClass(CLASS_NAME_COLLAPSE)
+      .addClass(CLASS_NAME_COLLAPSING)
+
+    this._element.style[dimension] = 0
+
+    if (this._triggerArray.length) {
+      $(this._triggerArray)
+        .removeClass(CLASS_NAME_COLLAPSED)
+        .attr('aria-expanded', true)
+    }
+
+    this.setTransitioning(true)
+
+    const complete = () => {
+      $(this._element)
+        .removeClass(CLASS_NAME_COLLAPSING)
+        .addClass(`${CLASS_NAME_COLLAPSE} ${CLASS_NAME_SHOW}`)
+
+      this._element.style[dimension] = ''
+
+      this.setTransitioning(false)
+
+      $(this._element).trigger(EVENT_SHOWN)
+    }
+
+    const capitalizedDimension = dimension[0].toUpperCase() + dimension.slice(1)
+    const scrollSize = `scroll${capitalizedDimension}`
+    const transitionDuration = Util.getTransitionDurationFromElement(this._element)
+
+    $(this._element)
+      .one(Util.TRANSITION_END, complete)
+      .emulateTransitionEnd(transitionDuration)
+
+    this._element.style[dimension] = `${this._element[scrollSize]}px`
+  }
+
+  hide() {
+    if (this._isTransitioning ||
+      !$(this._element).hasClass(CLASS_NAME_SHOW)) {
+      return
+    }
+
+    const startEvent = $.Event(EVENT_HIDE)
+    $(this._element).trigger(startEvent)
+    if (startEvent.isDefaultPrevented()) {
+      return
+    }
+
+    const dimension = this._getDimension()
+
+    this._element.style[dimension] = `${this._element.getBoundingClientRect()[dimension]}px`
+
+    Util.reflow(this._element)
+
+    $(this._element)
+  &nbs