Merge branch 'MDL-67713-master' of git://github.com/ferranrecio/moodle
authorSara Arjona <sara@moodle.com>
Mon, 4 May 2020 17:30:45 +0000 (19:30 +0200)
committerSara Arjona <sara@moodle.com>
Mon, 4 May 2020 17:30:45 +0000 (19:30 +0200)
63 files changed:
.travis.yml
calendar/amd/build/crud.min.js
calendar/amd/build/crud.min.js.map
calendar/amd/build/modal_delete.min.js
calendar/amd/build/modal_delete.min.js.map
calendar/amd/src/crud.js
calendar/amd/src/modal_delete.js
course/classes/management_renderer.php
course/renderer.php
h5p/tests/editor_test.php
lang/en/moodle.php
lib/amd/build/local/modal/alert.min.js [new file with mode: 0644]
lib/amd/build/local/modal/alert.min.js.map [new file with mode: 0644]
lib/amd/build/modal.min.js
lib/amd/build/modal.min.js.map
lib/amd/build/modal_cancel.min.js
lib/amd/build/modal_cancel.min.js.map
lib/amd/build/modal_factory.min.js
lib/amd/build/modal_factory.min.js.map
lib/amd/build/modal_save_cancel.min.js
lib/amd/build/modal_save_cancel.min.js.map
lib/amd/build/notification.min.js
lib/amd/build/notification.min.js.map
lib/amd/src/local/modal/alert.js [new file with mode: 0644]
lib/amd/src/modal.js
lib/amd/src/modal_cancel.js
lib/amd/src/modal_factory.js
lib/amd/src/modal_save_cancel.js
lib/amd/src/notification.js
lib/form/course.php
lib/form/tests/course_test.php [new file with mode: 0644]
lib/plist/classes/CFPropertyList/CFBinaryPropertyList.php
lib/plist/readme_moodle.txt
lib/table/classes/dynamic.php
lib/table/classes/external/dynamic/fetch.php
lib/tablelib.php
lib/templates/local/modal/alert.mustache [new file with mode: 0644]
lib/upgrade.txt
media/player/videojs/classes/plugin.php
mod/assign/feedback/editpdf/tests/behat/view_previous_annotations.feature
mod/assign/tests/behat/edit_previous_feedback.feature
mod/feedback/classes/responses_table.php
mod/h5pactivity/lib.php
question/type/ddmarker/amd/build/form.min.js
question/type/ddmarker/amd/build/form.min.js.map
question/type/ddmarker/amd/build/question.min.js
question/type/ddmarker/amd/build/question.min.js.map
question/type/ddmarker/amd/build/shapes.min.js
question/type/ddmarker/amd/build/shapes.min.js.map
question/type/ddmarker/amd/src/form.js
question/type/ddmarker/amd/src/question.js
question/type/ddmarker/amd/src/shapes.js
question/type/ddmarker/renderer.php
question/type/ddmarker/styles.css
question/type/ddmarker/tests/behat/behat_qtype_ddmarker.php
question/type/ddmarker/tests/behat/preview.feature
question/type/ddmarker/tests/walkthrough_test.php
tag/tests/behat/delete_tag.feature
theme/boost/scss/moodle/course.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/classes/table/participants.php
user/index.php

index 245d7fc..f60032e 100644 (file)
@@ -18,7 +18,7 @@ services:
 
 php:
     # We only run the highest and lowest supported versions to reduce the load on travis-ci.org.
-    - 7.3
+    - 7.4
     - 7.2
 
 addons:
@@ -48,11 +48,11 @@ jobs:
     fast_finish: true
 
     include:
-          # Run mysql only on 7.3 - it's just too slow
-        - php: 7.3
+          # Run mysql only on highest - it's just too slow
+        - php: 7.4
           env: DB=mysqli   TASK=PHPUNIT
-          # Run grunt/npm install on highest version ('node' is an alias for the latest node.js version.)
-        - php: 7.2
+          # Run grunt/npm install on highest version too ('node' is an alias for the latest node.js version.)
+        - php: 7.4
           env: DB=none     TASK=GRUNT   NVM_VERSION='lts/carbon'
 
 cache:
