Merge branch 'MDL-64856_m37v1' of git://github.com/sbourget/moodle
authorAdrian Greeve <abgreeve@gmail.com>
Wed, 20 Feb 2019 17:01:53 +0000 (18:01 +0100)
committerAdrian Greeve <abgreeve@gmail.com>
Wed, 20 Feb 2019 17:01:53 +0000 (18:01 +0100)
214 files changed:
.gitignore
admin/tool/analytics/classes/output/invalid_analysables.php
admin/tool/analytics/classes/output/models_list.php
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/templates/invalid_analysables.mustache
admin/tool/behat/cli/util.php
admin/tool/behat/lang/en/tool_behat.php
admin/tool/behat/tests/behat/manipulate_forms.feature
admin/tool/filetypes/edit_form.php
admin/tool/langimport/classes/locale.php [new file with mode: 0644]
admin/tool/langimport/index.php
admin/tool/langimport/lang/en/tool_langimport.php
admin/tool/langimport/tests/locale_test.php [new file with mode: 0644]
admin/tool/lp/classes/external.php
admin/tool/lp/classes/external/user_competency_summary_in_course_exporter.php
admin/tool/lp/classes/output/course_competencies_page.php
admin/tool/lp/templates/course_competencies_page.mustache
admin/tool/lp/templates/user_competency_summary_in_course.mustache
admin/tool/lp/tests/behat/plan_crud.feature
admin/tool/uploadcourse/classes/base_form.php
admin/tool/uploadcourse/classes/step2_form.php
admin/tool/uploaduser/user_form.php
admin/tool/usertours/classes/local/target/block.php
admin/tool/usertours/classes/local/target/selector.php
admin/tool/usertours/classes/local/target/unattached.php
admin/tool/xmldb/actions/add_persistent_mandatory/add_persistent_mandatory.class.php [new file with mode: 0644]
admin/tool/xmldb/actions/edit_table/edit_table.class.php
admin/tool/xmldb/lang/en/tool_xmldb.php
auth/mnet/classes/privacy/provider.php
auth/oauth2/classes/privacy/provider.php
auth/tests/behat/behat_auth.php
blocks/login/block_login.php
cache/classes/loaders.php
cache/tests/cache_test.php
cache/tests/fixtures/lib.php
competency/classes/api.php
competency/tests/api_test.php
completion/classes/privacy/provider.php
completion/tests/privacy_test.php
composer.json
config-dist.php
course/classes/deletecategory_form.php
course/completion.js
course/lib.php
course/modlib.php
course/moodleform_mod.php
course/renderer.php
course/tests/behat/app_courselist.feature [new file with mode: 0644]
course/tests/behat/view_subfolders_inline.feature
enrol/locallib.php
enrol/manual/amd/build/form-potential-user-selector.min.js
enrol/manual/amd/src/form-potential-user-selector.js
enrol/manual/classes/enrol_users_form.php
enrol/manual/tests/behat/quickenrolment.feature
enrol/renderer.php
enrol/tests/course_enrolment_manager_test.php
enrol/upgrade.txt
enrol/yui/otherusersmanager/otherusersmanager.js
install/lang/hr/admin.php
install/lang/ja/install.php
lang/en/competency.php
lang/en/enrol.php
lib/amd/build/checkbox-toggleall.min.js [new file with mode: 0644]
lib/amd/build/storagewrapper.min.js
lib/amd/src/checkbox-toggleall.js [new file with mode: 0644]
lib/amd/src/storagewrapper.js
lib/behat/behat_base.php
lib/behat/classes/behat_command.php
lib/behat/classes/behat_config_util.php
lib/behat/form_field/behat_form_field.php
lib/classes/analytics/analyser/courses.php
lib/classes/analytics/analyser/site_courses.php
lib/db/messages.php
lib/db/upgrade.php
lib/editor/atto/plugins/media/tests/behat/media.feature
lib/form/amd/build/showadvanced.min.js [new file with mode: 0644]
lib/form/amd/src/showadvanced.js [new file with mode: 0644]
lib/form/modgrade.php
lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-debug.js [deleted file]
lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-min.js [deleted file]
lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced.js [deleted file]
lib/form/yui/src/showadvanced/build.json [deleted file]
lib/form/yui/src/showadvanced/js/showadvanced.js [deleted file]
lib/form/yui/src/showadvanced/meta/showadvanced.json [deleted file]
lib/formslib.php
lib/moodlelib.php
lib/navigationlib.php
lib/outputrenderers.php
lib/outputrequirementslib.php
lib/phpunit/classes/arraydataset.php
lib/requirejs/moodle-config.js
lib/templates/loginform.mustache
lib/templates/url_select.mustache
lib/tests/behat/app_behat_runtime.js [new file with mode: 0644]
lib/tests/behat/behat_app.php [new file with mode: 0644]
lib/tests/behat/behat_hooks.php
lib/tests/moodlelib_test.php
lib/upgrade.txt
login/change_password_form.php
login/forgot_password_form.php
message/amd/build/notification_processor_settings.min.js
message/amd/build/preferences_notifications_list_controller.min.js
message/amd/build/preferences_processor_form.min.js
message/amd/src/notification_processor_settings.js
message/amd/src/preferences_notifications_list_controller.js
message/amd/src/preferences_processor_form.js
message/classes/api.php
message/pendingcontactrequests.php [deleted file]
message/templates/preferences_processor.mustache
message/tests/privacy_provider_test.php
mod/assign/classes/event/base.php
mod/assign/locallib.php
mod/assign/submission/file/locallib.php
mod/assign/submission/onlinetext/locallib.php
mod/assign/tests/events_test.php
mod/book/edit.php
mod/book/edit_form.php
mod/book/lang/en/book.php
mod/book/locallib.php
mod/book/tests/behat/create_chapters.feature
mod/book/tests/behat/reorganize_chapters.feature
mod/book/tests/behat/show_hide_chapters.feature
mod/chat/lib.php
mod/chat/tests/lib_test.php
mod/forum/index.php
mod/forum/lib.php
mod/forum/post.php
mod/forum/tests/behat/app_basic_usage.feature [new file with mode: 0644]
mod/forum/tests/behat/posts_ordering_general.feature
mod/forum/tests/lib_test.php
mod/lesson/lib.php
mod/lesson/locallib.php
mod/lesson/tests/lib_test.php
mod/lesson/tests/locallib_test.php
mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php
mod/quiz/amd/build/repaginate.min.js [new file with mode: 0644]
mod/quiz/amd/src/repaginate.js [new file with mode: 0644]
mod/quiz/attemptlib.php
mod/quiz/classes/output/edit_renderer.php
mod/quiz/lib.php
mod/quiz/tests/attempt_test.php
mod/quiz/tests/lib_test.php
mod/quiz/yui/build/moodle-mod_quiz-repaginate/moodle-mod_quiz-repaginate-debug.js [deleted file]
mod/quiz/yui/build/moodle-mod_quiz-repaginate/moodle-mod_quiz-repaginate-min.js [deleted file]
mod/quiz/yui/build/moodle-mod_quiz-repaginate/moodle-mod_quiz-repaginate.js [deleted file]
mod/quiz/yui/src/repaginate/build.json [deleted file]
mod/quiz/yui/src/repaginate/js/repaginate.js [deleted file]
mod/quiz/yui/src/repaginate/meta/repaginate.json [deleted file]
privacy/classes/local/request/moodle_content_writer.php
question/amd/build/qbankmanager.min.js [new file with mode: 0644]
question/amd/src/qbankmanager.js [new file with mode: 0644]
question/behaviour/adaptive/tests/walkthrough_test.php
question/behaviour/behaviourbase.php
question/behaviour/interactivecountback/tests/walkthrough_test.php
question/behaviour/manualgraded/tests/walkthrough_test.php
question/classes/bank/checkbox_column.php
question/classes/bank/view.php
question/engine/lib.php
question/engine/tests/helpers.php
question/engine/tests/questionutils_test.php
question/tests/behat/select_questions.feature [new file with mode: 0644]
question/type/ddimageortext/amd/build/question.min.js
question/type/ddimageortext/amd/src/question.js
question/type/ddimageortext/edit_ddimageortext_form.php
question/type/ddmarker/amd/build/question.min.js
question/type/ddmarker/amd/src/question.js
question/type/ddwtos/tests/behat/edit.feature
question/type/essay/backup/moodle2/restore_qtype_essay_plugin.class.php
question/type/essay/tests/restore_test.php [new file with mode: 0644]
question/type/gapselect/edit_form_base.php
question/type/gapselect/lang/en/qtype_gapselect.php
question/type/gapselect/renderer.php
question/type/gapselect/tests/walkthrough_test.php
question/type/match/tests/walkthrough_test.php
question/type/multianswer/tests/walkthrough_test.php
question/type/numerical/tests/walkthrough_test.php
question/type/randomsamatch/tests/walkthrough_test.php
question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager-debug.js [deleted file]
question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager-min.js [deleted file]
question/yui/build/moodle-question-qbankmanager/moodle-question-qbankmanager.js [deleted file]
question/yui/src/qbankmanager/build.json [deleted file]
question/yui/src/qbankmanager/js/qbankmanager.js [deleted file]
question/yui/src/qbankmanager/meta/qbankmanager.json [deleted file]
report/completion/index.php
report/insights/templates/insight.mustache
report/insights/templates/insight_details.mustache
report/insights/templates/insights_list.mustache
report/progress/index.php
repository/equella/lib.php
theme/boost/classes/output/core_renderer.php
theme/boost/layout/columns2.php
theme/boost/scss/moodle/forms.scss
theme/boost/style/moodle.css
theme/boost/templates/columns1.mustache
theme/boost/templates/columns2.mustache
theme/boost/templates/core/help_icon.mustache
theme/boost/templates/core/loginform.mustache
theme/boost/templates/core/navbar.mustache
theme/boost/templates/core_form/element-password.mustache
theme/boost/templates/flat_navigation.mustache
theme/boost/templates/footer.mustache
theme/boost/templates/login.mustache
theme/boost/templates/maintenance.mustache
theme/boost/templates/navbar-secure.mustache
theme/boost/templates/secure.mustache
theme/bootstrapbase/less/moodle/forms.less
theme/bootstrapbase/style/moodle.css
user/editadvanced_form.php
user/editlib.php
user/language_form.php
user/lib.php
user/tests/behat/behat_user.php
user/tests/behat/input-purpose.feature [new file with mode: 0644]
version.php

index 1dab0f7..b96359f 100644 (file)
@@ -9,10 +9,18 @@
 #
 # See gitignore(5) man page for more details
 #
+
+# Swap files (vim)
+[._]*.s[a-v][a-z]
+[._]*.sw[a-p]
+[._]s[a-rt-v][a-z]
+[._]ss[a-gi-z]
+[._]sw[a-p]
+# Temporary files including undo
+*~
+#
 /config.php
 /lib/editor/tinymce/extra/tools/temp/
-*~
-*.swp
 /tags
 /TAGS
 /cscope.*
index a5bda09..0bb3902 100644 (file)
@@ -153,6 +153,12 @@ class invalid_analysables implements \renderable, \templatable {
             $data->analysables[] = $obj;
         }
 
+        if (empty($data->analysables)) {
+            $data->noanalysables = [
+                'message' => get_string('noinvalidanalysables', 'tool_analytics'),
+                'announce' => true,
+            ];
+        }
         return $data;
     }
 }
index cbd0a60..83e2755 100644 (file)
@@ -247,7 +247,7 @@ class models_list implements \renderable, \templatable {
             }
 
             // Clear model.
