Merge branch 'MDL-65843-task-overrides' of https://github.com/jamie-catalyst/moodle
authorAdrian Greeve <abgreeve@gmail.com>
Thu, 19 Nov 2020 03:29:01 +0000 (11:29 +0800)
committerAdrian Greeve <abgreeve@gmail.com>
Thu, 19 Nov 2020 03:29:01 +0000 (11:29 +0800)
72 files changed:
admin/tool/behat/tests/behat/keyboard.feature [new file with mode: 0644]
admin/tool/dataprivacy/tests/behat/manage_data_requests.feature
admin/tool/dataprivacy/tests/behat/manage_purposes.feature
admin/tool/lp/tests/behat/course_competencies.feature
admin/tool/lp/tests/behat/plan_crud.feature
admin/tool/task/schedule_task.php
admin/tool/templatelibrary/classes/output/list_templates_page.php
admin/tool/templatelibrary/lang/en/tool_templatelibrary.php
admin/tool/templatelibrary/templates/list_templates_page.mustache
blocks/myoverview/classes/output/main.php
blocks/site_main_menu/tests/behat/edit_activities.feature
blocks/social_activities/tests/behat/edit_activities.feature
cohort/tests/behat/add_cohort.feature
course/format/topics/tests/behat/edit_delete_sections.feature
course/format/weeks/tests/behat/edit_delete_sections.feature
course/tests/behat/activities_edit_name.feature
customfield/tests/behat/edit_categories.feature
enrol/meta/lib.php
enrol/meta/tests/plugin_test.php
enrol/tests/behat/add_to_group.feature
grade/report/grader/index.php
grade/report/grader/tests/behat/ajax_grader.feature
grade/report/lib.php
lib/amd/build/fullscreen.min.js [new file with mode: 0644]
lib/amd/build/fullscreen.min.js.map [new file with mode: 0644]
lib/amd/build/modal.min.js
lib/amd/build/modal.min.js.map
lib/amd/build/modal_backdrop.min.js
lib/amd/build/modal_backdrop.min.js.map
lib/amd/src/fullscreen.js [new file with mode: 0644]
lib/amd/src/modal.js
lib/amd/src/modal_backdrop.js
lib/behat/behat_base.php
lib/behat/form_field/behat_form_autocomplete.php
lib/db/upgrade.php
lib/moodlelib.php
lib/tablelib.php
lib/tests/behat/action_menu.feature
lib/tests/behat/behat_general.php
lib/upgrade.txt
lib/weblib.php
message/tests/behat/message_manage_preferences.feature
mod/assign/gradingtable.php
mod/assign/tests/locallib_test.php
mod/folder/classes/external.php
mod/folder/tests/externallib_test.php
mod/forum/tests/behat/advanced_search.feature
mod/forum/tests/behat/grade_view_discussion.feature [new file with mode: 0644]
mod/lti/tests/behat/renametool.feature
mod/quiz/tests/behat/editing_add_from_question_bank.feature
mod/quiz/tests/behat/editing_set_marks_no_attempts.feature
mod/quiz/tests/behat/editing_set_marks_with_attempts.feature
mod/scorm/module.js
mod/scorm/request.js
question/preview.php
question/tests/behat/filter_questions_by_tag.feature
question/type/ddimageortext/tests/behat/behat_qtype_ddimageortext.php
question/type/ddmarker/tests/behat/behat_qtype_ddmarker.php
question/type/ddmarker/tests/behat/preview.feature
question/type/ddwtos/tests/behat/behat_qtype_ddwtos.php
report/competency/tests/behat/breakdown_by_activity.feature
tag/tests/behat/collections.feature
tag/tests/behat/edit_tag.feature
theme/boost/scss/moodle/question.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/amd/build/participants.min.js
user/amd/build/participants.min.js.map
user/amd/src/participants.js
user/tests/behat/edit_user_roles.feature
user/tests/behat/filter_participants.feature
user/tests/behat/view_participants.feature

diff --git a/admin/tool/behat/tests/behat/keyboard.feature b/admin/tool/behat/tests/behat/keyboard.feature
new file mode 100644 (file)
index 0000000..ca9a74f
--- /dev/null
@@ -0,0 +1,52 @@
+@tool_behat
+Feature: Verify that keyboard steps work as expected
+  In order to use behat step definitions
+  As a test writer
+  I need to verify that the keyboard steps work as expected
+
+  @javascript
+  Scenario: Typing keys into a field causes them to be input
+    Given the following "users" exist:
+      | username | email                        | firstname | lastname | password    |
+      | saffronr | saffron.rutledge@example.com | Saffron   | Rutledge | flowerpower |
+    Given I click on "Log in" "link"
+    And I click on "Username" "field"
+    When I type "saffronr"
+    And I press the tab key
+    And I type "flowerpower"
+    And I press enter
+    Then I should see "You are logged in as Saffron Rutledge"
+
+  @javascript
+  Scenario: Using tab changes focus to the next or previous field
+    Given I click on "Log in" "link"
+    And I click on "Username" "field"
+    And the focused element is "Username" "field"
+    When I press the tab key
+    Then the focused element is "Password" "field"
+
+    And I press the shift tab key
+    And the focused element is "Username" "field"
+
+  @javascript
+  Scenario: Using the arrow keys allows me to navigate through menus
+    Given the following "users" exist:
+      | username | email                        | firstname | lastname |
+      | saffronr | saffron.rutledge@example.com | Saffron   | Rutledge |
+    And I log in as "saffronr"
+    And I click on "Saffron Rutledge" "link" in the ".usermenu" "css_element"
+    When I press the up key
+    Then the focused element is "Log out" "link"
+
+  @javascript
+  Scenario: The escape key can be used to close a dialogue
+    Given the following "course" exists:
+     | fullname  | C1|
+     | shortname | C1 |
+    And I log in as "admin"
+    And I am on "C1" course homepage
+    And I navigate to course participants
+    And I press "Enrol users"
+    And "Enrol users" "dialogue" should be visible
+    When I press the escape key
+    Then "Enrol users" "dialogue" should not be visible
index bf839ed..0c0d943 100644 (file)
@@ -46,7 +46,7 @@ Feature: Manage data requests
     And I open the action menu in "John Doe" "table_row"
     And I should see "View the request"
     But I should not see "Mark as complete"
-    And I press key "27" in ".moodle-actionmenu" "css_element"
+    And I press the escape key
     And I open the action menu in "Jane Doe" "table_row"
     And I choose "Mark as complete" in the open action menu
     And I should see "Do you really want to mark this user enquiry as complete?"
index dcd0c2a..6619bda 100644 (file)
@@ -15,10 +15,10 @@ Feature: Manage data storage purposes
     And I click on ".form-autocomplete-downarrow" "css_element" in the "Lawful bases" "form_row"
     And I click on "Contract (GDPR Art. 6.1(b))" "list_item"
     And I click on "Legal obligation (GDPR Art 6.1(c))" "list_item"
-    And I press key "27" in the field "Lawful bases"
+    And I press the escape key
     And I click on ".form-autocomplete-downarrow" "css_element" in the "Sensitive personal data processing reasons" "form_row"
     And I click on "Explicit consent (GDPR Art. 9.2(a))" "list_item"
-    And I press key "27" in the field "Sensitive personal data processing reasons"
+    And I press the escape key
     And I set the field "retentionperiodnumber" to "2"
     When I press "Save"
     Then I should see "Purpose 1" in the "List of data purposes" "table"
@@ -36,7 +36,7 @@ Feature: Manage data storage purposes
     And I click on "Legal obligation (GDPR Art 6.1(c))" "text" in the ".form-autocomplete-selection" "css_element"
     And I click on ".form-autocomplete-downarrow" "css_element" in the "Lawful bases" "form_row"
     And I click on "Vital interests (GDPR Art. 6.1(d))" "list_item"
-    And I press key "27" in the field "Lawful bases"
+    And I press the escape key
     And I set the field "retentionperiodnumber" to "3"
     And I click on "protected" "checkbox"
     When I press "Save changes"
index bb57d22..b3caddc 100644 (file)
@@ -53,11 +53,11 @@ Feature: See the competencies for an activity on the course competencies page.
     Then I should see "Test-Comp1"
     And I should see "Test-Comp2"
     And I set the field "Filter competencies by resource or activity" to "PageName1"
-    And I press key "13" in the field "Filter competencies by resource or activity"
+    And I press the enter key
     And I should see "Test-Comp1"
     And I should not see "Test-Comp2"
     And I set the field "Filter competencies by resource or activity" to "PageName2"
-    And I press key "13" in the field "Filter competencies by resource or activity"
+    And I press the enter key
     And I should not see "Test-Comp1"
     And I should not see "Test-Comp2"
     And I should see "No competencies have been linked to this activity or resource."
index 0fbbfd1..55c085a 100644 (file)
@@ -30,7 +30,7 @@ Feature: Manage plearning plan
     And I click on ".template-userplans" "css_element" in the "Science template" "table_row"
     And I open the autocomplete suggestions list
     And I click on "Admin User" item in the autocomplete list
-    And I press key "27" in the field "Select users to create learning plans for"
+    And I press the escape key
     When I click on "Create learning plans" "button"
     Then I should see "A learning plan was created"
     And I should see "Admin User" in the "Science template" "table_row"
@@ -55,7 +55,7 @@ Feature: Manage plearning plan
     And I click on ".template-cohorts" "css_element" in the "Science template cohort" "table_row"
     And I click on ".form-autocomplete-downarrow" "css_element"
     And I click on "cohort plan" item in the autocomplete list
-    And I press key "27" in the field "Select cohorts to sync"
+    And I press the escape key
     When I click on "Add cohorts" "button"
     Then I should see "2 learning plans were created."
     And I follow "Learning plan templates"
