// 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');
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);
* @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.
*/
$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 = '';
$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 ///
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.
*
}}
<div class="form-item row" id="{{id}}">
<div class="form-label col-sm-3 text-sm-right">
- <label {{#labelfor}}for="{{labelfor}}"{{/labelfor}}>
- {{{title}}}
- {{#override}}
- <div class="alert alert-info">{{override}}</div>
- {{/override}}
- {{#warning}}
- <div class="alert alert-warning">{{warning}}</div>
- {{/warning}}
- </label>
+ {{#customcontrol}}
+ <p {{#labelfor}}id="{{labelfor}}_label"{{/labelfor}}>
+ {{{title}}}
+ {{#override}}
+ <div class="alert alert-info">{{override}}</div>
+ {{/override}}
+ {{#warning}}
+ <div class="alert alert-warning">{{warning}}</div>
+ {{/warning}}
+ </p>
+ {{/customcontrol}}
+ {{^customcontrol}}
+ <label {{#labelfor}}for="{{labelfor}}"{{/labelfor}}>
+ {{{title}}}
+ {{#override}}
+ <div class="alert alert-info">{{override}}</div>
+ {{/override}}
+ {{#warning}}
+ <div class="alert alert-warning">{{warning}}</div>
+ {{/warning}}
+ </label>
+ {{/customcontrol}}
<span class="form-shortname d-block small text-muted">{{{name}}}</span>
</div>
<div class="form-setting col-sm-9">
{{#dependenton}}<div class="form-dependenton mb-4 text-muted">{{{.}}}</div>{{/dependenton}}
</div>
</div>
+{{#customcontrol}}
+ {{#js}}
+ require(['jquery'], function($) {
+ $('#{{id}}_label').css('cursor', 'default');
+ $('#{{id}}_label').click(function() {
+ $('#{{id}}')
+ .find('button, a, input:not([type="hidden"]), select, textarea, [tabindex]')
+ .filter(':not([disabled]):not([tabindex="0"]):not([tabindex="-1"])')
+ .first().focus();
+ });
+ });
+ {{/js}}
+{{/customcontrol}}
}
}
+ $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.
--- /dev/null
+<?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]);
+ }
+}
$returnedblocks[] = $block['name'];
// Check the configuration returned for this default block.
if ($block['name'] == 'recentlyaccessedcourses') {
- $this->assertEquals('displaycategories', $block['configs'][0]['name']);
- $this->assertEquals(json_encode('0'), $block['configs'][0]['value']);
- $this->assertEquals('plugin', $block['configs'][0]['type']);
+ // Convert config to associative array to avoid DB sorting randomness.
+ $config = array_column($block['configs'], null, 'name');
+ $this->assertArrayHasKey('displaycategories', $config);
+ $this->assertEquals(json_encode('0'), $config['displaycategories']['value']);
+ $this->assertEquals('plugin', $config['displaycategories']['type']);
}
}
// Remove lp block.
// $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
//=========================================================================
* @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.
contentBank.classList.add('view-grid');
viewGrid.classList.add('active');
viewList.classList.remove('active');
+ setViewListPreference(false);
});
viewList.addEventListener('click', () => {
contentBank.classList.add('view-list');
viewList.classList.add('active');
viewGrid.classList.remove('active');
+ setViewListPreference(true);
});
// Sort by file name alphabetical
});
};
+
+/**
+ * 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.
*
'type' => $mimetype
);
}
+ $data->viewlist = get_user_preferences('core_contentbank_view_list');
$data->contents = $contentdata;
// The tools are displayed in the action bar on the index page.
foreach ($this->toolbar as $tool) {
// Customize the output of a tool, like dropdowns.
- $method = 'export_tool_'.$tool['name'];
+ $method = 'export_tool_'.$tool['action'];
if (method_exists($this, $method)) {
$this->$method($tool);
}
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.
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.
*
use core_h5p\api;
use core_h5p\editor as h5peditor;
use core_h5p\factory;
+use core_h5p\helper;
use stdClass;
/**
global $DB;
$mform = $this->_form;
+ $errors = [];
+ $notifications = [];
// Id of the content to edit.
$id = $this->_customdata['id'];
$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);
$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();
+ }
}
/**
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"
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"
if (!empty($editabletypes)) {
// Editor base URL.
$editbaseurl = new moodle_url('/contentbank/edit.php', ['contextid' => $contextid]);
- $toolbar[] = ['name' => get_string('add'), 'link' => $editbaseurl, 'dropdown' => true, 'contenttypes' => $editabletypes];
+ $toolbar[] = [
+ 'name' => get_string('add'),
+ 'link' => $editbaseurl, 'dropdown' => true,
+ 'contenttypes' => $editabletypes,
+ 'action' => 'add'
+ ];
}
}
$accepted = $cb->get_supported_extensions_as_string($context);
if (!empty($accepted)) {
$importurl = new moodle_url('/contentbank/upload.php', ['contextid' => $contextid]);
- $toolbar[] = array('name' => get_string('upload', 'contentbank'), 'link' => $importurl, 'icon' => 'i/upload');
+ $toolbar[] = [
+ 'name' => get_string('upload', 'contentbank'),
+ 'link' => $importurl,
+ 'icon' => 'i/upload',
+ 'action' => 'upload'
+ ];
}
}
--- /dev/null
+<?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'
+ ],
+ ];
+}
}
}}
-<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>
</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}}
{{/typeeditorparams}}
{{#typeeditorparams}}
<a class="dropdown-item icon-size-4" href="{{{ baseurl }}}&{{{ typeeditorparams }}}">
- <img alt="" class="icon" src="{{{ typeicon }}}"> {{ typename }}
+ {{#typeicon}}
+ <img alt="" class="icon" src="{{{ typeicon }}}">
+ {{/typeicon}}
+ {{^typeicon}}
+ {{#pix}} b/h5p_library, core {{/pix}}
+ {{/typeicon}} {{ typename }}
</a>
{{/typeeditorparams}}
{{/types}}
| 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"
--- /dev/null
+@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"
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;
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
+ );
+ }
}
events.forEach((event) => {
document.addEventListener(event, async(e) => {
if (e.target.closest(selectors.elements.sectionmodchooser)) {
+ let caller;
// We need to know who called this.
// Standard courses use the ID in the main section info.
const sectionDiv = e.target.closest(selectors.elements.section);
// Front page courses need some special handling.
const button = e.target.closest(selectors.elements.sectionmodchooser);
+
// If we don't have a section ID use the fallback ID.
- const caller = sectionDiv || button;
+ // We always want the sectionDiv caller first as it keeps track of section ID's after DnD changes.
+ // The button attribute is always just a fallback for us as the section div is not always available.
+ // A YUI change could be done maybe to only update the button attribute but we are going for minimal change here.
+ if (sectionDiv !== null && sectionDiv.hasAttribute('data-sectionid')) {
+ // We check for attributes just in case of outdated contrib course formats.
+ caller = sectionDiv;
+ } else {
+ caller = button;
+ }
// We want to show the modal instantly but loading whilst waiting for our data.
let bodyPromiseResolver;
preference is set, or when the theme sets $THEME->enablecourseajaxtheme to false. Formats which directly access
the '.section_add_menus' element or its children should be updated accordingly.
+* section_header() now needs to include 'data-sectionid' => $section->section in the .section li to ensure correct section
+ selection for the Activity Chooser.
+
=== 3.8 ===
* The following functions have been finally deprecated and can not be used anymore:
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:
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"
$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(
$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)) {
$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')));
}
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
$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';
$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;
}
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]
);
}
}
$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) {
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;
$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]);
}
/**
"//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.
--- /dev/null
+@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"
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"
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
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
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();
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);
$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);
inputname,
cancelSubmitCallback
);
+ document.querySelector('#' + elementId + ' iframe').setAttribute('name', 'h5p-editor');
};
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.
*
$contentvalidator = $factory->get_content_validator();
$editorajaxtoken = core::createToken(editor_ajax::EDITOR_AJAX_TOKEN);
+ $sesskey = sesskey();
$settings['editor'] = [
'filesPath' => $filespathbase . 'editor',
'fileIcon' => [
'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(),
)
WHERE hl2.runnable = 1
AND hl1.majorversion is null
- ORDER BY hl2.machinename";
+ ORDER BY hl2.title";
return $DB->get_records_sql($sql);
}
}
$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'],
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);
];
$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;
--- /dev/null
+<?php
+
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package installer
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['thislanguage'] = 'Mirandés';
$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';
editsettings,core_badges
availablelicenses,core_admin
managelicenses,core_admin
+userfilterplaceholder,core
$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';
$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';
// Deprecated since Moodle 3.9.
$string['participantscount'] = 'Number of participants: {$a}';
+$string['userfilterplaceholder'] = 'Search keyword or select filter';
$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:';
* the database.
*
* @access private
- * @param string $component examples: 'moodle', 'mod/forum', 'block/quiz_results'
+ * @param string $component examples: 'moodle', 'mod_forum', 'block_quiz_results'
* @return boolean true if success, exception in case of any problems
*/
function update_capabilities($component = 'moodle') {
private $forceltr = null;
/** @var array list of other settings that may cause this setting to be hidden */
private $dependenton = [];
+ /** @var bool Whether this setting uses a custom form control */
+ protected $customcontrol = false;
/**
* Constructor
public function get_dependent_on() {
return $this->dependenton;
}
+
+ /**
+ * Whether this setting uses a custom form control.
+ * This function is especially useful to decide if we should render a label element for this setting or not.
+ *
+ * @return bool
+ */
+ public function has_custom_form_control(): bool {
+ return $this->customcontrol;
+ }
}
/**
$context->description = highlight($query, markdown_to_html($description));
$context->element = $form;
$context->forceltr = $setting->get_force_ltr();
+ $context->customcontrol = $setting->has_custom_form_control();
return $OUTPUT->render_from_template('core_admin/setting', $context);
}
$this->filearea = $filearea;
$this->itemid = $itemid;
$this->options = (array)$options;
+ $this->customcontrol = true;
}
/**
'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'],
+ ],
];
}
$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);
+ }
+ }
}
.//*[contains(., %locator%) and not(.//*[contains(., %locator%)])]
XPATH
, 'form_row' => <<<XPATH
-.//*[self::label or self::div[contains(concat(' ', @class, ' '), ' fstaticlabel ')]][contains(., %locator%)]/ancestor::*[contains(concat(' ', @class, ' '), ' fitem ')]
+.//*[contains(concat(' ', @class, ' '), ' col-form-label ')]
+ [normalize-space(.)= %locator%]
+ /ancestor::*[contains(concat(' ', @class, ' '), ' fitem ')]
XPATH
, 'autocomplete_selection' => <<<XPATH
.//div[contains(concat(' ', normalize-space(@class), ' '), concat(' ', 'form-autocomplete-selection', ' '))]/span[@role='listitem'][contains(normalize-space(.), %locator%)]
,
'filemanager' => <<<XPATH
.//*[@data-fieldtype = 'filemanager' or @data-fieldtype = 'filepicker']
- /descendant::input[@id = //label[contains(normalize-space(string(.)), %locator%)]/@for]
+ /descendant::input[@id = substring-before(//p[contains(normalize-space(string(.)), %locator%)]/@id, '_label')]
XPATH
,
'passwordunmask' => <<<XPATH
$filepickerelement = behat_context_helper::escape($filepickerelement);
$filepickercontainer = $this->find(
'xpath',
- "//input[./@id = //label[normalize-space(.)=$filepickerelement]/@for]" .
+ "//input[./@id = substring-before(//p[normalize-space(.)=$filepickerelement]/@id, '_label')]" .
"//ancestor::*[@data-fieldtype = 'filemanager' or @data-fieldtype = 'filepicker']",
$exception
);
// Defaults to label.
if ($locatortype == 'label' || $locatortype == false) {
- $labelnode = $this->session->getPage()->find('xpath', '//label[@for="' . $fieldid . '"]');
+ $labelnode = $this->session->getPage()->find('xpath', "//label[@for='$fieldid']|//p[@id='{$fieldid}_label']");
// Exception only if $locatortype was specified.
if (!$labelnode && $locatortype == 'label') {
$fieldlabel = $this->get_field_locator();
// Get the name of the current directory elements.
- $xpath = "//label[contains(., '" . $fieldlabel . "')]" .
+ $xpath = "//p[normalize-space(.)='$fieldlabel']" .
"/ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' fitem ')]" .
"/descendant::div[@data-fieldtype = 'filemanager']" .
"/descendant::div[contains(concat(' ', normalize-space(@class), ' '), ' fp-filename ')]";
// 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;
'capabilities' => '',
'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE],
],
- 'core_table_dynamic_fetch' => [
- 'classname' => 'core_table\external\dynamic\fetch',
+ 'core_table_get_dynamic_table_content' => [
+ 'classname' => 'core_table\external\dynamic\get',
'methodname' => 'execute',
- 'description' => 'Fetch a dynamic table view raw html',
+ 'description' => 'Get the dynamic table content raw html',
'type' => 'read',
'ajax' => true,
'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE],
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);
+}
value="{{element.selectedvalue}}"
{{/element.selectedvalue}}
{{#element.checked}}checked{{/element.checked}}
- size="{{element.size}}"
{{#error}}
autofocus aria-describedby="{{element.iderror}}"
{{/error}}
value="1"
{{/element.value}}
{{#element.checked}}checked{{/element.checked}}
- size="{{element.size}}"
{{#error}}
autofocus aria-describedby="{{element.iderror}}"
{{/error}}
{{< core_form/element-template }}
+ {{$label}}
+ {{^element.hiddenlabel}}
+ <p id="{{element.id}}_label" class="col-form-label d-inline" aria-hidden="true">
+ {{{label}}}
+ </p>
+ {{/element.hiddenlabel}}
+ {{/label}}
{{$element}}
- {{{element.html}}}
+ <fieldset class="w-100 m-0 p-0 border-0" id="{{element.id}}_fieldset">
+ <legend class="sr-only">{{label}}</legend>
+ {{{element.html}}}
+ </fieldset>
{{/element}}
{{/ core_form/element-template }}
+{{#js}}
+(function() {
+ var label = document.getElementById('{{element.id}}_label');
+ if (label) {
+ label.style.cursor = 'default';
+ label.addEventListener('click', function() {
+ document.querySelectorAll('#{{element.id}}_fieldset div.fp-toolbar a')[0].focus();
+ });
+ }
+})();
+{{/js}}
{{< core_form/element-template }}
+ {{$label}}
+ {{^element.hiddenlabel}}
+ <p id="{{element.id}}_label" class="col-form-label d-inline" aria-hidden="true">
+ {{{label}}}
+ </p>
+ {{/element.hiddenlabel}}
+ {{/label}}
{{$element}}
- {{{element.html}}}
+ <fieldset class="w-100 m-0 p-0 border-0" id="{{element.id}}_fieldset">
+ <legend class="sr-only">{{label}}</legend>
+ {{{element.html}}}
+ </fieldset>
{{/element}}
{{/ core_form/element-template }}
+{{#js}}
+(function() {
+ var label = document.getElementById('{{element.id}}_label');
+ if (label) {
+ label.style.cursor = 'default';
+ label.addEventListener('click', function() {
+ document.querySelectorAll('#{{element.id}}_fieldset .fp-btn-choose')[0].focus();
+ });
+ }
+})();
+{{/js}}
{{< core_form/element-template-inline }}
{{$element}}
- <div class="d-flex flex-wrap">
+ <div class="d-flex flex-wrap align-items-center">
{{#element.elements}}
{{{separator}}}
{{{html}}}
{{$element}}
<fieldset class="w-100 m-0 p-0 border-0">
<legend class="sr-only">{{label}}</legend>
- <div class="d-flex flex-wrap">
+ <div class="d-flex flex-wrap align-items-center">
{{#element.elements}}
{{{separator}}}
{{{html}}}
require(['jquery'], function($) {
$('#{{element.id}}_label').css('cursor', 'default');
$('#{{element.id}}_label').click(function() {
- $('#{{element.id}}').find('button, a, input, select, textarea, [tabindex]:not([tabindex="-1"])').filter(':enabled').first().focus();
+ $('#{{element.id}}')
+ .find('button, a, input:not([type="hidden"]), select, textarea, [tabindex]')
+ .filter(':not([disabled]):not([tabindex="0"]):not([tabindex="-1"])')
+ .first().focus();
});
});
{{/js}}
name="{{element.name}}"
id="{{element.id}}"
value="{{element.value}}"
- size="{{element.size}}"
+ {{#element.size}}size="{{element.size}}"{{/element.size}}
{{#error}}
autofocus aria-describedby="{{element.iderror}}"
{{/error}} {{{element.attributes}}}>
name="{{element.name}}"
id="{{element.id}}"
value="{{element.value}}"
- size="{{element.size}}"
+ {{#element.size}}size="{{element.size}}"{{/element.size}}
{{#error}}
autofocus aria-describedby="{{element.iderror}}"
{{/error}} {{{element.attributes}}}>
{{#advanced}}<abbr class="initialism text-info" title="{{#str}}advanced{{/str}}">!</abbr>{{/advanced}}
{{{helpbutton}}}
</span>
- {{$ label }}
+ {{# label}}{{$ label }}
{{^element.staticlabel}}
<label class="col-form-label d-inline {{#element.hiddenlabel}}sr-only{{/element.hiddenlabel}}" for="{{element.id}}">
{{{label}}}
{{{label}}}
</span>
{{/element.staticlabel}}
- {{/ label }}
+ {{/ label }}{{/ label}}
</div>
<div class="col-md-9 form-inline felement" data-fieldtype="{{element.type}}">
{{$ element }}
readonly {{#element.hardfrozen}}disabled{{/element.hardfrozen}}
{{/element.frozen}}
value="{{element.value}}"
- size="{{element.size}}"
+ {{#element.size}}size="{{element.size}}"{{/element.size}}
{{#error}}
autofocus aria-describedby="{{element.iderror}}"
{{/error}}
{{/element.frozen}}
id="{{element.id}}"
value="{{element.value}}"
- size="{{element.size}}"
+ {{#element.size}}size="{{element.size}}"{{/element.size}}
{{#error}}
autofocus aria-describedby="{{element.iderror}}"
{{/error}}
name="{{element.name}}"
id="{{element.id}}"
value="{{element.value}}"
- size="{{element.size}}"
+ {{#element.size}}size="{{element.size}}"{{/element.size}}
{{#error}}
autofocus aria-describedby="{{element.iderror}}"
{{/error}}
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.
*
$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();
+ }
}
/**
* @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;
$questionstagobjects = core_tag_tag::get_items_tags('core_question', 'question', $questionids);
foreach ($questions as $question) {
- $tagobjects = $questionstagobjects[$question->id];
+ $tagobjects = $questionstagobjects[$question->id] ?? [];
foreach ($tagobjects as $tagobject) {
$tagid = $tagobject->taginstanceid;
* @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;
const filterset = getFiltersetFromTable(tableRoot);
addIconToContainer(tableRoot);
+ const pendingPromise = new Pending('core_table/dynamic:refreshTableContent');
+
return fetchTableData(
tableRoot.dataset.tableComponent,
tableRoot.dataset.tableHandler,
bubbles: true,
}));
+ return tableRoot;
+ })
+ .then(tableRoot => {
+ pendingPromise.resolve();
+
return 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);
// 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.
*
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.
*
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.
*
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.
*
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.
*
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.
*
hiddenColumns = {}
} = {}, resetPreferences = false) => {
return fetchMany([{
- methodname: `core_table_dynamic_fetch`,
+ methodname: `core_table_get_dynamic_table_content`,
args: {
component,
handler,
use external_single_structure;
use external_value;
use external_warnings;
-use moodle_url;
/**
* Core table external functions.
* @copyright 2020 Simey Lameze <simey@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class fetch extends external_api {
+class get extends external_api {
/**
* Describes the parameters for fetching the table html.
}
/**
- * External function to fetch a table view.
+ * External function to get the table view content.
*
* @param string $component The component.
* @param string $handler Dynamic table class name.
*/
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;
* @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;
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
/**
- * Unit tests for core_table\external\fetch;
+ * Unit tests for core_table\external\dynamic\get;
*
* @package core_table
* @category test
use advanced_testcase;
/**
- * Unit tests for core_table\external\fetch;
+ * Unit tests for core_table\external\dynamic\get;
*
* @package core_table
* @category test
* @copyright 2020 Simey Lameze <simey@moodle.com>
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
-class fetch_test extends advanced_testcase {
+class get_test extends advanced_testcase {
/**
* Setup before class.
$this->resetAfterTest();
$this->expectException(\invalid_parameter_exception::class);
- fetch::execute(
+ get::execute(
"core-user",
"participants",
"",
$this->resetAfterTest();
$this->expectException(\UnexpectedValueException::class);
- fetch::execute(
+ get::execute(
"core_users",
"participants",
"",
$this->expectExceptionMessage("Table handler class {$handler} not found. Please make sure that your table handler class is under the \\core_user\\table namespace.");
// Tests that invalid users_participants_table class gets an exception.
- fetch::execute(
+ get::execute(
"core_user",
"users_participants_table",
"",
$this->expectExceptionMessage("Invalid parameter value detected (filters => Invalid parameter value detected " .
"(Missing required key in single structure: name): Missing required key in single structure: name");
- fetch::execute(
+ get::execute(
"core_user",
"participants", "user-index-participants-{$course->id}",
$this->get_sort_array(['firstname' => SORT_ASC]),
}
/**
- * Test execute fetch table.
+ * Test execute method.
*/
- public function test_execute_fetch_table(): void {
+ public function test_table_get_execute(): void {
$this->resetAfterTest();
$course = $this->getDataGenerator()->create_course();
]
];
- $participantstable = fetch::execute(
+ $participantstable = get::execute(
"core_user",
"participants",
"user-index-participants-{$course->id}",
--- /dev/null
+{{!
+ 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">×</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}}
</div>
</div>
<div class="fp-original form-group row mx-0">
- <label class="form-control-label col-4 px-0">{{#str}}original, repository{{/str}}</label>
+ <div class="form-control-label col-4 px-0">{{#str}}original, repository{{/str}}</div>
<div class="col-8 form-inline">
<span class="fp-originloading">{{#pix}}i/loading_small{{/pix}} {{#str}}loading, repository{{/str}}</span><span class="fp-value"></span>
</div>
</div>
<div class="fp-reflist form-group row mx-0">
- <label class="form-control-label col-4 px-0">{{#str}}referenceslist, repository{{/str}}</label>
+ <div class="form-control-label col-4 px-0">{{#str}}referenceslist, repository{{/str}}</div>
<div class="col-8 form-inline">
<p class="fp-refcount"></p>
<span class="fp-reflistloading">{{#pix}}i/loading_small{{/pix}} {{#str}}loading, repository{{/str}}</span>
var bb;
if (this.get('closeButton') !== false) {
- // The buttons constructor does not allow custom attributes
- this.get('buttons').header[0].setAttribute('title', this.get('closeButtonTitle'));
+ var title = this.get('closeButtonTitle');
+ // The buttons constructor does not allow custom attributes.
+ this.get('buttons').header[0].setAttribute('title', title);
+ this.get('buttons').header[0].setAttribute('aria-label', title);
}
// Initialise the element cache.
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}}
<h3 class="h6 font-weight-bold">{{#str}} privacy, message {{/str}}</h3>
<p>{{#str}} privacy_desc, message {{/str}}</p>
<div data-preference="blocknoncontacts" class="mb-3">
- {{#privacy}}
- <div class="custom-control custom-radio mb-2">
- <input
- type="radio"
- name="message_blocknoncontacts"
- class="custom-control-input"
- id="block-noncontacts-{{uniqid}}-{{value}}"
- value="{{value}}"
- >
- <label class="custom-control-label ml-2" for="block-noncontacts-{{uniqid}}-{{value}}">
- {{text}}
- </label>
- </div>
- {{/privacy}}
+ <fieldset>
+ <legend class="sr-only">{{#str}} contactableprivacy, message {{/str}}</legend>
+ {{#privacy}}
+ <div class="custom-control custom-radio mb-2">
+ <input
+ type="radio"
+ name="message_blocknoncontacts"
+ class="custom-control-input"
+ id="block-noncontacts-{{uniqid}}-{{value}}"
+ value="{{value}}"
+ >
+ <label class="custom-control-label ml-2" for="block-noncontacts-{{uniqid}}-{{value}}">
+ {{text}}
+ </label>
+ </div>
+ {{/privacy}}
+ </fieldset>
</div>
<div class="hidden" data-region="notification-preference-container">
along with Moodle. If not, see <http://www.gnu.org/licenses/>.
}}
{{!
- @template core_message/message_drawer
+ @template core_message/message_index
This template will render the message drawer.
'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' => [],
];
'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),
'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
),
* 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),
'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;
}
}
]),
];
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);
}
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,
// 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'),
],
];
-<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
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'mod_h5pactivity';
-$plugin->version = 2020052100;
+$plugin->version = 2020052900;
$plugin->requires = 2020013000;
$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) {
$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);
$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);
$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
+ ));
}
}
}
$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));
}
$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);
$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 {
// 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;
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))) {
*
* @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;
}
$feedbackformat = FORMAT_MOODLE;
$feedback = null;
- if (isset($score->comment) && !empty($score->comment)) {
+ if (!empty($score->comment)) {
$feedback = $score->comment;
$feedbackformat = FORMAT_PLAIN;
}
$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);
}
* @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) {
* @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);
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());
}
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());
}
* @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",
$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();
}
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 {
$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');
'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,
);
'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';
}
/** Mod quiz edit **/
-#page-mod-quiz-edit .statusbar {
- margin: 0.6em 0.4em;
-}
-
#page-mod-quiz-edit .statusdisplay {
background-color: #ffc;
clear: both;
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;
}
#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;
}
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
- width: 70%;
display: inline-block;
height: 20px;
}
#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 {
#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 {
}
#page-mod-quiz-edit .section .activity .actions {
+ position: inherit;
white-space: nowrap;
background: #e6e6e6;
padding: 0.1em 0;
#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%;
}
}
// 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) {
}
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.
// manually call dynload for parent elements in the tree so we can load other siblings
if (options.dynload) {
var root = scope.treeview.getRoot();
+ // Whether search results are currently displayed in the active repository in the filepicker.
+ // We do not want to load siblings of parent elements when displaying search tree results.
+ var isSearchResult = typeof options.callbackcontext.active_repo !== 'undefined' &&
+ options.callbackcontext.active_repo.issearchresult;
while (root && root.children && root.children.length) {
root = root.children[0];
if (root.path == mytreeel.path) {
root.origpath = options.filepath;
root.origlist = fileslist;
+ } else if (!root.isLeaf && root.expanded && !isSearchResult) {
+ Y.bind(options.treeview_dynload, options.callbackcontext)(root, null);
}
- // Removed bind as of MDL-62415 as it overwrites the search tree results
}
}
} else {
| Name | Folder name |
| Description | Folder description |
And I upload "lib/tests/fixtures/upload_users.csv" file to "Files" filemanager
- And I click on "//label[contains(., 'Files')]/ancestor::div[contains(concat(' ', @class, ' '), ' fitem ')]//*[contains(@title, 'Add...')]" "xpath_element"
+ And I click on "Add..." "button" in the "Files" "form_row"
And I click on "Recent files" "link" in the ".fp-repo-area" "css_element"
And I click on "//a[contains(concat(' ', normalize-space(@class), ' '), ' fp-file ')][normalize-space(.)='empty.txt']" "xpath_element"
And I click on ".moodle-dialogue-focused .fp-select .fp-select-cancel" "css_element"
And I click on "Save and display" "button"
And I follow "Dashboard" in the user menu
And I follow "Manage private files"
- And I click on "//label[contains(., 'Files')]/ancestor::div[contains(concat(' ', @class, ' '), ' fitem ')]//*[contains(@title, 'Add...')]" "xpath_element"
+ And I click on "Add..." "button" in the "Files" "form_row"
And I click on "Recent files" "link" in the ".fp-repo-area" "css_element"
And I click on "Display folder with file icons" "link" in the ".file-picker" "css_element"
And I click on "//a[contains(concat(' ', normalize-space(@class), ' '), ' fp-file ')][normalize-space(.)='empty.txt']" "xpath_element"
And I click on "Save and display" "button"
And I follow "Dashboard" in the user menu
And I follow "Manage private files"
- And I click on "//label[contains(., 'Files')]/ancestor::div[contains(concat(' ', @class, ' '), ' fitem ')]//*[contains(@title, 'Add...')]" "xpath_element"
+ And I click on "Add..." "button" in the "Files" "form_row"
And I click on "Recent files" "link" in the ".fp-repo-area" "css_element"
And I click on "Display folder with file details" "link" in the ".file-picker" "css_element"
And I click on "//div[contains(concat(' ', normalize-space(@class), ' '), ' file-picker ')]/descendant::span[normalize-space(.)='empty.txt']/ancestor::a" "xpath_element"
And I click on "Save and display" "button"
And I follow "Dashboard" in the user menu
And I follow "Manage private files"
- And I click on "//label[contains(., 'Files')]/ancestor::div[contains(concat(' ', @class, ' '), ' fitem ')]//*[contains(@title, 'Add...')]" "xpath_element"
+ And I click on "Add..." "button" in the "Files" "form_row"
And I click on "Recent files" "link" in the ".fp-repo-area" "css_element"
And I click on "Display folder as file tree" "link" in the ".file-picker" "css_element"
And I click on "//div[contains(concat(' ', normalize-space(@class), ' '), ' file-picker ')]/descendant::span[normalize-space(.)='empty.txt']/ancestor::a" "xpath_element"
$filepickerelement = behat_context_helper::escape($filepickerelement);
$filepickercontainer = $this->find(
'xpath',
- "//input[./@id = //label[normalize-space(.)=$filepickerelement]/@for]" .
- "//ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' felement ')] |" .
- "//input[./@id = //label[normalize-space(.)=$filepickerelement]/@for]" .
- "//ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' form-setting ')]",
+ "//input[./@id = substring-before(//p[normalize-space(.)=$filepickerelement]/@id, '_label')]" .
+ "//ancestor::*[@data-fieldtype = 'filemanager' or @data-fieldtype = 'filepicker']",
$exception
);
}
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.
*/
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');
+ }
});
};
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,
+};
* @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);
});
});
});
-});
+};
.block_recentlyaccesseditems {
img.icon {
- height: auto;
- width: auto;
+ height: 24px;
+ width: 24px;
margin-right: 6px;
}
}
}
}
-.cb-toolbar .dropdown-scrollable {
+.cb-toolbar-container .dropdown-scrollable {
max-height: 190px;
overflow-y: auto;
-}
\ No newline at end of file
+}
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 {
#page-mod-quiz-edit ul.slots .activityinstance {
> a {
display: flex;
+ max-width: 100%;
align-items: center;
text-indent: 0;
padding-left: 0;
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;
margin-top: 0; }
.block_recentlyaccesseditems img.icon {
- height: auto;
- width: auto;
+ height: 24px;
+ width: 24px;
margin-right: 6px; }
.block_myoverview .content {
.content-bank-container.view-list .cb-btnsort.dir-desc .desc {
display: block; }
-.cb-toolbar .dropdown-scrollable {
+.cb-toolbar-container .dropdown-scrollable {
max-height: 190px;
overflow-y: auto; }
#page-mod-quiz-edit ul.slots .activityinstance > a {
display: flex;
+ max-width: 100%;
align-items: center;
text-indent: 0;
padding-left: 0; }
{{{ 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}}
</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');
});
</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}}
.blockcolumn,
.region-main {
flex: 0 0 100%;
+ max-width: 100%;
padding: 0 1rem;
margin-bottom: 1rem;
}
}
}
-.block_myoverview,
-.block_recentlyaccesseditems {
- .dashboard-card-deck {
- .dashboard-card {
- width: calc(33.33% - #{$card-gutter});
+@include media-breakpoint-up(sm) {
+ .block_myoverview,
+ .block_recentlyaccesseditems {
+ .dashboard-card-deck {
+ .dashboard-card {
+ width: calc(33.33% - #{$card-gutter});
+ }
}
}
}
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;
margin-top: 0; }
.block_recentlyaccesseditems img.icon {
- height: auto;
- width: auto;
+ height: 24px;
+ width: 24px;
margin-right: 6px; }
.block_myoverview .content {
.content-bank-container.view-list .cb-btnsort.dir-desc .desc {
display: block; }
-.cb-toolbar .dropdown-scrollable {
+.cb-toolbar-container .dropdown-scrollable {
max-height: 190px;
overflow-y: auto; }
#page-mod-quiz-edit ul.slots .activityinstance > a {
display: flex;
+ max-width: 100%;
align-items: center;
text-indent: 0;
padding-left: 0; }
.blockcolumn,
.region-main {
flex: 0 0 100%;
+ max-width: 100%;
padding: 0 1rem;
margin-bottom: 1rem; } }
padding: 0 1rem 0 !important;
/* stylelint-disable-line declaration-no-important */ } }
-.block_myoverview .dashboard-card-deck .dashboard-card,
-.block_recentlyaccesseditems .dashboard-card-deck .dashboard-card {
- width: calc(33.33% - 0.5rem); }
+@media (min-width: 576px) {
+ .block_myoverview .dashboard-card-deck .dashboard-card,
+ .block_recentlyaccesseditems .dashboard-card-deck .dashboard-card {
+ width: calc(33.33% - 0.5rem); } }
@media (min-width: 768px) {
.blockcolumn .dashboard-card-deck {
</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}}
</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}}
</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}}
$exception
);
} else {
- // Gets the ffilemanager node specified by the locator which contains the filepicker container.
+ // Gets the filemanager node specified by the locator which contains the filepicker container.
$filepickerelement = behat_context_helper::escape($filepickerelement);
$filepickercontainer = $this->find(
'xpath',
- "//input[./@id = //label[normalize-space(.)=$filepickerelement]/@for]" .
+ "//input[./@id = substring-before(//p[normalize-space(.)=$filepickerelement]/@id, '_label')]" .
"//ancestor::div[contains(concat(' ', normalize-space(@class), ' '), ' felement ')]",
$exception
);
*
* @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);
}
/**
/**
* 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.
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,
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.
*
all: `${getFilterRegion('filtertypedata')} [data-field-name]`,
},
typeList: getFilterRegion('filtertypelist'),
+ typeListSelect: `select${getFilterRegion('filtertypelist')}`,
},
};
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';
* @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);
*
* @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;
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];
};
/**
*
* @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);
/**
* 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);
+ }
+ });
};
/**
}
};
+ /**
+ * 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.
*
);
};
+ /**
+ * 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)) {
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);
+ }
};
/**
* 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
/**
* Init function.
*
+ * @deprecated since Moodle 3.9 MDL-68612 - user unified filter replaced by participants filter.
* @method init
* @private
*/
/**
* Return the unified user filter form.
*
+ * @deprecated since Moodle 3.9 MDL-68612 - user unified filter replaced by participants filter.
* @method getForm
* @return {DOMElement}
*/
/**
* 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.
*
$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;
}
'tableregionid' => $this->tableregionid,
'courseid' => $this->context->instanceid,
'filtertypes' => $this->get_filtertypes(),
+ 'rownumber' => 1,
];
return $data;
/**
* 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
/**
* 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 {
* @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)) {
* @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";
$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)) {
* 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;
$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,
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);
-&nbs