-            if (!empty($predictioncontexts)) {
+            if (!empty($predictioncontexts) || $model->is_trained()) {
                 $actionid = 'clear-' . $model->get_id();
                 $PAGE->requires->js_call_amd('tool_analytics/model', 'confirmAction', [$actionid, 'clear']);
                 $urlparams['action'] = 'clear';
index 8df2cc3..1c3bba2 100644 (file)
@@ -75,6 +75,7 @@ $string['nextpage'] = 'Next page';
 $string['nodatatoevaluate'] = 'There is no data to evaluate the model';
 $string['nodatatopredict'] = 'No new elements to get predictions for';
 $string['nodatatotrain'] = 'There is no new data that can be used for training';
+$string['noinvalidanalysables'] = 'This site does not contain any invalid analysable element.';
 $string['notdefined'] = 'Not yet defined';
 $string['pluginname'] = 'Analytic models';
 $string['predictionresults'] = 'Prediction results';
index c97dd6b..2b3ddd6 100644 (file)
 <div class="box">
     <h3>{{#str}}modelinvalidanalysables, tool_analytics, {{modelname}}{{/str}}</h3>
     <div>{{#str}}invalidanalysablesinfo, tool_analytics{{/str}}</div>
-    <div class="m-t-2 m-b-1">
-        <span>{{#prev}}{{> core/single_button}}{{/prev}}</span>
-        <span>{{#next}}{{> core/single_button}}{{/next}}</span>
-    </div>
-    <table class="generaltable fullwidth">
-        <caption class="accesshide">{{#str}}invalidanalysablestable, tool_analytics{{/str}}</caption>
-        <thead>
-            <tr>
-                <th scope="col">{{#str}}name{{/str}}</th>
-                <th scope="col">{{#str}}invalidtraining, tool_analytics{{/str}}</th>
-                <th scope="col">{{#str}}invalidprediction, tool_analytics{{/str}}</th>
-            </tr>
-        </thead>
-        <tbody>
-        {{#analysables}}
-            <tr>
-                <td>{{{url}}}</td>
-                <td>{{validtraining}}</td>
-                <td>{{validprediction}}</td>
-            </tr>
-        {{/analysables}}
-        </tbody>
-    </table>
-    <div class="m-t-1 m-b-2">
-        <span>{{#prev}}{{> core/single_button}}{{/prev}}</span>
-        <span>{{#next}}{{> core/single_button}}{{/next}}</span>
-    </div>
+    {{#noanalysables}}
+        <div class="m-t-2 m-b-1">
+            {{> core/notification_info}}
+        </div>
+    {{/noanalysables}}
+    {{^noanalysables}}
+        <div class="m-t-2 m-b-1">
+            <span>{{#prev}}{{> core/single_button}}{{/prev}}</span>
+            <span>{{#next}}{{> core/single_button}}{{/next}}</span>
+        </div>
+        <table class="generaltable fullwidth">
+            <caption class="accesshide">{{#str}}invalidanalysablestable, tool_analytics{{/str}}</caption>
+            <thead>
+                <tr>
+                    <th scope="col">{{#str}}name{{/str}}</th>
+                    <th scope="col">{{#str}}invalidtraining, tool_analytics{{/str}}</th>
+                    <th scope="col">{{#str}}invalidprediction, tool_analytics{{/str}}</th>
+                </tr>
+            </thead>
+            <tbody>
+            {{#analysables}}
+                <tr>
+                    <td>{{{url}}}</td>
+                    <td>{{validtraining}}</td>
+                    <td>{{validprediction}}</td>
+                </tr>
+            {{/analysables}}
+            </tbody>
+        </table>
+        <div class="m-t-1 m-b-2">
+            <span>{{#prev}}{{> core/single_button}}{{/prev}}</span>
+            <span>{{#next}}{{> core/single_button}}{{/next}}</span>
+        </div>
+    {{/noanalysables}}
 </div>
index e88125b..83a19af 100644 (file)
@@ -109,6 +109,17 @@ require_once(__DIR__ . '/../../../../lib/behat/lib.php');
 require_once(__DIR__ . '/../../../../lib/behat/classes/behat_command.php');
 require_once(__DIR__ . '/../../../../lib/behat/classes/behat_config_manager.php');
 
+// Remove error handling overrides done in config.php. This is consistent with admin/tool/behat/cli/util_single_run.php.
+$CFG->debug = (E_ALL | E_STRICT);
+$CFG->debugdisplay = 1;
+error_reporting($CFG->debug);
+ini_set('display_errors', '1');
+ini_set('log_errors', '1');
+
+// Import the necessary libraries.
+require_once($CFG->libdir . '/setuplib.php');
+require_once($CFG->libdir . '/behat/classes/util.php');
+
 // For drop option check if parallel site.
 if ((empty($options['parallel'])) && ($options['drop']) || $options['updatesteps']) {
     $options['parallel'] = behat_config_manager::get_behat_run_config_value('parallel');
index f2cd279..88e3365 100644 (file)
@@ -24,6 +24,7 @@
 
 $string['aim'] = 'This administration tool helps developers and test writers to create .feature files describing Moodle\'s functionalities and run them automatically. Step definitions available for use in .feature files are listed below.';
 $string['allavailablesteps'] = 'All available step definitions';
+$string['errorapproot'] = '$CFG->behat_ionic_dirroot is not pointing to a valid Moodle Mobile developer install.';
 $string['errorbehatcommand'] = 'Error running behat CLI command. Try running "{$a} --help" manually from CLI to find out more about the problem.';
 $string['errorcomposer'] = 'Composer dependencies are not installed.';
 $string['errordataroot'] = '$CFG->behat_dataroot is not set or is invalid.';
index 85f52de..8e4f776 100644 (file)
@@ -28,6 +28,6 @@ Feature: Forms manipulation
     When I expand all fieldsets
     Then I should see "Close the quiz"
     And I should see "Group mode"
-    And I should see "Grouping"
+    And I should see "ID number"
     And I should not see "Show more..." in the "region-main" "region"
     And I should see "Show less..."
index f2c60aa..ed94462 100644 (file)
@@ -65,12 +65,12 @@ class tool_filetypes_form extends moodleform {
         $mform->addElement('text', 'description',  get_string('description', 'tool_filetypes'));
         $mform->setType('description', PARAM_TEXT);
         $mform->addHelpButton('description', 'description', 'tool_filetypes');
-        $mform->disabledIf('description', 'descriptiontype', 'ne', 'custom');
+        $mform->hideIf('description', 'descriptiontype', 'ne', 'custom');
 
         $mform->addElement('text', 'corestring',  get_string('corestring', 'tool_filetypes'));
         $mform->setType('corestring', PARAM_ALPHANUMEXT);
         $mform->addHelpButton('corestring', 'corestring', 'tool_filetypes');
-        $mform->disabledIf('corestring', 'descriptiontype', 'ne', 'lang');
+        $mform->hideIf('corestring', 'descriptiontype', 'ne', 'lang');
 
         $mform->addElement('checkbox', 'defaulticon',  get_string('defaulticon', 'tool_filetypes'));
         $mform->addHelpButton('defaulticon', 'defaulticon', 'tool_filetypes');
diff --git a/admin/tool/langimport/classes/locale.php b/admin/tool/langimport/classes/locale.php
new file mode 100644 (file)
index 0000000..69c43b6
--- /dev/null
@@ -0,0 +1,84 @@
+<?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/>.
+
+/**
+ * Helper class for the language import tool.
+ *
+ * @package    tool_langimport
+ * @copyright  2018 Université Rennes 2 {@link https://www.univ-rennes2.fr}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_langimport;
+
+use coding_exception;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Helper class for the language import tool.
+ *
+ * @copyright  2018 Université Rennes 2 {@link https://www.univ-rennes2.fr}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class locale {
+    /**
+     * Checks availability of locale on current operating system.
+     *
+     * @param string $langpackcode E.g.: en, es, fr, de.
+     * @return bool TRUE if the locale is available on OS.
+     * @throws coding_exception when $langpackcode parameter is a non-empty string.
+     */
+    public function check_locale_availability(string $langpackcode) : bool {
+        global $CFG;
+
+        if (empty($langpackcode)) {
+            throw new coding_exception('Invalid language pack code in \\'.__METHOD__.'() call, only non-empty string is allowed');
+        }
+
+        // Fetch the correct locale based on ostype.
+        if ($CFG->ostype === 'WINDOWS') {
+            $stringtofetch = 'localewin';
+        } else {
+            $stringtofetch = 'locale';
+        }
+
+        // Store current locale.
+        $currentlocale = $this->set_locale(LC_ALL, 0);
+
+        $locale = get_string_manager()->get_string($stringtofetch, 'langconfig', $a = null, $langpackcode);
+
+        // Try to set new locale.
+        $return = $this->set_locale(LC_ALL, $locale);
+
+        // Restore current locale.
+        $this->set_locale(LC_ALL, $currentlocale);
+
+        // If $return is not equal to false, it means that setlocale() succeed to change locale.
+        return $return !== false;
+    }
+
+    /**
+     * Wrap for the native PHP function setlocale().
+     *
+     * @param int $category Specifying the category of the functions affected by the locale setting.
+     * @param string $locale E.g.: en_AU.utf8, en_GB.utf8, es_ES.utf8, fr_FR.utf8, de_DE.utf8.
+     * @return string|false Returns the new current locale, or FALSE on error.
+     */
+    protected function set_locale(int $category = LC_ALL, string $locale = '0') {
+        return setlocale($category, $locale);
+    }
+}
index 92a09da..1180abc 100644 (file)
@@ -109,9 +109,16 @@ echo $OUTPUT->header();
 echo $OUTPUT->heading(get_string('langimport', 'tool_langimport'));
 
 $installedlangs = get_string_manager()->get_list_of_translations(true);
+$locale = new \tool_langimport\locale();
 
+$missinglocales = '';
 $missingparents = array();
-foreach ($installedlangs as $installedlang => $unused) {
+foreach ($installedlangs as $installedlang => $langpackname) {
+    // Check locale availability.
+    if (!$locale->check_locale_availability($installedlang)) {
+        $missinglocales .= '<li>'.$langpackname.'</li>';
+    }
+
     $parent = get_parent_language($installedlang);
     if (empty($parent)) {
         continue;
@@ -121,6 +128,14 @@ foreach ($installedlangs as $installedlang => $unused) {
     }
 }
 
+if (!empty($missinglocales)) {
+    // There is at least one missing locale.
+    $a = new stdClass();
+    $a->globallocale = moodle_getlocale();
+    $a->missinglocales = $missinglocales;
+    $controller->errors[] = get_string('langunsupported', 'tool_langimport', $a);
+}
+
 if ($availablelangs = $controller->availablelangs) {
     $remote = true;
 } else {
index faeb02c..27739ed 100644 (file)
@@ -37,6 +37,7 @@ $string['langpackupdateskipped'] = 'Update of \'{$a}\' language pack skipped';
 $string['langpackuptodate'] = 'Language pack \'{$a}\' is up-to-date';
 $string['langpackupdated'] = 'Language pack \'{$a}\' was successfully updated';
 $string['langpackupdatedevent'] = 'Language pack updated';
+$string['langunsupported'] = '<p>Your server does not seem to fully support the following languages:</p><ul>{$a->missinglocales}</ul><p>Instead, the global locale ({$a->globallocale}) will be used to format certain strings such as dates or numbers.</p>';
 $string['langupdatecomplete'] = 'Language pack update completed';
 $string['missingcfglangotherroot'] = 'Missing configuration value $CFG->langotherroot';
 $string['missinglangparent'] = 'Missing parent language <em>{$a->parent}</em> of <em>{$a->lang}</em>.';
diff --git a/admin/tool/langimport/tests/locale_test.php b/admin/tool/langimport/tests/locale_test.php
new file mode 100644 (file)
index 0000000..4d1ffef
--- /dev/null
@@ -0,0 +1,71 @@
+<?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/>.
+
+/**
+ * Tests for \tool_langimport\locale class.
+ *
+ * @package    tool_langimport
+ * @copyright  2018 Université Rennes 2 {@link https://www.univ-rennes2.fr}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Tests for \tool_langimport\locale class.
+ *
+ * @copyright  2018 Université Rennes 2 {@link https://www.univ-rennes2.fr}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class locale_testcase extends \advanced_testcase {
+    /**
+     * Test that \tool_langimport\locale::check_locale_availability() works as expected.
+     *
+     * @return void
+     */
+    public function test_check_locale_availability() {
+        // Create a mock of set_locale() method to simulate :
+        // - first setlocale() call which backup current locale
+        // - second setlocale() call which try to set new 'es' locale
+        // - third setlocale() call which restore locale.
+        $mock = $this->getMockBuilder(\tool_langimport\locale::class)
+            ->setMethods(['set_locale'])
+            ->getMock();
+        $mock->method('set_locale')->will($this->onConsecutiveCalls('en', 'es', 'en'));
+
+        // Test what happen when locale is available on system.
+        $result = $mock->check_locale_availability('en');
+        $this->assertTrue($result);
+
+        // Create a mock of set_locale() method to simulate :
+        // - first setlocale() call which backup current locale
+        // - second setlocale() call which fail to set new locale
+        // - third setlocale() call which restore locale.
+        $mock = $this->getMockBuilder(\tool_langimport\locale::class)
+            ->setMethods(['set_locale'])
+            ->getMock();
+        $mock->method('set_locale')->will($this->onConsecutiveCalls('en', false, 'en'));
+
+        // Test what happen when locale is not available on system.
+        $result = $mock->check_locale_availability('en');
+        $this->assertFalse($result);
+
+        // Test an invalid parameter.
+        $locale = new \tool_langimport\locale();
+        $this->expectException(coding_exception::class);
+        $locale->check_locale_availability('');
+    }
+}
index 77cf893..f798e7d 100644 (file)
@@ -420,6 +420,9 @@ class external extends external_api {
                     ))
                 ),
                 'comppath' => competency_path_exporter::get_read_structure(),
+                'plans' => new external_multiple_structure(
+                    plan_exporter::get_read_structure()
+                ),
             ))),
             'manageurl' => new external_value(PARAM_LOCALURL, 'Url to the manage competencies page.'),
         ));
index d1b7b7f..78ea24a 100644 (file)
@@ -26,11 +26,13 @@ defined('MOODLE_INTERNAL') || die();
 
 use core_competency\api;
 use core_competency\user_competency;
+use core_competency\external\plan_exporter;
 use core_course\external\course_module_summary_exporter;
 use core_course\external\course_summary_exporter;
 use context_course;
 use renderer_base;
 use stdClass;
+use moodle_url;
 
 /**
  * Class for exporting user competency data with additional related data in a plan.
@@ -62,7 +64,14 @@ class user_competency_summary_in_course_exporter extends \core\external\exporter
             'coursemodules' => array(
                 'type' => course_module_summary_exporter::read_properties_definition(),
                 'multiple' => true
-            )
+            ),
+            'plans' => array(
+                'type' => plan_exporter::read_properties_definition(),
+                'multiple' => true
+            ),
+            'pluginbaseurl' => [
+                'type' => PARAM_URL
+            ],
         );
     }
 
@@ -95,6 +104,16 @@ class user_competency_summary_in_course_exporter extends \core\external\exporter
         }
         $result->coursemodules = $exportedmodules;
 
+        // User learning plans.
+        $plans = api::list_plans_with_competency($this->related['user']->id, $this->related['competency']);
+        $exportedplans = array();
+        foreach ($plans as $plan) {
+            $planexporter = new plan_exporter($plan, array('template' => $plan->get_template()));
+            $exportedplans[] = $planexporter->export($output);
+        }
+        $result->plans = $exportedplans;
+        $result->pluginbaseurl = (new moodle_url('/admin/tool/lp'))->out(true);
+
         return (array) $result;
     }
 }
index eaf7d34..218d8a5 100644 (file)
@@ -41,6 +41,7 @@ use core_competency\external\course_competency_exporter;
 use core_competency\external\course_competency_settings_exporter;
 use core_competency\external\user_competency_course_exporter;
 use core_competency\external\user_competency_exporter;
+use core_competency\external\plan_exporter;
 use tool_lp\external\competency_path_exporter;
 use tool_lp\external\course_competency_statistics_exporter;
 use core_course\external\course_module_summary_exporter;
@@ -113,6 +114,7 @@ class course_competencies_page implements renderable, templatable {
         $data->courseid = $this->courseid;
         $data->pagecontextid = $this->context->id;
         $data->competencies = array();
+        $data->pluginbaseurl = (new moodle_url('/admin/tool/lp'))->out(true);
 
         $gradable = is_enrolled($this->context, $USER, 'moodle/competency:coursecompetencygradable');
         if ($gradable) {
@@ -154,12 +156,21 @@ class course_competencies_page implements renderable, templatable {
                 'context' => $context
             ]);
 
+            // User learning plans.
+            $plans = api::list_plans_with_competency($USER->id, $competency);
+            $exportedplans = array();
+            foreach ($plans as $plan) {
+                $planexporter = new plan_exporter($plan, array('template' => $plan->get_template()));
+                $exportedplans[] = $planexporter->export($output);
+            }
+
             $onerow = array(
                 'competency' => $compexporter->export($output),
                 'coursecompetency' => $ccexporter->export($output),
                 'ruleoutcomeoptions' => $ccoutcomeoptions,
                 'coursemodules' => $exportedmodules,
-                'comppath' => $pathexporter->export($output)
+                'comppath' => $pathexporter->export($output),
+                'plans' => $exportedplans
             );
             if ($gradable) {
                 $foundusercompetencycourse = false;
index eaa0f43..6d89b20 100644 (file)
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
+    @template tool_lp/course_competencies_page
+
     Course competencies template.
 
     For a full list of the context for this template see the course_competencies_page renderable.
+
+    This template includes ajax functionality, so it cannot be shown in the template library.
 }}
 <div data-region="coursecompetenciespage">
     <div data-region="actions" class="clearfix">
@@ -66,7 +70,7 @@
         <div class="clearfix"></div>
         {{/canmanagecoursecompetencies}}
         {{#competency}}
-            <a href="{{pluginbaseurl}}user_competency_in_course.php?courseid={{courseid}}&competencyid={{competency.id}}&userid={{gradableuserid}}"
+            <a href="{{pluginbaseurl}}/user_competency_in_course.php?courseid={{courseid}}&competencyid={{competency.id}}&userid={{gradableuserid}}"
                    id="competency-info-link-{{competency.id}}"
                    title="{{#str}}viewdetails, tool_lp{{/str}}">
                 <p><strong>{{{competency.shortname}}} <em>{{competency.idnumber}}</em></strong></p>
         {{/canmanagecoursecompetencies}}
         <div data-region="coursecompetencyactivities">
         <p>
-        <ul class="inline list-inline">
+        <strong>{{#str}}activities{{/str}}</strong>
+        <ul class="inline list-inline p-2">
         {{#coursemodules}}
             <li class="list-inline-item"><a href="{{url}}"><img src="{{iconurl}}"> {{name}} </a></li>
         {{/coursemodules}}
         {{^coursemodules}}
-            <li class="list-inline-item"><span class="alert">{{#str}}noactivities, tool_lp{{/str}}</span></li>
+            <li class="list-inline-item">{{#str}}noactivities, tool_lp{{/str}}</li>
         {{/coursemodules}}
         </ul>
         </p>
         </div>
+        <div data-region="learningplans">
+        <p>
+        <strong>{{#str}}userplans, core_competency{{/str}}</strong>
+        <ul class="inline list-inline p-2">
+        {{#plans}}
+            <li class="list-inline-item"><a href="{{pluginbaseurl}}/plan.php?id={{id}}">{{{name}}}</a></li>
+        {{/plans}}
+        {{^plans}}
+            <li class="list-inline-item">{{#str}}nouserplanswithcompetency, core_competency{{/str}}</li>
+        {{/plans}}
+        </ul>
+        </p>
+        </div>
     </td>
     </tr>
 {{/competencies}}
index 22cf96d..ce80bbb 100644 (file)
         </dd>
         {{/user}}
         {{/displayuser}}
+        <dt>{{#str}}userplans, competency{{/str}}</dt>
+        <dd>
+        <p>
+        <ul class="inline list-inline">
+        {{#plans}}
+            <li class="list-inline-item"><a href="{{pluginbaseurl}}/plan.php?id={{id}}">{{{name}}}</a></li>
+        {{/plans}}
+        {{^plans}}
+            <li>{{#str}}nouserplanswithcompetency, competency{{/str}}</li>
+        {{/plans}}
+        </ul>
+        </p>
+        </dd>
         {{#usercompetencycourse}}
         <dt>{{#str}}proficient, tool_lp{{/str}}</dt>
         <dd>
index d2ca869..0fbbfd1 100644 (file)
@@ -130,3 +130,35 @@ Feature: Manage plearning plan
     When I click on "Delete" "button" in the "Confirm" "dialogue"
     And I wait until the page is ready
     Then I should not see "Science plan Year-4"
+
+  Scenario: See a learning plan from a course
+    Given the following lp "plans" exist:
+      | name | user | description |
+      | Science plan Year-manage | admin | science plan description |
+    And the following lp "frameworks" exist:
+      | shortname | idnumber |
+      | Framework 1 | sc-y-2 |
+    And the following lp "competencies" exist:
+      | shortname | framework |
+      | comp1 | sc-y-2 |
+      | comp2 | sc-y-2 |
+    And I follow "Learning plans"
+    And I should see "Science plan Year-manage"
+    And I follow "Science plan Year-manage"
+    And I should see "Add competency"
+    And I press "Add competency"
+    And "Competency picker" "dialogue" should be visible
+    And I select "comp1" of the competency tree
+    When I click on "Add" "button" in the "Competency picker" "dialogue"
+    Then "comp1" "table_row" should exist
+    And I create a course with:
+      | Course full name | New course fullname |
+      | Course short name | New course shortname |
+    And I follow "New course fullname"
+    And I follow "Competencies"
+    And I press "Add competencies to course"
+    And "Competency picker" "dialogue" should be visible
+    And I select "comp1" of the competency tree
+    And I click on "Add" "button" in the "Competency picker" "dialogue"
+    And I should see "Learning plans"
+    And I should see "Science plan Year-manage"
index 868fe6a..38aa6be 100644 (file)
@@ -72,26 +72,26 @@ class tool_uploadcourse_base_form extends moodleform {
         );
         $mform->addElement('select', 'options[updatemode]', get_string('updatemode', 'tool_uploadcourse'), $choices);
         $mform->setDefault('options[updatemode]', tool_uploadcourse_processor::UPDATE_NOTHING);
-        $mform->disabledIf('options[updatemode]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_NEW);
-        $mform->disabledIf('options[updatemode]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_ALL);
+        $mform->hideIf('options[updatemode]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_NEW);
+        $mform->hideIf('options[updatemode]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_ALL);
         $mform->addHelpButton('options[updatemode]', 'updatemode', 'tool_uploadcourse');
 
         $mform->addElement('selectyesno', 'options[allowdeletes]', get_string('allowdeletes', 'tool_uploadcourse'));
         $mform->setDefault('options[allowdeletes]', 0);
-        $mform->disabledIf('options[allowdeletes]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_NEW);
-        $mform->disabledIf('options[allowdeletes]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_ALL);
+        $mform->hideIf('options[allowdeletes]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_NEW);
+        $mform->hideIf('options[allowdeletes]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_ALL);
         $mform->addHelpButton('options[allowdeletes]', 'allowdeletes', 'tool_uploadcourse');
 
         $mform->addElement('selectyesno', 'options[allowrenames]', get_string('allowrenames', 'tool_uploadcourse'));
         $mform->setDefault('options[allowrenames]', 0);
-        $mform->disabledIf('options[allowrenames]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_NEW);
-        $mform->disabledIf('options[allowrenames]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_ALL);
+        $mform->hideIf('options[allowrenames]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_NEW);
+        $mform->hideIf('options[allowrenames]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_ALL);
         $mform->addHelpButton('options[allowrenames]', 'allowrenames', 'tool_uploadcourse');
 
         $mform->addElement('selectyesno', 'options[allowresets]', get_string('allowresets', 'tool_uploadcourse'));
         $mform->setDefault('options[allowresets]', 0);
-        $mform->disabledIf('options[allowresets]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_NEW);
-        $mform->disabledIf('options[allowresets]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_ALL);
+        $mform->hideIf('options[allowresets]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_NEW);
+        $mform->hideIf('options[allowresets]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_ALL);
         $mform->addHelpButton('options[allowresets]', 'allowresets', 'tool_uploadcourse');
     }
 
index 89ee15d..58c39d4 100644 (file)
@@ -57,8 +57,8 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
             'maxlength="100" size="20"');
         $mform->setType('options[shortnametemplate]', PARAM_RAW);
         $mform->addHelpButton('options[shortnametemplate]', 'shortnametemplate', 'tool_uploadcourse');
-        $mform->disabledIf('options[shortnametemplate]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_OR_UPDATE);
-        $mform->disabledIf('options[shortnametemplate]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_UPDATE_ONLY);
+        $mform->hideIf('options[shortnametemplate]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_OR_UPDATE);
+        $mform->hideIf('options[shortnametemplate]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_UPDATE_ONLY);
 
         // Restore file is not in the array options on purpose, because formslib can't handle it!
         $contextid = $this->_customdata['contextid'];
@@ -73,8 +73,8 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
 
         $mform->addElement('selectyesno', 'options[reset]', get_string('reset', 'tool_uploadcourse'));
         $mform->setDefault('options[reset]', 0);
-        $mform->disabledIf('options[reset]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_NEW);
-        $mform->disabledIf('options[reset]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_ALL);
+        $mform->hideIf('options[reset]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_NEW);
+        $mform->hideIf('options[reset]', 'options[mode]', 'eq', tool_uploadcourse_processor::MODE_CREATE_ALL);
         $mform->disabledIf('options[reset]', 'options[allowresets]', 'eq', 0);
         $mform->addHelpButton('options[reset]', 'reset', 'tool_uploadcourse');
 
index c0eb764..558f324 100644 (file)
@@ -95,7 +95,7 @@ class admin_uploaduser_form2 extends moodleform {
         $choices = array(0 => get_string('infilefield', 'auth'), 1 => get_string('createpasswordifneeded', 'auth'));
         $mform->addElement('select', 'uupasswordnew', get_string('uupasswordnew', 'tool_uploaduser'), $choices);
         $mform->setDefault('uupasswordnew', 1);
-        $mform->disabledIf('uupasswordnew', 'uutype', 'eq', UU_USER_UPDATE);
+        $mform->hideIf('uupasswordnew', 'uutype', 'eq', UU_USER_UPDATE);
 
         $choices = array(UU_UPDATE_NOCHANGES    => get_string('nochanges', 'tool_uploaduser'),
                          UU_UPDATE_FILEOVERRIDE => get_string('uuupdatefromfile', 'tool_uploaduser'),
@@ -103,16 +103,16 @@ class admin_uploaduser_form2 extends moodleform {
                          UU_UPDATE_MISSING      => get_string('uuupdatemissing', 'tool_uploaduser'));
         $mform->addElement('select', 'uuupdatetype', get_string('uuupdatetype', 'tool_uploaduser'), $choices);
         $mform->setDefault('uuupdatetype', UU_UPDATE_NOCHANGES);
-        $mform->disabledIf('uuupdatetype', 'uutype', 'eq', UU_USER_ADDNEW);
-        $mform->disabledIf('uuupdatetype', 'uutype', 'eq', UU_USER_ADDINC);
+        $mform->hideIf('uuupdatetype', 'uutype', 'eq', UU_USER_ADDNEW);
+        $mform->hideIf('uuupdatetype', 'uutype', 'eq', UU_USER_ADDINC);
 
         $choices = array(0 => get_string('nochanges', 'tool_uploaduser'), 1 => get_string('update'));
         $mform->addElement('select', 'uupasswordold', get_string('uupasswordold', 'tool_uploaduser'), $choices);
         $mform->setDefault('uupasswordold', 0);
-        $mform->disabledIf('uupasswordold', 'uutype', 'eq', UU_USER_ADDNEW);
-        $mform->disabledIf('uupasswordold', 'uutype', 'eq', UU_USER_ADDINC);
-        $mform->disabledIf('uupasswordold', 'uuupdatetype', 'eq', 0);
-        $mform->disabledIf('uupasswordold', 'uuupdatetype', 'eq', 3);
+        $mform->hideIf('uupasswordold', 'uutype', 'eq', UU_USER_ADDNEW);
+        $mform->hideIf('uupasswordold', 'uutype', 'eq', UU_USER_ADDINC);
+        $mform->hideIf('uupasswordold', 'uuupdatetype', 'eq', 0);
+        $mform->hideIf('uupasswordold', 'uuupdatetype', 'eq', 3);
 
         $choices = array(UU_PWRESET_WEAK => get_string('usersweakpassword', 'tool_uploaduser'),
                          UU_PWRESET_NONE => get_string('none'),
@@ -125,18 +125,18 @@ class admin_uploaduser_form2 extends moodleform {
 
         $mform->addElement('selectyesno', 'uuallowrenames', get_string('allowrenames', 'tool_uploaduser'));
         $mform->setDefault('uuallowrenames', 0);
-        $mform->disabledIf('uuallowrenames', 'uutype', 'eq', UU_USER_ADDNEW);
-        $mform->disabledIf('uuallowrenames', 'uutype', 'eq', UU_USER_ADDINC);
+        $mform->hideIf('uuallowrenames', 'uutype', 'eq', UU_USER_ADDNEW);
+        $mform->hideIf('uuallowrenames', 'uutype', 'eq', UU_USER_ADDINC);
 
         $mform->addElement('selectyesno', 'uuallowdeletes', get_string('allowdeletes', 'tool_uploaduser'));
         $mform->setDefault('uuallowdeletes', 0);
-        $mform->disabledIf('uuallowdeletes', 'uutype', 'eq', UU_USER_ADDNEW);
-        $mform->disabledIf('uuallowdeletes', 'uutype', 'eq', UU_USER_ADDINC);
+        $mform->hideIf('uuallowdeletes', 'uutype', 'eq', UU_USER_ADDNEW);
+        $mform->hideIf('uuallowdeletes', 'uutype', 'eq', UU_USER_ADDINC);
 
         $mform->addElement('selectyesno', 'uuallowsuspends', get_string('allowsuspends', 'tool_uploaduser'));
         $mform->setDefault('uuallowsuspends', 1);
-        $mform->disabledIf('uuallowsuspends', 'uutype', 'eq', UU_USER_ADDNEW);
-        $mform->disabledIf('uuallowsuspends', 'uutype', 'eq', UU_USER_ADDINC);
+        $mform->hideIf('uuallowsuspends', 'uutype', 'eq', UU_USER_ADDNEW);
+        $mform->hideIf('uuallowsuspends', 'uutype', 'eq', UU_USER_ADDINC);
 
         if (!empty($CFG->allowaccountssameemail)) {
             $mform->addElement('selectyesno', 'uunoemailduplicates', get_string('uunoemailduplicates', 'tool_uploaduser'));
@@ -209,14 +209,14 @@ class admin_uploaduser_form2 extends moodleform {
         $mform->addElement('text', 'username', get_string('uuusernametemplate', 'tool_uploaduser'), 'size="20"');
         $mform->setType('username', PARAM_RAW); // No cleaning here. The process verifies it later.
         $mform->addRule('username', get_string('requiredtemplate', 'tool_uploaduser'), 'required', null, 'client');
-        $mform->disabledIf('username', 'uutype', 'eq', UU_USER_ADD_UPDATE);
-        $mform->disabledIf('username', 'uutype', 'eq', UU_USER_UPDATE);
+        $mform->hideIf('username', 'uutype', 'eq', UU_USER_ADD_UPDATE);
+        $mform->hideIf('username', 'uutype', 'eq', UU_USER_UPDATE);
         $mform->setForceLtr('username');
 
         $mform->addElement('text', 'email', get_string('email'), 'maxlength="100" size="30"');
         $mform->setType('email', PARAM_RAW); // No cleaning here. The process verifies it later.
-        $mform->disabledIf('email', 'uutype', 'eq', UU_USER_ADD_UPDATE);
-        $mform->disabledIf('email', 'uutype', 'eq', UU_USER_UPDATE);
+        $mform->hideIf('email', 'uutype', 'eq', UU_USER_ADD_UPDATE);
+        $mform->hideIf('email', 'uutype', 'eq', UU_USER_UPDATE);
         $mform->setForceLtr('email');
 
         // only enabled and known to work plugins
index ffbd787..3f38f0a 100644 (file)
@@ -101,7 +101,7 @@ class block extends base {
      * @param   MoodleQuickForm $mform      The form to add configuration to.
      */
     public static function add_disabled_constraints_to_form(\MoodleQuickForm $mform) {
-        $mform->disabledIf('targetvalue_block', 'targettype', 'noteq',
+        $mform->hideIf('targetvalue_block', 'targettype', 'noteq',
                 \tool_usertours\target::get_target_constant_for_class(get_class()));
     }
 
index ae6f2fc..3e3fd58 100644 (file)
@@ -91,7 +91,7 @@ class selector extends base {
      * @param   MoodleQuickForm $mform      The form to add configuration to.
      */
     public static function add_disabled_constraints_to_form(\MoodleQuickForm $mform) {
-        $mform->disabledIf('targetvalue_selector', 'targettype', 'noteq',
+        $mform->hideIf('targetvalue_selector', 'targettype', 'noteq',
                 \tool_usertours\target::get_target_constant_for_class(get_class()));
     }
 
index 6a4b078..696efe5 100644 (file)
@@ -84,7 +84,7 @@ class unattached extends base {
         $myvalue = \tool_usertours\target::get_target_constant_for_class(get_class());
 
         foreach (array_keys(self::$forcedsettings) as $settingname) {
-            $mform->disabledIf($settingname, 'targettype', 'eq', $myvalue);
+            $mform->hideIf($settingname, 'targettype', 'eq', $myvalue);
         }
     }
 
diff --git a/admin/tool/xmldb/actions/add_persistent_mandatory/add_persistent_mandatory.class.php b/admin/tool/xmldb/actions/add_persistent_mandatory/add_persistent_mandatory.class.php
new file mode 100644 (file)
index 0000000..a323c1e
--- /dev/null
@@ -0,0 +1,154 @@
+<?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/>.
+
+/**
+ * @package    tool_xmldb
+ * @copyright  2003 onwards Eloy Lafuente (stronk7) {@link http://stronk7.com}
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Add the mandatory fields for persistent to the table.
+ *
+ * @package    tool_xmldb
+ * @copyright  2019 Michael Aherne
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class add_persistent_mandatory extends XMLDBAction {
+
+    function init() {
+
+        parent::init();
+
+        // Get needed strings.
+        $this->loadStrings(array(
+            'addpersistent' => 'tool_xmldb',
+            'persistentfieldsconfirm' => 'tool_xmldb',
+            'persistentfieldscomplete' => 'tool_xmldb',
+            'persistentfieldsexist' => 'tool_xmldb',
+            'back' => 'core'
+        ));
+
+    }
+
+    function getTitle() {
+        return $this->str['addpersistent'];
+    }
+
+    function invoke() {
+
+        parent::invoke();
+
+        $this->does_generate = ACTION_GENERATE_HTML;
+
+        global $CFG, $XMLDB, $OUTPUT;
+
+        $dir = required_param('dir', PARAM_PATH);
+        $dirpath = $CFG->dirroot . $dir;
+
+        if (empty($XMLDB->dbdirs)) {
+            return false;
+        }
+
+        if (!empty($XMLDB->editeddirs)) {
+            $editeddir = $XMLDB->editeddirs[$dirpath];
+            $structure = $editeddir->xml_file->getStructure();
+        }
+
+        $tableparam = required_param('table', PARAM_ALPHANUMEXT);
+
+        /** @var xmldb_table $table */
+        $table = $structure->getTable($tableparam);
+
+        $result = true;
+        // Launch postaction if exists (leave this here!)
+        if ($this->getPostAction() && $result) {
+            return $this->launch($this->getPostAction());
+        }
+
+        $confirm = optional_param('confirm', false, PARAM_BOOL);
+
+        $fields = ['usermodified', 'timecreated', 'timemodified'];
+        $existing = [];
+        foreach ($fields as $field) {
+            if ($table->getField($field)) {
+                $existing[] = $field;
+            }
+        }
+
+        $returnurl = new \moodle_url('/admin/tool/xmldb/index.php', [
+            'table' => $tableparam,
+            'dir' => $dir,
+            'action' => 'edit_table'
+        ]);
+
+        $backbutton = html_writer::link($returnurl, '[' . $this->str['back'] . ']');
+        $actionbuttons = html_writer::tag('p', $backbutton, ['class' => 'centerpara buttons']);
+
+        if (!$confirm) {
+
+            if (!empty($existing)) {
+
+                $message = html_writer::span($this->str['persistentfieldsexist']);
+                $message .= html_writer::alist($existing);
+                $this->output .= $OUTPUT->notification($message);
+
+                if (count($existing) == count($fields)) {
+                    $this->output .= $actionbuttons;
+                    return true;
+                }
+            }
+
+            $confirmurl = new \moodle_url('/admin/tool/xmldb/index.php', [
+                'table' => $tableparam,
+                'dir' => $dir,
+                'action' => 'add_persistent_mandatory',
+                'sesskey' => sesskey(),
+                'confirm' => '1'
+            ]);
+
+            $message = html_writer::span($this->str['persistentfieldsconfirm']);
+            $message .= html_writer::alist(array_diff($fields, $existing));
+            $this->output .= $OUTPUT->confirm($message, $confirmurl, $returnurl);
+
+        } else {
+
+            $fieldsadded = [];
+            foreach ($fields as $field) {
+                if (!in_array($field, $existing)) {
+                    $fieldsadded[] = $field;
+                    $table->add_field($field, XMLDB_TYPE_INTEGER, 10, null, XMLDB_NOTNULL, null, 0);
+                }
+            }
+
+            if (!$table->getKey('usermodified')) {
+                $table->add_key('usermodified', XMLDB_KEY_FOREIGN, ['usermodified'], 'user', ['id']);
+            }
+
+            $structure->setVersion(userdate(time(), '%Y%m%d', 99, false));
+            $structure->setChanged(true);
+
+            $message = html_writer::span($this->str['persistentfieldscomplete']);
+            $message .= html_writer::alist(array_diff($fields, $existing));
+            $this->output .= $OUTPUT->notification($message, 'success');
+
+            $this->output .= $actionbuttons;
+        }
+
+        return $result;
+    }
+
+}
index 5cd4bcd..aa82f99 100644 (file)
@@ -44,6 +44,7 @@ class edit_table extends XMLDBAction {
 
         // Get needed strings
         $this->loadStrings(array(
+            'addpersistent' => 'tool_xmldb',
             'change' => 'tool_xmldb',
             'vieworiginal' => 'tool_xmldb',
             'viewedited' => 'tool_xmldb',
@@ -177,6 +178,15 @@ class edit_table extends XMLDBAction {
         $b .= '<a href="index.php?action=view_table_sql&amp;table=' . $tableparam . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' .$this->str['viewsqlcode'] . ']</a>';
         // The view php code button
         $b .= '&nbsp;<a href="index.php?action=view_table_php&amp;table=' . $tableparam . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['viewphpcode'] . ']</a>';
+        // The add persistent fields button.
+        $url = new \moodle_url('/admin/tool/xmldb/index.php', [
+            'action' => 'add_persistent_mandatory',
+            'sesskey' => sesskey(),
+            'table' => $tableparam,
+            'dir'=> str_replace($CFG->dirroot, '', $dirpath)
+        ]);
+        $b .= '&nbsp;' . \html_writer::link($url, '[' . $this->str['addpersistent'] . ']');
+
         // The save button (if possible)
         if ($cansavenow) {
             $b .= '&nbsp;<a href="index.php?action=save_xml_file&amp;sesskey=' . sesskey() . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '&amp;time=' . time() . '&amp;unload=false&amp;postaction=edit_table&amp;table=' . $tableparam . '&amp;dir=' . urlencode(str_replace($CFG->dirroot, '', $dirpath)) . '">[' . $this->str['save'] . ']</a>';
index d531a86..d2efeab 100644 (file)
@@ -23,6 +23,7 @@
  */
 
 $string['actual'] = 'Actual';
+$string['addpersistent'] = 'Add mandatory persistent fields';
 $string['aftertable'] = 'After table:';
 $string['back'] = 'Back';
 $string['backtomainview'] = 'Back to main';
@@ -169,6 +170,9 @@ $string['numberincorrectwholepart'] = 'Too big whole number part for number fiel
 $string['pendingchanges'] = 'Note: You have performed changes to this file. They can be saved at any moment.';
 $string['pendingchangescannotbesaved'] = 'There are changes in this file but they cannot be saved! Please verify that both the directory and the "install.xml" within it have write permissions for the web server.';
 $string['pendingchangescannotbesavedreload'] = 'There are changes in this file but they cannot be saved! Please verify that both the directory and the "install.xml" within it have write permissions for the web server. Then reload this page and you should be able to save those changes.';
+$string['persistentfieldsconfirm'] = 'Do you want to add the following fields: ';
+$string['persistentfieldscomplete'] = 'The following fields have been added: ';
+$string['persistentfieldsexist'] = 'The following fields already exist: ';
 $string['pluginname'] = 'XMLDB editor';
 $string['primarykeyonlyallownotnullfields'] = 'Primary keys cannot be null';
 $string['reserved'] = 'Reserved';
index ccb32cf..c774de1 100644 (file)
@@ -265,13 +265,15 @@ class provider implements
             return;
         }
 
+        $userid = $contextlist->get_user()->id;
         foreach ($contextlist->get_contexts() as $context) {
             if ($context->contextlevel != CONTEXT_USER) {
-                return;
+                continue;
+            }
+            if ($context->instanceid == $userid) {
+                // Because we only use user contexts the instance ID is the user ID.
+                $DB->delete_records('mnet_log', ['userid' => $context->instanceid]);
             }
-
-            // Because we only use user contexts the instance ID is the user ID.
-            $DB->delete_records('mnet_log', ['userid' => $context->instanceid]);
         }
     }
 }
index cd1750c..f5130ae 100644 (file)
@@ -170,12 +170,15 @@ class provider implements
         if (empty($contextlist->count())) {
             return;
         }
+        $userid = $contextlist->get_user()->id;
         foreach ($contextlist->get_contexts() as $context) {
             if ($context->contextlevel != CONTEXT_USER) {
-                return;
+                continue;
+            }
+            if ($context->instanceid == $userid) {
+                // Because we only use user contexts the instance ID is the user ID.
+                static::delete_user_data($context->instanceid);
             }
-            // Because we only use user contexts the instance ID is the user ID.
-            static::delete_user_data($context->instanceid);
         }
     }
 
index 27ebd41..30d1691 100644 (file)
@@ -44,6 +44,12 @@ class behat_auth extends behat_base {
      * @Given /^I log in as "(?P<username_string>(?:[^"]|\\")*)"$/
      */
     public function i_log_in_as($username) {
+        // In the mobile app the required tasks are different.
+        if ($this->is_in_app()) {
+            $this->execute('behat_app::login', [$username]);
+            return;
+        }
+
         // Visit login page.
         $this->getSession()->visit($this->locate_path('login/index.php'));
 
index 67d0d03..8247355 100644 (file)
@@ -66,12 +66,16 @@ class block_login extends block_base {
 
             $this->content->text .= "\n".'<form class="loginform" id="login" method="post" action="'.get_login_url().'">';
 
-            $this->content->text .= '<div class="form-group"><label for="login_username">'.$strusername.'</label>';
-            $this->content->text .= '<input type="text" name="username" id="login_username" class="form-control" value="'.s($username).'" /></div>';
+            $this->content->text .= '<div class="form-group">';
+            $this->content->text .= '<label for="login_username">'.$strusername.'</label>';
+            $this->content->text .= '<input type="text" name="username" id="login_username" ';
+            $this->content->text .= ' class="form-control" value="'.s($username).'" autocomplete="username"/></div>';
 
             $this->content->text .= '<div class="form-group"><label for="login_password">'.get_string('password').'</label>';
 
-            $this->content->text .= '<input type="password" name="password" id="login_password" class="form-control" value="" /></div>';
+            $this->content->text .= '<input type="password" name="password" id="login_password" ';
+            $this->content->text .= ' class="form-control" value="" autocomplete="current-password"/>';
+            $this->content->text .= '</div>';
 
             if (isset($CFG->rememberusername) and $CFG->rememberusername == 2) {
                 $checked = $username ? 'checked="checked"' : '';
index 691d2fb..6b1337f 100644 (file)
@@ -1712,6 +1712,7 @@ class cache_session extends cache {
     public function __construct(cache_definition $definition, cache_store $store, $loader = null) {
         // First up copy the loadeduserid to the current user id.
         $this->currentuserid = self::$loadeduserid;
+        $this->set_session_id();
         parent::__construct($definition, $store, $loader);
 
         // This will trigger check tracked user. If this gets removed a call to that will need to be added here in its place.
@@ -1771,8 +1772,6 @@ class cache_session extends cache {
                 // Purge the data we have for the old user.
                 // This way we don't bloat the session.
                 $this->purge();
-                // Update the session id just in case!
-                $this->set_session_id();
             }
             self::$loadeduserid = $new;
             $this->currentuserid = $new;
@@ -1780,8 +1779,6 @@ class cache_session extends cache {
             // The current user matches the loaded user but not the user last used by this cache.
             $this->purge_current_user();
             $this->currentuserid = $new;
-            // Update the session id just in case!
-            $this->set_session_id();
         }
     }
 
index 6aa268f..9c0f1d9 100644 (file)
@@ -2327,4 +2327,51 @@ class core_cache_testcase extends advanced_testcase {
         $this->assertEquals('test data 2', $cache->get('testkey1'));
     }
 
+    /**
+     * Test that values set in different sessions are stored with different key prefixes.
+     */
+    public function test_session_distinct_storage_key() {
+        $this->resetAfterTest();
+
+        // Prepare a dummy session cache configuration.
+        $config = cache_config_testing::instance();
+        $config->phpunit_add_definition('phpunit/test_session_distinct_storage_key', array(
+            'mode' => cache_store::MODE_SESSION,
+            'component' => 'phpunit',
+            'area' => 'test_session_distinct_storage_key'
+        ));
+
+        // First anonymous user's session cache.
+        cache_phpunit_session::phpunit_mockup_session_id('foo');
+        $this->setUser(0);
+        $cache1 = cache::make('phpunit', 'test_session_distinct_storage_key');
+
+        // Reset cache instances to emulate a new request.
+        cache_factory::instance()->reset_cache_instances();
+
+        // Another anonymous user's session cache.
+        cache_phpunit_session::phpunit_mockup_session_id('bar');
+        $this->setUser(0);
+        $cache2 = cache::make('phpunit', 'test_session_distinct_storage_key');
+
+        cache_factory::instance()->reset_cache_instances();
+
+        // Guest user's session cache.
+        cache_phpunit_session::phpunit_mockup_session_id('baz');
+        $this->setGuestUser();
+        $cache3 = cache::make('phpunit', 'test_session_distinct_storage_key');
+
+        cache_factory::instance()->reset_cache_instances();
+
+        // Same guest user's session cache but in another browser window.
+        cache_phpunit_session::phpunit_mockup_session_id('baz');
+        $this->setGuestUser();
+        $cache4 = cache::make('phpunit', 'test_session_distinct_storage_key');
+
+        // Assert that different PHP session implies different key prefix for storing values.
+        $this->assertNotEquals($cache1->phpunit_get_key_prefix(), $cache2->phpunit_get_key_prefix());
+
+        // Assert that same PHP session implies same key prefix for storing values.
+        $this->assertEquals($cache3->phpunit_get_key_prefix(), $cache4->phpunit_get_key_prefix());
+    }
 }
index 6c42c39..bee6609 100644 (file)
@@ -465,6 +465,9 @@ class cache_phpunit_application extends cache_application {
  */
 class cache_phpunit_session extends cache_session {
 
+    /** @var Static member used for emulating the behaviour of session_id() during the tests. */
+    protected static $sessionidmockup = 'phpunitmockupsessionid';
+
     /**
      * Returns the class of the store immediately associated with this cache.
      * @return string
@@ -480,6 +483,31 @@ class cache_phpunit_session extends cache_session {
     public function phpunit_get_store_implements() {
         return class_implements($this->get_store());
     }
+
+    /**
+     * Provide access to the {@link cache_session::get_key_prefix()} method.
+     *
+     * @return string
+     */
+    public function phpunit_get_key_prefix() {
+        return $this->get_key_prefix();
+    }
+
+    /**
+     * Allows to inject the session identifier.
+     *
+     * @param string $sessionid
+     */
+    public static function phpunit_mockup_session_id($sessionid) {
+        static::$sessionidmockup = $sessionid;
+    }
+
+    /**
+     * Override the parent behaviour so that it does not need the actual session_id() call.
+     */
+    protected function set_session_id() {
+        $this->sessionid = static::$sessionidmockup;
+    }
 }
 
 /**
index 9d04083..97edf63 100644 (file)
@@ -3191,6 +3191,34 @@ class api {
         return $plancompetency;
     }
 
+    /**
+     * List the plans with a competency.
+     *
+     * @param  int $userid The user id we want the plans for.
+     * @param  int $competencyorid The competency, or its ID.
+     * @return array[plan] Array of learning plans.
+     */
+    public static function list_plans_with_competency($userid, $competencyorid) {
+        global $USER;
+
+        static::require_enabled();
+        $competencyid = $competencyorid;
+        $competency = null;
+        if (is_object($competencyid)) {
+            $competency = $competencyid;
+            $competencyid = $competency->get('id');
+        }
+
+        $plans = plan::get_by_user_and_competency($userid, $competencyid);
+        foreach ($plans as $index => $plan) {
+            // Filter plans we cannot read.
+            if (!$plan->can_read()) {
+                unset($plans[$index]);
+            }
+        }
+        return $plans;
+    }
+
     /**
      * List the competencies in a user plan.
      *
index 705a32a..3ecf3e9 100644 (file)
@@ -4631,4 +4631,38 @@ class core_competency_api_testcase extends advanced_testcase {
         $this->assertEquals($uc1b->get('id'), $result['competencies'][0]->usercompetency->get('id'));
         $this->assertEquals($uc1c->get('id'), $result['competencies'][1]->usercompetency->get('id'));
     }
+
+    /**
+     * Test we can get all of a users plans with a competency.
+     */
+    public function test_list_plans_with_competency() {
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+        $lpg = $this->getDataGenerator()->get_plugin_generator('core_competency');
+
+        $u1 = $this->getDataGenerator()->create_user();
+        $tpl = $this->getDataGenerator()->get_plugin_generator('core_competency')->create_template();
+
+        // Create a framework and assign competencies.
+        $framework = $lpg->create_framework();
+        $c1 = $lpg->create_competency(array('competencyframeworkid' => $framework->get('id')));
+
+        // Create two plans and assign the competency to each.
+        $plan1 = $lpg->create_plan(array('userid' => $u1->id));
+        $plan2 = $lpg->create_plan(array('userid' => $u1->id));
+
+        $lpg->create_plan_competency(array('planid' => $plan1->get('id'), 'competencyid' => $c1->get('id')));
+        $lpg->create_plan_competency(array('planid' => $plan2->get('id'), 'competencyid' => $c1->get('id')));
+
+        // Create one more plan without the competency.
+        $plan3 = $lpg->create_plan(array('userid' => $u1->id));
+
+        $plans = api::list_plans_with_competency($u1->id, $c1);
+
+        $this->assertEquals(2, count($plans));
+
+        $this->assertEquals(reset($plans)->get('id'), $plan1->get('id'));
+        $this->assertEquals(end($plans)->get('id'), $plan2->get('id'));
+    }
+
 }
index a1ec90b..0861a0e 100644 (file)
@@ -161,47 +161,55 @@ class provider implements
         $completioninfo = new \completion_info($course);
         $completion = $completioninfo->is_enabled();
 
-        if ($completion == COMPLETION_ENABLED) {
+        if ($completion != COMPLETION_ENABLED) {
+            return [];
+        }
+
+        $coursecomplete = $completioninfo->is_course_complete($user->id);
 
-            $coursecomplete = $completioninfo->is_course_complete($user->id);
+        if ($coursecomplete) {
+            $status = get_string('complete');
+        } else {
             $criteriacomplete = $completioninfo->count_course_user_data($user->id);
             $ccompletion = new \completion_completion(['userid' => $user->id, 'course' => $course->id]);
 
-            $status = ($coursecomplete) ? get_string('complete') : '';
-            $status = (!$criteriacomplete && !$ccompletion->timestarted) ? get_string('notyetstarted', 'completion') :
-                    get_string('inprogress', 'completion');
-
-            $completions = $completioninfo->get_completions($user->id);
-            $overall = get_string('nocriteriaset', 'completion');
-            if (!empty($completions)) {
-                if ($completioninfo->get_aggregation_method() == COMPLETION_AGGREGATION_ALL) {
-                    $overall = get_string('criteriarequiredall', 'completion');
-                } else {
-                    $overall = get_string('criteriarequiredany', 'completion');
-                }
+            if (!$criteriacomplete && !$ccompletion->timestarted) {
+                $status = get_string('notyetstarted', 'completion');
+            } else {
+                $status = get_string('inprogress', 'completion');
             }
+        }
 
-            $coursecompletiondata = [
-                'status' => $status,
-                'required' => $overall,
-            ];
-
-            $coursecompletiondata['criteria'] = array_map(function($completion) use ($completioninfo) {
-                $criteria = $completion->get_criteria();
-                $aggregation = $completioninfo->get_aggregation_method($criteria->criteriatype);
-                $required = ($aggregation == COMPLETION_AGGREGATION_ALL) ? get_string('all', 'completion') :
-                        get_string('any', 'completion');
-                $data = [
-                    'required' => $required,
-                    'completed' => transform::yesno($completion->is_complete()),
-                    'timecompleted' => isset($completion->timecompleted) ? transform::datetime($completion->timecompleted) : ''
-                ];
-                $details = $criteria->get_details($completion);
-                $data = array_merge($data, $details);
-                return $data;
-            }, $completions);
-            return $coursecompletiondata;
+        $completions = $completioninfo->get_completions($user->id);
+        $overall = get_string('nocriteriaset', 'completion');
+        if (!empty($completions)) {
+            if ($completioninfo->get_aggregation_method() == COMPLETION_AGGREGATION_ALL) {
+                $overall = get_string('criteriarequiredall', 'completion');
+            } else {
+                $overall = get_string('criteriarequiredany', 'completion');
+            }
         }
+
+        $coursecompletiondata = [
+            'status' => $status,
+            'required' => $overall,
+        ];
+
+        $coursecompletiondata['criteria'] = array_map(function($completion) use ($completioninfo) {
+            $criteria = $completion->get_criteria();
+            $aggregation = $completioninfo->get_aggregation_method($criteria->criteriatype);
+            $required = ($aggregation == COMPLETION_AGGREGATION_ALL) ? get_string('all', 'completion') :
+                    get_string('any', 'completion');
+            $data = [
+                'required' => $required,
+                'completed' => transform::yesno($completion->is_complete()),
+                'timecompleted' => isset($completion->timecompleted) ? transform::datetime($completion->timecompleted) : ''
+            ];
+            $details = $criteria->get_details($completion);
+            $data = array_merge($data, $details);
+            return $data;
+        }, $completions);
+        return $coursecompletiondata;
     }
 
     /**
index ffff66a..7bab239 100644 (file)
@@ -193,13 +193,31 @@ class core_completion_privacy_test extends \core_privacy\tests\provider_testcase
         $hasno = array_search('No', $coursecompletion1['criteria'], true);
         $this->assertFalse($hasno);
         $coursecompletion2 = \core_completion\privacy\provider::get_course_completion_info($user2, $this->course);
-        $hasyes = array_search('Yes', $coursecompletion1['criteria'], true);
+        $hasyes = array_search('Yes', $coursecompletion2['criteria'], true);
         $this->assertFalse($hasyes);
         $coursecompletion3 = \core_completion\privacy\provider::get_course_completion_info($user3, $this->course);
-        $hasno = array_search('No', $coursecompletion1['criteria'], true);
+        $hasno = array_search('No', $coursecompletion3['criteria'], true);
         $this->assertFalse($hasno);
         $coursecompletion4 = \core_completion\privacy\provider::get_course_completion_info($user4, $this->course);
-        $hasyes = array_search('Yes', $coursecompletion1['criteria'], true);
+        $hasyes = array_search('Yes', $coursecompletion4['criteria'], true);
         $this->assertFalse($hasyes);
     }
+
+    /**
+     * Test getting course completion information with completion disabled.
+     */
+    public function test_get_course_completion_info_completion_disabled() {
+        $this->resetAfterTest();
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $course = $this->getDataGenerator()->create_course(['enablecompletion' => 0]);
+
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, 'student');
+
+        $coursecompletion = \core_completion\privacy\provider::get_course_completion_info($user, $course);
+
+        $this->assertTrue(is_array($coursecompletion));
+        $this->assertEmpty($coursecompletion);
+    }
 }
index c5659ea..006768a 100644 (file)
@@ -6,8 +6,8 @@
     "homepage": "https://moodle.org",
     "require-dev": {
         "phpunit/phpunit": "6.5.*",
-        "phpunit/dbUnit": "3.0.*",
+        "phpunit/dbunit": "3.0.*",
         "moodlehq/behat-extension": "3.37.0",
-        "mikey179/vfsStream": "^1.6"
+        "mikey179/vfsstream": "^1.6"
     }
 }
index 9cd8bba..baff6db 100644 (file)
@@ -868,6 +868,13 @@ $CFG->admin = 'admin';
 // Example:
 //   define('BEHAT_DISABLE_HISTOGRAM', true);
 //
+// Mobile app Behat testing requires this option, pointing to a developer Moodle Mobile directory:
+//   $CFG->behat_ionic_dirroot = '/where/I/keep/my/git/checkouts/moodlemobile2';
+//
+// The following option can be used to indicate a running Ionic server (otherwise Behat will start
+// one automatically for each test run, which is convenient but takes ages):
+//   $CFG->behat_ionic_wwwroot = 'http://localhost:8100';
+//
 //=========================================================================
 // 12. DEVELOPER DATA GENERATOR
 //=========================================================================
index 89fc598..b75f19f 100644 (file)
@@ -104,7 +104,7 @@ class core_course_deletecategory_form extends moodleform {
             if (in_array($this->coursecat->parent, $displaylist)) {
                 $mform->setDefault('newparent', $this->coursecat->parent);
             }
-            $mform->disabledIf('newparent', 'fulldelete', 'eq', '1');
+            $mform->hideIf('newparent', 'fulldelete', 'eq', '1');
         }
 
         $mform->addElement('hidden', 'categoryid', $this->coursecat->id);
index 8e74142..a5b5c8e 100644 (file)
@@ -23,7 +23,6 @@ M.core_completion.init = function(Y) {
                 iconkey,
                 button = args.image.get('parentNode');
 
-
             if (current == 1) {
                 altstr = M.util.get_string('completion-alt-manual-y', 'completion', modulename);
                 iconkey = 'i/completion-manual-y';
@@ -33,11 +32,13 @@ M.core_completion.init = function(Y) {
                 iconkey = 'i/completion-manual-n';
                 args.state.set('value', 1);
             }
-            button.set('title', altstr);
 
             require(['core/templates', 'core/notification'], function(Templates, Notification) {
                 Templates.renderPix(iconkey, 'core', altstr).then(function(html) {
-                    Templates.replaceNode(args.image.getDOMNode(), html, '');
+                    var id = button.get('id'),
+                        postFocus = '$(document.getElementById("' + id + '")).focus();';
+
+                    Templates.replaceNode(args.image.getDOMNode(), html, postFocus);
                 }).catch(Notification.exception);
             });
         }
index 55cc640..36e7bd0 100644 (file)
@@ -3433,6 +3433,13 @@ function duplicate_module($course, $cm) {
     $rc = new restore_controller($backupid, $course->id,
             backup::INTERACTIVE_NO, backup::MODE_IMPORT, $USER->id, backup::TARGET_CURRENT_ADDING);
 
+    // Make sure that the restore_general_groups setting is always enabled when duplicating an activity.
+    $plan = $rc->get_plan();
+    $groupsetting = $plan->get_setting('groups');
+    if (empty($groupsetting->get_value())) {
+        $groupsetting->set_value(true);
+    }
+
     $cmcontext = context_module::instance($cm->id);
     if (!$rc->execute_precheck()) {
         $precheckresults = $rc->get_precheck_results();
index 4d2348e..caf3868 100644 (file)
@@ -61,9 +61,7 @@ function add_moduleinfo($moduleinfo, $course, $mform = null) {
     $newcm->instance         = 0; // Not known yet, will be updated later (this is similar to restore code).
     $newcm->visible          = $moduleinfo->visible;
     $newcm->visibleold       = $moduleinfo->visible;
-    if (isset($moduleinfo->visibleoncoursepage)) {
-        $newcm->visibleoncoursepage = $moduleinfo->visibleoncoursepage;
-    }
+    $newcm->visibleoncoursepage = $moduleinfo->visibleoncoursepage;
     if (isset($moduleinfo->cmidnumber)) {
         $newcm->idnumber         = $moduleinfo->cmidnumber;
     }
@@ -410,6 +408,9 @@ function set_moduleinfo_defaults($moduleinfo) {
     if (!isset($moduleinfo->conditionfieldgroup)) {
         $moduleinfo->conditionfieldgroup = array();
     }
+    if (!isset($moduleinfo->visibleoncoursepage)) {
+        $moduleinfo->visibleoncoursepage = 1;
+    }
 
     return $moduleinfo;
 }
index 5b47c51..c977a14 100644 (file)
@@ -283,7 +283,7 @@ abstract class moodleform_mod extends moodleform {
         // option (MDL-30764)
         if (empty($this->_cm) || !$this->_cm->groupingid) {
             if ($mform->elementExists('groupmode') && empty($COURSE->groupmodeforce)) {
-                $mform->disabledIf('groupingid', 'groupmode', 'eq', NOGROUPS);
+                $mform->hideIf('groupingid', 'groupmode', 'eq', NOGROUPS);
 
             } else if (!$mform->elementExists('groupmode')) {
                 // Groupings have no use without groupmode.
@@ -545,19 +545,19 @@ abstract class moodleform_mod extends moodleform {
                 }
             }
             $mform->addElement('modgrade', 'scale', get_string('scale'), $gradeoptions);
-            $mform->disabledIf('scale', 'assessed', 'eq', 0);
+            $mform->hideIf('scale', 'assessed', 'eq', 0);
             $mform->addHelpButton('scale', 'modgrade', 'grades');
             $mform->setDefault('scale', $CFG->gradepointdefault);
 
             $mform->addElement('checkbox', 'ratingtime', get_string('ratingtime', 'rating'));
-            $mform->disabledIf('ratingtime', 'assessed', 'eq', 0);
+            $mform->hideIf('ratingtime', 'assessed', 'eq', 0);
 
             $mform->addElement('date_time_selector', 'assesstimestart', get_string('from'));
-            $mform->disabledIf('assesstimestart', 'assessed', 'eq', 0);
+            $mform->hideIf('assesstimestart', 'assessed', 'eq', 0);
             $mform->disabledIf('assesstimestart', 'ratingtime');
 
             $mform->addElement('date_time_selector', 'assesstimefinish', get_string('to'));
-            $mform->disabledIf('assesstimefinish', 'assessed', 'eq', 0);
+            $mform->hideIf('assesstimefinish', 'assessed', 'eq', 0);
             $mform->disabledIf('assesstimefinish', 'ratingtime');
         }
 
@@ -677,7 +677,7 @@ abstract class moodleform_mod extends moodleform {
             if (plugin_supports('mod', $this->_modname, FEATURE_COMPLETION_TRACKS_VIEWS, false)) {
                 $mform->addElement('checkbox', 'completionview', get_string('completionview', 'completion'),
                     get_string('completionview_desc', 'completion'));
-                $mform->disabledIf('completionview', 'completion', 'ne', COMPLETION_TRACKING_AUTOMATIC);
+                $mform->hideIf('completionview', 'completion', 'ne', COMPLETION_TRACKING_AUTOMATIC);
                 // Check by default if automatic completion tracking is set.
                 if ($trackingdefault == COMPLETION_TRACKING_AUTOMATIC) {
                     $mform->setDefault('completionview', 1);
@@ -689,7 +689,7 @@ abstract class moodleform_mod extends moodleform {
             if (plugin_supports('mod', $this->_modname, FEATURE_GRADE_HAS_GRADE, false)) {
                 $mform->addElement('checkbox', 'completionusegrade', get_string('completionusegrade', 'completion'),
                     get_string('completionusegrade_desc', 'completion'));
-                $mform->disabledIf('completionusegrade', 'completion', 'ne', COMPLETION_TRACKING_AUTOMATIC);
+                $mform->hideIf('completionusegrade', 'completion', 'ne', COMPLETION_TRACKING_AUTOMATIC);
                 $mform->addHelpButton('completionusegrade', 'completionusegrade', 'completion');
                 $gotcompletionoptions = true;
 
@@ -702,7 +702,7 @@ abstract class moodleform_mod extends moodleform {
             // Automatic completion according to module-specific rules
             $this->_customcompletionelements = $this->add_completion_rules();
             foreach ($this->_customcompletionelements as $element) {
-                $mform->disabledIf($element, 'completion', 'ne', COMPLETION_TRACKING_AUTOMATIC);
+                $mform->hideIf($element, 'completion', 'ne', COMPLETION_TRACKING_AUTOMATIC);
             }
 
             $gotcompletionoptions = $gotcompletionoptions ||
@@ -719,7 +719,7 @@ abstract class moodleform_mod extends moodleform {
             $mform->addElement('date_time_selector', 'completionexpected', get_string('completionexpected', 'completion'),
                     array('optional' => true));
             $mform->addHelpButton('completionexpected', 'completionexpected', 'completion');
-            $mform->disabledIf('completionexpected', 'completion', 'eq', COMPLETION_TRACKING_NONE);
+            $mform->hideIf('completionexpected', 'completion', 'eq', COMPLETION_TRACKING_NONE);
         }
 
         // Populate module tags.
@@ -859,7 +859,7 @@ abstract class moodleform_mod extends moodleform {
                         get_string('gradingmethod', 'core_grading'), $this->current->_advancedgradingdata['methods']);
                     $mform->addHelpButton('advancedgradingmethod_'.$areaname, 'gradingmethod', 'core_grading');
                     if (!$this->_features->rating) {
-                        $mform->disabledIf('advancedgradingmethod_'.$areaname, 'grade[modgrade_type]', 'eq', 'none');
+                        $mform->hideIf('advancedgradingmethod_'.$areaname, 'grade[modgrade_type]', 'eq', 'none');
                     }
 
                 } else {
@@ -882,7 +882,7 @@ abstract class moodleform_mod extends moodleform {
                         grade_get_categories_menu($COURSE->id, $this->_outcomesused));
                 $mform->addHelpButton('gradecat', 'gradecategoryonmodform', 'grades');
                 if (!$this->_features->rating) {
-                    $mform->disabledIf('gradecat', 'grade[modgrade_type]', 'eq', 'none');
+                    $mform->hideIf('gradecat', 'grade[modgrade_type]', 'eq', 'none');
                 }
             }
 
@@ -892,9 +892,9 @@ abstract class moodleform_mod extends moodleform {
             $mform->setDefault('gradepass', '');
             $mform->setType('gradepass', PARAM_RAW);
             if (!$this->_features->rating) {
-                $mform->disabledIf('gradepass', 'grade[modgrade_type]', 'eq', 'none');
+                $mform->hideIf('gradepass', 'grade[modgrade_type]', 'eq', 'none');
             } else {
-                $mform->disabledIf('gradepass', 'assessed', 'eq', '0');
+                $mform->hideIf('gradepass', 'assessed', 'eq', '0');
             }
         }
     }
index d27e10f..3bba431 100644 (file)
@@ -503,7 +503,7 @@ class core_course_renderer extends plugin_renderer_base {
                     'type' => 'hidden', 'name' => 'completionstate', 'value' => $newstate));
                 $output .= html_writer::tag('button',
                     $this->output->pix_icon('i/completion-' . $completionicon, $imgalt),
-                        array('class' => 'btn btn-link', 'title' => $imgalt));
+                        array('class' => 'btn btn-link', 'aria-live' => 'assertive'));
                 $output .= html_writer::end_tag('div');
                 $output .= html_writer::end_tag('form');
             } else {
diff --git a/course/tests/behat/app_courselist.feature b/course/tests/behat/app_courselist.feature
new file mode 100644 (file)
index 0000000..ea68c05
--- /dev/null
@@ -0,0 +1,120 @@
+@core @core_course @app @javascript
+Feature: Test course list shown on app start tab
+  In order to select a course
+  As a student
+  I need to see the correct list of courses
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname |
+      | Course 1 | C1        |
+      | Course 2 | C2        |
+    And the following "users" exist:
+      | username |
+      | student1 |
+      | student2 |
+    And the following "course enrolments" exist:
+      | user     | course | role    |
+      | student1 | C1     | student |
+      | student2 | C1     | student |
+      | student2 | C2     | student |
+
+  Scenario: Student is registered on one course
+    When I enter the app
+    And I log in as "student1"
+    Then I should see "Course 1"
+    And I should not see "Course 2"
+
+  Scenario: Student is registered on two courses (shortnames not displayed)
+    When I enter the app
+    And I log in as "student2"
+    Then I should see "Course 1"
+    And I should see "Course 2"
+    And I should not see "C1"
+    And I should not see "C2"
+
+  Scenario: Student is registered on two courses (shortnames displayed)
+    Given the following config values are set as admin:
+      | courselistshortnames | 1 |
+    When I enter the app
+    And I log in as "student2"
+    Then I should see "Course 1"
+    And I should see "Course 2"
+    And I should see "C1"
+    And I should see "C2"
+
+  Scenario: Student uses course list to enter course, then leaves it again
+    When I enter the app
+    And I log in as "student2"
+    And I press "Course 2" near "Course overview" in the app
+    Then the header should be "Course 2" in the app
+    And I press the back button in the app
+    Then the header should be "Acceptance test site" in the app
+
+  Scenario: Student uses filter feature to reduce course list
+    Given the following config values are set as admin:
+      | courselistshortnames | 1 |
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Frog 3   | C3        |
+      | Frog 4   | C4        |
+      | Course 5 | C5        |
+      | Toad 6   | C6        |
+    And the following "course enrolments" exist:
+      | user     | course | role    |
+      | student2 | C3     | student |
+      | student2 | C4     | student |
+      | student2 | C5     | student |
+      | student2 | C6     | student |
+    # Create bogus courses so that the main ones aren't shown in the 'recently accessed' part.
+    # Because these come later in alphabetical order, they may not be displayed in the lower part
+    # which is OK.
+    And the following "courses" exist:
+      | fullname | shortname |
+      | Zogus 1  | Z1        |
+      | Zogus 2  | Z2        |
+      | Zogus 3  | Z3        |
+      | Zogus 4  | Z4        |
+      | Zogus 5  | Z5        |
+      | Zogus 6  | Z6        |
+      | Zogus 7  | Z7        |
+      | Zogus 8  | Z8        |
+      | Zogus 9  | Z9        |
+      | Zogus 10 | Z10       |
+    And the following "course enrolments" exist:
+      | user     | course | role    |
+      | student2 | Z1     | student |
+      | student2 | Z2     | student |
+      | student2 | Z3     | student |
+      | student2 | Z4     | student |
+      | student2 | Z5     | student |
+      | student2 | Z6     | student |
+      | student2 | Z7     | student |
+      | student2 | Z8     | student |
+      | student2 | Z9     | student |
+      | student2 | Z10    | student |
+    When I enter the app
+    And I log in as "student2"
+    Then I should see "C1"
+    And I should see "C2"
+    And I should see "C3"
+    And I should see "C4"
+    And I should see "C5"
+    And I should see "C6"
+    And I press "more" near "Course overview" in the app
+    And I press "Filter my courses" in the app
+    And I set the field "Filter my courses" to "fr" in the app
+    Then I should not see "C1"
+    And I should not see "C2"
+    And I should see "C3"
+    And I should see "C4"
+    And I should not see "C5"
+    And I should not see "C6"
+    And I press "more" near "Course overview" in the app
+    And I press "Filter my courses" in the app
+    Then I should see "C1"
+    And I should see "C2"
+    And I should see "C3"
+    And I should see "C4"
+    And I should see "C5"
+    And I should see "C6"
index d447b2b..b620e70 100644 (file)
@@ -19,6 +19,7 @@ Feature: View subfolders in a course in-line
     And I add a "Folder" to section "3" and I fill the form with:
       | Name | Test folder |
       | Display folder contents | On a separate page |
+      | Show subfolders expanded | |
     And I should see "Test folder"
     And I follow "Test folder"
     And I press "Edit"
@@ -42,6 +43,12 @@ Feature: View subfolders in a course in-line
     Then I should not see "Test subfolder 2"
     And I follow "Test folder"
     And I should see "Test subfolder 2"
+    Given I navigate to "Edit settings" in current page administration
+    And I set the field "Show subfolders expanded" to "1"
+    When I am on "Course 1" course homepage
+    Then I should not see "Test subfolder 2"
+    And I follow "Test folder"
+    And I should see "Test subfolder 2"
 
   @javascript
   Scenario: Make the subfolders viewable inline on the course page
@@ -51,14 +58,12 @@ Feature: View subfolders in a course in-line
     And I set the field "New folder name" to "Test sub subfolder"
     And I click on "button.fp-dlg-butcreate" "css_element" in the "div.fp-mkdir-dlg" "css_element"
     And I press "Save changes"
-    And I should see "Test sub subfolder"
     And I navigate to "Edit settings" in current page administration
-    And I set the field "Display folder contents" to "Inline on a course page"
-    And I set the field "Show subfolders expanded" to ""
+    When I set the field "Display folder contents" to "Inline on a course page"
     And I press "Save and return to course"
-    And I should see "Test subfolder 1"
+    Then I should see "Test subfolder 1"
     And I should not see "Test sub subfolder"
-    And I open "Test folder" actions menu
+    Given I open "Test folder" actions menu
     When I click on "Edit settings" "link" in the "Test folder" activity
     And I set the field "Show subfolders expanded" to "1"
     And I press "Save and return to course"
index 35e61ea..c77e151 100644 (file)
@@ -419,21 +419,50 @@ class course_enrolment_manager {
      * @param int $page which page number of the results to show.
      * @param int $perpage number of users per page.
      * @param int $addedenrollment number of users added to enrollment.
-     * @return array with two elememts:
-     *      int total number of users matching the search.
-     *      array of user objects returned by the query.
-     */
-    protected function execute_search_queries($search, $fields, $countfields, $sql, array $params, $page, $perpage, $addedenrollment=0) {
+     * @param bool $returnexactcount Return the exact total users using count_record or not.
+     * @return array with two or three elements:
+     *      int totalusers Number users matching the search. (This element only exist if $returnexactcount was set to true)
+     *      array users List of user objects returned by the query.
+     *      boolean moreusers True if there are still more users, otherwise is False.
+     * @throws dml_exception
+     */
+    protected function execute_search_queries($search, $fields, $countfields, $sql, array $params, $page, $perpage,
+            $addedenrollment = 0, $returnexactcount = false) {
         global $DB, $CFG;
 
         list($sort, $sortparams) = users_order_by_sql('u', $search, $this->get_context());
         $order = ' ORDER BY ' . $sort;
 
-        $totalusers = $DB->count_records_sql($countfields . $sql, $params);
+        $totalusers = 0;
+        $moreusers = false;
+        $results = [];
+
         $availableusers = $DB->get_records_sql($fields . $sql . $order,
-                array_merge($params, $sortparams), ($page*$perpage) - $addedenrollment, $perpage);
+                array_merge($params, $sortparams), ($page * $perpage) - $addedenrollment, $perpage + 1);
+        if ($availableusers) {
+            $totalusers = count($availableusers);
+            $moreusers = $totalusers > $perpage;
+
+            if ($moreusers) {
+                // We need to discard the last record.
+                array_pop($availableusers);
+            }
+
+            if ($returnexactcount && $moreusers) {
+                // There is more data. We need to do the exact count.
+                $totalusers = $DB->count_records_sql($countfields . $sql, $params);
+            }
+        }
 
-        return array('totalusers' => $totalusers, 'users' => $availableusers);
+        $results['users'] = $availableusers;
+        $results['moreusers'] = $moreusers;
+
+        if ($returnexactcount) {
+            // Include totalusers in result if $returnexactcount flag is true.
+            $results['totalusers'] = $totalusers;
+        }
+
+        return $results;
     }
 
     /**
@@ -446,9 +475,15 @@ class course_enrolment_manager {
      * @param int $page Defaults to 0
      * @param int $perpage Defaults to 25
      * @param int $addedenrollment Defaults to 0
-     * @return array Array(totalusers => int, users => array)
-     */
-    public function get_potential_users($enrolid, $search='', $searchanywhere=false, $page=0, $perpage=25, $addedenrollment=0) {
+     * @param bool $returnexactcount Return the exact total users using count_record or not.
+     * @return array with two or three elements:
+     *      int totalusers Number users matching the search. (This element only exist if $returnexactcount was set to true)
+     *      array users List of user objects returned by the query.
+     *      boolean moreusers True if there are still more users, otherwise is False.
+     * @throws dml_exception
+     */
+    public function get_potential_users($enrolid, $search = '', $searchanywhere = false, $page = 0, $perpage = 25,
+            $addedenrollment = 0, $returnexactcount = false) {
         global $DB;
 
         list($ufields, $params, $wherecondition) = $this->get_basic_search_conditions($search, $searchanywhere);
@@ -461,7 +496,8 @@ class course_enrolment_manager {
                       AND ue.id IS NULL";
         $params['enrolid'] = $enrolid;
 
-        return $this->execute_search_queries($search, $fields, $countfields, $sql, $params, $page, $perpage, $addedenrollment);
+        return $this->execute_search_queries($search, $fields, $countfields, $sql, $params, $page, $perpage, $addedenrollment,
+                $returnexactcount);
     }
 
     /**
@@ -472,9 +508,14 @@ class course_enrolment_manager {
      * @param bool $searchanywhere
      * @param int $page Starting at 0
      * @param int $perpage
-     * @return array
-     */
-    public function search_other_users($search='', $searchanywhere=false, $page=0, $perpage=25) {
+     * @param bool $returnexactcount Return the exact total users using count_record or not.
+     * @return array with two or three elements:
+     *      int totalusers Number users matching the search. (This element only exist if $returnexactcount was set to true)
+     *      array users List of user objects returned by the query.
+     *      boolean moreusers True if there are still more users, otherwise is False.
+     * @throws dml_exception
+     */
+    public function search_other_users($search = '', $searchanywhere = false, $page = 0, $perpage = 25, $returnexactcount = false) {
         global $DB, $CFG;
 
         list($ufields, $params, $wherecondition) = $this->get_basic_search_conditions($search, $searchanywhere);
@@ -487,7 +528,7 @@ class course_enrolment_manager {
                     AND ra.id IS NULL";
         $params['contextid'] = $this->context->id;
 
-        return $this->execute_search_queries($search, $fields, $countfields, $sql, $params, $page, $perpage);
+        return $this->execute_search_queries($search, $fields, $countfields, $sql, $params, $page, $perpage, 0, $returnexactcount);
     }
 
     /**
index 2ced5d6..bf66147 100644 (file)
Binary files a/enrol/manual/amd/build/form-potential-user-selector.min.js and b/enrol/manual/amd/build/form-potential-user-selector.min.js differ
index 066f59d..7fac602 100644 (file)
@@ -49,6 +49,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str'], function($, Ajax,
         transport: function(selector, query, success, failure) {
             var promise;
             var courseid = $(selector).attr('courseid');
+            var userfields = $(selector).attr('userfields').split(',');
             if (typeof courseid === "undefined") {
                 courseid = '1';
             }
@@ -78,7 +79,7 @@ define(['jquery', 'core/ajax', 'core/templates', 'core/str'], function($, Ajax,
                     $.each(results, function(index, user) {
                         var ctx = user,
                             identity = [];
-                        $.each(['idnumber', 'email', 'phone1', 'phone2', 'department', 'institution'], function(i, k) {
+                        $.each(userfields, function(i, k) {
                             if (typeof user[k] !== 'undefined' && user[k] !== '') {
                                 ctx.hasidentity = true;
                                 identity.push(user[k]);
index da4cd68..165a6e9 100644 (file)
@@ -98,7 +98,8 @@ class enrol_manual_enrol_users_form extends moodleform {
             'ajax' => 'enrol_manual/form-potential-user-selector',
             'multiple' => true,
             'courseid' => $course->id,
-            'enrolid' => $instance->id
+            'enrolid' => $instance->id,
+            'userfields' => implode(',', get_extra_user_fields($context))
         );
         $mform->addElement('autocomplete', 'userlist', get_string('selectusers', 'enrol_manual'), array(), $options);
 
index c5e3549..2afa506 100644 (file)
@@ -153,3 +153,28 @@ Feature: Teacher can search and enrol users one by one into the course
     When I set the field "Select users" to "example.com"
     And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
     Then I should see "Too many users (>100) to show"
+
+  @javascript
+  Scenario: Change the Show user identity setting affects the enrolment pop-up.
+    Given I log out
+    When I log in as "admin"
+    Then the following "users" exist:
+      | username    | firstname | lastname | email                   | phone1     | phone2     | department | institution | city    | country  |
+      | student100  | Student   | 100      | student100@example.com  | 1234567892 | 1234567893 | ABC1       | ABC2        | CITY1   | UK       |
+    And the following config values are set as admin:
+      | showuseridentity | idnumber,email,city,country,phone1,phone2,department,institution |
+    When I am on "Course 001" course homepage
+    Then I navigate to course participants
+    And I press "Enrol users"
+    When I set the field "Select users" to "student100@example.com"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
+    Then I should see "student100@example.com, CITY1, UK, 1234567892, 1234567893, ABC1, ABC2"
+    # Remove identity field in setting User policies
+    And the following config values are set as admin:
+      | showuseridentity | idnumber,email,phone1,phone2,department,institution |
+    When I am on "Course 001" course homepage
+    And I navigate to course participants
+    And I press "Enrol users"
+    When I set the field "Select users" to "student100@example.com"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
+    Then I should see "student100@example.com, 1234567892, 1234567893, ABC1, ABC2"
index a1bc241..32c5c10 100644 (file)
@@ -680,6 +680,7 @@ class course_enrolment_other_users_table extends course_enrolment_table {
             $this->manager->get_moodlepage()->requires->strings_for_js(array(
                     'ajaxoneuserfound',
                     'ajaxxusersfound',
+                    'ajaxxmoreusersfound',
                     'ajaxnext25',
                     'enrol',
                     'enrolmentoptions',
index 8f56a44..9e9f0ed 100644 (file)
@@ -104,6 +104,20 @@ class core_course_enrolment_manager_testcase extends advanced_testcase {
         $this->course = $course;
         $this->users = $users;
         $this->groups = $groups;
+
+        // Make sample users and not enroll to any course.
+        $this->getDataGenerator()->create_user([
+                'username' => 'testapiuser1',
+                'firstname' => 'testapiuser 1'
+        ]);
+        $this->getDataGenerator()->create_user([
+                'username' => 'testapiuser2',
+                'firstname' => 'testapiuser 2'
+        ]);
+        $this->getDataGenerator()->create_user([
+                'username' => 'testapiuser3',
+                'firstname' => 'testapiuser 3'
+        ]);
     }
 
     /**
@@ -239,4 +253,88 @@ class core_course_enrolment_manager_testcase extends advanced_testcase {
         $this->assertCount(1, $users, 'Only suspended users must be returned when suspended users filtering is applied.');
         $this->assertArrayHasKey($this->users['user22']->id, $users);
     }
+
+    /**
+     * Test get_potential_users without returnexactcount param.
+     *
+     * @dataProvider search_users_provider
+     *
+     * @param int $perpage Number of users per page.
+     * @param bool $returnexactcount Return the exact count or not.
+     * @param int $expectedusers Expected number of users return.
+     * @param int $expectedtotalusers Expected total of users in database.
+     * @param bool $expectedmoreusers Expected for more users return or not.
+     */
+    public function test_get_potential_users($perpage, $returnexactcount, $expectedusers, $expectedtotalusers, $expectedmoreusers) {
+        global $DB, $PAGE;
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $enrol = $DB->get_record('enrol', array('courseid' => $this->course->id, 'enrol' => 'manual'));
+        $manager = new course_enrolment_manager($PAGE, $this->course);
+        $users = $manager->get_potential_users($enrol->id,
+                'testapiuser',
+                true,
+                0,
+                $perpage,
+                0,
+                $returnexactcount);
+
+        $this->assertCount($expectedusers, $users['users']);
+        $this->assertEquals($expectedmoreusers, $users['moreusers']);
+        if ($returnexactcount) {
+            $this->assertArrayHasKey('totalusers', $users);
+            $this->assertEquals($expectedtotalusers, $users['totalusers']);
+        } else {
+            $this->assertArrayNotHasKey('totalusers', $users);
+        }
+    }
+
+    /**
+     * Test search_other_users with returnexactcount param.
+     *
+     * @dataProvider search_users_provider
+     *
+     * @param int $perpage Number of users per page.
+     * @param bool $returnexactcount Return the exact count or not.
+     * @param int $expectedusers Expected number of users return.
+     * @param int $expectedtotalusers Expected total of users in database.
+     * @param bool $expectedmoreusers Expected for more users return or not.
+     */
+    public function test_search_other_users($perpage, $returnexactcount, $expectedusers, $expectedtotalusers, $expectedmoreusers) {
+        global $PAGE;
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $manager = new course_enrolment_manager($PAGE, $this->course);
+        $users = $manager->search_other_users(
+                'testapiuser',
+                true,
+                0,
+                $perpage,
+                $returnexactcount);
+
+        $this->assertCount($expectedusers, $users['users']);
+        $this->assertEquals($expectedmoreusers, $users['moreusers']);
+        if ($returnexactcount) {
+            $this->assertArrayHasKey('totalusers', $users);
+            $this->assertEquals($expectedtotalusers, $users['totalusers']);
+        } else {
+            $this->assertArrayNotHasKey('totalusers', $users);
+        }
+    }
+
+    /**
+     * Test case for test_get_potential_users and test_search_other_users tests.
+     *
+     * @return array Dataset
+     */
+    public function search_users_provider() {
+        return [
+                [2, false, 2, 3, true],
+                [5, false, 3, 3, false],
+                [2, true, 2, 3, true],
+                [5, true, 3, 3, false]
+        ];
+    }
 }
index 3202160..df24f1f 100644 (file)
@@ -1,6 +1,13 @@
 This files describes API changes in /enrol/* - plugins,
 information provided here is intended especially for developers.
 
+=== 3.7 ===
+
+* Functions get_potential_users() and search_other_users() now return more information to avoid extra count query:
+  - users: List of user objects returned by the query.
+  - moreusers: True if there are still more users, otherwise is False.
+  - totalusers: Number users matching the search. (This element only exists if the function is called with $returnexactcount param set to true).
+
 === 3.6 ===
 
 * External function core_enrol_external::get_users_courses now return more information to avoid multiple queries to build the
index 23d8ba9..77daf94 100644 (file)
@@ -160,8 +160,9 @@ YUI.add('moodle-enrol-otherusersmanager', function(Y) {
             this._loadingNode.addClass(CSS.HIDDEN);
         },
         processSearchResults : function(tid, outcome, args) {
+            var result;
             try {
-                var result = Y.JSON.parse(outcome.responseText);
+                result = Y.JSON.parse(outcome.responseText);
                 if (result.error) {
                     return new M.core.ajaxException(result);
                 }
@@ -186,18 +187,26 @@ YUI.add('moodle-enrol-otherusersmanager', function(Y) {
             }
             this.set(USERCOUNT, count);
             if (!args.append) {
-                var usersstr = (result.response.totalusers == '1')?M.util.get_string('ajaxoneuserfound', 'enrol'):M.util.get_string('ajaxxusersfound','enrol', result.response.totalusers);
+                var usersstr = '';
+                if (this.get(USERCOUNT) === 1) {
+                    usersstr = M.util.get_string('ajaxoneuserfound', 'enrol');
+                } else if (result.response.moreusers) {
+                    usersstr = M.util.get_string('ajaxxmoreusersfound', 'enrol', this.get(USERCOUNT));
+                } else {
+                    usersstr = M.util.get_string('ajaxxusersfound', 'enrol', this.get(USERCOUNT));
+                }
+
                 var content = Y.Node.create('<div class="'+CSS.SEARCHRESULTS+'"></div>')
                     .append(Y.Node.create('<div class="'+CSS.TOTALUSERS+'">'+usersstr+'</div>'))
                     .append(usersnode);
-                if (result.response.totalusers > (this.get(PAGE)+1)*25) {
+                if (result.response.moreusers) {
                     var fetchmore = Y.Node.create('<div class="'+CSS.MORERESULTS+'"><a href="#">'+M.util.get_string('ajaxnext25', 'enrol')+'</a></div>');
                     fetchmore.on('click', this.getUsers, this, true);
                     content.append(fetchmore)
                 }
                 this.setContent(content);
             } else {
-                if (result.response.totalusers <= (this.get(PAGE)+1)*25) {
+                if (!result.response.moreusers) {
                     this.get(BASE).one('.'+CSS.MORERESULTS).remove();
                 }
             }
index 0e18336..3286cd5 100644 (file)
@@ -40,3 +40,4 @@ $string['cliunknowoption'] = 'Nepoznate opcije: {$a} Molimo koristite --help opc
 $string['cliyesnoprompt'] = 'unesite y (znači da) ili n (znači ne)';
 $string['environmentrequireinstall'] = 'je neophodno instalirati/omogućiti';
 $string['environmentrequireversion'] = 'neophodna inačica je {$a->needed}, a vi trenutačno koristite inačicu {$a->current}';
+$string['upgradekeyset'] = 'Ključ za ažuriranje (ostavite prazno kako ga ne bi zadali)';
index 578ef82..a535604 100644 (file)
@@ -73,9 +73,9 @@ $string['pathssubdataroot'] = '<p>ユーザによってアップロードされ
 <p>ウェブからは直接アクセスできないようにしてください。</p>
 <p>現在ディレクトリが存在しない場合、インストレーションプロセスは作成を試みます。</p';
 $string['pathssubdirroot'] = '<p>Moodleコードを含むディレクトリに関するフルパスです。</p>';
-$string['pathssubwwwroot'] = '<p>Moodleã\81«ã\82¢ã\82¯ã\82»ã\82¹ã\81\99ã\82\8bã\81\93ã\81¨ã\81®ã\81§ã\81\8dã\82\8bã\83\95ã\83«ã\82¦ã\82§ã\83\96ã\82¢ã\83\89ã\83¬ã\82¹ã\81§ã\81\99ã\80\82ä¾\8bã\81\88ã\81°ã\83¦ã\83¼ã\82¶ã\81\8cã\83\96ã\83©ã\82¦ã\82¶ã\81®ã\82¢ã\83\89ã\83¬ã\82¹ã\83\90ã\83¼ã\81«å\85¥å\8a\9bã\81\97ã\81¦Moodleã\81«ã\82¢ã\82¯ã\82»ã\82¹ã\81\99ã\82\8bã\81\9fã\82\81ã\81®ã\82¢ã\83\89ã\83¬ã\82¹ã\81§ã\81\99ã\80\82</p>
+$string['pathssubwwwroot'] = '<p>Moodleにアクセスできるフルウェブアドレスです。例えばユーザがブラウザのアドレスバーに入力してMoodleにアクセスするためのアドレスです。</p>
 
-<p>è¤\87æ\95°ã\82¢ã\83\89ã\83¬ã\82¹ã\82\92使ç\94¨ã\81\97ã\81¦Moodleã\81«ã\82¢ã\82¯ã\82»ã\82¹ã\81\99ã\82\8bã\81\93ã\81¨はできません。あなたのサイトに複数アドレスからアクセスできる場合、最も簡単なアドレスを選択して、すべてのアドレスにパーマネントリダイレクトを設定してください。</p>
+<p>è¤\87æ\95°ã\82¢ã\83\89ã\83¬ã\82¹ã\82\92使ç\94¨ã\81\97ã\81\9fMoodleã\81¸ã\81®ã\82¢ã\82¯ã\82»ã\82¹はできません。あなたのサイトに複数アドレスからアクセスできる場合、最も簡単なアドレスを選択して、すべてのアドレスにパーマネントリダイレクトを設定してください。</p>
 
 <p>あなたのサイトにインターネットおよび内部ネットワーク (イントラネットと呼ばれます) からアクセスできる場合、ここではパブリックアドレスを使用してください。</p>
 
index 42377c7..4047987 100644 (file)
@@ -107,6 +107,7 @@ $string['invalidpersistenterror'] = 'Error: {$a}';
 $string['invalidplan'] = 'Invalid learning plan';
 $string['invalidtaxonomy'] = 'Invalid taxonomy: {$a}';
 $string['invalidurl'] = 'The URL is not valid. Make sure it starts with \'http://\' or \'https://\'.';
+$string['nouserplanswithcompetency'] = 'No learning plans contain this competency.';
 $string['planstatusactive'] = 'Active';
 $string['planstatuscomplete'] = 'Complete';
 $string['planstatusdraft'] = 'Draft';
index 8f452db..ea1bb8e 100644 (file)
@@ -28,6 +28,7 @@ $string['addinstance'] = 'Add method';
 $string['addinstanceanother'] = 'Add method and create another';
 $string['ajaxoneuserfound'] = '1 user found';
 $string['ajaxxusersfound'] = '{$a} users found';
+$string['ajaxxmoreusersfound'] = 'More than {$a} users found';
 $string['ajaxnext25'] = 'Next 25...';
 $string['assignnotpermitted'] = 'You do not have permission or can not assign roles in this course.';
 $string['bulkuseroperation'] = 'Bulk user operation';
diff --git a/lib/amd/build/checkbox-toggleall.min.js b/lib/amd/build/checkbox-toggleall.min.js
new file mode 100644 (file)
index 0000000..90e5ebc
Binary files /dev/null and b/lib/amd/build/checkbox-toggleall.min.js differ
index 01fd0c6..a82abd9 100644 (file)
Binary files a/lib/amd/build/storagewrapper.min.js and b/lib/amd/build/storagewrapper.min.js differ
diff --git a/lib/amd/src/checkbox-toggleall.js b/lib/amd/src/checkbox-toggleall.js
new file mode 100644 (file)
index 0000000..c6d4103
--- /dev/null
@@ -0,0 +1,126 @@
+// 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 module to help with toggle select/deselect all.
+ *
+ * @module     core/checkbox-toggleall
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/pubsub'], function($, PubSub) {
+
+    var registered = false;
+
+    var events = {
+        checkboxToggled: 'core/checkbox-toggleall:checkboxToggled',
+    };
+
+    var getAllCheckboxes = function(root, toggleGroup) {
+        return root.find('[data-action="toggle"][data-togglegroup="' + toggleGroup + '"]');
+    };
+
+    var getAllSlaveCheckboxes = function(root, toggleGroup) {
+        return getAllCheckboxes(root, toggleGroup).filter('[data-toggle="slave"]');
+    };
+
+    var getControlCheckboxes = function(root, toggleGroup) {
+        return getAllCheckboxes(root, toggleGroup).filter('[data-toggle="master"]');
+    };
+
+    var toggleSlavesFromMasters = function(e) {
+        var root = e.data.root;
+        var target = $(e.target);
+
+        var toggleGroupName = target.data('togglegroup');
+        var targetState = target.is(':checked');
+
+        var slaves = getAllSlaveCheckboxes(root, toggleGroupName);
+        var checkedSlaves = slaves.filter(':checked');
+
+        setMasterStates(root, toggleGroupName, targetState);
+
+        // Set the slave checkboxes from the masters.
+        slaves.prop('checked', targetState);
+
+        PubSub.publish(events.checkboxToggled, {
+            root: root,
+            toggleGroupName: toggleGroupName,
+            slaves: slaves,
+            checkedSlaves: checkedSlaves,
+            anyChecked: targetState,
+        });
+    };
+
+    var toggleMastersFromSlaves = function(e) {
+        var root = e.data.root;
+        var target = $(e.target);
+
+        var toggleGroupName = target.data('togglegroup');
+
+        var slaves = getAllSlaveCheckboxes(root, toggleGroupName);
+        var checkedSlaves = slaves.filter(':checked');
+        var targetState = (slaves.length === checkedSlaves.length);
+
+        setMasterStates(root, toggleGroupName, targetState);
+
+        PubSub.publish(events.checkboxToggled, {
+            root: root,
+            toggleGroupName: toggleGroupName,
+            slaves: slaves,
+            checkedSlaves: checkedSlaves,
+            anyChecked: !!checkedSlaves.length,
+        });
+    };
+
+    var setMasterStates = function(root, toggleGroupName, targetState) {
+        // Set the master checkboxes value and ARIA labels..
+        var masters = getControlCheckboxes(root, toggleGroupName);
+        masters.prop('checked', targetState);
+        masters.each(function(i, masterCheckbox) {
+            masterCheckbox = $(masterCheckbox);
+            var masterLabel = root.find('[for="' + masterCheckbox.attr('id') + '"]');
+            var targetString;
+            if (masterLabel.length) {
+                if (targetState) {
+                    targetString = masterCheckbox.data('toggle-deselectall');
+                } else {
+                    targetString = masterCheckbox.data('toggle-selectall');
+                }
+
+                if (masterLabel.html() !== targetString) {
+                    masterLabel.html(targetString);
+                }
+            }
+        });
+    };
+
+    var registerListeners = function() {
+        if (!registered) {
+            registered = true;
+
+            var root = $(document.body);
+            root.on('change', '[data-action="toggle"][data-toggle="master"]', {root: root}, toggleSlavesFromMasters);
+            root.on('change', '[data-action="toggle"][data-toggle="slave"]', {root: root}, toggleMastersFromSlaves);
+        }
+    };
+
+    return {
+        init: function() {
+            registerListeners();
+        },
+        events: events,
+    };
+});
index 7f7f3e6..ce92e39 100644 (file)
@@ -35,7 +35,8 @@ define(['core/config'], function(config) {
         this.hashSource = config.wwwroot + '/' + config.jsrev;
         this.hash = this.hashString(this.hashSource);
         this.prefix = this.hash + '/';
-        this.jsrevPrefix = this.hash + '/jsrev';
+        this.jsrevPrefix = this.hashString(config.wwwroot) + '/jsrev';
+        this.validateCache();
     };
 
     /**
@@ -89,8 +90,8 @@ define(['core/config'], function(config) {
             this.storage.setItem(this.jsrevPrefix, config.jsrev);
             return;
         }
-        var moodleVersion = config.jsrev;
 
+        var moodleVersion = config.jsrev;
         if (moodleVersion != cacheVersion) {
             this.storage.clear();
             this.storage.setItem(this.jsrevPrefix, config.jsrev);
@@ -132,7 +133,6 @@ define(['core/config'], function(config) {
         if (!this.supported) {
             return false;
         }
-        this.validateCache();
         key = this.prefixKey(key);
 
         return this.storage.getItem(key);
@@ -150,7 +150,6 @@ define(['core/config'], function(config) {
         if (!this.supported) {
             return false;
         }
-        this.validateCache();
         key = this.prefixKey(key);
         // This can throw exceptions when the storage limit is reached.
         try {
index a6cca8a..cd8f0dc 100644 (file)
@@ -474,6 +474,21 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
         return get_class($this->getSession()->getDriver()) !== 'Behat\Mink\Driver\GoutteDriver';
     }
 
+    /**
+     * 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->getSession()->evaluateScript('typeof window.behat') === 'object';
+    }
+
     /**
      * Spins around an element until it exists
      *
@@ -647,6 +662,16 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
         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
index 0070634..2d23185 100644 (file)
@@ -219,6 +219,12 @@ class behat_command {
             return BEHAT_EXITCODE_CONFIG;
         }
 
+        // If app config is supplied, check the value is correct.
+        if (!empty($CFG->behat_ionic_dirroot) && !file_exists($CFG->behat_ionic_dirroot . '/ionic.config.json')) {
+            self::output_msg(get_string('errorapproot', 'tool_behat'));
+            return BEHAT_EXITCODE_CONFIG;
+        }
+
         return 0;
     }
 
index e393580..6738ef0 100644 (file)
@@ -620,6 +620,42 @@ class behat_config_util {
 
         // Check suite values.
         $behatprofilesuites = array();
+
+        // Automatically set tags information to skip app testing if necessary. We skip app testing
+        // if the browser is not Chrome. (Note: We also skip if it's not configured, but that is
+        // done on the theme/suite level.)
+        if (empty($values['browser']) || $values['browser'] !== 'chrome') {
+            if (!empty($values['tags'])) {
+                $values['tags'] .= ' && ~@app';
+            } else {
+                $values['tags'] = '~@app';
+            }
+        }
+
+        // Automatically add Chrome command line option to skip the prompt about allowing file
+        // storage - needed for mobile app testing (won't hurt for everything else either).
+        if (!empty($values['browser']) && $values['browser'] === 'chrome') {
+            if (!isset($values['capabilities'])) {
+                $values['capabilities'] = [];
+            }
+            if (!isset($values['capabilities']['chrome'])) {
+                $values['capabilities']['chrome'] = [];
+            }
+            if (!isset($values['capabilities']['chrome']['switches'])) {
+                $values['capabilities']['chrome']['switches'] = [];
+            }
+            $values['capabilities']['chrome']['switches'][] = '--unlimited-storage';
+
+            // If the mobile app is enabled, check its version and add appropriate tags.
+            if ($mobiletags = $this->get_mobile_version_tags()) {
+                if (!empty($values['tags'])) {
+                    $values['tags'] .= ' && ' . $mobiletags;
+                } else {
+                    $values['tags'] = $mobiletags;
+                }
+            }
+        }
+
         // Fill tags information.
         if (isset($values['tags'])) {
             $behatprofilesuites = array(
@@ -658,6 +694,102 @@ class behat_config_util {
         return array($profile => array_merge($behatprofilesuites, $behatprofileextension));
     }
 
+    /**
+     * Gets version tags to use for the mobile app.
+     *
+     * This is based on the current mobile app version (from its package.json) and all known
+     * mobile app versions (based on the list appversions.json in the lib/behat directory).
+     *
+     * @param bool $verbose If true, outputs information about installed app version
+     * @return string List of tags or '' if not supporting mobile
+     */
+    protected function get_mobile_version_tags($verbose = true) : string {
+        global $CFG;
+
+        if (!empty($CFG->behat_ionic_dirroot)) {
+            // Get app version from package.json.
+            $jsonpath = $CFG->behat_ionic_dirroot . '/package.json';
+            $json = @file_get_contents($jsonpath);
+            if (!$json) {
+                throw new coding_exception('Unable to load app version from ' . $jsonpath);
+            }
+            $package = json_decode($json);
+            if ($package === null || empty($package->version)) {
+                throw new coding_exception('Invalid app package data in ' . $jsonpath);
+            }
+            $installedversion = $package->version;
+        } else if (!empty($CFG->behat_ionic_wwwroot)) {
+            // Get app version from config.json inside wwwroot.
+            $jsonurl = $CFG->behat_ionic_wwwroot . '/config.json';
+            $json = @download_file_content($jsonurl);
+            if (!$json) {
+                throw new coding_exception('Unable to load app version from ' . $jsonurl);
+            }
+            $config = json_decode($json);
+            if ($config === null || empty($config->versionname)) {
+                throw new coding_exception('Invalid app config data in ' . $jsonurl);
+            }
+            $installedversion = str_replace('-dev', '', $config->versionname);
+        } else {
+            return '';
+        }
+
+        // Read all feature files to check which mobile tags are used. (Note: This could be cached
+        // but ideally, it is the sort of thing that really ought to be refreshed by doing a new
+        // Behat init. Also, at time of coding it only takes 0.3 seconds and only if app enabled.)
+        $usedtags = [];
+        foreach ($this->features as $filepath) {
+            $feature = file_get_contents($filepath);
+            // This may incorrectly detect versions used e.g. in a comment or something, but it
+            // doesn't do much harm if we have extra ones.
+            if (preg_match_all('~@app_(?:from|upto)(?:[0-9]+(?:\.[0-9]+)*)~', $feature, $matches)) {
+                foreach ($matches[0] as $tag) {
+                    // Store as key in array so we don't get duplicates.
+                    $usedtags[$tag] = true;
+                }
+            }
+        }
+
+        // Set up relevant tags for each version.
+        $tags = [];
+        foreach ($usedtags as $usedtag => $ignored) {
+            if (!preg_match('~^@app_(from|upto)([0-9]+(?:\.[0-9]+)*)$~', $usedtag, $matches)) {
+                throw new coding_exception('Unexpected tag format');
+            }
+            $direction = $matches[1];
+            $version = $matches[2];
+
+            switch (version_compare($installedversion, $version)) {
+                case -1:
+                    // Installed version OLDER than the one being considered, so do not
+                    // include any scenarios that only run from the considered version up.
+                    if ($direction === 'from') {
+                        $tags[] = '~@app_from' . $version;
+                    }
+                    break;
+
+                case 0:
+                    // Installed version EQUAL to the one being considered - no tags need
+                    // excluding.
+                    break;
+
+                case 1:
+                    // Installed version NEWER than the one being considered, so do not
+                    // include any scenarios that only run up to that version.
+                    if ($direction === 'upto') {
+                        $tags[] = '~@app_upto' . $version;
+                    }
+                    break;
+            }
+        }
+
+        if ($verbose) {
+            mtrace('Configured app tests for version ' . $installedversion);
+        }
+
+        return join(' && ', $tags);
+    }
+
     /**
      * Attempt to split feature list into fairish buckets using timing information, if available.
      * Simply add each one to lightest buckets until all files allocated.
@@ -1237,12 +1369,20 @@ class behat_config_util {
      * @return array ($blacklistfeatures, $blacklisttags, $features)
      */
     protected function get_behat_features_for_theme($theme) {
+        global $CFG;
 
         // Get list of features defined by theme.
         $themefeatures = $this->get_tests_for_theme($theme, 'features');
         $themeblacklistfeatures = $this->get_blacklisted_tests_for_theme($theme, 'features');
         $themeblacklisttags = $this->get_blacklisted_tests_for_theme($theme, 'tags');
 
+        // Mobile app tests are not theme-specific, so run only for the default theme (and if
+        // configured).
+        if ((empty($CFG->behat_ionic_dirroot) && empty($CFG->behat_ionic_wwwroot)) ||
+                $theme !== $this->get_default_theme()) {
+            $themeblacklisttags[] = '@app';
+        }
+
         // Clean feature key and path.
         $features = array();
         $blacklistfeatures = array();
index d6da75c..bb011e0 100644 (file)
@@ -136,6 +136,16 @@ class behat_form_field {
         return $instance->matches($expectedvalue);
     }
 
+    /**
+     * Get the value of an attribute set on this field.
+     *
+     * @param string $name The attribute name
+     * @return string The attribute value
+     */
+    public function get_attribute($name) {
+        return $this->field->getAttribute($name);
+    }
+
     /**
      * Guesses the element type we are dealing with in case is not a text-based element.
      *
index 46184a0..5b87b68 100644 (file)
@@ -122,7 +122,8 @@ class courses extends \core_analytics\local\analyser\by_course {
      * @return array array(string, \renderable)
      */
     public function sample_description($sampleid, $contextid, $sampledata) {
-        $description = format_string($sampledata['course']->fullname, true, array('context' => $sampledata['context']));
+        $description = format_string(
+            get_course_display_name_for_list($sampledata['course']), true, array('context' => $sampledata['context']));
         $courseimage = new \pix_icon('i/course', get_string('course'));
         return array($description, $courseimage);
     }
index b2fdc0f..aa3b50e 100644 (file)
@@ -131,7 +131,8 @@ class site_courses extends \core_analytics\local\analyser\sitewide {
      * @return array array(string, \renderable)
      */
     public function sample_description($sampleid, $contextid, $sampledata) {
-        $description = format_string($sampledata['course']->fullname, true, array('context' => $sampledata['context']));
+        $description = format_string(
+            get_course_display_name_for_list($sampledata['course']), true, array('context' => $sampledata['context']));
         $courseimage = new \pix_icon('i/course', get_string('course'));
         return array($description, $courseimage);
     }
index 7a08841..9c0593c 100644 (file)
@@ -104,7 +104,10 @@ $messageproviders = array (
 
     // User insights.
     'insights' => array (
-         'capability'  => 'moodle/analytics:listinsights'
+        'defaults' => [
+            'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF,
+            'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF,
+        ]
     ),
 
     // Message contact requests.
index cf8ad44..e9bbae5 100644 (file)
@@ -2719,5 +2719,14 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2019011801.03);
     }
 
+    if ($oldversion < 2019021500.01) {
+        $insights = $DB->get_record('message_providers', ['component' => 'moodle', 'name' => 'insights']);
+        if (!empty($insights)) {
+            $insights->capability = null;
+            $DB->update_record('message_providers', $insights);
+        }
+        upgrade_main_savepoint(true, 2019021500.01);
+    }
+
     return true;
 }
index b600418..2823529 100644 (file)
@@ -4,6 +4,7 @@ Feature: Add media to Atto
 
   Background:
     Given I log in as "admin"
+    And I change window size to "large"
     And I follow "Manage private files..."
     And I upload "lib/editor/atto/tests/fixtures/moodle-logo.webm" file to "Files" filemanager
     And I upload "lib/editor/atto/tests/fixtures/moodle-logo.mp4" file to "Files" filemanager
@@ -58,7 +59,6 @@ Feature: Add media to Atto
     And I click on "Private files" "link" in the ".moodle-dialogue-base[aria-hidden='false'] .fp-repo-area" "css_element"
     And I click on "moodle-logo.png" "link"
     And I click on "Select this file" "button" in the ".moodle-dialogue-base[aria-hidden='false']" "css_element"
-    And I change window size to "large"
     And I set the field with xpath "//*[contains(concat(' ', normalize-space(@class), ' '), ' atto_media_width_entry ')]" to "420"
     And I set the field with xpath "//*[contains(concat(' ', normalize-space(@class), ' '), ' atto_media_height_entry ')]" to "69"
     And I set the field "Enter title" to "VideoTitle"
@@ -84,7 +84,6 @@ Feature: Add media to Atto
   @javascript @atto_media_video
   Scenario: Insert some media as a video with tracks
     Given I click on "Video" "link"
-    And I change window size to "large"
     And I click on "Browse repositories..." "button" in the "#id_summary_editor_video .atto_media_source.atto_media_media_source" "css_element"
     And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
     And I click on "moodle-logo.webm" "link"
@@ -201,4 +200,4 @@ Feature: Add media to Atto
     And I set the field "audio_media-mute-toggle" to "1"
     And I set the field "audio_media-loop-toggle" to "1"
     When I click on "Insert media" "button"
-    Then "//audio[descendant::source[contains(@src, 'moodle-logo.mp4')]][@controls='true'][@loop='true'][@autoplay='true'][@autoplay='true']" "xpath_element" should exist
\ No newline at end of file
+    Then "//audio[descendant::source[contains(@src, 'moodle-logo.mp4')]][@controls='true'][@loop='true'][@autoplay='true'][@autoplay='true']" "xpath_element" should exist
diff --git a/lib/form/amd/build/showadvanced.min.js b/lib/form/amd/build/showadvanced.min.js
new file mode 100644 (file)
index 0000000..fb4ccde
Binary files /dev/null and b/lib/form/amd/build/showadvanced.min.js differ
diff --git a/lib/form/amd/src/showadvanced.js b/lib/form/amd/src/showadvanced.js
new file mode 100644 (file)
index 0000000..a9f9645
--- /dev/null
@@ -0,0 +1,219 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A class to help show and hide advanced form content.
+ *
+ * @module     core_form/showadvanced
+ * @class      showadvanced
+ * @package    core_form
+ * @copyright  2016 Damyon Wiese <damyon@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+define(['jquery', 'core/log', 'core/str', 'core/notification'], function($, Log, Strings, Notification) {
+
+    var SELECTORS = {
+            FIELDSETCONTAINSADVANCED: 'fieldset.containsadvancedelements',
+            DIVFITEMADVANCED: 'div.fitem.advanced',
+            DIVFCONTAINER: 'div.fcontainer',
+            MORELESSLINK: 'fieldset.containsadvancedelements .moreless-toggler'
+        },
+        CSS = {
+            SHOW: 'show',
+            MORELESSACTIONS: 'moreless-actions',
+            MORELESSTOGGLER: 'moreless-toggler',
+            SHOWLESS: 'moreless-less'
+        },
+        WRAPPERS = {
+            FITEM: '<div class="fitem"></div>',
+            FELEMENT: '<div class="felement"></div>'
+        },
+        IDPREFIX = 'showadvancedid-';
+
+    /** @type {Integer} uniqIdSeed Auto incrementing number used to generate ids. */
+    var uniqIdSeed = 0;
+
+    /**
+     * ShowAdvanced behaviour class.
+     * @param {String} id The id of the form.
+     */
+    var ShowAdvanced = function(id) {
+        this.id = id;
+
+        var form = $(document.getElementById(id));
+        this.enhanceForm(form);
+    };
+
+    /** @type {String} id The form id to enhance. */
+    ShowAdvanced.prototype.id = '';
+
+    /**
+     * @method enhanceForm
+     * @param {JQuery} form JQuery selector representing the form
+     * @return {ShowAdvanced}
+     */
+    ShowAdvanced.prototype.enhanceForm = function(form) {
+        var fieldsets = form.find(SELECTORS.FIELDSETCONTAINSADVANCED);
+
+        // Enhance each fieldset in the form matching the selector.
+        fieldsets.each(function(index, item) {
+            this.enhanceFieldset($(item));
+        }.bind(this));
+
+        // Attach some event listeners.
+        // Subscribe more/less links to click event.
+        form.on('click', SELECTORS.MORELESSLINK, this.switchState);
+
+        // Subscribe to key events but filter for space or enter.
+        form.on('keydown', SELECTORS.MORELESSLINK, function(e) {
+            // Enter or space.
+            if (e.which == 13 || e.which == 32) {
+                return this.switchState(e);
+            }
+            return true;
+        }.bind(this));
+        return this;
+    };
+
+
+    /**
+     * Generates a uniq id for the dom element it's called on unless the element already has an id.
+     * The id is set on the dom node before being returned.
+     *
+     * @method generateId
+     * @param {JQuery} node JQuery selector representing a single DOM Node.
+     * @return {String}
+     */
+    ShowAdvanced.prototype.generateId = function(node) {
+        var id = node.prop('id');
+        if (typeof id === 'undefined') {
+            id = IDPREFIX + (uniqIdSeed++);
+            node.prop('id', id);
+        }
+        return id;
+    };
+
+    /**
+     * @method enhanceFieldset
+     * @param {JQuery} fieldset JQuery selector representing a fieldset
+     * @return {ShowAdvanced}
+     */
+    ShowAdvanced.prototype.enhanceFieldset = function(fieldset) {
+        var statuselement = $('input[name=mform_showmore_' + fieldset.prop('id') + ']');
+        if (!statuselement.length) {
+            Log.debug("M.form.showadvanced::processFieldset was called on an fieldset without a status field: '" +
+                fieldset.prop('id') + "'");
+            return this;
+        }
+
+        // Fetch some strings.
+        Strings.get_strings([{
+            key: 'showmore',
+            component: 'core_form'
+        }, {
+            key: 'showless',
+            component: 'core_form'
+        }]).then(function(results) {
+            var showmore = results[0],
+                showless = results[1];
+
+            // Generate more/less links.
+            var morelesslink = $('<a href="#"></a>');
+            morelesslink.addClass(CSS.MORELESSTOGGLER);
+            if (statuselement.val() === '0') {
+                morelesslink.html(showmore);
+            } else {
+                morelesslink.html(showless);
+                morelesslink.addClass(CSS.SHOWLESS);
+                fieldset.find(SELECTORS.DIVFITEMADVANCED).addClass(CSS.SHOW);
+            }
+            // Build a list of advanced fieldsets.
+            var idlist = [];
+            fieldset.find(SELECTORS.DIVFITEMADVANCED).each(function(index, node) {
+                idlist[idlist.length] = this.generateId($(node));
+            }.bind(this));
+
+            // Set aria attributes.
+            morelesslink.attr('role', 'button');
+            morelesslink.attr('aria-controls', idlist.join(' '));
+
+            // Add elements to the DOM.
+            var fitem = $(WRAPPERS.FITEM);
+            fitem.addClass(CSS.MORELESSACTIONS);
+            var felement = $(WRAPPERS.FELEMENT);
+            felement.append(morelesslink);
+            fitem.append(felement);
+
+            fieldset.find(SELECTORS.DIVFCONTAINER).append(fitem);
+            return true;
+        }.bind(this)).fail(Notification.exception);
+
+        return this;
+    };
+
+    /**
+     * @method switchState
+     * @param {Event} e Event that triggered this action.
+     * @return {Boolean}
+     */
+    ShowAdvanced.prototype.switchState = function(e) {
+        e.preventDefault();
+
+        // Fetch some strings.
+        Strings.get_strings([{
+            key: 'showmore',
+            component: 'core_form'
+        }, {
+            key: 'showless',
+            component: 'core_form'
+        }]).then(function(results) {
+            var showmore = results[0],
+                showless = results[1],
+                fieldset = $(e.target).closest(SELECTORS.FIELDSETCONTAINSADVANCED);
+
+            // Toggle collapsed class.
+            fieldset.find(SELECTORS.DIVFITEMADVANCED).toggleClass(CSS.SHOW);
+
+            // Get corresponding hidden variable.
+            var statuselement = $('input[name=mform_showmore_' + fieldset.prop('id') + ']');
+
+            // Invert it and change the link text.
+            if (statuselement.val() === '0') {
+                statuselement.val(1);
+                $(e.target).addClass(CSS.SHOWLESS);
+                $(e.target).html(showless);
+            } else {
+                statuselement.val(0);
+                $(e.target).removeClass(CSS.SHOWLESS);
+                $(e.target).html(showmore);
+            }
+            return true;
+        }).fail(Notification.exception);
+
+        return this;
+    };
+
+    return {
+        /**
+         * Initialise this module.
+         * @method init
+         * @param {String} formid
+         * @return {ShowAdvanced}
+         */
+        init: function(formid) {
+            return new ShowAdvanced(formid);
+        }
+    };
+});
index 380bd30..b843629 100644 (file)
@@ -349,9 +349,9 @@ class MoodleQuickForm_modgrade extends MoodleQuickForm_group {
                 $name = $arg[0];
 
                 // Set disable actions.
-                $caller->disabledIf($name.'[modgrade_scale]', $name.'[modgrade_type]', 'neq', 'scale');
-                $caller->disabledIf($name.'[modgrade_point]', $name.'[modgrade_type]', 'neq', 'point');
-                $caller->disabledIf($name.'[modgrade_rescalegrades]', $name.'[modgrade_type]', 'neq', 'point');
+                $caller->hideIf($name.'[modgrade_scale]', $name.'[modgrade_type]', 'neq', 'scale');
+                $caller->hideIf($name.'[modgrade_point]', $name.'[modgrade_type]', 'neq', 'point');
+                $caller->hideIf($name.'[modgrade_rescalegrades]', $name.'[modgrade_type]', 'neq', 'point');
 
                 // Set validation rules for the sub-elements belonging to this element.
                 // A handy note: the parent scope of a closure is the function in which the closure was declared.
diff --git a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-debug.js b/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-debug.js
deleted file mode 100644 (file)
index 4a032e0..0000000
Binary files a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-debug.js and /dev/null differ
diff --git a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-min.js b/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-min.js
deleted file mode 100644 (file)
index 8cfbaa1..0000000
Binary files a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced-min.js and /dev/null differ
diff --git a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced.js b/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced.js
deleted file mode 100644 (file)
index 6c2ee05..0000000
Binary files a/lib/form/yui/build/moodle-form-showadvanced/moodle-form-showadvanced.js and /dev/null differ
diff --git a/lib/form/yui/src/showadvanced/build.json b/lib/form/yui/src/showadvanced/build.json
deleted file mode 100644 (file)
index 2f81737..0000000
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-    "name": "moodle-form-showadvanced",
-    "builds": {
-        "moodle-form-showadvanced": {
-            "jsfiles": [
-                "showadvanced.js"
-            ]
-        }
-    }
-}
diff --git a/lib/form/yui/src/showadvanced/js/showadvanced.js b/lib/form/yui/src/showadvanced/js/showadvanced.js
deleted file mode 100644 (file)
index 564f86f..0000000
+++ /dev/null
@@ -1,146 +0,0 @@
-/**
- * Provides the form showadvanced class.
- *
- * @module moodle-form-showadvanced
- */
-
-/**
- * A class to help show and hide advanced form content.
- *
- * @class M.form.showadvanced
- * @constructor
- * @extends Base
- */
-function SHOWADVANCED() {
-    SHOWADVANCED.superclass.constructor.apply(this, arguments);
-}
-
-var SELECTORS = {
-        FIELDSETCONTAINSADVANCED: 'fieldset.containsadvancedelements',
-        DIVFITEMADVANCED: 'div.fitem.advanced',
-        DIVFCONTAINER: 'div.fcontainer',
-        MORELESSLINK: 'fieldset.containsadvancedelements .moreless-toggler'
-    },
-    CSS = {
-        SHOW: 'show',
-        MORELESSACTIONS: 'moreless-actions',
-        MORELESSTOGGLER: 'moreless-toggler',
-        SHOWLESS: 'moreless-less'
-    },
-    WRAPPERS = {
-        FITEM: '<div class="fitem"></div>',
-        FELEMENT: '<div class="felement"></div>'
-    },
-    ATTRS = {};
-
-/**
- * The form ID attribute definition.
- *
- * @attribute formid
- * @type String
- * @default null
- * @writeOnce
- */
-ATTRS.formid = {
-    value: null
-};
-
-Y.extend(SHOWADVANCED, Y.Base, {
-    /**
-     * The initializer for the showadvanced instance.
-     *
-     * @method initializer
-     * @protected
-     */
-    initializer: function() {
-        var form = Y.one('#' + this.get('formid')),
-            fieldlist = form.all(SELECTORS.FIELDSETCONTAINSADVANCED);
-
-        // Look through fieldset divs that contain advanced elements.
-        fieldlist.each(this.processFieldset, this);
-
-        // Subscribe more/less links to click event.
-        form.delegate('click', this.switchState, SELECTORS.MORELESSLINK);
-        form.delegate('key', this.switchState, 'down:enter,32', SELECTORS.MORELESSLINK);
-    },
-
-    /**
-     * Process the supplied fieldset to add appropriate links, and ARIA roles.
-     *
-     * @method processFieldset
-     * @param {Node} fieldset The Node relating to the fieldset to add collapsing to.
-     * @chainable
-     */
-    processFieldset: function(fieldset) {
-        var statuselement = Y.one('input[name=mform_showmore_' + fieldset.get('id') + ']');
-        if (!statuselement) {
-            Y.log("M.form.showadvanced::processFieldset was called on an fieldset without a status field: '" +
-                fieldset.get('id') + "'", 'debug', 'moodle-form-showadvanced');
-            return this;
-        }
-
-        var morelesslink = Y.Node.create('<a href="#"></a>');
-        morelesslink.addClass(CSS.MORELESSTOGGLER);
-        if (statuselement.get('value') === '0') {
-            morelesslink.setHTML(M.util.get_string('showmore', 'form'));
-        } else {
-            morelesslink.setHTML(M.util.get_string('showless', 'form'));
-            morelesslink.addClass(CSS.SHOWLESS);
-            fieldset.all(SELECTORS.DIVFITEMADVANCED).addClass(CSS.SHOW);
-        }
-
-        // Get list of IDs controlled by this button to set the aria-controls attribute.
-        var idlist = [];
-        fieldset.all(SELECTORS.DIVFITEMADVANCED).each(function(node) {
-            idlist[idlist.length] = node.generateID();
-        });
-        morelesslink.setAttribute('role', 'button');
-        morelesslink.setAttribute('aria-controls', idlist.join(' '));
-
-        var fitem = Y.Node.create(WRAPPERS.FITEM);
-        fitem.addClass(CSS.MORELESSACTIONS);
-        var felement = Y.Node.create(WRAPPERS.FELEMENT);
-        felement.append(morelesslink);
-        fitem.append(felement);
-
-        fieldset.one(SELECTORS.DIVFCONTAINER).append(fitem);
-
-        return this;
-    },
-
-    /**
-     * Toggle the state for the fieldset that was clicked.
-     *
-     * @method switchState
-     * @param {EventFacade} e
-     */
-    switchState: function(e) {
-        e.preventDefault();
-        var fieldset = this.ancestor(SELECTORS.FIELDSETCONTAINSADVANCED);
-
-        // Toggle collapsed class.
-        fieldset.all(SELECTORS.DIVFITEMADVANCED).toggleClass(CSS.SHOW);
-
-        // Get corresponding hidden variable.
-        var statuselement = Y.one('input[name=mform_showmore_' + fieldset.get('id') + ']');
-
-        // Invert it and change the link text.
-        if (statuselement.get('value') === '0') {
-            statuselement.set('value', 1);
-            this.addClass(CSS.SHOWLESS);
-            this.setHTML(M.util.get_string('showless', 'form'));
-        } else {
-            statuselement.set('value', 0);
-            this.removeClass(CSS.SHOWLESS);
-            this.setHTML(M.util.get_string('showmore', 'form'));
-        }
-    }
-}, {
-    NAME: 'moodle-form-showadvanced',
-    ATTRS: ATTRS
-});
-
-M.form = M.form || {};
-M.form.showadvanced = M.form.showadvanced || function(params) {
-    return new SHOWADVANCED(params);
-};
diff --git a/lib/form/yui/src/showadvanced/meta/showadvanced.json b/lib/form/yui/src/showadvanced/meta/showadvanced.json
deleted file mode 100644 (file)
index befdc20..0000000
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-    "moodle-form-showadvanced": {
-        "requires": [
-            "node",
-            "base",
-            "selector-css3"
-        ]
-    }
-}
index caa39ef..acd0b21 100644 (file)
@@ -2919,8 +2919,7 @@ class MoodleQuickForm_Renderer extends HTML_QuickForm_Renderer_Tableless{
             $PAGE->requires->yui_module('moodle-form-shortforms', 'M.form.shortforms', array(array('formid' => $formid)));
         }
         if (!empty($this->_advancedElements)){
-            $PAGE->requires->strings_for_js(array('showmore', 'showless'), 'form');
-            $PAGE->requires->yui_module('moodle-form-showadvanced', 'M.form.showadvanced', array(array('formid' => $formid)));
+            $PAGE->requires->js_call_amd('core_form/showadvanced', 'init', [$formid]);
         }
     }
 
index 864719a..3c44836 100644 (file)
@@ -6436,8 +6436,10 @@ function send_password_change_info($user) {
 function email_is_not_allowed($email) {
     global $CFG;
 
+    // Comparing lowercase domains.
+    $email = strtolower($email);
     if (!empty($CFG->allowemailaddresses)) {
-        $allowed = explode(' ', $CFG->allowemailaddresses);
+        $allowed = explode(' ', strtolower($CFG->allowemailaddresses));
         foreach ($allowed as $allowedpattern) {
             $allowedpattern = trim($allowedpattern);
             if (!$allowedpattern) {
@@ -6456,7 +6458,7 @@ function email_is_not_allowed($email) {
         return get_string('emailonlyallowed', '', $CFG->allowemailaddresses);
 
     } else if (!empty($CFG->denyemailaddresses)) {
-        $denied = explode(' ', $CFG->denyemailaddresses);
+        $denied = explode(' ', strtolower($CFG->denyemailaddresses));
         foreach ($denied as $deniedpattern) {
             $deniedpattern = trim($deniedpattern);
             if (!$deniedpattern) {
@@ -8008,6 +8010,28 @@ function moodle_major_version($fromdisk = false) {
 
 // MISCELLANEOUS.
 
+/**
+ * Gets the system locale
+ *
+ * @return string Retuns the current locale.
+ */
+function moodle_getlocale() {
+    global $CFG;
+
+    // Fetch the correct locale based on ostype.
+    if ($CFG->ostype == 'WINDOWS') {
+        $stringtofetch = 'localewin';
+    } else {
+        $stringtofetch = 'locale';
+    }
+
+    if (!empty($CFG->locale)) { // Override locale for all language packs.
+        return $CFG->locale;
+    }
+
+    return get_string($stringtofetch, 'langconfig');
+}
+
 /**
  * Sets the system locale
  *
@@ -8021,20 +8045,11 @@ function moodle_setlocale($locale='') {
 
     $oldlocale = $currentlocale;
 
-    // Fetch the correct locale based on ostype.
-    if ($CFG->ostype == 'WINDOWS') {
-        $stringtofetch = 'localewin';
-    } else {
-        $stringtofetch = 'locale';
-    }
-
     // The priority is the same as in get_string() - parameter, config, course, session, user, global language.
     if (!empty($locale)) {
         $currentlocale = $locale;
-    } else if (!empty($CFG->locale)) { // Override locale for all language packs.
-        $currentlocale = $CFG->locale;
     } else {
-        $currentlocale = get_string($stringtofetch, 'langconfig');
+        $currentlocale = moodle_getlocale();
     }
 
     // Do nothing if locale already set up.
index 93d32b5..1090652 100644 (file)
@@ -430,15 +430,16 @@ class navigation_node implements renderable {
      *
      * @param flat_navigation $nodes List of the found flat navigation nodes.
      * @param boolean $showdivider Show a divider before the first node.
+     * @param string $label A label for the collection of navigation links.
      */
-    public function build_flat_navigation_list(flat_navigation $nodes, $showdivider = false) {
+    public function build_flat_navigation_list(flat_navigation $nodes, $showdivider = false, $label = '') {
         if ($this->showinflatnavigation) {
             $indent = 0;
             if ($this->type == self::TYPE_COURSE || $this->key === self::COURSE_INDEX_PAGE) {
                 $indent = 1;
             }
             $flat = new flat_navigation_node($this, $indent);
-            $flat->set_showdivider($showdivider);
+            $flat->set_showdivider($showdivider, $label);
             $nodes->add($flat);
         }
         foreach ($this->children as $child) {
@@ -913,6 +914,12 @@ class navigation_node_collection implements IteratorAggregate, Countable {
      */
     protected $count = 0;
 
+    /**
+     * Label for collection of nodes.
+     * @var string
+     */
+    protected $collectionlabel = '';
+
     /**
      * Adds a navigation node to the collection
      *
@@ -988,6 +995,24 @@ class navigation_node_collection implements IteratorAggregate, Countable {
         return $keys;
     }
 
+    /**
+     * Set a label for this collection.
+     *
+     * @param string $label
+     */
+    public function set_collectionlabel($label) {
+        $this->collectionlabel = $label;
+    }
+
+    /**
+     * Return a label for this collection.
+     *
+     * @return string
+     */
+    public function get_collectionlabel() {
+        return $this->collectionlabel;
+    }
+
     /**
      * Fetches a node from this collection.
      *
@@ -3770,6 +3795,9 @@ class flat_navigation_node extends navigation_node {
     /** @var $showdivider bool Show a divider before this element */
     private $showdivider = false;
 
+    /** @var $collectionlabel string Label for a group of nodes */
+    private $collectionlabel = '';
+
     /**
      * A proxy constructor
      *
@@ -3791,6 +3819,31 @@ class flat_navigation_node extends navigation_node {
         $this->indent = $indent;
     }
 
+    /**
+     * Setter, a label is required for a flat navigation node that shows a divider.
+     *
+     * @param string $label
+     */
+    public function set_collectionlabel($label) {
+        $this->collectionlabel = $label;
+    }
+
+    /**
+     * Getter, get the label for this flat_navigation node, or it's parent if it doesn't have one.
+     *
+     * @return string
+     */
+    public function get_collectionlabel() {
+        if (!empty($this->collectionlabel)) {
+            return $this->collectionlabel;
+        }
+        if ($this->parent && ($this->parent instanceof flat_navigation_node || $this->parent instanceof flat_navigation)) {
+            return $this->parent->get_collectionlabel();
+        }
+        debugging('Navigation region requires a label', DEBUG_DEVELOPER);
+        return '';
+    }
+
     /**
      * Does this node represent a course section link.
      * @return boolean
@@ -3828,9 +3881,15 @@ class flat_navigation_node extends navigation_node {
     /**
      * Setter for "showdivider"
      * @param $val boolean
+     * @param $label string Label for the group of nodes
      */
-    public function set_showdivider($val) {
+    public function set_showdivider($val, $label = '') {
         $this->showdivider = $val;
+        if ($this->showdivider && empty($label)) {
+            debugging('Navigation region requires a label', DEBUG_DEVELOPER);
+        } else {
+            $this->set_collectionlabel($label);
+        }
     }
 
     /**
@@ -3848,7 +3907,6 @@ class flat_navigation_node extends navigation_node {
     public function set_indent($val) {
         $this->indent = $val;
     }
-
 }
 
 /**
@@ -3903,6 +3961,7 @@ class flat_navigation extends navigation_node_collection {
                 format_string($course->fullname, true, array('context' => $coursecontext));
 
             $flat = new flat_navigation_node(navigation_node::create($coursename, $url), 0);
+            $flat->set_collectionlabel($coursename);
             $flat->key = 'coursehome';
             $flat->icon = new pix_icon('i/course', '');
 
@@ -3930,9 +3989,9 @@ class flat_navigation extends navigation_node_collection {
                 }
             }
 
-            $this->page->navigation->build_flat_navigation_list($this, true);
+            $this->page->navigation->build_flat_navigation_list($this, true, get_string('site'));
         } else {
-            $this->page->navigation->build_flat_navigation_list($this, false);
+            $this->page->navigation->build_flat_navigation_list($this, false, get_string('site'));
         }
 
         $admin = $PAGE->settingsnav->find('siteadministration', navigation_node::TYPE_SITE_ADMIN);
@@ -3942,7 +4001,7 @@ class flat_navigation extends navigation_node_collection {
         }
         if ($admin) {
             $flat = new flat_navigation_node($admin, 0);
-            $flat->set_showdivider(true);
+            $flat->set_showdivider(true, get_string('sitesettings'));
             $flat->key = 'sitesettings';
             $flat->icon = new pix_icon('t/preferences', '');
             $this->add($flat);
@@ -3956,7 +4015,7 @@ class flat_navigation extends navigation_node_collection {
             $url = new moodle_url($PAGE->url, ['bui_addblock' => '', 'sesskey' => sesskey()]);
             $addablock = navigation_node::create(get_string('addblock'), $url);
             $flat = new flat_navigation_node($addablock, 0);
-            $flat->set_showdivider(true);
+            $flat->set_showdivider(true, get_string('blocksaddedit'));
             $flat->key = 'addblock';
             $flat->icon = new pix_icon('i/addblock', '');
             $this->add($flat);
@@ -3969,6 +4028,26 @@ class flat_navigation extends navigation_node_collection {
         }
     }
 
+
+    /**
+     * Override the parent so we can set a label for this collection if it has not been set yet.
+     *
+     * @param navigation_node $node Node to add
+     * @param string $beforekey If specified, adds before a node with this key,
+     *   otherwise adds at end
+     * @return navigation_node Added node
+     */
+    public function add(navigation_node $node, $beforekey=null) {
+        $result = parent::add($node, $beforekey);
+        // Extend the parent to get a name for the collection of nodes if required.
+        if (empty($this->collectionlabel)) {
+            if ($node instanceof flat_navigation_node) {
+                $this->set_collectionlabel($node->get_collectionlabel());
+            }
+        }
+
+        return $result;
+    }
 }
 
 /**
index a3d110e..40a726f 100644 (file)
@@ -2606,9 +2606,16 @@ class core_renderer extends renderer_base {
 
         $src = $userpicture->get_url($this->page, $this);
 
-        $attributes = array('src'=>$src, 'alt'=>$alt, 'title'=>$alt, 'class'=>$class, 'width'=>$size, 'height'=>$size);
+        $attributes = array('src' => $src, 'class' => $class, 'width' => $size, 'height' => $size);
         if (!$userpicture->visibletoscreenreaders) {
             $attributes['role'] = 'presentation';
+            $alt = '';
+            $attributes['aria-hidden'] = 'true';
+        }
+
+        if (!empty($alt)) {
+            $attributes['alt'] = $alt;
+            $attributes['title'] = $alt;
         }
 
         // get the image html output fisrt
index 9cf4735..c7c0aa1 100644 (file)
@@ -1326,6 +1326,9 @@ class page_requirements_manager {
     protected function get_amd_footercode() {
         global $CFG;
         $output = '';
+
+        // We will cache JS if cachejs is not set, or it is true.
+        $cachejs = !isset($CFG->cachejs) || $CFG->cachejs;
         $jsrev = $this->get_jsrev();
 
         $jsloader = new moodle_url('/lib/javascript.php');
@@ -1341,15 +1344,21 @@ class page_requirements_manager {
             $jsextension = '';
         }
 
+        $minextension = '.min';
+        if (!$cachejs) {
+            $minextension = '';
+        }
+
         $requirejsconfig = str_replace('[BASEURL]', $requirejsloader, $requirejsconfig);
         $requirejsconfig = str_replace('[JSURL]', $jsloader, $requirejsconfig);
+        $requirejsconfig = str_replace('[JSMIN]', $minextension, $requirejsconfig);
         $requirejsconfig = str_replace('[JSEXT]', $jsextension, $requirejsconfig);
 
         $output .= html_writer::script($requirejsconfig);
-        if ($CFG->debugdeveloper) {
-            $output .= html_writer::script('', $this->js_fix_url('/lib/requirejs/require.js'));
-        } else {
+        if ($cachejs) {
             $output .= html_writer::script('', $this->js_fix_url('/lib/requirejs/require.min.js'));
+        } else {
+            $output .= html_writer::script('', $this->js_fix_url('/lib/requirejs/require.js'));
         }
 
         // First include must be to a module with no dependencies, this prevents multiple requests.
index c735be4..f91086f 100644 (file)
@@ -58,7 +58,7 @@ class phpunit_ArrayDataSet extends PHPUnit\DbUnit\DataSet\AbstractDataSet {
                 $columns = array_keys($firstrow);
             }
 
-            $metaData = new PHPUnit\DbUnit\DataSet\DefaultTableMetaData($tableName, $columns);
+            $metaData = new PHPUnit\DbUnit\DataSet\DefaultTableMetadata($tableName, $columns);
             $table = new PHPUnit\DbUnit\DataSet\DefaultTable($metaData);
 
             foreach ($rows AS $row) {
index acce48e..3260250 100644 (file)
@@ -6,8 +6,8 @@ var require = {
     waitSeconds : 0,
 
     paths: {
-        jquery: '[JSURL]lib/jquery/jquery-3.2.1.min[JSEXT]',
-        jqueryui: '[JSURL]lib/jquery/ui-1.12.1/jquery-ui.min[JSEXT]',
+        jquery: '[JSURL]lib/jquery/jquery-3.2.1[JSMIN][JSEXT]',
+        jqueryui: '[JSURL]lib/jquery/ui-1.12.1/jquery-ui[JSMIN][JSEXT]',
         jqueryprivate: '[JSURL]lib/requirejs/jquery-private[JSEXT]'
     },
 
index 7aa51a6..9448fea 100644 (file)
                         </label>
                     </div>
                     <div class="form-input">
-                        <input type="text" name="username" id="username" size="15" value="{{username}}">
+                        <input type="text" name="username" id="username" size="15" value="{{username}}" autocomplete="username">
                     </div>
                     <div class="clearer"><!-- --></div>
                     <div class="form-label">
                         <label for="password">{{#str}} password {{/str}}</label>
                     </div>
                     <div class="form-input">
-                        <input type="password" name="password" id="password" size="15" value="">
+                        <input type="password" name="password" id="password" size="15" value="" autocomplete="current-password">
                     </div>
                 </div>
 
index 7960b33..2015076 100644 (file)
                 {{#isgroup}}
                     <optgroup label="{{name}}">
                         {{#options}}
-                            <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
+                            <option value="{{value}}" {{#selected}}selected{{/selected}}>{{{name}}}</option>
                         {{/options}}
                     </optgroup>
                 {{/isgroup}}
                 {{^isgroup}}
-                    <option value="{{value}}" {{#selected}}selected{{/selected}}>{{name}}</option>
+                    <option value="{{value}}" {{#selected}}selected{{/selected}}>{{{name}}}</option>
                 {{/isgroup}}
             {{/options}}
         </select>
diff --git a/lib/tests/behat/app_behat_runtime.js b/lib/tests/behat/app_behat_runtime.js
new file mode 100644 (file)
index 0000000..d88fa92
--- /dev/null
@@ -0,0 +1,647 @@
+(function() {
+    // Set up the M object - only pending_js is implemented.
+    window.M = window.M ? window.M : {};
+    var M = window.M;
+    M.util = M.util ? M.util : {};
+    M.util.pending_js = M.util.pending_js ? M.util.pending_js : []; // eslint-disable-line camelcase
+
+    /**
+     * Logs information from this Behat runtime JavaScript, including the time and the 'BEHAT'
+     * keyword so we can easily filter for it if needed.
+     *
+     * @param {string} text Information to log
+     */
+    var log = function(text) {
+        var now = new Date();
+        var nowFormatted = String(now.getHours()).padStart(2, '0') + ':' +
+                String(now.getMinutes()).padStart(2, '0') + ':' +
+                String(now.getSeconds()).padStart(2, '0') + '.' +
+                String(now.getMilliseconds()).padStart(2, '0');
+        console.log('BEHAT: ' + nowFormatted + ' ' + text); // eslint-disable-line no-console
+    };
+
+    /**
+     * Run after several setTimeouts to ensure queued events are finished.
+     *
+     * @param {function} target function to run
+     * @param {number} count Number of times to do setTimeout (leave blank for 10)
+     */
+    var runAfterEverything = function(target, count) {
+        if (count === undefined) {
+            count = 10;
+        }
+        setTimeout(function() {
+            count--;
+            if (count == 0) {
+                target();
+            } else {
+                runAfterEverything(target, count);
+            }
+        }, 0);
+    };
+
+    /**
+     * Adds a pending key to the array.
+     *
+     * @param {string} key Key to add
+     */
+    var addPending = function(key) {
+        // Add a special DELAY entry whenever another entry is added.
+        if (window.M.util.pending_js.length == 0) {
+            window.M.util.pending_js.push('DELAY');
+        }
+        window.M.util.pending_js.push(key);
+
+        log('PENDING+: ' + window.M.util.pending_js);
+    };
+
+    /**
+     * Removes a pending key from the array. If this would clear the array, the actual clear only
+     * takes effect after the queued events are finished.
+     *
+     * @param {string} key Key to remove
+     */
+    var removePending = function(key) {
+        // Remove the key immediately.
+        window.M.util.pending_js = window.M.util.pending_js.filter(function(x) { // eslint-disable-line camelcase
+            return x !== key;
+        });
+        log('PENDING-: ' + window.M.util.pending_js);
+
+        // If the only thing left is DELAY, then remove that as well, later...
+        if (window.M.util.pending_js.length === 1) {
+            runAfterEverything(function() {
+                // Check there isn't a spinner...
+                updateSpinner();
+
+                // Only remove it if the pending array is STILL empty after all that.
+                if (window.M.util.pending_js.length === 1) {
+                    window.M.util.pending_js = []; // eslint-disable-line camelcase
+                    log('PENDING-: ' + window.M.util.pending_js);
+                }
+            });
+        }
+    };
+
+    /**
+     * Adds a pending key to the array, but removes it after some setTimeouts finish.
+     */
+    var addPendingDelay = function() {
+        addPending('...');
+        removePending('...');
+    };
+
+    // Override XMLHttpRequest to mark things pending while there is a request waiting.
+    var realOpen = XMLHttpRequest.prototype.open;
+    var requestIndex = 0;
+    XMLHttpRequest.prototype.open = function() {
+        var index = requestIndex++;
+        var key = 'httprequest-' + index;
+
+        // Add to the list of pending requests.
+        addPending(key);
+
+        // Detect when it finishes and remove it from the list.
+        this.addEventListener('loadend', function() {
+            removePending(key);
+        });
+
+        return realOpen.apply(this, arguments);
+    };
+
+    var waitingSpinner = false;
+
+    /**
+     * Checks if a loading spinner is present and visible; if so, adds it to the pending array
+     * (and if not, removes it).
+     */
+    var updateSpinner = function() {
+        var spinner = document.querySelector('span.core-loading-spinner');
+        if (spinner && spinner.offsetParent) {
+            if (!waitingSpinner) {
+                addPending('spinner');
+                waitingSpinner = true;
+            }
+        } else {
+            if (waitingSpinner) {
+                removePending('spinner');
+                waitingSpinner = false;
+            }
+        }
+    };
+
+    // It would be really beautiful if you could detect CSS transitions and animations, that would
+    // cover almost everything, but sadly there is no way to do this because the transitionstart
+    // and animationcancel events are not implemented in Chrome, so we cannot detect either of
+    // these reliably. Instead, we have to look for any DOM changes and do horrible polling. Most
+    // of the animations are set to 500ms so we allow it to continue from 500ms after any DOM
+    // change.
+
+    var recentMutation = false;
+    var lastMutation;
+
+    /**
+     * Called from the mutation callback to remove the pending tag after 500ms if nothing else
+     * gets mutated.
+     *
+     * This will be called after 500ms, then every 100ms until there have been no mutation events
+     * for 500ms.
+     */
+    var pollRecentMutation = function() {
+        if (Date.now() - lastMutation > 500) {
+            recentMutation = false;
+            removePending('dom-mutation');
+        } else {
+            setTimeout(pollRecentMutation, 100);
+        }
+    };
+
+    /**
+     * Mutation callback, called whenever the DOM is mutated.
+     */
+    var mutationCallback = function() {
+        lastMutation = Date.now();
+        if (!recentMutation) {
+            recentMutation = true;
+            addPending('dom-mutation');
+            setTimeout(pollRecentMutation, 500);
+        }
+        // Also update the spinner presence if needed.
+        updateSpinner();
+    };
+
+    // Set listener using the mutation callback.
+    var observer = new MutationObserver(mutationCallback);
+    observer.observe(document, {attributes: true, childList: true, subtree: true});
+
+    /**
+     * Generic shared function to find possible xpath matches within the document, that are visible,
+     * and then process them using a callback function.
+     *
+     * @param {string} xpath Xpath to use
+     * @param {function} process Callback function that handles each matched node
+     */
+    var findPossibleMatches = function(xpath, process) {
+        var matches = document.evaluate(xpath, document);
+        while (true) {
+            var match = matches.iterateNext();
+            if (!match) {
+                break;
+            }
+            // Skip invisible text nodes.
+            if (!match.offsetParent) {
+                continue;
+            }
+
+            process(match);
+        }
+    };
+
+    /**
+     * Function to find an element based on its text or Aria label.
+     *
+     * @param {string} text Text (full or partial)
+     * @param {string} [near] Optional 'near' text - if specified, must have a single match on page
+     * @return {HTMLElement} Found element
+     * @throws {string} Error message beginning 'ERROR:' if something went wrong
+     */
+    var findElementBasedOnText = function(text, near) {
+        // Find all the elements that contain this text (and don't have a child element that
+        // contains it - i.e. the most specific elements).
+        var escapedText = text.replace('"', '""');
+        var exactMatches = [];
+        var anyMatches = [];
+        findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText +
+                '") and not(child::*[contains(normalize-space(.), "' + escapedText + '")])]',
+                function(match) {
+                    // Get the text. Note that innerText returns capitalised values for Android buttons
+                    // for some reason, so we'll have to do a case-insensitive match.
+                    var matchText = match.innerText.trim().toLowerCase();
+
+                    // Let's just check - is this actually a label for something else? If so we will click
+                    // that other thing instead.
+                    var labelId = document.evaluate('string(ancestor-or-self::ion-label[@id][1]/@id)', match).stringValue;
+                    if (labelId) {
+                        var target = document.querySelector('*[aria-labelledby=' + labelId + ']');
+                        if (target) {
+                            match = target;
+                        }
+                    }
+
+                    // Add to array depending on if it's an exact or partial match.
+                    if (matchText === text.toLowerCase()) {
+                        exactMatches.push(match);
+                    } else {
+                        anyMatches.push(match);
+                    }
+                });
+
+        // Find all the Aria labels that contain this text.
+        var exactLabelMatches = [];
+        var anyLabelMatches = [];
+        findPossibleMatches('//*[@aria-label and contains(@aria-label, "' + escapedText +
+                '")]', function(match) {
+                    // Add to array depending on if it's an exact or partial match.
+                    if (match.getAttribute('aria-label').trim() === text) {
+                        exactLabelMatches.push(match);
+                    } else {
+                        anyLabelMatches.push(match);
+                    }
+                });
+
+        // If the 'near' text is set, use it to filter results.
+        var nearAncestors = [];
+        if (near !== undefined) {
+            escapedText = near.replace('"', '""');
+            var exactNearMatches = [];
+            var anyNearMatches = [];
+            findPossibleMatches('//*[contains(normalize-space(.), "' + escapedText +
+                    '") and not(child::*[contains(normalize-space(.), "' + escapedText +
+                    '")])]', function(match) {
+                        // Get the text.
+                        var matchText = match.innerText.trim();
+
+                        // Add to array depending on if it's an exact or partial match.
+                        if (matchText === text) {
+                            exactNearMatches.push(match);
+                        } else {
+                            anyNearMatches.push(match);
+                        }
+                    });
+
+            var nearFound = null;
+
+            // If there is an exact text match, use that (regardless of other matches).
+            if (exactNearMatches.length > 1) {
+                throw new Error('Too many exact matches for near text');
+            } else if (exactNearMatches.length) {
+                nearFound = exactNearMatches[0];
+            }
+
+            if (nearFound === null) {
+                // If there is one partial text match, use that.
+                if (anyNearMatches.length > 1) {
+                    throw new Error('Too many partial matches for near text');
+                } else if (anyNearMatches.length) {
+                    nearFound = anyNearMatches[0];
+                }
+            }
+
+            if (!nearFound) {
+                throw new Error('No matches for near text');
+            }
+
+            while (nearFound) {
+                nearAncestors.push(nearFound);
+                nearFound = nearFound.parentNode;
+            }
+
+            /**
+             * Checks the number of steps up the tree from a specified node before getting to an
+             * ancestor of the 'near' item
+             *
+             * @param {HTMLElement} node HTML node
+             * @returns {number} Number of steps up, or Number.MAX_SAFE_INTEGER if it never matched
+             */
+            var calculateNearDepth = function(node) {
+                var depth = 0;
+                while (node) {
+                    var ancestorDepth = nearAncestors.indexOf(node);
+                    if (ancestorDepth !== -1) {
+                        return depth + ancestorDepth;
+                    }
+                    node = node.parentNode;
+                    depth++;
+                }
+                return Number.MAX_SAFE_INTEGER;
+            };
+
+            /**
+             * Reduces an array to include only the nearest in each category.
+             *
+             * @param {Array} arr Array to
+             * @return {Array} Array including only the items with minimum 'near' depth
+             */
+            var filterNonNearest = function(arr) {
+                var nearDepth = arr.map(function(node) {
+                    return calculateNearDepth(node);
+                });
+                var minDepth = Math.min.apply(null, nearDepth);
+                return arr.filter(function(element, index) {
+                    return nearDepth[index] == minDepth;
+                });
+            };
+
+            // Filter all the category arrays.
+            exactMatches = filterNonNearest(exactMatches);
+            exactLabelMatches = filterNonNearest(exactLabelMatches);
+            anyMatches = filterNonNearest(anyMatches);
+            anyLabelMatches = filterNonNearest(anyLabelMatches);
+        }
+
+        // Select the resulting match. Note this 'do' loop is not really a loop, it is just so we
+        // can easily break out of it as soon as we find a match.
+        var found = null;
+        do {
+            // If there is an exact text match, use that (regardless of other matches).
+            if (exactMatches.length > 1) {
+                throw new Error('Too many exact matches for text');
+            } else if (exactMatches.length) {
+                found = exactMatches[0];
+                break;
+            }
+
+            // If there is an exact label match, use that.
+            if (exactLabelMatches.length > 1) {
+                throw new Error('Too many exact label matches for text');
+            } else if (exactLabelMatches.length) {
+                found = exactLabelMatches[0];
+                break;
+            }
+
+            // If there is one partial text match, use that.
+            if (anyMatches.length > 1) {
+                throw new Error('Too many partial matches for text');
+            } else if (anyMatches.length) {
+                found = anyMatches[0];
+                break;
+            }
+
+            // Finally if there is one partial label match, use that.
+            if (anyLabelMatches.length > 1) {
+                throw new Error('Too many partial label matches for text');
+            } else if (anyLabelMatches.length) {
+                found = anyLabelMatches[0];
+                break;
+            }
+        } while (false);
+
+        if (!found) {
+            throw new Error('No matches for text');
+        }
+
+        return found;
+    };
+
+    /**
+     * Function to find and click an app standard button.
+     *
+     * @param {string} button Type of button to press
+     * @return {string} OK if successful, or ERROR: followed by message
+     */
+    var behatPressStandard = function(button) {
+        log('Action - Click standard button: ' + button);
+        var selector;
+        switch (button) {
+            case 'back' :
+                selector = 'ion-navbar > button.back-button-md';
+                break;
+            case 'main menu' :
+                selector = 'page-core-mainmenu .tab-button > ion-icon[aria-label=more]';
+                break;
+            case 'page menu' :
+                // This lang string was changed in app version 3.6.
+                selector = 'core-context-menu > button[aria-label=Info], ' +
+                        'core-context-menu > button[aria-label=Information]';
+                break;
+            default:
+                return 'ERROR: Unsupported standard button type';
+        }
+        var buttons = Array.from(document.querySelectorAll(selector));
+        var foundButton = null;
+        var tooMany = false;
+        buttons.forEach(function(button) {
+            if (button.offsetParent) {
+                if (foundButton === null) {
+                    foundButton = button;
+                } else {
+                    tooMany = true;
+                }
+            }
+        });
+        if (!foundButton) {
+            return 'ERROR: Could not find button';
+        }
+        if (tooMany) {
+            return 'ERROR: Found too many buttons';
+        }
+        foundButton.click();
+
+        // Mark busy until the button click finishes processing.
+        addPendingDelay();
+
+        return 'OK';
+    };
+
+    /**
+     * When there is a popup, clicks on the backdrop.
+     *
+     * @return {string} OK if successful, or ERROR: followed by message
+     */
+    var behatClosePopup = function() {
+        log('Action - Close popup');
+
+        var backdrops = Array.from(document.querySelectorAll('ion-backdrop'));
+        var found = null;
+        var tooMany = false;
+        backdrops.forEach(function(backdrop) {
+            if (backdrop.offsetParent) {
+                if (found === null) {
+                    found = backdrop;
+                } else {
+                    tooMany = true;
+                }
+            }
+        });
+        if (!found) {
+            return 'ERROR: Could not find backdrop';
+        }
+        if (tooMany) {
+            return 'ERROR: Found too many backdrops';
+        }
+        found.click();
+
+        // Mark busy until the click finishes processing.
+        addPendingDelay();
+
+        return 'OK';
+    };
+
+    /**
+     * Function to press arbitrary item based on its text or Aria label.
+     *
+     * @param {string} text Text (full or partial)
+     * @param {string} near Optional 'near' text - if specified, must have a single match on page
+     * @return {string} OK if successful, or ERROR: followed by message
+     */
+    var behatPress = function(text, near) {
+        log('Action - Press ' + text + (near === undefined ? '' : ' - near ' + near));
+
+        var found;
+        try {
+            found = findElementBasedOnText(text, near);
+        } catch (error) {
+            return 'ERROR: ' + error.message;
+        }
+
+        // Simulate a mouse click on the button.
+        found.scrollIntoView();
+        var rect = found.getBoundingClientRect();
+        var eventOptions = {clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2,
+                bubbles: true, view: window, cancelable: true};
+        setTimeout(function() {
+            found.dispatchEvent(new MouseEvent('mousedown', eventOptions));
+        }, 0);
+        setTimeout(function() {
+            found.dispatchEvent(new MouseEvent('mouseup', eventOptions));
+        }, 0);
+        setTimeout(function() {
+            found.dispatchEvent(new MouseEvent('click', eventOptions));
+        }, 0);
+
+        // Mark busy until the button click finishes processing.
+        addPendingDelay();
+
+        return 'OK';
+    };
+
+    /**
+     * Gets the currently displayed page header.
+     *
+     * @return {string} OK: followed by header text if successful, or ERROR: followed by message.
+     */
+    var behatGetHeader = function() {
+        log('Action - Get header');
+
+        var result = null;
+        var resultCount = 0;
+        var titles = Array.from(document.querySelectorAll('ion-header ion-title'));
+        titles.forEach(function(title) {
+            if (title.offsetParent) {
+                result = title.innerText.trim();
+                resultCount++;
+            }
+        });
+
+        if (resultCount > 1) {
+            return 'ERROR: Too many possible titles';
+        } else if (!resultCount) {
+            return 'ERROR: No title found';
+        } else {
+            return 'OK:' + result;
+        }
+    };
+
+    /**
+     * Sets the text of a field to the specified value.
+     *
+     * This currently matches fields only based on the placeholder attribute.
+     *
+     * @param {string} field Field name
+     * @param {string} value New value
+     * @return {string} OK or ERROR: followed by message
+     */
+    var behatSetField = function(field, value) {
+        log('Action - Set field ' + field + ' to: ' + value);
+
+        // Find input(s) with given placeholder.
+        var escapedText = field.replace('"', '""');
+        var exactMatches = [];
+        var anyMatches = [];
+        findPossibleMatches(
+                '//input[contains(@placeholder, "' + escapedText + '")] |' +
+                '//textarea[contains(@placeholder, "' + escapedText + '")] |' +
+                '//core-rich-text-editor/descendant::div[contains(@data-placeholder-text, "' +
+                escapedText + '")]', function(match) {
+                    // Add to array depending on if it's an exact or partial match.
+                    var placeholder;
+                    if (match.nodeName === 'DIV') {
+                        placeholder = match.getAttribute('data-placeholder-text');
+                    } else {
+                        placeholder = match.getAttribute('placeholder');
+                    }
+                    if (placeholder.trim() === field) {
+                        exactMatches.push(match);
+                    } else {
+                        anyMatches.push(match);
+                    }
+                });
+
+        // Select the resulting match.
+        var found = null;
+        do {
+            // If there is an exact text match, use that (regardless of other matches).
+            if (exactMatches.length > 1) {
+                return 'ERROR: Too many exact placeholder matches for text';
+            } else if (exactMatches.length) {
+                found = exactMatches[0];
+                break;
+            }
+
+            // If there is one partial text match, use that.
+            if (anyMatches.length > 1) {
+                return 'ERROR: Too many partial placeholder matches for text';
+            } else if (anyMatches.length) {
+                found = anyMatches[0];
+                break;
+            }
+        } while (false);
+
+        if (!found) {
+            return 'ERROR: No matches for text';
+        }
+
+        // Functions to get/set value depending on field type.
+        var setValue;
+        var getValue;
+        switch (found.nodeName) {
+            case 'INPUT':
+            case 'TEXTAREA':
+                setValue = function(text) {
+                    found.value = text;
+                };
+                getValue = function() {
+                    return found.value;
+                };
+                break;
+            case 'DIV':
+                setValue = function(text) {
+                    found.innerHTML = text;
+                };
+                getValue = function() {
+                    return found.innerHTML;
+                };
+                break;
+        }
+
+        // Pretend we have cut and pasted the new text.
+        var event;
+        if (getValue() !== '') {
+            event = new InputEvent('input', {bubbles: true, view: window, cancelable: true,
+                inputType: 'devareByCut'});
+            setTimeout(function() {
+                setValue('');
+                found.dispatchEvent(event);
+            }, 0);
+        }
+        if (value !== '') {
+            event = new InputEvent('input', {bubbles: true, view: window, cancelable: true,
+                inputType: 'insertFromPaste', data: value});
+            setTimeout(function() {
+                setValue(value);
+                found.dispatchEvent(event);
+            }, 0);
+        }
+
+        return 'OK';
+    };
+
+    // Make some functions publicly available for Behat to call.
+    window.behat = {
+        pressStandard : behatPressStandard,
+        closePopup : behatClosePopup,
+        press : behatPress,
+        setField : behatSetField,
+        getHeader : behatGetHeader,
+    };
+})();
diff --git a/lib/tests/behat/behat_app.php b/lib/tests/behat/behat_app.php
new file mode 100644 (file)
index 0000000..8a5b238
--- /dev/null
@@ -0,0 +1,527 @@
+<?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/>.
+
+/**
+ * Mobile/desktop app steps definitions.
+ *
+ * @package core
+ * @category test
+ * @copyright 2018 The Open University
+ * @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/behat_base.php');
+
+use Behat\Mink\Exception\DriverException;
+use Behat\Mink\Exception\ExpectationException;
+
+/**
+ * Mobile/desktop app steps definitions.
+ *
+ * @package core
+ * @category test
+ * @copyright 2018 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_app extends behat_base {
+    /** @var stdClass Object with data about launched Ionic instance (if any) */
+    protected static $ionicrunning = null;
+
+    /** @var string URL for running Ionic server */
+    protected $ionicurl = '';
+
+    /**
+     * Checks if the current OS is Windows, from the point of view of task-executing-and-killing.
+     *
+     * @return bool True if Windows
+     */
+    protected static function is_windows() : bool {
+        return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
+    }
+
+    /**
+     * Called from behat_hooks when a new scenario starts, if it has the app tag.
+     *
+     * This updates Moodle configuration and starts Ionic running, if it isn't already.
+     */
+    public function start_scenario() {
+        $this->check_behat_setup();
+        $this->fix_moodle_setup();
+        $this->ionicurl = $this->start_or_reuse_ionic();
+}
+
+    /**
+     * Opens the Moodle app in the browser.
+     *
+     * Requires JavaScript.
+     *
+     * @Given /^I enter the app$/
+     * @throws DriverException Issue with configuration or feature file
+     * @throws dml_exception Problem with Moodle setup
+     * @throws ExpectationException Problem with resizing window
+     */
+    public function i_enter_the_app() {
+        // Check the app tag was set.
+        if (!$this->has_tag('app')) {
+            throw new DriverException('Requires @app tag on scenario or feature.');
+        }
+
+        // Restart the browser and set its size.
+        $this->getSession()->restart();
+        $this->resize_window('360x720', true);
+
+        // Go to page and prepare browser for app.
+        $this->prepare_browser($this->ionicurl);
+    }
+
+    /**
+     * Checks the Behat setup - tags and configuration.
+     *
+     * @throws DriverException
+     */
+    protected function check_behat_setup() {
+        global $CFG;
+
+        // Check JavaScript is enabled.
+        if (!$this->running_javascript()) {
+            throw new DriverException('The app requires JavaScript.');
+        }
+
+        // Check the config settings are defined.
+        if (empty($CFG->behat_ionic_wwwroot) && empty($CFG->behat_ionic_dirroot)) {
+            throw new DriverException('$CFG->behat_ionic_wwwroot or $CFG->behat_ionic_dirroot must be defined.');
+        }
+    }
+
+    /**
+     * Fixes the Moodle admin settings to allow mobile app use (if not already correct).
+     *
+     * @throws dml_exception If there is any problem changing Moodle settings
+     */
+    protected function fix_moodle_setup() {
+        global $CFG, $DB;
+
+        // Configure Moodle settings to enable app web services.
+        if (!$CFG->enablewebservices) {
+            set_config('enablewebservices', 1);
+        }
+        if (!$CFG->enablemobilewebservice) {
+            set_config('enablemobilewebservice', 1);
+        }
+
+        // Add 'Create token' and 'Use REST webservice' permissions to authenticated user role.
+        $userroleid = $DB->get_field('role', 'id', ['shortname' => 'user']);
+        $systemcontext = \context_system::instance();
+        role_change_permission($userroleid, $systemcontext, 'moodle/webservice:createtoken', CAP_ALLOW);
+        role_change_permission($userroleid, $systemcontext, 'webservice/rest:use', CAP_ALLOW);
+
+        // Check the value of the 'webserviceprotocols' config option. Due to weird behaviour
+        // in Behat with regard to config variables that aren't defined in a settings.php, the
+        // value in $CFG here may reflect a previous run, so get it direct from the database
+        // instead.
+        $field = $DB->get_field('config', 'value', ['name' => 'webserviceprotocols'], IGNORE_MISSING);
+        if (empty($field)) {
+            $protocols = [];
+        } else {
+            $protocols = explode(',', $field);
+        }
+        if (!in_array('rest', $protocols)) {
+            $protocols[] = 'rest';
+            set_config('webserviceprotocols', implode(',', $protocols));
+        }
+
+        // Enable mobile service.
+        require_once($CFG->dirroot . '/webservice/lib.php');
+        $webservicemanager = new webservice();
+        $service = $webservicemanager->get_external_service_by_shortname(
+                MOODLE_OFFICIAL_MOBILE_SERVICE, MUST_EXIST);
+        if (!$service->enabled) {
+            $service->enabled = 1;
+            $webservicemanager->update_external_service($service);
+        }
+
+        // If installed, also configure local_mobile plugin to enable additional features service.
+        $localplugins = core_component::get_plugin_list('local');
+        if (array_key_exists('mobile', $localplugins)) {
+            $service = $webservicemanager->get_external_service_by_shortname(
+                    'local_mobile', MUST_EXIST);
+            if (!$service->enabled) {
+                $service->enabled = 1;
+                $webservicemanager->update_external_service($service);
+            }
+        }
+    }
+
+    /**
+     * Starts an Ionic server if necessary, or uses an existing one.
+     *
+     * @return string URL to Ionic server
+     * @throws DriverException If there's a system error starting Ionic
+     */
+    protected function start_or_reuse_ionic() {
+        global $CFG;
+
+        if (empty($CFG->behat_ionic_dirroot) && !empty($CFG->behat_ionic_wwwroot)) {
+            // Use supplied Ionic server which should already be running.
+            $url = $CFG->behat_ionic_wwwroot;
+        } else if (self::$ionicrunning) {
+            // Use existing Ionic instance launched previously.
+            $url = self::$ionicrunning->url;
+        } else {
+            // Open Ionic process in relevant path.
+            $path = realpath($CFG->behat_ionic_dirroot);
+            $stderrfile = $CFG->dataroot . '/behat/ionic-stderr.log';
+            $prefix = '';
+            // Except on Windows, use 'exec' so that we get the pid of the actual Node process
+            // and not the shell it uses to execute. You can't do exec on Windows; there is a
+            // bypass_shell option but it is not the same thing and isn't usable here.
+            if (!self::is_windows()) {
+                $prefix = 'exec ';
+            }
+            $process = proc_open($prefix . 'ionic serve --no-interactive --no-open',
+                    [['pipe', 'r'], ['pipe', 'w'], ['file', $stderrfile, 'w']], $pipes, $path);
+            if ($process === false) {
+                throw new DriverException('Error starting Ionic process');
+            }
+            fclose($pipes[0]);
+
+            // Get pid - we will need this to kill the process.
+            $status = proc_get_status($process);
+            $pid = $status['pid'];
+
+            // Read data from stdout until the server comes online.
+            // Note: On Windows it is impossible to read simultaneously from stderr and stdout
+            // because stream_select and non-blocking I/O don't work on process pipes, so that is
+            // why stderr was redirected to a file instead. Also, this code is simpler.
+            $url = null;
+            $stdoutlog = '';
+            while (true) {
+                $line = fgets($pipes[1], 4096);
+                if ($line === false) {
+                    break;
+                }
+
+                $stdoutlog .= $line;
+
+                if (preg_match('~^\s*Local: (http\S*)~', $line, $matches)) {
+                    $url = $matches[1];
+                    break;
+                }
+            }
+
+            // If it failed, close the pipes and the process.
+            if (!$url) {
+                fclose($pipes[1]);
+                proc_close($process);
+                $logpath = $CFG->dataroot . '/behat/ionic-start.log';
+                $stderrlog = file_get_contents($stderrfile);
+                @unlink($stderrfile);
+                file_put_contents($logpath,
+                        "Ionic startup log from " . date('c') .
+                        "\n\n----STDOUT----\n$stdoutlog\n\n----STDERR----\n$stderrlog");
+                throw new DriverException('Unable to start Ionic. See ' . $logpath);
+            }
+
+            // Remember the URL, so we can reuse it next time, and other details so we can kill
+            // the process.
+            self::$ionicrunning = (object)['url' => $url, 'process' => $process, 'pipes' => $pipes,
+                    'pid' => $pid];
+        }
+        return $url;
+    }
+
+    /**
+     * Closes Ionic (if it was started) at end of test suite.
+     *
+     * @AfterSuite
+     */
+    public static function close_ionic() {
+        if (self::$ionicrunning) {
+            fclose(self::$ionicrunning->pipes[1]);
+
+            if (self::is_windows()) {
+                // Using proc_terminate here does not work. It terminates the process but not any
+                // other processes it might have launched. Instead, we need to use an OS-specific
+                // mechanism to kill the process and children based on its pid.
+                exec('taskkill /F /T /PID ' . self::$ionicrunning->pid);
+            } else {
+                // On Unix this actually works, although only due to the 'exec' command inserted
+                // above.
+                proc_terminate(self::$ionicrunning->process);
+            }
+            self::$ionicrunning = null;
+        }
+    }
+
+    /**
+     * Goes to the app page and then sets up some initial JavaScript so we can use it.
+     *
+     * @param string $url App URL
+     * @throws DriverException If the app fails to load properly
+     */
+    protected function prepare_browser(string $url) {
+        global $CFG;
+
+        // Visit the Ionic URL and wait for it to load.
+        $this->getSession()->visit($url);
+        $this->spin(
+                function($context, $args) {
+                    $title = $context->getSession()->getPage()->find('xpath', '//title');
+                    if ($title) {
+                        $text = $title->getHtml();
+                        if ($text === 'Moodle Desktop') {
+                            return true;
+                        }
+                    }
+                    throw new DriverException('Moodle app not found in browser');
+                }, false, 30);
+
+        // Run the scripts to install Moodle 'pending' checks.
+        $this->getSession()->executeScript(
+                file_get_contents(__DIR__ . '/app_behat_runtime.js'));
+
+        // Wait until the site login field appears OR the main page.
+        $situation = $this->spin(
+                function($context, $args) {
+                    $input = $context->getSession()->getPage()->find('xpath', '//input[@name="url"]');
+                    if ($input) {
+                        return 'login';
+                    }
+                    $mainmenu = $context->getSession()->getPage()->find('xpath', '//page-core-mainmenu');
+                    if ($mainmenu) {
+                        return 'mainpage';
+                    }
+                    throw new DriverException('Moodle app login URL prompt not found');
+                }, self::EXTENDED_TIMEOUT, 30);
+
+        // If it's the login page, we automatically fill in the URL and leave it on the user/pass
+        // page. If it's the main page, we just leave it there.
+        if ($situation === 'login') {
+            $this->i_set_the_field_in_the_app('Site address', $CFG->wwwroot);
+            $this->i_press_in_the_app('Connect!');
+        }
+
+        // Continue only after JS finishes.
+        $this->wait_for_pending_js();
+    }
+
+    /**
+     * Carries out the login steps for the app, assuming the user is on the app login page. Called
+     * from behat_auth.php.
+     *
+     * @param string $username Username (and password)
+     * @throws Exception Any error
+     */
+    public function login(string $username) {
+        $this->i_set_the_field_in_the_app('Username', $username);
+        $this->i_set_the_field_in_the_app('Password', $username);
+
+        // Note there are two 'Log in' texts visible (the title and the button) so we have to use
+        // a 'near' value here.
+        $this->i_press_near_in_the_app('Log in', 'Forgotten');
+
+        // Wait until the main page appears.
+        $this->spin(
+                function($context, $args) {
+                    $mainmenu = $context->getSession()->getPage()->find('xpath', '//page-core-mainmenu');
+                    if ($mainmenu) {
+                        return 'mainpage';
+                    }
+                    throw new DriverException('Moodle app main page not loaded after login');
+                }, false, 30);
+
+        // Wait for JS to finish as well.
+        $this->wait_for_pending_js();
+    }
+
+    /**
+     * Presses standard buttons in the app.
+     *
+     * @Given /^I press the (?P<button_name>back|main menu|page menu) button in the app$/
+     * @param string $button Button type
+     * @throws DriverException If the button push doesn't work
+     */
+    public function i_press_the_standard_button_in_the_app(string $button) {
+        $this->spin(function($context, $args) use ($button) {
+            $result = $this->getSession()->evaluateScript('return window.behat.pressStandard("' .
+                    $button . '");');
+            if ($result !== 'OK') {
+                throw new DriverException('Error pressing standard button - ' . $result);
+            }
+            return true;
+        });
+        $this->wait_for_pending_js();
+    }
+
+    /**
+     * Closes a popup by clicking on the 'backdrop' behind it.
+     *
+     * @Given /^I close the popup in the app$/
+     * @throws DriverException If there isn't a popup to close
+     */
+    public function i_close_the_popup_in_the_app() {
+        $this->spin(function($context, $args)  {
+            $result = $this->getSession()->evaluateScript('return window.behat.closePopup();');
+            if ($result !== 'OK') {
+                throw new DriverException('Error closing popup - ' . $result);
+            }
+            return true;
+        });
+        $this->wait_for_pending_js();
+    }
+
+    /**
+     * Clicks on / touches something that is visible in the app.
+     *
+     * Note it is difficult to use the standard 'click on' or 'press' steps because those do not
+     * distinguish visible items and the app always has many non-visible items in the DOM.
+     *
+     * @Given /^I press "(?P<text_string>(?:[^"]|\\")*)" in the app$/
+     * @param string $text Text identifying click target
+     * @throws DriverException If the press doesn't work
+     */
+    public function i_press_in_the_app(string $text) {
+        $this->press($text);
+    }
+
+    /**
+     * Clicks on / touches something that is visible in the app, near some other text.
+     *
+     * This is the same as the other step, but when there are multiple matches, it picks the one
+     * nearest (in DOM terms) the second text. The second text should be an exact match, or a partial
+     * match that only has one result.
+     *
+     * @Given /^I press "(?P<text_string>(?:[^"]|\\")*)" near "(?P<nearby_string>(?:[^"]|\\")*)" in the app$/
+     * @param string $text Text identifying click target
+     * @param string $near Text identifying a nearby unique piece of text
+     * @throws DriverException If the press doesn't work
+     */
+    public function i_press_near_in_the_app(string $text, string $near) {
+        $this->press($text, $near);
+    }
+
+    /**
+     * Clicks on / touches something that is visible in the app, near some other text.
+     *
+     * If the $near is specified then when there are multiple matches, it picks the one
+     * nearest (in DOM terms) $near. $near should be an exact match, or a partial match that only
+     * has one result.
+     *
+     * @param behat_base $base Behat context
+     * @param string $text Text identifying click target
+     * @param string $near Text identifying a nearby unique piece of text
+     * @throws DriverException If the press doesn't work
+     */
+    protected function press(string $text, string $near = '') {
+        $this->spin(function($context, $args) use ($text, $near) {
+            if ($near !== '') {
+                $nearbit = ', "' . addslashes_js($near) . '"';
+            } else {
+                $nearbit = '';
+            }
+            $result = $context->getSession()->evaluateScript('return window.behat.press("' .
+                    addslashes_js($text) . '"' . $nearbit .');');
+            if ($result !== 'OK') {
+                throw new DriverException('Error pressing item - ' . $result);
+            }
+            return true;
+        });
+        $this->wait_for_pending_js();
+    }
+
+    /**
+     * Sets a field to the given text value in the app.
+     *
+     * Currently this only works for input fields which must be identified using a partial or
+     * exact match on the placeholder text.
+     *
+     * @Given /^I set the field "(?P<field_name>(?:[^"]|\\")*)" to "(?P<text_string>(?:[^"]|\\")*)" in the app$/
+     * @param string $field Text identifying field
+     * @param string $value Value for field
+     * @throws DriverException If the field set doesn't work
+     */
+    public function i_set_the_field_in_the_app(string $field, string $value) {
+        $this->spin(function($context, $args) use ($field, $value) {
+            $result = $this->getSession()->evaluateScript('return window.behat.setField("' .
+                    addslashes_js($field) . '", "' . addslashes_js($value) . '");');
+            if ($result !== 'OK') {
+                throw new DriverException('Error setting field - ' . $result);
+            }
+            return true;
+        });
+        $this->wait_for_pending_js();
+    }
+
+    /**
+     * Checks that the current header stripe in the app contains the expected text.
+     *
+     * This can be used to see if the app went to the expected page.
+     *
+     * @Then /^the header should be "(?P<text_string>(?:[^"]|\\")*)" in the app$/
+     * @param string $text Expected header text
+     * @throws DriverException If the header can't be retrieved
+     * @throws ExpectationException If the header text is different to the expected value
+     */
+    public function the_header_should_be_in_the_app(string $text) {
+        $result = $this->spin(function($context, $args) {
+            $result = $this->getSession()->evaluateScript('return window.behat.getHeader();');
+            if (substr($result, 0, 3) !== 'OK:') {
+                throw new DriverException('Error getting header - ' . $result);
+            }
+            return $result;
+        });
+        $header = substr($result, 3);
+        if (trim($header) !== trim($text)) {
+            throw new ExpectationException('The header text was not as expected: \'' . $header . '\'',
+                    $this->getSession()->getDriver());
+        }
+    }
+
+    /**
+     * Switches to a newly-opened browser tab.
+     *
+     * This assumes the app opened a new tab.
+     *
+     * @Given /^I switch to the browser tab opened by the app$/
+     * @throws DriverException If there aren't exactly 2 tabs open
+     */
+    public function i_switch_to_the_browser_tab_opened_by_the_app() {
+        $names = $this->getSession()->getWindowNames();
+        if (count($names) !== 2) {
+            throw new DriverException('Expected to see 2 tabs open, not ' . count($names));
+        }
+        $this->getSession()->switchToWindow($names[1]);
+    }
+
+    /**
+     * Closes the current browser tab.
+     *
+     * This assumes it was opened by the app and you will now get back to the app.
+     *
+     * @Given /^I close the browser tab opened by the app$/
+     * @throws DriverException If there aren't exactly 2 tabs open
+     */
+    public function i_close_the_browser_tab_opened_by_the_app() {
+        $names = $this->getSession()->getWindowNames();
+        if (count($names) !== 2) {
+            throw new DriverException('Expected to see 2 tabs open, not ' . count($names));
+        }
+        $this->getSession()->getDriver()->executeScript('window.close()');
+        $this->getSession()->switchToWindow($names[0]);
+    }
+}
index d5254b1..3b523f1 100644 (file)
@@ -103,6 +103,11 @@ class behat_hooks extends behat_base {
      */
     protected static $runningsuite = '';
 
+    /**
+     * @var array Array (with tag names in keys) of all tags in current scenario.
+     */
+    protected static $scenariotags;
+
     /**
      * Hook to capture BeforeSuite event so as to give access to moodle codebase.
      * This will try and catch any exception and exists if anything fails.
@@ -384,6 +389,35 @@ class behat_hooks extends behat_base {
 
         // Run all test with medium (1024x768) screen size, to avoid responsive problems.
         $this->resize_window('medium');
+
+        // Set up the tags for current scenario.
+        self::fetch_tags_for_scenario($scope);
+
+        // If scenario requires the Moodle app to be running, set this up.
+        if ($this->has_tag('app')) {
+            $this->execute('behat_app::start_scenario');
+        }
+    }
+
+    /**
+     * Sets up the tags for the current scenario.
+     *
+     * @param \Behat\Behat\Hook\Scope\BeforeScenarioScope $scope Scope
+     */
+    protected static function fetch_tags_for_scenario(\Behat\Behat\Hook\Scope\BeforeScenarioScope $scope) {
+        self::$scenariotags = array_flip(array_merge(
+            $scope->getScenario()->getTags(),
+            $scope->getFeature()->getTags()
+        ));
+    }
+
+    /**
+     * Gets the tags for the current scenario
+     *
+     * @return array Array where key is tag name and value is an integer
+     */
+    public static function get_tags_for_scenario() : array {
+        return self::$scenariotags;
     }
 
     /**
index 5f3eab4..451893f 100644 (file)
@@ -3928,6 +3928,163 @@ class core_moodlelib_testcase extends advanced_testcase {
         $this->assertTrue(validate_email(generate_email_processing_address(23, $modargs)));
     }
 
+    /**
+     * Test allowemailaddresses setting.
+     *
+     * @param string $email Email address for the from user.
+     * @param string $config The CFG->allowemailaddresses config values
+     * @param false/string $result The expected result.
+     *
+     * @dataProvider data_email_is_not_allowed_for_allowemailaddresses
+     */
+    public function test_email_is_not_allowed_for_allowemailaddresses($email, $config, $result) {
+        $this->resetAfterTest();
+
+        set_config('allowemailaddresses', $config);
+        $this->assertEquals($result, email_is_not_allowed($email));
+    }
+
+    /**
+     * Data provider for data_email_is_not_allowed_for_allowemailaddresses.
+     *
+     * @return array Returns an array of test data for the above function.
+     */
+    public function data_email_is_not_allowed_for_allowemailaddresses() {
+        return [
+            // Test allowed domain empty list.
+            [
+                'email' => 'fromuser@example.com',
+                'config' => '',
+                'result' => false
+            ],
+            // Test from email is in allowed domain.
+            [
+                'email' => 'fromuser@example.com',
+                'config' => 'example.com test.com',
+                'result' => false
+            ],
+            // Test from email is in allowed domain but uppercase config.
+            [
+                'email' => 'fromuser@example.com',
+                'config' => 'EXAMPLE.com test.com',
+                'result' => false
+            ],
+            // Test from email is in allowed domain but uppercase email.
+            [
+                'email' => 'fromuser@EXAMPLE.com',
+                'config' => 'example.com test.com',
+                'result' => false
+            ],
+            // Test from email is in allowed subdomain.
+            [
+                'email' => 'fromuser@something.example.com',
+                'config' => '.example.com test.com',
+                'result' => false
+            ],
+            // Test from email is in allowed subdomain but uppercase config.
+            [
+                'email' => 'fromuser@something.example.com',
+                'config' => '.EXAMPLE.com test.com',
+                'result' => false
+            ],
+            // Test from email is in allowed subdomain but uppercase email.
+            [
+                'email' => 'fromuser@something.EXAMPLE.com',
+                'config' => '.example.com test.com',
+                'result' => false
+            ],
+            // Test from email is not in allowed domain.
+            [   'email' => 'fromuser@moodle.com',
+                'config' => 'example.com test.com',
+                'result' => get_string('emailonlyallowed', '', 'example.com test.com')
+            ],
+            // Test from email is not in allowed subdomain.
+            [   'email' => 'fromuser@something.example.com',
+                'config' => 'example.com test.com',
+                'result' => get_string('emailonlyallowed', '', 'example.com test.com')
+            ],
+        ];
+    }
+
+    /**
+     * Test denyemailaddresses setting.
+     *
+     * @param string $email Email address for the from user.
+     * @param string $config The CFG->denyemailaddresses config values
+     * @param false/string $result The expected result.
+     *
+     * @dataProvider data_email_is_not_allowed_for_denyemailaddresses
+     */
+    public function test_email_is_not_allowed_for_denyemailaddresses($email, $config, $result) {
+        $this->resetAfterTest();
+
+        set_config('denyemailaddresses', $config);
+        $this->assertEquals($result, email_is_not_allowed($email));
+    }
+
+
+    /**
+     * Data provider for test_email_is_not_allowed_for_denyemailaddresses.
+     *
+     * @return array Returns an array of test data for the above function.
+     */
+    public function data_email_is_not_allowed_for_denyemailaddresses() {
+        return [
+            // Test denied domain empty list.
+            [
+                'email' => 'fromuser@example.com',
+                'config' => '',
+                'result' => false
+            ],
+            // Test from email is in denied domain.
+            [
+                'email' => 'fromuser@example.com',
+                'config' => 'example.com test.com',
+                'result' => get_string('emailnotallowed', '', 'example.com test.com')
+            ],
+            // Test from email is in denied domain but uppercase config.
+            [
+                'email' => 'fromuser@example.com',
+                'config' => 'EXAMPLE.com test.com',
+                'result' => get_string('emailnotallowed', '', 'EXAMPLE.com test.com')
+            ],
+            // Test from email is in denied domain but uppercase email.
+            [
+                'email' => 'fromuser@EXAMPLE.com',
+                'config' => 'example.com test.com',
+                'result' => get_string('emailnotallowed', '', 'example.com test.com')
+            ],
+            // Test from email is in denied subdomain.
+            [
+                'email' => 'fromuser@something.example.com',
+                'config' => '.example.com test.com',
+                'result' => get_string('emailnotallowed', '', '.example.com test.com')
+            ],
+            // Test from email is in denied subdomain but uppercase config.
+            [
+                'email' => 'fromuser@something.example.com',
+                'config' => '.EXAMPLE.com test.com',
+                'result' => get_string('emailnotallowed', '', '.EXAMPLE.com test.com')
+            ],
+            // Test from email is in denied subdomain but uppercase email.
+            [
+                'email' => 'fromuser@something.EXAMPLE.com',
+                'config' => '.example.com test.com',
+                'result' => get_string('emailnotallowed', '', '.example.com test.com')
+            ],
+            // Test from email is not in denied domain.
+            [   'email' => 'fromuser@moodle.com',
+                'config' => 'example.com test.com',
+                'result' => false
+            ],
+            // Test from email is not in denied subdomain.
+            [   'email' => 'fromuser@something.example.com',
+                'config' => 'example.com test.com',
+                'result' => false
+            ],
+        ];
+    }
+
     /**
      * Test safe method unserialize_array().
      */
index 530bce0..9f6e4af 100644 (file)
@@ -3,6 +3,7 @@ information provided here is intended especially for developers.
 
 === 3.7 ===
 
+* Nodes in the navigation api can have labels for each group. See set/get_collectionlabel().
 * The method core_user::is_real_user() now returns false for userid = 0 parameter
 * 'mform1' dependencies (in themes, js...) will stop working because a randomly generated string has been added to the id
 attribute on forms to avoid collisions in forms loaded in AJAX requests.
index 5064690..4a25979 100644 (file)
@@ -26,7 +26,8 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-require_once $CFG->libdir.'/formslib.php';
+require_once($CFG->libdir.'/formslib.php');
+require_once($CFG->dirroot.'/user/lib.php');
 
 class login_change_password_form extends moodleform {
 
@@ -51,7 +52,8 @@ class login_change_password_form extends moodleform {
         if ($policies) {
             $mform->addElement('static', 'passwordpolicyinfo', '', implode('<br />', $policies));
         }
-        $mform->addElement('password', 'password', get_string('oldpassword'));
+        $purpose = user_edit_map_field_purpose($USER->id, 'password');
+        $mform->addElement('password', 'password', get_string('oldpassword'), $purpose);
         $mform->addRule('password', get_string('required'), 'required', null, 'client');
         $mform->setType('password', PARAM_RAW);
 
index 32e5310..67a4d9f 100644 (file)
@@ -25,6 +25,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 require_once($CFG->libdir.'/formslib.php');
+require_once($CFG->dirroot.'/user/lib.php');
 
 /**
  * Reset forgotten password form definition.
@@ -40,12 +41,15 @@ class login_forgot_password_form extends moodleform {
      * Define the forgot password form.
      */
     function definition() {
+        global $USER;
+
         $mform    = $this->_form;
         $mform->setDisableShortforms(true);
 
         $mform->addElement('header', 'searchbyusername', get_string('searchbyusername'), '');
 
-        $mform->addElement('text', 'username', get_string('username'));
+        $purpose = user_edit_map_field_purpose($USER->id, 'username');
+        $mform->addElement('text', 'username', get_string('username'), 'size="20"' . $purpose);
         $mform->setType('username', PARAM_RAW);
 
         $submitlabel = get_string('search');
@@ -53,7 +57,8 @@ class login_forgot_password_form extends moodleform {
 
         $mform->addElement('header', 'searchbyemail', get_string('searchbyemail'), '');
 
-        $mform->addElement('text', 'email', get_string('email'));
+        $purpose = user_edit_map_field_purpose($USER->id, 'email');
+        $mform->addElement('text', 'email', get_string('email'), 'maxlength="100" size="30"' . $purpose);
         $mform->setType('email', PARAM_RAW_TRIMMED);
 
         $submitlabel = get_string('search');
index 0bcc236..ff35374 100644 (file)
Binary files a/message/amd/build/notification_processor_settings.min.js and b/message/amd/build/notification_processor_settings.min.js differ
index d4347b0..3d1c9f6 100644 (file)
Binary files a/message/amd/build/preferences_notifications_list_controller.min.js and b/message/amd/build/preferences_notifications_list_controller.min.js differ
index 6964dfb..8ce9f05 100644 (file)
Binary files a/message/amd/build/preferences_processor_form.min.js and b/message/amd/build/preferences_processor_form.min.js differ
index bb3763b..1fb2132 100644 (file)
  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/ajax', 'core/notification', 'core/fragment', 'core/templates', 'core/str', 'tool_lp/dialogue'],
-        function($, Ajax, Notification, Fragment, Templates, Str, Dialogue) {
+define([
+        'jquery',
+        'core/ajax',
+        'core/str',
+        'core/notification',
+        'core/custom_interaction_events',
+        'core/modal',
+        'core/modal_registry',
+        'core/fragment',
+        ],
+        function(
+            $,
+            Ajax,
+            Str,
+            Notification,
+            CustomEvents,
+            Modal,
+            ModalRegistry,
+            Fragment
+        ) {
 
+    var registered = false;
     var SELECTORS = {
+        SAVE_BUTTON: '[data-action="save"]',
+        CANCEL_BUTTON: '[data-action="cancel"]',
         PROCESSOR: '[data-processor-name]',
         PREFERENCE_ROW: '[data-region="preference-row"]',
     };
 
     /**
-     * Constructor for the notification processor settings.
+     * Constructor for the Modal.
      *
-     * @param {object} element jQuery object root element of the processor
+     * @param {object} root The root jQuery element for the modal.
      */
-    var NotificationProcessorSettings = function(element) {
-        this.root = $(element);
-        this.name = this.root.attr('data-name');
-        this.userId = this.root.attr('data-user-id');
-        this.contextId = this.root.attr('data-context-id');
+    var NotificationProcessorSettings = function(root) {
+        Modal.call(this, root);
+        this.name = null;
+        this.userId = null;
+        this.contextId = null;
+        this.element = null;
+        this.saveButton = this.getFooter().find(SELECTORS.SAVE_BUTTON);
+        this.cancelButton = this.getFooter().find(SELECTORS.CANCEL_BUTTON);
+    };
+
+    NotificationProcessorSettings.TYPE = 'core_message-notification_processor_settings';
+    NotificationProcessorSettings.prototype = Object.create(Modal.prototype);
+    NotificationProcessorSettings.prototype.constructor = NotificationProcessorSettings;
+
+    /**
+     * Set the userid to the given value.
+     *
+     * @method setUserId
+     * @param {int} id The notification userid
+     */
+    NotificationProcessorSettings.prototype.setUserId = function(id) {
+        this.userId = id;
+    };
+
+    /**
+     * Retrieve the current userid, if any.
+     *
+     * @method getUserId
+     * @return {int|null} The notification userid
+     */
+    NotificationProcessorSettings.prototype.getUserId = function() {
+        return this.userId;
+    };
+
+    /**
+     * Set the object to the given value.
+     *
+     * @method setElement
+     * @param {object} element The notification node element.
+     */
+    NotificationProcessorSettings.prototype.setElement = function(element) {
+        this.element = element;
+    };
+
+    /**
+     * Retrieve the current element, if any.
+     *
+     * @method getElement
+     * @return {object|null} The notification node element.
+     */
+    NotificationProcessorSettings.prototype.getElement = function() {
+        return this.element;
+    };
+
+    /**
+     * Set the name to the given value.
+     *
+     * @method setName
+     * @param {string} name The notification name.
+     */
+    NotificationProcessorSettings.prototype.setName = function(name) {
+        this.name = name;
+    };
+
+    /**
+     * Retrieve the current name, if any.
+     *
+     * @method getName
+     * @return {string|null} The notification name.
+     */
+    NotificationProcessorSettings.prototype.getName = function() {
+        return this.name;
+    };
+    /**
+     * Set the context id to the given value.
+     *
+     * @method setContextId
+     * @param {Number} id The notification context id
+     */
+    NotificationProcessorSettings.prototype.setContextId = function(id) {
+        this.contextId = id;
+    };
+
+    /**
+     * Retrieve the current context id, if any.
+     *
+     * @method getContextId
+     * @return {Number|null} The notification context id
+     */
+    NotificationProcessorSettings.prototype.getContextId = function() {
+        return this.contextId;
+    };
+
+    /**
+     * Get the form element from the modal.
+     *
+     * @method getForm
+     * @return {object}
+     */
+    NotificationProcessorSettings.prototype.getForm = function() {
+        return this.getBody().find('form');
+    };
+
+    /**
+     * Disable the buttons in the footer.
+     *
+     * @method disableButtons
+     */
+    NotificationProcessorSettings.prototype.disableButtons = function() {
+        this.saveButton.prop('disabled', true);
+        this.cancelButton.prop('disabled', true);
+    };
+
+    /**
+     * Enable the buttons in the footer.
+     *
+     * @method enableButtons
+     */
+    NotificationProcessorSettings.prototype.enableButtons = function() {
+        this.saveButton.prop('disabled', false);
+        this.cancelButton.prop('disabled', false);
+    };
+
+    /**
+     * Load the title for the modal to the appropriate value
+     * depending on message outputs.
+     *
+     * @method loadTitleContent
+     * @return {object} A promise resolved with the new title text.
+     */
+    NotificationProcessorSettings.prototype.loadTitleContent = function() {
+        this.titlePromise = Str.get_string('processorsettings', 'message');
+        this.setTitle(this.titlePromise);
+
+        return this.titlePromise;
+    };
+
+    /**
+     * Load the body for the modal to the appropriate value
+     * depending on message outputs.
+     *
+     * @method loadBodyContent
+     * @return {object} A promise resolved with the fragment html and js from
+     */
+    NotificationProcessorSettings.prototype.loadBodyContent = function() {
+        this.disableButtons();
+
+        var args = {
+            userid: this.getUserId(),
+            type: this.getName(),
+        };
+
+        this.bodyPromise = Fragment.loadFragment('message', 'processor_settings', this.getContextId(), args);
+        this.setBody(this.bodyPromise);
+
+        this.bodyPromise.then(function() {
+            this.enableButtons();
+            return;
+        }.bind(this))
+        .fail(Notification.exception);
+
+        return this.bodyPromise;
     };
 
     /**
-     * Show the notification processor settings dialogue.
+     * Load both the title and body content.
+     *
+     * @method loadAllContent
+     * @return {object} promise
+     */
+    NotificationProcessorSettings.prototype.loadAllContent = function() {
+        return $.when(this.loadTitleContent(), this.loadBodyContent());
+    };
+
+    /**
+     * Load the modal content before showing it. This
+     * is to allow us to re-use the same modal for creating and
+     * editing different message outputs within the page.
      *
      * @method show
      */
     NotificationProcessorSettings.prototype.show = function() {
-        Fragment.loadFragment('message', 'processor_settings', this.contextId, {
-            userid: this.userId,
-            type: this.name,
-        })
-        .done(function(html, js) {
-            Str.get_string('processorsettings', 'message').done(function(string) {
-                var dialogue = new Dialogue(
-                    string,
-                    html,
-                    function() {
-                        Templates.runTemplateJS(js);
-                    },
-                    function() {
-                        // Removed dialogue from the DOM after close.
-                        dialogue.close();
-                    }
-                );
-
-                $(document).on('mpp:formsubmitted', function() {
-                    dialogue.close();
-                    this.updateConfiguredStatus();
-                }.bind(this));
-
-                $(document).on('mpp:formcancelled', function() {
-                    dialogue.close();
-                });
-            }.bind(this));
-        }.bind(this));
+        this.loadAllContent();
+        Modal.prototype.show.call(this);
+    };
+
+    /**
+     * Clear the notification from the modal when it's closed so
+     * that it is loaded fresh next time it's displayed.
+     *
+     * @method hide
+     */
+    NotificationProcessorSettings.prototype.hide = function() {
+        Modal.prototype.hide.call(this);
+        this.setContextId(null);
+        this.setName(null);
+        this.setUserId(null);
     };