Merge branch 'MDL-68761-master' of git://github.com/bmbrands/moodle
authorJake Dallimore <jake@moodle.com>
Fri, 5 Jun 2020 02:23:13 +0000 (10:23 +0800)
committerJake Dallimore <jake@moodle.com>
Fri, 5 Jun 2020 02:23:13 +0000 (10:23 +0800)
193 files changed:
admin/index.php
admin/renderer.php
admin/settings/top.php
admin/settings/userfeedback.php [new file with mode: 0644]
admin/templates/setting.mustache
backup/moodle2/restore_qtype_plugin.class.php
backup/tests/quiz_restore_decode_links_test.php [new file with mode: 0644]
badges/edit.php
badges/tests/behat/add_badge.feature
blocks/tests/externallib_test.php
config-dist.php
contentbank/amd/build/sort.min.js
contentbank/amd/build/sort.min.js.map
contentbank/amd/src/sort.js
contentbank/classes/output/bankcontent.php
contentbank/classes/privacy/provider.php
contentbank/contenttype/h5p/classes/form/editor.php
contentbank/contenttype/h5p/tests/behat/admin_upload_content.feature
contentbank/contenttype/h5p/tests/behat/teacher_upload_content.feature
contentbank/index.php
contentbank/lib.php [new file with mode: 0644]
contentbank/templates/bankcontent.mustache
contentbank/templates/bankcontent/toolbar.mustache
contentbank/templates/bankcontent/toolbar_dropdown.mustache
contentbank/tests/behat/edit_content.feature
contentbank/tests/behat/view_preferences.feature [new file with mode: 0644]
contentbank/tests/privacy_test.php
course/amd/build/activitychooser.min.js
course/amd/build/activitychooser.min.js.map
course/amd/src/activitychooser.js
course/format/upgrade.txt
course/tests/behat/rename_roles.feature
filter/displayh5p/filter.php
grade/edit/tree/index.php
grade/edit/tree/lib.php
grade/report/grader/lib.php
grade/report/lib.php
grade/tests/behat/behat_grade.php
grade/tests/behat/grade_item_duplication.feature [new file with mode: 0644]
group/tests/behat/create_groups.feature
group/tests/behat/group_description.feature
h5p/ajax.php
h5p/amd/build/editor_display.min.js
h5p/amd/build/editor_display.min.js.map
h5p/amd/src/editor_display.js
h5p/classes/core.php
h5p/classes/editor.php
h5p/classes/editor_ajax.php
h5p/classes/framework.php
h5p/classes/helper.php
h5p/tests/editor_ajax_test.php
install/lang/mwl/langconfig.php [new file with mode: 0644]
lang/en/admin.php
lang/en/contentbank.php
lang/en/deprecated.txt
lang/en/grades.php
lang/en/moodle.php
lang/en/user.php
lib/accesslib.php
lib/adminlib.php
lib/amd/build/userfeedback.min.js [new file with mode: 0644]
lib/amd/build/userfeedback.min.js.map [new file with mode: 0644]
lib/amd/src/userfeedback.js [new file with mode: 0644]
lib/behat/classes/behat_core_generator.php
lib/behat/classes/partial_named_selector.php
lib/behat/core_behat_file_helper.php
lib/behat/form_field/behat_form_field.php
lib/behat/form_field/behat_form_filemanager.php
lib/classes/event/userfeedback_give.php [new file with mode: 0644]
lib/classes/event/userfeedback_remind.php [new file with mode: 0644]
lib/classes/external/userfeedback/generate_url.php [new file with mode: 0644]
lib/classes/external/userfeedback/record_action.php [new file with mode: 0644]
lib/classes/notification.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/user.php
lib/classes/userfeedback.php [new file with mode: 0644]
lib/db/services.php
lib/deprecatedlib.php
lib/form/templates/element-advcheckbox-inline.mustache
lib/form/templates/element-checkbox-inline.mustache
lib/form/templates/element-filemanager.mustache
lib/form/templates/element-filepicker.mustache
lib/form/templates/element-group-inline.mustache
lib/form/templates/element-group.mustache
lib/form/templates/element-password-inline.mustache
lib/form/templates/element-password.mustache
lib/form/templates/element-template.mustache
lib/form/templates/element-text-inline.mustache
lib/form/templates/element-text.mustache
lib/form/templates/element-url.mustache
lib/grade/grade_item.php
lib/grade/tests/grade_item_test.php
lib/outputcomponents.php
lib/outputrenderers.php
lib/questionlib.php
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/build/local/dynamic/repository.min.js
lib/table/amd/build/local/dynamic/repository.min.js.map
lib/table/amd/src/dynamic.js
lib/table/amd/src/local/dynamic/repository.js
lib/table/classes/external/dynamic/get.php [moved from lib/table/classes/external/dynamic/fetch.php with 99% similarity]
lib/table/classes/local/filter/filter.php
lib/table/classes/local/filter/filterset.php
lib/table/tests/external/dynamic/get_test.php [moved from lib/table/tests/external/dynamic/fetch_test.php with 94% similarity]
lib/templates/campaign_content.mustache [new file with mode: 0644]
lib/templates/filemanager_fileselect.mustache
lib/templates/local/notification/cta.mustache [new file with mode: 0644]
lib/templates/userfeedback_footer_link.mustache [new file with mode: 0644]
lib/tests/external/userfeedback/generate_url_test.php [new file with mode: 0644]
lib/tests/external/userfeedback/record_action_test.php [new file with mode: 0644]
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js
lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue.js
lib/yui/src/notification/js/dialogue.js
message/output/popup/templates/notification_popover.mustache
message/templates/message_drawer_view_settings_body_content.mustache
message/templates/message_index.mustache
mod/h5pactivity/classes/external/get_results.php
mod/h5pactivity/classes/output/attempt.php
mod/h5pactivity/classes/output/result/sequencing.php
mod/h5pactivity/classes/output/result/truefalse.php
mod/h5pactivity/pix/icon.svg
mod/h5pactivity/version.php
mod/lti/launch.php
mod/lti/locallib.php
mod/lti/service/gradebookservices/backup/moodle2/restore_ltiservice_gradebookservices_subplugin.class.php
mod/lti/service/gradebookservices/classes/local/resources/lineitem.php
mod/lti/service/gradebookservices/classes/local/resources/lineitems.php
mod/lti/service/gradebookservices/classes/local/service/gradebookservices.php
mod/lti/service/gradebookservices/tests/gradebookservices_test.php
mod/lti/view.php
mod/quiz/classes/output/edit_renderer.php
mod/quiz/styles.css
my/index.php
pix/i/bullhorn.svg [new file with mode: 0644]
privacy/classes/local/request/moodle_content_writer.php
repository/filepicker.js
repository/tests/behat/cancel_add_file.feature
repository/tests/behat/select_file.feature
repository/upload/tests/behat/behat_repository_upload.php
theme/boost/amd/build/loader.min.js
theme/boost/amd/build/loader.min.js.map
theme/boost/amd/build/pending.min.js
theme/boost/amd/build/pending.min.js.map
theme/boost/amd/src/loader.js
theme/boost/amd/src/pending.js
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/contentbank.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/question.scss
theme/boost/style/moodle.css
theme/boost/templates/columns1.mustache
theme/boost/templates/columns2.mustache
theme/boost/templates/secure.mustache
theme/classic/scss/classic/post.scss
theme/classic/style/moodle.css
theme/classic/templates/columns.mustache
theme/classic/templates/contentonly.mustache
theme/classic/templates/secure.mustache
theme/classic/tests/behat/behat_theme_classic_behat_repository_upload.php
user/amd/build/local/participantsfilter/filter.min.js
user/amd/build/local/participantsfilter/filter.min.js.map
user/amd/build/local/participantsfilter/filtertypes/keyword.min.js
user/amd/build/local/participantsfilter/filtertypes/keyword.min.js.map
user/amd/build/local/participantsfilter/selectors.min.js
user/amd/build/local/participantsfilter/selectors.min.js.map
user/amd/build/participantsfilter.min.js
user/amd/build/participantsfilter.min.js.map
user/amd/build/unified_filter.min.js.map
user/amd/build/unified_filter_datasource.min.js.map
user/amd/src/local/participantsfilter/filter.js
user/amd/src/local/participantsfilter/filtertypes/keyword.js
user/amd/src/local/participantsfilter/selectors.js
user/amd/src/participantsfilter.js
user/amd/src/unified_filter.js
user/amd/src/unified_filter_datasource.js
user/classes/output/participants_filter.php
user/classes/output/unified_filter.php
user/classes/table/participants_search.php
user/index.php
user/lib.php
user/renderer.php
user/templates/local/participantsfilter/autocomplete_selection_items.mustache
user/templates/local/participantsfilter/filterrow.mustache
user/templates/unified_filter.mustache
user/tests/behat/filter_participants.feature
user/tests/behat/filter_participants_showall.feature
user/tests/behat/view_participants_groups.feature
user/tests/table/participants_search_test.php
user/tests/userlib_test.php
user/upgrade.txt
version.php

