Merge branch 'MDL-66874-master' of git://github.com/junpataleta/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Mon, 14 Oct 2019 08:05:27 +0000 (10:05 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Mon, 14 Oct 2019 08:05:27 +0000 (10:05 +0200)
55 files changed:
admin/tool/dataprivacy/amd/build/expand_contract.min.js
admin/tool/dataprivacy/amd/build/expand_contract.min.js.map
admin/tool/dataprivacy/amd/src/expand_contract.js
badges/upgradelib.php [new file with mode: 0644]
blocks/starredcourses/classes/external.php
blog/classes/privacy/provider.php
blog/tests/privacy_test.php
config-dist.php
lang/en/question.php
lib/badgeslib.php
lib/behat/behat_base.php
lib/behat/classes/behat_context_helper.php
lib/behat/classes/behat_selectors.php
lib/behat/classes/component_named_replacement.php [new file with mode: 0644]
lib/behat/classes/component_named_selector.php [new file with mode: 0644]
lib/behat/classes/exact_named_selector.php
lib/behat/classes/named_selector.php [new file with mode: 0644]
lib/behat/classes/partial_named_selector.php
lib/db/install.php
lib/db/upgrade.php
lib/outputrenderers.php
lib/questionlib.php
lib/tablelib.php
lib/tests/behat/behat_hooks.php
lib/tests/tablelib_test.php
message/tests/behat/behat_message.php
message/tests/behat/delete_messages.feature
message/tests/behat/favourite_conversations.feature
message/tests/behat/group_conversation.feature
message/tests/behat/message_delete_conversation.feature
message/tests/behat/message_send_messages.feature
message/tests/behat/mute_conversations.feature
message/tests/behat/self_conversation.feature
message/tests/behat/unread_messages.feature
mod/forum/report/summary/classes/summary_table.php
mod/forum/report/summary/index.php
mod/quiz/classes/question/bank/custom_view.php
mod/quiz/classes/question/bank/question_name_text_column.php
mod/quiz/locallib.php
mod/quiz/styles.css
mod/quiz/tests/behat/editing_add_from_question_bank.feature
mod/quiz/tests/behat/editing_add_random.feature
phpunit.xml.dist
question/category_class.php
question/classes/bank/column_base.php
question/classes/bank/question_name_idnumber_tags_column.php [new file with mode: 0644]
question/classes/bank/view.php
question/tests/behat/copy_questions.feature
question/tests/behat/question_categories.feature
question/tests/behat/question_categories_idnumber.feature
question/tests/behat/sort_questions.feature
question/upgrade.txt
tag/classes/output/taglist.php
tag/classes/tag.php
tag/templates/taglist.mustache

index ac0d11f..7f73e4c 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/expand_contract.min.js and b/admin/tool/dataprivacy/amd/build/expand_contract.min.js differ
index 9ee561c..5f7d8ab 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/expand_contract.min.js.map and b/admin/tool/dataprivacy/amd/build/expand_contract.min.js.map differ
index cf509b5..a369f7c 100644 (file)
@@ -28,6 +28,14 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) {
     var expandedImage = $('<img alt="" src="' + url.imageUrl('t/expanded') + '"/>');
     var collapsedImage = $('<img alt="" src="' + url.imageUrl('t/collapsed') + '"/>');
 
+    /*
+     * Class names to apply when expanding/collapsing nodes.
+     */
+    var CLASSES = {
+        EXPAND: 'fa-caret-right',
+        COLLAPSE: 'fa-caret-down'
+    };
+
     return /** @alias module:tool_dataprivacy/expand-collapse */ {
         /**
          * Expand or collapse a selected node.
@@ -40,15 +48,15 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) {
                 targetnode.removeClass('hide');
                 targetnode.addClass('visible');
                 targetnode.attr('aria-expanded', true);
-                thisnode.find(':header i.fa').removeClass('fa-plus-square');
-                thisnode.find(':header i.fa').addClass('fa-minus-square');
+                thisnode.find(':header i.fa').removeClass(CLASSES.EXPAND);
+                thisnode.find(':header i.fa').addClass(CLASSES.COLLAPSE);
                 thisnode.find(':header img.icon').attr('src', expandedImage.attr('src'));
             } else {
                 targetnode.removeClass('visible');
                 targetnode.addClass('hide');
                 targetnode.attr('aria-expanded', false);
-                thisnode.find(':header i.fa').removeClass('fa-minus-square');
-                thisnode.find(':header i.fa').addClass('fa-plus-square');
+                thisnode.find(':header i.fa').removeClass(CLASSES.COLLAPSE);
+                thisnode.find(':header i.fa').addClass(CLASSES.EXPAND);
                 thisnode.find(':header img.icon').attr('src', collapsedImage.attr('src'));
             }
         },
@@ -61,8 +69,8 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) {
         expandCollapseAll: function(nextstate) {
             var currentstate = (nextstate == 'visible') ? 'hide' : 'visible';
             var ariaexpandedstate = (nextstate == 'visible') ? true : false;
-            var iconclassnow = (nextstate == 'visible') ? 'fa-plus-square' : 'fa-minus-square';
-            var iconclassnext = (nextstate == 'visible') ? 'fa-minus-square' : 'fa-plus-square';
+            var iconclassnow = (nextstate == 'visible') ? CLASSES.EXPAND : CLASSES.COLLAPSE;
+            var iconclassnext = (nextstate == 'visible') ? CLASSES.COLLAPSE : CLASSES.EXPAND;
             var imagenow = (nextstate == 'visible') ? expandedImage.attr('src') : collapsedImage.attr('src');
             $('.' + currentstate).each(function() {
                 $(this).removeClass(currentstate);
diff --git a/badges/upgradelib.php b/badges/upgradelib.php
new file mode 100644 (file)
index 0000000..d1acd8b
--- /dev/null
@@ -0,0 +1,65 @@
+<?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/>.
+
+/**
+ * Contains upgrade and install functions for badges.
+ *
+ * @package    core_badges
+ * @copyright  2019 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Called on install or upgrade to create default list of backpacks a user can connect to.
+ * Don't use the global defines from badgeslib because this is for install/upgrade.
+ *
+ * @return void
+ */
+function badges_install_default_backpacks() {
+    global $DB;
+
+    $record = new stdClass();
+    $record->backpackweburl = 'https://backpack.openbadges.org';
+    $record->backpackapiurl = 'https://backpack.openbadges.org';
+    $record->apiversion = 1;
+    $record->sortorder = 0;
+    $record->password = '';
+
+    if (!($bp = $DB->get_record('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl)))) {
+        $bpid = $DB->insert_record('badge_external_backpack', $record);
+    } else {
+        $bpid = $bp->id;
+    }
+    set_config('badges_site_backpack', $bpid);
+
+    // All existing backpacks default to V1.
+    $DB->set_field('badge_backpack', 'externalbackpackid', $bpid);
+
+    $record = new stdClass();
+    $record->backpackapiurl = 'https://api.badgr.io/v2';
+    $record->backpackweburl = 'https://badgr.io';
+    $record->apiversion = 2;
+    $record->sortorder = 1;
+    $record->password = '';
+
+    if (!$DB->record_exists('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl))) {
+        $DB->insert_record('badge_external_backpack', $record);
+    }
+
+}
+
index f5cb1c2..d1ace2c 100644 (file)
@@ -88,13 +88,18 @@ class block_starredcourses_external extends core_course_external {
             return ($a->timemodified > $b->timemodified) ? -1 : 1;
         });
 
-        $formattedcourses = array_map(function($favourite) use ($renderer) {
+        $formattedcourses = array();
+        foreach ($favourites as $favourite) {
             $course = get_course($favourite->itemid);
             $context = \context_course::instance($favourite->itemid);
-
-            $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => true]);
-            return $exporter->export($renderer);
-        }, $favourites);
+            $canviewhiddencourses = has_capability('moodle/course:viewhiddencourses', $context);
+
+            if ($course->visible || $canviewhiddencourses) {
+                $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => true]);
+                $formattedcourse = $exporter->export($renderer);
+                $formattedcourses[] = $formattedcourse;
+            }
+        }
 
         return $formattedcourses;
     }
index 4bbc744..8ff19c3 100644 (file)
@@ -460,8 +460,7 @@ class provider implements
             $params = array_merge($inparams, ['userid' => $userid]);
             $associds = $DB->get_fieldset_sql($sql, $params);
 
-            list($insql, $inparams) = $DB->get_in_or_equal($associds, SQL_PARAMS_NAMED, 'param', true);
-            $DB->delete_records_select('blog_association', "id $insql", $inparams);
+            $DB->delete_records_list('blog_association', 'id', $associds);
         }
     }
 
index 8db5bdd..7002552 100644 (file)
@@ -370,6 +370,37 @@ class core_blog_privacy_testcase extends provider_testcase {
         $this->assertTrue($DB->record_exists('post', ['courseid' => $c1->id, 'userid' => $u1->id, 'module' => 'notes']));
     }
 
