Merge branch 'install_master' of https://git.in.moodle.com/amosbot/moodle-install
authorSara Arjona <sara@moodle.com>
Fri, 8 Oct 2021 10:43:08 +0000 (12:43 +0200)
committerSara Arjona <sara@moodle.com>
Fri, 8 Oct 2021 10:43:08 +0000 (12:43 +0200)
288 files changed:
admin/classes/local/entities/task_log.php
admin/environment.xml
admin/renderer.php
admin/tests/behat/task_logs.feature
admin/tool/componentlibrary/content/moodle/accessibility/links.md [new file with mode: 0644]
admin/tool/monitor/managerules.php
admin/tool/uploadcourse/classes/step2_form.php
admin/tool/usertours/amd/build/tour.min.js
admin/tool/usertours/amd/build/tour.min.js.map
admin/tool/usertours/amd/src/tour.js
admin/tool/usertours/classes/external/tour.php
admin/tool/usertours/classes/local/forms/edittour.php
admin/tool/usertours/classes/manager.php
admin/tool/usertours/classes/output/tour.php
admin/tool/usertours/classes/tour.php
admin/tool/usertours/db/install.xml
admin/tool/usertours/db/upgrade.php
admin/tool/usertours/lang/en/tool_usertours.php
admin/tool/usertours/templates/tourstep.mustache
admin/tool/usertours/tests/behat/create_tour.feature
admin/tool/usertours/tests/behat/tour_navigation.feature
admin/tool/usertours/tests/helper_trait.php
admin/tool/usertours/tours/311_activity_information_activity_page_student.json
admin/tool/usertours/tours/311_activity_information_activity_page_teacher.json
admin/tool/usertours/tours/311_activity_information_course_page_student.json
admin/tool/usertours/tours/311_activity_information_course_page_teacher.json
admin/tool/usertours/upgrade.txt
admin/tool/usertours/version.php
availability/classes/condition.php
availability/classes/info.php
availability/classes/info_module.php
availability/condition/completion/classes/condition.php
availability/condition/grade/classes/condition.php
availability/condition/grade/tests/behat/availability_grade.feature
availability/condition/grade/tests/condition_test.php
availability/condition/group/classes/condition.php
availability/condition/group/tests/behat/availability_group.feature
availability/condition/group/tests/condition_test.php
availability/condition/grouping/classes/condition.php
availability/condition/grouping/tests/behat/availability_grouping.feature
availability/condition/profile/classes/condition.php
availability/condition/profile/tests/behat/availability_profile.feature
availability/condition/profile/tests/condition_test.php
availability/upgrade.txt
backup/moodle2/backup_stepslib.php
backup/util/ui/classes/output/copy_form.php
badges/tests/behat/criteria_activity.feature
blocks/completionstatus/tests/behat/block_completionstatus_activity_completion.feature
blocks/site_main_menu/block_site_main_menu.php
blocks/social_activities/block_social_activities.php
blocks/timeline/amd/build/event_list.min.js
blocks/timeline/amd/build/event_list.min.js.map
blocks/timeline/amd/build/view_courses.min.js
blocks/timeline/amd/build/view_courses.min.js.map
blocks/timeline/amd/src/event_list.js
blocks/timeline/amd/src/view_courses.js
blocks/timeline/lang/en/block_timeline.php
blocks/timeline/styles.css [new file with mode: 0644]
blocks/timeline/templates/course-item.mustache
blocks/timeline/templates/event-list-content.mustache
blocks/timeline/templates/event-list-item.mustache
blocks/timeline/templates/event-list.mustache
blocks/timeline/templates/view-courses.mustache
blocks/timeline/tests/behat/block_timeline_courses.feature
blocks/timeline/tests/behat/block_timeline_dates.feature
calendar/classes/external/event_exporter_base.php
calendar/templates/month_detailed.mustache
cohort/edit_form.php
completion/classes/activity_custom_completion.php
completion/classes/bulkedit_form.php
completion/classes/cm_completion_details.php
completion/classes/edit_base_form.php
completion/classes/manager.php
completion/completion_criteria_completion.php
completion/criteria/completion_criteria_activity.php
completion/tests/behat/bulk_edit_activity_completion.feature
completion/tests/behat/enable_completion_on_pass_grade.feature [new file with mode: 0644]
completion/tests/behat/enable_completion_on_view_and_grade.feature [new file with mode: 0644]
completion/tests/bulk_update_test.php
completion/tests/cm_completion_details_test.php
completion/tests/completion_criteria_test.php [new file with mode: 0644]
completion/tests/externallib_test.php
completion/upgrade.txt
config-dist.php
course/classes/deletecategory_form.php
course/classes/editcategory_form.php
course/classes/output/course_module_name.php
course/edit_form.php
course/externallib.php
course/format/classes/base.php
course/format/classes/output/local/content/cm.php [moved from course/format/classes/output/local/content/section/cm.php with 53% similarity]
course/format/classes/output/local/content/cm/availability.php [new file with mode: 0644]
course/format/classes/output/local/content/cm/cmname.php [new file with mode: 0644]
course/format/classes/output/local/content/cm/controlmenu.php [new file with mode: 0644]
course/format/classes/output/local/content/cm/title.php [new file with mode: 0644]
course/format/classes/output/local/content/section/availability.php
course/format/classes/output/local/content/section/cmitem.php
course/format/classes/output/local/state/cm.php
course/format/classes/output/section_renderer.php
course/format/renderer.php
course/format/templates/local/content/cm.mustache
course/format/templates/local/content/cm/availability.mustache [new file with mode: 0644]
course/format/templates/local/content/cm/cmname.mustache [new file with mode: 0644]
course/format/templates/local/content/cm/controlmenu.mustache [new file with mode: 0644]
course/format/templates/local/content/cm/title.mustache [new file with mode: 0644]
course/format/templates/local/content/section/availability.mustache
course/format/topics/renderer.php [moved from question/format/examview/version.php with 74% similarity]
course/format/weeks/renderer.php [moved from question/format/examview/lang/en/qformat_examview.php with 62% similarity]
course/lib.php
course/modlib.php
course/moodleform_mod.php
course/renderer.php
course/request_form.php
course/tests/courselib_test.php
course/tests/externallib_test.php
course/tests/modlib_test.php
course/upgrade.txt
course/view.php
enrol/cohort/lib.php
enrol/cohort/tests/behat/enrolcohorts.feature [new file with mode: 0644]
grade/report/user/lib.php
grade/report/user/tests/behat/user_view.feature
lang/en/admin.php
lang/en/calendar.php
lang/en/completion.php
lang/en/moodle.php
lang/en/reportbuilder.php
lib/blocklib.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/plugin_manager.php
lib/classes/report_helper.php
lib/classes/userfeedback.php
lib/completionlib.php
lib/db/install.xml
lib/db/upgrade.php
lib/form/cohort.php
lib/modinfolib.php
lib/outputrequirementslib.php
lib/spout/README.md
lib/spout/readme_moodle.txt
lib/spout/src/Spout/Common/Entity/Cell.php
lib/spout/src/Spout/Common/Entity/Style/Style.php
lib/spout/src/Spout/Common/Helper/FileSystemHelper.php
lib/spout/src/Spout/Common/Helper/StringHelper.php
lib/spout/src/Spout/Reader/Common/Manager/RowManager.php
lib/spout/src/Spout/Reader/ODS/SheetIterator.php
lib/spout/src/Spout/Reader/XLSX/Helper/CellValueFormatter.php
lib/spout/src/Spout/Reader/XLSX/Manager/SharedStringsManager.php
lib/spout/src/Spout/Reader/XLSX/Manager/WorkbookRelationshipsManager.php
lib/spout/src/Spout/Reader/XLSX/RowIterator.php
lib/spout/src/Spout/Writer/Common/Manager/RegisteredStyle.php [new file with mode: 0644]
lib/spout/src/Spout/Writer/Common/Manager/Style/PossiblyUpdatedStyle.php [new file with mode: 0644]
lib/spout/src/Spout/Writer/Common/Manager/Style/StyleManager.php
lib/spout/src/Spout/Writer/Common/Manager/Style/StyleManagerInterface.php
lib/spout/src/Spout/Writer/Common/Manager/Style/StyleRegistry.php
lib/spout/src/Spout/Writer/Common/Manager/WorkbookManagerAbstract.php
lib/spout/src/Spout/Writer/ODS/Creator/ManagerFactory.php
lib/spout/src/Spout/Writer/ODS/Manager/Style/StyleRegistry.php
lib/spout/src/Spout/Writer/ODS/Manager/WorksheetManager.php
lib/spout/src/Spout/Writer/WriterAbstract.php
lib/spout/src/Spout/Writer/XLSX/Creator/ManagerFactory.php
lib/spout/src/Spout/Writer/XLSX/Manager/Style/StyleRegistry.php
lib/spout/src/Spout/Writer/XLSX/Manager/WorksheetManager.php
lib/templates/availability_info.mustache
lib/templates/local/notification/cta.mustache
lib/templates/userfeedback_footer_link.mustache
lib/testing/generator/module_generator.php
lib/testing/tests/generator_test.php
lib/tests/completionlib_test.php
lib/tests/modinfolib_test.php
lib/tests/report_helper_test.php
lib/tests/upgradelib_test.php
lib/thirdpartylibs.xml
lib/upgrade.txt
lib/upgradelib.php
mod/assign/classes/completion/custom_completion.php
mod/assign/classes/output/assign_header.php [new file with mode: 0644]
mod/assign/classes/output/assign_submission_status.php [new file with mode: 0644]
mod/assign/classes/output/renderer.php [new file with mode: 0644]
mod/assign/db/renamedclasses.php [moved from question/format/examview/classes/privacy/provider.php with 52% similarity]
mod/assign/feedback/file/importziplib.php
mod/assign/feedback/file/locallib.php
mod/assign/feedback/offline/locallib.php
mod/assign/lib.php
mod/assign/locallib.php
mod/assign/renderable.php
mod/assign/renderer.php
mod/assign/styles.css
mod/assign/tests/locallib_test.php
mod/assign/upgrade.txt
mod/assign/version.php
mod/choice/lib.php
mod/data/classes/completion/custom_completion.php
mod/data/lib.php
mod/data/tests/behat/data_activity_completion_pass_grade.feature [new file with mode: 0644]
mod/feedback/lib.php
mod/forum/classes/completion/custom_completion.php
mod/forum/lib.php
mod/forum/tests/behat/forum_activity_completion_pass_grade.feature [new file with mode: 0644]
mod/glossary/classes/completion/custom_completion.php
mod/glossary/tests/behat/glossary_activity_completion_pass_grade.feature [new file with mode: 0644]
mod/h5pactivity/tests/behat/h5pactivity_completion_pass_grade.feature [new file with mode: 0644]
mod/lesson/classes/completion/custom_completion.php
mod/lesson/lib.php
mod/lesson/tests/behat/lesson_completion_pass_grade.feature [new file with mode: 0644]
mod/lti/tests/behat/lti_completion_pass_grade.feature [new file with mode: 0644]
mod/quiz/backup/moodle2/backup_quiz_stepslib.php
mod/quiz/backup/moodle2/restore_quiz_stepslib.php
mod/quiz/classes/completion/custom_completion.php
mod/quiz/classes/external.php
mod/quiz/db/install.xml
mod/quiz/db/upgrade.php
mod/quiz/deprecatedlib.php
mod/quiz/lang/en/deprecated.txt
mod/quiz/lang/en/quiz.php
mod/quiz/lib.php
mod/quiz/locallib.php
mod/quiz/mod_form.php
mod/quiz/tests/behat/completion_condition_attempts_used.feature
mod/quiz/tests/behat/completion_condition_passing_grade.feature
mod/quiz/tests/behat/quiz_activity_completion.feature
mod/quiz/tests/custom_completion_test.php
mod/quiz/tests/external_test.php
mod/quiz/tests/lib_test.php
mod/quiz/upgrade.txt
mod/quiz/version.php
mod/resource/locallib.php
mod/scorm/classes/completion/custom_completion.php
mod/scorm/lib.php
mod/scorm/mod_form.php
mod/upgrade.txt
mod/workshop/lib.php
mod/workshop/mod_form.php
mod/workshop/tests/behat/workshop_completion_pass_grade.feature [new file with mode: 0644]
question/format/examview/format.php [deleted file]
question/format/examview/tests/examviewformat_test.php [deleted file]
question/format/examview/tests/fixtures/examview_sample.xml [deleted file]
question/format/examview/tests/fixtures/questions.examview.xml [deleted file]
question/format/upgrade.txt
report/competency/index.php
report/completion/index.php
report/configlog/classes/local/entities/config_change.php
report/insights/insights.php
report/log/classes/renderer.php
report/log/index.php
report/log/tests/behat/course_report_log.feature
report/loglive/index.php
report/outline/index.php
report/participation/index.php
report/progress/index.php
report/stats/index.php
report/upgrade.txt
report/view.php
reportbuilder/amd/build/filters.min.js
reportbuilder/amd/build/filters.min.js.map
reportbuilder/amd/build/local/selectors.min.js
reportbuilder/amd/build/local/selectors.min.js.map
reportbuilder/amd/src/filters.js
reportbuilder/amd/src/local/selectors.js
reportbuilder/classes/external/system_report_exporter.php
reportbuilder/classes/form/filter.php
reportbuilder/classes/local/filters/base.php
reportbuilder/classes/local/filters/date.php
reportbuilder/classes/local/report/base.php
reportbuilder/templates/local/filters/area.mustache
reportbuilder/tests/local/filters/date_test.php
theme/boost/amd/build/drawers.min.js
theme/boost/amd/build/drawers.min.js.map
theme/boost/amd/build/loader.min.js
theme/boost/amd/build/loader.min.js.map
theme/boost/amd/build/scroll.min.js [deleted file]
theme/boost/amd/build/scroll.min.js.map [deleted file]
theme/boost/amd/src/drawers.js
theme/boost/amd/src/loader.js
theme/boost/amd/src/scroll.js [deleted file]
theme/boost/classes/output/core_renderer.php
theme/boost/lang/en/deprecated.txt [new file with mode: 0644]
theme/boost/lang/en/theme_boost.php
theme/boost/layout/drawers.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/grade.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/boost/templates/columns2.mustache
theme/boost/templates/drawer.mustache
theme/boost/templates/drawers.mustache
theme/classic/style/moodle.css
version.php

index d54f260..d64a856 100644 (file)
@@ -281,7 +281,13 @@ class task_log extends base {
             $this->get_entity_name(),
             "{$tablealias}.timestart"
         ))
