Merge branch 'MDL-68615' of https://github.com/timhunt/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 6 May 2020 08:07:28 +0000 (10:07 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Wed, 6 May 2020 08:07:28 +0000 (10:07 +0200)
238 files changed:
.travis.yml
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/settings.php
admin/user/user_bulk_download.php
blocks/recent_activity/renderer.php
blocks/recent_activity/styles.css
calendar/amd/build/crud.min.js
calendar/amd/build/crud.min.js.map
calendar/amd/build/modal_delete.min.js
calendar/amd/build/modal_delete.min.js.map
calendar/amd/src/crud.js
calendar/amd/src/modal_delete.js
contentbank/amd/build/actions.min.js
contentbank/amd/build/actions.min.js.map
contentbank/amd/src/actions.js
contentbank/classes/content.php
contentbank/classes/contentbank.php
contentbank/classes/contenttype.php
contentbank/classes/external/rename_content.php [new file with mode: 0644]
contentbank/contenttype/h5p/classes/contenttype.php
contentbank/contenttype/h5p/tests/behat/manage_content.feature [new file with mode: 0644]
contentbank/contenttype/h5p/tests/content_h5p_test.php
contentbank/templates/renamecontent.mustache [new file with mode: 0644]
contentbank/tests/behat/events.feature [new file with mode: 0644]
contentbank/tests/content_test.php
contentbank/tests/contentbank_test.php
contentbank/tests/contenttype_test.php
contentbank/tests/external/rename_content_test.php [new file with mode: 0644]
contentbank/upload.php
contentbank/view.php
course/classes/management_renderer.php
course/format/topics/styles.css [deleted file]
course/format/weeks/styles.css [deleted file]
course/recent.php
course/renderer.php
dataformat/pdf/classes/writer.php
dataformat/upgrade.txt
enrol/database/settings.php
enrol/database/upgrade.txt
grade/grading/form/guide/lang/en/gradingform_guide.php
grade/grading/form/guide/templates/grades/grader/gradingpanel.mustache
h5p/tests/editor_test.php
install/lang/fr/error.php
lang/en/admin.php
lang/en/contentbank.php
lang/en/moodle.php
lang/en/role.php
lib/amd/build/local/modal/alert.min.js [new file with mode: 0644]
lib/amd/build/local/modal/alert.min.js.map [new file with mode: 0644]
lib/amd/build/modal.min.js
lib/amd/build/modal.min.js.map
lib/amd/build/modal_cancel.min.js
lib/amd/build/modal_cancel.min.js.map
lib/amd/build/modal_factory.min.js
lib/amd/build/modal_factory.min.js.map
lib/amd/build/modal_registry.min.js
lib/amd/build/modal_registry.min.js.map
lib/amd/build/modal_save_cancel.min.js
lib/amd/build/modal_save_cancel.min.js.map
lib/amd/build/notification.min.js
lib/amd/build/notification.min.js.map
lib/amd/build/prefetch.min.js [new file with mode: 0644]
lib/amd/build/prefetch.min.js.map [new file with mode: 0644]
lib/amd/build/templates.min.js
lib/amd/build/templates.min.js.map
lib/amd/src/local/modal/alert.js [new file with mode: 0644]
lib/amd/src/modal.js
lib/amd/src/modal_cancel.js
lib/amd/src/modal_factory.js
lib/amd/src/modal_registry.js
lib/amd/src/modal_save_cancel.js
lib/amd/src/notification.js
lib/amd/src/prefetch.js [new file with mode: 0644]
lib/amd/src/templates.js
lib/behat/classes/behat_core_generator.php
lib/classes/dataformat.php [new file with mode: 0644]
lib/classes/dataformat/base.php
lib/classes/dataformat/spout_base.php
lib/classes/event/contentbank_content_created.php [new file with mode: 0644]
lib/classes/event/contentbank_content_deleted.php [new file with mode: 0644]
lib/classes/event/contentbank_content_updated.php [new file with mode: 0644]
lib/classes/event/contentbank_content_uploaded.php [new file with mode: 0644]
lib/classes/event/contentbank_content_viewed.php [new file with mode: 0644]
lib/classes/task/completion_daily_task.php
lib/dataformatlib.php
lib/db/access.php
lib/db/services.php
lib/editor/atto/plugins/accessibilitychecker/tests/behat/accessibilitychecker.feature
lib/editor/atto/plugins/bold/tests/behat/bold.feature
lib/editor/atto/plugins/bold/yui/build/moodle-atto_bold-button/moodle-atto_bold-button-debug.js
lib/editor/atto/plugins/bold/yui/build/moodle-atto_bold-button/moodle-atto_bold-button-min.js
lib/editor/atto/plugins/bold/yui/build/moodle-atto_bold-button/moodle-atto_bold-button.js
lib/editor/atto/plugins/bold/yui/src/button/js/button.js
lib/editor/atto/plugins/image/lang/en/atto_image.php
lib/editor/atto/plugins/image/tests/behat/image.feature
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-debug.js
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-min.js
lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button.js
lib/editor/atto/plugins/image/yui/src/button/js/button.js
lib/editor/atto/plugins/italic/tests/behat/italic.feature
lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-debug.js
lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-min.js
lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button.js
lib/editor/atto/plugins/italic/yui/src/button/js/button.js
lib/editor/atto/plugins/strike/tests/behat/strike.feature
lib/editor/atto/plugins/strike/yui/build/moodle-atto_strike-button/moodle-atto_strike-button-debug.js
lib/editor/atto/plugins/strike/yui/build/moodle-atto_strike-button/moodle-atto_strike-button-min.js
lib/editor/atto/plugins/strike/yui/build/moodle-atto_strike-button/moodle-atto_strike-button.js
lib/editor/atto/plugins/strike/yui/src/button/js/button.js
lib/editor/atto/tests/behat/customtoolbar.feature
lib/editor/atto/tests/behat/disablecontrol.feature
lib/form/course.php
lib/form/tests/course_test.php [new file with mode: 0644]
lib/outputrequirementslib.php
lib/plist/classes/CFPropertyList/CFBinaryPropertyList.php
lib/plist/readme_moodle.txt
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/build/local/dynamic/repository.min.js
lib/table/amd/build/local/dynamic/repository.min.js.map
lib/table/amd/src/dynamic.js
lib/table/amd/src/local/dynamic/repository.js
lib/table/classes/dynamic.php
lib/table/classes/external/dynamic/fetch.php
lib/tablelib.php
lib/templates/drawer.mustache
lib/templates/local/modal/alert.mustache [new file with mode: 0644]
lib/tests/completion_daily_task_test.php [new file with mode: 0644]
lib/tests/dataformat_test.php [new file with mode: 0644]
lib/tests/event/contentbank_content_created_test.php [new file with mode: 0644]
lib/tests/event/contentbank_content_deleted_test.php [new file with mode: 0644]
lib/tests/event/contentbank_content_updated_test.php [new file with mode: 0644]
lib/tests/event/contentbank_content_uploaded_test.php [new file with mode: 0644]
lib/tests/event/contentbank_content_viewed_test.php [new file with mode: 0644]
lib/upgrade.txt
lib/xapi/classes/local/statement.php
lib/xapi/classes/local/statement/item_attachment.php [new file with mode: 0644]
lib/xapi/classes/local/statement/item_context.php [new file with mode: 0644]
lib/xapi/classes/local/statement/item_result.php [new file with mode: 0644]
lib/xapi/classes/local/statement/item_score.php [moved from enrol/database/settingslib.php with 53% similarity]
lib/xapi/tests/local/statement/item_attachment_test.php [new file with mode: 0644]
lib/xapi/tests/local/statement/item_context_test.php [new file with mode: 0644]
lib/xapi/tests/local/statement/item_result_test.php [new file with mode: 0644]
lib/xapi/tests/local/statement/item_score_test.php [new file with mode: 0644]
lib/xapi/tests/local/statement_test.php
media/player/videojs/amd/build/Youtube-lazy.min.js
media/player/videojs/amd/build/Youtube-lazy.min.js.map
media/player/videojs/amd/build/video-lazy.min.js
media/player/videojs/amd/build/video-lazy.min.js.map
media/player/videojs/amd/build/videojs-flash-lazy.min.js
media/player/videojs/amd/build/videojs-flash-lazy.min.js.map
media/player/videojs/amd/src/Youtube-lazy.js
media/player/videojs/amd/src/video-lazy.js
media/player/videojs/amd/src/videojs-flash-lazy.js
media/player/videojs/classes/plugin.php
media/player/videojs/fonts/VideoJS.svg [changed mode: 0644->0755]
media/player/videojs/fonts/VideoJS.ttf [changed mode: 0644->0755]
media/player/videojs/fonts/VideoJS.woff [changed mode: 0644->0755]
media/player/videojs/readme_moodle.txt
media/player/videojs/tests/player_test.php
media/player/videojs/thirdpartylibs.xml
media/player/videojs/videojs/lang/ar.js
media/player/videojs/videojs/lang/ar.json
media/player/videojs/videojs/lang/de.js
media/player/videojs/videojs/lang/de.json
media/player/videojs/videojs/lang/en.js
media/player/videojs/videojs/lang/en.json
media/player/videojs/videojs/lang/fa.js
media/player/videojs/videojs/lang/fa.json
media/player/videojs/videojs/lang/gd.json [changed mode: 0644->0755]
media/player/videojs/videojs/lang/nb.js
media/player/videojs/videojs/lang/nb.json
media/player/videojs/videojs/lang/nn.js
media/player/videojs/videojs/lang/nn.json
message/amd/build/message_drawer.min.js
message/amd/build/message_drawer.min.js.map
message/amd/build/message_drawer_helper.min.js
message/amd/build/message_drawer_helper.min.js.map
message/amd/build/message_popover.min.js
message/amd/build/message_popover.min.js.map
message/amd/build/message_user_button.min.js
message/amd/build/message_user_button.min.js.map
message/amd/src/message_drawer.js
message/amd/src/message_drawer_helper.js
message/amd/src/message_popover.js
message/amd/src/message_user_button.js
message/output/airnotifier/lang/en/message_airnotifier.php
message/output/airnotifier/settings.php
message/output/email/classes/task/send_email_task.php
message/output/email/lang/en/deprecated.txt [new file with mode: 0644]
message/output/email/lang/en/message_email.php
message/output/email/tests/send_email_task_test.php
message/templates/message_jumpto.mustache [new file with mode: 0644]
message/templates/message_popover.mustache
mod/assign/feedback/editpdf/tests/behat/view_previous_annotations.feature
mod/assign/lib.php
mod/assign/tests/behat/edit_previous_feedback.feature
mod/chat/lib.php
mod/feedback/classes/responses_table.php
mod/folder/lib.php
mod/forum/export.php
mod/forum/lib.php
mod/glossary/lib.php
mod/h5pactivity/backup/moodle2/backup_h5pactivity_stepslib.php
mod/h5pactivity/backup/moodle2/restore_h5pactivity_stepslib.php
mod/h5pactivity/lib.php
mod/h5pactivity/tests/local/attempt_test.php
mod/h5pactivity/tests/restore_test.php [new file with mode: 0644]
mod/h5pactivity/tests/xapi/handler_test.php
mod/survey/lib.php
mod/wiki/lib.php
mod/workshop/lib.php
question/format/gift/format.php
question/format/gift/tests/giftformat_test.php
question/type/ddmarker/amd/build/form.min.js
question/type/ddmarker/amd/build/form.min.js.map
question/type/ddmarker/amd/build/question.min.js
question/type/ddmarker/amd/build/question.min.js.map
question/type/ddmarker/amd/build/shapes.min.js
question/type/ddmarker/amd/build/shapes.min.js.map
question/type/ddmarker/amd/src/form.js
question/type/ddmarker/amd/src/question.js
question/type/ddmarker/amd/src/shapes.js
question/type/ddmarker/renderer.php
question/type/ddmarker/styles.css
question/type/ddmarker/tests/behat/behat_qtype_ddmarker.php
question/type/ddmarker/tests/behat/preview.feature
question/type/ddmarker/tests/walkthrough_test.php
tag/tests/behat/delete_tag.feature
theme/boost/scss/moodle/course.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/action_redir.php
user/classes/table/participants.php
user/index.php
user/tests/behat/view_participants.feature
version.php

index 245d7fc..f60032e 100644 (file)
@@ -18,7 +18,7 @@ services:
 
 php:
     # We only run the highest and lowest supported versions to reduce the load on travis-ci.org.
-    - 7.3
+    - 7.4
     - 7.2
 
 addons:
@@ -48,11 +48,11 @@ jobs:
     fast_finish: true
 
     include:
-          # Run mysql only on 7.3 - it's just too slow
-        - php: 7.3
+          # Run mysql only on highest - it's just too slow
+        - php: 7.4
           env: DB=mysqli   TASK=PHPUNIT
-          # Run grunt/npm install on highest version ('node' is an alias for the latest node.js version.)
-        - php: 7.2
+          # Run grunt/npm install on highest version too ('node' is an alias for the latest node.js version.)
+        - php: 7.4
           env: DB=none     TASK=GRUNT   NVM_VERSION='lts/carbon'
 
 cache:
index c6160d3..1136bd0 100644 (file)
@@ -49,6 +49,8 @@ class api {
     const LOGIN_VIA_EMBEDDED_BROWSER = 3;
     /** @var int seconds an auto-login key will expire. */
     const LOGIN_KEY_TTL = 60;
+    /** @var string URL of the Moodle Apps Portal */
+    const MOODLE_APPS_PORTAL_URL = 'https://apps.moodle.com';
 
     /**
      * Returns a list of Moodle plugins supporting the mobile app.
index 092f7da..ccc6867 100644 (file)
@@ -91,6 +91,7 @@ $string['mobilecssurl'] = 'CSS';
 $string['mobilefeatures'] = 'Mobile features';
 $string['mobilenotificationsdisabledwarning'] = 'Mobile notifications are not enabled. They should be enabled in Notification settings.';
 $string['mobilesettings'] = 'Mobile settings';
+$string['moodleappsportalfeatureswarning'] = 'Please note that some features may be restricted depending on your Moodle app subscription. For details, visit the <a href="{$a}" target="_blank">Moodle Apps Portal</a>.';
 $string['oauth2identityproviders'] = 'OAuth 2 identity providers';
 $string['offlineuse'] = 'Offline use';
 $string['pluginname'] = 'Moodle app tools';
index a9cdbcf..05ed4aa 100644 (file)
@@ -50,6 +50,12 @@ if ($hassiteconfig) {
 
     // Show only mobile settings if the mobile service is enabled.
     if (!empty($CFG->enablemobilewebservice)) {
+        // General notification about limited features due to app restrictions.
+        $notify = new \core\output\notification(
+            get_string('moodleappsportalfeatureswarning', 'tool_mobile', tool_mobile\api::MOODLE_APPS_PORTAL_URL),
+            \core\output\notification::NOTIFY_WARNING);
+        $featuresnotice = $OUTPUT->render($notify);
+
         // Type of login.
         $temp = new admin_settingpage('mobileauthentication', new lang_string('mobileauthentication', 'tool_mobile'));
         $options = array(
@@ -74,6 +80,8 @@ if ($hassiteconfig) {
         // Appearance related settings.
         $temp = new admin_settingpage('mobileappearance', new lang_string('mobileappearance', 'tool_mobile'));
 
+        $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeaturesappearance', '', $featuresnotice));
+
         $temp->add(new admin_setting_configtext('mobilecssurl', new lang_string('mobilecssurl', 'tool_mobile'),
                     new lang_string('configmobilecssurl', 'tool_mobile'), '', PARAM_URL));
 
@@ -106,6 +114,8 @@ if ($hassiteconfig) {
         // Features related settings.
         $temp = new admin_settingpage('mobilefeatures', new lang_string('mobilefeatures', 'tool_mobile'));
 
+        $temp->add(new admin_setting_heading('tool_mobile/moodleappsportalfeatures', '', $featuresnotice));
+
         $temp->add(new admin_setting_heading('tool_mobile/logout',
                     new lang_string('logout'), ''));
 
index babede0..4e5e8d3 100644 (file)
@@ -25,7 +25,6 @@
 define('NO_OUTPUT_BUFFERING', true);
 require_once('../../config.php');
 require_once($CFG->libdir.'/adminlib.php');
-require_once($CFG->libdir.'/dataformatlib.php');
 require_once($CFG->dirroot.'/user/profile/lib.php');
 
 $dataformat = optional_param('dataformat', '', PARAM_ALPHA);
@@ -69,9 +68,9 @@ if ($dataformat) {
     $downloadusers = new ArrayObject($SESSION->bulk_users);
     $iterator = $downloadusers->getIterator();
 
-    download_as_dataformat($filename, $dataformat, $fields, $iterator, function($userid) use ($extrafields, $fields) {
+    \core\dataformat::download_data($filename, $dataformat, $fields, $iterator, function($userid) use ($extrafields, $fields) {
         global $DB;
-        $row = array();
+
         if (!$user = $DB->get_record('user', array('id' => $userid))) {
             return null;
         }
index 2e96eeb..c5f86bb 100644 (file)
@@ -53,7 +53,7 @@ class block_recent_activity_renderer extends plugin_renderer_base {
         $output .= html_writer::tag('div',
                 html_writer::link(new moodle_url('/course/recent.php', array('id' => $course->id)),
                     get_string('recentactivityreport')),
-                array('class' => 'activityhead'));
+                array('class' => 'activityhead mb-3'));
 
         $content = false;
 
@@ -79,7 +79,7 @@ class block_recent_activity_renderer extends plugin_renderer_base {
         // Next, have there been any modifications to the course structure?
         if (!empty($structuralchanges)) {
             $content = true;
-            $output .= $this->heading(get_string("courseupdates").':', 3);
+            $output .= $this->heading(get_string("courseupdates") . ':', 6);
             foreach ($structuralchanges as $changeinfo => $change) {
                 $output .= $this->structural_change($change);
             }
index 3d1d9e3..f1e2c2a 100644 (file)
@@ -1,8 +1,3 @@
-.block_recent_activity .activitydate,
-.block_recent_activity .activityhead {
-    text-align: center;
-}
-
 .block_recent_activity .unlist li {
     margin-bottom: 1em;
 }
index d44cd94..31d3420 100644 (file)
Binary files a/calendar/amd/build/crud.min.js and b/calendar/amd/build/crud.min.js differ
index c6e0e09..499e7b6 100644 (file)
Binary files a/calendar/amd/build/crud.min.js.map and b/calendar/amd/build/crud.min.js.map differ
index 184a283..77dbc02 100644 (file)
Binary files a/calendar/amd/build/modal_delete.min.js and b/calendar/amd/build/modal_delete.min.js differ
index 4a4c185..090c326 100644 (file)
Binary files a/calendar/amd/build/modal_delete.min.js.map and b/calendar/amd/build/modal_delete.min.js.map differ
index 7853a67..c18cb56 100644 (file)
@@ -63,6 +63,7 @@ function(
      * @return {Promise}
      */
     function confirmDeletion(eventId, eventTitle, eventCount) {
+        var pendingPromise = new Pending('core_calendar/crud:confirmDeletion');
         var deleteStrings = [
             {
                 key: 'deleteevent',
@@ -139,6 +140,11 @@ function(
 
             return deleteModal;
         })
+        .then(function(modal) {
+            pendingPromise.resolve();
+
+            return modal;
+        })
         .catch(Notification.exception);
 
         return finalPromise;
index 9541dec..e457761 100644 (file)
@@ -55,6 +55,8 @@ function(
      */
     var ModalDelete = function(root) {
         Modal.call(this, root);
+
+        this.setRemoveOnClose(true);
     };
 
     ModalDelete.TYPE = 'core_calendar-modal_delete';
index 2e95d9a..4d78f53 100644 (file)
Binary files a/contentbank/amd/build/actions.min.js and b/contentbank/amd/build/actions.min.js differ
index 05f46fb..6e5344e 100644 (file)
Binary files a/contentbank/amd/build/actions.min.js.map and b/contentbank/amd/build/actions.min.js.map differ
index caa25c5..561cb99 100644 (file)
@@ -39,6 +39,7 @@ function($, Ajax, Notification, Str, Templates, Url, ModalFactory, ModalEvents)
      */
     var ACTIONS = {
         DELETE_CONTENT: '[data-action="deletecontent"]',
+        RENAME_CONTENT: '[data-action="renamecontent"]',
     };
 
     /**
@@ -108,6 +109,54 @@ function($, Ajax, Notification, Str, Templates, Url, ModalFactory, ModalEvents)
                 return;
             }).catch(Notification.exception);
         });
+
+        $(ACTIONS.RENAME_CONTENT).click(function(e) {
+            e.preventDefault();
+
+            var contentname = $(this).data('contentname');
+            var contentid = $(this).data('contentid');
+
+            var strings = [
+                {
+                    key: 'renamecontent',
+                    component: 'core_contentbank'
+                },
+                {
+                    key: 'rename',
+                    component: 'core_contentbank'
+                },
+            ];
+
+            var saveButtonText = '';
+            Str.get_strings(strings).then(function(langStrings) {
+                var modalTitle = langStrings[0];
+                saveButtonText = langStrings[1];
+
+                return ModalFactory.create({
+                    title: modalTitle,
+                    body: Templates.render('core_contentbank/renamecontent', {'contentid': contentid, 'name': contentname}),
+                    type: ModalFactory.types.SAVE_CANCEL
+                });
+            }).then(function(modal) {
+                modal.setSaveButtonText(saveButtonText);
+                modal.getRoot().on(ModalEvents.save, function() {
+                    // The action is now confirmed, sending an action for it.
+                    var newname = $("#newname").val();
+                    return renameContent(contentid, newname);
+                });
+
+                // Handle hidden event.
+                modal.getRoot().on(ModalEvents.hidden, function() {
+                    // Destroy when hidden.
+                    modal.destroy();
+                });
+
+                // Show the modal.
+                modal.show();
+
+                return;
+            }).catch(Notification.exception);
+        });
     };
 
     /**
@@ -146,6 +195,49 @@ function($, Ajax, Notification, Str, Templates, Url, ModalFactory, ModalEvents)
         }).fail(Notification.exception);
     }
 
+    /**
+     * Rename content in the content bank.
+     *
+     * @param {int} contentid The content to rename.
+     * @param {string} name The new name for the content.
+     */
+    function renameContent(contentid, name) {
+        var request = {
+            methodname: 'core_contentbank_rename_content',
+            args: {
+                contentid: contentid,
+                name: name
+            }
+        };
+        var requestType = 'success';
+        Ajax.call([request])[0].then(function(data) {
+            if (data) {
+                return Str.get_string('contentrenamed', 'core_contentbank');
+            }
+            requestType = 'error';
+            return Str.get_string('contentnotrenamed', 'core_contentbank');
+
+        }).then(function(message) {
+            var params = null;
+            if (requestType == 'success') {
+                params = {
+                    id: contentid,
+                    statusmsg: message
+                };
+                // Redirect to the content view page and display the message as a notification.
+                window.location.href = Url.relativeUrl('contentbank/view.php', params, false);
+            } else {
+                // Fetch error notifications.
+                Notification.addNotification({
+                    message: message,
+                    type: 'error'
+                });
+                Notification.fetchNotifications();
+            }
+            return;
+        }).catch(Notification.exception);
+    }
+
     return /** @alias module:core_contentbank/actions */ {
         // Public variables and functions.
 
index a77b7a0..25bc2dd 100644 (file)
 
 namespace core_contentbank;
 
+use core_text;
 use stored_file;
 use stdClass;
 use coding_exception;
 use moodle_url;
+use core\event\contentbank_content_updated;
 
 /**
  * Content manager class
@@ -99,7 +101,40 @@ abstract class content {
         }
         $this->content->usermodified = $USER->id;
         $this->content->timemodified = time();
-        return $DB->update_record('contentbank_content', $this->content);
+        $result = $DB->update_record('contentbank_content', $this->content);
+        if ($result) {
+            // Trigger an event for updating this content.
+            $event = contentbank_content_updated::create_from_record($this->content);
+            $event->trigger();
+        }
+        return $result;
+    }
+
+    /**
+     * Set a new name to the content.
+     *
+     * @param string $name  The name of the content.
+     * @return bool  True if the content has been succesfully updated. False otherwise.
+     * @throws \coding_exception if not loaded.
+     */
+    public function set_name(string $name): bool {
+        if (empty($name)) {
+            return false;
+        }
+
+        // Clean name.
+        $name = clean_param($name, PARAM_TEXT);
+        if (core_text::strlen($name) > 255) {
+            $name = core_text::substr($name, 0, 255);
+        }
+
+        $oldname = $this->content->name;
+        $this->content->name = $name;
+        $updated = $this->update_content();
+        if (!$updated) {
+            $this->content->name = $oldname;
+        }
+        return $updated;
     }
 
     /**
index 0a21353..35499ce 100644 (file)
@@ -24,6 +24,8 @@
 
 namespace core_contentbank;
 
+use stored_file;
+
 /**
  * Content bank class
  *
@@ -202,4 +204,32 @@ class contentbank {
 
         return $contents;
     }
+
+    /**
+     * Create content from a file information.
+     *
+     * @param \context $context Context where to upload the file and content.
+     * @param int $userid Id of the user uploading the file.
+     * @param stored_file $file The file to get information from
+     * @return content
+     */
+    public function create_content_from_file(\context $context, int $userid, stored_file $file): ?content {
+        global $USER;
+        if (empty($userid)) {
+            $userid = $USER->id;
+        }
+        // Get the contenttype to manage given file's extension.
+        $filename = $file->get_filename();
+        $extension = $this->get_extension($filename);
+        $plugin = $this->get_extension_supporter($extension, $context);
+        $classname = '\\contenttype_'.$plugin.'\\contenttype';
+        $record = new \stdClass();
+        $record->name = $filename;
+        $record->usercreated = $userid;
+        $contentype = new $classname($context);
+        $content = $contentype->create_content($record);
+        $event = \core\event\contentbank_content_uploaded::create_from_record($content->get_content());
+        $event->trigger();
+        return $content;
+    }
 }
index 46e92a9..7f7fbf5 100644 (file)
@@ -24,7 +24,9 @@
 
 namespace core_contentbank;
 
-use coding_exception;
+use core\event\contentbank_content_created;
+use core\event\contentbank_content_deleted;
+use core\event\contentbank_content_viewed;
 use moodle_url;
 
 /**
@@ -39,7 +41,7 @@ abstract class contenttype {
     /** Plugin implements uploading feature */
     const CAN_UPLOAD = 'upload';
 
-    /** @var context This content's context. **/
+    /** @var context This contenttype's context. **/
     protected $context = null;
 
     /**
@@ -72,10 +74,15 @@ abstract class contenttype {
         $entry->usermodified = $entry->usercreated;
         $entry->timemodified = $entry->timecreated;
         $entry->configdata = $record->configdata ?? '';
+        $entry->instanceid = $record->instanceid ?? 0;
         $entry->id = $DB->insert_record('contentbank_content', $entry);
         if ($entry->id) {
             $classname = '\\'.$entry->contenttype.'\\content';
-            return new $classname($entry);
+            $content = new $classname($entry);
+            // Trigger an event for creating the content.
+            $event = contentbank_content_created::create_from_record($content->get_content());
+            $event->trigger();
+            return $content;
         }
         return null;
     }
@@ -96,7 +103,35 @@ abstract class contenttype {
         }
 
         // Delete the contentbank DB entry.
-        return $DB->delete_records('contentbank_content', ['id' => $content->get_id()]);
+        $result = $DB->delete_records('contentbank_content', ['id' => $content->get_id()]);
+        if ($result) {
+            // Trigger an event for deleting this content.
+            $record = $content->get_content();
+            $event = contentbank_content_deleted::create([
+                'objectid' => $content->get_id(),
+                'relateduserid' => $record->usercreated,
+                'context' => \context::instance_by_id($record->contextid),
+                'other' => [
+                    'contenttype' => $content->get_content_type(),
+                    'name' => $content->get_name()
+                ]
+            ]);
+            $event->add_record_snapshot('contentbank_content', $record);
+            $event->trigger();
+        }
+        return $result;
+    }
+
+    /**
+     * Rename this content from the content_bank.
+     * This method can be overwritten by the plugins if they need to change some other specific information.
+     *
+     * @param  content $content The content to rename.
+     * @param string $name  The name of the content.
+     * @return boolean true if the content has been renamed; false otherwise.
+     */
+    public function rename_content(content $content, string $name): bool {
+        return $content->set_name($name);
     }
 
     /**
@@ -138,6 +173,10 @@ abstract class contenttype {
      * @return string           HTML code to include in view.php.
      */
     public function get_view_content(\stdClass $record): string {
+        // Trigger an event for viewing this content.
+        $event = contentbank_content_viewed::create_from_record($record);
+        $event->trigger();
+
         // Main contenttype class can visualize the content, but plugins could overwrite visualization.
         return '';
     }
@@ -240,6 +279,41 @@ abstract class contenttype {
         return true;
     }
 
+    /**
+     * Check if the user can managed this content.
+     *
+     * @param  content $content The content to be managed.
+     * @return bool     True if content could be managed. False otherwise.
+     */
+    public final function can_manage(content $content): bool {
+        global $USER;
+
+        if ($this->context->id != $content->get_content()->contextid) {
+            // The content has to have exactly the same context as this contenttype.
+            return false;
+        }
+
+        // Check main contentbank management permission.
+        $hascapability = has_capability('moodle/contentbank:manageanycontent', $this->context);
+        if ($content->get_content()->usercreated == $USER->id) {
+            // This content has been created by the current user; check if they can manage their content.
+            $hascapability = $hascapability || has_capability('moodle/contentbank:manageowncontent', $this->context);
+        }
+
+        return $hascapability && $this->is_manage_allowed($content);
+    }
+
+    /**
+     * Returns if content allows managing.
+     *
+     * @param  content $content The content to be managed.
+     * @return bool True if content allows uploading. False otherwise.
+     */
+    protected function is_manage_allowed(content $content): bool {
+        // Plugins can overwrite this function to add any check they need.
+        return true;
+    }
+
     /**
      * Returns the plugin supports the feature.
      *
diff --git a/contentbank/classes/external/rename_content.php b/contentbank/classes/external/rename_content.php
new file mode 100644 (file)
index 0000000..18d36f3
--- /dev/null
@@ -0,0 +1,136 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * External API to rename content bank content.
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_contentbank\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/externallib.php');
+
+use external_api;
+use external_function_parameters;
+use external_single_structure;
+use external_value;
+use external_warnings;
+
+/**
+ * This is the external method for renaming a content.
+ *
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class rename_content extends external_api {
+    /**
+     * rename_content parameters.
+     *
+     * @since  Moodle 3.9
+     * @return external_function_parameters
+     */
+    public static function execute_parameters(): external_function_parameters {
+        return new external_function_parameters(
+            [
+                'contentid' => new external_value(PARAM_INT, 'The content id to rename', VALUE_REQUIRED),
+                'name' => new external_value(PARAM_RAW, 'The new name for the content', VALUE_REQUIRED),
+            ]
+        );
+    }
+
+    /**
+     * Rename content from the contentbank.
+     *
+     * @since  Moodle 3.9
+     * @param  int $contentid The content id to rename.
+     * @param  string $name The new name.
+     * @return array True if the content has been renamed; false and the warning, otherwise.
+     */
+    public static function execute(int $contentid, string $name): array {
+        global $DB;
+
+        $result = false;
+        $warnings = [];
+
+        $params = self::validate_parameters(self::execute_parameters(), [
+            'contentid' => $contentid,
+            'name' => $name,
+        ]);
+        $params['name'] = clean_param($params['name'], PARAM_TEXT);
+        try {
+            $record = $DB->get_record('contentbank_content', ['id' => $contentid], '*', MUST_EXIST);
+            $contenttypeclass = "\\$record->contenttype\\contenttype";
+            if (class_exists($contenttypeclass)) {
+                $context = \context::instance_by_id($record->contextid, MUST_EXIST);
+                self::validate_context($context);
+                $contenttype = new $contenttypeclass($context);
+                $contentclass = "\\$record->contenttype\\content";
+                $content = new $contentclass($record);
+                // Check capability.
+                if ($contenttype->can_manage($content)) {
+                    // This content can be renamed.
+                    if ($contenttype->rename_content($content, $params['name'])) {
+                        $result = true;
+                    } else {
+                        $warnings[] = [
+                            'item' => $contentid,
+                            'warningcode' => 'contentnotrenamed',
+                            'message' => get_string('contentnotrenamed', 'core_contentbank')
+                        ];
+                    }
+                } else {
+                    // The user has no permission to manage this content.
+                    $warnings[] = [
+                        'item' => $contentid,
+                        'warningcode' => 'nopermissiontomanage',
+                        'message' => get_string('nopermissiontomanage', 'core_contentbank')
+                    ];
+                }
+            }
+        } catch (\moodle_exception $e) {
+            // The content or the context don't exist.
+            $warnings[] = [
+                'item' => $contentid,
+                'warningcode' => 'exception',
+                'message' => $e->getMessage()
+            ];
+        }
+
+        return [
+            'result' => $result,
+            'warnings' => $warnings
+        ];
+    }
+
+    /**
+     * rename_content return.
+     *
+     * @since  Moodle 3.9
+     * @return external_single_structure
+     */
+    public static function execute_returns(): external_single_structure {
+        return new external_single_structure([
+            'result' => new external_value(PARAM_BOOL, 'The processing result'),
+            'warnings' => new external_warnings()
+        ]);
+    }
+}
index f28bee8..806205d 100644 (file)
@@ -24,6 +24,7 @@
 
 namespace contenttype_h5p;
 
+use core\event\contentbank_content_viewed;
 use stdClass;
 use html_writer;
 
@@ -45,7 +46,9 @@ class contenttype extends \core_contentbank\contenttype {
     public function delete_content(\core_contentbank\content $content): bool {
         // Delete the H5P content.
         $factory = new \core_h5p\factory();
-        \core_h5p\api::delete_content_from_pluginfile_url($content->get_file_url(), $factory);
+        if (!empty($content->get_file_url())) {
+            \core_h5p\api::delete_content_from_pluginfile_url($content->get_file_url(), $factory);
+        }
 
         // Delete the content from the content_bank.
         return parent::delete_content($content);
@@ -58,6 +61,10 @@ class contenttype extends \core_contentbank\contenttype {
      * @return string            HTML code to include in view.php.
      */
     public function get_view_content(\stdClass $record): string {
+        // Trigger an event for viewing this content.
+        $event = contentbank_content_viewed::create_from_record($record);
+        $event->trigger();
+
         $content = new content($record);
         $fileurl = $content->get_file_url();
         $html = html_writer::tag('h2', $content->get_name());
diff --git a/contentbank/contenttype/h5p/tests/behat/manage_content.feature b/contentbank/contenttype/h5p/tests/behat/manage_content.feature
new file mode 100644 (file)
index 0000000..5705a9e
--- /dev/null
@@ -0,0 +1,47 @@
+@core @core_contentbank @contenttype_h5p @_file_upload @javascript
+Feature: Manage H5P content from the content bank
+  In order to manage H5P content in the content bank
+  As an admin
+  I need to be able to edit any H5P content in the content bank
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+    And the following "contentbank content" exist:
+      | course| contenttype     | user     | contentname       |
+      | C1    | contenttype_h5p | admin    | filltheblanks.h5p |
+      | C1    | contenttype_h5p | teacher1 | ipsums.h5p        |
+    And I log in as "admin"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+    And I log out
+
+  Scenario: Teachers can rename their own content in the content bank
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I follow "ipsums.h5p"
+    When I open the action menu in "region-main-settings-menu" "region"
+    And I should see "Rename"
+    And I choose "Rename" in the open action menu
+    And I set the field "Content name" to "New name"
+    And I click on "Rename" "button"
+    And I wait until the page is ready
+    Then I should not see "ipsums.h5p"
+    And I should see "New name"
+
+  Scenario: Teachers can't rename content created by other users in the content bank
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    When I follow "filltheblanks.h5p"
+    Then "region-main-settings-menu" "region" should not exist
index a4082f8..657a459 100644 (file)
@@ -51,14 +51,14 @@ class contenttype_h5p_content_plugin_testcase extends advanced_testcase {
 
         // Create a dummy file.
         $filename = 'content.h5p';
-        $dummy = array(
+        $dummy = [
             'contextid' => \context_system::instance()->id,
             'component' => 'contentbank',
             'filearea' => 'public',
             'itemid' => $content->get_id(),
             'filepath' => '/',
             'filename' => $filename
-        );
+        ];
         $fs = get_file_storage();
         $fs->create_file_from_string($dummy, 'dummy content');
 
diff --git a/contentbank/templates/renamecontent.mustache b/contentbank/templates/renamecontent.mustache
new file mode 100644 (file)
index 0000000..ea638d5
--- /dev/null
@@ -0,0 +1,30 @@
+{{!
+    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_contentbank/renamecontent
+
+    Example context (json):
+    {
+        "contentid": 17,
+        "name": "Accordion"
+    }
+
+}}
+    <div class="form-check w-100 justify-content-start">
+        <label for="newname">{{#str}}contentname, core_contentbank{{/str}}</label>
+        <input type="text" size="5" id="newname" name="newname"  value="{{{ name }}}" class="form-control text-ltr">
+    </div>
diff --git a/contentbank/tests/behat/events.feature b/contentbank/tests/behat/events.feature
new file mode 100644 (file)
index 0000000..c520199
--- /dev/null
@@ -0,0 +1,73 @@
+@core @core_contentbank @_file_upload @_switch_iframe @javascript
+Feature: Confirm content bank events are triggered
+  In order to log content bank actions
+  As an admin
+  I need to be able to check triggered events
+
+  Background:
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "contentbank content" exist:
+      | course | contenttype     | user  | contentname |
+      | C1     | contenttype_h5p | admin | Existing    |
+    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 am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+
+  Scenario: Content created and uploaded events when uploading a content file
+    Given I navigate to "Reports > Live logs" in site administration
+    And I should not see "Content uploaded"
+    And I should not see "Content created"
+    And I am on "Course 1" course homepage
+    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 navigate to "Reports > Live logs" in site administration
+    Then I should see "Content uploaded"
+    And I should see "Content created"
+
+  Scenario: Content viewed event
+    Given I navigate to "Reports > Live logs" in site administration
+    And I should not see "Content viewed"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    When I click on "Existing" "link"
+    And I navigate to "Reports > Live logs" in site administration
+    Then I should see "Content viewed"
+
+  Scenario: Content deleted event
+    Given I navigate to "Reports > Live logs" in site administration
+    And I should not see "Content deleted"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I click on "Existing" "link"
+    And I open the action menu in "region-main-settings-menu" "region"
+    When I choose "Delete" in the open action menu
+    And I click on "Delete" "button" in the "Delete content" "dialogue"
+    And I navigate to "Reports > Live logs" in site administration
+    Then I should see "Content deleted"
+
+  Scenario: Content updated event when renaming
+    Given I navigate to "Reports > Live logs" in site administration
+    And I should not see "Content updated"
+    And I am on "Course 1" course homepage
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I click on "Existing" "link"
+    And I open the action menu in "region-main-settings-menu" "region"
+    When I choose "Rename" in the open action menu
+    And I set the field "Content name" to "New name"
+    And I click on "Rename" "button"
+    And I navigate to "Reports > Live logs" in site administration
+    Then I should see "Content updated"
\ No newline at end of file
index 04b722f..2fcd66c 100644 (file)
@@ -64,6 +64,55 @@ class core_contenttype_content_testcase extends \advanced_testcase {
         $this->assertEquals($record->name, $content->get_name());
     }
 
+    /**
+     * Data provider for test_set_name.
+     *
+     * @return  array
+     */
+    public function set_name_provider() {
+        return [
+            'Standard name' => ['New name', 'New name'],
+            'Name with digits' => ['Today is 17/04/2017', 'Today is 17/04/2017'],
+            'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle'],
+            'Name with tags' => ['This is <b>bold</b>', 'This is bold'],
+            'Long name' => [str_repeat('a', 100), str_repeat('a', 100)],
+            'Too long name' => [str_repeat('a', 300), str_repeat('a', 255)]
+        ];
+    }
+
+    /**
+     * Tests for 'set_name' behaviour.
+     *
+     * @dataProvider    set_name_provider
+     * @param   string  $newname    The name to set
+     * @param   string   $expected   The name result
+     *
+     * @covers ::set_name
+     */
+    public function test_set_name(string $newname, string $expected) {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $oldname = "Old name";
+        $context = context_system::instance();
+
+        // Create content.
+        $record = new stdClass();
+        $record->name = $oldname;
+
+        $contenttype = new contenttype($context);
+        $content = $contenttype->create_content($record);
+        $this->assertEquals($oldname, $content->get_name());
+
+        $content->set_name($newname);
+        $this->assertEquals($expected, $content->get_name());
+
+        $record = $DB->get_record('contentbank_content', ['id' => $content->get_id()]);
+        $this->assertEquals($expected, $record->name);
+    }
+
     /**
      * Tests for behaviour of get_content_type().
      *
index 824f7a5..e420456 100644 (file)
@@ -181,11 +181,11 @@ class core_contentbank_testcase extends advanced_testcase {
      *
      * @dataProvider search_contents_provider
      * @param  string $search String to search.
-     * @param  int $contextid Contextid to search.
+     * @param  string $where Context where to search.
      * @param  int $expectedresult Expected result.
      * @param  array $contexts List of contexts where to create content.
      */
-    public function test_search_contents(?string $search, int $contextid, int $expectedresult, array $contexts = []): void {
+    public function test_search_contents(?string $search, string $where, int $expectedresult, array $contexts = []): void {
         global $DB;
 
         $this->resetAfterTest();
@@ -195,11 +195,26 @@ class core_contentbank_testcase extends advanced_testcase {
         $manager = $this->getDataGenerator()->create_user();
         $this->getDataGenerator()->role_assign($managerroleid, $manager->id);
 
+        // Create a category and a course.
+        $coursecat = $this->getDataGenerator()->create_category();
+        $course = $this->getDataGenerator()->create_course();
+        $existingcontexts = [];
+        $existingcontexts['system'] = \context_system::instance();
+        $existingcontexts['category'] = \context_coursecat::instance($coursecat->id);
+        $existingcontexts['course'] = \context_course::instance($course->id);
+
+        if (empty($where)) {
+            $contextid = 0;
+        } else {
+            $contextid = $existingcontexts[$where]->id;
+        }
+
         // Add some content to the content bank.
         $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
         foreach ($contexts as $context) {
+            $contextinstance = $existingcontexts[$context];
             $records = $generator->generate_contentbank_data('contenttype_h5p', 3,
-                $manager->id, $context, false);
+                $manager->id, $contextinstance, false);
         }
 
         // Search for some content.
@@ -220,98 +235,126 @@ class core_contentbank_testcase extends advanced_testcase {
      * @return array
      */
     public function search_contents_provider(): array {
-        // Create a category and a course.
-        $systemcontext = \context_system::instance();
-        $coursecat = $this->getDataGenerator()->create_category();
-        $course = $this->getDataGenerator()->create_course();
-        $coursecatcontext = \context_coursecat::instance($coursecat->id);
-        $coursecontext = \context_course::instance($course->id);
 
         return [
             'Search all content in all contexts' => [
                 null,
-                0,
+                '',
                 9,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in all contexts for existing string in all contents' => [
                 'content',
-                0,
+                '',
                 9,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in all contexts for unexisting string in all contents' => [
                 'chocolate',
+                '',
                 0,
-                0,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in all contexts for existing string in some contents' => [
                 '1',
-                0,
+                '',
                 3,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in all contexts for existing string in some contents (create only 1 context)' => [
                 '1',
-                0,
+                '',
                 1,
-                [$systemcontext]
+                ['system']
             ],
             'Search in system context for existing string in all contents' => [
                 'content',
-                $systemcontext->id,
+                'system',
                 3,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in category context for unexisting string in all contents' => [
                 'chocolate',
-                $coursecatcontext->id,
+                'category',
                 0,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in course context for existing string in some contents' => [
                 '1',
-                $coursecontext->id,
+                'course',
                 1,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in system context' => [
                 null,
-                $systemcontext->id,
+                'system',
                 3,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in course context with existing content' => [
                 null,
-                $coursecontext->id,
+                'course',
                 3,
-                [$systemcontext, $coursecatcontext, $coursecontext]
+                ['system', 'category', 'course']
             ],
             'Search in course context without existing content' => [
                 null,
-                $coursecontext->id,
+                'course',
                 0,
-                [$systemcontext, $coursecatcontext]
+                ['system', 'category']
             ],
             'Search in an empty contentbank' => [
                 null,
-                0,
+                '',
                 0,
                 []
             ],
             'Search in a context in an empty contentbank' => [
                 null,
-                $systemcontext->id,
+                'system',
                 0,
                 []
             ],
             'Search for a string in an empty contentbank' => [
                 'content',
-                0,
+                '',
                 0,
                 []
             ],
         ];
     }
+
+    /**
+     * Test create_content_from_file function.
+     *
+     * @covers ::create_content_from_file
+     */
+    public function test_create_content_from_file() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $systemcontext = \context_system::instance();
+        $name = 'dummy_h5p.h5p';
+
+        // Create a dummy H5P file.
+        $dummyh5p = array(
+            'contextid' => $systemcontext->id,
+            'component' => 'contentbank',
+            'filearea' => 'public',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => $name,
+            'userid' => $USER->id
+        );
+        $fs = get_file_storage();
+        $dummyh5pfile = $fs->create_file_from_string($dummyh5p, 'Dummy H5Pcontent');
+
+        $cb = new \core_contentbank\contentbank();
+        $content = $cb->create_content_from_file($systemcontext, $USER->id, $dummyh5pfile);
+
+        $this->assertEquals('contenttype_h5p', $content->get_content_type());
+        $this->assertInstanceOf('\\contenttype_h5p\\content', $content);
+        $this->assertEquals($name, $content->get_name());
+    }
 }
index 7175b63..e74bd1b 100644 (file)
@@ -176,7 +176,6 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
         $this->assertInstanceOf('\\contenttype_testable\\content', $content);
     }
 
-
     /**
      * Test the behaviour of can_delete().
      */
@@ -254,4 +253,105 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
 
         $this->contenttype = new \contenttype_testable\contenttype($systemcontext);
     }
+
+    /**
+     * Data provider for test_rename_content.
+     *
+     * @return  array
+     */
+    public function rename_content_provider() {
+        return [
+            'Standard name' => ['New name', 'New name'],
+            'Name with digits' => ['Today is 17/04/2017', 'Today is 17/04/2017'],
+            'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle'],
+            'Name with tags' => ['This is <b>bold</b>', 'This is bold'],
+            'Long name' => [str_repeat('a', 100), str_repeat('a', 100)],
+            'Too long name' => [str_repeat('a', 300), str_repeat('a', 255)]
+        ];
+    }
+
+    /**
+     * Test the behaviour of rename_content().
+     *
+     * @dataProvider    rename_content_provider
+     * @param   string  $newname    The name to set
+     * @param   string   $expected   The name result
+     *
+     * @covers ::rename_content
+     */
+    public function test_rename_content(string $newname, string $expected) {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        // Create course and teacher user.
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $coursecontext = \context_course::instance($course->id);
+        $contenttype = new contenttype($coursecontext);
+
+        // Add some content to the content bank as teacher.
+        $this->setUser($teacher);
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 1, $teacher->id);
+        $content = array_shift($contents);
+
+        $oldname = $content->get_name();
+
+        // Check the content is renamed as expected by a user with permission.
+        $renamed = $contenttype->rename_content($content, $newname);
+        $this->assertTrue($renamed);
+        $record = $DB->get_record('contentbank_content', ['id' => $content->get_id()]);
+        $this->assertNotEquals($oldname, $record->name);
+        $this->assertEquals($expected, $record->name);
+    }
+
+    /**
+     * Test the behaviour of can_manage().
+     *
+     * @covers ::can_manage
+     */
+    public function test_can_manage() {
+        global $DB, $USER;
+
+        $this->resetAfterTest();
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+
+        // Create course and teacher user.
+        $teacherroleid = $DB->get_field('role', 'id', ['shortname' => 'editingteacher']);
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $manager = $this->getDataGenerator()->create_and_enrol($course, 'manager');
+        $coursecontext = \context_course::instance($course->id);
+
+        $contenttype = new contenttype($coursecontext);
+
+        // Add some content to the content bank as admin.
+        $this->setAdminUser();
+        $contentsbyadmin = $generator->generate_contentbank_data('contenttype_testable', 1, $USER->id, $coursecontext);
+        $contentbyadmin = array_shift($contentsbyadmin);
+
+        // Add some content to the content bank as teacher.
+        $contentsbyteacher = $generator->generate_contentbank_data('contenttype_testable', 1, $teacher->id, $coursecontext);
+        $contentbyteacher = array_shift($contentsbyteacher);
+
+        // Check the content has been created as expected.
+        $records = $DB->count_records('contentbank_content');
+        $this->assertEquals(2, $records);
+
+        // Check manager can manage by default all the contents created.
+        $this->setUser($manager);
+        $this->assertTrue($contenttype->can_manage($contentbyteacher));
+        $this->assertTrue($contenttype->can_manage($contentbyadmin));
+
+        // Check teacher can only edit their own content.
+        $this->setUser($teacher);
+        $this->assertTrue($contenttype->can_manage($contentbyteacher));
+        $this->assertFalse($contenttype->can_manage($contentbyadmin));
+
+        // Unassign capability to teacher role and check they not can not edit any content.
+        unassign_capability('moodle/contentbank:manageowncontent', $teacherroleid);
+        $this->assertFalse($contenttype->can_manage($contentbyteacher));
+        $this->assertFalse($contenttype->can_manage($contentbyadmin));
+    }
 }
diff --git a/contentbank/tests/external/rename_content_test.php b/contentbank/tests/external/rename_content_test.php
new file mode 100644 (file)
index 0000000..4b1bf6b
--- /dev/null
@@ -0,0 +1,140 @@
+<?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/>.
+
+/**
+ * Core content bank external functions tests.
+ *
+ * @package    core_contentbank
+ * @category   external
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      Moodle 3.9
+ */
+
+namespace core_contentbank;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+use core_contentbank\external\delete_content;
+use core_contentbank\external\external;
+use core_contentbank\external\rename_content;
+use external_api;
+
+/**
+ * Core content bank external functions tests.
+ *
+ * @package    core_contentbank
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @coversDefaultClass \core_contentbank\external
+ */
+class rename_content_testcase extends \externallib_advanced_testcase {
+
+    /**
+     * Data provider for test_rename_content.
+     *
+     * @return  array
+     */
+    public function rename_content_provider() {
+        return [
+            'Standard name' => ['New name', 'New name'],
+            'Name with digits' => ['Today is 17/04/2017', 'Today is 17/04/2017'],
+            'Name with symbols' => ['Follow us: @moodle', 'Follow us: @moodle'],
+            'Name with tags' => ['This is <b>bold</b>', 'This is bold'],
+            'Long name' => [str_repeat('a', 100), str_repeat('a', 100)],
+            'Too long name' => [str_repeat('a', 300), str_repeat('a', 255)]
+        ];
+    }
+
+    /**
+     * Test the behaviour of rename_content() for users with permission.
+     *
+     * @dataProvider    rename_content_provider
+     * @param   string  $newname    The name to set
+     * @param   string   $expected   The name result
+     *
+     * @covers ::execute
+     */
+    public function test_rename_content_with_permission(string $newname, string $expected) {
+        global $DB;
+        $this->resetAfterTest();
+
+        // Create users.
+        $roleid = $DB->get_field('role', 'id', ['shortname' => 'editingteacher']);
+        $teacher = $this->getDataGenerator()->create_user();
+
+        $this->getDataGenerator()->role_assign($roleid, $teacher->id);
+        $this->setUser($teacher);
+
+        // Add some content to the content bank as teacher.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 1, $teacher->id);
+        $content = array_shift($contents);
+
+        $oldname = $content->get_name();
+
+        // Call the WS and check the content is renamed as expected.
+        $result = rename_content::execute($content->get_id(), $newname);
+        $result = external_api::clean_returnvalue(rename_content::execute_returns(), $result);
+        $this->assertTrue($result['result']);
+        $record = $DB->get_record('contentbank_content', ['id' => $content->get_id()]);
+        $this->assertNotEquals($oldname, $record->name);
+        $this->assertEquals($expected, $record->name);
+
+        // Call the WS using an unexisting contentid and check an error is thrown.
+        $this->expectException(\invalid_response_exception::class);
+        $result = rename_content::execute_returns($content->get_id() + 1, $oldname);
+        $result = external_api::clean_returnvalue(rename_content::execute_returns(), $result);
+        $this->assertFalse($result['result']);
+    }
+
+    /**
+     * Test the behaviour of rename_content() for users with permission.
+     *
+     * @covers ::execute
+     */
+    public function test_rename_content_without_permission() {
+        global $DB;
+        $this->resetAfterTest();
+
+        // Create users.
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'editingteacher');
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Add some content to the content bank as teacher.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 1, $teacher->id);
+        $content = array_shift($contents);
+
+        $oldname = $content->get_name();
+        $newname = 'New name';
+
+        // Call the WS and check the content has not been renamed by the student.
+        $this->setUser($student);
+        $result = rename_content::execute($content->get_id(), $newname);
+        $result = external_api::clean_returnvalue(rename_content::execute_returns(), $result);
+        $this->assertFalse($result['result']);
+        $record = $DB->get_record('contentbank_content', ['id' => $content->get_id()]);
+        $this->assertEquals($oldname, $record->name);
+        $this->assertNotEquals($newname, $record->name);
+    }
+}
index 48b651e..0a88a69 100644 (file)
@@ -72,25 +72,14 @@ if ($mform->is_cancelled()) {
     redirect($returnurl);
 } else if ($formdata = $mform->get_data()) {
     require_sesskey();
-
-    // Get the file and the contenttype to manage given file's extension.
-    $usercontext = context_user::instance($USER->id);
+    // Get the file and create the content based on it.
+    $usercontext = \context_user::instance($USER->id);
     $fs = get_file_storage();
     $files = $fs->get_area_files($usercontext->id, 'user', 'draft', $formdata->file, 'itemid, filepath, filename', false);
-
     if (!empty($files)) {
         $file = reset($files);
-        $filename = $file->get_filename();
-        $extension = $cb->get_extension($filename);
-        $plugin = $cb->get_extension_supporter($extension, $context);
-        $classname = '\\contenttype_'.$plugin.'\\contenttype';
-        $record = new stdClass();
-        $record->name = $filename;
-        if (class_exists($classname)) {
-            $contentype = new $classname($context);
-            $content = $contentype->create_content($record);
-            file_save_draft_area_files($formdata->file, $contextid, 'contentbank', 'public', $content->get_id());
-        }
+        $content = $cb->create_content_from_file($context, $USER->id, $file);
+        file_save_draft_area_files($formdata->file, $contextid, 'contentbank', 'public', $content->get_id());
     }
     redirect($returnurl);
 }
index 4c4a4c0..d34205c 100644 (file)
@@ -35,7 +35,10 @@ $record = $DB->get_record('contentbank_content', ['id' => $id], '*', MUST_EXIST)
 $context = context::instance_by_id($record->contextid, MUST_EXIST);
 require_capability('moodle/contentbank:access', $context);
 
-$returnurl = new \moodle_url('/contentbank/index.php');
+$statusmsg = optional_param('statusmsg', '', PARAM_RAW);
+$errormsg = optional_param('errormsg', '', PARAM_RAW);
+
+$returnurl = new \moodle_url('/contentbank/index.php', ['contextid' => $context->id]);
 $plugin = core_plugin_manager::instance()->get_plugin_info($record->contenttype);
 if (!$plugin || !$plugin->is_enabled()) {
     print_error('unsupported', 'core_contentbank', $returnurl);
@@ -46,7 +49,6 @@ $title = get_string('contentbank');
 if ($PAGE->course) {
     require_login($PAGE->course->id);
 }
-$returnurl = new \moodle_url('/contentbank/index.php', ['contextid' => $context->id]);
 
 $PAGE->set_url(new \moodle_url('/contentbank/view.php', ['id' => $id]));
 $PAGE->set_context($context);
@@ -57,13 +59,32 @@ $PAGE->set_title($title);
 $PAGE->set_pagetype('contenbank');
 
 $contenttypeclass = "\\$record->contenttype\\contenttype";
-$contenttype = new $contenttypeclass($context);
 $contentclass = "\\$record->contenttype\\content";
+if (!class_exists($contenttypeclass) || !class_exists($contentclass)) {
+    print_error('contenttypenotfound', 'error', $returnurl, $record->contenttype);
+}
+$contenttype = new $contenttypeclass($context);
 $content = new $contentclass($record);
+
+// Create the cog menu with all the secondary actions, such as delete, rename...
+$actionmenu = new action_menu();
+$actionmenu->set_alignment(action_menu::TR, action_menu::BR);
+if ($contenttype->can_manage($content)) {
+    // Add the rename content item to the menu.
+    $attributes = [
+        'data-action' => 'renamecontent',
+        'data-contentname' => $content->get_name(),
+        'data-contentid' => $content->get_id(),
+    ];
+    $actionmenu->add_secondary_action(new action_menu_link(
+        new moodle_url('#'),
+        new pix_icon('e/styleparagraph', get_string('rename')),
+        get_string('rename'),
+        false,
+        $attributes
+    ));
+}
 if ($contenttype->can_delete($content)) {
-    // Create the cog menu with all the secondary actions, such as delete, rename...
-    $actionmenu = new action_menu();
-    $actionmenu->set_alignment(action_menu::TR, action_menu::BR);
     // Add the delete content item to the menu.
     $attributes = [
                 'data-action' => 'deletecontent',
@@ -78,18 +99,24 @@ if ($contenttype->can_delete($content)) {
         false,
         $attributes
     ));
-
-    // Add the cog menu to the header.
-    $PAGE->add_header_action(html_writer::div(
-        $OUTPUT->render($actionmenu),
-        'd-print-none',
-        ['id' => 'region-main-settings-menu']
-    ));
 }
 
+// Add the cog menu to the header.
+$PAGE->add_header_action(html_writer::div(
+    $OUTPUT->render($actionmenu),
+    'd-print-none',
+    ['id' => 'region-main-settings-menu']
+));
+
 echo $OUTPUT->header();
 echo $OUTPUT->box_start('generalbox');
 
+// If needed, display notifications.
+if ($errormsg !== '') {
+    echo $OUTPUT->notification($errormsg);
+} else if ($statusmsg !== '') {
+    echo $OUTPUT->notification($statusmsg, 'notifysuccess');
+}
 if ($contenttype->can_access()) {
     echo $contenttype->get_view_content($record);
 }
index bece6a1..07394ca 100644 (file)
@@ -208,17 +208,18 @@ class core_course_management_renderer extends plugin_renderer_base {
         }
         $courseicon = $this->output->pix_icon('i/course', get_string('courses'));
         $bcatinput = array(
+                'id' => 'categorylistitem' . $category->id,
                 'type' => 'checkbox',
                 'name' => 'bcat[]',
                 'value' => $category->id,
-                'class' => 'bulk-action-checkbox',
-                'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+                'class' => 'bulk-action-checkbox custom-control-input',
                 'data-action' => 'select'
         );
 
+        $checkboxclass = '';
         if (!$category->can_resort_subcategories() && !$category->has_manage_capability()) {
             // Very very hardcoded here.
-            $bcatinput['style'] = 'visibility:hidden';
+            $checkboxclass = 'd-none';
         }
 
         $viewcaturl = new moodle_url('/course/management.php', array('categoryid' => $category->id));
@@ -260,8 +261,14 @@ class core_course_management_renderer extends plugin_renderer_base {
 
         $html = html_writer::start_tag('li', $attributes);
         $html .= html_writer::start_div('clearfix');
-        $html .= html_writer::start_div('float-left ba-checkbox');
-        $html .= html_writer::empty_tag('input', $bcatinput).'&nbsp;';
+        $html .= html_writer::start_div('float-left ' . $checkboxclass);
+        $html .= html_writer::start_div('custom-control custom-checkbox mr-1 ');
+        $html .= html_writer::empty_tag('input', $bcatinput);
+        $html .= html_writer::tag('label', '', array(
+            'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+            'class' => 'custom-control-label',
+            'for' => 'categorylistitem' . $category->id));
+        $html .= html_writer::end_div();
         $html .= html_writer::end_div();
         $html .= $icon;
         if ($hasactions) {
@@ -275,7 +282,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         $html .= html_writer::link($viewcaturl, $text, $textattributes);
         $html .= html_writer::start_div('float-right d-flex');
         if ($category->idnumber) {
-            $html .= html_writer::tag('span', s($category->idnumber), array('class' => 'dimmed idnumber'));
+            $html .= html_writer::tag('span', s($category->idnumber), array('class' => 'text-muted idnumber'));
         }
         if ($hasactions) {
             $html .= $this->category_listitem_actions($category, $actions);
@@ -285,7 +292,7 @@ class core_course_management_renderer extends plugin_renderer_base {
                 html_writer::span($category->get_courses_count()) .
                 html_writer::span(get_string('courses'), 'accesshide', array('id' => $countid)) .
                 $courseicon,
-                'course-count dimmed',
+                'course-count text-muted',
                 array('aria-labelledby' => $countid)
         );
         $html .= html_writer::end_div();
@@ -572,7 +579,7 @@ class core_course_management_renderer extends plugin_renderer_base {
                 $a->total = $totalcourses;
                 $str = get_string('showingxofycourses', 'moodle', $a);
             }
-            $html .= html_writer::div($str, 'listing-pagination-totals dimmed');
+            $html .= html_writer::div($str, 'listing-pagination-totals text-muted');
         }
 
         if ($viewmode !== 'default') {
@@ -607,16 +614,18 @@ class core_course_management_renderer extends plugin_renderer_base {
         );
 
         $bulkcourseinput = array(
+                'id' => 'courselistitem' . $course->id,
                 'type' => 'checkbox',
                 'name' => 'bc[]',
                 'value' => $course->id,
-                'class' => 'bulk-action-checkbox',
-                'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+                'class' => 'bulk-action-checkbox custom-control-input',
                 'data-action' => 'select'
         );
+
+        $checkboxclass = '';
         if (!$category->has_manage_capability()) {
             // Very very hardcoded here.
-            $bulkcourseinput['style'] = 'visibility:hidden';
+            $checkboxclass = 'd-none';
         }
 
         $viewcourseurl = new moodle_url($this->page->url, array('courseid' => $course->id));
@@ -629,13 +638,19 @@ class core_course_management_renderer extends plugin_renderer_base {
             $html .= html_writer::div($this->output->pix_icon('i/move_2d', get_string('dndcourse')), 'float-left drag-handle');
         }
 
-        $html .= html_writer::start_div('ba-checkbox float-left');
-        $html .= html_writer::empty_tag('input', $bulkcourseinput).'&nbsp;';
+        $html .= html_writer::start_div('float-left ' . $checkboxclass);
+        $html .= html_writer::start_div('custom-control custom-checkbox mr-1 ');
+        $html .= html_writer::empty_tag('input', $bulkcourseinput);
+        $html .= html_writer::tag('label', '', array(
+            'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+            'class' => 'custom-control-label',
+            'for' => 'courselistitem' . $course->id));
+        $html .= html_writer::end_div();
         $html .= html_writer::end_div();
         $html .= html_writer::link($viewcourseurl, $text, array('class' => 'float-left coursename'));
         $html .= html_writer::start_div('float-right');
         if ($course->idnumber) {
-            $html .= html_writer::tag('span', s($course->idnumber), array('class' => 'dimmed idnumber'));
+            $html .= html_writer::tag('span', s($course->idnumber), array('class' => 'text-muted idnumber'));
         }
         $html .= $this->course_listitem_actions($category, $course);
         $html .= html_writer::end_div();
@@ -1121,7 +1136,7 @@ class core_course_management_renderer extends plugin_renderer_base {
                 $a->total = $totalcourses;
                 $str = get_string('showingxofycourses', 'moodle', $a);
             }
-            $html .= html_writer::div($str, 'listing-pagination-totals dimmed');
+            $html .= html_writer::div($str, 'listing-pagination-totals text-muted');
         }
 
         if ($totalcourses < $perpage) {
@@ -1184,10 +1199,10 @@ class core_course_management_renderer extends plugin_renderer_base {
         if (core_course_category::get($course->category)->can_move_courses_out_of()) {
             $bulkcourseinput = array(
                     'type' => 'checkbox',
+                    'id' => 'coursesearchlistitem' . $course->id,
                     'name' => 'bc[]',
                     'value' => $course->id,
-                    'class' => 'bulk-action-checkbox',
-                    'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+                    'class' => 'bulk-action-checkbox custom-control-input',
                     'data-action' => 'select'
             );
         }
@@ -1198,14 +1213,20 @@ class core_course_management_renderer extends plugin_renderer_base {
         $html .= html_writer::start_div('clearfix');
         $html .= html_writer::start_div('float-left');
         if ($bulkcourseinput) {
-            $html .= html_writer::empty_tag('input', $bulkcourseinput).'&nbsp;';
+            $html .= html_writer::start_div('custom-control custom-checkbox mr-1');
+            $html .= html_writer::empty_tag('input', $bulkcourseinput);
+            $html .= html_writer::tag('label', '', array(
+                'aria-label' => get_string('bulkactionselect', 'moodle', $text),
+                'class' => 'custom-control-label',
+                'for' => 'coursesearchlistitem' . $course->id));
+            $html .= html_writer::end_div();
         }
         $html .= html_writer::end_div();
         $html .= html_writer::link($viewcourseurl, $text, array('class' => 'float-left coursename'));
-        $html .= html_writer::tag('span', $categoryname, array('class' => 'float-left categoryname'));
+        $html .= html_writer::tag('span', $categoryname, array('class' => 'float-left ml-3 text-muted'));
         $html .= html_writer::start_div('float-right');
         $html .= $this->search_listitem_actions($course);
-        $html .= html_writer::tag('span', s($course->idnumber), array('class' => 'dimmed idnumber'));
+        $html .= html_writer::tag('span', s($course->idnumber), array('class' => 'text-muted idnumber'));
         $html .= html_writer::end_div();
         $html .= html_writer::end_div();
         $html .= html_writer::end_tag('li');
diff --git a/course/format/topics/styles.css b/course/format/topics/styles.css
deleted file mode 100644 (file)
index 319e883..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-.course-content ul.topics {
-    margin: 0;
-    list-style: none;
-}
-
-.course-content ul.topics li.section .content {
-    margin: 0 40px;
-}
-
-.course-content ul.topics li.section .left,
-.course-content ul.topics li.section .right {
-    width: 40px;
-    padding: 0 6px;
-}
-
-.course-content ul.topics li.section .left {
-    padding-top: 22px;
-    text-align: right;
-}
-
-.jsenabled .course-content ul.topics li.section .left,
-.jsenabled .course-content ul.topics li.section .right {
-    width: auto;
-}
-
-.course-content ul.topics li.section .left .section-handle img.icon {
-    padding: 0;
-    vertical-align: baseline;
-}
-
-.course-content ul.topics li.section .section_action_menu .textmenu,
-.course-content ul.topics li.section .section_action_menu .menu-action-text {
-    white-space: nowrap;
-}
\ No newline at end of file
diff --git a/course/format/weeks/styles.css b/course/format/weeks/styles.css
deleted file mode 100644 (file)
index eee188f..0000000
+++ /dev/null
@@ -1,34 +0,0 @@
-.course-content ul.weeks {
-    margin: 0;
-    list-style: none;
-}
-
-.course-content ul.weeks li.section .content {
-    margin: 0 40px;
-}
-
-.course-content ul.weeks li.section .left,
-.course-content ul.weeks li.section .right {
-    width: 40px;
-    padding: 0 6px;
-}
-
-.course-content ul.weeks li.section .left {
-    padding-top: 22px;
-    text-align: right;
-}
-
-.jsenabled .course-content ul.weeks li.section .left,
-.jsenabled .course-content ul.weeks li.section .right {
-    width: auto;
-}
-
-.course-content ul.weeks li.section .left .section-handle img.icon {
-    padding: 0;
-    vertical-align: baseline;
-}
-
-.course-content ul.weeks li.section .section_action_menu .textmenu,
-.course-content ul.weeks li.section .section_action_menu .menu-action-text {
-    white-space: nowrap;
-}
\ No newline at end of file
index 9738ac4..13e0207 100644 (file)
@@ -208,13 +208,14 @@ if (!empty($activities)) {
 
         if (($activity->type == 'section') && ($param->sortby == 'default')) {
             if ($inbox) {
+                echo html_writer::end_tag('ul');
                 echo $OUTPUT->box_end();
-                echo $OUTPUT->spacer(array('height'=>30, 'br'=>true)); // should be done with CSS instead
             }
             echo $OUTPUT->box_start();
             if (strval($activity->name) !== '') {
-                echo html_writer::tag('h2', $activity->name);
+                echo html_writer::tag('h3', $activity->name, ['class' => 'h5']);
             }
+            echo html_writer::start_tag('ul', ['class' => 'list-unstyled']);
             $inbox = true;
 
         } else if ($activity->type == 'activity') {
@@ -233,7 +234,7 @@ if (!empty($activities)) {
                 $image = $OUTPUT->pix_icon('icon', $modfullname, $cm->modname, array('class' => 'icon smallicon'));
                 $link = html_writer::link(new moodle_url("/mod/$cm->modname/view.php",
                             array("id" => $cm->id)), $name, array('class' => $class));
-                echo html_writer::tag('h3', "$image $modfullname $link");
+                echo html_writer::tag('li', "$image $modfullname $link");
            }
 
         } else {
@@ -251,7 +252,9 @@ if (!empty($activities)) {
             $print_recent_mod_activity = $activity->type.'_print_recent_mod_activity';
 
             if (function_exists($print_recent_mod_activity)) {
+                echo html_writer::start_tag('li');
                 $print_recent_mod_activity($activity, $course->id, $detail, $modnames, $viewfullnames[$activity->cmid]);
+                echo html_writer::end_tag('li');
             }
         }
     }
index 784d6ee..6f1a956 100644 (file)
@@ -903,7 +903,7 @@ class core_course_renderer extends plugin_renderer_base {
             $output .= course_get_cm_move($mod, $sectionreturn);
         }
 
-        $output .= html_writer::start_tag('div', array('class' => 'mod-indent-outer'));
+        $output .= html_writer::start_tag('div', array('class' => 'mod-indent-outer w-100'));
 
         // This div is used to indent the content.
         $output .= html_writer::div('', $indentclasses);
index 1f204d8..1fce439 100644 (file)
@@ -72,6 +72,13 @@ class writer extends \core\dataformat\base {
     public function send_http_headers() {
     }
 
+    /**
+     * Start output to file, note that the actual writing of the file is done in {@see close_output_to_file()}
+     */
+    public function start_output_to_file(): void {
+        $this->start_output();
+    }
+
     public function start_output() {
         $this->pdf->AddPage('L');
     }
@@ -126,6 +133,17 @@ class writer extends \core\dataformat\base {
         $this->pdf->Output($filename, 'D');
     }
 
+    /**
+     * Write data to disk
+     *
+     * @return bool
+     */
+    public function close_output_to_file(): bool {
+        $this->pdf->Output($this->filepath, 'F');
+
+        return true;
+    }
+
     /**
      * Prints the heading row.
      */
index 5868991..cf4d09e 100644 (file)
@@ -1,8 +1,16 @@
 This files describes API changes in /dataformat/ download system,
 information provided here is intended especially for developers.
 
-=== 3.4 ===
+=== 3.9 ===
+* The following methods have been added to the base dataformat class to allow instances to export to a local
+  file. They can be overridden in extending classes to define how files should be created:
+  - start_output_to_file()
+  - close_output_to_file()
+* Calls to the following dataformat plugin methods have been removed:
+  - write_header()
+  - write_footer()
 
+=== 3.4 ===
 * In order to allow multiple sheets in an exported file the functions write_header() and write_footer() have
   been removed from core dataformat plugins and have been replaced.
   - write_header() has been replaced with the two functions start_output() and start_sheet().
index f5dde91..28c7504 100644 (file)
@@ -111,9 +111,9 @@ if ($ADMIN->fulltree) {
 
     $settings->add(new admin_setting_configtext('enrol_database/newcoursecategory', get_string('newcoursecategory', 'enrol_database'), '', ''));
 
-    require_once($CFG->dirroot.'/enrol/database/settingslib.php');
-
-    $settings->add(new enrol_database_admin_setting_category('enrol_database/defaultcategory', get_string('defaultcategory', 'enrol_database'), get_string('defaultcategory_desc', 'enrol_database')));
+    $settings->add(new admin_settings_coursecat_select('enrol_database/defaultcategory',
+        get_string('defaultcategory', 'enrol_database'),
+        get_string('defaultcategory_desc', 'enrol_database'), 1));
 
     $settings->add(new admin_setting_configtext('enrol_database/templatecourse', get_string('templatecourse', 'enrol_database'), get_string('templatecourse_desc', 'enrol_database'), ''));
 }
index 8fccd35..53c0931 100644 (file)
@@ -1,4 +1,8 @@
 This files describes API changes in the enrol_database code.
 
+=== 3.9 ===
+* Class enrol_database_admin_setting_category has been removed. This class was only used by the database
+  enrolment plugin settings and it was replaced by admin_settings_coursecat_select.
+
 === 3.7 ===
 * enrol/database/cli/sync.php script has been deprecated in favour of enrol_database\task\sync_enrolments task.
index 319ef65..dc70dfd 100644 (file)
@@ -26,6 +26,7 @@ defined('MOODLE_INTERNAL') || die();
 
 $string['addcomment'] = 'Add frequently used comment';
 $string['additionalcomments'] = 'Additional comments';
+$string['additionalcommentsforcriterion'] = 'Additional comments for criterion, {$a}';
 $string['addcriterion'] = 'Add criterion';
 $string['alwaysshowdefinition'] = 'Show guide definition to students';
 $string['backtoediting'] = 'Back to editing';
@@ -33,6 +34,7 @@ $string['clicktocopy'] = 'Click to copy this text into the criteria feedback';
 $string['clicktoedit'] = 'Click to edit';
 $string['clicktoeditname'] = 'Click to edit criterion name';
 $string['comment'] = 'Comment';
+$string['commentpickerforcriterion'] = 'Frequently used comments picker for criterion, {$a}';
 $string['comments'] = 'Frequently used comments';
 $string['commentsdelete'] = 'Delete comment';
 $string['commentsempty'] = 'Click to edit comment';
@@ -70,6 +72,7 @@ $string['guideoptions'] = 'Marking guide options';
 $string['guidestatus'] = 'Current marking guide status';
 $string['hidemarkerdesc'] = 'Hide marker criterion descriptions';
 $string['hidestudentdesc'] = 'Hide student criterion descriptions';
+$string['informationforcriterion'] = 'Information for criterion, {$a}';
 $string['insertcomment'] = 'Insert frequently used comment';
 $string['maxscore'] = 'Maximum score';
 $string['name'] = 'Name';
@@ -88,11 +91,14 @@ $string['regrademessage1'] = 'You are about to save changes to a marking guide t
 $string['regrademessage5'] = 'You are about to save significant changes to a marking guide that has already been used for grading. The gradebook value will be unchanged, but the marking guide will be hidden from students until their item is regraded.';
 $string['regradeoption0'] = 'Do not mark for regrade';
 $string['regradeoption1'] = 'Mark for regrade';
+$string['remark_help'] = 'Enter any additional comments that you would like to provide related to the grade for this criterion. You may also pick from the list of frequently used comments using the frequently used comments picker button.';
 $string['restoredfromdraft'] = 'NOTE: The last attempt to grade this person was not saved properly so draft grades have been restored. If you want to cancel these changes use the \'Cancel\' button below.';
 $string['save'] = 'Save';
 $string['saveguide'] = 'Save marking guide and make it ready';
 $string['saveguidedraft'] = 'Save as draft';
 $string['score'] = 'score';
+$string['scoreforcriterion'] = 'Score for criterion, {$a}';
+$string['score_help'] = 'Enter a score for the criterion, {$a->criterion}. Ensure that this is a number between 0 and {$a->maxscore}.';
 $string['showmarkerdesc'] = 'Show marker criterion descriptions';
 $string['showmarkspercriterionstudents'] = 'Show marks per criterion to students';
 $string['showstudentdesc'] = 'Show student criterion descriptions';
index 8b5fc97..965c805 100644 (file)
@@ -73,6 +73,7 @@
             type="button"
           >
             {{# pix }} info, gradingform_guide {{/ pix }}
+            <span class="sr-only">{{#str}}informationforcriterion, gradingform_guide, {{name}}{{/str}}</span>
         </button>
         <button class="criterion-toggle btn btn-icon icon-no-margin text-reset p-0 font-weight-bold mb-0 ml-auto"
                 type="button"
         <div class="form-group">
           <label for="gradingform_guide-{{uniqid}}-criteria-{{id}}-score">{{#str}}outof, gradingform_guide, {{maxscore}}{{/str}}</label>
           <input class="form-control" type="number" name="advancedgrading[criteria][{{id}}][score]" value="{{score}}"
-              id="gradingform_guide-{{uniqid}}-criteria-{{id}}-score"
-              aria-describedby="gradingform_guide-{{uniqid}}-help-{{id}}-score">
-          <small id="gradingform_guide-{{uniqid}}-help-{{id}}-score" class="sr-only">{{#str}}grade_help, gradingform_guide{{/str}}</small>
+            id="gradingform_guide-{{uniqid}}-criteria-{{id}}-score"
+            aria-describedby="gradingform_guide-{{uniqid}}-help-{{id}}-score"
+            min="0" max="{{maxscore}}"
+            aria-label="{{#str}}scoreforcriterion, gradingform_guide, {{name}}{{/str}}">
+          <span id="gradingform_guide-{{uniqid}}-help-{{id}}-score" aria-hidden="true" class="sr-only">{{!
+            }}{{#str}}score_help, gradingform_guide, { "criterion":  {{# quote }}{{ name }}{{/ quote }}, "maxscore": {{# quote }}{{ maxscore }}{{/ quote }} }{{/str}}
+          </span>
         </div>
         <div class="form-group ">
-          <label class="text-muted" for="gradingform_guide-{{uniqid}}-criteria-{{id}}-remark">{{#str}}additionalcomments, gradingform_guide{{/str}}</label>
+          <label for="gradingform_guide-{{uniqid}}-criteria-{{id}}-remark">{{#str}}additionalcomments, gradingform_guide{{/str}}</label>
           <div class="input-group mb-3 form-inset form-inset-right">
             <textarea class="form-control" type="text" name="advancedgrading[criteria][{{id}}][remark]"
                 id="gradingform_guide-{{uniqid}}-criteria-{{id}}-remark"
                 aria-describedby="gradingform_guide-{{uniqid}}-help-{{id}}-remark"
+                aria-label="{{#str}}additionalcommentsforcriterion, gradingform_guide, {{name}}{{/str}}"
                 data-gradingform-guide-role="remark"
                 rows="2"
                 data-max-rows="5"
                 type="button"
               >
                   {{#pix}}plus, gradingform_guide{{/pix}}
+                  <span class="sr-only">{{#str}}commentpickerforcriterion, gradingform_guide, {{name}}{{/str}}</span>
               </button>
             {{/hascomments}}
           </div>
               </div>
             </div>
           {{/hascomments}}
-          <small id="gradingform_guide-{{uniqid}}-help-{{id}}-remark" class="sr-only">{{#str}}grade_help, gradingform_guide{{/str}}</small>
+          <span id="gradingform_guide-{{uniqid}}-help-{{id}}-remark" aria-hidden="true" class="sr-only">{{#str}}remark_help, gradingform_guide{{/str}}</span>
         </div>
       </div>
     </div>
index 05f5877..fb007bf 100644 (file)
  * @copyright  2020 Victor Deniz <victor@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
+
 namespace core_h5p;
 
+defined('MOODLE_INTERNAL') || die();
+
 use advanced_testcase;
 use core_h5p\local\library\autoloader;
-use moodleform;
 use MoodleQuickForm;
 use page_requirements_manager;
 
@@ -42,6 +44,34 @@ use page_requirements_manager;
  */
 class editor_testcase extends advanced_testcase {
 
+    /**
+     * Form object to be used in test case.
+     */
+    protected function get_test_form() {
+        global $CFG;
+
+        require_once($CFG->libdir . '/formslib.php');
+
+        return new class extends \moodleform {
+            /**
+             * Form definition.
+             */
+            public function definition(): void {
+                // No definition required.
+            }
+
+            /**
+             * Returns form reference.
+             *
+             * @return MoodleQuickForm
+             */
+            public function getform() {
+                $mform = $this->_form;
+                return $mform;
+            }
+        };
+    }
+
     /**
      * Test that existing content is properly set.
      */
@@ -153,7 +183,7 @@ class editor_testcase extends advanced_testcase {
         global $PAGE, $CFG;
 
         // Get form data.
-        $form = new temp_form();
+        $form = $this->get_test_form();
         $mform = $form->getform();
 
         // Call method.
@@ -249,27 +279,3 @@ class editor_testcase extends advanced_testcase {
         $this->assertNotEmpty($out);
     }
 }
-
-/**
- * Form object to be used in test case.
- */
-class temp_form extends moodleform {
-    /**
-     * Form definition.
-     */
-    public function definition(): void {
-        // No definition required.
-    }
-
-    /**
-     * Returns form reference.
-     *
-     * @return MoodleQuickForm
-     */
-    public function getform() {
-        $mform = $this->_form;
-        // Set submitted flag, to simulate submission.
-        $mform->_flagSubmitted = true;
-        return $mform;
-    }
-}
index fed5364..b002b6d 100644 (file)
@@ -31,7 +31,7 @@
 defined('MOODLE_INTERNAL') || die();
 
 $string['cannotcreatedboninstall'] = '<p>Impossible de créer la base de données.</p>
-<p>La base de données indiquées n\'existe pas et l\'utilisateur spécifié n\'a pas les autorisations permettant de créer une base de données.</p>.
+<p>La base de données indiquées n\'existe pas et l\'utilisateur spécifié n\'a pas les permissions requises pour créer une base de données.</p>.
 <p>L\'administrateur du site doit revoir la configuration de la base de données.</p>';
 $string['cannotcreatelangdir'] = 'Création du dossier lang impossible';
 $string['cannotcreatetempdir'] = 'Création du dossier temp impossible';
index 9370d23..31bcd05 100644 (file)
@@ -858,8 +858,9 @@ $string['nosupportedlogstore'] = 'No supported logstore found';
 $string['maxtimelimit'] = 'Maximum time limit';
 $string['maxtimelimit_desc'] = 'To restrict the maximum PHP execution time that Moodle will allow without any output being displayed, enter a value in seconds here. 0 means that Moodle default restrictions are used. If you have a front-end server with its own time limit, set this value lower to receive PHP errors in logs. Does not apply to CLI scripts.';
 $string['moodleapp'] = 'Moodle app';
-$string['moodleapp_help'] = '<p>If you have an account on a Moodle site, you can access all your courses on your mobile devices with our free mobile app for Moodle.</p>
-<a href="https://moodle.com/mobile-app">Get the Moodle app</a>';
+$string['moodleapp_help'] = '<p>The free Moodle app enables users to access their courses on mobile devices. Additional app features are available with a Pro or Premium app plan.</p>
+<a href="https://moodle.com/mobile-app">Get the Moodle app</a><br />
+<a href="https://apps.moodle.com">Moodle Apps Portal</a>';
 $string['moodlebrandedapp'] = 'Branded Moodle app';
 $string['moodlebrandedapp_help'] = '<p>The Branded Moodle app has all the functionality of our free mobile app for Moodle combined with your own custom branding.</p>
 <a href="https://moodle.com/branded-app">About the Branded Moodle app</a>';
index c1c682b..06958d6 100644 (file)
 
 $string['author'] = 'Author';
 $string['contentdeleted'] = 'The content has been deleted.';
+$string['contentname'] = 'Content name';
 $string['contentnotdeleted'] = 'An error was encountered while trying to delete the content.';
+$string['contentnotrenamed'] = 'An error was encountered while trying to rename the content.';
+$string['contentrenamed'] = 'The content has been renamed.';
+$string['eventcontentcreated'] = 'Content created';
+$string['eventcontentdeleted'] = 'Content deleted';
+$string['eventcontentupdated'] = 'Content updated';
+$string['eventcontentuploaded'] = 'Content uploaded';
+$string['eventcontentviewed'] = 'Content viewed';
 $string['deletecontent'] = 'Delete content';
 $string['deletecontentconfirm'] = 'Are you sure you want to delete the content <em>\'{$a->name}\'</em> and all associated files? This action cannot be undone.';
 $string['file'] = 'Upload content';
 $string['file_help'] = 'Files may be stored in the content bank for use in courses. Only files used by content types enabled on the site may be uploaded.';
 $string['name'] = 'Content';
 $string['nopermissiontodelete'] = 'You do not have permission to delete content.';
+$string['nopermissiontomanage'] = 'You do not have permission to manage content.';
 $string['privacy:metadata:content:contenttype'] = 'The contenttype plugin of the content in the content bank.';
 $string['privacy:metadata:content:name'] = 'Name of the content in the content bank.';
 $string['privacy:metadata:content:timecreated'] = 'The time when the content was created.';
 $string['privacy:metadata:content:timemodified'] = 'The time when the content was modified.';
 $string['privacy:metadata:content:usercreated'] = 'The user has created the content.';
-$string['privacy:metadata:content:usercreated'] = 'The user who created the content.';
-$string['privacy:metadata:content:usermodified'] = 'Last user has modified the content.';
 $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['rename'] = 'Rename';
+$string['renamecontent'] = 'Rename content';
 $string['timecreated'] = 'Time created';
 $string['unsupported'] = 'This content type is not supported.';
 $string['upload'] = 'Upload';
index f0a53e9..4885cc4 100644 (file)
@@ -1499,6 +1499,7 @@ $string['numwords'] = '{$a} words';
 $string['numyear'] = '{$a} year';
 $string['numyears'] = '{$a} years';
 $string['ok'] = 'OK';
+$string['okay'] = 'Ok';
 $string['oldpassword'] = 'Current password';
 $string['olduserdirectory'] = 'This is the OLD users directory, and is no longer needed. You may safely delete it. The files it contains have been copied to the NEW user directory.';
 $string['optional'] = 'optional';
index 12a250e..21530de 100644 (file)
@@ -153,7 +153,9 @@ $string['confirmunassignno'] = 'Cancel';
 $string['contentbank:access'] = 'Access the content bank';
 $string['contentbank:deleteanycontent'] = 'Delete any content from the content bank';
 $string['contentbank:deleteowncontent'] = 'Delete content from own content bank';
-$string['contentbank:upload'] = 'Upload content to the content bank';
+$string['contentbank:manageanycontent'] = 'Manage any content from the content bank (rename, move, publish, share, etc.)';
+$string['contentbank:manageowncontent'] = 'Manage content from own content bank (rename, move, publish, share, etc.)';
+$string['contentbank:upload'] = 'Upload new content in the content bank';
 $string['context'] = 'Context';
 $string['course:activityvisibility'] = 'Hide/show activities';
 $string['course:bulkmessaging'] = 'Send a message to many people';
diff --git a/lib/amd/build/local/modal/alert.min.js b/lib/amd/build/local/modal/alert.min.js
new file mode 100644 (file)
index 0000000..8344163
Binary files /dev/null and b/lib/amd/build/local/modal/alert.min.js differ
diff --git a/lib/amd/build/local/modal/alert.min.js.map b/lib/amd/build/local/modal/alert.min.js.map
new file mode 100644 (file)
index 0000000..5d15d67
Binary files /dev/null and b/lib/amd/build/local/modal/alert.min.js.map differ
index 3269e6e..642c2ed 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index 5b24706..cb5d00f 100644 (file)
Binary files a/lib/amd/build/modal.min.js.map and b/lib/amd/build/modal.min.js.map differ
index c172543..90366c2 100644 (file)
Binary files a/lib/amd/build/modal_cancel.min.js and b/lib/amd/build/modal_cancel.min.js differ
index 95e19e1..ff44652 100644 (file)
Binary files a/lib/amd/build/modal_cancel.min.js.map and b/lib/amd/build/modal_cancel.min.js.map differ
index 2fa4d54..87e5370 100644 (file)
Binary files a/lib/amd/build/modal_factory.min.js and b/lib/amd/build/modal_factory.min.js differ
index ff93873..5d1aafd 100644 (file)
Binary files a/lib/amd/build/modal_factory.min.js.map and b/lib/amd/build/modal_factory.min.js.map differ
index b01e07b..05cda33 100644 (file)
Binary files a/lib/amd/build/modal_registry.min.js and b/lib/amd/build/modal_registry.min.js differ
index baa89bb..04a952b 100644 (file)
Binary files a/lib/amd/build/modal_registry.min.js.map and b/lib/amd/build/modal_registry.min.js.map differ
index 2a02302..5cf92de 100644 (file)
Binary files a/lib/amd/build/modal_save_cancel.min.js and b/lib/amd/build/modal_save_cancel.min.js differ
index c2f3a26..e934234 100644 (file)
Binary files a/lib/amd/build/modal_save_cancel.min.js.map and b/lib/amd/build/modal_save_cancel.min.js.map differ
index 4e559a2..e2a0bd3 100644 (file)
Binary files a/lib/amd/build/notification.min.js and b/lib/amd/build/notification.min.js differ
index a7dba21..c9b4369 100644 (file)
Binary files a/lib/amd/build/notification.min.js.map and b/lib/amd/build/notification.min.js.map differ
diff --git a/lib/amd/build/prefetch.min.js b/lib/amd/build/prefetch.min.js
new file mode 100644 (file)
index 0000000..eee33e0
Binary files /dev/null and b/lib/amd/build/prefetch.min.js differ
diff --git a/lib/amd/build/prefetch.min.js.map b/lib/amd/build/prefetch.min.js.map
new file mode 100644 (file)
index 0000000..66563ea
Binary files /dev/null and b/lib/amd/build/prefetch.min.js.map differ
index aefca08..d7d76f4 100644 (file)
Binary files a/lib/amd/build/templates.min.js and b/lib/amd/build/templates.min.js differ
index 3a6b52c..b95cb3a 100644 (file)
Binary files a/lib/amd/build/templates.min.js.map and b/lib/amd/build/templates.min.js.map differ
diff --git a/lib/amd/src/local/modal/alert.js b/lib/amd/src/local/modal/alert.js
new file mode 100644 (file)
index 0000000..da90eef
--- /dev/null
@@ -0,0 +1,39 @@
+// 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/>.
+
+/**
+ * Alert modal.
+ *
+ * @module     core/modal_alert
+ * @class      modal_alert
+ * @package    core
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Modal from 'core/modal';
+
+export default class extends Modal {
+    /**
+     * Register all event listeners.
+     */
+    registerEventListeners() {
+        // Call the parent registration.
+        super.registerEventListeners();
+
+        // Register to close on cancel.
+        this.registerCloseOnCancel();
+    }
+}
index aeefaed..154f974 100644 (file)
@@ -610,10 +610,11 @@ define([
      * already been.
      *
      * @method show
+     * @returns {Promise}
      */
     Modal.prototype.show = function() {
         if (this.isVisible()) {
-            return;
+            return $.Deferred().resolve();
         }
 
         var pendingPromise = new Pending('core/modal:show');
@@ -628,7 +629,7 @@ define([
             this.attachToDOM();
         }
 
-        this.getBackdrop()
+        return this.getBackdrop()
         .then(function(backdrop) {
             var currentIndex = this.calculateZIndex();
             var newIndex = currentIndex + 2;
@@ -699,6 +700,7 @@ define([
      * @method destroy
      */
     Modal.prototype.destroy = function() {
+        this.hide();
         this.root.remove();
         this.root.trigger(ModalEvents.destroyed, this);
     };
@@ -802,6 +804,52 @@ define([
         }.bind(this));
     };
 
+    /**
+     * Register a listener to close the dialogue when the cancel button is pressed.
+     *
+     * @method registerCloseOnCancel
+     */
+    Modal.prototype.registerCloseOnCancel = function() {
+        // Handle the clicking of the Cancel button.
+        this.getModal().on(CustomEvents.events.activate, this.getActionSelector('cancel'), function(e, data) {
+            var cancelEvent = $.Event(ModalEvents.cancel);
+            this.getRoot().trigger(cancelEvent, this);
+
+            if (!cancelEvent.isDefaultPrevented()) {
+                data.originalEvent.preventDefault();
+
+                if (this.removeOnClose) {
+                    this.destroy();
+                } else {
+                    this.hide();
+                }
+            }
+        }.bind(this));
+    };
+
+    /**
+     * Register a listener to close the dialogue when the save button is pressed.
+     *
+     * @method registerCloseOnSave
+     */
+    Modal.prototype.registerCloseOnSave = function() {
+        // Handle the clicking of the Cancel button.
+        this.getModal().on(CustomEvents.events.activate, this.getActionSelector('save'), function(e, data) {
+            var saveEvent = $.Event(ModalEvents.save);
+            this.getRoot().trigger(saveEvent, this);
+
+            if (!saveEvent.isDefaultPrevented()) {
+                data.originalEvent.preventDefault();
+
+                if (this.removeOnClose) {
+                    this.destroy();
+                } else {
+                    this.hide();
+                }
+            }
+        }.bind(this));
+    };
+
     /**
      * Set or resolve and set the value using the function.
      *
@@ -827,5 +875,44 @@ define([
         return p;
     };
 
+    /**
+     * Set the title text of a button.
+     *
+     * This method is overloaded to take either a string value for the button title or a jQuery promise that is resolved with
+     * text most commonly from a Str.get_string call.
+     *
+     * @param {DOMString} action The action of the button
+     * @param {(String|object)} value The button text, or a promise which will resolve to it
+     * @returns {Promise}
+     */
+    Modal.prototype.setButtonText = function(action, value) {
+        const button = this.getFooter().find(this.getActionSelector(action));
+
+        if (!button) {
+            throw new Error("Unable to find the '" + action + "' button");
+        }
+
+        return this.asyncSet(value, button.text.bind(button));
+    };
+
+    /**
+     * Get the Selector for an action.
+     *
+     * @param {String} action
+     * @returns {DOMString}
+     */
+    Modal.prototype.getActionSelector = function(action) {
+        return "[data-action='" + action + "']";
+    };
+
+    /**
+     * Set the flag to remove the modal from the DOM on close.
+     *
+     * @param {Boolean} remove
+     */
+    Modal.prototype.setRemoveOnClose = function(remove) {
+        this.removeOnClose = remove;
+    };
+
     return Modal;
 });
index 2e182c1..8f593f0 100644 (file)
  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/notification', 'core/custom_interaction_events', 'core/modal', 'core/modal_events'],
-        function($, Notification, CustomEvents, Modal, ModalEvents) {
+import Modal from 'core/modal';
 
-    var SELECTORS = {
-        CANCEL_BUTTON: '[data-action="cancel"]',
-    };
+export default class extends Modal {
+    constructor(root) {
+        super(root);
 
-    /**
-     * Constructor for the Modal.
-     *
-     * @param {object} root The root jQuery element for the modal
-     */
-    var ModalCancel = function(root) {
-        Modal.call(this, root);
-
-        if (!this.getFooter().find(SELECTORS.CANCEL_BUTTON).length) {
+        if (!this.getFooter().find(this.getActionSelector('cancel')).length) {
             Notification.exception({message: 'No cancel button found'});
         }
-    };
-
-    ModalCancel.prototype = Object.create(Modal.prototype);
-    ModalCancel.prototype.constructor = ModalCancel;
-
-    /**
-     * Override parent implementation to prevent changing the footer content.
-     */
-    ModalCancel.prototype.setFooter = function() {
-        Notification.exception({message: 'Can not change the footer of a cancel modal'});
-        return;
-    };
+    }
 
     /**
-     * Set up all of the event handling for the modal.
-     *
-     * @method registerEventListeners
+     * Register all event listeners.
      */
-    ModalCancel.prototype.registerEventListeners = function() {
-        // Apply parent event listeners.
-        Modal.prototype.registerEventListeners.call(this);
-
-        this.getModal().on(CustomEvents.events.activate, SELECTORS.CANCEL_BUTTON, function(e, data) {
-            var cancelEvent = $.Event(ModalEvents.cancel);
-            this.getRoot().trigger(cancelEvent, this);
-
-            if (!cancelEvent.isDefaultPrevented()) {
-                this.hide();
-                data.originalEvent.preventDefault();
-            }
-        }.bind(this));
-    };
-
-    return ModalCancel;
-});
+    registerEventListeners() {
+        // Call the parent registration.
+        super.registerEventListeners();
+
+        // Register to close on cancel.
+        this.registerCloseOnCancel();
+    }
+}
index 2b304bc..1e27c2f 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 define(['jquery', 'core/modal_events', 'core/modal_registry', 'core/modal',
-        'core/modal_save_cancel', 'core/modal_cancel',
+        'core/modal_save_cancel', 'core/modal_cancel', 'core/local/modal/alert',
         'core/templates', 'core/notification', 'core/custom_interaction_events',
         'core/pending'],
     function($, ModalEvents, ModalRegistry, Modal, ModalSaveCancel,
-        ModalCancel, Templates, Notification, CustomEvents, Pending) {
+        ModalCancel, ModalAlert, Templates, Notification, CustomEvents, Pending) {
 
     // The templates for each type of modal.
     var TEMPLATES = {
         DEFAULT: 'core/modal',
         SAVE_CANCEL: 'core/modal_save_cancel',
         CANCEL: 'core/modal_cancel',
+        ALERT: 'core/local/modal/alert',
     };
 
     // The available types of modals.
@@ -41,12 +42,14 @@ define(['jquery', 'core/modal_events', 'core/modal_registry', 'core/modal',
         DEFAULT: 'DEFAULT',
         SAVE_CANCEL: 'SAVE_CANCEL',
         CANCEL: 'CANCEL',
+        ALERT: 'ALERT',
     };
 
     // Register the common set of modals.
     ModalRegistry.register(TYPES.DEFAULT, Modal, TEMPLATES.DEFAULT);
     ModalRegistry.register(TYPES.SAVE_CANCEL, ModalSaveCancel, TEMPLATES.SAVE_CANCEL);
     ModalRegistry.register(TYPES.CANCEL, ModalCancel, TEMPLATES.CANCEL);
+    ModalRegistry.register(TYPES.ALERT, ModalAlert, TEMPLATES.ALERT);
 
     /**
      * Set up the events required to show the modal and return focus when the modal
@@ -185,10 +188,21 @@ define(['jquery', 'core/modal_events', 'core/modal_registry', 'core/modal',
                     modal.setFooter(modalConfig.footer);
                 }
 
+                if (modalConfig.buttons) {
+                    Object.entries(modalConfig.buttons).forEach(function([key, value]) {
+                        modal.setButtonText(key, value);
+                    });
+                }
+
                 if (isLarge) {
                     modal.setLarge();
                 }
 
+                if (typeof modalConfig.removeOnClose !== 'undefined') {
+                    // If configured remove the modal when hiding it.
+                    modal.setRemoveOnClose(modalConfig.removeOnClose);
+                }
+
                 return modal;
             });
 
index 164af17..fc7bdbc 100644 (file)
@@ -22,7 +22,7 @@
  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['core/notification'], function(Notification) {
+define(['core/notification', 'core/prefetch'], function(Notification, Prefetch) {
 
     // A singleton registry for all modules to access. Allows types to be
     // added at runtime.
@@ -64,6 +64,9 @@ define(['core/notification'], function(Notification) {
             module: module,
             template: template,
         };
+
+        // Prefetch the template.
+        Prefetch.prefetchTemplate(template);
     };
 
     return {
index f62751c..42416be 100644 (file)
  * @copyright  2016 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['jquery', 'core/notification', 'core/custom_interaction_events', 'core/modal', 'core/modal_events'],
-        function($, Notification, CustomEvents, Modal, ModalEvents) {
+import Modal from 'core/modal';
 
-    var SELECTORS = {
-        SAVE_BUTTON: '[data-action="save"]',
-        CANCEL_BUTTON: '[data-action="cancel"]',
-    };
+export default class extends Modal {
+    constructor(root) {
+        super(root);
 
-    /**
-     * Constructor for the Modal.
-     *
-     * @param {object} root The root jQuery element for the modal
-     */
-    var ModalSaveCancel = function(root) {
-        Modal.call(this, root);
-
-        if (!this.getFooter().find(SELECTORS.SAVE_BUTTON).length) {
+        if (!this.getFooter().find(this.getActionSelector('save')).length) {
             Notification.exception({message: 'No save button found'});
         }
 
-        if (!this.getFooter().find(SELECTORS.CANCEL_BUTTON).length) {
+        if (!this.getFooter().find(this.getActionSelector('cancel')).length) {
             Notification.exception({message: 'No cancel button found'});
         }
-    };
+    }
 
-    ModalSaveCancel.prototype = Object.create(Modal.prototype);
-    ModalSaveCancel.prototype.constructor = ModalSaveCancel;
+    /**
+     * Register all event listeners.
+     */
+    registerEventListeners() {
+        // Call the parent registration.
+        super.registerEventListeners();
+
+        // Register to close on save/cancel.
+        this.registerCloseOnSave();
+        this.registerCloseOnCancel();
+    }
 
     /**
      * Override parent implementation to prevent changing the footer content.
      */
-    ModalSaveCancel.prototype.setFooter = function() {
+    setFooter() {
         Notification.exception({message: 'Can not change the footer of a save cancel modal'});
         return;
-    };
-
-    /**
-     * Set up all of the event handling for the modal.
-     *
-     * @method registerEventListeners
-     */
-    ModalSaveCancel.prototype.registerEventListeners = function() {
-        // Apply parent event listeners.
-        Modal.prototype.registerEventListeners.call(this);
-
-        this.getModal().on(CustomEvents.events.activate, SELECTORS.SAVE_BUTTON, function(e, data) {
-            var saveEvent = $.Event(ModalEvents.save);
-            this.getRoot().trigger(saveEvent, this);
-
-            if (!saveEvent.isDefaultPrevented()) {
-                this.hide();
-                data.originalEvent.preventDefault();
-            }
-        }.bind(this));
-
-        this.getModal().on(CustomEvents.events.activate, SELECTORS.CANCEL_BUTTON, function(e, data) {
-            var cancelEvent = $.Event(ModalEvents.cancel);
-            this.getRoot().trigger(cancelEvent, this);
-
-            if (!cancelEvent.isDefaultPrevented()) {
-                this.hide();
-                data.originalEvent.preventDefault();
-            }
-        }.bind(this));
-    };
+    }
 
     /**
-     * Allows to overwrite the text of "Save changes" button.
-     *
-     * This method is overloaded to take either a string value for the button title or a jQuery promise that is resolved with
-     * text most commonly from a Str.get_string call.
+     * Set the title of the save button.
      *
-     * @param {(String|object)} value The button text, or a jQuery promise which will resolve it
+     * @param {String|Promise} value The button text, or a Promise which will resolve it
+     * @returns{Promise}
      */
-    ModalSaveCancel.prototype.setSaveButtonText = function(value) {
-        var button = this.getFooter().find(SELECTORS.SAVE_BUTTON);
-
-        this.asyncSet(value, button.text.bind(button));
-    };
-
-    return ModalSaveCancel;
-});
+    setSaveButtonText(value) {
+        return this.setButtonText('save', value);
+    }
+}
index 3bbe59f..0822899 100644 (file)
 // You should have received a copy of the GNU General Public License
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
+import Pending from 'core/pending';
+import Log from 'core/log';
+
+let currentContextId = M.cfg.contextid;
+
+const notificationTypes = {
+    success:  'core/notification_success',
+    info:     'core/notification_info',
+    warning:  'core/notification_warning',
+    error:    'core/notification_error',
+};
+
+const notificationRegionId = 'user-notifications';
+
+const Selectors = {
+    notificationRegion: `#${notificationRegionId}`,
+    fallbackRegionParents: [
+        '#region-main',
+        '[role="main"]',
+        'body',
+    ],
+};
+
+const setupTargetRegion = () => {
+    let targetRegion = getNotificationRegion();
+    if (targetRegion) {
+        return false;
+    }
+
+    const newRegion = document.createElement('span');
+    newRegion.id = notificationRegionId;
+
+    return Selectors.fallbackRegionParents.some(selector => {
+        const targetRegion = document.querySelector(selector);
+
+        if (targetRegion) {
+            targetRegion.prepend(newRegion);
+            return true;
+        }
+
+        return false;
+    });
+};
+
+
 /**
- * A system for displaying notifications to users from the session.
+ * Poll the server for any new notifications.
  *
- * Wrapper for the YUI M.core.notification class. Allows us to
- * use the YUI version in AMD code until it is replaced.
- *
- * @module     core/notification
- * @class      notification
- * @package    core
- * @copyright  2015 Damyon Wiese <damyon@moodle.com>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- * @since      2.9
+ * @returns {Promise}
  */
-define(['core/yui', 'jquery', 'core/log', 'core/pending'],
-function(Y, $, log, Pending) {
-    var notificationModule = {
-        types: {
-            'success':  'core/notification_success',
-            'info':     'core/notification_info',
-            'warning':  'core/notification_warning',
-            'error':    'core/notification_error',
-        },
+export const fetchNotifications = async() => {
+    const Ajax = await import('core/ajax');
 
-        fieldName: 'user-notifications',
-
-        fetchNotifications: function() {
-            var pendingPromise = new Pending('core/notification:fetchNotifications');
-
-            require(['core/ajax'], function(ajax) {
-                var promises = ajax.call([{
-                    methodname: 'core_fetch_notifications',
-                    args: {
-                        contextid: notificationModule.contextid
-                    }
-                }]);
-
-                // This currently fails when not logged in.
-                // eslint-disable-next-line promise/catch-or-return
-                promises[0]
-                .then(notificationModule.addNotifications)
-                .always(pendingPromise.resolve);
-            });
-        },
+    return Ajax.call([{
+        methodname: 'core_fetch_notifications',
+        args: {
+            contextid: currentContextId
+        }
+    }])[0]
+    .then(addNotifications);
+};
 
-        addNotifications: function(notifications) {
-            var pendingPromise = new Pending('core/notification:addNotifications');
+/**
+ * Add all of the supplied notifications.
+ *
+ * @param {Array} notifications The list of notificaitons
+ * @returns {Promise}
+ */
+const addNotifications = notifications => {
+    if (!notifications.length) {
+        return Promise.resolve();
+    }
 
-            if (!notifications) {
-                notifications = [];
-            }
+    const pendingPromise = new Pending('core/notification:addNotifications');
+    notifications.forEach(notification => renderNotification(notification.template, notification.variables));
 
-            $.each(notifications, function(i, notification) {
-                notificationModule.renderNotification(notification.template, notification.variables);
-            });
+    return pendingPromise.resolve();
+};
 
-            pendingPromise.resolve();
-        },
+/**
+ * Add a notification to the page.
+ *
+ * Note: This does not cause the notification to be added to the session.
+ *
+ * @param {Object}  notification                The notification to add.
+ * @param {string}  notification.message        The body of the notification
+ * @param {string}  notification.type           The type of notification to add (error, warning, info, success).
+ * @param {Boolean} notification.closebutton    Whether to show the close button.
+ * @param {Boolean} notification.announce       Whether to announce to screen readers.
+ * @returns {Promise}
+ */
+export const addNotification = notification => {
+    const pendingPromise = new Pending('core/notification:addNotifications');
 
-        setupTargetRegion: function() {
-            var targetRegion = $('#' + notificationModule.fieldName);
-            if (targetRegion.length) {
-                return false;
-            }
+    let template = notificationTypes.error;
 
-            var newRegion = $('<span>').attr('id', notificationModule.fieldName);
+    notification = {
+        closebutton:    true,
+        announce:       true,
+        type:           'error',
+        ...notification,
+    };
 
-            targetRegion = $('#region-main');
-            if (targetRegion.length) {
-                return targetRegion.prepend(newRegion);
-            }
+    if (notification.template) {
+        template = notification.template;
+        delete notification.template;
+    } else if (notification.type) {
+        if (typeof notificationTypes[notification.type] !== 'undefined') {
+            template = notificationTypes[notification.type];
+        }
+        delete notification.type;
+    }
 
-            targetRegion = $('[role="main"]');
-            if (targetRegion.length) {
-                return targetRegion.prepend(newRegion);
-            }
+    return renderNotification(template, notification)
+    .then(pendingPromise.resolve);
+};
 
-            targetRegion = $('body');
-            return targetRegion.prepend(newRegion);
-        },
+const renderNotification = async(template, variables) => {
+    if (typeof variables.message === 'undefined' || !variables.message) {
+        Log.debug('Notification received without content. Skipping.');
+        return;
+    }
 
-        addNotification: function(notification) {
-            var pendingPromise = new Pending('core/notification:addNotifications');
-
-            var template = notificationModule.types.error;
-
-            notification = $.extend({
-                closebutton:    true,
-                announce:       true,
-                type:           'error'
-            }, notification);
-
-            if (notification.template) {
-                template = notification.template;
-                delete notification.template;
-            } else if (notification.type) {
-                if (typeof notificationModule.types[notification.type] !== 'undefined') {
-                    template = notificationModule.types[notification.type];
-                }
-                delete notification.type;
-            }
+    const pendingPromise = new Pending('core/notification:renderNotification');
+    const Templates = await import('core/templates');
 
-            pendingPromise.resolve();
+    Templates.renderForPromise(template, variables)
+    .then(({html, js = ''}) => {
+        Templates.prependNodeContents(getNotificationRegion(), html, js);
 
-            return notificationModule.renderNotification(template, notification);
-        },
+        return;
+    })
+    .then(pendingPromise.resolve)
+    .catch(exception);
+};
 
-        renderNotification: function(template, variables) {
-            if (typeof variables.message === 'undefined' || !variables.message) {
-                log.debug('Notification received without content. Skipping.');
-                return;
-            }
+const getNotificationRegion = () => document.querySelector(Selectors.notificationRegion);
 
-            var pendingPromise = new Pending('core/notification:renderNotification');
+/**
+ * Alert dialogue.
+ *
+ * @param {String|Promise} title
+ * @param {String|Promise} message
+ * @param {String|Promise} cancelText
+ * @returns {Promise}
+ */
+export const alert = async(title, message, cancelText) => {
+    var pendingPromise = new Pending('core/notification:alert');
 
-            require(['core/templates'], function(templates) {
-                templates.render(template, variables)
-                .then(function(html, js) {
-                    $('#' + notificationModule.fieldName).prepend(html);
-                    templates.runTemplateJS(js);
+    const ModalFactory = await import('core/modal_factory');
 
-                    return;
-                })
-                .always(pendingPromise.resolve)
-                .catch(notificationModule.exception);
-            });
+    return ModalFactory.create({
+        type: ModalFactory.types.ALERT,
+        body: message,
+        title: title,
+        buttons: {
+            cancel: cancelText,
         },
+        removeOnClose: true,
+    })
+    .then(function(modal) {
+        modal.show();
 
-        alert: function(title, message, yesLabel) {
-            var pendingPromise = new Pending('core/notification:alert');
+        pendingPromise.resolve();
+        return modal;
+    });
+};
 
-            // Here we are wrapping YUI. This allows us to start transitioning, but
-            // wait for a good alternative without having inconsistent dialogues.
-            Y.use('moodle-core-notification-alert', function() {
-                var alert = new M.core.alert({
-                    title: title,
-                    message: message,
-                    yesLabel: yesLabel
-                });
-
-                alert.show();
+/**
+ * The confirm has now been replaced with a save and cancel dialogue.
+ *
+ * @param {String|Promise} title
+ * @param {String|Promise} question
+ * @param {String|Promise} saveLabel
+ * @param {String|Promise} noLabel
+ * @param {String|Promise} saveCallback
+ * @param {String|Promise} cancelCallback
+ * @returns {Promise}
+ */
+export const confirm = (title, question, saveLabel, noLabel, saveCallback, cancelCallback) =>
+        saveCancel(title, question, saveLabel, saveCallback, cancelCallback);
 
-                pendingPromise.resolve();
-            });
+/**
+ * The Save and Cancel dialogue helper.
+ *
+ * @param {String|Promise} title
+ * @param {String|Promise} question
+ * @param {String|Promise} saveLabel
+ * @param {String|Promise} saveCallback
+ * @param {String|Promise} cancelCallback
+ * @returns {Promise}
+ */
+export const saveCancel = async(title, question, saveLabel, saveCallback, cancelCallback) => {
+    const pendingPromise = new Pending('core/notification:confirm');
+
+    const [
+        ModalFactory,
+        ModalEvents,
+    ] = await Promise.all([
+        import('core/modal_factory'),
+        import('core/modal_events'),
+    ]);
+
+    return ModalFactory.create({
+        type: ModalFactory.types.SAVE_CANCEL,
+        title: title,
+        body: question,
+        buttons: {
+            // Note: The noLabel is no longer supported.
+            save: saveLabel,
         },
+        removeOnClose: true,
+    })
+    .then(function(modal) {
+        modal.show();
 
-        confirm: function(title, question, yesLabel, noLabel, yesCallback, noCallback) {
-            var pendingPromise = new Pending('core/notification:confirm');
-
-            // Here we are wrapping YUI. This allows us to start transitioning, but
-            // wait for a good alternative without having inconsistent dialogues.
-            Y.use('moodle-core-notification-confirm', function() {
-                var modal = new M.core.confirm({
-                    title: title,
-                    question: question,
-                    yesLabel: yesLabel,
-                    noLabel: noLabel
-                });
-
-                modal.on('complete-yes', function() {
-                    yesCallback();
-                });
-                if (noCallback) {
-                    modal.on('complete-no', function() {
-                        noCallback();
-                    });
-                }
-                modal.show();
-
-                pendingPromise.resolve();
-            });
-        },
+        modal.getRoot().on(ModalEvents.save, saveCallback);
+        modal.getRoot().on(ModalEvents.cancel, cancelCallback);
+        pendingPromise.resolve();
 
-        exception: function(ex) {
-            var pendingPromise = new Pending('core/notification:addNotifications');
+        return modal;
+    });
+};
 
-            // Fudge some parameters.
-            if (typeof ex.stack == 'undefined') {
-                ex.stack = '';
-            }
-            if (ex.debuginfo) {
-                ex.stack += ex.debuginfo + '\n';
-            }
-            if (!ex.backtrace && ex.stacktrace) {
-                ex.backtrace = ex.stacktrace;
-            }
-            if (ex.backtrace) {
-                ex.stack += ex.backtrace;
-                var ln = ex.backtrace.match(/line ([^ ]*) of/);
-                var fn = ex.backtrace.match(/ of ([^:]*): /);
-                if (ln && ln[1]) {
-                    ex.lineNumber = ln[1];
-                }
-                if (fn && fn[1]) {
-                    ex.fileName = fn[1];
-                    if (ex.fileName.length > 30) {
-                        ex.fileName = '...' + ex.fileName.substr(ex.fileName.length - 27);
-                    }
-                }
-            }
-            if (typeof ex.name == 'undefined' && ex.errorcode) {
-                ex.name = ex.errorcode;
+/**
+ * Wrap M.core.exception.
+ *
+ * @param {Error} ex
+ */
+export const exception = async ex => {
+    const pendingPromise = new Pending('core/notification:displayException');
+
+    // Fudge some parameters.
+    if (!ex.stack) {
+        ex.stack = '';
+    }
+
+    if (ex.debuginfo) {
+        ex.stack += ex.debuginfo + '\n';
+    }
+
+    if (!ex.backtrace && ex.stacktrace) {
+        ex.backtrace = ex.stacktrace;
+    }
+
+    if (ex.backtrace) {
+        ex.stack += ex.backtrace;
+        const ln = ex.backtrace.match(/line ([^ ]*) of/);
+        const fn = ex.backtrace.match(/ of ([^:]*): /);
+        if (ln && ln[1]) {
+            ex.lineNumber = ln[1];
+        }
+        if (fn && fn[1]) {
+            ex.fileName = fn[1];
+            if (ex.fileName.length > 30) {
+                ex.fileName = '...' + ex.fileName.substr(ex.fileName.length - 27);
             }
-
-            Y.use('moodle-core-notification-exception', function() {
-                var modal = new M.core.exception(ex);
-
-                modal.show();
-
-                pendingPromise.resolve();
-            });
         }
-    };
+    }
 
-    return /** @alias module:core/notification */{
-        init: function(contextid, notifications) {
-            notificationModule.contextid = contextid;
+    if (typeof ex.name === 'undefined' && ex.errorcode) {
+        ex.name = ex.errorcode;
+    }
 
-            // Setup the message target region if it isn't setup already
-            notificationModule.setupTargetRegion();
+    const Y = await import('core/yui');
+    Y.use('moodle-core-notification-exception', function() {
+        var modal = new M.core.exception(ex);
 
-            // Add provided notifications.
-            notificationModule.addNotifications(notifications);
+        modal.show();
 
-            // Poll for any new notifications.
-            notificationModule.fetchNotifications();
-        },
+        pendingPromise.resolve();
+    });
+};
 
-        /**
-         * Poll the server for any new notifications.
-         *
-         * @method fetchNotifications
-         */
-        fetchNotifications: notificationModule.fetchNotifications,
-
-        /**
-         * Add a notification to the page.
-         *
-         * Note: This does not cause the notification to be added to the session.
-         *
-         * @method addNotification
-         * @param {Object}  notification                The notification to add.
-         * @param {string}  notification.message        The body of the notification
-         * @param {string}  notification.type           The type of notification to add (error, warning, info, success).
-         * @param {Boolean} notification.closebutton    Whether to show the close button.
-         * @param {Boolean} notification.announce       Whether to announce to screen readers.
-         */
-        addNotification: notificationModule.addNotification,
-
-        /**
-         * Wrap M.core.alert.
-         *
-         * @method alert
-         * @param {string} title
-         * @param {string} message
-         * @param {string} yesLabel
-         */
-        alert: notificationModule.alert,
-
-        /**
-         * Wrap M.core.confirm.
-         *
-         * @method confirm
-         * @param {string} title
-         * @param {string} question
-         * @param {string} yesLabel
-         * @param {string} noLabel
-         * @param {function} yesCallback
-         * @param {function} noCallback Optional parameter to be called if the user presses cancel.
-         */
-        confirm: notificationModule.confirm,
-
-        /**
-         * Wrap M.core.exception.
-         *
-         * @method exception
-         * @param {Error} ex
-         */
-        exception: notificationModule.exception
-    };
-});
+/**
+ * Initialise the page for the suppled context, and displaying the supplied notifications.
+ *
+ * @param {Number} contextId
+ * @param {Array} notificationList
+ */
+export const init = (contextId, notificationList) => {
+    currentContextId = contextId;
+
+    // Setup the message target region if it isn't setup already
+    setupTargetRegion();
+
+    // Add provided notifications.
+    addNotifications(notificationList);
+
+    // Perform an initial poll for any new notifications.
+    fetchNotifications();
+};
+
+// To maintain backwards compatability we export default here.
+export default {
+    init,
+    fetchNotifications,
+    addNotification,
+    alert,
+    confirm,
+    saveCancel,
+    exception,
+};
diff --git a/lib/amd/src/prefetch.js b/lib/amd/src/prefetch.js
new file mode 100644 (file)
index 0000000..e8e6fb8
--- /dev/null
@@ -0,0 +1,194 @@
+// 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/>.
+
+/**
+ * Prefetch module to help lazily load content for use on the current page.
+ *
+ * @module     core/prefetch
+ * @class      prefetch
+ * @package    core
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import Config from 'core/config';
+
+// Keep track of whether the initial prefetch has occurred.
+let initialPrefetchComplete = false;
+
+// Prefetch templates.
+let templateList = [];
+
+// Prefetch strings.
+let stringList = {};
+
+let prefetchTimer;
+
+/**
+ * Fetch all queued items in the queue.
+ *
+ * Should only be called via processQueue.
+ */
+const fetchQueue = () => {
+    // Prefetch templates.
+    if (templateList) {
+        const templatesToLoad = templateList.slice();
+        templateList = [];
+        import('core/templates')
+        .then(Templates => Templates.prefetchTemplates(templatesToLoad))
+        .catch();
+    }
+
+    // Prefetch strings.
+    const mappedStringsToFetch = stringList;
+    stringList = {};
+
+    const stringsToFetch = [];
+    Object.keys(mappedStringsToFetch).forEach(component => {
+        stringsToFetch.push(...mappedStringsToFetch[component].map(key => {
+            return {component, key};
+        }));
+    });
+
+    if (stringsToFetch) {
+        import('core/str')
+        .then(Str => Str.get_strings(stringsToFetch))
+        .catch();
+    }
+};
+
+/**
+ * Process the prefetch queues as required.
+ *
+ * The initial call will queue the first fetch after a delay.
+ * Subsequent fetches are immediate.
+ */
+const processQueue = () => {
+    if (Config.jsrev <= 0) {
+        // No point pre-fetching when cachejs is disabled as we do not store anything in the cache anyway.
+        return;
+    }
+
+    if (prefetchTimer) {
+        // There is a live prefetch timer. The initial prefetch has been scheduled but is not complete.
+        return;
+    }
+
+    // The initial prefetch has compelted. Just queue as normal.
+    if (initialPrefetchComplete) {
+        fetchQueue();
+
+        return;
+    }
+
+    // Queue the initial prefetch in a short while.
+    prefetchTimer = setTimeout(() => {
+        initialPrefetchComplete = true;
+        prefetchTimer = null;
+
+        // Ensure that the icon system is loaded.
+        // This can be quite slow and delay UI interactions if it is loaded on demand.
+        import(Config.iconsystemmodule)
+        .then(IconSystem => {
+            const iconSystem = new IconSystem();
+            prefetchTemplate(iconSystem.getTemplateName());
+
+            return iconSystem;
+        })
+        .then(iconSystem => {
+            fetchQueue();
+            iconSystem.init();
+
+            return;
+        })
+        .catch();
+    }, 500);
+};
+
+/**
+ * Add a set of templates to the prefetch queue.
+ *
+ * @param {Array} templatesNames
+ */
+const prefetchTemplates = templatesNames => {
+    templateList = templateList.concat(templatesNames);
+
+    processQueue();
+};
+
+/**
+ * Add a single template to the prefetch queue.
+ *
+ * @param {String} templateName
+ * @returns {undefined}
+ */
+const prefetchTemplate = templateName => prefetchTemplates([templateName]);
+
+/**
+ * Add a set of strings from the same component to the prefetch queue.
+ *
+ * @param {String} component
+ * @param {String[]} keys
+ */
+const prefetchStrings = (component, keys) => {
+    if (!stringList[component]) {
+        stringList[component] = [];
+    }
+
+    stringList[component] = stringList[component].concat(keys);
+
+    processQueue();
+};
+
+/**
+ * Add a single string to the prefetch queue.
+ *
+ * @param {String} component
+ * @param {String} key
+ */
+const prefetchString = (component, key) => {
+    if (!stringList[component]) {
+        stringList[component] = [];
+    }
+
+    stringList[component].push(key);
+
+    processQueue();
+};
+
+// Prefetch some commonly-used templates.
+prefetchTemplates([].concat(
+    ['core/loading'],
+    ['core/modal'],
+    ['core/modal_backdrop'],
+));
+
+// And some commonly used strings.
+prefetchStrings('core', [
+    'cancel',
+    'closebuttontitle',
+    'loading',
+    'savechanges',
+]);
+prefetchStrings('core_form', [
+    'showless',
+    'showmore',
+]);
+
+export default {
+    prefetchTemplate,
+    prefetchTemplates,
+    prefetchString,
+    prefetchStrings,
+};
index afac7f3..8ac79ab 100644 (file)
@@ -298,7 +298,7 @@ define([
             return cachedPromise;
         }
 
-        // Check the buffer to seee if this template has already been added.
+        // Check the buffer to see if this template has already been added.
         var existingBufferRecords = loadTemplateBuffer.filter(function(record) {
             return record.searchKey == searchKey;
         });
@@ -329,6 +329,50 @@ define([
         return deferred.promise();
     };
 
+    /**
+     * Prefetch a set of templates without rendering them.
+     *
+     * @param {Array} templateNames The list of templates to fetch
+     * @param {String} currentTheme
+     */
+    Renderer.prototype.prefetchTemplates = function(templateNames, currentTheme) {
+        templateNames.forEach(function(templateName) {
+            var searchKey = currentTheme + '/' + templateName;
+
+            // If we haven't already seen this template then buffer it.
+            if (getTemplatePromiseFromCache(searchKey)) {
+                return;
+            }
+
+            // Check the buffer to see if this template has already been added.
+            var existingBufferRecords = loadTemplateBuffer.filter(function(record) {
+                return record.searchKey == searchKey;
+            });
+
+            if (existingBufferRecords.length) {
+                // This template is already in the buffer so just return the existing promise.
+                // No need to add it to the buffer again.
+                return;
+            }
+
+            // This is the first time this has been requested so let's add it to the buffer to be loaded.
+            var parts = templateName.split('/');
+            var component = parts.shift();
+            var name = parts.join('/');
+
+            // Add this template to the buffer to be loaded.
+            loadTemplateBuffer.push({
+                component: component,
+                name: name,
+                theme: currentTheme,
+                searchKey: searchKey,
+                deferred: $.Deferred(),
+            });
+        });
+
+        processLoadTemplateBuffer();
+    };
+
     /**
      * Load a partial from the cache or ajax.
      *
@@ -1045,6 +1089,25 @@ define([
             return renderer.render(templateName, context, themeName);
         },
 
+        /**
+         * Prefetch a set of templates without rendering them.
+         *
+         * @method getTemplate
+         * @param {Array} templateNames The list of templates to fetch
+         * @param {String} themeName
+         * @returns {Promise}
+         */
+        prefetchTemplates: function(templateNames, themeName) {
+            var renderer = new Renderer();
+
+            if (typeof themeName === "undefined") {
+                // System context by default.
+                themeName = config.theme;
+            }
+
+            return renderer.prefetchTemplates(templateNames, themeName);
+        },
+
         /**
          * Every call to render creates a new instance of the class and calls render on it. This
          * means each render call has it's own class variables.
index 253623c..a1ad313 100644 (file)
@@ -217,6 +217,11 @@ class behat_core_generator extends behat_generator_base {
                 'required' => array('user', 'preference', 'value'),
                 'switchids' => array('user' => 'userid')
             ],
+            'contentbank content' => [
+                'datagenerator' => 'contentbank_content',
+                'required' => array('course', 'contenttype', 'user', 'contentname'),
+                'switchids' => array('course' => 'courseid', 'user' => 'userid')
+            ],
         ];
     }
 
@@ -816,4 +821,22 @@ class behat_core_generator extends behat_generator_base {
     protected function process_user_preferences(array $data) {
         set_user_preference($data['preference'], $data['value'], $data['userid']);
     }
+
+    /**
+     * Create content in the given context's content bank
+     *
+     * @param array $data
+     * @return void
+     */
+    protected function process_contentbank_content(array $data) {
+        $context = context_course::instance($data['courseid']);
+        $contenttypeclass = "\\".$data['contenttype']."\\contenttype";
+        if (class_exists($contenttypeclass)) {
+            $contenttype = new $contenttypeclass($context);
+            $record = new stdClass();
+            $record->usercreated = $data['userid'];
+            $record->name = $data['contentname'];
+            $content = $contenttype->create_content($record);
+        }
+    }
 }
diff --git a/lib/classes/dataformat.php b/lib/classes/dataformat.php
new file mode 100644 (file)
index 0000000..42ae23d
--- /dev/null
@@ -0,0 +1,151 @@
+<?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/>.
+
+/**
+ * Class containing utility methods for dataformats
+ *
+ * @package     core
+ * @copyright   2020 Moodle Pty Ltd <support@moodle.com>
+ * @author      2020 Paul Holden <paulh@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @license     Moodle Workplace License, distribution is restricted, contact support@moodle.com
+ */
+
+namespace core;
+
+use coding_exception;
+use core_php_time_limit;
+
+/**
+ * Dataformat utility class
+ *
+ * @package     core
+ * @copyright   2020 Moodle Pty Ltd <support@moodle.com>
+ * @author      2020 Paul Holden <paulh@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @license     Moodle Workplace License, distribution is restricted, contact support@moodle.com
+ */
+class dataformat {
+
+    /**
+     * Return an instead of a dataformat writer from given dataformat type
+     *
+     * @param string $dataformat
+     * @return dataformat\base
+     * @throws coding_exception
+     */
+    protected static function get_format_instance(string $dataformat): \core\dataformat\base {
+        $classname = 'dataformat_' . $dataformat . '\writer';
+        if (!class_exists($classname)) {
+            throw new coding_exception('Invalid dataformat', $dataformat);
+        }
+
+        return new $classname();
+    }
+
+    /**
+     * Sends a formatted data file to the browser
+     *
+     * @param string $filename
+     * @param string $dataformat
+     * @param array $columns
+     * @param Iterable $iterator
+     * @param callable|null $callback
+     * @throws coding_exception
+     */
+    public static function download_data(string $filename, string $dataformat, array $columns, Iterable $iterator,
+            callable $callback = null): void {
+
+        if (ob_get_length()) {
+            throw new coding_exception('Output can not be buffered before calling download_data()');
+        }
+
+        $format = self::get_format_instance($dataformat);
+
+        // The data format export could take a while to generate.
+        core_php_time_limit::raise();
+
+        // Close the session so that the users other tabs in the same session are not blocked.
+        \core\session\manager::write_close();
+
+        // If this file was requested from a form, then mark download as complete (before sending headers).
+        \core_form\util::form_download_complete();
+
+        $format->set_filename($filename);
+        $format->send_http_headers();
+
+        $format->start_output();
+        $format->start_sheet($columns);
+
+        $rownum = 0;
+        foreach ($iterator as $row) {
+            if (is_callable($callback)) {
+                $row = $callback($row);
+            }
+            if ($row === null) {
+                continue;
+            }
+            $format->write_record($row, $rownum++);
+        }
+
+        $format->close_sheet($columns);
+        $format->close_output();
+    }
+
+    /**
+     * Writes a formatted data file with specified filename
+     *
+     * @param string $filename
+     * @param string $dataformat
+     * @param array $columns
+     * @param Iterable $iterator
+     * @param callable|null $callback
+     * @return string Complete path to the file on disk
+     */
+    public static function write_data(string $filename, string $dataformat, array $columns, Iterable $iterator,
+            callable $callback = null): string {
+
+        $format = self::get_format_instance($dataformat);
+
+        // The data format export could take a while to generate.
+        core_php_time_limit::raise();
+
+        // Close the session so that the users other tabs in the same session are not blocked.
+        \core\session\manager::write_close();
+
+        $filepath = make_request_directory() . '/' . $filename . $format->get_extension();
+        $format->set_filepath($filepath);
+
+        $format->start_output_to_file();
+        $format->start_sheet($columns);
+
+        $rownum = 0;
+        foreach ($iterator as $row) {
+            if (is_callable($callback)) {
+                $row = $callback($row);
+            }
+            if ($row === null) {
+                continue;
+            }
+            $format->write_record($row, $rownum++);
+        }
+
+        $format->close_sheet($columns);
+        $format->close_output_to_file();
+
+        return $filepath;
+    }
+}
index b9682b9..a0dc368 100644 (file)
@@ -25,6 +25,8 @@
 
 namespace core\dataformat;
 
+use coding_exception;
+
 /**
  * Base class for dataformat.
  *
@@ -44,6 +46,9 @@ abstract class base {
     /** @var $filename */
     protected $filename = '';
 
+    /** @var string The location to store the output content */
+    protected $filepath = '';
+
     /**
      * Get the file extension
      *
@@ -62,6 +67,24 @@ abstract class base {
         $this->filename = $filename;
     }
 
+    /**
+     * Set file path when writing to file
+     *
+     * @param string $filepath
+     * @throws coding_exception
+     */
+    public function set_filepath(string $filepath): void {
+        $filedir = dirname($filepath);
+        if (!is_writable($filedir)) {
+            throw new coding_exception('File path is not writable');
+        }
+
+        $this->filepath = $filepath;
+
+        // Some dataformat writers may expect filename to be set too.
+        $this->set_filename(pathinfo($this->filepath, PATHINFO_FILENAME));
+    }
+
     /**
      * Set the title of the worksheet inside a spreadsheet
      *
@@ -95,6 +118,17 @@ abstract class base {
         header("Content-Disposition: attachment; filename=\"$filename\"");
     }
 
+    /**
+     * Set the dataformat to be output to current file. Calling code must call {@see base::close_output_to_file()} when finished
+     */
+    public function start_output_to_file(): void {
+        // Raise memory limit to ensure we can store the entire content. Start collecting output.
+        raise_memory_limit(MEMORY_EXTRA);
+
+        ob_start();
+        $this->start_output();
+    }
+
     /**
      * Write the start of the file.
      */
@@ -134,4 +168,18 @@ abstract class base {
     public function close_output() {
         // Override me if needed.
     }
+
+    /**
+     * Write the data to disk. Calling code should have previously called {@see base::start_output_to_file()}
+     *
+     * @return bool Whether the write succeeded
+     */
+    public function close_output_to_file(): bool {
+        $this->close_output();
+
+        $filecontent = ob_get_contents();
+        ob_end_clean();
+
+        return file_put_contents($this->filepath, $filecontent) !== false;
+    }
 }
index 55947ef..02a827f 100644 (file)
@@ -66,6 +66,23 @@ abstract class spout_base extends \core\dataformat\base {
         $this->renamecurrentsheet = true;
     }
 
+    /**
+     * Set the dataformat to be output to current file
+     */
+    public function start_output_to_file(): void {
+        $this->writer = \Box\Spout\Writer\Common\Creator\WriterEntityFactory::createWriter($this->spouttype);
+        if (method_exists($this->writer, 'setTempFolder')) {
+            $this->writer->setTempFolder(make_request_directory());
+        }
+
+        $this->writer->openToFile($this->filepath);
+
+        // By default one sheet is always created, but we want to rename it when we call start_sheet().
+        $this->renamecurrentsheet = true;
+
+        $this->start_output();
+    }
+
     /**
      * Set the title of the worksheet inside a spreadsheet
      *
@@ -114,4 +131,15 @@ abstract class spout_base extends \core\dataformat\base {
         $this->writer->close();
         $this->writer = null;
     }
+
+    /**
+     * Write data to disk
+     *
+     * @return bool
+     */
+    public function close_output_to_file(): bool {
+        $this->close_output();
+
+        return true;
+    }
 }
diff --git a/lib/classes/event/contentbank_content_created.php b/lib/classes/event/contentbank_content_created.php
new file mode 100644 (file)
index 0000000..bf651c7
--- /dev/null
@@ -0,0 +1,137 @@
+<?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/>.
+
+/**
+ * Contentbank content created event.
+ *
+ * @package    core
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Content bank content created class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *      - string contenttype: the contenttype of the content.
+ *      - string name: the name of the content.
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.9
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank_content_created extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'contentbank_content';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from content bank content object
+     *
+     * @since Moodle 3.9
+     * @param \stdClass $record Data to create the event
+     * @return contentbank_content_created
+     */
+    public static function create_from_record(\stdClass $record) {
+        $event = self::create([
+            'objectid' => $record->id,
+            'relateduserid' => $record->usercreated,
+            'context' => \context::instance_by_id($record->contextid),
+            'other' => [
+                'contenttype' => $record->contenttype,
+                'name' => $record->name
+            ]
+        ]);
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcontentcreated', 'core_contentbank');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' created the content with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['contenttype'])) {
+            throw new \coding_exception('The \'contenttype\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        $url = new \moodle_url('/contentbank/view.php');
+        $url->param('id', $this->objectid);
+        return $url;
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'contentbank_content', 'restore' => 'contentbank_content');
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return bool
+     */
+    public static function get_other_mapping() {
+        // No mapping required.
+        return false;
+    }
+}
diff --git a/lib/classes/event/contentbank_content_deleted.php b/lib/classes/event/contentbank_content_deleted.php
new file mode 100644 (file)
index 0000000..6021d5a
--- /dev/null
@@ -0,0 +1,126 @@
+<?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/>.
+
+/**
+ * Contentbank content deleted event.
+ *
+ * @package    core
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Content bank content deleted class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *      - string contenttype: the contenttype of the content.
+ *      - string name: the name of the content.
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.9
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank_content_deleted extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'contentbank_content';
+        $this->data['crud'] = 'd';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from content bank content object
+     *
+     * @since Moodle 3.9
+     * @param \stdClass $record Data to create the event
+     * @return contentbank_content_deleted
+     */
+    public static function create_from_record(\stdClass $record) {
+        $event = self::create([
+            'objectid' => $record->id,
+            'relateduserid' => $record->usercreated,
+            'context' => \context::instance_by_id($record->contextid),
+            'other' => [
+                'contenttype' => $record->contenttype,
+                'name' => $record->name
+            ]
+        ]);
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcontentdeleted', 'core_contentbank');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' deleted the content with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['contenttype'])) {
+            throw new \coding_exception('The \'contenttype\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'contentbank_content', 'restore' => 'contentbank_content');
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return bool
+     */
+    public static function get_other_mapping() {
+        // No mapping required.
+        return false;
+    }
+}
diff --git a/lib/classes/event/contentbank_content_updated.php b/lib/classes/event/contentbank_content_updated.php
new file mode 100644 (file)
index 0000000..74a3420
--- /dev/null
@@ -0,0 +1,137 @@
+<?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/>.
+
+/**
+ * Contentbank content uploaded event.
+ *
+ * @package    core
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Content bank content updated class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *      - string contenttype: the contenttype of the content.
+ *      - string name: the name of the content.
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.9
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank_content_updated extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'contentbank_content';
+        $this->data['crud'] = 'u';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from content bank content object
+     *
+     * @since Moodle 3.9
+     * @param \stdClass $record Data to create the event
+     * @return contentbank_content_updated
+     */
+    public static function create_from_record(\stdClass $record) {
+        $event = self::create([
+            'objectid' => $record->id,
+            'relateduserid' => $record->usercreated,
+            'context' => \context::instance_by_id($record->contextid),
+            'other' => [
+                'contenttype' => $record->contenttype,
+                'name' => $record->name
+            ]
+        ]);
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcontentupdated', 'core_contentbank');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' updated the content with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['contenttype'])) {
+            throw new \coding_exception('The \'contenttype\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        $url = new \moodle_url('/contentbank/view.php');
+        $url->param('id', $this->objectid);
+        return $url;
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'contentbank_content', 'restore' => 'contentbank_content');
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return bool
+     */
+    public static function get_other_mapping() {
+        // No mapping required.
+        return false;
+    }
+}
diff --git a/lib/classes/event/contentbank_content_uploaded.php b/lib/classes/event/contentbank_content_uploaded.php
new file mode 100644 (file)
index 0000000..1080407
--- /dev/null
@@ -0,0 +1,137 @@
+<?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/>.
+
+/**
+ * Contentbank content uploaded event.
+ *
+ * @package    core
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Content bank content uploaded class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *      - string contenttype: the contenttype of the content.
+ *      - string name: the name of the content.
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.9
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank_content_uploaded extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'contentbank_content';
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from content bank content object
+     *
+     * @since Moodle 3.9
+     * @param \stdClass $record Data to create the event
+     * @return contentbank_content_uploaded
+     */
+    public static function create_from_record(\stdClass $record) {
+        $event = self::create([
+            'objectid' => $record->id,
+            'relateduserid' => $record->usercreated,
+            'context' => \context::instance_by_id($record->contextid),
+            'other' => [
+                'contenttype' => $record->contenttype,
+                'name' => $record->name
+            ]
+        ]);
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcontentuploaded', 'core_contentbank');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' uploaded the content with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['contenttype'])) {
+            throw new \coding_exception('The \'contenttype\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        $url = new \moodle_url('/contentbank/view.php');
+        $url->param('id', $this->objectid);
+        return $url;
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'contentbank_content', 'restore' => 'contentbank_content');
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return bool
+     */
+    public static function get_other_mapping() {
+        // No mapping required.
+        return false;
+    }
+}
diff --git a/lib/classes/event/contentbank_content_viewed.php b/lib/classes/event/contentbank_content_viewed.php
new file mode 100644 (file)
index 0000000..cd8b4d2
--- /dev/null
@@ -0,0 +1,137 @@
+<?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/>.
+
+/**
+ * Contentbank content viewed event.
+ *
+ * @package    core
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+/**
+ * Content bank content updated class.
+ *
+ * @property-read array $other {
+ *      Extra information about event.
+ *      - string contenttype: the contenttype of the content.
+ *      - string name: the name of the content.
+ * }
+ *
+ * @package    core
+ * @since      Moodle 3.9
+ * @copyright  2020 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class contentbank_content_viewed extends base {
+
+    /**
+     * Initialise the event data.
+     */
+    protected function init() {
+        $this->data['objecttable'] = 'contentbank_content';
+        $this->data['crud'] = 'r';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Creates an event from content bank content object
+     *
+     * @since Moodle 3.9
+     * @param \stdClass $record Data to create the event
+     * @return contentbank_content_viewed
+     */
+    public static function create_from_record(\stdClass $record) {
+        $event = self::create([
+            'objectid' => $record->id,
+            'relateduserid' => $record->usercreated,
+            'context' => \context::instance_by_id($record->contextid),
+            'other' => [
+                'contenttype' => $record->contenttype,
+                'name' => $record->name
+            ]
+        ]);
+        return $event;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('eventcontentviewed', 'core_contentbank');
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' viewed the content with id '$this->objectid'.";
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['contenttype'])) {
+            throw new \coding_exception('The \'contenttype\' value must be set in other.');
+        }
+
+        if (!isset($this->other['name'])) {
+            throw new \coding_exception('The \'name\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        $url = new \moodle_url('/contentbank/view.php');
+        $url->param('id', $this->objectid);
+        return $url;
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return array('db' => 'contentbank_content', 'restore' => 'contentbank_content');
+    }
+
+    /**
+     * Used for mapping events on restore
+     *
+     * @return bool
+     */
+    public static function get_other_mapping() {
+        // No mapping required.
+        return false;
+    }
+}
index 2457200..b7966bf 100644 (file)
@@ -71,7 +71,8 @@ class completion_daily_task extends scheduled_task {
                 INNER JOIN {user_enrolments} ue ON ue.userid = u.id
                 INNER JOIN {enrol} e ON e.id = ue.enrolid
                 INNER JOIN {course} c ON c.id = e.courseid
-                INNER JOIN {role_assignments} ra ON ra.userid = u.id
+                INNER JOIN {context} con ON con.contextlevel = ? AND con.instanceid = c.id
+                INNER JOIN {role_assignments} ra ON ra.userid = u.id AND ra.contextid = con.id
                  LEFT JOIN {course_completions} crc ON crc.course = c.id AND crc.userid = u.id
                      WHERE c.enablecompletion = 1
                        AND crc.timeenrolled IS NULL
@@ -83,7 +84,7 @@ class completion_daily_task extends scheduled_task {
                        $sqlroles
                   ORDER BY course, userid";
             $now = time();
-            $rs = $DB->get_recordset_sql($sql, [$now, $now, $now, $now]);
+            $rs = $DB->get_recordset_sql($sql, [CONTEXT_COURSE, $now, $now]);
 
             // Check if result is empty.
             if (!$rs->valid()) {
index 5842e53..0fe4b36 100644 (file)
  * @param string $dataformat A dataformat name
  * @param array $columns An ordered map of column keys and labels
  * @param Iterator $iterator An iterator over the records, usually a RecordSet
- * @param function $callback An option function applied to each record before writing
- * @param mixed $extra An optional value which is passed into the callback function
+ * @param callable $callback An option function applied to each record before writing
+ * @throws coding_exception
+ *
+ * @deprecated since Moodle 3.9 - MDL-68500 please use \core\dataformat::download_data
  */
 function download_as_dataformat($filename, $dataformat, $columns, $iterator, $callback = null) {
+    debugging('download_as_dataformat() is deprecated, please use \core\dataformat::download_data() instead', DEBUG_DEVELOPER);
 
-    if (ob_get_length()) {
-        throw new coding_exception("Output can not be buffered before calling download_as_dataformat");
-    }
-
-    $classname = 'dataformat_' . $dataformat . '\writer';
-    if (!class_exists($classname)) {
-        throw new coding_exception("Unable to locate dataformat/$dataformat/classes/writer.php");
-    }
-    $format = new $classname;
-
-    // The data format export could take a while to generate...
-    set_time_limit(0);
-
-    // Close the session so that the users other tabs in the same session are not blocked.
-    \core\session\manager::write_close();
-
-    // If this file was requested from a form, then mark download as complete (before sending headers).
-    \core_form\util::form_download_complete();
-
-    $format->set_filename($filename);
-    $format->send_http_headers();
-    // This exists to support all dataformats - see MDL-56046.
-    if (method_exists($format, 'write_header')) {
-        error_log('The function write_header() does not support multiple sheets. In order to support multiple sheets you ' .
-            'must implement start_output() and start_sheet() and remove write_header() in your dataformat.');
-        $format->write_header($columns);
-    } else {
-        $format->start_output();
-        $format->start_sheet($columns);
-    }
-    $c = 0;
-    foreach ($iterator as $row) {
-        if ($callback) {
-            $row = $callback($row);
-        }
-        if ($row === null) {
-            continue;
-        }
-        $format->write_record($row, $c++);
-    }
-    // This exists to support all dataformats - see MDL-56046.
-    if (method_exists($format, 'write_footer')) {
-        error_log('The function write_footer() does not support multiple sheets. In order to support multiple sheets you ' .
-            'must implement close_sheet() and close_output() and remove write_footer() in your dataformat.');
-        $format->write_footer($columns);
-    } else {
-        $format->close_sheet($columns);
-        $format->close_output();
-    }
+    \core\dataformat::download_data($filename, $dataformat, $columns, $iterator, $callback);
 }
-
index 155d223..93b736a 100644 (file)
@@ -2522,4 +2522,26 @@ $capabilities = array(
             'user' => CAP_ALLOW,
         ]
     ],
+
+    // Manage (rename, move, publish, share, etc.) any content from the content bank.
+    'moodle/contentbank:manageanycontent' => [
+        'riskbitmask' => RISK_DATALOSS,
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW,
+            'coursecreator' => CAP_ALLOW,
+        )
+    ],
+
+    // Manage (rename, move, publish, share, etc.) content created by yourself.
+    'moodle/contentbank:manageowncontent' => [
+        'captype' => 'write',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => array(
+            'manager' => CAP_ALLOW,
+            'coursecreator' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+        )
+    ],
 );
index a57a95d..2c4c81d 100644 (file)
@@ -2773,6 +2773,15 @@ $functions = array(
         'ajax'          => 'true',
         'capabilities'  => 'moodle/contentbank:deleteanycontent',
     ],
+    'core_contentbank_rename_content' => [
+        'classname'     => 'core_contentbank\external\rename_content',
+        'methodname'    => 'execute',
+        'classpath'     => '',
+        'description'   => 'Rename a content in the content bank.',
+        'type'          => 'write',
+        'ajax'          => 'true',
+        'capabilities'  => 'moodle/contentbank:manageowncontent',
+    ],
 );
 
 $services = array(
index e2b5c78..be495fe 100644 (file)
@@ -22,7 +22,7 @@ Feature: Atto accessibility checker
     And I select the text in the "Description" Atto editor
     And I click on "Insert or edit image" "button"
     And I set the field "Describe this image for someone who cannot see it" to ""
-    And I set the field "Description not necessary" to "1"
+    And I set the field "This image is decorative only" to "1"
     And I press "Save image"
     And I press "Accessibility checker"
     And I should see "Congratulations, no accessibility problems found!"
index 98b369b..fe5b861 100644 (file)
@@ -15,7 +15,7 @@ Feature: Atto bold button
     And I set the field "Text editor" to "Plain text area"
     And I press "Save changes"
     And I click on "Edit profile" "link" in the "region-main" "region"
-    Then I should see "<b>Badger</b>"
+    Then I should see "<strong>Badger</strong>"
 
   @javascript
   Scenario: Unbold some text
@@ -31,5 +31,5 @@ Feature: Atto bold button
     And I set the field "Text editor" to "Plain text area"
     And I press "Save changes"
     And I click on "Edit profile" "link" in the "region-main" "region"
-    Then I should not see "<b>Mouse</b>"
+    Then I should not see "<strong>Mouse</strong>"
     And I should see "Mouse"
index 3736b77..c1ae54d 100644 (file)
Binary files a/lib/editor/atto/plugins/bold/yui/build/moodle-atto_bold-button/moodle-atto_bold-button-debug.js and b/lib/editor/atto/plugins/bold/yui/build/moodle-atto_bold-button/moodle-atto_bold-button-debug.js differ
index f71bf58..31c9d63 100644 (file)
Binary files a/lib/editor/atto/plugins/bold/yui/build/moodle-atto_bold-button/moodle-atto_bold-button-min.js and b/lib/editor/atto/plugins/bold/yui/build/moodle-atto_bold-button/moodle-atto_bold-button-min.js differ
index 3736b77..c1ae54d 100644 (file)
Binary files a/lib/editor/atto/plugins/bold/yui/build/moodle-atto_bold-button/moodle-atto_bold-button.js and b/lib/editor/atto/plugins/bold/yui/build/moodle-atto_bold-button/moodle-atto_bold-button.js differ
index 7567ffe..3215bfe 100644 (file)
 
 Y.namespace('M.atto_bold').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
     initializer: function() {
-        this.addBasicButton({
-            exec: 'bold',
+        var bold;
+
+        this.addButton({
+            callback: this._toggleBold,
+            icon: 'e/bold',
+            buttonName: bold,
+            inlineFormat: true,
 
             // Key code for the keyboard shortcut which triggers this button:
             keys: '66',
 
             // Watch the following tags and add/remove highlighting as appropriate:
-            tags: 'b, strong'
+            tags: 'strong, b'
         });
+    },
+    /**
+     * Toggle the bold setting.
+     *
+     * @method _toggleBold
+     * @param {EventFacade} e
+     */
+    _toggleBold: function() {
+        var host = this.get('host');
+
+        // Use the "bold" command for simplicity. This will toggle <strong> tags off as well.
+        document.execCommand('bold', false, null);
+
+        // Then change all <b> tags to <strong> tags. This will change any existing <b> tags as well.
+        host.changeToCSS('b', 'bf-editor-bold-strong');
+        host.changeToTags('bf-editor-bold-strong', 'strong');
     }
 });
index e9bceb9..624247e 100644 (file)
@@ -36,7 +36,7 @@ $string['enteralt'] = 'Describe this image for someone who cannot see it';
 $string['enterurl'] = 'Enter URL';
 $string['height'] = 'Height';
 $string['imageproperties'] = 'Image properties';
-$string['presentation'] = 'Description not necessary';
+$string['presentation'] = 'This image is decorative only';
 $string['pluginname'] = 'Insert or edit image';
 $string['presentationoraltrequired'] = 'Images must have a description, except if the description is marked as not necessary.';
 $string['preview'] = 'Preview';
index 941acdf..c43debe 100644 (file)
@@ -39,8 +39,9 @@ Feature: Add images to Atto
     And I take focus off "Height" "field"
     And the field "Width" matches value "123"
     And the field "Height" matches value "456"
-    And I click on "Save image" "button"
-    And I click on "Update profile" "button"
+    And I change window size to "large"
+    And I press "Save image"
+    And I press "Update profile"
     And I click on "Edit profile" "link" in the "region-main" "region"
     And I select the text in the "Description" Atto editor
     And I click on "Insert or edit image" "button"
index 6e9752f..75f44a5 100644 (file)
Binary files a/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-debug.js and b/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-debug.js differ
index 94511b8..d6b125e 100644 (file)
Binary files a/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-min.js and b/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button-min.js differ
index 93e07c3..4bb3907 100644 (file)
Binary files a/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button.js and b/lib/editor/atto/plugins/image/yui/build/moodle-atto_image-button/moodle-atto_image-button.js differ
index 0289f06..774d8b1 100644 (file)
@@ -122,8 +122,14 @@ var CSS = {
                 '</div>' +
                 '<div class="mb-1">' +
                 '<label for="{{elementid}}_{{CSS.INPUTALT}}">{{get_string "enteralt" component}}</label>' +
-                '<input class="form-control fullwidth {{CSS.INPUTALT}}" type="text" value="" ' +
-                'id="{{elementid}}_{{CSS.INPUTALT}}" size="32"/>' +
+                '<textarea class="form-control fullwidth {{CSS.INPUTALT}}" ' +
+                'id="{{elementid}}_{{CSS.INPUTALT}}" maxlength="125"></textarea>' +
+
+                // Add the character count.
+                '<div id="the-count" class="d-flex justify-content-end small">' +
+                '<span id="currentcount">0</span>' +
+                '<span id="maximumcount"> / 125</span>' +
+                '</div>' +
 
                 // Add the presentation select box.
                 '<div class="form-check">' +
@@ -241,6 +247,7 @@ Y.namespace('M.atto_image').Button = Y.Base.create('button', Y.M.editor_atto.Edi
         });
         this.editor.delegate('dblclick', this._displayDialogue, 'img', this);
         this.editor.delegate('click', this._handleClick, 'img', this);
+        this.editor.on('paste', this._handlePaste, this);
         this.editor.on('drop', this._handleDragDrop, this);
 
         // e.preventDefault needed to stop the default event from clobbering the desired behaviour in some browsers.
@@ -257,118 +264,189 @@ Y.namespace('M.atto_image').Button = Y.Base.create('button', Y.M.editor_atto.Edi
      *
      * @method _handleDragDrop
      * @param {EventFacade} e
-     * @return mixed
+     * @return {boolean} false if we handled the event, else true.
      * @private
      */
     _handleDragDrop: function(e) {
+        if (!e._event || !e._event.dataTransfer) {
+            // Drop not fully supported in this browser.
+            return true;
+        }
 
-        var self = this,
-            host = this.get('host'),
-            template = Y.Handlebars.compile(IMAGETEMPLATE);
+        return this._handlePasteOrDropHelper(e, e._event.dataTransfer);
+    },
 
-        host.saveSelection();
-        e = e._event;
+    /**
+     * Handles paste events where - if the thing being pasted is an image.
+     *
+     * @method _handlePaste
+     * @param {EventFacade} e
+     * @return {boolean} false if we handled the event, else true.
+     * @private
+     */
+    _handlePaste: function(e) {
+        if (!e._event || !e._event.clipboardData) {
+            // Paste not fully supported in this browser.
+            return true;
+        }
 
-        // Only handle the event if an image file was dropped in.
-        var handlesDataTransfer = (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length);
-        if (handlesDataTransfer && /^image\//.test(e.dataTransfer.files[0].type)) {
+        return this._handlePasteOrDropHelper(e, e._event.clipboardData);
+    },
 
-            var options = host.get('filepickeroptions').image,
-                savepath = (options.savepath === undefined) ? '/' : options.savepath,
-                formData = new FormData(),
-                timestamp = 0,
-                uploadid = "",
-                xhr = new XMLHttpRequest(),
-                imagehtml = "",
-                keys = Object.keys(options.repositories);
+    /**
+     * Handle a drag and drop event with an image.
+     *
+     * @method _handleDragDrop
+     * @param {EventFacade} e
+     * @param {DataTransfer} dataTransfer
+     * @return {boolean} false if we handled the event, else true.
+     * @private
+     */
+    _handlePasteOrDropHelper: function(e, dataTransfer) {
+
+        var items = dataTransfer.items,
+            didUpload = false;
+        for (var i = 0; i < items.length; i++) {
+            var item = items[i];
+            if (item.kind !== 'file') {
+                continue;
+            }
+            if (!this._isImage(item.type)) {
+                continue;
+            }
+            this._uploadImage(item.getAsFile());
+            didUpload = true;
+        }
 
+        if (didUpload) {
+            // We handled this.
             e.preventDefault();
             e.stopPropagation();
-            formData.append('repo_upload_file', e.dataTransfer.files[0]);
-            formData.append('itemid', options.itemid);
-
-            // List of repositories is an object rather than an array.  This makes iteration more awkward.
-            for (var i = 0; i < keys.length; i++) {
-                if (options.repositories[keys[i]].type === 'upload') {
-                    formData.append('repo_id', options.repositories[keys[i]].id);
-                    break;
-                }
-            }
-            formData.append('env', options.env);
-            formData.append('sesskey', M.cfg.sesskey);
-            formData.append('client_id', options.client_id);
-            formData.append('savepath', savepath);
-            formData.append('ctx_id', options.context.id);
-
-            // Insert spinner as a placeholder.
-            timestamp = new Date().getTime();
-            uploadid = 'moodleimage_' + Math.round(Math.random() * 100000) + '-' + timestamp;
-            host.focus();
-            host.restoreSelection();
-            imagehtml = template({
-                url: M.util.image_url("i/loading_small", 'moodle'),
-                alt: M.util.get_string('uploading', COMPONENTNAME),
-                id: uploadid
-            });
-            host.insertContentAtFocusPoint(imagehtml);
-            self.markUpdated();
-
-            // Kick off a XMLHttpRequest.
-            xhr.onreadystatechange = function() {
-                var placeholder = self.editor.one('#' + uploadid),
-                    result,
-                    file,
-                    newhtml,
-                    newimage;
-
-                if (xhr.readyState === 4) {
-                    if (xhr.status === 200) {
-                        result = JSON.parse(xhr.responseText);
-                        if (result) {
-                            if (result.error) {
-                                if (placeholder) {
-                                    placeholder.remove(true);
-                                }
-                                return new M.core.ajaxException(result);
-                            }
+            return false;
+        } else {
+            // Let someone else try to handle it.
+            return true;
+        }
+    },
 
-                            file = result;
-                            if (result.event && result.event === 'fileexists') {
-                                // A file with this name is already in use here - rename to avoid conflict.
-                                // Chances are, it's a different image (stored in a different folder on the user's computer).
-                                // If the user wants to reuse an existing image, they can copy/paste it within the editor.
-                                file = result.newfile;
-                            }
+    /**
+     * Is this file an image?
+     *
+     * @method _isImage
+     * @param {string} mimeType the file's mime type.
+     * @return {boolean} true if the file has an image mimeType.
+     * @private
+     */
+    _isImage: function(mimeType) {
+        return mimeType.indexOf('image/') === 0;
+    },
+
+    /**
+     * Used by _handleDragDrop and _handlePaste to upload an image and insert it.
+     *
+     * @method _uploadImage
+     * @param {File} fileToSave
+     * @private
+     */
+    _uploadImage: function(fileToSave) {
+
+        var self = this,
+            host = this.get('host'),
+            template = Y.Handlebars.compile(IMAGETEMPLATE);
+
+        host.saveSelection();
 
-                            // Replace placeholder with actual image.
-                            newhtml = template({
-                                url: file.url,
-                                presentation: true
-                            });
-                            newimage = Y.Node.create(newhtml);
+        var options = host.get('filepickeroptions').image,
+            savepath = (options.savepath === undefined) ? '/' : options.savepath,
+            formData = new FormData(),
+            timestamp = 0,
+            uploadid = "",
+            xhr = new XMLHttpRequest(),
+            imagehtml = "",
+            keys = Object.keys(options.repositories);
+
+        formData.append('repo_upload_file', fileToSave);
+        formData.append('itemid', options.itemid);
+
+        // List of repositories is an object rather than an array.  This makes iteration more awkward.
+        for (var i = 0; i < keys.length; i++) {
+            if (options.repositories[keys[i]].type === 'upload') {
+                formData.append('repo_id', options.repositories[keys[i]].id);
+                break;
+            }
+        }
+        formData.append('env', options.env);
+        formData.append('sesskey', M.cfg.sesskey);
+        formData.append('client_id', options.client_id);
+        formData.append('savepath', savepath);
+        formData.append('ctx_id', options.context.id);
+
+        // Insert spinner as a placeholder.
+        timestamp = new Date().getTime();
+        uploadid = 'moodleimage_' + Math.round(Math.random() * 100000) + '-' + timestamp;
+        host.focus();
+        host.restoreSelection();
+        imagehtml = template({
+            url: M.util.image_url("i/loading_small", 'moodle'),
+            alt: M.util.get_string('uploading', COMPONENTNAME),
+            id: uploadid
+        });
+        host.insertContentAtFocusPoint(imagehtml);
+        self.markUpdated();
+
+        // Kick off a XMLHttpRequest.
+        xhr.onreadystatechange = function() {
+            var placeholder = self.editor.one('#' + uploadid),
+                result,
+                file,
+                newhtml,
+                newimage;
+
+            if (xhr.readyState === 4) {
+                if (xhr.status === 200) {
+                    result = JSON.parse(xhr.responseText);
+                    if (result) {
+                        if (result.error) {
                             if (placeholder) {
-                                placeholder.replace(newimage);
-                            } else {
-                                self.editor.appendChild(newimage);
+                                placeholder.remove(true);
                             }
-                            self.markUpdated();
+                            throw new M.core.ajaxException(result);
                         }
-                    } else {
-                        Y.use('moodle-core-notification-alert', function() {
-                            new M.core.alert({message: M.util.get_string('servererror', 'moodle')});
+
+                        file = result;
+                        if (result.event && result.event === 'fileexists') {
+                            // A file with this name is already in use here - rename to avoid conflict.
+                            // Chances are, it's a different image (stored in a different folder on the user's computer).
+                            // If the user wants to reuse an existing image, they can copy/paste it within the editor.
+                            file = result.newfile;
+                        }
+
+                        // Replace placeholder with actual image.
+                        newhtml = template({
+                            url: file.url,
+                            presentation: true
                         });
+                        newimage = Y.Node.create(newhtml);
                         if (placeholder) {
-                            placeholder.remove(true);
+                            placeholder.replace(newimage);
+                        } else {
+                            self.editor.appendChild(newimage);
                         }
+                        self.markUpdated();
+                    }
+                } else {
+                    Y.use('moodle-core-notification-alert', function() {
+                        new M.core.alert({message: M.util.get_string('servererror', 'moodle')});
+                    });
+                    if (placeholder) {
+                        placeholder.remove(true);
                     }
                 }
-            };
-            xhr.open("POST", M.cfg.wwwroot + '/repository/repository_ajax.php?action=upload', true);
-            xhr.send(formData);
-            return false;
-        }
-
-},
+            }
+        };
+        xhr.open("POST", M.cfg.wwwroot + '/repository/repository_ajax.php?action=upload', true);
+        xhr.send(formData);
+    },
 
     /**
      * Handle a click on an image.
@@ -531,6 +609,9 @@ Y.namespace('M.atto_image').Button = Y.Base.create('button', Y.M.editor_atto.Edi
             }, this);
         }
 
+        // Character count.
+        this._form.one('.' + CSS.INPUTALT).on('keyup', this._handleKeyup, this);
+
         return content;
     },
 
@@ -971,5 +1052,16 @@ Y.namespace('M.atto_image').Button = Y.Base.create('button', Y.M.editor_atto.Edi
         }
         this.getDialogue().centerDialogue();
         return state;
+    },
+
+    /**
+     * Handle the keyup to update the character count.
+     */
+    _handleKeyup: function() {
+        var form = this._form,
+            alt = form.one('.' + CSS.INPUTALT).get('value'),
+            characterCount = alt.length,
+            current = form.one('#currentcount');
+        current.setHTML(characterCount);
     }
 });
index 6449ef5..b62b377 100644 (file)
@@ -15,7 +15,7 @@ Feature: Atto italic button
     And I set the field "Text editor" to "Plain text area"
     And I press "Save changes"
     And I click on "Edit profile" "link" in the "region-main" "region"
-    Then I should see "<i>Tower of Pisa</i>"
+    Then I should see "<em>Tower of Pisa</em>"
 
   @javascript
   Scenario: Toggle italics in some text
@@ -31,5 +31,5 @@ Feature: Atto italic button
     And I set the field "Text editor" to "Plain text area"
     And I press "Save changes"
     And I click on "Edit profile" "link" in the "region-main" "region"
-    Then I should not see "<i>GHD - for hair</i>"
+    Then I should not see "<em>GHD - for hair</em>"
     And I should see "GHD - for hair"
index dcba9e9..d316663 100644 (file)
Binary files a/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-debug.js and b/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-debug.js differ
index 6a64c70..52208d1 100644 (file)
Binary files a/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-min.js and b/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button-min.js differ
index dcba9e9..d316663 100644 (file)
Binary files a/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button.js and b/lib/editor/atto/plugins/italic/yui/build/moodle-atto_italic-button/moodle-atto_italic-button.js differ
index 8823c30..fadc5e5 100644 (file)
 
 Y.namespace('M.atto_italic').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
     initializer: function() {
-        this.addBasicButton({
-            exec: 'italic',
+        var italic;
+
+        this.addButton({
+            callback: this._toggleItalic,
+            icon: 'e/italic',
+            buttonName: italic,
+            inlineFormat: true,
 
             // Key code for the keyboard shortcut which triggers this button:
             keys: '73',
 
             // Watch the following tags and add/remove highlighting as appropriate:
-            tags: 'i'
+            tags: 'em, i'
         });
+    },
+    /**
+     * Toggle the italic setting.
+     *
+     * @method _toggleItalic
+     * @param {EventFacade} e
+     */
+    _toggleItalic: function() {
+        var host = this.get('host');
+
+        // Use the "italic" command for simplicity. This will toggle <em> tags off as well.
+        document.execCommand('italic', false, null);
+
+        // Then change all <i> tags to <em> tags. This will change any existing <i> tags as well.
+        host.changeToCSS('i', 'bf-editor-italic-emphasis');
+        host.changeToTags('bf-editor-italic-emphasis', 'em');
     }
 });
index 57c55ea..c4eb973 100644 (file)
@@ -16,4 +16,4 @@ Feature: Atto strike button
     And I set the field "Text editor" to "Plain text area"
     And I press "Save changes"
     And I click on "Edit profile" "link" in the "region-main" "region"
-    Then I should see "<strike>MUA</strike>"
+    Then I should see "<del>MUA</del>"
index d646364..2f2bcae 100644 (file)
Binary files a/lib/editor/atto/plugins/strike/yui/build/moodle-atto_strike-button/moodle-atto_strike-button-debug.js and b/lib/editor/atto/plugins/strike/yui/build/moodle-atto_strike-button/moodle-atto_strike-button-debug.js differ
index 3627c73..341f0d9 100644 (file)
Binary files a/lib/editor/atto/plugins/strike/yui/build/moodle-atto_strike-button/moodle-atto_strike-button-min.js and b/lib/editor/atto/plugins/strike/yui/build/moodle-atto_strike-button/moodle-atto_strike-button-min.js differ
index d646364..2f2bcae 100644 (file)
Binary files a/lib/editor/atto/plugins/strike/yui/build/moodle-atto_strike-button/moodle-atto_strike-button.js and b/lib/editor/atto/plugins/strike/yui/build/moodle-atto_strike-button/moodle-atto_strike-button.js differ
index 0a7eab5..a19b5f1 100644 (file)
 
 Y.namespace('M.atto_strike').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
     initializer: function() {
-        this.addBasicButton({
-            exec: 'strikeThrough',
+        var strike;
+
+        this.addButton({
+            callback: this._toggleStrike,
             icon: 'e/strikethrough',
+            buttonName: strike,
+            inlineFormat: true,
 
             // Watch the following tags and add/remove highlighting as appropriate:
-            tags: 'strike'
+            tags: 'del, strike'
         });
+        this._strikeApplier = window.rangy.createClassApplier("bf-editor-strike-del");
+    },
+    /**
+     * Toggle the strikethrough setting.
+     *
+     * @method _toggleStrike
+     */
+    _toggleStrike: function() {
+        var host = this.get('host');
+
+        // Change all <del> and <strike> tags to applier class.
+        host.changeToCSS('del', 'bf-editor-strike-del');
+        host.changeToCSS('strike', 'bf-editor-strike-del');
+
+        // Use the applier toggle selection.
+        this._strikeApplier.toggleSelection();
+
+        // Then change the applier class back to <del> tags.
+        host.changeToTags('bf-editor-strike-del', 'del');
     }
 });
index 97f8df0..ec282c2 100644 (file)
@@ -20,7 +20,7 @@ Feature: Atto editor with customised toolbar
   Scenario: Confirm that both editors have different toolbars but still function
     Then ".atto_link_button" "css_element" should exist in the ".normaldiv" "css_element"
     And ".atto_link_button" "css_element" should not exist in the ".specialdiv" "css_element"
-    And ".atto_bold_button_bold" "css_element" should exist in the ".normaldiv" "css_element"
-    And ".atto_italic_button_italic" "css_element" should exist in the ".normaldiv" "css_element"
-    And ".atto_bold_button_bold" "css_element" should exist in the ".specialdiv" "css_element"
-    And ".atto_italic_button_italic" "css_element" should exist in the ".specialdiv" "css_element"
+    And ".atto_bold_button" "css_element" should exist in the ".normaldiv" "css_element"
+    And ".atto_italic_button" "css_element" should exist in the ".normaldiv" "css_element"
+    And ".atto_bold_button" "css_element" should exist in the ".specialdiv" "css_element"
+    And ".atto_italic_button" "css_element" should exist in the ".specialdiv" "css_element"
index 491e6e1..4e1d16a 100644 (file)
@@ -21,8 +21,8 @@ Feature: Atto with enable/disable function.
     When I set the field "mycontrol" to "Disable"
     Then the "disabled" attribute of "button.atto_collapse_button" "css_element" should contain "disabled"
     And the "disabled" attribute of "button.atto_title_button" "css_element" should contain "disabled"
-    And the "disabled" attribute of "button.atto_bold_button_bold" "css_element" should contain "disabled"
-    And the "disabled" attribute of "button.atto_italic_button_italic" "css_element" should contain "disabled"
+    And the "disabled" attribute of "button.atto_bold_button" "css_element" should contain "disabled"
+    And the "disabled" attribute of "button.atto_italic_button" "css_element" should contain "disabled"
     And the "disabled" attribute of "button.atto_unorderedlist_button_insertUnorderedList" "css_element" should contain "disabled"
     And the "disabled" attribute of "button.atto_orderedlist_button_insertOrderedList" "css_element" should contain "disabled"
     And the "disabled" attribute of "button.atto_link_button" "css_element" should contain "disabled"
@@ -35,8 +35,8 @@ Feature: Atto with enable/disable function.
     When I set the field "mycontrol" to "Enable"
     Then "button.atto_collapse_button[disabled]" "css_element" should not exist
     And "button.atto_title_button[disabled]" "css_element" should not exist
-    And "button.atto_bold_button_bold[disabled]" "css_element" should not exist
-    And "button.atto_italic_button_italic[disabled]" "css_element" should not exist
+    And "button.atto_bold_button[disabled]" "css_element" should not exist
+    And "button.atto_italic_button[disabled]" "css_element" should not exist
     And "button.atto_unorderedlist_button_insertUnorderedList[disabled]" "css_element" should not exist
     And "button.atto_orderedlist_button_insertOrderedList[disabled]" "css_element" should not exist
     And "button.atto_link_button[disabled]" "css_element" should not exist
index 1c11681..a26dcdf 100644 (file)
@@ -70,7 +70,7 @@ class MoodleQuickForm_course extends MoodleQuickForm_autocomplete {
      *                       'requiredcapabilities' - array of capabilities. Uses ANY to combine them.
      *                       'limittoenrolled' - boolean Limits to enrolled courses.
      *                       'includefrontpage' - boolean Enables the frontpage to be selected.
-     *                       'onlywithcompletion' - only courses where completion is enabled
+     *                       'onlywithcompletion' - boolean Limits to courses where completion is enabled.
      */
     public function __construct($elementname = null, $elementlabel = null, $attributes = array()) {
         if (!is_array($attributes)) {
@@ -105,8 +105,9 @@ class MoodleQuickForm_course extends MoodleQuickForm_autocomplete {
             $attributes['data-includefrontpage'] = SITEID;
             unset($attributes['includefrontpage']);
         }
-        if (!empty($options['onlywithcompletion'])) {
-            $validattributes['data-onlywithcompletion'] = 1;
+        if (!empty($attributes['onlywithcompletion'])) {
+            $attributes['data-onlywithcompletion'] = 1;
+            unset($attributes['onlywithcompletion']);
         }
 
         parent::__construct($elementname, $elementlabel, array(), $attributes);
diff --git a/lib/form/tests/course_test.php b/lib/form/tests/course_test.php
new file mode 100644 (file)
index 0000000..49c09ee
--- /dev/null
@@ -0,0 +1,77 @@
+<?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/>.
+
+/**
+ * Unit tests for MoodleQuickForm_course.
+ *
+ * This file contains unit tests related to course forms element.
+ *
+ * @package     core_form
+ * @category    test
+ * @copyright   2020 Ruslan Kabalin
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/form/course.php');
+
+/**
+ * Unit tests for MoodleQuickForm_course
+ *
+ * Contains test cases for testing MoodleQuickForm_course.
+ *
+ * @package    core_form
+ * @category   test
+ * @copyright  2020 Ruslan Kabalin
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_form_course_testcase extends basic_testcase {
+
+    /**
+     * Test constructor supports all declared attributes.
+     */
+    public function test_constructor_attributes() {
+        $attributes = [
+            'exclude' => [1, 2],
+            'requiredcapabilities' => ['moodle/course:update'],
+        ];
+
+        $element = new MoodleQuickForm_course('testel', null, $attributes);
+        $html = $element->toHtml();
+        $this->assertContains('data-exclude="1,2"', $html);
+        $this->assertContains('data-requiredcapabilities="moodle/course:update"', $html);
+        $this->assertContains('data-limittoenrolled="0"', $html);
+        $this->assertNotContains('multiple', $html);
+        $this->assertNotContains('data-includefrontpage', $html);
+        $this->assertNotContains('data-onlywithcompletion', $html);
+
+        // Add more attributes.
+        $attributes = [
+            'multiple' => true,
+            'limittoenrolled' => true,
+            'includefrontpage' => true,
+            'onlywithcompletion' => true,
+        ];
+        $element = new MoodleQuickForm_course('testel', null, $attributes);
+        $html = $element->toHtml();
+        $this->assertContains('multiple', $html);
+        $this->assertContains('data-limittoenrolled="1"', $html);
+        $this->assertContains('data-includefrontpage="' . SITEID . '"', $html);
+        $this->assertContains('data-onlywithcompletion="1"', $html);
+    }
+}
index 961fda1..236b0ed 100644 (file)
@@ -1390,6 +1390,9 @@ class page_requirements_manager {
         // First include must be to a module with no dependencies, this prevents multiple requests.
         $prefix = 'M.util.js_pending("core/first");';
         $prefix .= "require(['core/first'], function() {\n";
+        if ($cachejs) {
+            $prefix .= "require(['core/prefetch']);\n";
+        }
         $suffix = 'M.util.js_complete("core/first");';
         $suffix .= "\n});";
         $output .= html_writer::script($prefix . implode(";\n", $this->amdjscode) . $suffix);
index 34cb708..4c34006 100644 (file)
@@ -801,7 +801,7 @@ abstract class CFBinaryPropertyList {
    */
   protected static function binaryStrlen($val) {
     for($i=0;$i<strlen($val);++$i) {
-      if(ord($val{$i}) >= 128) {
+      if(ord($val[$i]) >= 128) {
         $val = self::convertCharset($val, 'UTF-8', 'UTF-16BE');
         return strlen($val);
       }
@@ -824,7 +824,7 @@ abstract class CFBinaryPropertyList {
       $utf16 = false;
 
       for($i=0;$i<strlen($val);++$i) {
-        if(ord($val{$i}) >= 128) {
+        if(ord($val[$i]) >= 128) {
           $utf16 = true;
           break;
         }
index 664f4b5..27003bc 100644 (file)
@@ -14,6 +14,11 @@ Removed:
  * examples
  * tests
 
+Local changes:
+(always verify if the changes below are already fixed by the
+next version to import or they need to be re-applied manually)
+  * PHP 7.4 comp: bf527c8 - Partially applied https://github.com/TECLIB/CFPropertyList/pull/61
+
 Added:
  * readme_moodle.txt
 
index 69210d8..127cd83 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js and b/lib/table/amd/build/dynamic.min.js differ
index 9f3aae9..fd3cbd2 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js.map and b/lib/table/amd/build/dynamic.min.js.map differ
index a377b54..ec438f1 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/repository.min.js and b/lib/table/amd/build/local/dynamic/repository.min.js differ
index 1f58475..af07af3 100644 (file)
Binary files a/lib/table/amd/build/local/dynamic/repository.min.js.map and b/lib/table/amd/build/local/dynamic/repository.min.js.map differ
index aa1d030..18e35fb 100644 (file)
@@ -61,9 +61,10 @@ const getFiltersetFromTable = tableRoot => {
  * Update the specified table based on its current values.
  *
  * @param {HTMLElement} tableRoot
+ * @param {Bool} resetContent
  * @returns {Promise}
  */
-export const refreshTableContent = tableRoot => {
+export const refreshTableContent = (tableRoot, resetContent = false) => {
     const filterset = getFiltersetFromTable(tableRoot);
 
     return fetchTableData(
@@ -80,7 +81,8 @@ export const refreshTableContent = tableRoot => {
             pageNumber: tableRoot.dataset.tablePageNumber,
             pageSize: tableRoot.dataset.tablePageSize,
             hiddenColumns: JSON.parse(tableRoot.dataset.tableHiddenColumns),
-        }
+        },
+        resetContent,
     )
     .then(data => {
         const placeholder = document.createElement('div');
@@ -246,6 +248,14 @@ export const showColumn = (tableRoot, columnToShow, refreshContent = true) => {
     updateTable(tableRoot, {hiddenColumns}, refreshContent);
 };
 
+/**
+ * Reset table preferences.
+ *
+ * @param {HTMLElement} tableRoot
+ * @returns {Promise}
+ */
+const resetTablePreferences = tableRoot => refreshTableContent(tableRoot, true);
+
 /**
  * Set up listeners to handle table updates.
  */
@@ -305,6 +315,12 @@ export const init = () => {
             showColumn(tableRoot, show.dataset.column);
         }
 
+        const resetTablePreferencesLink = e.target.closest('.resettable a');
+        if (resetTablePreferencesLink) {
+            e.preventDefault();
+
+            resetTablePreferences(tableRoot);
+        }
     });
 };
 
index 344de98..a8e6141 100644 (file)
@@ -37,6 +37,7 @@ import {call as fetchMany} from 'core/ajax';
  * @param {String} pageNumber The page number
  * @param {Number} pageSize The page size
  * @param {Number} params parameters to request table
+ * @param {Bool} resetPreferences
  * @return {Promise} Resolved with requested table view
  */
 export const fetch = (component, handler, uniqueid, {
@@ -49,8 +50,7 @@ export const fetch = (component, handler, uniqueid, {
         pageNumber = null,
         pageSize = null,
         hiddenColumns = {}
-    } = {}
-) => {
+    } = {}, resetPreferences = false) => {
     return fetchMany([{
         methodname: `core_table_dynamic_fetch`,
         args: {
@@ -66,6 +66,7 @@ export const fetch = (component, handler, uniqueid, {
             pagenumber: pageNumber,
             pagesize: pageSize,
             hiddencolumns: hiddenColumns,
+            resetpreferences: resetPreferences
         },
     }])[0];
 };
index 69701a0..4288756 100644 (file)
@@ -19,7 +19,6 @@
  *
  * @package core_table
  * @copyright 2020 Simey Lameze <simey@moodle.com>
- *
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
@@ -27,60 +26,19 @@ declare(strict_types=1);
 
 namespace core_table;
 
-defined('MOODLE_INTERNAL') || die();
-
-use moodle_url;
-use context;
-use core_table\local\filter\filterset;
-
 /**
- * Interface dynamic.
+ * Interface to identify this table as a table which can be dynamically updated via webservice calls.
+ *
+ * For a table to be defined as dynamic it must meet the following requirements:
+ *
+ * # it must be located with a namespaced class of \[component]\table\[tablename]
+ * # it must define a \core_table\local\filter\filterset implementation in \[component]\table\[tablename]_filterset
+ * # it must override the {{guess_base_url}} function and specify a base URL to be used when constructing URLs
+ * # it must override the {{get_context}} function to specify the correct context
  *
  * @package core_table
+ * @copyright 2020 Simey Lameze <simey@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 interface dynamic {
-
-    /**
-     * Take a string and convert it to the format expected by the table.
-     * For example, you may have a format such as:
-     *
-     *   mod_assign-submissions-[courseid]
-     *
-     * Passing this function an argument of [courseid] would return the fully-formed string.
-     *
-     * @param string $argument
-     * @return string
-     */
-    public static function get_unique_id_from_argument(string $argument): string;
-
-    /**
-     * Get the base url.
-     *
-     * @return moodle_url
-     */
-    public function get_base_url(): moodle_url;
-
-    /**
-     * Set the filterset filters build table object.
-     *
-     * @param filterset $filterset The filterset object to get the filters from.
-     * @return void
-     */
-    public function set_filterset(filterset $filterset): void;
-
-    /**
-     * Get the currently defined filterset.
-     *
-     * @return filterset
-     */
-    public function get_filterset(): ?filterset;
-
-    /**
-     * Get the context of the current table.
-     *
-     * Note: This function should not be called until after the filterset has been provided.
-     *
-     * @return context
-     */
-    public function get_context(): ?context;
 }
index 6e117e9..15b9583 100644 (file)
@@ -123,6 +123,12 @@ class fetch extends external_api {
                     null
                 )
             ),
+            'resetpreferences' => new external_value(
+                PARAM_BOOL,
+                'Whether the table preferences should be reset',
+                VALUE_REQUIRED,
+                null
+            ),
         ]);
     }
 
@@ -140,6 +146,8 @@ class fetch extends external_api {
      * @param string $lastinitial The last name initial to filter on
      * @param int $pagenumber The page number.
      * @param int $pagesize The number of records.
+     * @param string $jointype The join type.
+     * @param bool $resetpreferences Whether it is resetting table preferences or not.
      *
      * @return array
      */
@@ -155,7