index ee3b2e3..5660318 100644 (file)
@@ -899,6 +899,9 @@ if (empty($CFG->disabledevlibdirscheck) && (is_dir($CFG->dirroot.'/vendor') || i
 // Check if the site is being foced onto ssl.
 $overridetossl = !empty($CFG->overridetossl);
 
+// Check if moodle campaign content setting is enabled or not.
+$showcampaigncontent = !isset($CFG->showcampaigncontent) || $CFG->showcampaigncontent;
+
 admin_externalpage_setup('adminnotifications');
 
 $output = $PAGE->get_renderer('core', 'admin');
@@ -906,4 +909,5 @@ $output = $PAGE->get_renderer('core', 'admin');
 echo $output->admin_notifications_page($maturity, $insecuredataroot, $errorsdisplayed, $cronoverdue, $dbproblems,
                                        $maintenancemode, $availableupdates, $availableupdatesfetch, $buggyiconvnomb,
                                        $registered, $cachewarnings, $eventshandlers, $themedesignermode, $devlibdir,
-                                       $mobileconfigured, $overridetossl, $invalidforgottenpasswordurl, $croninfrequent);
+                                       $mobileconfigured, $overridetossl, $invalidforgottenpasswordurl, $croninfrequent,
+                                       $showcampaigncontent);
index 45f46a5..cda9ed6 100644 (file)
@@ -282,6 +282,7 @@ class core_admin_renderer extends plugin_renderer_base {
      * @param bool $overridetossl Whether or not ssl is being forced.
      * @param bool $invalidforgottenpasswordurl Whether the forgotten password URL does not link to a valid URL.
      * @param bool $croninfrequent If true, warn that cron hasn't run in the past few minutes
+     * @param bool $showcampaigncontent Whether the campaign content should be visible or not.
      *
      * @return string HTML to output.
      */
@@ -289,7 +290,9 @@ class core_admin_renderer extends plugin_renderer_base {
             $cronoverdue, $dbproblems, $maintenancemode, $availableupdates, $availableupdatesfetch,
             $buggyiconvnomb, $registered, array $cachewarnings = array(), $eventshandlers = 0,
             $themedesignermode = false, $devlibdir = false, $mobileconfigured = false,
-            $overridetossl = false, $invalidforgottenpasswordurl = false, $croninfrequent = false) {
+            $overridetossl = false, $invalidforgottenpasswordurl = false, $croninfrequent = false,
+            $showcampaigncontent = false) {
+
         global $CFG;
         $output = '';
 
@@ -312,6 +315,7 @@ class core_admin_renderer extends plugin_renderer_base {
         $output .= $this->registration_warning($registered);
         $output .= $this->mobile_configuration_warning($mobileconfigured);
         $output .= $this->forgotten_password_url_warning($invalidforgottenpasswordurl);
+        $output .= $this->campaign_content($showcampaigncontent);
 
         //////////////////////////////////////////////////////////////////////////////////////////////////
         ////  IT IS ILLEGAL AND A VIOLATION OF THE GPL TO HIDE, REMOVE OR MODIFY THIS COPYRIGHT NOTICE ///
@@ -878,6 +882,20 @@ class core_admin_renderer extends plugin_renderer_base {
         return $output;
     }
 
+    /**
+     * Display campaign content.
+     *
+     * @param bool $showcampaigncontent Whether the campaign content should be visible or not.
+     * @return string the campaign content raw html.
+     */
+    protected function campaign_content(bool $showcampaigncontent): string {
+        if (!$showcampaigncontent) {
+            return '';
+        }
+
+        return $this->render_from_template('core/campaign_content', ['lang' => current_language()]);
+    }
+
     /**
      * Display a warning about the forgotten password URL not linking to a valid URL.
      *
index 8717843..992436b 100644 (file)
@@ -20,6 +20,9 @@ $moodleservices = new admin_settingpage('moodleservices', new lang_string('moodl
     'admin'));
 $ADMIN->add('root', $moodleservices);
 
+$userfeedback = new admin_settingpage('userfeedback', new lang_string('feedbacksettings', 'admin'));
+$ADMIN->add('root', $userfeedback);
+
 if ($hassiteconfig) {
     $optionalsubsystems = new admin_settingpage('optionalsubsystems', new lang_string('advancedfeatures', 'admin'));
     $ADMIN->add('root', $optionalsubsystems);
diff --git a/admin/settings/userfeedback.php b/admin/settings/userfeedback.php
new file mode 100644 (file)
index 0000000..b7f3128
--- /dev/null
@@ -0,0 +1,49 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file contains call to feedback settings
+ *
+ * @package    core
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+if ($hassiteconfig) {
+
+    $userfeedback->add(new admin_setting_configcheckbox('enableuserfeedback',
+            new lang_string('enableuserfeedback', 'admin'),
+            new lang_string('enableuserfeedback_desc', 'admin'), 1, 1, 0));
+
+    $options = [
+        core_userfeedback::REMIND_AFTER_UPGRADE => new lang_string('userfeedbackafterupgrade', 'admin'),
+        core_userfeedback::REMIND_PERIODICALLY => new lang_string('userfeedbackperiodically', 'admin'),
+        core_userfeedback::REMIND_NEVER => new lang_string('never'),
+    ];
+    $userfeedback->add(new admin_setting_configselect('userfeedback_nextreminder',
+            new lang_string('userfeedbacknextreminder', 'admin'),
+            new lang_string('userfeedbacknextreminder_desc', 'admin'), 1, $options));
+    $userfeedback->hide_if('userfeedback_nextreminder', 'enableuserfeedback');
+
+    $userfeedback->add(new admin_setting_configtext('userfeedback_remindafter',
+            new lang_string('userfeedbackremindafter', 'admin'),
+            new lang_string('userfeedbackremindafter_desc', 'admin'), 90, PARAM_INT));
+    $userfeedback->hide_if('userfeedback_remindafter', 'enableuserfeedback');
+    $userfeedback->hide_if('userfeedback_remindafter', 'userfeedback_nextreminder', 'eq', 3);
+
+}
index a3a3a22..498aa2c 100644 (file)
 }}
 <div class="form-item row" id="{{id}}">
     <div class="form-label col-sm-3 text-sm-right">
-        <label {{#labelfor}}for="{{labelfor}}"{{/labelfor}}>
-            {{{title}}}
-            {{#override}}
-                <div class="alert alert-info">{{override}}</div>
-            {{/override}}
-            {{#warning}}
-                <div class="alert alert-warning">{{warning}}</div>
-            {{/warning}}
-        </label>
+        {{#customcontrol}}
+            <p {{#labelfor}}id="{{labelfor}}_label"{{/labelfor}}>
+                {{{title}}}
+                {{#override}}
+                    <div class="alert alert-info">{{override}}</div>
+                {{/override}}
+                {{#warning}}
+                    <div class="alert alert-warning">{{warning}}</div>
+                {{/warning}}
+            </p>
+        {{/customcontrol}}
+        {{^customcontrol}}
+            <label {{#labelfor}}for="{{labelfor}}"{{/labelfor}}>
+                {{{title}}}
+                {{#override}}
+                    <div class="alert alert-info">{{override}}</div>
+                {{/override}}
+                {{#warning}}
+                    <div class="alert alert-warning">{{warning}}</div>
+                {{/warning}}
+            </label>
+        {{/customcontrol}}
         <span class="form-shortname d-block small text-muted">{{{name}}}</span>
     </div>
     <div class="form-setting col-sm-9">
         {{#dependenton}}<div class="form-dependenton mb-4 text-muted">{{{.}}}</div>{{/dependenton}}
     </div>
 </div>
+{{#customcontrol}}
+    {{#js}}
+        require(['jquery'], function($) {
+            $('#{{id}}_label').css('cursor', 'default');
+            $('#{{id}}_label').click(function() {
+                $('#{{id}}')
+                    .find('button, a, input:not([type="hidden"]), select, textarea, [tabindex]')
+                    .filter(':not([disabled]):not([tabindex="0"]):not([tabindex="-1"])')
+                    .first().focus();
+            });
+        });
+    {{/js}}
+{{/customcontrol}}
index 7520aa3..49bd8a1 100644 (file)
@@ -175,6 +175,20 @@ abstract class restore_qtype_plugin extends restore_plugin {
                 }
             }
 
+            $rules = restore_course_task::define_decode_rules();
+            $rulesactivity = restore_quiz_activity_task::define_decode_rules();
+            $rules = array_merge($rules, $rulesactivity);
+
+            $decoder = $this->task->get_decoder();
+            foreach ($rules as $rule) {
+                $decoder->add_rule($rule);
+            }
+
+            $contentdecoded = $decoder->decode_content($data->answertext);
+            if ($contentdecoded) {
+                $data->answertext = $contentdecoded;
+            }
+
             if (!isset($this->questionanswercache[$data->answertext])) {
                 // If we haven't found the matching answer, something has gone really wrong, the question in the DB
                 // is missing answers, throw an exception.
diff --git a/backup/tests/quiz_restore_decode_links_test.php b/backup/tests/quiz_restore_decode_links_test.php
new file mode 100644 (file)
index 0000000..19a142d
--- /dev/null
@@ -0,0 +1,97 @@
+<?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/>.
+
+/**
+ * Decode links quiz restore tests.
+ *
+ * @package    core_backup
+ * @copyright  2020 Ilya Tregubov <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+// Include all the needed stuff.
+global $CFG;
+require_once($CFG->dirroot . '/course/lib.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+require_once($CFG->dirroot . '/question/engine/tests/helpers.php');
+
+/**
+ * restore_decode tests (both rule and content)
+ */
+class restore_quiz_decode_testcase extends \core_privacy\tests\provider_testcase {
+
+    /**
+     * Test restore_decode_rule class
+     */
+    public function test_restore_quiz_decode_links() {
+        global $DB, $CFG, $USER;
+
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course(
+            array('format' => 'topics', 'numsections' => 3,
+                'enablecompletion' => COMPLETION_ENABLED),
+            array('createsections' => true));
+        $quiz = $generator->create_module('quiz', array(
+            'course' => $course->id));
+
+        // Create questions.
+
+        $questiongenerator = $this->getDataGenerator()->get_plugin_generator('core_question');
+        $context = context_course::instance($course->id);
+        $cat = $questiongenerator->create_question_category(array('contextid' => $context->id));
+        $question = $questiongenerator->create_question('multichoice', null, array('category' => $cat->id));
+
+        // Add to the quiz.
+        quiz_add_quiz_question($question->id, $quiz);
+
+        $questiondata = question_bank::load_question_data($question->id);
+
+        $firstanswer = array_shift($questiondata->options->answers);
+        $DB->set_field('question_answers', 'answer', $CFG->wwwroot . '/course/view.php?id=' . $course->id,
+            ['id' => $firstanswer->id]);
+
+        $secondanswer = array_shift($questiondata->options->answers);
+        $DB->set_field('question_answers', 'answer', $CFG->wwwroot . '/mod/quiz/view.php?id=' . $quiz->cmid,
+            ['id' => $secondanswer->id]);
+
+        $thirdanswer = array_shift($questiondata->options->answers);
+        $DB->set_field('question_answers', 'answer', $CFG->wwwroot . '/grade/report/index.php?id=' . $quiz->cmid,
+            ['id' => $thirdanswer->id]);
+
+        $fourthanswer = array_shift($questiondata->options->answers);
+        $DB->set_field('question_answers', 'answer', $CFG->wwwroot . '/mod/quiz/index.php?id=' . $quiz->cmid,
+            ['id' => $fourthanswer->id]);
+
+        $newcm = duplicate_module($course, get_fast_modinfo($course)->get_cm($quiz->cmid));
+
+        $sql = "SELECT qa.answer
+                  FROM {quiz} q
+             LEFT JOIN {quiz_slots} qs ON qs.quizid = q.id
+             LEFT JOIN {question_answers} qa ON qa.question = qs.questionid
+                 WHERE q.id = :quizid";
+        $params = array('quizid' => $newcm->instance);
+        $answers = $DB->get_fieldset_sql($sql, $params);
+        $this->assertEquals($CFG->wwwroot . '/course/view.php?id=' . $course->id, $answers[0]);
+        $this->assertEquals($CFG->wwwroot . '/mod/quiz/view.php?id=' . $quiz->cmid, $answers[1]);
+        $this->assertEquals($CFG->wwwroot . '/grade/report/index.php?id=' . $quiz->cmid, $answers[2]);
+        $this->assertEquals($CFG->wwwroot . '/mod/quiz/index.php?id=' . $quiz->cmid, $answers[3]);
+    }
+}
index 2307d0d..d52a7c8 100644 (file)
@@ -100,9 +100,11 @@ if ($form->is_cancelled()) {
         $badge->imageauthorurl = $data->imageauthorurl;
         $badge->imagecaption = $data->imagecaption;
         $badge->usermodified = $USER->id;
-        $badge->issuername = $data->issuername;
-        $badge->issuerurl = $data->issuerurl;
-        $badge->issuercontact = $data->issuercontact;
+        if (badges_open_badges_backpack_api() == OPEN_BADGES_V1) {
+            $badge->issuername = $data->issuername;
+            $badge->issuerurl = $data->issuerurl;
+            $badge->issuercontact = $data->issuercontact;
+        }
         $badge->expiredate = ($data->expiry == 1) ? $data->expiredate : null;
         $badge->expireperiod = ($data->expiry == 2) ? $data->expireperiod : null;
 
index 09db662..bc7695d 100644 (file)
@@ -166,3 +166,32 @@ Feature: Add badges to the system
     And I click on "Site badges" "link" in the "Navigation" "block"
     Then I should see "Manage badges"
     And I should see "Add a new badge"
+
+  @javascript @_file_upload
+  Scenario: Edit a badge
+    Given I navigate to "Badges > Badges settings" in site administration
+    And I set the field "Badge issuer name" to "Test Badge Site"
+    And I set the field "Badge issuer email address" to "testuser@example.com"
+    And I press "Save changes"
+    And I navigate to "Badges > Add a new badge" in site administration
+    And I set the following fields to these values:
+      | Name | Test badge with 'apostrophe' and other friends (<>&@#) |
+      | Version | firstversion |
+      | Language | English |
+      | Description | Test badge description |
+      | Image author | http://author.example.com |
+      | Image caption | Test caption image |
+    And I upload "badges/tests/behat/badge.png" file to "Image" filemanager
+    And I press "Create badge"
+    When I follow "Edit details"
+    And I should see "Test badge with 'apostrophe' and other friends (&@#)"
+    And I should not see "Issuer details"
+    And I set the following fields to these values:
+      | Name | Test badge renamed |
+      | Version | secondversion |
+    And I press "Save changes"
+    And I follow "Overview"
+    Then I should not see "Test badge with 'apostrophe' and other friends (&@#)"
+    And I should not see "firstversion"
+    And I should see "Test badge renamed"
+    And I should see "secondversion"
index bd815e6..ee1b61b 100644 (file)
@@ -267,9 +267,11 @@ class core_block_externallib_testcase extends externallib_advanced_testcase {
             $returnedblocks[] = $block['name'];
             // Check the configuration returned for this default block.
             if ($block['name'] == 'recentlyaccessedcourses') {
-                $this->assertEquals('displaycategories', $block['configs'][0]['name']);
-                $this->assertEquals(json_encode('0'), $block['configs'][0]['value']);
-                $this->assertEquals('plugin', $block['configs'][0]['type']);
+                // Convert config to associative array to avoid DB sorting randomness.
+                $config = array_column($block['configs'], null, 'name');
+                $this->assertArrayHasKey('displaycategories', $config);
+                $this->assertEquals(json_encode('0'), $config['displaycategories']['value']);
+                $this->assertEquals('plugin', $config['displaycategories']['type']);
             }
         }
         // Remove lp block.
index ccc760a..995e0df 100644 (file)
@@ -1055,6 +1055,15 @@ $CFG->admin = 'admin';
 //      $CFG->alternative_file_system_class = '\\local_myfilestorage\\file_system';
 //
 //=========================================================================
+// 15. CAMPAIGN CONTENT
+//=========================================================================
+//
+// We have added a campaign content to the notifications page, in case you want to hide that from your site you just
+// need to set showcampaigncontent setting to false.
+//
+//      $CFG->showcampaigncontent = true;
+//
+//=========================================================================
 // ALL DONE!  To continue installation, visit your main page with a browser
 //=========================================================================
 
index 5d0babf..bd655b7 100644 (file)
Binary files a/contentbank/amd/build/sort.min.js and b/contentbank/amd/build/sort.min.js differ
index 4609a9e..663a438 100644 (file)
Binary files a/contentbank/amd/build/sort.min.js.map and b/contentbank/amd/build/sort.min.js.map differ
index bfa61c8..24f4f79 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-import selectors from 'core_contentbank/selectors';
+import selectors from './selectors';
 import {get_string as getString} from 'core/str';
 import Prefetch from 'core/prefetch';
+import Ajax from 'core/ajax';
+import Notification from 'core/notification';
 
 /**
  * Set up the contentbank views.
@@ -59,6 +61,7 @@ const registerListenerEvents = (contentBank) => {
         contentBank.classList.add('view-grid');
         viewGrid.classList.add('active');
         viewList.classList.remove('active');
+        setViewListPreference(false);
     });
 
     viewList.addEventListener('click', () => {
@@ -66,6 +69,7 @@ const registerListenerEvents = (contentBank) => {
         contentBank.classList.add('view-list');
         viewList.classList.add('active');
         viewGrid.classList.remove('active');
+        setViewListPreference(true);
     });
 
     // Sort by file name alphabetical
@@ -97,6 +101,35 @@ const registerListenerEvents = (contentBank) => {
     });
 };
 
+
+/**
+ * Set the contentbank user preference in list view
+ *
+ * @param  {Bool} viewList view ContentBank as list.
+ * @return {Promise} Repository promise.
+ */
+const setViewListPreference = function(viewList) {
+
+    // If the given status is not hidden, the preference has to be deleted with a null value.
+    if (viewList === false) {
+        viewList = null;
+    }
+
+    const request = {
+        methodname: 'core_user_update_user_preferences',
+        args: {
+            preferences: [
+                {
+                    type: 'core_contentbank_view_list',
+                    value: viewList
+                }
+            ]
+        }
+    };
+
+    return Ajax.call([request])[0].catch(Notification.exception);
+};
+
 /**
  * Update the sort button view.
  *
index b851222..5549392 100644 (file)
@@ -97,11 +97,12 @@ class bankcontent implements renderable, templatable {
                 'type' => $mimetype
             );
         }
+        $data->viewlist = get_user_preferences('core_contentbank_view_list');
         $data->contents = $contentdata;
         // The tools are displayed in the action bar on the index page.
         foreach ($this->toolbar as $tool) {
             // Customize the output of a tool, like dropdowns.
-            $method = 'export_tool_'.$tool['name'];
+            $method = 'export_tool_'.$tool['action'];
             if (method_exists($this, $method)) {
                 $this->$method($tool);
             }
index d654ab3..c0c49c5 100644 (file)
@@ -44,7 +44,8 @@ use context_course;
 class provider implements
     \core_privacy\local\metadata\provider,
     \core_privacy\local\request\core_userlist_provider,
-    \core_privacy\local\request\plugin\provider {
+    \core_privacy\local\request\plugin\provider,
+    \core_privacy\local\request\user_preference_provider {
 
     /**
      * Returns meta data about this system.
@@ -65,6 +66,26 @@ class provider implements
         return $collection;
     }
 
+    /**
+     * Export all user preferences for the contentbank
+     *
+     * @param int $userid The userid of the user whose data is to be exported.
+     */
+    public static function export_user_preferences(int $userid) {
+        $preference = get_user_preferences('core_contentbank_view_list', null, $userid);
+        if (isset($preference)) {
+            writer::export_user_preference(
+                    'core_contentbank',
+                    'core_contentbank_view_list',
+                    $preference,
+                    get_string('privacy:request:preference:set', 'core_contentbank', (object) [
+                            'name' => 'core_contentbank_view_list',
+                            'value' => $preference,
+                    ])
+            );
+        }
+    }
+
     /**
      * Get the list of contexts that contain user information for the specified user.
      *
index b7229b9..2db4714 100644 (file)
@@ -30,6 +30,7 @@ use core_contentbank\form\edit_content;
 use core_h5p\api;
 use core_h5p\editor as h5peditor;
 use core_h5p\factory;
+use core_h5p\helper;
 use stdClass;
 
 /**
@@ -53,6 +54,8 @@ class editor extends edit_content {
         global $DB;
 
         $mform = $this->_form;
+        $errors = [];
+        $notifications = [];
 
         // Id of the content to edit.
         $id = $this->_customdata['id'];
@@ -73,9 +76,22 @@ class editor extends edit_content {
             $file = $this->content->get_file();
 
             $h5p = api::get_content_from_pathnamehash($file->get_pathnamehash());
-            $mform->addElement('hidden', 'h5pid', $h5p->id);
-            $mform->setType('h5pid', PARAM_INT);
-            $this->h5peditor->set_content($h5p->id);
+            if (!$h5p) {
+                // H5P content has not been deployed yet. Let's check why.
+                $factory = new \core_h5p\factory();
+                $factory->get_framework()->set_file($file);
+
+                $h5pid = helper::save_h5p($factory, $file, new stdClass());
+                $errors = $factory->get_framework()->getMessages('error');
+                $notifications = $factory->get_framework()->getMessages('info');
+            } else {
+                $h5pid = $h5p->id;
+            }
+            if ($h5pid) {
+                $mform->addElement('hidden', 'h5pid', $h5pid);
+                $mform->setType('h5pid', PARAM_INT);
+                $this->h5peditor->set_content($h5pid);
+            }
         } else {
             // The H5P editor needs the H5P content type library name for a new content.
             $mform->addElement('hidden', 'library', $library);
@@ -86,11 +102,20 @@ class editor extends edit_content {
         $mformid = 'coolh5peditor';
         $mform->setAttributes(array('id' => $mformid) + $mform->getAttributes());
 
-        $this->add_action_buttons();
-
-        $this->h5peditor->add_editor_to_form($mform);
-
-        $this->add_action_buttons();
+        if ($errors || $notifications) {
+            // Show the error messages and a Cancel button.
+            foreach ($errors as $error) {
+                $mform->addElement('warning', $error->code, 'notify', $error->message);
+            }
+            foreach ($notifications as $key => $notification) {
+                $mform->addElement('warning', 'notification_'.$key, 'notify', $notification);
+            }
+            $mform->addElement('cancel', 'cancel', get_string('back'));
+        } else {
+            $this->add_action_buttons();
+            $this->h5peditor->add_editor_to_form($mform);
+            $this->add_action_buttons();
+        }
     }
 
     /**
index 8639d35..5e3bcbe 100644 (file)
@@ -70,3 +70,23 @@ Feature: H5P file upload to content bank for admins
     And I expand "Site pages" node
     And I click on "Content bank" "link"
     And I should not see "filltheblanks.h5p"
+
+  Scenario: Admins can upload and deployed content types when libraries are not installed
+    Given I navigate to "H5P > Manage H5P content types" in site administration
+    And I should not see "Fill in the Blanks"
+    And I follow "Dashboard" in the user menu
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I should not see "filltheblanks.h5p"
+    When I click on "Upload" "link"
+    And I click on "Choose a file..." "button"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Save changes" "button"
+    And I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    Then I should see "Of which countries"
+    And I switch to the main frame
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I should see "Fill in the Blanks"
index 9c25ac9..48f6e22 100644 (file)
@@ -71,3 +71,75 @@ Feature: H5P file upload to content bank for non admins
     And I expand "Site pages" node
     And I click on "Content bank" "link"
     Then I should see "filltheblanks.h5p"
+
+  Scenario: Teachers can not upload and deployed content types when libraries are not installed
+    Given I log out
+    And I log in as "admin"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I should not see "Fill in the Blanks"
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    When I click on "Upload" "link"
+    And I click on "Choose a file..." "button"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Save changes" "button"
+    And I switch to "h5p-player" class iframe
+    Then I should not see "Of which countries"
+    And I should see "missing-required-library"
+    And I switch to the main frame
+    And I log out
+    And I log in as "admin"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I should not see "Fill in the Blanks"
+
+  Scenario: Teachers can not see existing contents when libraries are not installed
+    Given I log out
+    And I log in as "admin"
+    And I follow "Manage private files..."
+    And I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "Files" filemanager
+    And I click on "Save changes" "button"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    And I should not see "Fill in the Blanks"
+    When I upload "h5p/tests/fixtures/filltheblanks.h5p" file to "H5P content type" filemanager
+    And I click on "Upload H5P content types" "button" in the "#fitem_id_uploadlibraries" "css_element"
+    And I wait until the page is ready
+    And I should see "Fill in the Blanks"
+    And I log out
+    And I log in as "teacher1"
+    Given I am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+    When I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I click on "Upload" "link"
+    And I click on "Choose a file..." "button"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Save changes" "button"
+    And I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    Then I should see "Of which countries"
+    Then I should not see "missing-required-library"
+    And I switch to the main frame
+    Given I log out
+    And I log in as "admin"
+    And I navigate to "H5P > Manage H5P content types" in site administration
+    When I click on "Delete version" "link" in the "Fill in the Blanks" "table_row"
+    And I press "Continue"
+    Then I should not see "Fill in the Blanks"
+    And I log out
+    And I log in as "teacher1"
+    Given I am on "Course 1" course homepage
+    When I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I should see "filltheblanks.h5p"
+    And I click on "filltheblanks.h5p" "link"
+    And I switch to "h5p-player" class iframe
+    Then I should not see "Of which countries"
+    Then I should see "missing-required-library"
index e4bb6d6..5608a9a 100644 (file)
@@ -70,7 +70,12 @@ if (has_capability('moodle/contentbank:useeditor', $context)) {
     if (!empty($editabletypes)) {
         // Editor base URL.
         $editbaseurl = new moodle_url('/contentbank/edit.php', ['contextid' => $contextid]);
-        $toolbar[] = ['name' => get_string('add'), 'link' => $editbaseurl, 'dropdown' => true, 'contenttypes' => $editabletypes];
+        $toolbar[] = [
+            'name' => get_string('add'),
+            'link' => $editbaseurl, 'dropdown' => true,
+            'contenttypes' => $editabletypes,
+            'action' => 'add'
+        ];
     }
 }
 
@@ -80,7 +85,12 @@ if (has_capability('moodle/contentbank:upload', $context)) {
     $accepted = $cb->get_supported_extensions_as_string($context);
     if (!empty($accepted)) {
         $importurl = new moodle_url('/contentbank/upload.php', ['contextid' => $contextid]);
-        $toolbar[] = array('name' => get_string('upload', 'contentbank'), 'link' => $importurl, 'icon' => 'i/upload');
+        $toolbar[] = [
+            'name' => get_string('upload', 'contentbank'),
+            'link' => $importurl,
+            'icon' => 'i/upload',
+            'action' => 'upload'
+        ];
     }
 }
 
diff --git a/contentbank/lib.php b/contentbank/lib.php
new file mode 100644 (file)
index 0000000..e709df7
--- /dev/null
@@ -0,0 +1,39 @@
+<?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/>.
+
+/**
+ * Library functions for contentbank
+ *
+ * @package   core_contentbank
+ * @copyright 2020 Bas Brands
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Get the current user preferences that are available
+ *
+ * @return Array preferences configuration
+ */
+function core_contentbank_user_preferences() {
+    return [
+        'core_contentbank_view_list' => [
+            'choices' => array(0, 1),
+            'type' => PARAM_INT,
+            'null' => NULL_NOT_ALLOWED,
+            'default' => 'none'
+        ],
+    ];
+}
index 020b905..4801a7f 100644 (file)
@@ -74,7 +74,8 @@
     }
 
 }}
-<div class="content-bank-container view-grid" data-region="contentbank">
+<div class="content-bank-container {{#viewlist}}view-list{{/viewlist}} {{^viewlist}}view-grid{{/viewlist}}"
+data-region="contentbank">
     <div class="d-flex justify-content-between flex-column flex-sm-row">
         <div class="cb-search-container mb-2">
             {{>core_contentbank/bankcontent/search}}
                         <div class="cb-thumbnail" role="img" aria-label="{{{ name }}}"
                         style="background-image: url('{{{ icon }}}');">
                         </div>
-                        <a href="{{{ link }}}" class="cb-link stretched-link">
+                        <a href="{{{ link }}}" class="cb-link stretched-link" title="{{{ name }}}">
                             <span class="cb-name word-break-all clamp-2" data-region="cb-content-name">
                                 {{{ name }}}
                             </span>
index 242fa1a..4d590ce 100644 (file)
         </a>
     {{/dropdown}}
 {{/tools}}
-<button class="icon-no-margin btn btn-secondary active ml-2"
+<button class="icon-no-margin btn btn-secondary {{^viewlist}}active{{/viewlist}} ml-2"
 title="{{#str}}  displayicons, contentbank  {{/str}}"
 data-action="viewgrid">
     {{#pix}}a/view_icon_active, core, {{#str}} displayicons, contentbank {{/str}} {{/pix}}
 </button>
-<button class="icon-no-margin btn btn-secondary"
+<button class="icon-no-margin btn btn-secondary {{#viewlist}}active{{/viewlist}}"
 title="{{#str}} displaydetails, contentbank {{/str}}"
 data-action="viewlist">
     {{#pix}}t/viewdetails, core, {{#str}} displaydetails, contentbank {{/str}} {{/pix}}
index 7a2fbf5..f49dbd6 100644 (file)
                 {{/typeeditorparams}}
                 {{#typeeditorparams}}
                     <a class="dropdown-item icon-size-4" href="{{{ baseurl }}}&{{{ typeeditorparams }}}">
-                        <img alt="" class="icon" src="{{{ typeicon }}}"> {{ typename }}
+                        {{#typeicon}}
+                            <img alt="" class="icon" src="{{{ typeicon }}}">
+                        {{/typeicon}}
+                        {{^typeicon}}
+                            {{#pix}} b/h5p_library, core {{/pix}}
+                        {{/typeicon}} {{ typename }}
                     </a>
                 {{/typeeditorparams}}
             {{/types}}
index 713768c..aef6eab 100644 (file)
@@ -97,3 +97,21 @@ Feature: Content bank use editor feature
       | moodle/contentbank:useeditor     | Prohibit   | editingteacher | System       |           |
     And I reload the page
     Then "[data-action=Add-content]" "css_element" should not exist
+
+  Scenario: Users can edit content and save changes
+    Given the following "contentbank content" exist:
+      | contextlevel | reference | contenttype     | user  | contentname             | filepath                                    |
+      | System       |           | contenttype_h5p | admin | filltheblanks.h5p       | /h5p/tests/fixtures/filltheblanks.h5p       |
+    And I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Content bank" "link" in the "Navigation" "block"
+    And I click on "filltheblanks.h5p" "link"
+    And I click on "Edit" "link"
+    And I switch to "h5p-editor-iframe" class iframe
+    And the field "Title" matches value "Geography"
+    And I set the field "Title" to "New title"
+    And I switch to the main frame
+    When I click on "Save" "button"
+    And I should see "filltheblanks.h5p" in the "h1" "css_element"
+    And I click on "Edit" "link"
+    And I switch to "h5p-editor-iframe" class iframe
+    Then the field "Title" matches value "New title"
diff --git a/contentbank/tests/behat/view_preferences.feature b/contentbank/tests/behat/view_preferences.feature
new file mode 100644 (file)
index 0000000..44fa8bf
--- /dev/null
@@ -0,0 +1,28 @@
+@core @core_contentbank @contentbank_h5p @javascript
+Feature: Store the content bank view preference
+  In order to consistantly view the content bank in icons or details view
+  As an admin
+  I need to be able to store my view preference
+
+  Background:
+    Given the following "contentbank content" exist:
+        | contextlevel | reference | contenttype       | user  | contentname          |
+        | System       |           | contenttype_h5p   | admin | filltheblanks.h5p    |
+        | System       |           | contenttype_h5p   | admin | mathsbook.h5p        |
+
+  Scenario: Admins can order content in the content bank
+    Given I log in as "admin"
+    And I am on site homepage
+    And I turn editing mode on
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    When I click on "Display content bank with file details" "button"
+    And I should see "Last modified"
+    And I follow "filltheblanks.h5p"
+    And I click on "Content bank" "link"
+    And I should see "Last modified"
+    And I click on "Display content bank with icons" "button"
+    And I follow "filltheblanks.h5p"
+    And I click on "Content bank" "link"
+    And I should not see "Last modified"
index e737117..3717d40 100644 (file)
@@ -29,6 +29,7 @@ use stdClass;
 use context_system;
 use context_coursecat;
 use context_course;
+use context_user;
 use core_contentbank\privacy\provider;
 use core_privacy\local\request\approved_contextlist;
 use core_privacy\local\request\writer;
@@ -361,4 +362,50 @@ class core_contentbank_privacy_testcase extends provider_testcase {
 
         return $scenario;
     }
+
+    /**
+     * Ensure that export_user_preferences returns no data if the user has not visited any content bank.
+     */
+    public function test_export_user_preferences_no_pref() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $user = $this->getDataGenerator()->create_user();
+        $managerroleid = $DB->get_field('role', 'id', ['shortname' => 'manager']);
+        $this->getDataGenerator()->role_assign($managerroleid, $user->id);
+
+        provider::export_user_preferences($user->id);
+        $writer = writer::with_context(context_system::instance());
+        $this->assertFalse($writer->has_any_data());
+    }
+
+    /**
+     * Test for provider::test_export_user_preferences().
+     */
+    public function test_export_user_preferences() {
+        global $DB;
+
+        // Test setup.
+        $this->resetAfterTest(true);
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+
+        set_user_preference('core_contentbank_view_list', 1);
+        // Test the user preferences export contains 1 user preference record for the User.
+        provider::export_user_preferences($user->id);
+        $contextuser = context_user::instance($user->id);
+        $writer = writer::with_context($contextuser);
+        $this->assertTrue($writer->has_any_data());
+
+        $prefs = $writer->get_user_preferences('core_contentbank');
+        $this->assertCount(1, (array) $prefs);
+        $this->assertEquals(1, $prefs->core_contentbank_view_list->value);
+        $this->assertEquals(
+                get_string('privacy:request:preference:set', 'core_contentbank', (object) [
+                        'name' => 'core_contentbank_view_list',
+                        'value' => $prefs->core_contentbank_view_list->value,
+                ]),
+                $prefs->core_contentbank_view_list->description
+        );
+    }
 }
index da936c1..82e4295 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js and b/course/amd/build/activitychooser.min.js differ
index 83b317b..288328b 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js.map and b/course/amd/build/activitychooser.min.js.map differ
index 0aa10d5..3704632 100644 (file)
@@ -91,13 +91,23 @@ const registerListenerEvents = (courseId, chooserConfig) => {
     events.forEach((event) => {
         document.addEventListener(event, async(e) => {
             if (e.target.closest(selectors.elements.sectionmodchooser)) {
+                let caller;
                 // We need to know who called this.
                 // Standard courses use the ID in the main section info.
                 const sectionDiv = e.target.closest(selectors.elements.section);
                 // Front page courses need some special handling.
                 const button = e.target.closest(selectors.elements.sectionmodchooser);
+
                 // If we don't have a section ID use the fallback ID.
-                const caller = sectionDiv || button;
+                // We always want the sectionDiv caller first as it keeps track of section ID's after DnD changes.
+                // The button attribute is always just a fallback for us as the section div is not always available.
+                // A YUI change could be done maybe to only update the button attribute but we are going for minimal change here.
+                if (sectionDiv !== null && sectionDiv.hasAttribute('data-sectionid')) {
+                    // We check for attributes just in case of outdated contrib course formats.
+                    caller = sectionDiv;
+                } else {
+                    caller = button;
+                }
 
                 // We want to show the modal instantly but loading whilst waiting for our data.
                 let bodyPromiseResolver;
index 382287d..ebb0e4a 100644 (file)
@@ -15,6 +15,9 @@ Overview of this plugin type at http://docs.moodle.org/dev/Course_formats
   preference is set, or when the theme sets $THEME->enablecourseajaxtheme to false. Formats which directly access
   the '.section_add_menus' element or its children should be updated accordingly.
 
+* section_header() now needs to include 'data-sectionid' => $section->section in the .section li to ensure correct section
+  selection for the Activity Chooser.
+
 === 3.8 ===
 
 * The following functions have been finally deprecated and can not be used anymore:
index 8c8a5e8..bb5ab23 100644 (file)
@@ -30,10 +30,11 @@ Feature: Rename roles within a course
     Then "Tutor" "button" should exist
     And "Learner" "button" should exist
     And I navigate to course participants
-    And I open the autocomplete suggestions list
-    And I should see "Role: Tutor" in the ".form-autocomplete-suggestions" "css_element"
-    And I should see "Role: Learner" in the ".form-autocomplete-suggestions" "css_element"
-    And I should not see "Role: Student" in the ".form-autocomplete-suggestions" "css_element"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I should see "Tutor" in the ".form-autocomplete-suggestions" "css_element"
+    And I should see "Learner" in the ".form-autocomplete-suggestions" "css_element"
+    And I should not see "Student" in the ".form-autocomplete-suggestions" "css_element"
     And I am on "Course 1" course homepage
     And I navigate to "Edit settings" in current page administration
     And I set the following fields to these values:
@@ -45,6 +46,7 @@ Feature: Rename roles within a course
     And "Student" "button" should exist
     And "Learner" "button" should not exist
     And I navigate to course participants
-    And I open the autocomplete suggestions list
-    And I should see "Role: Non-editing teacher" in the ".form-autocomplete-suggestions" "css_element"
-    And I should see "Role: Student" in the ".form-autocomplete-suggestions" "css_element"
+    And I set the field "type" in the "Filter 1" "fieldset" to "Roles"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I should see "Non-editing teacher" in the ".form-autocomplete-suggestions" "css_element"
+    And I should see "Student" in the ".form-autocomplete-suggestions" "css_element"
index 8d85c1c..f693b3c 100644 (file)
@@ -65,7 +65,7 @@ class filter_displayh5p extends moodle_text_filter {
         $allowedsources = get_config('filter_displayh5p', 'allowedsources');
         $allowedsources = array_filter(array_map('trim', explode("\n", $allowedsources)));
 
-        $localsource = '('.preg_quote($CFG->wwwroot).'/[^ &\#"\'<]*\.h5p([?][^ "\'<]*)?[^ \#"\'<]*)';
+        $localsource = '('.preg_quote($CFG->wwwroot, '~').'/[^ &\#"\'<]*\.h5p([?][^ "\'<]*)?[^ \#"\'<]*)';
         $allowedsources[] = $localsource;
 
         $params = array(
index aa58497..152cd65 100644 (file)
@@ -86,6 +86,17 @@ if ($action == 'moveselect') {
 $grade_edit_tree = new grade_edit_tree($gtree, $movingeid, $gpr);
 
 switch ($action) {
+    case 'duplicate':
+        if ($eid and confirm_sesskey()) {
+            if (!$el = $gtree->locate_element($eid)) {
+                print_error('invalidelementid', '', $returnurl);
+            }
+
+            $object->duplicate();
+            redirect($returnurl);
+        }
+        break;
+
     case 'delete':
         if ($eid && confirm_sesskey()) {
             if (!$grade_edit_tree->element_deletable($element)) {
index 8eac5fe..91744d8 100644 (file)
@@ -149,6 +149,18 @@ class grade_edit_tree {
                 $actionsmenu->add($icon);
             }
 
+            if ($this->element_duplicatable($element)) {
+                $duplicateparams = array();
+                $duplicateparams['id'] = $COURSE->id;
+                $duplicateparams['action'] = 'duplicate';
+                $duplicateparams['eid'] = $eid;
+                $duplicateparams['sesskey'] = sesskey();
+                $aurl = new moodle_url('index.php', $duplicateparams);
+                $duplicateicon = new pix_icon('t/copy', get_string('duplicate'));
+                $icon = new action_menu_link_secondary($aurl, $duplicateicon, get_string('duplicate'));
+                $actionsmenu->add($icon);
+            }
+
             $aurl = new moodle_url('index.php', array('id' => $COURSE->id, 'action' => 'moveselect', 'eid' => $eid, 'sesskey' => sesskey()));
             $moveaction .= $OUTPUT->action_icon($aurl, new pix_icon('t/move', get_string('move')));
         }
@@ -460,6 +472,24 @@ class grade_edit_tree {
         return false;
     }
 
+    /**
+     * Given an element of the grade tree, returns whether it is duplicatable or not (only manual grade items are duplicatable)
+     *
+     * @param array $element
+     * @return bool
+     */
+    public function element_duplicatable($element) {
+        if ($element['type'] != 'item') {
+            return false;
+        }
+
+        $gradeitem = $element['object'];
+        if ($gradeitem->itemtype != 'mod') {
+            return true;
+        }
+        return false;
+    }
+
     /**
      * Given the grade tree and an array of element ids (e.g. c15, i42), and expecting the 'moveafter' URL param,
      * moves the selected items to the requested location. Then redirects the user to the given $returnurl
index 5227baf..40a9b11 100644 (file)
@@ -725,13 +725,15 @@ class grade_report_grader extends grade_report {
             $usercell->scope = 'row';
 
             if ($showuserimage) {
-                $usercell->text = $OUTPUT->user_picture($user, array('visibletoscreenreaders' => false));
+                $usercell->text = $OUTPUT->user_picture($user, ['link' => false, 'visibletoscreenreaders' => false]);
             }
 
             $fullname = fullname($user, $viewfullnames);
-            $usercell->text .= html_writer::link(new moodle_url('/user/view.php', array('id' => $user->id, 'course' => $this->course->id)), $fullname, array(
-                'class' => 'username',
-            ));
+            $usercell->text = html_writer::link(
+                    new moodle_url('/user/view.php', ['id' => $user->id, 'course' => $this->course->id]),
+                    $usercell->text . $fullname,
+                    ['class' => 'username']
+            );
 
             if (!empty($user->suspendedenrolment)) {
                 $usercell->attributes['class'] .= ' usersuspended';
@@ -753,13 +755,18 @@ class grade_report_grader extends grade_report {
                 $a = new stdClass();
                 $a->user = $fullname;
                 $strgradesforuser = get_string('gradesforuser', 'grades', $a);
-                $url = new moodle_url('/grade/report/'.$CFG->grade_profilereport.'/index.php', array('userid' => $user->id, 'id' => $this->course->id));
-                $userreportcell->text .= $OUTPUT->action_icon($url, new pix_icon('t/grades', $strgradesforuser));
+                $url = new moodle_url('/grade/report/'.$CFG->grade_profilereport.'/index.php',
+                        ['userid' => $user->id, 'id' => $this->course->id]);
+                $userreportcell->text .= $OUTPUT->action_icon($url, new pix_icon('t/grades', ''), null,
+                        ['title' => $strgradesforuser, 'aria-label' => $strgradesforuser]);
             }
 
             if ($canseesingleview) {
-                $url = new moodle_url('/grade/report/singleview/index.php', array('id' => $this->course->id, 'itemid' => $user->id, 'item' => 'user'));
-                $singleview = $OUTPUT->action_icon($url, new pix_icon('t/editstring', get_string('singleview', 'grades', $fullname)));
+                $strsingleview = get_string('singleview', 'grades', $fullname);
+                $url = new moodle_url('/grade/report/singleview/index.php',
+                        ['id' => $this->course->id, 'itemid' => $user->id, 'item' => 'user']);
+                $singleview = $OUTPUT->action_icon($url, new pix_icon('t/editstring', ''), null,
+                        ['title' => $strsingleview, 'aria-label' => $strsingleview]);
                 $userreportcell->text .= $singleview;
             }
 
@@ -913,13 +920,16 @@ class grade_report_grader extends grade_report {
                         if (has_all_capabilities(array('gradereport/singleview:view', 'moodle/grade:viewall',
                             'moodle/grade:edit'), $this->context)) {
 
+                            $strsingleview = get_string('singleview', 'grades', $element['object']->get_name());
                             $url = new moodle_url('/grade/report/singleview/index.php', array(
                                 'id' => $this->course->id,
                                 'item' => 'grade',
                                 'itemid' => $element['object']->id));
                             $singleview = $OUTPUT->action_icon(
-                                $url,
-                                new pix_icon('t/editstring', get_string('singleview', 'grades', $element['object']->get_name()))
+                                    $url,
+                                    new pix_icon('t/editstring', ''),
+                                    null,
+                                    ['title' => $strsingleview, 'aria-label' => $strsingleview]
                             );
                         }
                     }
@@ -1269,7 +1279,8 @@ class grade_report_grader extends grade_report {
         $fulltable = new html_table();
         $fulltable->attributes['class'] = 'gradereport-grader-table';
         $fulltable->id = 'user-grades';
-        $fulltable->summary = get_string('summarygrader', 'gradereport_grader');
+        $fulltable->caption = get_string('summarygrader', 'gradereport_grader');
+        $fulltable->captionhide = true;
 
         // Extract rows from each side (left and right) and collate them into one row each
         foreach ($leftrows as $key => $row) {
@@ -1637,24 +1648,32 @@ class grade_report_grader extends grade_report {
 
             if (in_array($element['object']->id, $this->collapsed['aggregatesonly'])) {
                 $url->param('action', 'switch_plus');
-                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_plus', $strswitchplus), null, null);
+                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_plus', ''), null,
+                        ['title' => $strswitchplus, 'aria-label' => $strswitchplus]);
                 $showing = get_string('showingaggregatesonly', 'grades');
             } else if (in_array($element['object']->id, $this->collapsed['gradesonly'])) {
                 $url->param('action', 'switch_whole');
-                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_whole', $strswitchwhole), null, null);
+                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_whole', ''), null,
+                        ['title' => $strswitchwhole, 'aria-label' => $strswitchwhole]);
                 $showing = get_string('showinggradesonly', 'grades');
             } else {
                 $url->param('action', 'switch_minus');
-                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_minus', $strswitchminus), null, null);
+                $icon = $OUTPUT->action_icon($url, new pix_icon('t/switch_minus', ''), null,
+                        ['title' => $strswitchminus, 'aria-label' => $strswitchminus]);
                 $showing = get_string('showingfullmode', 'grades');
             }
         }
 
         $name = $element['object']->get_name();
-        $courseheaderid = 'courseheader_' . clean_param($name, PARAM_ALPHANUMEXT);
-        $courseheader = html_writer::tag('span', $name, array('id' => $courseheaderid,
-                'title' => $name, 'class' => 'gradeitemheader'));
-        $courseheader .= html_writer::label($showing, $courseheaderid, false, array('class' => 'accesshide'));
+        $describedbyid = uniqid();
+        $courseheader = html_writer::tag('span', $name, [
+            'title' => $name,
+            'class' => 'gradeitemheader',
+            'aria-describedby' => $describedbyid
+        ]);
+        $courseheader .= html_writer::div($showing, 'sr-only', [
+            'id' => $describedbyid
+        ]);
         $courseheader .= $icon;
 
         return $courseheader;
index 70be5da..8df6324 100644 (file)
@@ -417,8 +417,8 @@ abstract class grade_report {
         $matrix = array('up' => 'desc', 'down' => 'asc', 'move' => 'desc');
         $strsort = $this->get_lang_string('sort' . $matrix[$direction]);
 
-        $arrow = $OUTPUT->pix_icon($pix[$direction], $strsort, '', array('class' => 'sorticon'));
-        return html_writer::link($sortlink, $arrow, array('title'=>$strsort));
+        $arrow = $OUTPUT->pix_icon($pix[$direction], '', '', ['class' => 'sorticon']);
+        return html_writer::link($sortlink, $arrow, ['title' => $strsort, 'aria-label' => $strsort]);
     }
 
     /**
index b17c4cd..b5e6dde 100644 (file)
@@ -117,6 +117,29 @@ class behat_grade extends behat_base {
             "//tr[descendant::*[text() = " . $this->escape($gradeitem) . "]]", 'xpath_element'));
     }
 
+    /**
+     * Duplicates a grade item or category.
+     *
+     * Teacher must be on the grade setup page.
+     *
+     * @Given /^I duplicate the grade item "(?P<grade_item_string>(?:[^"]|\\")*)"$/
+     * @param string $gradeitem
+     */
+    public function i_duplicate_the_grade_item($gradeitem) {
+
+        $gradeitem = behat_context_helper::escape($gradeitem);
+
+        if ($this->running_javascript()) {
+            $xpath = "//tr[contains(.,$gradeitem)]//*[contains(@class,'moodle-actionmenu')]//a[contains(@class,'toggle-display')]";
+            if ($this->getSession()->getPage()->findAll('xpath', $xpath)) {
+                $this->execute("behat_general::i_click_on", array($this->escape($xpath), "xpath_element"));
+            }
+        }
+
+        $this->execute("behat_general::i_click_on_in_the", array(get_string('duplicate'), 'link',
+            "//tr[descendant::*[text() = " . $this->escape($gradeitem) . "]]", 'xpath_element'));
+    }
+
     /**
      * Sets a calculated manual grade item. Needs a table with item name - idnumber relation.
      * The step requires you to be in the 'Gradebook setup' page.
diff --git a/grade/tests/behat/grade_item_duplication.feature b/grade/tests/behat/grade_item_duplication.feature
new file mode 100644 (file)
index 0000000..8bbcdad
--- /dev/null
@@ -0,0 +1,45 @@
+@core @core_grades
+Feature: We can duplicate grade items that already exist.
+  In order to quickly create grade items that have similar settings.
+  As a teacher
+  I need to duplicate an existing grade item and check that its values are properly duplicated.
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | format |
+      | Course 1 | C1 | topics |
+    And the following "grade categories" exist:
+      | fullname  | course |
+      | Category1 | C1     |
+    And the following "activities" exist:
+      | activity | course | idnumber | name        | gradecategory |
+      | assign   | C1     | a1       | Assignment1 | Category1     |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And the following "grade items" exist:
+      | itemname | course | category  | idnumber | gradetype | grademax | grademin | gradepass | display | decimals | hidden | weightoverride |
+      | Item1    | C1     | Category1 | 001      | Value     | 80.00    | 5.00     | 40.00     | 1       | 1        | 0      | 1              |
+
+  Scenario: Ensure the duplicated grade item settings match the original grade item
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "Setup > Gradebook setup" in the course gradebook
+    And I should not see "Duplicate   Category1"
+    And I should not see "Duplicate   Assignment1"
+    When I duplicate the grade item "Item1"
+    Then I should see "Item1 (copy)"
+    And I follow "Edit   Item1 (copy)"
+    And the field "Item name" matches value "Item1 (copy)"
+    And the field "ID number" matches value ""
+    And the field "Grade type" matches value "Value"
+    And the field "Maximum grade" matches value "80.00"
+    And the field "Minimum grade" matches value "5.00"
+    And the field "Grade to pass" matches value "40.00"
+    And the field "Grade display type" matches value "Real"
+    And the field "Overall decimal places" matches value "1"
+    And the field "Hidden" matches value "0"
+    And the field "Weight adjusted" matches value "1"
index 4786637..ef8bc24 100644 (file)
@@ -49,14 +49,17 @@ Feature: Organize students into groups
     And the "members" select box should not contain "Student 0 (student0@example.com)"
     And the "members" select box should not contain "Student 1 (student1@example.com)"
     And I navigate to course participants
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group 1" item in the autocomplete list
+    And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Group 1" "list_item"
+    And I click on "Apply filters" "button"
     And I should see "Student 0"
     And I should see "Student 1"
     And I should not see "Student 2"
-    And I click on "Group: Group 1" "text" in the ".form-autocomplete-selection" "css_element"
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group 2" item in the autocomplete list
+    And I click on "Remove \"Group 1\" from filter" "button" in the "Filter 1" "fieldset"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Group 2" "list_item"
+    And I click on "Apply filters" "button"
     And I should see "Student 2"
     And I should see "Student 3"
     And I should not see "Student 0"
index 5f4b82e..e63ee05 100644 (file)
@@ -41,24 +41,32 @@ Feature: The description of a group can be viewed by students and teachers
     And I add "Student 2 (student2@example.com)" user to "Group B" group members
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group A" item in the autocomplete list
+    And I click on "Student 1" "link" in the "participants" "table"
+    And I click on "Group A" "link"
     And I should see "Description for Group A"
     And ".groupinfobox" "css_element" should exist
-    And I should see "Description for Group A"
-    And I click on "Group: Group A" "autocomplete_selection"
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group B" item in the autocomplete list
+    And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Group B" "list_item"
+    And I click on "Apply filters" "button"
+    And I click on "Student 2" "link" in the "participants" "table"
+    And I click on "Group B" "link"
+    And I should see "Student 2" in the "participants" "table"
     And ".groupinfobox" "css_element" should not exist
     And I log out
     When I log in as "student1"
     And I am on "Course 1" course homepage
     And I navigate to course participants
+    And I click on "Student 1" "link" in the "participants" "table"
+    And I click on "Group A" "link"
     Then I should see "Description for Group A"
     And I log out
     And I log in as "student2"
     And I am on "Course 1" course homepage
     And I navigate to course participants
+    And I click on "Student 2" "link" in the "participants" "table"
+    And I click on "Group B" "link"
+    And I should see "Student 2" in the "participants" "table"
     And ".groupinfobox" "css_element" should not exist
 
   @javascript
@@ -83,22 +91,31 @@ Feature: The description of a group can be viewed by students and teachers
     And I add "Student 2 (student2@example.com)" user to "Group B" group members
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group A" item in the autocomplete list
+    And I click on "Student 1" "link" in the "participants" "table"
+    And I click on "Group A" "link"
     And I should see "Description for Group A"
     And ".groupinfobox" "css_element" should exist
-    And I click on "Group: Group A" "autocomplete_selection"
-    And I open the autocomplete suggestions list
-    And I click on "Group: Group B" item in the autocomplete list
+    And I set the field "type" in the "Filter 1" "fieldset" to "Groups"
+    And I click on ".form-autocomplete-downarrow" "css_element" in the "Filter 1" "fieldset"
+    And I click on "Group B" "list_item"
+    And I click on "Apply filters" "button"
+    And I click on "Student 2" "link" in the "participants" "table"
+    And I click on "Group B" "link"
     And ".groupinfobox" "css_element" should not exist
     And I log out
     When I log in as "student1"
     And I am on "Course 1" course homepage
     And I navigate to course participants
-    Then I should not see "Description for Group A"
+    And I click on "Student 1" "link" in the "participants" "table"
+    And I click on "Group A" "link"
+    And I should see "Student 1" in the "participants" "table"
+    And I should not see "Description for Group A"
     And ".groupinfobox" "css_element" should not exist
     And I log out
     And I log in as "student2"
     And I am on "Course 1" course homepage
     And I navigate to course participants
+    And I click on "Student 2" "link" in the "participants" "table"
+    And I click on "Group B" "link"
+    And I should see "Student 2" in the "participants" "table"
     And ".groupinfobox" "css_element" should not exist
index 88e8e17..2291e2f 100644 (file)
 
 use core_h5p\factory;
 use core_h5p\framework;
+use core_h5p\local\library\autoloader;
 
 define('AJAX_SCRIPT', true);
 
 require(__DIR__ . '/../config.php');
 require_once($CFG->libdir . '/filelib.php');
 
-require_login();
-
-$action = required_param('action', PARAM_ALPHA);
-$contextid = required_param('contextId', PARAM_INT);
-
-$context = context::instance_by_id($contextid);
-
-if (!has_capability('moodle/h5p:updatelibraries', $context)) {
-    H5PCore::ajaxError(get_string('nopermissiontoedit', 'h5p'));
+if (!confirm_sesskey()) {
+    autoloader::register();
+    H5PCore::ajaxError(get_string('invalidsesskey', 'error'));
     header('HTTP/1.1 403 Forbidden');
     return;
 }
+require_login();
+
+$action = required_param('action', PARAM_ALPHA);
 
 $factory = new factory();
 $editor = $factory->get_editor();
@@ -71,6 +69,8 @@ switch ($action) {
         break;
 
     // Handle file upload through the editor.
+    // This endpoint needs a token that only users with H5P editor access could get.
+    // TODO: MDL-68907 to check capabilities.
     case 'files':
         $token = required_param('token', PARAM_RAW);
         $contentid = required_param('contentId', PARAM_INT);
@@ -78,22 +78,6 @@ switch ($action) {
         $editor->ajax->action(H5PEditorEndpoints::FILES, $token, $contentid);
         break;
 
-    // Install libraries from H5P and retrieve content json.
-    case 'libraryinstall':
-        $token = required_param('token', PARAM_RAW);
-        $machinename = required_param('id', PARAM_TEXT);
-        $editor->ajax->action(H5PEditorEndpoints::LIBRARY_INSTALL, $token, $machinename);
-        break;
-
-    // Handle file upload through the editor.
-    case 'libraryupload':
-        $token = required_param('token', PARAM_RAW);
-
-        $uploadpath = $_FILES['h5p']['tmp_name'];
-        $contentid = optional_param('contentId', 0, PARAM_INT);
-        $editor->ajax->action(H5PEditorEndpoints::LIBRARY_UPLOAD, $token, $uploadpath, $contentid);
-        break;
-
     // Get the $language libraries translations.
     case 'translations':
         $language = required_param('language', PARAM_RAW);
index 8d7d442..a33bad7 100644 (file)
Binary files a/h5p/amd/build/editor_display.min.js and b/h5p/amd/build/editor_display.min.js differ
index 9560735..09d896b 100644 (file)
Binary files a/h5p/amd/build/editor_display.min.js.map and b/h5p/amd/build/editor_display.min.js.map differ
index 8b89e4f..e27dcff 100644 (file)
@@ -59,4 +59,5 @@ export const init = (elementId) => {
         inputname,
         cancelSubmitCallback
     );
+    document.querySelector('#' + elementId + ' iframe').setAttribute('name', 'h5p-editor');
 };
index 4fb007b..c3a9a88 100644 (file)
@@ -357,27 +357,6 @@ class core extends \H5PCore {
         return true;
     }
 
-    /**
-     * Use sesskey instead of the H5P security token.
-     *
-     * @param string $action Not used.
-     * @return string sesskey
-     */
-    public static function createToken($action) {
-        return sesskey();
-    }
-
-    /**
-     * Check if the token matches the sesskey.
-     *
-     * @param string $action Not used.
-     * @param string $token Token submitted.
-     * @return boolean valid token
-     */
-    public static function validToken($action, $token) {
-        return confirm_sesskey($token);
-    }
-
     /**
      * Get the library string from a DB library record.
      *
index eed3774..8faa32f 100644 (file)
@@ -391,6 +391,7 @@ class editor {
         $contentvalidator = $factory->get_content_validator();
 
         $editorajaxtoken = core::createToken(editor_ajax::EDITOR_AJAX_TOKEN);
+        $sesskey = sesskey();
         $settings['editor'] = [
             'filesPath' => $filespathbase . 'editor',
             'fileIcon' => [
@@ -398,7 +399,7 @@ class editor {
                 'width' => 50,
                 'height' => 50,
             ],
-            'ajaxPath' => $CFG->wwwroot . '/h5p/' . "ajax.php?contextId={$context->id}&token={$editorajaxtoken}&action=",
+            'ajaxPath' => $CFG->wwwroot . "/h5p/ajax.php?sesskey={$sesskey}&token={$editorajaxtoken}&action=",
             'libraryUrl' => $url,
             'copyrightSemantics' => $contentvalidator->getCopyrightSemantics(),
             'metadataSemantics' => $contentvalidator->getMetadataSemantics(),
index e1090eb..6f293d0 100644 (file)
@@ -61,7 +61,7 @@ class editor_ajax implements H5PEditorAjaxInterface {
                             )
                  WHERE hl2.runnable = 1
                        AND hl1.majorversion is null
-              ORDER BY hl2.machinename";
+              ORDER BY hl2.title";
 
         return $DB->get_records_sql($sql);
     }
index 4524f0a..2e4a2ca 100644 (file)
@@ -796,7 +796,12 @@ class framework implements \H5PFrameworkInterface {
         }
 
         $content['disable'] = $content['disable'] ?? null;
-
+        // Add title to 'params' to use in the editor.
+        if (!empty($content['title'])) {
+            $params = json_decode($content['params']);
+            $params->title = $content['title'];
+            $content['params'] = json_encode($params);
+        }
         $data = [
             'jsoncontent' => $content['params'],
             'displayoptions' => $content['disable'],
@@ -1206,6 +1211,10 @@ class framework implements \H5PFrameworkInterface {
         if (empty($params->metadata)) {
             $params->metadata = new \stdClass();
         }
+        // Add title to metadata.
+        if (!empty($params->title) && empty($params->metadata->title)) {
+            $params->metadata->title = $params->title;
+        }
         $content['metadata'] = $params->metadata;
         $content['params'] = json_encode($params->params ?? $params);
 
index 8e85ffc..721187e 100644 (file)
@@ -75,6 +75,10 @@ class helper {
             ];
             $options = ['disable' => self::get_display_options($core, $config)];
 
+            // Add the 'title' if exists from 'h5p.json' data to keep it for the editor.
+            if (!empty($h5pvalidator->h5pC->mainJsonData['title'])) {
+                $content['title'] = $h5pvalidator->h5pC->mainJsonData['title'];
+            }
             $h5pstorage->savePackage($content, null, $skipcontent, $options);
 
             return $h5pstorage->contentId;
index 9ae0ae2..c0b6734 100644 (file)
@@ -79,24 +79,6 @@ class editor_ajax_testcase extends \advanced_testcase {
         $this->assertEquals($expectedlibraries, array_keys($actuallibraries));
     }
 
-    /**
-     * Test that the method validateEditorToken validates an existing token.
-     */
-    public function test_validateEditorToken(): void {
-        // The action param is not used at all.
-        $token = core::createToken('editorajax');
-        $wrongaction = core::createToken('wrongaction');
-        $badtoken = 'xkadfpuealkdjsflkajsñf';
-
-        $validtoken = $this->editorajax->validateEditorToken($token);
-        $invalidaction = $this->editorajax->validateEditorToken($wrongaction);
-        $invalidtoken = $this->editorajax->validateEditorToken($badtoken);
-
-        $this->assertTrue($validtoken);
-        $this->assertTrue($invalidaction);
-        $this->assertFalse($invalidtoken);
-    }
-
     /**
      * Test that the method getTranslations retrieves the translations of several libraries.
      *
diff --git a/install/lang/mwl/langconfig.php b/install/lang/mwl/langconfig.php
new file mode 100644 (file)
index 0000000..f5455b1
--- /dev/null
@@ -0,0 +1,33 @@
+<?php
+
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['thislanguage'] = 'Mirandés';
index d47cd39..6661110 100644 (file)
@@ -546,6 +546,8 @@ $string['enablerssfeeds'] = 'Enable RSS feeds';
 $string['enablesearchareas'] = 'Enable search areas';
 $string['enablestats'] = 'Enable statistics';
 $string['enabletrusttext'] = 'Enable trusted content';
+$string['enableuserfeedback'] = 'Enable feedback about Moodle';
+$string['enableuserfeedback_desc'] = 'If enabled, a \'Give feedback\' link is displayed in a Dashboard alert and in the footer for users to give feedback about the Moodle LMS to Moodle HQ. The Dashboard alert also has a \'Remind me later\' option.';
 $string['enablewebservices'] = 'Enable web services';
 $string['enablewsdocumentation'] = 'Web services documentation';
 $string['enrolinstancedefaults'] = 'Enrolment instance defaults';
@@ -578,6 +580,7 @@ $string['experimentalsettings'] = 'Experimental settings';
 $string['extendedusernamechars'] = 'Allow extended characters in usernames';
 $string['extramemorylimit'] = 'Extra PHP memory limit';
 $string['fatalsessionautostart'] = '<p>Serious configuration error detected, please notify server administrator.</p><p> To operate properly, Moodle requires that administrator changes PHP settings.</p><p><code>session.auto_start</code> must be set to <code>off</code>.</p><p>This setting is controlled by editing <code>php.ini</code>, Apache/IIS <br />configuration or <code>.htaccess</code> file on the server.</p>';
+$string['feedbacksettings'] = 'Feedback settings';
 $string['filescleanupperiod'] = 'Clean up trash pool files';
 $string['filescleanupperiod_help'] = 'How often trash pool files are deleted. These are files that are associated with a context that no longer exists, for example when a course is deleted. Please note: This setting can result in missing files in a course which is backed up, deleted and then restored if the setting \'Include files\' (backup_auto_files) in \'Automated backup settings\' is disabled.';
 $string['fileconversioncleanuptask'] = 'Cleanup of temporary records for file conversions.';
@@ -1413,6 +1416,12 @@ $string['useexternalyui'] = 'Use online YUI libraries';
 $string['user'] = 'User';
 $string['userbulk'] = 'Bulk user actions';
 $string['userbulkdownload'] = 'Export users as';
+$string['userfeedbackafterupgrade'] = 'After every major upgrade';
+$string['userfeedbacknextreminder'] = 'Next feedback reminder';
+$string['userfeedbacknextreminder_desc'] = 'When should we ask the user to give feedback again?';
+$string['userfeedbackperiodically'] = 'Periodically';
+$string['userfeedbackremindafter'] = 'Show reminder after';
+$string['userfeedbackremindafter_desc'] = 'Remind users again to give feedback after the given number of days.';
 $string['userimagesdisabled'] = 'Profile user images are disabled';
 $string['userlist'] = 'Browse list of users';
 $string['userdefaultpreferences'] = 'User default preferences';
index 8ea78e9..d34dcfe 100644 (file)
@@ -59,6 +59,7 @@ $string['privacy:metadata:content:usercreated'] = 'The user has created the cont
 $string['privacy:metadata:content:usermodified'] = 'The last user who modified the content.';
 $string['privacy:metadata:contentbankcontent'] = 'Stores the content of the content bank.';
 $string['privacy:metadata:userid'] = 'The ID of the user creating or modifying content bank content.';
+$string['privacy:request:preference:set'] = 'The value of the setting \'{$a->name}\' was \'{$a->value}\'';
 $string['rename'] = 'Rename';
 $string['renamecontent'] = 'Rename content';
 $string['searchcontentbankbyname'] = 'Search for content by name';
index 1bb420f..4203ac9 100644 (file)
@@ -144,3 +144,4 @@ pacific/yap,core_timezones
 editsettings,core_badges
 availablelicenses,core_admin
 managelicenses,core_admin
+userfilterplaceholder,core
index f717e4f..11d5b11 100644 (file)
@@ -163,6 +163,7 @@ $string['dropped'] = 'Dropped';
 $string['droplowestvalues'] = 'Drop {$a} lowest values';
 $string['dropxlowest'] = 'Drop X lowest';
 $string['dropxlowestwarning'] = 'Note: If you use drop x lowest the grading assumes that all items in the category have the same point value. If point values differ results will be unpredictable';
+$string['duplicatedgradeitem'] = '{$a} (copy)';
 $string['duplicatescale'] = 'Duplicate scale';
 $string['edit'] = 'Edit';
 $string['editcalculation'] = 'Edit calculation';
index 50ea6d2..acf4969 100644 (file)
@@ -225,6 +225,9 @@ $string['bycourseorder'] = 'By course order';
 $string['byname'] = 'by {$a}';
 $string['bypassed'] = 'Bypassed';
 $string['cachecontrols'] = 'Cache controls';
+$string['calltofeedback'] = 'Moodle HQ would like your feedback on the Moodle LMS.';
+$string['calltofeedback_give'] = 'Give feedback';
+$string['calltofeedback_remind'] = 'Remind me later';
 $string['cancel'] = 'Cancel';
 $string['cancelled'] = 'Cancelled';
 $string['categories'] = 'Course categories';
@@ -802,6 +805,8 @@ $string['eventsearchresultsviewed'] = 'Search results viewed';
 $string['eventunknownlogged'] = 'Unknown event';
 $string['eventusercreated'] = 'User created';
 $string['eventuserdeleted'] = 'User deleted';
+$string['eventuserfeedbackgiven'] = 'Feedback link clicked';
+$string['eventuserfeedbackremind'] = 'Remind me later feedback link clicked';
 $string['eventuserlistviewed'] = 'User list viewed';
 $string['eventuserloggedout'] = 'User logged out';
 $string['eventuserpasswordpolicyfailed'] = 'User password failed password policy';
@@ -2166,7 +2171,6 @@ $string['userdescription'] = 'Description';
 $string['userdescription_help'] = 'This box enables you to enter some text about yourself which will then be displayed on your profile page for others to view.';
 $string['userdetails'] = 'User details';
 $string['userfiles'] = 'User files';
-$string['userfilterplaceholder'] = 'Search keyword or select filter';
 $string['userlist'] = 'User list';
 $string['usermenu'] = 'User menu';
 $string['username'] = 'Username';
@@ -2292,3 +2296,4 @@ $string['sitemessage'] = 'Message users';
 
 // Deprecated since Moodle 3.9.
 $string['participantscount'] = 'Number of participants: {$a}';
+$string['userfilterplaceholder'] = 'Search keyword or select filter';
index 5eee2cb..ee3c7fd 100644 (file)
@@ -29,7 +29,9 @@ $string['adverbfor_or'] = 'or';
 $string['applyfilters'] = 'Apply filters';
 $string['clearfilterrow'] = 'Remove filter row';
 $string['clearfilters'] = 'Clear filters';
+$string['clearfilterselection'] = 'Remove "{$a}" from filter';
 $string['countparticipantsfound'] = '{$a} participants found';
+$string['filterrowlegend'] = 'Filter {$a}';
 $string['filtersetmatchdescription'] = 'How multiple filters should be combined';
 $string['match'] = 'Match';
 $string['matchofthefollowing'] = 'of the following:';
index eaefd6b..7e63155 100644 (file)
@@ -2234,7 +2234,7 @@ function reset_role_capabilities($roleid) {
  * the database.
  *
  * @access private
- * @param string $component examples: 'moodle', 'mod/forum', 'block/quiz_results'
+ * @param string $component examples: 'moodle', 'mod_forum', 'block_quiz_results'
  * @return boolean true if success, exception in case of any problems
  */
 function update_capabilities($component = 'moodle') {
index 1541db2..fd87ec4 100644 (file)
@@ -1686,6 +1686,8 @@ abstract class admin_setting {
     private $forceltr = null;
     /** @var array list of other settings that may cause this setting to be hidden */
     private $dependenton = [];
+    /** @var bool Whether this setting uses a custom form control */
+    protected $customcontrol = false;
 
     /**
      * Constructor
@@ -2081,6 +2083,16 @@ abstract class admin_setting {
     public function get_dependent_on() {
         return $this->dependenton;
     }
+
+    /**
+     * Whether this setting uses a custom form control.
+     * This function is especially useful to decide if we should render a label element for this setting or not.
+     *
+     * @return bool
+     */
+    public function has_custom_form_control(): bool {
+        return $this->customcontrol;
+    }
 }
 
 /**
@@ -8925,6 +8937,7 @@ function format_admin_setting($setting, $title='', $form='', $description='', $l
     $context->description = highlight($query, markdown_to_html($description));
     $context->element = $form;
     $context->forceltr = $setting->get_force_ltr();
+    $context->customcontrol = $setting->has_custom_form_control();
 
     return $OUTPUT->render_from_template('core_admin/setting', $context);
 }
@@ -10384,6 +10397,7 @@ class admin_setting_configstoredfile extends admin_setting {
         $this->filearea = $filearea;
         $this->itemid   = $itemid;
         $this->options  = (array)$options;
+        $this->customcontrol = true;
     }
 
     /**
diff --git a/lib/amd/build/userfeedback.min.js b/lib/amd/build/userfeedback.min.js
new file mode 100644 (file)
index 0000000..3eee54b
Binary files /dev/null and b/lib/amd/build/userfeedback.min.js differ
diff --git a/lib/amd/build/userfeedback.min.js.map b/lib/amd/build/userfeedback.min.js.map
new file mode 100644 (file)
index 0000000..2a4225f
Binary files /dev/null and b/lib/amd/build/userfeedback.min.js.map differ
diff --git a/lib/amd/src/userfeedback.js b/lib/amd/src/userfeedback.js
new file mode 100644 (file)
index 0000000..c1b1a7f
--- /dev/null
@@ -0,0 +1,115 @@
+// 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/>.
+
+/**
+ * Handle clicking on action links of the feedback alert.
+ *
+ * @module     core/cta_feedback
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Ajax from 'core/ajax';
+import Notification from 'core/notification';
+
+const Selectors = {
+    regions: {
+        root: '[data-region="core/userfeedback"]',
+    },
+    actions: {},
+};
+Selectors.actions.give = `${Selectors.regions.root} [data-action="give"]`;
+Selectors.actions.remind = `${Selectors.regions.root} [data-action="remind"]`;
+
+/**
+ * Attach the necessary event handlers to the action links
+ */
+export const registerEventListeners = () => {
+    document.addEventListener('click', e => {
+        const giveAction = e.target.closest(Selectors.actions.give);
+        if (giveAction) {
+            e.preventDefault();
+
+            giveFeedback()
+                .then(() => hideRoot(giveAction))
+                .then(recordAction)
+                .catch(Notification.exception);
+        }
+
+        const remindAction = e.target.closest(Selectors.actions.remind);
+        if (remindAction) {
+            e.preventDefault();
+
+            Promise.resolve(remindAction)
+                .then(hideRoot)
+                .then(recordAction)
+                .catch(Notification.exception);
+        }
+    });
+};
+
+/**
+ * The action function that is called when users choose to give feedback.
+ *
+ * @returns {Promise<void>}
+ */
+const giveFeedback = () => {
+    return Ajax.call([{
+        methodname: 'core_get_userfeedback_url',
+        args: {
+            contextid: M.cfg.contextid,
+        }
+    }])[0]
+        .then(url => {
+            if (!window.open(url)) {
+                throw new Error('Unable to open popup');
+            }
+            return;
+        });
+};
+
+/**
+ * Record the action that the user took.
+ *
+ * @param {HTMLElement} clickedItem The action element that the user chose.
+ * @returns {Promise}
+ */
+const recordAction = clickedItem => {
+    if (clickedItem.dataset.record) {
+        return Ajax.call([{
+            methodname: 'core_create_userfeedback_action_record',
+            args: {
+                action: clickedItem.dataset.action,
+                contextid: M.cfg.contextid,
+            }
+        }])[0];
+    }
+
+    return Promise.resolve();
+};
+
+/**
+ * Hide the root node of the CTA notification.
+ *
+ * @param {HTMLElement} clickedItem The action element that the user chose.
+ * @returns {HTMLElement}
+ */
+const hideRoot = clickedItem => {
+    if (clickedItem.dataset.hide) {
+        clickedItem.closest(Selectors.regions.root).remove();
+    }
+
+    return clickedItem;
+};
index a1ae1ac..6e2dd2d 100644 (file)
@@ -230,7 +230,12 @@ class behat_core_generator extends behat_generator_base {
                 'datagenerator' => 'setup_backpack_connected',
                 'required' => ['user', 'externalbackpack'],
                 'switchids' => ['user' => 'userid', 'externalbackpack' => 'externalbackpackid']
-            ]
+            ],
+            'last access times' => [
+                'datagenerator' => 'last_access_times',
+                'required' => ['user', 'course', 'lastaccess'],
+                'switchids' => ['user' => 'userid', 'course' => 'courseid'],
+            ],
         ];
     }
 
@@ -951,4 +956,100 @@ class behat_core_generator extends behat_generator_base {
         $backpack->externalbackpackid = $data['externalbackpackid'];
         $DB->insert_record('badge_backpack', $backpack);
     }
+
+    /**
+     * Creates user last access data within given courses.
+     *
+     * @param array $data
+     * @return void
+     */
+    protected function process_last_access_times(array $data) {
+        global $DB;
+
+        if (!isset($data['userid'])) {
+            throw new Exception('\'last acces times\' requires the field \'user\' to be specified');
+        }
+
+        if (!isset($data['courseid'])) {
+            throw new Exception('\'last acces times\' requires the field \'course\' to be specified');
+        }
+
+        if (!isset($data['lastaccess'])) {
+            throw new Exception('\'last acces times\' requires the field \'lastaccess\' to be specified');
+        }
+
+        $userdata = [];
+        $userdata['old'] = $DB->get_record('user', ['id' => $data['userid']], 'firstaccess, lastaccess, lastlogin, currentlogin');
+        $userdata['new'] = [
+            'firstaccess' => $userdata['old']->firstaccess,
+            'lastaccess' => $userdata['old']->lastaccess,
+            'lastlogin' => $userdata['old']->lastlogin,
+            'currentlogin' => $userdata['old']->currentlogin,
+        ];
+
+        // Check for lastaccess data for this course.
+        $lastaccessdata = [
+            'userid' => $data['userid'],
+            'courseid' => $data['courseid'],
+        ];
+
+        $lastaccessid = $DB->get_field('user_lastaccess', 'id', $lastaccessdata);
+
+        $dbdata = (object) $lastaccessdata;
+        $dbdata->timeaccess = $data['lastaccess'];
+
+        // Set the course last access time.
+        if ($lastaccessid) {
+            $dbdata->id = $lastaccessid;
+            $DB->update_record('user_lastaccess', $dbdata);
+        } else {
+            $DB->insert_record('user_lastaccess', $dbdata);
+        }
+
+        // Store changes to other user access times as needed.
+
+        // Update first access if this is the user's first login, or this access is earlier than their current first access.
+        if (empty($userdata['new']['firstaccess']) ||
+                $userdata['new']['firstaccess'] > $data['lastaccess']) {
+            $userdata['new']['firstaccess'] = $data['lastaccess'];
+        }
+
+        // Update last access if it is the user's most recent access.
+        if (empty($userdata['new']['lastaccess']) ||
+                $userdata['new']['lastaccess'] < $data['lastaccess']) {
+            $userdata['new']['lastaccess'] = $data['lastaccess'];
+        }
+
+        // Update last and current login if it is the user's most recent access.
+        if (empty($userdata['new']['lastlogin']) ||
+                $userdata['new']['lastlogin'] < $data['lastaccess']) {
+            $userdata['new']['lastlogin'] = $data['lastaccess'];
+            $userdata['new']['currentlogin'] = $data['lastaccess'];
+        }
+
+        $updatedata = [];
+
+        if ($userdata['new']['firstaccess'] != $userdata['old']->firstaccess) {
+            $updatedata['firstaccess'] = $userdata['new']['firstaccess'];
+        }
+
+        if ($userdata['new']['lastaccess'] != $userdata['old']->lastaccess) {
+            $updatedata['lastaccess'] = $userdata['new']['lastaccess'];
+        }
+
+        if ($userdata['new']['lastlogin'] != $userdata['old']->lastlogin) {
+            $updatedata['lastlogin'] = $userdata['new']['lastlogin'];
+        }
+
+        if ($userdata['new']['currentlogin'] != $userdata['old']->currentlogin) {
+            $updatedata['currentlogin'] = $userdata['new']['currentlogin'];
+        }
+
+        // Only update user access data if there have been any changes.
+        if (!empty($updatedata)) {
+            $updatedata['id'] = $data['userid'];
+            $updatedata = (object) $updatedata;
+            $DB->update_record('user', $updatedata);
+        }
+    }
 }
index a1825a6..1b2d43e 100644 (file)
@@ -221,7 +221,9 @@ XPATH
 .//*[contains(., %locator%) and not(.//*[contains(., %locator%)])]
 XPATH
         , 'form_row' => <<<XPATH
-.//*[self::label or self::div[contains(concat(' ', @class, ' '), ' fstaticlabel ')]][contains(., %locator%)]/ancestor::*[contains(concat(' ', @class, ' '), ' fitem ')]
+.//*[contains(concat(' ', @class, ' '), ' col-form-label ')]
+    [normalize-space(.)= %locator%]
+    /ancestor::*[contains(concat(' ', @class, ' '), ' fitem ')]
 XPATH
         , 'autocomplete_selection' => <<<XPATH
 .//div[contains(concat(' ', normalize-space(@class), ' '), concat(' ', 'form-autocomplete-selection', ' '))]/span[@role='listitem'][contains(normalize-space(.), %locator%)]
@@ -253,7 +255,7 @@ XPATH
         ,
             'filemanager' => <<<XPATH
 .//*[@data-fieldtype = 'filemanager' or @data-fieldtype = 'filepicker']
-    /descendant::input[@id = //label[contains(normalize-space(string(.)), %locator%)]/@for]
+    /descendant::input[@id = substring-before(//p[contains(normalize-space(string(.)), %locator%)]/@id, '_label')]
 XPATH
         ,
              'passwordunmask' => <<<XPATH
index 957a357..b4064ef 100644 (file)
@@ -74,7 +74,7 @@ trait core_behat_file_helper {
             $filepickerelement = behat_context_helper::escape($filepickerelement);
             $filepickercontainer = $this->find(
                     'xpath',
-                    "//input[./@id = //label[normalize-space(.)=$filepickerelement]/@for]" .
+                    "//input[./@id = substring-before(//p[normalize-space(.)=$filepickerelement]/@id, '_label')]" .
                     "//ancestor::*[@data-fieldtype = 'filemanager' or @data-fieldtype = 'filepicker']",
                     $exception
             );
index bb011e0..ff85a2f 100644 (file)
@@ -251,7 +251,7 @@ class behat_form_field {
         // Defaults to label.
         if ($locatortype == 'label' || $locatortype == false) {
 
-            $labelnode = $this->session->getPage()->find('xpath', '//label[@for="' . $fieldid . '"]');
+            $labelnode = $this->session->getPage()->find('xpath', "//label[@for='$fieldid']|//p[@id='{$fieldid}_label']");
 
             // Exception only if $locatortype was specified.
             if (!$labelnode && $locatortype == 'label') {
index 7d70949..1fa9ff6 100644 (file)
@@ -68,7 +68,7 @@ class behat_form_filemanager extends behat_form_field {
         $fieldlabel = $this->get_field_locator();
 
         // Get the name of the current directory elements.
-        $xpath = "//label[contains(., '" . $fieldlabel . "')]" .
+        $xpath = "//p[normalize-space(.)='$fieldlabel']" .
             "/ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' fitem ')]" .
             "/descendant::div[@data-fieldtype = 'filemanager']" .
             "/descendant::div[contains(concat(' ', normalize-space(@class), ' '), ' fp-filename ')]";
diff --git a/lib/classes/event/userfeedback_give.php b/lib/classes/event/userfeedback_give.php
new file mode 100644 (file)
index 0000000..e0b89df
--- /dev/null
@@ -0,0 +1,63 @@
+<?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/>.
+
+/**
+ * Feedback given.
+ *
+ * @package    core
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class userfeedback_give
+ *
+ * @package    core
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class userfeedback_give extends base {
+
+    /**
+     * Initialise required event data properties.
+     */
+    protected function init() {
+        $this->data['crud'] = 'r';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' clicked on the give feedback link";
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventuserfeedbackgiven');
+    }
+
+}
diff --git a/lib/classes/event/userfeedback_remind.php b/lib/classes/event/userfeedback_remind.php
new file mode 100644 (file)
index 0000000..e30205c
--- /dev/null
@@ -0,0 +1,63 @@
+<?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/>.
+
+/**
+ * Feedback remind.
+ *
+ * @package    core
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Class userfeedback_remind
+ *
+ * @package    core
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class userfeedback_remind extends base {
+
+    /**
+     * Initialise required event data properties.
+     */
+    protected function init() {
+        $this->data['crud'] = 'r';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Returns description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' clicked on the remind later to feedback link";
+    }
+
+    /**
+     * Return localised event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventuserfeedbackremind');
+    }
+
+}
diff --git a/lib/classes/external/userfeedback/generate_url.php b/lib/classes/external/userfeedback/generate_url.php
new file mode 100644 (file)
index 0000000..307bbc2
--- /dev/null
@@ -0,0 +1,81 @@
+<?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/>.
+
+/**
+ * External API to generate and return the URL of the feedback site.
+ *
+ * @package    core
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\external\userfeedback;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("$CFG->libdir/externallib.php");
+
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_single_structure;
+use external_multiple_structure;
+
+/**
+ * The external API to generate and return the feedback url.
+ *
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class generate_url extends external_api {
+    /**
+     * Returns description of parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function execute_parameters() {
+        return new external_function_parameters([
+            'contextid' => new external_value(PARAM_INT, 'The context id of the page the user is in'),
+        ]);
+    }
+
+    /**
+     * Prepare and return the URL of the feedback site
+     *
+     * @param int $contextid The context id
+     * @return \stdClass
+     */
+    public static function execute(int $contextid) {
+        global $PAGE;
+
+        external_api::validate_parameters(self::execute_parameters(), ['contextid' => $contextid]);
+
+        $context = \context::instance_by_id($contextid);
+        self::validate_context($context);
+        $PAGE->set_context($context);
+
+        return \core_userfeedback::make_link()->out(false);
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return external_value
+     */
+    public static function execute_returns() {
+        return new external_value(PARAM_URL, 'Feedback site\'s URL');
+    }
+}
diff --git a/lib/classes/external/userfeedback/record_action.php b/lib/classes/external/userfeedback/record_action.php
new file mode 100644 (file)
index 0000000..e8950f5
--- /dev/null
@@ -0,0 +1,95 @@
+<?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/>.
+
+/**
+ * External API to record users action on the feedback notification.
+ *
+ * @package    core
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\external\userfeedback;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("$CFG->libdir/externallib.php");
+
+use external_api;
+use external_function_parameters;
+use external_value;
+
+/**
+ * The external API to record users action on the feedback notification.
+ *
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class record_action extends external_api {
+    /**
+     * Returns description of parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function execute_parameters() {
+        return new external_function_parameters([
+            'action' => new external_value(PARAM_ALPHA, 'The action taken by user'),
+            'contextid' => new external_value(PARAM_INT, 'The context id of the page the user is in'),
+        ]);
+    }
+
+    /**
+     * Record users action to the feedback CTA
+     *
+     * @param string $action The action the user took
+     * @param int $contextid The context id
+     * @throws \invalid_parameter_exception
+     */
+    public static function execute(string $action, int $contextid) {
+        external_api::validate_parameters(self::execute_parameters(), [
+            'action' => $action,
+            'contextid' => $contextid,
+        ]);
+
+        $context = \context::instance_by_id($contextid);
+        self::validate_context($context);
+
+        switch ($action) {
+            case 'give':
+                set_user_preference('core_userfeedback_give', time());
+                $event = \core\event\userfeedback_give::create(['context' => $context]);
+                $event->trigger();
+                break;
+            case 'remind':
+                set_user_preference('core_userfeedback_remind', time());
+                $event = \core\event\userfeedback_remind::create(['context' => $context]);
+                $event->trigger();
+                break;
+            default:
+                throw new \invalid_parameter_exception('Invalid value for action parameter (value: ' . $action . '),' .
+                        'allowed values are: give,remind');
+        }
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return null
+     */
+    public static function execute_returns() {
+        return null;
+    }
+}
index 3c87836..59fceea 100644 (file)
@@ -96,6 +96,51 @@ class notification {
         );
     }
 
+    /**
+     * @param string[] $icon The icon to use. Required keys are 'pix' and 'component'.
+     * @param string $message The message to display.
+     * @param array $actions An array of action links
+     * @param string $region Optional region name
+     * @throws \coding_exception
+     */
+    public static function add_call_to_action(array $icon, string $message, array $actions, string $region = ''): void {
+        global $OUTPUT, $PAGE;
+
+        $context = new stdClass();
+        $context->icon = $icon;
+        $context->message = $message;
+        $context->region = $region;
+
+        $context->actions = array_map(function($action) {
+            $data = [];
+            foreach ($action['data'] as $name => $value) {
+                $data[] = ['name' => $name, 'value' => $value];
+            }
+            $action['data'] = $data;
+
+            return $action;
+        }, $actions);
+
+        $notification = $OUTPUT->render_from_template('core/local/notification/cta', $context);
+
+        if ($PAGE && $PAGE->state === \moodle_page::STATE_IN_BODY) {
+            $id = uniqid();
+            echo \html_writer::span($notification, '', ['id' => $id]);
+            echo \html_writer::script(
+                    "(function() {" .
+                    "var notificationHolder = document.getElementById('user-notifications');" .
+                    "if (!notificationHolder) { return; }" .
+                    "var thisNotification = document.getElementById('{$id}');" .
+                    "if (!thisNotification) { return; }" .
+                    "notificationHolder.insertBefore(thisNotification.firstChild, notificationHolder.firstChild);" .
+                    "thisNotification.remove();" .
+                    "})();"
+            );
+        } else {
+            throw new \coding_exception('You are calling add_call_to_action() either too early or too late.');
+        }
+    }
+
     /**
      * Fetch all of the notifications in the stack and clear the stack.
      *
index 2389dc4..ba4ccdb 100644 (file)
@@ -197,6 +197,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:i/backup' => 'fa-file-zip-o',
             'core:i/badge' => 'fa-shield',
             'core:i/breadcrumbdivider' => 'fa-angle-right',
+            'core:i/bullhorn' => 'fa-bullhorn',
             'core:i/calc' => 'fa-calculator',
             'core:i/calendar' => 'fa-calendar',
             'core:i/calendareventdescription' => 'fa-align-left',
index 40d99ac..7013287 100644 (file)
@@ -997,7 +997,7 @@ class core_user {
         // Core components that may want to define their preferences.
         // List of core components implementing callback is hardcoded here for performance reasons.
         // TODO MDL-58184 cache list of core components implementing a function.
-        $corecomponents = ['core_message', 'core_calendar'];
+        $corecomponents = ['core_message', 'core_calendar', 'core_contentbank'];
         foreach ($corecomponents as $component) {
             if (($pluginpreferences = component_callback($component, 'user_preferences')) && is_array($pluginpreferences)) {
                 $preferences += $pluginpreferences;
diff --git a/lib/classes/userfeedback.php b/lib/classes/userfeedback.php
new file mode 100644 (file)
index 0000000..99be75a
--- /dev/null
@@ -0,0 +1,169 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * This file contains the core_userfeedback class
+ *
+ * @package    core
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * This Class contains helper functions for user feedback functionality.
+ *
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_userfeedback {
+    /**
+     * @var int Ask user to give feedback a few days after each major upgrade.
+     */
+    public const REMIND_AFTER_UPGRADE = 1;
+
+    /**
+     * @var int Ask user to give feedback periodically.
+     */
+    public const REMIND_PERIODICALLY = 2;
+
+    /**
+     * @var int Do not ask user to give feedback.
+     */
+    public const REMIND_NEVER = 3;
+
+    /**
+     * Displays the feedback reminder block.
+     */
+    public static function print_reminder_block(): void {
+        global $PAGE;
+
+        static $jscalled = false;
+
+        $actions = [
+            [
+                'title' => get_string('calltofeedback_give'),
+                'url' => '#',
+                'data' => [
+                        'action' => 'give',
+                    'record' => 1,
+                    'hide' => 1,
+                ],
+            ],
+            [
+                'title' => get_string('calltofeedback_remind'),
+                'url' => '#',
+                'data' => [
+                    'action' => 'remind',
+                    'record' => 1,
+                    'hide' => 1,
+                ],
+            ],
+        ];
+        $icon = [
+            'pix' => 'i/bullhorn',
+            'component' => 'core'
+        ];
+
+        \core\notification::add_call_to_action($icon, get_string('calltofeedback'), $actions, 'core/userfeedback');
+
+        if (!$jscalled) {
+            $jscalled = true;
+            // Calling the following more than once will register event listeners twice.
+            $PAGE->requires->js_call_amd('core/userfeedback', 'registerEventListeners');
+        }
+    }
+
+    /**
+     * Indicates whether the feedback reminder block should be shown or not.
+     *
+     * @return bool
+     */
+    public static function should_display_reminder(): bool {
+        global $CFG;
+
+        if ($CFG->enableuserfeedback && isloggedin() && !isguestuser()) {
+            $give = get_user_preferences('core_userfeedback_give');
+            $remind = get_user_preferences('core_userfeedback_remind');
+
+            $lastactiontime = max($give ?: 0, $remind ?: 0);
+
+            switch ($CFG->userfeedback_nextreminder) {
+                case self::REMIND_AFTER_UPGRADE:
+                    $lastupgrade = self::last_major_upgrade_time();
+                    if ($lastupgrade >= $lastactiontime) {
+                        return $lastupgrade + ($CFG->userfeedback_remindafter * DAYSECS) < time();
+                    }
+                    break;
+                case self::REMIND_PERIODICALLY:
+                    return $lastactiontime + ($CFG->userfeedback_remindafter * DAYSECS) < time();
+                    break;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Prepare and return the URL of the feedback site
+     *
+     * @return moodle_url
+     */
+    public static function make_link(): moodle_url {
+        global $CFG, $PAGE;
+        require_once($CFG->libdir . '/adminlib.php');
+
+        $baseurl = $CFG->userfeedback_url ?? 'https://feedback.moodle.org/lms';
+        $lang = clean_param(current_language(), PARAM_LANG); // Avoid breaking WS because of incorrect package langs.
+        $moodleurl = $CFG->wwwroot;
+        $moodleversion = $CFG->release;
+        $theme = $PAGE->theme->name;
+        $themeversion = get_component_version('theme_' . $theme);
+
+        $url = new moodle_url($baseurl, [
+            'lang' => $lang,
+            'moodle_url' => $moodleurl,
+            'moodle_version' => $moodleversion,
+            'theme' => $theme,
+            'theme_version' => $themeversion,
+            'newtest' => 'Y', // Respondents might be using the same device/browser to fill out the survey.
+                              // The newtest param resets the session.
+        ]);
+
+        return $url;
+    }
+
+    /**
+     * Returns the last major upgrade time
+     *
+     * @return int
+     */
+    private static function last_major_upgrade_time(): int {
+        global $DB;
+
+        $targetversioncast = $DB->sql_cast_char2real('targetversion');
+        $versioncast = $DB->sql_cast_char2real('version');
+
+        // A time difference more than 3 months has to be a core upgrade.
+        // Also, passing IGNORE_MULTIPLE because we are only interested in the first field and LIMIT is not cross-DB.
+        $time = $DB->get_field_sql("SELECT timemodified
+                                     FROM {upgrade_log}
+                                    WHERE plugin = 'core' AND $targetversioncast - $versioncast > 30000
+                                 ORDER BY timemodified DESC", null, IGNORE_MULTIPLE);
+
+        return (int)$time;
+    }
+}
index e8c1350..7f1bf35 100644 (file)
@@ -2764,10 +2764,10 @@ $functions = array(
         'capabilities'  => '',
         'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE],
     ],
-    'core_table_dynamic_fetch' => [
-        'classname' => 'core_table\external\dynamic\fetch',
+    'core_table_get_dynamic_table_content' => [
+        'classname' => 'core_table\external\dynamic\get',
         'methodname' => 'execute',
-        'description' => 'Fetch a dynamic table view raw html',
+        'description' => 'Get the dynamic table content raw html',
         'type' => 'read',
         'ajax' => true,
         'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE],
@@ -2800,6 +2800,25 @@ $functions = array(
         'ajax'          => 'true',
         'capabilities'  => 'moodle/contentbank:manageowncontent',
     ],
+    'core_create_userfeedback_action_record' => [
+        'classname'     => 'core\external\userfeedback\record_action',
+        'methodname'    => 'execute',
+        'classpath'     => '',
+        'description'   => 'Record the action that the user takes in the user feedback notification for future use.',
+        'type'          => 'write',
+        'ajax'          => 'true',
+        'capabilities'  => '',
+    ],
+    'core_get_userfeedback_url' => [
+        'classname'     => 'core\external\userfeedback\generate_url',
+        'methodname'    => 'execute',
+        'classpath'     => '',
+        'description'   => 'Generate a dynamic URL for the external user feedback site.' .
+                           ' The URL includes some parameters to pre-fill the user feedback form.',
+        'type'          => 'read',
+        'ajax'          => 'true',
+        'capabilities'  => '',
+    ],
 );
 
 $services = array(
index faeb9ac..1327d93 100644 (file)
@@ -3559,3 +3559,267 @@ function cron_bc_hack_plugin_functions($plugintype, $plugins) {
 
     return $plugins;
 }
+
+/**
+ * Returns the SQL used by the participants table.
+ *
+ * @deprecated since Moodle 3.9 MDL-68612 - See \core_user\table\participants_search for an improved way to fetch participants.
+ * @param int $courseid The course id
+ * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
+ * @param int $accesssince The time since last access, 0 means any time
+ * @param int $roleid The role id, 0 means all roles and -1 no roles
+ * @param int $enrolid The enrolment id, 0 means all enrolment methods will be returned.
+ * @param int $statusid The user enrolment status, -1 means all enrolments regardless of the status will be returned, if allowed.
+ * @param string|array $search The search that was performed, empty means perform no search
+ * @param string $additionalwhere Any additional SQL to add to where
+ * @param array $additionalparams The additional params
+ * @return array
+ */
+function user_get_participants_sql($courseid, $groupid = 0, $accesssince = 0, $roleid = 0, $enrolid = 0, $statusid = -1,
+                                   $search = '', $additionalwhere = '', $additionalparams = array()) {
+    global $DB, $USER, $CFG;
+
+    $deprecatedtext = __FUNCTION__ . '() is deprecated. ' .
+                 'Please use \core\table\participants_search::class with table filtersets instead.';
+    debugging($deprecatedtext, DEBUG_DEVELOPER);
+
+    // Get the context.
+    $context = \context_course::instance($courseid, MUST_EXIST);
+
+    $isfrontpage = ($courseid == SITEID);
+
+    // Default filter settings. We only show active by default, especially if the user has no capability to review enrolments.
+    $onlyactive = true;
+    $onlysuspended = false;
+    if (has_capability('moodle/course:enrolreview', $context) && (has_capability('moodle/course:viewsuspendedusers', $context))) {
+        switch ($statusid) {
+            case ENROL_USER_ACTIVE:
+                // Nothing to do here.
+                break;
+            case ENROL_USER_SUSPENDED:
+                $onlyactive = false;
+                $onlysuspended = true;
+                break;
+            default:
+                // If the user has capability to review user enrolments, but statusid is set to -1, set $onlyactive to false.
+                $onlyactive = false;
+                break;
+        }
+    }
+
+    list($esql, $params) = get_enrolled_sql($context, null, $groupid, $onlyactive, $onlysuspended, $enrolid);
+
+    $joins = array('FROM {user} u');
+    $wheres = array();
+
+    $userfields = get_extra_user_fields($context);
+    $userfieldssql = user_picture::fields('u', $userfields);
+
+    if ($isfrontpage) {
+        $select = "SELECT $userfieldssql, u.lastaccess";
+        $joins[] = "JOIN ($esql) e ON e.id = u.id"; // Everybody on the frontpage usually.
+        if ($accesssince) {
+            $wheres[] = user_get_user_lastaccess_sql($accesssince);
+        }
+    } else {
+        $select = "SELECT $userfieldssql, COALESCE(ul.timeaccess, 0) AS lastaccess";
+        $joins[] = "JOIN ($esql) e ON e.id = u.id"; // Course enrolled users only.
+        // Not everybody has accessed the course yet.
+        $joins[] = 'LEFT JOIN {user_lastaccess} ul ON (ul.userid = u.id AND ul.courseid = :courseid)';
+        $params['courseid'] = $courseid;
+        if ($accesssince) {
+            $wheres[] = user_get_course_lastaccess_sql($accesssince);
+        }
+    }
+
+    // Performance hacks - we preload user contexts together with accounts.
+    $ccselect = ', ' . context_helper::get_preload_record_columns_sql('ctx');
+    $ccjoin = 'LEFT JOIN {context} ctx ON (ctx.instanceid = u.id AND ctx.contextlevel = :contextlevel)';
+    $params['contextlevel'] = CONTEXT_USER;
+    $select .= $ccselect;
+    $joins[] = $ccjoin;
+
+    // Limit list to users with some role only.
+    if ($roleid) {
+        // We want to query both the current context and parent contexts.
+        list($relatedctxsql, $relatedctxparams) = $DB->get_in_or_equal($context->get_parent_context_ids(true),
+            SQL_PARAMS_NAMED, 'relatedctx');
+
+        // Get users without any role.
+        if ($roleid == -1) {
+            $wheres[] = "u.id NOT IN (SELECT userid FROM {role_assignments} WHERE contextid $relatedctxsql)";
+            $params = array_merge($params, $relatedctxparams);
+        } else {
+            $wheres[] = "u.id IN (SELECT userid FROM {role_assignments} WHERE roleid = :roleid AND contextid $relatedctxsql)";
+            $params = array_merge($params, array('roleid' => $roleid), $relatedctxparams);
+        }
+    }
+
+    if (!empty($search)) {
+        if (!is_array($search)) {
+            $search = [$search];
+        }
+        foreach ($search as $index => $keyword) {
+            $searchkey1 = 'search' . $index . '1';
+            $searchkey2 = 'search' . $index . '2';
+            $searchkey3 = 'search' . $index . '3';
+            $searchkey4 = 'search' . $index . '4';
+            $searchkey5 = 'search' . $index . '5';
+            $searchkey6 = 'search' . $index . '6';
+            $searchkey7 = 'search' . $index . '7';
+
+            $conditions = array();
+            // Search by fullname.
+            $fullname = $DB->sql_fullname('u.firstname', 'u.lastname');
+            $conditions[] = $DB->sql_like($fullname, ':' . $searchkey1, false, false);
+
+            // Search by email.
+            $email = $DB->sql_like('email', ':' . $searchkey2, false, false);
+            if (!in_array('email', $userfields)) {
+                $maildisplay = 'maildisplay' . $index;
+                $userid1 = 'userid' . $index . '1';
+                // Prevent users who hide their email address from being found by others
+                // who aren't allowed to see hidden email addresses.
+                $email = "(". $email ." AND (" .
+                        "u.maildisplay <> :$maildisplay " .
+                        "OR u.id = :$userid1". // User can always find himself.
+                        "))";
+                $params[$maildisplay] = core_user::MAILDISPLAY_HIDE;
+                $params[$userid1] = $USER->id;
+            }
+            $conditions[] = $email;
+
+            // Search by idnumber.
+            $idnumber = $DB->sql_like('idnumber', ':' . $searchkey3, false, false);
+            if (!in_array('idnumber', $userfields)) {
+                $userid2 = 'userid' . $index . '2';
+                // Users who aren't allowed to see idnumbers should at most find themselves
+                // when searching for an idnumber.
+                $idnumber = "(". $idnumber . " AND u.id = :$userid2)";
+                $params[$userid2] = $USER->id;
+            }
+            $conditions[] = $idnumber;
+
+            if (!empty($CFG->showuseridentity)) {
+                // Search all user identify fields.
+                $extrasearchfields = explode(',', $CFG->showuseridentity);
+                foreach ($extrasearchfields as $extrasearchfield) {
+                    if (in_array($extrasearchfield, ['email', 'idnumber', 'country'])) {
+                        // Already covered above. Search by country not supported.
+                        continue;
+                    }
+                    $param = $searchkey3 . $extrasearchfield;
+                    $condition = $DB->sql_like($extrasearchfield, ':' . $param, false, false);
+                    $params[$param] = "%$keyword%";
+                    if (!in_array($extrasearchfield, $userfields)) {
+                        // User cannot see this field, but allow match if their own account.
+                        $userid3 = 'userid' . $index . '3' . $extrasearchfield;
+                        $condition = "(". $condition . " AND u.id = :$userid3)";
+                        $params[$userid3] = $USER->id;
+                    }
+                    $conditions[] = $condition;
+                }
+            }
+
+            // Search by middlename.
+            $middlename = $DB->sql_like('middlename', ':' . $searchkey4, false, false);
+            $conditions[] = $middlename;
+
+            // Search by alternatename.
+            $alternatename = $DB->sql_like('alternatename', ':' . $searchkey5, false, false);
+            $conditions[] = $alternatename;
+
+            // Search by firstnamephonetic.
+            $firstnamephonetic = $DB->sql_like('firstnamephonetic', ':' . $searchkey6, false, false);
+            $conditions[] = $firstnamephonetic;
+
+            // Search by lastnamephonetic.
+            $lastnamephonetic = $DB->sql_like('lastnamephonetic', ':' . $searchkey7, false, false);
+            $conditions[] = $lastnamephonetic;
+
+            $wheres[] = "(". implode(" OR ", $conditions) .") ";
+            $params[$searchkey1] = "%$keyword%";
+            $params[$searchkey2] = "%$keyword%";
+            $params[$searchkey3] = "%$keyword%";
+            $params[$searchkey4] = "%$keyword%";
+            $params[$searchkey5] = "%$keyword%";
+            $params[$searchkey6] = "%$keyword%";
+            $params[$searchkey7] = "%$keyword%";
+        }
+    }
+
+    if (!empty($additionalwhere)) {
+        $wheres[] = $additionalwhere;
+        $params = array_merge($params, $additionalparams);
+    }
+
+    $from = implode("\n", $joins);
+    if ($wheres) {
+        $where = 'WHERE ' . implode(' AND ', $wheres);
+    } else {
+        $where = '';
+    }
+
+    return array($select, $from, $where, $params);
+}
+
+/**
+ * Returns the total number of participants for a given course.
+ *
+ * @deprecated since Moodle 3.9 MDL-68612 - See \core_user\table\participants_search for an improved way to fetch participants.
+ * @param int $courseid The course id
+ * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
+ * @param int $accesssince The time since last access, 0 means any time
+ * @param int $roleid The role id, 0 means all roles
+ * @param int $enrolid The applied filter for the user enrolment ID.
+ * @param int $status The applied filter for the user's enrolment status.
+ * @param string|array $search The search that was performed, empty means perform no search
+ * @param string $additionalwhere Any additional SQL to add to where
+ * @param array $additionalparams The additional params
+ * @return int
+ */
+function user_get_total_participants($courseid, $groupid = 0, $accesssince = 0, $roleid = 0, $enrolid = 0, $statusid = -1,
+                                     $search = '', $additionalwhere = '', $additionalparams = array()) {
+    global $DB;
+
+    $deprecatedtext = __FUNCTION__ . '() is deprecated. ' .
+                      'Please use \core\table\participants_search::class with table filtersets instead.';
+    debugging($deprecatedtext, DEBUG_DEVELOPER);
+
+    list($select, $from, $where, $params) = user_get_participants_sql($courseid, $groupid, $accesssince, $roleid, $enrolid,
+        $statusid, $search, $additionalwhere, $additionalparams);
+
+    return $DB->count_records_sql("SELECT COUNT(u.id) $from $where", $params);
+}
+
+/**
+ * Returns the participants for a given course.
+ *
+ * @deprecated since Moodle 3.9 MDL-68612 - See \core_user\table\participants_search for an improved way to fetch participants.
+ * @param int $courseid The course id
+ * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
+ * @param int $accesssince The time since last access
+ * @param int $roleid The role id
+ * @param int $enrolid The applied filter for the user enrolment ID.
+ * @param int $status The applied filter for the user's enrolment status.
+ * @param string $search The search that was performed
+ * @param string $additionalwhere Any additional SQL to add to where
+ * @param array $additionalparams The additional params
+ * @param string $sort The SQL sort
+ * @param int $limitfrom return a subset of records, starting at this point (optional).
+ * @param int $limitnum return a subset comprising this many records (optional, required if $limitfrom is set).
+ * @return moodle_recordset
+ */
+function user_get_participants($courseid, $groupid = 0, $accesssince, $roleid, $enrolid = 0, $statusid, $search,
+                               $additionalwhere = '', $additionalparams = array(), $sort = '', $limitfrom = 0, $limitnum = 0) {
+    global $DB;
+
+    $deprecatedtext = __FUNCTION__ . '() is deprecated. ' .
+                      'Please use \core\table\participants_search::class with table filtersets instead.';
+    debugging($deprecatedtext, DEBUG_DEVELOPER);
+
+    list($select, $from, $where, $params) = user_get_participants_sql($courseid, $groupid, $accesssince, $roleid, $enrolid,
+        $statusid, $search, $additionalwhere, $additionalparams);
+
+    return $DB->get_recordset_sql("$select $from $where $sort", $params, $limitfrom, $limitnum);
+}
index 0584acc..a47a25c 100644 (file)
@@ -13,7 +13,6 @@
         value="{{element.selectedvalue}}"
     {{/element.selectedvalue}}
     {{#element.checked}}checked{{/element.checked}}
-    size="{{element.size}}"
     {{#error}}
         autofocus aria-describedby="{{element.iderror}}"
     {{/error}}
index 994ebc5..fb90de4 100644 (file)
@@ -13,7 +13,6 @@
         value="1"
     {{/element.value}}
     {{#element.checked}}checked{{/element.checked}}
-    size="{{element.size}}"
     {{#error}}
         autofocus aria-describedby="{{element.iderror}}"
     {{/error}}
index 6f02ca0..f2607eb 100644 (file)
@@ -1,5 +1,26 @@
 {{< core_form/element-template }}
+    {{$label}}
+        {{^element.hiddenlabel}}
+            <p id="{{element.id}}_label" class="col-form-label d-inline" aria-hidden="true">
+                {{{label}}}
+            </p>
+        {{/element.hiddenlabel}}
+    {{/label}}
     {{$element}}
-        {{{element.html}}}
+        <fieldset class="w-100 m-0 p-0 border-0" id="{{element.id}}_fieldset">
+            <legend class="sr-only">{{label}}</legend>
+            {{{element.html}}}
+        </fieldset>
     {{/element}}
 {{/ core_form/element-template }}
+{{#js}}
+(function() {
+    var label = document.getElementById('{{element.id}}_label');
+    if (label) {
+        label.style.cursor = 'default';
+        label.addEventListener('click', function() {
+            document.querySelectorAll('#{{element.id}}_fieldset div.fp-toolbar a')[0].focus();
+        });
+    }
+})();
+{{/js}}
index 6f02ca0..579cf20 100644 (file)
@@ -1,5 +1,26 @@
 {{< core_form/element-template }}
+    {{$label}}
+        {{^element.hiddenlabel}}
+            <p id="{{element.id}}_label" class="col-form-label d-inline" aria-hidden="true">
+                {{{label}}}
+            </p>
+        {{/element.hiddenlabel}}
+    {{/label}}
     {{$element}}
-        {{{element.html}}}
+        <fieldset class="w-100 m-0 p-0 border-0" id="{{element.id}}_fieldset">
+            <legend class="sr-only">{{label}}</legend>
+            {{{element.html}}}
+        </fieldset>
     {{/element}}
 {{/ core_form/element-template }}
+{{#js}}
+(function() {
+    var label = document.getElementById('{{element.id}}_label');
+    if (label) {
+        label.style.cursor = 'default';
+        label.addEventListener('click', function() {
+            document.querySelectorAll('#{{element.id}}_fieldset .fp-btn-choose')[0].focus();
+        });
+    }
+})();
+{{/js}}
index 2d3022e..bdddf75 100644 (file)
@@ -1,6 +1,6 @@
 {{< core_form/element-template-inline }}
     {{$element}}
-    <div class="d-flex flex-wrap">
+    <div class="d-flex flex-wrap align-items-center">
         {{#element.elements}}
             {{{separator}}}
             {{{html}}}
index 4664a09..d5cca77 100644 (file)
@@ -9,7 +9,7 @@
     {{$element}}
         <fieldset class="w-100 m-0 p-0 border-0">
             <legend class="sr-only">{{label}}</legend>
-            <div class="d-flex flex-wrap">
+            <div class="d-flex flex-wrap align-items-center">
             {{#element.elements}}
                 {{{separator}}}
                 {{{html}}}
 require(['jquery'], function($) {
     $('#{{element.id}}_label').css('cursor', 'default');
     $('#{{element.id}}_label').click(function() {
-        $('#{{element.id}}').find('button, a, input, select, textarea, [tabindex]:not([tabindex="-1"])').filter(':enabled').first().focus();
+        $('#{{element.id}}')
+            .find('button, a, input:not([type="hidden"]), select, textarea, [tabindex]')
+            .filter(':not([disabled]):not([tabindex="0"]):not([tabindex="-1"])')
+            .first().focus();
     });
 });
 {{/js}}
index 59da069..f832dec 100644 (file)
@@ -41,7 +41,7 @@
                 name="{{element.name}}"
                 id="{{element.id}}"
                 value="{{element.value}}"
-                size="{{element.size}}"
+                {{#element.size}}size="{{element.size}}"{{/element.size}}
                 {{#error}}
                     autofocus aria-describedby="{{element.iderror}}"
                 {{/error}} {{{element.attributes}}}>
index d6e80f8..9a7758d 100644 (file)
@@ -41,7 +41,7 @@
                 name="{{element.name}}"
                 id="{{element.id}}"
                 value="{{element.value}}"
-                size="{{element.size}}"
+                {{#element.size}}size="{{element.size}}"{{/element.size}}
                 {{#error}}
                     autofocus aria-describedby="{{element.iderror}}"
                 {{/error}} {{{element.attributes}}}>
index bbf538d..d9528fa 100644 (file)
@@ -49,7 +49,7 @@
             {{#advanced}}<abbr class="initialism text-info" title="{{#str}}advanced{{/str}}">!</abbr>{{/advanced}}
             {{{helpbutton}}}
         </span>
-        {{$ label }}
+        {{# label}}{{$ label }}
             {{^element.staticlabel}}
                 <label class="col-form-label d-inline {{#element.hiddenlabel}}sr-only{{/element.hiddenlabel}}" for="{{element.id}}">
                     {{{label}}}
@@ -60,7 +60,7 @@
                     {{{label}}}
                 </span>
             {{/element.staticlabel}}
-        {{/ label }}
+        {{/ label }}{{/ label}}
     </div>
     <div class="col-md-9 form-inline felement" data-fieldtype="{{element.type}}">
         {{$ element }}
index b6bb3e9..81f7a5a 100644 (file)
@@ -8,7 +8,7 @@
             readonly {{#element.hardfrozen}}disabled{{/element.hardfrozen}}
     {{/element.frozen}}
             value="{{element.value}}"
-            size="{{element.size}}"
+            {{#element.size}}size="{{element.size}}"{{/element.size}}
             {{#error}}
                 autofocus aria-describedby="{{element.iderror}}"
             {{/error}}
index 087201a..7e972a3 100644 (file)
@@ -8,7 +8,7 @@
         {{/element.frozen}}
                 id="{{element.id}}"
                 value="{{element.value}}"
-                size="{{element.size}}"
+                {{#element.size}}size="{{element.size}}"{{/element.size}}
                 {{#error}}
                     autofocus aria-describedby="{{element.iderror}}"
                 {{/error}}
index 5529d4a..e161c8f 100644 (file)
@@ -8,7 +8,7 @@
                 name="{{element.name}}"
                 id="{{element.id}}"
                 value="{{element.value}}"
-                size="{{element.size}}"
+                {{#element.size}}size="{{element.size}}"{{/element.size}}
                 {{#error}}
                     autofocus aria-describedby="{{element.iderror}}"
                 {{/error}}
index dd4a16a..1f21f76 100644 (file)
@@ -449,6 +449,41 @@ class grade_item extends grade_object {
         return true;
     }
 
+    /**
+     * Duplicate grade item.
+     *
+     * @return grade_item The duplicate grade item
+     */
+    public function duplicate() {
+        // Convert current object to array.
+        $copy = (array) $this;
+
+        if (empty($copy["id"])) {
+            throw new moodle_exception('invalidgradeitemid');
+        }
+
+        // Remove fields that will be either unique or automatically filled.
+        $removekeys = array();
+        $removekeys[] = 'id';
+        $removekeys[] = 'idnumber';
+        $removekeys[] = 'timecreated';
+        $removekeys[] = 'sortorder';
+        foreach ($removekeys as $key) {
+            unset($copy[$key]);
+        }
+
+        // Addendum to name.
+        $copy["itemname"] = get_string('duplicatedgradeitem', 'grades', $copy["itemname"]);
+
+        // Create new grade item.
+        $gradeitem = new grade_item($copy);
+
+        // Insert grade item into database.
+        $gradeitem->insert();
+
+        return $gradeitem;
+    }
+
     /**
      * In addition to perform parent::insert(), calls force_regrading() method too.
      *
index a420ceb..5c0b46b 100644 (file)
@@ -1050,4 +1050,78 @@ class core_grade_item_testcase extends grade_base_testcase {
         $this->assertEquals($gradeitem->itemmodule, $event->other['itemmodule']);
         $this->assertEquals('updatedname', $event->other['itemname']);
     }
+
+
+    /**
+     * Test grade item duplication expecting success.
+     */
+    public function test_grade_duplicate_grade_item_success() {
+        $cat = new grade_category();
+        $cat->courseid = $this->courseid;
+        $cat->fullname = 'Grade category';
+        $cat->insert();
+
+        // Method exists.
+        $gi = new grade_item();
+        $this->assertTrue(method_exists($gi, 'duplicate'));
+
+        // Grade item is inserted and valid for duplication.
+        $gi->courseid = $this->courseid;
+        $gi->categoryid = $cat->id;
+        $gi->itemtype = 'manual';
+        $gi->itemname = 'Grade Item 1';
+        $gi->idnumber = '1000';
+        $gi->insert();
+        $gi2 = $gi->duplicate();
+
+        $this->assertEquals($gi->courseid, $gi2->courseid);
+        $this->assertEquals($gi->categoryid, $gi2->categoryid);
+        $this->assertEquals($gi->itemtype, $gi2->itemtype);
+        $this->assertEquals($gi->gradetype, $gi2->gradetype);
+        $this->assertEquals($gi->grademax, $gi2->grademax);
+        $this->assertEquals($gi->grademin, $gi2->grademin);
+        $this->assertEquals($gi->gradepass, $gi2->gradepass);
+        $this->assertEquals($gi->display, $gi2->display);
+        $this->assertEquals($gi->decimals, $gi2->decimals);
+        $this->assertEquals($gi->hidden, $gi2->hidden);
+        $this->assertEquals($gi->weightoverride, $gi2->weightoverride);
+
+        $this->assertNotEquals($gi->id, $gi2->id);
+        $this->assertNotEquals($gi->idnumber, $gi2->idnumber);
+        $this->assertNotEquals($gi->sortorder, $gi2->sortorder);
+        $this->assertNotEquals($gi->itemname, $gi2->itemname);
+    }
+
+    /**
+     * Test grade item duplication exception expected with incomplete grade item.
+     */
+    public function test_grade_duplicate_grade_item_incomplete() {
+        // Grade item is not valid because it is empty.
+        $gi = new grade_item();
+        $gi->courseid = $this->courseid;
+        $this->expectException("moodle_exception");
+        $gi2 = $gi->duplicate();
+    }
+
+    /**
+     * Test grade item duplication exception expected because item must be in db.
+     */
+    public function test_grade_duplicate_grade_item_not_in_db() {
+        $cat = new grade_category();
+        $cat->courseid = $this->courseid;
+        $cat->fullname = 'Grade category';
+        $cat->insert();
+
+        // Grade item is valid for insertion but is not inserted into db.
+        // Duplicate method throws an exception.
+        $gi = new grade_item();
+        $gi->courseid = $this->courseid;
+        $gi->categoryid = $cat->id;
+        $gi->itemtype = 'manual';
+        $gi->itemname = 'Grade Item 1';
+        $gi->idnumber = '1000';
+
+        $this->expectException("moodle_exception");
+        $gi2 = $gi->duplicate();
+    }
 }
index 6c81e34..e08b3d3 100644 (file)
@@ -2765,6 +2765,12 @@ class html_table {
 
     /**
      * @var string Description of the contents for screen readers.
+     *
+     * The "summary" attribute on the "table" element is not supported in HTML5.
+     * Consider describing the structure of the table in a "caption" element or in a "figure" element containing the table;
+     * or, simplify the structure of the table so that no description is needed.
+     *
+     * @deprecated since Moodle 3.9.
      */
     public $summary;
 
index e3705ea..240d449 100644 (file)
@@ -835,6 +835,12 @@ class core_renderer extends renderer_base {
             }
         }
 
+        if (isloggedin() && !isguestuser()) {
+            $output .= html_writer::div(
+                $this->render_from_template('core/userfeedback_footer_link', ['url' => core_userfeedback::make_link()->out(false)])
+            );
+        }
+
         // This function is normally called from a layout.php file in {@link core_renderer::header()}
         // but some of the content won't be known until later, so we return a placeholder
         // for now. This will be replaced with the real content in {@link core_renderer::footer()}.
index 859e424..470b3be 100644 (file)
@@ -560,7 +560,7 @@ function question_move_question_tags_to_new_context(array $questions, context $n
     $questionstagobjects = core_tag_tag::get_items_tags('core_question', 'question', $questionids);
 
     foreach ($questions as $question) {
-        $tagobjects = $questionstagobjects[$question->id];
+        $tagobjects = $questionstagobjects[$question->id] ?? [];
 
         foreach ($tagobjects as $tagobject) {
             $tagid = $tagobject->taginstanceid;
index f4b2bdc..3bf3339 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js and b/lib/table/amd/build/dynamic.min.js differ
index 822e013..f1e7466 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js.map and b/lib/table/amd/build/dynamic.min.js.map differ
index ff5ca26..e07cb96 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/repository.min.js and b/lib/table/amd/build/local/dynamic/repository.min.js differ
index a8cb000..4cc7125 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/repository.min.js.map and b/lib/table/amd/build/local/dynamic/repository.min.js.map differ
index 235dd5e..1448991 100644 (file)
  * @copyright  2020 Simey Lameze <simey@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-import {fetch as fetchTableData} from 'core_table/local/dynamic/repository';
 import * as Selectors from 'core_table/local/dynamic/selectors';
 import Events from './local/dynamic/events';
+import Pending from 'core/pending';
 import {addIconToContainer} from 'core/loadingicon';
+import {fetch as fetchTableData} from 'core_table/local/dynamic/repository';
 
 let watching = false;
 
@@ -69,6 +70,8 @@ export const refreshTableContent = (tableRoot, resetContent = false) => {
     const filterset = getFiltersetFromTable(tableRoot);
     addIconToContainer(tableRoot);
 
+    const pendingPromise = new Pending('core_table/dynamic:refreshTableContent');
+
     return fetchTableData(
         tableRoot.dataset.tableComponent,
         tableRoot.dataset.tableHandler,
@@ -97,6 +100,11 @@ export const refreshTableContent = (tableRoot, resetContent = false) => {
             bubbles: true,
         }));
 
+        return tableRoot;
+    })
+    .then(tableRoot => {
+        pendingPromise.resolve();
+
         return tableRoot;
     });
 };
@@ -113,6 +121,8 @@ export const updateTable = (tableRoot, {
 } = {}, refreshContent = true) => {
     checkTableIsDynamic(tableRoot);
 
+    const pendingPromise = new Pending('core_table/dynamic:updateTable');
+
     // Update sort fields.
     if (sortBy && sortOrder) {
         const sortData = JSON.parse(tableRoot.dataset.tableSortData);
@@ -152,12 +162,29 @@ export const updateTable = (tableRoot, {
 
     // Refresh.
     if (refreshContent) {
-        return refreshTableContent(tableRoot);
+        return refreshTableContent(tableRoot)
+        .then(tableRoot => {
+            pendingPromise.resolve();
+            return tableRoot;
+        });
     } else {
+        pendingPromise.resolve();
         return Promise.resolve(tableRoot);
     }
 };
 
+/**
+ * Get the table dataset for the specified tableRoot, ensuring that the provided table is a dynamic table.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {DOMStringMap}
+ */
+const getTableData = tableRoot => {
+    checkTableIsDynamic(tableRoot);
+
+    return tableRoot.dataset;
+};
+
 /**
  * Update the specified table using the new filters.
  *
@@ -169,6 +196,18 @@ export const updateTable = (tableRoot, {
 export const setFilters = (tableRoot, filters, refreshContent = true) =>
     updateTable(tableRoot, {filters}, refreshContent);
 
+/**
+ * Get the filter data for the specified table.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {Object}
+ */
+export const getFilters = tableRoot => {
+    checkTableIsDynamic(tableRoot);
+
+    return getFiltersetFromTable(tableRoot);
+};
+
 /**
  * Update the sort order.
  *
@@ -192,6 +231,14 @@ export const setSortOrder = (tableRoot, sortBy, sortOrder, refreshContent = true
 export const setPageNumber = (tableRoot, pageNumber, refreshContent = true) =>
     updateTable(tableRoot, {pageNumber}, refreshContent);
 
+/**
+ * Get the current page number.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {Number}
+ */
+export const getPageNumber = tableRoot => getTableData(tableRoot).tablePageNumber;
+
 /**
  * Set the page size.
  *
@@ -203,6 +250,14 @@ export const setPageNumber = (tableRoot, pageNumber, refreshContent = true) =>
 export const setPageSize = (tableRoot, pageSize, refreshContent = true) =>
     updateTable(tableRoot, {pageSize, pageNumber: 0}, refreshContent);
 
+/**
+ * Get the current page size.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {Number}
+ */
+export const getPageSize = tableRoot => getTableData(tableRoot).tablePageSize;
+
 /**
  * Update the first initial to show.
  *
@@ -214,6 +269,14 @@ export const setPageSize = (tableRoot, pageSize, refreshContent = true) =>
 export const setFirstInitial = (tableRoot, firstInitial, refreshContent = true) =>
     updateTable(tableRoot, {firstInitial}, refreshContent);
 
+/**
+ * Get the current first initial filter.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {String}
+ */
+export const getFirstInitial = tableRoot => getTableData(tableRoot).tableFirstInitial;
+
 /**
  * Update the last initial to show.
  *
@@ -225,6 +288,14 @@ export const setFirstInitial = (tableRoot, firstInitial, refreshContent = true)
 export const setLastInitial = (tableRoot, lastInitial, refreshContent = true) =>
     updateTable(tableRoot, {lastInitial}, refreshContent);
 
+/**
+ * Get the current last initial filter.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {String}
+ */
+export const getLastInitial = tableRoot => getTableData(tableRoot).tableLastInitial;
+
 /**
  * Hide a column in the participants table.
  *
index 3e43ac9..e548e97 100644 (file)
@@ -54,7 +54,7 @@ export const fetch = (component, handler, uniqueid, {
         hiddenColumns = {}
     } = {}, resetPreferences = false) => {
     return fetchMany([{
-        methodname: `core_table_dynamic_fetch`,
+        methodname: `core_table_get_dynamic_table_content`,
         args: {
             component,
             handler,
similarity index 99%
rename from lib/table/classes/external/dynamic/fetch.php
rename to lib/table/classes/external/dynamic/get.php
index ad2fcda..f21e463 100644 (file)
@@ -31,7 +31,6 @@ use external_multiple_structure;
 use external_single_structure;
 use external_value;
 use external_warnings;
-use moodle_url;
 
 /**
  * Core table external functions.
@@ -41,7 +40,7 @@ use moodle_url;
  * @copyright  2020 Simey Lameze <simey@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class fetch extends external_api {
+class get extends external_api {
 
     /**
      * Describes the parameters for fetching the table html.
@@ -140,7 +139,7 @@ class fetch extends external_api {
     }
 
     /**
-     * External function to fetch a table view.
+     * External function to get the table view content.
      *
      * @param string $component The component.
      * @param string $handler Dynamic table class name.
index e6fc650..eda6039 100644 (file)
@@ -41,11 +41,8 @@ use Iterator;
  */
 class filter implements Countable, Iterator, JsonSerializable {
 
-    /**
-     * @var in The default filter type (ALL)
-     * Note: This is for backwards compatibility with the old UI behaviour and will be set to JOINTYPE_ANY as part of MDL-68612.
-     */
-    const JOINTYPE_DEFAULT = 2;
+    /** @var in The default filter type (ANY) */
+    const JOINTYPE_DEFAULT = 1;
 
     /** @var int None of the following match */
     const JOINTYPE_NONE = 0;
index d04eaef..0afddeb 100644 (file)
@@ -40,11 +40,8 @@ use moodle_exception;
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 abstract class filterset implements JsonSerializable {
-    /**
-     * @var in The default filter type (ALL)
-     * Note: This is for backwards compatibility with the old UI behaviour and will be set to JOINTYPE_ANY as part of MDL-68612.
-     */
-    const JOINTYPE_DEFAULT = 2;
+    /** @var in The default filter type (ANY) */
+    const JOINTYPE_DEFAULT = 1;
 
     /** @var int None of the following match */
     const JOINTYPE_NONE = 0;
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Unit tests for core_table\external\fetch;
+ * Unit tests for core_table\external\dynamic\get;
  *
  * @package   core_table
  * @category  test
@@ -31,14 +31,14 @@ use core_table\local\filter\filter;
 use advanced_testcase;
 
 /**
- * Unit tests for core_table\external\fetch;
+ * Unit tests for core_table\external\dynamic\get;
  *
  * @package   core_table
  * @category  test
  * @copyright  2020 Simey Lameze <simey@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class fetch_test extends advanced_testcase {
+class get_test extends advanced_testcase {
 
     /**
      * Setup before class.
@@ -55,7 +55,7 @@ class fetch_test extends advanced_testcase {
         $this->resetAfterTest();
 
         $this->expectException(\invalid_parameter_exception::class);
-        fetch::execute(
+        get::execute(
             "core-user",
             "participants",
             "",
@@ -79,7 +79,7 @@ class fetch_test extends advanced_testcase {
         $this->resetAfterTest();
 
         $this->expectException(\UnexpectedValueException::class);
-        fetch::execute(
+        get::execute(
             "core_users",
             "participants",
             "",
@@ -106,7 +106,7 @@ class fetch_test extends advanced_testcase {
         $this->expectExceptionMessage("Table handler class {$handler} not found. Please make sure that your table handler class is under the \\core_user\\table namespace.");
 
         // Tests that invalid users_participants_table class gets an exception.
-        fetch::execute(
+        get::execute(
             "core_user",
             "users_participants_table",
             "",
@@ -143,7 +143,7 @@ class fetch_test extends advanced_testcase {
         $this->expectExceptionMessage("Invalid parameter value detected (filters => Invalid parameter value detected " .
         "(Missing required key in single structure: name): Missing required key in single structure: name");
 
-        fetch::execute(
+        get::execute(
             "core_user",
             "participants", "user-index-participants-{$course->id}",
             $this->get_sort_array(['firstname' => SORT_ASC]),
@@ -153,9 +153,9 @@ class fetch_test extends advanced_testcase {
     }
 
     /**
-     * Test execute fetch table.
+     * Test execute method.
      */
-    public function test_execute_fetch_table(): void {
+    public function test_table_get_execute(): void {
         $this->resetAfterTest();
 
         $course = $this->getDataGenerator()->create_course();
@@ -176,7 +176,7 @@ class fetch_test extends advanced_testcase {
             ]
         ];
 
-        $participantstable = fetch::execute(
+        $participantstable = get::execute(
             "core_user",
             "participants",
             "user-index-participants-{$course->id}",
diff --git a/lib/templates/campaign_content.mustache b/lib/templates/campaign_content.mustache
new file mode 100644 (file)
index 0000000..dbba382
--- /dev/null
@@ -0,0 +1,50 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/campaign_content
+
+    Moodle campaign content template.
+
+    The purpose of this template is to render an iframe that contains campaign content.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * lang User's language.
+
+    Example context (json):
+    { "lang": "en"}
+}}
+<div class="alert alert-secondary alert-block fade in alert-dismissible">
+    <button type="button" class="close" data-dismiss="alert">&times;</button>
+    <iframe id="campaign-content" class="w-100 border-0"></iframe>
+</div>
+{{#js}}
+(function() {
+    var iframe = document.getElementById('campaign-content');
+    iframe.src = 'https://campaign.moodle.org/current/lms/{{lang}}/';
+    window.addEventListener('message', function (event) {
+        if (event.origin === 'https://campaign.moodle.org') {
+            iframe.style.height = event.data + 'px';
+        }
+    });
+})();
+{{/js}}
index 09084b6..c827c9a 100644 (file)
                     </div>
                 </div>
                 <div class="fp-original form-group row mx-0">
-                    <label class="form-control-label col-4 px-0">{{#str}}original, repository{{/str}}</label>
+                    <div class="form-control-label col-4 px-0">{{#str}}original, repository{{/str}}</div>
                     <div class="col-8 form-inline">
                         <span class="fp-originloading">{{#pix}}i/loading_small{{/pix}} {{#str}}loading, repository{{/str}}</span><span class="fp-value"></span>
                     </div>
                 </div>
                 <div class="fp-reflist form-group row mx-0">
-                    <label class="form-control-label col-4 px-0">{{#str}}referenceslist, repository{{/str}}</label>
+                    <div class="form-control-label col-4 px-0">{{#str}}referenceslist, repository{{/str}}</div>
                     <div class="col-8 form-inline">
                         <p class="fp-refcount"></p>
                         <span class="fp-reflistloading">{{#pix}}i/loading_small{{/pix}} {{#str}}loading, repository{{/str}}</span>
diff --git a/lib/templates/local/notification/cta.mustache b/lib/templates/local/notification/cta.mustache
new file mode 100644 (file)
index 0000000..1af3c1c
--- /dev/null
@@ -0,0 +1,68 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/local/notification/cta
+
+    Moodle cta notification template.
+
+    The purpose of this template is to render a call to action notification.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * message A cleaned string (use clean_text()) to display.
+    * extraclasses Additional classes to apply to the notification.
+    * actions List of action links.
+    * icon An icon.pix and icon.componrnt for the icon to be displauyed as the icon of CTA notification.
+
+    Example context (json):
+    {
+        "message": "What do you think about Moodle?",
+        "actions": [
+            {
+                "title": "Give feedback",
+                "url": "#",
+                "data": [
+                    {"name": "action", "value": "give"},
+                    {"name": "contextid", "value": "3"}
+                ]
+            }
+        ]
+    }
+}}
+
+<div class="cta alert alert-primary alert-block fade in {{ extraclasses }}" {{# region }}data-region="{{ region }}"{{/ region}}>
+    <div class="media">
+        <div class="mr-2 icon-size-5">
+            {{# pix }} {{ icon.pix }}, {{ icon.component }} {{/ pix }}
+        </div>
+        <div class="media-body align-self-center">
+            {{{ message }}}<br>
+            {{# actions }}
+                <a href="{{ url }}" class="link-underline aalink"
+                    {{# data }}
+                   data-{{ name }}="{{ value }}"
+                    {{/ data }}
+                >{{ title }}</a>
+            {{/ actions }}
+        </div>
+    </div>
+</div>
diff --git a/lib/templates/userfeedback_footer_link.mustache b/lib/templates/userfeedback_footer_link.mustache
new file mode 100644 (file)
index 0000000..baca852
--- /dev/null
@@ -0,0 +1,27 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/userfeedback_footer_link
+
+    Adds the give feedback link.
+
+    Example context (json):
+    {}
+}}
+
+{{# pix }} i/bullhorn, core {{/ pix }}
+<a href="{{url}}" target="_blank">{{#str}}calltofeedback_give{{/str}}</a>
diff --git a/lib/tests/external/userfeedback/generate_url_test.php b/lib/tests/external/userfeedback/generate_url_test.php
new file mode 100644 (file)
index 0000000..c205038
--- /dev/null
@@ -0,0 +1,118 @@
+<?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/>.
+
+/**
+ * External functions test for generate_url.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\external\userfeedback;
+
+defined('MOODLE_INTERNAL') || die();
+
+use externallib_advanced_testcase;
+use context_system;
+use context_course;
+use external_api;
+
+global $CFG;
+
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+/**
+ * Class generate_url_testcase
+ *
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass generate_url
+ */
+class generate_url_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Test the behaviour of generate_url().
+     *
+     * @covers ::execute
+     */
+    public function test_record_action_system() {
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $context = context_system::instance();
+
+        $this->setUser($user);
+
+        // Call the WS and check the requested data is returned as expected.
+        $result = generate_url::execute($context->id);
+        $result = external_api::clean_returnvalue(generate_url::execute_returns(), $result);
+
+        $this->assertStringStartsWith('https://feedback.moodle.org/lms', $result);
+        $this->assertStringContainsString('?lang=en', $result);
+        $this->assertStringContainsString('&moodle_url=https%3A%2F%2Fwww.example.com%2Fmoodle', $result);
+        $this->assertStringContainsString('&theme=boost', $result);
+    }
+
+    /**
+     * Test the behaviour of generate_url() in a course with a course theme.
+     *
+     * @covers ::execute
+     */
+    public function test_record_action_course_theme() {
+        $this->resetAfterTest();
+
+        // Enable course themes.
+        set_config('allowcoursethemes', 1);
+
+        $course = $this->getDataGenerator()->create_course(['theme' => 'classic']);
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $context = context_course::instance($course->id);
+
+        $this->setUser($user);
+
+        // Call the WS and check the requested data is returned as expected.
+        $result = generate_url::execute($context->id);
+        $result = external_api::clean_returnvalue(generate_url::execute_returns(), $result);
+
+        $this->assertStringContainsString('&theme=classic', $result);
+    }
+
+    /**
+     * Test the behaviour of generate_url() when a custom feedback url is set.
+     *
+     * @covers ::execute
+     */
+    public function test_record_action_custom_feedback_url() {
+        $this->resetAfterTest();
+
+        // Enable course themes.
+        set_config('userfeedback_url', 'https://feedback.moodle.org/abc');
+
+        $user = $this->getDataGenerator()->create_user();
+        $context = context_system::instance();
+
+        $this->setUser($user);
+
+        // Call the WS and check the requested data is returned as expected.
+        $result = generate_url::execute($context->id);
+        $result = external_api::clean_returnvalue(generate_url::execute_returns(), $result);
+
+        $this->assertStringStartsWith('https://feedback.moodle.org/abc', $result);
+    }
+}
diff --git a/lib/tests/external/userfeedback/record_action_test.php b/lib/tests/external/userfeedback/record_action_test.php
new file mode 100644 (file)
index 0000000..f67cf57
--- /dev/null
@@ -0,0 +1,88 @@
+<?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/>.
+
+/**
+ * External functions test for record_action.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\external\userfeedback;
+
+defined('MOODLE_INTERNAL') || die();
+
+use externallib_advanced_testcase;
+use context_system;
+
+global $CFG;
+
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+/**
+ * Class record_action_testcase
+ *
+ * @copyright  2020 Shamim Rezaie <shamim@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass record_action
+ */
+class record_action_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Data provider for test_record_action.
+     *
+     * @return  array
+     */
+    public function record_action_provider() {
+        return [
+            'give action' => ['give'],
+            'remind action' => ['remind'],
+        ];
+    }
+
+    /**
+     * Test the behaviour of record_action().
+     *
+     * @dataProvider record_action_provider
+     * @param string $action The action taken by the user
+     *
+     * @covers ::execute
+     */
+    public function test_record_action(string $action) {
+        $this->resetAfterTest();
+
+        $context = context_system::instance();
+        $user = $this->getDataGenerator()->create_user();
+        $this->setUser($user);
+        $eventsink = $this->redirectEvents();
+
+        $now = time();
+
+        // Call the WS and check the action is recorded as expected.
+        $result = record_action::execute($action, $context->id);
+        $this->assertNull($result);
+
+        $preference = get_user_preferences('core_userfeedback_' . $action);
+        $this->assertGreaterThanOrEqual($now, $preference);
+
+        $events = $eventsink->get_events();
+        $this->assertCount(1, $events);
+        $this->assertInstanceOf('\core\event\userfeedback_' . $action, $events[0]);
+        $eventsink->clear();
+    }
+}
index b7844a0..6551623 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js and b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js differ
index 596a348..56cfc98 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js and b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js differ
index 49a9120..e896593 100644 (file)
Binary files a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue.js and b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue.js differ
index 95a79e3..7c44355 100644 (file)
@@ -97,8 +97,10 @@ Y.extend(DIALOGUE, Y.Panel, {
         var bb;
 
         if (this.get('closeButton') !== false) {
-            // The buttons constructor does not allow custom attributes
-            this.get('buttons').header[0].setAttribute('title', this.get('closeButtonTitle'));
+            var title = this.get('closeButtonTitle');
+            // The buttons constructor does not allow custom attributes.
+            this.get('buttons').header[0].setAttribute('title', title);
+            this.get('buttons').header[0].setAttribute('aria-label', title);
         }
 
         // Initialise the element cache.
index d207c37..5c728cc 100644 (file)
            href="#"
            title="{{#str}} markallread {{/str}}"
            data-action="mark-all-read"
-           role="button">
-            <span class="normal-icon">{{#pix}} t/markasread, core, {{#str}} markallread {{/str}} {{/pix}}</span>
+           role="button"
+           aria-label="{{#str}} markallread {{/str}}">
+            <span class="normal-icon">{{#pix}} t/markasread, core {{/pix}}</span>
             {{> core/loading }}
         </a>
         <a href="{{{urls.preferences}}}"
-           title="{{#str}} notificationpreferences, message {{/str}}">
-            {{#pix}} i/settings, core, {{#str}} notificationpreferences, message {{/str}} {{/pix}}
+           title="{{#str}} notificationpreferences, message {{/str}}"
+           aria-label="{{#str}} notificationpreferences, message {{/str}}">
+            {{#pix}} i/settings, core {{/pix}}
         </a>
     {{/headeractions}}
 
index f359f34..4acd27d 100644 (file)
     <h3 class="h6 font-weight-bold">{{#str}} privacy, message {{/str}}</h3>
     <p>{{#str}} privacy_desc, message {{/str}}</p>
     <div data-preference="blocknoncontacts" class="mb-3">
-        {{#privacy}}
-            <div class="custom-control custom-radio mb-2">
-                <input
-                    type="radio"
-                    name="message_blocknoncontacts"
-                    class="custom-control-input"
-                    id="block-noncontacts-{{uniqid}}-{{value}}"
-                    value="{{value}}"
-                >
-                <label class="custom-control-label ml-2" for="block-noncontacts-{{uniqid}}-{{value}}">
-                    {{text}}
-                </label>
-            </div>
-        {{/privacy}}
+        <fieldset>
+            <legend class="sr-only">{{#str}} contactableprivacy, message {{/str}}</legend>
+            {{#privacy}}
+                <div class="custom-control custom-radio mb-2">
+                    <input
+                        type="radio"
+                        name="message_blocknoncontacts"
+                        class="custom-control-input"
+                        id="block-noncontacts-{{uniqid}}-{{value}}"
+                        value="{{value}}"
+                    >
+                    <label class="custom-control-label ml-2" for="block-noncontacts-{{uniqid}}-{{value}}">
+                        {{text}}
+                    </label>
+                </div>
+            {{/privacy}}
+        </fieldset>
     </div>
 
     <div class="hidden" data-region="notification-preference-container">
index 65c44fc..f47b39c 100644 (file)
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template core_message/message_drawer
+    @template core_message/message_index
 
     This template will render the message drawer.
 
index a37ab62..633631f 100644 (file)
@@ -150,7 +150,7 @@ class get_results extends external_api {
             'attempt' => $attemptdata->attempt,
             'rawscore' => $attemptdata->rawscore,
             'maxscore' => $attemptdata->maxscore,
-            'duration' => (empty($attemptdata->duration)) ? 0 : $attemptdata->duration,
+            'duration' => (empty($attemptdata->durationvalue)) ? 0 : $attemptdata->durationvalue,
             'scaled' => (empty($attemptdata->scaled)) ? 0 : $attemptdata->scaled,
             'results' => [],
         ];
@@ -257,7 +257,8 @@ class get_results extends external_api {
             'subcontent' => new external_value(PARAM_NOTAGS, 'Subcontent identifier'),
             'timecreated' => new external_value(PARAM_INT, 'Result creation'),
             'interactiontype' => new external_value(PARAM_NOTAGS, 'Interaction type'),
-            'description' => new external_value(PARAM_TEXT, 'Result description'),
+            'description' => new external_value(PARAM_RAW, 'Result description'),
+            'content' => new external_value(PARAM_RAW, 'Result extra content', VALUE_OPTIONAL),
             'rawscore' => new external_value(PARAM_INT, 'Result score value'),
             'maxscore' => new external_value(PARAM_INT, 'Result max score'),
             'duration' => new external_value(PARAM_INT, 'Result duration in seconds', VALUE_OPTIONAL, 0),
@@ -269,10 +270,10 @@ class get_results extends external_api {
             'track' => new external_value(PARAM_BOOL, 'If the result has valid track information', VALUE_OPTIONAL),
             'options' => new external_multiple_structure(
                 new external_single_structure([
-                    'description'    => new external_value(PARAM_TEXT, 'Option description'),
-                    'id' => new external_value(PARAM_INT, 'Option identifier'),
-                    'correctanswer' => self::get_answer_returns('The option correct answer'),
-                    'useranswer' => self::get_answer_returns('The option user answer'),
+                    'description'    => new external_value(PARAM_RAW, 'Option description', VALUE_OPTIONAL),
+                    'id' => new external_value(PARAM_TEXT, 'Option string identifier', VALUE_OPTIONAL),
+                    'correctanswer' => self::get_answer_returns('The option correct answer', VALUE_OPTIONAL),
+                    'useranswer' => self::get_answer_returns('The option user answer', VALUE_OPTIONAL),
                 ]),
                 'The statement options', VALUE_OPTIONAL
             ),
@@ -284,9 +285,10 @@ class get_results extends external_api {
      * Return the external structure of an answer or correctanswer
      *
      * @param string $description the return description
+     * @param int $required the return required value
      * @return external_single_structure
      */
-    private static function get_answer_returns(string $description): external_single_structure {
+    private static function get_answer_returns(string $description, int $required = VALUE_REQUIRED): external_single_structure {
 
         $result = new external_single_structure([
             'answer' => new external_value(PARAM_NOTAGS, 'Option text value', VALUE_OPTIONAL),
@@ -297,7 +299,7 @@ class get_results extends external_api {
             'unchecked' => new external_value(PARAM_BOOL, 'If has to be displayed as a unchecked option', VALUE_OPTIONAL),
             'pass' => new external_value(PARAM_BOOL, 'If has to be displayed as passed', VALUE_OPTIONAL),
             'fail' => new external_value(PARAM_BOOL, 'If has to be displayed as failed', VALUE_OPTIONAL),
-        ], $description);
+        ], $description, $required);
         return $result;
     }
 }
index ac433b4..6168e96 100644 (file)
@@ -96,7 +96,8 @@ class attempt implements renderable, templatable {
             ]),
         ];
         if ($attempt->get_duration() !== null) {
-            $duration = $this->extract_duration($attempt->get_duration());
+            $data->durationvalue = $attempt->get_duration();
+            $duration = $this->extract_duration($data->durationvalue);
             $data->duration = $this->format_duration($duration);
             $data->durationcompact = $this->format_duration_short($duration);
         }
index 286d1ed..f31aec1 100644 (file)
@@ -63,7 +63,7 @@ class sequencing extends result {
                 continue;
             }
             $option = (object)[
-                'id' => true,
+                'id' => 'true',
                 'description' => get_string('result_sequencing_position', 'mod_h5pactivity', $num),
                 'correctanswer' => $this->get_answer(parent::TEXT, $choices[$pattern]->description),
                 'correctanswerid' => 'item_'.$key,
index a16cf1a..aee3b8d 100644 (file)
@@ -47,16 +47,18 @@ class truefalse extends result {
         // This interaction type have only one entry which is the correct option.
         $correctpattern = reset($this->correctpattern);
         $correctpattern = filter_var(reset($correctpattern), FILTER_VALIDATE_BOOLEAN);
+        $correctpattern = $correctpattern ? 'true' : 'false';
 
         $response = filter_var(reset($this->response), FILTER_VALIDATE_BOOLEAN);
+        $response = $response ? 'true' : 'false';
 
         $options = [
             (object)[
-                'id' => true,
+                'id' => 'true',
                 'description' => get_string('true', 'mod_h5pactivity'),
             ],
             (object)[
-                'id' => false,
+                'id' => 'false',
                 'description' => get_string('false', 'mod_h5pactivity'),
             ],
         ];
index 97fef57..b3185ab 100644 (file)
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" preserveAspectRatio="xMinYMid meet"><title>h5p finalArtboard 1</title><rect width="24" height="24" rx="3" ry="3" fill="#0882c8"/><path d="M22.1,8a3.37,3.37,0,0,0-2.42-.77H16.05v2H11.71l-.36,1.46a6.32,6.32,0,0,1,1-.35,3.49,3.49,0,0,1,.86-.06,3.24,3.24,0,0,1,2.35.88,2.93,2.93,0,0,1,.9,2.2A3.72,3.72,0,0,1,16,15.19a3.16,3.16,0,0,1-1.31,1.32,3.41,3.41,0,0,1-.67.27H17.7V13.28h1.65A3.8,3.8,0,0,0,22,12.46a3,3,0,0,0,.88-2.28A2.9,2.9,0,0,0,22.1,8Zm-2.44,3a1.88,1.88,0,0,1-1.21.29H17.7V9.2h.87a1.56,1.56,0,0,1,1.13.31,1,1,0,0,1,.3.76A.94.94,0,0,1,19.66,11Z" fill="#fff"/><path d="M12.27,12.05a1.33,1.33,0,0,0-1.19.74l-2.6-.37,1.17-5.2H7.29v4.08H4V7.23H1.1v9.55H4V13.28H7.29v3.49h3.57a3.61,3.61,0,0,1-1.13-.53A3.2,3.2,0,0,1,9,15.43a4,4,0,0,1-.48-1.09L11.09,14a1.32,1.32,0,1,0,1.18-1.92Z" fill="#fff"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><title>h5p</title><rect width="24" height="24" rx="3" ry="3" fill="#0882c8"/><path d="M22.1,8a3.37,3.37,0,0,0-2.42-.77H16.05v2H11.71l-.36,1.46a6.33,6.33,0,0,1,1-.35,3.49,3.49,0,0,1,.86-.06,3.24,3.24,0,0,1,2.35.88,2.93,2.93,0,0,1,.9,2.2A3.72,3.72,0,0,1,16,15.19a3.16,3.16,0,0,1-1.31,1.32,3.41,3.41,0,0,1-.67.27H17.7V13.28h1.65A3.8,3.8,0,0,0,22,12.46a3,3,0,0,0,.88-2.28A2.9,2.9,0,0,0,22.1,8Zm-2.44,3a1.88,1.88,0,0,1-1.21.29H17.7V9.2h.87a1.56,1.56,0,0,1,1.13.31,1,1,0,0,1,.3.76A.94.94,0,0,1,19.66,11Zm-6.07,2.36a1.32,1.32,0,0,1-2.5.59l-2.61.38A4,4,0,0,0,9,15.43a3.2,3.2,0,0,0,.77.81,3.61,3.61,0,0,0,1.13.53H7.29V13.28H4v3.49H1.1V7.23H4v4.08H7.29V7.23H9.65l-1.17,5.2,2.6.37a1.32,1.32,0,0,1,2.51.59Z" fill="#fff"/></svg>
\ No newline at end of file
index 6bfe3b6..308fe0d 100644 (file)
@@ -25,5 +25,5 @@
 defined('MOODLE_INTERNAL') || die();
 
 $plugin->component = 'mod_h5pactivity';
-$plugin->version = 2020052100;
+$plugin->version = 2020052900;
 $plugin->requires = 2020013000;
index 6da25d5..3ae0440 100644 (file)
@@ -57,6 +57,9 @@ $cm = get_coursemodule_from_id('lti', $id, 0, false, MUST_EXIST);
 $lti = $DB->get_record('lti', array('id' => $cm->instance), '*', MUST_EXIST);
 
 $typeid = $lti->typeid;
+if (empty($typeid) && ($tool = lti_get_tool_by_url_match($lti->toolurl))) {
+    $typeid = $tool->id;
+}
 if ($typeid) {
     $config = lti_get_type_type_config($typeid);
     if ($config->lti_ltiversion === LTI_VERSION_1P3) {
index 95f5059..ff43403 100644 (file)
@@ -613,9 +613,9 @@ function lti_get_launch_data($instance, $nonce = '') {
 
     $launchcontainer = lti_get_launch_container($instance, $typeconfig);
     $returnurlparams = array('course' => $course->id,
-                             'launch_container' => $launchcontainer,
-                             'instanceid' => $instance->id,
-                             'sesskey' => sesskey());
+        'launch_container' => $launchcontainer,
+        'instanceid' => $instance->id,
+        'sesskey' => sesskey());
 
     // Add the return URL. We send the launch container along to help us avoid frames-within-frames when the user returns.
     $url = new \moodle_url('/mod/lti/return.php', $returnurlparams);
@@ -1185,7 +1185,7 @@ function lti_build_content_item_selection_request($id, $course, moodle_url $retu
         $services = lti_get_services();
         foreach ($services as $service) {
             $serviceparameters = $service->get_launch_parameters('ContentItemSelectionRequest',
-                    $course->id, $USER->id , $id);
+                $course->id, $USER->id , $id);
             foreach ($serviceparameters as $paramkey => $paramvalue) {
                 $requestparams['custom_' . $paramkey] = lti_parse_custom_parameter($toolproxy, $tool, $requestparams, $paramvalue,
                     $islti2);
index 7d39b59..b11d19e 100644 (file)
@@ -213,25 +213,24 @@ class restore_ltiservice_gradebookservices_subplugin extends restore_subplugin {
                 $DB->update_record('ltiservice_gradebookservices', $gbs);
             }
         }
-        // Pre 3.9 backups did not include a gradebookservices record. We create one here if idnumber is set.
-        $gradeitems = $DB->get_records('grade_items', array('itemtype' => 'mod', 'itemmodule' => 'lti', 'courseid' => $courseid));
-        foreach ($gradeitems as $gi) {
-            if (isset($gi->idnumber) && !empty(trim($gi->idnumber))) {
-                $gbs = $DB->get_records('ltiservice_gradebookservices', ['gradeitemid' => $gi->id]);
-                if (empty($gbs)  && !empty($gi->iteminstance)) {
-                    // We did not find an entry for an LTI grade item with an idnumber, so let's create a gbs entry.
-                    if ($instance = $DB->get_record('lti', array('id' => $gi->iteminstance))) {
-                        if ($tool = lti_get_instance_type($instance)) {
-                            $DB->insert_record('ltiservice_gradebookservices', (object) array(
-                                'gradeitemid' => $gi->id,
-                                'courseid' => $courseid,
-                                'toolproxyid' => $tool->toolproxyid,
-                                'ltilinkid' => $gi->iteminstance,
-                                'typeid' => $tool->id,
-                                'baseurl' => $tool->baseurl,
-                                'resourceid' => $gi->idnumber
-                            ));
-                        }
+        // Pre 3.9 backups did not include a gradebookservices record. Adding one here if missing for the restored instance.
+        $gi = $DB->get_record('grade_items', array('itemtype' => 'mod', 'itemmodule' => 'lti', 'courseid' => $courseid,
+            'iteminstance' => $this->task->get_activityid()));
+        if ($gi) {
+            $gbs = $DB->get_records('ltiservice_gradebookservices', ['gradeitemid' => $gi->id]);
+            if (empty($gbs)) {
+                // The currently restored LTI link has a grade item but no gbs, so let's create a gbs entry.
+                if ($instance = $DB->get_record('lti', array('id' => $gi->iteminstance))) {
+                    if ($tool = lti_get_instance_type($instance)) {
+                        $DB->insert_record('ltiservice_gradebookservices', (object) array(
+                            'gradeitemid' => $gi->id,
+                            'courseid' => $courseid,
+                            'toolproxyid' => $tool->toolproxyid,
+                            'ltilinkid' => $gi->iteminstance,
+                            'typeid' => $tool->id,
+                            'baseurl' => $tool->baseurl,
+                            'resourceid' => $gi->idnumber
+                        ));
                     }
                 }
             }
index 8595853..7427ca4 100644 (file)
@@ -127,7 +127,7 @@ class lineitem extends resource_base {
 
         $response->set_content_type($this->formats[0]);
         $lineitem = gradebookservices::item_for_json($item, substr(parent::get_endpoint(),
-                0, strrpos(parent::get_endpoint(), "/", -10)), $typeid);
+            0, strrpos(parent::get_endpoint(), "/", -10)), $typeid);
         $response->set_body(json_encode($lineitem));
 
     }
index 86deee9..5fd278c 100644 (file)
@@ -89,7 +89,7 @@ class lineitems extends resource_base {
             $typeid = $this->get_service()->get_type()->id;
             if (empty($contextid) || !($container ^ ($response->get_request_method() === self::HTTP_POST)) ||
                     (!empty($contenttype) && !in_array($contenttype, $this->formats))) {
-                    throw new \Exception('No context or unsupported content type', 400);
+                throw new \Exception('No context or unsupported content type', 400);
             }
             if (!($course = $DB->get_record('course', array('id' => $contextid), 'id', IGNORE_MISSING))) {
                 throw new \Exception("Not Found: Course {$contextid} doesn't exist", 404);
@@ -267,16 +267,8 @@ class lineitems extends resource_base {
             $baseurl = lti_get_type_type_config($typeid)->lti_toolurl;
         }
         $gradebookservices = new gradebookservices();
-        $id = $gradebookservices->add_standalone_lineitem($contextid,
-                                                         $json->label,
-                                                         $max,
-                                                         $baseurl,
-                                                         $ltilinkid,
-                                                         $resourceid,
-                                                         $tag,
-                                                         $typeid,
-                                                         $toolproxyid);
-
+        $id = $gradebookservices->add_standalone_lineitem($contextid, $json->label,
+            $max, $baseurl, $ltilinkid, $resourceid, $tag, $typeid, $toolproxyid);
         if (is_null($typeid)) {
             $json->id = parent::get_endpoint() . "/{$id}/lineitem";
         } else {
index 43039f4..9d35849 100644 (file)
@@ -155,7 +155,7 @@ class gradebookservices extends service_base {
         // Only inject parameters if the service is enabled for this tool.
         if (isset($this->get_typeconfig()['ltiservice_gradesynchronization'])) {
             if ($this->get_typeconfig()['ltiservice_gradesynchronization'] == self::GRADEBOOKSERVICES_READ ||
-                $this->get_typeconfig()['ltiservice_gradesynchronization'] == self::GRADEBOOKSERVICES_FULL) {
+                    $this->get_typeconfig()['ltiservice_gradesynchronization'] == self::GRADEBOOKSERVICES_FULL) {
                 // Check for used in context is only needed because there is no explicit site tool - course relation.
                 if ($this->is_allowed_in_context($typeid, $courseid)) {
                     $id = null;
@@ -235,12 +235,10 @@ class gradebookservices extends service_base {
                             array_push($lineitemstoreturn, $lineitem);
                         }
                     }
-                } else if (($lineitem->itemtype == 'mod'
-                             && $lineitem->itemmodule == 'lti'
-                             && !isset($resourceid)
-                             && !isset($tag)
-                             && (!isset($ltilinkid) || (isset($ltilinkid)
-                             && $lineitem->iteminstance == $ltilinkid)))) {
+                } else if (($lineitem->itemtype == 'mod' && $lineitem->itemmodule == 'lti'
+                        && !isset($resourceid) && !isset($tag)
+                        && (!isset($ltilinkid) || (isset($ltilinkid)
+                        && $lineitem->iteminstance == $ltilinkid)))) {
                     // We will need to check if the activity related belongs to our tool proxy.
                     $ltiactivity = $DB->get_record('lti', array('id' => $lineitem->iteminstance));
                     if (($ltiactivity) && (isset($ltiactivity->typeid))) {
@@ -343,15 +341,9 @@ class gradebookservices extends service_base {
      *
      * @return int id of the created gradeitem
      */
-    public function add_standalone_lineitem(string $courseid,
-                                            string $label,
-                                            float $maximumscore,
-                                            string $baseurl,
-                                            ?int $ltilinkid,
-                                            ?string $resourceid,
-                                            ?string $tag,
-                                            int $typeid,
-                                            int $toolproxyid = null) : int {
+    public function add_standalone_lineitem(string $courseid, string $label, float $maximumscore,
+            string $baseurl, ?int $ltilinkid, ?string $resourceid, ?string $tag, int $typeid,
+            int $toolproxyid = null) : int {
         global $DB;
         $params = array();
         $params['itemname'] = $label;
@@ -428,7 +420,7 @@ class gradebookservices extends service_base {
         }
         $feedbackformat = FORMAT_MOODLE;
         $feedback = null;
-        if (isset($score->comment) && !empty($score->comment)) {
+        if (!empty($score->comment)) {
             $feedback = $score->comment;
             $feedbackformat = FORMAT_PLAIN;
         }
@@ -447,9 +439,8 @@ class gradebookservices extends service_base {
             $grade->feedback = $feedback;
             $grade->rawgrade = $finalgrade;
             $status = grade_update($source, $gradeitem->courseid,
-                         $gradeitem->itemtype, $gradeitem->itemmodule,
-                         $gradeitem->iteminstance, $gradeitem->itemnumber,
-                         $grade);
+                $gradeitem->itemtype, $gradeitem->itemmodule,
+                $gradeitem->iteminstance, $gradeitem->itemnumber, $grade);
 
             $result = ($status == GRADE_UPDATE_OK);
         }
@@ -621,9 +612,7 @@ class gradebookservices extends service_base {
      * @param string|null $tag The tag to apply to the lineitem. If empty string which will be stored as null.
      *
      */
-    public static function update_coupled_gradebookservices(object $ltiinstance,
-                                                            ?string $resourceid,
-                                                            ?string $tag) : void {
+    public static function update_coupled_gradebookservices(object $ltiinstance, ?string $resourceid, ?string $tag) : void {
         global $DB;
 
         if ($ltiinstance && $ltiinstance->typeid) {
index d97c233..da713e9 100644 (file)
@@ -160,12 +160,8 @@ class mod_lti_gradebookservices_testcase extends advanced_testcase {
      * @param string|null $resourceid resourceid the line item should have
      * @param string|null $tag tag the line item should have
      */
-    private function assert_lineitems(object $course,
-                                      int $typeid,
-                                      string $label,
-                                      ?object $ltiinstance,
-                                      ?string $resourceid,
-                                      ?string $tag) : void {
+    private function assert_lineitems(object $course, int $typeid,
+            string $label, ?object $ltiinstance, ?string $resourceid, ?string $tag) : void {
         $gbservice = new gradebookservices();
         $gradeitems = $gbservice->get_lineitems($course->id, null, null, null, null, null, $typeid);
 
@@ -211,11 +207,11 @@ class mod_lti_gradebookservices_testcase extends advanced_testcase {
     private function create_graded_lti(int $typeid, object $course, ?string $resourceid, ?string $tag) : object {
 
         $lti = ['course' => $course->id,
-                'typeid' => $typeid,
-                'instructorchoiceacceptgrades' => LTI_SETTING_ALWAYS,
-                'grade' => 10,
-                'lineitemresourceid' => $resourceid,
-                'lineitemtag' => $tag];
+            'typeid' => $typeid,
+            'instructorchoiceacceptgrades' => LTI_SETTING_ALWAYS,
+            'grade' => 10,
+            'lineitemresourceid' => $resourceid,
+            'lineitemtag' => $tag];
 
         return $this->getDataGenerator()->create_module('lti', $lti, array());
     }
@@ -231,8 +227,8 @@ class mod_lti_gradebookservices_testcase extends advanced_testcase {
     private function create_notgraded_lti(int $typeid, object $course) : object {
 
         $lti = ['course' => $course->id,
-                'typeid' => $typeid,
-                'instructorchoiceacceptgrades' => LTI_SETTING_NEVER];
+            'typeid' => $typeid,
+            'instructorchoiceacceptgrades' => LTI_SETTING_NEVER];
 
         return $this->getDataGenerator()->create_module('lti', $lti, array());
     }
@@ -247,11 +243,8 @@ class mod_lti_gradebookservices_testcase extends advanced_testcase {
      * @param int|null $ltiinstanceid Id of the LTI instance the standalone line item will be related to.
      *
      */
-    private function create_standalone_lineitem(int $courseid,
-                                                int $typeid,
-                                                ?string $resourceid,
-                                                ?string $tag,
-                                                int $ltiinstanceid = null) : void {
+    private function create_standalone_lineitem(int $courseid, int $typeid, ?string $resourceid,
+            ?string $tag, int $ltiinstanceid = null) : void {
         $gbservice = new gradebookservices();
         $gbservice->add_standalone_lineitem($courseid,
             "manualtest",
index 06bc452..5bdceb5 100644 (file)
@@ -66,10 +66,12 @@ if ($l) {  // Two ways to specify the module.
 
 $course = $DB->get_record('course', array('id' => $cm->course), '*', MUST_EXIST);
 
-if (!empty($lti->typeid)) {
-    $toolconfig = lti_get_type_config($lti->typeid);
-} else if ($tool = lti_get_tool_by_url_match($lti->toolurl)) {
-    $toolconfig = lti_get_type_config($tool->id);
+$typeid = $lti->typeid;
+if (empty($typeid) && ($tool = lti_get_tool_by_url_match($lti->toolurl))) {
+    $typeid = $tool->id;
+}
+if ($typeid) {
+    $toolconfig = lti_get_type_config($typeid);
 } else {
     $toolconfig = array();
 }
@@ -116,7 +118,6 @@ if ($lti->showdescriptionlaunch && $lti->intro) {
     echo $OUTPUT->box(format_module_intro('lti', $lti, $cm->id), 'generalbox description', 'intro');
 }
 
-$typeid = $lti->typeid;
 if ($typeid) {
     $config = lti_get_type_type_config($typeid);
 } else {
index d6b2c44..9814c6b 100644 (file)
@@ -64,15 +64,24 @@ class edit_renderer extends \plugin_renderer_base {
         $output .= $this->quiz_state_warnings($structure);
 
         $output .= html_writer::start_div('mod_quiz-edit-top-controls');
+
+        $output .= html_writer::start_div('d-flex justify-content-between flex-wrap mb-1');
+        $output .= html_writer::start_div('d-flex flex-column justify-content-around');
         $output .= $this->quiz_information($structure);
+        $output .= html_writer::end_tag('div');
         $output .= $this->maximum_grade_input($structure, $pageurl);
+        $output .= html_writer::end_tag('div');
 
+        $output .= html_writer::start_div('d-flex justify-content-between flex-wrap mb-1');
         $output .= html_writer::start_div('mod_quiz-edit-action-buttons btn-group edit-toolbar', ['role' => 'group']);
         $output .= $this->repaginate_button($structure, $pageurl);
         $output .= $this->selectmultiple_button($structure);
         $output .= html_writer::end_tag('div');
 
+        $output .= html_writer::start_div('d-flex flex-column justify-content-around');
         $output .= $this->total_marks($quizobj->get_quiz());
+        $output .= html_writer::end_tag('div');
+        $output .= html_writer::end_tag('div');
 
         $output .= $this->selectmultiple_controls($structure);
         $output .= html_writer::end_tag('div');
@@ -204,7 +213,7 @@ class edit_renderer extends \plugin_renderer_base {
             'name'  => 'repaginate',
             'id'    => 'repaginatecommand',
             'value' => get_string('repaginatecommand', 'quiz'),
-            'class' => 'btn btn-secondary mb-1',
+            'class' => 'btn btn-secondary',
             'data-header' => $header,
             'data-form'   => $form,
         );
@@ -229,7 +238,7 @@ class edit_renderer extends \plugin_renderer_base {
             'name'  => 'selectmultiple',
             'id'    => 'selectmultiplecommand',
             'value' => get_string('selectmultipleitems', 'quiz'),
-            'class' => 'btn btn-secondary mb-1'
+            'class' => 'btn btn-secondary'
         );
         if (!$structure->can_be_edited()) {
             $buttonoptions['disabled'] = 'disabled';
index 20a3811..474e202 100644 (file)
@@ -617,10 +617,6 @@ table.quizreviewsummary td.cell {
 }
 
 /** Mod quiz edit **/
-#page-mod-quiz-edit .statusbar {
-    margin: 0.6em 0.4em;
-}
-
 #page-mod-quiz-edit .statusdisplay {
     background-color: #ffc;
     clear: both;
@@ -640,16 +636,6 @@ table.quizreviewsummary td.cell {
     min-height: 2.85em;
 }
 
-@media (min-width: 576px) {
-    #page-mod-quiz-edit .maxgrade,
-    #page-mod-quiz-edit .totalpoints {
-        position: absolute;
-        right: 0;
-        margin: -2.85em 0 0;
-        padding: .2em;
-    }
-}
-
 @media (max-width: 576px) {
     #page-mod-quiz-edit .maxgrade {
         margin-bottom: 0.6em;
@@ -889,15 +875,13 @@ table.quizreviewsummary td.cell {
 }
 
 #page-mod-quiz-edit ul.slots li.section li.activity .activityinstance {
-    display: block;
+    display: flex;
+    flex: 1 1 auto;
     min-height: 1.7em;
-    position: absolute;
-    top: 0;
-    left: 5em;
-    width: 100%;
 }
 
 #page-mod-quiz-edit ul.slots li.section li.activity .mod-indent-outer {
+    display: flex;
     padding-left: 22px;
 }
 
@@ -914,7 +898,6 @@ table.quizreviewsummary td.cell {
     white-space: nowrap;
     text-overflow: ellipsis;
     overflow: hidden;
-    width: 70%;
     display: inline-block;
     height: 20px;
 }
@@ -927,6 +910,9 @@ table.quizreviewsummary td.cell {
 #page-mod-quiz-edit ul.slots li.activity div.activityinstance .questionname {
     font-weight: bold;
     color: #555;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
 }
 
 #page-mod-quiz-edit ul.slots li.activity div.activityinstance .questiontext {
@@ -941,6 +927,10 @@ table.quizreviewsummary td.cell {
 
 #page-mod-quiz-edit ul.slots li.activity div.activityinstance .mod_quiz_random_qbank_link {
     font-size: 0.8em;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    margin-left: 0.25rem;
 }
 
 #page-mod-quiz-edit ul.slots .activityinstance img.activityicon {
@@ -950,6 +940,7 @@ table.quizreviewsummary td.cell {
 }
 
 #page-mod-quiz-edit .section .activity .actions {
+    position: inherit;
     white-space: nowrap;
     background: #e6e6e6;
     padding: 0.1em 0;
@@ -1219,8 +1210,10 @@ table#categoryquestions {
     #page-mod-quiz-edit ul.slots li.section li.activity .activityinstance {
         top: -30px;
         left: 0;
-    }
-    #page-mod-quiz-edit ul.slots .activityinstance span.instancename {
+        padding-right: 0;
+        overflow: hidden;
+        align-items: center;
+        position: absolute;
         width: 100%;
     }
 }
index 7e3957e..9722450 100644 (file)
@@ -163,6 +163,10 @@ if (empty($CFG->forcedefaultmymoodle) && $PAGE->user_allowed_editing()) {
 
 echo $OUTPUT->header();
 
+if (core_userfeedback::should_display_reminder()) {
+    core_userfeedback::print_reminder_block();
+}
+
 echo $OUTPUT->custom_block_region('content');
 
 echo $OUTPUT->footer();
diff --git a/pix/i/bullhorn.svg b/pix/i/bullhorn.svg
new file mode 100644 (file)
index 0000000..3c54a7e
--- /dev/null
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg width="1792" height="1792" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1664 640q53 0 90.5 37.5t37.5 90.5-37.5 90.5-90.5 37.5v384q0 52-38 90t-90 38q-417-347-812-380-58 19-91 66t-31 100.5 40 92.5q-20 33-23 65.5t6 58 33.5 55 48 50 61.5 50.5q-29 58-111.5 83t-168.5 11.5-132-55.5q-7-23-29.5-87.5t-32-94.5-23-89-15-101 3.5-98.5 22-110.5h-122q-66 0-113-47t-47-113v-192q0-66 47-113t113-47h480q435 0 896-384 52 0 90 38t38 90v384zm-128 604v-954q-394 302-768 343v270q377 42 768 341z" fill="#999"/></svg>
\ No newline at end of file
index fd0340d..ba9d231 100644 (file)
@@ -286,7 +286,6 @@ class moodle_content_writer implements content_writer {
         // This weird code is to look for a subcontext that contains a number and append an '_' to the front.
         // This is because there seems to be some weird problem with array_merge_recursive used in finalise_content().
         $subcontext = array_map(function($data) {
-            $data = clean_param($data, PARAM_PATH);
             if (stripos($data, DIRECTORY_SEPARATOR) !== false) {
                 $newpath = explode(DIRECTORY_SEPARATOR, $data);
                 $newpath = array_map(function($value) {
@@ -295,11 +294,18 @@ class moodle_content_writer implements content_writer {
                     }
                     return $value;
                 }, $newpath);
-                return implode(DIRECTORY_SEPARATOR, $newpath);
+                $data = implode(DIRECTORY_SEPARATOR, $newpath);
             } else if (is_numeric($data)) {
                 $data = '_' . $data;
             }
-            return $data;
+            // Because clean_param() normalises separators to forward-slashes
+            // and because there is code DIRECTORY_SEPARATOR dependent after
+            // this array_map(), we ensure we get the original separator.
+            // Note that maybe we could leave the clean_param() alone, but
+            // surely that means that the DIRECTORY_SEPARATOR dependent
+            // code is not needed at all. So better keep existing behavior
+            // until this is revisited.
+            return str_replace('/', DIRECTORY_SEPARATOR, clean_param($data, PARAM_PATH));
         }, $subcontext);
 
         // Combine the context path, and the subcontext data.
index e7c4c4b..ad9644f 100644 (file)
@@ -260,13 +260,18 @@ YUI.add('moodle-core_filepicker', function(Y) {
                 // manually call dynload for parent elements in the tree so we can load other siblings
                 if (options.dynload) {
                     var root = scope.treeview.getRoot();
+                    // Whether search results are currently displayed in the active repository in the filepicker.
+                    // We do not want to load siblings of parent elements when displaying search tree results.
+                    var isSearchResult = typeof options.callbackcontext.active_repo !== 'undefined' &&
+                        options.callbackcontext.active_repo.issearchresult;
                     while (root && root.children && root.children.length) {
                         root = root.children[0];
                         if (root.path == mytreeel.path) {
                             root.origpath = options.filepath;
                             root.origlist = fileslist;
+                        } else if (!root.isLeaf && root.expanded && !isSearchResult) {
+                            Y.bind(options.treeview_dynload, options.callbackcontext)(root, null);
                         }
-                        // Removed bind as of MDL-62415 as it overwrites the search tree results
                     }
                 }
             } else {
index 2742d57..76c4359 100644 (file)
@@ -19,7 +19,7 @@ Feature: A selected file can be cancelled
       | Name | Folder name |
       | Description | Folder description |
     And I upload "lib/tests/fixtures/upload_users.csv" file to "Files" filemanager
-    And I click on "//label[contains(., 'Files')]/ancestor::div[contains(concat(' ', @class, ' '), ' fitem ')]//*[contains(@title, 'Add...')]" "xpath_element"
+    And I click on "Add..." "button" in the "Files" "form_row"
     And I click on "Recent files" "link" in the ".fp-repo-area" "css_element"
     And I click on "//a[contains(concat(' ', normalize-space(@class), ' '), ' fp-file ')][normalize-space(.)='empty.txt']" "xpath_element"
     And I click on ".moodle-dialogue-focused .fp-select .fp-select-cancel" "css_element"
index e7cd4eb..43bce52 100644 (file)
@@ -21,7 +21,7 @@ Feature: Select file feature
     And I click on "Save and display" "button"
     And I follow "Dashboard" in the user menu
     And I follow "Manage private files"
-    And I click on "//label[contains(., 'Files')]/ancestor::div[contains(concat(' ', @class, ' '), ' fitem ')]//*[contains(@title, 'Add...')]" "xpath_element"
+    And I click on "Add..." "button" in the "Files" "form_row"
     And I click on "Recent files" "link" in the ".fp-repo-area" "css_element"
     And I click on "Display folder with file icons" "link" in the ".file-picker" "css_element"
     And I click on "//a[contains(concat(' ', normalize-space(@class), ' '), ' fp-file ')][normalize-space(.)='empty.txt']" "xpath_element"
@@ -42,7 +42,7 @@ Feature: Select file feature
     And I click on "Save and display" "button"
     And I follow "Dashboard" in the user menu
     And I follow "Manage private files"
-    And I click on "//label[contains(., 'Files')]/ancestor::div[contains(concat(' ', @class, ' '), ' fitem ')]//*[contains(@title, 'Add...')]" "xpath_element"
+    And I click on "Add..." "button" in the "Files" "form_row"
     And I click on "Recent files" "link" in the ".fp-repo-area" "css_element"
     And I click on "Display folder with file details" "link" in the ".file-picker" "css_element"
     And I click on "//div[contains(concat(' ', normalize-space(@class), ' '), ' file-picker ')]/descendant::span[normalize-space(.)='empty.txt']/ancestor::a" "xpath_element"
@@ -63,7 +63,7 @@ Feature: Select file feature
     And I click on "Save and display" "button"
     And I follow "Dashboard" in the user menu
     And I follow "Manage private files"
-    And I click on "//label[contains(., 'Files')]/ancestor::div[contains(concat(' ', @class, ' '), ' fitem ')]//*[contains(@title, 'Add...')]" "xpath_element"
+    And I click on "Add..." "button" in the "Files" "form_row"
     And I click on "Recent files" "link" in the ".fp-repo-area" "css_element"
     And I click on "Display folder as file tree" "link" in the ".file-picker" "css_element"
     And I click on "//div[contains(concat(' ', normalize-space(@class), ' '), ' file-picker ')]/descendant::span[normalize-space(.)='empty.txt']/ancestor::a" "xpath_element"
index 74d6abf..52fa09b 100644 (file)
@@ -209,10 +209,8 @@ class behat_repository_upload extends behat_base {
             $filepickerelement = behat_context_helper::escape($filepickerelement);
             $filepickercontainer = $this->find(
                     'xpath',
-                    "//input[./@id = //label[normalize-space(.)=$filepickerelement]/@for]" .
-                    "//ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' felement ')] |" .
-                    "//input[./@id = //label[normalize-space(.)=$filepickerelement]/@for]" .
-                    "//ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' form-setting ')]",
+                    "//input[./@id = substring-before(//p[normalize-space(.)=$filepickerelement]/@id, '_label')]" .
+                    "//ancestor::*[@data-fieldtype = 'filemanager' or @data-fieldtype = 'filepicker']",
                     $exception
             );
         }
index ca24dd7..baacba5 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js and b/theme/boost/amd/build/loader.min.js differ
index bf9bf8d..bcde39c 100644 (file)
Binary files a/theme/boost/amd/build/loader.min.js.map and b/theme/boost/amd/build/loader.min.js.map differ
index d231b81..3630be6 100644 (file)
Binary files a/theme/boost/amd/build/pending.min.js and b/theme/boost/amd/build/pending.min.js differ
index 74549e2..09b04e7 100644 (file)
Binary files a/theme/boost/amd/build/pending.min.js.map and b/theme/boost/amd/build/pending.min.js.map differ
index 6d5f9d8..a050c6f 100644 (file)
 
 import $ from 'jquery';
 import Aria from './aria';
-import Scroll from './scroll';
 import Bootstrap from './bootstrap/index';
-import CustomEvents from 'core/custom_interaction_events';
-
-/**
- * Set up the search.
- *
- * @method init
- */
-export {
-    init,
-    Bootstrap
-};
-
-/**
- * Bootstrap init function
- */
-const init = () => {
-    rememberTabs();
-
-    enablePopovers();
-
-    enableTooltips();
-
-    const scroll = new Scroll();
-    scroll.init();
-
-    // Disables flipping the dropdowns up and getting hidden behind the navbar.
-    $.fn.dropdown.Constructor.Default.flip = false;
-
-    Aria.init();
-};
+import Pending from 'core/pending';
+import Scroll from './scroll';
+import setupBootstrapPendingChecks from './pending';
 
 /**
  * Rember the last visited tabs.
@@ -83,17 +55,15 @@ const rememberTabs = () => {
  */
 const enablePopovers = () => {
     $('body').popover({
+        container: 'body',
         selector: '[data-toggle="popover"]',
-        trigger: 'focus hover',
-        placement: 'auto'
+        trigger: 'focus',
     });
 
-    CustomEvents.define($('body'), [
-        CustomEvents.events.escape,
-    ]);
-    $('body').on(CustomEvents.events.escape, '[data-toggle=popover]', function() {
-
-        $(this).trigger('blur');
+    document.addEventListener('keydown', e => {
+        if (e.key === 'Escape' && e.target.closest('[data-toggle="popover"]')) {
+            $(e.target).popover('hide');
+        }
     });
 };
 
@@ -104,6 +74,35 @@ const enablePopovers = () => {
 const enableTooltips = () => {
     $('body').tooltip({
         container: 'body',
-        selector: '[data-toggle="tooltip"]'
+        selector: '[data-toggle="tooltip"]',
     });
-};
\ No newline at end of file
+};
+
+const pendingPromise = new Pending('theme_boost/loader:init');
+
+// Add pending promise event listeners to relevant Bootstrap custom events.
+setupBootstrapPendingChecks();
+
+// Remember the last visited tabs.
+rememberTabs();
+
+// Enable all popovers.
+enablePopovers();
+
+// Enable all tooltips.
+enableTooltips();
+
+// Add scroll handling.
+(new Scroll()).init();
+
+// Disables flipping the dropdowns up and getting hidden behind the navbar.
+$.fn.dropdown.Constructor.Default.flip = false;
+
+// Setup Aria helpers for Bootstrap features.
+Aria.init();
+
+pendingPromise.resolve();
+
+export {
+    Bootstrap,
+};