Merge branch 'MDL-63266-master' of git://github.com/peterRd/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Thu, 26 Nov 2020 03:35:38 +0000 (11:35 +0800)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 26 Nov 2020 11:46:11 +0000 (12:46 +0100)
201 files changed:
.travis.yml
admin/tool/behat/tests/behat/inplaceeditable.feature [new file with mode: 0644]
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/renderer.php
admin/tool/task/schedule_task.php
admin/tool/task/scheduledtasks.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
auth/db/auth.php
auth/db/tests/db_test.php
auth/oauth2/login.php
blocks/classes/external.php
blocks/myoverview/classes/output/main.php
blocks/section_links/block_section_links.php
blocks/section_links/edit_form.php
blocks/section_links/lang/en/block_section_links.php
blocks/section_links/renderer.php
blocks/section_links/settings.php
blocks/section_links/tests/behat/show_section_name.feature [new file with mode: 0644]
blocks/section_links/upgrade.txt [new file with mode: 0644]
blocks/section_links/version.php
blocks/site_main_menu/tests/behat/behat_block_site_main_menu.php
blocks/site_main_menu/tests/behat/edit_activities.feature
blocks/social_activities/tests/behat/behat_block_social_activities.php
blocks/social_activities/tests/behat/edit_activities.feature
blocks/tag_youtube/block_tag_youtube.php
blocks/tag_youtube/db/upgrade.php [new file with mode: 0644]
blocks/tag_youtube/edit_form.php
blocks/tag_youtube/lang/en/block_tag_youtube.php
blocks/tag_youtube/tests/behat/configure_tag_youtube_block.feature [new file with mode: 0644]
blocks/tag_youtube/upgrade.txt
blocks/tag_youtube/version.php
cache/stores/redis/lib.php
cohort/tests/behat/add_cohort.feature
config-dist.php
course/format/topics/tests/behat/edit_delete_sections.feature
course/format/weeks/tests/behat/edit_delete_sections.feature
course/lib.php
course/tests/behat/activities_edit_name.feature
course/tests/behat/behat_course.php
course/tests/courselib_test.php
customfield/tests/behat/edit_categories.feature
dataformat/pdf/classes/writer.php
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
grade/tests/behat/grade_hidden_items_locked_category.feature [new file with mode: 0644]
install/lang/hy/langconfig.php
install/lang/km/error.php
install/lang/nb/langconfig.php [new file with mode: 0644]
lang/en/filters.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/icon_system_fontawesome.min.js
lib/amd/build/icon_system_fontawesome.min.js.map
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/build/user_date.min.js
lib/amd/build/user_date.min.js.map
lib/amd/src/fullscreen.js [new file with mode: 0644]
lib/amd/src/icon_system_fontawesome.js
lib/amd/src/modal.js
lib/amd/src/modal_backdrop.js
lib/amd/src/user_date.js
lib/behat/behat_base.php
lib/behat/behat_field_manager.php
lib/behat/classes/behat_session_interface.php [new file with mode: 0644]
lib/behat/classes/behat_session_trait.php [new file with mode: 0644]
lib/behat/classes/partial_named_selector.php
lib/behat/form_field/behat_form_autocomplete.php
lib/behat/form_field/behat_form_field.php
lib/behat/form_field/behat_form_inplaceeditable.php [new file with mode: 0644]
lib/classes/session/redis.php
lib/classes/task/manager.php
lib/classes/task/scheduled_task.php
lib/db/upgrade.php
lib/form/templates/element-advcheckbox.mustache
lib/form/templates/element-checkbox.mustache
lib/form/templates/element-date_selector.mustache
lib/form/templates/element-filemanager.mustache
lib/form/templates/element-filepicker.mustache
lib/form/templates/element-group.mustache
lib/form/templates/element-passwordunmask.mustache
lib/form/templates/element-radio.mustache
lib/form/templates/element-template-inline.mustache
lib/form/templates/element-template.mustache
lib/grade/grade_grade.php
lib/grade/grade_item.php
lib/grade/tests/grade_grade_test.php
lib/moodlelib.php
lib/tablelib.php
lib/tests/behat/action_menu.feature
lib/tests/behat/behat_general.php
lib/tests/scheduled_task_test.php
lib/tests/session_redis_test.php
lib/upgrade.txt
lib/weblib.php
lib/xhprof/readme_moodle.txt
lib/xhprof/xhprof_moodle.php
message/amd/build/message_drawer.min.js
message/amd/build/message_drawer.min.js.map
message/amd/src/message_drawer.js
message/templates/message_drawer.mustache
message/templates/message_drawer_view_contacts_header.mustache
message/templates/message_drawer_view_conversation_header.mustache
message/templates/message_drawer_view_overview_header.mustache
message/templates/message_drawer_view_search_header.mustache
message/templates/message_drawer_view_settings_header.mustache
message/tests/behat/message_manage_preferences.feature
mod/assign/gradingtable.php
mod/assign/tests/locallib_test.php
mod/book/tests/behat/edit_tags.feature
mod/book/tool/print/locallib.php
mod/book/upgrade.txt
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/locallib.php
mod/lti/tests/behat/renametool.feature
mod/quiz/classes/external.php
mod/quiz/mod_form.php
mod/quiz/tests/behat/behat_mod_quiz.php
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/quiz/tests/external_test.php
mod/quiz/upgrade.txt
mod/scorm/module.js
mod/scorm/request.js
payment/classes/external/get_available_gateways.php
payment/gateway/paypal/db/install.php
payment/templates/gateway.mustache
question/classes/bank/view.php
question/preview.php
question/tests/behat/filter_questions_by_tag.feature
question/type/calculated/tests/question_test.php
question/type/ddimageortext/amd/build/form.min.js
question/type/ddimageortext/amd/build/form.min.js.map
question/type/ddimageortext/amd/build/question.min.js
question/type/ddimageortext/amd/build/question.min.js.map
question/type/ddimageortext/amd/src/form.js
question/type/ddimageortext/amd/src/question.js
question/type/ddimageortext/styles.css
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
question/type/essay/question.php
question/type/essay/renderer.php
question/type/essay/tests/behat/max_file_size.feature
question/type/essay/tests/question_test.php
question/type/gapselect/questionbase.php
question/type/gapselect/tests/question_test.php
question/type/match/question.php
question/type/match/tests/question_test.php
question/type/multianswer/question.php
question/type/multianswer/tests/question_test.php
question/type/multichoice/question.php
question/type/multichoice/tests/question_multi_test.php
question/type/numerical/question.php
question/type/numerical/questiontype.php
question/type/numerical/tests/question_test.php
question/type/questionbase.php
question/type/shortanswer/question.php
question/type/shortanswer/tests/question_test.php
question/type/truefalse/question.php
question/type/truefalse/tests/question_test.php
report/competency/tests/behat/breakdown_by_activity.feature
report/insights/templates/insights_list.mustache
tag/classes/tag.php
tag/tests/behat/collections.feature
tag/tests/behat/edit_tag.feature
tag/tests/taglib_test.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/drawer.scss
theme/boost/scss/moodle/modules.scss
theme/boost/scss/moodle/question.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
theme/classic/templates/contentonly.mustache
user/action_redir.php
user/amd/build/participants.min.js
user/amd/build/participants.min.js.map
user/amd/src/participants.js
user/filters/lib.php
user/filters/user_filter_forms.php
user/tests/behat/edit_user_roles.feature
user/tests/behat/filter_participants.feature
user/tests/behat/view_participants.feature
version.php

index 7b533fe..89acef8 100644 (file)
@@ -16,31 +16,9 @@ services:
     - mysql
     - docker
 
-php:
-    # We only run the highest and lowest supported versions to reduce the load on travis-ci.org.
-    - 7.4
-    - 7.2
-
 addons:
   postgresql: "9.6"
 
-env:
-    # Although we want to run these jobs and see failures as quickly as possible, we also want to get the slowest job to
-    # start first so that the total run time is not too high.
-    #
-    # We only run MySQL on PHP 7.2, so run that first.
-    # CI Tests should be second-highest in priority as these only take <= 60 seconds to run under normal circumstances.
-    # Postgres is significantly is pretty reasonable in its run-time.
-
-    # Run CI Tests without running PHPUnit.
-    - DB=none     TASK=CITEST
-
-    # Run unit tests on Postgres
-    - DB=pgsql    TASK=PHPUNIT
-
-    # Perform an upgrade test too.
-    - DB=pgsql    TASK=UPGRADE
-
 jobs:
     # Enable fast finish.
     # This will fail the build if a single job fails (except those in allow_failures).
@@ -48,12 +26,35 @@ jobs:
     fast_finish: true
 
     include:
-          # Run mysql only on highest - it's just too slow
-        - php: 7.4
+        # First all the lowest php ones (7.2)
+        - php: 7.2
+          env: DB=none     TASK=CITEST
+        - php: 7.2
+          env: DB=none     TASK=GRUNT    NVM_VERSION='lts/carbon'
+
+        - if: env(MOODLE_DATABASE) = "pgsql" OR env(MOODLE_DATABASE) = "all" OR env(MOODLE_DATABASE) IS NOT present
+          php: 7.2
+          env: DB=pgsql    TASK=PHPUNIT
+
+        - if: env(MOODLE_DATABASE) = "mysqli" OR env(MOODLE_DATABASE) = "all"
+          php: 7.2
+          env: DB=mysqli   TASK=PHPUNIT
+
+        # Then, conditionally, all the highest php ones (7.4)
+        - if: env(MOODLE_PHP) = "all"
+          php: 7.4
+          env: DB=none     TASK=CITEST
+        - if: env(MOODLE_PHP) = "all"
+          php: 7.4
+          env: DB=none     TASK=GRUNT    NVM_VERSION='lts/carbon'
+
+        - if: env(MOODLE_PHP) = "all" AND (env(MOODLE_DATABASE) = "pgsql" OR env(MOODLE_DATABASE) = "all" OR env(MOODLE_DATABASE) IS NOT present)
+          php: 7.4
+          env: DB=pgsql    TASK=PHPUNIT
+
+        - if: env(MOODLE_PHP) = "all" AND (env(MOODLE_DATABASE) = "mysqli" OR env(MOODLE_DATABASE) = "all")
+          php: 7.4
           env: DB=mysqli   TASK=PHPUNIT
-          # Run grunt/npm install on highest version too ('node' is an alias for the latest node.js version.)
-        - php: 7.4
-          env: DB=none     TASK=GRUNT   NVM_VERSION='lts/carbon'
 
 cache:
     directories:
@@ -113,7 +114,7 @@ install:
 before_script:
     - phpenv config-rm xdebug.ini
     - >
-      if [ "$TASK" = 'PHPUNIT' -o "$TASK" = 'UPGRADE' ];
+      if [ "$TASK" = 'PHPUNIT' ];
       then
         # Copy generic configuration in place.
         cp config-dist.php config.php ;
@@ -224,32 +225,6 @@ before_script:
         export phpcmd=`which php`;
       fi
 
-    ########################################################################
-    # Upgrade test
-    ########################################################################
-    - >
-      if [ "$TASK" = 'UPGRADE' ];
-      then
-        # We need the official upstream.
-        git remote add upstream https://github.com/moodle/moodle.git;
-
-        # Checkout 30 STABLE branch (the first version compatible with PHP 7.x)
-        git fetch upstream MOODLE_30_STABLE;
-        git checkout MOODLE_30_STABLE;
-
-        # Perform the upgrade
-        php admin/cli/install_database.php --agree-license --adminpass=Password --adminemail=admin@example.com --fullname="Upgrade test" --shortname=Upgrade;
-
-        # Return to the previous commit
-        git checkout -;
-
-        # Perform the upgrade
-        php admin/cli/upgrade.php --non-interactive --allow-unstable ;
-
-        # The local_ci repository can be used to check upgrade savepoints.
-        git clone https://github.com/moodlehq/moodle-local_ci.git local/ci ;
-      fi
-
 script:
     - >
       if [ "$TASK" = 'PHPUNIT' ];
@@ -275,23 +250,6 @@ script:
         git diff --cached --exit-code ;
       fi
 
-    ########################################################################
-    # Upgrade test
-    ########################################################################
-    - >
-      if [ "$TASK" = 'UPGRADE' ];
-      then
-        cp local/ci/check_upgrade_savepoints/check_upgrade_savepoints.php ./check_upgrade_savepoints.php
-        result=`php check_upgrade_savepoints.php`;
-        # Check if there are problems
-        count=`echo "$result" | grep -P "ERROR|WARN" | wc -l` ;
-        if (($count > 0));
-        then
-          echo "$result"
-          exit 1 ;
-        fi
-      fi
-
 after_script:
     - >
       if [ "$TASK" = 'PHPUNIT' ];
