Merge branch 'MDL-60958-int-fix-1' of github.com:ryanwyllie/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Mon, 12 Feb 2018 05:51:55 +0000 (13:51 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Mon, 12 Feb 2018 05:51:55 +0000 (13:51 +0800)
37 files changed:
lang/en/question.php
lib/amd/build/modal.min.js
lib/amd/src/modal.js
lib/modinfolib.php
lib/templates/loading.mustache
lib/templates/overlay_loading.mustache
mod/lti/backup/moodle2/restore_lti_stepslib.php
mod/quiz/amd/build/modal_quiz_question_bank.min.js [new file with mode: 0644]
mod/quiz/amd/build/quizquestionbank.min.js [new file with mode: 0644]
mod/quiz/amd/src/modal_quiz_question_bank.js [new file with mode: 0644]
mod/quiz/amd/src/quizquestionbank.js [new file with mode: 0644]
mod/quiz/classes/output/edit_renderer.php
mod/quiz/classes/question/bank/custom_view.php
mod/quiz/classes/question/bank/fragment_view.php [new file with mode: 0644]
mod/quiz/lib.php
mod/quiz/questionbank.ajax.php [deleted file]
mod/quiz/tests/behat/editing_add_from_question_bank.feature [new file with mode: 0644]
mod/quiz/upgrade.txt
mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank-debug.js [deleted file]
mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank-min.js [deleted file]
mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank.js [deleted file]
mod/quiz/yui/src/quizquestionbank/build.json [deleted file]
mod/quiz/yui/src/quizquestionbank/js/quizquestionbank.js [deleted file]
mod/quiz/yui/src/quizquestionbank/meta/quizquestionbank.json [deleted file]
question/classes/bank/search/tag_condition.php [new file with mode: 0644]
question/classes/bank/view.php
question/edit.php
question/editlib.php
question/templates/tag_condition.mustache [new file with mode: 0644]
question/tests/behat/filter_questions_by_tag.feature [new file with mode: 0644]
tag/classes/tag.php
tag/tests/taglib_test.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/question.scss
theme/bootstrapbase/less/moodle/core.less
theme/bootstrapbase/less/moodle/question.less
theme/bootstrapbase/style/moodle.css

index e56d5d0..67a520d 100644 (file)
@@ -159,6 +159,7 @@ $string['fileformat'] = 'File format';
 $string['filesareacourse'] = 'the course files area';
 $string['filesareasite'] = 'the site files area';
 $string['filestomove'] = 'Move / copy files to {$a}?';
+$string['filterbytags'] = 'Filter by tags...';
 $string['firsttry'] = 'First try';
 $string['flagged'] = 'Flagged';
 $string['flagthisquestion'] = 'Flag this question';
@@ -230,6 +231,7 @@ $string['nopermissionmove'] = 'You don\'t have permission to move questions from
 $string['noprobs'] = 'No problems found in your question database.';
 $string['noquestions'] = 'No questions were found that could be exported. Make sure that you have selected a category to export that contains questions.';
 $string['noquestionsinfile'] = 'There are no questions in the import file';
+$string['notagfiltersapplied'] = 'No tag filters applied';
 $string['notenoughanswers'] = 'This type of question requires at least {$a} answers';
 $string['notenoughdatatoeditaquestion'] = 'Neither a question id, nor a category id and question type, was specified.';
 $string['notenoughdatatomovequestions'] = 'You need to provide the question ids of questions you want to move.';
index ab2020f..d3e25e6 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index 302cd8d..4b39d35 100644 (file)
@@ -272,7 +272,12 @@ define(['jquery', 'core/templates', 'core/notification', 'core/key_codes',
             if (value.state() == 'pending') {
                 // We're still waiting for the body promise to resolve so
                 // let's show a loading icon.
-                body.animate({height: '100px'}, 150);
+                var height = body.innerHeight();
+                if (height < 100) {
+                    height = 100;
+                }
+
+                body.animate({height: height + 'px'}, 150);
 
                 body.html('');
                 contentPromise = Templates.render(TEMPLATES.LOADING, {})
index f7179b4..98ef37e 100644 (file)
@@ -54,6 +54,12 @@ if (!defined('MAX_MODINFO_CACHE_SIZE')) {
  *     Is an array of grouping id => array of group id => group id. Includes grouping id 0 for 'all groups'
  */
 class course_modinfo {
+    /** @var int Maximum time the course cache building lock can be held */
+    const COURSE_CACHE_LOCK_EXPIRY = 180;
+
+    /** @var int Time to wait for the course cache building lock before throwing an exception */
+    const COURSE_CACHE_LOCK_WAIT = 60;
+
     /**
      * List of fields from DB table 'course' that are cached in MUC and are always present in course_modinfo::$course
      * @var array
@@ -448,7 +454,17 @@ class course_modinfo {
         // Retrieve modinfo from cache. If not present or cacherev mismatches, call rebuild and retrieve again.
         $coursemodinfo = $cachecoursemodinfo->get($course->id);
         if ($coursemodinfo === false || ($course->cacherev != $coursemodinfo->cacherev)) {
-            $coursemodinfo = self::build_course_cache($course);
+            $lock = self::get_course_cache_lock($course->id);
+            try {
+                // Only actually do the build if it's still needed after getting the lock (not if
+                // somebody else, who might have been holding the lock, built it already).
+                $coursemodinfo = $cachecoursemodinfo->get($course->id);
+                if ($coursemodinfo === false || ($course->cacherev != $coursemodinfo->cacherev)) {
+                    $coursemodinfo = self::inner_build_course_cache($course, $lock);
+                }
+            } finally {
+                $lock->release();
+            }
         }
 
         // Set initial values
@@ -577,6 +593,34 @@ class course_modinfo {
         return $compressedsections;
     }
 
+    /**
+     * Gets a lock for rebuilding the cache of a single course.
+     *
+     * Caller must release the returned lock.
+     *
+     * This is used to ensure that the cache rebuild doesn't happen multiple times in parallel.
+     * This function will wait up to 1 minute for the lock to be obtained. If the lock cannot
+     * be obtained, it throws an exception.
+     *
+     * @param int $courseid Course id
+     * @return \core\lock\lock Lock (must be released!)
+     * @throws moodle_exception If the lock cannot be obtained
+     */
+    protected static function get_course_cache_lock($courseid) {
+        // Get database lock to ensure this doesn't happen multiple times in parallel. Wait a
+        // reasonable time for the lock to be released, so we can give a suitable error message.
+        // In case the system crashes while building the course cache, the lock will automatically
+        // expire after a (slightly longer) period.
+        $lockfactory = \core\lock\lock_config::get_lock_factory('core_modinfo');
+        $lock = $lockfactory->get_lock('build_course_cache_' . $courseid,
+                self::COURSE_CACHE_LOCK_WAIT, self::COURSE_CACHE_LOCK_EXPIRY);
+        if (!$lock) {
+            throw new moodle_exception('locktimeout', '', '', null,
+                    'core_modinfo/build_course_cache_' . $courseid);
+        }
+        return $lock;
+    }
+
     /**
      * Builds and stores in MUC object containing information about course
      * modules and sections together with cached fields from table course.
@@ -589,11 +633,29 @@ class course_modinfo {
      *     necessary fields it is re-requested from database)
      */
     public static function build_course_cache($course) {
-        global $DB, $CFG;
-        require_once("$CFG->dirroot/course/lib.php");
         if (empty($course->id)) {
             throw new coding_exception('Object $course is missing required property \id\'');
         }
+
+        $lock = self::get_course_cache_lock($course->id);
+        try {
+            return self::inner_build_course_cache($course, $lock);
+        } finally {
+            $lock->release();
+        }
+    }
+
+    /**
+     * Called to build course cache when there is already a lock obtained.
+     *
+     * @param stdClass $course object from DB table course
+     * @param \core\lock\lock $lock Lock object - not actually used, just there to indicate you have a lock
+     * @return stdClass Course object that has been stored in MUC
+     */
+    protected static function inner_build_course_cache($course, \core\lock\lock $lock) {
+        global $DB, $CFG;
+        require_once("{$CFG->dirroot}/course/lib.php");
+
         // Ensure object has all necessary fields.
         foreach (self::$cachedfields as $key) {
             if (!isset($course->$key)) {
index d8199b9..c5fe4ee 100644 (file)
@@ -33,4 +33,4 @@
     Example context (json):
     {}
 }}
-<span class="loading-icon">{{#pix}} y/loading, core, {{#str}} loading {{/str}} {{/pix}}</span>
+<span class="loading-icon">{{#pix}} i/loading, core, {{#str}} loading {{/str}} {{/pix}}</span>
index 4cf2a2d..243ea75 100644 (file)
@@ -33,6 +33,6 @@
     Example context (json):
     {}
 }}
-<span class="overlay-icon-container hidden" data-region="overlay-icon-container">
+<span class="overlay-icon-container {{$hiddenclass}}{{^visible}}hidden{{/visible}}{{/hiddenclass}}" data-region="overlay-icon-container">
     {{> core/loading }}
 </span>
index c6bbf3f..5f4dac2 100644 (file)
@@ -179,7 +179,8 @@ class restore_lti_activity_structure_step extends restore_activity_structure_ste
         // LTI2 is not possible in the course so we add "lt.toolproxyid IS NULL" to the query.
         $sql = 'SELECT id
             FROM {lti_types}
-            WHERE baseurl = :baseurl AND course = :course AND name = :name AND toolproxyid IS NULL';
+           WHERE ' . $DB->sql_compare_text('baseurl', 255) . ' = ' . $DB->sql_compare_text(':baseurl', 255) . ' AND
+                 course = :course AND name = :name AND toolproxyid IS NULL';
         if ($ltitype = $DB->get_record_sql($sql, $params, IGNORE_MULTIPLE)) {
             $this->set_mapping('ltitype', $data->id, $ltitype->id);
             return $ltitype->id;
diff --git a/mod/quiz/amd/build/modal_quiz_question_bank.min.js b/mod/quiz/amd/build/modal_quiz_question_bank.min.js
new file mode 100644 (file)
index 0000000..63b526e
Binary files /dev/null and b/mod/quiz/amd/build/modal_quiz_question_bank.min.js differ
diff --git a/mod/quiz/amd/build/quizquestionbank.min.js b/mod/quiz/amd/build/quizquestionbank.min.js
new file mode 100644 (file)
index 0000000..4c3bfcc
Binary files /dev/null and b/mod/quiz/amd/build/quizquestionbank.min.js differ
diff --git a/mod/quiz/amd/src/modal_quiz_question_bank.js b/mod/quiz/amd/src/modal_quiz_question_bank.js
new file mode 100644 (file)
index 0000000..6180645
--- /dev/null
@@ -0,0 +1,311 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contain the logic for the question bank modal.
+ *
+ * @module     mod_quiz/modal_quiz_question_bank
+ * @package    mod_quiz
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define([
+    'jquery',
+    'core/yui',
+    'core/notification',
+    'core/modal',
+    'core/modal_events',
+    'core/modal_registry',
+    'core/fragment'
+],
+function(
+    $,
+    Y,
+    Notification,
+    Modal,
+    ModalEvents,
+    ModalRegistry,
+    Fragment
+) {
+
+    var registered = false;
+    var SELECTORS = {
+        ADD_TO_QUIZ_CONTAINER: 'td.addtoquizaction',
+        ANCHOR: 'a[href]',
+        PREVIEW_CONTAINER: 'td.previewaction',
+        SEARCH_OPTIONS: '#advancedsearch',
+        DISPLAY_OPTIONS: '#displayoptions',
+    };
+
+    /**
+     * Constructor for the Modal.
+     *
+     * @param {object} root The root jQuery element for the modal
+     */
+    var ModalQuizQuestionBank = function(root) {
+        Modal.call(this, root);
+
+        this.contextId = null;
+        this.addOnPageId = null;
+    };
+
+    ModalQuizQuestionBank.TYPE = 'mod_quiz-quiz-question-bank';
+    ModalQuizQuestionBank.prototype = Object.create(Modal.prototype);
+    ModalQuizQuestionBank.prototype.constructor = ModalQuizQuestionBank;
+
+    /**
+     * Save the Moodle context id that the question bank is being
+     * rendered in.
+     *
+     * @method setContextId
+     * @param {int} id
+     */
+    ModalQuizQuestionBank.prototype.setContextId = function(id) {
+        this.contextId = id;
+    };
+
+    /**
+     * Retrieve the saved Moodle context id.
+     *
+     * @method getContextId
+     * @return {int}
+     */
+    ModalQuizQuestionBank.prototype.getContextId = function() {
+        return this.contextId;
+    };
+
+    /**
+     * Set the id of the page that the question should be added to
+     * when the user clicks the add to quiz link.
+     *
+     * @method setAddOnPageId
+     * @param {int} id
+     */
+    ModalQuizQuestionBank.prototype.setAddOnPageId = function(id) {
+        this.addOnPageId = id;
+    };
+
+    /**
+     * Returns the saved page id for the question to be added it.
+     *
+     * @method getAddOnPageId
+     * @return {int}
+     */
+    ModalQuizQuestionBank.prototype.getAddOnPageId = function() {
+        return this.addOnPageId;
+    };
+
+    /**
+     * Override the parent show function.
+     *
+     * Reload the body contents when the modal is shown. The current
+     * window URL is used to inform the new content that should be
+     * displayed.
+     *
+     * @method show
+     * @return {void}
+     */
+    ModalQuizQuestionBank.prototype.show = function() {
+        this.reloadBodyContent(window.location.search);
+        return Modal.prototype.show.call(this);
+    };
+
+    /**
+     * Replaces the current body contents with a new version of the question
+     * bank.
+     *
+     * The contents of the question bank are generated using the provided
+     * query string.
+     *
+     * @method reloadBodyContent
+     * @param {string} queryString URL encoded string.
+     */
+    ModalQuizQuestionBank.prototype.reloadBodyContent = function(queryString) {
+        // Load the question bank fragment to be displayed in the modal.
+        var promise = Fragment.loadFragment(
+            'mod_quiz',
+            'quiz_question_bank',
+            this.getContextId(),
+            {
+                querystring: queryString
+            }
+        ).fail(Notification.exception);
+
+        this.setBody(promise);
+    };
+
+    /**
+     * Update the URL of the anchor element that the user clicked on to make
+     * sure that the question is added to the correct page.
+     *
+     * @method handleAddToQuizEvent
+     * @param {event} e A JavaScript event
+     * @param {object} anchorElement The anchor element that was triggered
+     */
+    ModalQuizQuestionBank.prototype.handleAddToQuizEvent = function(e, anchorElement) {
+        // If the user clicks the plus icon to add the question to the page
+        // directly then we need to intercept the click in order to adjust the
+        // href and include the correct add on page id before the page is
+        // redirected.
+        var href = anchorElement.attr('href') + '&addonpage=' + this.getAddOnPageId();
+        anchorElement.attr('href', href);
+    };
+
+    /**
+     * Open a popup window to show the preview of the question.
+     *
+     * @method handlePreviewContainerEvent
+     * @param {event} e A JavaScript event
+     * @param {object} anchorElement The anchor element that was triggered
+     */
+    ModalQuizQuestionBank.prototype.handlePreviewContainerEvent = function(e, anchorElement) {
+        var popupOptions = [
+            'height=600',
+            'width=800',
+            'top=0',
+            'left=0',
+            'menubar=0',
+            'location=0',
+            'scrollbars',
+            'resizable',
+            'toolbar',
+            'status',
+            'directories=0',
+            'fullscreen=0',
+            'dependent'
+        ];
+        window.openpopup(e, {
+            url: anchorElement.attr('href'),
+            name: 'questionpreview',
+            options: popupOptions.join(',')
+        });
+    };
+
+    /**
+     * Reload the modal body with the new display options the user has selected.
+     *
+     * A query string is built using the form elements to be used to generate the
+     * new body content.
+     *
+     * @method handleDisplayOptionFormEvent
+     * @param {event} e A JavaScript event
+     */
+    ModalQuizQuestionBank.prototype.handleDisplayOptionFormEvent = function(e) {
+        // Stop propagation to prevent other wild event handlers
+        // from submitting the form on change.
+        e.stopPropagation();
+        e.preventDefault();
+
+        var form = $(e.target).closest(SELECTORS.DISPLAY_OPTIONS);
+        var queryString = '?' + form.serialize();
+        this.reloadBodyContent(queryString);
+    };
+
+    /**
+     * Listen for changes to the display options form.
+     *
+     * This handles the user changing:
+     *      - The quiz category select box
+     *      - The tags to filter by
+     *      - Show/hide questions from sub categories
+     *      - Show/hide old questions
+     *
+     * @method registerDisplayOptionListeners
+     */
+    ModalQuizQuestionBank.prototype.registerDisplayOptionListeners = function() {
+        // Listen for changes to the display options form.
+        this.getModal().on('change', SELECTORS.DISPLAY_OPTIONS, function(e) {
+            // Get the element that was changed.
+            var modifiedElement = $(e.target);
+            if (modifiedElement.attr('aria-autocomplete')) {
+                // If the element that was change is the autocomplete
+                // input then we should ignore it because that is for
+                // display purposes only.
+                return;
+            }
+
+            this.handleDisplayOptionFormEvent(e);
+        }.bind(this));
+
+        // Listen for the display options form submission because the tags
+        // filter will submit the form when it is changed.
+        this.getModal().on('submit', SELECTORS.DISPLAY_OPTIONS, function(e) {
+            this.handleDisplayOptionFormEvent(e);
+        }.bind(this));
+    };
+
+    /**
+     * Set up all of the event handling for the modal.
+     *
+     * @method registerEventListeners
+     */
+    ModalQuizQuestionBank.prototype.registerEventListeners = function() {
+        // Apply parent event listeners.
+        Modal.prototype.registerEventListeners.call(this);
+
+        // Set up the event handlers for all of the display options.
+        this.registerDisplayOptionListeners();
+
+        this.getModal().on('click', SELECTORS.ANCHOR, function(e) {
+            var anchorElement = $(e.currentTarget);
+
+            // If the anchor element was the add to quiz link.
+            if (anchorElement.closest(SELECTORS.ADD_TO_QUIZ_CONTAINER).length) {
+                this.handleAddToQuizEvent(e, anchorElement);
+                return;
+            }
+
+            // If the anchor element was a preview question link.
+            if (anchorElement.closest(SELECTORS.PREVIEW_CONTAINER).length) {
+                this.handlePreviewContainerEvent(e, anchorElement);
+                return;
+            }
+
+            // Click on expand/collaspse search-options. Has its own handler.
+            // We should not interfere.
+            if (anchorElement.closest(SELECTORS.SEARCH_OPTIONS).length) {
+                return;
+            }
+
+            // Anything else means reload the pop-up contents.
+            e.preventDefault();
+            this.reloadBodyContent(anchorElement.prop('search'));
+        }.bind(this));
+
+        // Disable the form change checker when the body is rendered.
+        this.getRoot().on(ModalEvents.bodyRendered, function() {
+            // Make sure the form change checker is disabled otherwise it'll
+            // stop the user from navigating away from the page once the modal
+            // is hidden.
+            Y.use('moodle-core-formchangechecker', function() {
+                M.core_formchangechecker.reset_form_dirty_state();
+            });
+        });
+    };
+
+    // Automatically register with the modal registry the first time this module is
+    // imported so that you can create modals of this type using the modal factory.
+    if (!registered) {
+        ModalRegistry.register(
+            ModalQuizQuestionBank.TYPE,
+            ModalQuizQuestionBank,
+            'core/modal'
+        );
+
+        registered = true;
+    }
+
+    return ModalQuizQuestionBank;
+});
diff --git a/mod/quiz/amd/src/quizquestionbank.js b/mod/quiz/amd/src/quizquestionbank.js
new file mode 100644 (file)
index 0000000..7001421
--- /dev/null
@@ -0,0 +1,77 @@
+// 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/>.
+
+/**
+ * Initialise the question bank modal on the quiz page.
+ *
+ * @module    mod_quiz/quizquestionbank
+ * @package   mod_quiz
+ * @copyright 2018 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_factory',
+        'mod_quiz/modal_quiz_question_bank'
+    ],
+    function(
+        $,
+        Notification,
+        CustomEvents,
+        ModalFactory,
+        ModalQuizQuestionBank
+    ) {
+
+    var SELECTORS = {
+        ADD_QUESTION_LINKS:   '.menu [data-action="questionbank"]',
+    };
+
+    return {
+        init: function(contextId) {
+            var body = $('body');
+
+            // Create a question bank modal using the factory.
+            // The same modal will be used by all of the add question
+            // links on the page. The content of the modal will be
+            // changed depending on which link is clicked.
+            ModalFactory.create(
+                {
+                    type: ModalQuizQuestionBank.TYPE,
+                    large: true
+                },
+                // Created a deligated listener rather than a single
+                // trigger element.
+                [body, SELECTORS.ADD_QUESTION_LINKS]
+            ).then(function(modal) {
+                // Save the Moodle context id that the modal is being rendered in.
+                modal.setContextId(contextId);
+
+                body.on(CustomEvents.events.activate, SELECTORS.ADD_QUESTION_LINKS, function(e) {
+                    // We need to listen for activations on the trigger elements because there are
+                    // several on the page and we need to know which one was activated in order to
+                    // set some relevant data on the modal.
+                    var triggerElement = $(e.target).closest(SELECTORS.ADD_QUESTION_LINKS);
+                    modal.setAddOnPageId(triggerElement.attr('data-addonpage'));
+                    modal.setTitle(triggerElement.attr('data-header'));
+                });
+
+                return modal;
+            }).fail(Notification.exception);
+        }
+    };
+});
index 09350bb..49dbcd8 100644 (file)
@@ -100,10 +100,9 @@ class edit_renderer extends \plugin_renderer_base {
         if ($structure->can_be_edited()) {
             $popups = '';
 
-            $popups .= $this->question_bank_loading();
-            $this->page->requires->yui_module('moodle-mod_quiz-quizquestionbank',
-                    'M.mod_quiz.quizquestionbank.init',
-                    array('class' => 'questionbank', 'cmid' => $structure->get_cmid()));
+            $this->page->requires->js_call_amd('mod_quiz/quizquestionbank', 'init', [
+                $contexts->lowest()->id
+            ]);
 
             $popups .= $this->random_question_form($pageurl, $contexts, $pagevars);
             $this->page->requires->yui_module('moodle-mod_quiz-randomquestion',
@@ -1250,7 +1249,8 @@ class edit_renderer extends \plugin_renderer_base {
     public function question_bank_contents(\mod_quiz\question\bank\custom_view $questionbank, array $pagevars) {
 
         $qbank = $questionbank->render('editq', $pagevars['qpage'], $pagevars['qperpage'],
-                $pagevars['cat'], $pagevars['recurse'], $pagevars['showhidden'], $pagevars['qbshowtext']);
+                $pagevars['cat'], $pagevars['recurse'], $pagevars['showhidden'], $pagevars['qbshowtext'],
+                $pagevars['qtagids']);
         return html_writer::div(html_writer::div($qbank, 'bd'), 'questionbankformforpopup');
     }
 }
index 2f67383..fbb0c74 100644 (file)
@@ -138,9 +138,10 @@ class custom_view extends \core_question\bank\view {
      *
      * @return string HTML code for the form
      */
-    public function render($tabname, $page, $perpage, $cat, $recurse, $showhidden, $showquestiontext) {
+    public function render($tabname, $page, $perpage, $cat, $recurse, $showhidden,
+            $showquestiontext, $tagids = []) {
         ob_start();
-        $this->display($tabname, $page, $perpage, $cat, $recurse, $showhidden, $showquestiontext);
+        $this->display($tabname, $page, $perpage, $cat, $recurse, $showhidden, $showquestiontext, $tagids);
         $out = ob_get_contents();
         ob_end_clean();
         return $out;
diff --git a/mod/quiz/classes/question/bank/fragment_view.php b/mod/quiz/classes/question/bank/fragment_view.php
new file mode 100644 (file)
index 0000000..45e3b54
--- /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/>.
+
+/**
+ * Defines the custom question bank view used in the question bank modal.
+ *
+ * @package   mod_quiz
+ * @category  question
+ * @copyright 2018 Ryan Wyllie <ryan@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_quiz\question\bank;
+defined('MOODLE_INTERNAL') || die();
+
+
+/**
+ * Subclass to customise the view of the question bank for a fragment.
+ *
+ * This view is to be used when returning the question bank as part of
+ * a fragment.
+ *
+ * @copyright  2018 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class fragment_view extends custom_view {
+    /**
+     * Override the base implementation in \core_question\bank\view
+     * because we don't want to print the headers in the fragment
+     * for the modal.
+     */
+    protected function display_question_bank_header() {
+    }
+
+    /**
+     * Override the base implementation in \core_question\bank\view
+     * because we don't want it to read from the $_POST global variables
+     * for the sort parameters since they are not present in a fragment.
+     *
+     * Unfortunately the best we can do is to look at the URL for
+     * those parameters (only marginally better really).
+     */
+    protected function init_sort_from_params() {
+        $this->sort = [];
+        for ($i = 1; $i <= self::MAX_SORTS; $i++) {
+            if (!$sort = $this->baseurl->param('qbs' . $i)) {
+                break;
+            }
+            // Work out the appropriate order.
+            $order = 1;
+            if ($sort[0] == '-') {
+                $order = -1;
+                $sort = substr($sort, 1);
+                if (!$sort) {
+                    break;
+                }
+            }
+            // Deal with subsorts.
+            list($colname, $subsort) = $this->parse_subsort($sort);
+            $this->requiredcolumns[$colname] = $this->get_column_type($colname);
+            $this->sort[$sort] = $order;
+        }
+    }
+}
index 209c318..6bab4c4 100644 (file)
@@ -2402,3 +2402,44 @@ function mod_quiz_core_calendar_event_timestart_updated(\calendar_event $event,
         $event->trigger();
     }
 }
+
+/**
+ * Generates the question bank in a fargment output. This allows
+ * the question bank to be displayed in a modal.
+ *
+ * The only expected argument provided in the $args array is
+ * 'querystring'. The value should be the list of parameters
+ * URL encoded and used to build the question bank page.
+ *
+ * The individual list of parameters expected can be found in
+ * question_build_edit_resources.
+ *
+ * @param array $args The fragment arguments.
+ * @return string The rendered mform fragment.
+ */
+function mod_quiz_output_fragment_quiz_question_bank($args) {
+    global $CFG, $DB, $PAGE;
+    require_once($CFG->dirroot . '/mod/quiz/locallib.php');
+    require_once($CFG->dirroot . '/question/editlib.php');
+
+    $querystring = preg_replace('/^\?/', '', $args['querystring']);
+    $params = [];
+    parse_str($querystring, $params);
+
+    // Build the required resources. The $params are all cleaned as
+    // part of this process.
+    list($thispageurl, $contexts, $cmid, $cm, $quiz, $pagevars) =
+            question_build_edit_resources('editq', '/mod/quiz/edit.php', $params);
+
+    // Get the course object and related bits.
+    $course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST);
+    require_capability('mod/quiz:manage', $contexts->lowest());
+
+    // Create quiz question bank view.
+    $questionbank = new mod_quiz\question\bank\fragment_view($contexts, $thispageurl, $course, $cm, $quiz);
+    $questionbank->set_quiz_has_attempts(quiz_has_attempts($quiz->id));
+
+    // Output.
+    $renderer = $PAGE->get_renderer('mod_quiz', 'edit');
+    return $renderer->question_bank_contents($questionbank, $pagevars);
+}
diff --git a/mod/quiz/questionbank.ajax.php b/mod/quiz/questionbank.ajax.php
deleted file mode 100644 (file)
index 2f5018a..0000000
+++ /dev/null
@@ -1,49 +0,0 @@
-<?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/>.
-
-
-/**
- * Ajax script to update the contents of the question bank dialogue.
- *
- * @package    mod_quiz
- * @copyright  2014 The Open University
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-define('AJAX_SCRIPT', true);
-
-require_once(__DIR__ . '/../../config.php');
-require_once($CFG->dirroot . '/mod/quiz/locallib.php');
-require_once($CFG->dirroot . '/question/editlib.php');
-
-list($thispageurl, $contexts, $cmid, $cm, $quiz, $pagevars) =
-        question_edit_setup('editq', '/mod/quiz/edit.php', true);
-
-// Get the course object and related bits.
-$course = $DB->get_record('course', array('id' => $quiz->course), '*', MUST_EXIST);
-require_capability('mod/quiz:manage', $contexts->lowest());
-
-// Create quiz question bank view.
-$questionbank = new mod_quiz\question\bank\custom_view($contexts, $thispageurl, $course, $cm, $quiz);
-$questionbank->set_quiz_has_attempts(quiz_has_attempts($quiz->id));
-
-// Output.
-$output = $PAGE->get_renderer('mod_quiz', 'edit');
-$contents = $output->question_bank_contents($questionbank, $pagevars);
-echo json_encode(array(
-    'status'   => 'OK',
-    'contents' => $contents,
-));
diff --git a/mod/quiz/tests/behat/editing_add_from_question_bank.feature b/mod/quiz/tests/behat/editing_add_from_question_bank.feature
new file mode 100644 (file)
index 0000000..3db5abd
--- /dev/null
@@ -0,0 +1,49 @@
+@core @core_question
+Feature: Adding questions to a quiz from the question bank
+  In order to re-use questions
+  As a teacher
+  I want to add questions from the question bank
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1 | weeks |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And the following "activities" exist:
+      | activity   | name   | intro                           | course | idnumber |
+      | quiz       | Quiz 1 | Quiz 1 for testing the Add menu | C1     | quiz1    |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "questions" exist:
+      | questioncategory | qtype     | name              | user     | questiontext    |
+      | Test questions   | essay     | question 1 name | admin    | Question 1 text |
+      | Test questions   | essay     | question 2 name | teacher1 | Question 2 text |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Questions" node in "Course administration > Question bank"
+    And I click on "Edit" "link" in the "question 1 name" "table_row"
+    And I set the following fields to these values:
+      | Tags | foo |
+    And I press "id_submitbutton"
+    And I click on "Edit" "link" in the "question 2 name" "table_row"
+    And I set the following fields to these values:
+      | Tags | bar |
+    And I press "id_submitbutton"
+    And I am on "Course 1" course homepage
+    And I follow "Quiz 1"
+    And I navigate to "Edit quiz" in current page administration
+    And I open the "last" add to quiz menu
+    And I follow "from question bank"
+
+  @javascript
+  Scenario: The questions can be filtered by tag
+    When I set the field "Filter by tags..." to "foo"
+    And I press key "13" in the field "Filter by tags..."
+    Then I should see "question 1 name" in the "categoryquestions" "table"
+    And I should not see "question 2 name" in the "categoryquestions" "table"
index b0681b7..4f3239f 100644 (file)
@@ -1,5 +1,8 @@
 This files describes API changes in the quiz code.
 
+=== 3.5 ===
+* Removed questionbank.ajax.php. Please use the quiz_question_bank fragment instead.
+
 === 3.3.2 ===
 
 * quiz_refresh_events() Now takes two additional parameters to refine the update to a specific instance. This function
diff --git a/mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank-debug.js b/mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank-debug.js
deleted file mode 100644 (file)
index b5c343b..0000000
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank-debug.js and /dev/null differ
diff --git a/mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank-min.js b/mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank-min.js
deleted file mode 100644 (file)
index 5c8a6f1..0000000
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank-min.js and /dev/null differ
diff --git a/mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank.js b/mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank.js
deleted file mode 100644 (file)
index 2e49af4..0000000
Binary files a/mod/quiz/yui/build/moodle-mod_quiz-quizquestionbank/moodle-mod_quiz-quizquestionbank.js and /dev/null differ
diff --git a/mod/quiz/yui/src/quizquestionbank/build.json b/mod/quiz/yui/src/quizquestionbank/build.json
deleted file mode 100644 (file)
index 5ba44a3..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-    "name": "moodle-mod_quiz-quizquestionbank",
-    "builds": {
-        "moodle-mod_quiz-quizquestionbank": {
-            "jsfiles": [
-                "quizquestionbank.js"
-            ]
-        }
-    }
-}
diff --git a/mod/quiz/yui/src/quizquestionbank/js/quizquestionbank.js b/mod/quiz/yui/src/quizquestionbank/js/quizquestionbank.js
deleted file mode 100644 (file)
index d85053b..0000000
+++ /dev/null
@@ -1,215 +0,0 @@
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-
-/**
- * Add questions from question bank functionality for a popup in quiz editing page.
- *
- * @package   mod_quiz
- * @copyright 2014 The Open University
- * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-var CSS = {
-        QBANKLOADING:       'div.questionbankloading',
-        ADDQUESTIONLINKS:   '.menu [data-action="questionbank"]',
-        ADDTOQUIZCONTAINER: 'td.addtoquizaction',
-        PREVIEWCONTAINER:   'td.previewaction',
-        SEARCHOPTIONS:      '#advancedsearch'
-};
-
-var PARAMS = {
-    PAGE: 'addonpage',
-    HEADER: 'header'
-};
-
-var POPUP = function() {
-    POPUP.superclass.constructor.apply(this, arguments);
-};
-
-Y.extend(POPUP, Y.Base, {
-    loadingDiv: '',
-    dialogue: null,
-    addonpage: 0,
-    searchRegionInitialised: false,
-
-    create_dialogue: function() {
-        // Create a dialogue on the page and hide it.
-        var config = {
-            headerContent: '',
-            bodyContent: Y.one(CSS.QBANKLOADING),
-            draggable: true,
-            modal: true,
-            centered: true,
-            width: null,
-            visible: false,
-            postmethod: 'form',
-            footerContent: null,
-            extraClasses: ['mod_quiz_qbank_dialogue']
-        };
-        this.dialogue = new M.core.dialogue(config);
-        this.dialogue.bodyNode.delegate('click', this.link_clicked, 'a[href]', this);
-        this.dialogue.hide();
-
-        this.loadingDiv = this.dialogue.bodyNode.getHTML();
-
-        Y.later(100, this, function() {
-            this.load_content(window.location.search);
-        });
-    },
-
-    initializer: function() {
-        if (!Y.one(CSS.QBANKLOADING)) {
-            return;
-        }
-        this.create_dialogue();
-        Y.one('body').delegate('click', this.display_dialogue, CSS.ADDQUESTIONLINKS, this);
-    },
-
-    display_dialogue: function(e) {
-        e.preventDefault();
-        this.dialogue.set('headerContent', e.currentTarget.getData(PARAMS.HEADER));
-
-        this.addonpage = e.currentTarget.getData(PARAMS.PAGE);
-        var controlsDiv = this.dialogue.bodyNode.one('.modulespecificbuttonscontainer');
-        if (controlsDiv) {
-            var hidden = controlsDiv.one('input[name=addonpage]');
-            if (!hidden) {
-                hidden = controlsDiv.appendChild('<input type="hidden" name="addonpage">');
-            }
-            hidden.set('value', this.addonpage);
-        }
-
-        this.initialiseSearchRegion();
-        this.dialogue.show();
-    },
-
-    load_content: function(queryString) {
-        Y.log('Starting load.', 'debug', 'moodle-mod_quiz-quizquestionbank');
-        this.dialogue.bodyNode.append(this.loadingDiv);
-
-        // If to support old IE.
-        if (window.history.replaceState) {
-            window.history.replaceState(null, '', M.cfg.wwwroot + '/mod/quiz/edit.php' + queryString);
-        }
-
-        Y.io(M.cfg.wwwroot + '/mod/quiz/questionbank.ajax.php' + queryString, {
-            method: 'GET',
-            on: {
-                success: this.load_done,
-                failure: this.load_failed
-            },
-            context: this
-        });
-
-        Y.log('Load request sent.', 'debug', 'moodle-mod_quiz-quizquestionbank');
-    },
-
-    load_done: function(transactionid, response) {
-        var result = JSON.parse(response.responseText);
-        if (!result.status || result.status !== 'OK') {
-            // Because IIS is useless, Moodle can't send proper HTTP response
-            // codes, so we have to detect failures manually.
-            this.load_failed(transactionid, response);
-            return;
-        }
-
-        Y.log('Load completed.', 'debug', 'moodle-mod_quiz-quizquestionbank');
-
-        this.dialogue.bodyNode.setHTML(result.contents);
-        Y.use('moodle-question-chooser', function() {
-            M.question.init_chooser({});
-        });
-        this.dialogue.bodyNode.one('form').delegate('change', this.options_changed, '.searchoptions', this);
-
-        if (this.dialogue.visible) {
-            Y.later(0, this.dialogue, this.dialogue.centerDialogue);
-        }
-        M.question.qbankmanager.init();
-
-        this.searchRegionInitialised = false;
-        if (this.dialogue.get('visible')) {
-            this.initialiseSearchRegion();
-        }
-
-        this.dialogue.fire('widget:contentUpdate');
-        // TODO MDL-47602 really, the base class should listen for the even fired
-        // on the previous line, and fix things like makeResponsive.
-        // However, it does not. So the next two lines are a hack to fix up
-        // display issues (e.g. overall scrollbars on the page). Once the base class
-        // is fixed, this comment and the following four lines should be deleted.
-        if (this.dialogue.get('visible')) {
-            this.dialogue.hide();
-            this.dialogue.show();
-        }
-    },
-
-    load_failed: function() {
-        Y.log('Load failed.', 'debug', 'moodle-mod_quiz-quizquestionbank');
-    },
-
-    link_clicked: function(e) {
-        // Add question to quiz. mofify the URL, then let it work as normal.
-        if (e.currentTarget.ancestor(CSS.ADDTOQUIZCONTAINER)) {
-            e.currentTarget.set('href', e.currentTarget.get('href') + '&addonpage=' + this.addonpage);
-            return;
-        }
-
-        // Question preview. Needs to open in a pop-up.
-        if (e.currentTarget.ancestor(CSS.PREVIEWCONTAINER)) {
-            window.openpopup(e, {
-                url: e.currentTarget.get('href'),
-                name: 'questionpreview',
-                options: 'height=600,width=800,top=0,left=0,menubar=0,location=0,scrollbars,' +
-                         'resizable,toolbar,status,directories=0,fullscreen=0,dependent'
-            });
-            return;
-        }
-
-        // Click on expand/collaspse search-options. Has its own handler.
-        // We should not interfere.
-        if (e.currentTarget.ancestor(CSS.SEARCHOPTIONS)) {
-            return;
-        }
-
-        // Anything else means reload the pop-up contents.
-        e.preventDefault();
-        this.load_content(e.currentTarget.get('search'));
-    },
-
-    options_changed: function(e) {
-        e.preventDefault();
-        this.load_content('?' + Y.IO.stringify(e.currentTarget.get('form')));
-    },
-
-    initialiseSearchRegion: function() {
-        if (this.searchRegionInitialised === true) {
-            return;
-        }
-        if (!Y.one(CSS.SEARCHOPTIONS)) {
-            return;
-        }
-
-        M.util.init_collapsible_region(Y, "advancedsearch", "question_bank_advanced_search",
-                M.util.get_string('clicktohideshow', 'moodle'));
-        this.searchRegionInitialised = true;
-    }
-});
-
-M.mod_quiz = M.mod_quiz || {};
-M.mod_quiz.quizquestionbank = M.mod_quiz.quizquestionbank || {};
-M.mod_quiz.quizquestionbank.init = function() {
-    return new POPUP();
-};
diff --git a/mod/quiz/yui/src/quizquestionbank/meta/quizquestionbank.json b/mod/quiz/yui/src/quizquestionbank/meta/quizquestionbank.json
deleted file mode 100644 (file)
index ecea6eb..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-{
-    "moodle-mod_quiz-quizquestionbank": {
-        "requires": [
-            "base",
-            "event",
-            "node",
-            "io",
-            "io-form",
-            "yui-later",
-            "moodle-question-qbankmanager",
-            "moodle-core-notification-dialogue"
-        ]
-    }
-}
diff --git a/question/classes/bank/search/tag_condition.php b/question/classes/bank/search/tag_condition.php
new file mode 100644 (file)
index 0000000..3753749
--- /dev/null
@@ -0,0 +1,119 @@
+<?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/>.
+
+/**
+ * A condition for adding filtering by tag to the question bank.
+ *
+ * @package   core_question
+ * @copyright 2018 Ryan Wyllie <ryan@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_question\bank\search;
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Question bank search class to allow searching/filtering by tags on a question.
+ *
+ * @copyright 2018 Ryan Wyllie <ryan@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tag_condition extends condition {
+    /** @var string SQL fragment to add to the where clause. */
+    protected $where;
+    /** @var string SQL fragment to add to the where clause. */
+    protected $contexts;
+    /** @var array List of IDs for tags that have been selected in the form. */
+    protected $selectedtagids;
+
+    /**
+     * Constructor.
+     * @param context[] $contexts List of contexts to show tags from
+     * @param int[] $selectedtagids List of IDs for tags to filter by.
+     */
+    public function __construct(array $contexts, array $selectedtagids = []) {
+        global $DB;
+
+        $this->contexts = $contexts;
+
+        // If some tags have been selected then we need to filter
+        // the question list by the selected tags.
+        if ($selectedtagids) {
+            // We treat each additional tag as an AND condition rather than
+            // an OR condition.
+            //
+            // For example, if the user filters by the tags "foo" and "bar" then
+            // we reduce the question list to questions that are tagged with both
+            // "foo" AND "bar". Any question that does not have ALL of the specified
+            // tags will be omitted.
+            list($tagsql, $tagparams) = $DB->get_in_or_equal($selectedtagids, SQL_PARAMS_NAMED);
+            $tagparams['tagcount'] = count($selectedtagids);
+            $this->selectedtagids = $selectedtagids;
+            $this->params = $tagparams;
+            $this->where = "q.id IN (SELECT ti.itemid
+                                     FROM {tag_instance} ti
+                                     WHERE ti.tagid {$tagsql}
+                                     GROUP BY ti.itemid
+                                     HAVING COUNT(itemid) = :tagcount)";
+
+        } else {
+            $this->selectedtagids = [];
+            $this->params = [];
+            $this->where = '';
+        }
+    }
+
+    /**
+     * Get the SQL WHERE snippet to be used in the SQL to retrieve the
+     * list of questions. This SQL snippet will add the logic for the
+     * tag condition.
+     *
+     * @return string
+     */
+    public function where() {
+        return $this->where;
+    }
+
+    /**
+     * Named SQL params to be used with the SQL WHERE snippet.
+     *
+     * @return array
+     */
+    public function params() {
+        return $this->params;
+    }
+
+    /**
+     * Print HTML to display the list of tags to filter by.
+     */
+    public function display_options() {
+        global $OUTPUT;
+
+        $tags = \core_tag_tag::get_tags_by_area_in_contexts('core_question', 'question', $this->contexts);
+        $tagoptions = array_map(function($tag) {
+            return [
+                'id' => $tag->id,
+                'name' => $tag->name,
+                'selected' => in_array($tag->id, $this->selectedtagids)
+            ];
+        }, array_values($tags));
+        $context = [
+            'tagoptions' => $tagoptions
+        ];
+
+        echo $OUTPUT->render_from_template('core_question/tag_condition', $context);
+    }
+}
index bc65791..72c2601 100644 (file)
@@ -461,22 +461,25 @@ class view {
      * displayoptions Sets display options
      */
     public function display($tabname, $page, $perpage, $cat,
-            $recurse, $showhidden, $showquestiontext) {
-        global $PAGE, $OUTPUT;
+            $recurse, $showhidden, $showquestiontext, $tagids = []) {
+        global $PAGE;
 
         if ($this->process_actions_needing_ui()) {
             return;
         }
         $editcontexts = $this->contexts->having_one_edit_tab_cap($tabname);
+        list($categoryid, $contextid) = explode(',', $cat);
+        $catcontext = \context::instance_by_id($contextid);
         // Category selection form.
-        echo $OUTPUT->heading(get_string('questionbank', 'question'), 2);
+        $this->display_question_bank_header();
+        array_unshift($this->searchconditions, new \core_question\bank\search\tag_condition([$catcontext], $tagids));
         array_unshift($this->searchconditions, new \core_question\bank\search\hidden_condition(!$showhidden));
         array_unshift($this->searchconditions, new \core_question\bank\search\category_condition(
                 $cat, $recurse, $editcontexts, $this->baseurl, $this->course));
         $this->display_options_form($showquestiontext);
 
         // Continues with list of questions.
-        $this->display_question_list($this->contexts->having_one_edit_tab_cap($tabname),
+        $this->display_question_list($editcontexts,
                 $this->baseurl, $cat, $this->cm,
                 null, $page, $perpage, $showhidden, $showquestiontext,
                 $this->contexts->having_cap('moodle/question:add'));
@@ -594,7 +597,19 @@ class view {
         echo \html_writer::start_tag('form', array('method' => 'get',
                 'action' => new \moodle_url($scriptpath), 'id' => 'displayoptions'));
         echo \html_writer::start_div();
-        echo \html_writer::input_hidden_params($this->baseurl, array('recurse', 'showhidden', 'qbshowtext'));
+
+        $excludes = array('recurse', 'showhidden', 'qbshowtext');
+        // If the URL contains any tags then we need to prevent them
+        // being added to the form as hidden elements because the tags
+        // are managed separately.
+        if ($this->baseurl->param('qtagids[0]')) {
+            $index = 0;
+            while ($this->baseurl->param("qtagids[{$index}]")) {
+                $excludes[] = "qtagids[{$index}]";
+                $index++;
+            }
+        }
+        echo \html_writer::input_hidden_params($this->baseurl, $excludes);
 
         foreach ($this->searchconditions as $searchcondition) {
             echo $searchcondition->display_options($this);
@@ -635,6 +650,14 @@ class view {
         echo "</div>\n";
     }
 
+    /**
+     * Display the header element for the question bank.
+     */
+    protected function display_question_bank_header() {
+        global $OUTPUT;
+        echo $OUTPUT->heading(get_string('questionbank', 'question'), 2);
+    }
+
     protected function create_new_question_form($category, $canadd) {
         global $CFG;
         echo '<div class="createnewquestion">';
index f745918..9d3295e 100644 (file)
@@ -54,7 +54,7 @@ echo $renderer->extra_horizontal_navigation();
 echo '<div class="questionbankwindow boxwidthwide boxaligncenter">';
 $questionbank->display('questions', $pagevars['qpage'], $pagevars['qperpage'],
         $pagevars['cat'], $pagevars['recurse'], $pagevars['showhidden'],
-        $pagevars['qbshowtext']);
+        $pagevars['qbshowtext'], $pagevars['qtagids']);
 echo "</div>\n";
 
 echo $OUTPUT->footer();
index 6fdb286..093dc10 100644 (file)
@@ -278,21 +278,150 @@ class_alias('core_question\bank\view', 'question_bank_view', true);
  * @return array $thispageurl, $contexts, $cmid, $cm, $module, $pagevars
  */
 function question_edit_setup($edittab, $baseurl, $requirecmid = false, $unused = null) {
-    global $DB, $PAGE, $CFG;
+    global $PAGE;
 
     if ($unused !== null) {
         debugging('Deprecated argument passed to question_edit_setup()', DEBUG_DEVELOPER);
     }
 
+    $params = [];
+
+    if ($requirecmid) {
+        $params['cmid'] = required_param('cmid', PARAM_INT);
+    } else {
+        $params['cmid'] = optional_param('cmid', null, PARAM_INT);
+    }
+
+    if (!$params['cmid']) {
+        $params['courseid'] = required_param('courseid', PARAM_INT);
+    }
+
+    $params['qpage'] = optional_param('qpage', null, PARAM_INT);
+
+    // Pass 'cat' from page to page and when 'category' comes from a drop down menu
+    // then we also reset the qpage so we go to page 1 of
+    // a new cat.
+    $params['cat'] = optional_param('cat', null, PARAM_SEQUENCE); // If empty will be set up later.
+    $params['category'] = optional_param('category', null, PARAM_SEQUENCE);
+    $params['qperpage'] = optional_param('qperpage', null, PARAM_INT);
+
+    // Question table sorting options.
+    for ($i = 1; $i <= question_bank_view::MAX_SORTS; $i++) {
+        $param = 'qbs' . $i;
+        if ($sort = optional_param($param, '', PARAM_TEXT)) {
+            $params[$param] = $sort;
+        } else {
+            break;
+        }
+    }
+
+    // Display options.
+    $params['recurse'] = optional_param('recurse',    null, PARAM_BOOL);
+    $params['showhidden'] = optional_param('showhidden', null, PARAM_BOOL);
+    $params['qbshowtext'] = optional_param('qbshowtext', null, PARAM_BOOL);
+    // Category list page.
+    $params['cpage'] = optional_param('cpage', null, PARAM_INT);
+    $params['qtagids'] = optional_param_array('qtagids', null, PARAM_INT);
+
+    $PAGE->set_pagelayout('admin');
+
+    return question_build_edit_resources($edittab, $baseurl, $params);
+}
+
+/**
+ * Common function for building the generic resources required by the
+ * editing questions pages.
+ *
+ * Either a cmid or a course id must be provided as keys in $params or
+ * an exception will be thrown. All other params are optional and will have
+ * sane default applied if not provided.
+ *
+ * The acceptable keys for $params are:
+ * [
+ *      'cmid' => PARAM_INT,
+ *      'courseid' => PARAM_INT,
+ *      'qpage' => PARAM_INT,
+ *      'cat' => PARAM_SEQUENCE,
+ *      'category' => PARAM_SEQUENCE,
+ *      'qperpage' => PARAM_INT,
+ *      'recurse' => PARAM_INT,
+ *      'showhidden' => PARAM_INT,
+ *      'qbshowtext' => PARAM_INT,
+ *      'cpage' => PARAM_INT,
+ *      'recurse' => PARAM_BOOL,
+ *      'showhidden' => PARAM_BOOL,
+ *      'qbshowtext' => PARAM_BOOL,
+ *      'qtagids' => [PARAM_INT], (array of integers)
+ *      'qbs1' => PARAM_TEXT,
+ *      'qbs2' => PARAM_TEXT,
+ *      'qbs3' => PARAM_TEXT,
+ *      ... and more qbs keys up to question_bank_view::MAX_SORTS ...
+ *  ];
+ *
+ * @param string $edittab Code for this edit tab
+ * @param string $baseurl The name of the script calling this funciton. For examle 'qusetion/edit.php'.
+ * @param array $params The provided parameters to construct the resources with.
+ * @return array $thispageurl, $contexts, $cmid, $cm, $module, $pagevars
+ */
+function question_build_edit_resources($edittab, $baseurl, $params) {
+    global $DB, $PAGE, $CFG;
+
     $thispageurl = new moodle_url($baseurl);
     $thispageurl->remove_all_params(); // We are going to explicity add back everything important - this avoids unwanted params from being retained.
 
-    if ($requirecmid){
-        $cmid =required_param('cmid', PARAM_INT);
-    } else {
-        $cmid = optional_param('cmid', 0, PARAM_INT);
+    $cleanparams = [
+        'qsorts' => [],
+        'qtagids' => []
+    ];
+    $paramtypes = [
+        'cmid' => PARAM_INT,
+        'courseid' => PARAM_INT,
+        'qpage' => PARAM_INT,
+        'cat' => PARAM_SEQUENCE,
+        'category' => PARAM_SEQUENCE,
+        'qperpage' => PARAM_INT,
+        'recurse' => PARAM_INT,
+        'showhidden' => PARAM_INT,
+        'qbshowtext' => PARAM_INT,
+        'cpage' => PARAM_INT,
+        'recurse' => PARAM_BOOL,
+        'showhidden' => PARAM_BOOL,
+        'qbshowtext' => PARAM_BOOL
+    ];
+
+    foreach ($paramtypes as $name => $type) {
+        if (isset($params[$name])) {
+            $cleanparams[$name] = clean_param($params[$name], $type);
+        } else {
+            $cleanparams[$name] = null;
+        }
+    }
+
+    if (!empty($params['qtagids'])) {
+        $cleanparams['qtagids'] = clean_param_array($params['qtagids'], PARAM_INT);
     }
-    if ($cmid){
+
+    $cmid = $cleanparams['cmid'];
+    $courseid = $cleanparams['courseid'];
+    $qpage = $cleanparams['qpage'] ?: -1;
+    $cat = $cleanparams['cat'] ?: 0;
+    $category = $cleanparams['category'] ?: 0;
+    $qperpage = $cleanparams['qperpage'];
+    $recurse = $cleanparams['recurse'];
+    $showhidden = $cleanparams['showhidden'];
+    $qbshowtext = $cleanparams['qbshowtext'];
+    $cpage = $cleanparams['cpage'] ?: 1;
+    $recurse = $cleanparams['recurse'];
+    $showhidden = $cleanparams['showhidden'];
+    $qbshowtext = $cleanparams['qbshowtext'];
+    $qsorts = $cleanparams['qsorts'];
+    $qtagids = $cleanparams['qtagids'];
+
+    if (is_null($cmid) && is_null($courseid)) {
+        throw new \moodle_exception('Must provide a cmid or courseid');
+    }
+
+    if ($cmid) {
         list($module, $cm) = get_module_from_cmid($cmid);
         $courseid = $cm->course;
         $thispageurl->params(compact('cmid'));
@@ -301,7 +430,6 @@ function question_edit_setup($edittab, $baseurl, $requirecmid = false, $unused =
     } else {
         $module = null;
         $cm = null;
-        $courseid  = required_param('courseid', PARAM_INT);
         $thispageurl->params(compact('courseid'));
         require_login($courseid, false);
         $thiscontext = context_course::instance($courseid);
@@ -310,52 +438,55 @@ function question_edit_setup($edittab, $baseurl, $requirecmid = false, $unused =
     if ($thiscontext){
         $contexts = new question_edit_contexts($thiscontext);
         $contexts->require_one_edit_tab_cap($edittab);
-
     } else {
         $contexts = null;
     }
 
-    $PAGE->set_pagelayout('admin');
-
-    $pagevars['qpage'] = optional_param('qpage', -1, PARAM_INT);
+    $pagevars['qpage'] = $qpage;
 
-    //pass 'cat' from page to page and when 'category' comes from a drop down menu
-    //then we also reset the qpage so we go to page 1 of
-    //a new cat.
-    $pagevars['cat'] = optional_param('cat', 0, PARAM_SEQUENCE); // if empty will be set up later
-    if ($category = optional_param('category', 0, PARAM_SEQUENCE)) {
-        if ($pagevars['cat'] != $category) { // is this a move to a new category?
-            $pagevars['cat'] = $category;
-            $pagevars['qpage'] = 0;
-        }
+    // Pass 'cat' from page to page and when 'category' comes from a drop down menu
+    // then we also reset the qpage so we go to page 1 of
+    // a new cat.
+    if ($category && $category != $cat) { // Is this a move to a new category?
+        $pagevars['cat'] = $category;
+        $pagevars['qpage'] = 0;
+    } else {
+        $pagevars['cat'] = $cat; // If empty will be set up later.
     }
+
     if ($pagevars['cat']){
         $thispageurl->param('cat', $pagevars['cat']);
     }
+
     if (strpos($baseurl, '/question/') === 0) {
         navigation_node::override_active_url($thispageurl);
     }
 
+    // This need to occur after the override_active_url call above because
+    // these values change on the page request causing the URLs to mismatch
+    // when trying to work out the active node.
+    for ($i = 1; $i <= question_bank_view::MAX_SORTS; $i++) {
+        $param = 'qbs' . $i;
+        if (isset($params[$param])) {
+            $value = clean_param($params[$param], PARAM_TEXT);
+        } else {
+            break;
+        }
+        $thispageurl->param($param, $value);
+    }
+
     if ($pagevars['qpage'] > -1) {
         $thispageurl->param('qpage', $pagevars['qpage']);
     } else {
         $pagevars['qpage'] = 0;
     }
 
-    $pagevars['qperpage'] = question_get_display_preference(
-            'qperpage', DEFAULT_QUESTIONS_PER_PAGE, PARAM_INT, $thispageurl);
-
-    for ($i = 1; $i <= question_bank_view::MAX_SORTS; $i++) {
-        $param = 'qbs' . $i;
-        if (!$sort = optional_param($param, '', PARAM_TEXT)) {
-            break;
-        }
-        $thispageurl->param($param, $sort);
-    }
+    $pagevars['qperpage'] = question_build_display_preference(
+            'qperpage', $qperpage, DEFAULT_QUESTIONS_PER_PAGE, $thispageurl);
 
     $defaultcategory = question_make_default_categories($contexts->all());
 
-    $contextlistarr = array();
+    $contextlistarr = [];
     foreach ($contexts->having_one_edit_tab_cap($edittab) as $context){
         $contextlistarr[] = "'{$context->id}'";
     }
@@ -372,16 +503,21 @@ function question_edit_setup($edittab, $baseurl, $requirecmid = false, $unused =
     }
 
     // Display options.
-    $pagevars['recurse']    = question_get_display_preference('recurse',    1, PARAM_BOOL, $thispageurl);
-    $pagevars['showhidden'] = question_get_display_preference('showhidden', 0, PARAM_BOOL, $thispageurl);
-    $pagevars['qbshowtext'] = question_get_display_preference('qbshowtext', 0, PARAM_BOOL, $thispageurl);
+    $pagevars['recurse']    = question_build_display_preference('recurse', $recurse, 1, $thispageurl);
+    $pagevars['showhidden'] = question_build_display_preference('showhidden', $showhidden, 0, $thispageurl);
+    $pagevars['qbshowtext'] = question_build_display_preference('qbshowtext', $qbshowtext, 0, $thispageurl);
 
     // Category list page.
-    $pagevars['cpage'] = optional_param('cpage', 1, PARAM_INT);
+    $pagevars['cpage'] = $cpage;
     if ($pagevars['cpage'] != 1){
         $thispageurl->param('cpage', $pagevars['cpage']);
     }
 
+    $pagevars['qtagids'] = $qtagids;
+    foreach ($pagevars['qtagids'] as $index => $qtagid) {
+        $thispageurl->param("qtagids[{$index}]", $qtagid);
+    }
+
     return array($thispageurl, $contexts, $cmid, $cm, $module, $pagevars);
 }
 
@@ -412,13 +548,36 @@ function question_get_category_id_from_pagevars(array $pagevars) {
  */
 function question_get_display_preference($param, $default, $type, $thispageurl) {
     $submittedvalue = optional_param($param, null, $type);
-    if (is_null($submittedvalue)) {
-        return get_user_preferences('question_bank_' . $param, $default);
+    return question_build_display_preference($param, $submittedvalue, $default, $thispageurl);
+}
+
+/**
+ * Get a user preference by name or set the user preference to a given value.
+ *
+ * If $value is null then the function will only attempt to retrieve the
+ * user preference requested by $name. If no user preference is found then the
+ * $default value will be returned. In this case the user preferences are not
+ * modified and nor are the params on $thispageurl.
+ *
+ * If $value is anything other than null then the function will set the user
+ * preference $name to the provided $value and will also set it as a param
+ * on $thispageurl.
+ *
+ * @param string $name The user_preference name is 'question_bank_' . $name.
+ * @param mixed $value The preference value.
+ * @param mixed $default The default value to use, if not otherwise set.
+ * @param moodle_url $thispageurl if the value has been explicitly set, we add
+ *      it to this URL.
+ * @return mixed the parameter value to use.
+ */
+function question_build_display_preference($name, $value, $default, $thispageurl) {
+    if (is_null($value)) {
+        return get_user_preferences('question_bank_' . $name, $default);
     }
 
-    set_user_preference('question_bank_' . $param, $submittedvalue);
-    $thispageurl->param($param, $submittedvalue);
-    return $submittedvalue;
+    set_user_preference('question_bank_' . $name, $value);
+    $thispageurl->param($name, $value);
+    return $value;
 }
 
 /**
diff --git a/question/templates/tag_condition.mustache b/question/templates/tag_condition.mustache
new file mode 100644 (file)
index 0000000..1da0211
--- /dev/null
@@ -0,0 +1,95 @@
+{{!
+    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_question/tag_condition
+
+    An auto-complete select box containing a list of available tags to
+    filter the quesiton bank questions by.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * tagoptions A list of available tags
+
+    Example context (json):
+    {
+        "tagoptions": [
+            {
+                "id": 1,
+                "name": "foo",
+                "selected": true
+            },
+            {
+                "id": 2,
+                "name": "bar",
+                "selected": false
+            }
+        ]
+    }
+}}
+<div class="tag-condition-container" data-region="tag-condition-container-{{uniqid}}">
+    <div class="form-group">
+        <select multiple name="qtagids[]" class="form-control invisible" size="3" data-region="tag-select">
+            {{#tagoptions}}
+                <option {{#selected}}selected{{/selected}} value="{{id}}">{{name}}</option>
+            {{/tagoptions}}
+        </select>
+        {{< core/overlay_loading }}
+            {{$hiddenclass}}{{/hiddenclass}}
+        {{/ core/overlay_loading }}
+    </div>
+</div>
+{{#js}}
+require(
+[
+    'jquery',
+    'core/form-autocomplete'
+],
+function(
+    $,
+    AutoComplete
+) {
+    var root = $('[data-region="tag-condition-container-{{uniqid}}"]');
+    var selectElement = root.find('[data-region="tag-select"]');
+    var loadingContainer = root.find('[data-region="overlay-icon-container"]');
+    var placeholderText = '{{#str}} filterbytags, core_question {{/str}}';
+    var noSelectionText = '{{#str}} notagfiltersapplied, core_question {{/str}}';
+
+    AutoComplete.enhance(
+        selectElement, // Element to enhance.
+        false, // Don't allow support for creating new tags.
+        false, // Don't allow AMD module to handle loading new tags.
+        placeholderText, // Placeholder text.
+        false, // Make search case insensitive.
+        true, // Show suggestions for tags.
+        noSelectionText // Text when no tags are selected.
+    ).always(function() {
+        // Hide the loading icon once the autocomplete has initialised.
+        loadingContainer.addClass('hidden');
+    });
+
+    // We need to trigger a form submission because of how the question bank
+    // page handles reloading the questions when an option changes.
+    selectElement.on('change', function() {
+        selectElement.closest('form').submit();
+    });
+});
+{{/js}}
diff --git a/question/tests/behat/filter_questions_by_tag.feature b/question/tests/behat/filter_questions_by_tag.feature
new file mode 100644 (file)
index 0000000..8ea0682
--- /dev/null
@@ -0,0 +1,41 @@
+@core @core_question
+Feature: The questions in the question bank can be filtered by tags
+  In order to find the questions I need
+  As a teacher
+  I want to filter the questions by tags
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1 | weeks |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And the following "question categories" exist:
+      | contextlevel | reference | name           |
+      | Course       | C1        | Test questions |
+    And the following "questions" exist:
+      | questioncategory | qtype     | name              | user     | questiontext    |
+      | Test questions   | essay     | question 1 name | admin    | Question 1 text |
+      | Test questions   | essay     | question 2 name | teacher1 | Question 2 text |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Questions" node in "Course administration > Question bank"
+    And I click on "Edit" "link" in the "question 1 name" "table_row"
+    And I set the following fields to these values:
+      | Tags | foo |
+    And I press "id_submitbutton"
+    And I click on "Edit" "link" in the "question 2 name" "table_row"
+    And I set the following fields to these values:
+      | Tags | bar |
+    And I press "id_submitbutton"
+
+  @javascript
+  Scenario: The questions can be filtered by tag
+    When I set the field "Filter by tags..." to "foo"
+    And I press key "13" in the field "Filter by tags..."
+    Then I should see "question 1 name" in the "categoryquestions" "table"
+    And I should not see "question 2 name" in the "categoryquestions" "table"
index a5c0716..bbbe35c 100644 (file)
@@ -1604,4 +1604,38 @@ class core_tag_tag {
         // Finally delete all tags that we combined into the current one.
         self::delete_tags($ids);
     }
+
+    /**
+     * Retrieve a list of tags that have been used to tag the given $component
+     * and $itemtype in the provided $contexts.
+     *
+     * @param string $component The tag instance component
+     * @param string $itemtype The tag instance item type
+     * @param context[] $contexts The list of contexts to look for tag instances in
+     * @return core_tag_tag[]
+     */
+    public static function get_tags_by_area_in_contexts($component, $itemtype, array $contexts) {
+        global $DB;
+
+        $params = [$component, $itemtype];
+        $contextids = array_map(function($context) {
+            return $context->id;
+        }, $contexts);
+        list($contextsql, $contextsqlparams) = $DB->get_in_or_equal($contextids);
+        $params = array_merge($params, $contextsqlparams);
+
+        $subsql = "SELECT tagid
+                   FROM {tag_instance}
+                   WHERE component = ?
+                   AND itemtype = ?
+                   AND contextid {$contextsql}
+                   GROUP BY tagid";
+        $sql = "SELECT *
+                FROM {tag}
+                WHERE id IN ({$subsql})";
+
+        return array_map(function($record) {
+            return new core_tag_tag($record);
+        }, $DB->get_records_sql($sql, $params));
+    }
 }
index efdba75..0a1a23c 100644 (file)
@@ -1053,6 +1053,125 @@ class core_tag_taglib_testcase extends advanced_testcase {
         $this->assertEquals(['dogs', 'hippo'], $correlatedtags);
     }
 
+    /**
+     * get_tags_by_area_in_contexts should return an empty array if there
+     * are no tag instances for the area in the given context.
+     */
+    public function test_get_tags_by_area_in_contexts_empty() {
+        $tagnames = ['foo'];
+        $collid = core_tag_collection::get_default();
+        $tags = core_tag_tag::create_if_missing($collid, $tagnames);
+        $user = $this->getDataGenerator()->create_user();
+        $context = context_user::instance($user->id);
+        $component = 'core';
+        $itemtype = 'user';
+
+        $result = core_tag_tag::get_tags_by_area_in_contexts($component, $itemtype, [$context]);
+        $this->assertEmpty($result);
+    }
+
+    /**
+     * get_tags_by_area_in_contexts should return an array of tags that
+     * have instances in the given context even when there is only a single
+     * instance.
+     */
+    public function test_get_tags_by_area_in_contexts_single_tag_one_context() {
+        $tagnames = ['foo'];
+        $collid = core_tag_collection::get_default();
+        $tags = core_tag_tag::create_if_missing($collid, $tagnames);
+        $user = $this->getDataGenerator()->create_user();
+        $context = context_user::instance($user->id);
+        $component = 'core';
+        $itemtype = 'user';
+        core_tag_tag::set_item_tags($component, $itemtype, $user->id, $context, $tagnames);
+
+        $result = core_tag_tag::get_tags_by_area_in_contexts($component, $itemtype, [$context]);
+        $expected = array_map(function($t) {
+            return $t->id;
+        }, $tags);
+        $actual = array_map(function($t) {
+            return $t->id;
+        }, $result);
+
+        sort($expected);
+        sort($actual);
+
+        $this->assertEquals($expected, $actual);
+    }
+
+    /**
+     * get_tags_by_area_in_contexts should return all tags in an array
+     * that have tag instances in for the area in the given context and
+     * should ignore all tags that don't have an instance.
+     */
+    public function test_get_tags_by_area_in_contexts_multiple_tags_one_context() {
+        $tagnames = ['foo', 'bar', 'baz'];
+        $collid = core_tag_collection::get_default();
+        $tags = core_tag_tag::create_if_missing($collid, $tagnames);
+        $user = $this->getDataGenerator()->create_user();
+        $context = context_user::instance($user->id);
+        $component = 'core';
+        $itemtype = 'user';
+        core_tag_tag::set_item_tags($component, $itemtype, $user->id, $context, array_slice($tagnames, 0, 2));
+
+        $result = core_tag_tag::get_tags_by_area_in_contexts($component, $itemtype, [$context]);
+        $expected = ['foo', 'bar'];
+        $actual = array_map(function($t) {
+            return $t->name;
+        }, $result);
+
+        sort($expected);
+        sort($actual);
+
+        $this->assertEquals($expected, $actual);
+    }
+
+    /**
+     * get_tags_by_area_in_contexts should return the unique set of
+     * tags for a area in the given contexts. Multiple tag instances of
+     * the same tag don't result in duplicates in the result set.
+     *
+     * Tags with tag instances in the same area with in difference contexts
+     * should be ignored.
+     */
+    public function test_get_tags_by_area_in_contexts_multiple_tags_multiple_contexts() {
+        $tagnames = ['foo', 'bar', 'baz', 'bop', 'bam', 'bip'];
+        $collid = core_tag_collection::get_default();
+        $tags = core_tag_tag::create_if_missing($collid, $tagnames);
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $user3 = $this->getDataGenerator()->create_user();
+        $context1 = context_user::instance($user1->id);
+        $context2 = context_user::instance($user2->id);
+        $context3 = context_user::instance($user3->id);
+        $component = 'core';
+        $itemtype = 'user';
+
+        // User 1 tags: 'foo', 'bar'.
+        core_tag_tag::set_item_tags($component, $itemtype, $user1->id, $context1, array_slice($tagnames, 0, 2));
+        // User 2 tags: 'bar', 'baz'.
+        core_tag_tag::set_item_tags($component, $itemtype, $user2->id, $context2, array_slice($tagnames, 1, 2));
+        // User 3 tags: 'bop', 'bam'.
+        core_tag_tag::set_item_tags($component, $itemtype, $user3->id, $context3, array_slice($tagnames, 3, 2));
+
+        $result = core_tag_tag::get_tags_by_area_in_contexts($component, $itemtype, [$context1, $context2]);
+        // Both User 1 and 2 have tagged using 'bar' but we don't
+        // expect duplicate tags in the result since they are the same
+        // tag.
+        //
+        // User 3 has tagged 'bop' and 'bam' but we aren't searching in
+        // that context so they shouldn't be in the results.
+        $expected = ['foo', 'bar', 'baz'];
+        $actual = array_map(function($t) {
+            return $t->name;
+        }, $result);
+
+        sort($expected);
+        sort($actual);
+
+        $this->assertEquals($expected, $actual);
+    }
+
     /**
      * Help method to return sorted array of names of correlated tags to use for assertions
      * @param core_tag $tag
index 8a9ff6d..bf0bb17 100644 (file)
@@ -2179,9 +2179,9 @@ $footer-link-color: $bg-inverse-link-color !default;
         transform: translate(-50%, -50%);
 
         .icon {
-            height: 40px;
-            width: 40px;
-
+            height: 30px;
+            width: 30px;
+            font-size: 30px;
         }
     }
 }
index 61fff05..a87acbe 100644 (file)
@@ -622,6 +622,10 @@ body.path-question-type .mform fieldset.hidden {
     box-sizing: content-box;
 }
 
+.tag-condition-container {
+    position: relative;
+}
+
 @include media-breakpoint-down(sm) {
     .que .info {
         float: none;
index 52627ad..eeb6b2f 100644 (file)
@@ -2404,9 +2404,9 @@ h3.sectionname .inplaceeditable.inplaceeditingon .editinstructions {
         transform: translate(-50%, -50%);
 
         .icon {
-            height: 40px;
-            width: 40px;
-
+            height: 30px;
+            width: 30px;
+            font-size: 30px;
         }
     }
 }
index d7ecf3f..623fff8 100644 (file)
@@ -541,3 +541,7 @@ body.path-question-type .mform fieldset.hidden {
     padding: 0;
     margin: 0.7em 0 0;
 }
+
+.tag-condition-container {
+    position: relative;
+}
index 600138f..c09b834 100644 (file)
@@ -4795,8 +4795,9 @@ h3.sectionname .inplaceeditable.inplaceeditingon .editinstructions {
   transform: translate(-50%, -50%);
 }
 .overlay-icon-container .loading-icon .icon {
-  height: 40px;
-  width: 40px;
+  height: 30px;
+  width: 30px;
+  font-size: 30px;
 }
 [data-drag-type="move"] {
   cursor: move;
@@ -9684,6 +9685,9 @@ body.path-question-type .mform fieldset.hidden {
   padding: 0;
   margin: 0.7em 0 0;
 }
+.tag-condition-container {
+  position: relative;
+}
 /* user.less */
 .userprofile .fullprofilelink {
   text-align: center;