Merge branch 'MDL-67117-master-2' of git://github.com/marinaglancy/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 5 Nov 2019 00:03:58 +0000 (01:03 +0100)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 5 Nov 2019 00:03:58 +0000 (01:03 +0100)
138 files changed:
admin/cli/install.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/tests/externallib_test.php
analytics/classes/model.php
analytics/templates/insight_info_message_prediction.mustache
analytics/templates/notification_styles.mustache
analytics/tests/prediction_actions_test.php
auth/cas/auth.php
auth/shibboleth/auth.php
blocks/myoverview/lang/en/block_myoverview.php
cache/stores/redis/tests/compressor_test.php
calendar/externallib.php
calendar/tests/externallib_test.php
calendar/upgrade.txt
course/tests/externallib_test.php
filter/displayh5p/db/install.php [new file with mode: 0644]
filter/displayh5p/filter.php
filter/displayh5p/lang/en/filter_displayh5p.php
filter/displayh5p/tests/filter_test.php
grade/export/xls/grade_export_xls.php
grade/grading/lib.php
h5p/classes/core.php
h5p/classes/factory.php
h5p/classes/framework.php
h5p/tests/h5p_file_storage_test.php
lang/en/admin.php
lang/en/auth.php
lang/en/availability.php
lang/en/badges.php
lang/en/completion.php
lang/en/course.php
lang/en/install.php
lang/en/moodle.php
lang/en/role.php
lang/en/user.php
lib/csvlib.class.php
lib/dataformatlib.php
lib/db/services.php
lib/editor/atto/plugins/emojipicker/classes/privacy/provider.php [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/lang/en/atto_emojipicker.php [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/lib.php [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/styles.css [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/version.php [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-debug.js [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-min.js [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button.js [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/src/button/build.json [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/src/button/js/button.js [new file with mode: 0644]
lib/editor/atto/plugins/emojipicker/yui/src/button/meta/button.json [new file with mode: 0644]
lib/editor/atto/plugins/h5p/lang/en/atto_h5p.php
lib/editor/atto/plugins/h5p/lib.php
lib/editor/atto/plugins/h5p/pix/icon-white.png [new file with mode: 0644]
lib/editor/atto/plugins/h5p/pix/icon-white.svg [new file with mode: 0644]
lib/editor/atto/plugins/h5p/pix/icon.svg
lib/editor/atto/plugins/h5p/styles.css
lib/editor/atto/plugins/h5p/tests/behat/h5p.feature
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-debug.js
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-min.js
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button.js
lib/editor/atto/plugins/h5p/yui/src/button/js/button.js
lib/editor/atto/tests/fixtures/ipsums.h5p [new file with mode: 0644]
lib/filelib.php
lib/form/amd/build/submit.min.js
lib/form/amd/build/submit.min.js.map
lib/form/amd/src/submit.js
lib/form/classes/util.php [new file with mode: 0644]
lib/form/editor.php
lib/formslib.php
lib/installlib.php
lib/mlbackend/python/lang/en/mlbackend_python.php
lib/upgrade.txt
mod/assign/lang/en/assign.php
mod/data/preset.php
mod/forum/amd/build/discussion_list.min.js
mod/forum/amd/build/discussion_list.min.js.map
mod/forum/amd/build/discussion_modern.min.js [deleted file]
mod/forum/amd/build/discussion_modern.min.js.map [deleted file]
mod/forum/amd/build/discussion_nested_v2.min.js [new file with mode: 0644]
mod/forum/amd/build/discussion_nested_v2.min.js.map [new file with mode: 0644]
mod/forum/amd/build/inpage_reply.min.js
mod/forum/amd/build/inpage_reply.min.js.map
mod/forum/amd/build/posts_list.min.js
mod/forum/amd/build/posts_list.min.js.map
mod/forum/amd/src/discussion_list.js
mod/forum/amd/src/discussion_nested_v2.js [moved from mod/forum/amd/src/discussion_modern.js with 98% similarity]
mod/forum/amd/src/inpage_reply.js
mod/forum/amd/src/posts_list.js
mod/forum/classes/local/exporters/author.php
mod/forum/classes/local/factories/renderer.php
mod/forum/classes/local/renderers/discussion.php
mod/forum/classes/local/renderers/discussion_list.php
mod/forum/discuss.php
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/report/summary/amd/build/filters.min.js
mod/forum/report/summary/amd/build/filters.min.js.map
mod/forum/report/summary/amd/build/selectors.min.js
mod/forum/report/summary/amd/build/selectors.min.js.map
mod/forum/report/summary/amd/src/filters.js
mod/forum/report/summary/amd/src/selectors.js
mod/forum/report/summary/classes/form/dates_filter_form.php [new file with mode: 0644]
mod/forum/report/summary/classes/output/filters.php
mod/forum/report/summary/classes/summary_table.php
mod/forum/report/summary/index.php
mod/forum/report/summary/lang/en/forumreport_summary.php
mod/forum/report/summary/templates/filter_dates.mustache [new file with mode: 0644]
mod/forum/report/summary/templates/filter_dates_popover.mustache [new file with mode: 0644]
mod/forum/report/summary/templates/filter_groups.mustache [new file with mode: 0644]
mod/forum/report/summary/templates/filters.mustache
mod/forum/report/summary/version.php
mod/forum/templates/discussion_list.mustache
mod/forum/templates/discussion_settings_body_content.mustache
mod/forum/templates/forum_action_menu.mustache
mod/forum/templates/forum_discussion_nested_v2.mustache [moved from mod/forum/templates/forum_discussion_modern.mustache with 88% similarity]
mod/forum/templates/forum_discussion_nested_v2_first_post.mustache [moved from mod/forum/templates/forum_discussion_modern_first_post.mustache with 94% similarity]
mod/forum/templates/forum_discussion_nested_v2_post_reply.mustache [moved from mod/forum/templates/forum_discussion_modern_post_reply.mustache with 94% similarity]
mod/forum/templates/forum_discussion_nested_v2_posts.mustache [moved from mod/forum/templates/forum_discussion_modern_posts.mustache with 88% similarity]
mod/forum/templates/grades/grader/discussion/post_modal.mustache
mod/forum/templates/grades/grader/discussion/posts.mustache
mod/forum/templates/inpage_reply_v2.mustache [moved from mod/forum/templates/inpage_reply_modern.mustache with 95% similarity]
mod/forum/templates/settings_header.mustache
mod/forum/tests/behat/behat_mod_forum.php
mod/forum/tests/behat/discussion_subscriptions.feature
mod/forum/tests/behat/forum_subscriptions_default.feature
mod/forum/tests/lib_test.php
mod/forum/view.php
report/insights/classes/output/insights_list.php
report/insights/lang/en/report_insights.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/modules.scss
theme/boost/scss/moodle/undo.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
theme/classic/tests/behat/behat_theme_classic_behat_mod_forum.php [new file with mode: 0644]
user/forum.php
user/forum_form.php
user/templates/upcoming_activities_due_insight_body.mustache

index 2dea6ca..ea71571 100644 (file)
@@ -511,100 +511,105 @@ if ($interactive) {
 $database = $databases[$CFG->dbtype];
 
 
-// ask for db host
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('databasehost', 'install'));
-    if ($options['dbhost'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['dbhost']);
+// We cannot do any validation until all DB connection data is provided.
+$hintdatabase = '';
+do {
+    echo $hintdatabase;
+
+    // Ask for db host.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('databasehost', 'install'));
+        if ($options['dbhost'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['dbhost']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+        $CFG->dbhost = cli_input($prompt, $options['dbhost']);
+
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        $CFG->dbhost = $options['dbhost'];
     }
-    $CFG->dbhost = cli_input($prompt, $options['dbhost']);
 
-} else {
-    $CFG->dbhost = $options['dbhost'];
-}
+    // Ask for db name.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('databasename', 'install'));
+        if ($options['dbname'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['dbname']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+        $CFG->dbname = cli_input($prompt, $options['dbname']);
 
-// ask for db name
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('databasename', 'install'));
-    if ($options['dbname'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['dbname']);
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        $CFG->dbname = $options['dbname'];
     }
-    $CFG->dbname = cli_input($prompt, $options['dbname']);
 
-} else {
-    $CFG->dbname = $options['dbname'];
-}
+    // Ask for db prefix.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('dbprefix', 'install'));
+        //TODO: solve somehow the prefix trouble for oci.
+        if ($options['prefix'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['prefix']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+        $CFG->prefix = cli_input($prompt, $options['prefix']);
 
-// ask for db prefix
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('dbprefix', 'install'));
-    //TODO: solve somehow the prefix trouble for oci
-    if ($options['prefix'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['prefix']);
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        $CFG->prefix = $options['prefix'];
     }
-    $CFG->prefix = cli_input($prompt, $options['prefix']);
 
-} else {
-    $CFG->prefix = $options['prefix'];
-}
+    // Ask for db port.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('databaseport', 'install'));
+        $prompt = get_string('clitypevaluedefault', 'admin', $options['dbport']);
+        $CFG->dboptions['dbport'] = (int) cli_input($prompt, $options['dbport']);
 
-// ask for db port
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('databaseport', 'install'));
-    $prompt = get_string('clitypevaluedefault', 'admin', $options['dbport']);
-    $CFG->dboptions['dbport'] = (int)cli_input($prompt, $options['dbport']);
+    } else {
+        $CFG->dboptions['dbport'] = (int) $options['dbport'];
+    }
+    if ($CFG->dboptions['dbport'] <= 0) {
+        $CFG->dboptions['dbport'] = '';
+    }
 
-} else {
-    $CFG->dboptions['dbport'] = (int)$options['dbport'];
-}
-if ($CFG->dboptions['dbport'] <= 0) {
-    $CFG->dboptions['dbport'] = '';
-}
+    // Ask for db socket.
+    if ($CFG->ostype === 'WINDOWS') {
+        $CFG->dboptions['dbsocket'] = '';
 
-// ask for db socket
-if ($CFG->ostype === 'WINDOWS') {
-    $CFG->dboptions['dbsocket'] = '';
+    } else if ($interactive and empty($CFG->dboptions['dbport'])) {
+        cli_separator();
+        cli_heading(get_string('databasesocket', 'install'));
+        $prompt = get_string('clitypevaluedefault', 'admin', $options['dbsocket']);
+        $CFG->dboptions['dbsocket'] = cli_input($prompt, $options['dbsocket']);
 
-} else if ($interactive and empty($CFG->dboptions['dbport'])) {
-    cli_separator();
-    cli_heading(get_string('databasesocket', 'install'));
-    $prompt = get_string('clitypevaluedefault', 'admin', $options['dbsocket']);
-    $CFG->dboptions['dbsocket'] = cli_input($prompt, $options['dbsocket']);
+    } else {
+        $CFG->dboptions['dbsocket'] = $options['dbsocket'];
+    }
 
-} else {
-    $CFG->dboptions['dbsocket'] = $options['dbsocket'];
-}
+    // Ask for db user.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('databaseuser', 'install'));
+        if ($options['dbuser'] !== '') {
+            $prompt = get_string('clitypevaluedefault', 'admin', $options['dbuser']);
+        } else {
+            $prompt = get_string('clitypevalue', 'admin');
+        }
+        $CFG->dbuser = cli_input($prompt, $options['dbuser']);
 
-// ask for db user
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('databaseuser', 'install'));
-    if ($options['dbuser'] !== '') {
-        $prompt = get_string('clitypevaluedefault', 'admin', $options['dbuser']);
     } else {
-        $prompt = get_string('clitypevalue', 'admin');
+        $CFG->dbuser = $options['dbuser'];
     }
-    $CFG->dbuser = cli_input($prompt, $options['dbuser']);
 
-} else {
-    $CFG->dbuser = $options['dbuser'];
-}
+    // Ask for db password.
+    if ($interactive) {
+        cli_separator();
+        cli_heading(get_string('databasepass', 'install'));
 
-// ask for db password
-if ($interactive) {
-    cli_separator();
-    cli_heading(get_string('databasepass', 'install'));
-    do {
         if ($options['dbpass'] !== '') {
             $prompt = get_string('clitypevaluedefault', 'admin', $options['dbpass']);
         } else {
@@ -612,19 +617,23 @@ if ($interactive) {
         }
 
         $CFG->dbpass = cli_input($prompt, $options['dbpass']);
-        if (function_exists('distro_pre_create_db')) { // Hook for distros needing to do something before DB creation
-            $distro = distro_pre_create_db($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, array('dbpersist'=>0, 'dbport'=>$CFG->dboptions['dbport'], 'dbsocket'=>$CFG->dboptions['dbsocket']), $distro);
+        if (function_exists('distro_pre_create_db')) { // Hook for distros needing to do something before DB creation.
+            $distro = distro_pre_create_db($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix,
+                    array('dbpersist' => 0, 'dbport' => $CFG->dboptions['dbport'], 'dbsocket' => $CFG->dboptions['dbsocket']),
+                    $distro);
         }
-        $hint_database = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, array('dbpersist'=>0, 'dbport'=>$CFG->dboptions['dbport'], 'dbsocket'=>$CFG->dboptions['dbsocket']));
-    } while ($hint_database !== '');
+        $hintdatabase = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix,
+                array('dbpersist' => 0, 'dbport' => $CFG->dboptions['dbport'], 'dbsocket' => $CFG->dboptions['dbsocket']));
 
-} else {
-    $CFG->dbpass = $options['dbpass'];
-    $hint_database = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix, array('dbpersist'=>0, 'dbport'=>$CFG->dboptions['dbport'], 'dbsocket'=>$CFG->dboptions['dbsocket']));
-    if ($hint_database !== '') {
-        cli_error(get_string('dbconnectionerror', 'install'));
+    } else {
+        $CFG->dbpass = $options['dbpass'];
+        $hintdatabase = install_db_validate($database, $CFG->dbhost, $CFG->dbuser, $CFG->dbpass, $CFG->dbname, $CFG->prefix,
+                array('dbpersist' => 0, 'dbport' => $CFG->dboptions['dbport'], 'dbsocket' => $CFG->dboptions['dbsocket']));
+        if ($hintdatabase !== '') {
+            cli_error(get_string('dbconnectionerror', 'install'));
+        }
     }
-}
+} while ($hintdatabase !== '');
 
 // ask for fullname
 if ($interactive) {
index 1b34498..7d49d92 100644 (file)
@@ -210,6 +210,12 @@ class api {
         $identityprovidersdata = \auth_plugin_base::prepare_identity_providers_for_output($identityproviders, $OUTPUT);
         if (!empty($identityprovidersdata)) {
             $settings['identityproviders'] = $identityprovidersdata;
+            // Clean URLs to avoid breaking Web Services.
+            // We can't do it in prepare_identity_providers_for_output() because it may break the web output.
+            foreach ($settings['identityproviders'] as &$ip) {
+                $ip['url'] = (!empty($ip['url'])) ? clean_param($ip['url'], PARAM_URL) : '';
+                $ip['iconurl'] = (!empty($ip['iconurl'])) ? clean_param($ip['iconurl'], PARAM_URL) : '';
+            }
         }
 
         // If age is verified, return also the admin contact details.
index 5c7686a..f982776 100644 (file)
@@ -75,7 +75,7 @@ $string['loginintheapp'] = 'Via the app';
 $string['logininthebrowser'] = 'Via a browser window (for SSO plugins)';
 $string['loginintheembeddedbrowser'] = 'Via an embedded browser (for SSO plugins)';
 $string['mainmenu'] = 'Main menu';
-$string['minimumversion'] = 'Require users to upgrade their apps to the minimum version indicated. Those using previous versions of the app will not be able to access to the site. This works since app version 3.8.0 onward.';
+$string['minimumversion'] = 'If an app version is specified (3.8.0 or higher), any users using an older app version will be prompted to upgrade their app before being allowed access to the site.';
 $string['minimumversion_key'] = 'Minimum app version required';
 $string['mobileapp'] = 'Mobile app';
 $string['mobileappconnected'] = 'Mobile app connected';
@@ -93,7 +93,7 @@ $string['readingthisemailgettheapp'] = 'Reading this in an email? <a href="{$a}"
 $string['remoteaddons'] = 'Remote add-ons';
 $string['selfsignedoruntrustedcertificatewarning'] = 'It seems that the HTTPS certificate is self-signed or not trusted. The mobile app will only work with trusted sites.';
 $string['setuplink'] = 'App download page';
-$string['setuplink_desc'] = 'URL of page with links to download the mobile app from the App Store and Google Play.';
+$string['setuplink_desc'] = 'URL of page with options to download the mobile app from the App Store and Google Play. The app download page link is displayed in the page footer and in a user\'s profile. Leave blank to not display a link.';
 $string['smartappbanners'] = 'App Banners';
 $string['typeoflogin'] = 'Type of login';
 $string['typeoflogin_desc'] = 'If the site uses a SSO authentication method, then select via a browser window or via an embedded browser. An embedded browser provides a better user experience, though it doesn\'t work with all SSO plugins.';
index c2118dc..04ed07b 100644 (file)
@@ -103,6 +103,7 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         );
         $this->assertEquals($expected, $result);
 
+        $this->setAdminUser();
         // Change some values.
         set_config('registerauth', 'email');
         $authinstructions = 'Something with <b>html tags</b>';
@@ -117,6 +118,18 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         set_config('disabledfeatures', 'myoverview', 'tool_mobile');
         set_config('minimumversion', '3.8.0', 'tool_mobile');
 
+        // Enable couple of issuers.
+        $issuer = \core\oauth2\api::create_standard_issuer('google');
+        $irecord = $issuer->to_record();
+        $irecord->clientid = 'mock';
+        $irecord->clientsecret = 'mock';
+        core\oauth2\api::update_issuer($irecord);
+
+        set_config('hostname', 'localhost', 'auth_cas');
+        set_config('auth_logo', 'http://invalidurl.com//invalid/', 'auth_cas');
+
+        set_config('auth', 'oauth2,cas');
+
         list($authinstructions, $notusedformat) = external_format_text($authinstructions, FORMAT_MOODLE, $context->id);
         $expected['registerauth'] = 'email';
         $expected['authinstructions'] = $authinstructions;
@@ -139,7 +152,26 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
 
         $result = external::get_public_config();
         $result = external_api::clean_returnvalue(external::get_public_config_returns(), $result);
+        // First check providers.
+        $identityproviders = $result['identityproviders'];
+        unset($result['identityproviders']);
+
+        $this->assertEquals('Google', $identityproviders[0]['name']);
+        $this->assertEquals($irecord->image, $identityproviders[0]['iconurl']);
+        $this->assertContains($CFG->wwwroot, $identityproviders[0]['url']);
+
+        $this->assertEquals('CAS', $identityproviders[1]['name']);
+        $this->assertEmpty($identityproviders[1]['iconurl']);
+        $this->assertContains($CFG->wwwroot, $identityproviders[1]['url']);
+
         $this->assertEquals($expected, $result);
+
+        // Change providers img.
+        $newurl = 'validimage.png';
+        set_config('auth_logo', $newurl, 'auth_cas');
+        $result = external::get_public_config();
+        $result = external_api::clean_returnvalue(external::get_public_config_returns(), $result);
+        $this->assertContains($newurl, $result['identityproviders'][1]['iconurl']);
     }
 
     /**
index 9e484b1..5385c29 100644 (file)
@@ -1398,6 +1398,10 @@ class model {
             $current++;
         }
 
+        if (empty($predictions)) {
+            return array();
+        }
+
         return [$current, $predictions];
     }
 
index d6b2f09..6e66e8a 100644 (file)
@@ -38,9 +38,7 @@
 {{> core_analytics/notification_styles}}
 
 {{#body}}
-    <div>
-        {{{.}}}
-    </div>
+    {{{.}}}
 {{/body}}
 <br/>
 
index a942177..527db08 100644 (file)
     }
 }}
 
-<style>
+{{! The styles defined here will be included in the Moodle web UI and in emails. Emails do not include Moodle
+stylesheets so we want these styles to be applied to emails. However, they will also be included in the Moodle web UI.
+We use the not(.dir-ltr):not(.dir-rtl) so that this style is not applied to the Moodle UI.
+Note that gmail strips out HTML styles which selector includes the caracters (), so the font-family rule
+is not applied in gmail.}}
+<head><style>
 body:not(.dir-ltr):not(.dir-rtl) {
     font-family: 'Open Sans', sans-serif;
 }
-body:not(.dir-ltr):not(.dir-rtl) .btn-insight {
+.btn-insight {
     color: #007bff;
     background-color: transparent;
     display: inline-block;
@@ -47,10 +52,9 @@ body:not(.dir-ltr):not(.dir-rtl) .btn-insight {
     user-select: none;
     border: 1px solid #007bff;
     padding: .375rem .75rem;
-    font-size: .9375rem;
     line-height: 1.5;
     border-radius: 0;
     text-decoration: none;
     cursor: pointer;
 }
-</style>
+</style></head>
index 70d368d..f376878 100644 (file)
@@ -55,22 +55,24 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
 
         $this->resetAfterTest(true);
 
-        $course1 = $this->getDataGenerator()->create_course();
-        $course2 = $this->getDataGenerator()->create_course();
-        $this->context = \context_course::instance($course1->id);
+        $this->course1 = $this->getDataGenerator()->create_course();
+        $this->course2 = $this->getDataGenerator()->create_course();
+        $this->context = \context_course::instance($this->course1->id);
 
         $this->teacher1 = $this->getDataGenerator()->create_user();
         $this->teacher2 = $this->getDataGenerator()->create_user();
+        $this->teacher3 = $this->getDataGenerator()->create_user();
 
-        $this->getDataGenerator()->enrol_user($this->teacher1->id, $course1->id, 'editingteacher');
-        $this->getDataGenerator()->enrol_user($this->teacher2->id, $course1->id, 'editingteacher');
+        $this->getDataGenerator()->enrol_user($this->teacher1->id, $this->course1->id, 'editingteacher');
+        $this->getDataGenerator()->enrol_user($this->teacher2->id, $this->course1->id, 'editingteacher');
+        $this->getDataGenerator()->enrol_user($this->teacher3->id, $this->course1->id, 'editingteacher');
 
         // The only relevant fields are modelid, contextid and sampleid. I'm cheating and setting
         // contextid as the course context so teachers can access these predictions.
         $pred = new \stdClass();
         $pred->modelid = $this->model->get_id();
         $pred->contextid = $this->context->id;
-        $pred->sampleid = $course1->id;
+        $pred->sampleid = $this->course1->id;
         $pred->rangeindex = 1;
         $pred->prediction = 1;
         $pred->predictionscore = 1;
@@ -78,7 +80,7 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
         $pred->timecreated = time();
         $DB->insert_record('analytics_predictions', $pred);
 
-        $pred->sampleid = $course2->id;
+        $pred->sampleid = $this->course2->id;
         $DB->insert_record('analytics_predictions', $pred);
     }
 
@@ -114,6 +116,7 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
      * test_get_predictions
      */
     public function test_get_predictions() {
+        global $DB;
 
         // Already logged in as admin.
         list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
@@ -152,5 +155,13 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
         $recordset = $this->model->get_prediction_actions($this->context);
         $this->assertCount(3, $recordset);
         $recordset->close();
+
+        // Trying with a deleted course.
+        $DB->delete_records('course', ['id' => $this->course2->id]);
+        $this->setUser($this->teacher3);
+        list($ignored, $predictions) = $this->model->get_predictions($this->context);
+        $this->assertCount(1, $predictions);
+        reset($predictions)->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
+        $this->assertEmpty($this->model->get_predictions($this->context));
     }
 }
index 56e5a7d..c29c920 100644 (file)
@@ -365,13 +365,17 @@ class auth_plugin_cas extends auth_plugin_ldap {
             return [];
         }
 
-        $iconurl = moodle_url::make_pluginfile_url(
-            context_system::instance()->id,
-            'auth_cas',
-            'logo',
-            null,
-            '/',
-            $this->config->auth_logo);
+        if ($this->config->auth_logo) {
+            $iconurl = moodle_url::make_pluginfile_url(
+                context_system::instance()->id,
+                'auth_cas',
+                'logo',
+                null,
+                null,
+                $this->config->auth_logo);
+        } else {
+            $iconurl = null;
+        }
 
         return [
             [
index c52ad12..ad0c55f 100644 (file)
@@ -294,12 +294,19 @@ class auth_plugin_shibboleth extends auth_plugin_base {
         }
 
         $url = new moodle_url('/auth/shibboleth/index.php');
-        $iconurl = moodle_url::make_pluginfile_url(context_system::instance()->id,
-                                                   'auth_shibboleth',
-                                                   'logo',
-                                                   null,
-                                                   '/',
-                                                   $config->auth_logo);
+
+        if ($config->auth_logo) {
+            $iconurl = moodle_url::make_pluginfile_url(
+                context_system::instance()->id,
+                'auth_shibboleth',
+                'logo',
+                null,
+                null,
+                $config->auth_logo);
+        } else {
+            $iconurl = null;
+        }
+
         $result[] = ['url' => $url, 'iconurl' => $iconurl, 'name' => $config->login_name];
         return $result;
     }
index 7c5520f..09b5aed 100644 (file)
@@ -38,7 +38,7 @@ $string['aria:displaydropdown'] = 'Display drop-down menu';
 $string['aria:favourites'] = 'Show starred courses';
 $string['aria:future'] = 'Show future courses';
 $string['aria:groupingdropdown'] = 'Grouping drop-down menu';
-$string['aria:inprogress'] = 'Show in courses in progress';
+$string['aria:inprogress'] = 'Show courses in progress';
 $string['aria:lastaccessed'] = 'Sort courses by last accessed date';
 $string['aria:list'] = 'Switch to list view';
 $string['aria:title'] = 'Sort courses by course name';
@@ -54,7 +54,7 @@ $string['courseprogress'] = 'Course progress:';
 $string['completepercent'] = '{$a}% complete';
 $string['customfield'] = 'Custom field';
 $string['customfiltergrouping'] = 'Field to use';
-$string['customfiltergrouping_nofields'] = 'You need to add at least one course custom field in order to use this setting';
+$string['customfiltergrouping_nofields'] = 'This option requires a course custom field to be set up.';
 $string['displaycategories'] = 'Display categories';
 $string['displaycategories_help'] = 'Display the course category on dashboard course items including cards, list items and summary items.';
 $string['favourites'] = 'Starred';
index 0d9a583..7f8876e 100644 (file)
@@ -171,6 +171,11 @@ class cachestore_redis_compressor_test extends advanced_testcase {
      * @return array
      */
     public function provider_for_tests_setget() {
+        if (!cachestore_redis::are_requirements_met()) {
+            // Even though we skip all tests in this case, this provider can still show warnings about non-existing class.
+            return [];
+        }
+
         $data = [
             ['none, none',
                 Redis::SERIALIZER_NONE, cachestore_redis::COMPRESSOR_NONE,
index eccd04e..d7f8347 100644 (file)
@@ -1371,4 +1371,82 @@ class core_calendar_external extends external_api {
             ]
         );
     }
+
+    /**
+     * Convert the specified dates into unix timestamps.
+     *
+     * @param   array $datetimes Array of arrays containing date time details, each in the format:
+     *           ['year' => a, 'month' => b, 'day' => c,
+     *            'hour' => d (optional), 'minute' => e (optional), 'key' => 'x' (optional)]
+     * @return  array Provided array of dates converted to unix timestamps
+     * @throws moodle_exception If one or more of the dates provided does not convert to a valid timestamp.
+     */
+    public static function get_timestamps($datetimes) {
+        $params = self::validate_parameters(self::get_timestamps_parameters(), ['data' => $datetimes]);
+
+        $type = \core_calendar\type_factory::get_calendar_instance();
+        $timestamps = ['timestamps' => []];
+
+        foreach ($params['data'] as $key => $datetime) {
+            $hour = $datetime['hour'] ?? 0;
+            $minute = $datetime['minute'] ?? 0;
+
+            try {
+                $timestamp = $type->convert_to_timestamp(
+                    $datetime['year'], $datetime['month'], $datetime['day'], $hour, $minute);
+
+                $timestamps['timestamps'][] = [
+                    'key' => $datetime['key'] ?? $key,
+                    'timestamp' => $timestamp,
+                ];
+
+            } catch (Exception $e) {
+                throw new moodle_exception('One or more of the dates provided were invalid');
+            }
+        }
+
+        return $timestamps;
+    }
+
+    /**
+     * Describes the parameters for get_timestamps.
+     *
+     * @return external_function_parameters
+     */
+    public static function get_timestamps_parameters() {
+        return new external_function_parameters ([
+            'data' => new external_multiple_structure(
+                new external_single_structure(
+                    [
+                        'key' => new external_value(PARAM_ALPHANUMEXT, 'key', VALUE_OPTIONAL),
+                        'year' => new external_value(PARAM_INT, 'year'),
+                        'month' => new external_value(PARAM_INT, 'month'),
+                        'day' => new external_value(PARAM_INT, 'day'),
+                        'hour' => new external_value(PARAM_INT, 'hour', VALUE_OPTIONAL),
+                        'minute' => new external_value(PARAM_INT, 'minute', VALUE_OPTIONAL),
+                    ]
+                )
+            )
+        ]);
+    }
+
+    /**
+     * Describes the timestamps return format.
+     *
+     * @return external_single_structure
+     */
+    public static function get_timestamps_returns() {
+        return new external_single_structure(
+            [
+                'timestamps' => new external_multiple_structure(
+                    new external_single_structure(
+                        [
+                            'key' => new external_value(PARAM_ALPHANUMEXT, 'Timestamp key'),
+                            'timestamp' => new external_value(PARAM_INT, 'Unix timestamp'),
+                        ]
+                    )
+                )
+            ]
+        );
+    }
 }
index d3a7cf9..810c25f 100644 (file)
@@ -2696,4 +2696,87 @@ class core_calendar_externallib_testcase extends externallib_advanced_testcase {
         );
         $this->assertEquals(['user', 'course', 'group'], $data['allowedeventtypes']);
     }
+
+    /**
+     * Test get_timestamps with string keys, with and without optional hour/minute values.
+     */
+    public function test_get_timestamps_string_keys() {
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $time1 = new DateTime('2018-12-30 00:00:00');
+        $time2 = new DateTime('2019-03-27 23:59:00');
+
+        $dates = [
+            [
+                'key' => 'from',
+                'year' => $time1->format('Y'),
+                'month' => $time1->format('m'),
+                'day' => $time1->format('d'),
+            ],
+            [
+                'key' => 'to',
+                'year' => $time2->format('Y'),
+                'month' => (int) $time2->format('m'),
+                'day' => $time2->format('d'),
+                'hour' => $time2->format('H'),
+                'minute' => $time2->format('i'),
+            ],
+        ];
+
+        $expectedtimestamps = [
+            'from' => $time1->getTimestamp(),
+            'to' => $time2->getTimestamp(),
+        ];
+
+        $result = core_calendar_external::get_timestamps($dates);
+
+        $this->assertEquals(['timestamps'], array_keys($result));
+        $this->assertEquals(2, count($result['timestamps']));
+
+        foreach ($result['timestamps'] as $data) {
+            $this->assertTrue(in_array($data['key'], ['from', 'to']));
+            $this->assertEquals($expectedtimestamps[$data['key']], $data['timestamp']);
+        }
+    }
+
+    /**
+     * Test get_timestamps with no keys specified, with and without optional hour/minute values.
+     */
+    public function test_get_timestamps_no_keys() {
+        $this->resetAfterTest(true);
+        $this->setAdminUser();
+
+        $time1 = new DateTime('2018-12-30 00:00:00');
+        $time2 = new DateTime('2019-03-27 23:59:00');
+
+        $dates = [
+            [
+                'year' => $time1->format('Y'),
+                'month' => $time1->format('m'),
+                'day' => $time1->format('d'),
+            ],
+            [
+                'year' => $time2->format('Y'),
+                'month' => (int) $time2->format('m'),
+                'day' => $time2->format('d'),
+                'hour' => $time2->format('H'),
+                'minute' => $time2->format('i'),
+            ],
+        ];
+
+        $expectedtimestamps = [
+            0 => $time1->getTimestamp(),
+            1 => $time2->getTimestamp(),
+        ];
+
+        $result = core_calendar_external::get_timestamps($dates);
+
+        $this->assertEquals(['timestamps'], array_keys($result));
+        $this->assertEquals(2, count($result['timestamps']));
+
+        foreach ($result['timestamps'] as $data) {
+            $this->assertEquals($expectedtimestamps[$data['key']], $data['timestamp']);
+        }
+    }
 }
index 7bfbeb1..2f4273a 100644 (file)
@@ -9,6 +9,8 @@ information provided here is intended especially for developers.
   * calendar_cron()
   * calendar_get_mini()
   * calendar_get_upcoming()
+* Added core_calendar_external::get_timestamps(), which allows an array containing an arbitrary number of arrays of
+  date/time data to be converted and returned as timestamps, along with an optional key.
 
 === 3.6 ===
 * calendar_get_default_courses() function now has optional $userid parameter.
index dd752c0..1da624c 100644 (file)
@@ -2384,8 +2384,8 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(2, $result['courses']);
 
         // Check default filters.
-        $this->assertCount(3, $result['courses'][0]['filters']);
-        $this->assertCount(3, $result['courses'][1]['filters']);
+        $this->assertCount(4, $result['courses'][0]['filters']);
+        $this->assertCount(4, $result['courses'][1]['filters']);
 
         $result = core_course_external::get_courses_by_field('category', $category1->id);
         $result = external_api::clean_returnvalue(core_course_external::get_courses_by_field_returns(), $result);
@@ -2427,7 +2427,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
 
         // Check default filters.
         $filters = $result['courses'][0]['filters'];
-        $this->assertCount(3, $filters);
+        $this->assertCount(4, $filters);
         $found = false;
         foreach ($filters as $filter) {
             if ($filter['filter'] == 'mediaplugin' and $filter['localstate'] == TEXTFILTER_OFF) {
diff --git a/filter/displayh5p/db/install.php b/filter/displayh5p/db/install.php
new file mode 100644 (file)
index 0000000..a5a03e0
--- /dev/null
@@ -0,0 +1,38 @@
+<?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/>.
+
+/**
+ * Display H5P active by default
+ *
+ * @package    filter_displayh5p
+ * @copyright  2019 Amaia Anabitarte <amaia@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Enable displayh5p filter by default to render H5P contents.
+ * @throws coding_exception
+ */
+function xmldb_filter_displayh5p_install() {
+    global $CFG;
+
+    require_once("$CFG->libdir/filterlib.php");
+
+    // Display H5P filter should be enabled by default because we need this filter for H5P atto button to work.
+    filter_set_global_state('displayh5p', TEXTFILTER_ON, -1);
+}
index bd31b68..d13ff33 100644 (file)
@@ -47,49 +47,89 @@ class filter_displayh5p extends moodle_text_filter {
      * @return string
      */
     public function filter($text, array $options = array()) {
+        global $CFG;
 
         if (!is_string($text) or empty($text)) {
             // Non string data can not be filtered anyway.
             return $text;
         }
 
-        if (stripos($text, 'http') === false) {
+        // We are trying to minimize performance impact checking there's some H5P related URL.
+        $h5purl = '(http[^ &<]*h5p)';
+        if (!preg_match($h5purl, $text)) {
             return $text;
         }
 
         $allowedsources = get_config('filter_displayh5p', 'allowedsources');
         $allowedsources = array_filter(array_map('trim', explode("\n", $allowedsources)));
-        if (empty($allowedsources)) {
-            return $text;
-        }
+
+        $localsource = '('.preg_quote($CFG->wwwroot).'/[^ &<]*\.h5p([?][^ <]*)?[^ &<]*)';
+        $allowedsources[] = $localsource;
 
         $params = array(
-            'tagbegin' => "<iframe src=",
-            'tagend' => "</iframe>"
+            'tagbegin' => '<iframe src="',
+            'tagend' => '</iframe>'
         );
 
+        $specialchars = ['*', '?', '&', '[^<]'];
+        $escapedspecialchars = ['[^.]+', '\?', '&amp;', '[^<]*'];
+        $h5pcontents = array();
+
+        // Check all allowed sources.
         foreach ($allowedsources as $source) {
             // It is needed to add "/embed" at the end of URLs like https:://*.h5p.com/content/12345 (H5P.com).
             $params['urlmodifier'] = '';
-            if (!(stripos($source, 'embed'))) {
-                $params['urlmodifier'] = '/embed';
+
+            if (($source == $localsource)) {
+                $params['tagbegin'] = '<iframe src="'.$CFG->wwwroot.'/h5p/embed.php?url=';
+                $ultimatepattern = '#'.$source.'#';
+            } else {
+                if (!stripos($source, 'embed')) {
+                    $params['urlmodifier'] = '/embed';
+                }
+                // Convert special chars.
+                $sourceid = str_replace('[id]', '[0-9]+', $source);
+                $escapechars = str_replace($specialchars, $escapedspecialchars, $sourceid);
+                $ultimatepattern = '#(' . $escapechars . ')#';
             }
 
-            // Convert special chars.
-            $specialchars = ['*', '?', '&'];
-            $escapedspecialchars = ['[^.]+', '\?', '&amp;'];
-            $sourceid = str_replace('[id]', '[0-9]+', $source);
-            $escapechars = str_replace($specialchars, $escapedspecialchars, $sourceid);
-            $ultimatepattern = '#(' . $escapechars . ')#';
+            // Improve performance creating filterobjects only when needed.
+            if (!preg_match($ultimatepattern, $text)) {
+                continue;
+            }
 
             $h5pcontenturl = new filterobject($source, null, null, false,
-                   false, null, [$this, 'filterobject_prepare_replacement_callback'], $params);
+                false, null, [$this, 'filterobject_prepare_replacement_callback'], $params);
 
             $h5pcontenturl->workregexp = $ultimatepattern;
             $h5pcontents[] = $h5pcontenturl;
         }
 
-        return filter_phrases($text, $h5pcontents, null, null, false, true);
+        if (empty($h5pcontents)) {
+            // No matches to deal with.
+            return $text;
+        }
+
+        $result = filter_phrases($text, $h5pcontents, null, null, false, true);
+
+        // Encoding H5P file URLs.
+        // embed.php page is requesting a PARAM_LOCALURL url parameter, so for files/directories use non-alphanumeric
+        // characters, we need to encode the parameter. Fetch url parameter added to embed.php and encode the whole url.
+        $localurl = '#\?url=([^" <]*[\/]+[^" <]*\.h5p)([?][^"]*)?#';
+        $result = preg_replace_callback($localurl,
+            function ($matches) {
+                $baseurl = rawurlencode($matches[1]);
+                // Deal with possible parameters in the url link.
+                if (!empty($matches[2])) {
+                    $match = explode('?', $matches[2]);
+                    if (!empty($match[1])) {
+                        $baseurl = $baseurl."&".$match[1];
+                    }
+                }
+                return "?url=".$baseurl;
+            }, $result);
+
+        return $result;
     }
 
     /**
@@ -101,13 +141,13 @@ class filter_displayh5p extends moodle_text_filter {
      * @return array [$hreftagbegin, $hreftagend, $replacementphrase] for filterobject.
      */
     public function filterobject_prepare_replacement_callback($tagbegin, $tagend, $urlmodifier) {
-
         $sourceurl = "$1";
         if ($urlmodifier !== "") {
             $sourceurl .= $urlmodifier;
         }
 
-        $h5piframesrc = "\"".$sourceurl."\" width=\"100%\" height=\"637\" allowfullscreen=\"allowfullscreen\" style=\"border: 0;\">";
+        $h5piframesrc = $sourceurl.
+            '" class="h5p-iframe" style="height:230px; width: 100%; border: 0;" allowfullscreen="allowfullscreen">';
 
         // We want to request the resizing script only once.
         if (self::$loadresizerjs) {
index 6317fb6..950324d 100644 (file)
 defined('MOODLE_INTERNAL') || die;
 
 $string['allowedsourceslist'] = 'Allowed sources';
-$string['allowedsourceslistdesc'] = 'List of sources from which users can embed H5P content. If empty, the filter won\'t convert any external URL.
+$string['allowedsourceslistdesc'] = 'A list of URLs from which users can embed H5P content. If none are specified, all URLs will remain as links and not be displayed as embedded H5P content.
 
-<b>[id]</b> is a placeholder for the H5P content id in the external source.
+\'[id]\' is a placeholder for the H5P content ID in the external source.
 
-<b>*</b> wildcard is supported. For example, *.example.com will embed H5P content from any subdomain of example.com, but not from the example.com domain.';
+The wildcard character \'*\' may be used to specify subdomains. For example, *.example.com will allow embedded H5P content from any subdomain of example.com, but not from the example.com domain.';
 $string['filtername'] = 'Display H5P';
-$string['privacy:metadata'] = 'This H5P filter does not store any personal data.';
+$string['privacy:metadata'] = 'The display H5P filter does not store any personal data.';
index 2ac3aae..a8313c8 100644 (file)
@@ -45,8 +45,6 @@ class filter_displayh5p_testcase extends advanced_testcase {
             "https://h5p.org/h5p/embed/[id]\nhttps://*.h5p.com/content/[id]/embed\nhttps://*.h5p.com/content/[id]
                 \nhttps://generic.wordpress.soton.ac.uk/altc/wp-admin/admin-ajax.php?action=h5p_embed&id=[id]",
             'filter_displayh5p');
-        // Enable display h5p filter at top level.
-        filter_set_global_state('displayh5p', TEXTFILTER_ON);
     }
 
     /**
@@ -71,6 +69,8 @@ class filter_displayh5p_testcase extends advanced_testcase {
      * @return array
      */
     public function texts_provider() {
+        global $CFG;
+
         return [
             ["http:://example.com", "#http:://example.com#"],
             ["http://google.es/h5p/embed/3425234", "#http://google.es/h5p/embed/3425234#"],
@@ -83,7 +83,13 @@ class filter_displayh5p_testcase extends advanced_testcase {
             ["https://generic.wordpress.soton.ac.uk/altc/wp-admin/admin-ajax.php?action=h5p_embed&amp;id=13",
                     "#<iframe src=\"https://generic.wordpress.soton.ac.uk/altc/wp-admin/admin-ajax.php\?action=h5p_embed\&amp\;id=13\"[^>]+?>#"],
             ["https://h5p.org/h5p/embed/547225 another content in the same page https://moodle.h5p.com/content/1290729733828858779/embed",
-                    "#<iframe src=\"https://h5p.org/h5p/embed/547225\"[^>]+?>((?!<iframe).)*<iframe src=\"https://moodle.h5p.com/content/1290729733828858779/embed\"[^>]+?>#"]
+                    "#<iframe src=\"https://h5p.org/h5p/embed/547225\"[^>]+?>((?!<iframe).)*<iframe src=\"https://moodle.h5p.com/content/1290729733828858779/embed\"[^>]+?>#"],
+            [$CFG->wwwroot."/pluginfile.php/5/user/private/interactive-video.h5p?export=1&embed=1",
+                    "#<iframe src=\"{$CFG->wwwroot}/h5p/embed.php\?url=".rawurlencode("{$CFG->wwwroot}/pluginfile.php/5/user/private/interactive-video.h5p").
+                    "&export=1&embed=1\"[^>]*?></iframe>#"],
+            [$CFG->wwwroot."/pluginfile.php/5/user/private/accordion-6-7138%20%281%29.h5p.h5p",
+                    "#<iframe src=\"{$CFG->wwwroot}/h5p/embed.php\?url=".rawurlencode("{$CFG->wwwroot}/pluginfile.php/5/user/private/accordion-6-7138%20%281%29.h5p.h5p").
+                    "\"[^>]*?></iframe>#"]
         ];
     }
 }
\ No newline at end of file
index f81b0e2..ec4bbcd 100644 (file)
@@ -45,6 +45,9 @@ class grade_export_xls extends grade_export {
 
         $strgrades = get_string('grades');
 
+        // If this file was requested from a form, then mark download as complete (before sending headers).
+        \core_form\util::form_download_complete();
+
         // Calculate file name
         $shortname = format_string($this->course->shortname, true, array('context' => context_course::instance($this->course->id)));
         $downloadfilename = clean_filename("$shortname $strgrades.xls");
index d495448..a75dd20 100644 (file)
@@ -339,8 +339,10 @@ class grading_manager {
             }
 
         } else if ($this->get_context()->contextlevel == CONTEXT_MODULE) {
-            list($context, $course, $cm) = get_context_info_array($this->get_context()->id);
-            return self::available_areas('mod_'.$cm->modname);
+            $modulecontext = $this->get_context();
+            $coursecontext = $modulecontext->get_course_context();
+            $cm = get_fast_modinfo($coursecontext->instanceid)->get_cm($modulecontext->instanceid);
+            return self::available_areas("mod_{$cm->modname}");
 
         } else {
             throw new coding_exception('Unsupported gradable area context level');
index c855c2f..3702161 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * H5P player class.
+ * H5P core class.
  *
  * @package    core_h5p
  * @copyright  2019 Sara Arjona <sara@moodle.com>
 namespace core_h5p;
 
 use H5PCore;
+use H5PFrameworkInterface;
 use stdClass;
 use moodle_url;
 
 defined('MOODLE_INTERNAL') || die();
 
 /**
- * H5P player class, for displaying any local H5P content.
+ * H5P core class, containing functions and storage shared by the other H5P classes.
  *
  * @package    core_h5p
  * @copyright  2019 Sara Arjona <sara@moodle.com>
@@ -39,14 +40,45 @@ defined('MOODLE_INTERNAL') || die();
  */
 class core extends \H5PCore {
 
+    /** @var array The array containing all the present libraries */
     protected $libraries;
 
+    /**
+     * Constructor for core_h5p/core.
+     *
+     * @param H5PFrameworkInterface $framework The frameworks implementation of the H5PFrameworkInterface
+     * @param string|\H5PFileStorage $path The H5P file storage directory or class
+     * @param string $url The URL to the file storage directory
+     * @param string $language The language code. Defaults to english
+     * @param boolean $export Whether export is enabled
+     */
+    public function __construct(H5PFrameworkInterface $framework, $path, string $url, string $language = 'en',
+            bool $export = false) {
+
+        parent::__construct($framework, $path, $url, $language, $export);
+
+        // Aggregate the assets by default.
+        $this->aggregateAssets = true;
+    }
+
+    /**
+     * Get the path to the dependency.
+     *
+     * @param array $dependency An array containing the information of the requested dependency library
+     * @return string The path to the dependency library
+     */
     protected function getDependencyPath(array $dependency): string {
         $library = $this->find_library($dependency);
 
         return "libraries/{$library->id}/{$library->machinename}-{$library->majorversion}.{$library->minorversion}";
     }
 
+    /**
+     * Get the paths to the content dependencies.
+     *
+     * @param int $id The H5P content ID
+     * @return array An array containing the path of each content dependency
+     */
     public function get_dependency_roots(int $id): array {
         $roots = [];
         $dependencies = $this->h5pF->loadContentDependencies($id);
@@ -66,7 +98,13 @@ class core extends \H5PCore {
         return $roots;
     }
 
-    protected function find_library($dependency): \stdClass {
+    /**
+     * Get a particular dependency library.
+     *
+     * @param array $dependency An array containing information of the dependency library
+     * @return stdClass|null The library object if the library dependency exists, null otherwise
+     */
+    protected function find_library(array $dependency): ?\stdClass {
         global $DB;
         if (null === $this->libraries) {
             $this->libraries = $DB->get_records('h5p_libraries');
@@ -97,6 +135,11 @@ class core extends \H5PCore {
         return null;
     }
 
+    /**
+     * Get core JavaScript files.
+     *
+     * @return array The array containg urls of the core JavaScript files
+     */
     public static function get_scripts(): array {
         global $CFG;
         $cachebuster = '?ver='.$CFG->jsrev;
index d63bfc2..52c8cc0 100644 (file)
@@ -86,14 +86,14 @@ class factory {
      */
     public function get_core(): core {
         if (null === $this->core) {
-          $fs = new \core_h5p\file_storage();
-          $language = \core_h5p\framework::get_language();
-          $context = \context_system::instance();
+            $fs = new \core_h5p\file_storage();
+            $language = \core_h5p\framework::get_language();
+            $context = \context_system::instance();
 
-          $url = \moodle_url::make_pluginfile_url($context->id, 'core_h5p', '', null,
-              '', '')->out();
+            $url = \moodle_url::make_pluginfile_url($context->id, 'core_h5p', '', null,
+                '', '')->out();
 
-          $this->core = new core($this->get_framework(), $fs, $url, $language, true);
+            $this->core = new core($this->get_framework(), $fs, $url, $language, true);
         }
 
         return $this->core;
index 4905ab2..f043e93 100644 (file)
@@ -1518,44 +1518,6 @@ class framework implements \H5PFrameworkInterface {
         return !empty($results);
     }
 
-    /**
-     * Get type of h5p instance
-     *
-     * @param string|null $type Type of h5p instance to get
-     * @return \H5PContentValidator|\H5PCore|\H5PStorage|\H5PValidator|\core_h5p\framework|\H5peditor
-     */
-    public static function instance($type = null) {
-        global $CFG;
-        static $interface, $core;
-
-        if (!isset($interface)) {
-            $interface = new \core_h5p\framework();
-            $fs = new \core_h5p\file_storage();
-            $language = self::get_language();
-
-            $context = \context_system::instance();
-            $url = "{$CFG->wwwroot}/pluginfile.php/{$context->id}/core_h5p";
-
-            require_once("{$CFG->libdir}/h5p/h5p.classes.php");
-            $core = new core($interface, $fs, $url, $language, true);
-            $core->aggregateAssets = !(isset($CFG->core_h5p_aggregate_assets) && $CFG->core_h5p_aggregate_assets === '0');
-        }
-
-        switch ($type) {
-            case 'validator':
-                return new \H5PValidator($interface, $core);
-            case 'storage':
-                return new \H5PStorage($interface, $core);
-            case 'contentvalidator':
-                return new \H5PContentValidator($interface, $core);
-            case 'interface':
-                return $interface;
-            case 'core':
-            default:
-                return $core;
-        }
-    }
-
     /**
      * Get current H5P language code.
      *
index dc25c07..832b4f0 100644 (file)
@@ -26,6 +26,7 @@
 namespace core_h5p\local\tests;
 
 use core_h5p\file_storage;
+use core_h5p\autoloader;
 use file_archive;
 use zip_archive;
 
@@ -59,6 +60,8 @@ class h5p_file_storage_testcase extends \advanced_testcase {
         parent::setUp();
         $this->resetAfterTest(true);
 
+        autoloader::register();
+
         // Fetch generator.
         $generator = \testing_util::get_data_generator();
         $this->h5p_generator = $generator->get_plugin_generator('core_h5p');
index ad10a50..02427a6 100644 (file)
@@ -53,7 +53,7 @@ $string['allowcoursethemes'] = 'Allow course themes';
 $string['allowediplist'] = 'Allowed IP list';
 $string['allowedemaildomains'] = 'Allowed email domains';
 $string['allowemailaddresses'] = 'Allowed email domains';
-$string['allowemojipicker'] = 'Enable emoji picker';
+$string['allowemojipicker'] = 'Emoji picker';
 $string['allowindexing'] = 'Allow indexing by search engines';
 $string['allowindexing_desc'] = 'This determines whether to allow search engines to index your site. "Everywhere" will allow the search engines to search everywhere including login and signup pages, which means sites with Force Login turned on are still indexed. To avoid the risk of spam involved with the signup page being searchable, use "Everywhere except login and signup pages". "Nowhere" will tell search engines not to index any page. Note this is only a tag in the header of the site. It is up to the search engine to respect the tag.';
 $string['allowindexingeverywhere'] = 'Everywhere';
@@ -105,7 +105,7 @@ $string['bookmarkdeleted'] = 'Bookmark deleted.';
 $string['bookmarkthispage'] = 'Bookmark this page';
 $string['cachejs'] = 'Cache Javascript';
 $string['cachejs_help'] = 'Javascript caching and compression greatly improves page loading performance. it is strongly recommended for production sites. Developers will probably want to disable this feature.';
-$string['cachetemplates'] = 'Cache Templates';
+$string['cachetemplates'] = 'Cache templates';
 $string['cachetemplates_help'] = 'Template caching will improve page loading performance and is strongly recommended for production sites. Developers will probably want to disable this feature.';
 $string['calendarexportsalt'] = 'Calendar export salt';
 $string['calendarsettings'] = 'Calendar';
@@ -154,8 +154,8 @@ $string['configallowcohortthemes'] = 'If you enable this, then themes can be set
 $string['configallowcoursethemes'] = 'If you enable this, then courses will be allowed to set their own themes.  Course themes override all other theme choices (site, user, or session themes)';
 $string['configallowedemaildomains'] = 'List email domains that are allowed to be disclosed in the "From" section of outgoing email. The default of "Empty" will use the No-reply address for all outgoing email. The use of wildcards is allowed e.g. *.example.com will allow emails sent from any subdomain of example.com, but not example.com itself. This will require separate entry.';
 $string['configallowemailaddresses'] = 'To restrict new email addresses to particular domains, list them here separated by spaces. All other domains will be rejected. To allow subdomains, add the domain with a preceding \'.\'. To allow a root domain together with its subdomains, add the domain twice - once with a preceding \'.\' and once without e.g. .ourcollege.edu.au ourcollege.edu.au.';
-$string['configallowemojipicker'] = 'If enabled, the emoji picker will be available within the site';
-$string['configallowemojipickerincompatible'] = 'Your current database configuration does not properly support emojis. In order to enable the emoji picker you will need to <a href="https://docs.moodle.org/37/en/MySQL_full_unicode_support">upgrade your database for full unicode support</a>.';
+$string['configallowemojipicker'] = 'The emoji picker enables users to select emojis, such as smilies, to add to messages.';
+$string['configallowemojipickerincompatible'] = 'Your current database configuration does not properly support emojis. In order to enable the emoji picker you will need to <a href="https://docs.moodle.org/en/MySQL_full_unicode_support">upgrade your database for full unicode support</a>.';
 $string['configallowguestmymoodle'] = 'If enabled guests can access Dashboard, otherwise guests are redirected to the site front page.';
 $string['configallowobjectembed'] = 'As a default security measure, normal users are not allowed to embed multimedia (like Flash) within texts using explicit EMBED and OBJECT tags in their HTML (although it can still be done safely using the mediaplugins filter).  If you wish to allow these tags then enable this option.';
 $string['configallowoverride'] = 'You can allow people with the roles on the left side to override some of the column roles';
@@ -206,7 +206,7 @@ $string['configdebugdisplay'] = 'Set to on, the error reporting will go to the H
 $string['configdebugpageinfo'] = 'Enable if you want page information printed in page footer.';
 $string['configdebugvalidators'] = 'Enable if you want to have links to external validator servers in page footer. You may need to create new user with username <em>w3cvalidator</em>, and enable guest access. These changes may allow unauthorized access to server, do not enable on production sites!';
 $string['configdefaulthomepage'] = 'This determines the first link in the navigation for logged-in users.';
-$string['configdefaultrequestcategory'] = 'Courses requested by users will be placed in this category if the category is not specified.';
+$string['configdefaultrequestcategory'] = 'Courses requested by users with the capability to request new courses in the system context will be placed in this category unless users are able to select a different category.';
 $string['configdefaultrequestedcategory'] = 'Default category to put courses that were requested into, if they\'re approved.';
 $string['configdefaultuserroleid'] = 'All logged in users will be given the capabilities of the role you specify here, at the site level, in ADDITION to any other roles they may have been given.  The default is the Authenticated user role.  Note that this will not conflict with other roles they have unless you prohibit capabilities, it just ensures that all users have capabilities that are not assignable at the course level (eg post blog entries, manage own calendar, etc).';
 $string['configdeleteincompleteusers'] = 'After this period, any account without the first name, last name or email field filled in is deleted.';
@@ -228,7 +228,7 @@ $string['configemailfromvia'] = 'Add via information in the "From" section of ou
 $string['configemailsubjectprefix'] = 'Text to be prefixed to the subject line of all outgoing mail.';
 $string['configenablecalendarexport'] = 'Enable exporting or subscribing to calendars.';
 $string['configenablecomments'] = 'Enable comments';
-$string['configenablecourserequests'] = 'Enable course request functionality. Users with capability to request courses but without capability to create courses will be able to request courses.';
+$string['configenablecourserequests'] = 'If enabled, users with the capability to request new courses (moodle/course:request) will have the option to request a course. This capability is not allowed for any of the default roles. It may be applied in the system or category context.';
 $string['configenablemobilewebservice'] = 'Enable mobile service for the official Moodle app or other app requesting it. For more information, read the {$a}';
 $string['configenablerssfeeds'] = 'If enabled, RSS feeds are generated by various features across the site, such as blogs, forums, database activities and glossaries. Note that RSS feeds also need to be enabled for the particular activity modules.';
 $string['configenablerssfeedsdisabled'] = 'It is not available because RSS feeds are disabled in all the Site. To enable them, go to the Variables settings under Admin Configuration.';
@@ -273,7 +273,7 @@ $string['configlanglist'] = 'If left blank, all languages installed on the site
 $string['configlangmenu'] = 'Choose whether or not you want to display the general-purpose language menu on the home page, login page etc.  This does not affect the user\'s ability to set the preferred language in their own profile.';
 $string['configlatinexcelexport'] = 'Choose the encoding for Excel exports.';
 $string['configlocale'] = 'Choose a sitewide locale - this will override the format and language of dates for all language packs (though names of days in calendar are not affected). You need to have this locale data installed on your operating system (eg for linux en_US.UTF-8 or es_ES.UTF-8). In most cases this field should be left blank.';
-$string['configlockrequestcategory'] = 'Only allow course requests in the default course request category. This is a legacy setting, it is better not to use it but instead assign capability to request courses in the appropriate course category context';
+$string['configlockrequestcategory'] = 'If enabled, users with the capability to request new courses in the system context will not be able to select a category in the request a new course form. An alternative way of restricting users to requesting a new course in just one category is to apply the capability to request new courses in the category context.';
 $string['configloglifetime'] = 'This specifies the length of time you want to keep logs about user activity.  Logs that are older than this age are automatically deleted.  It is best to keep logs as long as possible, in case you need them, but if you have a very busy server and are experiencing performance problems, then you may want to lower the log lifetime. Values lower than 30 are not recommended because statistics may not work properly.';
 $string['configlookahead'] = 'Days to look ahead';
 $string['configmailnewline'] = 'Newline characters used in mail messages. CRLF is required according to RFC 822bis, some mail servers do automatic conversion from LF to CRLF, other mail servers do incorrect conversion from CRLF to CRCRLF, yet others reject mails with bare LF (qmail for example). Try changing this setting if you are having problems with undelivered emails or double newlines.';
@@ -719,7 +719,7 @@ $string['lockoutthreshold'] = 'Account lockout threshold';
 $string['lockoutthreshold_desc'] = 'Select number of failed login attempts that result in account lockout. This feature may be abused in denial of service attacks.';
 $string['lockoutwindow'] = 'Account lockout observation window';
 $string['lockoutwindow_desc'] = 'Observation time for lockout threshold, if there are no failed attempts the threshold counter is reset after this time.';
-$string['lockrequestcategory'] = 'Lock category for the course requests';
+$string['lockrequestcategory'] = 'Prevent category selection';
 $string['log'] = 'Logs';
 $string['logguests'] = 'Log guest access';
 $string['logguests_help'] = 'This setting enables logging of actions by guest account and not logged in users. High profile sites may want to disable this logging for performance reasons. It is recommended to keep this setting enabled on production sites.';
index 0b395a7..5caae68 100644 (file)
@@ -51,7 +51,7 @@ $string['auth_remove_keep'] = 'Keep internal';
 $string['auth_remove_suspend'] = 'Suspend internal';
 $string['auth_remove_user'] = 'Specify what to do with internal user account during mass synchronisation when user was removed from external source. Only suspended users are automatically restored if they reappear in the external source.';
 $string['auth_remove_user_key'] = 'Removed ext user';
-$string['auth_sync_suspended']  = 'When enabled, the suspended attribute will be used to update the local user account\'s suspension status.';
+$string['auth_sync_suspended']  = 'If enabled, the suspended attribute will be used to update the local user account\'s suspension status.';
 $string['auth_sync_suspended_key'] = 'Synchronise local user suspension status';
 $string['auth_sync_script'] = 'User account synchronisation';
 $string['auth_updatelocal'] = 'Update local';
index c92b544..e6620e2 100644 (file)
@@ -28,7 +28,7 @@ $string['and'] = 'and';
 $string['condition_group'] = 'Restriction set';
 $string['condition_group_info'] = 'Add a set of nested restrictions to apply complex logic.';
 $string['enableavailability'] = 'Enable restricted access';
-$string['enableavailability_desc'] = 'When enabled, this lets you set conditions (based on date, grade, or completion) that control whether an activity or resource can be accessed.';
+$string['enableavailability_desc'] = 'If enabled, conditions (based on date, grade, completion etc.) may be set to control whether an activity or resource can be accessed.';
 $string['error_list_nochildren'] = 'Restriction sets should contain at least one condition.';
 $string['hidden_marker'] = '(hidden otherwise)';
 $string['hidden_individual'] = 'Hidden entirely if user does not meet this condition';
index 6ff0088..3d3c4de 100644 (file)
@@ -176,7 +176,7 @@ $string['claimid'] = 'Claim URL';
 $string['clearsettings'] = 'Clear settings';
 $string['completionnotenabled'] = 'Course completion is not enabled for this course, so it cannot be included in badge criteria. Course completion may be enabled in the course settings.';
 $string['completioninfo'] = 'This badge was issued for completing: ';
-$string['configenablebadges'] = 'When enabled, this feature lets you create badges and award them to site users.';
+$string['configenablebadges'] = 'If enabled, this feature lets you create badges and award them to site users.';
 $string['configuremessage'] = 'Badge message';
 $string['connect'] = 'Connect';
 $string['connected'] = 'Connected';
index cae7b1e..786835c 100644 (file)
@@ -112,7 +112,7 @@ $string['completionupdated'] = 'Updated completion for activity <b>{$a}</b>';
 $string['completionview'] = 'Require view';
 $string['completionview_desc'] = 'Student must view this activity to complete it';
 $string['configcompletiondefault'] = 'The default setting for completion tracking when creating new activities.';
-$string['configenablecompletion'] = 'When enabled, this lets you turn on completion tracking (progress) features at course level.';
+$string['configenablecompletion'] = 'If enabled, course and activity completion conditions may be set. Setting activity completion conditions is recommended so that meaningful data is displayed for users in their course overview on the Dashboard.';
 $string['confirmselfcompletion'] = 'Confirm self completion';
 $string['courseaggregation'] = 'Condition requires';
 $string['courseaggregation_all'] = 'ALL selected courses to be completed';
index 636cf13..0d7e186 100644 (file)
@@ -48,10 +48,10 @@ $string['nocoursesections'] = 'No course sections';
 $string['nocoursestudents'] = 'No students';
 $string['noaccesssincestartinfomessage'] = 'Hi {$a->userfirstname},
 
-</br><br/>Students in {$a->coursename} have never accessed the course.';
+</br><br/>A number of students in {$a->coursename} have never accessed the course.';
 $string['norecentaccessesinfomessage'] = 'Hi {$a->userfirstname},
 
-</br><br/>Students in {$a->coursename} have not accessed the course recently.';
+</br><br/>A number of students in {$a->coursename} have not accessed the course recently.';
 $string['noteachinginfomessage'] = 'Hi {$a->userfirstname},
 
 </br><br/>Courses with start dates in the next week have been identified as having no teacher or student enrolments.';
@@ -75,7 +75,7 @@ $string['target:coursegradetopass_help'] = 'This target describes whether the st
 $string['target:noaccesssincecoursestart'] = 'Students who have not accessed the course yet';
 $string['target:noaccesssincecoursestart_help'] = 'This target describes students who never accessed a course they are enrolled in.';
 $string['target:norecentaccesses'] = 'Students who have not accessed the course recently';
-$string['target:norecentaccesses_help'] = 'This target describes students who have not accessed a course recently.';
+$string['target:norecentaccesses_help'] = 'This target identifies students who have not accessed a course they are enrolled in within the set analysis interval (by default the past month).';
 $string['target:noteachingactivity'] = 'Courses at risk of not starting';
 $string['target:noteachingactivity_help'] = 'This target describes whether courses due to start in the coming week will have teaching activity.';
 $string['targetlabelstudentcompletionno'] = 'Student who is likely to meet the course completion conditions';
index 9d06480..a077113 100644 (file)
@@ -144,6 +144,7 @@ $string['inputdatadirectory'] = 'Data directory:';
 $string['inputwebadress'] = 'Web address :';
 $string['inputwebdirectory'] = 'Moodle directory:';
 $string['installation'] = 'Installation';
+$string['invaliddbprefix'] = 'Invalid prefix. The prefix can only consist of lower case letters and underscore.';
 $string['langdownloaderror'] = 'Unfortunately the language "{$a}" could not be downloaded. The installation process will continue in English.';
 $string['langdownloadok'] = 'The language "{$a}" was installed successfully. The installation process will continue in this language.';
 $string['memorylimit'] = 'Memory limit';
index ef4c7ce..96dcb80 100644 (file)
@@ -724,7 +724,7 @@ $string['emailresetconfirmationsubject'] = '{$a}: Password reset request';
 $string['emailresetconfirmsent'] = 'An email has been sent to your address at <b>{$a}</b>.
 <br />It contains easy instructions to confirm and complete this password change.
 If you continue to have difficulty, contact the site administrator.';
-$string['emailstop'] = 'Email stop';
+$string['emailstop'] = 'Disable notifications';
 $string['emailtoprivatefiles'] = 'You can also e-mail files as attachments straight to your private files space. Simply attach your files to an e-mail and send it to {$a}';
 $string['emailtoprivatefilesdenied'] = 'Your administrator has disabled the option to upload your own private files.';
 $string['emailvia'] = '{$a->name} (via {$a->siteshortname})';
@@ -1057,7 +1057,7 @@ $string['indicator:accessesbeforestart_help'] = 'This indicator reflects if the
 $string['indicator:activitiesdue'] = 'Activities due';
 $string['indicator:activitiesdue_help'] = 'The user has activities due.';
 $string['indicator:anycourseaccess'] = 'Any course access';
-$string['indicator:anycourseaccess_help'] = 'This indicator reflects any accesses to the provided course for the provided user.';
+$string['indicator:anycourseaccess_help'] = 'This indicator reflects access to any course that the user is enrolled in.';
 $string['indicator:anywrite'] = 'Any write action';
 $string['indicator:anywrite_help'] = 'This indicator represents any write (submit) action taken by the student.';
 $string['indicator:anywriteincourse'] = 'Any write action in the course';
@@ -1816,8 +1816,8 @@ $string['separateandconnected'] = 'Separate and Connected ways of knowing';
 $string['separateandconnectedinfo'] = 'The scale based on the theory of separate and connected knowing. This theory describes two different ways that we can evaluate and learn about the things we see and hear.<ul><li><strong>Separate knowers</strong> remain as objective as possible without including feelings and emotions. In a discussion with other people, they like to defend their own ideas, using logic to find holes in opponent\'s ideas.</li><li><strong>Connected knowers</strong> are more sensitive to other people. They are skilled at empathy and tend to listen and ask questions until they feel they can connect and "understand things from their point of view". They learn by trying to share the experiences that led to the knowledge they find in other people.</li></ul>';
 $string['servererror'] = 'An error occurred whilst communicating with the server';
 $string['serverlocaltime'] = 'Server\'s local time';
-$string['sessionforceclean'] = 'As a security precaution, user-generated scripts have been disabled within this session';
-$string['sessiontimeoutsoon'] = 'Your session is about to timeout. Do you want to extend your current session?';
+$string['sessionforceclean'] = 'As a security precaution, user-generated scripts have been disabled within this session.';
+$string['sessiontimeoutsoon'] = 'Your session is about to time out. Do you want to extend your current session?';
 $string['setcategorytheme'] = 'Set category theme';
 $string['setpassword'] = 'Set password';
 $string['setpasswordinstructions'] = 'Please enter your new password below, then save changes.';
index 07d9407..d9ec365 100644 (file)
@@ -260,8 +260,8 @@ $string['grade:unlock'] = 'Unlock grades or items';
 $string['grade:view'] = 'View own grades';
 $string['grade:viewall'] = 'View grades of other users';
 $string['grade:viewhidden'] = 'View hidden grades for owner';
-$string['h5p:deploy'] = 'Allow to deploy H5P content';
-$string['h5p:setdisplayoptions'] = 'Set the display options to an H5P content';
+$string['h5p:deploy'] = 'Deploy H5P content';
+$string['h5p:setdisplayoptions'] = 'Set H5P display options';
 $string['highlightedcellsshowdefault'] = 'The permissions highlighted in the table below are the defaults for the role archetype currently selected above.';
 $string['highlightedcellsshowinherit'] = 'The highlighted cells in the table below show the permission (if any) that will be inherited. Apart from the capabilities whose permission you actually want to alter, you should leave everything set to Inherit.';
 $string['checkglobalpermissions'] = 'Check system permissions';
index 56777bb..aefc750 100644 (file)
@@ -47,7 +47,7 @@ $string['privacy:metadata:description'] = 'General details about this user.';
 $string['privacy:metadata:devicename'] = 'The device name, occam or iPhone etc..';
 $string['privacy:metadata:devicetablesummary'] = 'This table stores user\'s mobile devices information in order to send PUSH notifications';
 $string['privacy:metadata:email'] = 'An email address for contact.';
-$string['privacy:metadata:emailstop'] = 'A preference to stop email being sent to the user.';
+$string['privacy:metadata:emailstop'] = 'A preference to disable notifications from being sent to the user.';
 $string['privacy:metadata:fieldid'] = 'The ID relating to the custom user field.';
 $string['privacy:metadata:filelink'] = 'There are multiple different files for the user stored in the files table.';
 $string['privacy:metadata:firstaccess'] = 'The time that this user first accessed the site.';
index f159d5a..daf8da5 100644 (file)
@@ -503,6 +503,9 @@ class csv_export_writer {
      * Download the csv file.
      */
     public function download_file() {
+        // If this file was requested from a form, then mark download as complete.
+        \core_form\util::form_download_complete();
+
         $this->send_header();
         $this->print_csv_data();
         exit;
index 4412e96..5842e53 100644 (file)
@@ -54,6 +54,9 @@ function download_as_dataformat($filename, $dataformat, $columns, $iterator, $ca
     // 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.
index 0076942..15adc29 100644 (file)
@@ -261,6 +261,14 @@ $functions = array(
         'type' => 'read',
         'services' => array(MOODLE_OFFICIAL_MOBILE_SERVICE),
     ),
+    'core_calendar_get_timestamps' => [
+        'classname'     => 'core_calendar_external',
+        'methodname'    => 'get_timestamps',
+        'description'   => 'Fetch unix timestamps for given date times.',
+        'classpath' => 'calendar/externallib.php',
+        'type'          => 'read',
+        'ajax'          => true,
+    ],
     'core_cohort_add_cohort_members' => array(
         'classname' => 'core_cohort_external',
         'methodname' => 'add_cohort_members',
diff --git a/lib/editor/atto/plugins/emojipicker/classes/privacy/provider.php b/lib/editor/atto/plugins/emojipicker/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..308bf19
--- /dev/null
@@ -0,0 +1,46 @@
+<?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/>.
+
+/**
+ * Privacy Subsystem implementation for atto_emojipicker.
+ *
+ * @package    atto_emojipicker
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace atto_emojipicker\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for atto_emojipicker implementing null_provider.
+ *
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
diff --git a/lib/editor/atto/plugins/emojipicker/lang/en/atto_emojipicker.php b/lib/editor/atto/plugins/emojipicker/lang/en/atto_emojipicker.php
new file mode 100644 (file)
index 0000000..50bee0f
--- /dev/null
@@ -0,0 +1,27 @@
+<?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/>.
+
+/**
+ * Strings for component 'atto_emojipicker', language 'en'.
+ *
+ * @package    atto_emojipicker
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['emojipicker'] = 'Emoji picker';
+$string['pluginname'] = 'Emoji picker';
+$string['privacy:metadata'] = 'The atto_emojipicker plugin does not store any personal data.';
diff --git a/lib/editor/atto/plugins/emojipicker/lib.php b/lib/editor/atto/plugins/emojipicker/lib.php
new file mode 100644 (file)
index 0000000..2c50535
--- /dev/null
@@ -0,0 +1,48 @@
+<?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/>.
+
+/**
+ * Atto text editor emoji picker plugin lib.
+ *
+ * @package    atto_emojipicker
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Initialise the strings required for JS.
+ *
+ * @return void
+ */
+function atto_emojipicker_strings_for_js() {
+    global $PAGE;
+    $PAGE->requires->strings_for_js(['emojipicker'], 'atto_emojipicker');
+}
+
+/**
+ * Sends the parameters to JS module.
+ *
+ * @return array
+ */
+function atto_emojipicker_params_for_js() {
+    global $CFG;
+
+    return [
+        'disabled' => empty($CFG->allowemojipicker) ? true : false
+    ];
+}
diff --git a/lib/editor/atto/plugins/emojipicker/styles.css b/lib/editor/atto/plugins/emojipicker/styles.css
new file mode 100644 (file)
index 0000000..34278fe
--- /dev/null
@@ -0,0 +1,8 @@
+.emoji-picker-dialogue.moodle-dialogue-base .moodle-dialogue .moodle-dialogue-bd {
+    padding: 0;
+}
+
+.emoji-picker-dialogue .emoji-picker {
+    box-shadow: none !important; /* stylelint-disable-line declaration-no-important */
+    border: 0;
+}
\ No newline at end of file
diff --git a/lib/editor/atto/plugins/emojipicker/version.php b/lib/editor/atto/plugins/emojipicker/version.php
new file mode 100644 (file)
index 0000000..c569f25
--- /dev/null
@@ -0,0 +1,29 @@
+<?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/>.
+
+/**
+ * Atto text editor emoji picker plugin version file.
+ *
+ * @package    atto_emojipicker
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->version   = 2019101700;        // The current plugin version (Date: YYYYMMDDXX).
+$plugin->requires  = 2019101200;        // Requires this Moodle version.
+$plugin->component = 'atto_emojipicker';  // Full name of the plugin (used for diagnostics).
diff --git a/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-debug.js b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-debug.js
new file mode 100644 (file)
index 0000000..059c96a
Binary files /dev/null and b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-debug.js differ
diff --git a/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-min.js b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-min.js
new file mode 100644 (file)
index 0000000..e4293f0
Binary files /dev/null and b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button-min.js differ
diff --git a/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button.js b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button.js
new file mode 100644 (file)
index 0000000..059c96a
Binary files /dev/null and b/lib/editor/atto/plugins/emojipicker/yui/build/moodle-atto_emojipicker-button/moodle-atto_emojipicker-button.js differ
diff --git a/lib/editor/atto/plugins/emojipicker/yui/src/button/build.json b/lib/editor/atto/plugins/emojipicker/yui/src/button/build.json
new file mode 100644 (file)
index 0000000..0a50ee9
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "name": "moodle-atto_emojipicker-button",
+  "builds": {
+    "moodle-atto_emojipicker-button": {
+      "jsfiles": [
+        "button.js"
+      ]
+    }
+  }
+}
diff --git a/lib/editor/atto/plugins/emojipicker/yui/src/button/js/button.js b/lib/editor/atto/plugins/emojipicker/yui/src/button/js/button.js
new file mode 100644 (file)
index 0000000..be0c29e
--- /dev/null
@@ -0,0 +1,135 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/*
+ * @package    atto_emojipicker
+ * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * @module moodle-atto_emojipicker-button
+ */
+var COMPONENTNAME = 'atto_emojipicker';
+
+/**
+ * Atto text editor emoji picker plugin.
+ *
+ * @namespace M.atto_emojipicker
+ * @class button
+ * @extends M.editor_atto.EditorPlugin
+ */
+
+Y.namespace('M.atto_emojipicker').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
+
+    /**
+     * A reference to the current selection at the time that the dialogue
+     * was opened.
+     *
+     * @property _currentSelection
+     * @type Range
+     * @private
+     */
+    _currentSelection: null,
+
+    initializer: function() {
+        if (this.get('disabled')) {
+            return;
+        }
+
+        this.addButton({
+            icon: 'e/emoticons',
+            callback: this._displayDialogue
+        });
+    },
+
+    /**
+     * Display the emoji picker.
+     *
+     * @method _displayDialogue
+     * @private
+     */
+    _displayDialogue: function() {
+        // Store the current selection.
+        this._currentSelection = this.get('host').getSelection();
+        if (this._currentSelection === false) {
+            return;
+        }
+
+        var dialogue = this.getDialogue({
+            headerContent: M.util.get_string('emojipicker', COMPONENTNAME),
+            width: 'auto',
+            focusAfterHide: true,
+            additionalBaseClass: 'emoji-picker-dialogue'
+        }, true);
+
+        // Set the dialogue content, and then show the dialogue.
+        dialogue.set('bodyContent', this._getDialogueContent())
+                .show();
+    },
+
+    /**
+     * Insert the emoticon.
+     *
+     * @method _insertEmote
+     * @param {String} emoji
+     * @private
+     */
+    _insertEmoji: function(emoji) {
+        var host = this.get('host');
+
+        // Hide the dialogue.
+        this.getDialogue({
+            focusAfterHide: null
+        }).hide();
+
+        // Focus on the previous selection.
+        host.setSelection(this._currentSelection);
+
+        // And add the character.
+        host.insertContentAtFocusPoint(emoji);
+
+        this.markUpdated();
+    },
+
+    /**
+     * Generates the content of the dialogue, attaching event listeners to
+     * the content.
+     *
+     * @method _getDialogueContent
+     * @return {Node} Node containing the dialogue content
+     * @private
+     */
+    _getDialogueContent: function() {
+        var wrapper = Y.Node.create('<div></div>');
+
+        require(['core/templates', 'core/emoji/picker'], function(Templates, initialiseEmojiPicker) {
+                Templates.render('core/emoji/picker', {}).then(function(html) {
+                    var domNode = wrapper.getDOMNode();
+                    domNode.innerHTML = html;
+                    initialiseEmojiPicker(domNode, this._insertEmoji.bind(this));
+                    this.getDialogue().centerDialogue();
+                }.bind(this));
+        }.bind(this));
+
+        return wrapper;
+    }
+}, {
+    ATTRS: {
+        disabled: {
+            value: true
+        }
+    }
+});
diff --git a/lib/editor/atto/plugins/emojipicker/yui/src/button/meta/button.json b/lib/editor/atto/plugins/emojipicker/yui/src/button/meta/button.json
new file mode 100644 (file)
index 0000000..9ea3e81
--- /dev/null
@@ -0,0 +1,7 @@
+{
+    "moodle-atto_emojipicker-button": {
+        "requires": [
+            "moodle-editor_atto-plugin"
+        ]
+    }
+}
index 2343281..382ba5f 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-$string['enterurl'] = 'Enter URL';
+$string['browserepositories'] = 'Browse repositories...';
+$string['copyrightbutton'] = 'Copyright button';
+$string['downloadbutton'] = 'Allow download';
+$string['either'] = 'Either';
+$string['embedbutton'] = 'Embed button';
+$string['enterurl'] = 'URL or Embed code';
+$string['h5p:addembed'] = 'Add embedded H5P';
+$string['h5pfile'] = 'H5P file upload';
+$string['h5poptions'] = 'H5P options';
 $string['h5pproperties'] = 'H5P properties';
+$string['h5purl'] = 'H5P URL';
 $string['invalidh5purl'] = 'Invalid URL';
+$string['instructions'] = 'You can insert H5P content by <strong>either</strong> entering a URL or embed code from an external H5P site <strong>or</strong> by uploading an H5P file.';
+$string['noh5pcontent'] = 'No H5P content added';
 $string['pluginname'] = 'Insert H5P';
 $string['privacy:metadata'] = 'The atto_h5p plugin does not store any personal data.';
-$string['h5p:addembed'] = 'Add embedded H5P';
-$string['saveh5p'] = 'Save H5P';
\ No newline at end of file
+$string['or'] = 'or';
\ No newline at end of file
index d07ad2a..60b34e7 100644 (file)
@@ -36,14 +36,23 @@ function atto_h5p_params_for_js($elementid, $options, $fpoptions) {
     if (!$context) {
         $context = context_system::instance();
     }
+
     $addembed = has_capability('atto/h5p:addembed', $context);
+    $upload = has_capability('moodle/h5p:deploy', $context);
 
     $allowedmethods = 'none';
-    if ($addembed) {
+    if ($addembed && $upload) {
+        $allowedmethods = 'both';
+    } else if ($addembed) {
         $allowedmethods = 'embed';
+    } else if ($upload) {
+        $allowedmethods = 'upload';
     }
 
-    $params = ['allowedmethods' => $allowedmethods];
+    $params = [
+        'allowedmethods' => $allowedmethods,
+        'storeinrepo' => true
+    ];
     return $params;
 }
 
@@ -54,10 +63,21 @@ function atto_h5p_strings_for_js() {
     global $PAGE;
 
     $strings = array(
-        'saveh5p',
-        'h5pproperties',
+        'browserepositories',
+        'copyrightbutton',
+        'downloadbutton',
+        'instructions',
+        'either',
+        'embedbutton',
         'enterurl',
-        'invalidh5purl'
+        'h5pfile',
+        'h5poptions',
+        'h5pproperties',
+        'h5purl',
+        'invalidh5purl',
+        'noh5pcontent',
+        'or',
+        'pluginname'
     );
 
     $PAGE->requires->strings_for_js($strings, 'atto_h5p');
diff --git a/lib/editor/atto/plugins/h5p/pix/icon-white.png b/lib/editor/atto/plugins/h5p/pix/icon-white.png
new file mode 100644 (file)
index 0000000..d2ac8b2
Binary files /dev/null and b/lib/editor/atto/plugins/h5p/pix/icon-white.png differ
diff --git a/lib/editor/atto/plugins/h5p/pix/icon-white.svg b/lib/editor/atto/plugins/h5p/pix/icon-white.svg
new file mode 100644 (file)
index 0000000..3f3ba11
--- /dev/null
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+        viewBox="0 0 345 150" style="enable-background:new 0 0 345 150;" xml:space="preserve">
+<style type="text/css">
+       .st0{fill:#FFFFFF;}
+</style>
+<g>
+       <path class="st0" d="M325.7,14.7C317.6,6.9,305.3,3,289,3h-43.5H234v31h-66l-5.4,22.2c4.5-2.1,10.9-4.2,15.3-5.3
+               c4.4-1.1,8.8-0.9,13.1-0.9c14.6,0,26.5,4.5,35.6,13.3c9.1,8.8,13.6,20,13.6,33.4c0,9.4-2.3,18.5-7,27.2s-11.3,15.4-19.9,20
+               c-3.1,1.6-6.5,3.1-10.2,4.1h42.4H259V95h25c18.2,0,31.7-4.2,40.6-12.5s13.3-19.9,13.3-34.6C337.9,33.6,333.8,22.5,325.7,14.7z
+                M288.7,60.6c-3.5,3-9.6,4.4-18.3,4.4H259V33h13.2c8.4,0,14.2,1.5,17.2,4.7c3.1,3.2,4.6,6.9,4.6,11.5
+               C294,53.9,292.2,57.6,288.7,60.6z"/>
+       <path class="st0" d="M176.5,76.3c-7.9,0-14.7,4.6-18,11.2L119,81.9L136.8,3h-23.6H101v62H51V3H7v145h44V95h50v53h12.2h42
+               c-6.7-2-12.5-4.6-17.2-8.1c-4.8-3.6-8.7-7.7-11.7-12.3c-3-4.6-5.3-9.7-7.3-16.5l39.6-5.7c3.3,6.6,10.1,11.1,17.9,11.1
+               c11.1,0,20.1-9,20.1-20.1S187.5,76.3,176.5,76.3z"/>
+</g>
+</svg>
index 2aaf327..7856f9e 100644 (file)
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="utf-8"?>
 <!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
 <svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
-        viewBox="0 0 345 150" style="enable-background:new 0 0 345 150;" xml:space="preserve" preserveAspectRatio="xMinYMid meet">
+        viewBox="0 0 345 150" style="enable-background:new 0 0 345 150;" xml:space="preserve">
 <g>
        <path d="M325.7,14.7C317.6,6.9,305.3,3,289,3h-43.5H234v31h-66l-5.4,22.2c4.5-2.1,10.9-4.2,15.3-5.3c4.4-1.1,8.8-0.9,13.1-0.9
                c14.6,0,26.5,4.5,35.6,13.3c9.1,8.8,13.6,20,13.6,33.4c0,9.4-2.3,18.5-7,27.2s-11.3,15.4-19.9,20c-3.1,1.6-6.5,3.1-10.2,4.1h42.4
index c808561..4fecf84 100644 (file)
@@ -1,17 +1,20 @@
 .attoh5poverlay {
     display: none;
 }
-.editor_atto_content_wrap .attoh5poverlay {
-    display: block;
-    position: absolute;
-    cursor: pointer;
-    top: 0;
-    left: 0;
-    bottom: 0;
-    width: 100%;
-    height: 100%;
-    background: url([[pix:atto_h5p|icon]]) center center / 100px auto no-repeat #adb5bd;
+.attoh5pinstructions {
+    max-width: 500px;
 }
-.h5p-embed-placeholder .attoh5poverlay + br {
+.editor_atto_content_wrap .h5p-placeholder + br {
     display: none;
+}
+.editor_atto_content_wrap .h5p-placeholder {
+    color: #6c757d;
+    width: 100%;
+    word-break: break-all;
+    height: 260px;
+    cursor: pointer;
+    background: url([[pix:atto_h5p|icon-white]]) center center / 100px auto no-repeat #6c757d;
+}
+.atto_h5p_button .icon {
+    width: 24px;
 }
\ No newline at end of file
index 1d2c920..467e74e 100644 (file)
@@ -1,4 +1,4 @@
-@editor @editor_atto @atto @atto_h5p @_switch_iframe
+@editor @editor_atto @atto @atto_h5p @_file_upload @_switch_iframe
 Feature: Add h5ps to Atto
   To write rich text - I need to add h5ps.
 
@@ -19,38 +19,82 @@ Feature: Add h5ps to Atto
   @javascript
   Scenario: Insert an embedded h5p
     Given I log in as "admin"
+    And I change window size to "large"
     And I am on "Course 1" course homepage
     And I follow "PageName1"
     And I navigate to "Edit settings" in current page administration
     And I click on "Insert H5P" "button" in the "#fitem_id_page" "css_element"
-    And I set the field "Enter URL" to "https://h5p.org/h5p/embed/576651"
-    And I click on "Save H5P" "button" in the "H5P properties" "dialogue"
+    And I set the field with xpath "//textarea[@data-region='h5purl']" to "https://h5p.org/h5p/embed/576651"
+    And I click on "Insert H5P" "button" in the "Insert H5P" "dialogue"
     And I wait until the page is ready
     When I click on "Save and display" "button"
-    And I switch to "h5pcontent" iframe
-    Then ".h5p-iframe" "css_element" should exist
+    Then ".h5p-placeholder" "css_element" should exist
+
+  @javascript
+  Scenario: Insert an h5p file
+    Given I log in as "admin"
+    And I change window size to "large"
+    And I follow "Manage private files..."
+    And I upload "lib/editor/atto/tests/fixtures/ipsums.h5p" file to "Files" filemanager
+    And I click on "Save changes" "button"
+    And I am on "Course 1" course homepage
+    And I follow "PageName1"
+    And I navigate to "Edit settings" in current page administration
+    And I click on "Insert H5P" "button" in the "#fitem_id_page" "css_element"
+    And I click on "Browse repositories..." "button" in the "Insert H5P" "dialogue"
+    And I click on "Private files" "link" in the ".fp-repo-area" "css_element"
+    And I click on "ipsums.h5p" "link"
+    And I click on "Select this file" "button"
+    And I click on "Insert H5P" "button" in the "Insert H5P" "dialogue"
+    And I wait until the page is ready
+    When I click on "Save and display" "button"
+    Then ".h5p-placeholder" "css_element" should exist
 
   @javascript
   Scenario: Test an invalid url
     Given I log in as "admin"
+    And I change window size to "large"
     And I am on "Course 1" course homepage
     And I follow "PageName1"
     And I navigate to "Edit settings" in current page administration
     And I click on "Insert H5P" "button" in the "#fitem_id_page" "css_element"
-    And I set the field "Enter URL" to "ftp://h5p.org/h5p/embed/576651"
-    And I click on "Save H5P" "button" in the "H5P properties" "dialogue"
+    And I set the field with xpath "//textarea[@data-region='h5purl']" to "ftp://h5p.org/h5p/embed/576651"
+    When I click on "Insert H5P" "button" in the "Insert H5P" "dialogue"
     And I wait until the page is ready
-    Then I should see "Invalid URL" in the "H5P properties" "dialogue"
+    Then I should see "Invalid URL" in the "Insert H5P" "dialogue"
+
+  @javascript
+  Scenario: No h5p capabilities
+    Given the following "permission overrides" exist:
+    | capability | permission | role | contextlevel | reference |
+    | atto/h5p:addembed | Prohibit | editingteacher | Course | C1 |
+    | moodle/h5p:deploy | Prohibit | editingteacher | Course | C1 |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "PageName1"
+    When I navigate to "Edit settings" in current page administration
+    Then "Insert H5P" "button" should not exist
 
   @javascript
   Scenario: No embed h5p capabilities
-    Given I log in as "admin"
-    And I set the following system permissions of "Teacher" role:
-    | capability | permission |
-    | atto/h5p:addembed | Prohibit |
-    And I log out
+    Given the following "permission overrides" exist:
+    | capability | permission | role | contextlevel | reference |
+    | atto/h5p:addembed | Prohibit | editingteacher | Course | C1 |
     And I log in as "teacher1"
     And I am on "Course 1" course homepage
     And I follow "PageName1"
-    And I navigate to "Edit settings" in current page administration
-    Then "Insert H5P" "button" should not exist
\ No newline at end of file
+    When I navigate to "Edit settings" in current page administration
+    And I click on "Insert H5P" "button"
+    Then I should not see "URL or Embed code" in the "Insert H5P" "dialogue"
+
+  @javascript
+  Scenario: No upload h5p capabilities
+    Given the following "permission overrides" exist:
+    | capability | permission | role | contextlevel | reference |
+    | moodle/h5p:deploy | Prohibit | editingteacher | Course | C1 |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "PageName1"
+    When I navigate to "Edit settings" in current page administration
+    And I click on "Insert H5P" "button"
+    Then I should not see "H5P file upload" in the "Insert H5P" "dialogue"
index f954471..61169c4 100644 (file)
Binary files a/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-debug.js and b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-debug.js differ
index 5661975..a31effc 100644 (file)
Binary files a/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-min.js and b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-min.js differ
index f954471..61169c4 100644 (file)
Binary files a/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button.js and b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button.js differ
index d79e190..dbe30b3 100644 (file)
  */
 
 var CSS = {
+        CONTENTWARNING: 'att_h5p_contentwarning',
+        H5PBROWSER: 'openh5pbrowser',
         INPUTALT: 'atto_h5p_altentry',
-        INPUTSUBMIT: 'atto_h5p_urlentrysubmit',
+        INPUTH5PFILE: 'atto_h5p_file',
         INPUTH5PURL: 'atto_h5p_url',
+        INPUTSUBMIT: 'atto_h5p_urlentrysubmit',
+        OPTION_DOWNLOAD_BUTTON: 'atto_h5p_option_download_button',
+        OPTION_COPYRIGHT_BUTTON: 'atto_h5p_option_copyright_button',
+        OPTION_EMBED_BUTTON: 'atto_h5p_option_embed_button',
         URLWARNING: 'atto_h5p_warning'
     },
     SELECTORS = {
-        INPUTH5PURL: '.' + CSS.INPUTH5PURL
+        CONTENTWARNING: '.' + CSS.CONTENTWARNING,
+        H5PBROWSER: '.' + CSS.H5PBROWSER,
+        INPUTH5PFILE: '.' + CSS.INPUTH5PFILE,
+        INPUTH5PURL: '.' + CSS.INPUTH5PURL,
+        INPUTSUBMIT: '.' + CSS.INPUTSUBMIT,
+        OPTION_DOWNLOAD_BUTTON: '.' + CSS.OPTION_DOWNLOAD_BUTTON,
+        OPTION_COPYRIGHT_BUTTON: '.' + CSS.OPTION_COPYRIGHT_BUTTON,
+        OPTION_EMBED_BUTTON: '.' + CSS.OPTION_EMBED_BUTTON,
+        URLWARNING: '.' + CSS.URLWARNING
     },
 
     COMPONENTNAME = 'atto_h5p',
 
     TEMPLATE = '' +
-            '<form class="atto_form">' +
+            '<form class="atto_form mform" id="{{elementid}}_atto_h5p_form">' +
+                '<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.CONTENTWARNING}}">' +
+                    '{{get_string "noh5pcontent" component}}' +
+                '</div>' +
+                '{{#if canUploadAndEmbed}}' +
+                    '<div class="mt-2 attoh5pinstructions">{{{get_string "instructions" component}}}</div>' +
+                    '<div class="my-2"><strong>{{get_string "either" component}}</strong></div>' +
+                '{{/if}}' +
+                '{{#if canEmbed}}' +
                 '<div class="mb-4">' +
                     '<label for="{{elementid}}_{{CSS.INPUTH5PURL}}">{{get_string "enterurl" component}}</label>' +
                     '<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.URLWARNING}}">' +
                         '{{get_string "invalidh5purl" component}}' +
                     '</div>' +
-                    '<input class="form-control fullwidth {{CSS.INPUTH5PURL}}" type="url" ' +
-                    'id="{{elementid}}_{{CSS.INPUTH5PURL}}" size="32"/>' +
+                    '<textarea rows="3" data-region="h5purl" class="form-control {{CSS.INPUTH5PURL}}" type="url" ' +
+                    'id="{{elementid}}_{{CSS.INPUTH5PURL}}" />{{embedURL}}</textarea>' +
+                '</div>' +
+                '{{/if}}' +
+                '{{#if canUploadAndEmbed}}' +
+                    '<div class="my-2"><strong>{{get_string "or" component}}</strong></div>' +
+                '{{/if}}' +
+                '{{#if canUpload}}' +
+                '<div class="mb-4">' +
+                    '<label for="{{elementid}}_{{CSS.H5PBROWSER}}">{{get_string "h5pfile" component}}</label>' +
+                    '<div class="input-group input-append w-100">' +
+                        '<input class="form-control {{CSS.INPUTH5PFILE}}" type="url" value="{{fileURL}}" ' +
+                        'id="{{elementid}}_{{CSS.INPUTH5PFILE}}" size="32"/>' +
+                        '<span class="input-group-append">' +
+                            '<button class="btn btn-secondary {{CSS.H5PBROWSER}}" type="button">' +
+                            '{{get_string "browserepositories" component}}</button>' +
+                        '</span>' +
+                    '</div>' +
+                    '<fieldset class="collapsible {{#if collapseOptions}}collapsed{{/if}}" id="{{elementid}}_h5poptions">' +
+                        '<legend class="ftoggler">{{get_string "h5poptions" component}}</legend>' +
+                        '<div class="fcontainer">' +
+                            '<div class="form-check">' +
+                                '<input type="checkbox" {{optionDownloadButton}} ' +
+                                'class="form-check-input {{CSS.OPTION_DOWNLOAD_BUTTON}}"' +
+                                'id="{{elementid}}_h5p-option-allow-download"/>' +
+                                '<label class="form-check-label" for="{{elementid}}_h5p-option-allow-download">' +
+                                '{{get_string "downloadbutton" component}}' +
+                                '</label>' +
+                            '</div>' +
+                            '<div class="form-check">' +
+                                '<input type="checkbox" {{optionEmbedButton}} ' +
+                                'class="form-check-input {{CSS.OPTION_EMBED_BUTTON}}" ' +
+                                    'id="{{elementid}}_h5p-option-embed-button"/>' +
+                                '<label class="form-check-label" for="{{elementid}}_h5p-option-embed-button">' +
+                                '{{get_string "embedbutton" component}}' +
+                                '</label>' +
+                            '</div>' +
+                            '<div class="form-check mb-2">' +
+                                '<input type="checkbox" {{optionCopyrightButton}} ' +
+                                'class="form-check-input {{CSS.OPTION_COPYRIGHT_BUTTON}}" ' +
+                                    'id="{{elementid}}_h5p-option-copyright-button"/>' +
+                                '<label class="form-check-label" for="{{elementid}}_h5p-option-copyright-button">' +
+                                '{{get_string "copyrightbutton" component}}' +
+                                '</label>' +
+                            '</div>' +
+                        '</div>' +
+                    '</fieldset>' +
                 '</div>' +
+                '{{/if}}' +
                 '<div class="text-center">' +
                 '<button class="btn btn-secondary {{CSS.INPUTSUBMIT}}" type="submit">' + '' +
-                    '{{get_string "saveh5p" component}}</button>' +
+                    '{{get_string "pluginname" component}}</button>' +
                 '</div>' +
             '</form>',
 
         H5PTEMPLATE = '' +
-            '<div class="position-relative h5p-embed-placeholder">' +
-                '<div class="attoh5poverlay"></div>' +
-                '<iframe id="h5pcontent" class="h5pcontent" src="{{url}}/embed" ' +
-                    'width="100%" height="637" frameborder="0"' +
-                    'allowfullscreen="{{allowfullscreen}}" allowmedia="{{allowmedia}}">' +
-                '</iframe>' +
-                '<script src="' + M.cfg.wwwroot + '/lib/h5p/js/h5p-resizer.js"' +
-                    'charset="UTF-8"></script>' +
-                '</div>' +
-            '</div>' +
-            '<p><br></p>';
+            '<div class="h5p-placeholder">' +
+                '{{{url}}}' +
+            '</div><div><br></div>';
 
 Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
     /**
@@ -93,31 +153,60 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
     _form: null,
 
     /**
-     * A reference to the currently selected H5P placeholder.
+     * A reference to the currently selected H5P div.
      *
      * @param _form
      * @type Node
      * @private
      */
-    _placeholderH5P: null,
+    _H5PDiv: null,
+
+    /**
+     * Allowed methods of adding H5P.
+     *
+     * @param _allowedmethods
+     * @type String
+     * @private
+     */
+    _allowedmethods: 'none',
 
     initializer: function() {
-        var allowedmethods = this.get('allowedmethods');
-        if (allowedmethods !== 'embed') {
+        this._allowedmethods = this.get('allowedmethods');
+        if (this._allowedmethods === 'none') {
             // Plugin not available here.
             return;
         }
-
         this.addButton({
             icon: 'icon',
             iconComponent: 'atto_h5p',
             callback: this._displayDialogue,
-            tags: '.attoh5poverlay',
+            tags: '.h5p-placeholder',
             tagMatchRequiresAll: false
         });
 
-        this.editor.delegate('dblclick', this._handleDblClick, '.attoh5poverlay', this);
-        this.editor.delegate('click', this._handleClick, '.attoh5poverlay', this);
+        this.editor.on(['keyup', 'cut'], this._clearH5P, this);
+        this.editor.delegate('dblclick', this._handleDblClick, '.h5p-placeholder', this);
+        this.editor.delegate('click', this._handleClick, '.h5p-placeholder', this);
+    },
+
+    /**
+     * Deletes elements with class .h5p-placeholder on backspace and delete.
+     *
+     * @method _clearH5P
+     * @param {EventFacade} e
+     * @private
+     */
+    _clearH5P: function(e) {
+        if (e.keyCode === 8 || e.keyCode === 46) {
+            var parentNodes = this.get('host').getSelectedNodes().get('parentNode');
+            if (parentNodes.hasOwnProperty('_nodes')) {
+                var placeholder = parentNodes.filter('.h5p-placeholder');
+                if (!placeholder.isEmpty()) {
+                    placeholder.remove();
+                }
+            }
+        }
+        e.preventDefault();
     },
 
     /**
@@ -138,9 +227,7 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
      * @private
      */
     _handleClick: function(e) {
-        var h5pplaceholder = e.target;
-
-        var selection = this.get('host').getSelectionFromNode(h5pplaceholder);
+        var selection = this.get('host').getSelectionFromNode(e.target);
         if (this.get('host').getSelection() !== selection) {
             this.get('host').setSelection(selection);
         }
@@ -155,21 +242,22 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
     _displayDialogue: function() {
         // Store the current selection.
         this._currentSelection = this.get('host').getSelection();
-        this._placeholderH5P = this._getH5PIframe();
 
         if (this._currentSelection === false) {
             return;
         }
+
+        this._getH5PDiv();
+
         var dialogue = this.getDialogue({
-            headerContent: M.util.get_string('h5pproperties', COMPONENTNAME),
+            headerContent: M.util.get_string('pluginname', COMPONENTNAME),
             width: 'auto',
-            focusAfterHide: true,
-            focusOnShowSelector: SELECTORS.INPUTH5PURL
+            focusAfterHide: true
         });
-
         // Set the dialogue content, and then show the dialogue.
         dialogue.set('bodyContent', this._getDialogueContent())
             .show();
+        M.form.shortforms({formid: this.get('host').get('elementid') + '_atto_h5p_form'});
     },
 
     /**
@@ -179,12 +267,43 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
      * @return {Node} The H5P iframe selected.
      * @private
      */
-    _getH5PIframe: function() {
-        var selectednode = this.get('host').getSelectionParentNode();
-        if (!selectednode) {
-            return;
+    _getH5PDiv: function() {
+        var selectednodes = this.get('host').getSelectedNodes();
+        var H5PDiv = null;
+        selectednodes.each(function(selNode) {
+            if (selNode.hasClass('h5p-placeholder')) {
+                H5PDiv = selNode;
+            }
+        });
+        this._H5PDiv = H5PDiv;
+    },
+
+    /**
+     * Get the H5P button permissions.
+     *
+     * @return {Object} H5P button permissions.
+     * @private
+     */
+    _getPermissions: function() {
+        var permissions = {
+            'canUpload': false,
+            'canUploadAndEbmed': false,
+            'canEmbed': false
+        };
+
+        if (this.get('host').canShowFilepicker('h5p')) {
+            if (this._allowedmethods === 'both') {
+                permissions.canUploadAndEmbed = true;
+                permissions.canUpload = true;
+            } else if (this._allowedmethods === 'upload') {
+                permissions.canUpload = true;
+            }
         }
-        return Y.one(selectednode).one('iframe.h5pcontent');
+
+        if (this._allowedmethods === 'both' || this._allowedmethods === 'embed') {
+            permissions.canEmbed = true;
+        }
+        return permissions;
     },
 
 
@@ -197,27 +316,130 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
      * @private
      */
     _getDialogueContent: function() {
+
+        var permissions = this._getPermissions();
+
+        var fileURL,
+            embedURL,
+            optionDownloadButton,
+            optionEmbedButton,
+            optionCopyrightButton,
+            collapseOptions = true;
+
+        if (this._H5PDiv) {
+            var H5PURL = this._H5PDiv.get('innerHTML');
+            var fileBaseUrl = M.cfg.wwwroot + '/draftfile.php';
+            if (fileBaseUrl == H5PURL.substring(0, fileBaseUrl.length)) {
+                fileURL = H5PURL.split("?")[0];
+
+                var parameters = H5PURL.split("?")[1];
+                if (parameters) {
+                    if (parameters.match(/export=1/)) {
+                        optionDownloadButton = 'checked';
+                        collapseOptions = false;
+                    }
+
+                    if (parameters.match(/embed=1/)) {
+                        optionEmbedButton = 'checked';
+                        collapseOptions = false;
+                    }
+
+                    if (parameters.match(/copyright=1/)) {
+                        optionCopyrightButton = 'checked';
+                        collapseOptions = false;
+                    }
+                }
+            } else {
+                embedURL = H5PURL;
+            }
+        }
+
         var template = Y.Handlebars.compile(TEMPLATE),
             content = Y.Node.create(template({
                 elementid: this.get('host').get('elementid'),
                 CSS: CSS,
-                component: COMPONENTNAME
+                component: COMPONENTNAME,
+                canUpload: permissions.canUpload,
+                canEmbed: permissions.canEmbed,
+                fileURL: fileURL,
+                embedURL: embedURL,
+                canUploadAndEmbed: permissions.canUploadAndEmbed,
+                collapseOptions: collapseOptions,
+                optionDownloadButton: optionDownloadButton,
+                optionEmbedButton: optionEmbedButton,
+                optionCopyrightButton: optionCopyrightButton
             }));
 
         this._form = content;
 
-        if (this._placeholderH5P) {
-            var oldurl = this._placeholderH5P.getAttribute('src');
-            this._form.one(SELECTORS.INPUTH5PURL).setAttribute('value', oldurl);
+        // Listen to and act on Dialogue content events.
+        this._setEventListeners();
+
+        return content;
+    },
+
+    /**
+     * Update the dialogue after an h5p was selected in the File Picker.
+     *
+     * @method _filepickerCallback
+     * @param {object} params The parameters provided by the filepicker
+     * containing information about the h5p.
+     * @private
+     */
+    _filepickerCallback: function(params) {
+        if (params.url !== '') {
+            var input = this._form.one(SELECTORS.INPUTH5PFILE);
+            input.set('value', params.url);
+            this._form.one(SELECTORS.INPUTH5PURL).set('value', '');
+            this._removeWarnings();
         }
+    },
 
-        this._form.one('.' + CSS.INPUTSUBMIT).on('click', this._setH5P, this);
+    /**
+     * Set event Listeners for Dialogue content actions.
+     *
+     * @method  _setEventListeners
+     * @private
+     */
+    _setEventListeners: function() {
+        var form = this._form;
+        var permissions = this._getPermissions();
 
-        return content;
+        form.one(SELECTORS.INPUTSUBMIT).on('click', this._setH5P, this);
+
+        if (permissions.canUpload) {
+            form.one(SELECTORS.H5PBROWSER).on('click', function() {
+                this.get('host').showFilepicker('h5p', this._filepickerCallback, this);
+            }, this);
+        }
+
+        if (permissions.canUploadAndEmbed) {
+            form.one(SELECTORS.INPUTH5PFILE).on('change', function() {
+                form.one(SELECTORS.INPUTH5PURL).set('value', '');
+                this._removeWarnings();
+            }, this);
+            form.one(SELECTORS.INPUTH5PURL).on('change', function() {
+                form.one(SELECTORS.INPUTH5PFILE).set('value', '');
+                this._removeWarnings();
+            }, this);
+        }
     },
 
     /**
-     * Set the h5p in the contenteditable.
+     * Remove warnings shown in the dialogue.
+     *
+     * @method _removeWarnings
+     * @private
+     */
+    _removeWarnings: function() {
+        var form = this._form;
+        form.one(SELECTORS.URLWARNING).setStyle('display', 'none');
+        form.one(SELECTORS.CONTENTWARNING).setStyle('display', 'none');
+    },
+
+    /**
+     * Update the h5p in the contenteditable.
+
      *
      * @method _setH5P
      * @param {EventFacade} e
@@ -227,7 +449,17 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
         var form = this._form,
             url = form.one(SELECTORS.INPUTH5PURL).get('value'),
             h5phtml,
-            host = this.get('host');
+            host = this.get('host'),
+            h5pfile,
+            permissions = this._getPermissions();
+
+        if (permissions.canEmbed) {
+            url = form.one(SELECTORS.INPUTH5PURL).get('value');
+        }
+
+        if (permissions.canUpload) {
+            h5pfile = form.one(SELECTORS.INPUTH5PFILE).get('value');
+        }
 
         e.preventDefault();
 
@@ -239,19 +471,69 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
         // Focus on the editor in preparation for inserting the h5p.
         host.focus();
 
-        // If a H5P placeholder was selected we only update the placeholder.
-        if (this._placeholderH5P) {
-            this._placeholderH5P.setAttribute('src', url);
+        // If a H5P placeholder was selected we can destroy it now.
+        if (this._H5PDiv) {
+            this._H5PDiv.remove();
+        }
 
-        } else if (url !== '') {
+        if (url !== '') {
 
             host.setSelection(this._currentSelection);
 
-            var template = Y.Handlebars.compile(H5PTEMPLATE);
-            h5phtml = template({
-                url: url,
-                allowfullscreen: 'allowfullscreen',
-                allowmedia: 'geolocation *; microphone *; camera *; midi *; encrypted-media *'
+            if (this._validEmbed(url)) {
+                var embedtemplate = Y.Handlebars.compile(H5PTEMPLATE);
+                var regex = /<iframe.*?src="(.*?)".*<\/iframe>/;
+                var src = url.match(regex)[1];
+
+                // In case a local H5P embed code is used we need get the url
+                // param form the src and decode it.
+                if (src.startsWith(M.cfg.wwwroot + '/h5p/embed.php')) {
+                    src = decodeURIComponent(src.split("url=")[1]);
+                }
+
+                h5phtml = embedtemplate({
+                    url: src
+                });
+            } else {
+                var urltemplate = Y.Handlebars.compile(H5PTEMPLATE);
+                h5phtml = urltemplate({
+                    url: url
+                });
+            }
+
+            this.get('host').insertContentAtFocusPoint(h5phtml);
+
+            this.markUpdated();
+        } else if (h5pfile !== '') {
+
+            host.setSelection(this._currentSelection);
+
+            var options = {};
+
+            if (form.one(SELECTORS.OPTION_DOWNLOAD_BUTTON).get('checked')) {
+                options['export'] = '1';
+            }
+            if (form.one(SELECTORS.OPTION_EMBED_BUTTON).get('checked')) {
+                options.embed = '1';
+            }
+            if (form.one(SELECTORS.OPTION_COPYRIGHT_BUTTON).get('checked')) {
+                options.copyright = '1';
+            }
+
+            var params = "";
+            for (var opt in options) {
+                if (params === "" && (h5pfile.indexOf("?") === -1)) {
+                    params += "?";
+                } else {
+                    params += "&amp;";
+                }
+                params += opt + "=" + options[opt];
+            }
+
+            var h5ptemplate = Y.Handlebars.compile(H5PTEMPLATE);
+
+            h5phtml = h5ptemplate({
+                url: h5pfile + params
             });
 
             this.get('host').insertContentAtFocusPoint(h5phtml);
@@ -264,12 +546,25 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
         }).hide();
     },
 
+    /**
+     * Check if this could be a h5p embed.
+     *
+     * @method _validEmbed
+     * @param {String} str
+     * @return {boolean} whether this is a iframe tag.
+     * @private
+     */
+    _validEmbed: function(str) {
+        var pattern = new RegExp('^(<iframe).*(<\\/iframe>)'); // Port and path.
+        return !!pattern.test(str);
+    },
+
     /**
      * Check if this could be a h5p URL.
      *
-     * @method _updateWarning
+     * @method _validURL
      * @param {String} str
-     * @return {boolean} whether a warning should be displayed.
+     * @return {boolean} whether this is a valid URL.
      * @private
      */
     _validURL: function(str) {
@@ -290,14 +585,36 @@ Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.Edito
     _updateWarning: function() {
         var form = this._form,
             state = true,
-            url = form.one('.' + CSS.INPUTH5PURL).get('value');
-        if (this._validURL(url)) {
-            form.one('.' + CSS.URLWARNING).setStyle('display', 'none');
-            state = false;
-        } else {
-            form.one('.' + CSS.URLWARNING).setStyle('display', 'block');
-            state = true;
+            url,
+            h5pfile,
+            permissions = this._getPermissions();
+
+
+        if (permissions.canEmbed) {
+            url = form.one(SELECTORS.INPUTH5PURL).get('value');
+            if (url !== '') {
+                if (this._validURL(url) || this._validEmbed(url)) {
+                    form.one(SELECTORS.URLWARNING).setStyle('display', 'none');
+                    state = false;
+                } else {
+                    form.one(SELECTORS.URLWARNING).setStyle('display', 'block');
+                    state = true;
+                }
+                return state;
+            }
         }
+
+        if (permissions.canUpload) {
+            h5pfile = form.one(SELECTORS.INPUTH5PFILE).get('value');
+            if (h5pfile !== '') {
+                form.one(SELECTORS.CONTENTWARNING).setStyle('display', 'none');
+                state = false;
+            } else {
+                form.one(SELECTORS.CONTENTWARNING).setStyle('display', 'block');
+                state = true;
+            }
+        }
+
         return state;
     }
 }, {
diff --git a/lib/editor/atto/tests/fixtures/ipsums.h5p b/lib/editor/atto/tests/fixtures/ipsums.h5p
new file mode 100644 (file)
index 0000000..a903fd5
Binary files /dev/null and b/lib/editor/atto/tests/fixtures/ipsums.h5p differ
index daae058..36a85d8 100644 (file)
@@ -2299,6 +2299,9 @@ function send_temp_file($path, $filename, $pathisstring=false) {
         $filename = urlencode($filename);
     }
 
+    // If this file was requested from a form, then mark download as complete.
+    \core_form\util::form_download_complete();
+
     header('Content-Disposition: attachment; filename="'.$filename.'"');
     if (is_https()) { // HTTPS sites - watch out for IE! KB812935 and KB316431.
         header('Cache-Control: private, max-age=10, no-transform');
@@ -2450,6 +2453,9 @@ function send_file($path, $filename, $lifetime = null , $filter=0, $pathisstring
 
     if ($forcedownload) {
         header('Content-Disposition: attachment; filename="'.$filename.'"');
+
+        // If this file was requested from a form, then mark download as complete.
+        \core_form\util::form_download_complete();
     } else if ($mimetype !== 'application/x-shockwave-flash') {
         // If this is an swf don't pass content-disposition with filename as this makes the flash player treat the file
         // as an upload and enforces security that may prevent the file from being loaded.
index 46f722e..2631f65 100644 (file)
Binary files a/lib/form/amd/build/submit.min.js and b/lib/form/amd/build/submit.min.js differ
index afc8bae..1649623 100644 (file)
Binary files a/lib/form/amd/build/submit.min.js.map and b/lib/form/amd/build/submit.min.js.map differ
index 1e4e7dd..af4271e 100644 (file)
  * @since 3.8
  */
 
+/** @type {number} ID for setInterval used when polling for download cookie */
+let cookieListener = 0;
+
+/** @type {Array} Array of buttons that need re-enabling if we get a download cookie */
+const cookieListeningButtons = [];
+
+/**
+ * Listens in case a download cookie is provided.
+ *
+ * This function is used to detect file downloads. If there is a file download then we get a
+ * beforeunload event, but the page is never unloaded and when the file download completes we
+ * should re-enable the buttons. We detect this by watching for a specific cookie.
+ *
+ * PHP function \core_form\util::form_download_complete() can be used to send this cookie.
+ *
+ * @param {HTMLElement} button Button to re-enable
+ */
+const listenForDownloadCookie = (button) => {
+    cookieListeningButtons.push(button);
+    if (!cookieListener) {
+        cookieListener = setInterval(() => {
+            // Look for cookie.
+            const parts = document.cookie.split(getCookieName() + '=');
+            if (parts.length == 2) {
+                // We found the cookie, so the file is ready. Expire the cookie and cancel polling.
+                clearDownloadCookie();
+                clearInterval(cookieListener);
+                cookieListener = 0;
+
+                // Re-enable all the buttons.
+                cookieListeningButtons.forEach((button) => {
+                    button.disabled = false;
+                });
+            }
+        }, 500);
+    }
+};
+
+/**
+ * Gets a unique name for the download cookie.
+ *
+ * @returns {string} Cookie name
+ */
+const getCookieName = () => {
+    return 'moodledownload_' + M.cfg.sesskey;
+};
+
+/**
+ * Clears the download cookie if there is one.
+ */
+const clearDownloadCookie = () => {
+    document.cookie = encodeURIComponent(getCookieName()) + '=deleted; expires=' + new Date(0).toUTCString();
+};
+
 /**
  * Initialises submit buttons.
  *
  */
 export const init = (elementId) => {
     const button = document.getElementById(elementId);
-    button.form.addEventListener('submit', function() {
+    // If the form has double submit protection disabled, do nothing.
+    if (button.form.dataset.doubleSubmitProtection === 'off') {
+        return;
+    }
+    button.form.addEventListener('submit', function(event) {
         // Only disable it if the browser is really going to another page as a result of the
         // submit.
         const disableAction = function() {
+            // If the submit was cancelled, or the button is already disabled, don't do anything.
+            if (event.defaultPrevented || button.disabled) {
+                return;
+            }
+
             button.disabled = true;
+            clearDownloadCookie();
+            listenForDownloadCookie(button);
         };
         window.addEventListener('beforeunload', disableAction);
         // If there is no beforeunload event as a result of this form submit, then the form
diff --git a/lib/form/classes/util.php b/lib/form/classes/util.php
new file mode 100644 (file)
index 0000000..2a252a3
--- /dev/null
@@ -0,0 +1,64 @@
+<?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/>.
+
+/**
+ * Provides the {@link core_form\util} class.
+ *
+ * @package core_form
+ * @copyright 2019 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_form;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * General utility class for form-related methods.
+ *
+ * @copyright 2019 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class util {
+    /**
+     * This function should be called if a form submit results in a file download (i.e. with the
+     * Content-Disposition: attachment header) instead of navigating to a new page, before the
+     * file download is sent. It will set a cookie which is used to inform page javascript in
+     * submit.js.
+     *
+     * You may call this function in scripts which might not necessarily be called from forms; it
+     * will only set the cookie if there is a POST request from a form.
+     *
+     * This is automatically called from various points in Moodle such as send_file_xx functions
+     * in filelib.php.
+     */
+    public static function form_download_complete() {
+        // If this doesn't look like a Moodle QuickForms request, ignore.
+        $quickform = false;
+        foreach ($_POST as $name => $value) {
+            if (preg_match('~^_qf__~', $name)) {
+                $quickform = true;
+                break;
+            }
+        }
+        if (!$quickform) {
+            return;
+        }
+
+        // Set a session cookie.
+        setcookie('moodledownload_' . sesskey(), time());
+    }
+}
index bbec709..f0631f9 100644 (file)
@@ -390,10 +390,21 @@ class MoodleQuickForm_editor extends HTML_QuickForm_element implements templatab
             $subtitle_options->env = 'editor';
             $subtitle_options->itemid = $draftitemid;
 
+            // H5P plugin.
+            $args->accepted_types = array('.h5p');
+            $h5poptions = initialise_filepicker($args);
+            $h5poptions->context = $ctx;
+            $h5poptions->client_id = uniqid();
+            $h5poptions->maxbytes  = $this->_options['maxbytes'];
+            $h5poptions->areamaxbytes  = $this->_options['areamaxbytes'];
+            $h5poptions->env = 'editor';
+            $h5poptions->itemid = $draftitemid;
+
             $fpoptions['image'] = $image_options;
             $fpoptions['media'] = $media_options;
             $fpoptions['link'] = $link_options;
             $fpoptions['subtitle'] = $subtitle_options;
+            $fpoptions['h5p'] = $h5poptions;
         }
 
         //If editor is required and tinymce, then set required_tinymce option to initalize tinymce validation.
index 990ab73..4a000a3 100644 (file)
@@ -169,6 +169,10 @@ abstract class moodleform {
      * @param mixed $attributes you can pass a string of html attributes here or an array.
      *               Special attribute 'data-random-ids' will randomise generated elements ids. This
      *               is necessary when there are several forms on the same page.
+     *               Special attribute 'data-double-submit-protection' set to 'off' will turn off
+     *               double-submit protection JavaScript - this may be necessary if your form sends
+     *               downloadable files in response to a submit button, and can't call
+     *               \core_form\util::form_download_complete();
      * @param bool $editable
      * @param array $ajaxformdata Forms submitted via ajax, must pass their data here, instead of relying on _GET and _POST.
      */
index e8a4bc9..5144936 100644 (file)
@@ -179,6 +179,9 @@ function install_helpbutton($url, $title='') {
  * @return string
  */
 function install_db_validate($database, $dbhost, $dbuser, $dbpass, $dbname, $prefix, $dboptions) {
+    if (!preg_match('/^[a-z_]*$/', $prefix)) {
+        return get_string('invaliddbprefix', 'install');
+    }
     try {
         try {
             $database->connect($dbhost, $dbuser, $dbpass, $dbname, $prefix, $dboptions);
index 5cc2050..5464537 100644 (file)
@@ -26,21 +26,21 @@ $string['errornoconfigdata'] = 'The server configuration is not complete.';
 $string['errorserver'] = 'Server error {$a}';
 $string['host'] = 'Host';
 $string['hostdesc'] = 'Host';
-$string['packageinstalledshouldbe'] = '"moodlemlbackend" python package should be updated. The required version is "{$a->required}" and the installed version is "{$a->installed}"';
-$string['packageinstalledtoohigh'] = '"moodlemlbackend" python package is not compatible with this Moodle version. The required version is "{$a->required}" or higher as long as it is API-compatible. The installed version "{$a->installed}" is too high.';
+$string['packageinstalledshouldbe'] = 'The moodlemlbackend Python package should be updated. The required version is "{$a->required}" and the installed version is "{$a->installed}".';
+$string['packageinstalledtoohigh'] = 'The moodlemlbackend Python package is not compatible with this version of Moodle. The required version is "{$a->required}" or higher as long as it is API-compatible. The installed version "{$a->installed}" is too high.';
 $string['pluginname'] = 'Python machine learning backend';
 $string['port'] = 'Port';
 $string['portdesc'] = 'Port';
 $string['privacy:metadata'] = 'The Python machine learning backend plugin does not store any personal data.';
-$string['pythonpackagenotinstalled'] = '"moodlemlbackend" python package is not installed or there is a problem with it. Please execute "{$a}" from command line interface for more info';
+$string['pythonpackagenotinstalled'] = 'The moodlemlbackend Python package is not installed or there is a problem with it. Please execute "{$a}" from command line interface for more info.';
 $string['pythonpathnotdefined'] = 'The path to your executable Python binary has not been defined. Please visit "{$a}" to set it.';
-$string['serversettingsinfo'] = 'Tick "Use a server" setting to show the server settings.';
+$string['serversettingsinfo'] = 'If \'Use a server\' is enabled, the server settings will be displayed.';
 $string['username'] = 'Username';
-$string['usernamedesc'] = 'String of characters used as a username to communicate between your Moodle server and the python server';
+$string['usernamedesc'] = 'String of characters used as a username to communicate between the Moodle server and the Python server.';
 $string['password'] = 'Password';
-$string['passworddesc'] = 'String of characters used as a password to communicate between your Moodle server and the python server';
+$string['passworddesc'] = 'String of characters used as a password to communicate between the Moodle server and the Python server.';
 $string['secure'] = 'Use HTTPS';
-$string['securedesc'] = 'Whether to use HTTP or HTTPS';
+$string['securedesc'] = 'Whether to use HTTP or HTTPS.';
 $string['useserver'] = 'Use a server';
-$string['useserverdesc'] = 'The machine learning backend python package is not installed in the web server but in a different server.';
+$string['useserverdesc'] = 'The machine learning backend Python package is not installed on the web server but on a different server.';
 $string['tensorboardinfo'] = 'Launch TensorBoard from command line by typing tensorboard --logdir=\'{$a}\' in your web server.';
\ No newline at end of file
index 3075147..9ade507 100644 (file)
@@ -76,6 +76,13 @@ validation against and defaults to null (so, no user needed) if not provided.
 the itemid and filepath for the filearea and path defined in $args. It has been added in order to get the correct itemid and
 filepath because some components, such as mod_page or mod_resource, add the revision to the URL where the itemid should be placed
 (to prevent caching problems), but then they don't store it in database.
+* New utility function \core_form\util::form_download_complete should be called if your code sends
+  a file with Content-Disposition: Attachment in response to a Moodle form submit button (to ensure
+  that disabled submit buttons get re-enabled in that case). It is automatically called by the
+  filelib.php send_xx functions.
+* If you have a form which sends a file in response to a Moodle form submit button, but you cannot
+  call the above function because the file is sent by a third party library, then you should add
+  the attribute data-double-submit-protection="off" to your form.
 
 === 3.7 ===
 
index 592d50b..0b39b44 100644 (file)
@@ -291,7 +291,9 @@ $string['groupoverrides'] = 'Group overrides';
 $string['groupoverridesdeleted'] = 'Group overrides deleted';
 $string['groupsnone'] = 'No groups you can access.';
 $string['hidegrader'] = 'Hide grader identity from students';
-$string['hidegrader_help'] = 'Hides the identity of any user who grades an assignment submission, so students can\'t see who marked their work.';
+$string['hidegrader_help'] = 'If enabled, the identity of any user who grades an assignment submission is not shown, so students can\'t see who marked their work.
+
+Note that this setting has no effect on the comments box on the grading page.';
 $string['hideshow'] = 'Hide/Show';
 $string['hiddenuser'] = 'Participant ';
 $string['inactiveoverridehelp'] = '* Student does not have the correct group or role to attempt the assignment';
index 9a166b2..d76becd 100644 (file)
@@ -140,6 +140,10 @@ if (optional_param('sesskey', false, PARAM_BOOL) && confirm_sesskey()) {
         header('Expires: 0');
         header('Cache-Control: must-revalidate,post-check=0,pre-check=0');
         header('Pragma: public');
+
+        // If this file was requested from a form, then mark download as complete.
+        \core_form\util::form_download_complete();
+
         $exportfilehandler = fopen($exportfile, 'rb');
         print fread($exportfilehandler, filesize($exportfile));
         fclose($exportfilehandler);
index 5428f9c..ed06505 100644 (file)
Binary files a/mod/forum/amd/build/discussion_list.min.js and b/mod/forum/amd/build/discussion_list.min.js differ
index 481e49e..a2d9f13 100644 (file)
Binary files a/mod/forum/amd/build/discussion_list.min.js.map and b/mod/forum/amd/build/discussion_list.min.js.map differ
diff --git a/mod/forum/amd/build/discussion_modern.min.js b/mod/forum/amd/build/discussion_modern.min.js
deleted file mode 100644 (file)
index 237b203..0000000
Binary files a/mod/forum/amd/build/discussion_modern.min.js and /dev/null differ
diff --git a/mod/forum/amd/build/discussion_modern.min.js.map b/mod/forum/amd/build/discussion_modern.min.js.map
deleted file mode 100644 (file)
index 5baf93e..0000000
Binary files a/mod/forum/amd/build/discussion_modern.min.js.map and /dev/null differ
diff --git a/mod/forum/amd/build/discussion_nested_v2.min.js b/mod/forum/amd/build/discussion_nested_v2.min.js
new file mode 100644 (file)
index 0000000..0f3d72b
Binary files /dev/null and b/mod/forum/amd/build/discussion_nested_v2.min.js differ
diff --git a/mod/forum/amd/build/discussion_nested_v2.min.js.map b/mod/forum/amd/build/discussion_nested_v2.min.js.map
new file mode 100644 (file)
index 0000000..46697fa
Binary files /dev/null and b/mod/forum/amd/build/discussion_nested_v2.min.js.map differ
index 49ab60e..7a3bc75 100644 (file)
Binary files a/mod/forum/amd/build/inpage_reply.min.js and b/mod/forum/amd/build/inpage_reply.min.js differ
index 097c4c3..8172888 100644 (file)
Binary files a/mod/forum/amd/build/inpage_reply.min.js.map and b/mod/forum/amd/build/inpage_reply.min.js.map differ
index a3aced7..12d10bb 100644 (file)
Binary files a/mod/forum/amd/build/posts_list.min.js and b/mod/forum/amd/build/posts_list.min.js differ
index 9e0e5a0..0423c5e 100644 (file)
Binary files a/mod/forum/amd/build/posts_list.min.js.map and b/mod/forum/amd/build/posts_list.min.js.map differ
index cc432d5..57dd2d9 100644 (file)
@@ -34,7 +34,7 @@ define([
 ], function(
     $,
     Templates,
-    String,
+    Str,
     Notification,
     SubscriptionToggle,
     Selectors,
@@ -107,7 +107,7 @@ define([
                     return Templates.replaceNode(toggleElement, html, js);
                 })
                 .then(function() {
-                    return String.get_string('lockupdated', 'forum')
+                    return Str.get_string('lockupdated', 'forum')
                         .done(function(s) {
                             return Notification.addNotification({
                                 message: s,
@@ -123,10 +123,16 @@ define([
 
     return {
         init: function(root) {
-            SubscriptionToggle.init(root, true, function(toggleElement, context) {
-                return Templates.render('mod_forum/discussion_subscription_toggle', context)
-                    .then(function(html, js) {
-                        return Templates.replaceNode(toggleElement, html, js);
+            SubscriptionToggle.init(root, false, function(toggleElement, context) {
+                var toggleId = toggleElement.attr('id');
+                var newTargetState = context.userstate.subscribed ? 0 : 1;
+                toggleElement.data('targetstate', newTargetState);
+
+                var stringKey = context.userstate.subscribed ? 'unsubscribediscussion' : 'subscribediscussion';
+                return Str.get_string(stringKey, 'mod_forum')
+                    .then(function(string) {
+                        toggleElement.closest('td').find('label[for="' + toggleId + '"]').text(string);
+                        return string;
                     });
             });
             registerEventListeners(root);
similarity index 98%
rename from mod/forum/amd/src/discussion_modern.js
rename to mod/forum/amd/src/discussion_nested_v2.js
index f5d97e0..6c0e89c 100644 (file)
@@ -14,7 +14,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Module for viewing a discussion in modern view.
+ * Module for viewing a discussion in nested v2 view.
  *
  * @copyright  2019 Ryan Wyllie <ryan@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -326,7 +326,7 @@ const renderInPageReplyTemplate = (additionalTemplateContext, button, postContai
         ...additionalTemplateContext
     };
 
-    return Templates.render('mod_forum/inpage_reply_modern', context);
+    return Templates.render('mod_forum/inpage_reply_v2', context);
 };
 
 /**
@@ -393,7 +393,7 @@ const registerEventListeners = (root) => {
 };
 
 /**
- * Initialise the javascript for the discussion in modern display mode.
+ * Initialise the javascript for the discussion in nested v2 display mode.
  *
  * @param {Object} root jQuery element for the discussion container
  * @param {Object} context Additional render context for the in page reply template
index 98625d8..0f2d1c9 100644 (file)
@@ -36,7 +36,7 @@ define([
     ) {
 
     var DISPLAYCONSTANTS = {
-        MODERN: 4,
+        NESTED_V2: 4,
         THREADED: 2,
         NESTED: 3,
         FLAT_OLDEST_FIRST: 1,
@@ -133,15 +133,21 @@ define([
                         newid = post.id;
 
                         switch (mode) {
-                            case DISPLAYCONSTANTS.MODERN:
+                            case DISPLAYCONSTANTS.NESTED_V2:
                                 var capabilities = post.capabilities;
-                                post.showactionmenu = capabilities.controlreadstatus ||
+                                var currentAuthorName = currentRoot.children()
+                                                                   .not(Selectors.post.repliesContainer)
+                                                                   .find(Selectors.post.authorName)
+                                                                   .text();
+                                post.parentauthorname = currentAuthorName;
+                                post.showactionmenu = capabilities.view ||
+                                                      capabilities.controlreadstatus ||
                                                       capabilities.edit ||
                                                       capabilities.split ||
                                                       capabilities.delete ||
                                                       capabilities.export ||
                                                       post.urls.viewparent;
-                                return Templates.render('mod_forum/forum_discussion_modern_post_reply', post);
+                                return Templates.render('mod_forum/forum_discussion_nested_v2_post_reply', post);
                             case DISPLAYCONSTANTS.THREADED:
                                 return Templates.render('mod_forum/forum_discussion_threaded_post', post);
                             case DISPLAYCONSTANTS.NESTED:
index 3fbc593..ca92955 100644 (file)
@@ -29,12 +29,14 @@ define([
         'jquery',
         'core/templates',
         'core/notification',
+        'core/pending',
         'mod_forum/selectors',
         'mod_forum/inpage_reply',
     ], function(
         $,
         Templates,
         Notification,
+        Pending,
         Selectors,
         InPageReply
     ) {
@@ -52,6 +54,7 @@ define([
                 var url = window.location.href.split('#')[0];
                 history.pushState({}, document.title, url);
             }
+            var pending = new Pending('inpage-reply');
             var currentTarget = $(e.currentTarget).parents(Selectors.post.forumCoreContent);
             var currentSubject = currentTarget.find(Selectors.post.forumSubject);
             var currentRoot = $(e.currentTarget).parents(Selectors.post.forumContent);
@@ -70,12 +73,13 @@ define([
                         return Templates.appendNodeContents(currentTarget, html, js);
                     })
                     .then(function() {
-                        return currentRoot.find(Selectors.post.inpageReplyContent).slideToggle(300).find('textarea').focus();
+                        return currentRoot.find(Selectors.post.inpageReplyContent)
+                            .slideToggle(300, pending.resolve).find('textarea').focus();
                     })
                     .fail(Notification.exception);
             } else {
                 var form = currentRoot.find(Selectors.post.inpageReplyContent);
-                form.slideToggle(300);
+                form.slideToggle(300, pending.resolve);
                 if (form.is(':visible')) {
                     form.find('textarea').focus();
                 }
index 21508ab..93f995b 100644 (file)
@@ -161,11 +161,15 @@ class author extends exporter {
                     ]
                 ];
             } else {
-                $groups = array_map(function($group) use ($urlfactory, $context) {
+                $groups = array_map(function($group) use ($urlfactory, $context, $output) {
                     $imageurl = null;
                     $groupurl = null;
                     if (!$group->hidepicture) {
                         $imageurl = get_group_picture_url($group, $group->courseid, true);
+                        if (empty($imageurl)) {
+                            // Get a generic group image URL.
+                            $imageurl = $output->image_url('g/g1');
+                        }
                     }
                     if (course_can_view_participants($context)) {
                         $groupurl = $urlfactory->get_author_group_url($group);
index d341a70..2db3a45 100644 (file)
@@ -183,8 +183,8 @@ class renderer {
             case FORUM_MODE_NESTED:
                 $template = 'mod_forum/forum_discussion_nested_posts';
                 break;
-            case FORUM_MODE_MODERN:
-                $template = 'mod_forum/forum_discussion_modern_posts';
+            case FORUM_MODE_NESTED_V2:
+                $template = 'mod_forum/forum_discussion_nested_v2_posts';
                 break;
             default;
                 $template = 'mod_forum/forum_discussion_posts';
@@ -228,8 +228,9 @@ class renderer {
                             $seenfirstunread = true;
                         }
 
-                        if ($displaymode === FORUM_MODE_MODERN) {
-                            $exportedpost->showactionmenu = $exportedpost->capabilities['controlreadstatus'] ||
+                        if ($displaymode === FORUM_MODE_NESTED_V2) {
+                            $exportedpost->showactionmenu = $exportedpost->capabilities['view'] ||
+                                                            $exportedpost->capabilities['controlreadstatus'] ||
                                                             $exportedpost->capabilities['edit'] ||
                                                             $exportedpost->capabilities['split'] ||
                                                             $exportedpost->capabilities['delete'] ||
@@ -245,7 +246,7 @@ class renderer {
                 if (
                     $displaymode === FORUM_MODE_NESTED ||
                     $displaymode === FORUM_MODE_THREADED ||
-                    $displaymode === FORUM_MODE_MODERN
+                    $displaymode === FORUM_MODE_NESTED_V2
                 ) {
                     $sortedposts = $exportedpostssorter->sort_into_children($exportedposts);
                     $sortintoreplies = function($nestedposts) use (&$sortintoreplies) {
index d919b45..d117ff6 100644 (file)
@@ -208,7 +208,7 @@ class discussion {
             'html' => [
                 'hasanyactions' => $hasanyactions,
                 'posts' => $this->postsrenderer->render($user, [$this->forum], [$this->discussion], $posts),
-                'modeselectorform' => $this->get_display_mode_selector_html($displaymode),
+                'modeselectorform' => $this->get_display_mode_selector_html($displaymode, $user),
                 'subscribe' => null,
                 'movediscussion' => null,
                 'pindiscussion' => null,
@@ -232,8 +232,8 @@ class discussion {
             ];
         }
 
-        if ($this->displaymode === FORUM_MODE_MODERN) {
-            $template = 'mod_forum/forum_discussion_modern';
+        if ($this->displaymode === FORUM_MODE_NESTED_V2) {
+            $template = 'mod_forum/forum_discussion_nested_v2';
         } else {
             $template = 'mod_forum/forum_discussion';
         }
@@ -274,14 +274,15 @@ class discussion {
      * Get the HTML for the display mode selector.
      *
      * @param int $displaymode The current display mode
+     * @param stdClass $user The current user
      * @return string
      */
-    private function get_display_mode_selector_html(int $displaymode) : string {
+    private function get_display_mode_selector_html(int $displaymode, stdClass $user) : string {
         $baseurl = $this->baseurl;
         $select = new single_select(
             $baseurl,
             'mode',
-            forum_get_layout_modes(),
+            forum_get_layout_modes(get_user_preferences('forum_useexperimentalui', false, $user)),
             $displaymode,
             null,
             'mode'
@@ -332,7 +333,7 @@ class discussion {
                 $html = '<div class="movediscussionoption">';
 
                 $movebutton = get_string('move');
-                if ($this->displaymode === FORUM_MODE_MODERN) {
+                if ($this->displaymode === FORUM_MODE_NESTED_V2) {
                     // Move discussion selector will be rendered on the settings drawer. We won't output the button in this mode.
                     $movebutton = null;
                 }
index 3fd846b..a5414f6 100644 (file)
@@ -188,7 +188,8 @@ class discussion_list {
             'notifications' => $this->get_notifications($user, $groupid),
             'settings' => [
                 'excludetext' => true,
-                'togglemoreicon' => true
+                'togglemoreicon' => true,
+                'excludesubscription' => true
             ],
             'totaldiscussioncount' => $alldiscussionscount,
             'visiblediscussioncount' => count($discussions)
index a43590c..e884477 100644 (file)
@@ -241,11 +241,27 @@ forum_discussion_view($modcontext, $forumrecord, $discussionrecord);
 
 unset($SESSION->fromdiscussion);
 
+$saveddisplaymode = get_user_preferences('forum_displaymode', $CFG->forum_displaymode);
+
 if ($mode) {
-    set_user_preference('forum_displaymode', $mode);
+    $displaymode = $mode;
+} else {
+    $displaymode = $saveddisplaymode;
 }
 
-$displaymode = get_user_preferences('forum_displaymode', $CFG->forum_displaymode);
+if (get_user_preferences('forum_useexperimentalui', false)) {
+    if ($displaymode == FORUM_MODE_NESTED) {
+        $displaymode = FORUM_MODE_NESTED_V2;
+    }
+} else {
+    if ($displaymode == FORUM_MODE_NESTED_V2) {
+        $displaymode = FORUM_MODE_NESTED;
+    }
+}
+
+if ($displaymode != $saveddisplaymode) {
+    set_user_preference('forum_displaymode', $displaymode);
+}
 
 if ($parent) {
     // If flat AND parent, then force nested display this time
@@ -291,12 +307,11 @@ if ($node && $post->get_id() != $discussion->get_first_post_id()) {
     $node->add(format_string($post->get_subject()), $PAGE->url);
 }
 
-$ismoderndisplaymode = $displaymode == FORUM_MODE_MODERN;
+$isnestedv2displaymode = $displaymode == FORUM_MODE_NESTED_V2;
 $PAGE->set_title("$course->shortname: " . format_string($discussion->get_name()));
 $PAGE->set_heading($course->fullname);
-if ($ismoderndisplaymode) {
-    $PAGE->add_body_class('modern-display-mode reset-style');
-    $PAGE->set_include_region_main_settings_in_header_actions(true);
+if ($isnestedv2displaymode) {
+    $PAGE->add_body_class('nested-v2-display-mode reset-style');
     $settingstrigger = $OUTPUT->render_from_template('mod_forum/settings_drawer_trigger', null);
     $PAGE->add_header_action($settingstrigger);
 } else {
@@ -304,7 +319,7 @@ if ($ismoderndisplaymode) {
 }
 
 echo $OUTPUT->header();
-if (!$ismoderndisplaymode) {
+if (!$isnestedv2displaymode) {
     echo $OUTPUT->heading(format_string($forum->get_name()), 2);
     echo $OUTPUT->heading(format_string($discussion->get_name()), 3, 'discussionname');
 }
index bad04fd..8b37ecf 100644 (file)
@@ -263,11 +263,11 @@ $string['existingsubscribers'] = 'Existing subscribers';
 $string['export'] = 'Export';
 $string['exportattachmentname'] = 'Export attachment {$a} to portfolio';
 $string['exportdiscussion'] = 'Export whole discussion to portfolio';
-$string['exportstriphtml'] = 'Strip HTML';
-$string['exportstriphtml_help'] = 'Select this option if you want the HTML tags to be stripped from the Whether to export raw HTML or strip HTML codes.';
+$string['exportstriphtml'] = 'Remove HTML';
+$string['exportstriphtml_help'] = 'Whether HTML tags such as p and br should be removed from the forum post message.';
 $string['exportoptions'] = 'Export options';
 $string['exporthumandates'] = 'Human-readable dates';
-$string['exporthumandates_help'] = 'Select this option if you want dates to be exported in a human readable format. Otherwise, dates will be exported as timestamps.';
+$string['exporthumandates_help'] = 'Whether dates should be exported in a human-readable format or as a timestamp (sequence of numbers).';
 $string['firstpost'] = 'First post';
 $string['favourites'] = 'Starred';
 $string['favouriteupdated'] = 'Your star option has been updated.';
@@ -394,8 +394,8 @@ $string['messageprovider:posts'] = 'Subscribed forum posts';
 $string['missingsearchterms'] = 'The following search terms occur only in the HTML markup of this message:';
 $string['modeflatnewestfirst'] = 'Display replies flat, with newest first';
 $string['modeflatoldestfirst'] = 'Display replies flat, with oldest first';
-$string['modemodern'] = 'Display replies in modern form';
 $string['modenested'] = 'Display replies in nested form';
+$string['modenestedv2'] = 'Display replies in experimental nested form';
 $string['modethreaded'] = 'Display replies in threaded form';
 $string['modulename'] = 'Forum';
 $string['modulename_help'] = 'The forum activity module enables participants to have asynchronous discussions i.e. discussions that take place over an extended period of time.
@@ -702,6 +702,7 @@ $string['unsubscribealldone'] = 'All optional forum subscriptions were removed.
 $string['unsubscribeallempty'] = 'You are not subscribed to any forums. To disable all notifications from this server go to Messaging in My Profile Settings.';
 $string['unsubscribed'] = 'Unsubscribed';
 $string['unsubscribeshort'] = 'Unsubscribe';
+$string['useexperimentalui'] = 'Use experimental nested discussion view';
 $string['usermarksread'] = 'Manual message read marking';
 $string['unpindiscussion'] = 'Unpin this discussion';
 $string['viewalldiscussions'] = 'View all discussions';
index 8294df5..de85487 100644 (file)
@@ -32,7 +32,7 @@ define('FORUM_MODE_FLATOLDEST', 1);
 define('FORUM_MODE_FLATNEWEST', -1);
 define('FORUM_MODE_THREADED', 2);
 define('FORUM_MODE_NESTED', 3);
-define('FORUM_MODE_MODERN', 4);
+define('FORUM_MODE_NESTED_V2', 4);
 
 define('FORUM_CHOOSESUBSCRIBE', 0);
 define('FORUM_FORCESUBSCRIBE', 1);
@@ -2541,12 +2541,29 @@ function forum_get_discussion_subscription_icon_preloaders() {
  */
 function forum_print_mode_form($id, $mode, $forumtype='') {
     global $OUTPUT;
+    $useexperimentalui = get_user_preferences('forum_useexperimentalui', false);
     if ($forumtype == 'single') {
-        $select = new single_select(new moodle_url("/mod/forum/view.php", array('f'=>$id)), 'mode', forum_get_layout_modes(), $mode, null, "mode");
+        $select = new single_select(
+            new moodle_url("/mod/forum/view.php",
+            array('f' => $id)),
+            'mode',
+            forum_get_layout_modes($useexperimentalui),
+            $mode,
+            null,
+            "mode"
+        );
         $select->set_label(get_string('displaymode', 'forum'), array('class' => 'accesshide'));
         $select->class = "forummode";
     } else {
-        $select = new single_select(new moodle_url("/mod/forum/discuss.php", array('d'=>$id)), 'mode', forum_get_layout_modes(), $mode, null, "mode");
+        $select = new single_select(
+            new moodle_url("/mod/forum/discuss.php",
+            array('d' => $id)),
+            'mode',
+            forum_get_layout_modes($useexperimentalui),
+            $mode,
+            null,
+            "mode"
+        );
         $select->set_label(get_string('displaymode', 'forum'), array('class' => 'accesshide'));
     }
     echo $OUTPUT->render($select);
@@ -5301,14 +5318,23 @@ function forum_reset_course_form_defaults($course) {
 /**
  * Returns array of forum layout modes
  *
+ * @param bool $useexperimentalui use experimental layout modes or not
  * @return array
  */
-function forum_get_layout_modes() {
-    return array (FORUM_MODE_FLATOLDEST => get_string('modeflatoldestfirst', 'forum'),
-                  FORUM_MODE_FLATNEWEST => get_string('modeflatnewestfirst', 'forum'),
-                  FORUM_MODE_THREADED   => get_string('modethreaded', 'forum'),
-                  FORUM_MODE_NESTED     => get_string('modenested', 'forum'),
-                  FORUM_MODE_MODERN        => get_string('modemodern', 'forum'));
+function forum_get_layout_modes(bool $useexperimentalui = false) {
+    $modes = [
+        FORUM_MODE_FLATOLDEST => get_string('modeflatoldestfirst', 'forum'),
+        FORUM_MODE_FLATNEWEST => get_string('modeflatnewestfirst', 'forum'),
+        FORUM_MODE_THREADED   => get_string('modethreaded', 'forum')
+    ];
+
+    if ($useexperimentalui) {
+        $modes[FORUM_MODE_NESTED_V2] = get_string('modenestedv2', 'forum');
+    } else {
+        $modes[FORUM_MODE_NESTED] = get_string('modenested', 'forum');
+    }
+
+    return $modes;
 }
 
 /**
@@ -6842,6 +6868,11 @@ function mod_forum_user_preferences() {
             $discussionlistvault::SORTORDER_REPLIES_ASC
         )
     );
+    $preferences['forum_useexperimentalui'] = [
+        'null' => NULL_NOT_ALLOWED,
+        'default' => false,
+        'type' => PARAM_BOOL
+    ];
 
     return $preferences;
 }
index 6618d3f..25eb61a 100644 (file)
Binary files a/mod/forum/report/summary/amd/build/filters.min.js and b/mod/forum/report/summary/amd/build/filters.min.js differ
index 69e1fba..54c31d6 100644 (file)
Binary files a/mod/forum/report/summary/amd/build/filters.min.js.map and b/mod/forum/report/summary/amd/build/filters.min.js.map differ
index 320b8f5..ca815cd 100644 (file)
Binary files a/mod/forum/report/summary/amd/build/selectors.min.js and b/mod/forum/report/summary/amd/build/selectors.min.js differ
index 2fc0418..88ee010 100644 (file)
Binary files a/mod/forum/report/summary/amd/build/selectors.min.js.map and b/mod/forum/report/summary/amd/build/selectors.min.js.map differ
index 9ee20d8..55fbf4a 100644 (file)
@@ -26,6 +26,8 @@ import $ from 'jquery';
 import Popper from 'core/popper';
 import CustomEvents from 'core/custom_interaction_events';
 import Selectors from 'forumreport_summary/selectors';
+import Y from 'core/yui';
+import Ajax from 'core/ajax';
 
 export const init = (root) => {
     let jqRoot = $(root);
@@ -40,8 +42,8 @@ export const init = (root) => {
     // Generic filter handlers.
 
     // Called to override click event to trigger a proper generate request with filtering.
-    var generateWithFilters = (event) => {
-        var newLink = $('#filtersform').attr('action');
+    const generateWithFilters = (event) => {
+        let newLink = $('#filtersform').attr('action');
 
         if (event) {
             event.preventDefault();
@@ -70,7 +72,7 @@ export const init = (root) => {
     });
 
     // Submit report via filter
-    var submitWithFilter = (containerelement) => {
+    const submitWithFilter = (containerelement) => {
         // Close the container (eg popover).
         $(containerelement).addClass('hidden');
 
@@ -78,6 +80,25 @@ export const init = (root) => {
         generateWithFilters(false);
     };
 
+    // Use popper to override date mform calendar position.
+    const updateCalendarPosition = (referenceid) => {
+        let referenceElement = document.querySelector(referenceid),
+            popperContent = document.querySelector(Selectors.filters.date.calendar);
+
+        popperContent.style.removeProperty("z-index");
+        new Popper(referenceElement, popperContent, {placement: 'bottom'});
+    };
+
+    // Call when opening filter to ensure only one can be activated.
+    const canOpenFilter = (event) => {
+        if (document.querySelector('[data-openfilter="true"]')) {
+            return false;
+        }
+
+        event.target.setAttribute('data-openfilter', "true");
+        return true;
+    };
+
     // Groups filter specific handlers.
 
     // Event handler for clicking select all groups.
@@ -98,9 +119,13 @@ export const init = (root) => {
     });
 
     // Event handler for showing groups filter popover.
-    jqRoot.on(CustomEvents.events.activate, Selectors.filters.group.trigger, function() {
+    jqRoot.on(CustomEvents.events.activate, Selectors.filters.group.trigger, function(event) {
+        if (!canOpenFilter(event)) {
+            return false;
+        }
+
         // Create popover.
-        var referenceElement = root.querySelector(Selectors.filters.group.trigger),
+        let referenceElement = root.querySelector(Selectors.filters.group.trigger),
             popperContent = root.querySelector(Selectors.filters.group.popover);
 
         new Popper(referenceElement, popperContent, {placement: 'bottom'});
@@ -114,10 +139,128 @@ export const init = (root) => {
 
         // Let screen readers know that it's now expanded.
         referenceElement.setAttribute('aria-expanded', true);
+        return true;
     });
 
     // Event handler to click save groups filter.
     jqRoot.on(CustomEvents.events.activate, Selectors.filters.group.save, function() {
         submitWithFilter('#filter-groups-popover');
     });
+
+    // Dates filter specific handlers.
+
+   // Event handler for showing dates filter popover.
+    jqRoot.on(CustomEvents.events.activate, Selectors.filters.date.trigger, function(event) {
+        if (!canOpenFilter(event)) {
+            return false;
+        }
+
+        // Create popover.
+        let referenceElement = root.querySelector(Selectors.filters.date.trigger),
+            popperContent = root.querySelector(Selectors.filters.date.popover);
+
+        new Popper(referenceElement, popperContent, {placement: 'bottom'});
+
+        // Show popover and move focus.
+        popperContent.classList.remove('hidden');
+        popperContent.querySelector('[name="filterdatefrompopover[enabled]"]').focus();
+
+        // Change to outlined button.
+        referenceElement.classList.add('btn-outline-primary');
+        referenceElement.classList.remove('btn-primary');
+
+        // Let screen readers know that it's now expanded.
+        referenceElement.setAttribute('aria-expanded', true);
+        return true;
+    });
+
+    // Event handler to save dates filter.
+    jqRoot.on(CustomEvents.events.activate, Selectors.filters.date.save, function() {
+        // Populate the hidden form inputs to submit the data.
+        let filtersForm = document.forms.filtersform;
+        const datesPopover = root.querySelector(Selectors.filters.date.popover);
+        const fromEnabled = datesPopover.querySelector('[name="filterdatefrompopover[enabled]"]').checked ? 1 : 0;
+        const toEnabled = datesPopover.querySelector('[name="filterdatetopopover[enabled]"]').checked ? 1 : 0;
+
+        // Disable the mform checker to prevent unsubmitted form warning to the user when closing the popover.
+        Y.use('moodle-core-formchangechecker', function() {
+            M.core_formchangechecker.reset_form_dirty_state();
+        });
+
+        if (!fromEnabled && !toEnabled) {
+            // Update the elements in the filter form.
+            filtersForm.elements['datefrom[timestamp]'].value = 0;
+            filtersForm.elements['datefrom[enabled]'].value = fromEnabled;
+            filtersForm.elements['dateto[timestamp]'].value = 0;
+            filtersForm.elements['dateto[enabled]'].value = toEnabled;
+
+            // Submit the filter values and re-generate report.
+            submitWithFilter('#filter-dates-popover');
+        } else {
+            let args = {data: []};
+
+            if (fromEnabled) {
+                args.data.push({
+                    'key': 'from',
+                    'year': datesPopover.querySelector('[name="filterdatefrompopover[year]"]').value,
+                    'month': datesPopover.querySelector('[name="filterdatefrompopover[month]"]').value,
+                    'day': datesPopover.querySelector('[name="filterdatefrompopover[day]"]').value,
+                    'hour': 0,
+                    'minute': 0
+                });
+            }
+
+            if (toEnabled) {
+                args.data.push({
+                    'key': 'to',
+                    'year': datesPopover.querySelector('[name="filterdatetopopover[year]"]').value,
+                    'month': datesPopover.querySelector('[name="filterdatetopopover[month]"]').value,
+                    'day': datesPopover.querySelector('[name="filterdatetopopover[day]"]').value,
+                    'hour': 23,
+                    'minute': 59
+                });
+            }
+
+            const request = {
+                methodname: 'core_calendar_get_timestamps',
+                args: args
+            };
+
+            Ajax.call([request])[0].done(function(result) {
+                let fromTimestamp = 0,
+                    toTimestamp = 0;
+
+                result['timestamps'].forEach(function(data){
+                    if (data.key === 'from') {
+                        fromTimestamp = data.timestamp;
+                    } else if (data.key === 'to') {
+                        toTimestamp = data.timestamp;
+                    }
+                });
+
+                // Display an error if the from date is later than the do date.
+                if (toTimestamp > 0 && fromTimestamp > toTimestamp) {
+                    const warningdiv = document.getElementById('dates-filter-warning');
+                    warningdiv.classList.remove('hidden');
+                    warningdiv.classList.add('d-block');
+                } else {
+                    filtersForm.elements['datefrom[timestamp]'].value = fromTimestamp;
+                    filtersForm.elements['datefrom[enabled]'].value = fromEnabled;
+                    filtersForm.elements['dateto[timestamp]'].value = toTimestamp;
+                    filtersForm.elements['dateto[enabled]'].value = toEnabled;
+
+                    // Submit the filter values and re-generate report.
+                    submitWithFilter('#filter-dates-popover');
+                }
+            });
+        }
+    });
+
+    jqRoot.on(CustomEvents.events.activate, Selectors.filters.date.calendariconfrom, function() {
+        updateCalendarPosition(Selectors.filters.date.calendariconfrom);
+    });
+
+    jqRoot.on(CustomEvents.events.activate, Selectors.filters.date.calendariconto, function() {
+        updateCalendarPosition(Selectors.filters.date.calendariconto);
+    });
 };
index df0c6db..553cf28 100644 (file)
@@ -31,6 +31,14 @@ export default {
             save: '[data-region="filter-groups"] .filter-save',
             selectall: '[data-region="filter-groups"] .select-all',
             trigger: '#filter-groups-button',
+        },
+        date: {
+            calendar: '#dateselector-calendar-panel',
+            calendariconfrom: '#id_filterdatefrompopover_calendar',
+            calendariconto: '#id_filterdatetopopover_calendar',
+            popover: '#filter-dates-popover',
+            save: '[data-region="filter-dates"] .filter-save',
+            trigger: '#filter-dates-button',
         }
     }
 };
diff --git a/mod/forum/report/summary/classes/form/dates_filter_form.php b/mod/forum/report/summary/classes/form/dates_filter_form.php
new file mode 100644 (file)
index 0000000..5d9f32e
--- /dev/null
@@ -0,0 +1,53 @@
+<?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/>.
+
+/**
+ * The mform used by the forum summary report dates filter.
+ *
+ * @package forumreport_summary
+ * @copyright 2019 Michael Hawkins <michaelh@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace forumreport_summary\form;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->dirroot.'/lib/formslib.php');
+
+/**
+ * The mform class for creating the forum summary report dates filter.
+ *
+ * @copyright 2019 Michael Hawkins <michaelh@moodle.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class dates_filter_form extends \moodleform {
+    /**
+     * The form definition.
+     *
+     */
+    public function definition() {
+        $attributes = [
+            'class' => 'align-items-center',
+        ];
+
+        // From date field.
+        $this->_form->addElement('date_selector', 'filterdatefrompopover', get_string('from'), ['optional' => true], $attributes);
+
+        // To date field.
+        $this->_form->addElement('date_selector', 'filterdatetopopover', get_string('to'), ['optional' => true], $attributes);
+    }
+}
index 1c6be47..60a7123 100644 (file)
@@ -29,6 +29,7 @@ use renderable;
 use renderer_base;
 use stdClass;
 use templatable;
+use forumreport_summary;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -69,6 +70,20 @@ class filters implements renderable, templatable {
      */
     protected $groupsselected = [];
 
+    /**
+     * HTML for dates filter.
+     *
+     * @var array $datesdata
+     */
+    protected $datesdata = [];
+
+    /**
+     * Text to display on the dates filter button.
+     *
+     * @var string $datesbuttontext
+     */
+    protected $datesbuttontext;
+
     /**
      * Builds renderable filter data.
      *
@@ -84,6 +99,11 @@ class filters implements renderable, templatable {
         // Prepare groups filter data.
         $groupsdata = $filterdata['groups'] ?? [];
         $this->prepare_groups_data($groupsdata);
+
+        // Prepare dates filter data.
+        $datefromdata = $filterdata['datefrom'] ?? [];
+        $datetodata = $filterdata['dateto'] ?? [];
+        $this->prepare_dates_data($datefromdata, $datetodata);
     }
 
     /**
@@ -129,6 +149,78 @@ class filters implements renderable, templatable {
         $this->groupsselected = $groupsselected;
     }
 
+    /**
+     * Prepares from date, to date and button text.
+     * Empty data will default to a disabled filter with today's date.
+     *
+     * @param array $datefromdata From date selected for filtering, and whether the filter is enabled.
+     * @param array $datetodata To date selected for filtering, and whether the filter is enabled.
+     * @return void.
+     */
+    private function prepare_dates_data(array $datefromdata, array $datetodata): void {
+        $timezone = \core_date::get_user_timezone_object();
+        $calendartype = \core_calendar\type_factory::get_calendar_instance();
+        $timestamptoday = time();
+        $datetoday  = $calendartype->timestamp_to_date_array($timestamptoday, $timezone);
+
+        // Prepare date/enabled data.
+        if (empty($datefromdata['enabled'])) {
+            $fromdate = $datetoday;
+            $fromtimestamp = $timestamptoday;
+            $fromenabled = false;
+        } else {
+            $fromdate = $calendartype->timestamp_to_date_array($datefromdata['timestamp'], $timezone);
+            $fromtimestamp = $datefromdata['timestamp'];
+            $fromenabled = true;
+        }
+
+        if (empty($datetodata['enabled'])) {
+            $todate = $datetoday;
+            $totimestamp = $timestamptoday;
+            $toenabled = false;
+        } else {
+            $todate = $calendartype->timestamp_to_date_array($datetodata['timestamp'], $timezone);
+            $totimestamp = $datetodata['timestamp'];
+            $toenabled = true;
+        }
+
+        $this->datesdata = [
+            'from' => [
+                'day'       => $fromdate['mday'],
+                'month'     => $fromdate['mon'],
+                'year'      => $fromdate['year'],
+                'timestamp' => $fromtimestamp,
+                'enabled'   => $fromenabled,
+            ],
+            'to' => [
+                'day'       => $todate['mday'],
+                'month'     => $todate['mon'],
+                'year'      => $todate['year'],
+                'timestamp' => $totimestamp,
+                'enabled'   => $toenabled,
+            ],
+        ];
+
+        // Prepare button string data.
+        $displayformat = get_string('strftimedatemonthabbr', 'langconfig');
+        $fromdatestring = $calendartype->timestamp_to_date_string($fromtimestamp, $displayformat, $timezone, true, true);
+        $todatestring = $calendartype->timestamp_to_date_string($totimestamp, $displayformat, $timezone, true, true);
+
+        if ($fromenabled && $toenabled) {
+            $datestrings = [
+                'datefrom' => $fromdatestring,
+                'dateto'   => $todatestring,
+            ];
+            $this->datesbuttontext = get_string('filter:datesfromto', 'forumreport_summary', $datestrings);
+        } else if ($fromenabled) {
+            $this->datesbuttontext = get_string('filter:datesfrom', 'forumreport_summary', $fromdatestring);
+        } else if ($toenabled) {
+            $this->datesbuttontext = get_string('filter:datesto', 'forumreport_summary', $todatestring);
+        } else {
+            $this->datesbuttontext = get_string('filter:datesname', 'forumreport_summary');
+        }
+    }
+
     /**
      * Export data for use as the context of a mustache template.
      *
@@ -171,6 +263,33 @@ class filters implements renderable, templatable {
             $output->hasgroups = false;
         }
 
+        // Set date button and generate dates popover mform.
+        $datesformdata = [];
+
+        if ($this->datesdata['from']['enabled']) {
+            $datesformdata['filterdatefrompopover'] = $this->datesdata['from'];
+        }
+
+        if ($this->datesdata['to']['enabled']) {
+            $datesformdata['filterdatetopopover'] = $this->datesdata['to'];
+        }
+
+        $output->filterdatesname = $this->datesbuttontext;
+        $datesform = new forumreport_summary\form\dates_filter_form();
+        $datesform->set_data($datesformdata);
+        $output->filterdatesform = $datesform->render();
+
+         // Set dates filter data within filters form.
+        $disableddate = [
+            'day' => '',
+            'month' => '',
+            'year' => '',
+            'enabled' => '0',
+        ];
+        $datefromdata = ['type' => 'from'] + ($this->datesdata['from']['enabled'] ? $this->datesdata['from'] : $disableddate);
+        $datetodata = ['type' => 'to'] + ($this->datesdata['to']['enabled'] ? $this->datesdata['to'] : $disableddate);
+        $output->filterdatesdata = [$datefromdata, $datetodata];
+
         return $output;
     }
 }
index a6d565c..3032c49 100644 (file)
@@ -44,6 +44,9 @@ class summary_table extends table_sql {
     /** Groups filter type */
     const FILTER_GROUPS = 2;
 
+    /** Dates filter type */
+    const FILTER_DATES = 3;
+
     /** Table to store summary data extracted from the log table */
     const LOG_SUMMARY_TEMP_TABLE = 'forum_report_summary_counts';
 
@@ -146,23 +149,26 @@ class summary_table extends table_sql {
         // Define configs.
         $this->define_table_configs();
 
-        // Define the basic SQL data and object format.
-        $this->define_base_sql();
-
         // Apply relevant filters.
+        $this->define_base_filter_sql();
         $this->apply_filters($filters);
+
+        // Define the basic SQL data and object format.
+        $this->define_base_sql();
     }
 
     /**
-     * Provides the string name of each filter type.
+     * Provides the string name of each filter type, to be used by errors.
+     * Note: This does not use language strings as the value is injected into error strings.
      *
      * @param int $filtertype Type of filter
      * @return string Name of the filter
      */
-    public function get_filter_name(int $filtertype): string {
+    protected function get_filter_name(int $filtertype): string {
         $filternames = [
             self::FILTER_FORUM => 'Forum',
             self::FILTER_GROUPS => 'Groups',
+            self::FILTER_DATES => 'Dates',
         ];
 
         return $filternames[$filtertype];
@@ -362,6 +368,38 @@ class summary_table extends table_sql {
 
                 break;
 
+            case self::FILTER_DATES:
+                if (!isset($values['from']['enabled']) || !isset($values['to']['enabled']) ||
+                        ($values['from']['enabled'] && !isset($values['from']['timestamp'])) ||
+                        ($values['to']['enabled'] && !isset($values['to']['timestamp']))) {
+                    $paramcounterror = true;
+                } else {
+                    $this->sql->filterbase['dates'] = '';
+                    $this->sql->filterbase['dateslog'] = '';
+                    $this->sql->filterbase['dateslogparams'] = [];
+
+                    // From date.
+                    if ($values['from']['enabled']) {
+                        // If the filter was enabled, include the date restriction.
+                        // Needs to form part of the base join to posts, so will be injected by define_base_sql().
+                        $this->sql->filterbase['dates'] .= " AND p.created >= :fromdate";
+                        $this->sql->params['fromdate'] = $values['from']['timestamp'];
+                        $this->sql->filterbase['dateslog'] .= ' AND timecreated >= :fromdate';
+                        $this->sql->filterbase['dateslogparams']['fromdate'] = $values['from']['timestamp'];
+                    }
+
+                    // To date.
+                    if ($values['to']['enabled']) {
+                        // If the filter was enabled, include the date restriction.
+                        // Needs to form part of the base join to posts, so will be injected by define_base_sql().
+                        $this->sql->filterbase['dates'] .= " AND p.created <= :todate";
+                        $this->sql->params['todate'] = $values['to']['timestamp'];
+                        $this->sql->filterbase['dateslog'] .= ' AND timecreated <= :todate';
+                        $this->sql->filterbase['dateslogparams']['todate'] = $values['to']['timestamp'];
+                    }
+                }
+
+                break;
             default:
                 throw new coding_exception("Report filter type '{$filtertype}' not found.");
                 break;
@@ -385,6 +423,8 @@ class summary_table extends table_sql {
         $this->is_downloadable(true);
         $this->no_sorting('select');
         $this->set_attribute('id', 'forumreport_summary_table');
+        $this->sql = new \stdClass();
+        $this->sql->params = [];
     }
 
     /**
@@ -395,8 +435,6 @@ class summary_table extends table_sql {
     protected function define_base_sql(): void {
         global $USER;
 
-        $this->sql = new \stdClass();
-
         $userfields = get_extra_user_fields($this->context);
         $userfieldssql = \user_picture::fields('u', $userfields);
 
@@ -429,7 +467,8 @@ class summary_table extends table_sql {
                                     JOIN {forum_discussions} d ON d.forum = f.id
                                LEFT JOIN {forum_posts} p ON p.discussion =  d.id
                                      AND p.userid = ue.userid
-                                     ' . $privaterepliessql . '
+                                     ' . $privaterepliessql
+                                       . $this->sql->filterbase['dates'] . '
                                LEFT JOIN (
                                             SELECT COUNT(fi.id) AS attcount, fi.itemid AS postid, fi.userid
                                               FROM {files} fi
@@ -457,7 +496,7 @@ class summary_table extends table_sql {
             $this->sql->basefields .= ', SUM(CASE WHEN p.charcount IS NOT NULL THEN p.charcount ELSE 0 END) AS charcount';
         }
 
-        $this->sql->params = [
+        $this->sql->params += [
             'component' => 'mod_forum',
             'courseid' => $this->cm->course,
         ] + $privaterepliesparams;
@@ -467,7 +506,14 @@ class summary_table extends table_sql {
             $this->sql->basewhere .= ' AND ue.userid = :userid';
             $this->sql->params['userid'] = $this->userid;
         }
+    }
 
+    /**
+     * Instantiate the properties to store filter values.
+     *
+     * @return void.
+     */
+    protected function define_base_filter_sql(): void {
         // Filter values will be populated separately where required.
         $this->sql->filterfields = '';
         $this->sql->filterfromjoins = '';
@@ -533,6 +579,13 @@ class summary_table extends table_sql {
 
         // Apply groups filter.
         $this->add_filter(self::FILTER_GROUPS, $filters['groups']);
+
+        // Apply dates filter.
+        $datevalues = [
+            'from' => $filters['datefrom'],
+            'to' => $filters['dateto'],
+        ];
+        $this->add_filter(self::FILTER_DATES, $datevalues);
     }
 
     /**
@@ -614,11 +667,17 @@ class summary_table extends table_sql {
             $logtable = $this->logreader->get_internal_log_table_name();
             $nonanonymous = 'AND anonymous = 0';
         }
-        $params = ['contextid' => $contextid];
+
+        // Apply dates filter if applied.
+        $datewhere = $this->sql->filterbase['dateslog'] ?? '';
+        $dateparams = $this->sql->filterbase['dateslogparams'] ?? [];
+
+        $params = ['contextid' => $contextid] + $dateparams;
         $sql = "INSERT INTO {" . self::LOG_SUMMARY_TEMP_TABLE . "} (userid, viewcount)
                      SELECT userid, COUNT(*) AS viewcount
                        FROM {" . $logtable . "}
                       WHERE contextid = :contextid
+                            $datewhere
                             $nonanonymous
                    GROUP BY userid";
         $DB->execute($sql, $params);
index 9b8e828..9872fc9 100644 (file)
@@ -36,6 +36,8 @@ $filters = [];
 // Establish filter values.
 $filters['forums'] = [$forumid];
 $filters['groups'] = optional_param_array('filtergroups', [], PARAM_INT);
+$filters['datefrom'] = optional_param_array('datefrom', ['enabled' => 0], PARAM_INT);
+$filters['dateto'] = optional_param_array('dateto', ['enabled' => 0], PARAM_INT);
 
 $download = optional_param('download', '', PARAM_ALPHA);
 
index 50d1068..71bb442 100644 (file)
@@ -26,6 +26,12 @@ $string['attachmentcount'] = 'Number of attachments';
 $string['charcount'] = 'Character count';
 $string['viewcount'] = 'Number of views';
 $string['earliestpost'] = 'Earliest post';
+$string['filter:datesbuttonlabel'] = 'Open the dates filter';
+$string['filter:datesname'] = 'Dates';
+$string['filter:datesfrom'] = 'From {$a}';
+$string['filter:datesfromto'] = '{$a->datefrom} - {$a->dateto}';
+$string['filter:datesorderwarning'] = 'Please ensure the "From" date selected is earlier than the "To" date selected.';
+$string['filter:datesto'] = 'To {$a}';
 $string['filter:groupsbuttonlabel'] = 'Open the groups filter';
 $string['filter:groupsname'] = 'Groups';
 $string['filter:groupscountall'] = 'Groups (all)';
diff --git a/mod/forum/report/summary/templates/filter_dates.mustache b/mod/forum/report/summary/templates/filter_dates.mustache
new file mode 100644 (file)
index 0000000..d39c897
--- /dev/null
@@ -0,0 +1,50 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template forumreport_summary/filter_dates
+
+    Summary report dates filter.
+
+    Example context (json):
+    {
+        "filterdatesname": "Dates",
+        "filterdatesdata": [
+            {
+                "type": "from",
+                "timestamp": "1571377510",
+                "enabled": "1"
+            },
+            {
+                "type": "to",
+                "timestamp": "1571377510",
+                "enabled": "1"
+            }
+        ]
+    }
+}}
+
+
+<button type="button" id="filter-dates-button" class="btn btn-primary rounded p-1" aria-expanded="false"
+        aria-haspopup="true" aria-label="{{# str}} filter:datesbuttonlabel, forumreport_summary {{/ str}}">
+    {{filterdatesname}}
+</button>
+
+{{! Hidden dates fields to populate from visible mform in popover. }}
+{{#filterdatesdata}}
+<input type="hidden" name="date{{type}}[timestamp]" value="{{timestamp}}">
+<input type="hidden" name="date{{type}}[enabled]" value="{{enabled}}">
+{{/filterdatesdata}}
diff --git a/mod/forum/report/summary/templates/filter_dates_popover.mustache b/mod/forum/report/summary/templates/filter_dates_popover.mustache
new file mode 100644 (file)
index 0000000..a95b450
--- /dev/null
@@ -0,0 +1,40 @@
+{{!
+    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 forumreport_summary/filter_dates
+
+    Summary report dates filter.
+
+    Example context (json):
+    {
+        "filterdatesform": "<div>HTML for date filters</div>"
+    }
+}}
+
+<div id="filter-dates-popover" class="popover filter-dates-popover mt-3 hidden">
+    <h3 class="popover-header">{{# str}} filter:datesname, forumreport_summary {{/ str}}</h3>
+    <div class="popover-body" data-region="filter-dates">
+        {{{filterdatesform}}}
+        <div id="dates-filter-warning" class="form-control-feedback text-danger hidden float-right">
+            {{# str}} filter:datesorderwarning, forumreport_summary {{/ str}}
+        </div>
+        <br>&nbsp;
+        <button type="button" class="filter-save float-right btn btn-link p-0" aria-label="{{# str}} save {{/ str}}">
+            <strong>{{# str}} save {{/ str}}</strong>
+        </button>
+    </div>
+</div>
diff --git a/mod/forum/report/summary/templates/filter_groups.mustache b/mod/forum/report/summary/templates/filter_groups.mustache
new file mode 100644 (file)
index 0000000..939ed55
--- /dev/null
@@ -0,0 +1,70 @@
+{{!
+    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 forumreport_summary/filter_groups
+
+    Summary report groups filter.
+
+    Example context (json):
+    {
+        "hasgroups": true,
+        "filtergroupsname" : "Groups (all)",
+        "filtergroups": [
+            {
+                "groupid": "1",
+                "groupname": "Group A",
+                "checked": true
+            },
+            {
+                "groupid": "3",
+                "groupname": "Group C",
+                "checked": false
+            }
+        ]
+    }
+}}
+
+{{#hasgroups}}
+<button type="button" id="filter-groups-button" class="btn btn-primary rounded p-1 ml-2" aria-expanded="false"
+        aria-haspopup="true" aria-label="{{# str}} filter:groupsbuttonlabel, forumreport_summary {{/ str}}">
+    {{filtergroupsname}}
+</button>
+
+{{! Groups filter popover }}
+<div id="filter-groups-popover" class="popover m-t-1 hidden">
+    <h3 class="popover-header">{{# str}} filter:groupsname, forumreport_summary {{/ str}}</h3>
+    <div class="popover-body" data-region="filter-groups">
+        <div class="form-check filter-scrollable">
+            {{#filtergroups}}
+            <input id="filtergroups{{groupid}}" class="form-check-input" type="checkbox" name="filtergroups[]"
+                value="{{groupid}}" {{#checked}} checked="checked" {{/checked}}>
+            <label class="form-check-label pt-1" for="filtergroups{{groupid}}">{{groupname}}</label>
+            <br>
+            {{/filtergroups}}
+        </div>
+        <div class="filter-actions">
+            <button type="button" class="select-all btn btn-link p-0 pr-1" aria-label="{{# str}} selectall {{/ str}}">{{# str}} selectall {{/ str}}</button>
+            <div class="float-right">
+                <button type="button" class="filter-clear btn btn-link p-0 px-1" aria-label="{{# str}} clear {{/ str}}">{{# str}} clear {{/ str}}</button>
+                <button type="button" class="filter-save btn btn-link p-0" aria-label="{{# str}} save {{/ str}}">
+                    <strong>{{# str}} save {{/ str}}</strong>
+                </button>
+            </div>
+        </div>
+    </div>
+</div>
+{{/hasgroups}}
index 211546c..f4dd7fb 100644 (file)
                 "groupname": "Group C",
                 "checked": false
             }
+        ],
+        "filterdatesname": "Dates",
+        "filterdatesform": "<div>HTML for date filters</div>",
+        "filterdatesdata": [
+            {
+                "type": "from",
+                "timestamp": "510969600",
+                "enabled": "1"
+            },
+            {
+                "type": "to",
+                "timestamp": "725673600",
+                "enabled": "1"
+            }
         ]
     }
 }}
 
-<div class="p-b-3" data-report-id="{{uniqid}}">
+<div class="pb-4" data-report-id="{{uniqid}}">
     <form id="filtersform" name="filtersform" method="post" action="{{actionurl}}">
         <input type="hidden" name="submitted" value="true">
-        <div id="filtersbuttons">
-            {{#hasgroups}}
-            <button type="button" id="filter-groups-button" class="btn btn-primary rounded p-1" aria-expanded="false"
-                    aria-haspopup="true" aria-label="{{# str}} filter:groupsbuttonlabel, forumreport_summary {{/ str}}">
-                {{filtergroupsname}}
-            </button>
 
-            {{! Start groups filter popover}}
-            <div id="filter-groups-popover" class="popover m-t-1 hidden">
-                <h3 class="popover-header">{{# str}} filter:groupsname, forumreport_summary {{/ str}}</h3>
-                <div class="popover-body" data-region="filter-groups">
-                    <div class="form-check filter-scrollable">
-                        {{#filtergroups}}
-                        <input id="filtergroups{{groupid}}" class="form-check-input" type="checkbox" name="filtergroups[]"
-                            value="{{groupid}}" {{#checked}} checked="checked" {{/checked}}>
-                        <label class="form-check-label pt-1" for="filtergroups{{groupid}}">{{groupname}}</label>
-                        <br>
-                        {{/filtergroups}}
-                    </div>
-                    <div class="filter-actions">
-                        <button type="button" class="select-all btn btn-link p-0 pr-1" aria-label="{{# str}} selectall {{/ str}}">{{# str}} selectall {{/ str}}</button>
-                        <div class="float-right">
-                            <button type="button" class="filter-clear btn btn-link p-0 px-1" aria-label="{{# str}} clear {{/ str}}">{{# str}} clear {{/ str}}</button>
-                            <button type="button" class="filter-save btn btn-link p-0" aria-label="{{# str}} save {{/ str}}">
-                                <strong>{{# str}} save {{/ str}}</strong>
-                            </button>
-                        </div>
-                    </div>
-                </div>
-            </div>
-            {{! End groups filter popover}}
-            {{/hasgroups}}
+        <div id="filtersbuttons">
+            {{> forumreport_summary/filter_dates}}
+            {{> forumreport_summary/filter_groups}}
         </div>
     </form>
+
+    {{! Dates filter popover - mform must exist outside of the filtersform }}
+    {{> forumreport_summary/filter_dates_popover}}
 </div>
 
 {{#js}}
index d695f93..5a0c84e 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version  = 2019090200;
+$plugin->version  = 2019101800;
 $plugin->requires = 2019071900;
 $plugin->component = 'forumreport_summary';
index 2140770..a7a9586 100644 (file)
     }}data-gradable-itemtype="forum" {{!
     }}{{#firstgradeduserid}}data-initialuserid="{{firstgradeduserid}}" {{/firstgradeduserid}}{{!
     }}>
-    {{{groupchangemenu}}}
 
     {{#notifications}}
         {{> core/notification}}
     {{/notifications}}
 
+    {{{groupchangemenu}}}
+
     {{#forum.capabilities.create}}
         <div class="p-t-1 p-b-1">
             <a class="btn btn-primary" data-toggle="collapse" href="#collapseAddForm">
@@ -81,7 +82,7 @@
                 {{#str}} showingcountoftotaldiscussions, mod_forum, {"count": "{{visiblediscussioncount}}", "total":"{{totaldiscussioncount}}"} {{/str}}
             </span>
             <table
-                class="table table-hover discussion-list"
+                class="table discussion-list"
                 aria-label='{{#str}} showingcountoftotaldiscussions, mod_forum, {"count": "{{visiblediscussioncount}}", "total":"{{totaldiscussioncount}}"} {{/str}}'
                 aria-describedby="discussion-table-description-{{uniqid}}"
             >
                             {{/state.sortorder.isdiscussiondesc}}
                         </th>
                         {{#forum.state.groupmode}}
-                        <th scope="col" class="group">
+                        <th scope="col" class="group px-3">
                             {{#state.sortorder.isgroupdesc}}
                                 <a href="{{{forum.urls.sortgroupasc}}}" aria-label="{{#str}}discussionlistsortbygroupasc, mod_forum{{/str}}">{{#str}}group{{/str}}</a> <span class="text-primary">{{#pix}}t/downlong, core, {{#str}}desc, core{{/str}}{{/pix}}</span>
                             {{/state.sortorder.isgroupdesc}}
                             {{/state.sortorder.isgroupdesc}}
                         </th>
                         {{/forum.state.groupmode}}
-                        <th scope="col" class="author">
+                        <th scope="col" class="author px-3">
                             {{#state.sortorder.isstarterdesc}}
                                 <a href="{{{forum.urls.sortstarterasc}}}" aria-label="{{#str}}discussionlistsortbystarterasc, mod_forum{{/str}}">{{#str}}startedby, mod_forum{{/str}}</a> <span class="text-primary">{{#pix}}t/downlong, core, {{#str}}desc, core{{/str}}{{/pix}}</span>
                             {{/state.sortorder.isstarterdesc}}
                                 {{/state.sortorder.isstarterasc}}
                             {{/state.sortorder.isstarterdesc}}
                         </th>
-                        <th scope="col" class="lastpost">
+                        <th scope="col" class="lastpost px-3">
                             {{#state.sortorder.islastpostdesc}}
                                 <a href="{{{forum.urls.sortlastpostasc}}}" aria-label="{{#str}}discussionlistsortbylastpostasc, mod_forum{{/str}}">{{#str}}lastpost, mod_forum{{/str}}</a> <span class="text-primary">{{#pix}}t/downlong, core, {{#str}}desc, core{{/str}}{{/pix}}</span>
                             {{/state.sortorder.islastpostdesc}}
                             {{/state.sortorder.islastpostdesc}}
                         </th>
                         {{#forum.capabilities.viewdiscussions}}
-                            <th scope="col" class="text-center">
+                            <th scope="col" class="text-center px-2">
                                 {{#state.sortorder.isrepliesdesc}}
                                     <a href="{{{forum.urls.sortrepliesasc}}}" aria-label="{{#str}}discussionlistsortbyrepliesasc, mod_forum{{/str}}">{{#str}}replies, mod_forum{{/str}}</a> <span class="text-primary">{{#pix}}t/downlong, core, {{#str}}desc, core{{/str}}{{/pix}}</span>
                                 {{/state.sortorder.isrepliesdesc}}
                                 {{/forum.userstate.tracked}}
                             </th>
                         {{/forum.capabilities.viewdiscussions}}
+                        {{#forum.capabilities.subscribe}}
+                            <th scope="col" class="text-center px-2">{{#str}} notsubscribed, mod_forum {{/str}}</th>
+                        {{/forum.capabilities.subscribe}}
                         <th scope="col" class="discussionsubscription">
                             <span class="accesshide">{{#str}}actions{{/str}}</span>
                         </th>
                                         <span class="badge badge-danger rounded" data-region="locked-label" {{^discussion.locked}}hidden{{/discussion.locked}}>
                                             {{#str}}locked, forum{{/str}}
                                         </span>
-                                        <span class="badge badge-light rounded" data-region="subscribed-label" {{^discussion.userstate.subscribed}}hidden{{/discussion.userstate.subscribed}}>
-                                            {{#str}}subscribed, forum{{/str}}
-                                        </span>
                                         {{#discussion.timed.istimed}}
                                         <span>
                                              <button class="btn badge badge-primary rounded border-0" data-region="timed-label"
                                 </div>
                             </th>
                             {{#forum.state.groupmode}}
-                                <td class="group align-middle">
+                                <td class="group align-middle fit-content limit-width px-3">
                                     {{#discussion.group}}
                                         <img alt="{{#str}} pictureof, core, {{name}} {{/str}}"
                                              class="border rounded h-auto rounded-circle grouppicture"
                                     {{/discussion.group}}
                                 </td>
                             {{/forum.state.groupmode}}
-                            <td class="author align-middle">
+                            <td class="author align-middle fit-content limit-width px-3">
                                 {{#firstpostauthor}}
-                                    <div class="d-flex flex-row">
+                                    <div class="d-flex">
                                         <div class="align-middle p-0">
                                             <img class="rounded-circle userpicture" src="{{urls.profileimage}}"
                                                  alt="{{#str}}pictureof, moodle, {{fullname}}{{/str}}"
                                                  title="{{#str}}pictureof, moodle, {{fullname}}{{/str}}" >
                                         </div>
-                                        <div class="align-middle">
-                                            <div>{{fullname}}</div>
-                                            <div>
-                                                <small>{{#userdate}}{{discussion.times.created}}, {{#str}}strftimedatemonthabbr, langconfig{{/str}}{{/userdate}}</small>
+                                        <div class="author-info align-middle">
+                                            <div class="mb-1 line-height-3 text-truncate">{{fullname}}</div>
+                                            <div class="line-height-3">
+                                                {{#userdate}}{{discussion.times.created}}, {{#str}}strftimedatemonthabbr, langconfig{{/str}}{{/userdate}}
                                             </div>
                                         </div>
                                     </div>
                                 {{/firstpostauthor}}
                             </td>
-                            <td class="text-left align-middle">
+                            <td class="text-left align-middle fit-content limit-width px-3">
                                 {{! TODO Check q&a, eachuser }}
-                                {{#latestpostid}}
-                                    <a href="{{{discussion.urls.viewlatest}}}" title="{{#userdate}}{{discussion.times.modified}},{{#str}}strftimerecentfull{{/str}}{{/userdate}}">
-                                        {{#userdate}}
-                                            {{discussion.times.modified}}, {{#str}}strftimedateshortmonthabbr, langconfig{{/str}}
-                                        {{/userdate}}
-                                    </a>
-                                {{/latestpostid}}
+                                {{#latestpostauthor}}
+                                    <div class="d-flex">
+                                        <div class="align-middle p-0">
+                                            <img class="rounded-circle userpicture" src="{{urls.profileimage}}"
+                                                 alt="{{#str}}pictureof, moodle, {{fullname}}{{/str}}"
+                                                 title="{{#str}}pictureof, moodle, {{fullname}}{{/str}}" >
+                                        </div>
+                                        <div class="author-info align-middle">
+                                            <div class="mb-1 line-height-3 text-truncate">{{fullname}}</div>
+                                            <div class="line-height-3">
+                                                {{#latestpostid}}
+                                                    <a href="{{{discussion.urls.viewlatest}}}" title="{{#userdate}}{{discussion.times.modified}},{{#str}}strftimerecentfull{{/str}}{{/userdate}}">
+                                                        {{#userdate}}{{discussion.times.created}}, {{#str}}strftimedatemonthabbr, langconfig{{/str}}{{/userdate}}
+                                                    </a>
+                                                {{/latestpostid}}
+                                            </div>
+                                        </div>
+                                    </div>
+                                {{/latestpostauthor}}
                             </td>
                             {{#forum.capabilities.viewdiscussions}}
-                                <td class="p-0 text-center align-middle">
+                                <td class="p-0 text-center align-middle fit-content px-2">
                                     <span>{{replies}}</span>
                                     {{#forum.userstate.tracked}}
                                         {{#unread}}
                                     {{/forum.userstate.tracked}}
                                 </td>
                             {{/forum.capabilities.viewdiscussions}}
-                            <td class="p-0 align-middle" data-container="discussion-summary-actions" style="width: 1px;">
+                            {{#forum.capabilities.subscribe}}
+                                <td class="text-center align-middle fit-content px-2">
+                                    {{#discussion}}
+                                        <span class="switch sr-only-label">
+                                            <input
+                                                type="checkbox"
+                                                id="subscription-toggle-{{id}}"
+                                                data-type="subscription-toggle"
+                                                data-action="toggle"
+                                                data-discussionid="{{id}}"
+                                                data-forumid="{{forumid}}"
+                                                {{#userstate.subscribed}}data-targetstate="0" checked{{/userstate.subscribed}}
+                                                {{^userstate.subscribed}}data-targetstate="1"{{/userstate.subscribed}}
+                                            >
+                                            <label for="subscription-toggle-{{id}}">
+                                                {{#userstate.subscribed}}
+                                                    {{#str}}unsubscribediscussion, forum{{/str}}
+                                                {{/userstate.subscribed}}
+                                                {{^userstate.subscribed}}
+                                                    {{#str}}subscribediscussion, forum{{/str}}
+                                                {{/userstate.subscribed}}
+                                            </label>
+                                        </span>
+                                    {{/discussion}}
+                                </td>
+                            {{/forum.capabilities.subscribe}}
+                            <td class="p-0 align-middle fit-content" data-container="discussion-summary-actions">
                                 {{#discussion}}
                                     <div class="d-flex flex-wrap justify-content-end icon-no-margin">
                                         {{#hasanyactions}}
index a7dc0d2..bec4bec 100644 (file)
         {{/istimelocked}}
     {{/capabilities.manage}}
 
-    {{#modeselectorform}}
-        <h3 class="h6 mt-4 font-weight-bold">{{#str}} view, core {{/str}}</h3>
-        {{{.}}}
-    {{/modeselectorform}}
-
     {{#movediscussion}}
         <h3 class="h6 mt-4 font-weight-bold">{{#str}} move, core {{/str}}</h3>
         {{{.}}}
index 8a00523..23750ca 100644 (file)
@@ -50,7 +50,7 @@
     }
 }}
 <div class="ml-auto dropdown">
-    <a href="#" class="{{^settings.excludetext}}dropdown-toggle{{/settings.excludetext}} btn btn-link"
+    <a href="#" class="{{^settings.excludetext}}dropdown-toggle{{/settings.excludetext}} btn btn-link {{#settings.togglemoreicon}}btn-icon colour-inherit text-decoration-none d-flex align-items-center justify-content-center{{/settings.togglemoreicon}}"
         role="button"
         data-toggle="dropdown"
         aria-haspopup="true"
@@ -83,7 +83,9 @@
                 {{> forum/discussion_lock_toggle }}
             {{/istimelocked}}
         {{/capabilities.manage}}
-        {{> forum/discussion_subscription_toggle }}
+        {{^settings.excludesubscription}}
+            {{> forum/discussion_subscription_toggle }}
+        {{/settings.excludesubscription}}
         {{#unread}}
             {{> forum/mark_as_read }}
         {{/unread}}
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template mod_forum/forum_discussion_modern
+    @template mod_forum/forum_discussion_nested_v2
 
     Template for displaying a single forum discussion.
 
@@ -30,7 +30,7 @@
     }
 }}
 
-<div id="discussion-container-{{uniqid}}" data-content="forum-discussion" class="mt-4">
+<div id="discussion-container-{{uniqid}}" data-content="forum-discussion">
     {{#html}}
         {{> mod_forum/discussion_settings_drawer}}
     {{/html}}
         {{> core/notification}}
     {{/notifications}}
 
+    <div class="mb-5">{{{html.modeselectorform}}}</div>
+
     {{{html.posts}}}
 </div>
 {{#js}}
-require(['jquery', 'mod_forum/discussion_modern'], function($, Discussion) {
+require(['jquery', 'mod_forum/discussion_nested_v2'], function($, Discussion) {
     var root = $('#discussion-container-{{uniqid}}');
     Discussion.init(root, {
         {{#loggedinuser}}
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template mod_forum/forum_discussion_modern_first_post
+    @template mod_forum/forum_discussion_nested_v2_first_post
 
     Template to render a single post from a discussion.
 
@@ -47,7 +47,7 @@
     >
         {{#isfirstunread}}<a id="unread" aria-hidden="true"></a>{{/isfirstunread}}
 
-        <div class="author-image-container d-inline-block pt-2">
+        <div class="author-image-container d-inline-block">
             {{^isdeleted}}
                 {{#author}}
                     {{#urls.profileimage}}
@@ -76,7 +76,7 @@
         <div class="d-flex flex-column w-100"  data-region-content="forum-post-core">
             <header id="post-header-{{uniqid}}">
                 {{^isdeleted}}
-                    <div class="d-flex flex-wrap align-items-center mb-3">
+                    <div class="d-flex flex-wrap align-items-center mb-1">
                         <address class="mb-0 mr-2" tabindex="-1">
                             {{#author}}
                                 <h4 class="h6 d-lg-inline-block mb-0 author-header mr-1">
                             {{/author.groups}}
 
                             {{^readonly}}
-                                {{#capabilities.view}}
-                                    <a
-                                        href="{{{urls.view}}}"
-                                        class="d-inline-block mr-1 icon-no-margin"
-                                        title="{{#str}} permanentlinktopost, mod_forum {{/str}}"
-                                    >
-                                        {{#pix}} e/insert_edit_link, core {{/pix}}
-                                    </a>
-                                {{/capabilities.view}}
-
                                 {{#showactionmenu}}
                                     <div class="dropdown">
                                         <button
                                             {{#pix}} i/moremenu {{/pix}}
                                         </button>
                                         <!-- inline style to fix RTL placement bug -->
-                                        <div class="dropdown-menu" aria-labelledby="post-actions-menu-{{uniqid}}" style="right: auto">
+                                        <div class="dropdown-menu dropdown-menu-right rounded-lg shadow border-0" aria-labelledby="post-actions-menu-{{uniqid}}" style="right: auto">
                                             {{#capabilities}}
+                                                {{#view}}
+                                                    <a
+                                                        href="{{{urls.view}}}"
+                                                        class="dropdown-item"
+                                                        role="menuitem"
+                                                    >
+                                                        {{#str}} permalink, mod_forum {{/str}}
+                                                    </a>
+                                                {{/view}}
                                                 {{#controlreadstatus}}
                                                     {{#unread}}
                                                         <a
                 {{/hasreplycount}}
             </header>
 
-            <div class="post-message" id="post-content-{{id}}">
+            <div class="post-message pr-md-5" id="post-content-{{id}}">
                 {{{message}}}
             </div>
 
                         <div class="d-flex mt-3">
                             {{#capabilities.reply}}
                                 <button
-                                    class="btn btn-primary btn-lg"
+                                    class="btn btn-primary btn-lg font-weight-bold px-4"
                                     data-href="{{{urls.reply}}}"
                                     data-post-id="{{id}}"
                                     data-action="create-inpage-reply"
         <div class="indent replies-container" data-region="replies-container">
             {{#hasreplies}}
                 {{#replies}}
-                    {{> mod_forum/forum_discussion_modern_post_reply }}
+                    {{> mod_forum/forum_discussion_nested_v2_post_reply }}
                 {{/replies}}
             {{/hasreplies}}
         </div>
@@ -29,7 +29,7 @@
     {
     }
 }}
-{{< mod_forum/forum_discussion_modern_first_post }}
+{{< mod_forum/forum_discussion_nested_v2_first_post }}
     {{$subject}}
         <h3
             {{#isdeleted}}class="h6 font-weight-bold"{{/isdeleted}}
@@ -43,7 +43,7 @@
                 {{#capabilities.reply}}
                     <div class="d-flex mt-1">
                         <button
-                            class="font-weight-bold btn btn-link btn-lg px-0"
+                            class="font-weight-bold btn btn-link px-0"
                             data-href="{{{urls.reply}}}"
                             data-post-id="{{id}}"
                             data-action="create-inpage-reply"
         <div class="indent replies-container" data-region="replies-container">
             {{#hasreplies}}
                 {{#replies}}
-                    {{> mod_forum/forum_discussion_modern_post_reply }}
+                    {{> mod_forum/forum_discussion_nested_v2_post_reply }}
                 {{/replies}}
             {{/hasreplies}}
         </div>
         <div class="indent inline-reply-container" data-region="inpage-reply-container"></div>
     {{/replies}}
-{{/ mod_forum/forum_discussion_modern_first_post }}
+{{/ mod_forum/forum_discussion_nested_v2_first_post }}
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template mod_forum/forum_discussion_modern_posts
+    @template mod_forum/forum_discussion_nested_v2_posts
 
     Template to render a list of posts for a discussion.
 
@@ -30,5 +30,5 @@
     }
 }}
 {{#posts}}
-    {{> mod_forum/forum_discussion_modern_first_post }}
+    {{> mod_forum/forum_discussion_nested_v2_first_post }}
 {{/posts}}
index 44c2a04..f402197 100644 (file)
@@ -31,6 +31,6 @@
 }}
 <div data-region="posts-modal">
     {{#.}}
-        {{> mod_forum/forum_discussion_modern_post_reply }}
+        {{> mod_forum/forum_discussion_nested_v2_post_reply }}
     {{/.}}
 </div>
index 16b7df8..323e993 100644 (file)
         <div class="hr-sect mt-0">{{name}}</div>
         {{#posts}}
             {{#parent}}
-                {{> mod_forum/forum_discussion_modern_post_reply }}
+                {{> mod_forum/forum_discussion_nested_v2_post_reply }}
             {{/parent}}
             {{#starter}}
-                {{> mod_forum/forum_discussion_modern_first_post }}
+                {{> mod_forum/forum_discussion_nested_v2_first_post }}
                 <a class="btn btn-outline-dark"
                    role="button"
                    data-action="view-context"
@@ -52,7 +52,7 @@
                 <div class="forum-post-container" data-region="replies-container">
                     <div class="indent replies-container" data-region="replies-container">
                         <div class="indent replies-container" data-region="replies-container">
-                            {{> mod_forum/forum_discussion_modern_post_reply }}
+                            {{> mod_forum/forum_discussion_nested_v2_post_reply }}
                             <a class="btn btn-outline-dark"
                                role="button"
                                data-action="view-context"
@@ -15,9 +15,9 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template mod_forum/inpage_reply_modern
+    @template mod_forum/inpage_reply_v2
 
-    In page reply HTML for the "modern" discussion display mode.
+    In page reply HTML for the nested v2 discussion display mode.
 
     Classes required for JS:
     * none
@@ -73,7 +73,7 @@
                 <input type="hidden" name="reply" value="{{postid}}"/>
                 <input type="hidden" name="sesskey" value="{{sesskey}}"/>
                 <div class="d-flex mt-3 align-items-center flex-wrap">
-                    <button class="btn btn-primary" data-action="forum-inpage-submit">
+                    <button class="btn btn-primary font-weight-bold px-3" data-action="forum-inpage-submit">
                         <span data-region="submit-text">{{#str}} post, core {{/str}}</span>
                         <span data-region="loading-icon-container" class="hidden">{{> core/loading }}</span>
                     </button>
index f7da214..f2acb4c 100644 (file)
@@ -44,7 +44,7 @@
         <span class="dir-ltr-hide">{{#pix}} t/left, core {{/pix}}</span>
         <span class="dir-rtl-hide">{{#pix}} t/right, core {{/pix}}</span>
     </button>
-    <h2 class="font-weight-bold h3 mb-0">
+    <h2 class="font-weight-bold h4 mb-0">
         {{#str}} settings, core {{/str}}
     </h2>
 </div>
index 29241df..d167882 100644 (file)
@@ -252,6 +252,66 @@ class behat_mod_forum extends behat_base {
         }
     }
 
+    /**
+     * Checks if the user can subscribe to the forum.
+     *
+     * @Given /^I can subscribe to this forum$/
+     */
+    public function i_can_subscribe_to_this_forum() {
+        if ($this->running_javascript()) {
+            $this->execute('behat_general::i_click_on', [get_string('actionsmenu'), 'link']);
+        }
+
+        $this->execute('behat_general::assert_page_contains_text', [get_string('subscribe', 'mod_forum')]);
+
+        if ($this->running_javascript()) {
+            $this->execute('behat_general::i_click_on', [get_string('actionsmenu'), 'link']);
+        }
+    }
+
+    /**
+     * Checks if the user can unsubscribe from the forum.
+     *
+     * @Given /^I can unsubscribe from this forum$/
+     */
+    public function i_can_unsubscribe_from_this_forum() {
+        if ($this->running_javascript()) {
+            $this->execute('behat_general::i_click_on', [get_string('actionsmenu'), 'link']);
+        }
+
+        $this->execute('behat_general::assert_page_contains_text', [get_string('unsubscribe', 'mod_forum')]);
+
+        if ($this->running_javascript()) {
+            $this->execute('behat_general::i_click_on', [get_string('actionsmenu'), 'link']);
+        }
+    }
+
+    /**
+     * Subscribes to the forum.
+     *
+     * @Given /^I subscribe to this forum$/
+     */
+    public function i_subscribe_to_this_forum() {
+        if ($this->running_javascript()) {
+            $this->execute('behat_general::i_click_on', [get_string('actionsmenu'), 'link']);
+        }
+
+        $this->execute('behat_general::click_link', [get_string('subscribe', 'mod_forum')]);
+    }
+
+    /**
+     * Unsubscribes from the forum.
+     *
+     * @Given /^I unsubscribe from this forum$/
+     */
+    public function i_unsubscribe_from_this_forum() {
+        if ($this->running_javascript()) {
+            $this->execute('behat_general::i_click_on', [get_string('actionsmenu'), 'link']);
+        }
+
+        $this->execute('behat_general::click_link', [get_string('unsubscribe', 'mod_forum')]);
+    }
+
     /**
      * Fetch user ID from its username.
      *
index 3c6ee29..e193928 100644 (file)
@@ -17,6 +17,7 @@ Feature: A user can control their own subscription preferences for a discussion
     And I log in as "admin"
     And I am on "Course 1" course homepage with editing mode on
 
+  @javascript
   Scenario: An optional forum can have discussions subscribed to
     Given I add a "Forum" to section "1" and I fill the form with:
       | Forum name        | Test forum name |
@@ -33,35 +34,33 @@ Feature: A user can control their own subscription preferences for a discussion
     When I log in as "student1"
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
-    Then I should see "Subscribe to this forum"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject two" "table_row"
-    And I click on "You are not subscribed to this discussion. Click to subscribe." "link" in the "Test post subject one" "table_row"
-    And I should see "Student One will be notified of new posts in 'Test post subject one' of 'Test forum name'"
-    And I should see "Subscribe to this forum"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject two" "table_row"
-    And I click on "You are subscribed to this discussion. Click to unsubscribe." "link" in the "Test post subject one" "table_row"
-    And I should see "Student One will NOT be notified of new posts in 'Test post subject one' of 'Test forum name'"
-    And I should see "Subscribe to this forum"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject two" "table_row"
-    And I click on "You are not subscribed to this discussion. Click to subscribe." "link" in the "Test post subject one" "table_row"
-    And I should see "Student One will be notified of new posts in 'Test post subject one' of 'Test forum name'"
-    And I should see "Subscribe to this forum"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject two" "table_row"
-    And I follow "Subscribe to this forum"
+    Then I can subscribe to this forum
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
+    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I can subscribe to this forum
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
+    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I can subscribe to this forum
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
+    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I can subscribe to this forum
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
+    And I subscribe to this forum
     And I should see "Student One will be notified of new posts in 'Test forum name'"
-    And I should see "Unsubscribe from this forum"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject two" "table_row"
-    And I follow "Unsubscribe from this forum"
+    And I can unsubscribe from this forum
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
+    And I unsubscribe from this forum
     And I should see "Student One will NOT be notified of new posts in 'Test forum name'"
-    And I should see "Subscribe to this forum"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject two" "table_row"
+    And I can subscribe to this forum
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
 
+  @javascript
   Scenario: An automatic subscription forum can have discussions unsubscribed from
     Given I add a "Forum" to section "1" and I fill the form with:
       | Forum name        | Test forum name |
@@ -78,35 +77,33 @@ Feature: A user can control their own subscription preferences for a discussion
     When I log in as "student1"
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
-    Then I should see "Unsubscribe from this forum"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject two" "table_row"
-    And I click on "You are subscribed to this discussion. Click to unsubscribe." "link" in the "Test post subject one" "table_row"
-    And I should see "Student One will NOT be notified of new posts in 'Test post subject one' of 'Test forum name'"
-    And I should see "Unsubscribe from this forum"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject two" "table_row"
-    And I click on "You are not subscribed to this discussion. Click to subscribe." "link" in the "Test post subject one" "table_row"
-    And I should see "Student One will be notified of new posts in 'Test post subject one' of 'Test forum name'"
-    And I should see "Unsubscribe from this forum"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject two" "table_row"
-    And I click on "You are subscribed to this discussion. Click to unsubscribe." "link" in the "Test post subject one" "table_row"
-    And I should see "Student One will NOT be notified of new posts in 'Test post subject one' of 'Test forum name'"
-    And I should see "Unsubscribe from this forum"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject two" "table_row"
-    And I follow "Unsubscribe from this forum"
+    Then I can unsubscribe from this forum
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
+    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I can unsubscribe from this forum
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
+    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I can unsubscribe from this forum
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
+    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I can unsubscribe from this forum
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
+    And I unsubscribe from this forum
     And I should see "Student One will NOT be notified of new posts in 'Test forum name'"
-    And I should see "Subscribe to this forum"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject two" "table_row"
-    And I follow "Subscribe to this forum"
+    And I can subscribe to this forum
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
+    And I subscribe to this forum
     And I should see "Student One will be notified of new posts in 'Test forum name'"
-    And I should see "Unsubscribe from this forum"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject two" "table_row"
+    And I can unsubscribe from this forum
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
 
+  @javascript
   Scenario: A user does not lose their preferences when a forum is switch from optional to automatic
     Given I add a "Forum" to section "1" and I fill the form with:
       | Forum name        | Test forum name |
@@ -123,14 +120,13 @@ Feature: A user can control their own subscription preferences for a discussion
     And I log in as "student1"
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
-    And I should see "Subscribe to this forum"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject two" "table_row"
-    And I click on "You are not subscribed to this discussion. Click to subscribe." "link" in the "Test post subject one" "table_row"
-    And I should see "Student One will be notified of new posts in 'Test post subject one' of 'Test forum name'"
-    And I should see "Subscribe to this forum"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are not subscribed to this discussion. Click to subscribe." "link" should exist in the "Test post subject two" "table_row"
+    And I can subscribe to this forum
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
+    And I click on "label[for^=subscription-toggle]" "css_element" in the "Test post subject one" "table_row"
+    And I can subscribe to this forum
+    And "Unsubscribe from this discussion" "checkbox" should exist in the "Test post subject one" "table_row"
+    And "Subscribe to this discussion" "checkbox" should exist in the "Test post subject two" "table_row"
     And I log out
     And I log in as "admin"
     And I am on "Course 1" course homepage
@@ -143,15 +139,16 @@ Feature: A user can control their own subscription preferences for a discussion
     And I log in as "student1"
     And I am on "Course 1" course homepage
     And I follow "Test forum name"
-    And I should see "Unsubscribe from this forum"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subject one" "table_row"
-    And "You are subscribed to this discussion. Click to unsubscribe." "link" should exist in the "Test post subj