diff --git a/admin/tool/behat/tests/behat/inplaceeditable.feature b/admin/tool/behat/tests/behat/inplaceeditable.feature
new file mode 100644 (file)
index 0000000..014c5f5
--- /dev/null
@@ -0,0 +1,30 @@
+@tool_behat
+Feature: Verify that the inplace editable field works as expected
+  In order to use behat step definitions
+  As a test write
+  I need to ensure that the inplace editable works in forms
+
+  Background:
+    Given the following "course" exists:
+      | fullname  | Course 1 |
+      | shortname | C1       |
+    And the following "activities" exist:
+      | activity | course | name                | idnumber |
+      | forum    | C1     | My first forum      | forum1   |
+      | assign   | C1     | My first assignment | assign1  |
+      | quiz     | C1     | My first quiz       | quiz1    |
+    And I log in as "admin"
+    And I am on "Course 1" course homepage with editing mode on
+
+  @javascript
+  Scenario: Using an inplace editable updates the name of an activity
+    When I set the field "Edit title" in the "My first assignment" "activity" to "Coursework submission"
+    Then I should see "Coursework submission"
+    And I should not see "My first assignment"
+    But I should see "My first forum"
+    And I should see "My first quiz"
+    And I set the field "Edit title" in the "Coursework submission" "activity" to "My first assignment"
+    And I should not see "Coursework submission"
+    But I should see "My first assignment"
+    And I should see "My first forum"
+    And I should see "My first quiz"
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 ec85231..d690924 100644 (file)
@@ -82,7 +82,7 @@ class tool_task_renderer extends plugin_renderer_base {
             $defaulttask = \core\task\manager::get_default_scheduled_task($classname, false);
 
             $customised = $task->is_customised() ? $no : $yes;
-            if (empty($CFG->preventscheduledtaskchanges)) {
+            if (empty($CFG->preventscheduledtaskchanges) && !$task->is_overridden()) {
                 $configureurl = new moodle_url('/admin/tool/task/scheduledtasks.php',
                         ['action' => 'edit', 'task' => $classname]);
                 $editlink = $this->output->action_icon($configureurl, new pix_icon('t/edit',
@@ -100,8 +100,13 @@ class tool_task_renderer extends plugin_renderer_base {
                 ));
             }
 
-            $namecell = new html_table_cell($task->get_name() . "\n" .
-                    html_writer::span('\\' . $classname, 'task-class text-ltr'));
+            $namecellcontent = $task->get_name() . "\n" .
+                html_writer::span('\\' . $classname, 'task-class text-ltr');
+            if ($task->is_overridden()) {
+                // Let the user know the scheduled task is defined in config.
+                $namecellcontent .= "\n" . html_writer::div(get_string('configoverride', 'admin'), 'alert-info');
+            }
+            $namecell = new html_table_cell($namecellcontent);
             $namecell->header = true;
 
             $plugininfo = core_plugin_manager::instance()->get_plugin_info($task->get_component());
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 243039e..85a4055 100644 (file)
@@ -53,7 +53,7 @@ if ($task) {
 
 $renderer = $PAGE->get_renderer('tool_task');
 
-if ($mform && ($mform->is_cancelled() || !empty($CFG->preventscheduledtaskchanges))) {
+if ($mform && ($mform->is_cancelled() || !empty($CFG->preventscheduledtaskchanges) || $task->is_overridden())) {
     redirect($nexturl);
 } else if ($action == 'edit' && empty($CFG->preventscheduledtaskchanges)) {
 
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 5c0f74f..9d3cc18 100644 (file)
@@ -460,7 +460,7 @@ class auth_plugin_db extends auth_plugin_base {
                     continue;
                 }
                 try {
-                    $id = user_create_user($user, false); // It is truly a new user.
+                    $id = user_create_user($user, false, false); // It is truly a new user.
                     $trace->output(get_string('auth_dbinsertuser', 'auth_db', array('name'=>$user->username, 'id'=>$id)), 1);
                 } catch (moodle_exception $e) {
                     $trace->output(get_string('auth_dbinsertusererror', 'auth_db', $user->username), 1);
@@ -479,6 +479,8 @@ class auth_plugin_db extends auth_plugin_base {
 
                 // Make sure user context is present.
                 context_user::instance($id);
+
+                \core\event\user_created::create_from_userid($id)->trigger();
             }
             unset($add_users);
         }
index b17a7c0..1de432c 100644 (file)
@@ -119,6 +119,7 @@ class auth_db_testcase extends advanced_testcase {
         $table->add_field('email', XMLDB_TYPE_CHAR, '255', null, null, null);
         $table->add_field('firstname', XMLDB_TYPE_CHAR, '255', null, null, null);
         $table->add_field('lastname', XMLDB_TYPE_CHAR, '255', null, null, null);
+        $table->add_field('animal', XMLDB_TYPE_CHAR, '255', null, null, null);
         $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
         if ($dbman->table_exists($table)) {
             $dbman->drop_table($table);
@@ -137,6 +138,15 @@ class auth_db_testcase extends advanced_testcase {
         set_config('field_updateremote_email', '0', 'auth_db');
         set_config('field_lock_email', 'unlocked', 'auth_db');
 
+        // Create a user profile field and add mapping to it.
+        $DB->insert_record('user_info_field', ['shortname' => 'pet', 'name' => 'Pet', 'required' => 0,
+            'visible' => 1, 'locked' => 0, 'categoryid' => 1, 'datatype' => 'text']);
+
+        set_config('field_map_profile_field_pet', 'animal', 'auth_db');
+        set_config('field_updatelocal_profile_field_pet', 'oncreate', 'auth_db');
+        set_config('field_updateremote_profile_field_pet', '0', 'auth_db');
+        set_config('field_lock_profile_field_pet', 'unlocked', 'auth_db');
+
         // Init the rest of settings.
         set_config('passtype', 'plaintext', 'auth_db');
         set_config('changepasswordurl', '', 'auth_db');
@@ -156,6 +166,7 @@ class auth_db_testcase extends advanced_testcase {
 
     public function test_plugin() {
         global $DB, $CFG;
+        require_once($CFG->dirroot . '/user/profile/lib.php');
 
         $this->resetAfterTest(true);
 
@@ -193,7 +204,7 @@ class auth_db_testcase extends advanced_testcase {
 
         // Test bulk user account creation.
 
-        $user2 = (object)array('name'=>'u2', 'pass'=>'heslo', 'email'=>'u2@example.com');
+        $user2 = (object)['name' => 'u2', 'pass' => 'heslo', 'email' => 'u2@example.com', 'animal' => 'cat'];
         $user2->id = $DB->insert_record('auth_db_users', $user2);
 
         $user3 = (object)array('name'=>'admin', 'pass'=>'heslo', 'email'=>'admin@example.com'); // Should be skipped.
@@ -202,13 +213,24 @@ class auth_db_testcase extends advanced_testcase {
         $this->assertCount(2, $DB->get_records('user'));
 
         $trace = new null_progress_trace();
+
+        // Sync users and make sure that two events user_created werer triggered.
+        $sink = $this->redirectEvents();
         $auth->sync_users($trace, false);
+        $events = $sink->get_events();
+        $sink->close();
+        $this->assertCount(2, $events);
+        $this->assertTrue($events[0] instanceof  \core\event\user_created);
+        $this->assertTrue($events[1] instanceof  \core\event\user_created);
 
+        // Assert the two users were created.
         $this->assertEquals(4, $DB->count_records('user'));
         $u1 = $DB->get_record('user', array('username'=>$user1->name, 'auth'=>'db'));
         $this->assertSame($user1->email, $u1->email);
+        $this->assertEmpty(profile_user_record($u1->id)->pet);
         $u2 = $DB->get_record('user', array('username'=>$user2->name, 'auth'=>'db'));
         $this->assertSame($user2->email, $u2->email);
+        $this->assertSame($user2->animal, profile_user_record($u2->id)->pet);
         $admin = $DB->get_record('user', array('username'=>'admin', 'auth'=>'manual'));
         $this->assertNotEmpty($admin);
 
@@ -217,12 +239,14 @@ class auth_db_testcase extends advanced_testcase {
 
         $user2b = clone($user2);
         $user2b->email = 'u2b@example.com';
+        $user2b->animal = 'dog';
         $DB->update_record('auth_db_users', $user2b);
 
         $auth->sync_users($trace, false);
         $this->assertEquals(4, $DB->count_records('user'));
         $u2 = $DB->get_record('user', array('username'=>$user2->name));
         $this->assertSame($user2->email, $u2->email);
+        $this->assertSame($user2->animal, profile_user_record($u2->id)->pet);
 
         $auth->sync_users($trace, true);
         $this->assertEquals(4, $DB->count_records('user'));
@@ -231,6 +255,8 @@ class auth_db_testcase extends advanced_testcase {
 
         set_config('field_updatelocal_email', 'onlogin', 'auth_db');
         $auth->config->field_updatelocal_email = 'onlogin';
+        set_config('field_updatelocal_profile_field_pet', 'onlogin', 'auth_db');
+        $auth->config->field_updatelocal_profile_field_pet = 'onlogin';
 
         $auth->sync_users($trace, false);
         $this->assertEquals(4, $DB->count_records('user'));
@@ -241,6 +267,7 @@ class auth_db_testcase extends advanced_testcase {
         $this->assertEquals(4, $DB->count_records('user'));
         $u2 = $DB->get_record('user', array('username'=>$user2->name));
         $this->assertSame($user2b->email, $u2->email);
+        $this->assertSame($user2b->animal, profile_user_record($u2->id)->pet);
 
 
         // Test sync deletes and suspends.
index 5a9d7e8..9abb2a3 100644 (file)
@@ -27,6 +27,9 @@ require_once('../../config.php');
 $issuerid = required_param('id', PARAM_INT);
 $wantsurl = new moodle_url(optional_param('wantsurl', '', PARAM_URL));
 
+$PAGE->set_context(context_system::instance());
+$PAGE->set_url(new moodle_url('/auth/oauth2/login.php', ['id' => $issuerid]));
+
 require_sesskey();
 
 if (!\auth_oauth2\api::is_enabled()) {
index cc08180..41cddfa 100644 (file)
@@ -92,6 +92,9 @@ class core_block_external extends external_api {
     private static function get_all_current_page_blocks($includeinvisible = false, $returncontents = false) {
         global $PAGE, $OUTPUT;
 
+        // Set page URL to a fake URL to avoid errors.
+        $PAGE->set_url(new \moodle_url('/webservice/core_block_external/'));
+
         // Load the block instances for all the regions.
         $PAGE->blocks->load_blocks($includeinvisible);
         $PAGE->blocks->create_all_block_instances();
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 6c638ea..f7e555e 100644 (file)
@@ -105,6 +105,9 @@ class block_section_links extends block_base {
             }
         }
 
+        // Whether or not section name should be displayed.
+        $showsectionname = !empty($config->showsectionname) ? true : false;
+
         // Prepare an array of sections to create links for.
         $sections = array();
         $canviewhidden = has_capability('moodle/course:update', $context);
@@ -126,13 +129,17 @@ class block_section_links extends block_base {
                     $sections[$i]->highlight = true;
                     $sectiontojumpto = $section->section;
                 }
+                if ($showsectionname) {
+                    $sections[$i]->name = $courseformat->get_section_name($i);
+                }
             }
         }
 
         if (!empty($sections)) {
             // Render the sections.
             $renderer = $this->page->get_renderer('block_section_links');
-            $this->content->text = $renderer->render_section_links($this->page->course, $sections, $sectiontojumpto);
+            $this->content->text = $renderer->render_section_links($this->page->course, $sections,
+                $sectiontojumpto, $showsectionname);
         }
 
         return $this->content;
index 63a3593..3cedaf0 100644 (file)
@@ -82,5 +82,8 @@ class block_section_links_edit_form extends block_edit_form {
             $mform->addHelpButton('config_incby'.$i, 'incby'.$i, 'block_section_links');
         }
 
+        $mform->addElement('selectyesno', 'config_showsectionname', get_string('showsectionname', 'block_section_links'));
+        $mform->setDefault('config_showsectionname', !empty($config->showsectionname) ? 1 : 0);
+        $mform->addHelpButton('config_showsectionname', 'showsectionname', 'block_section_links');
     }
 }
\ No newline at end of file
index b267898..950a0bb 100644 (file)
@@ -34,6 +34,8 @@ $string['numsections2'] = 'Alternative number of sections';
 $string['numsections2_help'] = 'Once the number of sections in the course reaches this number then the Alternative increment by value is used.';
 $string['pluginname'] = 'Section links';
 $string['section_links:addinstance'] = 'Add a new section links block';
+$string['showsectionname'] = 'Display section name';
+$string['showsectionname_help'] = 'Display section name in addition to section number';
 $string['topics'] = 'Topics';
 $string['weeks'] = 'Weeks';
 $string['privacy:metadata'] = 'The Section links block only shows data stored in other locations.';
index 338855b..a1ebe87 100644 (file)
@@ -38,10 +38,12 @@ class block_section_links_renderer extends plugin_renderer_base {
      * @param stdClass $course The course we are rendering for.
      * @param array $sections An array of section objects to render.
      * @param bool|int The section to provide a jump to link for.
+     * @param bool $showsectionname Whether or not section name should be displayed.
      * @return string The HTML to display.
      */
-    public function render_section_links(stdClass $course, array $sections, $jumptosection = false) {
-        $html = html_writer::start_tag('ol', array('class' => 'inline-list'));
+    public function render_section_links(stdClass $course, array $sections, $jumptosection = false, $showsectionname = false) {
+        $olparams = $showsectionname ? ['class' => 'unlist'] : ['class' => 'inline-list'];
+        $html = html_writer::start_tag('ol', $olparams);
         foreach ($sections as $section) {
             $attributes = array();
             if (!$section->visible) {
@@ -49,6 +51,9 @@ class block_section_links_renderer extends plugin_renderer_base {
             }
             $html .= html_writer::start_tag('li');
             $sectiontext = $section->section;
+            if ($showsectionname) {
+                $sectiontext .= ': ' . $section->name;
+            }
             if ($section->highlight) {
                 $sectiontext = html_writer::tag('strong', $sectiontext);
             }
index 2fcb4a4..ef18237 100644 (file)
@@ -48,4 +48,9 @@ if ($ADMIN->fulltree) {
                             get_string('incby'.$i.'_help', 'block_section_links'),
                             $selected[$i][1], $increments));
     }
+
+    $settings->add(new admin_setting_configcheckbox('block_section_links/showsectionname',
+        get_string('showsectionname', 'block_section_links'),
+        get_string('showsectionname_help', 'block_section_links'),
+        0));
 }
\ No newline at end of file
diff --git a/blocks/section_links/tests/behat/show_section_name.feature b/blocks/section_links/tests/behat/show_section_name.feature
new file mode 100644 (file)
index 0000000..8c0b713
--- /dev/null
@@ -0,0 +1,43 @@
+@block @block_section_links
+Feature: The Section links block can be configured to display section name in addition to section number
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category | numsections | coursedisplay |
+      | Course 1 | C1        | 0        | 10          | 1             |
+    And the following "activities" exist:
+      | activity | name              | course | idnumber | section |
+      | assign   | First assignment  | C1     | assign1  | 7       |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And I log in as "admin"
+    And I set the following administration settings values:
+      | showsectionname | 1 |
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Section links" block
+    And I log out
+
+  Scenario: Student can see section name under the Section links block
+    Given I log in as "student1"
+    When I am on "Course 1" course homepage
+    Then I should see "7: Topic 7" in the "Section links" "block"
+    And I follow "7: Topic 7"
+    And I should see "First assignment"
+
+  Scenario: Teacher can configure existing Section links block to display section number or section name
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    When I configure the "Section links" block
+    And I set the following fields to these values:
+      | Display section name | No |
+    And I click on "Save changes" "button"
+    Then I should not see "7: Topic 7" in the "Section links" "block"
+    And I should see "7" in the "Section links" "block"
+    And I follow "7"
+    And I should see "First assignment"
diff --git a/blocks/section_links/upgrade.txt b/blocks/section_links/upgrade.txt
new file mode 100644 (file)
index 0000000..bf4f9a4
--- /dev/null
@@ -0,0 +1,6 @@
+This file describes API changes in the section_links block code.
+
+=== 3.11 ===
+
+* New optional parameter $showsectionname has been added to render_section_links(). Setting this to true will display
+  section name in addition to section number.
index 96451a2..dd45489 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2021052500;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2021052501;        // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2021052500;        // Requires this Moodle version
 $plugin->component = 'block_section_links'; // Full name of the plugin (used for diagnostics)
index b80713c..f99f479 100644 (file)
@@ -150,4 +150,17 @@ class behat_block_site_main_menu extends behat_base {
         $xpath = "//*[contains(concat(' ',normalize-space(@class),' '),' block_site_main_menu ')]//li[contains(., $activityname)]";
         $this->execute('behat_action_menu::i_open_the_action_menu_in', [$xpath, 'xpath_element']);
     }
+
+    /**
+     * Return the list of partial named selectors.
+     *
+     * @return array
+     */
+    public static function get_partial_named_selectors(): array {
+        return [
+            new behat_component_named_selector('Activity', [
+                "//*[contains(concat(' ',normalize-space(@class),' '),' block_site_main_menu ')]//li[contains(., %locator%)]"
+            ]),
+        ];
+    }
 }
index 3b5d632..51b60ea 100644 (file)
@@ -6,15 +6,16 @@ Feature: Edit activities in main menu block
 
   @javascript
   Scenario: Edit name of acitivity in-place in site main menu block
-    Given I log in as "admin"
+    Given the following "activity" exists:
+      | activity | forum                |
+      | course   | Acceptance test site |
+      | name     | My forum name        |
+      | idnumber | forum                |
+    And I log in as "admin"
     And I am on site homepage
     And I navigate to "Turn editing on" in current page administration
     And I add the "Main menu" block
-    When I add a "Forum" to section "0" and I fill the form with:
-      | 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"
+    When I set the field "Edit title" in the "My forum name" "block_site_main_menu > Activity" to "New forum name"
     Then I should not see "My forum name"
     And I should see "New forum name"
     And I follow "New forum name"
index 167b24d..eb28523 100644 (file)
@@ -158,4 +158,17 @@ class behat_block_social_activities extends behat_base {
         $xpath = "//*[contains(concat(' ',normalize-space(@class),' '),' block_social_activities ')]//li[contains(., $activityname)]";
         $this->execute('behat_action_menu::i_open_the_action_menu_in', [$xpath, 'xpath_element']);
     }
+
+    /**
+     * Return the list of partial named selectors.
+     *
+     * @return array
+     */
+    public static function get_partial_named_selectors(): array {
+        return [
+            new behat_component_named_selector('Activity', [
+                "//*[contains(concat(' ',normalize-space(@class),' '),' block_social_activities ')]//li[contains(., %locator%)]",
+            ]),
+        ];
+    }
 }
index 2cf87a5..a21e848 100644 (file)
@@ -25,9 +25,7 @@ Feature: Edit activities in social activities block
     And I click on "Add a new Forum" "link" in the "Add an activity or resource" "dialogue"
     And I set the field "Forum name" to "My forum name"
     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"
+    When I set the field "Edit title" in the "My forum name" "block_social_activities > Activity" to "New forum name"
     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"
@@ -84,4 +82,3 @@ Feature: Edit activities in social activities block
     And I should not see "My forum name" in the "Social activities" "block"
     And I click on "My forum name" "link" in the "Recent activity" "block"
     And I should see "My forum name" in the ".breadcrumb" "css_element"
-    And I log out
index 51a33d5..c8ec5fc 100644 (file)
@@ -51,9 +51,6 @@ class block_tag_youtube extends block_base {
 
     function specialization() {
         $this->title = !empty($this->config->title) ? $this->config->title : get_string('pluginname', 'block_tag_youtube');
-        // Convert numeric categories (old YouTube API) to
-        // textual ones (new Google Data API)
-        $this->config->category = !empty($this->config->category) ? $this->category_map_old2new($this->config->category) : '0';
     }
 
     function instance_allow_multiple() {
@@ -341,64 +338,73 @@ class block_tag_youtube extends block_base {
         return $text;
     }
 
-    function get_categories() {
-        // TODO: Right now using sticky categories from
-        // http://gdata.youtube.com/schemas/2007/categories.cat
-        // This should be performed from time to time by the block insead
-        // and cached somewhere, avoiding deprecated ones and observing regions
-        return array (
-            '0' => get_string('anycategory', 'block_tag_youtube'),
-            'Film'  => get_string('filmsanimation', 'block_tag_youtube'),
-            'Autos' => get_string('autosvehicles', 'block_tag_youtube'),
-            'Music' => get_string('music', 'block_tag_youtube'),
-            'Animals'=> get_string('petsanimals', 'block_tag_youtube'),
-            'Sports' => get_string('sports', 'block_tag_youtube'),
-            'Travel' => get_string('travel', 'block_tag_youtube'),
-            'Games'  => get_string('gadgetsgames', 'block_tag_youtube'),
-            'Comedy' => get_string('comedy', 'block_tag_youtube'),
-            'People' => get_string('peopleblogs', 'block_tag_youtube'),
-            'News'   => get_string('newspolitics', 'block_tag_youtube'),
-            'Entertainment' => get_string('entertainment', 'block_tag_youtube'),
-            'Education' => get_string('education', 'block_tag_youtube'),
-            'Howto'  => get_string('howtodiy', 'block_tag_youtube'),
-            'Tech'   => get_string('scienceandtech', 'block_tag_youtube')
-        );
-    }
-
     /**
-     * Provide conversion from old numeric categories available in youtube API
-     * to the new ones available in the Google API
-     *
-     * @param int $oldcat old category code
-     * @return mixed new category code or 0 (if no match found)
+     * Method that returns an array containing all relevant video categories obtained through an API call, where the
+     * array index represents the category ID and the array value represents the category name.
      *
-     * TODO: Someday this should be applied on upgrade for all the existing
-     * block instances so we won't need the mapping any more. That would imply
-     * to implement restore handling to perform the conversion of old blocks.
+     * @return array The array containing the relevant video categories
+     * @throws moodle_exception If the API key is not set
+     * @throws Google_Service_Exception If an error occurs while obtaining the categories through the API call
      */
-    function category_map_old2new($oldcat) {
-        $oldoptions = array (
-            0  => '0',
-            1  => 'Film',
-            2  => 'Autos',
-            23 => 'Comedy',
-            24 => 'Entertainment',
-            10 => 'Music',
-            25 => 'News',
-            22 => 'People',
-            15 => 'Animals',
-            26 => 'Howto',
-            17 => 'Sports',
-            19 => 'Travel',
-            20 => 'Games'
-        );
-        if (array_key_exists($oldcat, $oldoptions)) {
-            return $oldoptions[$oldcat];
+    public function get_categories() {
+        // Get the default categories and it's translations.
+        $categorytranslations = $this->category_map_translation();
+
+        if ($service = $this->get_service()) {
+            // Call the API to fetch the youtube video categories.
+            // This API call requires the regionCode parameter which instructs the API to return the list of video
+            // categories available in the specified country. Currently 'us' is hardcoded as the returned categories
+            // for this region correspond to the previously used (legacy) hardcoded list of categories.
+            // TODO: We should improve this in the future and avoid hardcoding this value.
+            $response = $service->videoCategories->listVideoCategories('snippet', ['regionCode' => 'us']);
+            $categoryitems = $response['modelData']['items'];
+
+            // Return an array with the relevant categories.
+            return array_reduce($categoryitems, function($categories, $category) use ($categorytranslations) {
+                $categoryid = $category['id'];
+                $categoryname = $category['snippet']['title'];
+                // Videos can be associated with this category.
+                if ($category['snippet']['assignable']) {
+                    // If the category name can be mapped with a translation, add it to the categories array.
+                    if (array_key_exists($categoryname, $categorytranslations)) {
+                        $categories[$categoryid] = $categorytranslations[$categoryname];
+                    } else { // Otherwise, display the untranslated category name and show a debugging message.
+                        $categories[$categoryid] = $categoryname;
+                        debugging("The category '{$categoryname}' does not have a translatable language string.");
+                    }
+                }
+                return $categories;
+            }, []);
         } else {
-            return $oldcat;
+            throw new \moodle_exception('apierror', 'block_tag_youtube');
         }
     }
 
+    /**
+     * Method that provides mapping between the video category names and their translations.
+     *
+     * @return array The array that maps the video category names with their translations
+     */
+    private function category_map_translation() {
+        return [
+            'Film & Animation' => get_string('filmsanimation', 'block_tag_youtube'),
+            'Autos & Vehicles' => get_string('autosvehicles', 'block_tag_youtube'),
+            'Music' => get_string('music', 'block_tag_youtube'),
+            'Pets & Animals' => get_string('petsanimals', 'block_tag_youtube'),
+            'Sports' => get_string('sports', 'block_tag_youtube'),
+            'Travel & Events' => get_string('travel', 'block_tag_youtube'),
+            'Gaming' => get_string('gadgetsgames', 'block_tag_youtube'),
+            'People & Blogs' => get_string('peopleblogs', 'block_tag_youtube'),
+            'Comedy' => get_string('comedy', 'block_tag_youtube'),
+            'Entertainment' => get_string('entertainment', 'block_tag_youtube'),
+            'News & Politics' => get_string('newspolitics', 'block_tag_youtube'),
+            'Howto & Style'  => get_string('howtodiy', 'block_tag_youtube'),
+            'Education' => get_string('education', 'block_tag_youtube'),
+            'Science & Technology' => get_string('scienceandtech', 'block_tag_youtube'),
+            'Nonprofits & Activism' => get_string('nonprofitactivism', 'block_tag_youtube'),
+        ];
+    }
+
     /**
      * Return the plugin config settings for external functions.
      *
diff --git a/blocks/tag_youtube/db/upgrade.php b/blocks/tag_youtube/db/upgrade.php
new file mode 100644 (file)
index 0000000..897eb1e
--- /dev/null
@@ -0,0 +1,130 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file keeps track of upgrades to the tag_youtube block
+ *
+ * @package block_tag_youtube
+ * @copyright 2020 Mihail Geshoski <mihail@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Upgrade code for the Tag Youtube block.
+ *
+ * @param int $oldversion
+ */
+function xmldb_block_tag_youtube_upgrade($oldversion) {
+    global $DB, $CFG, $OUTPUT;
+
+    // Automatically generated Moodle v3.10.0 release upgrade line.
+    // Put any upgrade step following this.
+
+    if ($oldversion < 2021052501) {
+        // We need to fix every tag_youtube block instance that has used a legacy category name as a category config.
+        // The category config needs to store the category ID instead.
+
+        // If tag_youtube block instances exist.
+        if ($blockinstances = $DB->get_records('block_instances', ['blockname' => 'tag_youtube'])) {
+            $categories = [];
+            // The block tag youtube needs to be configured and have a valid API key in order to obtain the video
+            // category list.
+            if ($apikey = get_config('block_tag_youtube', 'apikey')) {
+                require_once($CFG->libdir . '/google/lib.php');
+                $client = get_google_client();
+                $client->setDeveloperKey($apikey);
+                $client->setScopes(array(Google_Service_YouTube::YOUTUBE_READONLY));
+                $service = new Google_Service_YouTube($client);
+
+                try {
+                    // Get the video category list.
+                    $response = $service->videoCategories->listVideoCategories('snippet', ['regionCode' => 'us']);
+
+                    // Return an array of categories, where the key is the category name and the value is the
+                    // category ID.
+                    $categories = array_reduce($response['modelData']['items'], function ($categories, $category) {
+                        $categoryid = $category['id'];
+                        $categoryname = $category['snippet']['title'];
+                        // If videos can be associated with this category, add it to the categories list.
+                        if ($category['snippet']['assignable']) {
+                            $categories[$categoryname] = $categoryid;
+                        }
+                        return $categories;
+                    }, []);
+                } catch (Exception $e) {
+                    $warn = "Due to the following error the youtube video categories were not obtained through the API:
+                    '{$e->getMessage()}'. Therefore, any legacy values used as a category setting in Tag Youtube
+                    block instances cannot be properly mapped and updated. All legacy values used as category setting
+                    will still be updated and set by default to 'Any category'.";
+                    echo $OUTPUT->notification($warn, 'notifyproblem');
+                }
+            } else {
+                $warn = "The API key is missing in the Tag Youtube block configuration. Therefore, the youtube video
+                categories cannot be obtained and mapped with the legacy values used as category setting. All legacy
+                values used as category setting will still be updated and set by default to 'Any category'.";
+                echo $OUTPUT->notification($warn, 'notifyproblem');
+            }
+
+            // Array that maps the old category names to the current category names.
+            $categorynamemap = [
+                'Film' => 'Film & Animation',
+                'Autos' => 'Autos & Vehicles',
+                'Comedy' => 'Comedy',
+                'Entertainment' => 'Entertainment',
+                'Music' => 'Music',
+                'News' => 'News & Politics',
+                'People' => 'People & Blogs',
+                'Animals' => 'Pets & Animals',
+                'Howto' => 'Howto & Style',
+                'Sports' => 'Sports',
+                'Travel' => 'Travel & Events',
+                'Games' => 'Gaming',
+                'Education' => 'Education',
+                'Tech' => 'Tech'
+            ];
+
+            // If the block uses a legacy category name, update it to use the current category ID instead.
+            foreach ($blockinstances as $blockinstance) {
+                $blockconfig = unserialize(base64_decode($blockinstance->configdata));
+                $blockcategoryconfig = $blockconfig->category;
+                // The block is using a legacy category name as a category config.
+                if (array_key_exists($blockcategoryconfig, $categorynamemap)) {
+                    if (!empty($categories)) { // The categories were successfully obtained through the API call.
+                        // Get the current category name.
+                        $currentcategoryname = $categorynamemap[$blockcategoryconfig];
+                        // Add the category ID as a new category config for this block instance.
+                        $blockconfig->category = $categories[$currentcategoryname];
+                    } else { // The categories were not obtained through the API call.
+                        // If the categories were not obtained through the API call, we are not able to map the
+                        // current legacy category name with the category ID. Therefore, we should default the category
+                        // config value to 0 ('Any category') to at least enable the block to function properly. The
+                        // user can later manually select the desired category and re-save the config through the UI.
+                        $blockconfig->category = 0;
+                    }
+
+                    $blockinstance->configdata = base64_encode(serialize($blockconfig));
+                    $DB->update_record('block_instances', $blockinstance);
+                }
+            }
+        }
+
+        upgrade_block_savepoint(true, 2021052501, 'tag_youtube', false);
+    }
+
+    return true;
+}
index 92b54d9..75e5c0d 100644 (file)
@@ -30,6 +30,8 @@
  */
 class block_tag_youtube_edit_form extends block_edit_form {
     protected function specific_definition($mform) {
+        global $OUTPUT;
+
         $mform->addElement('header', 'configheader', get_string('blocksettings', 'block'));
 
         $mform->addElement('text', 'config_title', get_string('configtitle', 'block_tag_youtube'));
@@ -38,10 +40,26 @@ class block_tag_youtube_edit_form extends block_edit_form {
         $mform->addElement('text', 'config_numberofvideos', get_string('numberofvideos', 'block_tag_youtube'), array('size' => 5));
         $mform->setType('config_numberofvideos', PARAM_INT);
 
-        $categorychoices = $this->block->get_categories();
-        $mform->addElement('select', 'config_category', get_string('category', 'block_tag_youtube'), $categorychoices);
+        // Category setting.
+        $categorychoices = ['0' => get_string('anycategory', 'block_tag_youtube')];
+        $categoryerror = '';
+
+        try {
+            // Get all video categories through an API call and add them to the category list.
+            $categorychoices += $this->block->get_categories();
+        } catch (Exception $e) {
+            $categoryerror = $e->getMessage();
+        }
+        $mform->addElement('select', 'config_category', get_string('category', 'block_tag_youtube'),
+            $categorychoices);
         $mform->setDefault('config_category', 0);
 
+        if ($categoryerror) {
+            $notification = $OUTPUT->notification(get_string('categoryerror', 'block_tag_youtube', $categoryerror),
+                'error');
+            $mform->addElement('static', 'config_category_error', '', $notification);
+        }
+
         $mform->addElement('text', 'config_playlist', get_string('includeonlyvideosfromplaylist', 'block_tag_youtube'));
         $mform->setType('config_playlist', PARAM_ALPHANUM);
     }
index 7b3fd69..defa7bd 100644 (file)
@@ -32,12 +32,14 @@ $string['comedy'] = 'Comedy';
 $string['configtitle'] = 'YouTube block title';
 $string['education'] = 'Education';
 $string['entertainment'] = 'Entertainment';
+$string['categoryerror'] = 'Failed to obtain the list of categories. <br> {$a}';
 $string['filmsanimation'] = 'Films & Animation';
 $string['gadgetsgames'] = 'Gadgets & Games';
 $string['howtodiy'] = 'How-to & DIY';
 $string['includeonlyvideosfromplaylist'] = 'Include only videos from the playlist with id';
 $string['music'] = 'Music';
 $string['newspolitics'] = 'News & Politics';
+$string['nonprofitactivism'] = 'Nonprofits & Activism';
 $string['numberofvideos'] = 'Number of videos';
 $string['peopleblogs'] = 'People & Blogs';
 $string['petsanimals'] = 'Pets & Animals';
diff --git a/blocks/tag_youtube/tests/behat/configure_tag_youtube_block.feature b/blocks/tag_youtube/tests/behat/configure_tag_youtube_block.feature
new file mode 100644 (file)
index 0000000..6952b95
--- /dev/null
@@ -0,0 +1,48 @@
+@block @block_tag_youtube
+Feature: Adding and configuring YouTube block
+  In order to have the YouTube block used
+  As a admin
+  I need to add the YouTube block to the tags site page
+
+  Background:
+    Given I log in as "admin"
+    And I navigate to "Plugins > Blocks > Manage blocks" in site administration
+    And I click on "Show" "icon" in the "YouTube" "table_row"
+
+  @javascript
+  Scenario: Category options are not available (except default) in the block settings if the YouTube API key is not set.
+    Given the following config values are set as admin:
+      | apikey |  | block_tag_youtube |
+    And I follow "Dashboard" in the user menu
+    And I press "Customise this page"
+    # TODO MDL-57120 site "Tags" link not accessible without navigation block.
+    And I add the "Navigation" block if not present
+    And I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Tags" "link" in the "Navigation" "block"
+    And I add the "YouTube" block
+    When I configure the "YouTube" block
+    Then I should see "Category"
+    And I should see "Failed to obtain the list of categories."
+    And I should see "The YouTube API key is not set. Contact your administrator."
+    And the "Category" select box should contain "Any category"
+    And the "Category" select box should not contain "Films & Animation"
+    And the "Category" select box should not contain "Entertainment"
+    And the "Category" select box should not contain "Education"
+
+  @javascript
+  Scenario: Category options are not available (except default) in the block settings when invalid YouTube API key is set.
+    Given the following config values are set as admin:
+      | apikey | invalidapikeyvalue | block_tag_youtube |
+    And I follow "Dashboard" in the user menu
+    And I press "Customise this page"
+    And I add the "Navigation" block if not present
+    And I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Tags" "link" in the "Navigation" "block"
+    And I add the "YouTube" block
+    When I configure the "YouTube" block
+    Then I should see "Category"
+    And I should see "Failed to obtain the list of categories."
+    And the "Category" select box should contain "Any category"
+    And the "Category" select box should not contain "Comedy"
+    And the "Category" select box should not contain "Autos & Vehicles"
+    And the "Category" select box should not contain "News & Politics"
index ae3d80d..b8afc46 100644 (file)
@@ -1,5 +1,11 @@
 This files describes API changes in the block tag_youtube code.
 
+=== 3.10.1 ===
+
+* The config category now stores the category ID, instead of a string representation of the category name.
+  In YouTube Data API v3, the API call to fetch the videos related to a certain category expects the category ID to be
+  passed to the videoCategoryId parameter, instead of the category name.
+
 === 3.0 ===
 
 * Due to the final YouTube API v2.0 deprecation we needed to adapt the current
index eb799bb..61ca95e 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2021052500;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2021052501;        // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2021052500;        // Requires this Moodle version
 $plugin->component = 'block_tag_youtube'; // Full name of the plugin (used for diagnostics)
index bde6b9e..9a6b057 100644 (file)
@@ -182,22 +182,28 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
             $server = $serverconf[0];
             $port = $serverconf[1];
         }
-        if ($redis->connect($server, $port)) {
-            if (!empty($password)) {
-                $redis->auth($password);
-            }
-            // If using compressor, serialisation will be done at cachestore level, not php-redis.
-            if ($this->compressor == self::COMPRESSOR_NONE) {
-                $redis->setOption(Redis::OPT_SERIALIZER, $this->serializer);
-            }
-            if (!empty($prefix)) {
-                $redis->setOption(Redis::OPT_PREFIX, $prefix);
+
+        try {
+            if ($redis->connect($server, $port)) {
+                if (!empty($password)) {
+                    $redis->auth($password);
+                }
+                // If using compressor, serialisation will be done at cachestore level, not php-redis.
+                if ($this->compressor == self::COMPRESSOR_NONE) {
+                    $redis->setOption(Redis::OPT_SERIALIZER, $this->serializer);
+                }
+                if (!empty($prefix)) {
+                    $redis->setOption(Redis::OPT_PREFIX, $prefix);
+                }
+                // Database setting option...
+                $this->isready = $this->ping($redis);
+            } else {
+                $this->isready = false;
             }
-            // Database setting option...
-            $this->isready = $this->ping($redis);
-        } else {
+        } catch (\RedisException $e) {
             $this->isready = false;
         }
+
         return $redis;
     }
 
index b93d41f..c422c70 100644 (file)
@@ -62,9 +62,7 @@ Feature: Add cohorts of users
   @javascript
   Scenario: Edit cohort name in-place
     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 set the field "Edit cohort name" to "Students cohort"
     Then I should not see "Test cohort name"
     And I should see "Students cohort"
     And I follow "Cohorts"
index 083f217..b8c5d51 100644 (file)
@@ -324,6 +324,9 @@ $CFG->admin = 'admin';
 //      Use the igbinary serializer instead of the php default one. Note that phpredis must be compiled with
 //      igbinary support to make the setting to work. Also, if you change the serializer you have to flush the database!
 //      $CFG->session_redis_serializer_use_igbinary = false; // Optional, default is PHP builtin serializer.
+//      $CFG->session_redis_compressor = 'none'; // Optional, possible values are:
+//                                               // 'gzip' - PHP GZip compression
+//                                               // 'zstd' - PHP Zstandard compression
 //
 // Please be aware that when selecting Memcached for sessions that it is advised to use a dedicated
 // memcache server. The memcached extension does not provide isolated environments for individual uses.
@@ -1084,6 +1087,46 @@ $CFG->admin = 'admin';
 //      $CFG->alternative_cache_factory_class = 'tool_alternativecache_cache_factory';
 //
 //=========================================================================
+// 17. SCHEDULED TASK OVERRIDES
+//=========================================================================
+//
+// It is now possible to define scheduled tasks directly within config.
+// The overridden value will take precedence over the values that have been set VIA the UI from the
+// next time the task is run.
+//
+// Tasks are configured as an array of tasks that can override a task's schedule, as well as setting
+// the task as disabled. I.e:
+//
+//      $CFG->scheduled_tasks = [
+//          '\local_plugin\task\my_task' => [
+//              'schedule' => '*/15 0 0 0 0',
+//              'disabled' => 0,
+//          ],
+//      ];
+//
+// The format for the schedule definition is: '{minute} {hour} {day} {dayofweek} {month}'.
+//
+// The classname of the task also supports wildcards:
+//
+//      $CFG->scheduled_tasks = [
+//          '\local_plugin\*' => [
+//              'schedule' => '*/15 0 0 0 0',
+//              'disabled' => 0,
+//          ],
+//          '*' => [
+//              'schedule' => '0 0 0 0 0',
+//              'disabled' => 0,
+//          ],
+//      ];
+//
+// In this example, any task classnames matching '\local_plugin\*' would match the first rule and
+// use that schedule the next time the task runs. Note that even though the 'local_plugin' tasks match
+// the second rule as well, the highest rule takes precedence. Therefore, the second rule would be
+// applied to all tasks, except for tasks within '\local_plugin\'.
+//
+// When the full classname is used, this rule always takes priority over any wildcard rules.
+//
+//=========================================================================
 // ALL DONE!  To continue installation, visit your main page with a browser
 //=========================================================================
 
index e55b58d..573c7e5 100644 (file)
@@ -53,9 +53,7 @@ Feature: Sections can be edited and deleted in topics format
 
   @javascript
   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"
+    When I set the field "Edit topic name" in the "li#section-1" "css_element" to "Midterm evaluation"
     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..6fdc14a 100644 (file)
@@ -54,9 +54,7 @@ Feature: Sections can be edited and deleted in weeks format
 
   @javascript
   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"
+    When I set the field "Edit week name" in the "li#section-1" "css_element" to "Midterm evaluation"
     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 e34df5a..220fbdf 100644 (file)
@@ -4743,26 +4743,26 @@ function course_get_recent_courses(int $userid = null, int $limit = 0, int $offs
               JOIN {user_lastaccess} ul
                    ON ul.courseid = c.id
             $favsql
+         LEFT JOIN {enrol} eg ON eg.courseid = c.id AND eg.status = :statusenrolg AND eg.enrol = :guestenrol
              WHERE ul.userid = :userid
                AND c.visible = :visible
-               AND EXISTS (SELECT e.id
+               AND (eg.id IS NOT NULL
+                    OR EXISTS (SELECT e.id
                              FROM {enrol} e
-                        LEFT JOIN {user_enrolments} ue ON ue.enrolid = e.id
+                             JOIN {user_enrolments} ue ON ue.enrolid = e.id
                             WHERE e.courseid = c.id
                               AND e.status = :statusenrol
-                              AND ((ue.status = :status
-                                    AND ue.userid = ul.userid
-                                    AND ue.timestart < :now1
-                                    AND (ue.timeend = 0 OR ue.timeend > :now2)
-                                   )
-                                   OR e.enrol = :guestenrol
-                                  )
-                          )
+                              AND ue.status = :status
+                              AND ue.userid = :userid2
+                              AND ue.timestart < :now1
+                              AND (ue.timeend = 0 OR ue.timeend > :now2)
+                          ))
             $orderby";
 
     $now = round(time(), -2); // Improves db caching.
     $params = ['userid' => $userid, 'contextlevel' => CONTEXT_COURSE, 'visible' => 1, 'status' => ENROL_USER_ACTIVE,
-               'statusenrol' => ENROL_INSTANCE_ENABLED, 'guestenrol' => 'guest', 'now1' => $now, 'now2' => $now] + $favparams;
+               'statusenrol' => ENROL_INSTANCE_ENABLED, 'guestenrol' => 'guest', 'now1' => $now, 'now2' => $now,
+               'userid2' => $userid, 'statusenrolg' => ENROL_INSTANCE_ENABLED] + $favparams;
 
     $recentcourses = $DB->get_records_sql($sql, $params, $offset, $limit);
 
index 29e8911..1821738 100644 (file)
@@ -15,15 +15,16 @@ Feature: Edit activity name in-place
     And the following "course enrolments" exist:
       | user | course | role |
       | teacher1 | C1 | editingteacher |
+    And the following "activity" exists:
+      | course      | C1                     |
+      | activity    | forum                  |
+      | name        | Test forum name        |
+      | description | Test forum description |
+      | idnumber    | forum1                 |
     When I log in as "teacher1"
     And I am on "Course 1" course homepage with editing mode on
-    And I add a "Forum" to section "1" and I fill the form with:
-      | Forum name | Test forum name |
-      | Description | Test forum description |
     # 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 set the field "Edit title" in the "Test forum name" "activity" to "Good news"
     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"
@@ -32,12 +33,11 @@ Feature: Edit activity name in-place
     And I should not see "Test forum name"
     # 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 type "Terrible 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 26cecd1..d06a8bf 100644 (file)
@@ -852,22 +852,12 @@ class behat_course extends behat_base {
      * @param string $newactivityname
      */
     public function i_change_activity_name_to($activityname, $newactivityname) {
-
-        if (!$this->running_javascript()) {
-            throw new DriverException('Change activity name step is not available with Javascript disabled');
-        }
-
-        $activity = $this->escape($activityname);
-
-        $this->execute('behat_course::i_click_on_in_the_activity',
-            array(get_string('edittitle'), "link", $activity)
-        );
-
-        // Adding chr(10) to save changes.
-        $this->execute('behat_forms::i_set_the_field_to',
-            array('title', $this->escape($newactivityname) . chr(10))
-        );
-
+        $this->execute('behat_forms::i_set_the_field_in_container_to', [
+            get_string('edittitle'),
+            $activityname,
+            'activity',
+            $newactivityname
+        ]);
     }
 
     /**
index f555d56..6db81ec 100644 (file)
@@ -5464,6 +5464,50 @@ class core_course_courselib_testcase extends advanced_testcase {
         $this->assertArrayNotHasKey($courses[0]->id, $result);
     }
 
+    /**
+     * Test the course_get_recent_courses function.
+     */
+    public function test_course_get_recent_courses_with_guest() {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        $student = $this->getDataGenerator()->create_user();
+
+        // Course 1 with guest access and no direct enrolment.
+        $course1 = $this->getDataGenerator()->create_course();
+        $context1 = context_course::instance($course1->id);
+        $record = $DB->get_record('enrol', ['courseid' => $course1->id, 'enrol' => 'guest']);
+        enrol_get_plugin('guest')->update_status($record, ENROL_INSTANCE_ENABLED);
+
+        // Course 2 where student is enrolled with two enrolment methods.
+        $course2 = $this->getDataGenerator()->create_course();
+        $context2 = context_course::instance($course2->id);
+        $record = $DB->get_record('enrol', ['courseid' => $course2->id, 'enrol' => 'self']);
+        enrol_get_plugin('guest')->update_status($record, ENROL_INSTANCE_ENABLED);
+        $this->getDataGenerator()->enrol_user($student->id, $course2->id, 'student', 'manual', 0, 0, ENROL_USER_ACTIVE);
+        $this->getDataGenerator()->enrol_user($student->id, $course2->id, 'student', 'self', 0, 0, ENROL_USER_ACTIVE);
+
+        // Course 3.
+        $course3 = $this->getDataGenerator()->create_course();
+        $context3 = context_course::instance($course3->id);
+
+        // Student visits first two courses, course_get_recent_courses returns two courses.
+        $this->setUser($student);
+        course_view($context1);
+        course_view($context2);
+
+        $result = course_get_recent_courses($student->id);
+        $this->assertEqualsCanonicalizing([$course2->id, $course1->id], array_column($result, 'id'));
+
+        // Admin visits all three courses. Only the one with guest access is returned.
+        $this->setAdminUser();
+        course_view($context1);
+        course_view($context2);
+        course_view($context3);
+        $result = course_get_recent_courses(get_admin()->id);
+        $this->assertEqualsCanonicalizing([$course1->id], array_column($result, 'id'));
+    }
+
     /**
      * Test cases for the course_get_course_dates_for_user_ids tests.
      */
index 5fb4802..b44a661 100644 (file)
@@ -12,7 +12,6 @@ Feature: Managers can manage categories for course custom fields
     Then I should see "Other fields" in the "#customfield_catlist" "css_element"
     And I navigate to "Reports > Logs" in site administration
     And I press "Get these logs"
-    And I log out
 
   Scenario: Edit a category name for custom course fields
     Given the following "custom field categories" exist:
@@ -20,15 +19,12 @@ Feature: Managers can manage categories for course custom fields
       | Category for test | core_course | course | 0      |
     And I log in as "admin"
     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 set the field "Edit category name" in the "//div[contains(@class,'categoryinstance') and contains(.,'Category for test')]" "xpath_element" to "Good fields"
     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"
     And I navigate to "Reports > Logs" in site administration
     And I press "Get these logs"
-    And I log out
 
   Scenario: Delete a category for custom course fields
     Given the following "custom field categories" exist:
@@ -46,7 +42,6 @@ Feature: Managers can manage categories for course custom fields
     Then I should not see "Test category" in the "#customfield_catlist" "css_element"
     And I navigate to "Reports > Logs" in site administration
     And I press "Get these logs"
-    And I log out
 
   Scenario: Move field in the course custom fields to another category
     Given the following "custom field categories" exist:
@@ -78,7 +73,6 @@ Feature: Managers can manage categories for course custom fields
     And I press "Move \"Field1\""
     And I follow "After field Field2"
     And "Field1" "text" should appear after "Field2" "text"
-    And I log out
 
   Scenario: Reorder course custom field categories
     Given the following "custom field categories" exist:
@@ -108,4 +102,3 @@ Feature: Managers can manage categories for course custom fields
     And "Field1" "text" should appear after "Category1" "text"
     And "Category2" "text" should appear after "Field1" "text"
     And "Category3" "text" should appear after "Category2" "text"
-    And I log out
index 3f916b1..a4b2127 100644 (file)
@@ -90,7 +90,7 @@ class writer extends \core\dataformat\base {
         $this->colwidth = $pagewidth / count($columns);
         $this->columns = $columns;
 
-        $this->print_heading();
+        $this->print_heading($this->pdf);
     }
 
     /**
@@ -137,10 +137,12 @@ class writer extends \core\dataformat\base {
             $pdf2->startTransaction();
             $numpages = $pdf2->getNumPages();
             $pdf2->AddPage('L');
+            $this->print_heading($pdf2);
             $pdf2->writeHTMLCell($this->colwidth, 0, '', '', $cell, 1, 1, false, true, 'L');
             $pagesadded = $pdf2->getNumPages() - $numpages;
-            $pageheight = $pdf2->getPageHeight() - $pdf2->getMargins()['top'] - $pdf2->getMargins()['bottom'];
-            $cellheight = ($pagesadded - 1) * $pageheight + $pdf2->getLastH();
+            $margins = $pdf2->getMargins();
+            $pageheight = $pdf2->getPageHeight() - $margins['top'] - $margins['bottom'];
+            $cellheight = ($pagesadded - 1) * $pageheight + $pdf2->getY() - $margins['top'] - $this->get_heading_height();
             $rowheight = max($rowheight, $cellheight);
             $pdf2->rollbackTransaction();
         }
@@ -149,7 +151,7 @@ class writer extends \core\dataformat\base {
         if ($this->pdf->getNumPages() > 1 &&
                 ($this->pdf->GetY() + $rowheight + $margins['bottom'] > $this->pdf->getPageHeight())) {
             $this->pdf->AddPage('L');
-            $this->print_heading();
+            $this->print_heading($this->pdf);
         }
 
         // Get the last key for this record.
@@ -186,25 +188,36 @@ class writer extends \core\dataformat\base {
     }
 
     /**
-     * Prints the heading row.
+     * Prints the heading row for a given PDF.
+     *
+     * @param \pdf $pdf A pdf to print headings in
      */
-    private function print_heading() {
-        $fontfamily = $this->pdf->getFontFamily();
-        $fontstyle = $this->pdf->getFontStyle();
-        $this->pdf->SetFont($fontfamily, 'B');
-        $rowheight = 0;
-        foreach ($this->columns as $columns) {
-            $rowheight = max($rowheight, $this->pdf->getStringHeight($this->colwidth, $columns, false, true, '', 1));
-        }
+    private function print_heading(\pdf $pdf) {
+        $fontfamily = $pdf->getFontFamily();
+        $fontstyle = $pdf->getFontStyle();
+        $pdf->SetFont($fontfamily, 'B');
 
         $total = count($this->columns);
         $counter = 1;
         foreach ($this->columns as $columns) {
             $nextposition = ($counter == $total) ? 1 : 0;
-            $this->pdf->Multicell($this->colwidth, $rowheight, $columns, 1, 'C', true, $nextposition);
+            $pdf->Multicell($this->colwidth, $this->get_heading_height(), $columns, 1, 'C', true, $nextposition);
             $counter++;
         }
 
-        $this->pdf->SetFont($fontfamily, $fontstyle);
+        $pdf->SetFont($fontfamily, $fontstyle);
+    }
+
+    /**
+     * Returns the heading height.
+     *
+     * @return int
+     */
+    private function get_heading_height() {
+        $height = 0;
+        foreach ($this->columns as $columns) {
+            $height = max($height, $this->pdf->getStringHeight($this->colwidth, $columns, false, true, '', 1));
+        }
+        return $height;
     }
 }
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/grade/tests/behat/grade_hidden_items_locked_category.feature b/grade/tests/behat/grade_hidden_items_locked_category.feature
new file mode 100644 (file)
index 0000000..6cc1154
--- /dev/null
@@ -0,0 +1,92 @@
+@core @core_grades
+Feature: Hidden grade items should be hidden when grade category is locked, but should be visible in overridden category
+  In order to verify existing grades items display as expected
+  As an teacher
+  I need to modify grade items and grade categories
+  I need to ensure existing grades display in an expected manner
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category | groupmode |
+      | Course 1 | C1 | 0 | 1 |
+    And the following "users" exist:
+      | username | firstname | lastname | email | idnumber |
+      | teacher1 | Teacher | 1 | teacher1@example.com | t1 |
+      | student1 | Student | 1 | student1@example.com | s1 |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student |
+    And I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I navigate to "Setup > Gradebook setup" in the course gradebook
+    And I press "Add category"
+    And I set the following fields to these values:
+      | Category name | Test locked category |
+    And I press "Save changes"
+    And I press "Add grade item"
+    And I set the following fields to these values:
+      | Item name | Hidden item |
+      | Hidden | 1 |
+      | Grade category | Test locked category |
+    And I press "Save changes"
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "View > Grader report" in the course gradebook
+    And I turn editing mode on
+    And I give the grade "50.00" to the user "Student 1" for the grade item "Hidden item"
+    And I press "Save changes"
+    And I navigate to "Setup > Gradebook setup" in the course gradebook
+    And I set the following settings for grade item "Test locked category":
+      | Locked | 1 |
+    And I press "Save changes"
+    And I log out
+
+  Scenario: Hidden grade items in locked category is hidden for teacher
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "View > User report" in the course gradebook
+    And I select "Myself" from the "View report as" singleselect
+    When I select "Student 1" from the "Select all or one user" singleselect
+    Then the following should exist in the "user-grade" table:
+      | Grade item | Calculated weight | Grade | Range | Percentage | Contribution to course total |
+      | Test locked category total | 100.00 % | 50.00 | 0–100 | 50.00 % | - |
+      | Course total | - | 50.00 | 0–100 | 50.00 % | - |
+
+  Scenario: Hidden grade items in locked category is hidden for student
+    Given I log in as "student1"
+    And I am on "Course 1" course homepage
+    When I navigate to "User report" in the course gradebook
+    Then the following should exist in the "user-grade" table:
+      | Grade item | Calculated weight | Grade | Range | Percentage | Contribution to course total |
+      | Test locked category total | 100.00 % | - | 0–100 | - | - |
+      | Course total | - | - | 0–100 | - | - |
+    And I should not see "Hidden item"
+
+  Scenario: Hidden grade items in overridden category should show
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Setup > Gradebook setup" in the course gradebook
+    And I press "Add category"
+    And I set the following fields to these values:
+      | Category name | Test overridden category B|
+    And I press "Save changes"
+    And I press "Add grade item"
+    And I set the following fields to these values:
+      | Item name | Cat b item |
+      | Grade category | Test overridden category B |
+    And I press "Save changes"
+    When I navigate to "View > Grader report" in the course gradebook
+    And I turn editing mode on
+    And I give the grade "50.00" to the user "Student 1" for the grade item "Test overridden category B total"
+    And I press "Save changes"
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I navigate to "User report" in the course gradebook
+    Then the following should exist in the "user-grade" table:
+      | Grade item | Calculated weight | Grade | Range | Percentage | Contribution to course total |
+      | Test locked category total | 50.00 % | - | 0–100 | - | - |
+      | Test overridden category B total | 50.00 % | 50.00 | 0–100 | 50.00 % | - |
+      | Course total | - | - | 0–200 | - | - |
index 57dd669..f315a4a 100644 (file)
@@ -30,5 +30,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['parentlanguage'] = '-';
 $string['thisdirection'] = 'ltr';
 $string['thislanguage'] = 'Հայերեն';
index 114fcfe..c392295 100644 (file)
@@ -41,7 +41,7 @@ $string['cannotunzipfile'] = 'មិនអាចពន្លាឯកសារ
 $string['componentisuptodate'] = 'សមាសភាគគឺទាន់សម័យ ។';
 $string['downloadedfilecheckfailed'] = 'បានបរាជ័យក្នុងការពិនិត្យឯកសារដែលបានទាញយក ។';
 $string['invalidmd5'] = 'md5 មិនត្រឹមត្រូវ';
-$string['missingrequiredfield'] = 'á\9e\94á\9e¶á\9e\8fá\9f\8bá\9e\9cá\9e¶á\9e\9bá\9e\8aá\9f\82á\9e\9bá\9e\8fá\9f\92á\9e\9aá\9e¼á\9e\9cá\9e\80ារមួយចំនួន';
+$string['missingrequiredfield'] = 'á\9e\94á\9e¶á\9e\8fá\9f\8bá\9e\94á\9f\92á\9e\9aá\9e¢á\9e\94á\9f\8bá\9e\91á\9e·á\9e\93á\9f\92á\9e\93á\9e\93á\9f\90á\9e\99á\9e\8aá\9f\82á\9e\9bá\9e\8fá\9f\92á\9e\9aá\9e¼á\9e\9cá\9e\91á\9e¶á\9e\98á\9e\91ារមួយចំនួន';
 $string['remotedownloaderror'] = 'បរាជ័យក្នុងការទាញយកសមាសភាគទៅម៉ាស៊ីនបម្រើរបស់អ្នក សូមផ្ទៀងផ្ទាត់ប្រូកស៊ី ផ្នែកបន្ថែម PHP cURL ត្រូវបានផ្ដល់អនុសាសន៍ ។<br /><br />អ្នកត្រូវតែទាញយកឯកសារ <a href="{$a->url}">{$a->url}</a> ដោយដៃ ចម្លងវាទៅ "{$a->dest}" ក្នុងម៉ាស៊ីនបម្រើរបស់អ្នក និងពន្លាវានៅទីនោះ ។';
 $string['wrongdestpath'] = 'ផ្លូវទិសដៅមិនត្រឹមត្រូវ ។';
 $string['wrongsourcebase'] = 'មូលដ្ឋាន URL ប្រភពមិនត្រឹមត្រូវ ។';
diff --git a/install/lang/nb/langconfig.php b/install/lang/nb/langconfig.php
new file mode 100644 (file)
index 0000000..f441538
--- /dev/null
@@ -0,0 +1,34 @@
+<?php
+
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['parentlanguage'] = 'no';
+$string['thislanguage'] = 'Norsk bokmål';
index f178887..e7fec91 100644 (file)
@@ -80,6 +80,7 @@ $string['profilelabel'] = '{$a->label}: {$a->profile} {$a->operator} {$a->value}
 $string['profilelabelnovalue'] = '{$a->label}: {$a->profile} {$a->operator}';
 $string['removeall'] = 'Remove all filters';
 $string['removeselected'] = 'Remove selected';
+$string['replacefilters'] = 'Replace filters';
 $string['selectlabel'] = '{$a->label} {$a->operator} {$a->value}';
 $string['startswith'] = 'starts with';
 $string['tablenosave'] = 'Changes in table above are saved automatically.';
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 47c4244..10ffe0b 100644 (file)
Binary files a/lib/amd/build/icon_system_fontawesome.min.js and b/lib/amd/build/icon_system_fontawesome.min.js differ
index 1bb4e1d..374c798 100644 (file)
Binary files a/lib/amd/build/icon_system_fontawesome.min.js.map and b/lib/amd/build/icon_system_fontawesome.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
index c221d1a..884e803 100644 (file)
Binary files a/lib/amd/build/user_date.min.js and b/lib/amd/build/user_date.min.js differ
index 87973d0..0b1e565 100644 (file)
Binary files a/lib/amd/build/user_date.min.js.map and b/lib/amd/build/user_date.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 25c749c..39c4711 100644 (file)
@@ -42,11 +42,13 @@ define(['core/icon_system', 'jquery', 'core/ajax', 'core/mustache', 'core/locals
      * @return {Promise}
      */
     IconSystemFontawesome.prototype.init = function() {
+        var currTheme = M.cfg.theme;
+
         if (staticMap) {
             return $.when(this);
         }
 
-        var map = LocalStorage.get('core/iconmap-fontawesome');
+        var map = LocalStorage.get('core_iconsystem/theme/' + currTheme + '/core/iconmap-fontawesome');
         if (map) {
             map = JSON.parse(map);
         }
@@ -70,7 +72,7 @@ define(['core/icon_system', 'jquery', 'core/ajax', 'core/mustache', 'core/locals
             $.each(map, function(index, value) {
                 staticMap[value.component + '/' + value.pix] = value.to;
             });
-            LocalStorage.set('core/iconmap-fontawesome', JSON.stringify(staticMap));
+            LocalStorage.set('core_iconsystem/theme/' + currTheme + '/core/iconmap-fontawesome', JSON.stringify(staticMap));
             return this;
         }.bind(this));
     };
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 95efe3d..15c8be7 100644 (file)
@@ -112,7 +112,7 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
             return {
                 timestamp: data.timestamp,
                 format: data.format,
-                type: data.type || '',
+                type: data.type || null,
                 fixday: fixDay,
                 fixhour: fixHour
             };
index db165c7..0fe5c09 100644 (file)
 
 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 
-use Behat\Mink\Exception\DriverException;
-use Behat\Mink\Exception\ExpectationException;
-use Behat\Mink\Exception\ElementNotFoundException;
-use Behat\Mink\Element\NodeElement;
-use Behat\Mink\Element\Element;
-use Behat\Mink\Session;
-
-require_once(__DIR__ . '/classes/component_named_selector.php');
-require_once(__DIR__ . '/classes/component_named_replacement.php');
+require_once(__DIR__ . '/classes/behat_session_interface.php');
+require_once(__DIR__ . '/classes/behat_session_trait.php');
 
 /**
  * Steps definitions base class.
@@ -56,1257 +49,8 @@ require_once(__DIR__ . '/classes/component_named_replacement.php');
  * @copyright 2012 David Monllaó
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
-
-    /**
-     * Small timeout.
-     *
-     * A reduced timeout for cases where self::TIMEOUT is too much
-     * and a simple $this->getSession()->getPage()->find() could not
-     * be enough.
-     *
-     * @deprecated since Moodle 3.7 MDL-64979 - please use get_reduced_timeout() instead
-     * @todo MDL-64982 This will be deleted in Moodle 3.11
-     * @see behat_base::get_reduced_timeout()
-     */
-    const REDUCED_TIMEOUT = 2;
-
-    /**
-     * The timeout for each Behat step (load page, wait for an element to load...).
-     *
-     * @deprecated since Moodle 3.7 MDL-64979 - please use get_timeout() instead
-     * @todo MDL-64982 This will be deleted in Moodle 3.11
-     * @see behat_base::get_timeout()
-     */
-    const TIMEOUT = 6;
-
-    /**
-     * And extended timeout for specific cases.
-     *
-     * @deprecated since Moodle 3.7 MDL-64979 - please use get_extended_timeout() instead
-     * @todo MDL-64982 This will be deleted in Moodle 3.11
-     * @see behat_base::get_extended_timeout()
-     */
-    const EXTENDED_TIMEOUT = 10;
-
-    /**
-     * The JS code to check that the page is ready.
-     *
-     * The document must be complete and either M.util.pending_js must be empty, or it must not be defined at all.
-     */
-    const PAGE_READY_JS = "document.readyState === 'complete' && " .
-        "(typeof M !== 'object' || typeof M.util !== 'object' || " .
-        "typeof M.util.pending_js === 'undefined' || M.util.pending_js.length === 0)";
-
-    /**
-     * Locates url, based on provided path.
-     * Override to provide custom routing mechanism.
-     *
-     * @see Behat\MinkExtension\Context\MinkContext
-     * @param string $path
-     * @return string
-     */
-    protected function locate_path($path) {
-        $starturl = rtrim($this->getMinkParameter('base_url'), '/') . '/';
-        return 0 !== strpos($path, 'http') ? $starturl . ltrim($path, '/') : $path;
-    }
-
-    /**
-     * Returns the first matching element.
-     *
-     * @link http://mink.behat.org/#traverse-the-page-selectors
-     * @param string $selector The selector type (css, xpath, named...)
-     * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator...
-     * @param Exception $exception Otherwise we throw exception with generic info
-     * @param NodeElement $node Spins around certain DOM node instead of the whole page
-     * @param int $timeout Forces a specific time out (in seconds).
-     * @return NodeElement
-     */
-    protected function find($selector, $locator, $exception = false, $node = false, $timeout = false) {
-        if ($selector === 'NodeElement' && is_a($locator, NodeElement::class)) {
-            // Support a NodeElement being passed in for use in step chaining.
-            return $locator;
-        }
-
-        // Returns the first match.
-        $items = $this->find_all($selector, $locator, $exception, $node, $timeout);
-        return count($items) ? reset($items) : null;
-    }
-
-    /**
-     * Returns all matching elements.
-     *
-     * Adapter to Behat\Mink\Element\Element::findAll() using the spin() method.
-     *
-     * @link http://mink.behat.org/#traverse-the-page-selectors
-     * @param string $selector The selector type (css, xpath, named...)
-     * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator...
-     * @param Exception $exception Otherwise we throw expcetion with generic info
-     * @param NodeElement $container Restrict the search to just children of the specified container
-     * @param int $timeout Forces a specific time out (in seconds). If 0 is provided the default timeout will be applied.
-     * @return array NodeElements list
-     */
-    protected function find_all($selector, $locator, $exception = false, $container = false, $timeout = false) {
-        // Throw exception, so dev knows it is not supported.
-        if ($selector === 'named') {
-            $exception = 'Using the "named" selector is deprecated as of 3.1. '
-                .' Use the "named_partial" or use the "named_exact" selector instead.';
-            throw new ExpectationException($exception, $this->getSession());
-        }
-
-        // Generic info.
-        if (!$exception) {
-            // With named selectors we can be more specific.
-            if (($selector == 'named_exact') || ($selector == 'named_partial')) {
-                $exceptiontype = $locator[0];
-                $exceptionlocator = $locator[1];
-
-                // If we are in a @javascript session all contents would be displayed as HTML characters.
-                if ($this->running_javascript()) {
-                    $locator[1] = html_entity_decode($locator[1], ENT_NOQUOTES);
-                }
-
-            } else {
-                $exceptiontype = $selector;
-                $exceptionlocator = $locator;
-            }
-
-            $exception = new ElementNotFoundException($this->getSession(), $exceptiontype, null, $exceptionlocator);
-        }
-
-        // How much we will be waiting for the element to appear.
-        if (!$timeout) {
-            $timeout = self::get_timeout();
-            $microsleep = false;
-        } else {
-            // Spinning each 0.1 seconds if the timeout was forced as we understand
-            // that is a special case and is good to refine the performance as much
-            // as possible.
-            $microsleep = true;
-        }
-
-        // Normalise the values in order to perform the search.
-        [
-            'selector' => $selector,
-            'locator' => $locator,
-            'container' => $container,
-        ] = $this->normalise_selector($selector, $locator, $container ?: $this->getSession()->getPage());
-
-        // Waits for the node to appear if it exists, otherwise will timeout and throw the provided exception.
-        return $this->spin(
-            function() use ($selector, $locator, $container) {
-                return $container->findAll($selector, $locator);
-            }, [], $timeout, $exception, $microsleep
-        );
-    }
-
-    /**
-     * Normalise the locator and selector.
-     *
-     * @param string $selector The type of thing to search
-     * @param mixed $locator The locator value. Can be an array, but is more likely a string.
-     * @param Element $container An optional container to search within
-     * @return array The selector, locator, and container to search within
-     */
-    public function normalise_selector(string $selector, $locator, Element $container): array {
-        // Check for specific transformations for this selector type.
-        $transformfunction = "transform_find_for_{$selector}";
-        if (method_exists('behat_selectors', $transformfunction)) {
-            // A selector-specific transformation exists.
-            // Perform initial transformation of the selector within the current container.
-            [
-                'selector' => $selector,
-                'locator' => $locator,
-                'container' => $container,
-            ] = behat_selectors::{$transformfunction}($this, $locator, $container);
-        }
-
-        // Normalise the css and xpath selector types.
-        if ('css_element' === $selector) {
-            $selector = 'css';
-        } else if ('xpath_element' === $selector) {
-            $selector = 'xpath';
-        }
-
-        // Convert to a named selector where the selector type is not a known selector.
-        $converttonamed = !$this->getSession()->getSelectorsHandler()->isSelectorRegistered($selector);
-        $converttonamed = $converttonamed && 'xpath' !== $selector;
-        if ($converttonamed) {
-            if (behat_partial_named_selector::is_deprecated_selector($selector)) {
-                if ($replacement = behat_partial_named_selector::get_deprecated_replacement($selector)) {
-                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
-                    $selector = $replacement;
-                }
-            } else if (behat_exact_named_selector::is_deprecated_selector($selector)) {
-                if ($replacement = behat_exact_named_selector::get_deprecated_replacement($selector)) {
-                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
-                    $selector = $replacement;
-                }
-            }
-
-            $allowedpartialselectors = behat_partial_named_selector::get_allowed_selectors();
-            $allowedexactselectors = behat_exact_named_selector::get_allowed_selectors();
-            if (isset($allowedpartialselectors[$selector])) {
-                $locator = behat_selectors::normalise_named_selector($allowedpartialselectors[$selector], $locator);
-                $selector = 'named_partial';
-            } else if (isset($allowedexactselectors[$selector])) {
-                $locator = behat_selectors::normalise_named_selector($allowedexactselectors[$selector], $locator);
-                $selector = 'named_exact';
-            } else {
-                throw new ExpectationException("The '{$selector}' selector type is not registered.", $this->getSession()->getDriver());
-            }
-        }
-
-        return [
-            'selector' => $selector,
-            'locator' => $locator,
-            'container' => $container,
-        ];
-    }
-
-    /**
-     * Finds DOM nodes in the page using named selectors.
-     *
-     * The point of using this method instead of Mink ones is the spin
-     * method of behat_base::find() that looks for the element until it
-     * is available or it timeouts, this avoids the false failures received
-     * when selenium tries to execute commands on elements that are not
-     * ready to be used.
-     *
-     * All steps that requires elements to be available before interact with
-     * them should use one of the find* methods.
-     *
-     * The methods calls requires a {'find_' . $elementtype}($locator)
-     * format, like find_link($locator), find_select($locator),
-     * find_button($locator)...
-     *
-     * @link http://mink.behat.org/#named-selectors
-     * @throws coding_exception
-     * @param string $name The name of the called method
-     * @param mixed $arguments
-     * @return NodeElement
-     */
-    public function __call($name, $arguments) {
-        if (substr($name, 0, 5) === 'find_') {
-            return call_user_func_array([$this, 'find'], array_merge(
-                [substr($name, 5)],
-                $arguments
-            ));
-        }
-
-        throw new coding_exception("The '{$name}' method does not exist");
-    }
-
-    /**
-     * Escapes the double quote character.
-     *
-     * Double quote is the argument delimiter, it can be escaped
-     * with a backslash, but we auto-remove this backslashes
-     * before the step execution, this method is useful when using
-     * arguments as arguments for other steps.
-     *
-     * @param string $string
-     * @return string
-     */
-    public function escape($string) {
-        return str_replace('"', '\"', $string);
-    }
-
-    /**
-     * Executes the passed closure until returns true or time outs.
-     *
-     * In most cases the document.readyState === 'complete' will be enough, but sometimes JS
-     * requires more time to be completely loaded or an element to be visible or whatever is required to
-     * perform some action on an element; this method receives a closure which should contain the
-     * required statements to ensure the step definition actions and assertions have all their needs
-     * satisfied and executes it until they are satisfied or it timeouts. Redirects the return of the
-     * closure to the caller.
-     *
-     * The closures requirements to work well with this spin method are:
-     * - Must return false, null or '' if something goes wrong
-     * - Must return something != false if finishes as expected, this will be the (mixed) value
-     * returned by spin()
-     *
-     * The arguments of the closure are mixed, use $args depending on your needs.
-     *
-     * You can provide an exception to give more accurate feedback to tests writers, otherwise the
-     * closure exception will be used, but you must provide an exception if the closure does not throw
-     * an exception.
-     *
-     * @throws Exception If it timeouts without receiving something != false from the closure
-     * @param Function|array|string $lambda The function to execute or an array passed to call_user_func (maps to a class method)
-     * @param mixed $args Arguments to pass to the closure
-     * @param int $timeout Timeout in seconds
-     * @param Exception $exception The exception to throw in case it time outs.
-     * @param bool $microsleep If set to true it'll sleep micro seconds rather than seconds.
-     * @return mixed The value returned by the closure
-     */
-    protected function spin($lambda, $args = false, $timeout = false, $exception = false, $microsleep = false) {
-
-        // Using default timeout which is pretty high.
-        if (!$timeout) {
-            $timeout = self::get_timeout();
-        }
-
-        $start = microtime(true);
-        $end = $start + $timeout;
-
-        do {
-            // We catch the exception thrown by the step definition to execute it again.
-            try {
-                // We don't check with !== because most of the time closures will return
-                // direct Behat methods returns and we are not sure it will be always (bool)false
-                // if it just runs the behat method without returning anything $return == null.
-                if ($return = call_user_func($lambda, $this, $args)) {
-                    return $return;
-                }
-            } catch (Exception $e) {
-                // We would use the first closure exception if no exception has been provided.
-                if (!$exception) {
-                    $exception = $e;
-                }
-            }
-
-            if (!$this->running_javascript()) {
-                break;
-            }
-
-            usleep(100000);
-
-        } while (microtime(true) < $end);
-
-        // Using coding_exception as is a development issue if no exception has been provided.
-        if (!$exception) {
-            $exception = new coding_exception('spin method requires an exception if the callback does not throw an exception');
-        }
-
-        // Throwing exception to the user.
-        throw $exception;
-    }
-
-    /**
-     * Gets a NodeElement based on the locator and selector type received as argument from steps definitions.
-     *
-     * Use behat_base::get_text_selector_node() for text-based selectors.
-     *
-     * @throws ElementNotFoundException Thrown by behat_base::find
-     * @param string $selectortype
-     * @param string $element
-     * @return NodeElement
-     */
-    protected function get_selected_node($selectortype, $element) {
-        return $this->find($selectortype, $element);
-    }
-
-    /**
-     * Gets a NodeElement based on the locator and selector type received as argument from steps definitions.
-     *
-     * @throws ElementNotFoundException Thrown by behat_base::find
-     * @param string $selectortype
-     * @param string $element
-     * @return NodeElement
-     */
-    protected function get_text_selector_node($selectortype, $element) {
-        // Getting Mink selector and locator.
-        list($selector, $locator) = $this->transform_text_selector($selectortype, $element);
-
-        // Returns the NodeElement.
-        return $this->find($selector, $locator);
-    }
-
-    /**
-     * Gets the requested element inside the specified container.
-     *
-     * @throws ElementNotFoundException Thrown by behat_base::find
-     * @param mixed $selectortype The element selector type.
-     * @param mixed $element The element locator.
-     * @param mixed $containerselectortype The container selector type.
-     * @param mixed $containerelement The container locator.
-     * @return NodeElement
-     */
-    protected function get_node_in_container($selectortype, $element, $containerselectortype, $containerelement) {
-        // Gets the container, it will always be text based.
-        $containernode = $this->get_text_selector_node($containerselectortype, $containerelement);
-
-        $locatorexceptionmsg = $element . '" in the "' . $containerelement. '" "' . $containerselectortype. '"';
-        $exception = new ElementNotFoundException($this->getSession(), $selectortype, null, $locatorexceptionmsg);
-
-        return $this->find($selectortype, $element, $exception, $containernode);
-    }
-
-    /**
-     * Transforms from step definition's argument style to Mink format.
-     *
-     * Mink has 3 different selectors css, xpath and named, where named
-     * selectors includes link, button, field... to simplify and group multiple
-     * steps in one we use the same interface, considering all link, buttons...
-     * at the same level as css selectors and xpath; this method makes the
-     * conversion from the arguments received by the steps to the selectors and locators
-     * required to interact with Mink.
-     *
-     * @throws ExpectationException
-     * @param string $selectortype It can be css, xpath or any of the named selectors.
-     * @param string $element The locator (or string) we are looking for.
-     * @return array Contains the selector and the locator expected by Mink.
-     */
-    protected function transform_selector($selectortype, $element) {
-        // Here we don't know if an allowed text selector is being used.
-        $selectors = behat_selectors::get_allowed_selectors();
-        if (!isset($selectors[$selectortype])) {
-            throw new ExpectationException('The "' . $selectortype . '" selector type does not exist', $this->getSession());
-        }
-
-        [
-            'selector' => $selector,
-            'locator' => $locator,
-        ] = $this->normalise_selector($selectortype, $element, $this->getSession()->getPage());
-
-        return [$selector, $locator];
-    }
-
-    /**
-     * Transforms from step definition's argument style to Mink format.
-     *
-     * Delegates all the process to behat_base::transform_selector() checking
-     * the provided $selectortype.
-     *
-     * @throws ExpectationException
-     * @param string $selectortype It can be css, xpath or any of the named selectors.
-     * @param string $element The locator (or string) we are looking for.
-     * @return array Contains the selector and the locator expected by Mink.
-     */
-    protected function transform_text_selector($selectortype, $element) {
-
-        $selectors = behat_selectors::get_allowed_text_selectors();
-        if (empty($selectors[$selectortype])) {
-            throw new ExpectationException('The "' . $selectortype . '" selector can not be used to select text nodes', $this->getSession());
-        }
-
-        return $this->transform_selector($selectortype, $element);
-    }
-
-    /**
-     * Whether Javascript is available in the current Session.
-     *
-     * @return boolean
-     */
-    protected function running_javascript() {
-        return self::running_javascript_in_session($this->getSession());
-    }
-
-    /**
-     * Require that javascript be available in the current Session.
-     *
-     * @throws DriverException
-     */
-    protected function require_javascript() {
-        return self::require_javascript_in_session($this->getSession());
-    }
-
-    /**
-     * Whether Javascript is available in the specified Session.
-     *
-     * @param Session $session
-     * @return boolean
-     */
-    protected static function running_javascript_in_session(Session $session): bool {
-        return get_class($session->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver';
-    }
-
-    /**
-     * Require that javascript be available for the specified Session.
-     *
-     * @param Session $session
-     * @throws DriverException
-     */
-    protected static function require_javascript_in_session(Session $session): void {
-        if (self::running_javascript_in_session($session)) {
-            return;
-        }
-
-        throw new DriverException('Javascript is required');
-    }
-
-    /**
-     * Checks if the current page is part of the mobile app.
-     *
-     * @return bool True if it's in the app
-     */
-    protected function is_in_app() : bool {
-        // Cannot be in the app if there's no @app tag on scenario.
-        if (!$this->has_tag('app')) {
-            return false;
-        }
-
-        // Check on page to see if it's an app page. Safest way is to look for added JavaScript.
-        return $this->evaluate_script('return typeof window.behat') === 'object';
-    }
-
-    /**
-     * Spins around an element until it exists
-     *
-     * @throws ExpectationException
-     * @param string $locator
-     * @param string $selectortype
-     * @return void
-     */
-    protected function ensure_element_exists($locator, $selectortype) {
-        // Exception if it timesout and the element is still there.
-        $msg = "The '{$locator}' element does not exist and should";
-        $exception = new ExpectationException($msg, $this->getSession());
-
-        // Normalise the values in order to perform the search.
-        [
-            'selector' => $selector,
-            'locator' => $locator,
-            'container' => $container,
-        ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage());
-
-        // It will stop spinning once the find() method returns true.
-        $this->spin(
-            function() use ($selector, $locator, $container) {
-                if ($container->find($selector, $locator)) {
-                    return true;
-                }
-                return false;
-            },
-            [],
-            self::get_extended_timeout(),
-            $exception,
-            true
-        );
-    }
-
-    /**
-     * Spins until the element does not exist
-     *
-     * @throws ExpectationException
-     * @param string $locator
-     * @param string $selectortype
-     * @return void
-     */
-    protected function ensure_element_does_not_exist($locator, $selectortype) {
-        // Exception if it timesout and the element is still there.
-        $msg = "The '{$locator}' element exists and should not exist";
-        $exception = new ExpectationException($msg, $this->getSession());
-
-        // Normalise the values in order to perform the search.
-        [
-            'selector' => $selector,
-            'locator' => $locator,
-            'container' => $container,
-        ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage());
-
-        // It will stop spinning once the find() method returns false.
-        $this->spin(
-            function() use ($selector, $locator, $container) {
-                if ($container->find($selector, $locator)) {
-                    return false;
-                }
-                return true;
-            },
-            // Note: We cannot use $this because the find will then be $this->find(), which leads us to a nested spin().
-            // We cannot nest spins because the outer spin times out before the inner spin completes.
-            [],
-            self::get_extended_timeout(),
-            $exception,
-            true
-        );
-    }
-
-    /**
-     * Ensures that the provided node is visible and we can interact with it.
-     *
-     * @throws ExpectationException
-     * @param NodeElement $node
-     * @return void Throws an exception if it times out without the element being visible
-     */
-    protected function ensure_node_is_visible($node) {
-
-        if (!$this->running_javascript()) {
-            return;
-        }
-
-        // Exception if it timesout and the element is still there.
-        $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible';
-        $exception = new ExpectationException($msg, $this->getSession());
-
-        // It will stop spinning once the isVisible() method returns true.
-        $this->spin(
-            function($context, $args) {
-                if ($args->isVisible()) {
-                    return true;
-                }
-                return false;
-            },
-            $node,
-            self::get_extended_timeout(),
-            $exception,
-            true
-        );
-    }
-
-    /**
-     * Ensures that the provided node has a attribute value set. This step can be used to check if specific
-     * JS has finished modifying the node.
-     *
-     * @throws ExpectationException
-     * @param NodeElement $node
-     * @param string $attribute attribute name
-     * @param string $attributevalue attribute value to check.
-     * @return void Throws an exception if it times out without the element being visible
-     */
-    protected function ensure_node_attribute_is_set($node, $attribute, $attributevalue) {
-
-        if (!$this->running_javascript()) {
-            return;
-        }
-
-        // Exception if it timesout and the element is still there.
-        $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible';
-        $exception = new ExpectationException($msg, $this->getSession());
-
-        // It will stop spinning once the $args[1]) == $args[2], and method returns true.
-        $this->spin(
-            function($context, $args) {
-                if ($args[0]->getAttribute($args[1]) == $args[2]) {
-                    return true;
-                }
-                return false;
-            },
-            array($node, $attribute, $attributevalue),
-            self::get_extended_timeout(),
-            $exception,
-            true
-        );
-    }
-
-    /**
-     * Ensures that the provided element is visible and we can interact with it.
-     *
-     * Returns the node in case other actions are interested in using it.
-     *
-     * @throws ExpectationException
-     * @param string $element
-     * @param string $selectortype
-     * @return NodeElement Throws an exception if it times out without being visible
-     */
-    protected function ensure_element_is_visible($element, $selectortype) {
-
-        if (!$this->running_javascript()) {
-            return;
-        }
-
-        $node = $this->get_selected_node($selectortype, $element);
-        $this->ensure_node_is_visible($node);
-
-        return $node;
-    }
-
-    /**
-     * Ensures that all the page's editors are loaded.
-     *
-     * @deprecated since Moodle 2.7 MDL-44084 - please do not use this function any more.
-     * @throws ElementNotFoundException
-     * @throws ExpectationException
-     * @return void
-     */
-    protected function ensure_editors_are_loaded() {
-        global $CFG;
-
-        if (empty($CFG->behat_usedeprecated)) {
-            debugging('Function behat_base::ensure_editors_are_loaded() is deprecated. It is no longer required.');
-        }
-        return;
-    }
-
-    /**
-     * Checks if the current scenario, or its feature, has a specified tag.
-     *
-     * @param string $tag Tag to check
-     * @return bool True if the tag exists in scenario or feature
-     */
-    public function has_tag(string $tag) : bool {
-        return array_key_exists($tag, behat_hooks::get_tags_for_scenario());
-    }
-
-    /**
-     * Change browser window size.
-     *   - small: 640x480
-     *   - medium: 1024x768
-     *   - large: 2560x1600
-     *
-     * @param string $windowsize size of window.
-     * @param bool $viewport If true, changes viewport rather than window size
-     * @throws ExpectationException
-     */
-    protected function resize_window($windowsize, $viewport = false) {
-        // Non JS don't support resize window.
-        if (!$this->running_javascript()) {
-            return;
-        }
-
-        switch ($windowsize) {
-            case "small":
-                $width = 1024;
-                $height = 768;
-                break;
-            case "medium":
-                $width = 1366;
-                $height = 768;
-                break;
-            case "large":
-                $width = 2560;
-                $height = 1600;
-                break;
-            default:
-                preg_match('/^(\d+x\d+)$/', $windowsize, $matches);
-                if (empty($matches) || (count($matches) != 2)) {
-                    throw new ExpectationException("Invalid screen size, can't resize", $this->getSession());
-                }
-                $size = explode('x', $windowsize);
-                $width = (int) $size[0];
-                $height = (int) $size[1];
-        }
-        if ($viewport) {
-            // When setting viewport size, we set it so that the document width will be exactly
-            // as specified, assuming that there is a vertical scrollbar. (In cases where there is
-            // no scrollbar it will be slightly wider. We presume this is rare and predictable.)
-            // The window inner height will be as specified, which means the available viewport will
-            // actually be smaller if there is a horizontal scrollbar. We assume that horizontal
-            // scrollbars are rare so this doesn't matter.
-            $js = <<<EOF
-return (function() {
-    var before = document.body.style.overflowY;
-    document.body.style.overflowY = "scroll";
-    var result = {};
-    result.x = window.outerWidth - document.body.offsetWidth;
-    result.y = window.outerHeight - window.innerHeight;
-    document.body.style.overflowY = before;
-    return result;
-})();
-EOF;
-            $offset = $this->evaluate_script($js);
-            $width += $offset['x'];
-            $height += $offset['y'];
-        }
-
-        $this->getSession()->getDriver()->resizeWindow($width, $height);
-    }
-
-    /**
-     * Waits for all the JS to be loaded.
-     *
-     * @return  bool Whether any JS is still pending completion.
-     */
-    public function wait_for_pending_js() {
-        if (!$this->running_javascript()) {
-            // JS is not available therefore there is nothing to wait for.
-            return false;
-        }
-
-        return static::wait_for_pending_js_in_session($this->getSession());
-    }
-
-    /**
-     * Waits for all the JS to be loaded.
-     *
-     * @param   Session $session The Mink Session where JS can be run
-     * @return  bool Whether any JS is still pending completion.
-     */
-    public static function wait_for_pending_js_in_session(Session $session) {
-        // We don't use behat_base::spin() here as we don't want to end up with an exception
-        // if the page & JSs don't finish loading properly.
-        for ($i = 0; $i < self::get_extended_timeout() * 10; $i++) {
-            $pending = '';
-            try {
-                $jscode = trim(preg_replace('/\s+/', ' ', '
-                    return (function() {
-                        if (document.readyState !== "complete") {
-                            return "incomplete";
-                        }
-
-                        if (typeof M !== "object" || typeof M.util !== "object" || typeof M.util.pending_js === "undefined") {
-                            return "";
-                        }
-
-                        return M.util.pending_js.join(":");
-                    })()'));
-                $pending = self::evaluate_script_in_session($session, $jscode);
-            } catch (NoSuchWindow $nsw) {
-                // We catch an exception here, in case we just closed the window we were interacting with.
-                // No javascript is running if there is no window right?
-                $pending = '';
-            } catch (UnknownError $e) {
-                // M is not defined when the window or the frame don't exist anymore.
-                if (strstr($e->getMessage(), 'M is not defined') != false) {
-                    $pending = '';
-                }
-            }
-
-            // If there are no pending JS we stop waiting.
-            if ($pending === '') {
-                return true;
-            }
-
-            // 0.1 seconds.
-            usleep(100000);
-        }
-
-        // Timeout waiting for JS to complete. It will be caught and forwarded to behat_hooks::i_look_for_exceptions().
-        // It is unlikely that Javascript code of a page or an AJAX request needs more than get_extended_timeout() seconds
-        // to be loaded, although when pages contains Javascript errors M.util.js_complete() can not be executed, so the
-        // number of JS pending code and JS completed code will not match and we will reach this point.
-        throw new \Exception('Javascript code and/or AJAX requests are not ready after ' .
-                self::get_extended_timeout() .
-                ' seconds. There is a Javascript error or the code is extremely slow (' . $pending .
-                '). If you are using a slow machine, consider setting $CFG->behat_increasetimeout.');
-    }
-
-    /**
-     * Internal step definition to find exceptions, debugging() messages and PHP debug messages.
-     *
-     * Part of behat_hooks class as is part of the testing framework, is auto-executed
-     * after each step so no features will splicitly use it.
-     *
-     * @throws Exception Unknown type, depending on what we caught in the hook or basic \Exception.
-     * @see Moodle\BehatExtension\Tester\MoodleStepTester
-     */
-    public function look_for_exceptions() {
-        // Wrap in try in case we were interacting with a closed window.
-        try {
-
-            // Exceptions.
-            $exceptionsxpath = "//div[@data-rel='fatalerror']";
-            // Debugging messages.
-            $debuggingxpath = "//div[@data-rel='debugging']";
-            // PHP debug messages.
-            $phperrorxpath = "//div[@data-rel='phpdebugmessage']";
-            // Any other backtrace.
-            $othersxpath = "(//*[contains(., ': call to ')])[1]";
-
-            $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath);
-            $joinedxpath = implode(' | ', $xpaths);
-
-            // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check
-            // is faster than to send the 4 xpath queries for each step.
-            if (!$this->getSession()->getDriver()->find($joinedxpath)) {
-                // Check if we have recorded any errors in driver process.
-                $phperrors = behat_get_shutdown_process_errors();
-                if (!empty($phperrors)) {
-                    foreach ($phperrors as $error) {
-                        $errnostring = behat_get_error_string($error['type']);
-                        $msgs[] = $errnostring . ": " .$error['message'] . " at " . $error['file'] . ": " . $error['line'];
-                    }
-                    $msg = "PHP errors found:\n" . implode("\n", $msgs);
-                    throw new \Exception(htmlentities($msg));
-                }
-
-                return;
-            }
-
-            // Exceptions.
-            if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) {
-
-                // Getting the debugging info and the backtrace.
-                $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error');
-                // If errorinfoboxes is empty, try find alert-danger (bootstrap4) class.
-                if (empty($errorinfoboxes)) {
-                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-danger');
-                }
-                // If errorinfoboxes is empty, try find notifytiny (original) class.
-                if (empty($errorinfoboxes)) {
-                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny');
-                }
-
-                // If errorinfoboxes is empty, try find ajax/JS exception in dialogue.
-                if (empty($errorinfoboxes)) {
-                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.moodle-exception-message');
-
-                    // If ajax/JS exception.
-                    if ($errorinfoboxes) {
-                        $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml());
-                    }
-
-                } else {
-                    $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" .
-                        $this->get_debug_text($errorinfoboxes[1]->getHtml());
-                }
-
-                $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo;
-                throw new \Exception(html_entity_decode($msg));
-            }
-
-            // Debugging messages.
-            if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) {
-                $msgs = array();
-                foreach ($debuggingmessages as $debuggingmessage) {
-                    $msgs[] = $this->get_debug_text($debuggingmessage->getHtml());
-                }
-                $msg = "debugging() message/s found:\n" . implode("\n", $msgs);
-                throw new \Exception(html_entity_decode($msg));
-            }
-
-            // PHP debug messages.
-            if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) {
-
-                $msgs = array();
-                foreach ($phpmessages as $phpmessage) {
-                    $msgs[] = $this->get_debug_text($phpmessage->getHtml());
-                }
-                $msg = "PHP debug message/s found:\n" . implode("\n", $msgs);
-                throw new \Exception(html_entity_decode($msg));
-            }
-
-            // Any other backtrace.
-            // First looking through xpath as it is faster than get and parse the whole page contents,
-            // we get the contents and look for matches once we found something to suspect that there is a backtrace.
-            if ($this->getSession()->getDriver()->find($othersxpath)) {
-                $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/';
-                if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) {
-                    $msgs = array();
-                    foreach ($backtraces[0] as $backtrace) {
-                        $msgs[] = $backtrace . '()';
-                    }
-                    $msg = "Other backtraces found:\n" . implode("\n", $msgs);
-                    throw new \Exception(htmlentities($msg));
-                }
-            }
-
-        } catch (NoSuchWindow $e) {
-            // If we were interacting with a popup window it will not exists after closing it.
-        } catch (DriverException $e) {
-            // Same reason as above.
-        }
-    }
-
-    /**
-     * Converts HTML tags to line breaks to display the info in CLI
-     *
-     * @param string $html
-     * @return string
-     */
-    protected function get_debug_text($html) {
-
-        // Replacing HTML tags for new lines and keeping only the text.
-        $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
-        return preg_replace("/(\n)+/s", "\n", $notags);
-    }
-
-    /**
-     * Helper function to execute api in a given context.
-     *
-     * @param string $contextapi context in which api is defined.
-     * @param array $params list of params to pass.
-     * @throws Exception
-     */
-    protected function execute($contextapi, $params = array()) {
-        if (!is_array($params)) {
-            $params = array($params);
-        }
-
-        // Get required context and execute the api.
-        $contextapi = explode("::", $contextapi);
-        $context = behat_context_helper::get($contextapi[0]);
-        call_user_func_array(array($context, $contextapi[1]), $params);
-
-        // NOTE: Wait for pending js and look for exception are not optional, as this might lead to unexpected results.
-        // Don't make them optional for performance reasons.
-
-        // Wait for pending js.
-        $this->wait_for_pending_js();
-
-        // Look for exceptions.
-        $this->look_for_exceptions();
-    }
-
-    /**
-     * Get the actual user in the behat session (note $USER does not correspond to the behat session's user).
-     * @return mixed
-     * @throws coding_exception
-     */
-    protected function get_session_user() {
-        global $DB;
-
-        $sid = $this->getSession()->getCookie('MoodleSession');
-        if (empty($sid)) {
-            throw new coding_exception('failed to get moodle session');
-        }
-        $userid = $DB->get_field('sessions', 'userid', ['sid' => $sid]);
-        if (empty($userid)) {
-            throw new coding_exception('failed to get user from seession id '.$sid);
-        }
-        return $DB->get_record('user', ['id' => $userid]);
-    }
-
-    /**
-     * Set current $USER, reset access cache.
-     *
-     * In some cases, behat will execute the code as admin but in many cases we need to set an specific user as some
-     * API's might rely on the logged user to take some action.
-     *
-     * @param null|int|stdClass $user user record, null or 0 means non-logged-in, positive integer means userid
-     */
-    public static function set_user($user = null) {
-        global $DB;
-
-        if (is_object($user)) {
-            $user = clone($user);
-        } else if (!$user) {
-            // Assign valid data to admin user (some generator-related code needs a valid user).
-            $user = $DB->get_record('user', array('username' => 'admin'));
-        } else {
-            $user = $DB->get_record('user', array('id' => $user));
-        }
-        unset($user->description);
-        unset($user->access);
-        unset($user->preference);
-
-        // Ensure session is empty, as it may contain caches and user specific info.
-        \core\session\manager::init_empty_session();
-
-        \core\session\manager::set_user($user);
-    }
-    /**
-     * Trigger click on node via javascript instead of actually clicking on it via pointer.
-     *
-     * This function resolves the issue of nested elements with click listeners or links - in these cases clicking via
-     * the pointer may accidentally cause a click on the wrong element.
-     * Example of issue: clicking to expand navigation nodes when the config value linkadmincategories is enabled.
-     * @param NodeElement $node
-     */
-    protected function js_trigger_click($node) {
-        if (!$this->running_javascript()) {
-            $node->click();
-        }
-        $this->ensure_node_is_visible($node); // Ensures hidden elements can't be clicked.
-        $xpath = $node->getXpath();
-        $driver = $this->getSession()->getDriver();
-        if ($driver instanceof \Moodle\BehatExtension\Driver\MoodleSelenium2Driver) {
-            $script = "Syn.click({{ELEMENT}})";
-            $driver->triggerSynScript($xpath, $script);
-        } else {
-            $driver->click($xpath);
-        }
-    }
-
-    /**
-     * Convert page names to URLs for steps like 'When I am on the "[page name]" page'.
-     *
-     * You should override this as appropriate for your plugin. The method
-     * {@link behat_navigation::resolve_core_page_url()} is a good example.
-     *
-     * Your overridden method should document the recognised page types with
-     * a table like this:
-     *
-     * Recognised page names are:
-     * | Page            | Description                                                    |
-     *
-     * @param string $page name of the page, with the component name removed e.g. 'Admin notification'.
-     * @return moodle_url the corresponding URL.
-     * @throws Exception with a meaningful error message if the specified page cannot be found.
-     */
-    protected function resolve_page_url(string $page): moodle_url {
-        throw new Exception('Component "' . get_class($this) .
-                '" does not support the generic \'When I am on the "' . $page .
-                '" page\' navigation step.');
-    }
-
-    /**
-     * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
-     *
-     * A typical example might be:
-     *     When I am on the "Test quiz" "mod_quiz > Responses report" page
-     * which would cause this method in behat_mod_quiz to be called with
-     * arguments 'Responses report', 'Test quiz'.
-     *
-     * You should override this as appropriate for your plugin. The method
-     * {@link behat_navigation::resolve_core_page_instance_url()} is a good example.
-     *
-     * Your overridden method should document the recognised page types with
-     * a table like this:
-     *
-     * Recognised page names are:
-     * | Type      | identifier meaning | Description                                     |
-     *
-     * @param string $type identifies which type of page this is, e.g. 'Attempt review'.
-     * @param string $identifier identifies the particular page, e.g. 'Test quiz > student > Attempt 1'.
-     * @return moodle_url the corresponding URL.
-     * @throws Exception with a meaningful error message if the specified page cannot be found.
-     */
-    protected function resolve_page_instance_url(string $type, string $identifier): moodle_url {
-        throw new Exception('Component "' . get_class($this) .
-                '" does not support the generic \'When I am on the "' . $identifier .
-                '" "' . $type . '" page\' navigation step.');
-    }
-
-    /**
-     * Gets the required timeout in seconds.
-     *
-     * @param int $timeout One of the TIMEOUT constants
-     * @return int Actual timeout (in seconds)
-     */
-    protected static function get_real_timeout(int $timeout) : int {
-        global $CFG;
-        if (!empty($CFG->behat_increasetimeout)) {
-            return $timeout * $CFG->behat_increasetimeout;
-        } else {
-            return $timeout;
-        }
-    }
-
-    /**
-     * Gets the default timeout.
-     *
-     * The timeout for each Behat step (load page, wait for an element to load...).
-     *
-     * @return int Timeout in seconds
-     */
-    public static function get_timeout() : int {
-        return self::get_real_timeout(6);
-    }
-
-    /**
-     * Gets the reduced timeout.
-     *
-     * A reduced timeout for cases where self::get_timeout() is too much
-     * and a simple $this->getSession()->getPage()->find() could not
-     * be enough.
-     *
-     * @return int Timeout in seconds
-     */
-    public static function get_reduced_timeout() : int {
-        return self::get_real_timeout(2);
-    }
-
-    /**
-     * Gets the extended timeout.
-     *
-     * A longer timeout for cases where the normal timeout is not enough.
-     *
-     * @return int Timeout in seconds
-     */
-    public static function get_extended_timeout() : int {
-        return self::get_real_timeout(10);
-    }
-
-    /**
-     * Return a list of the exact named selectors for the component.
-     *
-     * Named selectors are what make Behat steps like
-     *   Then I should see "Useful text" in the "General" "fieldset"
-     * work. Here, "fieldset" is the named selector, and "General" is the locator.
-     *
-     * If you override this method in your plugin (e.g. mod_mymod), to define
-     * new selectors specific to your plugin. For example, if you returned
-     *   new behat_component_named_selector('Thingy',
-     *           [".//some/xpath//img[contains(@alt, %locator%)]/.."])
-     * then
-     *   Then I should see "Useful text" in the "Whatever" "mod_mymod > Thingy"
-     * would work.
-     *
-     * This method should return a list of {@link behat_component_named_selector} and
-     * the docs on that class explain how it works.
-     *
-     * @return behat_component_named_selector[]
-     */
-    public static function get_exact_named_selectors(): array {
-        return [];
-    }
-
-    /**
-     * Return a list of the partial named selectors for the component.
-     *
-     * Like the exact named selectors above, but the locator only
-     * needs to match part of the text. For example, the standard
-     * "button" is a partial selector, so:
-     *   When I click "Save" "button"
-     * will activate "Save changes".
-     *
-     * @return behat_component_named_selector[]
-     */
-    public static function get_partial_named_selectors(): array {
-        return [];
-    }
-
-    /**
-     * Return a list of the Mink named replacements for the component.
-     *
-     * Named replacements allow you to define parts of an xpath that can be reused multiple times, or in multiple
-     * xpaths.
-     *
-     * This method should return a list of {@link behat_component_named_replacement} and the docs on that class explain
-     * how it works.
-     *
-     * @return behat_component_named_replacement[]
-     */
-    public static function get_named_replacements(): array {
-        return [];
-    }
-
-    /**
-     * Evaluate the supplied script in the current session, returning the result.
-     *
-     * @param string $script
-     * @return mixed
-     */
-    public function evaluate_script(string $script) {
-        return self::evaluate_script_in_session($this->getSession(), $script);
-    }
-
-    /**
-     * Evaluate the supplied script in the specified session, returning the result.
-     *
-     * @param Session $session
-     * @param string $script
-     * @return mixed
-     */
-    public static function evaluate_script_in_session(Session $session, string $script) {
-        self::require_javascript_in_session($session);
-
-        return $session->evaluateScript($script);
-    }
-
-    /**
-     * Execute the supplied script in the current session.
-     *
-     * No result will be returned.
-     *
-     * @param string $script
-     */
-    public function execute_script(string $script): void {
-        self::execute_script_in_session($this->getSession(), $script);
-    }
-
-    /**
-     * Excecute the supplied script in the specified session.
-     *
-     * No result will be returned.
-     *
-     * @param Session $session
-     * @param string $script
-     */
-    public static function execute_script_in_session(Session $session, string $script): void {
-        self::require_javascript_in_session($session);
-
-        $session->executeScript($script);
-    }
-
-    /**
-     * Get the session key for the current session via Javascript.
-     *
-     * @return string
-     */
-    public function get_sesskey(): string {
-        $script = <<<EOF
-return (function() {
-if (M && M.cfg && M.cfg.sesskey) {
-    return M.cfg.sesskey;
-}
-return '';
-})()
-EOF;
+class behat_base extends Behat\MinkExtension\Context\RawMinkContext implements behat_session_interface {
 
-        return $this->evaluate_script($script);
-    }
+    // All of the functionality of behat_base is shared with form fields via the behat_session_trait trait.
+    use behat_session_trait;
 }
index 6d9b4c7..1a807a2 100644 (file)
@@ -48,7 +48,6 @@ class behat_field_manager {
      * @return behat_form_field
      */
     public static function get_form_field_from_label($label, RawMinkContext $context) {
-
         // There are moodle form elements that are not directly related with
         // a basic HTML form field, we should also take care of them.
         // The DOM node.
@@ -172,6 +171,10 @@ class behat_field_manager {
         } else if ($tagname == 'select') {
             // Select tag.
             return 'select';
+        } else if ($tagname == 'span') {
+            if ($fieldnode->hasAttribute('data-inplaceeditable') && $fieldnode->getAttribute('data-inplaceeditable')) {
+                return 'inplaceeditable';
+            }
         }
 
         // We can not provide a closer field type.
diff --git a/lib/behat/classes/behat_session_interface.php b/lib/behat/classes/behat_session_interface.php
new file mode 100644 (file)
index 0000000..2746070
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The Interface for a behat root context.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * The Interface for a behat root context.
+ *
+ * This interface should be implemented by the behat_base context, and behat form fields, and it should be paired with
+ * the behat_session_trait.
+ *
+ * It should not be necessary to implement this interface, and the behat_session_trait trait in normal circumstances.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+interface behat_session_interface {
+    /**
+     * Small timeout.
+     *
+     * A reduced timeout for cases where self::TIMEOUT is too much
+     * and a simple $this->getSession()->getPage()->find() could not
+     * be enough.
+     *
+     * @deprecated since Moodle 3.7 MDL-64979 - please use get_reduced_timeout() instead
+     * @todo MDL-64982 This will be deleted in Moodle 3.11
+     * @see behat_base::get_reduced_timeout()
+     */
+    const REDUCED_TIMEOUT = 2;
+
+    /**
+     * The timeout for each Behat step (load page, wait for an element to load...).
+     *
+     * @deprecated since Moodle 3.7 MDL-64979 - please use get_timeout() instead
+     * @todo MDL-64982 This will be deleted in Moodle 3.11
+     * @see behat_base::get_timeout()
+     */
+    const TIMEOUT = 6;
+
+    /**
+     * And extended timeout for specific cases.
+     *
+     * @deprecated since Moodle 3.7 MDL-64979 - please use get_extended_timeout() instead
+     * @todo MDL-64982 This will be deleted in Moodle 3.11
+     * @see behat_base::get_extended_timeout()
+     */
+    const EXTENDED_TIMEOUT = 10;
+
+    /**
+     * The JS code to check that the page is ready.
+     *
+     * The document must be complete and either M.util.pending_js must be empty, or it must not be defined at all.
+     */
+    const PAGE_READY_JS = "document.readyState === 'complete' && " .
+        "(typeof M !== 'object' || typeof M.util !== 'object' || " .
+        "typeof M.util.pending_js === 'undefined' || M.util.pending_js.length === 0)";
+
+    /**
+     * Returns the Mink session.
+     *
+     * @param   string|null $name name of the session OR active session will be used
+     * @return  \Behat\Mink\Session
+     */
+    public function getSession($name = null);
+}
diff --git a/lib/behat/classes/behat_session_trait.php b/lib/behat/classes/behat_session_trait.php
new file mode 100644 (file)
index 0000000..5e9d3c0
--- /dev/null
@@ -0,0 +1,1312 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A trait containing functionality used by the behat base context, and form fields.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use Behat\Mink\Exception\DriverException;
+use Behat\Mink\Exception\ExpectationException;
+use Behat\Mink\Exception\ElementNotFoundException;
+use Behat\Mink\Element\NodeElement;
+use Behat\Mink\Element\Element;
+use Behat\Mink\Session;
+
+// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
+
+require_once(__DIR__ . '/component_named_replacement.php');
+require_once(__DIR__ . '/component_named_selector.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');
+
+/**
+ * A trait containing functionality used by the behat base context, and form fields.
+ *
+ * This trait should be used by the behat_base context, and behat form fields, and it should be paired with the
+ * behat_session_interface interface.
+ *
+ * It should not be necessary to use this trait, and the behat_session_interface interface in normal circumstances.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+trait behat_session_trait {
+
+    /**
+     * Locates url, based on provided path.
+     * Override to provide custom routing mechanism.
+     *
+     * @see Behat\MinkExtension\Context\MinkContext
+     * @param string $path
+     * @return string
+     */
+    protected function locate_path($path) {
+        $starturl = rtrim($this->getMinkParameter('base_url'), '/') . '/';
+        return 0 !== strpos($path, 'http') ? $starturl . ltrim($path, '/') : $path;
+    }
+
+    /**
+     * Returns the first matching element.
+     *
+     * @link http://mink.behat.org/#traverse-the-page-selectors
+     * @param string $selector The selector type (css, xpath, named...)
+     * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator...
+     * @param Exception $exception Otherwise we throw exception with generic info
+     * @param NodeElement $node Spins around certain DOM node instead of the whole page
+     * @param int $timeout Forces a specific time out (in seconds).
+     * @return NodeElement
+     */
+    protected function find($selector, $locator, $exception = false, $node = false, $timeout = false) {
+        if ($selector === 'NodeElement' && is_a($locator, NodeElement::class)) {
+            // Support a NodeElement being passed in for use in step chaining.
+            return $locator;
+        }
+
+        // Returns the first match.
+        $items = $this->find_all($selector, $locator, $exception, $node, $timeout);
+        return count($items) ? reset($items) : null;
+    }
+
+    /**
+     * Returns all matching elements.
+     *
+     * Adapter to Behat\Mink\Element\Element::findAll() using the spin() method.
+     *
+     * @link http://mink.behat.org/#traverse-the-page-selectors
+     * @param string $selector The selector type (css, xpath, named...)
+     * @param mixed $locator It depends on the $selector, can be the xpath, a name, a css locator...
+     * @param Exception $exception Otherwise we throw expcetion with generic info
+     * @param NodeElement $container Restrict the search to just children of the specified container
+     * @param int $timeout Forces a specific time out (in seconds). If 0 is provided the default timeout will be applied.
+     * @return array NodeElements list
+     */
+    protected function find_all($selector, $locator, $exception = false, $container = false, $timeout = false) {
+        // Throw exception, so dev knows it is not supported.
+        if ($selector === 'named') {
+            $exception = 'Using the "named" selector is deprecated as of 3.1. '
+                .' Use the "named_partial" or use the "named_exact" selector instead.';
+            throw new ExpectationException($exception, $this->getSession());
+        }
+
+        // Generic info.
+        if (!$exception) {
+            // With named selectors we can be more specific.
+            if (($selector == 'named_exact') || ($selector == 'named_partial')) {
+                $exceptiontype = $locator[0];
+                $exceptionlocator = $locator[1];
+
+                // If we are in a @javascript session all contents would be displayed as HTML characters.
+                if ($this->running_javascript()) {
+                    $locator[1] = html_entity_decode($locator[1], ENT_NOQUOTES);
+                }
+
+            } else {
+                $exceptiontype = $selector;
+                $exceptionlocator = $locator;
+            }
+
+            $exception = new ElementNotFoundException($this->getSession(), $exceptiontype, null, $exceptionlocator);
+        }
+
+        // How much we will be waiting for the element to appear.
+        if (!$timeout) {
+            $timeout = self::get_timeout();
+            $microsleep = false;
+        } else {
+            // Spinning each 0.1 seconds if the timeout was forced as we understand
+            // that is a special case and is good to refine the performance as much
+            // as possible.
+            $microsleep = true;
+        }
+
+        // Normalise the values in order to perform the search.
+        [
+            'selector' => $selector,
+            'locator' => $locator,
+            'container' => $container,
+        ] = $this->normalise_selector($selector, $locator, $container ?: $this->getSession()->getPage());
+
+        // Waits for the node to appear if it exists, otherwise will timeout and throw the provided exception.
+        return $this->spin(
+            function() use ($selector, $locator, $container) {
+                return $container->findAll($selector, $locator);
+            }, [], $timeout, $exception, $microsleep
+        );
+    }
+
+    /**
+     * Normalise the locator and selector.
+     *
+     * @param string $selector The type of thing to search
+     * @param mixed $locator The locator value. Can be an array, but is more likely a string.
+     * @param Element $container An optional container to search within
+     * @return array The selector, locator, and container to search within
+     */
+    public function normalise_selector(string $selector, $locator, Element $container): array {
+        // Check for specific transformations for this selector type.
+        $transformfunction = "transform_find_for_{$selector}";
+        if (method_exists('behat_selectors', $transformfunction)) {
+            // A selector-specific transformation exists.
+            // Perform initial transformation of the selector within the current container.
+            [
+                'selector' => $selector,
+                'locator' => $locator,
+                'container' => $container,
+            ] = behat_selectors::{$transformfunction}($this, $locator, $container);
+        }
+
+        // Normalise the css and xpath selector types.
+        if ('css_element' === $selector) {
+            $selector = 'css';
+        } else if ('xpath_element' === $selector) {
+            $selector = 'xpath';
+        }
+
+        // Convert to a named selector where the selector type is not a known selector.
+        $converttonamed = !$this->getSession()->getSelectorsHandler()->isSelectorRegistered($selector);
+        $converttonamed = $converttonamed && 'xpath' !== $selector;
+        if ($converttonamed) {
+            if (behat_partial_named_selector::is_deprecated_selector($selector)) {
+                if ($replacement = behat_partial_named_selector::get_deprecated_replacement($selector)) {
+                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
+                    $selector = $replacement;
+                }
+            } else if (behat_exact_named_selector::is_deprecated_selector($selector)) {
+                if ($replacement = behat_exact_named_selector::get_deprecated_replacement($selector)) {
+                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
+                    $selector = $replacement;
+                }
+            }
+
+            $allowedpartialselectors = behat_partial_named_selector::get_allowed_selectors();
+            $allowedexactselectors = behat_exact_named_selector::get_allowed_selectors();
+            if (isset($allowedpartialselectors[$selector])) {
+                $locator = behat_selectors::normalise_named_selector($allowedpartialselectors[$selector], $locator);
+                $selector = 'named_partial';
+            } else if (isset($allowedexactselectors[$selector])) {
+                $locator = behat_selectors::normalise_named_selector($allowedexactselectors[$selector], $locator);
+                $selector = 'named_exact';
+            } else {
+                throw new ExpectationException("The '{$selector}' selector type is not registered.", $this->getSession()->getDriver());
+            }
+        }
+
+        return [
+            'selector' => $selector,
+            'locator' => $locator,
+            'container' => $container,
+        ];
+    }
+
+    /**
+     * 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.
+     *
+     * The point of using this method instead of Mink ones is the spin
+     * method of behat_base::find() that looks for the element until it
+     * is available or it timeouts, this avoids the false failures received
+     * when selenium tries to execute commands on elements that are not
+     * ready to be used.
+     *
+     * All steps that requires elements to be available before interact with
+     * them should use one of the find* methods.
+     *
+     * The methods calls requires a {'find_' . $elementtype}($locator)
+     * format, like find_link($locator), find_select($locator),
+     * find_button($locator)...
+     *
+     * @link http://mink.behat.org/#named-selectors
+     * @throws coding_exception
+     * @param string $name The name of the called method
+     * @param mixed $arguments
+     * @return NodeElement
+     */
+    public function __call($name, $arguments) {
+        if (substr($name, 0, 5) === 'find_') {
+            return call_user_func_array([$this, 'find'], array_merge(
+                [substr($name, 5)],
+                $arguments
+            ));
+        }
+
+        throw new coding_exception("The '{$name}' method does not exist");
+    }
+
+    /**
+     * Escapes the double quote character.
+     *
+     * Double quote is the argument delimiter, it can be escaped
+     * with a backslash, but we auto-remove this backslashes
+     * before the step execution, this method is useful when using
+     * arguments as arguments for other steps.
+     *
+     * @param string $string
+     * @return string
+     */
+    public function escape($string) {
+        return str_replace('"', '\"', $string);
+    }
+
+    /**
+     * Executes the passed closure until returns true or time outs.
+     *
+     * In most cases the document.readyState === 'complete' will be enough, but sometimes JS
+     * requires more time to be completely loaded or an element to be visible or whatever is required to
+     * perform some action on an element; this method receives a closure which should contain the
+     * required statements to ensure the step definition actions and assertions have all their needs
+     * satisfied and executes it until they are satisfied or it timeouts. Redirects the return of the
+     * closure to the caller.
+     *
+     * The closures requirements to work well with this spin method are:
+     * - Must return false, null or '' if something goes wrong
+     * - Must return something != false if finishes as expected, this will be the (mixed) value
+     * returned by spin()
+     *
+     * The arguments of the closure are mixed, use $args depending on your needs.
+     *
+     * You can provide an exception to give more accurate feedback to tests writers, otherwise the
+     * closure exception will be used, but you must provide an exception if the closure does not throw
+     * an exception.
+     *
+     * @throws Exception If it timeouts without receiving something != false from the closure
+     * @param Function|array|string $lambda The function to execute or an array passed to call_user_func (maps to a class method)
+     * @param mixed $args Arguments to pass to the closure
+     * @param int $timeout Timeout in seconds
+     * @param Exception $exception The exception to throw in case it time outs.
+     * @param bool $microsleep If set to true it'll sleep micro seconds rather than seconds.
+     * @return mixed The value returned by the closure
+     */
+    protected function spin($lambda, $args = false, $timeout = false, $exception = false, $microsleep = false) {
+
+        // Using default timeout which is pretty high.
+        if (!$timeout) {
+            $timeout = self::get_timeout();
+        }
+
+        $start = microtime(true);
+        $end = $start + $timeout;
+
+        do {
+            // We catch the exception thrown by the step definition to execute it again.
+            try {
+                // We don't check with !== because most of the time closures will return
+                // direct Behat methods returns and we are not sure it will be always (bool)false
+                // if it just runs the behat method without returning anything $return == null.
+                if ($return = call_user_func($lambda, $this, $args)) {
+                    return $return;
+                }
+            } catch (Exception $e) {
+                // We would use the first closure exception if no exception has been provided.
+                if (!$exception) {
+                    $exception = $e;
+                }
+            }
+
+            if (!$this->running_javascript()) {
+                break;
+            }
+
+            usleep(100000);
+
+        } while (microtime(true) < $end);
+
+        // Using coding_exception as is a development issue if no exception has been provided.
+        if (!$exception) {
+            $exception = new coding_exception('spin method requires an exception if the callback does not throw an exception');
+        }
+
+        // Throwing exception to the user.
+        throw $exception;
+    }
+
+    /**
+     * Gets a NodeElement based on the locator and selector type received as argument from steps definitions.
+     *
+     * Use behat_base::get_text_selector_node() for text-based selectors.
+     *
+     * @throws ElementNotFoundException Thrown by behat_base::find
+     * @param string $selectortype
+     * @param string $element
+     * @return NodeElement
+     */
+    protected function get_selected_node($selectortype, $element) {
+        return $this->find($selectortype, $element);
+    }
+
+    /**
+     * Gets a NodeElement based on the locator and selector type received as argument from steps definitions.
+     *
+     * @throws ElementNotFoundException Thrown by behat_base::find
+     * @param string $selectortype
+     * @param string $element
+     * @return NodeElement
+     */
+    protected function get_text_selector_node($selectortype, $element) {
+        // Getting Mink selector and locator.
+        list($selector, $locator) = $this->transform_text_selector($selectortype, $element);
+
+        // Returns the NodeElement.
+        return $this->find($selector, $locator);
+    }
+
+    /**
+     * Gets the requested element inside the specified container.
+     *
+     * @throws ElementNotFoundException Thrown by behat_base::find
+     * @param mixed $selectortype The element selector type.
+     * @param mixed $element The element locator.
+     * @param mixed $containerselectortype The container selector type.
+     * @param mixed $containerelement The container locator.
+     * @return NodeElement
+     */
+    protected function get_node_in_container($selectortype, $element, $containerselectortype, $containerelement) {
+        if ($containerselectortype === 'NodeElement' && is_a($containerelement, NodeElement::class)) {
+            // Support a NodeElement being passed in for use in step chaining.
+            $containernode = $containerelement;
+            $locatorexceptionmsg = $element;
+        } else {
+            // Gets the container, it will always be text based.
+            $containernode = $this->get_text_selector_node($containerselectortype, $containerelement);
+            $locatorexceptionmsg = $element . '" in the "' . $containerelement. '" "' . $containerselectortype. '"';
+        }
+
+        $exception = new ElementNotFoundException($this->getSession(), $selectortype, null, $locatorexceptionmsg);
+
+        return $this->find($selectortype, $element, $exception, $containernode);
+    }
+
+    /**
+     * Transforms from step definition's argument style to Mink format.
+     *
+     * Mink has 3 different selectors css, xpath and named, where named
+     * selectors includes link, button, field... to simplify and group multiple
+     * steps in one we use the same interface, considering all link, buttons...
+     * at the same level as css selectors and xpath; this method makes the
+     * conversion from the arguments received by the steps to the selectors and locators
+     * required to interact with Mink.
+     *
+     * @throws ExpectationException
+     * @param string $selectortype It can be css, xpath or any of the named selectors.
+     * @param string $element The locator (or string) we are looking for.
+     * @return array Contains the selector and the locator expected by Mink.
+     */
+    protected function transform_selector($selectortype, $element) {
+        // Here we don't know if an allowed text selector is being used.
+        $selectors = behat_selectors::get_allowed_selectors();
+        if (!isset($selectors[$selectortype])) {
+            throw new ExpectationException('The "' . $selectortype . '" selector type does not exist', $this->getSession());
+        }
+
+        [
+            'selector' => $selector,
+            'locator' => $locator,
+        ] = $this->normalise_selector($selectortype, $element, $this->getSession()->getPage());
+
+        return [$selector, $locator];
+    }
+
+    /**
+     * Transforms from step definition's argument style to Mink format.
+     *
+     * Delegates all the process to behat_base::transform_selector() checking
+     * the provided $selectortype.
+     *
+     * @throws ExpectationException
+     * @param string $selectortype It can be css, xpath or any of the named selectors.
+     * @param string $element The locator (or string) we are looking for.
+     * @return array Contains the selector and the locator expected by Mink.
+     */
+    protected function transform_text_selector($selectortype, $element) {
+
+        $selectors = behat_selectors::get_allowed_text_selectors();
+        if (empty($selectors[$selectortype])) {
+            throw new ExpectationException('The "' . $selectortype . '" selector can not be used to select text nodes', $this->getSession());
+        }
+
+        return $this->transform_selector($selectortype, $element);
+    }
+
+    /**
+     * Whether Javascript is available in the current Session.
+     *
+     * @return boolean
+     */
+    protected function running_javascript() {
+        return self::running_javascript_in_session($this->getSession());
+    }
+
+    /**
+     * Require that javascript be available in the current Session.
+     *
+     * @throws DriverException
+     */
+    protected function require_javascript() {
+        return self::require_javascript_in_session($this->getSession());
+    }
+
+    /**
+     * Whether Javascript is available in the specified Session.
+     *
+     * @param Session $session
+     * @return boolean
+     */
+    protected static function running_javascript_in_session(Session $session): bool {
+        return get_class($session->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver';
+    }
+
+    /**
+     * Require that javascript be available for the specified Session.
+     *
+     * @param Session $session
+     * @throws DriverException
+     */
+    protected static function require_javascript_in_session(Session $session): void {
+        if (self::running_javascript_in_session($session)) {
+            return;
+        }
+
+        throw new DriverException('Javascript is required');
+    }
+
+    /**
+     * Checks if the current page is part of the mobile app.
+     *
+     * @return bool True if it's in the app
+     */
+    protected function is_in_app() : bool {
+        // Cannot be in the app if there's no @app tag on scenario.
+        if (!$this->has_tag('app')) {
+            return false;
+        }
+
+        // Check on page to see if it's an app page. Safest way is to look for added JavaScript.
+        return $this->evaluate_script('return typeof window.behat') === 'object';
+    }
+
+    /**
+     * Spins around an element until it exists
+     *
+     * @throws ExpectationException
+     * @param string $locator
+     * @param string $selectortype
+     * @return void
+     */
+    protected function ensure_element_exists($locator, $selectortype) {
+        // Exception if it timesout and the element is still there.
+        $msg = "The '{$locator}' element does not exist and should";
+        $exception = new ExpectationException($msg, $this->getSession());
+
+        // Normalise the values in order to perform the search.
+        [
+            'selector' => $selector,
+            'locator' => $locator,
+            'container' => $container,
+        ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage());
+
+        // It will stop spinning once the find() method returns true.
+        $this->spin(
+            function() use ($selector, $locator, $container) {
+                if ($container->find($selector, $locator)) {
+                    return true;
+                }
+                return false;
+            },
+            [],
+            self::get_extended_timeout(),
+            $exception,
+            true
+        );
+    }
+
+    /**
+     * Spins until the element does not exist
+     *
+     * @throws ExpectationException
+     * @param string $locator
+     * @param string $selectortype
+     * @return void
+     */
+    protected function ensure_element_does_not_exist($locator, $selectortype) {
+        // Exception if it timesout and the element is still there.
+        $msg = "The '{$locator}' element exists and should not exist";
+        $exception = new ExpectationException($msg, $this->getSession());
+
+        // Normalise the values in order to perform the search.
+        [
+            'selector' => $selector,
+            'locator' => $locator,
+            'container' => $container,
+        ] = $this->normalise_selector($selectortype, $locator, $this->getSession()->getPage());
+
+        // It will stop spinning once the find() method returns false.
+        $this->spin(
+            function() use ($selector, $locator, $container) {
+                if ($container->find($selector, $locator)) {
+                    return false;
+                }
+                return true;
+            },
+            // Note: We cannot use $this because the find will then be $this->find(), which leads us to a nested spin().
+            // We cannot nest spins because the outer spin times out before the inner spin completes.
+            [],
+            self::get_extended_timeout(),
+            $exception,
+            true
+        );
+    }
+
+    /**
+     * Ensures that the provided node is visible and we can interact with it.
+     *
+     * @throws ExpectationException
+     * @param NodeElement $node
+     * @return void Throws an exception if it times out without the element being visible
+     */
+    protected function ensure_node_is_visible($node) {
+
+        if (!$this->running_javascript()) {
+            return;
+        }
+
+        // Exception if it timesout and the element is still there.
+        $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible';
+        $exception = new ExpectationException($msg, $this->getSession());
+
+        // It will stop spinning once the isVisible() method returns true.
+        $this->spin(
+            function($context, $args) {
+                if ($args->isVisible()) {
+                    return true;
+                }
+                return false;
+            },
+            $node,
+            self::get_extended_timeout(),
+            $exception,
+            true
+        );
+    }
+
+    /**
+     * Ensures that the provided node has a attribute value set. This step can be used to check if specific
+     * JS has finished modifying the node.
+     *
+     * @throws ExpectationException
+     * @param NodeElement $node
+     * @param string $attribute attribute name
+     * @param string $attributevalue attribute value to check.
+     * @return void Throws an exception if it times out without the element being visible
+     */
+    protected function ensure_node_attribute_is_set($node, $attribute, $attributevalue) {
+
+        if (!$this->running_javascript()) {
+            return;
+        }
+
+        // Exception if it timesout and the element is still there.
+        $msg = 'The "' . $node->getXPath() . '" xpath node is not visible and it should be visible';
+        $exception = new ExpectationException($msg, $this->getSession());
+
+        // It will stop spinning once the $args[1]) == $args[2], and method returns true.
+        $this->spin(
+            function($context, $args) {
+                if ($args[0]->getAttribute($args[1]) == $args[2]) {
+                    return true;
+                }
+                return false;
+            },
+            array($node, $attribute, $attributevalue),
+            self::get_extended_timeout(),
+            $exception,
+            true
+        );
+    }
+
+    /**
+     * Ensures that the provided element is visible and we can interact with it.
+     *
+     * Returns the node in case other actions are interested in using it.
+     *
+     * @throws ExpectationException
+     * @param string $element
+     * @param string $selectortype
+     * @return NodeElement Throws an exception if it times out without being visible
+     */
+    protected function ensure_element_is_visible($element, $selectortype) {
+
+        if (!$this->running_javascript()) {
+            return;
+        }
+
+        $node = $this->get_selected_node($selectortype, $element);
+        $this->ensure_node_is_visible($node);
+
+        return $node;
+    }
+
+    /**
+     * Ensures that all the page's editors are loaded.
+     *
+     * @deprecated since Moodle 2.7 MDL-44084 - please do not use this function any more.
+     * @throws ElementNotFoundException
+     * @throws ExpectationException
+     * @return void
+     */
+    protected function ensure_editors_are_loaded() {
+        global $CFG;
+
+        if (empty($CFG->behat_usedeprecated)) {
+            debugging('Function behat_base::ensure_editors_are_loaded() is deprecated. It is no longer required.');
+        }
+        return;
+    }
+
+    /**
+     * Checks if the current scenario, or its feature, has a specified tag.
+     *
+     * @param string $tag Tag to check
+     * @return bool True if the tag exists in scenario or feature
+     */
+    public function has_tag(string $tag) : bool {
+        return array_key_exists($tag, behat_hooks::get_tags_for_scenario());
+    }
+
+    /**
+     * Change browser window size.
+     *   - small: 640x480
+     *   - medium: 1024x768
+     *   - large: 2560x1600
+     *
+     * @param string $windowsize size of window.
+     * @param bool $viewport If true, changes viewport rather than window size
+     * @throws ExpectationException
+     */
+    protected function resize_window($windowsize, $viewport = false) {
+        // Non JS don't support resize window.
+        if (!$this->running_javascript()) {
+            return;
+        }
+
+        switch ($windowsize) {
+            case "small":
+                $width = 1024;
+                $height = 768;
+                break;
+            case "medium":
+                $width = 1366;
+                $height = 768;
+                break;
+            case "large":
+                $width = 2560;
+                $height = 1600;
+                break;
+            default:
+                preg_match('/^(\d+x\d+)$/', $windowsize, $matches);
+                if (empty($matches) || (count($matches) != 2)) {
+                    throw new ExpectationException("Invalid screen size, can't resize", $this->getSession());
+                }
+                $size = explode('x', $windowsize);
+                $width = (int) $size[0];
+                $height = (int) $size[1];
+        }
+        if ($viewport) {
+            // When setting viewport size, we set it so that the document width will be exactly
+            // as specified, assuming that there is a vertical scrollbar. (In cases where there is
+            // no scrollbar it will be slightly wider. We presume this is rare and predictable.)
+            // The window inner height will be as specified, which means the available viewport will
+            // actually be smaller if there is a horizontal scrollbar. We assume that horizontal
+            // scrollbars are rare so this doesn't matter.
+            $js = <<<EOF
+return (function() {
+    var before = document.body.style.overflowY;
+    document.body.style.overflowY = "scroll";
+    var result = {};
+    result.x = window.outerWidth - document.body.offsetWidth;
+    result.y = window.outerHeight - window.innerHeight;
+    document.body.style.overflowY = before;
+    return result;
+})();
+EOF;
+            $offset = $this->evaluate_script($js);
+            $width += $offset['x'];
+            $height += $offset['y'];
+        }
+
+        $this->getSession()->getDriver()->resizeWindow($width, $height);
+    }
+
+    /**
+     * Waits for all the JS to be loaded.
+     *
+     * @return  bool Whether any JS is still pending completion.
+     */
+    public function wait_for_pending_js() {
+        return static::wait_for_pending_js_in_session($this->getSession());
+    }
+
+    /**
+     * Waits for all the JS to be loaded.
+     *
+     * @param   Session $session The Mink Session where JS can be run
+     * @return  bool Whether any JS is still pending completion.
+     */
+    public static function wait_for_pending_js_in_session(Session $session) {
+        if (!self::running_javascript_in_session($session)) {
+            // JS is not available therefore there is nothing to wait for.
+            return false;
+        }
+
+        // We don't use behat_base::spin() here as we don't want to end up with an exception
+        // if the page & JSs don't finish loading properly.
+        for ($i = 0; $i < self::get_extended_timeout() * 10; $i++) {
+            $pending = '';
+            try {
+                $jscode = trim(preg_replace('/\s+/', ' ', '
+                    return (function() {
+                        if (document.readyState !== "complete") {
+                            return "incomplete";
+                        }
+
+                        if (typeof M !== "object" || typeof M.util !== "object" || typeof M.util.pending_js === "undefined") {
+                            return "";
+                        }
+
+                        return M.util.pending_js.join(":");
+                    })()'));
+                $pending = self::evaluate_script_in_session($session, $jscode);
+            } catch (NoSuchWindow $nsw) {
+                // We catch an exception here, in case we just closed the window we were interacting with.
+                // No javascript is running if there is no window right?
+                $pending = '';
+            } catch (UnknownError $e) {
+                // M is not defined when the window or the frame don't exist anymore.
+                if (strstr($e->getMessage(), 'M is not defined') != false) {
+                    $pending = '';
+                }
+            }
+
+            // If there are no pending JS we stop waiting.
+            if ($pending === '') {
+                return true;
+            }
+
+            // 0.1 seconds.
+            usleep(100000);
+        }
+
+        // Timeout waiting for JS to complete. It will be caught and forwarded to behat_hooks::i_look_for_exceptions().
+        // It is unlikely that Javascript code of a page or an AJAX request needs more than get_extended_timeout() seconds
+        // to be loaded, although when pages contains Javascript errors M.util.js_complete() can not be executed, so the
+        // number of JS pending code and JS completed code will not match and we will reach this point.
+        throw new \Exception('Javascript code and/or AJAX requests are not ready after ' .
+                self::get_extended_timeout() .
+                ' seconds. There is a Javascript error or the code is extremely slow (' . $pending .
+                '). If you are using a slow machine, consider setting $CFG->behat_increasetimeout.');
+    }
+
+    /**
+     * Internal step definition to find exceptions, debugging() messages and PHP debug messages.
+     *
+     * Part of behat_hooks class as is part of the testing framework, is auto-executed
+     * after each step so no features will splicitly use it.
+     *
+     * @throws Exception Unknown type, depending on what we caught in the hook or basic \Exception.
+     * @see Moodle\BehatExtension\Tester\MoodleStepTester
+     */
+    public function look_for_exceptions() {
+        // Wrap in try in case we were interacting with a closed window.
+        try {
+
+            // Exceptions.
+            $exceptionsxpath = "//div[@data-rel='fatalerror']";
+            // Debugging messages.
+            $debuggingxpath = "//div[@data-rel='debugging']";
+            // PHP debug messages.
+            $phperrorxpath = "//div[@data-rel='phpdebugmessage']";
+            // Any other backtrace.
+            $othersxpath = "(//*[contains(., ': call to ')])[1]";
+
+            $xpaths = array($exceptionsxpath, $debuggingxpath, $phperrorxpath, $othersxpath);
+            $joinedxpath = implode(' | ', $xpaths);
+
+            // Joined xpath expression. Most of the time there will be no exceptions, so this pre-check
+            // is faster than to send the 4 xpath queries for each step.
+            if (!$this->getSession()->getDriver()->find($joinedxpath)) {
+                // Check if we have recorded any errors in driver process.
+                $phperrors = behat_get_shutdown_process_errors();
+                if (!empty($phperrors)) {
+                    foreach ($phperrors as $error) {
+                        $errnostring = behat_get_error_string($error['type']);
+                        $msgs[] = $errnostring . ": " .$error['message'] . " at " . $error['file'] . ": " . $error['line'];
+                    }
+                    $msg = "PHP errors found:\n" . implode("\n", $msgs);
+                    throw new \Exception(htmlentities($msg));
+                }
+
+                return;
+            }
+
+            // Exceptions.
+            if ($errormsg = $this->getSession()->getPage()->find('xpath', $exceptionsxpath)) {
+
+                // Getting the debugging info and the backtrace.
+                $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-error');
+                // If errorinfoboxes is empty, try find alert-danger (bootstrap4) class.
+                if (empty($errorinfoboxes)) {
+                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.alert-danger');
+                }
+                // If errorinfoboxes is empty, try find notifytiny (original) class.
+                if (empty($errorinfoboxes)) {
+                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.notifytiny');
+                }
+
+                // If errorinfoboxes is empty, try find ajax/JS exception in dialogue.
+                if (empty($errorinfoboxes)) {
+                    $errorinfoboxes = $this->getSession()->getPage()->findAll('css', 'div.moodle-exception-message');
+
+                    // If ajax/JS exception.
+                    if ($errorinfoboxes) {
+                        $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml());
+                    }
+
+                } else {
+                    $errorinfo = $this->get_debug_text($errorinfoboxes[0]->getHtml()) . "\n" .
+                        $this->get_debug_text($errorinfoboxes[1]->getHtml());
+                }
+
+                $msg = "Moodle exception: " . $errormsg->getText() . "\n" . $errorinfo;
+                throw new \Exception(html_entity_decode($msg));
+            }
+
+            // Debugging messages.
+            if ($debuggingmessages = $this->getSession()->getPage()->findAll('xpath', $debuggingxpath)) {
+                $msgs = array();
+                foreach ($debuggingmessages as $debuggingmessage) {
+                    $msgs[] = $this->get_debug_text($debuggingmessage->getHtml());
+                }
+                $msg = "debugging() message/s found:\n" . implode("\n", $msgs);
+                throw new \Exception(html_entity_decode($msg));
+            }
+
+            // PHP debug messages.
+            if ($phpmessages = $this->getSession()->getPage()->findAll('xpath', $phperrorxpath)) {
+
+                $msgs = array();
+                foreach ($phpmessages as $phpmessage) {
+                    $msgs[] = $this->get_debug_text($phpmessage->getHtml());
+                }
+                $msg = "PHP debug message/s found:\n" . implode("\n", $msgs);
+                throw new \Exception(html_entity_decode($msg));
+            }
+
+            // Any other backtrace.
+            // First looking through xpath as it is faster than get and parse the whole page contents,
+            // we get the contents and look for matches once we found something to suspect that there is a backtrace.
+            if ($this->getSession()->getDriver()->find($othersxpath)) {
+                $backtracespattern = '/(line [0-9]* of [^:]*: call to [\->&;:a-zA-Z_\x7f-\xff][\->&;:a-zA-Z0-9_\x7f-\xff]*)/';
+                if (preg_match_all($backtracespattern, $this->getSession()->getPage()->getContent(), $backtraces)) {
+                    $msgs = array();
+                    foreach ($backtraces[0] as $backtrace) {
+                        $msgs[] = $backtrace . '()';
+                    }
+                    $msg = "Other backtraces found:\n" . implode("\n", $msgs);
+                    throw new \Exception(htmlentities($msg));
+                }
+            }
+
+        } catch (NoSuchWindow $e) {
+            // If we were interacting with a popup window it will not exists after closing it.
+        } catch (DriverException $e) {
+            // Same reason as above.
+        }
+    }
+
+    /**
+     * Converts HTML tags to line breaks to display the info in CLI
+     *
+     * @param string $html
+     * @return string
+     */
+    protected function get_debug_text($html) {
+
+        // Replacing HTML tags for new lines and keeping only the text.
+        $notags = preg_replace('/<+\s*\/*\s*([A-Z][A-Z0-9]*)\b[^>]*\/*\s*>*/i', "\n", $html);
+        return preg_replace("/(\n)+/s", "\n", $notags);
+    }
+
+    /**
+     * Helper function to execute api in a given context.
+     *
+     * @param string $contextapi context in which api is defined.
+     * @param array $params list of params to pass.
+     * @throws Exception
+     */
+    protected function execute($contextapi, $params = array()) {
+        if (!is_array($params)) {
+            $params = array($params);
+        }
+
+        // Get required context and execute the api.
+        $contextapi = explode("::", $contextapi);
+        $context = behat_context_helper::get($contextapi[0]);
+        call_user_func_array(array($context, $contextapi[1]), $params);
+
+        // NOTE: Wait for pending js and look for exception are not optional, as this might lead to unexpected results.
+        // Don't make them optional for performance reasons.
+
+        // Wait for pending js.
+        $this->wait_for_pending_js();
+
+        // Look for exceptions.
+        $this->look_for_exceptions();
+    }
+
+    /**
+     * Get the actual user in the behat session (note $USER does not correspond to the behat session's user).
+     * @return mixed
+     * @throws coding_exception
+     */
+    protected function get_session_user() {
+        global $DB;
+
+        $sid = $this->getSession()->getCookie('MoodleSession');
+        if (empty($sid)) {
+            throw new coding_exception('failed to get moodle session');
+        }
+        $userid = $DB->get_field('sessions', 'userid', ['sid' => $sid]);
+        if (empty($userid)) {
+            throw new coding_exception('failed to get user from seession id '.$sid);
+        }
+        return $DB->get_record('user', ['id' => $userid]);
+    }
+
+    /**
+     * Set current $USER, reset access cache.
+     *
+     * In some cases, behat will execute the code as admin but in many cases we need to set an specific user as some
+     * API's might rely on the logged user to take some action.
+     *
+     * @param null|int|stdClass $user user record, null or 0 means non-logged-in, positive integer means userid
+     */
+    public static function set_user($user = null) {
+        global $DB;
+
+        if (is_object($user)) {
+            $user = clone($user);
+        } else if (!$user) {
+            // Assign valid data to admin user (some generator-related code needs a valid user).
+            $user = $DB->get_record('user', array('username' => 'admin'));
+        } else {
+            $user = $DB->get_record('user', array('id' => $user));
+        }
+        unset($user->description);
+        unset($user->access);
+        unset($user->preference);
+
+        // Ensure session is empty, as it may contain caches and user specific info.
+        \core\session\manager::init_empty_session();
+
+        \core\session\manager::set_user($user);
+    }
+    /**
+     * Trigger click on node via javascript instead of actually clicking on it via pointer.
+     *
+     * This function resolves the issue of nested elements with click listeners or links - in these cases clicking via
+     * the pointer may accidentally cause a click on the wrong element.
+     * Example of issue: clicking to expand navigation nodes when the config value linkadmincategories is enabled.
+     * @param NodeElement $node
+     */
+    protected function js_trigger_click($node) {
+        if (!$this->running_javascript()) {
+            $node->click();
+        }
+        $this->ensure_node_is_visible($node); // Ensures hidden elements can't be clicked.
+        $xpath = $node->getXpath();
+        $driver = $this->getSession()->getDriver();
+        if ($driver instanceof \Moodle\BehatExtension\Driver\MoodleSelenium2Driver) {
+            $script = "Syn.click({{ELEMENT}})";
+            $driver->triggerSynScript($xpath, $script);
+        } else {
+            $driver->click($xpath);
+        }
+    }
+
+    /**
+     * Convert page names to URLs for steps like 'When I am on the "[page name]" page'.
+     *
+     * You should override this as appropriate for your plugin. The method
+     * {@link behat_navigation::resolve_core_page_url()} is a good example.
+     *
+     * Your overridden method should document the recognised page types with
+     * a table like this:
+     *
+     * Recognised page names are:
+     * | Page            | Description                                                    |
+     *
+     * @param string $page name of the page, with the component name removed e.g. 'Admin notification'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_page_url(string $page): moodle_url {
+        throw new Exception('Component "' . get_class($this) .
+                '" does not support the generic \'When I am on the "' . $page .
+                '" page\' navigation step.');
+    }
+
+    /**
+     * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
+     *
+     * A typical example might be:
+     *     When I am on the "Test quiz" "mod_quiz > Responses report" page
+     * which would cause this method in behat_mod_quiz to be called with
+     * arguments 'Responses report', 'Test quiz'.
+     *
+     * You should override this as appropriate for your plugin. The method
+     * {@link behat_navigation::resolve_core_page_instance_url()} is a good example.
+     *
+     * Your overridden method should document the recognised page types with
+     * a table like this:
+     *
+     * Recognised page names are:
+     * | Type      | identifier meaning | Description                                     |
+     *
+     * @param string $type identifies which type of page this is, e.g. 'Attempt review'.
+     * @param string $identifier identifies the particular page, e.g. 'Test quiz > student > Attempt 1'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_page_instance_url(string $type, string $identifier): moodle_url {
+        throw new Exception('Component "' . get_class($this) .
+                '" does not support the generic \'When I am on the "' . $identifier .
+                '" "' . $type . '" page\' navigation step.');
+    }
+
+    /**
+     * Gets the required timeout in seconds.
+     *
+     * @param int $timeout One of the TIMEOUT constants
+     * @return int Actual timeout (in seconds)
+     */
+    protected static function get_real_timeout(int $timeout) : int {
+        global $CFG;
+        if (!empty($CFG->behat_increasetimeout)) {
+            return $timeout * $CFG->behat_increasetimeout;
+        } else {
+            return $timeout;
+        }
+    }
+
+    /**
+     * Gets the default timeout.
+     *
+     * The timeout for each Behat step (load page, wait for an element to load...).
+     *
+     * @return int Timeout in seconds
+     */
+    public static function get_timeout() : int {
+        return self::get_real_timeout(6);
+    }
+
+    /**
+     * Gets the reduced timeout.
+     *
+     * A reduced timeout for cases where self::get_timeout() is too much
+     * and a simple $this->getSession()->getPage()->find() could not
+     * be enough.
+     *
+     * @return int Timeout in seconds
+     */
+    public static function get_reduced_timeout() : int {
+        return self::get_real_timeout(2);
+    }
+
+    /**
+     * Gets the extended timeout.
+     *
+     * A longer timeout for cases where the normal timeout is not enough.
+     *
+     * @return int Timeout in seconds
+     */
+    public static function get_extended_timeout() : int {
+        return self::get_real_timeout(10);
+    }
+
+    /**
+     * Return a list of the exact named selectors for the component.
+     *
+     * Named selectors are what make Behat steps like
+     *   Then I should see "Useful text" in the "General" "fieldset"
+     * work. Here, "fieldset" is the named selector, and "General" is the locator.
+     *
+     * If you override this method in your plugin (e.g. mod_mymod), to define
+     * new selectors specific to your plugin. For example, if you returned
+     *   new behat_component_named_selector('Thingy',
+     *           [".//some/xpath//img[contains(@alt, %locator%)]/.."])
+     * then
+     *   Then I should see "Useful text" in the "Whatever" "mod_mymod > Thingy"
+     * would work.
+     *
+     * This method should return a list of {@link behat_component_named_selector} and
+     * the docs on that class explain how it works.
+     *
+     * @return behat_component_named_selector[]
+     */
+    public static function get_exact_named_selectors(): array {
+        return [];
+    }
+
+    /**
+     * Return a list of the partial named selectors for the component.
+     *
+     * Like the exact named selectors above, but the locator only
+     * needs to match part of the text. For example, the standard
+     * "button" is a partial selector, so:
+     *   When I click "Save" "button"
+     * will activate "Save changes".
+     *
+     * @return behat_component_named_selector[]
+     */
+    public static function get_partial_named_selectors(): array {
+        return [];
+    }
+
+    /**
+     * Return a list of the Mink named replacements for the component.
+     *
+     * Named replacements allow you to define parts of an xpath that can be reused multiple times, or in multiple
+     * xpaths.
+     *
+     * This method should return a list of {@link behat_component_named_replacement} and the docs on that class explain
+     * how it works.
+     *
+     * @return behat_component_named_replacement[]
+     */
+    public static function get_named_replacements(): array {
+        return [];
+    }
+
+    /**
+     * Evaluate the supplied script in the current session, returning the result.
+     *
+     * @param string $script
+     * @return mixed
+     */
+    public function evaluate_script(string $script) {
+        return self::evaluate_script_in_session($this->getSession(), $script);
+    }
+
+    /**
+     * Evaluate the supplied script in the specified session, returning the result.
+     *
+     * @param Session $session
+     * @param string $script
+     * @return mixed
+     */
+    public static function evaluate_script_in_session(Session $session, string $script) {
+        self::require_javascript_in_session($session);
+
+        return $session->evaluateScript($script);
+    }
+
+    /**
+     * Execute the supplied script in the current session.
+     *
+     * No result will be returned.
+     *
+     * @param string $script
+     */
+    public function execute_script(string $script): void {
+        self::execute_script_in_session($this->getSession(), $script);
+    }
+
+    /**
+     * Excecute the supplied script in the specified session.
+     *
+     * No result will be returned.
+     *
+     * @param Session $session
+     * @param string $script
+     */
+    public static function execute_script_in_session(Session $session, string $script): void {
+        self::require_javascript_in_session($session);
+
+        $session->executeScript($script);
+    }
+
+    /**
+     * Get the session key for the current session via Javascript.
+     *
+     * @return string
+     */
+    public function get_sesskey(): string {
+        $script = <<<EOF
+return (function() {
+if (M && M.cfg && M.cfg.sesskey) {
+    return M.cfg.sesskey;
+}
+return '';
+})()
+EOF;
+
+        return $this->evaluate_script($script);
+    }
+}
index 18c1526..74054ee 100644 (file)
@@ -135,7 +135,7 @@ class behat_partial_named_selector extends \Behat\Mink\Selector\PartialNamedSele
      */
     protected static $moodleselectors = array(
         'activity' => <<<XPATH
-.//li[contains(concat(' ', normalize-space(@class), ' '), ' activity ')][normalize-space(.) = %locator% ]
+.//li[contains(concat(' ', normalize-space(@class), ' '), ' activity ')][descendant::*[contains(normalize-space(.), %locator%)]]
 XPATH
         , 'block' => <<<XPATH
 .//*[@data-block][contains(concat(' ', normalize-space(@class), ' '), concat(' ', %locator%, ' ')) or
@@ -262,6 +262,11 @@ XPATH
 .//*[@data-passwordunmask='wrapper']
     /descendant::input[@id = %locator% or @id = //label[contains(normalize-space(string(.)), %locator%)]/@for]
 XPATH
+        ,
+             'inplaceeditable' => <<<XPATH
+.//descendant::span[@data-inplaceeditable][descendant::a[%titleMatch%]]
+XPATH
+        ,
         ],
     ];
 
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 ff85a2f..c2aeee7 100644 (file)
@@ -25,8 +25,8 @@
 
 // NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
 
-use Behat\Mink\Session as Session,
-    Behat\Mink\Element\NodeElement as NodeElement;
+use Behat\Mink\Element\NodeElement;
+use Behat\Mink\Session;
 
 /**
  * Representation of a form field.
@@ -38,7 +38,10 @@ use Behat\Mink\Session as Session,
  * @copyright  2012 David Monllaó
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class behat_form_field {
+class behat_form_field implements behat_session_interface {
+
+    // All of the functionality of behat_base is shared with form fields via the behat_session_trait trait.
+    use behat_session_trait;
 
     /**
      * @var Session Behat session.
@@ -55,6 +58,16 @@ class behat_form_field {
      */
     protected $fieldlocator = false;
 
+    /**
+     * Returns the Mink session.
+     *
+     * @param   string|null $name name of the session OR active session will be used
+     * @return  \Behat\Mink\Session
+     */
+    public function getSession($name = null) {
+        return $this->session;
+    }
+
 
     /**
      * General constructor with the node and the session to interact with.
diff --git a/lib/behat/form_field/behat_form_inplaceeditable.php b/lib/behat/form_field/behat_form_inplaceeditable.php
new file mode 100644 (file)
index 0000000..57cc8b7
--- /dev/null
@@ -0,0 +1,74 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Custom interaction with inplace editable elements.
+ *
+ * @package    core_form
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+// NOTE: no MOODLE_INTERNAL test here, this file may be required by behat before including /config.php.
+
+require_once(__DIR__  . '/behat_form_text.php');
+
+/**
+ * Custom interaction with inplace editable elements.
+ *
+ * @package    core_form
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_form_inplaceeditable extends behat_form_text {
+    /**
+     * Sets the value to a field.
+     *
+     * @param string $value
+     * @return void
+     */
+    public function set_value($value) {
+        // Require JS to run this step.
+        self::require_javascript();
+
+        // Click to enable editing.
+        self::execute(
+            'behat_general::i_click_on_in_the',
+            [
+                '[data-inplaceeditablelink]',
+                'css_element',
+                $this->field,
+                'NodeElement',
+            ]
+        );
+
+        // Note: It is not possible to use the NodeElement->keyDown() and related functions because
+        // this can trigger a focusOnElement call each time.
+        // Instead use the behat_base::type_keys() function.
+
+        // The inplace editable selects all existing content on focus.
+        // Clear the existing value.
+        self::type_keys($this->session, [behat_keys::BACKSPACE]);
+
+        // Type in the new value, followed by ENTER to save the value.
+        self::type_keys($this->session, array_merge(
+            str_split($value),
+            [behat_keys::ENTER]
+        ));
+    }
+}
index 61ee51e..1855238 100644 (file)
@@ -40,6 +40,19 @@ defined('MOODLE_INTERNAL') || die();
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class redis extends handler {
+    /**
+     * Compressor: none.
+     */
+    const COMPRESSION_NONE      = 'none';
+    /**
+     * Compressor: PHP GZip.
+     */
+    const COMPRESSION_GZIP      = 'gzip';
+    /**
+     * Compressor: PHP Zstandard.
+     */
+    const COMPRESSION_ZSTD      = 'zstd';
+
     /** @var string $host save_path string  */
     protected $host = '';
     /** @var int $port The port to connect to */
@@ -56,6 +69,8 @@ class redis extends handler {
     protected $lockretry = 100;
     /** @var int $serializer The serializer to use */
     protected $serializer = \Redis::SERIALIZER_PHP;
+    /** @var int $compressor The compressor to use */
+    protected $compressor = self::COMPRESSION_NONE;
     /** @var string $lasthash hash of the session data content */
     protected $lasthash = null;
 
@@ -122,6 +137,10 @@ class redis extends handler {
         if (isset($CFG->session_redis_lock_expire)) {
             $this->lockexpire = (int)$CFG->session_redis_lock_expire;
         }
+
+        if (isset($CFG->session_redis_compressor)) {
+            $this->compressor = $CFG->session_redis_compressor;
+        }
     }
 
     /**
@@ -268,7 +287,8 @@ class redis extends handler {
             if ($this->requires_write_lock()) {
                 $this->lock_session($id);
             }
-            $sessiondata = $this->connection->get($id);
+            $sessiondata = $this->uncompress($this->connection->get($id));
+
             if ($sessiondata === false) {
                 if ($this->requires_write_lock()) {
                     $this->unlock_session($id);
@@ -285,6 +305,53 @@ class redis extends handler {
         return $sessiondata;
     }
 
+    /**
+     * Compresses session data.
+     *
+     * @param mixed $value
+     * @return string
+     */
+    private function compress($value) {
+        switch ($this->compressor) {
+            case self::COMPRESSION_NONE:
+                return $value;
+            case self::COMPRESSION_GZIP:
+                return gzencode($value);
+            case self::COMPRESSION_ZSTD:
+                return zstd_compress($value);
+            default:
+                debugging("Invalid compressor: {$this->compressor}");
+                return $value;
+        }
+    }
+
+    /**
+     * Uncompresses session data.
+     *
+     * @param string $value
+     * @return mixed
+     */
+    private function uncompress($value) {
+        if ($value === false) {
+            return false;
+        }
+
+        switch ($this->compressor) {
+            case self::COMPRESSION_NONE:
+                break;
+            case self::COMPRESSION_GZIP:
+                $value = gzdecode($value);
+                break;
+            case self::COMPRESSION_ZSTD:
+                $value = zstd_uncompress($value);
+                break;
+            default:
+                debugging("Invalid compressor: {$this->compressor}");
+        }
+
+        return $value;
+    }
+
     /**
      * Write the serialized session data to our session store.
      *
@@ -312,6 +379,8 @@ class redis extends handler {
         // There can be race conditions on new sessions racing each other but we can
         // address that in the future.
         try {
+            $data = $this->compress($data);
+
             $this->connection->setex($id, $this->timeout, $data);
         } catch (RedisException $e) {
             error_log('Failed talking to redis: '.$e->getMessage());
index 08b6242..559c68f 100644 (file)
@@ -67,7 +67,7 @@ class manager {
 
         foreach ($tasks as $task) {
             $record = (object) $task;
-            $scheduledtask = self::scheduled_task_from_record($record, $expandr);
+            $scheduledtask = self::scheduled_task_from_record($record, $expandr, false);
             // Safety check in case the task in the DB does not match a real class (maybe something was uninstalled).
             if ($scheduledtask) {
                 $scheduledtask->set_component($componentname);
@@ -338,9 +338,10 @@ class manager {
      * @param \stdClass $record
      * @param bool $expandr - if true (default) an 'R' value in a time is expanded to an appropriate int.
      *      If false, they are left as 'R'
+     * @param bool $override - if true loads overridden settings from config.
      * @return \core\task\scheduled_task|false
      */
-    public static function scheduled_task_from_record($record, $expandr = true) {
+    public static function scheduled_task_from_record($record, $expandr = true, $override = true) {
         $classname = self::get_canonical_class_name($record->classname);
         if (!class_exists($classname)) {
             debugging("Failed to load task: " . $classname, DEBUG_DEVELOPER);
@@ -348,6 +349,12 @@ class manager {
         }
         /** @var \core\task\scheduled_task $task */
         $task = new $classname;
+
+        if ($override) {
+            // Update values with those defined in the config, if any are set.
+            $record = self::get_record_with_config_overrides($record);
+        }
+
         if (isset($record->lastruntime)) {
             $task->set_last_run_time($record->lastruntime);
         }
@@ -391,6 +398,7 @@ class manager {
         if (isset($record->pid)) {
             $task->set_pid($record->pid);
         }
+        $task->set_overridden(self::scheduled_task_has_override($classname));
 
         return $task;
     }
@@ -701,10 +709,12 @@ class manager {
                     }
                 }
 
-                // Make sure the task data is unchanged.
-                if (!$DB->record_exists('task_scheduled', (array) $record)) {
-                    $lock->release();
-                    continue;
+                if (!self::scheduled_task_has_override($record->classname)) {
+                    // Make sure the task data is unchanged unless an override is being used.
+                    if (!$DB->record_exists('task_scheduled', (array)$record)) {
+                        $lock->release();
+                        continue;
+                    }
                 }
 
                 // The global cron lock is under the most contention so request it
@@ -1106,4 +1116,91 @@ class manager {
 
         return true;
     }
+
+    /**
<