-            ->add_joins($this->get_joins());
+            ->add_joins($this->get_joins())
+            ->set_limited_operators([
+                date::DATE_ANY,
+                date::DATE_RANGE,
+                date::DATE_PREVIOUS,
+                date::DATE_CURRENT,
+            ]);
 
         // Duration filter.
         $filters[] = (new filter(
index 6faa7de..55a1aee 100644 (file)
       </CUSTOM_CHECK>
       <CUSTOM_CHECK file="lib/upgradelib.php" function="check_max_input_vars" level="optional">
       </CUSTOM_CHECK>
+      <CUSTOM_CHECK file="lib/upgradelib.php" function="check_admin_dir_usage" level="optional">
+        <FEEDBACK>
+          <ON_CHECK message="iscustomadminwarnings" />
+        </FEEDBACK>
+      </CUSTOM_CHECK>
     </CUSTOM_CHECKS>
   </MOODLE>
 </COMPATIBILITY_MATRIX>
index 6b2e025..f4f55b5 100644 (file)
@@ -1616,12 +1616,18 @@ class core_admin_renderer extends plugin_renderer_base {
 
         $plugininfo = $pluginman->get_plugins();
 
-        $numtotal = $numextension = $numupdatable = 0;
+        $numtotal = $numextension = $numupdatable = $numinstallable = 0;
 
         foreach ($plugininfo as $type => $plugins) {
             foreach ($plugins as $name => $plugin) {
-                if ($plugin->available_updates()) {
+                if ($res = $plugin->available_updates()) {
                     $numupdatable++;
+                    foreach ($res as $updateinfo) {
+                        if ($pluginman->is_remote_plugin_installable($updateinfo->component, $updateinfo->version, $reason, false)) {
+                            $numinstallable++;
+                            break;
+                        }
+                    }
                 }
                 if ($plugin->get_status() === core_plugin_manager::PLUGIN_STATUS_MISSING) {
                     continue;
@@ -1664,16 +1670,13 @@ class core_admin_renderer extends plugin_renderer_base {
             $out .= $this->output->heading(get_string('overviewext', 'core_plugin'), 3);
         }
 
-        if ($numupdatable) {
-            $installableupdates = $pluginman->filter_installable($pluginman->available_updates());
-            if ($installableupdates) {
-                $out .= $this->output->single_button(
-                    new moodle_url($this->page->url, array('installupdatex' => 1)),
-                    get_string('updateavailableinstallall', 'core_admin', count($installableupdates)),
-                    'post',
-                    array('class' => 'singlebutton updateavailableinstallall')
-                );
-            }
+        if ($numinstallable) {
+            $out .= $this->output->single_button(
+                new moodle_url($this->page->url, array('installupdatex' => 1)),
+                get_string('updateavailableinstallall', 'core_admin', $numinstallable),
+                'post',
+                array('class' => 'singlebutton updateavailableinstallall')
+            );
         }
 
         $out .= html_writer::div($infoall, 'info info-all').
@@ -1907,7 +1910,7 @@ class core_admin_renderer extends plugin_renderer_base {
             'infos'
         );
 
-        if ($pluginman->is_remote_plugin_installable($updateinfo->component, $updateinfo->version, $reason)) {
+        if ($pluginman->is_remote_plugin_installable($updateinfo->component, $updateinfo->version, $reason, false)) {
             $box .= $this->output->single_button(
                 new moodle_url($this->page->url, array('installupdate' => $updateinfo->component,
                     'installupdateversion' => $updateinfo->version)),
index bf9acf5..3c11386 100644 (file)
@@ -19,6 +19,7 @@ Feature: View task logs report and use its filters
       | Name value    | <name>   |
     And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element"
     Then I should see "Filters applied"
+    And I should see "Filters (1)" in the "#dropdownFiltersButton" "css_element"
     And the following should exist in the "reportbuilder-table" table:
       | Type      | Name    |
       | Scheduled | <match> |
@@ -61,9 +62,12 @@ Feature: View task logs report and use its filters
       | Result value    | Fail        |
     And I click on "Apply" "button" in the "[data-region='report-filters']" "css_element"
     Then I should see "Filters applied"
+    And I should see "Filters (1)" in the "#dropdownFiltersButton" "css_element"
     And I should see "Nothing to display"
     And I click on "Reset" "button" in the "[data-region='report-filters']" "css_element"
     And I should see "Filters reset"
+    And I should not see "Filters (1)" in the "#dropdownFiltersButton" "css_element"
+    And I should see "Filters" in the "#dropdownFiltersButton" "css_element"
     And "[data-region='report-filters']" "css_element" should be visible
     And the following fields in the "Result" "core_reportbuilder > Filter" match these values:
       | Result operator | Is any value |
diff --git a/admin/tool/componentlibrary/content/moodle/accessibility/links.md b/admin/tool/componentlibrary/content/moodle/accessibility/links.md
new file mode 100644 (file)
index 0000000..589f0b4
--- /dev/null
@@ -0,0 +1,70 @@
+---
+layout: docs
+title: "Links"
+description: "Designing links that lead to a change in context"
+date: 2021-10-03T20:00:00+08:00
+draft: false
+---
+## Links that open in a new window
+
+Links that open in a new window or tab should indicate that they open in a new window or tab. This is especially helpful for screen reader users to let them know that clicking on the link will open a new window or tab. This helps eliminate confusion caused by changes in context such as opening a web page in a new window or tab.
+
+There are several techniques that we can use when creating links that open in a new window.
+
+### Using an image icon with alt text to indicate that the link opens in a new window
+
+{{< example >}}
+<a href="https://moodle.org" target="_blank">
+    Moodle.org <i class="fa fa-external-link" aria-label="Opens in new window" title="Opens in new window"></i>
+</a>
+{{< /example  >}}
+
+The core/userfeedback_footer_link is also good example for this one.
+
+{{< mustache template="core/userfeedback_footer_link" >}}
+{{< /mustache >}}
+
+### Text that indicates that the link opens in a new window
+
+This may be the most straightforward way of indicating that links open in a new window. The potential downside of this approach is that it can be quite distracting, especially when the page has a lot of links that open in a new window.
+
+{{< example >}}
+<a href="https://moodle.org" target="_blank">
+    Moodle.org (Opens in new window)
+</a>
+{{< /example  >}}
+
+## Links to a file
+
+A link to a file such as a PDF file or Word document will usually open the relevant application associated to the file type and will cause a change in context. It would be helpful to let users know when a link will open a file.
+
+### File type indicated in the link text
+{{< example >}}
+<a href="https://www.w3.org/WAI/WCAG20/versions/guidelines/wcag20-guidelines-20081211-a4.pdf">
+    PDF version of the Web Content Accessibility Guidelines (WCAG) 2.0
+</a>
+{{< /example  >}}
+
+### File type indicated via an icon/image's alt text
+{{< example >}}
+<a href="https://www.w3.org/WAI/WCAG20/versions/guidelines/wcag20-guidelines-20081211-a4.pdf">
+    Web Content Accessibility Guidelines (WCAG) 2.0 <i class="fa fa-file-pdf-o" aria-label="PDF document" title="PDF document"></i>
+</a>
+{{< /example  >}}
+
+## Links that lead to an external site
+
+Similar to links that open in a new window, links that redirect to an external site may also have an indication that they lead to an external site to let users know that clicking on the link will take them away from Moodle.
+
+The example link below opens in the same browser window and lets the user know that they will be redirected to Moodle.org, which is an external site.
+
+{{< example >}}
+<a href="https://moodle.org">
+    Moodle.org <i class="fa fa-external-link" aria-label="Link leads to external site" title="Link leads to external site"></i>
+</a>
+{{< /example  >}}
+
+## More information
+
+* [Understanding Success Criterion 3.2.5](https://www.w3.org/TR/2016/NOTE-UNDERSTANDING-WCAG20-20161007/consistent-behavior-no-extreme-changes-context.html)
+* [Opening new windows and tabs from a link only when necessary](https://www.w3.org/TR/WCAG20-TECHS/G200.html)
index 1889c3e..caf42db 100644 (file)
@@ -113,8 +113,6 @@ if (!empty($action) && $ruleid) {
     echo $OUTPUT->header();
 }
 
-report_helper::save_selected_report($courseid, $manageurl);
-
 // Print the selected dropdown.
 $managerules = get_string('managerules', 'tool_monitor');
 report_helper::print_report_selector($managerules);
index a6875f6..4cd4f27 100644 (file)
@@ -84,6 +84,7 @@ class tool_uploadcourse_step2_form extends tool_uploadcourse_base_form {
 
         $displaylist = core_course_category::make_categories_list('moodle/course:create');
         $mform->addElement('autocomplete', 'defaults[category]', get_string('coursecategory'), $displaylist);
+        $mform->addRule('defaults[category]', null, 'required', null, 'client');
         $mform->addHelpButton('defaults[category]', 'coursecategory');
 
         $choices = array();
index a55e43f..4dfc599 100644 (file)
Binary files a/admin/tool/usertours/amd/build/tour.min.js and b/admin/tool/usertours/amd/build/tour.min.js differ
index fc20304..097cdf2 100644 (file)
Binary files a/admin/tool/usertours/amd/build/tour.min.js.map and b/admin/tool/usertours/amd/build/tour.min.js.map differ
index 853442c..71f4d0e 100644 (file)
@@ -26,6 +26,8 @@ import * as Aria from 'core/aria';
 import Popper from 'core/popper';
 import {dispatchEvent} from 'core/event_dispatcher';
 import {eventTypes} from './events';
+import {get_string as getString} from 'core/str';
+import {prefetchStrings} from 'core/prefetch';
 
 /**
  * A user tour.
@@ -72,6 +74,10 @@ const Tour = class {
             this.storageKey = '';
         }
 
+        prefetchStrings('tool_usertours', [
+            'nextstep_sequence'
+        ]);
+
         return this;
     }
 
@@ -352,6 +358,27 @@ const Tour = class {
         return false;
     }
 
+    /**
+     * Get potentially visible steps in a tour.
+     *
+     * @return {{stepId: number, position: number}[]} An associative array with stepNumber as keys
+     *                                                and an object of stepId and position as values.
+     */
+    getPotentiallyVisibleSteps() {
+        let position = 1;
+        let result = [];
+        // Checking the total steps.
+        for (let stepNumber = 0; stepNumber < this.steps.length; stepNumber++) {
+            const stepConfig = this.getStepConfig(stepNumber);
+            if (this.isStepPotentiallyVisible(stepConfig)) {
+                result[stepNumber] = {stepId: stepConfig.stepid, position: position};
+                position++;
+            }
+        }
+
+        return result;
+    }
+
     /**
      * Is this step actually visible?
      *
@@ -659,24 +686,43 @@ const Tour = class {
         template.find('[data-placeholder="body"]')
             .html(stepConfig.body);
 
+        // Buttons.
+        const nextBtn = template.find('[data-role="next"]');
+        const previousBtn = template.find('[data-role="previous"]');
+        const endBtn = template.find('[data-role="end"]');
+
         // Is this the first step?
         if (this.isFirstStep(stepConfig.stepNumber)) {
-            template.find('[data-role="previous"]').hide();
+            previousBtn.hide();
         } else {
-            template.find('[data-role="previous"]').prop('disabled', false);
+            previousBtn.prop('disabled', false);
         }
 
         // Is this the final step?
         if (this.isLastStep(stepConfig.stepNumber)) {
-            template.find('[data-role="next"]').hide();
-            template.find('[data-role="end"]').removeClass("btn-secondary").addClass("btn-primary");
+            nextBtn.hide();
+            endBtn.removeClass("btn-secondary").addClass("btn-primary");
         } else {
-            template.find('[data-role="next"]').prop('disabled', false);
+            nextBtn.prop('disabled', false);
         }
 
-        template.find('[data-role="previous"]').attr('role', 'button');
-        template.find('[data-role="next"]').attr('role', 'button');
-        template.find('[data-role="end"]').attr('role', 'button');
+        previousBtn.attr('role', 'button');
+        nextBtn.attr('role', 'button');
+        endBtn.attr('role', 'button');
+
+        if (this.originalConfiguration.displaystepnumbers) {
+            const stepsPotentiallyVisible = this.getPotentiallyVisibleSteps();
+            const totalStepsPotentiallyVisible = stepsPotentiallyVisible.length;
+            const position = stepsPotentiallyVisible[stepConfig.stepNumber].position;
+            if (totalStepsPotentiallyVisible > 1) {
+                // Change the label of the Next button to include the sequence.
+                getString('nextstep_sequence', 'tool_usertours',
+                    {position: position, total: totalStepsPotentiallyVisible}).then(value => {
+                    nextBtn.html(value);
+                    return;
+                }).catch();
+            }
+        }
 
         // Replace the template with the updated version.
         stepConfig.template = template;
index 502c041..342fd44 100644 (file)
@@ -104,7 +104,8 @@ class tour extends external_api {
             'tourconfig'        => new external_single_structure([
                 'name'          => new external_value(PARAM_RAW, 'Tour Name'),
                 'steps'         => new external_multiple_structure(self::step_structure_returns()),
-                'onesteptour'   => new external_value(PARAM_BOOL, 'Is a one step tour', VALUE_DEFAULT, false),
+                'endtourlabel'  => new external_value(PARAM_RAW, 'Label of the end tour button'),
+                'displaystepnumbers' => new external_value(PARAM_BOOL, 'display step number'),
             ], 'Tour config', VALUE_OPTIONAL)
         ]);
     }
index 272267d..69830bb 100644 (file)
@@ -79,6 +79,13 @@ class edittour extends \moodleform {
 
         $mform->addElement('checkbox', 'enabled', get_string('tourisenabled', 'tool_usertours'));
 
+        $mform->addElement('text', 'endtourlabel', get_string('endtourlabel', 'tool_usertours'));
+        $mform->setType('endtourlabel', PARAM_TEXT);
+        $mform->addHelpButton('endtourlabel', 'endtourlabel', 'tool_usertours');
+
+        $mform->addElement('checkbox', 'displaystepnumbers', get_string('displaystepnumbers', 'tool_usertours'));
+        $mform->addHelpButton('displaystepnumbers', 'displaystepnumbers', 'tool_usertours');
+
         // Configuration.
         $this->tour->add_config_to_form($mform);
 
index 0911878..cc0eb1b 100644 (file)
@@ -370,6 +370,8 @@ class manager {
             $tour->set_description($data->description);
             $tour->set_pathmatch($data->pathmatch);
             $tour->set_enabled(!empty($data->enabled));
+            $tour->set_endtourlabel($data->endtourlabel);
+            $tour->set_display_step_numbers(!empty($data->displaystepnumbers));
 
             foreach (configuration::get_defaultable_keys() as $key) {
                 $tour->set_config($key, $data->$key);
index 1127772..000c2a8 100644 (file)
@@ -60,18 +60,14 @@ class tour implements \renderable {
         $result = (object) [
             'name'  => $this->tour->get_tour_key(),
             'steps' => [],
-            'onesteptour' => false,
+            'endtourlabel' => $this->tour->get_endtourlabel(),
+            'displaystepnumbers' => $this->tour->get_display_step_numbers(),
         ];
 
         foreach ($this->tour->get_steps() as $step) {
             $result->steps[] = (new step($step))->export_for_template($output);
         }
 
-        // Check if its a only one step tour.
-        if (count($result->steps) == 1) {
-            $result->onesteptour = true;
-        }
-
         return $result;
     }
 }
index 08e8279..38e409c 100644 (file)
@@ -89,6 +89,11 @@ class tour {
      */
     protected $enabled;
 
+    /**
+     * @var $endtourlabel The end tour label.
+     */
+    protected $endtourlabel;
+
     /**
      * @var $sortorder The sort order.
      */
@@ -114,6 +119,11 @@ class tour {
      */
     protected $steps = [];
 
+    /**
+     * @var bool $displaystepnumbers Display the step numbers in this tour.
+     */
+    protected $displaystepnumbers = true;
+
     /**
      * Create an instance of the specified tour.
      *
@@ -187,9 +197,11 @@ class tour {
         if (isset($record->sortorder)) {
             $this->sortorder = $record->sortorder;
         }
+        $this->endtourlabel = $record->endtourlabel ?? null;
         $this->config       = json_decode($record->configdata);
         $this->dirty        = false;
         $this->steps        = [];
+        $this->displaystepnumbers = !empty($record->displaystepnumbers);
 
         return $this;
     }
@@ -322,6 +334,36 @@ class tour {
         return $this;
     }
 
+    /**
+     * The end tour label for the tour.
+     *
+     * @return string
+     */
+    public function get_endtourlabel(): string {
+        if ($this->endtourlabel) {
+            $label = $this->endtourlabel;
+        } else if ($this->count_steps() == 1) {
+            $label = get_string('endonesteptour', 'tool_usertours');
+        } else {
+            $label = get_string('endtour', 'tool_usertours');
+        }
+
+        return $label;
+    }
+
+    /**
+     * Set the endtourlabel of the tour to the specified value.
+     *
+     * @param string $value
+     * @return $this
+     */
+    public function set_endtourlabel(string $value): tour {
+        $this->endtourlabel = $value;
+        $this->dirty = true;
+
+        return $this;
+    }
+
     /**
      * The link to view this tour.
      *
@@ -389,7 +431,9 @@ class tour {
             'pathmatch'     => $this->pathmatch,
             'enabled'       => $this->enabled,
             'sortorder'     => $this->sortorder,
+            'endtourlabel'  => $this->endtourlabel,
             'configdata'    => json_encode($this->config),
+            'displaystepnumbers' => $this->displaystepnumbers,
         );
     }
 
@@ -806,4 +850,26 @@ class tour {
 
         return $results;
     }
+
+    /**
+     * Set the value for the display step numbers setting.
+     *
+     * @param bool $value True for enable.
+     * @return $this
+     */
+    public function set_display_step_numbers(bool $value): tour {
+        $this->displaystepnumbers = $value;
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * Get the value of the display step numbers setting.
+     *
+     * @return bool
+     */
+    public function get_display_step_numbers(): bool {
+        return $this->displaystepnumbers;
+    }
 }
index c275f46..72401e3 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="admin/tool/usertours/db" VERSION="20160830" COMMENT="XMLDB file for Moodle tool/usertours"
+<XMLDB PATH="admin/tool/usertours/db" VERSION="20211007" COMMENT="XMLDB file for Moodle tool/usertours"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../../lib/xmldb/xmldb.xsd"
 >
@@ -12,7 +12,9 @@
         <FIELD NAME="pathmatch" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="enabled" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="sortorder" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="endtourlabel" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="Custom label for the end tour button"/>
         <FIELD NAME="configdata" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="displaystepnumbers" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Setting to display step numbers of the tour"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
index 95782b2..40d814e 100644 (file)
@@ -36,6 +36,8 @@ use tool_usertours\tour;
 function xmldb_tool_usertours_upgrade($oldversion) {
     global $CFG, $DB;
 
+    $dbman = $DB->get_manager();
+
     // Automatically generated Moodle v3.6.0 release upgrade line.
     // Put any upgrade step following this.
 
@@ -83,5 +85,34 @@ function xmldb_tool_usertours_upgrade($oldversion) {
         upgrade_plugin_savepoint(true, 2021052508, 'tool', 'usertours');
     }
 
+    if ($oldversion < 2021092300) {
+        // Define field endtourlabel to be added to tool_usertours_tours.
+        $table = new xmldb_table('tool_usertours_tours');
+        $field = new xmldb_field('endtourlabel', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'sortorder');
+
+        // Conditionally launch add field endtourlabel.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Usertours savepoint reached.
+        upgrade_plugin_savepoint(true, 2021092300, 'tool', 'usertours');
+    }
+
+    if ($oldversion < 2021100700) {
+
+        // Define field displaystepnumbers to be added to tool_usertours_tours.
+        $table = new xmldb_table('tool_usertours_tours');
+        $field = new xmldb_field('displaystepnumbers', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '0', 'configdata');
+
+        // Conditionally launch add field displaystepnumbers.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Usertours savepoint reached.
+        upgrade_plugin_savepoint(true, 2021100700, 'tool', 'usertours');
+    }
+
     return true;
 }
index 26215e4..1ac0cc4 100644 (file)
@@ -31,6 +31,8 @@ $string['block_named'] = 'Block named \'{$a}\'';
 $string['cachedef_stepdata'] = 'List of user tour steps';
 $string['cachedef_tourdata'] = 'List of enabled user tours information which is fetched on every page';
 $string['description'] = 'Description';
+$string['displaystepnumbers'] = 'Display step numbers';
+$string['displaystepnumbers_help'] = 'Whether the number of steps remaining is displayed within the tour navigation';
 $string['confirmstepremovalquestion'] = 'Are you sure that you wish to remove this step?';
 $string['confirmstepremovaltitle'] = 'Confirm step removal';
 $string['confirmtourremovalquestion'] = 'Are you sure that you wish to remove this tour?';
@@ -49,6 +51,8 @@ $string['duplicatetour_name'] = '{$a} (copy)';
 $string['editstep'] = 'Editing "{$a}"';
 $string['tourisenabled'] = 'Tour is enabled';
 $string['enabled'] = 'Enabled';
+$string['endtourlabel'] = 'End tour button\'s label';
+$string['endtourlabel_help'] = 'You can optionally specify a custom label for the end tour button. The default label is "Got it!" for single-step, and "End tour" for multiple-step tours.';
 $string['event_tour_started'] = 'Tour started';
 $string['event_tour_reset'] = 'Tour reset';
 $string['event_tour_ended'] = 'Tour ended';
@@ -87,6 +91,7 @@ $string['newstep'] = 'New step';
 $string['newtour'] = 'Create a new tour';
 $string['next'] = 'Next';
 $string['nextstep'] = 'Next';
+$string['nextstep_sequence'] = 'Next ({$a->position}/{$a->total})';
 $string['options_heading'] = 'Options';
 $string['pathmatch'] = 'Apply to URL match';
 $string['pathmatch_help'] = 'Tours will be displayed on any page whose URL matches this value.
index 4856ca2..ba7fa53 100644 (file)
@@ -43,7 +43,7 @@
     Example context (json):
     {
       "name" : "tour_activity_completion",
-      "onesteptour" : true,
+      "endtourlabel" : "Got it!",
       "steps": [
         {
           "stepid": 10,
     <div class="modal-footer">
       <button type="button" class="btn btn-secondary" data-role="previous">{{# str }} previousstep, tool_usertours {{/ str }}</button>
       <button type="button" class="btn btn-primary" data-role="next">{{# str }} nextstep, tool_usertours {{/ str }}</button>
-      {{#onesteptour}}
-          <button class="btn btn-secondary" data-role="end"> {{# str }} endonesteptour, tool_usertours {{/ str }} </button>
-      {{/onesteptour}}
-      {{^onesteptour}}
-          <button class="btn btn-secondary" data-role="end"> {{# str }} endtour, tool_usertours {{/ str }} </button>
-      {{/onesteptour}}
+      <button class="btn btn-secondary" data-role="end"> {{endtourlabel}} </button>
     </div>
   </div>
 </div>
index 09f99ff..b508499 100644 (file)
@@ -79,3 +79,79 @@ Feature: Add a new user tour
     When I click on "Enable" "link" in the "My first tour" "table_row"
     And I am on homepage
     Then I should see "Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful"
+
+  @javascript
+  Scenario: Display step numbers was enabled
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+    And I log in as "admin"
+    And I add a new user tour with:
+      | Name                 | Steps tour    |
+      | Description          | My steps tour |
+      | Apply to URL match   | /my/%         |
+      | Tour is enabled      | 1             |
+      | Display step numbers | 1             |
+    And I add steps to the "Steps tour" tour:
+      | targettype                | Title   | Content                |
+      | Display in middle of page | Welcome | First step of the Tour |
+    And I add steps to the "Steps tour" tour:
+      | targettype | targetvalue_block | Title           | Content                 |
+      | Block      | Course overview   | Course overview | Second step of the Tour |
+      | Block      | Calendar          | Calendar        | Third step of the Tour  |
+    When I am on homepage
+    Then I should see "First step of the Tour"
+    And I should see "Next (1/3)"
+    And I click on "Next (1/3)" "button" in the "[data-role='flexitour-step']" "css_element"
+    And I should see "Second step of the Tour"
+    And I should see "Next (2/3)"
+    And I click on "Next (2/3)" "button" in the "[data-role='flexitour-step']" "css_element"
+    And I should see "Third step of the Tour"
+    And I should not see "Next (3/3)"
+
+  @javascript
+  Scenario: Display step numbers was disabled
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+    And I log in as "admin"
+    And I add a new user tour with:
+      | Name                 | Steps tour    |
+      | Description          | My steps tour |
+      | Apply to URL match   | /my/%         |
+      | Tour is enabled      | 1             |
+      | Display step numbers | 0             |
+    And I add steps to the "Steps tour" tour:
+      | targettype                | Title   | Content                |
+      | Display in middle of page | Welcome | First step of the Tour |
+    And I add steps to the "Steps tour" tour:
+      | targettype | targetvalue_block | Title           | Content                 |
+      | Block      | Course overview   | Course overview | Second step of the Tour |
+      | Block      | Calendar          | Calendar        | Third step of the Tour  |
+    When I am on homepage
+    Then I should see "First step of the Tour"
+    And I should see "Next"
+    And I should not see "Next (1/3)"
+    And I click on "Next" "button" in the "[data-role='flexitour-step']" "css_element"
+    And I should see "Second step of the Tour"
+    And I should see "Next"
+    And I should not see "Next (2/3)"
+
+  @javascript
+  Scenario: Single step tour with display step numbers was enable
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+    And I log in as "admin"
+    And I add a new user tour with:
+      | Name                 | Steps tour    |
+      | Description          | My steps tour |
+      | Apply to URL match   | /my/%         |
+      | Tour is enabled      | 1             |
+      | Display step numbers | 1             |
+    And I add steps to the "Steps tour" tour:
+      | targettype                | Title   | Content                    |
+      | Display in middle of page | Welcome | This is a single step tour |
+    When I am on homepage
+    Then I should see "This is a single step tour"
+    And I should not see "Next (1/1)"
index 22b85bd..51b9a2e 100644 (file)
@@ -60,3 +60,21 @@ Feature: Steps can be navigated within a tour
     And I should see "Next"
     And I click on "Next" "button"
     And I should see "End tour"
+
+  @javascript
+  Scenario: Customised 'end tour' button text for one step tours
+    Given I log in as "admin"
+    And I add a new user tour with:
+      | Name                    | Calendar tour |
+      | Description             | Calendar tour |
+      | Apply to URL match      | /my/%         |
+      | Tour is enabled         | 1             |
+      | End tour button's label | CustomText    |
+    And I add steps to the "Calendar tour" tour:
+      | targettype   | Block        | Title             | Content |
+      | Block        | Calendar     | Calendar events   | This is the calendar block |
+    And I change window size to "large"
+    And I follow "Dashboard" in the user menu
+    And I wait until the page is ready
+    And I should see "This is the calendar block"
+    Then I should see "CustomText"
index aab15de..c3638a1 100644 (file)
@@ -47,6 +47,7 @@ trait tool_usertours_helper_trait {
             'name' => '',
             'description' => '',
             'configdata' => '',
+            'displaystepnumbers' => true
         ];
 
         if ($tourconfig === null) {
index 17459f8..ffa1cb4 100644 (file)
@@ -1 +1 @@
-{"name":"Activity information in activity page (Student)","description":"A tour of the activity information for Student display on the activity page","pathmatch":"\/mod\/%\/view.php%","enabled":"1","sortorder":"0","configdata":"{\"placement\":\"bottom\",\"orphan\":\"0\",\"backdrop\":\"1\",\"reflex\":\"0\",\"filtervalues\":{\"accessdate\":{\"filter_accessdate\":\"tool_usertours_accountcreation\",\"filter_accessdate_range\":0,\"filter_accessdate_enabled\":\"0\"},\"category\":[],\"course\":[],\"courseformat\":[],\"role\":[\"student\"],\"theme\":[],\"cssselector\":[\"[data-region=activity-information]\"]},\"majorupdatetime\":1620110287,\"shipped_tour\":true,\"shipped_filename\":\"311_activity_information_activity_page_student.json\",\"shipped_version\":1}","version":"2021052507","steps":[{"title":"tour_activityinfo_activity_student_title,tool_usertours","content":"tour_activityinfo_activity_student_content,tool_usertours","targettype":"0","targetvalue":"[data-region=activity-information]","sortorder":"0","configdata":"{}"}]}
\ No newline at end of file
+{"name":"Activity information in activity page (Student)","description":"A tour of the activity information for Student display on the activity page","pathmatch":"\/mod\/%\/view.php%","enabled":"1","sortorder":"0","configdata":"{\"placement\":\"bottom\",\"orphan\":\"0\",\"backdrop\":\"1\",\"reflex\":\"0\",\"filtervalues\":{\"accessdate\":{\"filter_accessdate\":\"tool_usertours_accountcreation\",\"filter_accessdate_range\":0,\"filter_accessdate_enabled\":\"0\"},\"category\":[],\"course\":[],\"courseformat\":[],\"role\":[\"student\"],\"theme\":[],\"cssselector\":[\"[data-region=activity-information]\"]},\"majorupdatetime\":1620110287,\"shipped_tour\":true,\"shipped_filename\":\"311_activity_information_activity_page_student.json\",\"shipped_version\":1}","displaystepnumbers":"1","version":"2021052507","steps":[{"title":"tour_activityinfo_activity_student_title,tool_usertours","content":"tour_activityinfo_activity_student_content,tool_usertours","targettype":"0","targetvalue":"[data-region=activity-information]","sortorder":"0","configdata":"{}"}]}
\ No newline at end of file
index 1bd1189..a8fca2b 100644 (file)
@@ -1 +1 @@
-{"name":"Activity information in activity page (Teacher)","description":"A tour of the activity information for Teacher display on the activity page","pathmatch":"\/mod\/%\/view.php%","enabled":"1","sortorder":"1","configdata":"{\"placement\":\"bottom\",\"orphan\":\"0\",\"backdrop\":\"1\",\"reflex\":\"0\",\"filtervalues\":{\"accessdate\":{\"filter_accessdate\":\"tool_usertours_accountcreation\",\"filter_accessdate_range\":0,\"filter_accessdate_enabled\":\"0\"},\"category\":[],\"course\":[],\"courseformat\":[],\"role\":[\"manager\",\"teacher\",\"editingteacher\"],\"theme\":[],\"cssselector\":[\"[data-region=activity-information]\"]},\"majorupdatetime\":1620110287,\"shipped_tour\":true,\"shipped_filename\":\"311_activity_information_activity_page_teacher.json\",\"shipped_version\":1}","version":"2021052507","steps":[{"title":"tour_activityinfo_activity_teacher_title,tool_usertours","content":"tour_activityinfo_activity_teacher_content,tool_usertours","targettype":"0","targetvalue":"[data-region=activity-information]","sortorder":"0","configdata":"{}"}]}
\ No newline at end of file
+{"name":"Activity information in activity page (Teacher)","description":"A tour of the activity information for Teacher display on the activity page","pathmatch":"\/mod\/%\/view.php%","enabled":"1","sortorder":"1","configdata":"{\"placement\":\"bottom\",\"orphan\":\"0\",\"backdrop\":\"1\",\"reflex\":\"0\",\"filtervalues\":{\"accessdate\":{\"filter_accessdate\":\"tool_usertours_accountcreation\",\"filter_accessdate_range\":0,\"filter_accessdate_enabled\":\"0\"},\"category\":[],\"course\":[],\"courseformat\":[],\"role\":[\"manager\",\"teacher\",\"editingteacher\"],\"theme\":[],\"cssselector\":[\"[data-region=activity-information]\"]},\"majorupdatetime\":1620110287,\"shipped_tour\":true,\"shipped_filename\":\"311_activity_information_activity_page_teacher.json\",\"shipped_version\":1}","displaystepnumbers":"1","version":"2021052507","steps":[{"title":"tour_activityinfo_activity_teacher_title,tool_usertours","content":"tour_activityinfo_activity_teacher_content,tool_usertours","targettype":"0","targetvalue":"[data-region=activity-information]","sortorder":"0","configdata":"{}"}]}
\ No newline at end of file
index 885d818..c265f92 100644 (file)
@@ -1 +1 @@
-{"name":"Activity information in course homepage (Student)","description":"A tour of the activity information for Student display on the course homepage","pathmatch":"\/course\/view.php%","enabled":"1","sortorder":"2","configdata":"{\"placement\":\"bottom\",\"orphan\":\"0\",\"backdrop\":\"1\",\"reflex\":\"0\",\"filtervalues\":{\"accessdate\":{\"filter_accessdate\":\"tool_usertours_accountcreation\",\"filter_accessdate_range\":0,\"filter_accessdate_enabled\":\"0\"},\"category\":[],\"course\":[],\"courseformat\":[],\"role\":[\"student\"],\"theme\":[],\"cssselector\":[\"[data-region=activity-information]\"]},\"majorupdatetime\":1620109487,\"shipped_tour\":true,\"shipped_filename\":\"311_activity_information_course_page_student.json\",\"shipped_version\":1}","version":"2021052507","steps":[{"title":"tour_activityinfo_course_student_title,tool_usertours","content":"tour_activityinfo_course_student_content,tool_usertours","targettype":"0","targetvalue":"[data-region=activity-information]","sortorder":"0","configdata":"{}"}]}
\ No newline at end of file
+{"name":"Activity information in course homepage (Student)","description":"A tour of the activity information for Student display on the course homepage","pathmatch":"\/course\/view.php%","enabled":"1","sortorder":"2","configdata":"{\"placement\":\"bottom\",\"orphan\":\"0\",\"backdrop\":\"1\",\"reflex\":\"0\",\"filtervalues\":{\"accessdate\":{\"filter_accessdate\":\"tool_usertours_accountcreation\",\"filter_accessdate_range\":0,\"filter_accessdate_enabled\":\"0\"},\"category\":[],\"course\":[],\"courseformat\":[],\"role\":[\"student\"],\"theme\":[],\"cssselector\":[\"[data-region=activity-information]\"]},\"majorupdatetime\":1620109487,\"shipped_tour\":true,\"shipped_filename\":\"311_activity_information_course_page_student.json\",\"shipped_version\":1}","displaystepnumbers":"1","version":"2021052507","steps":[{"title":"tour_activityinfo_course_student_title,tool_usertours","content":"tour_activityinfo_course_student_content,tool_usertours","targettype":"0","targetvalue":"[data-region=activity-information]","sortorder":"0","configdata":"{}"}]}
\ No newline at end of file
index 381ee3b..b67c817 100644 (file)
@@ -1 +1 @@
-{"name":"Activity information in course homepage (Teacher)","description":"A tour of the activity information for Teacher display on the course homepage","pathmatch":"\/course\/view.php%","enabled":"1","sortorder":"3","configdata":"{\"placement\":\"bottom\",\"orphan\":\"0\",\"backdrop\":\"1\",\"reflex\":\"0\",\"filtervalues\":{\"accessdate\":{\"filter_accessdate\":\"tool_usertours_accountcreation\",\"filter_accessdate_range\":0,\"filter_accessdate_enabled\":\"0\"},\"category\":[],\"course\":[],\"courseformat\":[],\"role\":[\"manager\",\"teacher\",\"editingteacher\"],\"theme\":[],\"cssselector\":[\"[data-region=activity-information]\"]},\"majorupdatetime\":1620109487,\"shipped_tour\":true,\"shipped_filename\":\"311_activity_information_course_page_teacher.json\",\"shipped_version\":1}","version":"2021052507","steps":[{"title":"tour_activityinfo_course_teacher_title,tool_usertours","content":"tour_activityinfo_course_teacher_content,tool_usertours","targettype":"0","targetvalue":"[data-region=activity-information]","sortorder":"0","configdata":"{}"}]}
\ No newline at end of file
+{"name":"Activity information in course homepage (Teacher)","description":"A tour of the activity information for Teacher display on the course homepage","pathmatch":"\/course\/view.php%","enabled":"1","sortorder":"3","configdata":"{\"placement\":\"bottom\",\"orphan\":\"0\",\"backdrop\":\"1\",\"reflex\":\"0\",\"filtervalues\":{\"accessdate\":{\"filter_accessdate\":\"tool_usertours_accountcreation\",\"filter_accessdate_range\":0,\"filter_accessdate_enabled\":\"0\"},\"category\":[],\"course\":[],\"courseformat\":[],\"role\":[\"manager\",\"teacher\",\"editingteacher\"],\"theme\":[],\"cssselector\":[\"[data-region=activity-information]\"]},\"majorupdatetime\":1620109487,\"shipped_tour\":true,\"shipped_filename\":\"311_activity_information_course_page_teacher.json\",\"shipped_version\":1}","displaystepnumbers":"1","version":"2021052507","steps":[{"title":"tour_activityinfo_course_teacher_title,tool_usertours","content":"tour_activityinfo_course_teacher_content,tool_usertours","targettype":"0","targetvalue":"[data-region=activity-information]","sortorder":"0","configdata":"{}"}]}
\ No newline at end of file
index 3eaa56e..78c6749 100644 (file)
@@ -2,8 +2,8 @@ This files describes API changes in the tool_usertours code.
 
 === 4.0 ===
 * The `tourconfig` property returned by the `tool_usertours_fetch_and_start_tour`
-  external method has also a `onsteptour` property to differentiate between
-  one step tours and multiple step tours.
+  external method has also an `endtourlabel` property that contains the label to be used
+  for the end tour button.
 
 === 3.9 ===
 * The `tourconfig` property returned by the `tool_usertours_fetch_and_start_tour`
index cc6b23f..cf35e04 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2021052508;            // The current module version (Date: YYYYMMDDXX).
+$plugin->version   = 2021100700;            // The current module version (Date: YYYYMMDDXX).
 $plugin->requires  = 2021052500;            // Requires this Moodle version.
 $plugin->component = 'tool_usertours';      // Full name of the plugin (used for diagnostics).
index e10b0bd..ef88972 100644 (file)
@@ -108,6 +108,64 @@ abstract class condition extends tree_node {
         return preg_replace('~^availability_(.*?)\\\\condition$~', '$1', get_class($this));
     }
 
+    /**
+     * Returns a marker indicating that an activity name should be placed in a description.
+     *
+     * Gets placeholder text which will be decoded by info::format_info later when we can safely
+     * display names.
+     *
+     * @param int $cmid Course-module id
+     * @return string Placeholder text
+     * @since Moodle 4.0
+     */
+    public static function description_cm_name(int $cmid): string {
+        return '<AVAILABILITY_CMNAME_' . $cmid . '/>';
+    }
+
+    /**
+     * Returns a marker indicating that formatted text should be placed in a description.
+     *
+     * Gets placeholder text which will be decoded by info::format_info later when we can safely
+     * call format_string.
+     *
+     * @param string $str Text to be processed with format_string
+     * @return string Placeholder text
+     * @since Moodle 4.0
+     */
+    public static function description_format_string(string $str): string {
+        return '<AVAILABILITY_FORMAT_STRING>' . htmlspecialchars($str, ENT_NOQUOTES) .
+                '</AVAILABILITY_FORMAT_STRING>';
+    }
+
+    /**
+     * Returns a marker indicating that some of the description text should be computed at display
+     * time.
+     *
+     * This will result in a call to the get_description_callback_value static function within
+     * the condition class.
+     *
+     * Gets placeholder text which will be decoded by info::format_info later when we can safely
+     * call most Moodle functions.
+     *
+     * @param string[] $params Array of arbitrary parameters
+     * @return string Placeholder text
+     * @since Moodle 4.0
+     */
+    public function description_callback(array $params): string {
+        $out = '<AVAILABILITY_CALLBACK type="' . $this->get_type() . '">';
+        $first = true;
+        foreach ($params as $param) {
+            if ($first) {
+                $first = false;
+            } else {
+                $out .= '<P/>';
+            }
+            $out .= htmlspecialchars($param, ENT_NOQUOTES);
+        }
+        $out .= '</AVAILABILITY_CALLBACK>';
+        return $out;
+    }
+
     /**
      * Obtains a string describing this restriction (whether or not
      * it actually applies). Used to obtain information that is displayed to
@@ -119,11 +177,17 @@ abstract class condition extends tree_node {
      * (when displaying only conditions they don't meet).
      *
      * If implementations require a course or modinfo, they should use
-     * the get methods in $info.
+     * the get methods in $info. They should not use any other functions that
+     * might rely on modinfo, such as format_string.
+     *
+     * To work around this limitation, use the functions:
+     *
+     * description_cm_name()
+     * description_format_string()
+     * description_callback()
      *
-     * The special string <AVAILABILITY_CMNAME_123/> can be returned, where
-     * 123 is any number. It will be replaced with the correctly-formatted
-     * name for that activity.
+     * These return special markers which will be added to the string and processed
+     * later after modinfo is complete.
      *
      * @param bool $full Set true if this is the 'full information' view
      * @param bool $not Set true if we are inverting the condition
@@ -142,11 +206,17 @@ abstract class condition extends tree_node {
      * the list, in front of the standard get_description call.
      *
      * If implementations require a course or modinfo, they should use
-     * the get methods in $info.
+     * the get methods in $info. They should not use any other functions that
+     * might rely on modinfo, such as format_string.
+     *
+     * To work around this limitation, use the functions:
+     *
+     * description_cm_name()
+     * description_format_string()
+     * description_callback()
      *
-     * The special string <AVAILABILITY_CMNAME_123/> can be returned, where
-     * 123 is any number. It will be replaced with the correctly-formatted
-     * name for that activity.
+     * These return special markers which will be added to the string and processed
+     * later after modinfo is complete.
      *
      * @param bool $full Set true if this is the 'full information' view
      * @param bool $not Set true if we are inverting the condition
index 25fb076..3d81263 100644 (file)
@@ -232,7 +232,8 @@ abstract class info {
      * @return bool True if activity is available for all
      */
     public function is_available_for_all() {
-        if (is_null($this->availability)) {
+        global $CFG;
+        if (is_null($this->availability) || !empty($CFG->enableavailability)) {
             return true;
         } else {
             try {
@@ -746,6 +747,26 @@ abstract class info {
                         return format_string($cm->get_name(), true, ['context' => $context]);
                     }
                 }, $info);
+        $info = preg_replace_callback('~<AVAILABILITY_FORMAT_STRING>(.*?)</AVAILABILITY_FORMAT_STRING>~s',
+                function($matches) use ($context) {
+                    $decoded = htmlspecialchars_decode($matches[1], ENT_NOQUOTES);
+                    return format_string($decoded, true, ['context' => $context]);
+                }, $info);
+        $info = preg_replace_callback('~<AVAILABILITY_CALLBACK type="([a-z0-9_]+)">(.*?)</AVAILABILITY_CALLBACK>~s',
+                function($matches) use ($modinfo, $context) {
+                    // Find the class, it must have already been loaded by now.
+                    $fullclassname = 'availability_' . $matches[1] . '\condition';
+                    if (!class_exists($fullclassname, false)) {
+                        return '<!-- Error finding class ' . $fullclassname .' -->';
+                    }
+                    // Load the parameters.
+                    $params = [];
+                    $encodedparams = preg_split('~<P/>~', $matches[2], 0);
+                    foreach ($encodedparams as $encodedparam) {
+                        $params[] = htmlspecialchars_decode($encodedparam, ENT_NOQUOTES);
+                    }
+                    return $fullclassname::get_description_callback_value($modinfo, $context, $params);
+                }, $info);
 
         return $info;
     }
index f20f536..7bcd1af 100644 (file)
@@ -51,7 +51,7 @@ class info_module extends info {
         // We cannot access $cm->name as a property at this point, because this
         // code may itself run in response to the $cm->name property access, and
         // PHP magic function properties do not allow recursion (because PHP).
-        return '<AVAILABILITY_CMNAME_' . $this->cm->id . '/>';
+        return condition::description_cm_name($this->cm->id);
     }
 
     protected function set_in_database($availability) {
index 991af64..ae6e73d 100644 (file)
@@ -354,7 +354,7 @@ class condition extends \core_availability\condition {
             if (!array_key_exists($cmid, $modinfo->cms) || $modinfo->cms[$cmid]->deletioninprogress) {
                 $modname = get_string('missing', 'availability_completion');
             } else {
-                $modname = '<AVAILABILITY_CMNAME_' . $modinfo->cms[$cmid]->id . '/>';
+                $modname = self::description_cm_name($modinfo->cms[$cmid]->id);
             }
         }
 
index 440683c..983c4bf 100644 (file)
@@ -142,10 +142,29 @@ class condition extends \core_availability\condition {
                 $string = 'notgeneral';
             }
         }
-        $name = self::get_cached_grade_name($course->id, $this->gradeitemid);
+        // We cannot get the name at this point because it requires format_string which is not
+        // allowed here. Instead, get it later with the callback function below.
+        $name = $this->description_callback([$this->gradeitemid]);
         return get_string('requires_' . $string, 'availability_grade', $name);
     }
 
+    /**
+     * Gets the grade name at display time.
+     *
+     * @param \course_modinfo $modinfo Modinfo
+     * @param \context $context Context
+     * @param string[] $params Parameters (just grade item id)
+     * @return string Text value
+     */
+    public static function get_description_callback_value(
+            \course_modinfo $modinfo, \context $context, array $params): string {
+        if (count($params) !== 1 || !is_number($params[0])) {
+            return '<!-- Invalid grade description callback -->';
+        }
+        $gradeitemid = (int)$params[0];
+        return self::get_cached_grade_name($modinfo->get_course_id(), $gradeitemid);
+    }
+
     protected function get_debug_string() {
         $out = '#' . $this->gradeitemid;
         if (!is_null($this->min)) {
index fc35b9b..5b495c5 100644 (file)
@@ -123,3 +123,39 @@ Feature: availability_grade
     Then I should see "P2" in the "region-main" "region"
     And I should see "P4" in the "region-main" "region"
     And I should not see "P3" in the "region-main" "region"
+
+  @javascript
+  Scenario: Condition display with filters
+    # Teacher sets up a restriction on group G1, using multilang filter.
+    Given the following "activity" exists:
+      | activity    | assign                                                                                      |
+      | name        | <span lang="en" class="multilang">A-One</span><span lang="fr" class="multilang">A-Un</span> |
+      | intro       | Test                                                                                        |
+      | course      | C1                                                                                          |
+      | idnumber    | 0001                                                                                        |
+      | section     | 1                                                                                           |
+    And the "multilang" filter is "on"
+    And the "multilang" filter applies to "content and headings"
+    # The activity names filter is enabled because it triggered a bug in older versions.
+    And the "activitynames" filter is "on"
+    And the "activitynames" filter applies to "content and headings"
+    And I am on the "C1" "Course" page logged in as "teacher1"
+    And I turn editing mode on
+    And I add a "Page" to section "1"
+    And I expand all fieldsets
+    And I set the following fields to these values:
+      | Name         | P1 |
+      | Description  | x  |
+      | Page content | x  |
+    And I click on "Add restriction..." "button"
+    And I click on "Grade" "button" in the "Add restriction..." "dialogue"
+    And I set the field "Grade" to "A-One"
+    And I click on "min" "checkbox" in the ".availability-item" "css_element"
+    And I set the field "Minimum grade percentage (inclusive)" to "10"
+    And I press "Save and return to course"
+    And I log out
+
+    # Student sees information about no access to group, with group name in correct language.
+    When I am on the "C1" "Course" page logged in as "student1"
+    Then I should see "Not available unless: You achieve a required score in A-One"
+    And I should not see "A-Un"
index 1d27589..3bcbc86 100644 (file)
@@ -68,6 +68,7 @@ class availability_grade_condition_testcase extends advanced_testcase {
         // Check if available (not available).
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~have a grade.*Test!~', $information);
         $this->assertTrue($cond->is_available(true, $info, true, $user->id));
 
@@ -76,6 +77,7 @@ class availability_grade_condition_testcase extends advanced_testcase {
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
         $information = $cond->get_description(false, true, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~do not have a grade.*Test!~', $information);
 
         // Construct directly and test remaining conditions; first, min grade (fail).
@@ -84,6 +86,7 @@ class availability_grade_condition_testcase extends advanced_testcase {
         $cond = new condition($structure);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~achieve a required score.*Test!~', $information);
         $this->assertTrue($cond->is_available(true, $info, true, $user->id));
 
@@ -92,6 +95,7 @@ class availability_grade_condition_testcase extends advanced_testcase {
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
         $information = $cond->get_description(false, true, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~do not get certain scores.*Test!~', $information);
 
         // Max grade (fail).
@@ -100,6 +104,7 @@ class availability_grade_condition_testcase extends advanced_testcase {
         $cond = new condition($structure);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~get an appropriate score.*Test!~', $information);
         $this->assertTrue($cond->is_available(true, $info, true, $user->id));
 
@@ -108,6 +113,7 @@ class availability_grade_condition_testcase extends advanced_testcase {
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
         $information = $cond->get_description(false, true, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~do not get certain scores.*Test!~', $information);
 
         // Max and min (fail).
@@ -116,6 +122,7 @@ class availability_grade_condition_testcase extends advanced_testcase {
         $cond = new condition($structure);
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~get a particular score.*Test!~', $information);
         $this->assertTrue($cond->is_available(true, $info, true, $user->id));
 
@@ -128,6 +135,7 @@ class availability_grade_condition_testcase extends advanced_testcase {
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
         $information = $cond->get_description(false, true, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~do not get certain scores.*Test!~', $information);
 
         // Success (bottom end).
@@ -135,6 +143,7 @@ class availability_grade_condition_testcase extends advanced_testcase {
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
         $information = $cond->get_description(false, true, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~do not get certain scores.*Test!~', $information);
     }
 
index 20240a2..fd247e2 100644 (file)
@@ -110,9 +110,8 @@ class condition extends \core_availability\condition {
             if (!array_key_exists($this->groupid, self::$groupnames)) {
                 $name = get_string('missing', 'availability_group');
             } else {
-                $context = \context_course::instance($course->id);
-                $name = format_string(self::$groupnames[$this->groupid], true,
-                        array('context' => $context));
+                // Not safe to call format_string here; use the special function to call it later.
+                $name = self::description_format_string(self::$groupnames[$this->groupid]);
             }
         } else {
             return get_string($not ? 'requires_notanygroup' : 'requires_anygroup',
index 307b31c..4ef128f 100644 (file)
@@ -101,3 +101,33 @@ Feature: availability_group
     Then I should see "P1" in the "region-main" "region"
     And I should see "P2" in the "region-main" "region"
     And I should not see "P3" in the "region-main" "region"
+
+  @javascript
+  Scenario: Condition display with filters
+    # Teacher sets up a restriction on group G1, using multilang filter.
+    Given the following "groups" exist:
+      | name                                                                                        | course | idnumber |
+      | <span lang="en" class="multilang">G-One</span><span lang="fr" class="multilang">G-Un</span> | C1     | GI1      |
+    And the "multilang" filter is "on"
+    And the "multilang" filter applies to "content and headings"
+    # The activity names filter is enabled because it triggered a bug in older versions.
+    And the "activitynames" filter is "on"
+    And the "activitynames" filter applies to "content and headings"
+    And I am on the "C1" "Course" page logged in as "teacher1"
+    And I turn editing mode on
+    And I add a "Page" to section "1"
+    And I expand all fieldsets
+    And I set the following fields to these values:
+      | Name         | P1 |
+      | Description  | x  |
+      | Page content | x  |
+    And I click on "Add restriction..." "button"
+    And I click on "Group" "button" in the "Add restriction..." "dialogue"
+    And I set the field "Group" to "G-One"
+    And I click on "Save and return to course" "button"
+    And I log out
+
+    # Student sees information about no access to group, with group name in correct language.
+    When I am on the "C1" "Course" page logged in as "student1"
+    Then I should see "Not available unless: You belong to G-One"
+    And I should not see "G-Un"
index b0dcd5f..c7a3453 100644 (file)
@@ -73,6 +73,7 @@ class availability_group_condition_testcase extends advanced_testcase {
         // Check if available (when not available).
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~You belong to.*G1!~', $information);
         $this->assertTrue($cond->is_available(true, $info, true, $user->id));
 
@@ -85,6 +86,7 @@ class availability_group_condition_testcase extends advanced_testcase {
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
         $information = $cond->get_description(false, true, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~do not belong to.*G1!~', $information);
 
         // Check group 2 works also.
@@ -96,6 +98,7 @@ class availability_group_condition_testcase extends advanced_testcase {
         $this->assertTrue($cond->is_available(false, $info, true, $user->id));
         $this->assertFalse($cond->is_available(true, $info, true, $user->id));
         $information = $cond->get_description(false, true, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~do not belong to any~', $information);
 
         // Admin user doesn't belong to a group, but they can access it
@@ -108,6 +111,7 @@ class availability_group_condition_testcase extends advanced_testcase {
         $cond = new condition((object)array('id' => $group2->id + 1000));
         $this->assertFalse($cond->is_available(false, $info, true, $user->id));
         $information = $cond->get_description(false, false, $info);
+        $information = \core_availability\info::format_info($information, $course);
         $this->assertMatchesRegularExpression('~You belong to.*\(Missing group\)~', $information);
     }
 
index 1fdd928..5b7321a 100644 (file)
@@ -141,9 +141,8 @@ class condition extends \core_availability\condition {
         if (!array_key_exists($groupingid, self::$groupingnames)) {
             $name = get_string('missing', 'availability_grouping');
         } else {
-            $context = \context_course::instance($course->id);
-            $name = format_string(self::$groupingnames[$groupingid], true,
-                    array('context' => $context));
+            // Not safe to call format_string here; use the special function to call it later.
+            $name = self::description_format_string(self::$groupingnames[$groupingid]);
         }
 
         return get_string($not ? 'requires_notgrouping' : 'requires_grouping',
index 2e24230..d0108fd 100644 (file)
@@ -114,3 +114,31 @@ Feature: availability_grouping
     And I press "Add group/grouping access restriction"
     When I press "Save and return to course"
     Then I should see "Not available unless: You belong to a group in Grouping A"
+
+  @javascript
+  Scenario: Condition display with filters
+    # Teacher sets up a restriction on group G1, using multilang filter.
+    Given the following "groupings" exist:
+      | name                                                                                          | course | idnumber |
+      | <span lang="en" class="multilang">Gr-One</span><span lang="fr" class="multilang">Gr-Un</span> | C1     | GA       |
+    And the following "activities" exist:
+      | activity  | name        | intro              | course | idnumber | groupmode | grouping |
+      | assign    | Test assign | Assign description | C1     | assign1  | 1         | GA       |
+    And the "multilang" filter is "on"
+    And the "multilang" filter applies to "content and headings"
+    # The activity names filter is enabled because it triggered a bug in older versions.
+    And the "activitynames" filter is "on"
+    And the "activitynames" filter applies to "content and headings"
+    And I am on the "C1" "Course" page logged in as "teacher1"
+    And I turn editing mode on
+    And I open "Test assign" actions menu
+    And I choose "Edit settings" in the open action menu
+    And I expand all fieldsets
+    And I press "Add group/grouping access restriction"
+    And I press "Save and return to course"
+    And I log out
+
+    # Student sees information about no access to group, with group name in correct language.
+    When I am on the "C1" "Course" page logged in as "student1"
+    Then I should see "Not available unless: You belong to a group in Gr-One"
+    And I should not see "Gr-Un"
index 39399b7..70b54d7 100644 (file)
@@ -199,9 +199,9 @@ class condition extends \core_availability\condition {
         } else {
             $translatedfieldname = \core_user\fields::get_display_name($this->standardfield);
         }
-        $context = \context_course::instance($course->id);
         $a = new \stdClass();
-        $a->field = format_string($translatedfieldname, true, array('context' => $context));
+        // Not safe to call format_string here; use the special function to call it later.
+        $a->field = self::description_format_string($translatedfieldname);
         $a->value = s($this->value);
         if ($not) {
             // When doing NOT strings, we replace the operator with its inverse.
index b616033..82fed62 100644 (file)
@@ -106,3 +106,35 @@ Feature: availability_profile
     And I log in as "student1"
     And I am on "Course 1" course homepage
     Then I should see "P1" in the "region-main" "region"
+
+  @javascript
+  Scenario: Condition display with filters
+    # Teacher sets up a restriction on group G1, using multilang filter.
+    Given the following "custom profile fields" exist:
+      | datatype | shortname | name                                                                                        | param2 |
+      | text     | frog      | <span lang="en" class="multilang">F-One</span><span lang="fr" class="multilang">F-Un</span> | 100    |
+    And the "multilang" filter is "on"
+    And the "multilang" filter applies to "content and headings"
+    # The activity names filter is enabled because it triggered a bug in older versions.
+    And the "activitynames" filter is "on"
+    And the "activitynames" filter applies to "content and headings"
+    And I am on the "C1" "Course" page logged in as "teacher1"
+    And I turn editing mode on
+    And I add a "Page" to section "1"
+    And I expand all fieldsets
+    And I set the following fields to these values:
+      | Name         | P1 |
+      | Description  | x  |
+      | Page content | x  |
+    And I click on "Add restriction..." "button"
+    And I click on "User profile" "button" in the "Add restriction..." "dialogue"
+    And I set the following fields to these values:
+      | User profile field       | F-One |
+      | Value to compare against | 111   |
+    And I click on "Save and return to course" "button"
+    And I log out
+
+    # Student sees information about no access to group, with group name in correct language.
+    When I am on the "C1" "Course" page logged in as "student1"
+    Then I should see "Not available unless: Your F-One is 111"
+    And I should not see "F-Un"
index 12d4477..5a987c2 100644 (file)
@@ -309,6 +309,7 @@ class availability_profile_condition_testcase extends advanced_testcase {
         // Check the message (should be using lang string with capital, which
         // is evidence that it called the right function to get the name).
         $information = $cond->get_description(false, false, $info);
+        $information = \core_availability\info::format_info($information, $info->get_course());
         $this->assertMatchesRegularExpression('~Department~', $information);
 
         // Set the field to true for both users and retry.
@@ -388,6 +389,7 @@ class availability_profile_condition_testcase extends advanced_testcase {
                 'Failed checking normal (positive) result');
         if (!$yes) {
             $information = $cond->get_description(false, false, $info);
+            $information = \core_availability\info::format_info($information, $info->get_course());
             $this->assertMatchesRegularExpression($failpattern, $information);
         }
 
@@ -396,6 +398,7 @@ class availability_profile_condition_testcase extends advanced_testcase {
                 'Failed checking NOT (negative) result');
         if ($yes) {
             $information = $cond->get_description(false, true, $info);
+            $information = \core_availability\info::format_info($information, $info->get_course());
             $this->assertMatchesRegularExpression($failpattern, $information);
         }
     }
index a509b02..8c010ac 100644 (file)
@@ -2,6 +2,13 @@ This files describes API changes in /availability/*.
 
 The information here is intended only for developers.
 
+=== 4.0 ===
+
+* There were existing restrictions on what condition plugins can do in the get_description
+  method (for example they mustn't call format_string), which were not well documented.
+  New functions description_cm_name(), description_format_string(), description_callback()
+  can be used so that condition plugins to behave correctly in all situations.
+
 === 3.2 ===
 
 * Condition plugins must replace the CSS selector "#fitem_id_availabilityconditionsjson" with ".availability-field".
index d17b0da..e507698 100644 (file)
@@ -273,7 +273,8 @@ class backup_module_structure_step extends backup_structure_step {
             'modulename', 'sectionid', 'sectionnumber', 'idnumber',
             'added', 'score', 'indent', 'visible', 'visibleoncoursepage',
             'visibleold', 'groupmode', 'groupingid',
-            'completion', 'completiongradeitemnumber', 'completionview', 'completionexpected',
+            'completion', 'completiongradeitemnumber', 'completionpassgrade',
+            'completionview', 'completionexpected',
             'availability', 'showdescription'));
 
         $tags = new backup_nested_element('tags');
@@ -2806,12 +2807,13 @@ class backup_completion_defaults_structure_step extends backup_structure_step {
         $cc = new backup_nested_element('course_completion_defaults');
 
         $defaults = new backup_nested_element('course_completion_default', array('id'), array(
-            'modulename', 'completion', 'completionview', 'completionusegrade', 'completionexpected', 'customrules'
+            'modulename', 'completion', 'completionview', 'completionusegrade', 'completionpassgrade',
+            'completionexpected', 'customrules'
         ));
 
         // Use module name instead of module id so we can insert into another site later.
         $sourcesql = "SELECT d.id, m.name as modulename, d.completion, d.completionview, d.completionusegrade,
-                  d.completionexpected, d.customrules
+                  d.completionpassgrade, d.completionexpected, d.customrules
                 FROM {course_completion_defaults} d join {modules} m on d.module = m.id
                 WHERE d.course = ?";
         $defaults->set_source_sql($sourcesql, array(backup::VAR_COURSEID));
index 51f8bb8..78d56c7 100644 (file)
@@ -107,6 +107,7 @@ class copy_form extends \moodleform {
             $displaylist[$course->category] = \core_course_category::get($course->category, MUST_EXIST, true)->get_formatted_name();
         }
         $mform->addElement('autocomplete', 'category', get_string('coursecategory'), $displaylist);
+        $mform->addRule('category', null, 'required', null, 'client');
         $mform->addHelpButton('category', 'coursecategory');
 
         // Course visibility.
index 128e932..98cae5e 100644 (file)
@@ -25,8 +25,8 @@ Feature: Award badges based on activity completion
       | questioncategory | qtype     | name           | questiontext              |
       | Test questions   | truefalse | First question | Answer the first question |
     And the following "activities" exist:
-      | activity   | name           | course | idnumber | attempts | gradepass | completion | completionattemptsexhausted | completionpass | completionusegrade |
-      | quiz       | Test quiz name | C1     | quiz1    | 2        | 5.00      | 2          | 1                           | 1              | 1                  |
+      | activity   | name           | course | idnumber | attempts | gradepass | completion | completionattemptsexhausted | completionpassgrade | completionusegrade |
+      | quiz       | Test quiz name | C1     | quiz1    | 2        | 5.00      | 2          | 1                           | 1                   | 1                  |
     And quiz "Test quiz name" contains the following questions:
       | question       | page |
       | First question | 1    |
@@ -53,7 +53,8 @@ Feature: Award badges based on activity completion
   Scenario: Student earns a badge using activity completion, but does not get passing grade
     Given I log in as "student1"
     And I am on "Course 1" course homepage
-    And the "Receive a grade" completion condition of "Test quiz name" is displayed as "failed"
+    And the "Receive a grade" completion condition of "Test quiz name" is displayed as "done"
+    And the "Receive a passing grade" completion condition of "Test quiz name" is displayed as "failed"
     And the "Receive a pass grade or complete all available attempts" completion condition of "Test quiz name" is displayed as "todo"
     When I am on the "Test quiz name" "quiz activity" page
     And I press "Re-attempt quiz"
index 7697f77..ffd06c4 100644 (file)
@@ -17,8 +17,9 @@ Feature: Enable Block Completion in a course using activity completion
       | teacher1 | C1     | editingteacher |
       | student1 | C1     | student        |
     And the following "activities" exist:
-      | activity | course | idnumber | name           | intro                 |
-      | page     | C1     | page1    | Test page name | Test page description |
+      | activity   | course | idnumber   | name             | intro                 |
+      | page       | C1     | page1      | Test page name   | Test page description |
+      | assign     | C1     | assign1    | Test assign name | Test page description |
 
   Scenario: Add the block to a the course and add course completion items
     Given I log in as "teacher1"
@@ -65,3 +66,71 @@ Feature: Enable Block Completion in a course using activity completion
     And I should see "1 of 1" in the "Activity completion" "table_row"
     And I follow "More details"
     And I should see "Yes" in the "Activity completion" "table_row"
+
+  @javascript
+  Scenario: Add the block to a the course and add course completion items with passing grade
+    Given I am on the "Test assign name" "assign activity" page logged in as teacher1
+    And I navigate to "Settings" in current page administration
+    And I set the following fields to these values:
+      | Completion tracking       | Show activity as complete when conditions are met |
+      | completionusegrade        | 1                                                 |
+      | completionpassgrade       | 1                                                 |
+      | gradepass                 | 50                                                |
+    And I press "Save and return to course"
+    And I am on the "Test assign name" "assign activity" page
+    And I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student" "table_row"
+    And I set the field "Grade out of 100" to "53"
+    And I set the field "Notify students" to "0"
+    And I press "Save changes"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Course completion status" block
+    And I navigate to "Course completion" in current page administration
+    And I expand all fieldsets
+    And I set the following fields to these values:
+      | Test assign name | 1 |
+    And I press "Save changes"
+    And I log out
+    When I am on the "Test assign name" "assign activity" page logged in as student1
+    And I am on "Course 1" course homepage
+    Then I should see "Status: Pending" in the "Course completion status" "block"
+    And I should see "0 of 1" in the "Activity completion" "table_row"
+    And I trigger cron
+    And I am on "Course 1" course homepage
+    And I follow "More details"
+    And I should see "Achieving grade, Achieving passing grade" in the "Activity completion" "table_row"
+    And I should see "Yes" in the "Activity completion" "table_row"
+
+  @javascript
+  Scenario: Add the block to a the course and add course completion items with failing grade.
+    Given I am on the "Test assign name" "assign activity" page logged in as teacher1
+    And I navigate to "Settings" in current page administration
+    And I set the following fields to these values:
+      | Completion tracking       | Show activity as complete when conditions are met |
+      | completionusegrade        | 1                                                 |
+      | completionpassgrade       | 1                                                 |
+      | gradepass                 | 50                                                |
+    And I press "Save and return to course"
+    And I am on the "Test assign name" "assign activity" page
+    And I navigate to "View all submissions" in current page administration
+    And I click on "Grade" "link" in the "Student" "table_row"
+    And I set the field "Grade out of 100" to "49"
+    And I set the field "Notify students" to "0"
+    And I press "Save changes"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Course completion status" block
+    And I navigate to "Course completion" in current page administration
+    And I expand all fieldsets
+    And I set the following fields to these values:
+      | Test assign name | 1 |
+    And I press "Save changes"
+    And I log out
+    When I am on the "Test assign name" "assign activity" page logged in as student1
+    And I am on "Course 1" course homepage
+    Then I should see "Status: Pending" in the "Course completion status" "block"
+    And I should see "0 of 1" in the "Activity completion" "table_row"
+    And I trigger cron
+    And I am on "Course 1" course homepage
+    And I follow "More details"
+    And I should see "Achieving grade, Achieving passing grade" in the "Activity completion" "table_row"
+    And I should see "Yes" in the "Activity completion" "table_row"
index a0de2d1..299bb29 100644 (file)
@@ -48,12 +48,19 @@ class block_site_main_menu extends block_list {
         }
 
         $course = get_site();
+        $format = course_get_format($course);
+        $courserenderer = $format->get_renderer($this->page);
+
         require_once($CFG->dirroot.'/course/lib.php');
+
         $context = context_course::instance($course->id);
         $isediting = $this->page->user_is_editing() && has_capability('moodle/course:manageactivities', $context);
-        $courserenderer = $this->page->get_renderer('core', 'course');
 
-/// extra fast view mode
+        // Output classes.
+        $cmnameclass = $format->get_output_classname('content\\cm\\cmname');
+        $controlmenuclass = $format->get_output_classname('content\\cm\\controlmenu');
+
+        // Extra fast view mode.
         if (!$isediting) {
             $modinfo = get_fast_modinfo($course);
             if (!empty($modinfo->sections[0])) {
@@ -69,13 +76,17 @@ class block_site_main_menu extends block_list {
                         $indent = '';
                     }
 
-                    if (!empty($cm->url)) {
-                        $content = html_writer::div($courserenderer->course_section_cm_name($cm), 'activity');
+                    if (empty($cm->url)) {
+                        $content = html_writer::div(
+                            $cm->get_formatted_content(['overflowdiv' => true, 'noclean' => true]),
+                            'contentwithoutlink'
+                        );
                     } else {
-                        $content = $courserenderer->course_section_cm_text($cm);
+                        $cmname = new $cmnameclass($format, $cm->get_section_info(), $cm, $isediting);
+                        $content = $courserenderer->render($cmname);
                     }
 
-                    $this->content->items[] = $indent . html_writer::div($content, 'main-menu-content');
+                    $this->content->items[] = $indent . html_writer::div($content, 'main-menu-content activity');
                 }
             }
             return $this->content;
@@ -106,18 +117,28 @@ class block_site_main_menu extends block_list {
                     continue;
                 }
                 if (!$ismoving) {
-                    $actions = course_get_cm_edit_actions($mod, $mod->indent);
-
-                    // Prepend list of actions with the 'move' action.
-                    $actions = array('move' => new action_menu_link_primary(
-                        new moodle_url('/course/mod.php', array('sesskey' => sesskey(), 'copy' => $mod->id)),
-                        new pix_icon('t/move', $strmove, 'moodle', array('class' => 'iconsmall', 'title' => '')),
-                        $strmove
-                    )) + $actions;
-
-                    $editbuttons = html_writer::tag('div',
-                        $courserenderer->course_section_cm_edit_actions($actions, $mod, array('donotenhance' => true)),
-                        array('class' => 'buttons')
+
+                    $controlmenu = new $controlmenuclass(
+                        $format,
+                        $mod->get_section_info(),
+                        $mod
+                    );
+
+                    $menu = $controlmenu->get_action_menu();
+
+                    // Add a move primary action.
+                    $menu->add(
+                        new action_menu_link_primary(
+                            new moodle_url('/course/mod.php', ['sesskey' => sesskey(), 'copy' => $mod->id]),
+                            new pix_icon('t/move', $strmove, 'moodle', ['class' => 'iconsmall', 'title' => '']),
+                            $strmove
+                        )
+                    );
+
+                    $editbuttons = html_writer::tag(
+                        'div',
+                        $courserenderer->render($controlmenu),
+                        ['class' => 'buttons']
                     );
                 } else {
                     $editbuttons = '';
@@ -138,9 +159,13 @@ class block_site_main_menu extends block_list {
                         $indent = '';
                     }
                     if (!$mod->url) {
-                        $content = $courserenderer->course_section_cm_text($mod);
+                        $content = html_writer::div(
+                            $mod->get_formatted_content(['overflowdiv' => true, 'noclean' => true]),
+                            'contentwithoutlink'
+                        );
                     } else {
-                        $content = html_writer::div($courserenderer->course_section_cm_name($mod), ' activity');
+                        $cmname = new $cmnameclass($format, $mod->get_section_info(), $mod, $isediting);
+                        $content = html_writer::div($courserenderer->render($cmname), 'activity');
                     }
                     $this->content->items[] = $indent . html_writer::div($content . $editbuttons, 'main-menu-content');
                 }
@@ -159,5 +184,3 @@ class block_site_main_menu extends block_list {
         return $this->content;
     }
 }
-
-
index 35c651b..4f5a085 100644 (file)
@@ -48,7 +48,8 @@ class block_social_activities extends block_list {
         }
 
         $course = $this->page->course;
-        $courserenderer = $this->page->get_renderer('core', 'course');
+        $format = course_get_format($course);
+        $courserenderer = $format->get_renderer($this->page);
 
         require_once($CFG->dirroot.'/course/lib.php');
 
@@ -56,7 +57,11 @@ class block_social_activities extends block_list {
         $isediting = $this->page->user_is_editing() && has_capability('moodle/course:manageactivities', $context);
         $modinfo = get_fast_modinfo($course);
 
-/// extra fast view mode
+        // Output classes.
+        $cmnameclass = $format->get_output_classname('content\\cm\\cmname');
+        $controlmenuclass = $format->get_output_classname('content\\cm\\controlmenu');
+
+        // Extra fast view mode.
         if (!$isediting) {
             if (!empty($modinfo->sections[0])) {
                 foreach($modinfo->sections[0] as $cmid) {
@@ -66,18 +71,21 @@ class block_social_activities extends block_list {
                     }
 
                     if (!$cm->url) {
-                        $content = $courserenderer->course_section_cm_text($cm);
+                        $content = html_writer::div(
+                            $cm->get_formatted_content(['overflowdiv' => true, 'noclean' => true]),
+                            'contentwithoutlink'
+                        );
                         $this->content->items[] = $content;
                         $this->content->icons[] = '';
                     } else {
-                        $this->content->items[] = html_writer::div($courserenderer->course_section_cm_name($cm), 'activity');
+                        $cmname = new $cmnameclass($format, $cm->get_section_info(), $cm, $isediting);
+                        $this->content->items[] = html_writer::div($courserenderer->render($cmname), 'activity');
                     }
                 }
             }
             return $this->content;
         }
 
-
         // Slow & hacky editing mode.
         $ismoving = ismoving($course->id);
         $section = $modinfo->get_section_info(0);
@@ -102,18 +110,28 @@ class block_social_activities extends block_list {
                     continue;
                 }
                 if (!$ismoving) {
-                    $actions = course_get_cm_edit_actions($mod, -1);
 
-                    // Prepend list of actions with the 'move' action.
-                    $actions = array('move' => new action_menu_link_primary(
-                        new moodle_url('/course/mod.php', array('sesskey' => sesskey(), 'copy' => $mod->id)),
-                        new pix_icon('t/move', $strmove, 'moodle', array('class' => 'iconsmall', 'title' => '')),
-                        $strmove
-                    )) + $actions;
+                    $controlmenu = new $controlmenuclass(
+                        $format,
+                        $mod->get_section_info(),
+                        $mod,
+                        ['disableindentation' => true]
+                    );
+
+                    $menu = $controlmenu->get_action_menu();
+
+                    // Add a move primary action.
+                    $menu->add(
+                        new action_menu_link_primary(
+                            new moodle_url('/course/mod.php', ['sesskey' => sesskey(), 'copy' => $mod->id]),
+                            new pix_icon('t/move', $strmove, 'moodle', ['class' => 'iconsmall', 'title' => '']),
+                            $strmove
+                        )
+                    );
 
                     $editbuttons = html_writer::tag('div',
-                        $courserenderer->course_section_cm_edit_actions($actions, $mod, array('donotenhance' => true)),
-                        array('class' => 'buttons')
+                        $courserenderer->render($controlmenu),
+                        ['class' => 'buttons']
                     );
                 } else {
                     $editbuttons = '';
@@ -129,12 +147,15 @@ class block_social_activities extends block_list {
                         $this->content->icons[] = '';
                     }
                     if (!$mod->url) {
-                        $content = $courserenderer->course_section_cm_text($mod);
+                        $content = html_writer::div(
+                            $mod->get_formatted_content(['overflowdiv' => true, 'noclean' => true]),
+                            'contentwithoutlink'
+                        );
                         $this->content->items[] = $content . $editbuttons;
                         $this->content->icons[] = '';
                     } else {
-                        $this->content->items[] = html_writer::div($courserenderer->course_section_cm_name($mod), 'activity') .
-                            $editbuttons;
+                        $cmname = new $cmnameclass($format, $mod->get_section_info(), $mod, $isediting);
+                        $this->content->items[] = html_writer::div($courserenderer->render($cmname), 'activity') . $editbuttons;
                     }
                 }
             }
index 185c3cd..3fbed12 100644 (file)
Binary files a/blocks/timeline/amd/build/event_list.min.js and b/blocks/timeline/amd/build/event_list.min.js differ
index be868ae..0bd718d 100644 (file)
Binary files a/blocks/timeline/amd/build/event_list.min.js.map and b/blocks/timeline/amd/build/event_list.min.js.map differ
index 70345b1..0e8c409 100644 (file)
Binary files a/blocks/timeline/amd/build/view_courses.min.js and b/blocks/timeline/amd/build/view_courses.min.js differ
index dcf3ab3..959b6f0 100644 (file)
Binary files a/blocks/timeline/amd/build/view_courses.min.js.map and b/blocks/timeline/amd/build/view_courses.min.js.map differ
index 785939b..30f9368 100644 (file)
@@ -42,6 +42,7 @@ function(
 ) {
 
     var SECONDS_IN_DAY = 60 * 60 * 24;
+    var courseview = false;
 
     var SELECTORS = {
         EMPTY_MESSAGE: '[data-region="empty-message"]',
@@ -127,6 +128,7 @@ function(
     var buildTemplateContext = function(calendarEvents, midnight) {
         var eventsByDay = {};
         var templateContext = {
+            courseview,
             eventsbyday: []
         };
 
@@ -405,9 +407,11 @@ function(
      * @param {string} paginationAriaLabel String to set as the aria label for the pagination bar.
      * @param {object} additionalConfig Additional config options to pass to pagedContentFactory
      */
-    var init = function(root, pageLimit, preloadedPages, paginationAriaLabel, additionalConfig) {
+    var init = function(root, pageLimit, preloadedPages, paginationAriaLabel, additionalConfig = {}) {
         root = $(root);
 
+        courseview = !!additionalConfig.courseview;
+
         // Create a promise that will be resolved once the first set of page
         // data has been loaded. This ensures that the loading placeholder isn't
         // hidden until we have all of the data back to prevent the page elements
index a31f496..221c4e8 100644 (file)
@@ -322,10 +322,9 @@ function(
      * @param {Number} midnight The midnight timestamp in the user's timezone.
      * @param {Number} daysOffset Number of days from today to offset the events.
      * @param {Number} daysLimit Number of days from today to limit the events to.
-     * @param {string} noEventsURL URL for the image to display for no events.
      * @return {object} jQuery promise resolved after rendering is complete.
      */
-    var updateDisplayFromCourses = function(courses, root, midnight, daysOffset, daysLimit, noEventsURL) {
+    var updateDisplayFromCourses = function(courses, root, midnight, daysOffset, daysLimit) {
         // Render the courses template.
         return Templates.render(TEMPLATES.COURSE_ITEMS, {
             courses: courses,
@@ -335,9 +334,6 @@ function(
             daysoffset: daysOffset,
             dayslimit: daysLimit,
             nodayslimit: daysLimit == undefined,
-            urls: {
-                noevents: noEventsURL
-            }
         }).then(function(html) {
             hideLoadingPlaceholder(root);
 
@@ -398,13 +394,13 @@ function(
             var midnight = getMidnight(root);
             var startTime = getStartTime(root);
             var endTime = getEndTime(root);
-            var noEventsURL = root.attr('data-no-events-url');
+
             // Record the next offset if we want to request more courses.
             setOffset(root, nextOffset);
             // Load the events for these courses.
             var eventsPromise = loadEventsForCourses(courses, startTime, endTime);
             // Render the courses in the DOM.
-            var renderPromise = updateDisplayFromCourses(courses, root, midnight, daysOffset, daysLimit, noEventsURL);
+            var renderPromise = updateDisplayFromCourses(courses, root, midnight, daysOffset, daysLimit);
 
             return $.when(eventsPromise, renderPromise)
                 .then(function(eventsByCourse) {
@@ -495,16 +491,19 @@ function(
 
                     pageDeferred.resolve({events: events});
 
+                    var additionalConfig = {courseview: true};
                     // Re-initialise the events list with the preloaded events we just got from
                     // the server.
                     Str.get_string('ariaeventlistpaginationnavcourses', 'block_timeline', courseName)
                         .then(function(string) {
-                            EventList.init(eventListContainer, COURSE_EVENT_LIMIT, {'1': pageDeferred.promise()}, string);
+                            EventList.init(eventListContainer, COURSE_EVENT_LIMIT, {'1': pageDeferred.promise()},
+                                string, additionalConfig);
                             return string;
                         })
                         .catch(function() {
                             // Ignore a failure to load the string. Just render with the default string.
-                            EventList.init(eventListContainer, COURSE_EVENT_LIMIT, {'1': pageDeferred.promise()});
+                            EventList.init(eventListContainer, COURSE_EVENT_LIMIT, {'1': pageDeferred.promise()},
+                                undefined, additionalConfig);
                         });
                 });
 
index aad2bf0..f2fc7dc 100644 (file)
@@ -34,7 +34,7 @@ $string['duedate'] = 'Due date';
 $string['morecourses'] = 'More courses';
 $string['timeline:myaddinstance'] = 'Add a new timeline block to Dashboard';
 $string['nocoursesinprogress'] = 'No in-progress courses';
-$string['noevents'] = 'No upcoming activities due';
+$string['noevents'] = 'No activities require action';
 $string['next30days'] = 'Next 30 days';
 $string['next7days'] = 'Next 7 days';
 $string['next3months'] = 'Next 3 months';
diff --git a/blocks/timeline/styles.css b/blocks/timeline/styles.css
new file mode 100644 (file)
index 0000000..23ebfb1
--- /dev/null
@@ -0,0 +1,9 @@
+#block-region-side-pre .block_timeline h6.event-action {
+    flex-basis: 100%;
+}
+#block-region-side-pre .block_timeline .event-name-container {
+    flex-basis: 50%;
+}
+#block-region-side-pre .block_timeline h6.event-action a.btn {
+    width: auto;
+}
index dafb42a..8c8ffda 100644 (file)
@@ -28,7 +28,7 @@
 }}
 <li class="list-group-item mt-3 p-0 border-0">
     <div data-region="course-events-container" id="course-events-container-{{id}}" data-course-id="{{id}}">
-        <h4 class="h5"><a href="{{viewurl}}" data-region="course-name">{{{fullnamedisplay}}}</a></h4>
+        <h4 class="h5 font-weight-bold">{{{fullnamedisplay}}}</h4>
         {{< block_timeline/event-list }}
             {{$courseid}}{{id}}{{/courseid}}
         {{/ block_timeline/event-list }}
index f138e9c..11c7993 100644 (file)
         ]
     }
 }}
-<div class="border-bottom pb-2">
+<div class="pb-2">
     {{#eventsbyday}}
-        <h5 class="h6 mt-3 mb-0 {{#past}}text-danger{{/past}}">{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedaydate, core_langconfig {{/str}}  {{/userdate}}</h5>
+        <div class="mt-3">
+            <h5 class="h6 d-inline {{^courseview}}font-weight-bold{{/courseview}}">{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedaydate, core_langconfig {{/str}}  {{/userdate}}</h5>
+            {{#past}}<span class="badge badge-pill badge-danger">{{#str}} overdue, block_timeline {{/str}}</span>{{/past}}
+        </div>
         {{> block_timeline/event-list-items }}
     {{/eventsbyday}}
-</div>
\ No newline at end of file
+</div>
index a33a10e..b824daf 100644 (file)
 
     Example context (json):
     {
-        "name": "Assignment due 1",
+        "name": "Assignment 1 is due",
+        "activityname": "Assignment",
+        "activitystr": "Assignment is due",
+        "courseview": true,
         "url": "https://www.google.com",
         "timesort": 1490320388,
         "course": {
         }
     }
 }}
-<div class="list-group-item flex-column py-2 pl-0 pr-0 border-0"
-    data-region="event-list-item"
-    >
-    <div class="d-flex">
-        <div class="icon-size-4 d-flex align-self-top">
+<div class="list-group-item timeline-event-list-item flex-column py-2 pl-0 pr-0 border-bottom"
+        data-region="event-list-item">
+    <div class="d-flex justify-content-between flex-wrap">
+        <small class="text-right text-nowrap align-self-center ml-1">
+            {{#userdate}} {{timesort}}, {{#str}} strftimetime24, core_langconfig {{/str}} {{/userdate}}
+        </small>
+        <div class="icon-size-4 align-self-top align-self-center mx-2">
             {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}}
         </div>
-        <div class="w-100 event-name-container text-truncate line-height-3">
+        <div class="event-name-container flex-grow-1 text-truncate line-height-3">
             <a href="{{url}}"
                title={{#quote}}{{{name}}}{{/quote}}
-               aria-label='{{#str}} ariaeventlistitem, block_timeline, { "name": {{#quote}}{{{name}}}{{/quote}}, "course": {{#quote}}{{{course.fullnamedisplay}}}{{/quote}}, "date": "{{#userdate}} {{timesort}}, {{#str}} strftimedatetime, core_langconfig {{/str}} {{/userdate}}" } {{/str}}'
-            ><h6 class="event-name text-truncate mb-0">{{{name}}}</h6></a>
-            {{#course.fullnamedisplay}}
-            <small class="text-muted text-truncate mb-0">{{{course.fullnamedisplay}}}</small>
-            {{/course.fullnamedisplay}}
-            {{#action.actionable}}
-            <h6 class="mb-0 pt-2">
-                <a href="{{action.url}}" aria-label="{{action.name}}" title="{{action.name}}" class="list-group-item-action">{{{action.name}}}</a>
-                {{#action.showitemcount}}
-                <span class="badge badge-secondary">{{action.itemcount}}</span>
-                {{/action.showitemcount}}
-            </h6>
-            {{/action.actionable}}
+               aria-label='{{#str}} ariaeventlistitem, block_timeline, { "name": {{#quote}}{{{activityname}}}{{/quote}}, "course": {{#quote}}{{{course.fullnamedisplay}}}{{/quote}}, "date": "{{#userdate}} {{timesort}}, {{#str}} strftimedatetime, core_langconfig {{/str}} {{/userdate}}" } {{/str}}'>
+                <h6 class="event-name text-truncate mb-0">{{{activityname}}}</h6>
+            </a>
+            <small class="text-truncate mb-0">
+                {{#courseview}}
+                    {{activitystr}}
+                {{/courseview}}
+                {{^courseview}}
+                    {{#course.fullnamedisplay}}{{{course.fullnamedisplay}}} &middot; {{/course.fullnamedisplay}}{{activitystr}}
+                {{/courseview}}
+            </small>
         </div>
-        <small class="text-right text-nowrap ml-1">
-            {{#userdate}} {{timesort}}, {{#str}} strftimetime24, core_langconfig {{/str}} {{/userdate}}
-        </small>
-
+        {{#action.actionable}}
+        <h6 class="event-action">
+            <a class="list-group-item-action btn btn-outline-secondary btn-sm text-nowrap"
+                   href="{{action.url}}"
+                   aria-label="{{action.name}}"
+                   title="{{action.name}}">
+               {{{action.name}}}
+               {{#action.showitemcount}}
+               <span class="badge badge-secondary">{{action.itemcount}}</span>
+               {{/action.showitemcount}}
+            </a>
+        </h6>
+        {{/action.actionable}}
     </div>
 </div>
index 999d1fb..2925d0d 100644 (file)
@@ -43,6 +43,7 @@
         </div>
     </div>
     <div data-region="event-list-content"></div>
+    {{#urls.noevents}}
     <div class="hidden text-xs-center text-center mt-3" data-region="empty-message">
         <img
             src="{{urls.noevents}}"
         >
         <p class="text-muted mt-1">{{#str}} noevents, block_timeline {{/str}}</p>
     </div>
+    {{/urls.noevents}}
+    {{^urls.noevents}}
+    <div class="hidden text-xs-left text-left mt-3" data-region="empty-message">
+        <p class="text-muted mt-1">{{#str}} noevents, block_timeline {{/str}}</p>
+    </div>
+    {{/urls.noevents}}
 </div>
index 83844f6..7073e64 100644 (file)
@@ -30,7 +30,7 @@
     <div class="bg-pulse-grey mt-1" style="width: 100px; height: 30px; margin-left: auto; margin-right: auto"></div>
 </div>
 <ul class="list-group unstyled" data-region="courses-list"></ul>
-<div class="hidden text-xs-center text-center pt-3" data-region="more-courses-button-container">
+<div class="hidden text-xs-left text-left pt-3" data-region="more-courses-button-container">
     <button type="button" class="btn btn-secondary" data-action="more-courses">
         {{#str}} morecourses, block_timeline {{/str}}
         <span class="hidden" data-region="loading-icon-container">
index bde9ef6..dcc924b 100644 (file)
@@ -41,14 +41,17 @@ Feature: The timeline block allows users to see upcoming courses
     Then I should see "Course 1" in the "Timeline" "block"
     And I should see "Course 2" in the "Timeline" "block"
     And I should see "More courses" in the "Timeline" "block"
-    And I should see "Test choice 1 closes" in the "Timeline" "block"
-    And I should see "Test feedback 1 closes" in the "Timeline" "block"
+    And "Test choice 1" "link" should exist in the "Timeline" "block"
+    And I should see "Choice closes" in the "Timeline" "block"
+    And "Test feedback 1" "link" should exist in the "Timeline" "block"
+    And I should see "Feedback closes" in the "Timeline" "block"
     And I should not see "Course 3" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
-    And I should not see "Test choice 3 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 2 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 3 closes" in the "Timeline" "block"
-    And I should not see "Test assign 1 is due" in the "Timeline" "block"
+    And "Test choice 2" "link" should not exist in the "Timeline" "block"
+    And "Test choice 3" "link" should not exist in the "Timeline" "block"
+    And "Test feedback 2" "link" should not exist in the "Timeline" "block"
+    And "Test feedback 3" "link" should not exist in the "Timeline" "block"
+    And "Test assign 1" "link" should not exist in the "Timeline" "block"
+    And I should not see "Assignment is due" in the "Timeline" "block"
 
   Scenario: All in course view
     Given I log in as "student1"
@@ -60,16 +63,18 @@ Feature: The timeline block allows users to see upcoming courses
     Then I should see "Course 3" in the "Timeline" "block"
     And I should see "Course 2" in the "Timeline" "block"
     And I should see "Course 1" in the "Timeline" "block"
-    And I should see "Test choice 1 closes" in the "Timeline" "block"
-    And I should see "Test choice 3 closes" in the "Timeline" "block"
-    And I should see "Test feedback 1 closes" in the "Timeline" "block"
-    And I should see "Test feedback 2 closes" in the "Timeline" "block"
-    And I should see "Test feedback 3 closes" in the "Timeline" "block"
-    And I should see "Test assign 1 is due" in the "Timeline" "block"
+    And "Test choice 1" "link" should exist in the "Timeline" "block"
+    And "Test choice 3" "link" should exist in the "Timeline" "block"
+    And "Test feedback 1" "link" should exist in the "Timeline" "block"
+    And "Test feedback 2" "link" should exist in the "Timeline" "block"
+    And "Test feedback 3" "link" should exist in the "Timeline" "block"
+    And "Test assign 1" "link" should exist in the "Timeline" "block"
+    And I should see "Assignment is due" in the "Timeline" "block"
+    And I should see "Choice closes" in the "Timeline" "block"
     And I should not see "More courses" in the "Timeline" "block"
     And I should not see "Course 4" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 4 closes" in the "Timeline" "block"
+    And "Test choice 2" "link" should not exist in the "Timeline" "block"
+    And I should not see "Test feedback 4" in the "Timeline" "block"
 
   Scenario: Persistent sort filter
     Given I log in as "student1"
@@ -81,14 +86,14 @@ Feature: The timeline block allows users to see upcoming courses
     Then I should see "Course 1" in the "Timeline" "block"
     And I should see "Course 2" in the "Timeline" "block"
     And I should see "More courses" in the "Timeline" "block"
-    And I should see "Test choice 1 closes" in the "Timeline" "block"
-    And I should see "Test feedback 1 closes" in the "Timeline" "block"
+    And "Test choice 1" "link" should exist in the "Timeline" "block"
+    And "Test feedback 1" "link" should exist in the "Timeline" "block"
     And I should not see "Course 3" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
-    And I should not see "Test choice 3 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 2 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 3 closes" in the "Timeline" "block"
-    And I should not see "Test assign 1 is due" in the "Timeline" "block"
+    And "Test choice 2" "link" should not exist in the "Timeline" "block"
+    And "Test choice 3" "link" should not exist in the "Timeline" "block"
+    And "Test feedback 2" "link" should not exist in the "Timeline" "block"
+    And "Test feedback 3" "link" should not exist in the "Timeline" "block"
+    And "Test assign 1" "link" should not exist in the "Timeline" "block"
 
   Scenario: Persistent All in course view
     Given I log in as "student1"
@@ -101,16 +106,16 @@ Feature: The timeline block allows users to see upcoming courses
     Then I should see "Course 3" in the "Timeline" "block"
     And I should see "Course 2" in the "Timeline" "block"
     And I should see "Course 1" in the "Timeline" "block"
-    And I should see "Test choice 1 closes" in the "Timeline" "block"
-    And I should see "Test choice 3 closes" in the "Timeline" "block"
-    And I should see "Test feedback 1 closes" in the "Timeline" "block"
-    And I should see "Test feedback 2 closes" in the "Timeline" "block"
-    And I should see "Test feedback 3 closes" in the "Timeline" "block"
-    And I should see "Test assign 1 is due" in the "Timeline" "block"
+    And "Test choice 1" "link" should exist in the "Timeline" "block"
+    And "Test choice 3" "link" should exist in the "Timeline" "block"
+    And "Test feedback 1" "link" should exist in the "Timeline" "block"
+    And "Test feedback 2" "link" should exist in the "Timeline" "block"
+    And "Test feedback 3" "link" should exist in the "Timeline" "block"
+    And "Test assign 1" "link" should exist in the "Timeline" "block"
     And I should not see "More courses" in the "Timeline" "block"
     And I should not see "Course 4" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 4 closes" in the "Timeline" "block"
+    And "Test choice 2" "link" should not exist in the "Timeline" "block"
+    And "Test feedback 4" "link" should not exist in the "Timeline" "block"
 
   Scenario: Current filtering always applies in courses view
     Given I log in as "student1"
@@ -119,8 +124,8 @@ Feature: The timeline block allows users to see upcoming courses
     And I click on "Filter timeline by date" "button" in the "Timeline" "block"
     And I click on "Overdue" "link" in the "Timeline" "block"
     And I reload the page
-    And I should see "Test assign 1 is due" in the "Timeline" "block"
-    And I should not see "Test feedback 2 closes" in the "Timeline" "block"
+    And "Test assign 1" "link" should exist in the "Timeline" "block"
+    And "Test feedback 2" "link" should not exist in the "Timeline" "block"
     And I click on "Sort timeline items" "button" in the "Timeline" "block"
     And I click on "Sort by dates" "link" in the "Timeline" "block"
     And I click on "Filter timeline by date" "button" in the "Timeline" "block"
@@ -129,5 +134,5 @@ Feature: The timeline block allows users to see upcoming courses
     And I click on "Sort timeline items" "button" in the "Timeline" "block"
     And I click on "Sort by courses" "link" in the "Timeline" "block"
     And I click on "More courses" "button" in the "Timeline" "block"
-    Then I should see "Test assign 1 is due" in the "Timeline" "block"
-    And I should see "Test feedback 2 closes" in the "Timeline" "block"
+    Then "Test assign 1" "link" should exist in the "Timeline" "block"
+    And "Test feedback 2" "link" should exist in the "Timeline" "block"
index 3e7810f..a0acb89 100644 (file)
@@ -35,43 +35,53 @@ Feature: The timeline block allows users to see upcoming activities
     Given I log in as "student1"
     And I click on "Filter timeline by date" "button" in the "Timeline" "block"
     When I click on "Next 7 days" "link" in the "Timeline" "block"
-    Then I should see "Test choice 1 closes" in the "Timeline" "block"
-    And I should see "Test feedback 1 closes" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
-    And I should not see "Test choice 3 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 3 closes" in the "Timeline" "block"
-    And I should not see "Test assign 1 is due" in the "Timeline" "block"
+    Then "Test choice 1" "link" should exist in the "Timeline" "block"
+    And I should see "Course 2 · Choice closes" in the "Timeline" "block"
+    And "Test feedback 1" "link" should exist in the "Timeline" "block"
+    And I should see "Course 2 · Feedback closes" in the "Timeline" "block"
+    And "Test choice 2" "link" should not exist in the "Timeline" "block"
+    And "Test choice 3" "link" should not exist in the "Timeline" "block"
+    And "Test feedback 3" "link" should not exist in the "Timeline" "block"
+    And "Test assign 1" "link" should not exist in the "Timeline" "block"
+    And I should not see "Course 1 · Assignment is due" in the "Timeline" "block"
 
   Scenario: Overdue in date view
     Given I log in as "student1"
     And I click on "Filter timeline by date" "button" in the "Timeline" "block"
     When I click on "Overdue" "link" in the "Timeline" "block"
-    Then I should see "Test assign 1 is due" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 1 closes" in the "Timeline" "block"
-    And I should not see "Test choice 1 closes" in the "Timeline" "block"
-    And I should not see "Test choice 3 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 3 closes" in the "Timeline" "block"
+    And "Test assign 1" "link" should exist in the "Timeline" "block"
+    Then I should see "Course 1 · Assignment is due" in the "Timeline" "block"
+    And "Test choice 2" "link" should not exist in the "Timeline" "block"
+    And "Test feedback 1" "link" should not exist in the "Timeline" "block"
+    And "Test choice 1" "link" should not exist in the "Timeline" "block"
+    And "Test choice 3" "link" should not exist in the "Timeline" "block"
+    And "Test feedback 3" "link" should not exist in the "Timeline" "block"
 
   Scenario: All in date view
     Given I log in as "student1"
     And I click on "Filter timeline by date" "button" in the "Timeline" "block"
     When I click on "All" "link" in the "Timeline" "block"
-    Then I should see "Test assign 1 is due" in the "Timeline" "block"
-    And I should see "Test feedback 1 closes" in the "Timeline" "block"
-    And I should see "Test choice 1 closes" in the "Timeline" "block"
-    And I should see "Test choice 3 closes" in the "Timeline" "block"
-    And I should see "Test feedback 3 closes" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 2 closes" in the "Timeline" "block"
+    Then "Test assign 1" "link" should exist in the "Timeline" "block"
+    And I should see "Course 1 · Assignment is due" in the "Timeline" "block"
+    And "Test feedback 1" "link" should exist in the "Timeline" "block"
+    And I should see "Course 2 · Feedback closes" in the "Timeline" "block"
+    And "Test choice 1" "link" should exist in the "Timeline" "block"
+    And I should see "Course 2 · Choice closes" in the "Timeline" "block"
+    And "Test choice 3" "link" should exist in the "Timeline" "block"
+    And I should see "Course 3 · Choice closes" in the "Timeline" "block"
+    And "Test feedback 3" "link" should exist in the "Timeline" "block"
+    And I should see "Course 3 · Feedback closes" in the "Timeline" "block"
+    And "Test choice 2" "link" should not exist in the "Timeline" "block"
+    And "Test feedback 2" "link" should not exist in the "Timeline" "block"
     And I click on "[data-region='paging-bar'] [data-control='next'] [data-region='page-link']" "css_element" in the "Timeline" "block"
-    And I should see "Test feedback 2 closes" in the "Timeline" "block"
-    And I should not see "Test assign 1 is due" in the "Timeline" "block"
-    And I should not see "Test feedback 1 closes" in the "Timeline" "block"
-    And I should not see "Test choice 1 closes" in the "Timeline" "block"
-    And I should not see "Test choice 3 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 3 closes" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
+    And "Test feedback 2" "link" should exist in the "Timeline" "block"
+    And I should see "Course 1 · Feedback closes" in the "Timeline" "block"
+    And I should not see "Test assign 1" in the "Timeline" "block"
+    And I should not see "Test feedback 1" in the "Timeline" "block"
+    And I should not see "Test choice 1" in the "Timeline" "block"
+    And I should not see "Test choice 3" in the "Timeline" "block"
+    And I should not see "Test feedback 3" in the "Timeline" "block"
+    And I should not see "Test choice 2" in the "Timeline" "block"
 
   Scenario: All in date view no next
     Given I log in as "student1"
@@ -79,46 +89,58 @@ Feature: The timeline block allows users to see upcoming activities
     And I click on "All" "link" in the "Timeline" "block"
     And I click on "5" "button" in the "Timeline" "block"
     When I click on "25" "link" in the "Timeline" "block"
-    Then I should see "Test assign 1 is due" in the "Timeline" "block"
-    And I should see "Test feedback 1 closes" in the "Timeline" "block"
-    And I should see "Test choice 1 closes" in the "Timeline" "block"
-    And I should see "Test choice 3 closes" in the "Timeline" "block"
-    And I should see "Test feedback 3 closes" in the "Timeline" "block"
-    And I should see "Test feedback 2 closes" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
+    Then "Test assign 1" "link" should exist in the "Timeline" "block"
+    And I should see "Course 1 · Assignment is due" in the "Timeline" "block"
+    And "Test feedback 1" "link" should exist in the "Timeline" "block"
+    And I should see "Course 2 · Feedback closes" in the "Timeline" "block"
+    And "Test choice 1" "link" should exist in the "Timeline" "block"
+    And I should see "Course 2 · Choice closes" in the "Timeline" "block"
+    And "Test choice 3" "link" should exist in the "Timeline" "block"
+    And I should see "Course 3 · Choice closes" in the "Timeline" "block"
+    And "Test feedback 3" "link" should exist in the "Timeline" "block"
+    And I should see "Course 3 · Feedback closes" in the "Timeline" "block"
+    And "Test feedback 2" "link" should exist in the "Timeline" "block"
+    And I should see "Course 1 · Feedback closes" in the "Timeline" "block"
+    And I should not see "Test choice 2" in the "Timeline" "block"
 
   Scenario: Persistent All in date view
     Given I log in as "student1"
     And I click on "Filter timeline by date" "button" in the "Timeline" "block"
     When I click on "All" "link" in the "Timeline" "block"
     And I reload the page
-    Then I should see "Test assign 1 is due" in the "Timeline" "block"
-    And I should see "Test feedback 1 closes" in the "Timeline" "block"
-    And I should see "Test choice 1 closes" in the "Timeline" "block"
-    And I should see "Test choice 3 closes" in the "Timeline" "block"
-    And I should see "Test feedback 3 closes" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 2 closes" in the "Timeline" "block"
+    Then "Test assign 1" "link" should exist in the "Timeline" "block"
+    And I should see "Course 1 · Assignment is due" in the "Timeline" "block"
+    And "Test feedback 1" "link" should exist in the "Timeline" "block"
+    And I should see "Course 2 · Feedback closes" in the "Timeline" "block"
+    And "Test choice 1" "link" should exist in the "Timeline" "block"
+    And I should see "Course 2 · Choice closes" in the "Timeline" "block"
+    And "Test choice 3" "link" should exist in the "Timeline" "block"
+    And I should see "Course 3 · Choice closes" in the "Timeline" "block"
+    And "Test feedback 3" "link" should exist in the "Timeline" "block"
+    And I should see "Course 3 · Feedback closes" in the "Timeline" "block"
+    And I should not see "Test choice 2" in the "Timeline" "block"
+    And I should not see "Test feedback 2" in the "Timeline" "block"
     And I click on "[data-region='paging-bar'] [data-control='next']" "css_element" in the "Timeline" "block"
-    And I should see "Test feedback 2 closes" in the "Timeline" "block"
-    And I should not see "Test assign 1 is due" in the "Timeline" "block"
-    And I should not see "Test feedback 1 closes" in the "Timeline" "block"
-    And I should not see "Test choice 1 closes" in the "Timeline" "block"
-    And I should not see "Test choice 3 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 3 closes" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
+    And "Test feedback 2" "link" should exist in the "Timeline" "block"
+    And I should see "Course 1 · Feedback closes" in the "Timeline" "block"
+    And I should not see "Test assign 1" in the "Timeline" "block"
+    And I should not see "Test feedback 1" in the "Timeline" "block"
+    And I should not see "Test feedback 3" in the "Timeline" "block"
+    And I should not see "Test choice 1" in the "Timeline" "block"
+    And I should not see "Test choice 2" in the "Timeline" "block"
+    And I should not see "Test choice 3" in the "Timeline" "block"
 
   Scenario: Persistent Overdue in date view
     Given I log in as "student1"
     And I click on "Filter timeline by date" "button" in the "Timeline" "block"
     When I click on "Overdue" "link" in the "Timeline" "block"
     And I reload the page
-    Then I should see "Test assign 1 is due" in the "Timeline" "block"
-    And I should not see "Test choice 2 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 1 closes" in the "Timeline" "block"
-    And I should not see "Test choice 1 closes" in the "Timeline" "block"
-    And I should not see "Test choice 3 closes" in the "Timeline" "block"
-    And I should not see "Test feedback 3 closes" in the "Timeline" "block"
+    Then "Test assign 1" "link" should exist in the "Timeline" "block"
+    And "Test choice 2" "link" should not exist in the "Timeline" "block"
+    And "Test feedback 1" "link" should not exist in the "Timeline" "block"
+    And "Test choice 1" "link" should not exist in the "Timeline" "block"
+    And "Test choice 3" "link" should not exist in the "Timeline" "block"
+    And "Test feedback 3" "link" should not exist in the "Timeline" "block"
 
   Scenario: Current filtering always applies in date view
     Given I log in as "student1"
@@ -128,8 +150,8 @@ Feature: The timeline block allows users to see upcoming activities
     # Expand to 25 results, to ensure we see the earliest and latest timeline items.
     And I click on "Show 5 activities per page" "button" in the "Timeline" "block"
     And I click on "25" "link"
-    And I should see "Test assign 1 is due" in the "Timeline" "block"
-    And I should not see "Test feedback 2 closes" in the "Timeline" "block"
+    And "Test assign 1" "link" should exist in the "Timeline" "block"
+    And "Test feedback 2" "link" should not exist in the "Timeline" "block"
     And I click on "Sort timeline items" "button" in the "Timeline" "block"
     And I click on "Sort by courses" "link" in the "Timeline" "block"
     And I click on "Filter timeline by date" "button" in the "Timeline" "block"
@@ -137,5 +159,5 @@ Feature: The timeline block allows users to see upcoming activities
     When I click on "All" "link" in the "Timeline" "block"
     And I click on "Sort timeline items" "button" in the "Timeline" "block"
     And I click on "Sort by dates" "link" in the "Timeline" "block"
-    Then I should see "Test assign 1 is due" in the "Timeline" "block"
-    And I should see "Test feedback 2 closes" in the "Timeline" "block"
+    Then "Test assign 1" "link" should exist in the "Timeline" "block"
+    And "Test feedback 2" "link" should exist in the "Timeline" "block"
index 2107414..7f76119 100644 (file)
@@ -100,6 +100,19 @@ class event_exporter_base extends exporter {
         if ($cm = $event->get_course_module()) {
             $data->modulename = $cm->get('modname');
             $data->instance = $cm->get('id');
+            $data->activityname = $cm->get('name');
+
+            $component = 'mod_' . $data->modulename;
+            if (!component_callback_exists($component, 'core_calendar_get_event_action_string')) {
+                $modulename = get_string('modulename', $data->modulename);
+                $data->activitystr = get_string('requiresaction', 'calendar', $modulename);
+            } else {
+                $data->activitystr = component_callback(
+                    $component,
+                    'core_calendar_get_event_action_string',
+                    [$event->get_type()]
+                );
+            }
         }
 
         parent::__construct($data, $related);
@@ -174,6 +187,18 @@ class event_exporter_base extends exporter {
                 'default' => null,
                 'null' => NULL_ALLOWED
             ],
+            'activityname' => [
+                'type' => PARAM_TEXT,
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED
+            ],
+            'activitystr' => [
+                'type' => PARAM_TEXT,
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED
+            ],
             'instance' => [
                 'type' => PARAM_INT,
                 'optional' => true,
index e7cf9fe..59247d0 100644 (file)
@@ -91,7 +91,7 @@
                                         {{/underway}}
                                         {{^underway}}
                                             <li data-region="event-item"
-                                                dava-event-component="{{component}}"
+                                                data-event-component="{{component}}"
                                                 data-event-eventtype="{{eventtype}}"
                                                 data-eventtype-{{normalisedeventtype}}="1"
                                                 {{#draggable}}
index 77599fd..55e7a54 100644 (file)
@@ -44,6 +44,7 @@ class cohort_edit_form extends moodleform {
 
         $options = $this->get_category_options($cohort->contextid);
         $mform->addElement('autocomplete', 'contextid', get_string('context', 'role'), $options);
+        $mform->addRule('contextid', null, 'required', null, 'client');
 
         $mform->addElement('text', 'idnumber', get_string('idnumber', 'cohort'), 'maxlength="254" size="50"');
         $mform->setType('idnumber', PARAM_RAW); // Idnumbers are plain text, must not be changed.
index 4f06cc2..b7f95a4 100644 (file)
@@ -40,15 +40,20 @@ abstract class activity_custom_completion {
     /** @var int The user's ID. */
     protected $userid;
 
+    /** @var array The current state of core completion */
+    protected $completionstate;
+
     /**
      * activity_custom_completion constructor.
      *
      * @param cm_info $cm
      * @param int $userid
+     * @param array|null $completionstate The current state of the core completion criteria
      */
-    public function __construct(cm_info $cm, int $userid) {
+    public function __construct(cm_info $cm, int $userid, ?array $completionstate = null) {
         $this->cm = $cm;
         $this->userid = $userid;
+        $this->completionstate = $completionstate;
     }
 
     /**
index a67cce7..0e6159a 100644 (file)
@@ -137,7 +137,8 @@ class core_completion_bulkedit_form extends core_completion_edit_base_form {
         // Completion: Don't let them choose automatic completion without turning
         // on some conditions.
         if (array_key_exists('completion', $data) &&
-                $data['completion'] == COMPLETION_TRACKING_AUTOMATIC && !empty($data['completionusegrade'])) {
+                $data['completion'] == COMPLETION_TRACKING_AUTOMATIC &&
+                (!empty($data['completionusegrade']) || !empty($data['completionpassgrade']))) {
             require_once($CFG->libdir.'/gradelib.php');
             $moduleswithoutgradeitem = [];
             foreach ($this->cms as $cm) {
index d081961..79004fb 100644 (file)
@@ -73,7 +73,11 @@ class cm_completion_details {
         $this->returndetails = $returndetails;
         $cmcompletionclass = activity_custom_completion::get_cm_completion_class($this->cminfo->modname);
         if ($cmcompletionclass) {
-            $this->cmcompletion = new $cmcompletionclass($this->cminfo, $this->userid);
+            $this->cmcompletion = new $cmcompletionclass(
+                $this->cminfo,
+                $this->userid,
+                $completioninfo->get_core_completion_state($cminfo, $userid)
+            );
         }
     }
 
@@ -128,6 +132,13 @@ class cm_completion_details {
                 'status' => $status,
                 'description' => get_string('detail_desc:receivegrade', 'completion'),
             ];
+
+            if (!is_null($this->cminfo->completionpassgrade) && $this->cminfo->completionpassgrade) {
+                $details['completionpassgrade'] = (object)[
+                    'status' => $completiondata->passgrade ?? COMPLETION_INCOMPLETE,
+                    'description' => get_string('detail_desc:receivepassgrade', 'completion'),
+                ];
+            }
         }
 
         if ($this->cmcompletion) {
index 505c789..903a51a 100644 (file)
@@ -199,10 +199,19 @@ abstract class core_completion_edit_base_form extends moodleform {
 
         // Automatic completion once it's graded.
         if ($this->support_grades()) {
-            $mform->addElement('advcheckbox', 'completionusegrade', get_string('completionusegrade', 'completion'),
+            $group = [];
+            $group[] = $mform->createElement('advcheckbox', 'completionusegrade', get_string('completionusegrade', 'completion'),
                 get_string('completionusegrade_desc', 'completion'));
-            $mform->disabledIf('completionusegrade', 'completion', 'ne', COMPLETION_TRACKING_AUTOMATIC);
-            $mform->addHelpButton('completionusegrade', 'completionusegrade', 'completion');
+            $mform->addHelpButton('completionusegrade', 'completionusegrade', 'completion', '', true);
+
+            $group[] = $mform->createElement('advcheckbox', 'completionpassgrade', get_string('completionpassgrade', 'completion'),
+                get_string('completionpassgrade_desc', 'completion'));
+            $mform->addHelpButton('completionpassgrade', 'completionpassgrade', 'completion', '', true);
+            $mform->disabledIf('completionpassgrade', 'completionusegrade', 'notchecked');
+
+            $mform->addGroup($group, 'completionpassgroup', get_string('completionpassgrade', 'completion'), ' &nbsp; ', false);
+            $mform->disabledIf('completionpassgroup', 'completion', 'ne', COMPLETION_TRACKING_AUTOMATIC);
+
             $autocompletionpossible = true;
         }
 
@@ -247,7 +256,7 @@ abstract class core_completion_edit_base_form extends moodleform {
         // on some conditions.
         if (array_key_exists('completion', $data) &&
             $data['completion'] == COMPLETION_TRACKING_AUTOMATIC) {
-            if (empty($data['completionview']) && empty($data['completionusegrade']) &&
+            if (empty($data['completionview']) && empty($data['completionusegrade']) && empty($data['completionpassgrade']) &&
                 !$this->completion_rule_enabled($data)) {
                 $errors['completion'] = get_string('badautocompletion', 'completion');
             }
index 2f66775..83738d1 100644 (file)
@@ -176,7 +176,13 @@ class manager {
             }
             if ($moduledata instanceof cm_info && !is_null($moduledata->completiongradeitemnumber) ||
                 ($moduledata instanceof stdClass && !empty($moduledata->completionusegrade))) {
-                $activeruledescriptions[] = get_string('completionusegrade_desc', 'completion');
+
+                $description = 'completionusegrade_desc';
+                if (!empty($moduledata->completionpassgrade)) {
+                    $description = 'completionpassgrade_desc';
+                }
+
+                $activeruledescriptions[] = get_string($description, 'completion');
             }
 
             // Now, ask the module to provide descriptions for its custom conditional completion rules.
@@ -351,8 +357,11 @@ class manager {
     protected function apply_completion_cm(\cm_info $cm, $data, $updateinstance) {
         global $DB;
 
-        $defaults = ['completion' => COMPLETION_DISABLED, 'completionview' => COMPLETION_VIEW_NOT_REQUIRED,
-            'completionexpected' => 0, 'completiongradeitemnumber' => null];
+        $defaults = [
+            'completion' => COMPLETION_DISABLED, 'completionview' => COMPLETION_VIEW_NOT_REQUIRED,
+            'completionexpected' => 0, 'completiongradeitemnumber' => null,
+            'completionpassgrade' => 0
+        ];
 
         $data += ['completion' => $cm->completion,
             'completionexpected' => $cm->completionexpected,
@@ -374,6 +383,8 @@ class manager {
             $data['completiongradeitemnumber'] = !empty($data['completionusegrade']) ? 0 : null;
             unset($data['completionusegrade']);
         } else {
+            // Completion grade item number is classified in mod_edit forms as 'use grade'.
+            $data['completionusegrade'] = is_null($cm->completiongradeitemnumber) ? 0 : 1;
             $data['completiongradeitemnumber'] = $cm->completiongradeitemnumber;
         }
 
@@ -413,7 +424,8 @@ class manager {
             'completion' => COMPLETION_DISABLED,
             'completionview' => COMPLETION_VIEW_NOT_REQUIRED,
             'completionexpected' => 0,
-            'completionusegrade' => 0
+            'completionusegrade' => 0,
+            'completionpassgrade' => 0
         ];
 
         $data = (array)$data;
@@ -470,7 +482,7 @@ class manager {
     public static function get_default_completion($course, $module, $flatten = true) {
         global $DB, $CFG;
         if ($data = $DB->get_record('course_completion_defaults', ['course' => $course->id, 'module' => $module->id],
-            'completion, completionview, completionexpected, completionusegrade, customrules')) {
+            'completion, completionview, completionexpected, completionusegrade, completionpassgrade, customrules')) {
             if ($data->customrules && ($customrules = @json_decode($data->customrules, true))) {
                 if ($flatten) {
                     foreach ($customrules as $key => $value) {
index c84d1c7..dac4a1c 100644 (file)
@@ -102,11 +102,16 @@ class completion_criteria_completion extends data_object {
      * Mark this criteria complete for the associated user
      *
      * This method creates a course_completion_crit_compl record
+     *
+     * @param   int $timecompleted Time completed (optional).
      * @return  int id of completion record.
      */
-    public function mark_complete() {
+    public function mark_complete($timecompleted = null) {
+        if (empty($timecompleted)) {
+            $timecompleted = time();
+        }
         // Create record
-        $this->timecompleted = time();
+        $this->timecompleted = $timecompleted;
 
         // Save record
         if ($this->id) {
index 118db63..3b80836 100644 (file)
@@ -240,6 +240,10 @@ class completion_criteria_activity extends completion_criteria {
             if (!is_null($cm->completiongradeitemnumber)) {
                 $details['requirement'][] = get_string('achievinggrade', 'completion');
             }
+
+            if ($cm->completionpassgrade) {
+                $details['requirement'][] = get_string('achievingpassinggrade', 'completion');
+            }
         }
 
         $details['requirement'] = implode(', ', $details['requirement']);
index 371998c..316ca46 100644 (file)
@@ -1,14 +1,10 @@
-@core @core_completion
+@core @core_completion @javascript
 Feature: Allow teachers to bulk edit activity completion rules in a course.
   In order to avoid editing single activities
   As a teacher
   I need to be able to edit the completion rules for a group of activities.
 
-  # Given I am a teacher in a course with completion tracking enabled and activities present.
-  # When I bulk edit activity completion rules for activities of the same kind.
-  # Then the completion rules should be updated for all selected activities.
-  @javascript
-  Scenario: Bulk edit activity completion rules
+  Background:
     Given the following "courses" exist:
       | fullname | shortname | category |
       | Course 1 | C1 | 0 |
@@ -32,6 +28,14 @@ Feature: Allow teachers to bulk edit activity completion rules in a course.
     And I set the following fields to these values:
       | Enable completion tracking | Yes |
     And I press "Save and display"
+    And I log out
+
+  # Given I am a teacher in a course with completion tracking enabled and activities present.
+  # When I bulk edit activity completion rules for activities of the same kind.
+  # Then the completion rules should be updated for all selected activities.
+  Scenario: Bulk edit activity completion rules
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
     When I navigate to "Course completion" in current page administration
     And I follow "Bulk edit activity completion"
     And I click on "Test assignment one" "checkbox"
@@ -56,3 +60,29 @@ Feature: Allow teachers to bulk edit activity completion rules in a course.
     And I should see "Student must receive a grade to complete this activity" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' row ')][.//*[text() = 'Test assignment two']]" "xpath_element"
     And I should see "Student must submit to this activity to complete it" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' row ')][.//*[text() = 'Test assignment two']]" "xpath_element"
     And I should not see "Completion expected on" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' row ')][.//*[text() = 'Test assignment two']]" "xpath_element"
+
+  # Same conditions as above,
+  # However if completionpassgrade is set, only the completionpassgrade detail should be shown.
+  # It is implied requires grade is selected as it passgrade is dependent on it.
+  Scenario: Bulk edit passing grade completion
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    When I navigate to "Course completion" in current page administration
+    And I follow "Bulk edit activity completion"
+    And I click on "Test assignment one" "checkbox"
+    And I click on "Test assignment two" "checkbox"
+    And I click on "Edit" "button"
+    And I should see "Completion tracking"
+    And I should see "The changes will affect the following 2 activities or resources:"
+    And I should see "Student must submit to this activity to complete it"
+    And I select "Show activity as complete when conditions are met" from the "completion" singleselect
+    And I click on "completionusegrade" "checkbox"
+    And I click on "completionpassgrade" "checkbox"
+    And I click on "Save changes" "button"
+    Then I should see "Changes saved"
+    And I should see "With conditions" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' row ')][.//*[text() = 'Test assignment one']]" "xpath_element"
+    And I should see "Student must receive a passing grade to complete this activity" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' row ')][.//*[text() = 'Test assignment one']]" "xpath_element"
+    And I should not see "Completion expected on" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' row ')][.//*[text() = 'Test assignment one']]" "xpath_element"
+    And I should see "With conditions" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' row ')][.//*[text() = 'Test assignment two']]" "xpath_element"
+    And I should see "Student must receive a passing grade to complete this activity" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' row ')][.//*[text() = 'Test assignment two']]" "xpath_element"
+    And I should not see "Completion expected on" in the "//div[contains(concat(' ', normalize-space(@class), ' '), ' row ')][.//*[text() = 'Test assignment two']]" "xpath_element"
diff --git a/completion/tests/behat/enable_completion_on_pass_grade.feature b/completion/tests/behat/enable_completion_on_pass_grade.feature
new file mode 100644 (file)
index 0000000..062bacd
--- /dev/null
@@ -0,0 +1,52 @@
+@core @core_completion
+Feature: Students will be marked as completed if they have achieved a passing grade.
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category | enablecompletion |
+      | Course 1 | C1        | 0        | 1                |
+    And the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | Frist    | teacher1@example.com |
+      | student1 | Student   | First    | student1@example.com |
+      | student2 | Student   | Second   | student2@example.com |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+      | student2 | C1     | student        |
+    And the following "activity" exists:
+      | idnumber                            | a1                      |
+      | activity                            | assign                  |
+      | course                              | C1                      |
+      | name                                | Test assignment name    |
+      | intro                               | Submit your online text |
+      | assignsubmission_onlinetext_enabled | 1                       |
+      | assignsubmission_file_enabled       | 0                       |
+      | completion                          | 2                       |
+      | completionpassgrade                 | 1                       |
+      | completionusegrade                  | 1                       |
+      | gradepass                           | 50                      |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And "Student First" user has not completed "Test assignment name" activity
+    And I log out
+
+  Scenario: Passing grade completion
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "View > Grader report" in the course gradebook
+    And I turn editing mode on
+    And I give the grade "21" to the user "Student First" for the grade item "Test assignment name"
+    And I give the grade "50" to the user "Student Second" for the grade item "Test assignment name"
+    And I press "Save changes"
+    And I log out
+    When I log in as "student1"
+    And I am on "Course 1" course homepage
+    And the "Receive a grade" completion condition of "Test assignment name" is displayed as "done"
+    And the "Receive a passing grade" completion condition of "Test assignment name" is displayed as "failed"
+    And I log out
+    And I log in as "student2"
+    And I am on "Course 1" course homepage
+    And the "Receive a grade" completion condition of "Test assignment name" is displayed as "done"
+    And the "Receive a passing grade" completion condition of "Test assignment name" is displayed as "done"
diff --git a/completion/tests/behat/enable_completion_on_view_and_grade.feature b/completion/tests/behat/enable_completion_on_view_and_grade.feature
new file mode 100644 (file)
index 0000000..cf9d21a
--- /dev/null
@@ -0,0 +1,69 @@
+@core @core_completion
+Feature: Students will be marked as completed and pass/fail
+  if they have viewed an activity and achieved a grade.
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category | enablecompletion |
+      | Course 1 | C1        | 0        | 1                |
+    And the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | Frist    | teacher1@example.com |
+      | student1 | Student   | First    | student1@example.com |
+      | student2 | Student   | Second   | student2@example.com |
+      | student3 | Student   | Third    | student3@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role           |
+      | teacher1 | C1 | editingteacher |
+      | student1 | C1 | student        |
+      | student2 | C1 | student        |
+      | student3 | C1 | student        |
+    And the following "activity" exists:
+      | activity                            | assign                  |
+      | course                              | C1                      |
+      | idnumber                            | a1                      |
+      | name                                | Test assignment name    |
+      | intro                               | Submit your online text |
+      | assignsubmission_onlinetext_enabled | 1                       |
+      | assignsubmission_file_enabled       | 0                       |
+      | completion                          | 2                       |
+      | completionview                      | 1                       |
+      | completionusegrade                  | 1                       |
+      | gradepass                           | 50                      |
+      | completionpassgrade                 | 1                       |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And "Student First" user has not completed "Test assignment name" activity
+    And I log out
+    And I am on the "Test assignment name" "assign activity" page logged in as student2
+    And I log out
+    And I am on the "Test assignment name" "assign activity" page logged in as student1
+    And I log out
+
+  Scenario: Confirm completion (incomplete/pass/fail) are set correctly
+    Given I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I navigate to "View > Grader report" in the course gradebook
+    And I turn editing mode on
+    And I give the grade "21" to the user "Student First" for the grade item "Test assignment name"
+    And I give the grade "50" to the user "Student Second" for the grade item "Test assignment name"
+    And I give the grade "30" to the user "Student Third" for the grade item "Test assignment name"
+    And I press "Save changes"
+    And I log out
+    When I log in as "student1"
+    And I am on "Course 1" course homepage
+    And the "View" completion condition of "Test assignment name" is displayed as "done"
+    And the "Receive a grade" completion condition of "Test assignment name" is displayed as "done"
+    And the "Receive a passing grade" completion condition of "Test assignment name" is displayed as "failed"
+    And I log out
+    And I log in as "student2"
+    And I am on "Course 1" course homepage
+    And the "View" completion condition of "Test assignment name" is displayed as "done"
+    And the "Receive a grade" completion condition of "Test assignment name" is displayed as "done"
+    And the "Receive a passing grade" completion condition of "Test assignment name" is displayed as "done"
+    And I log out
+    And I log in as "student3"
+    And I am on "Course 1" course homepage
+    And the "View" completion condition of "Test assignment name" is displayed as "todo"
+    And the "Receive a grade" completion condition of "Test assignment name" is displayed as "done"
+    And the "Receive a passing grade" completion condition of "Test assignment name" is displayed as "failed"
index 69dcd09..834ba7f 100644 (file)
@@ -80,7 +80,7 @@ class core_completion_bulk_update_testcase extends advanced_testcase {
             'lti-2' => ['lti', ['completion' => COMPLETION_TRACKING_MANUAL]],
             'page-1' => ['page', ['completion' => COMPLETION_TRACKING_AUTOMATIC, 'completionview' => 1]],
             'page-2' => ['page', ['completion' => COMPLETION_TRACKING_MANUAL]],
-            'quiz-1' => ['quiz', ['completion' => COMPLETION_TRACKING_AUTOMATIC, 'completionpass' => 1]],
+            'quiz-1' => ['quiz', ['completion' => COMPLETION_TRACKING_AUTOMATIC, 'completionpassgrade' => 1]],
             'quiz-2' => ['quiz', ['completion' => COMPLETION_TRACKING_MANUAL]],
             'resource-1' => ['resource', ['completion' => COMPLETION_TRACKING_AUTOMATIC, 'completionview' => 1]],
             'resource-2' => ['resource', ['completion' => COMPLETION_TRACKING_MANUAL]],
index bdbfcf6..00d660a 100644 (file)
@@ -93,6 +93,7 @@ class cm_completion_details_test extends advanced_testcase {
                 ['modname', $modname],
                 ['completionview', $completionoptions['completionview'] ?? COMPLETION_VIEW_NOT_REQUIRED],
                 ['completiongradeitemnumber', $completionoptions['completionusegrade'] ?? null],
+                ['completionpassgrade', $completionoptions['completionpassgrade'] ?? null],
             ]));
 
         return new cm_completion_details($this->completioninfo, $mockcminfo, 2);
@@ -192,13 +193,13 @@ class cm_completion_details_test extends advanced_testcase {
     public function get_details_provider() {
         return [
             'No completion tracking' => [
-                COMPLETION_TRACKING_NONE, null, null, []
+                COMPLETION_TRACKING_NONE, null, null, null, []
             ],
             'Manual completion tracking' => [
-                COMPLETION_TRACKING_MANUAL, null, null, []
+                COMPLETION_TRACKING_MANUAL, null, null, null, []
             ],
             'Automatic, require view, not viewed' => [
-                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_INCOMPLETE, null, [
+                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_INCOMPLETE, null, null, [
                     'completionview' => (object)[
                         'status' => COMPLETION_INCOMPLETE,
                         'description' => get_string('detail_desc:view', 'completion'),
@@ -206,7 +207,7 @@ class cm_completion_details_test extends advanced_testcase {
                 ]
             ],
             'Automatic, require view, viewed' => [
-                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_COMPLETE, null, [
+                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_COMPLETE, null, null, [
                     'completionview' => (object)[
                         'status' => COMPLETION_COMPLETE,
                         'description' => get_string('detail_desc:view', 'completion'),
@@ -214,7 +215,7 @@ class cm_completion_details_test extends advanced_testcase {
                 ]
             ],
             'Automatic, require grade, incomplete' => [
-                COMPLETION_TRACKING_AUTOMATIC, null, COMPLETION_INCOMPLETE, [
+                COMPLETION_TRACKING_AUTOMATIC, null, COMPLETION_INCOMPLETE, null, [
                     'completionusegrade' => (object)[
                         'status' => COMPLETION_INCOMPLETE,
                         'description' => get_string('detail_desc:receivegrade', 'completion'),
@@ -222,7 +223,7 @@ class cm_completion_details_test extends advanced_testcase {
                 ]
             ],
             'Automatic, require grade, complete' => [
-                COMPLETION_TRACKING_AUTOMATIC, null, COMPLETION_COMPLETE, [
+                COMPLETION_TRACKING_AUTOMATIC, null, COMPLETION_COMPLETE, null, [
                     'completionusegrade' => (object)[
                         'status' => COMPLETION_COMPLETE,
                         'description' => get_string('detail_desc:receivegrade', 'completion'),
@@ -230,7 +231,7 @@ class cm_completion_details_test extends advanced_testcase {
                 ]
             ],
             'Automatic, require view (complete) and grade (incomplete)' => [
-                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_COMPLETE, COMPLETION_INCOMPLETE, [
+                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_COMPLETE, COMPLETION_INCOMPLETE, null, [
                     'completionview' => (object)[
                         'status' => COMPLETION_COMPLETE,
                         'description' => get_string('detail_desc:view', 'completion'),
@@ -242,7 +243,7 @@ class cm_completion_details_test extends advanced_testcase {
                 ]
             ],
             'Automatic, require view (incomplete) and grade (complete)' => [
-                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_INCOMPLETE, COMPLETION_COMPLETE, [
+                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_INCOMPLETE, COMPLETION_COMPLETE, null, [
                     'completionview' => (object)[
                         'status' => COMPLETION_INCOMPLETE,
                         'description' => get_string('detail_desc:view', 'completion'),
@@ -253,6 +254,62 @@ class cm_completion_details_test extends advanced_testcase {
                     ]
                 ]
             ],
+            'Automatic, require grade, require pass grade, complete' => [
+                COMPLETION_TRACKING_AUTOMATIC, null, COMPLETION_COMPLETE, COMPLETION_COMPLETE, [
+                    'completionusegrade' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:receivegrade', 'completion'),
+                    ],
+                    'completionpassgrade' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:receivepassgrade', 'completion'),
+                    ],
+                ]
+            ],
+            'Automatic, require grade, require pass grade, incomplete' => [
+                COMPLETION_TRACKING_AUTOMATIC, null, COMPLETION_COMPLETE, COMPLETION_INCOMPLETE, [
+                    'completionusegrade' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:receivegrade', 'completion'),
+                    ],
+                    'completionpassgrade' => (object)[
+                        'status' => COMPLETION_INCOMPLETE,
+                        'description' => get_string('detail_desc:receivepassgrade', 'completion'),
+                    ],
+                ]
+            ],
+            'Automatic, require view (complete), require grade(complete), require pass grade(complete)' => [
+                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_COMPLETE, COMPLETION_COMPLETE, COMPLETION_COMPLETE, [
+                    'completionview' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:view', 'completion'),
+                    ],
+                    'completionusegrade' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:receivegrade', 'completion'),
+                    ],
+                    'completionpassgrade' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:receivepassgrade', 'completion'),
+                    ],
+                ]
+            ],
+            'Automatic, require view (incomplete), require grade(complete), require pass grade(complete)' => [
+                COMPLETION_TRACKING_AUTOMATIC, COMPLETION_INCOMPLETE, COMPLETION_COMPLETE, COMPLETION_COMPLETE, [
+                    'completionview' => (object)[
+                        'status' => COMPLETION_INCOMPLETE,
+                        'description' => get_string('detail_desc:view', 'completion'),
+                    ],
+                    'completionusegrade' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:receivegrade', 'completion'),
+                    ],
+                    'completionpassgrade' => (object)[
+                        'status' => COMPLETION_COMPLETE,
+                        'description' => get_string('detail_desc:receivepassgrade', 'completion'),
+                    ],
+                ]
+            ],
         ];
     }
 
@@ -263,13 +320,16 @@ class cm_completion_details_test extends advanced_testcase {
      * @param int $completion The completion tracking mode.
      * @param int|null $completionview Completion status of the "view" completion condition.
      * @param int|null $completiongrade Completion status of the "must receive grade" completion condition.
+     * @param int|null $completionpassgrade Completion status of the "must receive passing grade" completion condition.
      * @param array $expecteddetails Expected completion details returned by get_details().
      */
-    public function test_get_details(int $completion, ?int $completionview, ?int $completiongrade, array $expecteddetails) {
+    public function test_get_details(int $completion, ?int $completionview,
+             ?int $completiongrade, ?int $completionpassgrade, array $expecteddetails) {
         $options = [];
         $getdatareturn = (object)[
             'viewed' => $completionview,
             'completiongrade' => $completiongrade,
+            'passgrade' => $completionpassgrade,
         ];
 
         if (!is_null($completionview)) {
@@ -278,13 +338,16 @@ class cm_completion_details_test extends advanced_testcase {
         if (!is_null($completiongrade)) {
             $options['completionusegrade'] = true;
         }
+        if (!is_null($completionpassgrade)) {
+            $options['completionpassgrade'] = true;
+        }
 
         $cmcompletion = $this->setup_data($completion, $options, $getdatareturn);
         $this->assertEquals($expecteddetails, $cmcompletion->get_details());
     }
 
     /**
-     * Data provider for test_get_details().
+     * Data provider for test_get_details_custom_order().
      * @return array[]
      */
     public function get_details_custom_order_provider() {
diff --git a/completion/tests/completion_criteria_test.php b/completion/tests/completion_criteria_test.php
new file mode 100644 (file)
index 0000000..3c648fa
--- /dev/null
@@ -0,0 +1,229 @@
+<?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/>.
+
+namespace core_completion;
+
+/**
+ * Test completion criteria.
+ *
+ * @package   core_completion
+ * @category  test
+ * @copyright 2021 Mikhail Golenkov <mikhailgolenkov@catalyst-au.net>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_completion_criteria_testcase extends \advanced_testcase {
+
+    /**
+     * Test setup.
+     */
+    public function setUp(): void {
+        global $CFG;
+        require_once($CFG->dirroot.'/completion/criteria/completion_criteria_course.php');
+        require_once($CFG->dirroot.'/completion/criteria/completion_criteria_activity.php');
+        require_once($CFG->dirroot.'/completion/criteria/completion_criteria_duration.php');
+        require_once($CFG->dirroot.'/completion/criteria/completion_criteria_grade.php');
+        require_once($CFG->dirroot.'/completion/criteria/completion_criteria_date.php');
+
+        $this->setAdminUser();
+        $this->resetAfterTest();
+    }
+
+    /**
+     * Test that activity completion dates are used when activity criteria is marked as completed.
+     */
+    public function test_completion_criteria_activity(): void {
+        global $DB;
+        $timestarted = time();
+
+        // Create a course, an activity and enrol a user.
+        $course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]);
+        $assign = $this->getDataGenerator()->create_module('assign', ['course' => $course->id], ['completion' => 1]);
+        $user = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id);
+
+        // Set completion criteria and mark the user to complete the criteria.
+        $criteriadata = (object) [
+            'id' => $course->id,
+            'criteria_activity' => [$assign->cmid => 1],
+        ];
+        $criterion = new \completion_criteria_activity();
+        $criterion->update_config($criteriadata);
+        $cmassign = get_coursemodule_from_id('assign', $assign->cmid);
+        $completion = new \completion_info($course);
+        $completion->update_state($cmassign, COMPLETION_COMPLETE, $user->id);
+
+        // Completion criteria for the user is supposed to be marked as completed at now().
+        $result = \core_completion_external::get_activities_completion_status($course->id, $user->id);
+        $actual = reset($result['statuses']);
+        $this->assertEquals(1, $actual['state']);
+        $this->assertGreaterThanOrEqual($timestarted, $actual['timecompleted']);
+
+        // And the whole course is marked as completed at now().
+        $ccompletion = new \completion_completion(['userid' => $user->id, 'course' => $course->id]);
+        $this->assertGreaterThanOrEqual($timestarted, $ccompletion->timecompleted);
+        $this->assertTrue($ccompletion->is_complete());
+    }
+
+    /**
+     * Test that enrolment timestart/timecreated are used when duration criteria is marked as completed.
+     */
+    public function test_completion_criteria_duration(): void {
+        global $DB;
+        $timestarted = 1610000000;
+        $timecreated = 1620000000;
+        $durationperiod = DAYSECS;
+
+        // Create a course and users.
+        $course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]);
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+
+        // Enrol User 1 with time start = $timestarted.
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, $studentrole->id, 'manual', $timestarted);
+
+        // Enrol User 2 with an empty time start, but update the record like it was created at $timecreated.
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, $studentrole->id);
+        $DB->set_field('user_enrolments', 'timecreated', $timecreated, ['userid' => $user2->id]);
+
+        // Set completion criteria.
+        $criteriadata = (object) [
+            'id' => $course->id,
+            'criteria_duration' => 1,
+            'criteria_duration_days' => $durationperiod,
+        ];
+        $criterion = new \completion_criteria_duration();
+        $criterion->update_config($criteriadata);
+
+        // Run completion scheduled task.
+        $task = new \core\task\completion_regular_task();
+        $this->expectOutputRegex("/Marking complete/");
+        $task->execute();
+        // Hopefully, some day MDL-33320 will be fixed and all these sleeps
+        // and double cron calls in behat and unit tests will be removed.
+        sleep(1);
+        $task->execute();
+
+        // The course for User 1 is supposed to be marked as completed at $timestarted + $durationperiod.
+        $ccompletion = new \completion_completion(['userid' => $user1->id, 'course' => $course->id]);
+        $this->assertEquals($timestarted + $durationperiod, $ccompletion->timecompleted);
+        $this->assertTrue($ccompletion->is_complete());
+
+        // The course for User 2 is supposed to be marked as completed at $timecreated + $durationperiod.
+        $ccompletion = new \completion_completion(['userid' => $user2->id, 'course' => $course->id]);
+        $this->assertEquals($timecreated + $durationperiod, $ccompletion->timecompleted);
+        $this->assertTrue($ccompletion->is_complete());
+    }
+
+    /**
+     * Test that criteria date is used as a course completion date.
+     */
+    public function test_completion_criteria_date(): void {
+        global $DB;
+        $timeend = 1610000000;
+
+        // Create a course and enrol a user.
+        $course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]);
+        $user = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id);
+
+        // Set completion criteria.
+        $criteriadata = (object) [
+            'id' => $course->id,
+            'criteria_date' => 1,
+            'criteria_date_value' => $timeend,
+        ];
+        $criterion = new \completion_criteria_date();
+        $criterion->update_config($criteriadata);
+
+        // Run completion scheduled task.
+        $task = new \core\task\completion_regular_task();
+        $this->expectOutputRegex("/Marking complete/");
+        $task->execute();
+        // Hopefully, some day MDL-33320 will be fixed and all these sleeps
+        // and double cron calls in behat and unit tests will be removed.
+        sleep(1);
+        $task->execute();
+
+        // The course is supposed to be marked as completed at $timeend.
+        $ccompletion = new \completion_completion(['userid' => $user->id, 'course' => $course->id]);
+        $this->assertEquals($timeend, $ccompletion->timecompleted);
+        $this->assertTrue($ccompletion->is_complete());
+    }
+
+    /**
+     * Test that grade timemodified is used when grade criteria is marked as completed.
+     */
+    public function test_completion_criteria_grade(): void {
+        global $DB;
+        $timegraded = 1610000000;
+
+        // Create a course and enrol a couple of users.
+        $course = $this->getDataGenerator()->create_course(['enablecompletion' => 1]);
+        $user1 = $this->getDataGenerator()->create_user();
+        $user2 = $this->getDataGenerator()->create_user();
+        $studentrole = $DB->get_record('role', ['shortname' => 'student']);
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id, $studentrole->id);
+        $this->getDataGenerator()->enrol_user($user2->id, $course->id, $studentrole->id);
+
+        // Set completion criteria.
+        $criteriadata = (object) [
+            'id' => $course->id,
+            'criteria_grade' => 1,
+            'criteria_grade_value' => 66,
+        ];
+        $criterion = new \completion_criteria_grade();
+        $criterion->update_config($criteriadata);
+
+        $coursegradeitem = \grade_item::fetch_course_item($course->id);
+
+        // Grade User 1 with a passing grade.
+        $grade1 = new \grade_grade();
+        $grade1->itemid = $coursegradeitem->id;
+        $grade1->timemodified = $timegraded;
+        $grade1->userid = $user1->id;
+        $grade1->finalgrade = 80;
+        $grade1->insert();
+
+        // Grade User 2 with a non-passing grade.
+        $grade2 = new \grade_grade();
+        $grade2->itemid = $coursegradeitem->id;
+        $grade2->timemodified = $timegraded;
+        $grade2->userid = $user2->id;
+        $grade2->finalgrade = 40;
+        $grade2->insert();
+
+        // Run completion scheduled task.
+        $task = new \core\task\completion_regular_task();
+        $this->expectOutputRegex("/Marking complete/");
+        $task->execute();
+        // Hopefully, some day MDL-33320 will be fixed and all these sleeps
+        // and double cron calls in behat and unit tests will be removed.
+        sleep(1);
+        $task->execute();
+
+        // The course for User 1 is supposed to be marked as completed when the user was graded.
+        $ccompletion = new \completion_completion(['userid' => $user1->id, 'course' => $course->id]);
+        $this->assertEquals($timegraded, $ccompletion->timecompleted);
+        $this->assertTrue($ccompletion->is_complete());
+
+        // The course for User 2 is supposed to be marked as not completed.
+        $ccompletion = new \completion_completion(['userid' => $user2->id, 'course' => $course->id]);
+        $this->assertFalse($ccompletion->is_complete());
+    }
+}
index 2d5c6e8..098efb3 100644 (file)
@@ -122,6 +122,15 @@ class core_completion_externallib_testcase extends externallib_advanced_testcase
             ['course' => $course->id],
             ['availability' => $availability],
         );
+        $assignautocompletion = $this->getDataGenerator()->create_module('assign',
+            ['course' => $course->id], [
+                'showdescription' => true,
+                'completionview' => 1,
+                'completion' => COMPLETION_TRACKING_AUTOMATIC,
+                'completiongradeitemnumber' => 1,
+                'completionpassgrade' => 1,
+            ],
+        );
         $page = $this->getDataGenerator()->create_module('page',  array('course' => $course->id),
                                                             array('completion' => 1, 'visible' => 0));
 
@@ -151,11 +160,11 @@ class core_completion_externallib_testcase extends externallib_advanced_testcase
         $result = external_api::clean_returnvalue(
             core_completion_external::get_activities_completion_status_returns(), $result);
 
-        // We added 5 activities, but only 4 with completion enabled and one of those is hidden.
-        $numberofactivities = 5;
+        // We added 6 activities, but only 4 with completion enabled and one of those is hidden.
+        $numberofactivities = 6;
         $numberofhidden = 1;
         $numberofcompletions = $numberofactivities - $numberofhidden;
-        $numberofstatusstudent = 3;
+        $numberofstatusstudent = 4;
 
         $this->assertCount($numberofstatusstudent, $result['statuses']);
 
@@ -186,6 +195,26 @@ class core_completion_externallib_testcase extends externallib_advanced_testcase
                 $this->assertEquals('completionview', $details[0]['rulename']);
                 $this->assertEquals(0, $details[0]['rulevalue']['status']);
 
+            } else if ($status['cmid'] == $assignautocompletion->cmid) {
+                $activitiesfound++;
+                $this->assertEquals(COMPLETION_INCOMPLETE, $status['state']);
+                $this->assertEquals(COMPLETION_TRACKING_AUTOMATIC, $status['tracking']);
+                $this->assertFalse($status['valueused']);
+                $this->assertTrue($status['hascompletion']);
+                $this->assertTrue($status['isautomatic']);
+                $this->assertTrue($status['istrackeduser']);
+                $this->assertTrue($status['uservisible']);
+                $details = $status['details'];
+                $this->assertCount(3, $details);
+                $expecteddetails = [
+                    'completionview',
+                    'completionusegrade',
+                    'completionpassgrade',
+                ];
+                foreach ($expecteddetails as $index => $name) {
+                    $this->assertEquals($name, $details[$index]['rulename']);
+                    $this->assertEquals(0, $details[$index]['rulevalue']['status']);
+                }
             } else if ($status['cmid'] == $data->cmid and $status['modname'] == 'data' and $status['instance'] == $data->id) {
                 $activitiesfound++;
                 $this->assertEquals(COMPLETION_INCOMPLETE, $status['state']);
@@ -200,7 +229,7 @@ class core_completion_externallib_testcase extends externallib_advanced_testcase
                 $this->assertCount(0, $details);
             }
         }
-        $this->assertEquals(3, $activitiesfound);
+        $this->assertEquals(4, $activitiesfound);
 
         // Teacher should see students status, they are in different groups but the teacher can access all groups.
         $this->setUser($teacher);
@@ -248,7 +277,7 @@ class core_completion_externallib_testcase extends externallib_advanced_testcase
                 $activitiesfound++;
                 $this->assertEquals(COMPLETION_COMPLETE, $status['state']);
                 $this->assertEquals(COMPLETION_TRACKING_MANUAL, $status['tracking']);
-            } else if ($status['cmid'] == $forumautocompletion->cmid) {
+            } else if (in_array($status['cmid'], [$forumautocompletion->cmid, $assignautocompletion->cmid])) {
                 $activitiesfound++;
                 $this->assertEquals(COMPLETION_INCOMPLETE, $status['state']);
                 $this->assertEquals(COMPLETION_TRACKING_AUTOMATIC, $status['tracking']);
@@ -258,7 +287,7 @@ class core_completion_externallib_testcase extends externallib_advanced_testcase
                 $this->assertEquals(COMPLETION_TRACKING_MANUAL, $status['tracking']);
             }
         }
-        $this->assertEquals(4, $activitiesfound);
+        $this->assertEquals(5, $activitiesfound);
 
         // Change teacher role capabilities (disable access all groups).
         $context = context_course::instance($course->id);
index 5cdbea2..c836f09 100644 (file)
@@ -4,6 +4,17 @@ information provided here is intended especially for developers.
 === 4.0 ===
 * New method mark_course_completions_activity_criteria() has been added to mark course completions instantly. It is
   based on cron for completion_criteria_activity.php which is refactored to use it as well.
+* Modified completion criteria to allow plugins to override core completion logic.
+* Core now passes an additional parameter to '_get_completion_state'. This is an array representation of the completion results that have already been
+  tested. Currently contains - viewed, usegrade, passgrade. Any plugin that are dependent on these criteria can now check this array instead of retesting it.
+* Introduced a new plugin function - '_get_completion_aggregation_state', that would indicate the aggregation type/relationship between the plugin and core
+  completion criteria. This callback should either return a COMPLETION_STANDARD_FLOW / COMPLETION_CUSTOM_MODULE_FLOW. The former for default existing core
+  behaviour while the latter enforces the override logic from the plugin. Defaults to COMPLETION_STANDARD_FLOW if not defined. This is useful when plugins
+  need to override the core completion criteria in cases where it may be dependent on them. In these cases, the 'source of truth' would be the response
+  from the plugin's 'get_completion_state' function. e.g. Quiz's completion defines a criteria of 'requires passing grade OR all attempts AND min attempts
+  reached.' In these cases, even if a passing grade has not been achieved, the activity should be marked as completed if the no.of attempts have been reached.
+* The method \completion_criteria_completion::mark_complete() now has the optional $timecompleted parameter to specify when the
+  criteria was completed.
 
 === 3.11 ===
 * New Behat steps for activity completion in the behat_completion class:
index 31b7dce..869f915 100644 (file)
@@ -166,8 +166,11 @@ $CFG->directorypermissions = 02777;
 
 
 //=========================================================================
-// 5. DIRECTORY LOCATION  (most people can just ignore this setting)
+// 5. ADMIN DIRECTORY LOCATION  (deprecated)
 //=========================================================================
+// Please note: Support from this feature has been deprecated and it will be
+// removed after Moodle 4.2.
+//
 // A very few webhosts use /admin as a special URL for you to access a
 // control panel or something.  Unfortunately this conflicts with the
 // standard location for the Moodle admin pages.  You can work around this
index 906837e..a566611 100644 (file)
@@ -112,6 +112,7 @@ class core_course_deletecategory_form extends moodleform {
 
         if ($displaylist) {
             $mform->addElement('autocomplete', 'newparent', get_string('movecategorycontentto'), $displaylist);
+            $mform->addRule('newparent', null, 'required', null, 'client');
             if (in_array($this->coursecat->parent, $displaylist)) {
                 $mform->setDefault('newparent', $this->coursecat->parent);
             }
index 236fbad..1006fb4 100644 (file)
@@ -64,6 +64,7 @@ class core_course_editcategory_form extends moodleform {
         }
 
         $mform->addElement('autocomplete', 'parent', get_string('parentcategory'), $options);
+        $mform->addRule('parent', null, 'required', null, 'client');
 
         $mform->addElement('text', 'name', get_string('categoryname'), array('size' => '30'));
         $mform->addRule('name', get_string('required'), 'required', null);
index 709846c..7f30f90 100644 (file)
@@ -31,6 +31,7 @@ use cm_info;
 /**
  * Class to prepare a course module name for display and in-place editing
  *
+ * @deprecated since Moodle 4.0 MDL-72656 - please do not use this class any more.
  * @package   core_course
  * @copyright 2016 Marina Glancy
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -51,6 +52,10 @@ class course_module_name extends \core\output\inplace_editable {
      * @param array $displayoptions
      */
     public function __construct(cm_info $cm, $editable, $displayoptions = array()) {
+        debugging(
+            'course_section_cm_list is deprecated. Use core_courseformat\\output\\local\\cm\\cmname instead',
+            DEBUG_DEVELOPER
+        );
         $this->cm = $cm;
         $this->displayoptions = $displayoptions;
         $value = $cm->name;
index 73b5190..5654393 100644 (file)
@@ -78,6 +78,7 @@ class course_edit_form extends moodleform {
             if (has_capability('moodle/course:create', $categorycontext)) {
                 $displaylist = core_course_category::make_categories_list('moodle/course:create');
                 $mform->addElement('autocomplete', 'category', get_string('coursecategory'), $displaylist);
+                $mform->addRule('category', null, 'required', null, 'client');
                 $mform->addHelpButton('category', 'coursecategory');
                 $mform->setDefault('category', $category->id);
             } else {
@@ -94,6 +95,7 @@ class course_edit_form extends moodleform {
                         ->get_formatted_name();
                 }
                 $mform->addElement('autocomplete', 'category', get_string('coursecategory'), $displaylist);
+                $mform->addRule('category', null, 'required', null, 'client');
                 $mform->addHelpButton('category', 'coursecategory');
             } else {
                 //keep current
index 17b5fe6..0ae4854 100644 (file)
@@ -2867,6 +2867,7 @@ class core_course_external extends external_api {
                         'visibleoncoursepage' => new external_value(PARAM_INT, 'If visible on course page', VALUE_OPTIONAL),
                         'visibleold' => new external_value(PARAM_INT, 'Visible old', VALUE_OPTIONAL),
                         'completiongradeitemnumber' => new external_value(PARAM_INT, 'Completion grade item', VALUE_OPTIONAL),
+                        'completionpassgrade' => new external_value(PARAM_INT, 'Completion pass grade setting', VALUE_OPTIONAL),
                         'completionview' => new external_value(PARAM_INT, 'Completion view setting', VALUE_OPTIONAL),
                         'completionexpected' => new external_value(PARAM_INT, 'Completion time expected', VALUE_OPTIONAL),
                         'showdescription' => new external_value(PARAM_INT, 'If the description is showed', VALUE_OPTIONAL),
index 63a05e1..f82495e 100644 (file)
@@ -1109,7 +1109,7 @@ abstract class base {
     }
 
     /**
-     * Returns instance of output compornent used by this plugin
+     * Returns instance of output component used by this plugin
      *
      * @throws coding_exception if the format class does not extends the original core one.
      * @param string $outputname the element to render (section, activity...)
@@ -22,7 +22,7 @@
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-namespace core_courseformat\output\local\content\section;
+namespace core_courseformat\output\local\content;
 
 use cm_info;
 use core\activity_dates;
@@ -33,6 +33,7 @@ use renderable;
 use section_info;
 use stdClass;
 use templatable;
+use \core_availability\info_module;
 
 /**
  * Base class to render a course module inside a course format.
@@ -55,6 +56,21 @@ class cm implements renderable, templatable {
     /** @var array optional display options */
     protected $displayoptions;
 
+    /** @var string activity link css classes */
+    protected $linkclasses = null;
+
+    /** @var string text css classes */
+    protected $textclasses = null;
+
+    /** @var string the activity name output class name */
+    protected $cmnameclass;
+
+    /** @var string the activity control menu class name */
+    protected $controlmenuclass;
+
+    /** @var string the activity availability class name */
+    protected $availabilityclass;
+
     /**
      * Constructor.
      *
@@ -68,6 +84,13 @@ class cm implements renderable, templatable {
         $this->section = $section;
         $this->mod = $mod;
         $this->displayoptions = $displayoptions;
+
+        $this->load_classes();
+
+        // Get the necessary classes.
+        $this->cmnameclass = $format->get_output_classname('content\\cm\\cmname');
+        $this->controlmenuclass = $format->get_output_classname('content\\cm\\controlmenu');
+        $this->availabilityclass = $format->get_output_classname('content\\cm\\availability');
     }
 
     /**
@@ -80,6 +103,7 @@ class cm implements renderable, templatable {
         global $USER;
 
         $format = $this->format;
+        $section = $this->section;
         $mod = $this->mod;
         $displayoptions = $this->displayoptions;
         $course = $mod->get_course();
@@ -94,6 +118,12 @@ class cm implements renderable, templatable {
             $activitydates = activity_dates::get_dates_for_module($mod, $USER->id);
         }
 
+        $displayoptions['linkclasses'] = $this->get_link_classes();
+        $displayoptions['textclasses'] = $this->get_text_classes();
+
+        // Grouping activity.
+        $groupinglabel = $mod->get_grouping_label($displayoptions['textclasses']);
+
         $activityinfodata = null;
         // - There are activity dates to be shown; or
         // - Completion info needs to be displayed
@@ -107,13 +137,32 @@ class cm implements renderable, templatable {
             $activityinfodata = $activityinfo->export_for_template($output);
         }
 
+        // Mod inplace name editable.
+        $cmname = new $this->cmnameclass(
+            $format,
+            $this->section,
+            $mod,
+            $format->show_editor(),
+            $this->displayoptions
+        );
+
+        // Mod availability.
+        $availability = new $this->availabilityclass(
+            $format,
+            $this->section,
+            $mod,
+            $this->displayoptions
+        );
+
         $data = (object)[
-            'cmname' => $output->course_section_cm_name($mod, $displayoptions),
+            'cmname' => $cmname->export_for_template($output),
+            'grouping' => $groupinglabel,
             'afterlink' => $mod->afterlink,
-            'altcontent' => $output->course_section_cm_text($mod, $displayoptions),
-            'availability' => $output->course_section_cm_availability($mod, $displayoptions),
+            'altcontent' => $mod->get_formatted_content(['overflowdiv' => true, 'noclean' => true]),
+            'modavailability' => $availability->export_for_template($output),
             'url' => $mod->url,
             'activityinfo' => $activityinfodata,
+            'textclasses' => $displayoptions['textclasses'],
         ];
 
         if (!empty($mod->indent)) {
@@ -131,22 +180,80 @@ class cm implements renderable, templatable {
         }
 
         $returnsection = $format->get_section_number();
-        $data->extras = [];
+
         if ($format->show_editor()) {
             // Edit actions.
-            $editactions = course_get_cm_edit_actions($mod, $mod->indent, $returnsection);
-            $data->extras[] = $output->course_section_cm_edit_actions($editactions, $mod, $displayoptions);
-            if (!empty($mod->afterediticons)) {
-                $data->extras[] = $mod->afterediticons;
-            }
+            $controlmenu = new $this->controlmenuclass(
+                $format,
+                $this->section,
+                $mod,
+                $this->displayoptions
+            );
+            $data->controlmenu = $controlmenu->export_for_template($output);
+
             // Move and select options.
             $data->moveicon = course_get_cm_move($mod, $returnsection);
         }
 
-        if (!empty($data->extras)) {
-            $data->hasextras = true;
+        return $data;
+    }
+
+    /**
+     * Returns the CSS classes for the activity name/content
+     *
+     * For items which are hidden, unavailable or stealth but should be displayed
+     * to current user ($mod->is_visible_on_course_page()), we show those as dimmed.
+     * Students will also see as dimmed activities names that are not yet available
+     * but should still be displayed (without link) with availability info.
+     */
+    protected function load_classes() {
+
+        $mod = $this->mod;
+
+        $linkclasses = '';
+        $textclasses = '';
+        if ($mod->uservisible) {
+            $info = new info_module($mod);
+            $conditionalhidden = !$info->is_available_for_all();
+            $accessiblebutdim = (!$mod->visible || $conditionalhidden) &&
+                has_capability('moodle/course:viewhiddenactivities', $mod->context);
+            if ($accessiblebutdim) {
+                $linkclasses .= ' dimmed';
+                $textclasses .= ' dimmed_text';
+                if ($conditionalhidden) {
+                    $linkclasses .= ' conditionalhidden';
+                    $textclasses .= ' conditionalhidden';
+                }
+            }
+            if ($mod->is_stealth()) {
+                // Stealth activity is the one that is not visible on course page.
+                // It still may be displayed to the users who can manage it.
+                $linkclasses .= ' stealth';
+                $textclasses .= ' stealth';
+            }
+        } else {
+            $linkclasses .= ' dimmed';
+            $textclasses .= ' dimmed dimmed_text';
         }
+        $this->linkclasses = $linkclasses;
+        $this->textclasses = $textclasses;
+    }
 
-        return $data;
+    /**
+     * Get the activity link classes.
+     *
+     * @return string the activity link classes.
+     */
+    public function get_link_classes(): string {
+        return $this->linkclasses;
+    }
+
+    /**
+     * Get the activity text/description classes.
+     *
+     * @return string the activity text classes.
+     */
+    public function get_text_classes(): string {
+        return $this->textclasses;
     }
 }
diff --git a/course/format/classes/output/local/content/cm/availability.php b/course/format/classes/output/local/content/cm/availability.php
new file mode 100644 (file)
index 0000000..06dc765
--- /dev/null
@@ -0,0 +1,161 @@
+<?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/>.
+
+/**
+ * Contains the default activity availability information.
+ *
+ * @package   core_courseformat
+ * @copyright 2020 Ferran Recio <ferran@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_courseformat\output\local\content\cm;
+
+use core_courseformat\output\local\content\section\availability as section_avalability;
+use cm_info;
+use core_courseformat\base as course_format;
+use section_info;
+use stdClass;
+use core_availability\info_module;
+use core_availability\info;
+
+/**
+ * Base class to render a course module availability inside a course format.
+ *
+ * @package   core_courseformat
+ * @copyright 2020 Ferran Recio <ferran@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class availability extends section_avalability {
+
+    /** @var course_format the course format */
+    protected $format;
+
+    /** @var section_info the section object */
+    protected $section;
+
+    /** @var cm_info the course module instance */
+    protected $mod;
+
+    /** @var array optional display options */
+    protected $displayoptions;
+
+    /**
+     * Constructor.
+     *
+     * @param course_format $format the course format
+     * @param section_info $section the section info
+     * @param cm_info $mod the course module ionfo
+     * @param array $displayoptions optional extra display options
+     */
+    public function __construct(course_format $format, section_info $section, cm_info $mod, array $displayoptions = []) {
+        $this->format = $format;
+        $this->section = $section;
+        $this->mod = $mod;
+        $this->displayoptions = $displayoptions;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param \renderer_base $output typically, the renderer that's calling this function
+     * @return stdClass data context for a mustache template
+     */
+    public function export_for_template(\renderer_base $output): stdClass {
+
+        $data = (object)[
+            'info' => $this->get_info($output),
+        ];
+
+        if (!empty($data->info)) {
+            $data->hasmodavailability = true;
+        }
+
+        return $data;
+    }
+
+    /**
+     * Get the availability HTML form the course renderer.
+     *
+     * @param \renderer_base $output typically, the renderer that's calling this function
+     * @return string the availability HTML
+     */
+    protected function get_info(\renderer_base $output): array {
+        global $CFG;
+
+        $format = $this->format;
+        $mod = $this->mod;
+        $section = $this->section;
+
+        $info = [];
+
+        if (!$mod->is_visible_on_course_page()) {
+            // Nothing to be displayed to the user.
+            return $info;
+        }
+
+        if (!$mod->uservisible) {
+            // This is a student who is not allowed to see the module but might be allowed
+            // to see availability info (i.e. "Available from ...").
+            if (!empty($mod->availableinfo)) {
+                $formattedinfo = \core_availability\info::format_info(
+                    $mod->availableinfo,
+                    $mod->get_course()
+                );
+                $info[] = $this->availability_info($formattedinfo, 'isrestricted');
+            }
+            return $info;
+        }
+
+        // This is a teacher who is allowed to see module but still should see the
+        // information that module is not available to all/some students.
+        $modcontext = $mod->context;
+        $canviewhidden = has_capability('moodle/course:viewhiddenactivities', $modcontext);
+        if ($canviewhidden && !$mod->visible) {
+            // This module is hidden but current user has capability to see it.
+            // Do not display the availability info if the whole section is hidden.
+            if ($section->visible) {
+                $info[] = $this->availability_info(get_string('hiddenfromstudents'), 'ishidden');
+            }
+        } else if ($mod->is_stealth()) {
+            // This module is available but is normally not displayed on the course page
+            // (this user can see it because they can manage it).
+            $info[] = $this->availability_info(get_string('hiddenoncoursepage'), 'isstealth');
+        }
+
+        if ($canviewhidden && !empty($CFG->enableavailability)) {
+            // Display information about conditional availability.
+            // Don't add availability information if user is not editing and activity is hidden.
+            if ($mod->visible || $format->show_editor()) {
+                $hidinfoclass = 'isrestricted isfullinfo';
+                if (!$mod->visible) {
+                    $hidinfoclass .= ' hide';
+                }
+                $ci = new info_module($mod);
+                $fullinfo = $ci->get_full_information();
+                if ($fullinfo) {
+                    $formattedinfo = info::format_info(
+                        $fullinfo,
+                        $mod->get_course()
+                    );
+                    $info[] = $this->availability_info($formattedinfo, $hidinfoclass);
+                }
+            }
+        }
+
+        return $info;
+    }
+}
diff --git a/course/format/classes/output/local/content/cm/cmname.php b/course/format/classes/output/local/content/cm/cmname.php
new file mode 100644 (file)
index 0000000..7ab70dc
--- /dev/null
@@ -0,0 +1,159 @@
+<?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/>.
+
+/**
+ * Contains the default activity name inplace editable.
+ *
+ * @package   core_courseformat
+ * @copyright 2020 Ferran Recio <ferran@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_courseformat\output\local\content\cm;
+
+use cm_info;
+use core_courseformat\base as course_format;
+use section_info;
+use stdClass;
+use context_module;
+use lang_string;
+use external_api;
+use core\output\inplace_editable;
+
+/**
+ * Base class to render a course module inplace editable header.
+ *
+ * @package   core_courseformat
+ * @copyright 2020 Ferran Recio <ferran@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cmname extends inplace_editable {
+
+    /** @var course_format the course format */
+    protected $format;
+
+    /** @var section_info the section object */
+    private $section;
+
+    /** @var cm_info the course module instance */
+    protected $mod;
+
+    /** @var editable if the title is editable */
+    protected $editable;
+
+    /** @var array optional display options */
+    protected $displayoptions;
+
+    /** @var string the activity title output class name */
+    protected $titleclass;
+
+    /**
+     * Constructor.
+     *
+     * @param course_format $format the course format
+     * @param section_info $section the section info
+     * @param cm_info $mod the course module ionfo
+     * @param bool $editable if it is editable
+     * @param array $displayoptions optional extra display options
+     */
+    public function __construct(
+        course_format $format,
+        section_info $section,
+        cm_info $mod,
+        bool $editable,
+        array $displayoptions = []
+    ) {
+        $this->format = $format;
+        $this->section = $section;
+        $this->mod = $mod;
+        $this->displayoptions = $displayoptions;
+
+        $this->editable = $editable && has_capability(
+            'moodle/course:manageactivities',
+            $mod->context
+        );
+
+        // Get the necessary classes.
+        $this->titleclass = $format->get_output_classname('content\\cm\\title');
+
+        // Setup inplace editable.
+        parent::__construct(
+            'core_course',
+            'activityname',
+            $mod->id,
+            $this->editable,
+            $mod->name,
+            $mod->name,
+            new lang_string('edittitle'),
+            new lang_string('newactivityname', '', $mod->get_formatted_name())
+        );
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param \renderer_base $output typically, the renderer that's calling this function
+     * @return stdClass data context for a mustache template
+     */
+    public function export_for_template(\renderer_base $output): array {
+        global $PAGE;
+
+        // Inplace editable uses core renderer by default. However, course elements require
+        // the format specific renderer.
+        $courseoutput = $this->format->get_renderer($PAGE);
+
+        // Inplace editable uses pre-rendered elements and does not allow line beaks in the UI value.
+        $title = new $this->titleclass(
+            $this->format,
+            $this->section,
+            $this->mod,
+            $this->displayoptions
+        );
+        $this->displayvalue = str_replace("\n", "", $courseoutput->render($title));
+
+        if (trim($this->displayvalue) == '') {
+            $this->editable = false;
+        }
+        $data = parent::export_for_template($output);
+
+        return $data;
+    }
+
+    /**
+     * Updates course module name
+     *
+     * @param int $itemid course module id
+     * @param string $newvalue new name
+     * @return static
+     */
+    public static function update($itemid, $newvalue) {
+        $context = context_module::instance($itemid);
+        // Check access.
+        external_api::validate_context($context);
+        require_capability('moodle/course:manageactivities', $context);
+
+        // Trim module name and Update value.
+        set_coursemodule_name($itemid, trim($newvalue));
+        $coursemodulerecord = get_coursemodule_from_id('', $itemid, 0, false, MUST_EXIST);
+        // Return instance.
+        $modinfo = get_fast_modinfo($coursemodulerecord->course);
+        $cm = $modinfo->get_cm($itemid);
+        $section = $modinfo->get_section_info($cm->sectionnum);
+
+        $format = course_get_format($cm->course);
+        return new static($format, $section, $cm, true);
+    }
+}
diff --git a/course/format/classes/output/local/content/cm/controlmenu.php b/course/format/classes/output/local/content/cm/controlmenu.php
new file mode 100644 (file)
index 0000000..35720d7
--- /dev/null
@@ -0,0 +1,174 @@
+<?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/>.
+
+/**
+ * Contains the default activity control menu.
+ *
+ * @package   core_courseformat
+ * @copyright 2020 Ferran Recio <ferran@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_courseformat\output\local\content\cm;
+
+use cm_info;
+use core_courseformat\base as course_format;
+use renderable;
+use section_info;
+use stdClass;
+use templatable;
+use action_menu;
+use action_menu_link;
+
+/**
+ * Base class to render a course module menu inside a course format.
+ *
+ * @package   core_courseformat
+ * @copyright 2020 Ferran Recio <ferran@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class controlmenu implements renderable, templatable {
+
+    /** @var course_format the course format */
+    protected $format;
+
+    /** @var section_info the section object */
+    private $section;
+
+    /** @var action_menu the activity aciton menu */
+    protected $menu;
+
+    /** @var cm_info the course module instance */
+    protected $mod;
+
+    /** @var array optional display options */
+    protected $displayoptions;
+
+    /**
+     * Constructor.
+     *
+     * @param course_format $format the course format
+     * @param section_info $section the section info
+     * @param cm_info $mod the course module ionfo
+     * @param array $displayoptions optional extra display options
+     */
+    public function __construct(course_format $format, section_info $section, cm_info $mod, array $displayoptions = []) {
+        $this->format = $format;
+        $this->section = $section;
+        $this->mod = $mod;
+        $this->displayoptions = $displayoptions;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param \renderer_base $output typically, the renderer that's calling this function
+     * @return stdClass data context for a mustache template
+     */
+    public function export_for_template(\renderer_base $output): stdClass {
+
+        $mod = $this->mod;
+
+        $menu = $this->get_action_menu();
+
+        if (empty($menu)) {
+            return new stdClass();
+        }
+
+        $data = (object)[
+            'menu' => $menu->export_for_template($output),
+            'hasmenu' => true,
+            'id' => $mod->id,
+        ];
+
+        // After icons.
+        if (!empty($mod->afterediticons)) {
+            $data->afterediticons = $mod->afterediticons;
+        }
+
+        return $data;
+    }
+
+    /**
+     * Generate the aciton menu element.
+     *
+     * This method is public in case some block needs to modify the menu before output it.
+     *
+     * @return aciton_menu the activity action menu
+     */
+    public function get_action_menu(): ?action_menu {
+
+        if (!empty($this->menu)) {
+            return $this->menu;
+        }
+
+        $mod = $this->mod;
+
+        $controls = $this->cm_control_items();
+
+        if (empty($controls)) {
+            return null;
+        }
+
+        // Convert control array into an action_menu.
+        $menu = new action_menu();
+        $menu->set_alignment(action_menu::TR, action_menu::BR);
+        $menu->set_menu_trigger(get_string('edit'));
+
+        $menu->attributes['class'] .= ' section-cm-edit-actions commands';
+
+        // Prioritise the menu ahead of all other actions.
+        $menu->prioritise = true;
+
+        $ownerselector = $displayoptions['ownerselector'] ?? '#module-' . $mod->id;
+        $menu->set_owner_selector($ownerselector);
+
+        $constraint = $displayoptions['constraintselector'] ?? '.course-content';
+        $menu->set_constraint($constraint);
+
+        foreach ($controls as $control) {
+            if ($control instanceof action_menu_link) {
+                $control->add_class('cm-edit-action');
+            }
+&