MDL-61135 question: add tag filter condition
authorRyan Wyllie <ryan@moodle.com>
Fri, 2 Feb 2018 03:41:34 +0000 (03:41 +0000)
committerRyan Wyllie <ryan@moodle.com>
Thu, 8 Feb 2018 02:44:16 +0000 (02:44 +0000)
lang/en/question.php
question/classes/bank/search/tag_condition.php [new file with mode: 0644]
question/templates/tag_condition.mustache [new file with mode: 0644]
theme/boost/scss/moodle/question.scss
theme/bootstrapbase/less/moodle/question.less
theme/bootstrapbase/style/moodle.css

index b3c377d..e5f5910 100644 (file)
@@ -157,6 +157,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';
@@ -228,6 +229,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.';
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);
+    }
+}
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}}
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 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 7574aab..c09b834 100644 (file)
@@ -9685,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;