Merge branch 'MDL-68905-master' of git://github.com/sarjona/moodle
authorVíctor Déniz Falcón <victor@moodle.com>
Thu, 4 Jun 2020 15:22:10 +0000 (16:22 +0100)
committerVíctor Déniz Falcón <victor@moodle.com>
Thu, 4 Jun 2020 15:22:10 +0000 (16:22 +0100)
143 files changed:
admin/index.php
admin/renderer.php
admin/settings/top.php
admin/settings/userfeedback.php [new file with mode: 0644]
backup/moodle2/restore_qtype_plugin.class.php
backup/tests/quiz_restore_decode_links_test.php [new file with mode: 0644]
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/lib.php [new file with mode: 0644]
contentbank/templates/bankcontent.mustache
contentbank/templates/bankcontent/toolbar.mustache
contentbank/tests/behat/edit_content.feature
contentbank/tests/behat/view_preferences.feature [new file with mode: 0644]
contentbank/tests/privacy_test.php
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
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/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/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/grade/grade_item.php
lib/grade/tests/grade_item_test.php
lib/outputcomponents.php
lib/outputrenderers.php
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/src/dynamic.js
lib/table/classes/local/filter/filter.php
lib/table/classes/local/filter/filterset.php
lib/templates/campaign_content.mustache [new file with mode: 0644]
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]
message/output/popup/templates/notification_popover.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
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/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/style/moodle.css
theme/classic/templates/columns.mustache
theme/classic/templates/contentonly.mustache
theme/classic/templates/secure.mustache
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 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 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 93b512b..5549392 100644 (file)
@@ -97,6 +97,7 @@ 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) {
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"
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 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 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 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:';
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);
+        }
+    }
 }
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 1a9ae7f..7f1bf35 100644 (file)
@@ -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 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 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 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 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;
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}}
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 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 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 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,
+};
index 2e25c81..4b3a548 100644 (file)
  * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery'], function($) {