index d44cd94..31d3420 100644 (file)
Binary files a/calendar/amd/build/crud.min.js and b/calendar/amd/build/crud.min.js differ
index c6e0e09..499e7b6 100644 (file)
Binary files a/calendar/amd/build/crud.min.js.map and b/calendar/amd/build/crud.min.js.map differ
index 184a283..77dbc02 100644 (file)
Binary files a/calendar/amd/build/modal_delete.min.js and b/calendar/amd/build/modal_delete.min.js differ
index 4a4c185..090c326 100644 (file)
Binary files a/calendar/amd/build/modal_delete.min.js.map and b/calendar/amd/build/modal_delete.min.js.map differ
index 7853a67..c18cb56 100644 (file)
@@ -63,6 +63,7 @@ function(
      * @return {Promise}
      */
     function confirmDeletion(eventId, eventTitle, eventCount) {
+        var pendingPromise = new Pending('core_calendar/crud:confirmDeletion');
         var deleteStrings = [
             {
                 key: 'deleteevent',
@@ -139,6 +140,11 @@ function(
 
             return deleteModal;
         })
+        .then(function(modal) {
+            pendingPromise.resolve();
+
+            return modal;
+        })
         .catch(Notification.exception);
 
         return finalPromise;
index 9541dec..e457761 100644 (file)
@@ -55,6 +55,8 @@ function(
      */
     var ModalDelete = function(root) {
         Modal.call(this, root);
+
+        this.setRemoveOnClose(true);
     };
 
     ModalDelete.TYPE = 'core_calendar-modal_delete';
index bece6a1..07394ca 100644 (file)
@@ -208,17 +208,18 @@ class core_course_management_renderer extends plugin_renderer_base {
         }
         $courseicon = $this->output->pix_icon('i/course', get_string('courses'));
         $bcatinput = array(
+                'id' => 'categorylistitem' . $category->id,
                 'type' => 'checkbox',
                 'name' => 'bcat[]',
                 'value' => $category->id,
-                'class' => 'bulk-action-checkbox',
-                'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+                'class' => 'bulk-action-checkbox custom-control-input',
                 'data-action' => 'select'
         );
 
+        $checkboxclass = '';
         if (!$category->can_resort_subcategories() && !$category->has_manage_capability()) {
             // Very very hardcoded here.
-            $bcatinput['style'] = 'visibility:hidden';
+            $checkboxclass = 'd-none';
         }
 
         $viewcaturl = new moodle_url('/course/management.php', array('categoryid' => $category->id));
@@ -260,8 +261,14 @@ class core_course_management_renderer extends plugin_renderer_base {
 
         $html = html_writer::start_tag('li', $attributes);
         $html .= html_writer::start_div('clearfix');
-        $html .= html_writer::start_div('float-left ba-checkbox');
-        $html .= html_writer::empty_tag('input', $bcatinput).'&nbsp;';
+        $html .= html_writer::start_div('float-left ' . $checkboxclass);
+        $html .= html_writer::start_div('custom-control custom-checkbox mr-1 ');
+        $html .= html_writer::empty_tag('input', $bcatinput);
+        $html .= html_writer::tag('label', '', array(
+            'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+            'class' => 'custom-control-label',
+            'for' => 'categorylistitem' . $category->id));
+        $html .= html_writer::end_div();
         $html .= html_writer::end_div();
         $html .= $icon;
         if ($hasactions) {
@@ -275,7 +282,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         $html .= html_writer::link($viewcaturl, $text, $textattributes);
         $html .= html_writer::start_div('float-right d-flex');
         if ($category->idnumber) {
-            $html .= html_writer::tag('span', s($category->idnumber), array('class' => 'dimmed idnumber'));
+            $html .= html_writer::tag('span', s($category->idnumber), array('class' => 'text-muted idnumber'));
         }
         if ($hasactions) {
             $html .= $this->category_listitem_actions($category, $actions);
@@ -285,7 +292,7 @@ class core_course_management_renderer extends plugin_renderer_base {
                 html_writer::span($category->get_courses_count()) .
                 html_writer::span(get_string('courses'), 'accesshide', array('id' => $countid)) .
                 $courseicon,
-                'course-count dimmed',
+                'course-count text-muted',
                 array('aria-labelledby' => $countid)
         );
         $html .= html_writer::end_div();
@@ -572,7 +579,7 @@ class core_course_management_renderer extends plugin_renderer_base {
                 $a->total = $totalcourses;
                 $str = get_string('showingxofycourses', 'moodle', $a);
             }
-            $html .= html_writer::div($str, 'listing-pagination-totals dimmed');
+            $html .= html_writer::div($str, 'listing-pagination-totals text-muted');
         }
 
         if ($viewmode !== 'default') {
@@ -607,16 +614,18 @@ class core_course_management_renderer extends plugin_renderer_base {
         );
 
         $bulkcourseinput = array(
+                'id' => 'courselistitem' . $course->id,
                 'type' => 'checkbox',
                 'name' => 'bc[]',
                 'value' => $course->id,
-                'class' => 'bulk-action-checkbox',
-                'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+                'class' => 'bulk-action-checkbox custom-control-input',
                 'data-action' => 'select'
         );
+
+        $checkboxclass = '';
         if (!$category->has_manage_capability()) {
             // Very very hardcoded here.
-            $bulkcourseinput['style'] = 'visibility:hidden';
+            $checkboxclass = 'd-none';
         }
 
         $viewcourseurl = new moodle_url($this->page->url, array('courseid' => $course->id));
@@ -629,13 +638,19 @@ class core_course_management_renderer extends plugin_renderer_base {
             $html .= html_writer::div($this->output->pix_icon('i/move_2d', get_string('dndcourse')), 'float-left drag-handle');
         }
 
-        $html .= html_writer::start_div('ba-checkbox float-left');
-        $html .= html_writer::empty_tag('input', $bulkcourseinput).'&nbsp;';
+        $html .= html_writer::start_div('float-left ' . $checkboxclass);
+        $html .= html_writer::start_div('custom-control custom-checkbox mr-1 ');
+        $html .= html_writer::empty_tag('input', $bulkcourseinput);
+        $html .= html_writer::tag('label', '', array(
+            'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+            'class' => 'custom-control-label',
+            'for' => 'courselistitem' . $course->id));
+        $html .= html_writer::end_div();
         $html .= html_writer::end_div();
         $html .= html_writer::link($viewcourseurl, $text, array('class' => 'float-left coursename'));
         $html .= html_writer::start_div('float-right');
         if ($course->idnumber) {
-            $html .= html_writer::tag('span', s($course->idnumber), array('class' => 'dimmed idnumber'));
+            $html .= html_writer::tag('span', s($course->idnumber), array('class' => 'text-muted idnumber'));
         }
         $html .= $this->course_listitem_actions($category, $course);
         $html .= html_writer::end_div();
@@ -1121,7 +1136,7 @@ class core_course_management_renderer extends plugin_renderer_base {
                 $a->total = $totalcourses;
                 $str = get_string('showingxofycourses', 'moodle', $a);
             }
-            $html .= html_writer::div($str, 'listing-pagination-totals dimmed');
+            $html .= html_writer::div($str, 'listing-pagination-totals text-muted');
         }
 
         if ($totalcourses < $perpage) {
@@ -1184,10 +1199,10 @@ class core_course_management_renderer extends plugin_renderer_base {
         if (core_course_category::get($course->category)->can_move_courses_out_of()) {
             $bulkcourseinput = array(
                     'type' => 'checkbox',
+                    'id' => 'coursesearchlistitem' . $course->id,
                     'name' => 'bc[]',
                     'value' => $course->id,
-                    'class' => 'bulk-action-checkbox',
-                    'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+                    'class' => 'bulk-action-checkbox custom-control-input',
                     'data-action' => 'select'
             );
         }
@@ -1198,14 +1213,20 @@ class core_course_management_renderer extends plugin_renderer_base {
         $html .= html_writer::start_div('clearfix');
         $html .= html_writer::start_div('float-left');
         if ($bulkcourseinput) {
-            $html .= html_writer::empty_tag('input', $bulkcourseinput).'&nbsp;';
+            $html .= html_writer::start_div('custom-control custom-checkbox mr-1');
+            $html .= html_writer::empty_tag('input', $bulkcourseinput);
+            $html .= html_writer::tag('label', '', array(
+                'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+                'class' => 'custom-control-label',
+                'for' => 'coursesearchlistitem' . $course->id));
+            $html .= html_writer::end_div();
         }
         $html .= html_writer::end_div();
         $html .= html_writer::link($viewcourseurl, $text, array('class' => 'float-left coursename'));
-        $html .= html_writer::tag('span', $categoryname, array('class' => 'float-left categoryname'));
+        $html .= html_writer::tag('span', $categoryname, array('class' => 'float-left ml-3 text-muted'));
         $html .= html_writer::start_div('float-right');
         $html .= $this->search_listitem_actions($course);
-        $html .= html_writer::tag('span', s($course->idnumber), array('class' => 'dimmed idnumber'));
+        $html .= html_writer::tag('span', s($course->idnumber), array('class' => 'text-muted idnumber'));
         $html .= html_writer::end_div();
         $html .= html_writer::end_div();
         $html .= html_writer::end_tag('li');
index 784d6ee..6f1a956 100644 (file)
@@ -903,7 +903,7 @@ class core_course_renderer extends plugin_renderer_base {
             $output .= course_get_cm_move($mod, $sectionreturn);
         }
 
-        $output .= html_writer::start_tag('div', array('class' => 'mod-indent-outer'));
+        $output .= html_writer::start_tag('div', array('class' => 'mod-indent-outer w-100'));
 
         // This div is used to indent the content.
         $output .= html_writer::div('', $indentclasses);
index 05f5877..fb007bf 100644 (file)
  * @copyright  2020 Victor Deniz <victor@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
+
 namespace core_h5p;
 
+defined('MOODLE_INTERNAL') || die();
+
 use advanced_testcase;
 use core_h5p\local\library\autoloader;
-use moodleform;
 use MoodleQuickForm;
 use page_requirements_manager;
 
@@ -42,6 +44,34 @@ use page_requirements_manager;
  */
 class editor_testcase extends advanced_testcase {
 
+    /**
+     * Form object to be used in test case.
+     */
+    protected function get_test_form() {
+        global $CFG;
+
+        require_once($CFG->libdir . '/formslib.php');
+
+        return new class extends \moodleform {
+            /**
+             * Form definition.
+             */
+            public function definition(): void {
+                // No definition required.
+            }
+
+            /**
+             * Returns form reference.
+             *
+             * @return MoodleQuickForm
+             */
+            public function getform() {
+                $mform = $this->_form;
+                return $mform;
+            }
+        };
+    }
+
     /**
      * Test that existing content is properly set.
      */
@@ -153,7 +183,7 @@ class editor_testcase extends advanced_testcase {
         global $PAGE, $CFG;
 
         // Get form data.
-        $form = new temp_form();
+        $form = $this->get_test_form();
         $mform = $form->getform();
 
         // Call method.
@@ -249,27 +279,3 @@ class editor_testcase extends advanced_testcase {
         $this->assertNotEmpty($out);
     }
 }
-
-/**
- * Form object to be used in test case.
- */
-class temp_form extends moodleform {
-    /**
-     * Form definition.
-     */
-    public function definition(): void {
-        // No definition required.
-    }
-
-    /**
-     * Returns form reference.
-     *
-     * @return MoodleQuickForm
-     */
-    public function getform() {
-        $mform = $this->_form;
-        // Set submitted flag, to simulate submission.
-        $mform->_flagSubmitted = true;
-        return $mform;
-    }
-}
index f0a53e9..4885cc4 100644 (file)
@@ -1499,6 +1499,7 @@ $string['numwords'] = '{$a} words';
 $string['numyear'] = '{$a} year';
 $string['numyears'] = '{$a} years';
 $string['ok'] = 'OK';
+$string['okay'] = 'Ok';
 $string['oldpassword'] = 'Current password';
 $string['olduserdirectory'] = 'This is the OLD users directory, and is no longer needed. You may safely delete it. The files it contains have been copied to the NEW user directory.';
 $string['optional'] = 'optional';
diff --git a/lib/amd/build/local/modal/alert.min.js b/lib/amd/build/local/modal/alert.min.js
new file mode 100644 (file)
index 0000000..8344163
Binary files /dev/null and b/lib/amd/build/local/modal/alert.min.js differ
diff --git a/lib/amd/build/local/modal/alert.min.js.map b/lib/amd/build/local/modal/alert.min.js.map
new file mode 100644 (file)
index 0000000..5d15d67
Binary files /dev/null and b/lib/amd/build/local/modal/alert.min.js.map differ
index 3269e6e..642c2ed 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index 5b24706..cb5d00f 100644 (file)
Binary files a/lib/amd/build/modal.min.js.map and b/lib/amd/build/modal.min.js.map differ
index c172543..90366c2 100644 (file)
Binary files a/lib/amd/build/modal_cancel.min.js and b/lib/amd/build/modal_cancel.min.js differ
index 95e19e1..ff44652 100644 (file)
Binary files a/lib/amd/build/modal_cancel.min.js.map and b/lib/amd/build/modal_cancel.min.js.map differ
index 2fa4d54..87e5370 100644 (file)
Binary files a/lib/amd/build/modal_factory.min.js and b/lib/amd/build/modal_factory.min.js differ
index ff93873..5d1aafd 100644 (file)
Binary files a/lib/amd/build/modal_factory.min.js.map and b/lib/amd/build/modal_factory.min.js.map differ
index 2a02302..5cf92de 100644 (file)
Binary files a/lib/amd/build/modal_save_cancel.min.js and b/lib/amd/build/modal_save_cancel.min.js differ
index c2f3a26..e934234 100644 (file)
Binary files a/lib/amd/build/modal_save_cancel.min.js.map and b/lib/amd/build/modal_save_cancel.min.js.map differ
index 4e559a2..e2a0bd3 100644 (file)
Binary files a/lib/amd/build/notification.min.js and b/lib/amd/build/notification.min.js differ
index a7dba21..c9b4369 100644 (file)
Binary files a/lib/amd/build/notification.min.js.map and b/lib/amd/build/notification.min.js.map differ
diff --git a/lib/amd/src/local/modal/alert.js b/lib/amd/src/local/modal/alert.js
new file mode 100644 (file)
index 0000000..da90eef
--- /dev/null
@@ -0,0 +1,39 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Alert modal.
+ *
+ * @module     core/modal_alert
+ * @class      modal_alert
+ * @package    core
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Modal from 'core/modal';
+
+export default class extends Modal {
+    /**
+     * Register all event listeners.
+     */
+    registerEventListeners() {
+        // Call the parent registration.
+        super.registerEventListeners();
+
+        // Register to close on cancel.
+        this.registerCloseOnCancel();
+    }
+}
index aeefaed..154f974 100644 (file)
@@ -610,10 +610,11 @@ define([
      * already been.
      *
      * @method show
+     * @returns {Promise}
      */
     Modal.prototype.show = function() {
         if (this.isVisible()) {
-            return;
+            return $.Deferred().resolve();
         }
 
         var pendingPromise = new Pending('core/modal:show');
@@ -628,7 +629,7 @@ define([
             this.attachToDOM();
         }
 
-        this.getBackdrop()
+        return this.getBackdrop()
         .then(function(backdrop) {
             var currentIndex = this.calculateZIndex();
             var newIndex = currentIndex + 2;
@@ -699,6 +700,7 @@ define([
      * @method destroy
      */
     Modal.prototype.destroy = function() {
+        this.hide();
         this.root.remove();
         this.root.trigger(ModalEvents.destroyed, this);
     };
@@ -802,6 +804,52 @@ define([
         }.bind(this));
     };
 
+    /**
+     * Register a listener to close the dialogue when the cancel button is pressed.
+     *
+     * @method registerCloseOnCancel
+     */
+    Modal.prototype.registerCloseOnCancel = function() {
+        // Handle the clicking of the Cancel button.
+        this.getModal().on(CustomEvents.events.activate, this.getActionSelector('cancel'), function(e, data) {
+            var cancelEvent = $.Event(ModalEvents.cancel);
+            this.getRoot().trigger(cancelEvent, this);
+
+            if (!cancelEvent.isDefaultPrevented()) {
+                data.originalEvent.preventDefault();
+
+                if (this.removeOnClose) {
+                    this.destroy();
+                } else {
+                    this.hide();
+                }
+            }
+        }.bind(this));
+    };
+
+    /**
+     * Register a listener to close the dialogue when the save button is pressed.
+     *
+     * @method registerCloseOnSave
+     */
+    Modal.prototype.registerCloseOnSave = function() {
+        // Handle the clicking of the Cancel button.
+        this.getModal().on(CustomEvents.events.activate, this.getActionSelector('save'), function(e, data) {
+            var saveEvent = $.Event(ModalEvents.save);
+            this.getRoot().trigger(saveEvent, this);
+
+            if (!saveEvent.isDefaultPrevented()) {
+                data.originalEvent.preventDefault();
+
+                if (this.removeOnClose) {
+                    this.destroy();
+                } else {
+                    this.hide();
+                }
+            }
+        }.bind(this));
+    };
+
     /**
      * Set or resolve and set the value using the function.
      *
@@ -827,5 +875,44 @@ define([
         return p;
     };
 
+    /**
+     * Set the title text of a button.
+     *
+     * This method is overloaded to take either a string value for the button title or a jQuery promise that is resolved with
+     * text most commonly from a Str.get_string call.
+     *
+     * @param {DOMString} action The action of the button
+     * @param {(String|object)} value The button text, or a promise which will resolve to it
+     * @returns {Promise}
+     */
+    Modal.prototype.setButtonText = function(action, value) {
+        const button = this.getFooter().find(this.getActionSelector(action));
+
+        if (!button) {
+            throw new Error("Unable to find the '" + action + "' button");
+        }
+
+        return this.asyncSet(value, button.text.bind(button));
+    };
+
+    /**
+     * Get the Selector for an action.
+     *
+     * @param {String} action
+     * @returns {DOMString}
+     */
+    Modal.prototype.getActionSelector = function(action) {
+        return "[data-action='" + action + "']";
+    };
+
+    /**
+     * Set the flag to remove the modal from the DOM on close.
+     *
+     * @param {Boolean} remove
+     */
+    Modal.prototype.setRemoveOnClose = function(remove) {
+        this.removeOnClose = remove;
+    };
+
     return Modal;
 });
index 2e182c1..8f593f0 100644 (file)
  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/notification', 'core/custom_interaction_events', 'core/modal', 'core/modal_events'],
-        function($, Notification, CustomEvents, Modal, ModalEvents) {
+import Modal from 'core/modal';
 
-    var SELECTORS = {
-        CANCEL_BUTTON: '[data-action="cancel"]',
-    };
+export default class extends Modal {
+    constructor(root) {
+        super(root);
 
-    /**
-     * Constructor for the Modal.
-     *
-     * @param {object} root The root jQuery element for the modal
-     */
-    var ModalCancel = function(root) {
-        Modal.call(this, root);
-
-        if (!this.getFooter().find(SELECTORS.CANCEL_BUTTON).length) {
+        if (!this.getFooter().find(this.getActionSelector('cancel')).length) {
             Notification.exception({message: 'No cancel button found'});
         }
-    };
-
-    ModalCancel.prototype = Object.create(Modal.prototype);
-    ModalCancel.prototype.constructor = ModalCancel;
-
-    /**
-     * Override parent implementation to prevent changing the footer content.
-     */
-    ModalCancel.prototype.setFooter = function() {
-        Notification.exception({message: 'Can not change the footer of a cancel modal'});
-        return;
-    };
+    }
 
     /**
-     * Set up all of the event handling for the modal.
-     *
-     * @method registerEventListeners
+     * Register all event listeners.
      */
-    ModalCancel.prototype.registerEventListeners = function() {
-        // Apply parent event listeners.
-        Modal.prototype.registerEventListeners.call(this);
-
-        this.getModal().on(CustomEvents.events.activate, SELECTORS.CANCEL_BUTTON, function(e, data) {
-            var cancelEvent = $.Event(ModalEvents.cancel);
-            this.getRoot().trigger(cancelEvent, this);
-
-            if (!cancelEvent.isDefaultPrevented()) {
-                this.hide();
-                data.originalEvent.preventDefault();
-            }
-        }.bind(this));
-    };
-
-    return ModalCancel;
-});
+    registerEventListeners() {
+        // Call the parent registration.
+        super.registerEventListeners();
+
+        // Register to close on cancel.
+        this.registerCloseOnCancel();
+    }
+}
index 2b304bc..1e27c2f 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 define(['jquery', 'core/modal_events', 'core/modal_registry', 'core/modal',
-        'core/modal_save_cancel', 'core/modal_cancel',
+        'core/modal_save_cancel', 'core/modal_cancel', 'core/local/modal/alert',
         'core/templates', 'core/notification', 'core/custom_interaction_events',
         'core/pending'],
     function($, ModalEvents, ModalRegistry, Modal, ModalSaveCancel,
-        ModalCancel, Templates, Notification, CustomEvents, Pending) {
+        ModalCancel, ModalAlert, Templates, Notification, CustomEvents, Pending) {
 
     // The templates for each type of modal.
     var TEMPLATES = {
         DEFAULT: 'core/modal',
         SAVE_CANCEL: 'core/modal_save_cancel',
         CANCEL: 'core/modal_cancel',
+        ALERT: 'core/local/modal/alert',
     };
 
     // The available types of modals.
@@ -41,12 +42,14 @@ define(['jquery', 'core/modal_events', 'core/modal_registry', 'core/modal',
         DEFAULT: 'DEFAULT',
         SAVE_CANCEL: 'SAVE_CANCEL',
         CANCEL: 'CANCEL',
+        ALERT: 'ALERT',
     };
 
     // Register the common set of modals.
     ModalRegistry.register(TYPES.DEFAULT, Modal, TEMPLATES.DEFAULT);
     ModalRegistry.register(TYPES.SAVE_CANCEL, ModalSaveCancel, TEMPLATES.SAVE_CANCEL);
     ModalRegistry.register(TYPES.CANCEL, ModalCancel, TEMPLATES.CANCEL);
+    ModalRegistry.register(TYPES.ALERT, ModalAlert, TEMPLATES.ALERT);
 
     /**
      * Set up the events required to show the modal and return focus when the modal
@@ -185,10 +188,21 @@ define(['jquery', 'core/modal_events', 'core/modal_registry', 'core/modal',
                     modal.setFooter(modalConfig.footer);
                 }
 
+                if (modalConfig.buttons) {
+                    Object.entries(modalConfig.buttons).forEach(function([key, value]) {
+                        modal.setButtonText(key, value);
+                    });
+                }
+
                 if (isLarge) {
                     modal.setLarge();
                 }
 
+                if (typeof modalConfig.removeOnClose !== 'undefined') {
+                    // If configured remove the modal when hiding it.
+                    modal.setRemoveOnClose(modalConfig.removeOnClose);
+                }
+
                 return modal;
             });
 
index f62751c..42416be 100644 (file)
  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/notification', 'core/custom_interaction_events', 'core/modal', 'core/modal_events'],
-        function($, Notification, CustomEvents, Modal, ModalEvents) {
+import Modal from 'core/modal';
 
-    var SELECTORS = {
-        SAVE_BUTTON: '[data-action="save"]',
-        CANCEL_BUTTON: '[data-action="cancel"]',
-    };
+export default class extends Modal {
+    constructor(root) {
+        super(root);
 
-    /**
-     * Constructor for the Modal.
-     *
-     * @param {object} root The root jQuery element for the modal
-     */
-    var ModalSaveCancel = function(root) {
-        Modal.call(this, root);
-
-        if (!this.getFooter().find(SELECTORS.SAVE_BUTTON).length) {
+        if (!this.getFooter().find(this.getActionSelector('save')).length) {
             Notification.exception({message: 'No save button found'});
         }
 
-        if (!this.getFooter().find(SELECTORS.CANCEL_BUTTON).length) {
+        if (!this.getFooter().find(this.getActionSelector('cancel')).length) {
             Notification.exception({message: 'No cancel button found'});
         }
-    };
+    }
 
-    ModalSaveCancel.prototype = Object.create(Modal.prototype);
-    ModalSaveCancel.prototype.constructor = ModalSaveCancel;
+    /**
+     * Register all event listeners.
+     */
+    registerEventListeners() {
+        // Call the parent registration.
+        super.registerEventListeners();
+
+        // Register to close on save/cancel.
+        this.registerCloseOnSave();
+        this.registerCloseOnCancel();
+    }
 
     /**
      * Override parent implementation to prevent changing the footer content.
      */
-    ModalSaveCancel.prototype.setFooter = function() {
+    setFooter() {
         Notification.exception({message: 'Can not change the footer of a save cancel modal'});
         return;
-    };
-
-    /**
-     * Set up all of the event handling for the modal.
-     *
-     * @method registerEventListeners
-     */
-    ModalSaveCancel.prototype.registerEventListeners = function() {
-        // Apply parent event listeners.
-        Modal.prototype.registerEventListeners.call(this);
-
-        this.getModal().on(CustomEvents.events.activate, SELECTORS.SAVE_BUTTON, function(e, data) {
-            var saveEvent = $.Event(ModalEvents.save);
-            this.getRoot().trigger(saveEvent, this);
-
-            if (!saveEvent.isDefaultPrevented()) {
-                this.hide();
-                data.originalEvent.preventDefault();
-            }
-        }.bind(this));
-
-        this.getModal().on(CustomEvents.events.activate, SELECTORS.CANCEL_BUTTON, function(e, data) {
-            var cancelEvent = $.Event(ModalEvents.cancel);
-            this.getRoot().trigger(cancelEvent, this);
-
-            if (!cancelEvent.isDefaultPrevented()) {
-                this.hide();
-                data.originalEvent.preventDefault();
-            }
-        }.bind(this));
-    };
+    }
 
     /**
-     * Allows to overwrite the text of "Save changes" button.
-     *
-     * This method is overloaded to take either a string value for the button title or a jQuery promise that is resolved with
-     * text most commonly from a Str.get_string call.
+     * Set the title of the save button.
      *
-     * @param {(String|object)} value The button text, or a jQuery promise which will resolve it
+     * @param {String|Promise} value The button text, or a Promise which will resolve it
+     * @returns{Promise}
      */
-    ModalSaveCancel.prototype.setSaveButtonText = function(value) {
-        var button = this.getFooter().find(SELECTORS.SAVE_BUTTON);
-
-        this.asyncSet(value, button.text.bind(button));
-    };
-
-    return ModalSaveCancel;
-});
+    setSaveButtonText(value) {
+        return this.setButtonText('save', value);
+    }
+}
index 3bbe59f..0822899 100644 (file)
 // You should have received a copy of the GNU General Public License
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
+import Pending from 'core/pending';
+import Log from 'core/log';
+
+let currentContextId = M.cfg.contextid;
+
+const notificationTypes = {
+    success:  'core/notification_success',
+    info:     'core/notification_info',
+    warning:  'core/notification_warning',
+    error:    'core/notification_error',
+};
+
+const notificationRegionId = 'user-notifications';
+
+const Selectors = {
+    notificationRegion: `#${notificationRegionId}`,
+    fallbackRegionParents: [
+        '#region-main',
+        '[role="main"]',
+        'body',
+    ],
+};
+
+const setupTargetRegion = () => {
+    let targetRegion = getNotificationRegion();
+    if (targetRegion) {
+        return false;
+    }
+
+    const newRegion = document.createElement('span');
+    newRegion.id = notificationRegionId;
+
+    return Selectors.fallbackRegionParents.some(selector => {
+        const targetRegion = document.querySelector(selector);
+
+        if (targetRegion) {
+            targetRegion.prepend(newRegion);
+            return true;
+        }
+
+        return false;
+    });
+};
+
+
 /**
- * A system for displaying notifications to users from the session.
+ * Poll the server for any new notifications.
  *
- * Wrapper for the YUI M.core.notification class. Allows us to
- * use the YUI version in AMD code until it is replaced.
- *
- * @module     core/notification
- * @class      notification
- * @package    core
- * @copyright  2015 Damyon Wiese <damyon@moodle.com>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @since      2.9
+ * @returns {Promise}
  */
-define(['core/yui', 'jquery', 'core/log', 'core/pending'],
-function(Y, $, log, Pending) {
-    var notificationModule = {
-        types: {
-            'success':  'core/notification_success',
-            'info':     'core/notification_info',
-            'warning':  'core/notification_warning',
-            'error':    'core/notification_error',
-        },
+export const fetchNotifications = async() => {
+    const Ajax = await import('core/ajax');
 
-        fieldName: 'user-notifications',
-
-        fetchNotifications: function() {
-            var pendingPromise = new Pending('core/notification:fetchNotifications');
-
-            require(['core/ajax'], function(ajax) {
-                var promises = ajax.call([{
-                    methodname: 'core_fetch_notifications',
-                    args: {
-                        contextid: notificationModule.contextid
-                    }
-                }]);
-
-                // This currently fails when not logged in.
-                // eslint-disable-next-line promise/catch-or-return
-                promises[0]
-                .then(notificationModule.addNotifications)
-                .always(pendingPromise.resolve);
-            });
-        },
+    return Ajax.call([{
+        methodname: 'core_fetch_notifications',
+        args: {
+            contextid: currentContextId
+        }
+    }])[0]
+    .then(addNotifications);
+};
 
-        addNotifications: function(notifications) {
-            var pendingPromise = new Pending('core/notification:addNotifications');
+/**
+ * Add all of the supplied notifications.
+ *
+ * @param {Array} notifications The list of notificaitons
+ * @returns {Promise}
+ */
+const addNotifications = notifications => {
+    if (!notifications.length) {
+        return Promise.resolve();
+    }
 
-            if (!notifications) {
-                notifications = [];
-            }
+    const pendingPromise = new Pending('core/notification:addNotifications');
+    notifications.forEach(notification => renderNotification(notification.template, notification.variables));
 
-            $.each(notifications, function(i, notification) {
-                notificationModule.renderNotification(notification.template, notification.variables);
-            });
+    return pendingPromise.resolve();
+};
 
-            pendingPromise.resolve();
-        },
+/**
+ * Add a notification to the page.
+ *
+ * Note: This does not cause the notification to be added to the session.
+ *
+ * @param {Object}  notification                The notification to add.
+ * @param {string}  notification.message        The body of the notification
+ * @param {string}  notification.type           The type of notification to add (error, warning, info, success).
+ * @param {Boolean} notification.closebutton    Whether to show the close button.
+ * @param {Boolean} notification.announce       Whether to announce to screen readers.
+ * @returns {Promise}
+ */
+export const addNotification = notification => {
+    const pendingPromise = new Pending('core/notification:addNotifications');
 
-        setupTargetRegion: function() {
-            var targetRegion = $('#' + notificationModule.fieldName);
-            if (targetRegion.length) {
-                return false;
-            }
+    let template = notificationTypes.error;
 
-            var newRegion = $('<span>').attr('id', notificationModule.fieldName);
+    notification = {
+        closebutton:    true,
+        announce:       true,
+        type:           'error',
+        ...notification,
+    };
 
-            targetRegion = $('#region-main');
-            if (targetRegion.length) {
-                return targetRegion.prepend(newRegion);
-            }
+    if (notification.template) {
+        template = notification.template;
+        delete notification.template;
+    } else if (notification.type) {
+        if (typeof notificationTypes[notification.type] !== 'undefined') {
+            template = notificationTypes[notification.type];
+        }
+        delete notification.type;
+    }
 
-            targetRegion = $('[role="main"]');
-            if (targetRegion.length) {
-                return targetRegion.prepend(newRegion);
-            }
+    return renderNotification(template, notification)
+    .then(pendingPromise.resolve);
+};
 
-            targetRegion = $('body');
-            return targetRegion.prepend(newRegion);
-        },
+const renderNotification = async(template, variables) => {
+    if (typeof variables.message === 'undefined' || !variables.message) {
+        Log.debug('Notification received without content. Skipping.');
+        return;
+    }
 
-        addNotification: function(notification) {
-            var pendingPromise = new Pending('core/notification:addNotifications');
-
-            var template = notificationModule.types.error;
-
-            notification = $.extend({
-                closebutton:    true,
-                announce:       true,
-                type:           'error'
-            }, notification);
-
-            if (notification.template) {
-                template = notification.template;
-                delete notification.template;
-            } else if (notification.type) {
-                if (typeof notificationModule.types[notification.type] !== 'undefined') {
-                    template = notificationModule.types[notification.type];
-                }
-                delete notification.type;
-            }
+    const pendingPromise = new Pending('core/notification:renderNotification');
+    const Templates = await import('core/templates');
 
-            pendingPromise.resolve();
+    Templates.renderForPromise(template, variables)
+    .then(({html, js = ''}) => {
+        Templates.prependNodeContents(getNotificationRegion(), html, js);
 
-            return notificationModule.renderNotification(template, notification);
-        },
+        return;
+    })
+    .then(pendingPromise.resolve)
+    .catch(exception);
+};
 
-        renderNotification: function(template, variables) {
-            if (typeof variables.message === 'undefined' || !variables.message) {
-                log.debug('Notification received without content. Skipping.');
-                return;
-            }
+const getNotificationRegion = () => document.querySelector(Selectors.notificationRegion);
 
-            var pendingPromise = new Pending('core/notification:renderNotification');
+/**
+ * Alert dialogue.
+ *
+ * @param {String|Promise} title
+ * @param {String|Promise} message
+ * @param {String|Promise} cancelText
+ * @returns {Promise}
+ */
+export const alert = async(title, message, cancelText) => {
+    var pendingPromise = new Pending('core/notification:alert');
 
-            require(['core/templates'], function(templates) {
-                templates.render(template, variables)
-                .then(function(html, js) {
-                    $('#' + notificationModule.fieldName).prepend(html);
-                    templates.runTemplateJS(js);
+    const ModalFactory = await import('core/modal_factory');
 
-                    return;
-                })
-                .always(pendingPromise.resolve)
-                .catch(notificationModule.exception);
-            });
+    return ModalFactory.create({
+        type: ModalFactory.types.ALERT,
+        body: message,
+        title: title,
+        buttons: {
+            cancel: cancelText,
         },
+        removeOnClose: true,
+    })
+    .then(function(modal) {
+        modal.show();
 
-        alert: function(title, message, yesLabel) {
-            var pendingPromise = new Pending('core/notification:alert');
+        pendingPromise.resolve();
+        return modal;
+    });
+};
 
-            // Here we are wrapping YUI. This allows us to start transitioning, but
-            // wait for a good alternative without having inconsistent dialogues.
-            Y.use('moodle-core-notification-alert', function() {
-                var alert = new M.core.alert({
-                    title: title,
-                    message: message,
-                    yesLabel: yesLabel
-                });
-
-                alert.show();
+/**
+ * The confirm has now been replaced with a save and cancel dialogue.
+ *
+ * @param {String|Promise} title
+ * @param {String|Promise} question
+ * @param {String|Promise} saveLabel
+ * @param {String|Promise} noLabel
+ * @param {String|Promise} saveCallback
+ * @param {String|Promise} cancelCallback
+ * @returns {Promise}
+ */
+export const confirm = (title, question, saveLabel, noLabel, saveCallback, cancelCallback) =>
+        saveCancel(title, question, saveLabel, saveCallback, cancelCallback);
 
-                pendingPromise.resolve();
-            });
+/**
+ * The Save and Cancel dialogue helper.
+ *
+ * @param {String|Promise} title
+ * @param {String|Promise} question
+ * @param {String|Promise} saveLabel
+ * @param {String|Promise} saveCallback
+ * @param {String|Promise} cancelCallback
+ * @returns {Promise}
+ */
+export const saveCancel = async(title, question, saveLabel, saveCallback, cancelCallback) => {
+    const pendingPromise = new Pending('core/notification:confirm');
+
+    const [
+        ModalFactory,
+        ModalEvents,
+    ] = await Promise.all([
+        import('core/modal_factory'),
+        import('core/modal_events'),
+    ]);
+
+    return ModalFactory.create({
+        type: ModalFactory.types.SAVE_CANCEL,
+        title: title,
+        body: question,
+        buttons: {
+            // Note: The noLabel is no longer supported.
+            save: saveLabel,
         },
+        removeOnClose: true,
+    })
+    .then(function(modal) {
+        modal.show();
 
-        confirm: function(title, question, yesLabel, noLabel, yesCallback, noCallback) {
-            var pendingPromise = new Pending('core/notification:confirm');
-
-            // Here we are wrapping YUI. This allows us to start transitioning, but
-            // wait for a good alternative without having inconsistent dialogues.
-            Y.use('moodle-core-notification-confirm', function() {
-                var modal = new M.core.confirm({
-                    title: title,
-                    question: question,
-                    yesLabel: yesLabel,
-                    noLabel: noLabel
-                });
-
-                modal.on('complete-yes', function() {
-                    yesCallback();
-                });
-                if (noCallback) {
-                    modal.on('complete-no', function() {
-                        noCallback();
-                    });
-                }
-                modal.show();
-
-                pendingPromise.resolve();
-            });
-        },
+        modal.getRoot().on(ModalEvents.save, saveCallback);
+        modal.getRoot().on(ModalEvents.cancel, cancelCallback);
+        pendingPromise.resolve();
 
-        exception: function(ex) {
-            var pendingPromise = new Pending('core/notification:addNotifications');
+        return modal;
+    });
+};
 
-            // Fudge some parameters.
-            if (typeof ex.stack == 'undefined') {
-                ex.stack = '';
-            }
-            if (ex.debuginfo) {
-                ex.stack += ex.debuginfo + '\n';
-            }
-            if (!ex.backtrace && ex.stacktrace) {
-                ex.backtrace = ex.stacktrace;
-            }
-            if (ex.backtrace) {
-                ex.stack += ex.backtrace;
-                var ln = ex.backtrace.match(/line ([^ ]*) of/);
-                var fn = ex.backtrace.match(/ of ([^:]*): /);
-                if (ln && ln[1]) {
-                    ex.lineNumber = ln[1];
-                }
-                if (fn && fn[1]) {
-                    ex.fileName = fn[1];
-                    if (ex.fileName.length > 30) {
-                        ex.fileName = '...' + ex.fileName.substr(ex.fileName.length - 27);
-                    }
-                }
-            }
-            if (typeof ex.name == 'undefined' && ex.errorcode) {
-                ex.name = ex.errorcode;
+/**
+ * Wrap M.core.exception.
+ *
+ * @param {Error} ex
+ */
+export const exception = async ex => {
+    const pendingPromise = new Pending('core/notification:displayException');
+
+    // Fudge some parameters.
+    if (!ex.stack) {
+        ex.stack = '';
+    }
+
+    if (ex.debuginfo) {
+        ex.stack += ex.debuginfo + '\n';
+    }
+
+    if (!ex.backtrace && ex.stacktrace) {
+        ex.backtrace = ex.stacktrace;
+    }
+
+    if (ex.backtrace) {
+        ex.stack += ex.backtrace;
+        const ln = ex.backtrace.match(/line ([^ ]*) of/);
+        const fn = ex.backtrace.match(/ of ([^:]*): /);
+        if (ln && ln[1]) {
+            ex.lineNumber = ln[1];
+        }
+        if (fn && fn[1]) {
+            ex.fileName = fn[1];
+            if (ex.fileName.length > 30) {
+                ex.fileName = '...' + ex.fileName.substr(ex.fileName.length - 27);
             }
-
-            Y.use('moodle-core-notification-exception', function() {
-                var modal = new M.core.exception(ex);
-
-                modal.show();
-
-                pendingPromise.resolve();
-            });
         }
-    };
+    }
 
-    return /** @alias module:core/notification */{
-        init: function(contextid, notifications) {
-            notificationModule.contextid = contextid;
+    if (typeof ex.name === 'undefined' && ex.errorcode) {
+        ex.name = ex.errorcode;
+    }
 
-            // Setup the message target region if it isn't setup already
-            notificationModule.setupTargetRegion();
+    const Y = await import('core/yui');
+    Y.use('moodle-core-notification-exception', function() {
+        var modal = new M.core.exception(ex);
 
-            // Add provided notifications.
-            notificationModule.addNotifications(notifications);
+        modal.show();
 
-            // Poll for any new notifications.
-            notificationModule.fetchNotifications();
-        },
+        pendingPromise.resolve();
+    });
+};
 
-        /**
-         * Poll the server for any new notifications.
-         *
-         * @method fetchNotifications
-         */
-        fetchNotifications: notificationModule.fetchNotifications,
-
-        /**
-         * Add a notification to the page.
-         *
-         * Note: This does not cause the notification to be added to the session.
-         *
-         * @method addNotification
-         * @param {Object}  notification                The notification to add.
-         * @param {string}  notification.message        The body of the notification
-         * @param {string}  notification.type           The type of notification to add (error, warning, info, success).
-         * @param {Boolean} notification.closebutton    Whether to show the close button.
-         * @param {Boolean} notification.announce       Whether to announce to screen readers.
-         */
-        addNotification: notificationModule.addNotification,
-
-        /**
-         * Wrap M.core.alert.
-         *
-         * @method alert
-         * @param {string} title
-         * @param {string} message
-         * @param {string} yesLabel
-         */
-        alert: notificationModule.alert,
-
-        /**
-         * Wrap M.core.confirm.
-         *
-         * @method confirm
-         * @param {string} title
-         * @param {string} question
-         * @param {string} yesLabel
-         * @param {string} noLabel
-         * @param {function} yesCallback
-         * @param {function} noCallback Optional parameter to be called if the user presses cancel.
-         */
-        confirm: notificationModule.confirm,
-
-        /**
-         * Wrap M.core.exception.
-         *
-         * @method exception
-         * @param {Error} ex
-         */
-        exception: notificationModule.exception
-    };
-});
+/**
+ * Initialise the page for the suppled context, and displaying the supplied notifications.
+ *
+ * @param {Number} contextId
+ * @param {Array} notificationList
+ */
+export const init = (contextId, notificationList) => {
+    currentContextId = contextId;
+
+    // Setup the message target region if it isn't setup already
+    setupTargetRegion();
+
+    // Add provided notifications.
+    addNotifications(notificationList);
+
+    // Perform an initial poll for any new notifications.
+    fetchNotifications();
+};
+
+// To maintain backwards compatability we export default here.
+export default {
+    init,
+    fetchNotifications,
+    addNotification,
+    alert,
+    confirm,
+    saveCancel,
+    exception,
+};
index 1c11681..a26dcdf 100644 (file)
@@ -70,7 +70,7 @@ class MoodleQuickForm_course extends MoodleQuickForm_autocomplete {
      *                       'requiredcapabilities' - array of capabilities. Uses ANY to combine them.
      *                       'limittoenrolled' - boolean Limits to enrolled courses.
      *                       'includefrontpage' - boolean Enables the frontpage to be selected.
-     *                       'onlywithcompletion' - only courses where completion is enabled
+     *                       'onlywithcompletion' - boolean Limits to courses where completion is enabled.
      */
     public function __construct($elementname = null, $elementlabel = null, $attributes = array()) {
         if (!is_array($attributes)) {
@@ -105,8 +105,9 @@ class MoodleQuickForm_course extends MoodleQuickForm_autocomplete {
             $attributes['data-includefrontpage'] = SITEID;
             unset($attributes['includefrontpage']);
         }
-        if (!empty($options['onlywithcompletion'])) {
-            $validattributes['data-onlywithcompletion'] = 1;
+        if (!empty($attributes['onlywithcompletion'])) {
+            $attributes['data-onlywithcompletion'] = 1;
+            unset($attributes['onlywithcompletion']);
         }
 
         parent::__construct($elementname, $elementlabel, array(), $attributes);
diff --git a/lib/form/tests/course_test.php b/lib/form/tests/course_test.php
new file mode 100644 (file)
index 0000000..49c09ee
--- /dev/null
@@ -0,0 +1,77 @@
+<?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/>.
+
+/**
+ * Unit tests for MoodleQuickForm_course.
+ *
+ * This file contains unit tests related to course forms element.
+ *
+ * @package     core_form
+ * @category    test
+ * @copyright   2020 Ruslan Kabalin
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/form/course.php');
+
+/**
+ * Unit tests for MoodleQuickForm_course
+ *
+ * Contains test cases for testing MoodleQuickForm_course.
+ *
+ * @package    core_form
+ * @category   test
+ * @copyright  2020 Ruslan Kabalin
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_form_course_testcase extends basic_testcase {
+
+    /**
+     * Test constructor supports all declared attributes.
+     */
+    public function test_constructor_attributes() {
+        $attributes = [
+            'exclude' => [1, 2],
+            'requiredcapabilities' => ['moodle/course:update'],
+        ];
+
+        $element = new MoodleQuickForm_course('testel', null, $attributes);
+        $html = $element->toHtml();
+        $this->assertContains('data-exclude="1,2"', $html);
+        $this->assertContains('data-requiredcapabilities="moodle/course:update"', $html);
+        $this->assertContains('data-limittoenrolled="0"', $html);
+        $this->assertNotContains('multiple', $html);
+        $this->assertNotContains('data-includefrontpage', $html);
+        $this->assertNotContains('data-onlywithcompletion', $html);
+
+        // Add more attributes.
+        $attributes = [
+            'multiple' => true,
+            'limittoenrolled' => true,
+            'includefrontpage' => true,
+            'onlywithcompletion' => true,
+        ];
+        $element = new MoodleQuickForm_course('testel', null, $attributes);
+        $html = $element->toHtml();
+        $this->assertContains('multiple', $html);
+        $this->assertContains('data-limittoenrolled="1"', $html);
+        $this->assertContains('data-includefrontpage="' . SITEID . '"', $html);
+        $this->assertContains('data-onlywithcompletion="1"', $html);
+    }
+}
index 34cb708..4c34006 100644 (file)
@@ -801,7 +801,7 @@ abstract class CFBinaryPropertyList {
    */
   protected static function binaryStrlen($val) {
     for($i=0;$i<strlen($val);++$i) {
-      if(ord($val{$i}) >= 128) {
+      if(ord($val[$i]) >= 128) {
         $val = self::convertCharset($val, 'UTF-8', 'UTF-16BE');
         return strlen($val);
       }
@@ -824,7 +824,7 @@ abstract class CFBinaryPropertyList {
       $utf16 = false;
 
       for($i=0;$i<strlen($val);++$i) {
-        if(ord($val{$i}) >= 128) {
+        if(ord($val[$i]) >= 128) {
           $utf16 = true;
           break;
         }
index 664f4b5..27003bc 100644 (file)
@@ -14,6 +14,11 @@ Removed:
  * examples
  * tests
 
+Local changes:
+(always verify if the changes below are already fixed by the
+next version to import or they need to be re-applied manually)
+  * PHP 7.4 comp: bf527c8 - Partially applied https://github.com/TECLIB/CFPropertyList/pull/61
+
 Added:
  * readme_moodle.txt
 
index 69701a0..4288756 100644 (file)
@@ -19,7 +19,6 @@
  *
  * @package core_table
  * @copyright 2020 Simey Lameze <simey@moodle.com>
- *
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
@@ -27,60 +26,19 @@ declare(strict_types=1);
 
 namespace core_table;
 
-defined('MOODLE_INTERNAL') || die();
-
-use moodle_url;
-use context;
-use core_table\local\filter\filterset;
-
 /**
- * Interface dynamic.
+ * Interface to identify this table as a table which can be dynamically updated via webservice calls.
+ *
+ * For a table to be defined as dynamic it must meet the following requirements:
+ *
+ * # it must be located with a namespaced class of \[component]\table\[tablename]
+ * # it must define a \core_table\local\filter\filterset implementation in \[component]\table\[tablename]_filterset
+ * # it must override the {{guess_base_url}} function and specify a base URL to be used when constructing URLs
+ * # it must override the {{get_context}} function to specify the correct context
  *
  * @package core_table
+ * @copyright 2020 Simey Lameze <simey@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 interface dynamic {
-
-    /**
-     * Take a string and convert it to the format expected by the table.
-     * For example, you may have a format such as:
-     *
-     *   mod_assign-submissions-[courseid]
-     *
-     * Passing this function an argument of [courseid] would return the fully-formed string.
-     *
-     * @param string $argument
-     * @return string
-     */
-    public static function get_unique_id_from_argument(string $argument): string;
-
-    /**
-     * Get the base url.
-     *
-     * @return moodle_url
-     */
-    public function get_base_url(): moodle_url;
-
-    /**
-     * Set the filterset filters build table object.
-     *
-     * @param filterset $filterset The filterset object to get the filters from.
-     * @return void
-     */
-    public function set_filterset(filterset $filterset): void;
-
-    /**
-     * Get the currently defined filterset.
-     *
-     * @return filterset
-     */
-    public function get_filterset(): ?filterset;
-
-    /**
-     * Get the context of the current table.
-     *
-     * Note: This function should not be called until after the filterset has been provided.
-     *
-     * @return context
-     */
-    public function get_context(): ?context;
 }
index 6e117e9..890efb5 100644 (file)
@@ -214,6 +214,8 @@ class fetch extends external_api {
 
         $instance = new $tableclass($uniqueid);
         $instance->set_filterset($filterset);
+        self::validate_context($instance->get_context());
+
         $instance->set_sorting($sortby, $sortorder);
 
         if ($firstinitial !== null) {
@@ -236,10 +238,6 @@ class fetch extends external_api {
             $instance->set_hidden_columns($hiddencolumns);
         }
 
-        $context = $instance->get_context();
-        self::validate_context($context);
-        $PAGE->set_url($instance->get_base_url());
-
         ob_start();
         $instance->out($pagesize, true);
         $tablehtml = ob_get_contents();
index 7e669a2..55a83c2 100644 (file)
@@ -46,6 +46,7 @@ define('TABLE_P_TOP',    1);
 define('TABLE_P_BOTTOM', 2);
 /**#@-*/
 
+use core_table\local\filter\filterset;
 
 /**
  * @package   moodlecore
@@ -157,6 +158,12 @@ class flexible_table {
     /** @var array $hiddencolumns List of hidden columns. */
     protected $hiddencolumns;
 
+    /**
+     * @var filterset The currently applied filerset
+     * This is required for dynamic tables, but can be used by other tables too if desired.
+     */
+    protected $filterset = null;
+
     /**
      * Constructor
      * @param string $uniqueid all tables have to have a unique id, this is used
@@ -831,9 +838,9 @@ class flexible_table {
      * @return string contents of cell in column 'fullname', for this row.
      */
     function col_fullname($row) {
-        global $PAGE, $COURSE;
+        global $COURSE;
 
-        $name = fullname($row, has_capability('moodle/site:viewfullnames', $PAGE->context));
+        $name = fullname($row, has_capability('moodle/site:viewfullnames', $this->get_context()));
         if ($this->download) {
             return $name;
         }
@@ -1211,7 +1218,7 @@ class flexible_table {
      * This function is not part of the public api.
      */
     function print_headers() {
-        global $CFG, $OUTPUT, $PAGE;
+        global $CFG, $OUTPUT;
 
         echo html_writer::start_tag('thead');
         echo html_writer::start_tag('tr');
@@ -1233,7 +1240,7 @@ class flexible_table {
 
                 case 'fullname':
                     // Check the full name display for sortable fields.
-                    if (has_capability('moodle/site:viewfullnames', $PAGE->context)) {
+                    if (has_capability('moodle/site:viewfullnames', $this->get_context())) {
                         $nameformat = $CFG->alternativefullnameformat;
                     } else {
                         $nameformat = $CFG->fullnamedisplay;
@@ -1693,6 +1700,55 @@ class flexible_table {
 
         return false;
     }
+
+    /**
+     * Get the context for the table.
+     *
+     * Note: This function _must_ be overridden by dynamic tables to ensure that the context is correctly determined
+     * from the filterset parameters.
+     *
+     * @return context
+     */
+    public function get_context(): context {
+        global $PAGE;
+
+        if (is_a($this, \core_table\dynamic::class)) {
+            throw new coding_exception('The get_context function must be defined for a dynamic table');
+        }
+
+        return $PAGE->context;
+    }
+
+    /**
+     * Set the filterset in the table class.
+     *
+     * The use of filtersets is a requirement for dynamic tables, but can be used by other tables too if desired.
+     *
+     * @param filterset $filterset The filterset object to get filters and table parameters from
+     */
+    public function set_filterset(filterset $filterset): void {
+        $this->filterset = $filterset;
+
+        $this->guess_base_url();
+    }
+
+    /**
+     * Get the currently defined filterset.
+     *
+     * @return filterset
+     */
+    public function get_filterset(): ?filterset {
+        return $this->filterset;
+    }
+
+    /**
+     * Attempt to guess the base URL.
+     */
+    public function guess_base_url(): void {
+        if (is_a($this, \core_table\dynamic::class)) {
+            throw new coding_exception('The guess_base_url function must be defined for a dynamic table');
+        }
+    }
 }
 
 
diff --git a/lib/templates/local/modal/alert.mustache b/lib/templates/local/modal/alert.mustache
new file mode 100644 (file)
index 0000000..78ce7cd
--- /dev/null
@@ -0,0 +1,45 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/local/modal/alert
+
+    Moodle modal template with one oaky button.
+
+    The purpose of this template is to render an alert modal with an acceptance option.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * title A cleaned string (use clean_text()) to display.
+    * body HTML content for the boday
+
+    Example context (json):
+    {
+        "title": "Example cancel modal",
+        "body": "Some example content for the body"
+    }
+}}
+
+{{< core/modal }}
+    {{$footer}}
+        <button type="button" class="btn btn-primary" data-action="cancel">{{#str}}okay, moodle{{/str}}</button>
+    {{/footer}}
+{{/ core/modal }}
index eb04ddc..8fa66f4 100644 (file)
@@ -47,6 +47,11 @@ information provided here is intended especially for developers.
   db/services.php. Note - this also requires $CFG->enable_read_only_sessions to be set to true.
 * database_manager::check_database_schema() now checks for missing and extra indexes.
 * Implement a more direct xsendfile_file() method for an alternative_file_system_class
+* A new `dynamic` table interface has been defined, which allows any `flexible_table` to be converted into a table which
+  is updatable via ajax calls. See MDL-68495 and `\core_table\dynamic` for further information.
+* The core/notification module has been updated to use AMD modals for its confirmation and alert dialogues.
+  The confirmation dialogue no longer has a configurable "No" button as per similar changes in MDL-59759.
+  This set of confirmation modals was unintentionally missed from that deprecation process.
 
 === 3.8 ===
 * Add CLI option to notify all cron tasks to stop: admin/cli/cron.php --stop
index 1f29e2e..dfcf80a 100644 (file)
@@ -201,7 +201,7 @@ class media_videojs_plugin extends core_media_player_native {
             }
         }
 
-        return html_writer::div($text, 'mediaplugin mediaplugin_videojs');
+        return html_writer::div($text, 'mediaplugin mediaplugin_videojs d-block');
     }
 
     /**
index b593498..1b341ee 100644 (file)
@@ -56,7 +56,7 @@ Feature: In an assignment, teacher can view the feedback for a previous attempt.
     And I should see "The changes to the grade and feedback were saved"
     And I press "Ok"
     And I follow "View a different attempt"
-    And I click on "//div[contains(@class, 'moodle-dialogue-bd')]//label[2]" "xpath_element"
+    And I click on "Attempt 1" "radio" in the "View a different attempt" "dialogue"
     And I press "View"
     And I wait until the page is ready
     And I should see "You are editing the feedback for a previous attempt. This is attempt 1 out of 2."
index 007d4fd..add1bec 100644 (file)
@@ -60,7 +60,7 @@ Feature: In an assignment, teachers can edit feedback for a students previous su
     And I navigate to "View all submissions" in current page administration
     And I click on "Grade" "link" in the "Student 2" "table_row"
     And I click on "View a different attempt" "link"
-    And I click on "//div[contains(concat(' ', normalize-space(@class), ' '), ' confirmation-dialogue ')]//input[@value='0']" "xpath_element"
+    And I click on "Attempt 1" "radio" in the "View a different attempt" "dialogue"
     And I click on "View" "button"
     And I set the following fields to these values:
       | Grade | 50 |
index 9ce2b39..383a322 100644 (file)
@@ -181,7 +181,7 @@ class mod_feedback_responses_table extends table_sql {
      * Current context
      * @return context_module
      */
-    protected function get_context() {
+    public function get_context(): context {
         return context_module::instance($this->feedbackstructure->get_cm()->id);
     }
 
index 914d25d..5f2fff8 100644 (file)
@@ -365,14 +365,14 @@ function h5pactivity_get_file_areas(stdClass $course, stdClass $cm, stdClass $co
  * @param stdClass $cm
  * @param context $context
  * @param string $filearea
- * @param int $itemid
- * @param string $filepath
- * @param string $filename
+ * @param int|null $itemid
+ * @param string|null $filepath
+ * @param string|null $filename
  * @return file_info_stored|null file_info_stored instance or null if not found
  */
 function h5pactivity_get_file_info(file_browser $browser, array $areas, stdClass $course,
-            stdClass $cm, context $context, string $filearea, int $itemid,
-            string $filepath, string $filename): ?file_info_stored {
+            stdClass $cm, context $context, string $filearea, ?int $itemid = null,
+            ?string $filepath = null, ?string $filename = null): ?file_info_stored {
     global $CFG;
 
     if (!has_capability('moodle/course:managefiles', $context)) {
index 35fb91b..2607883 100644 (file)
Binary files a/question/type/ddmarker/amd/build/form.min.js and b/question/type/ddmarker/amd/build/form.min.js differ
index 5eb2a66..3fc5453 100644 (file)
Binary files a/question/type/ddmarker/amd/build/form.min.js.map and b/question/type/ddmarker/amd/build/form.min.js.map differ
index a44cd93..4144b19 100644 (file)
Binary files a/question/type/ddmarker/amd/build/question.min.js and b/question/type/ddmarker/amd/build/question.min.js differ
index 9af4a60..8621ca8 100644 (file)
Binary files a/question/type/ddmarker/amd/build/question.min.js.map and b/question/type/ddmarker/amd/build/question.min.js.map differ
index 521fd2f..fe4a6ff 100644 (file)
Binary files a/question/type/ddmarker/amd/build/shapes.min.js and b/question/type/ddmarker/amd/build/shapes.min.js differ
index 3f3b32f..8bc1858 100644 (file)
Binary files a/question/type/ddmarker/amd/build/shapes.min.js.map and b/question/type/ddmarker/amd/build/shapes.min.js.map differ
index c2951e8..cb8b617 100644 (file)
@@ -51,7 +51,8 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes'], function($, dragDro
         if (this.shape.getCoordinates() === coordinates) {
             return;
         }
-        if (!this.shape.parse(coordinates)) {
+        // We don't need to scale the shape for editing form.
+        if (!this.shape.parse(coordinates, 1)) {
             // Invalid coordinates. Don't update the preview.
             return;
         }
@@ -70,6 +71,8 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes'], function($, dragDro
             // Simple update.
             this.updateSvgEl();
         }
+        // Update the rounded coordinates if needed.
+        this.setCoordinatesInForm();
     };
 
     /**
index ae812d8..7dfa8ff 100644 (file)
@@ -30,56 +30,44 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
      * Object to handle one drag-drop markers question.
      *
      * @param {String} containerId id of the outer div for this question.
-     * @param {String} bgImgUrl the URL of the background image.
      * @param {boolean} readOnly whether the question is being displayed read-only.
      * @param {Object[]} visibleDropZones the geometry of any drop-zones to show.
      *      Objects have fields shape, coords and markertext.
      * @constructor
      */
-    function DragDropMarkersQuestion(containerId, bgImgUrl, readOnly, visibleDropZones) {
+    function DragDropMarkersQuestion(containerId, readOnly, visibleDropZones) {
+        var thisQ = this;
         this.containerId = containerId;
         this.visibleDropZones = visibleDropZones;
+        this.shapes = [];
+        this.shapeSVGs = [];
+        this.isPrinting = false;
         if (readOnly) {
             this.getRoot().addClass('qtype_ddmarker-readonly');
         }
-        this.loadImage(bgImgUrl);
+        thisQ.cloneDrags();
+        thisQ.repositionDrags();
+        thisQ.drawDropzones();
     }
 
-    /**
-     * Load the background image is loaded, then do the rest of the display.
-     *
-     * @param {String} bgImgUrl the URL of the background image.
-     */
-    DragDropMarkersQuestion.prototype.loadImage = function(bgImgUrl) {
-        var thisQ = this;
-        this.getRoot().find('.dropbackground')
-            .one('load', function() {
-                if (thisQ.visibleDropZones.length > 0) {
-                    thisQ.drawDropzones();
-                }
-                thisQ.repositionDrags();
-            })
-            .attr('src', bgImgUrl)
-            .css({'border': '1px solid #000', 'max-width': 'none'});
-    };
-
     /**
      * Draws the svg shapes of any drop zones that should be visible for feedback purposes.
      */
     DragDropMarkersQuestion.prototype.drawDropzones = function() {
-        var bgImage = this.getRoot().find('img.dropbackground');
-
-        this.getRoot().find('div.dropzones').html('<svg xmlns="http://www.w3.org/2000/svg" class="dropzones" ' +
-            'width="' + bgImage.outerWidth() + '" ' +
-            'height="' + bgImage.outerHeight() + '"></svg>');
-        var svg = this.getRoot().find('svg.dropzones');
-        svg.css('position', 'absolute');
-
-        var nextColourIndex = 0;
-        for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) {
-            var colourClass = 'color' + nextColourIndex;
-            nextColourIndex = (nextColourIndex + 1) % 8;
-            this.addDropzone(svg, dropZoneNo, colourClass);
+        if (this.visibleDropZones.length > 0) {
+            var bgImage = this.bgImage();
+
+            this.getRoot().find('div.dropzones').html('<svg xmlns="http://www.w3.org/2000/svg" class="dropzones" ' +
+                'width="' + bgImage.outerWidth() + '" ' +
+                'height="' + bgImage.outerHeight() + '"></svg>');
+            var svg = this.getRoot().find('svg.dropzones');
+
+            var nextColourIndex = 0;
+            for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) {
+                var colourClass = 'color' + nextColourIndex;
+                nextColourIndex = (nextColourIndex + 1) % 8;
+                this.addDropzone(svg, dropZoneNo, colourClass);
+            }
         }
     };
 
@@ -93,8 +81,9 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
     DragDropMarkersQuestion.prototype.addDropzone = function(svg, dropZoneNo, colourClass) {
         var dropZone = this.visibleDropZones[dropZoneNo],
             shape = Shapes.make(dropZone.shape, ''),
-            existingmarkertext;
-        if (!shape.parse(dropZone.coords)) {
+            existingmarkertext,
+            bgRatio = this.bgRatio();
+        if (!shape.parse(dropZone.coords, bgRatio)) {
             return;
         }
 
@@ -109,40 +98,26 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
             var classnames = 'markertext markertext' + dropZoneNo;
             this.getRoot().find('div.markertexts').append('<span class="' + classnames + '">' +
                 dropZone.markertext + '</span>');
+            var markerspan = this.getRoot().find('div.ddarea div.markertexts span.markertext' + dropZoneNo);
+            if (markerspan.length) {
+                var handles = shape.getHandlePositions();
+                var positionLeft = handles.moveHandle.x - (markerspan.outerWidth() / 2) - 4;
+                var positionTop = handles.moveHandle.y - (markerspan.outerHeight() / 2);
+                markerspan
+                    .css('left', positionLeft)
+                    .css('top', positionTop);
+                markerspan
+                    .data('originX', markerspan.position().left / bgRatio)
+                    .data('originY', markerspan.position().top / bgRatio);
+                this.handleElementScale(markerspan, 'center');
+            }
         }
 
         var shapeSVG = shape.makeSvg(svg[0]);
         shapeSVG.setAttribute('class', 'dropzone ' + colourClass);
-    };
-
-    /**
-     * Draws the drag items on the page (and drop zones if required).
-     * The idea is to re-draw all the drags and drops whenever there is a change
-     * like a widow resize or an item dropped in place.
-     */
-    DragDropMarkersQuestion.prototype.repositionDropZones = function() {
-        var svg = this.getRoot().find('svg.dropzones');
-        if (svg.length === 0) {
-            return;
-        }
-        var bgPosition = this.convertToWindowXY(new Shapes.Point(-1, 0));
-        svg.offset({'left': bgPosition.x, 'top': bgPosition.y});
 
-        for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) {
-            var markerspan = this.getRoot().find('div.ddarea div.markertexts span.markertext' + dropZoneNo);
-            if (markerspan.length === 0) {
-                continue;
-            }
-            var dropZone = this.visibleDropZones[dropZoneNo],
-                shape = Shapes.make(dropZone.shape, '');
-            if (!shape.parse(dropZone.coords)) {
-                continue;
-            }
-            var handles = shape.getHandlePositions(),
-                textPos = this.convertToWindowXY(handles.moveHandle.offset(
-                    -markerspan.outerWidth() / 2, -markerspan.outerHeight() / 2));
-            markerspan.offset({'left': textPos.x - 4, 'top': textPos.y});
-        }
+        this.shapes[this.shapes.length] = shape;
+        this.shapeSVGs[this.shapeSVGs.length] = shapeSVG;
     };
 
     /**
@@ -154,37 +129,25 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
         var root = this.getRoot(),
             thisQ = this;
 
-        root.find('div.dragitems .dragitem').each(function(key, item) {
+        root.find('div.draghomes .marker').not('.dragplaceholder').each(function(key, item) {
             $(item).addClass('unneeded');
         });
 
         root.find('input.choices').each(function(key, input) {
             var choiceNo = thisQ.getChoiceNoFromElement(input),
-                coords = thisQ.getCoords(input),
-                dragHome = thisQ.dragHome(choiceNo);
-            for (var i = 0; i < coords.length; i++) {
-                var drag = thisQ.dragItem(choiceNo, i);
-                if (!drag.length || drag.hasClass('beingdragged')) {
-                    drag = thisQ.cloneNewDragItem(dragHome, i);
-                } else {
-                    drag.removeClass('unneeded');
+                coords = thisQ.getCoords(input);
+            if (coords.length) {
+                var drag = thisQ.getRoot().find('.draghomes' + ' span.marker' + '.choice' + choiceNo).not('.dragplaceholder');
+                drag.remove();
+                for (var i = 0; i < coords.length; i++) {
+                    var dragInDrop = drag.clone();
+                    dragInDrop.data('pagex', coords[i].x).data('pagey', coords[i].y);
+                    thisQ.sendDragToDrop(dragInDrop, false);
                 }
-                drag.offset({'left': coords[i].x, 'top': coords[i].y});
-            }
-        });
-
-        root.find('div.dragitems .dragitem').each(function(key, itm) {
-            var item = $(itm);
-            if (item.hasClass('unneeded') && !item.hasClass('beingdragged')) {
-                item.remove();
+                thisQ.getDragClone(drag).addClass('active');
+                thisQ.cloneDragIfNeeded(drag);
             }
         });
-
-        this.repositionDropZones();
-
-        var bgImage = this.bgImage(),
-            bgPosition = bgImage.offset();
-        bgImage.data('prev-top', bgPosition.top).data('prev-left', bgPosition.left);
     };
 
     /**
@@ -197,11 +160,7 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
      * @returns {Point[]} coordinates of however many copies of the drag item should be shown.
      */
     DragDropMarkersQuestion.prototype.getCoords = function(inputNode) {
-        var root = this.getRoot(),
-            choiceNo = this.getChoiceNoFromElement(inputNode),
-            noOfDrags = Number(this.getClassnameNumericSuffix(inputNode, 'noofdrags')),
-            dragging = root.find('span.dragitem.beingdragged.choice' + choiceNo).length > 0,
-            coords = [],
+        var coords = [],
             val = $(inputNode).val();
         if (val !== '') {
             var coordsStrings = val.split(';');
@@ -209,10 +168,6 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
                 coords[i] = this.convertToWindowXY(Shapes.Point.parse(coordsStrings[i]));
             }
         }
-        var displayeddrags = coords.length + (dragging ? 1 : 0);
-        if ($(inputNode).hasClass('infinite') || (displayeddrags < noOfDrags)) {
-            coords[coords.length] = this.dragHomeXY(choiceNo);
-        }
         return coords;
     };
 
@@ -252,19 +207,10 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
      */
     DragDropMarkersQuestion.prototype.coordsInBgImg = function(point) {
         var bgImage = this.bgImage();
-        return point.x > 0 && point.x <= bgImage.width() &&
-                point.y > 0 && point.y <= bgImage.height();
-    };
+        var bgPosition = bgImage.offset();
 
-    /**
-     * Returns coordinates for the home position of a choice.
-     *
-     * @param {Number} choiceNo
-     * @returns {Point} coordinates
-     */
-    DragDropMarkersQuestion.prototype.dragHomeXY = function(choiceNo) {
-        var dragItemHome = this.dragHome(choiceNo);
-        return new Shapes.Point(dragItemHome.offset().left, dragItemHome.offset().top);
+        return point.x >= bgPosition.left && point.x < bgPosition.left + bgImage.width()
+            && point.y >= bgPosition.top && point.y < bgPosition.top + bgImage.height();
     };
 
     /**
@@ -283,52 +229,28 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
         return this.getRoot().find('img.dropbackground');
     };
 
-    /**
-     * Return the DOM node for this choice's home position.
-     * @param {Number} choiceNo
-     * @returns {jQuery} containing the home.
-     */
-    DragDropMarkersQuestion.prototype.dragHome = function(choiceNo) {
-        return this.getRoot().find('div.dragitems span.draghome.choice' + choiceNo);
-    };
-
-    /**
-     * Return the DOM node for a particular instance of a particular choice.
-     * @param {Number} choiceNo
-     * @param {Number} itemNo
-     * @returns {jQuery} containing the item.
-     */
-    DragDropMarkersQuestion.prototype.dragItem = function(choiceNo, itemNo) {
-        return this.getRoot().find('div.dragitems span.dragitem.choice' + choiceNo + '.item' + itemNo);
-    };
-
-    /**
-     * Create a draggable copy of the drag item.
-     *
-     * @param {jQuery} dragHome to clone
-     * @param {Number} itemNo new item number
-     * @return {jQuery} drag
-     */
-    DragDropMarkersQuestion.prototype.cloneNewDragItem = function(dragHome, itemNo) {
-        var drag = dragHome.clone(true);
-        drag.removeClass('draghome').addClass('dragitem').addClass('item' + itemNo);
-        dragHome.after(drag);
-        drag.attr('tabIndex', 0);
-        return drag;
-    };
-
     DragDropMarkersQuestion.prototype.handleDragStart = function(e) {
         var thisQ = this,
-            dragged = $(e.target).closest('.dragitem');
+            dragged = $(e.target).closest('.marker');
 
         var info = dragDrop.prepare(e);
         if (!info.start) {
             return;
         }
 
-        dragged.addClass('beingdragged');
+        dragged.addClass('beingdragged').css('transform', '');
+
+        var placed = !dragged.hasClass('unneeded');
+        if (!placed) {
+            var hiddenDrag = thisQ.getDragClone(dragged);
+            if (hiddenDrag.length) {
+                hiddenDrag.addClass('active');
+                dragged.offset(hiddenDrag.offset());
+            }
+        }
+
         dragDrop.start(e, dragged, function() {
-            void (1); // Nothing to do, but we need a function.
+            void (1);
         }, function(x, y, dragged) {
             thisQ.dragEnd(dragged);
         });
@@ -339,52 +261,56 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
      * @param {jQuery} dragged the marker that was dragged.
      */
     DragDropMarkersQuestion.prototype.dragEnd = function(dragged) {
-        dragged.removeClass('beingdragged');
-        var choiceNo = this.getChoiceNoFromElement(dragged);
-        this.saveCoordsForChoice(choiceNo, dragged);
-        this.repositionDrags();
+        var placed = false,
+            choiceNo = this.getChoiceNoFromElement(dragged),
+            bgRatio = this.bgRatio(),
+            dragXY;
+
+        dragged.data('pagex', dragged.offset().left).data('pagey', dragged.offset().top);
+        dragXY = new Shapes.Point(dragged.data('pagex'), dragged.data('pagey'));
+        if (this.coordsInBgImg(dragXY)) {
+            this.sendDragToDrop(dragged, true);
+            placed = true;
+
+            // It seems that the dragdrop sometimes leaves the drag
+            // one pixel out of position. Put it in exactly the right place.
+            var bgImgXY = this.convertToBgImgXY(dragXY);
+            bgImgXY = new Shapes.Point(bgImgXY.x / bgRatio, bgImgXY.y / bgRatio);
+            dragged.data('originX', bgImgXY.x).data('originY', bgImgXY.y);
+        }
+
+        if (!placed) {
+            this.sendDragHome(dragged);
+            this.removeDragIfNeeded(dragged);
+        } else {
+            this.cloneDragIfNeeded(dragged);
+        }
+
+        this.saveCoordsForChoice(choiceNo);
     };
 
     /**
      * Save the coordinates for a dropped item in the form field.
      * @param {Number} choiceNo which copy of the choice this was.
-     * @param {jQuery} dropped the choice that was dropped here.
      */
-    DragDropMarkersQuestion.prototype.saveCoordsForChoice = function(choiceNo, dropped) {
+    DragDropMarkersQuestion.prototype.saveCoordsForChoice = function(choiceNo) {
         var coords = [],
-            numItems = this.getRoot().find('span.dragitem.choice' + choiceNo).length,
-            bgImgXY,
-            addme = true;
-
-        // Re-build the coords array based on data in the ddform inputs.
-        // While long winded and unnecessary if there is only one drop item
-        // for a choice, it does account for moving any one of several drop items
-        // within a choice that have already been placed.
-        for (var i = 0; i <= numItems; i++) {
-            var drag = this.dragItem(choiceNo, i);
-            if (drag.length === 0) {
-                continue;
-            }
-
-            if (!drag.hasClass('beingdragged')) {
-                bgImgXY = this.convertToBgImgXY(new Shapes.Point(drag.offset().left, drag.offset().top));
-                if (this.coordsInBgImg(bgImgXY)) {
-                    coords[coords.length] = bgImgXY;
+            items = this.getRoot().find('div.droparea span.marker.choice' + choiceNo),
+            thiQ = this,
+            bgRatio = this.bgRatio();
+
+        if (items.length) {
+            items.each(function() {
+                var drag = $(this);
+                if (!drag.hasClass('beingdragged')) {
+                    var dragXY = new Shapes.Point(drag.data('pagex'), drag.data('pagey'));
+                    if (thiQ.coordsInBgImg(dragXY)) {
+                        var bgImgXY = thiQ.convertToBgImgXY(dragXY);
+                        bgImgXY = new Shapes.Point(bgImgXY.x / bgRatio, bgImgXY.y / bgRatio);
+                        coords[coords.length] = bgImgXY;
+                    }
                 }
-            }
-
-            if (dropped && dropped.length !== 0 && (dropped[0].innerText === drag[0].innerText)) {
-                addme = false;
-            }
-        }
-
-        // If dropped has been passed it is because a new item has been dropped onto the background image
-        // so add its coordinates to the array.
-        if (addme) {
-            bgImgXY = this.convertToBgImgXY(new Shapes.Point(dropped.offset().left, dropped.offset().top));
-            if (this.coordsInBgImg(bgImgXY)) {
-                coords[coords.length] = bgImgXY;
-            }
+            });
         }
 
         this.getRoot().find('input.choice' + choiceNo).val(coords.join(';'));
@@ -395,7 +321,7 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
      * @param {KeyboardEvent} e
      */
     DragDropMarkersQuestion.prototype.handleKeyPress = function(e) {
-        var drag = $(e.target).closest('.dragitem'),
+        var drag = $(e.target).closest('.marker'),
             point = new Shapes.Point(drag.offset().left, drag.offset().top),
             choiceNo = this.getChoiceNoFromElement(drag);
 
@@ -427,12 +353,28 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
 
         if (point !== null) {
             point = this.constrainToBgImg(point);
+            drag.offset({'left': point.x, 'top': point.y});
+            drag.data('pagex', drag.offset().left).data('pagey', drag.offset().top);
+            var dragXY = this.convertToBgImgXY(new Shapes.Point(drag.data('pagex'), drag.data('pagey')));
+            drag.data('originX', dragXY.x / this.bgRatio()).data('originY', dragXY.y / this.bgRatio());
+            if (this.coordsInBgImg(new Shapes.Point(drag.offset().left, drag.offset().top))) {
+                if (drag.hasClass('unneeded')) {
+                    this.sendDragToDrop(drag, true);
+                    var hiddenDrag = this.getDragClone(drag);
+                    if (hiddenDrag.length) {
+                        hiddenDrag.addClass('active');
+                    }
+                    this.cloneDragIfNeeded(drag);
+                }
+            }
         } else {
-            point = this.dragHomeXY(choiceNo);
+            drag.css('left', '').css('top', '');
+            drag.data('pagex', drag.offset().left).data('pagey', drag.offset().top);
+            this.sendDragHome(drag);
+            this.removeDragIfNeeded(drag);
         }
-        drag.offset({'left': point.x, 'top': point.y});
-        this.saveCoordsForChoice(choiceNo, drag);
-        this.repositionDrags();
+        drag.focus();
+        this.saveCoordsForChoice(choiceNo);
     };
 
     /**
@@ -488,27 +430,204 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
      * Handle when the window is resized.
      */
     DragDropMarkersQuestion.prototype.handleResize = function() {
-        this.repositionDrags();
+        var thisQ = this,
+            bgRatio = this.bgRatio();
+        if (this.isPrinting) {
+            bgRatio = 1;
+        }
+
+        this.getRoot().find('div.droparea .marker').not('.beingdragged').each(function(key, drag) {
+            $(drag)
+                .css('left', parseFloat($(drag).data('originX')) * parseFloat(bgRatio))
+                .css('top', parseFloat($(drag).data('originY')) * parseFloat(bgRatio));
+            thisQ.handleElementScale(drag, 'left top');
+        });
+
+        this.getRoot().find('div.droparea svg.dropzones')
+            .width(this.bgImage().width())
+            .height(this.bgImage().height());
+
+        for (var dropZoneNo = 0; dropZoneNo < this.visibleDropZones.length; dropZoneNo++) {
+            var dropZone = thisQ.visibleDropZones[dropZoneNo];
+            var originCoords = dropZone.coords;
+            var shape = thisQ.shapes[dropZoneNo];
+            var shapeSVG = thisQ.shapeSVGs[dropZoneNo];
+            shape.parse(originCoords, bgRatio);
+            shape.updateSvg(shapeSVG);
+
+            var handles = shape.getHandlePositions();
+            var markerSpan = this.getRoot().find('div.ddarea div.markertexts span.markertext' + dropZoneNo);
+            markerSpan
+                .css('left', handles.moveHandle.x - (markerSpan.outerWidth() / 2) - 4)
+                .css('top', handles.moveHandle.y - (markerSpan.outerHeight() / 2));
+            thisQ.handleElementScale(markerSpan, 'center');
+        }
     };
 
     /**
-     * Check to see if the background image has moved. If so, refresh the layout.
+     * Clone the drag.
      */
-    DragDropMarkersQuestion.prototype.fixLayoutIfBackgroundMoved = function() {
-        var bgImage = this.bgImage(),
-            bgPosition = bgImage.offset(),
-            prevTop = bgImage.data('prev-top'),
-            prevLeft = bgImage.data('prev-left');
-        if (prevLeft === undefined || prevTop === undefined) {
-            // Question is not set up yet. Nothing to do.
-            return;
+    DragDropMarkersQuestion.prototype.cloneDrags = function() {
+        var thisQ = this;
+        this.getRoot().find('div.draghomes span.marker').each(function(index, draghome) {
+            var drag = $(draghome);
+            var placeHolder = drag.clone();
+            placeHolder.removeClass();
+            placeHolder.addClass('marker choice' +
+                thisQ.getChoiceNoFromElement(drag) + ' dragno' + thisQ.getDragNo(drag) + ' dragplaceholder');
+            drag.before(placeHolder);
+        });
+    };
+
+    /**
+     * Get the drag number of a drag.
+     *
+     * @param {jQuery} drag the drag.
+     * @returns {Number} the drag number.
+     */
+    DragDropMarkersQuestion.prototype.getDragNo = function(drag) {
+        return this.getClassnameNumericSuffix(drag, 'dragno');
+    };
+
+    /**
+     * Get drag clone for a given drag.
+     *
+     * @param {jQuery} drag the drag.
+     * @returns {jQuery} the drag's clone.
+     */
+    DragDropMarkersQuestion.prototype.getDragClone = function(drag) {
+        return this.getRoot().find('.draghomes' + ' span.marker' +
+            '.choice' + this.getChoiceNoFromElement(drag) + '.dragno' + this.getDragNo(drag) + '.dragplaceholder');
+    };
+
+    /**
+     * Get the drop area element.
+     * @returns {jQuery} droparea element.
+     */
+    DragDropMarkersQuestion.prototype.dropArea = function() {
+        return this.getRoot().find('div.droparea');
+    };
+
+    /**
+     * Animate a drag back to its home.
+     *
+     * @param {jQuery} drag the item being moved.
+     */
+    DragDropMarkersQuestion.prototype.sendDragHome = function(drag) {
+        drag.removeClass('beingdragged')
+            .addClass('unneeded')
+            .css('top', '')
+            .css('left', '')
+            .css('transform', '');
+        var placeHolder = this.getDragClone(drag);
+        placeHolder.after(drag);
+        placeHolder.removeClass('active');
+    };
+
+    /**
+     * Animate a drag item into a given place.
+     *
+     * @param {jQuery} drag the item to place.
+     * @param {boolean} isScaling Scaling or not
+     */
+    DragDropMarkersQuestion.prototype.sendDragToDrop = function(drag, isScaling) {
+        var dropArea = this.dropArea(),
+            bgRatio = this.bgRatio();
+        drag.removeClass('beingdragged').removeClass('unneeded');
+        var dragXY = this.convertToBgImgXY(new Shapes.Point(drag.data('pagex'), drag.data('pagey')));
+        if (isScaling) {
+            drag.data('originX', dragXY.x / bgRatio).data('originY', dragXY.y / bgRatio);
+            drag.css('left', dragXY.x).css('top', dragXY.y);
+        } else {
+            drag.data('originX', dragXY.x).data('originY', dragXY.y);
+            drag.css('left', dragXY.x * bgRatio).css('top', dragXY.y * bgRatio);
         }
-        if (prevTop === bgPosition.top && prevLeft === bgPosition.left) {
-            // Things have not moved.
-            return;
+        dropArea.append(drag);
+        this.handleElementScale(drag, 'left top');
+    };
+
+    /**
+     * Clone the drag at the draghome area if needed.
+     *
+     * @param {jQuery} drag the item to place.
+     */
+    DragDropMarkersQuestion.prototype.cloneDragIfNeeded = function(drag) {
+        var inputNode = this.getInput(drag),
+            noOfDrags = Number(this.getClassnameNumericSuffix(inputNode, 'noofdrags')),
+            displayedDragsInDropArea = this.getRoot().find('div.droparea .marker.choice' +
+                this.getChoiceNoFromElement(drag) + '.dragno' + this.getDragNo(drag)).length,
+            displayedDragsInDragHomes = this.getRoot().find('div.draghomes .marker.choice' +
+                this.getChoiceNoFromElement(drag) + '.dragno' + this.getDragNo(drag)).not('.dragplaceholder').length;
+
+        if (displayedDragsInDropArea < noOfDrags && displayedDragsInDragHomes === 0) {
+            var dragclone = drag.clone();
+            dragclone.addClass('unneeded')
+                .css('top', '')
+                .css('left', '')
+                .css('transform', '');
+            this.getDragClone(drag)
+                .removeClass('active')
+                .after(dragclone);
+        }
+    };
+
+    /**
+     * Remove the clone drag at the draghome area if needed.
+     *
+     * @param {jQuery} drag the item to place.
+     */
+    DragDropMarkersQuestion.prototype.removeDragIfNeeded = function(drag) {
+        var displayeddrags = this.getRoot().find('div.draghomes .marker.choice' +
+            this.getChoiceNoFromElement(drag) + '.dragno' + this.getDragNo(drag)).not('.dragplaceholder').length;
+        if (displayeddrags > 1) {
+            this.getRoot().find('div.draghomes .marker.choice' +
+                this.getChoiceNoFromElement(drag) + '.dragno' + this.getDragNo(drag)).not('.dragplaceholder').first().remove();
         }
-        // We need to reposition things.
-        this.repositionDrags();
+    };
+
+    /**
+     * Get the input belong to drag.
+     *
+     * @param {jQuery} drag the item to place.
+     * @returns {jQuery} input element.
+     */
+    DragDropMarkersQuestion.prototype.getInput = function(drag) {
+        var choiceNo = this.getChoiceNoFromElement(drag);
+        return this.getRoot().find('input.choices.choice' + choiceNo);
+    };
+
+    /**
+     * Return the background ratio.
+     *
+     * @returns {number} Background ratio.
+     */
+    DragDropMarkersQuestion.prototype.bgRatio = function() {
+        var bgImg = this.bgImage();
+        var bgImgNaturalWidth = bgImg.get(0).naturalWidth;
+        var bgImgClientWidth = bgImg.width();
+
+        return bgImgClientWidth / bgImgNaturalWidth;
+    };
+
+    /**
+     * Scale the drag if needed.
+     *
+     * @param {jQuery} element the item to place.
+     * @param {String} type scaling type
+     */
+    DragDropMarkersQuestion.prototype.handleElementScale = function(element, type) {
+        var bgRatio = parseFloat(this.bgRatio());
+        if (this.isPrinting) {
+            bgRatio = 1;
+        }
+        $(element).css({
+            '-webkit-transform': 'scale(' + bgRatio + ')',
+            '-moz-transform': 'scale(' + bgRatio + ')',
+            '-ms-transform': 'scale(' + bgRatio + ')',
+            '-o-transform': 'scale(' + bgRatio + ')',
+            'transform': 'scale(' + bgRatio + ')',
+            'transform-origin': type
+        });
     };
 
     /**
@@ -524,6 +643,16 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
          */
         eventHandlersInitialised: false,
 
+        /**
+         * {boolean} is printing or not.
+         */
+        isPrinting: false,
+
+        /**
+         * {boolean} is keyboard navigation.
+         */
+        isKeyboardNavigation: false,
+
         /**
          * {Object} all the questions on this page, indexed by containerId (id on the .que div).
          */
@@ -533,13 +662,12 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
          * Initialise one question.
          *
          * @param {String} containerId the id of the div.que that contains this question.
-         * @param {String} bgImgUrl URL fo the background image.
          * @param {boolean} readOnly whether the question is read-only.
          * @param {Object[]} visibleDropZones data on any drop zones to draw as part of the feedback.
          */
-        init: function(containerId, bgImgUrl, readOnly, visibleDropZones) {
+        init: function(containerId, readOnly, visibleDropZones) {
             questionManager.questions[containerId] =
-                new DragDropMarkersQuestion(containerId, bgImgUrl, readOnly, visibleDropZones);
+                new DragDropMarkersQuestion(containerId, readOnly, visibleDropZones);
             if (!questionManager.eventHandlersInitialised) {
                 questionManager.setupEventHandlers();
                 questionManager.eventHandlersInitialised = true;
@@ -550,14 +678,45 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
          * Set up the event handlers that make this question type work. (Done once per page.)
          */
         setupEventHandlers: function() {
-            $('body').on('mousedown touchstart',
-                '.que.ddmarker:not(.qtype_ddmarker-readonly) div.dragitems .dragitem',
-                questionManager.handleDragStart)
+            $('body')
+                .on('mousedown touchstart',
+                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', questionManager.handleDragStart)
+                .on('mousedown touchstart',
+                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', questionManager.handleDragStart)
+                .on('keydown keypress',
+                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', questionManager.handleKeyPress)
                 .on('keydown keypress',
-                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.dragitems .dragitem',
-                    questionManager.handleKeyPress);
-            $(window).on('resize', questionManager.handleWindowResize);
-            setTimeout(questionManager.fixLayoutIfThingsMoved, 100);
+                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', questionManager.handleKeyPress)
+                .on('focusin',
+                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', function(e) {
+                        questionManager.handleKeyboardFocus(e, true);
+                    })
+                .on('focusin',
+                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', function(e) {
+                        questionManager.handleKeyboardFocus(e, true);
+                    })
+                .on('focusout',
+                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.draghomes .marker', function(e) {
+                        questionManager.handleKeyboardFocus(e, false);
+                    })
+                .on('focusout',
+                    '.que.ddmarker:not(.qtype_ddmarker-readonly) div.droparea .marker', function(e) {
+                        questionManager.handleKeyboardFocus(e, false);
+                    });
+            $(window).on('resize', function() {
+                questionManager.handleWindowResize(false);
+            });
+            window.addEventListener('beforeprint', function() {
+                questionManager.isPrinting = true;
+                questionManager.handleWindowResize(questionManager.isPrinting);
+            });
+            window.addEventListener('afterprint', function() {
+                questionManager.isPrinting = false;
+                questionManager.handleWindowResize(questionManager.isPrinting);
+            });
+            setTimeout(function() {
+                questionManager.fixLayoutIfThingsMoved();
+            }, 100);
         },
 
         /**
@@ -585,31 +744,41 @@ define(['jquery', 'core/dragdrop', 'qtype_ddmarker/shapes', 'core/key_codes'], f
 
         /**
          * Handle when the window is resized.
+         * @param {boolean} isPrinting
          */
-        handleWindowResize: function() {
+        handleWindowResize: function(isPrinting) {
             for (var containerId in questionManager.questions) {
                 if (questionManager.questions.hasOwnProperty(containerId)) {
+                    questionManager.questions[containerId].isPrinting = isPrinting;
                     questionManager.questions[containerId].handleResize();
                 }
             }
         },
 
+        /**
+         * Handle focus lost events on markers.
+         * @param {Event} e
+         * @param {boolean} isNavigating
+         */
+        handleKeyboardFocus: function(e, isNavigating) {
+            questionManager.isKeyboardNavigation = isNavigating;
+        },
+
         /**
          * Sometimes, despite our best efforts, things change in a way that cannot
          * be specifically caught (e.g. dock expanding or collapsing in Boost).
          * Therefore, we need to periodically check everything is in the right position.
          */
         fixLayoutIfThingsMoved: function() {
-            for (var containerId in questionManager.questions) {
-                if (questionManager.questions.hasOwnProperty(containerId)) {
-                    questionManager.questions[containerId].fixLayoutIfBackgroundMoved();
-                }
+            if (!questionManager.isKeyboardNavigation) {
+                this.handleWindowResize(questionManager.isPrinting);
             }
-
             // We use setTimeout after finishing work, rather than setInterval,
             // in case positioning things is slow. We want 100 ms gap
             // between executions, not what setInterval does.
-            setTimeout(questionManager.fixLayoutIfThingsMoved, 100);
+            setTimeout(function() {
+                questionManager.fixLayoutIfThingsMoved(questionManager.isPrinting);
+            }, 100);
         },
 
         /**
index dec8c09..5fd765e 100644 (file)
@@ -126,10 +126,11 @@ define(function() {
      * Update the shape from the string representation.
      *
      * @param {String} coordinates in the form returned by getCoordinates.
+     * @param {number} ratio Ratio to scale.
      * @return {boolean} true if the string could be parsed and the shape updated, else false.
      */
-    Shape.prototype.parse = function(coordinates) {
-        void (coordinates);
+    Shape.prototype.parse = function(coordinates, ratio) {
+        void (coordinates, ratio);
         throw new Error('Not implemented.');
     };
 
@@ -264,14 +265,16 @@ define(function() {
         svgEl.childNodes[1].textContent = this.label;
     };
 
-    Circle.prototype.parse = function(coordinates) {
-        if (!coordinates.match(/^\d+,\d+;\d+$/)) {
+    Circle.prototype.parse = function(coordinates, ratio) {
+        if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?$/)) {
             return false;
         }
 
         var bits = coordinates.split(';');
         this.centre = Point.parse(bits[0]);
-        this.radius = Math.round(bits[1]);
+        this.centre.x = this.centre.x * parseFloat(ratio);
+        this.centre.y = this.centre.y * parseFloat(ratio);
+        this.radius = Math.round(bits[1]) * parseFloat(ratio);
         return true;
     };
 
@@ -384,16 +387,18 @@ define(function() {
         svgEl.childNodes[1].textContent = this.label;
     };
 
-    Rectangle.prototype.parse = function(coordinates) {
-        if (!coordinates.match(/^\d+,\d+;\d+,\d+$/)) {
+    Rectangle.prototype.parse = function(coordinates, ratio) {
+        if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?;\d+(\.\d+)?,\d+(\.\d+)?$/)) {
             return false;
         }
 
         var bits = coordinates.split(';');
         this.centre = Point.parse(bits[0]);
+        this.centre.x = this.centre.x * parseFloat(ratio);
+        this.centre.y = this.centre.y * parseFloat(ratio);
         var size = Point.parse(bits[1]);
-        this.width = size.x;
-        this.height = size.y;
+        this.width = size.x * parseFloat(ratio);
+        this.height = size.y * parseFloat(ratio);
         return true;
     };
 
@@ -479,6 +484,7 @@ define(function() {
         Shape.call(this, label, 0, 0);
         this.points = points ? points.slice() : [new Point(10, 10), new Point(40, 10), new Point(10, 40)];
         this.normalizeShape();
+        this.ratio = 1;
     }
     Polygon.prototype = new Shape();
 
@@ -502,13 +508,14 @@ define(function() {
 
     Polygon.prototype.updateSvg = function(svgEl) {
         svgEl.childNodes[0].setAttribute('points', this.getCoordinates().replace(/[,;]/g, ' '));
+        svgEl.childNodes[0].setAttribute('transform', 'scale(' + parseFloat(this.ratio) + ')');
         svgEl.childNodes[1].setAttribute('x', this.centre.x);
         svgEl.childNodes[1].setAttribute('y', this.centre.y + 15);
         svgEl.childNodes[1].textContent = this.label;
     };
 
-    Polygon.prototype.parse = function(coordinates) {
-        if (!coordinates.match(/^\d+,\d+(?:;\d+,\d+)*$/)) {
+    Polygon.prototype.parse = function(coordinates, ratio) {
+        if (!coordinates.match(/^\d+(\.\d+)?,\d+(\.\d+)?(?:;\d+(\.\d+)?,\d+(\.\d+)?)*$/)) {
             return false;
         }
 
@@ -521,6 +528,7 @@ define(function() {
         this.points = points;
         this.centre.x = 0;
         this.centre.y = 0;
+        this.ratio = ratio;
         this.normalizeShape();
 
         return true;
@@ -636,6 +644,9 @@ define(function() {
             editHandles.push(this.points[i].offset(this.centre.x, this.centre.y));
         }
 
+        this.centre.x = this.centre.x * parseFloat(this.ratio);
+        this.centre.y = this.centre.y * parseFloat(this.ratio);
+
         return {
             moveHandle: this.centre,
             editHandles: editHandles
index 3bf692d..180faa5 100644 (file)
@@ -42,88 +42,86 @@ class qtype_ddmarker_renderer extends qtype_ddtoimage_renderer_base {
 
         $question = $qa->get_question();
         $response = $qa->get_last_qt_data();
+        $componentname = $question->qtype->plugin_name();
 
         $questiontext = $question->format_questiontext($qa);
 
-        $output = html_writer::tag('div', $questiontext, array('class' => 'qtext'));
+        $dropareaclass = 'droparea';
+        $draghomesclass = 'draghomes';
+        if ($options->readonly) {
+            $dropareaclass .= ' readonly';
+            $draghomesclass .= ' readonly';
+        }
 
-        $bgimage = self::get_url_for_image($qa, 'bgimage');
+        $output = html_writer::div($questiontext, 'qtext');
 
-        $img = html_writer::empty_tag('img', array(
-                'class' => 'dropbackground',
-                'alt' => get_string('dropbackground', 'qtype_ddmarker')));
+        $output .= html_writer::start_div('ddarea');
+        $output .= html_writer::start_div($dropareaclass);
+        $output .= html_writer::img(self::get_url_for_image($qa, 'bgimage'), get_string('dropbackground', 'qtype_ddmarker'),
+                ['class' => 'dropbackground img-responsive img-fluid']);
 
-        $droparea = html_writer::tag('div', $img, array('class' => 'droparea'));
+        $output .= html_writer::div('', 'dropzones');
+        $output .= html_writer::div('', 'markertexts');
+
+        $output .= html_writer::end_div();
+        $output .= html_writer::start_div($draghomesclass);
 
-        $draghomes = '';
         $orderedgroup = $question->get_ordered_choices(1);
-        $componentname = $question->qtype->plugin_name();
         $hiddenfields = '';
         foreach ($orderedgroup as $choiceno => $drag) {
-            $classes = array('draghome',
-                             "choice{$choiceno}");
+            $classes = ['marker', 'choice' . $choiceno];
+            $attr = [];
             if ($drag->infinite) {
                 $classes[] = 'infinite';
             } else {
-                $classes[] = 'dragno'.$drag->noofdrags;
+                $classes[] = 'dragno' . $drag->noofdrags;
             }
-            $targeticonhtml =
-                    $this->output->image_icon('crosshairs', '', $componentname, array('class' => 'target'));
-
-            $markertextattrs = array('class' => 'markertext');
-            $markertext = html_writer::tag('span', $drag->text, $markertextattrs);
-            $draghomesattrs = array('class' => join(' ', $classes));
-            $draghomes .= html_writer::tag('span', $targeticonhtml . $markertext, $draghomesattrs);
+            if (!$options->readonly) {
+                $attr['tabindex'] = 0;
+            }
+            $dragoutput = html_writer::start_span(join(' ', $classes), $attr);
+            $targeticonhtml = $this->output->image_icon('crosshairs', '', $componentname, ['class' => 'target']);
+            $markertext = html_writer::span($drag->text, 'markertext');
+            $dragoutput .= $targeticonhtml . $markertext;
+            $dragoutput .= html_writer::end_span();
+            $output .= $dragoutput;
             $hiddenfields .= $this->hidden_field_choice($qa, $choiceno, $drag->infinite, $drag->noofdrags);
         }
 
-        $dragitemsclass = 'dragitems';
-        if ($options->readonly) {
-            $dragitemsclass .= ' readonly';
-        }
-
-        $dragitems = html_writer::tag('div', $draghomes, array('class' => $dragitemsclass));
-        $dropzones = html_writer::tag('div', '', array('class' => 'dropzones'));
-        $texts = html_writer::tag('div', '', array('class' => 'markertexts'));
-        $output .= html_writer::tag('div',
-                                    $droparea.$dragitems.$dropzones . $texts,
-                                    array('class' => 'ddarea'));
+        $output .= html_writer::end_div();
+        $output .= html_writer::end_div();
 
         if ($question->showmisplaced && $qa->get_state()->is_finished()) {
             $visibledropzones = $question->get_drop_zones_without_hit($response);
         } else {
-            $visibledropzones = array();
+            $visibledropzones = [];
         }
 
-        $this->page->requires->js_call_amd('qtype_ddmarker/question', 'init',
-                [$qa->get_outer_question_div_unique_id(), $bgimage, $options->readonly, $visibledropzones]);
-
         if ($qa->get_state() == question_state::$invalid) {
-            $output .= html_writer::nonempty_tag('div',
-                                        $question->get_validation_error($qa->get_last_qt_data()),
-                                        array('class' => 'validationerror'));
+            $output .= html_writer::div($question->get_validation_error($qa->get_last_qt_data()), 'validationerror');
         }
 
         if ($question->showmisplaced && $qa->get_state()->is_finished()) {
             $wrongparts = $question->get_drop_zones_without_hit($response);
             if (count($wrongparts) !== 0) {
-                $wrongpartsstringspans = array();
+                $wrongpartsstringspans = [];
                 foreach ($wrongparts as $wrongpart) {
-                    $wrongpartsstringspans[] = html_writer::nonempty_tag('span',
-                                    $wrongpart->markertext, array('class' => 'wrongpart'));
+                    $wrongpartsstringspans[] = html_writer::span($wrongpart->markertext, 'wrongpart');
                 }
                 $wrongpartsstring = join(', ', $wrongpartsstringspans);
-                $output .= html_writer::nonempty_tag('span',
-                                                    get_string('followingarewrongandhighlighted',
-                                                                'qtype_ddmarker',
-                                                                $wrongpartsstring),
-                                                    array('class' => 'wrongparts'));
+                $output .= html_writer::span(get_string('followingarewrongandhighlighted', 'qtype_ddmarker', $wrongpartsstring),
+                        'wrongparts');
             }
         }
 
-        $output .= html_writer::tag('div', $hiddenfields, array('class' => 'ddform'));
+        $output .= html_writer::div($hiddenfields, 'ddform');
+
+        $this->page->requires->js_call_amd('qtype_ddmarker/question', 'init',
+                [$qa->get_outer_question_div_unique_id(), $options->readonly, $visibledropzones]);
+
         return $output;
     }
+
     protected function hidden_field_choice(question_attempt $qa, $choiceno, $infinite, $noofdrags, $value = null) {
         $varname = 'c'.$choiceno;
         $classes = array('choices', 'choice'.$choiceno, 'noofdrags'.$noofdrags);
index b826e1d..65bcb07 100644 (file)
@@ -3,29 +3,46 @@
     display: block;
 }
 
-.que.ddmarker .draghome img,
-.que.ddmarker .draghome span {
-    visibility: hidden;
+.que.ddmarker .droparea {
+    display: inline-block;
+    position: relative;
+}
+
+.que.ddmarker .droparea .dropzones,
+.que.ddmarker .droparea .markertexts {
+    position: absolute;
+    top: 0;
+    left: 0;
 }
 
-.que.ddmarker .dragitems .dragitem {
+.que.ddmarker .draghomes .marker,
+.que.ddmarker .droparea .marker {
+    vertical-align: top;
     cursor: move;
+}
+
+.que.ddmarker .draghomes.readonly .marker,
+.que.ddmarker .droparea.readonly .marker {
+    cursor: auto;
+}
+
+.que.ddmarker .droparea .marker {
     position: absolute;
-    z-index: 2;
 }
 
-.que.ddmarker .dragitems .draghome {
+.que.ddmarker .draghomes .marker {
+    position: relative;
     display: inline-block;
     margin: 10px;
-    vertical-align: top;
 }
 
-.que.ddmarker .dragitems {
-    margin-top: 10px;
+.que.ddmarker .draghomes .marker.dragplaceholder {
+    display: none;
 }
 
-.que.ddmarker .dragitems.readonly .dragitem {
-    cursor: auto;
+.que.ddmarker .draghomes .marker.dragplaceholder.active {
+    visibility: hidden;
+    display: inline-block;
 }
 
 .que.ddmarker div.ddarea,
@@ -41,9 +58,16 @@ form.mform fieldset#id_previewareaheader div.ddarea .markertexts {
 form.mform fieldset#id_previewareaheader .dropbackground {
     margin: 0 auto;
     border: 1px solid black;
+}
+
+form.mform fieldset#id_previewareaheader .dropbackground {
     max-width: none;
 }
 
+.que.ddmarker .dropbackground.img-responsive.img-fluid {
+    width: 100%;
+}
+
 .que.ddmarker div.dragitems div.draghome,
 .que.ddmarker div.dragitems div.dragitem,
 form.mform fieldset#id_previewareaheader div.draghome,
@@ -51,7 +75,8 @@ form.mform fieldset#id_previewareaheader div.drag {
     font: 13px/1.231 arial, helvetica, clean, sans-serif;
 }
 
-.que.ddmarker div.dragitems span.markertext,
+.que.ddmarker .droparea .marker span.markertext,
+.que.ddmarker .draghomes .marker span.markertext,
 .que.ddmarker div.markertexts span.markertext,
 form.mform fieldset#id_previewareaheader div.markertexts span.markertext {
     margin: 0 5px;
@@ -66,11 +91,17 @@ form.mform fieldset#id_previewareaheader div.markertexts span.markertext {
     opacity: 0.6;
 }
 
+.que.ddmarker .droparea .marker span.markertext,
+.que.ddmarker .draghomes .marker span.markertext {
+    white-space: nowrap;
+}
+
 .que.ddmarker div.markertexts span.markertext {
     z-index: 2;
     background-color: yellow;
     border: 2px solid khaki;
     position: absolute;
+    white-space: nowrap;
 }
 
 .que.ddmarker span.wrongpart {
@@ -84,7 +115,8 @@ form.mform fieldset#id_previewareaheader div.markertexts span.markertext {
     display: inline-block;
 }
 
-.que.ddmarker div.dragitems img.target {
+.que.ddmarker .droparea .marker img.target,
+.que.ddmarker .draghomes .marker img.target {
     position: absolute;
     left: -7px; /* This must be half the size of the target image, minus 0.5. */
     top: -7px;  /* In other words, this works for a 15x15 cross-hair. */
@@ -94,7 +126,11 @@ form.mform fieldset#id_previewareaheader div.markertexts span.markertext {
     display: none;
 }
 
-.que.ddmarker .dragitem.beingdragged span.markertext {
+.que.ddmarker .marker.beingdragged {
+    position: absolute;
+}
+
+.que.ddmarker .marker.beingdragged span.markertext {
     z-index: 3;
     box-shadow: 3px 3px 4px #000;
 }
@@ -196,3 +232,7 @@ body#page-question-type-ddmarker div[id^=fitem_id_][id*=hintclearwrong_] {
 body#page-question-type-ddmarker #fitem_id_penalty {
     margin-bottom: 2em;
 }
+
+body#page-question-type-ddmarker .ddarea.que.ddmarker {
+    overflow-y: scroll;
+}
index 364f48b..04cabba 100644 (file)
@@ -37,22 +37,20 @@ class behat_qtype_ddmarker extends behat_base {
 
     /**
      * Get the xpath for a given drag item.
-     * @param string $dragitem the text of the item to drag.
+     *
+     * @param string $marker the text of the item to drag.
+     * @param bool $iskeyboard is using keyboard or not.
      * @return string the xpath expression.
      */
-    protected function marker_xpath($marker, $item = 0) {
-        return '//span[contains(@class, " dragitem ") and contains(@class, " item' . $item .
-                '") and span[@class = "markertext" and contains(normalize-space(.), "' .
-                $this->escape($marker) . '")]]';
-    }
-
-    protected function parse_marker_name($marker) {
-        $item = 0;
-        if (preg_match('~,(\d+)$~', $marker, $matches)) {
-            $item = $matches[1];
-            $marker = substr($marker, 0, -1 - strlen($item));
+    protected function marker_xpath($marker, $iskeyboard = false) {
+        if ($iskeyboard) {
+            return '//span[contains(@class, "marker") and not(contains(@class, "dragplaceholder")) ' .
+                    'and span[@class = "markertext" and contains(normalize-space(.), "' .
+                    $this->escape($marker) . '")]]';
         }
-        return array($marker, $item);
+        return '//span[contains(@class, "marker") and contains(@class, "unneeded") ' .
+                'and not(contains(@class, "dragplaceholder")) and span[@class = "markertext" and contains(normalize-space(.), "' .
+                $this->escape($marker) . '")]]';
     }
 
     /**
@@ -64,7 +62,6 @@ class behat_qtype_ddmarker extends behat_base {
      * @Given /^I drag "(?P<marker>[^"]*)" to "(?P<coordinates>\d+,\d+)" in the drag and drop markers question$/
      */
     public function i_drag_to_in_the_drag_and_drop_markers_question($marker, $coordinates) {
-        list($marker, $item) = $this->parse_marker_name($marker);
         list($x, $y) = explode(',', $coordinates);
 
         // This is a bit nasty, but Behat (indeed Selenium) will only drag on
@@ -81,7 +78,6 @@ class behat_qtype_ddmarker extends behat_base {
                     var target = document.createElement('div');
                     target.setAttribute('id', 'target-{$x}-{$y}');
                     var container = document.querySelector('.droparea');
-                    container.style.setProperty('position', 'relative');
                     container.insertBefore(target, image);
                     var xadjusted = {$x} + (container.offsetWidth - image.offsetWidth) / 2;
                     var yadjusted = {$y} + (container.offsetHeight - image.offsetHeight) / 2;
@@ -93,7 +89,7 @@ class behat_qtype_ddmarker extends behat_base {
                 }())");
 
         $generalcontext = behat_context_helper::get('behat_general');
-        $generalcontext->i_drag_and_i_drop_it_in($this->marker_xpath($marker, $item),
+        $generalcontext->i_drag_and_i_drop_it_in($this->marker_xpath($marker),
                 'xpath_element', "#target-{$x}-{$y}", 'css_element');
     }
 
@@ -113,8 +109,7 @@ class behat_qtype_ddmarker extends behat_base {
             'left'  => chr(37),
             'right' => chr(39),
         );
-        list($marker, $item) = $this->parse_marker_name($marker);
-        $node = $this->get_selected_node('xpath_element', $this->marker_xpath($marker, $item));
+        $node = $this->get_selected_node('xpath_element', $this->marker_xpath($marker, true));
         $this->ensure_node_is_visible($node);
         for ($i = 0; $i < $repeats; $i++) {
             $node->keyDown($keycodes[$direction]);
index b95996e..560c688 100644 (file)
@@ -33,10 +33,10 @@ Feature: Preview a drag-drop marker question
     And I change window size to "large"
     And I wait "2" seconds
     # Odd, but the <br>s go to nothing, not a space.
-    And I drag "OU" to "342,230" in the drag and drop markers question
-    And I drag "Railway station" to "254,197" in the drag and drop markers question
-    And I drag "Railway station,1" to "326,319" in the drag and drop markers question
-    And I drag "Railway station,2" to "203,101" in the drag and drop markers question
+    And I drag "OU" to "345,230" in the drag and drop markers question
+    And I drag "Railway station" to "262,197" in the drag and drop markers question
+    And I drag "Railway station" to "334,319" in the drag and drop markers question
+    And I drag "Railway station" to "211,101" in the drag and drop markers question
     And I press "Submit and finish"
     Then the state of "Please place the markers on the map of Milton Keynes" question is shown as "Correct"
     And I should see "Mark 1.00 out of 1.00"
index afaf4a6..527c71d 100644 (file)
@@ -46,7 +46,7 @@ class qtype_ddmarker_walkthrough_test extends qbehaviour_walkthrough_test_base {
      * @return question_contains_tag_with_attributes the expectation.
      */
     protected function get_contains_draggable_marker_home_expectation($choice, $infinite) {
-        $class = 'draghome choice'.$choice;
+        $class = 'marker choice'.$choice;
         if ($infinite) {
             $class .= ' infinite';
         }
index 5423604..18eb048 100644 (file)
@@ -53,7 +53,7 @@ Feature: Manager is able to delete tags
     And I follow "Default collection"
     And I click on "Delete" "link" in the "Turtle" "table_row"
     Then I should see "Are you sure you want to delete this tag?"
-    And I press "No"
+    And I click on "Cancel" "button" in the "Delete" "dialogue"
     And I should not see "Tag(s) deleted"
     And I should see "Turtle"
     And I click on "Delete" "link" in the "Dog" "table_row"
@@ -81,7 +81,7 @@ Feature: Manager is able to delete tags
       | Select tag Cat | 1 |
     And I press "Delete selected"
     And I should see "Are you sure you want to delete selected tags?"
-    And I press "No"
+    And I click on "Cancel" "button" in the "Delete" "dialogue"
     And I should not see "Tag(s) deleted"
     And I should see "Cat"
     And I set the following fields to these values:
index 54f5e9d..96208ce 100644 (file)
@@ -844,10 +844,8 @@ span.editinstructions {
     .listitem {
 
         &[data-selected='1'] {
-            background-color: $table-bg-accent;
-            &:hover {
-                background-color: $table-bg-hover;
-            }
+            border-left: calc(#{$list-group-border-width} + 5px) solid map-get($theme-colors, 'info');
+            padding-left: calc(#{$list-group-item-padding-x} - 5px);
         }
     }
 
@@ -894,7 +892,6 @@ span.editinstructions {
             }
 
             .idnumber {
-                color: #a1a1a8;
                 margin-right: 2em;
             }
         }
@@ -959,25 +956,6 @@ span.editinstructions {
         }
 
         .listitem {
-            > div {
-                > .ba-checkbox {
-                    width: 2.2em;
-                    text-align: center;
-                    margin: -1px 0.5em 0 0;
-                    padding-top: 2px;
-                }
-            }
-
-            &.highlight > div > .ba-checkbox {
-                background-color: $table-bg-hover;
-            }
-
-            &[data-selected='1'] > div > .ba-checkbox {
-                margin: 0 0.5em 0 0;
-                padding: 0;
-                background-color: inherit;
-            }
-
             &:first-child > div .item-actions .action-moveup,
             &:last-child > div .item-actions .action-movedown {
                 display: none;
index c0fa457..c20aefd 100644 (file)
@@ -13195,9 +13195,8 @@ span.editinstructions {
     #course-category-listings ul.ml ul.ml {
       margin: 0; }
   #course-category-listings .listitem[data-selected='1'] {
-    background-color: rgba(0, 0, 0, 0.05); }
-    #course-category-listings .listitem[data-selected='1']:hover {
-      background-color: rgba(0, 0, 0, 0.075); }
+    border-left: calc(1px + 5px) solid #5bc0de;
+    padding-left: calc(1.25rem - 5px); }
   #course-category-listings .item-actions {
     margin-right: 1em;
     display: inline-block; }
@@ -13218,7 +13217,6 @@ span.editinstructions {
   #course-category-listings .listitem > div .without-actions {
     color: #333; }
   #course-category-listings .listitem > div .idnumber {
-    color: #a1a1a8;
     margin-right: 2em; }
   #course-category-listings .listitem[data-visible="0"] {
     color: #6c757d; }
@@ -13247,17 +13245,6 @@ span.editinstructions {
     margin: -2px 6px 0 0; }
   #course-category-listings #category-listing .listitem.collapsed > ul.ml {
     display: none; }
-  #course-category-listings #category-listing .listitem > div > .ba-checkbox {
-    width: 2.2em;
-    text-align: center;
-    margin: -1px 0.5em 0 0;
-    padding-top: 2px; }
-  #course-category-listings #category-listing .listitem.highlight > div > .ba-checkbox {
-    background-color: rgba(0, 0, 0, 0.075); }
-  #course-category-listings #category-listing .listitem[data-selected='1'] > div > .ba-checkbox {
-    margin: 0 0.5em 0 0;
-    padding: 0;
-    background-color: inherit; }
   #course-category-listings #category-listing .listitem:first-child > div .item-actions .action-moveup,
   #course-category-listings #category-listing .listitem:last-child > div .item-actions .action-movedown {
     display: none; }
index 1f2d364..775dd80 100644 (file)
@@ -13410,9 +13410,8 @@ span.editinstructions {
     #course-category-listings ul.ml ul.ml {
       margin: 0; }
   #course-category-listings .listitem[data-selected='1'] {
-    background-color: rgba(0, 0, 0, 0.05); }
-    #course-category-listings .listitem[data-selected='1']:hover {
-      background-color: rgba(0, 0, 0, 0.075); }
+    border-left: calc(1px + 5px) solid #5bc0de;
+    padding-left: calc(1.25rem - 5px); }
   #course-category-listings .item-actions {
     margin-right: 1em;
     display: inline-block; }
@@ -13433,7 +13432,6 @@ span.editinstructions {
   #course-category-listings .listitem > div .without-actions {
     color: #333; }
   #course-category-listings .listitem > div .idnumber {
-    color: #a1a1a8;
     margin-right: 2em; }
   #course-category-listings .listitem[data-visible="0"] {
     color: #6c757d; }
@@ -13462,17 +13460,6 @@ span.editinstructions {
     margin: -2px 6px 0 0; }
   #course-category-listings #category-listing .listitem.collapsed > ul.ml {
     display: none; }
-  #course-category-listings #category-listing .listitem > div > .ba-checkbox {
-    width: 2.2em;
-    text-align: center;
-    margin: -1px 0.5em 0 0;
-    padding-top: 2px; }
-  #course-category-listings #category-listing .listitem.highlight > div > .ba-checkbox {
-    background-color: rgba(0, 0, 0, 0.075); }
-  #course-category-listings #category-listing .listitem[data-selected='1'] > div > .ba-checkbox {
-    margin: 0 0.5em 0 0;
-    padding: 0;
-    background-color: inherit; }
   #course-category-listings #category-listing .listitem:first-child > div .item-actions .action-moveup,
   #course-category-listings #category-listing .listitem:last-child > div .item-actions .action-movedown {
     display: none; }
index b1f4395..6dee76b 100644 (file)
@@ -495,9 +495,6 @@ class participants extends \table_sql implements dynamic_table {
      * @param filterset $filterset The filterset object to get the filters from.
      */
     public function set_filterset(filterset $filterset): void {
-        // Store the filterset for later.
-        $this->filterset = $filterset;
-
         // Get the context.
         $this->courseid = $filterset->get_filter('courseid')->current();
         $this->course = get_course($this->courseid);
@@ -534,29 +531,14 @@ class participants extends \table_sql implements dynamic_table {
             $this->search = $filterset->get_filter('keywords')->get_filter_values();
         }
 
-        $this->define_baseurl($this->get_base_url());
+        parent::set_filterset($filterset);
     }
 
     /**
-     * Get an unique id for the participants table.
-     * @param string $argument An argument for the unique id, can be course id.
-     * @return string
-     */
-    public static function get_unique_id_from_argument(string $argument): string {
-        return "user-index-participants-{$argument}";
-    }
-
-    /**
-     * Get the base url for the participants table.
-     *
-     * @return moodle_url
+     * Guess the base url for the participants table.
      */
-    public function get_base_url(): moodle_url {
-        if ($this->baseurl === null) {
-            return new moodle_url('/user/index.php', ['id' => $this->courseid]);
-        }
-
-        return $this->baseurl;
+    public function guess_base_url(): void {
+        $this->baseurl = new moodle_url('/user/index.php', ['id' => $this->courseid]);
     }
 
     /**
@@ -566,16 +548,7 @@ class participants extends \table_sql implements dynamic_table {
      *
      * @return context
      */
-    public function get_context(): ?context {
+    public function get_context(): context {
         return $this->context;
     }
-
-    /**
-     * Get the currently defined filterset.
-     *
-     * @return filterset
-     */
-    public function get_filterset(): ?filterset {
-        return $this->filterset;
-    }
 }
index 1082bbc..92f81e2 100644 (file)
@@ -275,7 +275,7 @@ if (count($keywordfilter)) {
     $filterset->add_filter($keywordfilter);
 }
 
-$participanttable = new \core_user\table\participants(\core_user\table\participants::get_unique_id_from_argument($course->id));
+$participanttable = new \core_user\table\participants("user-index-participants-{$course->id}");
 $participanttable->set_selectall($selectall);
 $participanttable->set_filterset($filterset);
 $participanttable->define_baseurl($baseurl);