+    /**
+     * Test provider delete_data_for_user with a context that contains no entries
+     *
+     * @return void
+     */
+    public function test_delete_data_for_user_empty_context() {
+        global $DB;
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Create a blog entry for user, associated with course.
+        $entry = new blog_entry($this->create_post(['userid' => $user->id, 'courseid' => $course->id])->id);
+        $entry->add_association($context->id);
+
+        // Generate list of contexts for user.
+        $contexts = provider::get_contexts_for_userid($user->id);
+        $this->assertContains($context->id, $contexts->get_contextids());
+
+        // Now delete the blog entry.
+        $entry->delete();
+
+        // Try to delete user data using contexts obtained prior to entry deletion.
+        $contextlist = new approved_contextlist($user, 'core_blog', $contexts->get_contextids());
+        provider::delete_data_for_user($contextlist);
+
+        // Sanity check to ensure blog_associations is really empty.
+        $this->assertEmpty($DB->get_records('blog_association', ['contextid' => $context->id]));
+    }
+
     public function test_delete_data_for_all_users_in_context() {
         global $DB;
 
index 5227584..ba04c38 100644 (file)
@@ -626,6 +626,23 @@ $CFG->admin = 'admin';
 //
 //      $CFG->uninstallclionly = true;
 //
+//
+// Customise question bank display
+//
+// The display of Moodle's question bank is made up of a number of columns.
+// You can customise this display by giving a comma-separated list of column class
+// names here. Each class must be a subclass of \core_question\bank\column_base.
+// For example you might define a class like
+//      class \local_qbank_extensions\my_column extends \core_question\bank\column_base
+// in a local plugin, then add it to the list here. At the time of writing,
+// the default question bank display is equivalent to the following, but you  might like
+// to check the latest default in question/classes/bank/view.php before setting this.
+//
+//      $CFG->questionbankcolumns = 'checkbox_column,question_type_column,'
+//              . 'question_name_idnumber_tags_column,tags_action_column,edit_action_column,'
+//              . 'copy_action_column,preview_action_column,delete_action_column,'
+//              . 'creator_name_column,modifier_name_column';
+//
 //=========================================================================
 // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
 //=========================================================================
index 7dc290e..8da8aa4 100644 (file)
@@ -70,6 +70,9 @@ $string['categoryinfo'] = 'Category info';
 $string['categorymove'] = 'The category \'{$a->name}\' contains {$a->count} questions (some of which may be hidden questions or random questions that are still in use in a quiz). Please choose another category to move them to.';
 $string['categorymoveto'] = 'Save in category';
 $string['categorynamecantbeblank'] = 'The category name cannot be blank.';
+$string['categorynamewithcount'] = '{$a->name} ({$a->questioncount})';
+$string['categorynamewithidnumber'] = '{$a->name} [{$a->idnumber}]';
+$string['categorynamewithidnumberandcount'] = '{$a->name} [{$a->idnumber}] ({$a->questioncount})';
 $string['clickflag'] = 'Flag question';
 $string['clicktoflag'] = 'Flag this question for future reference';
 $string['clicktounflag'] = 'Remove flag';
index 986e503..572a520 100644 (file)
@@ -233,13 +233,16 @@ function badges_calculate_message_schedule($schedule) {
 
     switch ($schedule) {
         case BADGE_MESSAGE_DAILY:
-            $nextcron = time() + 60 * 60 * 24;
+            $tomorrow = new DateTime("1 day", core_date::get_server_timezone_object());
+            $nextcron = $tomorrow->getTimestamp();
             break;
         case BADGE_MESSAGE_WEEKLY:
-            $nextcron = time() + 60 * 60 * 24 * 7;
+            $nextweek = new DateTime("1 week", core_date::get_server_timezone_object());
+            $nextcron = $nextweek->getTimestamp();
             break;
         case BADGE_MESSAGE_MONTHLY:
-            $nextcron = time() + 60 * 60 * 24 * 7 * 30;
+            $nextmonth = new DateTime("1 month", core_date::get_server_timezone_object());
+            $nextcron = $nextmonth->getTimestamp();
             break;
     }
 
@@ -856,45 +859,6 @@ function badges_get_badge_api_versions() {
     ];
 }
 
-/**
- * Called on install or upgrade to create default list of backpacks a user can connect to.
- *
- * @return void
- */
-function badges_install_default_backpacks() {
-    global $DB;
-
-    $record = new stdClass();
-    $record->backpackweburl = BADGE_BACKPACKWEBURL;
-    $record->backpackapiurl = BADGE_BACKPACKAPIURL;
-    $record->apiversion = OPEN_BADGES_V1;
-    $record->sortorder = 0;
-    $record->password = '';
-
-    $bpid = 0;
-    if (!($bp = $DB->get_record('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl)))) {
-        $bpid = $DB->insert_record('badge_external_backpack', $record);
-    } else {
-        $bpid = $bp->id;
-    }
-    set_config('badges_site_backpack', $bpid);
-
-    // All existing backpacks default to V1.
-    $DB->set_field('badge_backpack', 'externalbackpackid', $bpid);
-
-    $record = new stdClass();
-    $record->backpackapiurl = BADGRIO_BACKPACKAPIURL;
-    $record->backpackweburl = BADGRIO_BACKPACKWEBURL;
-    $record->apiversion = OPEN_BADGES_V2;
-    $record->sortorder = 1;
-    $record->password = '';
-
-    if (!$DB->record_exists('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl))) {
-        $DB->insert_record('badge_external_backpack', $record);
-    }
-
-}
-
 /**
  * Get the default issuer for a badge from this site.
  *
index c6053b9..4ebc6f6 100644 (file)
@@ -35,6 +35,9 @@ use Behat\Mink\Element\NodeElement;
 use Behat\Mink\Element\Element;
 use Behat\Mink\Session;
 
+require_once(__DIR__ . '/classes/component_named_selector.php');
+require_once(__DIR__ . '/classes/component_named_replacement.php');
+
 /**
  * Steps definitions base class.
  *
@@ -216,10 +219,22 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
             $selector = 'xpath';
         }
 
-        // Convert to named_partial where the selector type is not named_partial, named_exact, xpath, or css.
+        // Convert to a named selector where the selector type is not a known selector.
         $converttonamed = !$this->getSession()->getSelectorsHandler()->isSelectorRegistered($selector);
         $converttonamed = $converttonamed && 'xpath' !== $selector;
         if ($converttonamed) {
+            if (behat_partial_named_selector::is_deprecated_selector($selector)) {
+                if ($replacement = behat_partial_named_selector::get_deprecated_replacement($selector)) {
+                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
+                    $selector = $replacement;
+                }
+            } else if (behat_exact_named_selector::is_deprecated_selector($selector)) {
+                if ($replacement = behat_exact_named_selector::get_deprecated_replacement($selector)) {
+                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
+                    $selector = $replacement;
+                }
+            }
+
             $allowedpartialselectors = behat_partial_named_selector::get_allowed_selectors();
             $allowedexactselectors = behat_exact_named_selector::get_allowed_selectors();
             if (isset($allowedpartialselectors[$selector])) {
@@ -229,7 +244,7 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
                 $locator = behat_selectors::normalise_named_selector($allowedexactselectors[$selector], $locator);
                 $selector = 'named_exact';
             } else {
-                throw new ExpectationException("The '{$selector}' selector type is not registered.", $this);
+                throw new ExpectationException("The '{$selector}' selector type is not registered.", $this->getSession()->getDriver());
             }
         }
 
@@ -1092,4 +1107,58 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
     public static function get_extended_timeout() : int {
         return self::get_real_timeout(10);
     }
+
+    /**
+     * Return a list of the exact named selectors for the component.
+     *
+     * Named selectors are what make Behat steps like
+     *   Then I should see "Useful text" in the "General" "fieldset"
+     * work. Here, "fieldset" is the named selector, and "General" is the locator.
+     *
+     * If you override this method in your plugin (e.g. mod_mymod), to define
+     * new selectors specific to your plugin. For example, if you returned
+     *   new behat_component_named_selector('Thingy',
+     *           [".//some/xpath//img[contains(@alt, %locator%)]/.."])
+     * then
+     *   Then I should see "Useful text" in the "Whatever" "mod_mymod > Thingy"
+     * would work.
+     *
+     * This method should return a list of {@link behat_component_named_selector} and
+     * the docs on that class explain how it works.
+     *
+     * @return behat_component_named_selector[]
+     */
+    public static function get_exact_named_selectors(): array {
+        return [];
+    }
+
+    /**
+     * Return a list of the partial named selectors for the component.
+     *
+     * Like the exact named selectors above, but the locator only
+     * needs to match part of the text. For example, the standard
+     * "button" is a partial selector, so:
+     *   When I click "Save" "button"
+     * will activate "Save changes".
+     *
+     * @return behat_component_named_selector[]
+     */
+    public static function get_partial_named_selectors(): array {
+        return [];
+    }
+
+    /**
+     * Return a list of the Mink named replacements for the component.
+     *
+     * Named replacements allow you to define parts of an xpath that can be reused multiple times, or in multiple
+     * xpaths.
+     *
+     * This method should return a list of {@link behat_component_named_replacement} and the docs on that class explain
+     * how it works.
+     *
+     * @return behat_component_named_replacement[]
+     */
+    public static function get_named_replacements(): array {
+        return [];
+    }
 }
index 8b050cc..d7a2b60 100644 (file)
@@ -90,12 +90,8 @@ class behat_context_helper {
      * @return behat_base
      */
     public static function get($classname) {
-        $contexts = self::$environment->getContexts();
-
-        foreach ($contexts as $context) {
-            if (is_a($context, $classname)) {
-                return $context;
-            }
+        if (self::$environment->hasContextClass($classname)) {
+            return self::$environment->getContext($classname);
         }
 
         $suitename = self::$environment->getSuite()->getName();
@@ -121,6 +117,16 @@ class behat_context_helper {
         return self::$environment->getContext($classname);
     }
 
+    /**
+     * Return whether there is a context of the specified classname.
+     *
+     * @param string $classname
+     * @return bool
+     */
+    public static function has_context(string $classname): bool {
+        return self::$environment->hasContextClass($classname);
+    }
+
     /**
      * Translates string to XPath literal.
      *
index f093e75..e90ec56 100644 (file)
@@ -23,6 +23,7 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+require_once(__DIR__ . '/named_selector.php');
 require_once(__DIR__ . '/exact_named_selector.php');
 require_once(__DIR__ . '/partial_named_selector.php');
 
diff --git a/lib/behat/classes/component_named_replacement.php b/lib/behat/classes/component_named_replacement.php
new file mode 100644 (file)
index 0000000..f11a4ce
--- /dev/null
@@ -0,0 +1,99 @@
+<?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 class for recording the definition of Mink replacements.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * A class for recording the definition of Mink replacements for use in Mink selectors.
+ *
+ * These are comprised of a source string, and a replacement.
+ *
+ * During use the source string is converted from the string to be in the format:
+ *
+ *      %[component]/[string]%
+ *
+ * For example:
+ *
+ *      %mod_forum/title%
+ *
+ * Mink replacements are used in xpath translation to translate regularly used items such as title.
+ * Here is an example from the upstream Mink project:
+ *
+ * '%tagTextMatch%' => 'contains(normalize-space(string(.)), %locator%)'
+ *
+ * And can be used in an xpath:
+ *
+ *      .//label[%tagTextMatch%]
+ *
+ * This would be expanded to:
+ *
+ *      .//label[contains(normalize-space(string(.)), %locator%)]
+ *
+ * Replacements can also be used in other replacements, as long as that replacement is defined later.
+ *
+ *      '%linkMatch%' => '(%idMatch% or %tagTextMatch% or %titleMatch% or %relMatch%)'
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_component_named_replacement {
+    /** @var string */
+    protected $from;
+
+    /** @var string */
+    protected $to;
+
+    /**
+     * Create the replacement.
+     *
+     * @param string $from this is the old selector that should no longer be used.
+     *      For example 'group_message'.
+     * @param string $to this is the new equivalent that should be used instead.
+     *      For example 'core_message > Message'.
+     */
+    public function __construct(string $from, string $to) {
+        $this->from = $from;
+        $this->to = $to;
+    }
+
+    /**
+     * Get the 'from' part of the replacement, formatted for the component.
+     *
+     * @param string $component
+     * @return string
+     */
+    public function get_from(string $component): string {
+        return "%{$component}/{$this->from}%";
+    }
+
+    /**
+     * Get the 'to' part of the replacement.
+     *
+     * @return string Target xpath
+     */
+    public function get_to(): string {
+        return $this->to;
+    }
+}
diff --git a/lib/behat/classes/component_named_selector.php b/lib/behat/classes/component_named_selector.php
new file mode 100644 (file)
index 0000000..8b58484
--- /dev/null
@@ -0,0 +1,124 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class representing a named selector that can be used in Behat tests.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Class representing a named selector that can be used in Behat tests.
+ *
+ * Named selectors are what make Behat steps like
+ *   Then I should see "Useful text" in the "General" "fieldset"
+ * Here, "fieldset" is the named selector, and "General" is the locator.
+ *
+ * Selectors can either be exact, in which case the locator needs to
+ * match exactly, or can be partial, for example the way
+ *   When I click "Save" "button"
+ * will trigger a "Save changes" button.
+ *
+ * Instances of this class get returned by the get_exact_named_selectors()
+ * and get_partial_named_selectors() methods in classes like behat_mod_mymod.
+ * The code that makes the magic work is in the trait behat_named_selector
+ * used by both behat_exact_named_selector and behat_partial_named_selector.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_component_named_selector {
+    /** @var string */
+    protected $alias;
+
+    /** @var array List of xpaths */
+    protected $xpaths;
+
+    /** @var string */
+    protected $istextselector;
+
+    /**
+     * Create the selector definition.
+     *
+     * As an example, if you define
+     *   new behat_component_named_selector('Message',
+     *           [".//*[@data-conversation-id]//img[contains(@alt, %locator%)]/.."])
+     * in get_partial_named_selectors in behat_message in
+     * message/tests/behat/behat_message.php, then steps like
+     *   When "Group 1" "core_message > Message" should exist
+     * will work.
+     *
+     * Text selectors are things that contain other things (e.g. some particular text), e.g.
+     *   Then I can see "Some text" in the "Whatever" "text_selector"
+     * whereas non-text selectors are atomic things, like
+     *   When I click the "Whatever" "widget".
+     *
+     * @param string $alias The 'friendly' name of the thing. This will be prefixed with the component name.
+     *      For example, if the mod_mymod plugin, says 'Thingy', then "mod_mymod > Thingy" becomes a selector.
+     * @param array $xpaths A list of xpaths one or more XPaths that the selector gets transformed into.
+     * @param bool $istextselector Whether this selector can also be used as a text selector.
+     */
+    public function __construct(string $alias, array $xpaths, bool $istextselector = true) {
+        $this->alias = $alias;
+        $this->xpaths = $xpaths;
+        $this->istextselector = $istextselector;
+    }
+
+    /**
+     * Whether this is a text selector.
+     *
+     * @return bool
+     */
+    public function is_text_selector(): bool {
+        return $this->istextselector;
+    }
+
+    /**
+     * Get the name of the selector.
+     * This is a back-end feature and contains a namespaced md5 of the human-readable name.
+     *
+     * @param string $component
+     * @return string
+     */
+    public function get_name(string $component): string {
+        return implode('_', [$component, md5($this->alias)]);
+    }
+
+    /**
+     * Get the alias of the selector.
+     * This is the human-readable name that you would typically interact with.
+     *
+     * @param string $component
+     * @return string
+     */
+    public function get_alias(string $component): string {
+        return implode(" > ", [$component, $this->alias]);;
+    }
+
+    /**
+     * Get the list of combined xpaths.
+     *
+     * @return string The list of xpaths combined with the xpath | (OR) operator
+     */
+    public function get_combined_xpath(): string {
+        return implode(' | ', $this->xpaths);
+    }
+}
index 4902075..e178dae 100644 (file)
@@ -32,6 +32,9 @@
  */
 class behat_exact_named_selector extends \Behat\Mink\Selector\ExactNamedSelector {
 
+    // Use the named selector trait.
+    use behat_named_selector;
+
     /**
      * Creates selector instance.
      */
@@ -63,6 +66,9 @@ class behat_exact_named_selector extends \Behat\Mink\Selector\ExactNamedSelector
         'text_exact' => 'text',
     );
 
+    /** @var List of deprecated selectors */
+    protected static $deprecatedselectors = [];
+
     /**
      * Allowed selectors getter.
      *
diff --git a/lib/behat/classes/named_selector.php b/lib/behat/classes/named_selector.php
new file mode 100644 (file)
index 0000000..e683ffc
--- /dev/null
@@ -0,0 +1,113 @@
+<?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/>.
+
+/**
+ * Moodle-specific common functions for named selectors.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Common functions for named selectors.
+ *
+ * This has to be a trait, because we need this in both the classes
+ * behat_exact_named_selector and behat_partial_named_selector, and
+ * those classes have to be subclasses of \Behat\Mink\Selector\ExactNamedSelector
+ * and \Behat\Mink\Selector\PartialNamedSelector. This trait is a way achieve
+ * that without duplciated code.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+trait behat_named_selector {
+
+    /**
+     * Registers new XPath selector with specified name.
+     *
+     * @param string $component
+     * @param behat_component_named_selector $selector
+     */
+    public function register_component_selector(string $component, behat_component_named_selector $selector) {
+        $alias = $selector->get_alias($component);
+        $name = $selector->get_name($component);
+        static::$allowedselectors[$alias] = $name;
+
+        if ($selector->is_text_selector()) {
+            static::$allowedtextselectors[$alias] = $name;
+        }
+
+        // We must use Reflection here. The replacements property is private and cannot be accessed otherwise.
+        // This is due to an API limitation in Mink.
+        $rc = new \ReflectionClass(\Behat\Mink\Selector\NamedSelector::class);
+        $r = $rc->getProperty('replacements');
+        $r->setAccessible(true);
+        $replacements = $r->getValue($this);
+
+        $selectorxpath = strtr($selector->get_combined_xpath(), $replacements);
+
+        parent::registerNamedXpath($name, $selectorxpath);
+    }
+
+    /**
+     * Registers new XPath selector with specified name.
+     *
+     * @param string $component
+     * @param behat_component_named_replacement $replacement
+     */
+    public function register_replacement(string $component, behat_component_named_replacement $replacement) {
+        // We must use Reflection here. The replacements property is private and cannot be accessed otherwise.
+        // This is due to an API limitation in Mink.
+        $rc = new \ReflectionClass(\Behat\Mink\Selector\NamedSelector::class);
+        $r = $rc->getProperty('replacements');
+        $r->setAccessible(true);
+        $existing = $r->getValue($this);
+
+        $from = $replacement->get_from($component);
+
+        if (isset($existing[$from])) {
+            throw new \coding_exception("A named replacement already exists in the partial named selector for '{$from}'.  " .
+                "Replacement names must be unique, and should be namespaced to the component");
+        }
+
+        $translatedto = strtr($replacement->get_to(), $existing);
+        $this->registerReplacement($from, $translatedto);
+    }
+
+    /**
+     * Check whether the specified selector has been deprecated and marked for replacement.
+     *
+     * @param string $selector
+     * @return bool
+     */
+    public static function is_deprecated_selector(string $selector): bool {
+        return array_key_exists($selector, static::$deprecatedselectors);
+    }
+
+    /**
+     * Fetch the replacement name of a deprecated selector.
+     *
+     * @param string $selector
+     * @return bool
+     */
+    public static function get_deprecated_replacement(string $selector): ?string {
+        return static::$deprecatedselectors[$selector];
+    }
+}
index 8503da8..86830de 100644 (file)
@@ -33,6 +33,9 @@
  */
 class behat_partial_named_selector extends \Behat\Mink\Selector\PartialNamedSelector {
 
+    // Use the named selector trait.
+    use behat_named_selector;
+
     /**
      * Creates selector instance.
      */
@@ -271,6 +274,15 @@ XPATH
         ],
     ];
 
+    /** @var List of deprecated selectors */
+    protected static $deprecatedselectors = [
+        'group_message' => 'core_message > Message',
+        'group_message_member' => 'core_message > Message member',
+        'group_message_tab' => 'core_message > Message tab',
+        'group_message_list_area' => 'core_message > Message list area',
+        'group_message_message_content' => 'core_message > Message content',
+    ];
+
     /**
      * Allowed selectors getter.
      *
index 71f216e..be9692d 100644 (file)
@@ -321,6 +321,6 @@ function xmldb_main_install() {
     make_default_scale();
     make_competence_scale();
 
-    require_once($CFG->libdir . '/badgeslib.php');
+    require_once($CFG->dirroot . '/badges/upgradelib.php'); // Core install and upgrade related functions only for badges.
     badges_install_default_backpacks();
 }
index 667b6c1..9355360 100644 (file)
@@ -2028,16 +2028,18 @@ function xmldb_main_upgrade($oldversion) {
 
     if ($oldversion < 2018092800.02) {
         // Delete any contacts that are not mutual (meaning they both haven't added each other).
-        $sql = "SELECT c1.id
-                  FROM {message_contacts} c1
-             LEFT JOIN {message_contacts} c2
-                    ON c1.userid = c2.contactid
-                   AND c1.contactid = c2.userid
-                 WHERE c2.id IS NULL";
-        if ($contacts = $DB->get_records_sql($sql)) {
-            list($insql, $inparams) = $DB->get_in_or_equal(array_keys($contacts));
-            $DB->delete_records_select('message_contacts', "id $insql", $inparams);
-        }
+        do {
+            $sql = "SELECT c1.id
+                      FROM {message_contacts} c1
+                 LEFT JOIN {message_contacts} c2
+                        ON c1.userid = c2.contactid
+                       AND c1.contactid = c2.userid
+                     WHERE c2.id IS NULL";
+            if ($contacts = $DB->get_records_sql($sql, null, 0, 1000)) {
+                list($insql, $inparams) = $DB->get_in_or_equal(array_keys($contacts));
+                $DB->delete_records_select('message_contacts', "id $insql", $inparams);
+            }
+        } while ($contacts);
 
         upgrade_main_savepoint(true, 2018092800.02);
     }
@@ -3361,7 +3363,7 @@ function xmldb_main_upgrade($oldversion) {
         }
 
         // Add default backpacks.
-        require_once($CFG->libdir.'/badgeslib.php'); // Core Upgrade-related functions for badges only.
+        require_once($CFG->dirroot . '/badges/upgradelib.php'); // Core install and upgrade related functions only for badges.
         badges_install_default_backpacks();
 
         // Main savepoint reached.
index e0570e4..10124c0 100644 (file)
@@ -4511,10 +4511,12 @@ EOD;
      * @param int $limit limit the number of tags to display, if size of $tags is more than this limit the "more" link
      *               will be appended to the end, JS will toggle the rest of the tags
      * @param context $pagecontext specify if needed to overwrite the current page context for the view tag link
+     * @param bool $accesshidelabel if true, the label should have class="accesshide" added.
      * @return string
      */
-    public function tag_list($tags, $label = null, $classes = '', $limit = 10, $pagecontext = null) {
-        $list = new \core_tag\output\taglist($tags, $label, $classes, $limit, $pagecontext);
+    public function tag_list($tags, $label = null, $classes = '', $limit = 10,
+            $pagecontext = null, $accesshidelabel = false) {
+        $list = new \core_tag\output\taglist($tags, $label, $classes, $limit, $pagecontext, $accesshidelabel);
         return $this->render_from_template('core_tag/taglist', $list->export_for_template($this));
     }
 
index 8ab9a38..7aee57f 100644 (file)
@@ -1440,11 +1440,25 @@ function question_category_options($contexts, $top = false, $currentcat = 0,
             if ($category->contextid == $contextid) {
                 $cid = $category->id;
                 if ($currentcat != $cid || $currentcat == 0) {
-                    $countstring = !empty($category->questioncount) ?
-                            " ($category->questioncount)" : '';
-                    $categoriesarray[$contextstring][$cid] =
-                            format_string($category->indentedname, true,
-                                array('context' => $context)) . $countstring;
+                    $a = new stdClass;
+                    $a->name = format_string($category->indentedname, true,
+                            array('context' => $context));
+                    if ($category->idnumber !== null && $category->idnumber !== '') {
+                        $a->idnumber = s($category->idnumber);
+                    }
+                    if (!empty($category->questioncount)) {
+                        $a->questioncount = $category->questioncount;
+                    }
+                    if (isset($a->idnumber) && isset($a->questioncount)) {
+                        $formattedname = get_string('categorynamewithidnumberandcount', 'question', $a);
+                    } else if (isset($a->idnumber)) {
+                        $formattedname = get_string('categorynamewithidnumber', 'question', $a);
+                    } else if (isset($a->questioncount)) {
+                        $formattedname = get_string('categorynamewithcount', 'question', $a);
+                    } else {
+                        $formattedname = $a->name;
+                    }
+                    $categoriesarray[$contextstring][$cid] = $formattedname;
                 }
             }
         }
@@ -1875,14 +1889,14 @@ class question_edit_contexts {
     }
 
     /**
-     * @return array all parent contexts
+     * @return context[] all parent contexts
      */
     public function all() {
         return $this->allcontexts;
     }
 
     /**
-     * @return object lowest context which must be either the module or course context
+     * @return context lowest context which must be either the module or course context
      */
     public function lowest() {
         return $this->allcontexts[0];
@@ -1890,7 +1904,7 @@ class question_edit_contexts {
 
     /**
      * @param string $cap capability
-     * @return array parent contexts having capability, zero based index
+     * @return context[] parent contexts having capability, zero based index
      */
     public function having_cap($cap) {
         $contextswithcap = array();
@@ -1904,7 +1918,7 @@ class question_edit_contexts {
 
     /**
      * @param array $caps capabilities
-     * @return array parent contexts having at least one of $caps, zero based index
+     * @return context[] parent contexts having at least one of $caps, zero based index
      */
     public function having_one_cap($caps) {
         $contextswithacap = array();
@@ -1921,14 +1935,14 @@ class question_edit_contexts {
 
     /**
      * @param string $tabname edit tab name
-     * @return array parent contexts having at least one of $caps, zero based index
+     * @return context[] parent contexts having at least one of $caps, zero based index
      */
     public function having_one_edit_tab_cap($tabname) {
         return $this->having_one_cap(self::$caps[$tabname]);
     }
 
     /**
-     * @return those contexts where a user can add a question and then use it.
+     * @return context[] those contexts where a user can add a question and then use it.
      */
     public function having_add_and_use() {
         $contextswithcap = array();
@@ -1993,7 +2007,7 @@ class question_edit_contexts {
     /**
      * Throw error if at least one parent context hasn't got one of the caps $caps
      *
-     * @param array $cap capabilities
+     * @param array $caps capabilities
      */
     public function require_one_cap($caps) {
         if (!$this->have_one_cap($caps)) {
index 2b72e08..81a944a 100644 (file)
@@ -863,9 +863,9 @@ class flexible_table {
      * @return string contents of cell in column 'fullname', for this row.
      */
     function col_fullname($row) {
-        global $COURSE;
+        global $PAGE, $COURSE;
 
-        $name = fullname($row);
+        $name = fullname($row, has_capability('moodle/site:viewfullnames', $PAGE->context));
         if ($this->download) {
             return $name;
         }
index 46b3c87..bbf18b7 100644 (file)
@@ -396,7 +396,7 @@ class behat_hooks extends behat_base {
      * Yes, this is in a strange location and should be in the BeforeScenario hook, but failures in the test setUp lead
      * to the test being incorrectly marked as skipped with no way to force the test to be failed.
      *
-     * @param   BeforeStepScope $scope
+     * @param BeforeStepScope $scope
      * @BeforeStep
      */
     public function before_step(BeforeStepScope $scope) {
@@ -425,7 +425,6 @@ class behat_hooks extends behat_base {
                         new ExpectationException($message, $session)
                     );
 
-                self::$initprocessesfinished = true;
             }
             $this->scenariorunning = true;
         }
@@ -709,6 +708,70 @@ class behat_hooks extends behat_base {
     protected static function is_first_scenario() {
         return !(self::$initprocessesfinished);
     }
+
+    /**
+     * Register component selectors.
+     *
+     * @param BeforeScenarioScope $scope scope passed by event fired before scenario.
+     * @BeforeScenario
+     */
+    public function register_component_selectors(BeforeScenarioScope $scope) {
+        foreach (\core_component::get_component_names() as $component) {
+            $this->register_component_selectors_for_component($component);
+        }
+    }
+
+    /**
+     * Register a set of component selectors.
+     *
+     * @param string $component
+     */
+    public function register_component_selectors_for_component(string $component): void {
+        $componentclassname = "behat_{$component}";
+
+        if (!behat_context_helper::has_context($componentclassname)) {
+            if ("core_" === substr($component, 0, 5)) {
+                $componentclassname = "behat_" . substr($component, 5);
+                if (!behat_context_helper::has_context($componentclassname)) {
+                    return;
+                }
+            } else {
+                return;
+            }
+        }
+
+        $context = behat_context_helper::get($componentclassname);
+        $namedpartial = $this->getSession()->getSelectorsHandler()->getSelector('named_partial');
+        $namedexact = $this->getSession()->getSelectorsHandler()->getSelector('named_exact');
+
+        // Replacements must come before selectors as they are used in the selectors.
+        foreach ($context->get_named_replacements() as $replacement) {
+            $namedpartial->register_replacement($component, $replacement);
+            $namedexact->register_replacement($component, $replacement);
+        }
+
+        foreach ($context->get_partial_named_selectors() as $selector) {
+            $namedpartial->register_component_selector($component, $selector);
+        }
+
+        foreach ($context->get_exact_named_selectors() as $selector) {
+            $namedexact->register_component_selector($component, $selector);
+        }
+
+    }
+
+    /**
+     * Mark the first step as having been completed.
+     *
+     * This must be the last BeforeStep hook in the setup.
+     *
+     * @param BeforeStepScope $scope
+     * @BeforeStep
+     */
+    public function first_step_setup_complete(BeforeStepScope $scope) {
+        self::$initprocessesfinished = true;
+    }
+
 }
 
 /**
index 6314669..25bf48a 100644 (file)
@@ -37,7 +37,7 @@ require_once($CFG->libdir . '/tests/fixtures/testable_flexible_table.php');
  * @copyright  2013 Damyon Wiese <damyon@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class core_tablelib_testcase extends basic_testcase {
+class core_tablelib_testcase extends advanced_testcase {
     protected function generate_columns($cols) {
         $columns = array();
         foreach (range(0, $cols - 1) as $j) {
@@ -352,6 +352,67 @@ class core_tablelib_testcase extends basic_testcase {
         );
     }
 
+    /**
+     * Data provider for test_fullname_column
+     *
+     * @return array
+     */
+    public function fullname_column_provider() {
+        return [
+            ['language'],
+            ['alternatename lastname'],
+            ['firstname lastnamephonetic'],
+        ];
+    }
+
+    /**
+     * Test fullname column observes configured alternate fullname format configuration
+     *
+     * @param string $format
+     * @return void
+     *
+     * @dataProvider fullname_column_provider
+     */
+    public function test_fullname_column(string $format) {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        set_config('alternativefullnameformat', $format);
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $table = $this->create_and_setup_table(['fullname'], [], true, false, [], []);
+        $this->assertContains(fullname($user, true), $table->format_row($user)['fullname']);
+    }
+
+    /**
+     * Test fullname column ignores fullname format configuration for a user with viewfullnames capability prohibited
+     *
+     * @param string $format
+     * @return void
+     *
+     * @dataProvider fullname_column_provider
+     */
+    public function test_fullname_column_prohibit_viewfullnames(string $format) {
+        global $DB, $CFG;
+
+        $this->resetAfterTest();
+
+        set_config('alternativefullnameformat', $format);
+
+        $currentuser = $this->getDataGenerator()->create_user();
+        $this->setUser($currentuser);
+
+        // Prohibit the viewfullnames from the default user role.
+        $userrole = $DB->get_record('role', ['id' => $CFG->defaultuserroleid]);
+        role_change_permission($userrole->id, context_system::instance(), 'moodle/site:viewfullnames', CAP_PROHIBIT);
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $table = $this->create_and_setup_table(['fullname'], [], true, false, [], []);
+        $this->assertContains(fullname($user, false), $table->format_row($user)['fullname']);
+    }
+
     public function test_get_row_html() {
         $data = $this->generate_data(1, 5);
         $columns = $this->generate_columns(5);
index cd8bc43..28731d9 100644 (file)
@@ -37,6 +37,69 @@ require_once(__DIR__ . '/../../../lib/behat/behat_base.php');
  */
 class behat_message extends behat_base {
 
+    /**
+     * Return the list of partial named selectors.
+     *
+     * @return array
+     */
+    public static function get_partial_named_selectors(): array {
+        return [
+            new behat_component_named_selector('Message', [".//*[@data-conversation-id]//img[%altMatch%]/.."]),
+            new behat_component_named_selector('Message conversation', [
+                <<<XPATH
+    .//*[@data-region='message-drawer' and contains(., %locator%)]//div[@data-region='content-message-container']
+XPATH
+            ], false),
+            new behat_component_named_selector('Message header', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//div[@data-region='header-content' and contains(., %locator%)]
+XPATH
+            ]),
+            new behat_component_named_selector('Message member', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//div[@data-region='group-info-content-container']
+    //div[@class='list-group' and not(contains(@class, 'hidden'))]//*[%core_message/textMatch%]
+XPATH
+                , <<<XPATH
+    .//*[@data-region='message-drawer']//div[@data-region='group-info-content-container']
+    //div[@data-region='empty-message-container' and not(contains(@class, 'hidden')) and contains(., %locator%)]
+XPATH
+            ], false),
+            new behat_component_named_selector('Message tab', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//button[@data-toggle='collapse' and contains(string(), %locator%)]
+XPATH
+            ], false),
+            new behat_component_named_selector('Message list area', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//*[contains(@data-region, concat('view-overview-', %locator%))]
+XPATH
+            ], false),
+            new behat_component_named_selector('Message content', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//*[@data-region='message' and @data-message-id and contains(., %locator%)]
+XPATH
+            ], false),
+        ];
+    }
+
+    /**
+     * Return a list of the Mink named replacements for the component.
+     *
+     * Named replacements allow you to define parts of an xpath that can be reused multiple times, or in multiple
+     * xpaths.
+     *
+     * This method should return a list of {@link behat_component_named_replacement} and the docs on that class explain
+     * how it works.
+     *
+     * @return behat_component_named_replacement[]
+     */
+    public static function get_named_replacements(): array {
+        return [
+            new behat_component_named_replacement('textMatch', 'text()[contains(., %locator%)]'),
+        ];
+    }
+
     /**
      * Open the messaging UI.
      *
@@ -57,7 +120,7 @@ class behat_message extends behat_base {
     public function i_open_the_conversations_list(string $tab) {
         $this->execute('behat_general::i_click_on', [
             $this->escape($tab),
-            'group_message_tab'
+            'core_message > Message tab'
         ]);
     }
 
@@ -213,7 +276,7 @@ class behat_message extends behat_base {
         $this->execute('behat_general::i_click_on',
             array(
                 $this->escape($conversationname),
-                'group_message',
+                'core_message > Message',
             )
         );
     }
index 9de5bc3..1504d8e 100644 (file)
@@ -40,15 +40,15 @@ Feature: Delete messages from conversations
   Scenario: Delete a message sent by the user from a group conversation
     Given I log in as "student1"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "How are you?" "group_message_message_content"
-    And I click on "Can somebody help me?" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "How are you?" "core_message > Message content"
+    And I click on "Can somebody help me?" "core_message > Message content"
     And I should see "3" in the "[data-region='message-selected-court']" "css_element"
 #   Clicking to unselect
-    And I click on "How are you?" "group_message_message_content"
-    And I click on "Can somebody help me?" "group_message_message_content"
+    And I click on "How are you?" "core_message > Message content"
+    And I click on "Can somebody help me?" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -57,19 +57,19 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
-    And I should see "How are you?" in the "Group 1" "group_message_conversation"
-    And I should see "Can somebody help me?" in the "Group 1" "group_message_conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
+    And I should see "How are you?" in the "Group 1" "core_message > Message conversation"
+    And I should see "Can somebody help me?" in the "Group 1" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Delete two messages from a group conversation; one sent by another user.
     Given I log in as "student1"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
-    And I click on "How are you?" "group_message_message_content"
+    And I click on "How are you?" "core_message > Message content"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -78,9 +78,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
-    And I should not see "How are you?" in the "Group 1" "group_message_conversation"
-    And I should see "Can somebody help me?" in the "Group 1" "group_message_conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
+    And I should not see "How are you?" in the "Group 1" "core_message > Message conversation"
+    And I should see "Can somebody help me?" in the "Group 1" "core_message > Message conversation"
     And I should not see "Messages selected"
 #   Check messages were not deleted for other users
     And I log out
@@ -94,10 +94,10 @@ Feature: Delete messages from conversations
   Scenario: Cancel deleting two messages from a group conversation
     Given I log in as "student1"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "How are you?" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "How are you?" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
 #   Canceling deletion, so messages should be there
@@ -105,7 +105,7 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     Then I should not see "Cancel"
     And I should see "Hi!"
-    And I should see "How are you?" in the "Group 1" "group_message_conversation"
+    And I should see "How are you?" in the "Group 1" "core_message > Message conversation"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
 
   Scenario: Delete a message sent by the user from a private conversation
@@ -115,7 +115,7 @@ Feature: Delete messages from conversations
     And I open the "Private" conversations list
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -124,9 +124,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
-    And I should see "Are you free?" in the "Student 2" "group_message_conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
+    And I should see "Are you free?" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Delete two messages from a private conversation; one sent by another user
@@ -136,9 +136,9 @@ Feature: Delete messages from conversations
     And I open the "Private" conversations list
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hello!" "core_message > Message content"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -147,9 +147,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should not see "Hello!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Are you free?" in the "Student 2" "group_message_conversation"
+    And I should not see "Hello!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Are you free?" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 #   Check messages were not deleted for the other user
     And I log out
@@ -168,8 +168,8 @@ Feature: Delete messages from conversations
     And I open the "Private" conversations list
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "Hello!" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
 #   Canceling deletion, so messages should be there
@@ -177,7 +177,7 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     Then I should not see "Cancel"
     And I should see "Hi!"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
 
   Scenario: Delete a message sent by the user from a favorite conversation
@@ -188,7 +188,7 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -197,8 +197,8 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Delete two messages from a favourite conversation; one sent by another user
@@ -209,9 +209,9 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hello!" "core_message > Message content"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -220,9 +220,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should not see "Hello!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Are you free?" in the "Student 2" "group_message_conversation"
+    And I should not see "Hello!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Are you free?" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Cancel deleting two messages from a favourite conversation
@@ -233,8 +233,8 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "Hello!" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
 #   Canceling deletion, so messages should be there
@@ -242,7 +242,7 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     Then I should not see "Cancel"
     And I should see "Hi!"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
 
   Scenario: Check an empty favourite conversation is still favourite
@@ -253,9 +253,9 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in the "favourites" conversations list
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "Hello!" "group_message_message_content"
-    And I click on "Are you free?" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "Hello!" "core_message > Message content"
+    And I click on "Are you free?" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
     And I should see "Delete"
index 842b55e..51ca228 100644 (file)
@@ -31,35 +31,35 @@ Feature: Star and unstar conversations
     Given I log in as "student1"
     Then I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should see "Group 1" in the "favourites" "group_message_list_area"
+    And I should see "Group 1" in the "favourites" "core_message > Message list area"
     And I open the "Group" conversations list
-    And I should not see "Group 1" in the "group-messages" "group_message_list_area"
+    And I should not see "Group 1" in the "group-messages" "core_message > Message list area"
 
   Scenario: Unstar a group conversation
     Given I log in as "student1"
     Then I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should see "Group 1" in the "favourites" "group_message_list_area"
+    And I should see "Group 1" in the "favourites" "core_message > Message list area"
     And I select "Group 1" conversation in messaging
     And I open contact menu
     And I click on "Unstar" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Group 1" in the "favourites" "group_message_list_area"
+    And I should not see "Group 1" in the "favourites" "core_message > Message list area"
     And I open the "Group" conversations list
-    And I should see "Group 1" in the "group-messages" "group_message_list_area"
+    And I should see "Group 1" in the "group-messages" "core_message > Message list area"
 
   Scenario: Star a private conversation
     Given the following "private messages" exist:
@@ -68,15 +68,15 @@ Feature: Star and unstar conversations
     Then I log in as "student1"
     And I open messaging
     And I open the "Private" conversations list
-    And "Student 2" "group_message" should exist
+    And "Student 2" "core_message > Message" should exist
     And I select "Student 2" conversation in messaging
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should see "Student 2" in the "favourites" "group_message_list_area"
+    And I should see "Student 2" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should not see "Student 2" in the "messages" "group_message_list_area"
+    And I should not see "Student 2" in the "messages" "core_message > Message list area"
 
   Scenario: Unstar a private conversation
     Given the following "private messages" exist:
@@ -87,12 +87,12 @@ Feature: Star and unstar conversations
       | student1 | student2 |
     Then I log in as "student1"
     And I open messaging
-    And I should see "Student 2" in the "favourites" "group_message_list_area"
+    And I should see "Student 2" in the "favourites" "core_message > Message list area"
     And I select "Student 2" conversation in messaging
     And I open contact menu
     And I click on "Unstar" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Group 1" in the "favourites" "group_message_list_area"
+    And I should not see "Group 1" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should see "Student 2" in the "messages" "group_message_list_area"
\ No newline at end of file
+    And I should see "Student 2" in the "messages" "core_message > Message list area"
index e990727..bde9a02 100644 (file)
@@ -47,26 +47,26 @@ Feature: Create conversations for course's groups
     Given I log in as "teacher1"
     Then I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
-    And "Group 2" "group_message" should exist
-    And "Group 3" "group_message" should not exist
+    And "Group 1" "core_message > Message" should exist
+    And "Group 2" "core_message > Message" should exist
+    And "Group 3" "core_message > Message" should not exist
     And I log out
     And I log in as "student1"
     And I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
-    And "Group 2" "group_message" should not exist
-    And "Group 3" "group_message" should not exist
+    And "Group 1" "core_message > Message" should exist
+    And "Group 2" "core_message > Message" should not exist
+    And "Group 3" "core_message > Message" should not exist
 
   Scenario: View group conversation's participants numbers
     Given I log in as "teacher1"
     Then I open messaging
     And I open the "Group" conversations list
     And I select "Group 1" conversation in messaging
-    And I should see "5 participants" in the "Group 1" "group_message_header"
+    And I should see "5 participants" in the "Group 1" "core_message > Message header"
     And I go back in "view-conversation" message drawer
     And I select "Group 2" conversation in messaging
-    And I should see "1 participants" in the "Group 2" "group_message_header"
+    And I should see "1 participants" in the "Group 2" "core_message > Message header"
 
   Scenario: View group conversation's participants list
     Given I log in as "teacher1"
@@ -75,20 +75,20 @@ Feature: Create conversations for course's groups
     # Check Group 1 participants list.
     And I select "Group 1" conversation in messaging
     And I open messaging information
-    And "Teacher 1" "group_message_member" should not exist
-    And "Student 0" "group_message_member" should exist
-    And "Student 1" "group_message_member" should exist
-    And "Student 2" "group_message_member" should exist
-    And "Student 3" "group_message_member" should exist
-    And "Student 4" "group_message_member" should not exist
+    And "Teacher 1" "core_message > Message member" should not exist
+    And "Student 0" "core_message > Message member" should exist
+    And "Student 1" "core_message > Message member" should exist
+    And "Student 2" "core_message > Message member" should exist
+    And "Student 3" "core_message > Message member" should exist
+    And "Student 4" "core_message > Message member" should not exist
     And I go back in "group-info-content-container" message drawer
     And I go back in "view-conversation" message drawer
     # Check Group 2 participants list.
     And I select "Group 2" conversation in messaging
     And I open messaging information
-    And "Teacher 1" "group_message_member" should not exist
-    And "No participants" "group_message_member" should exist
-    And "Student 4" "group_message_member" should not exist
+    And "Teacher 1" "core_message > Message member" should not exist
+    And "No participants" "core_message > Message member" should exist
+    And "Student 4" "core_message > Message member" should not exist
 
   Scenario: Check group conversation members are synced when a new group member is added
     Given I log in as "teacher1"
@@ -99,13 +99,13 @@ Feature: Create conversations for course's groups
     And I open messaging
     And I open the "Group" conversations list
     And I select "Group 1" conversation in messaging
-    And I should see "6 participants" in the "Group 1" "group_message_header"
+    And I should see "6 participants" in the "Group 1" "core_message > Message header"
     And I open messaging information
-    And "Student 4" "group_message_member" should exist
+    And "Student 4" "core_message > Message member" should exist
     And I go back in "group-info-content-container" message drawer
     And I go back in "view-conversation" message drawer
     And I select "Group 2" conversation in messaging
-    And I should see "2 participants" in the "Group 2" "group_message_header"
+    And I should see "2 participants" in the "Group 2" "core_message > Message header"
     And I open messaging information
-    And "No participants" "group_message_member" should not exist
-    And "Student 4" "group_message_member" should exist
+    And "No participants" "core_message > Message member" should not exist
+    And "Student 4" "core_message > Message member" should exist
index 183c07a..5d7d65a 100644 (file)
@@ -35,17 +35,17 @@ Feature: Message delete conversations
     And I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
-    And I should not see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should not see "What do you need?" in the "Student 1" "group_message_conversation"
-    And I should not see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should not see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should not see "What do you need?" in the "Student 1" "core_message > Message conversation"
+    And I should not see "##today##j F##" in the "Student 1" "core_message > Message conversation"
 #   Check user is deleting private conversation only for them
     And I log out
     And I log in as "student1"
     And I open messaging
     And I select "Student 2" conversation in the "messages" conversations list
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "What do you need?" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "What do you need?" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Cancel deleting a private conversation
     Given I log in as "student1"
@@ -57,8 +57,8 @@ Feature: Message delete conversations
     And I should see "Cancel"
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     And I should not see "Cancel"
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Delete a starred conversation
     Given the following "favourite conversations" exist:
@@ -73,17 +73,17 @@ Feature: Message delete conversations
     And I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
-    And I should not see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should not see "What do you need?" in the "Student 2" "group_message_conversation"
-    And I should not see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should not see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should not see "What do you need?" in the "Student 2" "core_message > Message conversation"
+    And I should not see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 #   Check user is deleting private conversation only for them
     And I log out
     And I log in as "student2"
     And I open messaging
     And I select "Student 1" conversation in the "messages" conversations list
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should see "What do you need?" in the "Student 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should see "What do you need?" in the "Student 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 1" "core_message > Message conversation"
 
   Scenario: Cancel deleting a starred conversation
     Given the following "favourite conversations" exist:
@@ -92,16 +92,16 @@ Feature: Message delete conversations
     When I log in as "student1"
     And I open messaging
     And I select "Student 2" conversation in the "favourites" conversations list
-    Then I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    Then I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
     And I open contact menu
     And I click on "Delete conversation" "link" in the "//div[@data-region='header-container']" "xpath_element"
 #   Cancel deletion, so conversation should be there
     And I should see "Cancel"
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     And I should not see "Cancel"
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Check a deleted starred conversation is still starred
     Given the following "favourite conversations" exist:
@@ -115,10 +115,10 @@ Feature: Message delete conversations
     Then I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
-    And I should not see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should not see "Hi!" in the "Student 2" "core_message > Message conversation"
     And I go back in "view-conversation" message drawer
-    And I should not see "Student 2" in the "favourites" "group_message_list_area"
+    And I should not see "Student 2" in the "favourites" "core_message > Message list area"
     And I send "Hi!" message to "Student 2" user
     And I go back in "view-conversation" message drawer
     And I go back in "view-search" message drawer
-    And I should see "Student 2" in the "favourites" "group_message_list_area"
+    And I should see "Student 2" in the "favourites" "core_message > Message list area"
index 45f1a93..519cee1 100644 (file)
@@ -31,23 +31,23 @@ Feature: Message send messages
     Given I log in as "student1"
     And I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
     When I send "Hi!" message in the message area
-    Then I should see "Hi!" in the "Group 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
+    Then I should see "Hi!" in the "Group 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
     And I log out
     And I log in as "student2"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I should see "Hi!" in the "Group 1" "group_message_conversation"
+    And I should see "Hi!" in the "Group 1" "core_message > Message conversation"
 
   Scenario: Send a message to a starred conversation
     Given I log in as "student1"
     When I open messaging
     And I open the "Group" conversations list
-    Then "Group 1" "group_message" should exist
+    Then "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in the "group-messages" conversations list
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
@@ -56,11 +56,11 @@ Feature: Message send messages
     And I should see "Group 1"
     And I select "Group 1" conversation in the "favourites" conversations list
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "Group 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
+    And I should see "Hi!" in the "Group 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
     And I go back in "view-conversation" message drawer
     And I open the "Group" conversations list
-    And I should not see "Group 1" in the "Group" "group_message_tab"
+    And I should not see "Group 1" in the "Group" "core_message > Message tab"
 
   Scenario: Send a message to a private conversation via contact tab
     Given the following "message contacts" exist:
@@ -71,17 +71,17 @@ Feature: Message send messages
     And I click on "Contacts" "link"
     And I click on "Student 2" "link" in the "//*[@data-section='contacts']" "xpath_element"
     When I send "Hi!" message in the message area
-    Then I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    Then I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Try to send a message to a private conversation is not contact but you are allowed to send a message
     Given I log in as "student1"
     And I open messaging
     When I send "Hi!" message to "Student 2" user
-    Then I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    Then I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
     And I log out
     And I log in as "student2"
     And I open messaging
     And I select "Student 1" conversation in messaging
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
\ No newline at end of file
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
index 4a40e60..ca30488 100644 (file)
@@ -33,15 +33,15 @@ Feature: Mute and unmute conversations
     Given I log in as "student1"
     When I open messaging
     And I open the "Group" conversations list
-    Then "Group 1" "group_message" should exist
-    And "muted" "icon_container" in the "Group 1" "group_message" should not be visible
+    Then "Group 1" "core_message > Message" should exist
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should not be visible
     And I select "Group 1" conversation in messaging
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should not be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should not be visible
     And I open contact menu
     And I click on "Mute" "link" in the "[data-region='header-container']" "css_element"
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Group 1" "group_message" should be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should be visible
 
   Scenario: Mute a private conversation
     When I log in as "student1"
@@ -49,14 +49,14 @@ Feature: Mute and unmute conversations
     Then I should see "Private"
     And I open the "Private" conversations list
     And I should see "Student 2"
-    And "muted" "icon_container" in the "Student 2" "group_message" should not be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should not be visible
     And I select "Student 2" conversation in messaging
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should not be visible
     And I open contact menu
     And I click on "Mute" "link" in the "[data-region='header-container']" "css_element"
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Student 2" "group_message" should be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should be visible
 
   Scenario: Unmute a group conversation
     Given the following "muted group conversations" exist:
@@ -65,15 +65,15 @@ Feature: Mute and unmute conversations
     When I log in as "student1"
     And I open messaging
     And I open the "Group" conversations list
-    Then "Group 1" "group_message" should exist
-    And "muted" "icon_container" in the "Group 1" "group_message" should be visible
+    Then "Group 1" "core_message > Message" should exist
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should be visible
     And I select "Group 1" conversation in messaging
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should be visible
     And I open contact menu
     And I click on "Unmute" "link" in the "[data-region='header-container']" "css_element"
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should not be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should not be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Group 1" "group_message" should not be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should not be visible
 
   Scenario: Unmute a private conversation
     Given the following "muted private conversations" exist:
@@ -84,11 +84,11 @@ Feature: Mute and unmute conversations
     Then I should see "Private"
     And I open the "Private" conversations list
     And I should see "Student 2"
-    And "muted" "icon_container" in the "Student 2" "group_message" should be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should be visible
     And I select "Student 2" conversation in messaging
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should be visible
     And I open contact menu
     And I click on "Unmute" "link" in the "[data-region='header-container']" "css_element"
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should not be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Student 2" "group_message" should not be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should not be visible
index 5063b3c..ea7a669 100644 (file)
@@ -15,47 +15,47 @@ Feature: Self conversation
   Scenario: Self conversation exists
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student" conversation in messaging
     And I should see "Personal space"
 
   Scenario: Self conversation can be unstarred
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student" conversation in messaging
     And I open contact menu
-    And I click on "Unstar" "link" in the "Student 1" "group_message_header"
+    And I click on "Unstar" "link" in the "Student 1" "core_message > Message header"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Student 1" in the "favourites" "group_message_list_area"
+    And I should not see "Student 1" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should see "Student 1" in the "messages" "group_message_list_area"
+    And I should see "Student 1" in the "messages" "core_message > Message list area"
 
   Scenario: Self conversation can be deleted
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student 1" conversation in messaging
     And I open contact menu
-    And I click on "Delete conversation" "link" in the "Student 1" "group_message_header"
+    And I click on "Delete conversation" "link" in the "Student 1" "core_message > Message header"
     And I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Student 1" in the "favourites" "group_message_list_area"
+    And I should not see "Student 1" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should not see "Student 1" in the "messages" "group_message_list_area"
+    And I should not see "Student 1" in the "messages" "core_message > Message list area"
 
   Scenario: Send a message to a self-conversation via message drawer
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student 1" conversation in messaging
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 1" "core_message > Message conversation"
 
   Scenario: Send a message to a self-conversation via user profile
     Given I log in as "student1"
@@ -63,5 +63,5 @@ Feature: Self conversation
     Then I should see "Message"
     And I click on "Message" "icon"
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 1" "core_message > Message conversation"
index 97f5ffa..bf6095e 100644 (file)
@@ -31,23 +31,23 @@ Feature: Unread messages
     Given I log in as "student1"
     When I open messaging
     And I open the "Group" conversations list
-    Then "New group" "group_message" should exist
+    Then "New group" "core_message > Message" should exist
     And I select "New group" conversation in messaging
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "New group" "group_message_conversation"
-    And I should see "##today##j F##" in the "New group" "group_message_conversation"
+    And I should see "Hi!" in the "New group" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "New group" "core_message > Message conversation"
     And I log out
     And I log in as "student2"
     And I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
     And I open messaging
-    And I should see "1" in the "Group" "group_message_tab"
-    And "New group" "group_message" should exist
-    And I should see "1" in the "New group" "group_message"
+    And I should see "1" in the "Group" "core_message > Message tab"
+    And "New group" "core_message > Message" should exist
+    And I should see "1" in the "New group" "core_message > Message"
     And I select "New group" conversation in messaging
-    And I should see "Hi!" in the "New group" "group_message_conversation"
+    And I should see "Hi!" in the "New group" "core_message > Message conversation"
     And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
-    And I should not see "1" in the "Group" "group_message_tab"
-    And I should not see "1" in the "New group" "group_message"
+    And I should not see "1" in the "Group" "core_message > Message tab"
+    And I should not see "1" in the "New group" "core_message > Message"
 
   Scenario: Unread messages for private conversation
     Given the following "private messages" exist:
@@ -57,14 +57,14 @@ Feature: Unread messages
     When I log in as "student1"
     Then I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
     And I open messaging
-    And I should see "1" in the "Private" "group_message_tab"
-    And "Student 2" "group_message" should exist
-    And I should see "1" in the "Student 2" "group_message"
+    And I should see "1" in the "Private" "core_message > Message tab"
+    And "Student 2" "core_message > Message" should exist
+    And I should see "1" in the "Student 2" "core_message > Message"
     And I select "Student 2" conversation in messaging
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
     And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
-    And I should not see "1" in the "Private" "group_message_tab"
-    And I should not see "1" in the "Student 2" "group_message"
+    And I should not see "1" in the "Private" "core_message > Message tab"
+    And I should not see "1" in the "Student 2" "core_message > Message"
 
   Scenario: Unread messages for starred conversation
     Given the following "private messages" exist:
@@ -77,11 +77,11 @@ Feature: Unread messages
     When I log in as "student1"
     Then I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
     And I open messaging
-    And I should see "1" in the "Starred" "group_message_tab"
-    And "Student 2" "group_message" should exist
-    And I should see "1" in the "Student 2" "group_message"
+    And I should see "1" in the "Starred" "core_message > Message tab"
+    And "Student 2" "core_message > Message" should exist
+    And I should see "1" in the "Student 2" "core_message > Message"
     And I select "Student 2" conversation in messaging
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
     And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
-    And I should not see "1" in the "Starred" "group_message_tab"
-    And I should not see "1" in the "Student 2" "group_message"
+    And I should not see "1" in the "Starred" "core_message > Message tab"
+    And I should not see "1" in the "Student 2" "core_message > Message"
index 2aff436..117351b 100644 (file)
@@ -179,8 +179,11 @@ class summary_table extends table_sql {
      * @return string User's full name.
      */
     public function col_fullname($data): string {
-        global $OUTPUT;
+        if ($this->is_downloading()) {
+            return fullname($data);
+        }
 
+        global $OUTPUT;
         return $OUTPUT->user_picture($data, array('size' => 35, 'courseid' => $this->cm->course, 'includefullname' => true));
     }
 
@@ -384,6 +387,7 @@ class summary_table extends table_sql {
         $this->collapsible(false);
         $this->sortable(true, 'firstname', SORT_ASC);
         $this->pageable(true);
+        $this->is_downloadable(true);
         $this->no_sorting('select');
         $this->set_attribute('id', 'forumreport_summary_table');
     }
@@ -646,4 +650,17 @@ class summary_table extends table_sql {
 
         return (count($groups) < $groupsavailablecount);
     }
+
+    /**
+     * Download the summary report in the selected format.
+     *
+     * @param string $format The format to download the report.
+     */
+    public function download($format) {
+        $filename = 'summary_report_' . userdate(time(), get_string('backupnameformat', 'langconfig'),
+                99, false);
+
+        $this->is_downloading($format, $filename);
+        $this->out($this->perpage, false);
+    }
 }
index 78ac735..61f407b 100644 (file)
@@ -37,6 +37,8 @@ $filters = [];
 $filters['forums'] = [$forumid];
 $filters['groups'] = optional_param_array('filtergroups', [], PARAM_INT);
 
+$download = optional_param('download', '', PARAM_ALPHA);
+
 $cm = null;
 $modinfo = get_fast_modinfo($courseid);
 
@@ -73,27 +75,32 @@ $PAGE->set_title($forumname);
 $PAGE->set_heading($course->fullname);
 $PAGE->navbar->add(get_string('nodetitle', "forumreport_summary"));
 
-echo $OUTPUT->header();
-echo $OUTPUT->heading(get_string('summarytitle', 'forumreport_summary', $forumname), 2, 'p-b-2');
-
-if (!empty($filters['groups'])) {
-    \core\notification::info(get_string('viewsdisclaimer', 'forumreport_summary'));
-}
-
-// Render the report filters form.
-$renderer = $PAGE->get_renderer('forumreport_summary');
-echo $renderer->render_filters_form($cm, $url, $filters);
-
 // Prepare and display the report.
-$bulkoperations = !empty($CFG->messaging) && has_capability('moodle/course:bulkmessaging', $context);
+$bulkoperations = !$download && !empty($CFG->messaging) && has_capability('moodle/course:bulkmessaging', $context);
 
 $table = new \forumreport_summary\summary_table($courseid, $filters, $bulkoperations);
 $table->baseurl = $url;
 
-echo $renderer->render_summary_table($table, $perpage);
+if ($download) {
+    $table->download($download);
+} else {
+    echo $OUTPUT->header();
+    echo $OUTPUT->heading(get_string('summarytitle', 'forumreport_summary', $forumname), 2, 'p-b-2');
 
-if ($bulkoperations) {
-    echo $renderer->render_bulk_action_menu();
-}
+    if (!empty($filters['groups'])) {
+        \core\notification::info(get_string('viewsdisclaimer', 'forumreport_summary'));
+    }
+
+    // Render the report filters form.
+    $renderer = $PAGE->get_renderer('forumreport_summary');
+
+    echo $renderer->render_filters_form($cm, $url, $filters);
+    $table->show_download_buttons_at(array(TABLE_P_BOTTOM));
+    echo $renderer->render_summary_table($table, $perpage);
 
-echo $OUTPUT->footer();
+    if ($bulkoperations) {
+        echo $renderer->render_bulk_action_menu();
+    }
+
+    echo $OUTPUT->footer();
+}
index e4d5269..8eef0ef 100644 (file)
@@ -207,7 +207,7 @@ class custom_view extends \core_question\bank\view {
     }
 
     protected function print_category_info($category) {
-        $formatoptions = new stdClass();
+        $formatoptions = new \stdClass();
         $formatoptions->noclean = true;
         $strcategory = get_string('category', 'quiz');
         echo '<div class="categoryinfo"><div class="categorynamefieldcontainer">' .
index fa15f54..ecde327 100644 (file)
@@ -44,7 +44,7 @@ class question_name_text_column extends \core_question\bank\question_name_column
         if ($labelfor) {
             echo '<label for="' . $labelfor . '">';
         }
-        echo quiz_question_tostring($question);
+        echo quiz_question_tostring($question, false, true, true, $question->tags);
         if ($labelfor) {
             echo '</label>';
         }
@@ -55,6 +55,12 @@ class question_name_text_column extends \core_question\bank\question_name_column
         $fields = parent::get_required_fields();
         $fields[] = 'q.questiontext';
         $fields[] = 'q.questiontextformat';
+        $fields[] = 'q.idnumber';
         return $fields;
     }
+
+    public function load_additional_data(array $questions) {
+        parent::load_additional_data($questions);
+        parent::load_question_tags($questions);
+    }
 }
index 8db703b..ad16a5a 100644 (file)
@@ -2048,17 +2048,43 @@ class qubaids_for_quiz_user extends qubaid_join {
  * @param bool $showicon If true, show the question's icon with the question. False by default.
  * @param bool $showquestiontext If true (default), show question text after question name.
  *       If false, show only question name.
- * @return string
- */
-function quiz_question_tostring($question, $showicon = false, $showquestiontext = true) {
+ * @param bool $showidnumber If true, show the question's idnumber, if any. False by default.
+ * @param core_tag_tag[]|bool $showtags if array passed, show those tags. Else, if true, get and show tags,
+ *       else, don't show tags (which is the default).
+ * @return string HTML fragment.
+ */
+function quiz_question_tostring($question, $showicon = false, $showquestiontext = true,
+        $showidnumber = false, $showtags = false) {
+    global $OUTPUT;
     $result = '';
 
+    // Question name.
     $name = shorten_text(format_string($question->name), 200);
     if ($showicon) {
         $name .= print_question_icon($question) . ' ' . $name;
     }
     $result .= html_writer::span($name, 'questionname');
 
+    // Question idnumber.
+    if ($showidnumber && $question->idnumber !== null && $question->idnumber !== '') {
+        $result .= ' ' . html_writer::span(
+                html_writer::span(get_string('idnumber', 'question'), 'accesshide') .
+                ' ' . $question->idnumber, 'badge badge-primary');
+    }
+
+    // Question tags.
+    if (is_array($showtags)) {
+        $tags = $showtags;
+    } else if ($showtags) {
+        $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id);
+    } else {
+        $tags = [];
+    }
+    if ($tags) {
+        $result .= $OUTPUT->tag_list($tags, null, 'd-inline', 0, null, true);
+    }
+
+    // Question text.
     if ($showquestiontext) {
         $questiontext = question_utils::to_plain_text($question->questiontext,
                 $question->questiontextformat, array('noclean' => true, 'para' => false));
index eae1b28..ca30770 100644 (file)
@@ -968,8 +968,7 @@ table.quizreviewsummary td.cell {
     border: 0 none;
 }
 
-#categoryquestions th.modifiername .sorters,
-#categoryquestions th.creatorname .sorters {
+#categoryquestions th .sorters {
     font-weight: normal;
     font-size: 0.8em;
 }
index 56e983d..2e8708e 100644 (file)
@@ -21,9 +21,9 @@ Feature: Adding questions to a quiz from the question bank
       | contextlevel | reference | name           |
       | Course       | C1        | Test questions |
     And the following "questions" exist:
-      | questioncategory | qtype     | name             | user     | questiontext     |
-      | Test questions   | essay     | question 01 name | admin    | Question 01 text |
-      | Test questions   | essay     | question 02 name | teacher1 | Question 02 text |
+      | questioncategory | qtype     | name             | user     | questiontext     | idnumber |
+      | Test questions   | essay     | question 01 name | admin    | Question 01 text |          |
+      | Test questions   | essay     | question 02 name | teacher1 | Question 02 text | qidnum   |
 
   Scenario: The questions can be filtered by tag
     Given I log in as "teacher1"
@@ -42,9 +42,12 @@ Feature: Adding questions to a quiz from the question bank
     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"
+    Then I should see "foo" in the "question 01 name" "table_row"
+    And I should see "bar" in the "question 02 name" "table_row"
+    And I should see "qidnum" in the "question 02 name" "table_row"
     And 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 01 name" in the "categoryquestions" "table"
+    And I should see "question 01 name" in the "categoryquestions" "table"
     And I should not see "question 02 name" in the "categoryquestions" "table"
 
   Scenario: The question modal can be paginated
index 2249d11..3e3a53b 100644 (file)
@@ -35,7 +35,7 @@ Feature: Adding random questions to a quiz based on category and tags
       | Tags | foo |
     And I press "id_submitbutton"
     And I click on "Manage tags" "link" in the "question 2 name" "table_row"
-    And I set the following fields to these values:
+    And I set the following fields in the "Question tags" "dialogue" to these values:
       | Tags | bar |
     And I press "Save changes"
     And I am on "Course 1" course homepage
index 4c262dd..1ba7628 100644 (file)
@@ -67,7 +67,7 @@
         <testsuite name="core_cohort_testsuite">
             <directory suffix="_test.php">cohort/tests</directory>
         </testsuite>
-        <testsuite name="core_grade_testsuite">
+        <testsuite name="core_grades_testsuite">
             <directory suffix="_test.php">lib/grade/tests</directory>
             <directory suffix="_test.php">grade/tests</directory>
             <directory suffix="_test.php">grade/grading/tests</directory>
index 856da40..929ab9e 100644 (file)
@@ -143,8 +143,13 @@ class question_category_list_item extends list_item {
         $questionbankurl = new moodle_url('/question/edit.php', $this->parentlist->pageurl->params());
         $questionbankurl->param('cat', $category->id . ',' . $category->contextid);
         $item = '';
-        $text = format_string($category->name, true, ['context' => $this->parentlist->context])
-                . ' (' . $category->questioncount . ')';
+        $text = format_string($category->name, true, ['context' => $this->parentlist->context]);
+        if ($category->idnumber !== null && $category->idnumber !== '') {
+            $text .= ' ' . html_writer::span(
+                    html_writer::span(get_string('idnumber', 'question'), 'accesshide') .
+                    ' ' . $category->idnumber, 'badge badge-primary');
+        }
+        $text .= ' (' . $category->questioncount . ')';
         $item .= html_writer::tag('b', html_writer::link($questionbankurl, $text,
                         ['title' => $editqestions]) . ' ');
         $item .= format_text($category->info, $category->infoformat,
@@ -469,9 +474,8 @@ class question_category_object {
         $cat->infoformat = $newinfoformat;
         $cat->sortorder = 999;
         $cat->stamp = make_unique_id_code();
-        if ($idnumber) {
-            $cat->idnumber = $idnumber;
-        }
+        $cat->idnumber = $idnumber;
+
         $categoryid = $DB->insert_record("question_categories", $cat);
 
         // Log the creation of this category.
@@ -534,6 +538,7 @@ class question_category_object {
             }
         }
 
+        $updateidnumber = true;
         if ((string) $idnumber === '') {
             $idnumber = null;
         } else if (!empty($tocontextid)) {
@@ -541,6 +546,7 @@ class question_category_object {
             if ($DB->record_exists('question_categories',
                     ['idnumber' => $idnumber, 'contextid' => $tocontextid])) {
                 $idnumber = null;
+                $updateidnumber = false;
             }
         }
 
@@ -552,7 +558,7 @@ class question_category_object {
         $cat->infoformat = $newinfoformat;
         $cat->parent = $parentid;
         $cat->contextid = $tocontextid;
-        if ($idnumber) {
+        if ($updateidnumber) {
             $cat->idnumber = $idnumber;
         }
         if ($newstamprequired) {
index 9994b9f..58f892f 100644 (file)
@@ -34,7 +34,7 @@ namespace core_question\bank;
 
 abstract class column_base {
     /**
-     * @var question_bank_view
+     * @var view $qbank the question bank view we are helping to render.
      */
     protected $qbank;
 
@@ -43,7 +43,7 @@ abstract class column_base {
 
     /**
      * Constructor.
-     * @param $qbank the question_bank_view we are helping to render.
+     * @param view $qbank the question bank view we are helping to render.
      */
     public function __construct(view $qbank) {
         $this->qbank = $qbank;
@@ -103,8 +103,6 @@ abstract class column_base {
 
     /**
      * Title for this column. Not used if is_sortable returns an array.
-     * @param object $question the row from the $question table, augmented with extra information.
-     * @param string $rowclasses CSS class names that should be applied to this row of output.
      */
     protected abstract function get_title();
 
@@ -118,10 +116,10 @@ abstract class column_base {
 
     /**
      * Get a link that changes the sort order, and indicates the current sort state.
-     * @param $name internal name used for this type of sorting.
-     * @param $currentsort the current sort order -1, 0, 1 for descending, none, ascending.
-     * @param $title the link text.
-     * @param $defaultreverse whether the default sort order for this column is descending, rather than ascending.
+     * @param string $sort the column to sort on.
+     * @param string $title the link text.
+     * @param string $tip the link tool-tip text. If empty, defaults to title.
+     * @param bool $defaultreverse whether the default sort order for this column is descending, rather than ascending.
      * @return string HTML fragment.
      */
     protected function make_sort_link($sort, $title, $tip, $defaultreverse = false) {
@@ -149,7 +147,7 @@ abstract class column_base {
 
     /**
      * Get an icon representing the corrent sort state.
-     * @param $reverse sort is descending, not ascending.
+     * @param bool $reverse sort is descending, not ascending.
      * @return string HTML image tag.
      */
     protected function get_sort_icon($reverse) {
@@ -175,8 +173,8 @@ abstract class column_base {
     /**
      * Output the opening column tag.  If it is set as heading, it will use <th> tag instead of <td>
      *
-     * @param stdClass $question
-     * @param array $rowclasses
+     * @param \stdClass $question
+     * @param string $rowclasses
      */
     protected function display_start($question, $rowclasses) {
         $tag = 'td';
@@ -198,9 +196,10 @@ abstract class column_base {
     }
 
     /**
-     * @param object $question the row from the $question table, augmented with extra information.
-     * @return string internal name for this column. Used as a CSS class name,
-     *     and to store information about the current sort. Must match PARAM_ALPHA.
+     * Get the internal name for this column. Used as a CSS class name,
+     * and to store information about the current sort. Must match PARAM_ALPHA.
+     *
+     * @return string column name.
      */
     public abstract function get_name();
 
@@ -258,6 +257,42 @@ abstract class column_base {
         return array();
     }
 
+    /**
+     * If this column needs extra data (e.g. tags) then load that here.
+     *
+     * The extra data should be added to the question object in the array.
+     * Probably a good idea to check that another column has not already
+     * loaded the data you want.
+     *
+     * @param \stdClass[] $questions the questions that will be displayed.
+     */
+    public function load_additional_data(array $questions) {
+    }
+
+    /**
+     * Load the tags for each question.
+     *
+     * Helper that can be used from {@link load_additional_data()};
+     *
+     * @param array $questions
+     */
+    public function load_question_tags(array $questions) {
+        $firstquestion = reset($questions);
+        if (isset($firstquestion->tags)) {
+            // Looks like tags are already loaded, so don't do it again.
+            return;
+        }
+
+        // Load the tags.
+        $tagdata = \core_tag_tag::get_items_tags('core_question', 'question',
+                array_keys($questions));
+
+        // Add them to the question objects.
+        foreach ($tagdata as $questionid => $tags) {
+            $questions[$questionid]->tags = $tags;
+        }
+    }
+
     /**
      * Can this column be sorted on? You can return either:
      *  + false for no (the default),
@@ -265,7 +300,7 @@ abstract class column_base {
      *  + an array of subnames to sort on as follows
      *  return array(
      *      'firstname' => array('field' => 'uc.firstname', 'title' => get_string('firstname')),
-     *      'lastname' => array('field' => 'uc.lastname', 'field' => get_string('lastname')),
+     *      'lastname' => array('field' => 'uc.lastname', 'title' => get_string('lastname')),
      *  );
      * As well as field, and field, you can also add 'revers' => 1 if you want the default sort
      * order to be DESC.
@@ -278,7 +313,6 @@ abstract class column_base {
     /**
      * Helper method for building sort clauses.
      * @param bool $reverse whether the normal direction should be reversed.
-     * @param string $normaldir 'ASC' or 'DESC'
      * @return string 'ASC' or 'DESC'
      */
     protected function sortorder($reverse) {
@@ -290,8 +324,8 @@ abstract class column_base {
     }
 
     /**
-     * @param $reverse Whether to sort in the reverse of the default sort order.
-     * @param $subsort if is_sortable returns an array of subnames, then this will be
+     * @param bool $reverse Whether to sort in the reverse of the default sort order.
+     * @param string $subsort if is_sortable returns an array of subnames, then this will be
      *      one of those. Otherwise will be empty.
      * @return string some SQL to go in the order by clause.
      */
@@ -299,14 +333,14 @@ abstract class column_base {
         $sortable = $this->is_sortable();
         if (is_array($sortable)) {
             if (array_key_exists($subsort, $sortable)) {
-                return $sortable[$subsort]['field'] . $this->sortorder($reverse, !empty($sortable[$subsort]['reverse']));
+                return $sortable[$subsort]['field'] . $this->sortorder($reverse);
             } else {
-                throw new coding_exception('Unexpected $subsort type: ' . $subsort);
+                throw new \coding_exception('Unexpected $subsort type: ' . $subsort);
             }
         } else if ($sortable) {
             return $sortable . $this->sortorder($reverse);
         } else {
-            throw new coding_exception('sort_expression called on a non-sortable column.');
+            throw new \coding_exception('sort_expression called on a non-sortable column.');
         }
     }
 }
diff --git a/question/classes/bank/question_name_idnumber_tags_column.php b/question/classes/bank/question_name_idnumber_tags_column.php
new file mode 100644 (file)
index 0000000..b9dc3f8
--- /dev/null
@@ -0,0 +1,89 @@
+<?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 question bank column showing the question name with idnumber and tags.
+ *
+ * @package   core_question
+ * @copyright 2019 The Open University
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_question\bank;
+defined('MOODLE_INTERNAL') || die();
+
+
+/**
+ * A question bank column showing the question name with idnumber and tags.
+ *
+ * @copyright 2019 The Open University
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_name_idnumber_tags_column extends question_name_column {
+    public function get_name() {
+        return 'qnameidnumbertags';
+    }
+
+    protected function display_content($question, $rowclasses) {
+        global $OUTPUT;
+
+        $layoutclasses = 'd-inline-flex flex-nowrap overflow-hidden w-100';
+        $labelfor = $this->label_for($question);
+        if ($labelfor) {
+            echo '<label for="' . $labelfor . '" class="' . $layoutclasses . '">';
+            $closetag = '</label>';
+        } else {
+            echo '<span class="' . $layoutclasses . '">';
+            $closetag = '</span>';
+        }
+
+        // Question name.
+        echo \html_writer::span(format_string($question->name), 'questionname flex-grow-1 flex-shrink-1 text-truncate');
+
+        // Question idnumber.
+        if ($question->idnumber !== null && $question->idnumber !== '') {
+            echo ' ' . \html_writer::span(
+                            \html_writer::span(get_string('idnumber', 'question'), 'accesshide') . ' ' .
+                            \html_writer::span($question->idnumber, 'badge badge-primary'), 'ml-1');
+        }
+
+        // Question tags.
+        if (!empty($question->tags)) {
+            $tags = \core_tag_tag::get_item_tags('core_question', 'question', $question->id);
+            echo $OUTPUT->tag_list($tags, null, 'd-inline flex-shrink-1 text-truncate ml-1', 0, null, true);
+        }
+
+        echo $closetag; // Computed above to ensure it matches.
+    }
+
+    public function get_required_fields() {
+        $fields = parent::get_required_fields();
+        $fields[] = 'q.idnumber';
+        return $fields;
+    }
+
+    public function is_sortable() {
+        return [
+            'name' => ['field' => 'q.name', 'title' => get_string('questionname', 'question')],
+            'lastname' => ['field' => 'q.idnumber', 'title' => get_string('idnumber', 'question')],
+        ];
+    }
+
+    public function load_additional_data(array $questions) {
+        parent::load_additional_data($questions);
+        parent::load_question_tags($questions);
+    }
+}
index 12b781f..850ce2b 100644 (file)
@@ -17,6 +17,8 @@
 
 namespace core_question\bank;
 
+use core_question\bank\search\condition;
+
 /**
  * Functions used to show question editing interface
  *
@@ -50,21 +52,80 @@ namespace core_question\bank;
 class view {
     const MAX_SORTS = 3;
 
+    /**
+     * @var \moodle_url base URL for the current page. Used as the
+     * basis for making URLs for actions that reload the page.
+     */
     protected $baseurl;
+
+    /**
+     * @var \moodle_url used as a basis for URLs that edit a question.
+     */
     protected $editquestionurl;
-    protected $quizorcourseid;
+
+    /**
+     * @var \question_edit_contexts
+     */
     protected $contexts;
+
+    /**
+     * @var object|\cm_info|null if we are in a module context, the cm.
+     */
     protected $cm;
+
+    /**
+     * @var object the course we are within.
+     */
     protected $course;
+
+    /**
+     * @var \question_bank_column_base[] these are all the 'columns' that are
+     * part of the display. Array keys are the class name.
+     */
+    protected $requiredcolumns;
+
+    /**
+     * @var \question_bank_column_base[] these are the 'columns' that are
+     * actually displayed as a column, in order. Array keys are the class name.
+     */
     protected $visiblecolumns;
+
+    /**
+     * @var \question_bank_column_base[] these are the 'columns' that are
+     * actually displayed as an additional row (e.g. question text), in order.
+     * Array keys are the class name.
+     */
     protected $extrarows;
-    protected $requiredcolumns;
+
+    /**
+     * @var array list of column class names for which columns to sort on.
+     */
     protected $sort;
+
+    /**
+     * @var int|null id of the a question to highlight in the list (if present).
+     */
     protected $lastchangedid;
+
+    /**
+     * @var string SQL to count the number of questions matching the current
+     * search conditions.
+     */
     protected $countsql;
+
+    /**
+     * @var string SQL to actually load the question data to display.
+     */
     protected $loadsql;
+
+    /**
+     * @var array params used by $countsql and $loadsql (which currently must be the same).
+     */
     protected $sqlparams;
-    /** @var array of \core_question\bank\search\condition objects. */
+
+    /**
+     * @var condition[] search conditions.
+     */
     protected $searchconditions = array();
 
     /**
@@ -75,19 +136,11 @@ class view {
      * @param object $cm (optional) activity settings.
      */
     public function __construct($contexts, $pageurl, $course, $cm = null) {
-        global $CFG, $PAGE;
-
         $this->contexts = $contexts;
         $this->baseurl = $pageurl;
         $this->course = $course;
         $this->cm = $cm;
 
-        if (!empty($cm) && $cm->modname == 'quiz') {
-            $this->quizorcourseid = '&amp;quizid=' . $cm->instance;
-        } else {
-            $this->quizorcourseid = '&amp;courseid=' .$this->course->id;
-        }
-
         // Create the url of the new question page to forward to.
         $returnurl = $pageurl->out_as_local_url(false);
         $this->editquestionurl = new \moodle_url('/question/question.php',
@@ -102,7 +155,7 @@ class view {
 
         $this->init_columns($this->wanted_columns(), $this->heading_column());
         $this->init_sort();
-        $this->init_search_conditions($this->contexts, $this->course, $this->cm);
+        $this->init_search_conditions();
     }
 
     /**
@@ -124,9 +177,9 @@ class view {
 
         if (empty($CFG->questionbankcolumns)) {
             $questionbankcolumns = array('checkbox_column', 'question_type_column',
-                                     'question_name_column', 'tags_action_column', 'edit_action_column',
-                                     'copy_action_column', 'preview_action_column', 'delete_action_column',
-                                     'creator_name_column', 'modifier_name_column');
+                    'question_name_idnumber_tags_column', 'tags_action_column', 'edit_action_column',
+                    'copy_action_column', 'preview_action_column', 'delete_action_column',
+                    'creator_name_column', 'modifier_name_column');
         } else {
              $questionbankcolumns = explode(',', $CFG->questionbankcolumns);
         }
@@ -226,8 +279,10 @@ class view {
 
     /**
      * Deal with a sort name of the form columnname, or colname_subsort by
-     * breaking it up, validating the bits that are presend, and returning them.
+     * breaking it up, validating the bits that are present, and returning them.
      * If there is no subsort, then $subsort is returned as ''.
+     *
+     * @param string $sort the sort parameter to process.
      * @return array array($colname, $subsort).
      */
     protected function parse_subsort($sort) {
@@ -272,7 +327,7 @@ class view {
                 }
             }
             // Deal with subsorts.
-            list($colname, $subsort) = $this->parse_subsort($sort);
+            list($colname) = $this->parse_subsort($sort);
             $this->requiredcolumns[$colname] = $this->get_column_type($colname);
             $this->sort[$sort] = $order;
         }
@@ -296,7 +351,7 @@ class view {
     }
 
     /**
-     * @param $sort a column or column_subsort name.
+     * @param string $sort a column or column_subsort name.
      * @return int the current sort order for this column -1, 0, 1
      */
     public function get_primary_sort_order($sort) {
@@ -311,6 +366,7 @@ class view {
 
     /**
      * Get a URL to redisplay the page with a new sort for the question bank.
+     *
      * @param string $sort the column, or column_subsort to sort on.
      * @param bool $newsortreverse whether to sort in reverse order.
      * @return string The new URL.
@@ -336,7 +392,8 @@ class view {
 
     /**
      * Create the SQL query to retrieve the indicated questions
-     * @param stdClass $category no longer used.
+     *
+     * @param \stdClass $category no longer used.
      * @param bool $recurse no longer used.
      * @param bool $showhidden no longer used.
      * @deprecated since Moodle 2.7 MDL-40313.
@@ -355,8 +412,6 @@ class view {
      * \core_question\bank\search\condition filters.
      */
     protected function build_query() {
-        global $DB;
-
         // Get the required tables and fields.
         $joins = array();
         $fields = array('q.hidden', 'q.category');
@@ -402,12 +457,19 @@ class view {
         return $DB->count_records_sql($this->countsql, $this->sqlparams);
     }
 
+    /**
+     * Load the questions we need to display.
+     *
+     * @param int $page page to display.
+     * @param int $perpage number of questions per page.
+     * @return \moodle_recordset questionid => data about each question.
+     */
     protected function load_page_questions($page, $perpage) {
         global $DB;
         $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, $page * $perpage, $perpage);
-        if (!$questions->valid()) {
-            // No questions on this page. Reset to page 0.
+        if (empty($questions)) {
             $questions->close();
+            // No questions on this page. Reset to page 0.
             $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, 0, $perpage);
         }
         return $questions;
@@ -424,7 +486,7 @@ class view {
     /**
      * Get the URL for duplicating a given question.
      * @param int $questionid the question id.
-     * @return moodle_url the URL.
+     * @return string the URL, HTML-escaped.
      */
     public function copy_question_url($questionid) {
         return $this->editquestionurl->out(true, array('id' => $questionid, 'makecopy' => 1));
@@ -432,7 +494,7 @@ class view {
 
     /**
      * Get the context we are displaying the question bank for.
-     * @return context context object.
+     * @return \context context object.
      */
     public function get_most_specific_context() {
         return $this->contexts->lowest();
@@ -440,8 +502,8 @@ class view {
 
     /**
      * Get the URL to preview a question.
-     * @param stdClass $questiondata the data defining the question.
-     * @return moodle_url the URL.
+     * @param \stdClass $questiondata the data defining the question.
+     * @return \moodle_url the URL.
      */
     public function preview_question_url($questiondata) {
         return question_preview_url($questiondata->id, null, null, null, null,
@@ -458,7 +520,15 @@ class view {
      * deleteselected Deletes the selected questions from the category
      * Other actions:
      * category      Chooses the category
-     * displayoptions Sets display options
+     *
+     * @param string $tabname question bank edit tab name, for permission checking.
+     * @param int $page the page number to show.
+     * @param int $perpage the number of questions per page to show.
+     * @param string $cat 'categoryid,contextid'.
+     * @param int $recurse     Whether to include subcategories.
+     * @param bool $showhidden  whether deleted questions should be displayed.
+     * @param bool $showquestiontext whether the text of each question should be shown in the list. Deprecated.
+     * @param array $tagids current list of selected tags.
      */
     public function display($tabname, $page, $perpage, $cat,
             $recurse, $showhidden, $showquestiontext, $tagids = []) {
@@ -468,7 +538,7 @@ class view {
             return;
         }
         $editcontexts = $this->contexts->having_one_edit_tab_cap($tabname);
-        list($categoryid, $contextid) = explode(',', $cat);
+        list(, $contextid) = explode(',', $cat);
         $catcontext = \context::instance_by_id($contextid);
         $thiscontext = $this->get_most_specific_context();
         // Category selection form.
@@ -521,7 +591,7 @@ class view {
 
     /**
      * prints category information
-     * @param stdClass $category the category row from the database.
+     * @param \stdClass $category the category row from the database.
      * @deprecated since Moodle 2.7 MDL-40313.
      * @see \core_question\bank\search\condition
      * @todo MDL-41978 This will be deleted in Moodle 2.8
@@ -568,7 +638,7 @@ class view {
      */
     protected function display_options($recurse, $showhidden, $showquestiontext) {
         debugging('display_options() is deprecated, please use display_options_form instead.', DEBUG_DEVELOPER);
-        return $this->display_options_form($showquestiontext);
+        $this->display_options_form($showquestiontext);
     }
 
     /**
@@ -594,7 +664,7 @@ class view {
     /**
      * Display the form with options for which questions are displayed and how they are displayed.
      * @param bool $showquestiontext Display the text of the question within the list.
-     * @param string $path path to the script displaying this page.
+     * @param string $scriptpath path to the script displaying this page.
      * @param bool $showtextoption whether to include the 'Show question text' checkbox.
      */
     protected function display_options_form($showquestiontext, $scriptpath = '/question/edit.php',
@@ -619,7 +689,7 @@ class view {
         echo \html_writer::input_hidden_params($this->baseurl, $excludes);
 
         foreach ($this->searchconditions as $searchcondition) {
-            echo $searchcondition->display_options($this);
+            echo $searchcondition->display_options();
         }
         if ($showtextoption) {
             $this->display_showtext_checkbox($showquestiontext);
@@ -639,7 +709,7 @@ class view {
         print_collapsible_region_start('', 'advancedsearch', get_string('advancedsearchoptions', 'question'),
                                                'question_bank_advanced_search');
         foreach ($this->searchconditions as $searchcondition) {
-            echo $searchcondition->display_options_adv($this);
+            echo $searchcondition->display_options_adv();
         }
         print_collapsible_region_end();
     }
@@ -666,7 +736,6 @@ class view {
     }
 
     protected function create_new_question_form($category, $canadd) {
-        global $CFG;
         echo '<div class="createnewquestion">';
         if ($canadd) {
             create_new_question_button($category->id, $this->editquestionurl->params(),
@@ -681,20 +750,20 @@ class view {
      * Prints the table of questions in a category with interactions
      *
      * @param array      $contexts    Not used!
-     * @param moodle_url $pageurl     The URL to reload this page.
+     * @param \moodle_url $pageurl     The URL to reload this page.
      * @param string     $categoryandcontext 'categoryID,contextID'.
-     * @param stdClass   $cm          Not used!
-     * @param bool       $recurse     Whether to include subcategories.
+     * @param \stdClass  $cm          Not used!
+     * @param int        $recurse     Whether to include subcategories.
      * @param int        $page        The number of the page to be displayed
      * @param int        $perpage     Number of questions to show per page
-     * @param bool       $showhidden  whether deleted questions should be displayed.
-     * @param bool       $showquestiontext whether the text of each question should be shown in the list. Deprecated.
+     * @param bool       $showhidden  Not used! This is now controlled in a different way.
+     * @param bool       $showquestiontext Not used! This is now controlled in a different way.
      * @param array      $addcontexts contexts where the user is allowed to add new questions.
      */
     protected function display_question_list($contexts, $pageurl, $categoryandcontext,
             $cm = null, $recurse=1, $page=0, $perpage=100, $showhidden=false,
             $showquestiontext = false, $addcontexts = array()) {
-        global $CFG, $DB, $OUTPUT, $PAGE;
+        global $OUTPUT;
 
         // This function can be moderately slow with large question counts and may time out.
         // We probably do not want to raise it to unlimited, so randomly picking 5 minutes.
@@ -715,11 +784,18 @@ class view {
         if ($totalnumber == 0) {
             return;
         }
-        $questions = $this->load_page_questions($page, $perpage);
+        $questionsrs = $this->load_page_questions($page, $perpage);
+        $questions = [];
+        foreach ($questionsrs as $question) {
+            $questions[$question->id] = $question;
+        }
+        $questionsrs->close();
+        foreach ($this->requiredcolumns as $name => $column) {
+            $column->load_additional_data($questions);
+        }
 
         echo '<div class="categorypagingbarcontainer">';
-        $pageingurl = new \moodle_url('edit.php');
-        $r = $pageingurl->params($pageurl->params());
+        $pageingurl = new \moodle_url('edit.php', $pageurl->params());
         $pagingbar = new \paging_bar($totalnumber, $page, $perpage, $pageingurl);
         $pagingbar->pagevar = 'qpage';
         echo $OUTPUT->render($pagingbar);
@@ -737,7 +813,6 @@ class view {
             $this->print_table_row($question, $rowcount);
             $rowcount += 1;
         }
-        $questions->close();
         $this->end_table();
         echo "</div>\n";
 
@@ -771,8 +846,8 @@ class view {
      * Display the controls at the bottom of the list of questions.
      * @param int      $totalnumber Total number of questions that might be shown (if it was not for paging).
      * @param bool     $recurse     Whether to include subcategories.
-     * @param stdClass $category    The question_category row from the database.
-     * @param context  $catcontext  The context of the category being displayed.
+     * @param \stdClass $category    The question_category row from the database.
+     * @param \context  $catcontext  The context of the category being displayed.
      * @param array    $addcontexts contexts where the user is allowed to add new questions.
      */
     protected function display_bottom_controls($totalnumber, $recurse, $category, \context $catcontext, array $addcontexts) {
@@ -865,7 +940,7 @@ class view {
     }
 
     public function process_actions() {
-        global $CFG, $DB;
+        global $DB;
         // Now, check for commands on this page and modify variables as necessary.
         if (optional_param('move', false, PARAM_BOOL) and confirm_sesskey()) {
             // Move selected questions to new category.
@@ -886,7 +961,6 @@ class view {
             }
             if ($questionids) {
                 list($usql, $params) = $DB->get_in_or_equal($questionids);
-                $sql = "";
                 $questions = $DB->get_records_sql("
                         SELECT q.*, c.contextid
                         FROM {question} q
@@ -976,11 +1050,13 @@ class view {
 
             return true;
         }
+
+        return false;
     }
 
     /**
      * Add another search control to this view.
-     * @param \core_question\bank\search\condition $searchcondition the condition to add.
+     * @param condition $searchcondition the condition to add.
      */
     public function add_searchcondition($searchcondition) {
         $this->searchconditions[] = $searchcondition;
index e6e6096..612e4fb 100644 (file)
@@ -34,7 +34,7 @@ Feature: A teacher can duplicate questions in the question bank
     Then I should see "Duplicated question name"
     And I should see "Test question to be copied"
     And "Duplicated question name" row "Last modified by" column of "categoryquestions" table should contain "Teacher 1"
-    And "Test question to be copied" row "Created by" column of "categoryquestions" table should contain "Admin User"
+    And "Test question to be copied ID number qid" row "Created by" column of "categoryquestions" table should contain "Admin User"
 
   @javascript
   Scenario: Duplicated questions automatically get a new name suggested
index 6d7edf2..b0786e0 100644 (file)
@@ -32,9 +32,11 @@ Feature: A teacher can put questions in categories in the question bank
       | Name            | New Category 1    |
       | Parent category | Top               |
       | Category info   | Created as a test |
+      | ID number       | newcatidnumber    |
     And I press "submitbutton"
-    Then I should see "New Category 1 (0)"
+    Then I should see "New Category 1 ID number newcatidnumber (0)"
     And I should see "Created as a test" in the "New Category 1" "list_item"
+    And "New Category 1 [newcatidnumber]" "option" should exist in the "Parent category" "select"
 
   Scenario: A question category can be edited
     When I navigate to "Question bank > Categories" in current page administration
index 695a5a4..021fc14 100644 (file)
@@ -35,7 +35,7 @@ Feature: A teacher can put questions with idnumbers in categories with idnumbers
     # Correction to a unique idnumber for the context.
     And I set the field "ID number" to "c1unused"
     And I press "Add category"
-    Then I should see "Sub used category (0)"
+    Then I should see "Sub used category ID number c1unused (0)"
     And I should see "Created as a test" in the "Sub used category" "list_item"
 
   Scenario: A question category can be edited and saved without changing the idnumber
index 92ba9b5..7a5c84d 100644 (file)
@@ -18,10 +18,10 @@ Feature: The questions in the question bank can be sorted in various ways
       | contextlevel | reference | name           |
       | Course       | C1        | Test questions |
     And the following "questions" exist:
-      | questioncategory | qtype     | name              | user     | questiontext    |
-      | Test questions   | essay     | A question 1 name | admin    | Question 1 text |
-      | Test questions   | essay     | B question 2 name | teacher1 | Question 2 text |
-      | Test questions   | numerical | C question 3 name | teacher1 | Question 3 text |
+      | questioncategory | qtype     | name              | user     | questiontext    | idnumber |
+      | Test questions   | essay     | A question 1 name | admin    | Question 1 text |          |
+      | Test questions   | essay     | B question 2 name | teacher1 | Question 2 text |          |
+      | Test questions   | numerical | C question 3 name | teacher1 | Question 3 text | numidnum |
     And I log in as "teacher1"
     And I am on "Course 1" course homepage
     And I navigate to "Question bank > Questions" in current page administration
@@ -30,6 +30,12 @@ Feature: The questions in the question bank can be sorted in various ways
   Scenario: The questions are sorted by type by default
     Then "A question 1 name" "checkbox" should appear before "C question 3 name" "checkbox"
 
+  @javascript
+  Scenario: The questions can be sorted by idnumber
+    When I follow "Sort by ID number ascending"
+    Then "C question 3 name" "checkbox" should appear before "A question 1 name" "checkbox"
+    And I should see "numidnum" in the "C question 3 name" "table_row"
+
   @javascript
   Scenario: The questions can be sorted in reverse order by type
     When I follow "Sort by Question type descending"
@@ -37,14 +43,14 @@ Feature: The questions in the question bank can be sorted in various ways
 
   @javascript
   Scenario: The questions can be sorted by name
-    When I follow "Sort by Question ascending"
+    When I follow "Sort by Question name ascending"
     Then "A question 1 name" "checkbox" should appear before "B question 2 name" "checkbox"
     And "B question 2 name" "checkbox" should appear before "C question 3 name" "checkbox"
 
   @javascript
   Scenario: The questions can be sorted in reverse order by name
-    When I follow "Sort by Question ascending"
-    And I follow "Sort by Question descending"
+    When I follow "Sort by Question name ascending"
+    And I follow "Sort by Question name descending"
     Then "C question 3 name" "checkbox" should appear before "B question 2 name" "checkbox"
     And "B question 2 name" "checkbox" should appear before "A question 1 name" "checkbox"
 
index 94f5e58..cb8daf9 100644 (file)
@@ -1,5 +1,13 @@
 This files describes API changes for code that uses the question API.
 
+=== 3.8 ===
+
+If you have customised the display of the question bank (using $CFG->questionbankcolumns)
+then be aware that the default configuration has changed, and you may wish to make
+equivalent changes in your customised version. The old column question_name_column
+has been replaced by question_name_idnumber_tags_column. The old question_name_column
+still exists, so it is safe to continue using it.
+
 === 3.7 ===
 
 The code for the is_valid_number function that was duplicated in the
index df821f4..dda618d 100644 (file)
@@ -45,6 +45,9 @@ class taglist implements templatable {
     /** @var string */
     protected $label;
 
+    /** @var bool $accesshidelabel if true, the label should have class="accesshide" added. */
+    protected $accesshidelabel;
+
     /** @var string */
     protected $classes;
 
@@ -59,14 +62,17 @@ class taglist implements templatable {
      *               to use default, set to '' (empty string) to omit the label completely
      * @param string $classes additional classes for the enclosing div element
      * @param int $limit limit the number of tags to display, if size of $tags is more than this limit the "more" link
-     *               will be appended to the end, JS will toggle the rest of the tags
+     *               will be appended to the end, JS will toggle the rest of the tags. 0 means no limit.
      * @param context $pagecontext specify if needed to overwrite the current page context for the view tag link
+     * @param bool $accesshidelabel if true, the label should have class="accesshide" added.
      */
-    public function __construct($tags, $label = null, $classes = '', $limit = 10, $pagecontext = null) {
+    public function __construct($tags, $label = null, $classes = '',
+            $limit = 10, $pagecontext = null, $accesshidelabel = false) {
         global $PAGE;
         $canmanagetags = has_capability('moodle/tag:manage', \context_system::instance());
 
         $this->label = ($label === null) ? get_string('tags') : $label;
+        $this->accesshidelabel = $accesshidelabel;
         $this->classes = $classes;
         $fromctx = $pagecontext ? $pagecontext->id :
                 (($PAGE->context->contextlevel == CONTEXT_SYSTEM) ? 0 : $PAGE->context->id);
@@ -106,6 +112,7 @@ class taglist implements templatable {
         return (object)array(
             'tags' => array_values($this->tags),
             'label' => $this->label,
+            'accesshidelabel' => $this->accesshidelabel,
             'tagscount' => $cnt,
             'overflow' => ($this->limit && $cnt > $this->limit) ? 1 : 0,
             'classes' => $this->classes,
index 5047197..ba04be8 100644 (file)
@@ -648,7 +648,8 @@ class core_tag_tag {
      * @param int[] $itemids
      * @param int $standardonly wether to return only standard tags or any
      * @param int $tiuserid tag instance user id, only needed for tag areas with user tagging
-     * @return core_tag_tag[] each object contains additional fields taginstanceid, taginstancecontextid and ordering
+     * @return core_tag_tag[][] first array key is itemid. For each itemid,
+     *      an array tagid => tag object with additional fields taginstanceid, taginstancecontextid and ordering
      */
     public static function get_items_tags($component, $itemtype, $itemids, $standardonly = self::BOTH_STANDARD_AND_NOT,
             $tiuserid = 0) {
index 72975fd..7dc30a3 100644 (file)
@@ -38,6 +38,7 @@
         {"id":1,"name":"Mice","viewurl":"http://moodle.org/tag/index.php?tag=Mice","isstandard":"0","flag":0}
     ],
         "label": "Tags",
+        "accesshidelabel": false,
         "tagscount": 3,
         "overflow": 1,
         "classes": "someadditionalclass"
@@ -47,7 +48,7 @@
 {{#tagscount}}
     <div class="tag_list hideoverlimit {{classes}}">
     {{#label}}
-    <b>{{label}}:</b>
+        <b{{#accesshidelabel}} class="accesshide"{{/accesshidelabel}}>{{label}}:</b>
     {{/label}}
     <ul class="inline-list">
         {{#tags}}