-    var moduleTransitions = {
-        alert: [
-            // Alert.
-            {
-                start: 'close',
-                end: 'closed',
-            },
-        ],
 
-        carousel: [
-            {
-                start: 'slide',
-                end: 'slid',
-            },
-        ],
+import jQuery from 'jquery';
+const moduleTransitions = {
+    alert: [
+        // Alert.
+        {
+            start: 'close',
+            end: 'closed',
+        },
+    ],
 
-        collapse: [
-            {
-                start: 'hide',
-                end: 'hidden',
-            },
-            {
-                start: 'show',
-                end: 'shown',
-            },
-        ],
+    carousel: [
+        {
+            start: 'slide',
+            end: 'slid',
+        },
+    ],
 
-        dropdown: [
-            {
-                start: 'hide',
-                end: 'hidden',
-            },
-            {
-                start: 'show',
-                end: 'shown',
-            },
-        ],
+    collapse: [
+        {
+            start: 'hide',
+            end: 'hidden',
+        },
+        {
+            start: 'show',
+            end: 'shown',
+        },
+    ],
 
-        modal: [
-            {
-                start: 'hide',
-                end: 'hidden',
-            },
-            {
-                start: 'show',
-                end: 'shown',
-            },
-        ],
+    dropdown: [
+        {
+            start: 'hide',
+            end: 'hidden',
+        },
+        {
+            start: 'show',
+            end: 'shown',
+        },
+    ],
 
-        popover: [
-            {
-                start: 'hide',
-                end: 'hidden',
-            },
-            {
-                start: 'show',
-                end: 'shown',
-            },
-        ],
+    modal: [
+        {
+            start: 'hide',
+            end: 'hidden',
+        },
+        {
+            start: 'show',
+            end: 'shown',
+        },
+    ],
 
-        tab: [
-            {
-                start: 'hide',
-                end: 'hidden',
-            },
-            {
-                start: 'show',
-                end: 'shown',
-            },
-        ],
+    popover: [
+        {
+            start: 'hide',
+            end: 'hidden',
+        },
+        {
+            start: 'show',
+            end: 'shown',
+        },
+    ],
 
-        toast: [
-            {
-                start: 'hide',
-                end: 'hidden',
-            },
-            {
-                start: 'show',
-                end: 'shown',
-            },
-        ],
+    tab: [
+        {
+            start: 'hide',
+            end: 'hidden',
+        },
+        {
+            start: 'show',
+            end: 'shown',
+        },
+    ],
 
-        tooltip: [
-            {
-                start: 'hide',
-                end: 'hidden',
-            },
-            {
-                start: 'show',
-                end: 'shown',
-            },
-        ],
-    };
+    toast: [
+        {
+            start: 'hide',
+            end: 'hidden',
+        },
+        {
+            start: 'show',
+            end: 'shown',
+        },
+    ],
 
-    Object.keys(moduleTransitions).forEach(function(key) {
-        moduleTransitions[key].forEach(function(pair) {
-            var eventStart = pair.start + '.bs.' + key;
-            var eventEnd = pair.end + '.bs.' + key;
-            $(document.body).on(eventStart, function() {
+    tooltip: [
+        {
+            start: 'hide',
+            end: 'hidden',
+        },
+        {
+            start: 'show',
+            end: 'shown',
+        },
+    ],
+};
+
+export default () => {
+    Object.entries(moduleTransitions).forEach(([key, pairs]) => {
+        pairs.forEach(pair => {
+            const eventStart = `${pair.start}.bs.${key}`;
+            const eventEnd = `${pair.end}.bs.${key}`;
+            jQuery(document.body).on(eventStart, () => {
                 M.util.js_pending(eventEnd);
             });
 
-            $(document.body).on(eventEnd, function() {
+            jQuery(document.body).on(eventEnd, () => {
                 M.util.js_complete(eventEnd);
             });
         });
     });
-});
+};
index 77607a3..8ca7510 100644 (file)
@@ -296,8 +296,8 @@ body.drawer-open-left #region-main.has-blocks {
 
 .block_recentlyaccesseditems {
     img.icon {
-        height: auto;
-        width: auto;
+        height: 24px;
+        width: 24px;
         margin-right: 6px;
     }
 }
index 2bfa166..47bec3e 100644 (file)
@@ -48,13 +48,7 @@ $gototop-bottom-position: 50px !default;
     opacity: 0;
     transition: opacity .7s ease 0s, visibility .1s ease .8s;
     display: block;
-    position: fixed; /* IE compatibility hack */
-    @supports (position: sticky) {
-        position: sticky;
-    }
-    @supports (-ms-ime-align:auto) {
-        position: fixed; /* Edge compatibility hack */
-    }
+    position: fixed;
     bottom: $gototop-bottom-position;
     right: 0;
     a {
@@ -2653,4 +2647,22 @@ $picker-emojis-per-row: 7 !default;
         position: relative;
         z-index: inherit;
     }
-}
\ No newline at end of file
+}
+
+.link-underline {
+    text-decoration: underline;
+    &:focus {
+        text-decoration: none;
+    }
+}
+
+.alert.cta {
+    .icon {
+        padding: 0.3rem;
+        &.fa {
+            border-radius: 50%;
+            border-style: solid;
+            border-width: 0.125rem;
+        }
+    }
+}
index 38ac391..4ed5c9a 100644 (file)
@@ -424,6 +424,7 @@ body.jsenabled .questionflag input[type=checkbox] {
 #page-mod-quiz-edit ul.slots .activityinstance {
     > a {
         display: flex;
+        max-width: 100%;
         align-items: center;
         text-indent: 0;
         padding-left: 0;
index 5dda75a..9ec378a 100644 (file)
@@ -9663,16 +9663,8 @@ input[disabled] {
   transition: opacity .7s ease 0s, visibility .1s ease .8s;
   display: block;
   position: fixed;
-  /* IE compatibility hack */
   bottom: 50px;
   right: 0; }
-  @supports (position: sticky) {
-    #goto-top-link {
-      position: sticky; } }
-  @supports (-ms-ime-align: auto) {
-    #goto-top-link {
-      position: fixed;
-      /* Edge compatibility hack */ } }
   #goto-top-link a {
     position: absolute;
     right: 0;
@@ -11822,6 +11814,18 @@ body.h5p-embed .h5pmessages {
     position: relative;
     z-index: inherit; } }
 
+.link-underline {
+  text-decoration: underline; }
+  .link-underline:focus {
+    text-decoration: none; }
+
+.alert.cta .icon {
+  padding: 0.3rem; }
+  .alert.cta .icon.fa {
+    border-radius: 50%;
+    border-style: solid;
+    border-width: 0.125rem; }
+
 .icon {
   font-size: 16px;
   width: 16px;
@@ -12625,8 +12629,8 @@ body.h5p-embed .h5pmessages {
   margin-top: 0; }
 
 .block_recentlyaccesseditems img.icon {
-  height: auto;
-  width: auto;
+  height: 24px;
+  width: 24px;
   margin-right: 6px; }
 
 .block_myoverview .content {
@@ -15678,6 +15682,7 @@ body.jsenabled .questionflag input[type=checkbox] {
 
 #page-mod-quiz-edit ul.slots .activityinstance > a {
   display: flex;
+  max-width: 100%;
   align-items: center;
   text-indent: 0;
   padding-left: 0; }
index 08a0a12..51fb23e 100644 (file)
@@ -64,8 +64,9 @@
 {{{ output.standard_end_of_body_html }}}
 </body>
 </html>
+{{#js}}
 M.util.js_pending('theme_boost/loader');
-require(['theme_boost/loader'], function(loader) {
-    loader.init();
+require(['theme_boost/loader'], function() {
     M.util.js_complete('theme_boost/loader');
 });
+{{/js}}
index fcb9b2c..5feab1c 100644 (file)
@@ -97,8 +97,7 @@
 </html>
 {{#js}}
 M.util.js_pending('theme_boost/loader');
-require(['theme_boost/loader'], function(loader) {
-    loader.init();
+require(['theme_boost/loader'], function() {
     M.util.js_complete('theme_boost/loader');
 });
 
index 9d8b289..c4ccb98 100644 (file)
@@ -87,8 +87,9 @@
 
 </body>
 </html>
+{{#js}}
 M.util.js_pending('theme_boost/loader');
-require(['theme_boost/loader'], function(loader) {
-    loader.init();
+require(['theme_boost/loader'], function() {
     M.util.js_complete('theme_boost/loader');
 });
+{{/js}}
index ab07c5f..8d967d8 100644 (file)
@@ -9868,16 +9868,8 @@ input[disabled] {
   transition: opacity .7s ease 0s, visibility .1s ease .8s;
   display: block;
   position: fixed;
-  /* IE compatibility hack */
   bottom: 50px;
   right: 0; }
-  @supports (position: sticky) {
-    #goto-top-link {
-      position: sticky; } }
-  @supports (-ms-ime-align: auto) {
-    #goto-top-link {
-      position: fixed;
-      /* Edge compatibility hack */ } }
   #goto-top-link a {
     position: absolute;
     right: 0;
@@ -12036,6 +12028,18 @@ body.h5p-embed .h5pmessages {
     position: relative;
     z-index: inherit; } }
 
+.link-underline {
+  text-decoration: underline; }
+  .link-underline:focus {
+    text-decoration: none; }
+
+.alert.cta .icon {
+  padding: 0.3rem; }
+  .alert.cta .icon.fa {
+    border-radius: 50%;
+    border-style: solid;
+    border-width: 0.125rem; }
+
 .icon {
   font-size: 16px;
   width: 16px;
@@ -12840,8 +12844,8 @@ body.h5p-embed .h5pmessages {
   margin-top: 0; }
 
 .block_recentlyaccesseditems img.icon {
-  height: auto;
-  width: auto;
+  height: 24px;
+  width: 24px;
   margin-right: 6px; }
 
 .block_myoverview .content {
@@ -15901,6 +15905,7 @@ body.jsenabled .questionflag input[type=checkbox] {
 
 #page-mod-quiz-edit ul.slots .activityinstance > a {
   display: flex;
+  max-width: 100%;
   align-items: center;
   text-indent: 0;
   padding-left: 0; }
index 1ae3fc6..787b41f 100644 (file)
@@ -97,8 +97,7 @@
 </html>
 {{#js}}
 M.util.js_pending('theme_boost/loader');
-require(['theme_boost/loader'], function(loader) {
-    loader.init();
+require(['theme_boost/loader'], function() {
     M.util.js_complete('theme_boost/loader');
 });
 {{/js}}
index 4d8ca86..cb5d9c2 100644 (file)
@@ -69,8 +69,7 @@
 </html>
 {{#js}}
 M.util.js_pending('theme_boost/loader');
-require(['theme_boost/loader'], function(loader) {
-    loader.init();
+require(['theme_boost/loader'], function() {
     M.util.js_complete('theme_boost/loader');
 });
 {{/js}}
index 1d06183..b237592 100644 (file)
 </html>
 {{#js}}
 M.util.js_pending('theme_boost/loader');
-require(['theme_boost/loader'], function(loader) {
-    loader.init();
+require(['theme_boost/loader'], function() {
     M.util.js_complete('theme_boost/loader');
 });
 {{/js}}
index 3ef1d57..0f13ff2 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/filter.min.js and b/user/amd/build/local/participantsfilter/filter.min.js differ
index e694bbf..1cc0896 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/filter.min.js.map and b/user/amd/build/local/participantsfilter/filter.min.js.map differ
index 4e178f8..b9b0dda 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js and b/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js differ
index 0c000b7..9a66bfa 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js.map and b/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js.map differ
index 21be665..69b63a7 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/selectors.min.js and b/user/amd/build/local/participantsfilter/selectors.min.js differ
index 023c1a6..15c6dd7 100644 (file)
Binary files a/user/amd/build/local/participantsfilter/selectors.min.js.map and b/user/amd/build/local/participantsfilter/selectors.min.js.map differ
index bbb20c6..1176f2b 100644 (file)
Binary files a/user/amd/build/participantsfilter.min.js and b/user/amd/build/participantsfilter.min.js differ
index 214f7dc..b4c9f89 100644 (file)
Binary files a/user/amd/build/participantsfilter.min.js.map and b/user/amd/build/participantsfilter.min.js.map differ
index fee4c4f..2eef9fc 100644 (file)
Binary files a/user/amd/build/unified_filter.min.js.map and b/user/amd/build/unified_filter.min.js.map differ
index ebe12c4..3ac811e 100644 (file)
Binary files a/user/amd/build/unified_filter_datasource.min.js.map and b/user/amd/build/unified_filter_datasource.min.js.map differ
index 7895ca0..6670c2e 100644 (file)
@@ -44,12 +44,13 @@ export default class {
      *
      * @param {String} filterType The type of filter that this relates to
      * @param {HTMLElement} rootNode The root node for the participants filterset
+     * @param {Array} initialValues The initial values for the selector
      */
-    constructor(filterType, rootNode) {
+    constructor(filterType, rootNode, initialValues) {
         this.filterType = filterType;
         this.rootNode = rootNode;
 
-        this.addValueSelector();
+        this.addValueSelector(initialValues);
     }
 
     /**
@@ -79,8 +80,10 @@ export default class {
 
     /**
      * Add the value selector to the filter row.
+     *
+     * @param {Array} initialValues
      */
-    async addValueSelector() {
+    async addValueSelector(initialValues = []) {
         const filterValueNode = this.getFilterValueNode();
 
         // Copy the data in place.
@@ -88,6 +91,21 @@ export default class {
 
         const dataSource = filterValueNode.querySelector('select');
 
+        // If there are any initial values then attempt to apply them.
+        initialValues.forEach(filterValue => {
+            let selectedOption = dataSource.querySelector(`option[value="${filterValue}"]`);
+            if (selectedOption) {
+                selectedOption.selected = true;
+            } else if (!this.showSuggestions) {
+                selectedOption = document.createElement('option');
+                selectedOption.value = filterValue;
+                selectedOption.innerHTML = filterValue;
+                selectedOption.selected = true;
+
+                dataSource.append(selectedOption);
+            }
+        });
+
         Autocomplete.enhance(
             // The source select element.
             dataSource,
index c7b7872..a1aa479 100644 (file)
@@ -25,10 +25,6 @@ import Filter from '../filter';
 import {get_string as getString} from 'core/str';
 
 export default class extends Filter {
-    constructor(filterType, filterSet) {
-        super(filterType, filterSet);
-    }
-
     /**
      * For keywords the final value is an Array of strings.
      *
index 3d0ef29..27c9419 100644 (file)
@@ -63,5 +63,6 @@ export default {
             all: `${getFilterRegion('filtertypedata')} [data-field-name]`,
         },
         typeList: getFilterRegion('filtertypelist'),
+        typeListSelect: `select${getFilterRegion('filtertypelist')}`,
     },
 };
index 3dcdb88..718e5ab 100644 (file)
@@ -25,6 +25,7 @@
 import CourseFilter from './local/participantsfilter/filtertypes/courseid';
 import * as DynamicTable from 'core_table/dynamic';
 import GenericFilter from './local/participantsfilter/filter';
+import {get_strings as getStrings} from 'core/str';
 import Notification from 'core/notification';
 import Selectors from './local/participantsfilter/selectors';
 import Templates from 'core/templates';
@@ -56,7 +57,8 @@ export const init = participantsRegionId => {
      * @return {Promise}
      */
     const addFilterRow = () => {
-        return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})
+        const rownum = 1 + getFilterRegion().querySelectorAll(Selectors.filter.region).length;
+        return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rownum})
         .then(({html, js}) => {
             const newContentNodes = Templates.appendNodeContents(getFilterRegion(), html, js);
 
@@ -104,8 +106,10 @@ export const init = participantsRegionId => {
      *
      * @param {HTMLElement} filterRow
      * @param {String} filterType
+     * @param {Array} initialFilterValues The initially selected values for the filter
+     * @returns {Filter}
      */
-    const addFilter = async(filterRow, filterType) => {
+    const addFilter = async(filterRow, filterType, initialFilterValues) => {
         // Name the filter on the filter row.
         filterRow.dataset.filterType = filterType;
 
@@ -116,14 +120,17 @@ export const init = participantsRegionId => {
         if (filterDataNode.dataset.filterTypeClass) {
             Filter = await import(filterDataNode.dataset.filterTypeClass);
         }
-        activeFilters[filterType] = new Filter(filterType, filterSet);
+        activeFilters[filterType] = new Filter(filterType, filterSet, initialFilterValues);
 
         // Disable the select.
         const typeField = filterRow.querySelector(Selectors.filter.fields.type);
+        typeField.value = filterType;
         typeField.disabled = 'disabled';
 
         // Update the list of available filter types.
         updateFiltersOptions();
+
+        return activeFilters[filterType];
     };
 
     /**
@@ -157,31 +164,40 @@ export const init = participantsRegionId => {
      *
      * @param {HTMLElement} filterRow
      */
-    const removeFilterRow = filterRow => {
+    const removeFilterRow = async filterRow => {
         // Remove the filter object.
         removeFilterObject(filterRow.dataset.filterType);
 
         // Remove the actual filter HTML.
         filterRow.remove();
 
+        // Update the list of available filter types.
+        updateFiltersOptions();
+
         // Refresh the table.
         updateTableFromFilter();
 
-        // Update the list of available filter types.
-        updateFiltersOptions();
+        // Update filter fieldset legends.
+        const filterLegends = await getAvailableFilterLegends();
+
+        getFilterRegion().querySelectorAll(Selectors.filter.region).forEach((filterRow, index) => {
+            filterRow.querySelector('legend').innerText = filterLegends[index];
+        });
+
     };
 
     /**
      * Replace the specified filter row with a new one.
      *
      * @param {HTMLElement} filterRow
+     * @param {Number} rowNum The number used to label the filter fieldset legend (eg Row 1). Defaults to 1 (the first filter).
      * @return {Promise}
      */
-    const replaceFilterRow = filterRow => {
+    const replaceFilterRow = (filterRow, rowNum = 1) => {
         // Remove the filter object.
         removeFilterObject(filterRow.dataset.filterType);
 
-        return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})
+        return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {"rownumber": rowNum})
         .then(({html, js}) => {
             const newContentNodes = Templates.replaceNode(filterRow, html, js);
 
@@ -237,15 +253,28 @@ export const init = participantsRegionId => {
 
     /**
      * Remove all filters.
+     *
+     * @returns {Promise}
      */
-    const removeAllFilters = async() => {
+    const removeAllFilters = () => {
         const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
-        filters.forEach((filterRow) => {
-            removeOrReplaceFilterRow(filterRow);
-        });
+        filters.forEach(filterRow => removeOrReplaceFilterRow(filterRow));
 
         // Refresh the table.
-        updateTableFromFilter();
+        return updateTableFromFilter();
+    };
+
+    /**
+     * Remove any empty filters.
+     */
+    const removeEmptyFilters = () => {
+        const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
+        filters.forEach(filterRow => {
+            const filterType = filterRow.querySelector(Selectors.filter.fields.type);
+            if (!filterType.value) {
+                removeOrReplaceFilterRow(filterRow);
+            }
+        });
     };
 
     /**
@@ -287,6 +316,49 @@ export const init = participantsRegionId => {
         }
     };
 
+    /**
+     * Set the current filter options based on a provided configuration.
+     *
+     * @param {Object} config
+     * @param {Number} config.jointype
+     * @param {Object} config.filters
+     */
+    const setFilterFromConfig = config => {
+        const filterConfig = Object.entries(config.filters);
+
+        if (!filterConfig.length) {
+            // There are no filters to set from.
+            return;
+        }
+
+        // Set the main join type.
+        filterSet.querySelector(Selectors.filterset.fields.join).value = config.jointype;
+
+        const filterPromises = filterConfig.map(([filterType, filterData]) => {
+            if (filterType === 'courseid') {
+                // The courseid is a special case.
+                return Promise.resolve();
+            }
+
+            const filterValues = filterData.values;
+
+            if (!filterValues.length) {
+                // There are no values for this filter.
+                // Skip it.
+                return Promise.resolve();
+            }
+
+            return addFilterRow().then(([filterRow]) => addFilter(filterRow, filterType, filterValues));
+        });
+
+        Promise.all(filterPromises).then(() => {
+            return removeEmptyFilters();
+        })
+        .then(updateFiltersOptions)
+        .then(updateTableFromFilter)
+        .catch();
+    };
+
     /**
      * Update the Dynamic table based upon the current filter.
      *
@@ -302,6 +374,33 @@ export const init = participantsRegionId => {
         );
     };
 
+    /**
+     * Fetch the strings used to populate the fieldset legends for the maximum number of filters possible.
+     *
+     * @return {array}
+     */
+    const getAvailableFilterLegends = async() => {
+        const maxFilters = document.querySelector(Selectors.data.typeListSelect).length - 1;
+        let requests = [];
+
+        [...Array(maxFilters)].forEach((_, rowIndex) => {
+            requests.push({
+                "key": "filterrowlegend",
+                "component": "core_user",
+                // Add 1 since rows begin at 1 (index begins at zero).
+                "param": rowIndex + 1
+            });
+        });
+
+        const legendStrings = await getStrings(requests)
+        .then(fetchedStrings => {
+            return fetchedStrings;
+        })
+        .catch(Notification.exception);
+
+        return legendStrings;
+    };
+
     // Add listeners for the main actions.
     filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
         if (e.target.closest(Selectors.filterset.actions.addRow)) {
@@ -345,4 +444,11 @@ export const init = participantsRegionId => {
     filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
         filterSet.dataset.filterverb = e.target.value;
     });
+
+    const tableRoot = DynamicTable.getTableFromId(filterSet.dataset.tableRegion);
+    const initialFilters = DynamicTable.getFilters(tableRoot);
+    if (initialFilters) {
+        // Apply the initial filter configuration.
+        setFilterFromConfig(initialFilters);
+    }
 };
index a18d2fd..9248710 100644 (file)
@@ -16,6 +16,7 @@
 /**
  * Unified filter page JS module for the course participants page.
  *
+ * @deprecated since Moodle 3.9 MDL-68612 - user unified filter replaced by participants filter.
  * @module     core_user/unified_filter
  * @package    core_user
  * @copyright  2017 Jun Pataleta
@@ -37,6 +38,7 @@ define(['jquery', 'core/form-autocomplete', 'core/str', 'core/notification'],
     /**
      * Init function.
      *
+     * @deprecated since Moodle 3.9 MDL-68612 - user unified filter replaced by participants filter.
      * @method init
      * @private
      */
@@ -117,6 +119,7 @@ define(['jquery', 'core/form-autocomplete', 'core/str', 'core/notification'],
     /**
      * Return the unified user filter form.
      *
+     * @deprecated since Moodle 3.9 MDL-68612 - user unified filter replaced by participants filter.
      * @method getForm
      * @return {DOMElement}
      */
index 60700ac..fdbb206 100644 (file)
@@ -15,6 +15,7 @@
 
 /**
  * Datasource for the core_user/unified_filter.
+ * @deprecated since Moodle 3.9 MDL-68612 - user unified filter replaced by participants filter.
  *
  * This module is compatible with core/form-autocomplete.
  *
index c98fccb..443c5a2 100644 (file)
@@ -210,7 +210,8 @@ class participants_filter implements renderable, templatable {
             $groups = groups_get_all_groups($this->course->id, $USER->id);
         }
 
-        if (empty($groups)) {
+        // Return no data if no groups found (which includes if the only value is 'No group').
+        if (empty($groups) || (count($groups) === 1 && array_key_exists(-1, $groups))) {
             return null;
         }
 
@@ -349,6 +350,7 @@ class participants_filter implements renderable, templatable {
             'tableregionid' => $this->tableregionid,
             'courseid' => $this->context->instanceid,
             'filtertypes' => $this->get_filtertypes(),
+            'rownumber' => 1,
         ];
 
         return $data;
index 849a986..6a05f86 100644 (file)
@@ -17,6 +17,7 @@
 /**
  * Class containing the filter options data for rendering the unified filter autocomplete element for the course participants page.
  *
+ * @deprecated since Moodle 3.9 MDL-68612 - Please use \core_user\table\participants_search::class and table filtersets instead.
  * @package    core_user
  * @copyright  2017 Jun Pataleta
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -34,8 +35,10 @@ defined('MOODLE_INTERNAL') || die();
 /**
  * Class containing the filter options data for rendering the unified filter autocomplete element for the course participants page.
  *
+ * @deprecated since Moodle 3.9 MDL-68612 - Please use \core_user\table\participants_search::class and table filtersets instead.
  * @copyright  2017 Jun Pataleta
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ *
  */
 class unified_filter implements renderable, templatable {
 
@@ -56,6 +59,10 @@ class unified_filter implements renderable, templatable {
      * @param string|moodle_url $baseurl The url with params needed to call up this page.
      */
     public function __construct($filteroptions, $selectedoptions, $baseurl = null) {
+        $deprecatedtext = __CLASS__ . ' class is deprecated. Please use \core\table\participants_search::class' .
+                                      ' with table filtersets instead.';
+        debugging($deprecatedtext, DEBUG_DEVELOPER);
+
         $this->filteroptions = $filteroptions;
         $this->selectedoptions = $selectedoptions;
         if (!empty($baseurl)) {
index d9aaccf..0d94483 100644 (file)
@@ -314,6 +314,8 @@ class participants_search {
      * @return array SQL query data in the format ['sql' => '', 'forcedsql' => '', 'params' => []].
      */
     protected function get_enrolled_sql(): array {
+        global $USER;
+
         $isfrontpage = ($this->context->instanceid == SITEID);
         $prefix = 'eu_';
         $filteruid = "{$prefix}u.id";
@@ -357,15 +359,43 @@ class participants_search {
             $params = array_merge($params, $methodparams, $statusparams);
         }
 
-        // Prepare any groups filtering.
         $groupids = [];
 
         if ($this->filterset->has_filter('groups')) {
             $groupids = $this->filterset->get_filter('groups')->get_filter_values();
         }
 
+        // Force additional groups filtering if required due to lack of capabilities.
+        // Note: This means results will always be limited to allowed groups, even if the user applies their own groups filtering.
+        $canaccessallgroups = has_capability('moodle/site:accessallgroups', $this->context);
+        $forcegroups = ($this->course->groupmode == SEPARATEGROUPS && !$canaccessallgroups);
+
+        if ($forcegroups) {
+            $allowedgroupids = array_keys(groups_get_all_groups($this->course->id, $USER->id));
+
+            // Users not in any group in a course with separate groups mode should not be able to access the participants filter.
+            if (empty($allowedgroupids)) {
+                // The UI does not support this, so it should not be reachable unless someone is trying to bypass the restriction.
+                throw new \coding_exception('User must be part of a group to filter by participants.');
+            }
+
+            $forceduid = "{$forcedprefix}u.id";
+            $forcedjointype = $this->get_groups_jointype(\core_table\local\filter\filter::JOINTYPE_ANY);
+            $forcedgroupjoin = groups_get_members_join($allowedgroupids, $forceduid, $this->context, $forcedjointype);
+
+            $forcedjoins[] = $forcedgroupjoin->joins;
+            $forcedwhere .= "AND ({$forcedgroupjoin->wheres})";
+
+            $params = array_merge($params, $forcedgroupjoin->params);
+
+            // Remove any filtered groups the user does not have access to.
+            $groupids = array_intersect($allowedgroupids, $groupids);
+        }
+
+        // Prepare any user defined groups filtering.
         if ($groupids) {
             $groupjoin = groups_get_members_join($groupids, $filteruid, $this->context, $this->get_groups_jointype());
+
             $joins[] = $groupjoin->joins;
             $params = array_merge($params, $groupjoin->params);
             if (!empty($groupjoin->wheres)) {
@@ -685,12 +715,28 @@ class participants_search {
      * Fetch the groups filter's grouplib jointype, based on its filterset jointype.
      * This mapping is to ensure compatibility between the two, should their values ever differ.
      *
+     * @param int|null $forcedjointype If set, specifies the join type to fetch mapping for (used when applying forced filtering).
+     *                            If null, then user defined filter join type is used.
      * @return int
      */
-    protected function get_groups_jointype(): int {
+    protected function get_groups_jointype(?int $forcedjointype = null): int {
+
+        // If applying forced groups filter and no manual groups filtering is applied, add an empty filter so we can map the join.
+        if (!is_null($forcedjointype) && !$this->filterset->has_filter('groups')) {
+            $this->filterset->add_filter(new \core_table\local\filter\integer_filter('groups'));
+        }
+
         $groupsfilter = $this->filterset->get_filter('groups');
 
-        switch ($groupsfilter->get_join_type()) {
+        if (is_null($forcedjointype)) {
+            // Fetch join type mapping for a user supplied groups filtering.
+            $filterjointype = $groupsfilter->get_join_type();
+        } else {
+            // Fetch join type mapping for forced groups filtering.
+            $filterjointype = $forcedjointype;
+        }
+
+        switch ($filterjointype) {
             case $groupsfilter::JOINTYPE_NONE:
                 $groupsjoin = GROUPS_JOIN_NONE;
                 break;
index 08169f4..f3addab 100644 (file)
@@ -43,7 +43,7 @@ $contextid    = optional_param('contextid', 0, PARAM_INT); // One of this or.
 $courseid     = optional_param('id', 0, PARAM_INT); // This are required.
 $newcourse    = optional_param('newcourse', false, PARAM_BOOL);
 $roleid       = optional_param('roleid', 0, PARAM_INT);
-$groupparam   = optional_param('group', 0, PARAM_INT);
+$urlgroupid   = optional_param('group', 0, PARAM_INT);
 
 $PAGE->set_url('/user/index.php', array(
         'page' => $page,
@@ -102,137 +102,47 @@ if ($node) {
 echo $OUTPUT->header();
 echo $OUTPUT->heading(get_string('participants'));
 
-// Get the currently applied filters.
-$filtersapplied = optional_param_array('unified-filters', [], PARAM_NOTAGS);
-$filterwassubmitted = optional_param('unified-filter-submitted', 0, PARAM_BOOL);
-
-// If they passed a role make sure they can view that role.
-if ($roleid) {
-    $viewableroles = get_profile_roles($context);
+$filterset = new \core_user\table\participants_filterset();
+$filterset->add_filter(new integer_filter('courseid', filter::JOINTYPE_DEFAULT, [(int)$course->id]));
 
-    // Check if the user can view this role.
-    if (array_key_exists($roleid, $viewableroles)) {
-        $filtersapplied[] = USER_FILTER_ROLE . ':' . $roleid;
-    } else {
-        $roleid = 0;
-    }
-}
+$participanttable = new \core_user\table\participants("user-index-participants-{$course->id}");
 
-// Default group ID.
-$groupid = false;
 $canaccessallgroups = has_capability('moodle/site:accessallgroups', $context);
-if ($course->groupmode != NOGROUPS) {
-    if ($canaccessallgroups) {
-        // Change the group if the user can access all groups and has specified group in the URL.
-        if ($groupparam) {
-            $groupid = $groupparam;
-        }
-    } else {
-        // Otherwise, get the user's default group.
-        $groupid = groups_get_course_group($course, true);
-        if ($course->groupmode == SEPARATEGROUPS && !$groupid) {
-            // The user is not in the group so show message and exit.
-            echo $OUTPUT->notification(get_string('notingroup'));
-            echo $OUTPUT->footer();
-            exit;
-        }
-    }
-}
-$hasgroupfilter = false;
-$lastaccess = 0;
-$searchkeywords = [];
-$enrolid = 0;
+$filtergroupids = $urlgroupid ? [$urlgroupid] : [];
 
-$participanttable = new \core_user\table\participants("user-index-participants-{$course->id}");
-
-$filterset = new \core_user\table\participants_filterset();
-$filterset->add_filter(new integer_filter('courseid', filter::JOINTYPE_DEFAULT, [(int)$course->id]));
-$enrolfilter = new integer_filter('enrolments');
-$groupfilter = new integer_filter('groups');
-$keywordfilter = new string_filter('keywords');
-$lastaccessfilter = new integer_filter('accesssince');
-$rolefilter = new integer_filter('roles');
-$statusfilter = new integer_filter('status');
-
-foreach ($filtersapplied as $filter) {
-    $filtervalue = explode(':', $filter, 2);
-    $value = null;
-    if (count($filtervalue) == 2) {
-        $key = clean_param($filtervalue[0], PARAM_INT);
-        $value = clean_param($filtervalue[1], PARAM_INT);
-    } else {
-        // Search string.
-        $key = USER_FILTER_STRING;
-        $value = clean_param($filtervalue[0], PARAM_TEXT);
-    }
+// Force group filtering if user should only see a subset of groups' users.
+if ($course->groupmode == SEPARATEGROUPS && !$canaccessallgroups) {
+    $filtergroupids = array_keys(groups_get_all_groups($course->id, $USER->id));
 
-    switch ($key) {
-        case USER_FILTER_ENROLMENT:
-            $enrolid = $value;
-            $enrolfilter->add_filter_value($value);
-            break;
-        case USER_FILTER_GROUP:
-            $groupid = $value;
-            $groupfilter->add_filter_value($value);
-            $hasgroupfilter = true;
-            break;
-        case USER_FILTER_LAST_ACCESS:
-            $lastaccess = $value;
-            $lastaccessfilter->add_filter_value($value);
-            break;
-        case USER_FILTER_ROLE:
-            $roleid = $value;
-            $rolefilter->add_filter_value($value);
-            break;
-        case USER_FILTER_STATUS:
-            // We only accept active/suspended statuses.
-            if ($value == ENROL_USER_ACTIVE || $value == ENROL_USER_SUSPENDED) {
-                $status = $value;
-                $statusfilter->add_filter_value($value);
-            }
-            break;
-        default:
-            // Search string.
-            $searchkeywords[] = $value;
-            $keywordfilter->add_filter_value($value);
-            break;
+    if (empty($filtergroupids)) {
+        // The user is not in a group so show message and exit.
+        echo $OUTPUT->notification(get_string('notingroup'));
+        echo $OUTPUT->footer();
+        exit();
     }
 }
-// If course supports groups we may need to set a default.
-if (!empty($groupid)) {
-    if ($canaccessallgroups) {
-        // User can access all groups, let them filter by whatever was selected.
-        $filtersapplied[] = USER_FILTER_GROUP . ':' . $groupid;
-        $groupfilter->add_filter_value((int)$groupid);
-    } else if (!$filterwassubmitted && $course->groupmode == VISIBLEGROUPS) {
-        // If we are in a course with visible groups and the user has not submitted anything and does not have
-        // access to all groups, then set a default group.
-        $filtersapplied[] = USER_FILTER_GROUP . ':' . $groupid;
-        $groupfilter->add_filter_value((int)$groupid);
-    } else if (!$hasgroupfilter && $course->groupmode != VISIBLEGROUPS) {
-        // The user can't access all groups and has not set a group filter in a course where the groups are not visible
-        // then apply a default group filter.
-        $filtersapplied[] = USER_FILTER_GROUP . ':' . $groupid;
-        $groupfilter->add_filter_value((int)$groupid);
-    } else if (!$hasgroupfilter) { // No need for the group id to be set.
-        $groupid = false;
-    }
+
+// Apply groups filter if included in URL or forced due to lack of capabilities.
+if (!empty($filtergroupids)) {
+    $filterset->add_filter(new integer_filter('groups', filter::JOINTYPE_DEFAULT, $filtergroupids));
 }
 
-if ($groupid > 0 && ($course->groupmode != SEPARATEGROUPS || $canaccessallgroups)) {
+// Display single group information if requested in the URL.
+if ($urlgroupid > 0 && ($course->groupmode != SEPARATEGROUPS || $canaccessallgroups)) {
     $grouprenderer = $PAGE->get_renderer('core_group');
-    $groupdetailpage = new \core_group\output\group_details($groupid);
+    $groupdetailpage = new \core_group\output\group_details($urlgroupid);
     echo $grouprenderer->group_details($groupdetailpage);
 }
 
-// Should use this variable so that we don't break stuff every time a variable is added or changed.
-$baseurl = new moodle_url('/user/index.php', array(
-        'contextid' => $context->id,
-        'id' => $course->id,
-        'perpage' => $perpage));
+// Filter by role if passed via URL (used on profile page).
+if ($roleid) {
+    $viewableroles = get_profile_roles($context);
 
-$participanttable = new \core_user\table\participants("user-index-participants-{$course->id}");
-$participanttable->define_baseurl($baseurl);
+    // Apply filter if the user can view this role.
+    if (array_key_exists($roleid, $viewableroles)) {
+        $filterset->add_filter(new integer_filter('roles', filter::JOINTYPE_DEFAULT, [$roleid]));
+    }
+}
 
 // Manage enrolments.
 $manager = new course_enrolment_manager($PAGE, $course);
@@ -242,50 +152,18 @@ $enrolbuttonsout = '';
 foreach ($enrolbuttons as $enrolbutton) {
     $enrolbuttonsout .= $enrolrenderer->render($enrolbutton);
 }
+
 echo html_writer::div($enrolbuttonsout, 'd-flex justify-content-end', [
     'data-region' => 'wrapper',
     'data-table-uniqueid' => $participanttable->uniqueid,
 ]);
 
-// Render the unified filter.
-$renderer = $PAGE->get_renderer('core_user');
-echo $renderer->unified_filter($course, $context, $filtersapplied, $baseurl);
-
 // Render the user filters.
 $userrenderer = $PAGE->get_renderer('core_user');
 echo $userrenderer->participants_filter($context, $participanttable->uniqueid);
 
 echo '<div class="userlist">';
 
-// Add filters to the baseurl after creating unified_filter to avoid losing them.
-foreach (array_unique($filtersapplied) as $filterix => $filter) {
-    $baseurl->param('unified-filters[' . $filterix . ']', $filter);
-}
-
-if (count($groupfilter)) {
-    $filterset->add_filter($groupfilter);
-}
-
-if (count($lastaccessfilter)) {
-    $filterset->add_filter($lastaccessfilter);
-}
-
-if (count($rolefilter)) {
-    $filterset->add_filter($rolefilter);
-}
-
-if (count($enrolfilter)) {
-    $filterset->add_filter($enrolfilter);
-}
-
-if (count($statusfilter)) {
-    $filterset->add_filter($statusfilter);
-}
-
-if (count($keywordfilter)) {
-    $filterset->add_filter($keywordfilter);
-}
-
 // Do this so we can get the total number of rows.
 ob_start();
 $participanttable->set_filterset($filterset);
@@ -317,8 +195,10 @@ echo html_writer::tag(
 
 echo $participanttablehtml;
 
-$perpageurl = clone($baseurl);
-$perpageurl->remove_params('perpage');
+$perpageurl = new moodle_url('/user/index.php', [
+    'contextid' => $context->id,
+    'id' => $course->id,
+]);
 $perpagesize = DEFAULT_PAGE_SIZE;
 $perpagevisible = false;
 $perpagestring = '';
@@ -442,13 +322,16 @@ if ($bulkoperations) {
 echo '</div>';  // Userlist.
 
 $enrolrenderer = $PAGE->get_renderer('core_enrol');
-echo '<div class="float-right">';
 // Need to re-generate the buttons to avoid having elements with duplicate ids on the page.
 $enrolbuttons = $manager->get_manual_enrol_buttons();
+$enrolbuttonsout = '';
 foreach ($enrolbuttons as $enrolbutton) {
-    echo $enrolrenderer->render($enrolbutton);
+    $enrolbuttonsout .= $enrolrenderer->render($enrolbutton);
 }
-echo '</div>';
+echo html_writer::div($enrolbuttonsout, 'd-flex justify-content-end', [
+    'data-region' => 'wrapper',
+    'data-table-uniqueid' => $participanttable->uniqueid,
+]);
 
 if ($newcourse == 1) {
     $str = get_string('proceedtocourse', 'enrol');
index 078aa0b..0134a2e 100644 (file)
@@ -1287,255 +1287,6 @@ function user_get_tagged_users($tag, $exclusivemode = false, $fromctx = 0, $ctx
             $exclusivemode, $fromctx, $ctx, $rec, $page, $totalpages);
 }
 
-/**
- * Returns the SQL used by the participants table.
- *
- * @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;
-
-    // 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.
- *
- * @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;
-
-    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.
- *
- * @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;
-
-    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);
-}
-
 /**
  * Returns SQL that can be used to limit a query to a period where the user last accessed / did not access a course.
  *
index 7be0ce5..b85ec06 100644 (file)
@@ -110,6 +110,7 @@ class core_user_renderer extends plugin_renderer_base {
 
     /**
      * Renders the unified filter element for the course participants page.
+     * @deprecated since Moodle 3.9 MDL-68612 - Please use participants_filter() instead.
      *
      * @param stdClass $course The course object.
      * @param context $context The context object.
@@ -120,6 +121,8 @@ class core_user_renderer extends plugin_renderer_base {
     public function unified_filter($course, $context, $filtersapplied, $baseurl = null) {
         global $CFG, $DB, $USER;
 
+        debugging('core_user_renderer->unified_filter() is deprecated. Please use participants_filter() instead.', DEBUG_DEVELOPER);
+
         require_once($CFG->dirroot . '/enrol/locallib.php');
         require_once($CFG->dirroot . '/lib/grouplib.php');
         $manager = new course_enrolment_manager($this->page, $course);
index 9f358b6..732bb23 100644 (file)
 {{#items}}
     <span role="listitem" data-value="{{value}}" aria-selected="true"
             class="badge badge-secondary clickable text-wrap text-break line-height-4 mr-2 my-1">
-        {{label}}<i class="icon fa fa-times pl-2 mr-0"></i>
+        {{label}}
+        <button class="btn btn-link text-reset p-0" aria-label='{{#str}}clearfilterselection, core_user, {{label}}{{/str}}'>
+            <i class="icon fa fa-times pl-2 mr-0"></i>
+        </button>
     </span>
 {{/items}}
 {{^items}}
index 9d2bdc6..2ae3c42 100644 (file)
                 "name": "status",
                 "title": "Status"
             }
-        ]
+        ],
+        "rownumber": 1
     }
 }}
 <div data-filterregion="filter">
-    <div class="border-radius my-2 p-2 bg-white border d-flex flex-column flex-md-row align-items-md-start">
-        <div class="d-flex flex-column flex-md-row align-items-md-center">
-            <label for="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}" class="mr-md-2 mb-md-0">{{#str}}match, core_user{{/str}}</label>
-            <select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="join" id="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}">
-                <option value="0">{{#str}}none{{/str}}</option>
-                <option selected=selected value="1">{{#str}}any{{/str}}</option>
-                <option value="2">{{#str}}all{{/str}}</option>
-            </select>
-        </div>
+    <fieldset>
+        <legend class="sr-only">{{#str}}filterrowlegend, core_user, {{rownumber}}{{/str}}</legend>
+        <div class="border-radius my-2 p-2 bg-white border d-flex flex-column flex-md-row align-items-md-start">
+            <div class="d-flex flex-column flex-md-row align-items-md-center">
+                <label for="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}" class="mr-md-2 mb-md-0">{{#str}}match, core_user{{/str}}</label>
+                <select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="join" id="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}">
+                    <option value="0">{{#str}}none{{/str}}</option>
+                    <option selected=selected value="1">{{#str}}any{{/str}}</option>
+                    <option value="2">{{#str}}all{{/str}}</option>
+                </select>
+            </div>
 
-        <label class="sr-only pt-2" for="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">filtertype</label>
-        <select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="type" id="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">
-            <option value="">{{#str}}selectfiltertype, core_user{{/str}}</option>
-            {{#filtertypes}}
-            <option value="{{name}}">{{title}}</option>
-            {{/filtertypes}}
-        </select>
+            <label class="sr-only pt-2" for="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">filtertype</label>
+            <select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="type" id="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">
+                <option value="">{{#str}}selectfiltertype, core_user{{/str}}</option>
+                {{#filtertypes}}
+                <option value="{{name}}">{{title}}</option>
+                {{/filtertypes}}
+            </select>
 
-        <div data-filterregion="value" class="d-md-flex flex-column align-items-start flex-lg-row"></div>
+            <div data-filterregion="value" class="d-md-flex flex-column align-items-start flex-lg-row"></div>
 
-        <button data-filteraction="remove" class="ml-auto icon-no-margin icon-size-4 btn text-reset" aria-label="{{#str}}clearfilterrow, core_user{{/str}}">
-            <i class="icon fa fa-times-circle"></i>
-        </button>
-    </div>
-    <div data-filterregion="joinadverb" class="pl-1 text-uppercase font-weight-bold">
-        <div data-filterverbfor="0">{{#str}}adverbfor_andnot, core_user{{/str}}</div>
-        <div data-filterverbfor="1">{{#str}}adverbfor_or, core_user{{/str}}</div>
-        <div data-filterverbfor="2">{{#str}}adverbfor_and, core_user{{/str}}</div>
-    </div>
+            <button data-filteraction="remove" class="ml-auto icon-no-margin icon-size-4 btn text-reset" aria-label="{{#str}}clearfilterrow, core_user{{/str}}">
+                <i class="icon fa fa-times-circle"></i>
+            </button>
+        </div>
+        <div data-filterregion="joinadverb" class="pl-1 text-uppercase font-weight-bold">
+            <div data-filterverbfor="0">{{#str}}adverbfor_andnot, core_user{{/str}}</div>
+            <div data-filterverbfor="1">{{#str}}adverbfor_or, core_user{{/str}}</div>
+            <div data-filterverbfor="2">{{#str}}adverbfor_and, core_user{{/str}}</div>
+        </div>
+    </fieldset>
 </div>
index 243f085..30e7fe1 100644 (file)
@@ -16,6 +16,7 @@
 }}
 {{!
     @template core_user/unified_filter
+    @deprecated since Moodle 3.9 MDL-68612 - please use core_user/participantsfilter instead.
 
     Template for the unified filter element.
 
index c716a18..16839b0 100644 (file)
@@ -6,17 +6,17 @@ Feature: Course participants can be filtered
 
   Background:
     Given the following "courses" exist:
-      | fullname | shortname | groupmode |
-      | Course 1 | C1        |     1     |
-      | Course 2 | C2        |     0     |
-      | Course 3 | C3        |     0     |
+      | fullname | shortname | groupmode | startdate        |
+      | Course 1 | C1        |     1     | ##5 months ago## |
+      | Course 2 | C2        |     0     | ##4 months ago## |
+      | Course 3 | C3        |     0     | ##3 months ago## |
     And the following "users" exist:
       | username | firstname | lastname | e