index 1b0de98..3c5aa2e 100644 (file)
@@ -83,6 +83,8 @@ if (!optional_param('confirm', 0, PARAM_INT)) {
 // Action requires session key.
 require_sesskey();
 
+\core\session\manager::write_close();
+
 // Prepare to handle output via mtrace.
 echo html_writer::start_tag('pre');
 $CFG->mtrace_wrapper = 'tool_task_mtrace_wrapper';
index cecf9cb..ebd4500 100644 (file)
@@ -27,6 +27,8 @@ use renderable;
 use templatable;
 use renderer_base;
 use stdClass;
+use core_collator;
+use core_component;
 use core_plugin_manager;
 use tool_templatelibrary\api;
 
@@ -44,29 +46,47 @@ class list_templates_page implements renderable, templatable {
      * @return stdClass
      */
     public function export_for_template(renderer_base $output) {
-        $data = new stdClass();
-        $data->allcomponents = array();
         $fulltemplatenames = api::list_templates();
         $pluginmanager = core_plugin_manager::instance();
-        $components = array();
+        $components = [];
 
         foreach ($fulltemplatenames as $templatename) {
-            list($component, $templatename) = explode('/', $templatename, 2);
-            $components[$component] = 1;
-        }
+            [$component, ] = explode('/', $templatename, 2);
+            [$type, ] = core_component::normalize_component($component);
+
+            // Core sub-systems are grouped together and are denoted by a distinct lang string.
+            $coresubsystem = (strpos($component, 'core') === 0);
+
+            if (!array_key_exists($type, $components)) {
+                $typename = $coresubsystem
+                    ? get_string('core', 'tool_templatelibrary')
+                    : $pluginmanager->plugintype_name_plural($type);
 
-        $components = array_keys($components);
-        foreach ($components as $component) {
-            $info = new stdClass();
-            $info->component = $component;
-            if (strpos($component, 'core') === 0) {
-                $info->name = get_string('coresubsystem', 'tool_templatelibrary', $component);
-            } else {
-                $info->name = $pluginmanager->plugin_name($component);
+                $components[$type] = (object) [
+                    'type' => $typename,
+                    'plugins' => [],
+                ];
             }
-            $data->allcomponents[] = $info;
+
+            $pluginname = $coresubsystem
+                ? get_string('coresubsystem', 'tool_templatelibrary', $component)
+                : $pluginmanager->plugin_name($component);
+
+            $components[$type]->plugins[$component] = (object) [
+                'name' => $pluginname,
+                'component' => $component,
+            ];
         }
 
-        return $data;
+        // Sort returned components according to their type, followed by name.
+        core_collator::asort_objects_by_property($components, 'type');
+        array_walk($components, function(stdClass $component) {
+            core_collator::asort_objects_by_property($component->plugins, 'name');
+            $component->plugins = array_values($component->plugins);
+        });
+
+        return (object) [
+            'allcomponents' => array_values($components),
+        ];
     }
 }
index 4f3134b..e62b529 100644 (file)
@@ -24,6 +24,7 @@
 
 $string['all'] = 'All components';
 $string['component'] = 'Component';
+$string['core'] = 'Core';
 $string['coresubsystem'] = 'Subsystem ({$a})';
 $string['documentation'] = 'Documentation';
 $string['example'] = 'Example';
index c5a454e..71d8042 100644 (file)
             <select id="selectcomponent" class="form-control" data-field="component">
                 <option value="">{{#str}}all, tool_templatelibrary{{/str}}</option>
                 {{#allcomponents}}
-                    <option value="{{component}}">{{name}}</option>
+                    <optgroup label="{{type}}">
+                        {{#plugins}}
+                            <option value="{{component}}">{{name}}</option>
+                        {{/plugins}}
+                    </optgroup>
                 {{/allcomponents}}
             </select>
         {{/element}}
index 93478ab..c290c98 100644 (file)
@@ -370,8 +370,9 @@ class main implements renderable, templatable {
         $select = "instanceid $csql AND fieldid = :fieldid";
         $params['fieldid'] = $fieldid;
         $distinctablevalue = $DB->sql_compare_text('value');
-        $values = $DB->get_records_select_menu('customfield_data', $select, $params, $DB->sql_order_by_text('value'),
+        $values = $DB->get_records_select_menu('customfield_data', $select, $params, '',
             "DISTINCT $distinctablevalue, $distinctablevalue AS value2");
+        \core_collator::asort($values, \core_collator::SORT_NATURAL);
         $values = array_filter($values);
         if (!$values) {
             return [];
index 3b5d632..0f6d8c2 100644 (file)
@@ -14,7 +14,7 @@ Feature: Edit activities in main menu block
       | Forum name | My forum name |
     And I click on "Edit title" "link" in the "My forum name" activity in site main menu block
     And I set the field "New name for activity My forum name" to "New forum name"
-    And I press key "13" in the field "New name for activity My forum name"
+    And I press the enter key
     Then I should not see "My forum name"
     And I should see "New forum name"
     And I follow "New forum name"
index 2cf87a5..984b731 100644 (file)
@@ -27,7 +27,7 @@ Feature: Edit activities in social activities block
     And I press "Save and return to course"
     And I click on "Edit title" "link" in the "My forum name" activity in social activities block
     And I set the field "New name for activity My forum name" to "New forum name"
-    And I press key "13" in the field "New name for activity My forum name"
+    And I press the enter key
     Then I should not see "My forum name" in the "Social activities" "block"
     And I should see "New forum name"
     And I follow "New forum name"
index b93d41f..9bc5469 100644 (file)
@@ -64,7 +64,7 @@ Feature: Add cohorts of users
     When I follow "Cohorts"
     And I click on "Edit cohort name" "link" in the "Test cohort name" "table_row"
     And I set the field "New name for cohort Test cohort name" to "Students cohort"
-    And I press key "13" in the field "New name for cohort Test cohort name"
+    And I press the enter key
     Then I should not see "Test cohort name"
     And I should see "Students cohort"
     And I follow "Cohorts"
index e55b58d..a1e205b 100644 (file)
@@ -55,7 +55,7 @@ Feature: Sections can be edited and deleted in topics format
   Scenario: Inline edit section name in topics format
     When I click on "Edit topic name" "link" in the "li#section-1" "css_element"
     And I set the field "New name for topic Topic 1" to "Midterm evaluation"
-    And I press key "13" in the field "New name for topic Topic 1"
+    And I press the enter key
     Then I should not see "Topic 1" in the "region-main" "region"
     And "New name for topic" "field" should not exist
     And I should see "Midterm evaluation" in the "li#section-1" "css_element"
index a51a803..5d8ff89 100644 (file)
@@ -56,7 +56,7 @@ Feature: Sections can be edited and deleted in weeks format
   Scenario: Inline edit section name in weeks format
     When I click on "Edit week name" "link" in the "li#section-1" "css_element"
     And I set the field "New name for week 1 May - 7 May" to "Midterm evaluation"
-    And I press key "13" in the field "New name for week 1 May - 7 May"
+    And I press the enter key
     Then I should not see "1 May - 7 May" in the "region-main" "region"
     And "New name for week" "field" should not exist
     And I should see "Midterm evaluation" in the "li#section-1" "css_element"
index 29e8911..7372c17 100644 (file)
@@ -23,7 +23,7 @@ Feature: Edit activity name in-place
     # Rename activity
     And I click on "Edit title" "link" in the "//div[contains(@class,'activityinstance') and contains(.,'Test forum name')]" "xpath_element"
     And I set the field "New name for activity Test forum name" to "Good news"
-    And I press key "13" in the field "New name for activity Test forum name"
+    And I press the enter key
     Then I should not see "Test forum name" in the ".course-content" "css_element"
     And "New name for activity Test forum name" "field" should not exist
     And I should see "Good news"
@@ -33,11 +33,10 @@ Feature: Edit activity name in-place
     # Cancel renaming
     And I click on "Edit title" "link" in the "//div[contains(@class,'activityinstance') and contains(.,'Good news')]" "xpath_element"
     And I set the field "New name for activity Good news" to "Terrible news"
-    And I press key "27" in the field "New name for activity Good news"
+    And I press the escape key
     And "New name for activity Good news" "field" should not exist
     And I should see "Good news"
     And I should not see "Terrible news"
     And I am on "Course 1" course homepage
     And I should see "Good news"
     And I should not see "Terrible news"
-    And I log out
index 5fb4802..2b88046 100644 (file)
@@ -22,7 +22,7 @@ Feature: Managers can manage categories for course custom fields
     And I navigate to "Courses > Course custom fields" in site administration
     And I click on "Edit category name" "link" in the "//div[contains(@class,'categoryinstance') and contains(.,'Category for test')]" "xpath_element"
     And I set the field "New value for Category for test" to "Good fields"
-    And I press key "13" in the field "New value for Category for test"
+    And I press the enter key
     Then I should not see "Category for test" in the "#customfield_catlist" "css_element"
     And "New value for Category for test" "field" should not exist
     And I should see "Good fields" in the "#customfield_catlist" "css_element"
index 632860a..4a725fe 100644 (file)
@@ -328,31 +328,45 @@ class enrol_meta_plugin extends enrol_plugin {
      */
     public function edit_instance_validation($data, $files, $instance, $context) {
         global $DB;
+
         $errors = array();
         $thiscourseid = $context->instanceid;
-        $c = false;
 
         if (!empty($data['customint1'])) {
-            $courses = is_array($data['customint1']) ? $data['customint1'] : [$data['customint1']];
-            foreach ($courses as $courseid) {
-                $c = $DB->get_record('course', array('id' => $courseid), '*', MUST_EXIST);
-                $coursecontext = context_course::instance($c->id);
-
-                $sqlexists = 'enrol = :meta AND courseid = :currentcourseid AND customint1 = :courseid AND id != :id';
-                $existing = $DB->record_exists_select('enrol', $sqlexists, [
+            $coursesidarr = is_array($data['customint1']) ? $data['customint1'] : [$data['customint1']];
+            list($coursesinsql, $coursesinparams) = $DB->get_in_or_equal($coursesidarr, SQL_PARAMS_NAMED, 'metacourseid');
+            if ($coursesrecords = $DB->get_records_select('course', "id {$coursesinsql}",
+                $coursesinparams, '', 'id,visible')) {
+                // Cast NULL to 0 to avoid possible mess with the SQL.
+                $instanceid = $instance->id ?? 0;
+
+                $existssql = "enrol = :meta AND courseid = :currentcourseid AND id != :id AND customint1 {$coursesinsql}";
+                $existsparams = [
                     'meta' => 'meta',
                     'currentcourseid' => $thiscourseid,
-                    'courseid' => $c->id,
-                    'id' => $instance->id
-                ]);
-
-                if (!$c->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
-                    $errors['customint1'] = get_string('error');
-                } else if (!has_capability('enrol/meta:selectaslinked', $coursecontext)) {
-                    $errors['customint1'] = get_string('error');
-                } else if ($c->id == SITEID or $c->id == $thiscourseid or $existing) {
-                    $errors['customint1'] = get_string('error');
+                    'id' => $instanceid
+                ];
+                $existsparams += $coursesinparams;
+                if ($DB->record_exists_select('enrol', $existssql, $existsparams)) {
+                    // We may leave right here as further checks do not make sense in case we have existing enrol records
+                    // with the parameters from above.
+                    $errors['customint1'] = get_string('invalidcourseid', 'error');
+                } else {
+                    foreach ($coursesrecords as $coursesrecord) {
+                        $coursecontext = context_course::instance($coursesrecord->id);
+                        if (!$coursesrecord->visible and !has_capability('moodle/course:viewhiddencourses', $coursecontext)) {
+                            $errors['customint1'] = get_string('nopermissions', 'error',
+                                'moodle/course:viewhiddencourses');
+                        } else if (!has_capability('enrol/meta:selectaslinked', $coursecontext)) {
+                            $errors['customint1'] = get_string('nopermissions', 'error',
+                                'enrol/meta:selectaslinked');
+                        } else if ($coursesrecord->id == SITEID or $coursesrecord->id == $thiscourseid) {
+                            $errors['customint1'] = get_string('invalidcourseid', 'error');
+                        }
+                    }
                 }
+            } else {
+                $errors['customint1'] = get_string('invalidcourseid', 'error');
             }
         } else {
             $errors['customint1'] = get_string('required');
index c51ba22..8fb9b1c 100644 (file)
@@ -922,4 +922,146 @@ class enrol_meta_plugin_testcase extends advanced_testcase {
         // Meta-link enrolment has enrol actions for suspended students -- unenrol.
         $this->assertCount(1, $actions);
     }
+
+    /**
+     * Test how data for instance editing is validated.
+     */
+    public function test_edit_instance_validation() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $metaplugin = enrol_get_plugin('meta');
+
+        // A course with meta enrolment.
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+
+        // Create a meta enrolment instance.
+        $instance = (object)$metaplugin->get_instance_defaults();
+        $instance->id       = null;
+        $instance->courseid = $course->id;
+        $instance->status   = ENROL_INSTANCE_ENABLED;
+        // Emulate the form data.
+        $data = [
+            'customint1' => 0,
+            'customint2' => 0
+        ];
+        // Test when no valid 'customint1' field (meta courses links) is provided.
+        $errors = $metaplugin->edit_instance_validation($data, [], $instance, $coursecontext);
+        // We're going to check the string contents of the errors returned as this is the only way
+        // to differentiate the errors produced by the 'edit_instance_validation()' method somehow.
+        // The method always returns what the edit instance form expects and this is an array of form fields
+        // with the corresponding errors messages.
+        $this->assertEquals('Required', $errors['customint1']);
+
+        // Test when 'customint1' contains an unknown course.
+        // Fetch the max course id from the courses table and increment it to get
+        // the course id which surely doesn't exist.
+        $maxid = $DB->get_field_sql('SELECT MAX(id) FROM {course}');
+        // Use the same instance as before but set another data.
+        $data = [
+            'customint1' => [$maxid + 1],
+            'customint2' => 0
+        ];
+        $errors = $metaplugin->edit_instance_validation($data, [], $instance, $coursecontext);
+        $this->assertEquals('You are trying to use an invalid course ID', $errors['customint1']);
+
+        // Test when 'customint1' field already contains courses meta linked with the current one.
+        $metacourse1 = $this->getDataGenerator()->create_course();
+        $metaplugin->add_instance($course, array('customint1' => $metacourse1->id));
+        // Use the same instance as before but set another data.
+        $data = [
+            'customint1' => [$metacourse1->id],
+            'customint2' => 0
+        ];
+        $errors = $metaplugin->edit_instance_validation($data, [], $instance, $coursecontext);
+        $this->assertEquals('You are trying to use an invalid course ID', $errors['customint1']);
+
+        // Test when a course is set as a not visible and a user doesn't have the capability to use it here.
+        $metacourse2record = new stdClass();
+        $metacourse2record->visible = 0;
+        $metacourse2 = $this->getDataGenerator()->create_course($metacourse2record);
+        $metacourse2context = context_course::instance($metacourse2->id);
+
+        $user = $this->getDataGenerator()->create_user();
+        $teacherrole = $DB->get_record('role', array('shortname' => 'teacher'));
+        role_assign($teacherrole->id, $user->id, $metacourse2context->id);
+        unassign_capability('moodle/course:viewhiddencourses', $teacherrole->id);
+        $this->setUser($user);
+
+        // Use the same instance as before but set another data.
+        $data = [
+            'customint1' => [$metacourse2->id],
+            'customint2' => 0
+        ];
+        $errors = $metaplugin->edit_instance_validation($data, [], $instance, $coursecontext);
+        $this->assertEquals('Sorry, but you do not currently have permissions to do that (moodle/course:viewhiddencourses).',
+            $errors['customint1']);
+
+        // Revert some changes from the last assertion to reuse the course.
+        $metacourse2->visible = 1;
+        $DB->update_record('course', $metacourse2);
+        assign_capability('moodle/course:viewhiddencourses', CAP_ALLOW,
+            $teacherrole->id, context_course::instance($metacourse2->id));
+
+        // Test with no 'enrol/meta:selectaslinked' capability.
+        unassign_capability('enrol/meta:selectaslinked', $teacherrole->id);
+        $errors = $metaplugin->edit_instance_validation($data, [], $instance, $coursecontext);
+        $this->assertEquals('Sorry, but you do not currently have permissions to do that (enrol/meta:selectaslinked).',
+            $errors['customint1']);
+
+        // Back to admin user to regain the capabilities quickly.
+        $this->setAdminUser();
+
+        // Test when meta course id is the site id.
+        $site = $DB->get_record('course', ['id' => SITEID]);
+        // Use the same instance as before but set another data.
+        $data = [
+            'customint1' => [$site->id],
+            'customint2' => 0
+        ];
+        $errors = $metaplugin->edit_instance_validation($data, [], $instance, $coursecontext);
+        $this->assertEquals('You are trying to use an invalid course ID', $errors['customint1']);
+
+        // Test when meta course id is id of the current course.
+        // Use the same instance as before but set another data.
+        $data = [
+            'customint1' => [$course->id],
+            'customint2' => 0
+        ];
+        $errors = $metaplugin->edit_instance_validation($data, [], $instance, $coursecontext);
+        $this->assertEquals('You are trying to use an invalid course ID', $errors['customint1']);
+
+        // Test with the 'customint2' field set (which is groups).
+        // Prepare some groups data.
+        $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $this->getDataGenerator()->create_group(array('courseid' => $course->id));
+        $groups = [];
+        foreach (groups_get_all_groups($course->id) as $group) {
+            $groups[$group->id] = format_string($group->name, true, array('context' => $coursecontext));
+        }
+
+        // Use the same instance as before but set another data.
+        // Use a non-existing group id.
+        if (!$maxid = $DB->get_field_sql('SELECT MAX(id) FROM {groups}')) {
+            $maxid = 0;
+        }
+        $data = [
+            'customint1' => [$metacourse2->id],
+            'customint2' => [$maxid + 1]
+        ];
+        $errors = $metaplugin->edit_instance_validation($data, [], $instance, $coursecontext);
+        $this->assertArrayHasKey('customint2', $errors);
+
+        // Test with valid data.
+        $validgroup = reset($groups);
+        $data = [
+            'customint1' => [$metacourse2->id],
+            'customint2' => $validgroup
+        ];
+        $errors = $metaplugin->edit_instance_validation($data, [], $instance, $coursecontext);
+        $this->assertArrayNotHasKey('customint1', $errors);
+        $this->assertArrayNotHasKey('customint2', $errors);
+    }
 }
index 0a7aab9..e44f9c2 100644 (file)
@@ -32,6 +32,6 @@ Feature: Users can be added to multiple groups at once
     And I click on "Group 1" item in the autocomplete list
     And I click on ".form-autocomplete-downarrow" "css_element" in the "student1" "table_row"
     And I click on "Group 2" item in the autocomplete list
-    And I press key "27" in the field "Edit groups for \"Student 1\""
+    And I press the escape key
     And I click on "Save changes" "link" in the "student1" "table_row"
     Then I should see "Group 1, Group 2"
index 4eeff9e..63a3bd1 100644 (file)
@@ -43,14 +43,6 @@ $toggle_type   = optional_param('toggle_type', 0, PARAM_ALPHANUM);
 $graderreportsifirst  = optional_param('sifirst', null, PARAM_NOTAGS);
 $graderreportsilast   = optional_param('silast', null, PARAM_NOTAGS);
 
-// The report object is recreated each time, save search information to SESSION object for future use.
-if (isset($graderreportsifirst)) {
-    $SESSION->gradereport['filterfirstname'] = $graderreportsifirst;
-}
-if (isset($graderreportsilast)) {
-    $SESSION->gradereport['filtersurname'] = $graderreportsilast;
-}
-
 $PAGE->set_url(new moodle_url('/grade/report/grader/index.php', array('id'=>$courseid)));
 $PAGE->requires->yui_module('moodle-gradereport_grader-gradereporttable', 'Y.M.gradereport_grader.init', null, null, true);
 
@@ -61,6 +53,14 @@ if (!$course = $DB->get_record('course', array('id' => $courseid))) {
 require_login($course);
 $context = context_course::instance($course->id);
 
+// The report object is recreated each time, save search information to SESSION object for future use.
+if (isset($graderreportsifirst)) {
+    $SESSION->gradereport["filterfirstname-{$context->id}"] = $graderreportsifirst;
+}
+if (isset($graderreportsilast)) {
+    $SESSION->gradereport["filtersurname-{$context->id}"] = $graderreportsilast;
+}
+
 require_capability('gradereport/grader:view', $context);
 require_capability('moodle/grade:viewall', $context);
 
@@ -161,8 +161,8 @@ echo $report->group_selector;
 
 // User search
 $url = new moodle_url('/grade/report/grader/index.php', array('id' => $course->id));
-$firstinitial = isset($SESSION->gradereport['filterfirstname']) ? $SESSION->gradereport['filterfirstname'] : '';
-$lastinitial  = isset($SESSION->gradereport['filtersurname']) ? $SESSION->gradereport['filtersurname'] : '';
+$firstinitial = $SESSION->gradereport["filterfirstname-{$context->id}"] ?? '';
+$lastinitial  = $SESSION->gradereport["filtersurname-{$context->id}"] ?? '';
 $totalusers = $report->get_numusers(true, false);
 $renderer = $PAGE->get_renderer('core_user');
 echo $renderer->user_search($url, $firstinitial, $lastinitial, $numusers, $totalusers, $report->currentgroupname);
index 358f9a5..3500be1 100644 (file)
@@ -59,18 +59,18 @@ Feature: Using the AJAX grading feature of Grader report to update grades and fe
     Then I should see a grade field for "Student 2" and grade item "Item VU"
     And I should not see a feedback field for "Student 2" and grade item "Item VU"
     And I set the field "ajaxgrade" to "33"
-    And I press key "13" in the field "ajaxgrade"
+    And I press the enter key
     And I should not see a grade field for "Student 2" and grade item "Item VU"
     And I should not see a feedback field for "Student 2" and grade item "Item VU"
+    And I click on student "Student 3" for grade item "Item SU"
+    And I set the field "ajaxgrade" to "Very good"
+    And I press the shift tab key
     And I click on student "Student 3" for grade item "Item VU"
     And I set the field "ajaxgrade" to "50"
-    And I press key "13" in the field "ajaxgrade"
+    And I press the enter key
     And I click on student "Student 3" for grade item "Item 1"
     And I set the field "ajaxgrade" to "80"
-    And I press key "13" in the field "ajaxgrade"
-    And I click on student "Student 3" for grade item "Item SU"
-    And I set the field "ajaxgrade" to "Very good"
-    And I press key "13" in the field "ajaxgrade"
+    And I press the enter key
     And the following should exist in the "user-grades" table:
       | -1-                | -6-      | -7-      | -13-      | -16-         |
       | Student 2          | -        | 33.00    | -         | 33.00        |
@@ -88,7 +88,7 @@ Feature: Using the AJAX grading feature of Grader report to update grades and fe
     And I should see a grade field for "Student 1" and grade item "Course total"
     And I should not see a feedback field for "Student 1" and grade item "Course total"
     And I set the field "ajaxgrade" to "90"
-    And I press key "13" in the field "ajaxgrade"
+    And I press the enter key
     And the following should exist in the "user-grades" table:
       | -1-                | -16-      |
       | Student 1          | 90.00     |
@@ -112,7 +112,7 @@ Feature: Using the AJAX grading feature of Grader report to update grades and fe
     And I should see a feedback field for "Student 2" and grade item "Item VU"
     And I set the field "ajaxgrade" to "33"
     And I set the field "ajaxfeedback" to "Student 2 VU feedback"
-    And I press key "13" in the field "ajaxfeedback"
+    And I press the enter key
     And I click on student "Student 3" for grade item "Item VL"
     And I should not see a grade field for "Student 3" and grade item "Item VL"
     And I should not see a feedback field for "Student 3" and grade item "Item VL"
@@ -120,11 +120,11 @@ Feature: Using the AJAX grading feature of Grader report to update grades and fe
     And I should not see a grade field for "Student 3" and grade item "Item TU"
     And I should see a feedback field for "Student 3" and grade item "Item TU"
     And I set the field "ajaxfeedback" to "Student 3 TU feedback"
-    And I press key "13" in the field "ajaxfeedback"
+    And I press the enter key
     And I click on student "Student 2" for grade item "Item SU"
     And I set the field "ajaxgrade" to "Very good"
     And I set the field "ajaxfeedback" to "Student 2 SU feedback"
-    And I press key "13" in the field "ajaxfeedback"
+    And I press the enter key
     # Reload grader report:
     And I navigate to "View > User report" in the course gradebook
     And I navigate to "View > Grader report" in the course gradebook
@@ -148,7 +148,7 @@ Feature: Using the AJAX grading feature of Grader report to update grades and fe
     Then I should see a grade field for "Student 2" and grade item "Item VU"
     And I should see a feedback field for "Student 2" and grade item "Item VU"
     And I set the field "ajaxgrade" to "33"
-    And I press key "13" in the field "ajaxgrade"
+    And I press the enter key
     And I click on student "Student 2" for grade item "Course total"
     And I should not see a grade field for "Student 3" and grade item "Course total"
     And I should not see a feedback field for "Student 3" and grade item "Course total"
index 8df6324..98b46a0 100644 (file)
@@ -394,15 +394,18 @@ abstract class grade_report {
     public function setup_users() {
         global $SESSION, $DB;
 
+        $filterfirstnamekey = "filterfirstname-{$this->context->id}";
+        $filtersurnamekey = "filtersurname-{$this->context->id}";
+
         $this->userwheresql = "";
         $this->userwheresql_params = array();
-        if (isset($SESSION->gradereport['filterfirstname']) && !empty($SESSION->gradereport['filterfirstname'])) {
+        if (!empty($SESSION->gradereport[$filterfirstnamekey])) {
             $this->userwheresql .= ' AND '.$DB->sql_like('u.firstname', ':firstname', false, false);
-            $this->userwheresql_params['firstname'] = $SESSION->gradereport['filterfirstname'].'%';
+            $this->userwheresql_params['firstname'] = $SESSION->gradereport[$filterfirstnamekey] . '%';
         }
-        if (isset($SESSION->gradereport['filtersurname']) && !empty($SESSION->gradereport['filtersurname'])) {
+        if (!empty($SESSION->gradereport[$filtersurnamekey])) {
             $this->userwheresql .= ' AND '.$DB->sql_like('u.lastname', ':lastname', false, false);
-            $this->userwheresql_params['lastname'] = $SESSION->gradereport['filtersurname'].'%';
+            $this->userwheresql_params['lastname'] = $SESSION->gradereport[$filtersurnamekey] . '%';
         }
     }
 
diff --git a/lib/amd/build/fullscreen.min.js b/lib/amd/build/fullscreen.min.js
new file mode 100644 (file)
index 0000000..b227c0a
Binary files /dev/null and b/lib/amd/build/fullscreen.min.js differ
diff --git a/lib/amd/build/fullscreen.min.js.map b/lib/amd/build/fullscreen.min.js.map
new file mode 100644 (file)
index 0000000..82ff7a4
Binary files /dev/null and b/lib/amd/build/fullscreen.min.js.map differ
index 1374a94..91677e8 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index 3b050c2..e330a08 100644 (file)
Binary files a/lib/amd/build/modal.min.js.map and b/lib/amd/build/modal.min.js.map differ
index dfedcae..1778e05 100644 (file)
Binary files a/lib/amd/build/modal_backdrop.min.js and b/lib/amd/build/modal_backdrop.min.js differ
index 9b2bf43..df2f559 100644 (file)
Binary files a/lib/amd/build/modal_backdrop.min.js.map and b/lib/amd/build/modal_backdrop.min.js.map differ
diff --git a/lib/amd/src/fullscreen.js b/lib/amd/src/fullscreen.js
new file mode 100644 (file)
index 0000000..4ac2acc
--- /dev/null
@@ -0,0 +1,48 @@
+// 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/>.
+
+/**
+ * Detects if an element is fullscreen.
+ *
+ * @module     core/fullscreen
+ * @class      fullscreen
+ * @package    core
+ * @copyright  2020 University of Nottingham
+ * @author     Neill Magill <neill.magill@nottingham.ac.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Gets the element that is fullscreen or null if no element is fullscreen.
+ *
+ * @returns {HTMLElement}
+ */
+export const getElement = () => {
+    let element = null;
+    if (document.fullscreenElement) {
+        element = document.fullscreenElement;
+    } else if (document.mozFullscreenElement) {
+        // Fallback for older Firefox.
+        element = document.mozFullscreenElement;
+    } else if (document.msFullscreenElement) {
+        // Fallback for Edge and IE.
+        element = document.msFullscreenElement;
+    } else if (document.webkitFullscreenElement) {
+        // Fallback for Chrome, Edge and Safari.
+        element = document.webkitFullscreenElement;
+    }
+
+    return element;
+};
index 37ce574..118d679 100644 (file)
@@ -34,7 +34,21 @@ define([
     'core/local/aria/focuslock',
     'core/pending',
     'core/aria',
-], function($, Templates, Notification, KeyCodes, CustomEvents, ModalBackdrop, Event, ModalEvents, FocusLock, Pending, Aria) {
+    'core/fullscreen'
+], function(
+    $,
+    Templates,
+    Notification,
+    KeyCodes,
+    CustomEvents,
+    ModalBackdrop,
+    Event,
+    ModalEvents,
+    FocusLock,
+    Pending,
+    Aria,
+    Fullscreen
+) {
 
     var SELECTORS = {
         CONTAINER: '[data-region="modal-container"]',
@@ -117,17 +131,20 @@ define([
     };
 
     /**
-     * Add the modal to the page, if it hasn't already been added. This includes running any
+     * Attach the modal to the correct part of the page.
+     *
+     * If it hasn't already been added it runs any
      * javascript that has been cached until now.
      *
      * @method attachToDOM
      */
     Modal.prototype.attachToDOM = function() {
+        this.getAttachmentPoint().append(this.root);
+
         if (this.isAttached) {
             return;
         }
 
-        $('body').append(this.root);
         FocusLock.trapFocus(this.root[0]);
 
         // If we'd cached any JS then we can run it how that the modal is
@@ -625,6 +642,15 @@ define([
         return this.getRoot().hasClass('fade');
     };
 
+    /**
+     * Gets the jQuery wrapped node that the Modal should be attached to.
+     *
+     * @returns {jQuery}
+     */
+    Modal.prototype.getAttachmentPoint = function() {
+        return $(Fullscreen.getElement() || document.body);
+    };
+
     /**
      * Display this modal. The modal will be attached to the DOM if it hasn't
      * already been.
@@ -645,9 +671,7 @@ define([
             this.hideFooter();
         }
 
-        if (!this.isAttached) {
-            this.attachToDOM();
-        }
+        this.attachToDOM();
 
         return this.getBackdrop()
         .then(function(backdrop) {
@@ -689,6 +713,7 @@ define([
     Modal.prototype.hide = function() {
         this.getBackdrop().done(function(backdrop) {
             FocusLock.untrapFocus();
+
             if (!this.countOtherVisibleModals()) {
                 // Hide the backdrop if we're the last open modal.
                 backdrop.hide();
@@ -710,6 +735,11 @@ define([
                 this.getRoot().removeClass('show').addClass('hide');
             }
 
+            // Ensure the modal is moved onto the body node if it is still attached to the DOM.
+            if ($(document.body).find(this.getRoot()).length) {
+                $(document.body).append(this.getRoot());
+            }
+
             this.root.trigger(ModalEvents.hidden, this);
         }.bind(this));
     };
index 75a7b26..1cbc1fd 100644 (file)
@@ -22,8 +22,8 @@
  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/templates', 'core/notification'],
-     function($, Templates, Notification) {
+define(['jquery', 'core/templates', 'core/notification', 'core/fullscreen'],
+     function($, Templates, Notification, Fullscreen) {
 
     var SELECTORS = {
         ROOT: '[data-region="modal-backdrop"]',
@@ -53,17 +53,27 @@ define(['jquery', 'core/templates', 'core/notification'],
         return this.root;
     };
 
+     /**
+      * Gets the jQuery wrapped node that the Modal should be attached to.
+      *
+      * @returns {jQuery}
+      */
+     ModalBackdrop.prototype.getAttachmentPoint = function() {
+         return $(Fullscreen.getElement() || document.body);
+     };
+
     /**
      * Add the modal backdrop to the page, if it hasn't already been added.
      *
      * @method attachToDOM
      */
     ModalBackdrop.prototype.attachToDOM = function() {
+        this.getAttachmentPoint().append(this.root);
+
         if (this.isAttached) {
             return;
         }
 
-        $('body').append(this.root);
         this.isAttached = true;
     };
 
@@ -108,9 +118,7 @@ define(['jquery', 'core/templates', 'core/notification'],
             return;
         }
 
-        if (!this.isAttached) {
-            this.attachToDOM();
-        }
+        this.attachToDOM();
 
         this.root.removeClass('hide').addClass('show');
     };
@@ -133,6 +141,11 @@ define(['jquery', 'core/templates', 'core/notification'],
         } else {
             this.getRoot().removeClass('show').addClass('hide');
         }
+
+        // Ensure the modal is moved onto the body node if it is still attached to the DOM.
+        if ($(document.body).find(this.getRoot()).length) {
+            $(document.body).append(this.getRoot());
+        }
     };
 
     /**
index db165c7..57688c8 100644 (file)
@@ -38,6 +38,10 @@ use Behat\Mink\Session;
 require_once(__DIR__ . '/classes/component_named_selector.php');
 require_once(__DIR__ . '/classes/component_named_replacement.php');
 
+// Alias the WebDriver\Key  class to behat_keys to make future transition to a different WebDriver implementation
+// easier.
+class_alias('WebDriver\\Key', 'behat_keys');
+
 /**
  * Steps definitions base class.
  *
@@ -264,6 +268,44 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
         ];
     }
 
+    /**
+     * Send key presses straight to the currently active element.
+     *
+     * The `$keys` array contains a list of key values to send to the session as defined in the WebDriver and JsonWire
+     * specifications:
+     * - JsonWire: https://github.com/SeleniumHQ/selenium/wiki/JsonWireProtocol#sessionsessionidkeys
+     * - W3C WebDriver: https://www.w3.org/TR/webdriver/#keyboard-actions
+     *
+     * This may be a combination of typable characters, modifier keys, and other supported keypoints.
+     *
+     * The NULL_KEY should be used to release modifier keys. If the NULL_KEY is not used then modifier keys will remain
+     * in the pressed state.
+     *
+     * Example usage:
+     *
+     *      behat_base::type_keys($this->getSession(), [behat_keys::SHIFT, behat_keys::TAB, behat_keys::NULL_KEY]);
+     *      behat_base::type_keys($this->getSession(), [behat_keys::ENTER, behat_keys::NULL_KEY]);
+     *      behat_base::type_keys($this->getSession(), [behat_keys::ESCAPE, behat_keys::NULL_KEY]);
+     *
+     * It can also be used to send text input, for example:
+     *
+     *      behat_base::type_keys(
+     *          $this->getSession(),
+     *          ['D', 'o', ' ', 'y', 'o', 'u', ' ', 'p', 'l', 'a' 'y', ' ', 'G', 'o', '?', behat_base::NULL_KEY]
+     *      );
+     *
+     *
+     * Please note: This function does not use the element/sendKeys variants but sends keys straight to the browser.
+     *
+     * @param Session $session
+     * @param string[] $keys
+     */
+    public static function type_keys(Session $session, array $keys): void {
+        $session->getDriver()->getWebDriverSession()->keys([
+            'value' => $keys,
+        ]);
+    }
+
     /**
      * Finds DOM nodes in the page using named selectors.
      *
index 8b1d390..238bbee 100644 (file)
@@ -78,14 +78,12 @@ class behat_form_autocomplete extends behat_form_text {
                 $suggestion->click();
             } else {
                 // Press the return key to create a new tag.
-                // Note: We cannot use $this->key_press() because the keyPress action, in combination with the keyDown
-                // submits the form.
-                $this->field->keyDown(13);
-                $this->field->keyUp(13);
+                behat_base::type_keys($this->session, [behat_keys::ENTER]);
             }
-
             $this->wait_for_pending_js();
-            $this->key_press(27);
+
+            // Press the escape to close the autocomplete suggestions list.
+            behat_base::type_keys($this->session, [behat_keys::ESCAPE]);
             $this->wait_for_pending_js();
         }
     }
index e3e2548..d24a5c2 100644 (file)
@@ -2994,5 +2994,23 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2021052500.36);
     }
 
+    if ($oldversion < 2021052500.42) {
+        // Get all lessons that are set with a completion criteria of 'requires grade' but with no grade type set.
+        $sql = "SELECT cm.id
+                  FROM {course_modules} cm
+                  JOIN {lesson} l ON l.id = cm.instance
+                  JOIN {modules} m ON m.id = cm.module
+                 WHERE m.name = :name AND cm.completiongradeitemnumber IS NOT NULL AND l.grade = :grade";
+
+        do {
+            if ($invalidconfigrations = $DB->get_records_sql($sql, ['name' => 'lesson', 'grade' => 0], 0, 1000)) {
+                list($insql, $inparams) = $DB->get_in_or_equal(array_keys($invalidconfigrations), SQL_PARAMS_NAMED);
+                $DB->set_field_select('course_modules', 'completiongradeitemnumber', null, "id $insql", $inparams);
+            }
+        } while ($invalidconfigrations);
+
+        upgrade_main_savepoint(true, 2021052500.42);
+    }
+
     return true;
 }
index ae47df3..7532952 100644 (file)
@@ -1457,8 +1457,6 @@ function set_config($name, $value, $plugin=null) {
  *
  * NOTE: this function is called from lib/db/upgrade.php
  *
- * @static string|false $siteidentifier The site identifier is not cached. We use this static cache so
- *     that we need only fetch it once per request.
  * @param string $plugin full component name
  * @param string $name default null
  * @return mixed hash-like object or single value, return false no config found
@@ -1467,52 +1465,29 @@ function set_config($name, $value, $plugin=null) {
 function get_config($plugin, $name = null) {
     global $CFG, $DB;
 
-    static $siteidentifier = null;
-
     if ($plugin === 'moodle' || $plugin === 'core' || empty($plugin)) {
         $forced =& $CFG->config_php_settings;
-        $iscore = true;
+        $table = 'config';
+        $filter = [];
         $plugin = 'core';
     } else {
         if (array_key_exists($plugin, $CFG->forced_plugin_settings)) {
             $forced =& $CFG->forced_plugin_settings[$plugin];
         } else {
-            $forced = array();
+            $forced = [];
         }
-        $iscore = false;
+        $table = 'config_plugins';
+        $filter = ['plugin' => $plugin];
     }
 
-    if ($siteidentifier === null) {
-        try {
-            // This may fail during installation.
-            // If you have a look at {@link initialise_cfg()} you will see that this is how we detect the need to
-            // install the database.
-            $siteidentifier = $DB->get_field('config', 'value', array('name' => 'siteidentifier'));
-        } catch (dml_exception $ex) {
-            // Set siteidentifier to false. We don't want to trip this continually.
-            $siteidentifier = false;
-            throw $ex;
-        }
-    }
-
-    if (!empty($name)) {
-        if (array_key_exists($name, $forced)) {
-            return (string)$forced[$name];
-        } else if ($name === 'siteidentifier' && $plugin == 'core') {
-            return $siteidentifier;
-        }
+    if (!empty($name) && array_key_exists($name, $forced)) {
+        return (string)$forced[$name];
     }
 
     $cache = cache::make('core', 'config');
     $result = $cache->get($plugin);
     if ($result === false) {
-        // The user is after a recordset.
-        if (!$iscore) {
-            $result = $DB->get_records_menu('config_plugins', array('plugin' => $plugin), '', 'name,value');
-        } else {
-            // This part is not really used any more, but anyway...
-            $result = $DB->get_records_menu('config', array(), '', 'name,value');;
-        }
+        $result = $DB->get_records_menu($table, $filter, '', 'name,value');
         $cache->set($plugin, $result);
     }
 
@@ -1523,10 +1498,6 @@ function get_config($plugin, $name = null) {
         return false;
     }
 
-    if ($plugin === 'core') {
-        $result['siteidentifier'] = $siteidentifier;
-    }
-
     foreach ($forced as $key => $value) {
         if (is_null($value) or is_array($value) or is_object($value)) {
             // We do not want any extra mess here, just real settings that could be saved in db.
index 63510a6..e464d39 100644 (file)
@@ -1201,6 +1201,12 @@ class flexible_table {
     function print_headers() {
         global $CFG, $OUTPUT;
 
+        // Set the primary sort column/order where possible, so that sort links/icons are correct.
+        [
+            'sortby' => $primarysortcolumn,
+            'sortorder' => $primarysortorder,
+        ] = $this->get_primary_sort_order();
+
         echo html_writer::start_tag('thead');
         echo html_writer::start_tag('tr');
         foreach ($this->columns as $column => $index) {
@@ -1209,14 +1215,6 @@ class flexible_table {
             if ($this->is_collapsible) {
                 $icon_hide = $this->show_hide_link($column, $index);
             }
-
-            $primarysortcolumn = '';
-            $primarysortorder  = '';
-            if (reset($this->prefs['sortby'])) {
-                $primarysortcolumn = key($this->prefs['sortby']);
-                $primarysortorder  = current($this->prefs['sortby']);
-            }
-
             switch ($column) {
 
                 case 'fullname':
@@ -1596,6 +1594,22 @@ class flexible_table {
                 ]) . ' ' . $this->sort_icon($isprimary, $order);
     }
 
+    /**
+     * Return primary sorting column/order, either the first preferred "sortby" value or defaults defined for the table
+     *
+     * @return array
+     */
+    protected function get_primary_sort_order(): array {
+        if (reset($this->prefs['sortby'])) {
+            return $this->get_sort_order();
+        }
+
+        return [
+            'sortby' => $this->sort_default_column,
+            'sortorder' => $this->sort_default_order,
+        ];
+    }
+
     /**
      * Return sorting attributes values.
      *
index 9946041..06458e2 100644 (file)
@@ -12,7 +12,7 @@ Feature: Navigate action menu
     # The menu should now be visible.
     Then ".usermenu [role='menu']" "css_element" should be visible
     # Press down arrow.
-    And I press key "40" in "#actionmenuaction-1" "css_element"
+    And I press the down key
     # The menu should still be visible.
     And ".usermenu [role='menu']" "css_element" should be visible
 
index 49b2fdd..dacbabc 100644 (file)
 
 require_once(__DIR__ . '/../../behat/behat_base.php');
 
-use Behat\Mink\Exception\ExpectationException as ExpectationException,
-    Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException,
-    Behat\Mink\Exception\DriverException as DriverException,
-    WebDriver\Exception\NoSuchElement as NoSuchElement,
-    WebDriver\Exception\StaleElementReference as StaleElementReference,
-    Behat\Gherkin\Node\TableNode as TableNode;
+use Behat\Gherkin\Node\TableNode as TableNode;
+use Behat\Mink\Exception\DriverException as DriverException;
+use Behat\Mink\Exception\ElementNotFoundException as ElementNotFoundException;
+use Behat\Mink\Exception\ExpectationException as ExpectationException;
+use WebDriver\Exception\NoSuchElement as NoSuchElement;
+use WebDriver\Exception\StaleElementReference as StaleElementReference;
 
 /**
  * Cross component steps definitions.
@@ -1710,6 +1710,143 @@ EOF;
         }
     }
 
+    /**
+     * Send key presses to the browser without first changing focusing, or applying the key presses to a specific
+     * element.
+     *
+     * Example usage of this step:
+     *     When I type "Penguin"
+     *
+     * @When    I type :keys
+     * @param   string $keys The key, or list of keys, to type
+     */
+    public function i_type(string $keys): void {
+        behat_base::type_keys($this->getSession(), str_split($keys));
+    }
+
+    /**
+     * Press a named key with an optional set of modifiers.
+     *
+     * Supported named keys are:
+     * - up
+     * - down
+     * - left
+     * - right
+     * - pageup|page_up
+     * - pagedown|page_down
+     * - home
+     * - end
+     * - insert
+     * - delete
+     * - backspace
+     * - escape
+     * - enter
+     * - tab
+     *
+     * Supported moderators are:
+     * - shift
+     * - ctrl
+     * - alt
+     * - meta
+     *
+     * Example usage of this new step:
+     *     When I press the up key
+     *     When I press the space key
+     *     When I press the shift tab key
+     *
+     * Multiple moderator keys can be combined using the '+' operator, for example:
+     *     When I press the ctrl+shift enter key
+     *     When I press the ctrl + shift enter key
+     *
+     * @When    /^I press the (?P<modifiers_string>.* )?(?P<key_string>.*) key$/
+     * @param   string $modifiers A list of keyboard modifiers, separated by the `+` character
+     * @param   string $key The name of the key to press
+     */
+    public function i_press_named_key(string $modifiers, string $key): void {
+        behat_base::require_javascript_in_session($this->getSession());
+
+        $keys = [];
+
+        foreach (explode('+', $modifiers) as $modifier) {
+            switch (strtoupper(trim($modifier))) {
+                case '':
+                    break;
+                case 'SHIFT':
+                    $keys[] = behat_keys::SHIFT;
+                    break;
+                case 'CTRL':
+                    $keys[] = behat_keys::CONTROL;
+                    break;
+                case 'ALT':
+                    $keys[] = behat_keys::ALT;
+                    break;
+                case 'META':
+                    $keys[] = behat_keys::META;
+                    break;
+                default:
+                    throw new \coding_exception("Unknown modifier key '$modifier'}");
+            }
+        }
+
+        $modifier = trim($key);
+        switch (strtoupper($key)) {
+            case 'UP':
+                $keys[] = behat_keys::UP_ARROW;
+                break;
+            case 'DOWN':
+                $keys[] = behat_keys::DOWN_ARROW;
+                break;
+            case 'LEFT':
+                $keys[] = behat_keys::LEFT_ARROW;
+                break;
+            case 'RIGHT':
+                $keys[] = behat_keys::RIGHT_ARROW;
+                break;
+            case 'HOME':
+                $keys[] = behat_keys::HOME;
+                break;
+            case 'END':
+                $keys[] = behat_keys::END;
+                break;
+            case 'INSERT':
+                $keys[] = behat_keys::INSERT;
+                break;
+            case 'BACKSPACE':
+                $keys[] = behat_keys::BACKSPACE;
+                break;
+            case 'DELETE':
+                $keys[] = behat_keys::DELETE;
+                break;
+            case 'PAGEUP':
+            case 'PAGE_UP':
+                $keys[] = behat_keys::PAGE_UP;
+                break;
+            case 'PAGEDOWN':
+            case 'PAGE_DOWN':
+                $keys[] = behat_keys::PAGE_DOWN;
+                break;
+            case 'ESCAPE':
+                $keys[] = behat_keys::ESCAPE;
+                break;
+            case 'ENTER':
+                $keys[] = behat_keys::ENTER;
+                break;
+            case 'TAB':
+                $keys[] = behat_keys::TAB;
+                break;
+            case 'SPACE':
+                $keys[] = behat_keys::SPACE;
+                break;
+            default:
+                throw new \coding_exception("Unknown key '$key'}");
+        }
+
+        // Always send the NULL key as the last key.
+        $keys[] = behat_keys::NULL_KEY;
+
+        behat_base::type_keys($this->getSession(), $keys);
+    }
+
     /**
      * Trigger a keydown event for a key on a specific element.
      *
@@ -1761,12 +1898,8 @@ EOF;
         }
         // Gets the node based on the requested selector type and locator.
         $node = $this->get_selected_node($selectortype, $element);
-        $driver = $this->getSession()->getDriver();
-        if ($driver instanceof \Moodle\BehatExtension\Driver\MoodleSelenium2Driver) {
-            $driver->post_key("\xEE\x80\x84", $node->getXpath());
-        } else {
-            $driver->keyDown($node->getXpath(), "\t");
-        }
+        $this->execute('behat_general::i_click_on', [$node, 'NodeElement']);
+        $this->execute('behat_general::i_press_named_key', ['', 'tab']);
     }
 
     /**
@@ -1863,12 +1996,11 @@ EOF;
      * @throws DriverException
      */
     public function i_manually_press_tab($shift = '') {
-        if (!$this->running_javascript()) {
-            throw new DriverException($shift . ' Tab press step is not available with Javascript disabled');
+        if (empty($shift)) {
+            $this->execute('behat_general::i_press_named_key', ['', 'tab']);
+        } else {
+            $this->execute('behat_general::i_press_named_key', ['shift', 'tab']);
         }
-
-        $value = ($shift == ' shift') ? [\WebDriver\Key::SHIFT . \WebDriver\Key::TAB] : [\WebDriver\Key::TAB];
-        $this->getSession()->getDriver()->getWebDriverSession()->activeElement()->postValue(['value' => $value]);
     }
 
     /**
@@ -1930,12 +2062,7 @@ EOF;
      * @throws DriverException
      */
     public function i_manually_press_enter() {
-        if (!$this->running_javascript()) {
-            throw new DriverException('Enter press step is not available with Javascript disabled');
-        }
-
-        $value = [\WebDriver\Key::ENTER];
-        $this->getSession()->getDriver()->getWebDriverSession()->activeElement()->postValue(['value' => $value]);
+        $this->execute('behat_general::i_press_named_key', ['', 'enter']);
     }
 
     /**
index 60b2b8c..d7d768c 100644 (file)
@@ -1,6 +1,10 @@
 This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
+=== 3.11 ===
+* New optional parameter $extracontent for print_collapsible_region_start(). This allows developers to add interactive HTML elements
+  (e.g. a help icon) after the collapsible region's toggle link.
+
 === 3.10 ===
 * PHPUnit has been upgraded to 8.5. That comes with a few changes:
   - Breaking change: All the "template methods" (setUp(), tearDown()...) now require to return void. This implies
index 4251846..a2ba0c3 100644 (file)
@@ -2441,9 +2441,11 @@ function print_collapsible_region($contents, $classes, $id, $caption, $userpref
  *      (May be blank if you do not wish the state to be persisted.
  * @param boolean $default Initial collapsed state to use if the user_preference it not set.
  * @param boolean $return if true, return the HTML as a string, rather than printing it.
+ * @param string $extracontent the extra content will show next to caption, eg.Help icon.
  * @return string|void if $return is false, returns nothing, otherwise returns a string of HTML.
  */
-function print_collapsible_region_start($classes, $id, $caption, $userpref = '', $default = false, $return = false) {
+function print_collapsible_region_start($classes, $id, $caption, $userpref = '', $default = false, $return = false,
+        $extracontent = null) {
     global $PAGE;
 
     // Work out the initial state.
@@ -2463,8 +2465,11 @@ function print_collapsible_region_start($classes, $id, $caption, $userpref = '',
     $output .= '<div id="' . $id . '" class="collapsibleregion ' . $classes . '">';
     $output .= '<div id="' . $id . '_sizer">';
     $output .= '<div id="' . $id . '_caption" class="collapsibleregioncaption">';
-    $output .= $caption . ' ';
-    $output .= '</div><div id="' . $id . '_inner" class="collapsibleregioninner">';
+    $output .= $caption . ' </div>';
+    if ($extracontent) {
+        $output .= html_writer::div($extracontent, 'collapsibleregionextracontent');
+    }
+    $output .= '<div id="' . $id . '_inner" class="collapsibleregioninner">';
     $PAGE->requires->js_init_call('M.util.init_collapsible_region', array($id, $userpref, get_string('clicktohideshow')));
 
     if ($return) {
index 65c013e..42e6634 100644 (file)
@@ -102,7 +102,7 @@ Feature: Manage preferences
     And I open messaging
     And I select "Student 2" user in messaging
     And I set the field with xpath "//textarea[@data-region='send-message-txt']" to "Hi!"
-    And I press key "13" in "//textarea[@data-region='send-message-txt']" "xpath_element"
+    And I press the enter key
     Then I should see "Hi!" in the "//*[@data-region='message-drawer']//div[@data-region='content-message-container']" "xpath_element"
 
   Scenario: Sending a message after 'Use enter to send' is disabled
@@ -113,7 +113,7 @@ Feature: Manage preferences
     And I go back in "view-settings" message drawer
     Then I select "Student 2" user in messaging
     And I set the field with xpath "//textarea[@data-region='send-message-txt']" to "Hi!"
-    And I press key "13" in "//textarea[@data-region='send-message-txt']" "xpath_element"
+    And I press the enter key
     And I should not see "Hi!" in the "//*[@data-region='message-drawer']//div[@data-region='content-message-container']" "xpath_element"
     And I press "Send message"
     And I should see "Hi!" in the "//*[@data-region='message-drawer']//div[@data-region='content-message-container']" "xpath_element"
index a8e7192..4a9962d 100644 (file)
@@ -82,7 +82,9 @@ class assign_grading_table extends table_sql implements renderable {
                                 $quickgrading,
                                 $downloadfilename = null) {
         global $CFG, $PAGE, $DB, $USER;
-        parent::__construct('mod_assign_grading');
+
+        parent::__construct('mod_assign_grading-' . $assignment->get_context()->id);
+
         $this->is_persistent(true);
         $this->assignment = $assignment;
 
index 31831a5..8c51acb 100644 (file)
@@ -391,7 +391,7 @@ class mod_assign_locallib_testcase extends advanced_testcase {
         $document = new DOMDocument();
         @$document->loadHTML($output);
         $xpath = new DOMXPath($document);
-        $this->assertEquals('', $xpath->evaluate('string(//td[@id="mod_assign_grading_r0_c8"])'));
+        $this->assertEmpty($xpath->evaluate('string(//td[@id="mod_assign_grading-' . $assign->get_context()->id. '_r0_c8"])'));
     }
 
     /**
@@ -462,25 +462,30 @@ class mod_assign_locallib_testcase extends advanced_testcase {
         @$document->loadHTML($output);
         $xpath = new DOMXPath($document);
 
+        // The XPath expression is based on the unique ID of the table.
+        $xpathuniqueidroot = 'mod_assign_grading-' . $assign->get_context()->id;
+
         // Check status.
-        $this->assertSame(get_string('submissionstatus_submitted', 'assign'), $xpath->evaluate('string(//td[@id="mod_assign_grading_r0_c4"]/div[@class="submissionstatussubmitted"])'));
-        $this->assertSame(get_string('submissionstatus_submitted', 'assign'), $xpath->evaluate('string(//td[@id="mod_assign_grading_r3_c4"]/div[@class="submissionstatussubmitted"])'));
+        $this->assertSame(get_string('submissionstatus_submitted', 'assign'),
+            $xpath->evaluate('string(//td[@id="' . $xpathuniqueidroot . '_r0_c4"]/div[@class="submissionstatussubmitted"])'));
+        $this->assertSame(get_string('submissionstatus_submitted', 'assign'),
+            $xpath->evaluate('string(//td[@id="' . $xpathuniqueidroot . '_r3_c4"]/div[@class="submissionstatussubmitted"])'));
 
         // Check submission last modified date
-        $this->assertGreaterThan(0, strtotime($xpath->evaluate('string(//td[@id="mod_assign_grading_r0_c8"])')));
-        $this->assertGreaterThan(0, strtotime($xpath->evaluate('string(//td[@id="mod_assign_grading_r3_c8"])')));
+        $this->assertGreaterThan(0, strtotime($xpath->evaluate('string(//td[@id="' . $xpathuniqueidroot . '_r0_c8"])')));
+        $this->assertGreaterThan(0, strtotime($xpath->evaluate('string(//td[@id="' . $xpathuniqueidroot . '_r3_c8"])')));
 
         // Check group.
-        $this->assertSame($group->name, $xpath->evaluate('string(//td[@id="mod_assign_grading_r0_c5"])'));
-        $this->assertSame($group->name, $xpath->evaluate('string(//td[@id="mod_assign_grading_r3_c5"])'));
+        $this->assertSame($group->name, $xpath->evaluate('string(//td[@id="' . $xpathuniqueidroot . '_r0_c5"])'));
+        $this->assertSame($group->name, $xpath->evaluate('string(//td[@id="' . $xpathuniqueidroot . '_r3_c5"])'));
 
         // Check submission text.
-        $this->assertSame('Submission text', $xpath->evaluate('string(//td[@id="mod_assign_grading_r0_c9"]/div/div)'));
-        $this->assertSame('Submission text', $xpath->evaluate('string(//td[@id="mod_assign_grading_r3_c9"]/div/div)'));
+        $this->assertSame('Submission text', $xpath->evaluate('string(//td[@id="' . $xpathuniqueidroot . '_r0_c9"]/div/div)'));
+        $this->assertSame('Submission text', $xpath->evaluate('string(//td[@id="' . $xpathuniqueidroot . '_r3_c9"]/div/div)'));
 
         // Check comments can be made.
-        $this->assertSame(1, (int)$xpath->evaluate('count(//td[@id="mod_assign_grading_r0_c10"]//textarea)'));
-        $this->assertSame(1, (int)$xpath->evaluate('count(//td[@id="mod_assign_grading_r3_c10"]//textarea)'));
+        $this->assertEquals(1, $xpath->evaluate('count(//td[@id="' . $xpathuniqueidroot . '_r0_c10"]//textarea)'));
+        $this->assertEquals(1, $xpath->evaluate('count(//td[@id="' . $xpathuniqueidroot . '_r3_c10"]//textarea)'));
     }
 
     public function test_show_intro() {
index 81ccfc9..46d36fb 100644 (file)
@@ -197,6 +197,7 @@ class mod_folder_external extends external_api {
                             'display' => new external_value(PARAM_INT, 'Display type of folder contents on a separate page or inline'),
                             'showexpanded' => new external_value(PARAM_INT, '1 = expanded, 0 = collapsed for sub-folders'),
                             'showdownloadfolder' => new external_value(PARAM_INT, 'Whether to show the download folder button'),
+                            'forcedownload' => new external_value(PARAM_INT, 'Whether file download is forced'),
                             'section' => new external_value(PARAM_INT, 'Course section id'),
                             'visible' => new external_value(PARAM_INT, 'Module visibility'),
                             'groupmode' => new external_value(PARAM_INT, 'Group mode'),
index 912a3d6..8b03ddb 100644 (file)
@@ -131,11 +131,13 @@ class mod_folder_external_testcase extends externallib_advanced_testcase {
         // First folder.
         $record = new stdClass();
         $record->course = $course1->id;
+        $record->forcedownload = 1;
         $folder1 = self::getDataGenerator()->create_module('folder', $record);
 
         // Second folder.
         $record = new stdClass();
         $record->course = $course2->id;
+        $record->forcedownload = 0;
         $folder2 = self::getDataGenerator()->create_module('folder', $record);
 
         // Execute real Moodle enrolment as we'll call unenrol() method on the instance later.
@@ -154,7 +156,7 @@ class mod_folder_external_testcase extends externallib_advanced_testcase {
         // Create what we expect to be returned when querying the two courses.
         $expectedfields = array('id', 'coursemodule', 'course', 'name', 'intro', 'introformat', 'introfiles', 'revision',
                                 'timemodified', 'display', 'showexpanded', 'showdownloadfolder', 'section', 'visible',
-                                'groupmode', 'groupingid');
+                                'forcedownload', 'groupmode', 'groupingid');
 
         // Add expected coursemodule and data.
         $folder1->coursemodule = $folder1->cmid;
index 3fe90ac..90dd162 100644 (file)
@@ -131,7 +131,7 @@ Feature: The forum search allows users to perform advanced searches for forum po
     And I should see "Advanced search"
     And I set the field "Is tagged with" to "SearchedTag"
     And I click on "[data-value='SearchedTag']" "css_element"
-    And I press key "27" in the field "Is tagged with"
+    And I press the escape key
     When I press "Search forums"
     Then I should see "My subject"
     And I should not see "Your subjective"
diff --git a/mod/forum/tests/behat/grade_view_discussion.feature b/mod/forum/tests/behat/grade_view_discussion.feature
new file mode 100644 (file)
index 0000000..4cafd08
--- /dev/null
@@ -0,0 +1,60 @@
+@mod @mod_forum @javascript
+Feature: View discussion while grading in a forum
+  In order to grade efficiently
+  As a teacher
+  I want to be able to see the full discussion the student was taking part in.
+
+  Background:
+    # Student 1 needs to be created first or they will not be the first user on the grading screen.
+    Given the following "users" exist:
+      | username | firstname | lastname | email                 |
+      | student1 | Student   | 1        | student.1@example.com |
+      | student2 | Student   | 2        | student.2@example.com |
+      | teacher  | Teacher   | Tom      | teacher@example.com   |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | student1 | C1     | student        |
+      | student2 | C1     | student        |
+      | teacher  | C1     | editingteacher |
+    And the following "activity" exists:
+      | activity    | forum                      |
+      | name        | Gradable forum             |
+      | intro       | Standard forum description |
+      | course      | C1                         |
+      | idnumber    | forum1                     |
+      | grade_forum | 100                        |
+      | scale       | 100                        |
+    # If there is more than one pots for Student 1 the test will not be able to select the
+    # correct View discussion link, as there is no selector for thier container.
+    And the following forum discussions exist in course "Course 1":
+      | forum          | user     | name     | message                          |
+      | Gradable forum | student1 | My topic | This is the thing I posted about |
+    And the following forum replies exist in course "Course 1":
+      | forum          | user     | discussion | message    |
+      | Gradable forum | student2 | My topic   | I disagree |
+
+  Scenario: Viewing a discussion
+    Given I log in as "teacher"
+    And I am on "Course 1" course homepage
+    And I follow "Gradable forum"
+    And I press "Grade users"
+    When I press "View discussion"
+    Then I should see "I disagree" in the "My topic" "dialogue"
+    And I click on "Cancel" "button" in the "My topic" "dialogue"
+    And I should not see "I disagree"
+
+  Scenario: Viewing a discussion while grading is fullscreen
+    Given I log in as "teacher"
+    And I am on "Course 1" course homepage
+    And I follow "Gradable forum"
+    And I press "Grade users"
+    # Uses the aria-label for the menu in in the grading interface.
+    And I press "Actions for the grader interface"
+    And I press "Toggle full screen"
+    When I press "View discussion"
+    Then I should see "I disagree" in the "My topic" "dialogue"
+    And I click on "Cancel" "button" in the "My topic" "dialogue"
+    And I should not see "I disagree"
index 25eefbf..30eed07 100644 (file)
@@ -23,7 +23,7 @@ Feature: Rename external tools via inline editing
       | Activity name | Test tool activity 1 |
     And I click on "Edit title" "link" in the "li#section-1" "css_element"
     And I set the field "New name for activity Test tool activity 1" to "Test tool activity renamed"
-    And I press key "13" in the field "New name for activity Test tool activity 1"
+    And I press the enter key
     And I navigate to "Setup > Gradebook setup" in the course gradebook
     Then I should not see "Test tool activity 1"
     And I should see "Test tool activity renamed"
index 3ff5acd..94b56cf 100644 (file)
@@ -44,7 +44,7 @@ Feature: Adding questions to a quiz from the question bank
     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..."
+    And I press the enter key
     And I should see "question 01 name" in the "categoryquestions" "table"
     And I should not see "question 02 name" in the "categoryquestions" "table"
 
index 7b4184d..b4bee51 100644 (file)
@@ -36,8 +36,7 @@ Feature: Edit quiz marks with no attempts
     And I should see "Total of marks: 10.00"
 
     When I follow "Edit maximum mark"
-    And I wait until "li input[name=maxmark]" "css_element" exists
-    And I take focus off "li input[name=maxmark]" "css_element"
+    And I press the escape key
     Then I should see "7.00"
     And I should see "3.00"
     And I should see "Total of marks: 10.00"
index a23acfb..36c2edd 100644 (file)
@@ -46,8 +46,7 @@ Feature: Edit quiz marks with attempts
     And I should see "Total of marks: 10.00"
 
     When I follow "Edit maximum mark"
-    And I wait until "li input[name=maxmark]" "css_element" exists
-    And I take focus off "li input[name=maxmark]" "css_element"
+    And I press the escape key
     Then I should see "7.00"
     And I should see "3.00"
     And I should see "Total of marks: 10.00"
index 0fd411a..c54a19f 100644 (file)
@@ -162,6 +162,12 @@ M.mod_scorm.init = function(Y, nav_display, navposition_left, navposition_top, h
             if (!window_name && node.title != null) {
                 obj.setAttribute('src', url_prefix + node.title);
             }
+            // Attach unload observers to the iframe. The scorm package may be observing these unload events
+            // and trying to save progress when they occur. We need to ensure we use the Beacon API in those
+            // situations.
+            if (typeof mod_scorm_monitorForBeaconRequirement !== 'undefined') {
+                mod_scorm_monitorForBeaconRequirement(obj);
+            }
             if (window_name) {
                 var mine = window.open('','','width=1,height=1,left=0,top=0,scrollbars=no');
                 if(! mine) {
@@ -308,7 +314,12 @@ M.mod_scorm.init = function(Y, nav_display, navposition_left, navposition_top, h
 
         };
 
-        // Handle AJAX Request
+        /**
+         * @deprecated as it is now unused.
+         * @param {string} url
+         * @param {string} datastring
+         * @returns {string|*|boolean}
+         */
         var scorm_ajax_request = function(url, datastring) {
             var myRequest = NewHttpReq();
             var result = DoRequest(myRequest, url + datastring);
@@ -454,13 +465,30 @@ M.mod_scorm.init = function(Y, nav_display, navposition_left, navposition_top, h
             return null;
         };
 
+        /**
+         * Sends a request to the sequencing handler script on the server.
+         * @param {string} datastring
+         * @returns {string|boolean|*}
+         */
+        var scorm_dorequest_sequencing = function(datastring) {
+            var myRequest = NewHttpReq();
+            var result = DoRequest(
+                myRequest,
+                M.cfg.wwwroot + '/mod/scorm/datamodels/sequencinghandler.php?' + datastring,
+                '',
+                false
+            );
+            return result;
+        };
+
         // Launch prev sco
         var scorm_launch_prev_sco = function() {
             var result = null;
             if (scoes_nav[launch_sco].flow === 1) {
                 var datastring = scoes_nav[launch_sco].url + '&function=scorm_seq_flow&request=backward';
-                result = scorm_ajax_request(M.cfg.wwwroot + '/mod/scorm/datamodels/sequencinghandler.php?', datastring);
+                result = scorm_dorequest_sequencing(datastring);
 
+                // Check the scorm_ajax_result, it may be false.
                 if (result === false) {
                     // Either the outcome was a failure, or we are unloading and simply just don't know
                     // what the outcome actually was.
@@ -498,8 +526,9 @@ M.mod_scorm.init = function(Y, nav_display, navposition_left, navposition_top, h
             var result = null;
             if (scoes_nav[launch_sco].flow === 1) {
                 var datastring = scoes_nav[launch_sco].url + '&function=scorm_seq_flow&request=forward';
-                result = scorm_ajax_request(M.cfg.wwwroot + '/mod/scorm/datamodels/sequencinghandler.php?', datastring);
+                result = scorm_dorequest_sequencing(datastring);
 
+                // Check the scorm_ajax_result, it may be false.
                 if (result === false) {
                     // Either the outcome was a failure, or we are unloading and simply just don't know
                     // what the outcome actually was.
index 4bea10b..21a1ce0 100644 (file)
@@ -35,10 +35,57 @@ function NewHttpReq() {
     return httpReq;
 }
 
-function DoRequest(httpReq,url,param) {
+/**
+ *
+ * @param {XMLHttpRequest} httpReq
+ * @param {string} url
+ * @param {string} param
+ * @param {boolean} allowBeaconAPI Should the BeaconAPI be used if required? Defaults to true
+ *    If True, and we can use the Beacon API and are should use the beacon API then we will.
+ *    If False, we will not use the Beacon API, even if we expect a synchronous XHR request to fail.
+ * @returns {string|boolean|*}
+ * @constructor
+ */
+function DoRequest(httpReq, url, param, allowBeaconAPI) {
 
-    // If we are unloading, and we can use sendBeacon then do that, Chrome does not permit synchronous XHR requests on unload.
-    if (window.mod_scorm_is_window_closing && navigator && navigator.sendBeacon && FormData) {
+    // Default allowBeaconAPI to true. This argument was added to the function late.
+    if (typeof allowBeaconAPI === 'undefined') {
+        allowBeaconAPI = true;
+    }
+
+    /**
+     * Returns true if we are able to use the Beacon API in this browser.
+     * @returns boolean
+     */
+    var canUseBeaconAPI = function() {
+        return (allowBeaconAPI && navigator && navigator.sendBeacon && FormData);
+    };
+
+    /**
+     * Returns true if we should use the Beacon API.
+     * We don't use the Beacon API unless we have to as it stiffles our ability to return data on the request.
+     * @returns {boolean}
+     */
+    var useBeaconAPI = function() {
+        if (typeof window.mod_scorm_useBeaconAPI === 'undefined' || window.mod_scorm_useBeaconAPI === false) {
+            // Last ditch effort, the SCORM package may have introduced its own listeners before our listeners.
+            // This is OLD API, window.event is not reliable and is not recommended API.
+            // https://developer.mozilla.org/en-US/docs/Web/API/Window/event
+            if (window.event && ['beforeunload', 'unload', 'pagehide'].indexOf(window.event.type)) {
+                window.mod_scorm_useBeaconAPI = true;
+            }
+        }
+        return (window.mod_scorm_useBeaconAPI && canUseBeaconAPI());
+    };
+
+    /**
+     * Uses the Beacon API to communicate this request to the server.
+     * This function always returns a successful result, because we don't get the actual result, the page doesn't wait for it.
+     * @param {string} url
+     * @param {string} param
+     * @returns {string}
+     */
+    var useSendBeacon = function(url, param) {
         // Ok, old API alert, the param is a URI encoded string. We need to split it and convert it to a supported format.
         // I've chosen FormData and FormData.append as they are compatible with our supported browsers:
         //  - https://developer.mozilla.org/en-US/docs/Web/API/FormData/FormData
@@ -59,10 +106,31 @@ function DoRequest(httpReq,url,param) {
         // We'll also inform it that we are unloading, potentially useful in the future.
         formData.append('unloading', '1');
 
+        // We're going to add a token to the URL that will identify this request as going to the beacon API.
+        // In the future this would allow our server side scripts to respond differently when the beacon API
+        // is being used, as the response will be discarded.
+        if (url.indexOf('?') === -1) {
+            // First param
+            url += '?api=beacon';
+        } else {
+            url += '&api=beacon';
+        }
+
         // The results is true or false, we don't get the response from the server. Make it look like it was a success.
-        navigator.sendBeacon(url, formData);
+        var outcome = navigator.sendBeacon(url, formData);
+        if (!outcome) {
+            if (console && console.log) {
+                console.log('mod_scorm: Failed to queue navigator.sendBeacon request');
+            }
+            return "false\n101";
+        }
         // This is what a success looks like when it comes back from the server.
         return "true\n0";
+    };
+
+    // If we are unloading, and we can use sendBeacon then do that, Chrome does not permit synchronous XHR requests on unload.
+    if (useBeaconAPI()) {
+        return useSendBeacon(url, param);
     }
 
     // httpReq.open (Method("get","post"), URL(string), Asyncronous(true,false))
@@ -72,6 +140,20 @@ function DoRequest(httpReq,url,param) {
     try {
         httpReq.send(param);
     } catch (e) {
+        if (console && console.log) {
+            // This may be frivolous as during a shutdown the console log will most likely be lost. But it may help someone.
+            var message = 'XHR request from mod_scorm::DoRequest failed';
+            if (canUseBeaconAPI()) {
+                message += '; attempting to use Beacon API.';
+            }
+            console.log(message);
+        }
+        // The HTTP request failed. We don't know why, but as a last ditch effort, in case we are unloading and haven't detected it
+        // we will attempt to send the request one more time using the Beacon API. This will result in a successful result regardless
+        // of the actual outcome.
+        if (canUseBeaconAPI()) {
+            return useSendBeacon(url, param);
+        }
         return false;
     }
     if (httpReq.status == 200) {
@@ -89,6 +171,12 @@ function popupwin(content) {
     op.document.close();
 }
 
+/**
+ * Global variable to track whether we should use the Beacon API instead of synchronous XHR.
+ * This gets set to true in situations where we expect synchronoush XHR requests to fail.
+ */
+window.mod_scorm_useBeaconAPI = false;
+
 /**
  * We wire up a small marker for the unload events triggered when the user is navigating away or closing the tab.
  * This is done because Chrome does not allow synchronous XHR requests on the following unload events:
@@ -97,15 +185,73 @@ function popupwin(content) {
  *  - pagehide
  *  - visibilitychange
  */
-(function() {
-    // Set up a global var. Sorry about this, old code ... old ways.
-    window.mod_scorm_is_window_closing = false;
-    var toggle = function() {
-        window.mod_scorm_is_window_closing = true;
+function mod_scorm_monitorForBeaconRequirement(target) {
+
+    if (typeof target.mod_scorm_monitoring_for_beacon_requirement !== 'undefined') {
+        // We're already observing unload events on this target.
+        console.log('mod_scorm: unload event handlers already attached');
+        return;
+    }
+    target.mod_scorm_monitoring_for_beacon_requirement = true;
+
+    // The navigator.sendBeacon API is available in all browsers EXCEPT Internet Explorer (IE)
+    // Internet explorer should never get past this check.
+    if (!navigator || !navigator.sendBeacon) {
+        // We can't use the BeaconAPI. There is no point in proceeding to observe unload events.
+        // This is done after adding the flag to target, and establishing the window variable.
+        return;
+    }
+
+    /**
+     * Turns on the use of the Beacon API.
+     */
+    var toggleOn = function() {
+        window.mod_scorm_useBeaconAPI = true;
     };
-    // Listen to the four events known to represent an unload operation.
-    window.addEventListener('beforeunload', toggle);
-    window.addEventListener('unload', toggle);
-    window.addEventListener('pagehide', toggle);
-    window.addEventListener('visibilitychange', toggle);
-})();
\ No newline at end of file
+
+    /**
+     * Turns off the use of the Beacon API.
+     */
+    var toggleOff = function() {
+        window.mod_scorm_useBeaconAPI = false;
+    };
+
+    /**
+     * Observes an event.
+     * Required because this patch will be backported.
+     * @param {string} on
+     * @param {CallableFunction} callback
+     */
+    var observe = function(on, callback) {
+        if (!target.addEventListener) {
+            console.log('Unable to attach page dismissal event listeners');
+            return null;
+        }
+        return target.addEventListener(on, callback);
+    };
+
+    // Listen to the three events known to represent an unload operation.
+    observe('beforeunload', toggleOn);
+    observe('unload', toggleOn);
+    observe('pagehide', toggleOn);
+
+    // Listen to the event fired when navigating to a page and ensure we toggle useBeaconAPI off.
+    // This shouldn't be needed (page should be uncached) but just in case!
+    observe('pageshow', toggleOff);
+
+    // Finally listen to the visibility change event, and respond to it.
+    // This unfortunately is not ideal, but is required as a SCORM package may also be listening to this and
+    // trying to save content when the user hides the page. As this can occur as part of the page dismissal lifecycle
+    // we also need to ensure we use the Beacon API here.
+    observe('visibilitychange', function() {
+        // Visible means synchronous XHR permitted, use XHR.
+        // Hidden means synchronous XHR not permitted, use Beacon API.
+        if (document.visibilityState === 'visible' || document.visibilityState === 'prerender') {
+            toggleOff();
+        } else if (document.visibilityState === 'hidden') {
+            toggleOn();
+        }
+    });
+}
+// Begin monitoring on the main window immediately.
+mod_scorm_monitorForBeaconRequirement(window);
\ No newline at end of file
index ff2346c..0d2e019 100644 (file)
@@ -266,9 +266,8 @@ echo html_writer::end_tag('div');
 echo html_writer::end_tag('form');
 
 // Output the technical info.
-print_collapsible_region_start('', 'techinfo', get_string('technicalinfo', 'question') .
-        $OUTPUT->help_icon('technicalinfo', 'question'),
-        'core_question_preview_techinfo_collapsed', true);
+print_collapsible_region_start('', 'techinfo', get_string('technicalinfo', 'question'),
+        'core_question_preview_techinfo_collapsed', true, false, $OUTPUT->help_icon('technicalinfo', 'question'));
 foreach ($technical as $info) {
     echo html_writer::tag('p', $info, array('class' => 'notifytiny'));
 }
index acbefe6..ce9df72 100644 (file)
@@ -36,6 +36,6 @@ Feature: The questions in the question bank can be filtered by tags
   @javascript
   Scenario: The questions can be filtered by tag
     When I set the field "Filter by tags..." to "foo"
-    And I press key "13" in the field "Filter by tags..."
+    And I press the enter key
     Then I should see "question 1 name" in the "categoryquestions" "table"
     And I should not see "question 2 name" in the "categoryquestions" "table"
index ced73dc..d2fc17d 100644 (file)
@@ -80,10 +80,10 @@ class behat_qtype_ddimageortext extends behat_base {
     public function i_type_on_place_in_the_drag_and_drop_onto_image_question($keys, $placenumber) {
         $node = $this->get_selected_node('xpath_element', $this->drop_xpath($placenumber));
         $this->ensure_node_is_visible($node);
+
+        $node->focus();
         foreach (str_split($keys) as $key) {
-            $node->keyDown($key);
-            $node->keyPress($key);
-            $node->keyUp($key);
+            behat_base::type_keys($this->getSession(), [$key]);
             $this->wait_for_pending_js();
         }
     }
index 9a74a13..c90480a 100644 (file)
@@ -103,18 +103,11 @@ class behat_qtype_ddmarker extends behat_base {
      * @Given /^I type "(?P<direction>up|down|left|right)" "(?P<repeats>\d+)" times on marker "(?P<marker>[^"]*)" in the drag and drop markers question$/
      */
     public function i_type_on_marker_in_the_drag_and_drop_markers_question($direction, $repeats, $marker) {
-        $keycodes = array(
-            'up'    => chr(38),
-            'down'  => chr(40),
-            'left'  => chr(37),
-            'right' => chr(39),
-        );
         $node = $this->get_selected_node('xpath_element', $this->marker_xpath($marker, true));
         $this->ensure_node_is_visible($node);
+        $node->focus();
         for ($i = 0; $i < $repeats; $i++) {
-            $node->keyDown($keycodes[$direction]);
-            $node->keyPress($keycodes[$direction]);
-            $node->keyUp($keycodes[$direction]);
+            $this->execute('behat_general::i_press_named_key', ['', $direction]);
         }
     }
 }
index 560c688..049dec2 100644 (file)
@@ -50,8 +50,8 @@ Feature: Preview a drag-drop marker question
     # Keep window large else drag will scroll the window to find element.
     And I change window size to "medium"
     And I wait "2" seconds
-    And I type "up" "44" times on marker "Railway station" in the drag and drop markers question
-    And I type "right" "13" times on marker "Railway station" in the drag and drop markers question
+    And I type "up" "88" times on marker "Railway station" in the drag and drop markers question
+    And I type "right" "26" times on marker "Railway station" in the drag and drop markers question
     And I press "Submit and finish"
     Then the state of "Please place the markers on the map of Milton Keynes" question is shown as "Partially correct"
     And I should see "Mark 0.25 out of 1.00"
index f04131d..c15dba0 100644 (file)
@@ -79,10 +79,9 @@ class behat_qtype_ddwtos extends behat_base {
     public function i_type_into_space_in_the_drag_and_drop_into_text_question($keys, $spacenumber) {
         $node = $this->get_selected_node('xpath_element', $this->drop_xpath($spacenumber));
         $this->ensure_node_is_visible($node);
+        $node->focus();
         foreach (str_split($keys) as $key) {
-            $node->keyDown($key);
-            $node->keyPress($key);
-            $node->keyUp($key);
+            behat_base::type_keys($this->getSession(), [$key]);
             $this->wait_for_pending_js();
         }
     }
index dfa0876..62368c9 100644 (file)
@@ -47,7 +47,7 @@ Feature: See the competencies for an activity
   Scenario: Go to the competency breakdown report
     When I navigate to "Reports > Competency breakdown" in current page administration
     And I set the field "Filter competencies by resource or activity" to "PageName1"
-    And I press key "13" in the field "Filter competencies by resource or activity"
+    And I press the enter key
     Then I should see "Test-Comp1"
     And I should not see "Test-Comp2"
     And I click on "Not rated" "link"
@@ -56,6 +56,6 @@ Feature: See the competencies for an activity
     And I click on "Rate" "button" in the ".competency-grader" "css_element"
     And I click on "Close" "button"
     And I set the field "Filter competencies by resource or activity" to "No filters applied"
-    And I press key "13" in the field "Filter competencies by resource or activity"
+    And I press the enter key
     And I should see "Test-Comp1"
     And I should see "Test-Comp2"
index eaad727..6cdb568 100644 (file)
@@ -33,7 +33,7 @@ Feature: Managers can create and manage tag collections
   Scenario: Editing tag collections
     When I click on "Edit tag collection name" "link" in the "//table[contains(@class,'tag-collections-table')]//tr[contains(.,'Hobbies')]" "xpath_element"
     And I set the field "New name for tag collection Hobbies" to "Newname"
-    And I press key "13" in the field "New name for tag collection Hobbies"
+    And I press the enter key
     Then I should not see "Hobbies"
     And I should see "Newname"
     And I log out
index 555facd..4682edb 100644 (file)
@@ -52,7 +52,6 @@ Feature: Users can edit tags to add description or rename
     And I should see "Dog" in the ".tag_list" "css_element"
     And I should see "Turtle" in the ".tag_list" "css_element"
     And I should see "Fish" in the ".tag_list" "css_element"
-    And I log out
 
   @javascript
   Scenario: Manager can change tag description, related tags and rename the tag from tag view page
@@ -87,7 +86,6 @@ Feature: Users can edit tags to add description or rename
     And I should see "Turtle" in the ".tag_list" "css_element"
     And I should see "Fish" in the ".tag_list" "css_element"
     And I should not see "Dog"
-    And I log out
 
   Scenario: Renaming the tag from tag view page
     When I log in as "manager1"
@@ -112,7 +110,6 @@ Feature: Users can edit tags to add description or rename
       | Tag name | KITTEN |
     And I press "Update"
     And "KITTEN" "text" should exist in the ".breadcrumb" "css_element"
-    And I log out
 
   @javascript
   Scenario: Manager can change tag description and rename the tag from tag manage page
@@ -133,7 +130,6 @@ Feature: Users can edit tags to add description or rename
     And I should see "Dog" in the ".tag_list" "css_element"
     And I should see "Turtle" in the ".tag_list" "css_element"
     And I should see "Fish" in the ".tag_list" "css_element"
-    And I log out
 
   Scenario: Renaming the tag in edit tag form from tag manage page
     When I log in as "manager1"
@@ -155,7 +151,6 @@ Feature: Users can edit tags to add description or rename
     And "Default collection" "text" should exist in the ".breadcrumb" "css_element"
     And I should see "KITTEN"
     And I should not see "Kitten"
-    And I log out
 
   @javascript
   Scenario: Renaming the tag using quick edit field on tag manage page
@@ -165,7 +160,7 @@ Feature: Users can edit tags to add description or rename
     # Renaming tag to a valid name
     And I click on "Edit tag name" "link" in the "Cat" "table_row"
     And I set the field "New name for tag Cat" to "Kitten"
-    And I press key "13" in the field "New name for tag Cat"
+    And I press the enter key
     Then I should not see "Cat"
     And "New name for tag" "field" should not exist
     And I wait until "Kitten" "link" exists
@@ -175,7 +170,7 @@ Feature: Users can edit tags to add description or rename
     # Renaming tag to an invalid name
     And I click on "Edit tag name" "link" in the "Turtle" "table_row"
     And I set the field "New name for tag Turtle" to "DOG"
-    And I press key "13" in the field "New name for tag Turtle"
+    And I press the enter key
     And I should see "The tag name is already in use. Do you want to combine these tags?"
     And I click on "Cancel" "button" in the "Confirm" "dialogue"
     And "New name for tag" "field" should not exist
@@ -189,14 +184,13 @@ Feature: Users can edit tags to add description or rename
     # Cancel tag renaming
     And I click on "Edit tag name" "link" in the "Dog" "table_row"
     And I set the field "New name for tag Dog" to "Penguin"
-    And I press key "27" in the field "New name for tag Dog"
+    And I press the escape key
     And "New name for tag" "field" should not exist
     And I should see "Turtle"
     And I should not see "Penguin"
     And I follow "Default collection"
     And I should see "Turtle"
     And I should not see "Penguin"
-    And I log out
 
   @javascript
   Scenario: Combining tags when renaming
@@ -205,13 +199,12 @@ Feature: Users can edit tags to add description or rename
     And I follow "Default collection"
     And I click on "Edit tag name" "link" in the "Turtle" "table_row"
     And I set the field "New name for tag Turtle" to "DOG"
-    And I press key "13" in the field "New name for tag Turtle"
+    And I press the enter key
     And I should see "The tag name is already in use. Do you want to combine these tags?"
     And I press "Yes"
     Then I should not see "Turtle"
     And I should not see "DOG"
     And I should see "Dog"
-    And I log out
 
   @javascript
   Scenario: Combining multiple tags
@@ -232,7 +225,6 @@ Feature: Users can edit tags to add description or rename
     And I should see "Turtle"
     # Even though Turtle was not standard but at least one of combined tags was (Neverusedtag). Now Turtle is also standard.
     And "Remove from standard tags" "link" should exist in the "Turtle" "table_row"
-    And I log out
 
   Scenario: Filtering tags
     When I log in as "manager1"
@@ -249,4 +241,3 @@ Feature: Users can edit tags to add description or rename
     And I should see "Dog"
     And I should see "Cat"
     And I should see "Turtle"
-    And I log out
index 06e97bf..ea0931a 100644 (file)
@@ -423,6 +423,13 @@ body.jsenabled .questionflag input[type=checkbox] {
     margin: 1em 0;
 }
 
+#page-question-preview .collapsibleregion {
+    .collapsibleregioncaption,
+    .collapsibleregionextracontent {
+        display: inline-block;
+    }
+}
+
 // imported from quiz.css
 
 #page-mod-quiz-edit ul.slots .activityinstance {
index 5379e2f..59d3819 100644 (file)
@@ -15820,6 +15820,10 @@ body.jsenabled .questionflag input[type=checkbox] {
 #page-question-preview #techinfo {
   margin: 1em 0; }
 
+#page-question-preview .collapsibleregion .collapsibleregioncaption,
+#page-question-preview .collapsibleregion .collapsibleregionextracontent {
+  display: inline-block; }
+
 #page-mod-quiz-edit ul.slots .activityinstance > a {
   display: flex;
   max-width: 100%;
index 5fd375c..56c28d1 100644 (file)
@@ -16041,6 +16041,10 @@ body.jsenabled .questionflag input[type=checkbox] {
 #page-question-preview #techinfo {
   margin: 1em 0; }
 
+#page-question-preview .collapsibleregion .collapsibleregioncaption,
+#page-question-preview .collapsibleregion .collapsibleregionextracontent {
+  display: inline-block; }
+
 #page-mod-quiz-edit ul.slots .activityinstance > a {
   display: flex;
   max-width: 100%;
index 4e49ea2..071682e 100644 (file)
Binary files a/user/amd/build/participants.min.js and b/user/amd/build/participants.min.js differ
index 5938593..aeaff37 100644 (file)
Binary files a/user/amd/build/participants.min.js.map and b/user/amd/build/participants.min.js.map differ
index ebe72f6..63c8e0a 100644 (file)
@@ -125,7 +125,7 @@ export const init = ({
                 .then(tableRoot => {
                     // Always update the toggle state.
                     // This ensures that the bulk actions are disabled after changing the page size.
-                    CheckboxToggleAll.setGroupState(tableRoot, 'participants-table', checkCountButtonClicked);
+                    CheckboxToggleAll.setGroupState(root, 'participants-table', checkCountButtonClicked);
 
                     return tableRoot;
                 })
@@ -144,7 +144,7 @@ export const init = ({
             const currentPageSize = parseInt(tableRoot.dataset.tablePageSize, 10);
             const totalRowCount = parseInt(tableRoot.dataset.tableTotalRows, 10);
 
-            CheckboxToggleAll.updateSlavesFromMasterState(tableRoot, 'participants-table');
+            CheckboxToggleAll.updateSlavesFromMasterState(root, 'participants-table');
 
             const pageCountStrings = [
                 {
index 90ff654..dbf1130 100644 (file)
@@ -27,7 +27,7 @@ Feature: Edit user roles
     And I click on "Student 1's role assignments" "link"
     And I click on ".form-autocomplete-downarrow" "css_element" in the "student1" "table_row"
     And I click on "Non-editing teacher" item in the autocomplete list
-    And I press key "27" in the field "Student 1's role assignments"
+    And I press the escape key
     When I click on "Save changes" "link"
     Then I should see "Student, Non-editing teacher" in the "Student 1" "table_row"
 
@@ -38,6 +38,6 @@ Feature: Edit user roles
     And I navigate to course participants
     And I click on "Student 1's role assignments" "link"
     And I click on ".form-autocomplete-selection [aria-selected=true]" "css_element"
-    And I press key "27" in the field "Student 1's role assignments"
+    And I press the escape key
     When I click on "Save changes" "link"
     Then I should see "No roles" in the "Student 1" "table_row"
index 9ebe633..e962dfc 100644 (file)
@@ -238,7 +238,7 @@ Feature: Course participants can be filtered
     And I navigate to course participants
     And I click on "Student 1's role assignments" "link"
     And I click on ".form-autocomplete-selection [aria-selected=true]" "css_element"
-    And I press key "27" in the field "Student 1's role assignments"
+    And I press the escape key
     And I click on "Save changes" "link"
     And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
     And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
@@ -350,7 +350,7 @@ Feature: Course participants can be filtered
     And I set the field "Match" in the "Filter 3" "fieldset" to "Any"
     And I set the field "type" in the "Filter 3" "fieldset" to "Keyword"
     And I set the field "Type..." to "teacher1"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Student 2" in the "participants" "table"
     And I should see "Student 4" in the "participants" "table"
@@ -396,7 +396,7 @@ Feature: Course participants can be filtered
     And I set the field "Match" in the "Filter 3" "fieldset" to "Any"
     And I set the field "type" in the "Filter 3" "fieldset" to "Keyword"
     And I set the field "Type..." to "3@"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Student 1" in the "participants" "table"
     And I should not see "Student 2" in the "participants" "table"
@@ -419,7 +419,7 @@ Feature: Course participants can be filtered
     And I set the field "Match" in the "Filter 1" "fieldset" to "Any"
     And I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
     And I set the field "Type..." to "1@example"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     When I click on "Apply filters" "button"
     Then I should see "Student 1" in the "participants" "table"
     And I should see "Teacher 1" in the "participants" "table"
@@ -449,7 +449,7 @@ Feature: Course participants can be filtered
     And I should not see "Teacher 1" in the "participants" "table"
     # Add a second keyword filter value
     And I set the field "Type..." to "moodle"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Student 2" in the "participants" "table"
     And I should see "Student 3" in the "participants" "table"
@@ -534,7 +534,7 @@ Feature: Course participants can be filtered
     And I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
     # Search by email (only).
     And I set the field "Type..." to "student1@example.com"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     When I click on "Apply filters" "button"
     Then I should see "Student 1" in the "participants" "table"
     And I should not see "Student 2" in the "participants" "table"
@@ -542,7 +542,7 @@ Feature: Course participants can be filtered
     # Search by idnumber (only).
     And I click on "student1@example.com" "autocomplete_selection"
     And I set the field "Type..." to "SID"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Student 1" in the "participants" "table"
     And I should see "Student 2" in the "participants" "table"
@@ -552,7 +552,7 @@ Feature: Course participants can be filtered
     # Search by city (only).
     And I click on "SID" "autocomplete_selection"
     And I set the field "Type..." to "SCITY"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Student 1" in the "participants" "table"
     And I should see "Student 2" in the "participants" "table"
@@ -562,13 +562,13 @@ Feature: Course participants can be filtered
     # Search by country text (only) - should not match.
     And I click on "SCITY" "autocomplete_selection"
     And I set the field "Type..." to "GB"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Nothing to display"
     # Check no match.
     And I click on "GB" "autocomplete_selection"
     And I set the field "Type..." to "NOTHING"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Nothing to display"
 
@@ -586,7 +586,7 @@ Feature: Course participants can be filtered
     # Search by email (only) - should only see visible email + own.
     And I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
     And I set the field "Type..." to "@example."
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     When I click on "Apply filters" "button"
     Then I should not see "Student 1" in the "participants" "table"
     And I should see "Student 2" in the "participants" "table"
@@ -596,25 +596,25 @@ Feature: Course participants can be filtered
     # Search for other fields - should only see own results.
     And I click on "@example." "autocomplete_selection"
     And I set the field "Type..." to "SID"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Nothing to display"
     And I click on "SID" "autocomplete_selection"
     And I set the field "Type..." to "TID"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Teacher 1" in the "participants" "table"
     And I should not see "Student 1" in the "participants" "table"
     And I click on "TID" "autocomplete_selection"
     And I set the field "Type..." to "CITY"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Teacher 1" in the "participants" "table"
     And I should not see "Student 1" in the "participants" "table"
     # Check no match.
     And I click on "CITY" "autocomplete_selection"
     And I set the field "Type..." to "NOTHING"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Nothing to display"
 
@@ -633,7 +633,7 @@ Feature: Course participants can be filtered
     And I set the field "Match" in the "Filter 2" "fieldset" to "Any"
     And I set the field "type" in the "Filter 2" "fieldset" to "Keyword"
     And I set the field "Type..." to "@example"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Student 1" in the "participants" "table"
     And I should see "Student 2" in the "participants" "table"
@@ -662,7 +662,7 @@ Feature: Course participants can be filtered
     And I set the field "Match" in the "Filter 2" "fieldset" to "Any"
     And I set the field "type" in the "Filter 2" "fieldset" to "Keyword"
     And I set the field "Type..." to "@example"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Apply filters" "button"
     And I should see "Student 1" in the "participants" "table"
     And I should see "Student 2" in the "participants" "table"
@@ -684,7 +684,7 @@ Feature: Course participants can be filtered
     And I set the field "Match" in the "Filter 1" "fieldset" to "Any"
     And I set the field "type" in the "Filter 1" "fieldset" to "Keyword"
     And I set the field "Type..." to "@example.com"
-    And I press key "13" in the field "Type..."
+    And I press the enter key
     And I click on "Add condition" "button"
     # Set filterset to match none.
     And I set the field "Match" to "None"
index 67d9209..f023b5d 100644 (file)
@@ -180,6 +180,7 @@ Feature: View course participants
     And the field "Select 'Student 18x'" matches value "0"
     And the field "Select 'Student 19x'" matches value "0"
 
+    # Pressing the "Select all X users" button should select all including the 21st user (Student 9x).
     And I press "Select all 21 users"
     And I should see "Student 9x"
     And the field "Select 'Teacher 1x'" matches value "1"
@@ -204,6 +205,7 @@ Feature: View course participants
     And the field "Select 'Student 17x'" matches value "1"
     And the field "Select 'Student 18x'" matches value "1"
     And the field "Select 'Student 19x'" matches value "1"
+    And the "With selected users..." "select" should be enabled
 
     And I click on "Deselect all" "checkbox"
     And the field "Select 'Teacher 1x'" matches value "0"