Merge branch 'MDL-68846' of https://github.com/stronk7/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Wed, 27 May 2020 23:35:10 +0000 (07:35 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 27 May 2020 23:35:10 +0000 (07:35 +0800)
101 files changed:
admin/tool/dataprivacy/tests/behat/manage_categories.feature
admin/tool/dataprivacy/tests/behat/manage_purposes.feature
analytics/tests/behat/manage_models.feature
blocks/myoverview/templates/view-list.mustache
blocks/myoverview/templates/view-summary.mustache
calendar/amd/build/view_manager.min.js
calendar/amd/build/view_manager.min.js.map
calendar/amd/src/view_manager.js
calendar/templates/header.mustache
calendar/templates/month_detailed.mustache
course/classes/management_renderer.php
course/renderer.php
course/templates/coursecard.mustache
course/templates/local/activitychooser/item.mustache
course/tests/behat/behat_course.php
files/renderer.php
grade/lib.php
grade/report/grader/lib.php
grade/report/grader/styles.css
grade/report/singleview/classes/local/screen/grade.php
grade/report/singleview/classes/local/screen/tablelike.php
grade/report/singleview/classes/local/screen/user.php
grade/report/singleview/templates/text_attribute.mustache
lang/en/repository.php
lang/en/user.php
lib/amd/build/form-autocomplete.min.js
lib/amd/build/form-autocomplete.min.js.map
lib/amd/build/notification.min.js
lib/amd/build/notification.min.js.map
lib/amd/build/tag.min.js
lib/amd/build/tag.min.js.map
lib/amd/build/templates.min.js
lib/amd/build/templates.min.js.map
lib/amd/src/form-autocomplete.js
lib/amd/src/notification.js
lib/amd/src/tag.js
lib/amd/src/templates.js
lib/behat/classes/behat_core_generator.php
lib/behat/classes/partial_named_selector.php
lib/db/access.php
lib/form/dateselector.php
lib/form/datetimeselector.php
lib/outputcomponents.php
lib/outputrenderers.php
lib/table/classes/external/dynamic/fetch.php
lib/tablelib.php
lib/templates/action_menu_link.mustache
lib/templates/action_menu_trigger.mustache
lib/templates/filemanager_modal_generallayout.mustache
lib/templates/filemanager_page_generallayout.mustache
lib/templates/form_autocomplete_input.mustache
lib/templates/form_autocomplete_layout.mustache [new file with mode: 0644]
lib/templates/inplace_editable.mustache
lib/templates/loginform.mustache
lib/tests/behat/app_behat_runtime.js
lib/tests/behat/behat_app.php
mod/quiz/attemptlib.php
repository/contentbank/tests/behat/select_content.feature
repository/draftfiles_ajax.php
repository/filepicker.js
repository/lib.php
repository/tests/behat/delete_files.feature
theme/boost/scss/moodle/buttons.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/filemanager.scss
theme/boost/scss/moodle/grade.scss
theme/boost/scss/moodle/modules.scss
theme/boost/scss/moodle/user.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/boost/templates/navbar.mustache
theme/classic/scss/preset/default.scss
theme/classic/style/moodle.css
theme/classic/templates/navbar.mustache
user/amd/build/local/participantsfilter/filter.min.js [new file with mode: 0644]
user/amd/build/local/participantsfilter/filter.min.js.map [new file with mode: 0644]
user/amd/build/local/participantsfilter/filtertypes/courseid.min.js [new file with mode: 0644]
user/amd/build/local/participantsfilter/filtertypes/courseid.min.js.map [new file with mode: 0644]
user/amd/build/local/participantsfilter/filtertypes/keyword.min.js [new file with mode: 0644]
user/amd/build/local/participantsfilter/filtertypes/keyword.min.js.map [new file with mode: 0644]
user/amd/build/local/participantsfilter/selectors.min.js [new file with mode: 0644]
user/amd/build/local/participantsfilter/selectors.min.js.map [new file with mode: 0644]
user/amd/build/participantsfilter.min.js [new file with mode: 0644]
user/amd/build/participantsfilter.min.js.map [new file with mode: 0644]
user/amd/src/local/participantsfilter/filter.js [new file with mode: 0644]
user/amd/src/local/participantsfilter/filtertypes/courseid.js [new file with mode: 0644]
user/amd/src/local/participantsfilter/filtertypes/keyword.js [new file with mode: 0644]
user/amd/src/local/participantsfilter/selectors.js [new file with mode: 0644]
user/amd/src/participantsfilter.js [new file with mode: 0644]
user/classes/output/participants_filter.php [new file with mode: 0644]
user/classes/table/participants_search.php
user/index.php
user/renderer.php
user/templates/local/participantsfilter/autocomplete_layout.mustache [new file with mode: 0644]
user/templates/local/participantsfilter/autocomplete_selection.mustache [new file with mode: 0644]
user/templates/local/participantsfilter/autocomplete_selection_items.mustache [new file with mode: 0644]
user/templates/local/participantsfilter/filterrow.mustache [new file with mode: 0644]
user/templates/local/participantsfilter/filtertype.mustache [new file with mode: 0644]
user/templates/local/participantsfilter/filtertypes.mustache [new file with mode: 0644]
user/templates/participantsfilter.mustache [new file with mode: 0644]

index c6f7f0e..8384b2e 100644 (file)
@@ -12,7 +12,7 @@ Feature: Manage data categories
     And I press "Add category"
     And I set the field "Name" to "Category 1"
     And I set the field "Description" to "Category 1 description"
-    When I click on "Save" "button" in the "Delete category" "dialogue"
+    When I click on "Save" "button" in the "Add category" "dialogue"
     Then I should see "Category 1" in the "List of data categories" "table"
     And I should see "Category 1 description" in the "Category 1" "table_row"
 
@@ -30,5 +30,5 @@ Feature: Manage data categories
     And I choose "Delete" in the open action menu
     And I should see "Delete category"
     And I should see "Are you sure you want to delete the category 'Category 1'?"
-    When I click on "Delete" "button" in the "Confirm" "dialogue"
+    When I click on "Delete" "button" in the "Delete category" "dialogue"
     Then I should not see "Category 1" in the "List of data categories" "table"
index 772c590..dcd0c2a 100644 (file)
@@ -52,5 +52,5 @@ Feature: Manage data storage purposes
     And I choose "Delete" in the open action menu
     And I should see "Delete purpose"
     And I should see "Are you sure you want to delete the purpose 'Purpose 1'?"
-    When I click on "Delete" "button" in the "Confirm" "dialogue"
+    When I click on "Delete" "button" in the "Delete purpose" "dialogue"
     Then I should not see "Purpose 1" in the "List of data purposes" "table"
index 9064a0b..816a253 100644 (file)
@@ -158,5 +158,5 @@ Feature: Manage analytics models
   Scenario: Delete model
     When I open the action menu in "Students at risk of not meeting the course completion conditions" "table_row"
     And I choose "Delete" in the open action menu
-    And I click on "Delete" "button" in the "Confirm" "dialogue"
+    And I click on "Delete" "button" in the "Delete" "dialogue"
     Then I should not see "Students at risk of not meeting the course completion conditions"
index c4d527c..f32e7e8 100644 (file)
@@ -63,7 +63,7 @@
                         <div>{{{shortname}}}</div>
                         {{/showshortname}}
                     </div>
-                    <a href="{{viewurl}}" class="coursename">
+                    <a href="{{viewurl}}" class="aalink coursename">
                         {{> core_course/favouriteicon }}
                         <span class="sr-only">
                             {{#str}}aria:coursename, core_course{{/str}}
index 9f92990..02dd284 100644 (file)
@@ -73,7 +73,7 @@
                     {{/showshortname}}
                 </div>
                 <div class="d-flex mb-1">
-                    <a href="{{viewurl}}" class="coursename">
+                    <a href="{{viewurl}}" class="aalink coursename">
                         {{> core_course/favouriteicon }}
                         <span class="sr-only">
                             {{#str}}aria:coursename, core_course{{/str}}
index 0e7cb63..f69895d 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js and b/calendar/amd/build/view_manager.min.js differ
index a0e67dc..9106713 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js.map and b/calendar/amd/build/view_manager.min.js.map differ
index e5edf31..37fca71 100644 (file)
@@ -162,7 +162,7 @@ export const refreshMonthContent = (root, year, month, courseId, categoryId, tar
             return Templates.replaceNode(target, html, js);
         })
         .then(() => {
-            document.querySelector('body').dispatchEvent(new Event(CalendarEvents.viewUpdated));
+            document.querySelector('body').dispatchEvent(new CustomEvent(CalendarEvents.viewUpdated));
             return;
         })
         .always(() => {
@@ -248,7 +248,7 @@ export const refreshDayContent = (root, year, month, day, courseId, categoryId,
             return Templates.replaceNode(target, html, js);
         })
         .then(() => {
-            document.querySelector('body').dispatchEvent(new Event(CalendarEvents.viewUpdated));
+            document.querySelector('body').dispatchEvent(new CustomEvent(CalendarEvents.viewUpdated));
             return;
         })
         .always(() => {
@@ -355,7 +355,7 @@ export const reloadCurrentUpcoming = (root, courseId = 0, categoryId = 0, target
             return Templates.replaceNode(target, html, js);
         })
         .then(() => {
-            document.querySelector('body').dispatchEvent(new Event(CalendarEvents.viewUpdated));
+            document.querySelector('body').dispatchEvent(new CustomEvent(CalendarEvents.viewUpdated));
             return;
         })
         .always(function() {
index ce7c76f..6bfc798 100644 (file)
@@ -31,7 +31,7 @@
     {
     }
 }}
-<div class="header d-flex flex-wrap">
+<div class="header d-flex flex-wrap p-1">
     {{> core_calendar/view_selector}}
     {{#filter_selector}}
         {{{filter_selector}}}
index 11608cd..a88f65e 100644 (file)
@@ -72,7 +72,7 @@
                         data-new-event-timestamp="{{neweventtimestamp}}">
                         <div class="d-none d-md-block hidden-phone text-xs-center">
                             {{#hasevents}}
-                                <a data-action="view-day-link" href="#" class="day" aria-label="{{viewdaylinktitle}}"
+                                <a data-action="view-day-link" href="#" class="aalink day" aria-label="{{viewdaylinktitle}}"
                                     data-year="{{date.year}}" data-month="{{date.mon}}" data-day="{{mday}}"
                                     data-courseid="{{courseid}}" data-categoryid="{{categoryid}}"
                                     data-timestamp="{{timestamp}}">{{mday}}</a>
                         </div>
                         <div class="d-md-none hidden-desktop hidden-tablet">
                             {{#hasevents}}
-                                <a data-action="view-day-link" href="#" class="day" aria-label="{{viewdaylinktitle}}"
+                                <a data-action="view-day-link" href="#" class="day aalink" aria-label="{{viewdaylinktitle}}"
                                     data-year="{{date.year}}" data-month="{{date.mon}}" data-day="{{mday}}"
                                     data-courseid="{{courseid}}" data-categoryid="{{categoryid}}"
                                     data-timestamp="{{timestamp}}">{{mday}}</a>
index 07394ca..ada3a69 100644 (file)
@@ -272,7 +272,7 @@ class core_course_management_renderer extends plugin_renderer_base {
         $html .= html_writer::end_div();
         $html .= $icon;
         if ($hasactions) {
-            $textattributes = array('class' => 'float-left categoryname');
+            $textattributes = array('class' => 'float-left categoryname aalink');
         } else {
             $textattributes = array('class' => 'float-left categoryname without-actions');
         }
@@ -647,7 +647,7 @@ class core_course_management_renderer extends plugin_renderer_base {
             'for' => 'courselistitem' . $course->id));
         $html .= html_writer::end_div();
         $html .= html_writer::end_div();
-        $html .= html_writer::link($viewcourseurl, $text, array('class' => 'float-left coursename'));
+        $html .= html_writer::link($viewcourseurl, $text, array('class' => 'float-left coursename aalink'));
         $html .= html_writer::start_div('float-right');
         if ($course->idnumber) {
             $html .= html_writer::tag('span', s($course->idnumber), array('class' => 'text-muted idnumber'));
@@ -1222,7 +1222,7 @@ class core_course_management_renderer extends plugin_renderer_base {
             $html .= html_writer::end_div();
         }
         $html .= html_writer::end_div();
-        $html .= html_writer::link($viewcourseurl, $text, array('class' => 'float-left coursename'));
+        $html .= html_writer::link($viewcourseurl, $text, array('class' => 'float-left coursename aalink'));
         $html .= html_writer::tag('span', $categoryname, array('class' => 'float-left ml-3 text-muted'));
         $html .= html_writer::start_div('float-right');
         $html .= $this->search_listitem_actions($course);
index b5260c4..bb7b3b9 100644 (file)
@@ -709,7 +709,7 @@ class core_course_renderer extends plugin_renderer_base {
                 'class' => 'iconlarge activityicon', 'alt' => '', 'role' => 'presentation', 'aria-hidden' => 'true')) .
                 html_writer::tag('span', $instancename . $altname, array('class' => 'instancename'));
         if ($mod->uservisible) {
-            $output .= html_writer::link($url, $activitylink, array('class' => $linkclasses, 'onclick' => $onclick));
+            $output .= html_writer::link($url, $activitylink, array('class' => 'aalink' . $linkclasses, 'onclick' => $onclick));
         } else {
             // We may be displaying this just in order to show information
             // about visibility, without the actual link ($mod->is_visible_on_course_page()).
@@ -1142,7 +1142,7 @@ class core_course_renderer extends plugin_renderer_base {
         }
         $coursename = $chelper->get_course_formatted_name($course);
         $coursenamelink = html_writer::link(new moodle_url('/course/view.php', ['id' => $course->id]),
-            $coursename, ['class' => $course->visible ? '' : 'dimmed']);
+            $coursename, ['class' => $course->visible ? 'aalink' : 'aalink dimmed']);
         $content .= html_writer::tag($nametag, $coursenamelink, ['class' => 'coursename']);
         // If we display course in collapsed form but the course has summary or course contacts, display the link to the info page.
         $content .= html_writer::start_tag('div', ['class' => 'moreinfo']);
@@ -1648,7 +1648,7 @@ class core_course_renderer extends plugin_renderer_base {
         }
         $content .= html_writer::start_tag('div', array('class' => 'info'));
 
-        $content .= html_writer::tag(($depth > 1) ? 'h4' : 'h3', $categoryname, array('class' => 'categoryname'));
+        $content .= html_writer::tag(($depth > 1) ? 'h4' : 'h3', $categoryname, array('class' => 'categoryname aabtn'));
         $content .= html_writer::end_tag('div'); // .info
 
         // add category content to the output
@@ -1682,7 +1682,7 @@ class core_course_renderer extends plugin_renderer_base {
 
         if ($coursecat->get_children_count()) {
             $classes = array(
-                'collapseexpand',
+                'collapseexpand', 'aabtn'
             );
 
             // Check if the category content contains subcategories with children's content loaded.
@@ -2391,7 +2391,7 @@ class core_course_renderer extends plugin_renderer_base {
         }
         $output = html_writer::link('#' . $skipdivid,
             get_string('skipa', 'access', core_text::strtolower(strip_tags($header))),
-            array('class' => 'skip-block skip'));
+            array('class' => 'skip-block skip aabtn'));
 
         // Wrap frontpage part in div container.
         $output .= html_writer::start_tag('div', array('id' => $contentsdivid));
index 74300b9..b24636c 100644 (file)
@@ -57,7 +57,7 @@
                     </div>
                     {{/showshortname}}
                 </div>
-                <a href="{{viewurl}}" class="coursename mr-2">
+                <a href="{{viewurl}}" class="aalink coursename mr-2">
                     {{> core_course/favouriteicon }}
                     <span class="sr-only">
                             {{#str}}aria:coursename, core_course{{/str}}
index ccebf94..82e6de3 100644 (file)
@@ -29,7 +29,7 @@
         "icon": "<img class='icon' src='http://urltooptionicon'>"
     }
 }}
-<div role="menuitem" tabindex="-1" aria-label="{{title}}" class="option d-block text-center py-1 mb-1" data-region="chooser-option-container" data-internal="{{name}}" data-modname="{{componentname}}_{{link}}">
+<div role="menuitem" tabindex="-1" aria-label="{{title}}" class="option d-block text-center py-1 mb-1 aabtn" data-region="chooser-option-container" data-internal="{{name}}" data-modname="{{componentname}}_{{link}}">
     <div class="optioninfo w-100" data-region="chooser-option-info-container">
         <a class="d-block" href="{{link}}" title="{{#str}} addnew, moodle, {{title}} {{/str}}" tabindex="-1" data-action="add-chooser-option">
             <span class="optionicon d-block">
index 6847675..ad17e53 100644 (file)
@@ -1883,7 +1883,8 @@ class behat_course extends behat_base {
 
         $exception = new ExpectationException('"' . $categoryname . '" category can not be found', $this->getSession());
         $categoryliteral = behat_context_helper::escape($categoryname);
-        $xpath = "//div[@class='info']/descendant::*[" . implode(' or ', $headingtags) . "][@class='categoryname'][./descendant::a[.=$categoryliteral]]";
+        $xpath = "//div[@class='info']/descendant::*[" . implode(' or ', $headingtags) .
+            "][contains(@class,'categoryname')][./descendant::a[.=$categoryliteral]]";
         $node = $this->find('xpath', $xpath, $exception);
         $node->click();
 
index 62e15b6..2df7fa9 100644 (file)
@@ -166,7 +166,7 @@ class core_files_renderer extends plugin_renderer_base {
     protected function fm_js_template_iconfilename() {
         $rv = '
 <div class="fp-file">
-    <a href="#">
+    <a href="#" class="d-block aabtn">
     <div style="position:relative;">
         <div class="fp-thumbnail"></div>
         <div class="fp-reficons1"></div>
@@ -176,7 +176,8 @@ class core_files_renderer extends plugin_renderer_base {
         <div class="fp-filename text-truncate"></div>
     </div>
     </a>
-    <a class="fp-contextmenu" href="#">'.$this->pix_icon('i/menu', '▶').'</a>
+    <a class="fp-contextmenu btn btn-icon btn-light border icon-no-margin icon-size-3" href="#">
+        <span>'.$this->pix_icon('i/menu', '▶').'</span></a>
 </div>';
         return $rv;
     }
@@ -226,7 +227,7 @@ class core_files_renderer extends plugin_renderer_base {
 <div class="filemanager fp-mkdir-dlg" role="dialog" aria-live="assertive" aria-labelledby="fp-mkdir-dlg-title">
     <div class="fp-mkdir-dlg-text">
         <label id="fp-mkdir-dlg-title">' . get_string('newfoldername', 'repository') . '</label><br/>
-        <input type="text" />
+        <input type="text" class="form-control"/>
     </div>
     <button class="fp-dlg-butcreate btn-primary btn">'.get_string('makeafolder').'</button>
     <button class="fp-dlg-butcancel btn-cancel btn">'.get_string('cancel').'</button>
index 67857cb..2e8c8d3 100644 (file)
@@ -1791,8 +1791,9 @@ class grade_structure {
             return '';
         }
 
-        return $OUTPUT->action_icon($url, new pix_icon('t/preview',
-            get_string('gradeanalysis', 'core_grades')));
+        $title = get_string('gradeanalysis', 'core_grades');
+        return $OUTPUT->action_icon($url, new pix_icon('t/preview', ''), null,
+                ['title' => $title, 'aria-label' => $title]);
     }
 
     /**
index 53741c4..5227baf 100644 (file)
@@ -710,12 +710,16 @@ class grade_report_grader extends grade_report {
         $rows = $this->get_left_icons_row($rows, $colspan);
 
         $suspendedstring = null;
+
+        $usercount = 0;
         foreach ($this->users as $userid => $user) {
             $userrow = new html_table_row();
             $userrow->id = 'fixed_user_'.$userid;
+            $userrow->attributes['class'] = ($usercount % 2) ? 'userrow even' : 'userrow odd';
 
             $usercell = new html_table_cell();
-            $usercell->attributes['class'] = 'header user';
+            $usercell->attributes['class'] = ($usercount % 2) ? 'header user even' : 'header user odd';
+            $usercount++;
 
             $usercell->header = true;
             $usercell->scope = 'row';
index 3e54686..92d658e 100644 (file)
     white-space: nowrap;
 }
 
-/**
- * Stripped table.
- */
-.path-grade-report-grader .gradeparent tr:nth-of-type(even) .cell {
-    background-color: #f9f9f9;
-}
-
 /**
  * All the floating divs.
  */
     text-align: left;
 }
 
-/**
- * All the floating cells.
- */
-.path-grade-report-grader .gradeparent .floater .cell {
-    background-color: #f9f9f9;
-}
-
 /**
  * The user cells.
  */
     float: left;
 }
 
-.path-grade-report .gradeparent .floater .controls.cell,
-.path-grade-report-grader .gradeparent .controls {
-    background-color: #f3ead8;
-}
-
 .path-grade-report-grader .gradeparent .category {
     text-align: left;
 }
index ddebda7..f3b4ad7 100644 (file)
@@ -208,11 +208,12 @@ class grade extends tablelike implements selectable_items, filterable_items {
         $url = new moodle_url("/user/view.php", array('id' => $item->id, 'course' => $this->courseid));
         $iconstring = get_string('filtergrades', 'gradereport_singleview', $fullname);
         $grade->label = $fullname;
+        $userpic = $OUTPUT->user_picture($item, ['link' => false, 'visibletoscreenreaders' => false]);
 
         $line = array(
-            $OUTPUT->action_icon($this->format_link('user', $item->id), new pix_icon('t/editstring', $iconstring)),
-            $OUTPUT->user_picture($item, array('visibletoscreenreaders' => false)) .
-            html_writer::link($url, $fullname),
+            $OUTPUT->action_icon($this->format_link('user', $item->id), new pix_icon('t/editstring', ''), null,
+                    ['title' => $iconstring, 'aria-label' => $iconstring]),
+            html_writer::link($url, $userpic . $fullname),
             $this->item_range()
         );
         $lineclasses = array(
index 9c5f025..3498fe4 100644 (file)
@@ -172,7 +172,8 @@ abstract class tablelike extends screen {
 
         $summary = $this->summary();
         if (!empty($summary)) {
-            $table->summary = $summary;
+            $table->caption = $summary;
+            $table->captionhide = true;
         }
 
         // To be used for extra formatting.
index 3043a86..3e90a88 100644 (file)
@@ -190,7 +190,8 @@ class user extends tablelike implements selectable_items {
         $grade->label = $item->get_name();
 
         $line = array(
-            $OUTPUT->action_icon($this->format_link('grade', $item->id), new pix_icon('t/editstring', $iconstring)),
+            $OUTPUT->action_icon($this->format_link('grade', $item->id), new pix_icon('t/editstring', ''), null,
+                    ['title' => $iconstring, 'aria-label' => $iconstring]),
             $this->format_icon($item) . $lockicon . $itemlabel,
             $this->category($item),
             new range($item)
index 6c73b9c..47a1ae3 100644 (file)
@@ -28,6 +28,6 @@
         "disabled": "true"
     }
 }}
-<label for="{{name}}" class="accesshide">{{label}}</label>
+{{#label}}<label for="{{name}}" class="accesshide">{{label}}</label>{{/label}}
 <input id="{{name}}" name="{{name}}" type="text" value="{{value}}" class="form-control" {{#tabindex}}tabindex="{{.}}"{{/tabindex}} {{#disabled}}disabled{{/disabled}}>
 <input type="hidden" name="old{{name}}" value="{{value}}">
index 7ed41e6..78a3234 100644 (file)
@@ -99,8 +99,6 @@ $string['displaytree'] = 'Display folder as file tree';
 $string['download'] = 'Download';
 $string['downloadallfiles'] = 'Download all files';
 $string['downloadfolder'] = 'Download all';
-$string['downloadselected'] = 'Download selected files';
-$string['deleteselected'] = 'Delete selected';
 $string['downloadsucc'] = 'The file has been downloaded successfully';
 $string['draftareanofiles'] = 'Cannot be downloaded because there is no files attached';
 $string['editrepositoryinstance'] = 'Edit repository instance';
index e010e09..5eee2cb 100644 (file)
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['addcondition'] = 'Add condition';
+$string['adverbfor_and'] = 'and';
+$string['adverbfor_andnot'] = 'and';
+$string['adverbfor_or'] = 'or';
+$string['applyfilters'] = 'Apply filters';
+$string['clearfilterrow'] = 'Remove filter row';
+$string['clearfilters'] = 'Clear filters';
 $string['countparticipantsfound'] = '{$a} participants found';
+$string['filtersetmatchdescription'] = 'How multiple filters should be combined';
+$string['match'] = 'Match';
+$string['matchofthefollowing'] = 'of the following:';
+$string['placeholdertypeorselect'] = 'Type or select...';
+$string['placeholdertype'] = 'Type...';
 $string['privacy:courserequestpath'] = 'Requested courses';
 $string['privacy:descriptionpath'] = 'Profile description';
 $string['privacy:devicespath'] = 'User devices';
@@ -126,6 +138,8 @@ $string['privacy:passwordresetpath'] = 'Password resets';
 $string['privacy:profileimagespath'] = 'Profile images';
 $string['privacy:privatefilespath'] = 'Private files';
 $string['privacy:sessionpath'] = 'Session data';
+$string['filterbykeyword'] = 'Keyword';
+$string['selectfiltertype'] = 'Select';
 $string['target:upcomingactivitiesdue'] = 'Upcoming activities due';
 $string['target:upcomingactivitiesdue_help'] = 'This target generates reminders for upcoming activities due.';
 $string['target:upcomingactivitiesdueinfo'] = 'All upcoming activities due insights are listed here. These students have received these insights directly.';
index 154a606..8ef27f0 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index 944df5c..77c999b 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js.map and b/lib/amd/build/form-autocomplete.min.js.map differ
index ae72462..42cdec9 100644 (file)
Binary files a/lib/amd/build/notification.min.js and b/lib/amd/build/notification.min.js differ
index ed26296..1d721de 100644 (file)
Binary files a/lib/amd/build/notification.min.js.map and b/lib/amd/build/notification.min.js.map differ
index 603a149..ba37d4e 100644 (file)
Binary files a/lib/amd/build/tag.min.js and b/lib/amd/build/tag.min.js differ
index 9749568..7db6808 100644 (file)
Binary files a/lib/amd/build/tag.min.js.map and b/lib/amd/build/tag.min.js.map differ
index 24665b1..bcc63b6 100644 (file)
Binary files a/lib/amd/build/templates.min.js and b/lib/amd/build/templates.min.js differ
index 119e917..b75e37f 100644 (file)
Binary files a/lib/amd/build/templates.min.js.map and b/lib/amd/build/templates.min.js.map differ
index 035f94e..b91c5fb 100644 (file)
@@ -114,7 +114,7 @@ function($, log, str, templates, notification, LoadingIcon) {
         });
         var context = $.extend({items: items}, options, state);
         // Render the template.
-        return templates.render('core/form_autocomplete_selection_items', context)
+        return templates.render(options.templates.items, context)
         .then(function(html, js) {
             // Add it to the page.
             templates.replaceNodeContents(newSelection, html, js);
@@ -970,10 +970,11 @@ function($, log, str, templates, notification, LoadingIcon) {
          * @param {Boolean} showSuggestions - If suggestions should be shown
          * @param {String} noSelectionString - Text to display when there is no selection
          * @param {Boolean} closeSuggestionsOnSelect - Whether to close the suggestions immediately after making a selection.
+         * @param {Object} templateOverrides A set of templates to use instead of the standard templates
          * @return {Promise}
          */
         enhance: function(selector, tags, ajax, placeholder, caseSensitive, showSuggestions, noSelectionString,
-                          closeSuggestionsOnSelect) {
+                          closeSuggestionsOnSelect, templateOverrides) {
             // Set some default values.
             var options = {
                 selector: selector,
@@ -982,7 +983,14 @@ function($, log, str, templates, notification, LoadingIcon) {
                 placeholder: placeholder,
                 caseSensitive: false,
                 showSuggestions: true,
-                noSelectionString: noSelectionString
+                noSelectionString: noSelectionString,
+                templates: $.extend({
+                        input: 'core/form_autocomplete_input',
+                        items: 'core/form_autocomplete_selection_items',
+                        layout: 'core/form_autocomplete_layout',
+                        selection: 'core/form_autocomplete_selection',
+                        suggestions: 'core/form_autocomplete_suggestions',
+                    }, templateOverrides),
             };
             var pendingKey = 'autocomplete-setup-' + selector;
             M.util.js_pending(pendingKey);
@@ -1058,27 +1066,35 @@ function($, log, str, templates, notification, LoadingIcon) {
             // Collect rendered inline JS to be executed once the HTML is shown.
             var collectedjs = '';
 
-            var renderInput = templates.render('core/form_autocomplete_input', context).then(function(html, js) {
+            var renderLayout = templates.render(options.templates.layout, {})
+            .then(function(html) {
+                return $(html);
+            });
+
+            var renderInput = templates.render(options.templates.input, context).then(function(html, js) {
                 collectedjs += js;
-                return html;
+                return $(html);
             });
 
-            var renderDatalist = templates.render('core/form_autocomplete_suggestions', context).then(function(html, js) {
+            var renderDatalist = templates.render(options.templates.suggestions, context).then(function(html, js) {
                 collectedjs += js;
-                return html;
+                return $(html);
             });
 
-            var renderSelection = templates.render('core/form_autocomplete_selection', context).then(function(html, js) {
+            var renderSelection = templates.render(options.templates.selection, context).then(function(html, js) {
                 collectedjs += js;
-                return html;
+                return $(html);
             });
 
-            return $.when(renderInput, renderDatalist, renderSelection)
-            .then(function(input, suggestions, selection) {
+            return $.when(renderLayout, renderInput, renderDatalist, renderSelection)
+            .then(function(layout, input, suggestions, selection) {
                 originalSelect.hide();
-                originalSelect.after(suggestions);
-                originalSelect.after(input);
-                originalSelect.after(selection);
+                var container = originalSelect.parent();
+
+                container.append(layout);
+                container.find('[data-region="form_autocomplete-input"]').replaceWith(input);
+                container.find('[data-region="form_autocomplete-suggestions"]').replaceWith(suggestions);
+                container.find('[data-region="form_autocomplete-selection"]').replaceWith(selection);
 
                 templates.runTemplateJS(collectedjs);
 
index 0822899..62cec7e 100644 (file)
@@ -292,8 +292,9 @@ export const exception = async ex => {
  *
  * @param {Number} contextId
  * @param {Array} notificationList
+ * @param {Boolean} userLoggedIn
  */
-export const init = (contextId, notificationList) => {
+export const init = (contextId, notificationList, userLoggedIn) => {
     currentContextId = contextId;
 
     // Setup the message target region if it isn't setup already
@@ -302,8 +303,11 @@ export const init = (contextId, notificationList) => {
     // Add provided notifications.
     addNotifications(notificationList);
 
-    // Perform an initial poll for any new notifications.
-    fetchNotifications();
+    // If the user is not logged in then we can not fetch anything for them.
+    if (userLoggedIn) {
+        // Perform an initial poll for any new notifications.
+        fetchNotifications();
+    }
 };
 
 // To maintain backwards compatability we export default here.
index 2527032..603cc9f 100644 (file)
@@ -266,12 +266,13 @@ define([
 
                     e.preventDefault(); // This will prevent default error dialogue.
                     str.get_strings([
+                        {key: 'confirm', component: 'core'},
                         {key: 'nameuseddocombine', component: 'tag'},
-                        {key: 'yes'},
-                        {key: 'cancel'},
+                        {key: 'yes', component: 'core'},
+                        {key: 'cancel', component: 'core'},
                     ])
                     .then(function(s) {
-                        return notification.confirm(e.message, s[0], s[1], s[2], function() {
+                        return notification.confirm(s[0], s[1], s[2], s[3], function() {
                             window.location.href = window.location.href + "&newname=" + encodeURIComponent(newvalue) +
                                 "&tagid=" + encodeURIComponent(tagid) +
                                 '&action=renamecombine&sesskey=' + M.cfg.sesskey;
index c145173..8ba0e8b 100644 (file)
@@ -876,6 +876,7 @@ define([
      * @param {String} newHTML - HTML to insert / replace.
      * @param {String} newJS - Javascript to run after the insertion.
      * @param {Boolean} replaceChildNodes - Replace only the childnodes, alternative is to replace the entire node.
+     * @return {Array} The list of new DOM Nodes
      */
     var domReplace = function(element, newHTML, newJS, replaceChildNodes) {
         var replaceNode = $(element);
@@ -904,7 +905,11 @@ define([
             runTemplateJS(newJS);
             // Notify all filters about the new content.
             event.notifyFilterContentUpdated(newNodes);
+
+            return newNodes.get();
         }
+
+        return [];
     };
 
     /**
@@ -1043,17 +1048,23 @@ define([
      * @param {jQuery|String} element - Element or selector to prepend HTML to
      * @param {String} html - HTML to prepend
      * @param {String} js - Javascript to run after we prepend the html
+     * @return {Array} The list of new DOM Nodes
      */
     var domPrepend = function(element, html, js) {
         var node = $(element);
         if (node.length) {
             // Prepend the html.
-            node.prepend(html);
+            var newContent = $(html);
+            node.prepend(newContent);
             // Run any javascript associated with the new HTML.
             runTemplateJS(js);
             // Notify all filters about the new content.
             event.notifyFilterContentUpdated(node);
+
+            return newContent.get();
         }
+
+        return [];
     };
 
     /**
@@ -1064,17 +1075,23 @@ define([
      * @param {jQuery|String} element - Element or selector to append HTML to
      * @param {String} html - HTML to append
      * @param {String} js - Javascript to run after we append the html
+     * @return {Array} The list of new DOM Nodes
      */
     var domAppend = function(element, html, js) {
         var node = $(element);
         if (node.length) {
             // Append the html.
-            node.append(html);
+            var newContent = $(html);
+            node.append(newContent);
             // Run any javascript associated with the new HTML.
             runTemplateJS(js);
             // Notify all filters about the new content.
             event.notifyFilterContentUpdated(node);
+
+            return newContent.get();
         }
+
+        return [];
     };
 
     return /** @alias module:core/templates */ {
@@ -1175,9 +1192,10 @@ define([
          * @param {JQuery} element - Element or selector to replace.
          * @param {String} newHTML - HTML to insert / replace.
          * @param {String} newJS - Javascript to run after the insertion.
+         * @return {Array} The list of new DOM Nodes
          */
         replaceNodeContents: function(element, newHTML, newJS) {
-            domReplace(element, newHTML, newJS, true);
+            return domReplace(element, newHTML, newJS, true);
         },
 
         /**
@@ -1187,9 +1205,10 @@ define([
          * @param {JQuery} element - Element or selector to replace.
          * @param {String} newHTML - HTML to insert / replace.
          * @param {String} newJS - Javascript to run after the insertion.
+         * @return {Array} The list of new DOM Nodes
          */
         replaceNode: function(element, newHTML, newJS) {
-            domReplace(element, newHTML, newJS, false);
+            return domReplace(element, newHTML, newJS, false);
         },
 
         /**
@@ -1199,9 +1218,10 @@ define([
          * @param {jQuery|String} element - Element or selector to prepend HTML to
          * @param {String} html - HTML to prepend
          * @param {String} js - Javascript to run after we prepend the html
+         * @return {Array} The list of new DOM Nodes
          */
         prependNodeContents: function(element, html, js) {
-            domPrepend(element, html, js);
+            return domPrepend(element, html, js);
         },
 
         /**
@@ -1211,9 +1231,10 @@ define([
          * @param {jQuery|String} element - Element or selector to append HTML to
          * @param {String} html - HTML to append
          * @param {String} js - Javascript to run after we append the html
+         * @return {Array} The list of new DOM Nodes
          */
         appendNodeContents: function(element, html, js) {
-            domAppend(element, html, js);
+            return domAppend(element, html, js);
         },
     };
 });
index 2fd9ea1..a1ae1ac 100644 (file)
@@ -882,6 +882,7 @@ class behat_core_generator extends behat_generator_base {
             $content = $contenttype->create_content($record);
 
             if (!empty($data['filepath'])) {
+                $filename = basename($data['filepath']);
                 $fs = get_file_storage();
                 $filerecord = array(
                     'component' => 'contentbank',
@@ -889,7 +890,7 @@ class behat_core_generator extends behat_generator_base {
                     'contextid' => $context->id,
                     'userid' => $data['userid'],
                     'itemid' => $content->get_id(),
-                    'filename' => $data['contentname'],
+                    'filename' => $filename,
                     'filepath' => '/'
                 );
                 $fs->create_file_from_pathname($filerecord, $CFG->dirroot . $data['filepath']);
index 87c602e..a1825a6 100644 (file)
@@ -161,7 +161,7 @@ XPATH
 .//div[
         contains(concat(' ', normalize-space(@class), ' '), ' modal ')
             and
-        normalize-space(descendant::*[contains(concat(' ', normalize-space(@class), ' '), ' modal-header ')] = %locator%)
+        normalize-space(descendant::*[contains(concat(' ', normalize-space(@class), ' '), ' modal-header ')]) = %locator%
     ]
 XPATH
         , 'group_message' => <<<XPATH
index 46af6d3..0db35e1 100644 (file)
@@ -969,7 +969,7 @@ $capabilities = array(
 
     'moodle/course:viewhiddenactivities' => array(
 
-        'captype' => 'write',
+        'captype' => 'read',
         'contextlevel' => CONTEXT_MODULE,
         'archetypes' => array(
             'teacher' => CAP_ALLOW,
@@ -1558,7 +1558,7 @@ $capabilities = array(
 
     'moodle/course:viewhiddensections' => array(
 
-        'captype' => 'write',
+        'captype' => 'read',
         'contextlevel' => CONTEXT_COURSE,
         'archetypes' => array(
             'editingteacher' => CAP_ALLOW,
index 7d24f66..4ae8400 100644 (file)
@@ -135,8 +135,7 @@ class MoodleQuickForm_date_selector extends MoodleQuickForm_group {
         if ($calendartype->get_name() === 'gregorian') {
             $image = $OUTPUT->pix_icon('i/calendar', get_string('calendar', 'calendar'), 'moodle');
             $this->_elements[] = $this->createFormElement('link', 'calendar',
-                    null, '#', $image,
-                    array('class' => 'visibleifjs'));
+                    null, '#', $image);
         }
         // If optional we add a checkbox which the user can use to turn if on
         if ($this->_options['optional']) {
index 9c1ff76..084768d 100644 (file)
@@ -154,8 +154,7 @@ class MoodleQuickForm_date_time_selector extends MoodleQuickForm_group {
         if ($calendartype->get_name() === 'gregorian') {
             $image = $OUTPUT->pix_icon('i/calendar', get_string('calendar', 'calendar'), 'moodle');
             $this->_elements[] = $this->createFormElement('link', 'calendar',
-                    null, '#', $image,
-                    array('class' => 'visibleifjs'));
+                    null, '#', $image);
         }
         // If optional we add a checkbox which the user can use to turn if on
         if ($this->_options['optional']) {
index 75980f0..6c81e34 100644 (file)
@@ -2207,8 +2207,9 @@ class html_writer {
                     $heading->header = true;
                 }
 
-                if ($heading->header && empty($heading->scope)) {
-                    $heading->scope = 'col';
+                $tagtype = 'td';
+                if ($heading->header && (string)$heading->text != '') {
+                    $tagtype = 'th';
                 }
 
                 $heading->attributes['class'] .= ' header c' . $key;
@@ -2224,16 +2225,15 @@ class html_writer {
                     $heading->attributes['class'] .= ' ' . $table->colclasses[$key];
                 }
                 $heading->attributes['class'] = trim($heading->attributes['class']);
-                $attributes = array_merge($heading->attributes, array(
-                        'style'     => $table->align[$key] . $table->size[$key] . $heading->style,
-                        'scope'     => $heading->scope,
-                        'colspan'   => $heading->colspan,
-                    ));
+                $attributes = array_merge($heading->attributes, [
+                    'style'     => $table->align[$key] . $table->size[$key] . $heading->style,
+                    'colspan'   => $heading->colspan,
+                ]);
 
-                $tagtype = 'td';
-                if ($heading->header === true) {
-                    $tagtype = 'th';
+                if ($tagtype == 'th') {
+                    $attributes['scope'] = !empty($heading->scope) ? $heading->scope : 'col';
                 }
+
                 $output .= html_writer::tag($tagtype, $heading->text, $attributes) . "\n";
             }
             $output .= html_writer::end_tag('tr') . "\n";
index a72bb26..e3705ea 100644 (file)
@@ -1167,7 +1167,7 @@ class core_renderer extends renderer_base {
         if ($this->page->pagetype == 'site-index') {
             // Special case for site home page - please do not remove
             return '<div class="sitelink">' .
-                   '<a title="Moodle" href="http://moodle.org/">' .
+                   '<a title="Moodle" class="d-inline-block aalink" href="http://moodle.org/">' .
                    '<img src="' . $this->image_url('moodlelogo_grayhat') . '" alt="'.get_string('moodlelogo').'" /></a></div>';
 
         } else if (!empty($CFG->target_release) && $CFG->target_release != $CFG->release) {
@@ -1414,7 +1414,8 @@ class core_renderer extends renderer_base {
         if (!empty($this->page->context->id)) {
             $this->page->requires->js_call_amd('core/notification', 'init', array(
                 $this->page->context->id,
-                \core\notification::fetch_as_array($this)
+                \core\notification::fetch_as_array($this),
+                isloggedin()
             ));
         }
         $footer = str_replace($this->unique_end_html_token, $this->page->requires->get_end_code(), $footer);
@@ -2571,7 +2572,7 @@ class core_renderer extends renderer_base {
             $url = new moodle_url('/user/view.php', array('id' => $user->id, 'course' => $courseid));
         }
 
-        $attributes = array('href'=>$url);
+        $attributes = array('href' => $url, 'class' => 'd-inline-block aabtn');
         if (!$userpicture->visibletoscreenreaders) {
             $attributes['tabindex'] = '-1';
             $attributes['aria-hidden'] = 'true';
index b63c6c2..ad2fcda 100644 (file)
@@ -217,6 +217,7 @@ class fetch extends external_api {
         }
 
         $filterset = new $filtersetclass();
+        $filterset->set_join_type($jointype);
         foreach ($filters as $rawfilter) {
             $filterset->add_filter_from_params(
                 $rawfilter['name'],
index 79052b3..8e28ab4 100644 (file)
@@ -1503,7 +1503,7 @@ class flexible_table {
         $this->sortdata = [];
         foreach ($sortdata as $sortitem) {
             if (!array_key_exists($sortitem['sortby'], $this->sortdata)) {
-                $this->sortdata[$sortitem['sortby']] = $sortitem['sortorder'];
+                $this->sortdata[$sortitem['sortby']] = (int) $sortitem['sortorder'];
             }
         }
     }
index 0e9f42a..0184d49 100644 (file)
@@ -27,7 +27,7 @@
     }
 }}
 {{^disabled}}
-    <a href="{{url}}" class="{{classes}}" {{#attributes}}{{name}}={{#quote}}{{value}}{{/quote}} {{/attributes}}{{#showtext}}aria-labelledby="actionmenuaction-{{instance}}"{{/showtext}}>{{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{#showtext}}<span class="menu-action-text" id="actionmenuaction-{{instance}}">{{{text}}}</span>{{/showtext}}</a>
+    <a href="{{url}}" class="aabtn {{classes}}" {{#attributes}}{{name}}={{#quote}}{{value}}{{/quote}} {{/attributes}}{{#showtext}}aria-labelledby="actionmenuaction-{{instance}}"{{/showtext}}>{{#icon}}{{#pix}}{{key}}, {{component}}, {{title}}{{/pix}}{{/icon}}{{#showtext}}<span class="menu-action-text" id="actionmenuaction-{{instance}}">{{{text}}}</span>{{/showtext}}</a>
 {{/disabled}}
 {{#disabled}}
     <span class="currentlink" role="menuitem">{{#icon}}{{#pix}}{{key}},{{component}},{{title}}{{/pix}}{{/icon}}{{{text}}}</span>
index cf34a04..8a172c9 100644 (file)
@@ -78,7 +78,7 @@
     }
 }}
 <div class="dropdown{{^secondary.items}} hidden{{/secondary.items}}">
-    <a href="#" tabindex="0" class="{{triggerextraclasses}} dropdown-toggle icon-no-margin" id="action-menu-toggle-{{instance}}" aria-label="{{title}}" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" aria-controls="action-menu-{{instance}}-menu">
+    <a href="#" tabindex="0" class="d-inline-block {{triggerextraclasses}} dropdown-toggle icon-no-margin" id="action-menu-toggle-{{instance}}" aria-label="{{title}}" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false" aria-controls="action-menu-{{instance}}-menu">
         {{{actiontext}}}
         {{{menutrigger}}}
             {{#icon}}
index 57ec021..6bd05e1 100644 (file)
@@ -77,7 +77,7 @@
                         <div class="fp-clear-left"></div>
                     </div>
                     <div class="fp-pathbar">
-                        <span class="fp-path-folder"><a class="fp-path-folder-name" href="#"></a></span>
+                        <span class="fp-path-folder"><a class="fp-path-folder-name btn btn-icon icon-no-margin icon-size-3" href="#"></a></span>
                     </div>
                 </div>
                 <div class="fp-content card"></div>
index 7300170..dcdc356 100644 (file)
                     </a>
                 </div>
                 <div class="fp-btn-download">
-                    <a role="button" title="{{#str}}downloadselected, repository{{/str}}" class="btn btn-secondary btn-sm" href="#">
+                    <a role="button" title="{{#str}}download, repository{{/str}}" class="btn btn-secondary btn-sm" href="#">
                         {{#pix}}a/download_all{{/pix}}
                     </a>
                 </div>
                 <div class="fp-btn-delete">
-                    <a role="button" title="{{#str}}deleteselected, repository{{/str}}" class="btn btn-secondary btn-sm" href="#">
+                    <a role="button" title="{{#str}}delete{{/str}}" class="btn btn-secondary btn-sm" href="#">
                         {{#pix}}i/trash{{/pix}}
                     </a>
                 </div>
@@ -72,7 +72,7 @@
             </div>
         </div>
         <div class="fp-pathbar">
-            <span class="fp-path-folder"><a class="fp-path-folder-name" href="#"></a></span>
+            <span class="fp-path-folder"><a class="fp-path-folder-name aalink" href="#"></a></span>
         </div>
     </div>
     <div class="filemanager-loading mdl-align">{{#pix}}i/loading_small{{/pix}}<span class="sr-only">{{#str}}loadinghelp{{/str}}</span></div>
index 1b2ec1a..ac76ed5 100644 (file)
     { "inputID": 1, "suggestionsId": 2, "selectionId": 3, "downArrowId": 4, "placeholder": "Select something" }
 }}
 {{#showSuggestions}}
-<div class="d-inline-block position-relative">
+<div class="d-md-inline-block mr-md-2 position-relative">
     <input type="text" id="{{inputId}}" class="form-control" list="{{suggestionsId}}" placeholder="{{placeholder}}" role="combobox" aria-expanded="false" autocomplete="off" autocorrect="off" autocapitalize="off" aria-autocomplete="list" aria-owns="{{suggestionsId}} {{selectionId}}" {{#tags}}data-tags="1"{{/tags}}/>
     <span class="form-autocomplete-downarrow position-absolute p-1" id="{{downArrowId}}">&#x25BC;</span>
 </div>
 {{/showSuggestions}}
 {{^showSuggestions}}
-<input type="text" id="{{inputId}}" placeholder="{{placeholder}}" role="textbox" aria-owns="{{selectionId}}" {{#tags}}data-tags="1"{{/tags}}/>
+<div class="d-md-inline-block mr-md-2">
+    <input type="text" id="{{inputId}}" class="form-control" placeholder="{{placeholder}}" role="textbox" aria-owns="{{selectionId}}" {{#tags}}data-tags="1"{{/tags}}/>
+</div>
 {{/showSuggestions}}
 
 {{#js}}
diff --git a/lib/templates/form_autocomplete_layout.mustache b/lib/templates/form_autocomplete_layout.mustache
new file mode 100644 (file)
index 0000000..f224fa8
--- /dev/null
@@ -0,0 +1,38 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/form_autocomplete_layout
+
+    Moodle template for the layout of autocomplete elements.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * data-region="form_autocomplete-input"
+    * data-region="form_autocomplete-suggestions"
+    * data-region="form_autocomplete-selection"
+
+    Context variables required for this template:
+    * none
+
+    Example context (json):
+    {}
+}}
+<div data-region="form_autocomplete-selection"></div>
+<div data-region="form_autocomplete-input"></div>
+<div data-region="form_autocomplete-suggestions"></div>
index dc78b41..cfdf87c 100644 (file)
@@ -54,7 +54,7 @@
 <span class="inplaceeditable inplaceeditable-{{type}}" data-inplaceeditable="1" data-component="{{component}}" data-itemtype="{{itemtype}}" data-itemid="{{itemid}}"
     data-value="{{value}}" data-editlabel="{{editlabel}}" data-type="{{type}}" data-options="{{options}}">
         {{^ linkeverything }}{{{displayvalue}}}{{/ linkeverything }}
-        <a href="#" class="quickeditlink" data-inplaceeditablelink="1" title="{{edithint}}">
+        <a href="#" class="quickeditlink aalink" data-inplaceeditablelink="1" title="{{edithint}}">
             {{# linkeverything }}{{{displayvalue}}}{{/ linkeverything }}
             <span class="quickediticon visibleifjs">{{#pix}}t/editstring,core,{{{edithint}}}{{/pix}}</span>
         </a>
index 27b8ec5..d41a055 100644 (file)
 
                 {{#hasidentityproviders}}
                     <h6 class="mt-2">{{#str}} potentialidps, auth {{/str}}</h6>
-                    <div class="potentialidplist" class="mt-3">
+                    <div class="potentialidplist mt-3">
                         {{#identityproviders}}
                             <div class="potentialidp">
                                 <a href="{{url}}" title={{#quote}}{{name}}{{/quote}} class="btn btn-secondary btn-block">
index 0112157..56fdbc3 100644 (file)
             case 'page menu' :
                 // This lang string was changed in app version 3.6.
                 selector = 'core-context-menu > button[aria-label=Info], ' +
-                        'core-context-menu > button[aria-label=Information]';
+                        'core-context-menu > button[aria-label=Information], ' +
+                        'core-context-menu > button[aria-label="Display options"]';
                 break;
             default:
                 return 'ERROR: Unsupported standard button type';
index f82ce5d..af82844 100644 (file)
@@ -303,12 +303,22 @@ class behat_app extends behat_base {
         // Wait until the site login field appears OR the main page.
         $situation = $this->spin(
                 function($context, $args) {
-                    $input = $context->getSession()->getPage()->find('xpath', '//input[@name="url"]');
-                    if ($input) {
+                    $page = $context->getSession()->getPage();
+
+                    $element = $page->find('xpath', '//page-core-login-site//input[@name="url"]');
+                    if ($element) {
+                        // Wait for the onboarding modal to open, if any.
+                        $this->wait_for_pending_js();
+                        $element = $page->find('xpath', '//page-core-login-site-onboarding');
+                        if ($element) {
+                            $this->i_press_in_the_app('Skip');
+                        }
+
                         return 'login';
                     }
-                    $mainmenu = $context->getSession()->getPage()->find('xpath', '//page-core-mainmenu');
-                    if ($mainmenu) {
+
+                    $element = $page->find('xpath', '//page-core-mainmenu');
+                    if ($element) {
                         return 'mainpage';
                     }
                     throw new DriverException('Moodle app login URL prompt not found');
@@ -317,7 +327,7 @@ class behat_app extends behat_base {
         // If it's the login page, we automatically fill in the URL and leave it on the user/pass
         // page. If it's the main page, we just leave it there.
         if ($situation === 'login') {
-            $this->i_set_the_field_in_the_app('Site address', $CFG->wwwroot);
+            $this->i_set_the_field_in_the_app('campus.example.edu', $CFG->wwwroot);
             $this->i_press_in_the_app('Connect!');
         }
 
index e85f696..5efd8ff 100644 (file)
@@ -2794,7 +2794,7 @@ class quiz_attempt_nav_panel extends quiz_nav_panel_base {
             return '';
         }
         return html_writer::link($this->attemptobj->summary_url(),
-                get_string('endtest', 'quiz'), array('class' => 'endtestlink')) .
+                get_string('endtest', 'quiz'), array('class' => 'endtestlink aalink')) .
                 $output->countdown_timer($this->attemptobj, time()) .
                 $this->render_restart_preview_link($output);
     }
index 3c11346..0bb7adc 100644 (file)
@@ -153,3 +153,19 @@ Feature: Select content bank files using the content bank files repository
     And I click on "Link" "button"
     When I click on "Browse repositories..." "button"
     Then I should not see "Content bank" in the ".fp-repo-area" "css_element"
+
+  Scenario: Both content name and file name are shown when a content is selected
+    Given the following "contentbank content" exist:
+      | contextlevel | reference | contenttype     | user  | contentname | filepath                       |
+      | Course       | mscC1     | contenttype_h5p | admin | My content  | /h5p/tests/fixtures/ipsums.h5p |
+    And I log in as "admin"
+    And I am on "MiscellaneousCourse1" course homepage
+    And I follow "Folder"
+    And I click on "Edit" "button"
+    And I click on "Add..." "button"
+    And I should see "Content bank" in the ".fp-repo-area" "css_element"
+    When I select "Content bank" repository in file picker
+    Then I should see "My content"
+    And I click on "My content" "link"
+    And I should see "Select My content"
+    And the field "Save as" matches value "ipsums.h5p"
index 328f2d2..d00d068 100644 (file)
@@ -175,6 +175,13 @@ switch ($action) {
     case 'downloadselected':
         $selected   = required_param('selected', PARAM_RAW);
         $selectedfiles = json_decode($selected);
+        if (!count($selectedfiles)) {
+            $filepath = required_param('filepath', PARAM_PATH);
+            $selectedfiles = [(object)[
+                'filename' => '',
+                'filepath' => $filepath
+            ]];
+        }
         $return = repository_download_selected_files($usercontext, 'user', 'draft', $draftid, $selectedfiles);
         echo (json_encode($return));
         die;
index 2f8744a..e7c4c4b 100644 (file)
@@ -1149,7 +1149,7 @@ M.core_filepicker.init = function(Y, options) {
             }, false);
         },
         select_file: function(args) {
-            var argstitle = args.title;
+            var argstitle = args.shorttitle ? args.shorttitle : args.title;
             // Limit the string length so it fits nicely on mobile devices
             var titlelength = 30;
             if (argstitle.length > titlelength) {
index a840ff5..6429f1c 100644 (file)
@@ -3267,7 +3267,7 @@ function repository_download_selected_files($context, string $component, string
     $filestoarchive = [];
 
     foreach ($files as $selectedfile) {
-        $filename = clean_filename($selectedfile->filename); // Default to '.' for root.
+        $filename = $selectedfile->filename ? clean_filename($selectedfile->filename) : '.'; // Default to '.' for root.
         $filepath = clean_param($selectedfile->filepath, PARAM_PATH); // Default to '/' for downloadall.
         $filepath = file_correct_filepath($filepath);
         $area = file_get_draft_area_info($itemid, $filepath);
@@ -3279,7 +3279,9 @@ function repository_download_selected_files($context, string $component, string
         // If it is empty we are downloading a directory.
         $archivefile = $storedfile->get_filename();
         if (!$filename || $filename == '.' ) {
-            $archivefile = $filepath;
+            $foldername = explode('/', trim($filepath, '/'));
+            $folder = trim(array_pop($foldername), '/');
+            $archivefile = $folder ?? '/';
         }
 
         $filestoarchive[$archivefile] = $storedfile;
index 7cbedae..d4ed751 100644 (file)
@@ -30,7 +30,7 @@ Feature: Delete files and folders from the file manager
     And I follow "Manage private files"
     And I click on "Display folder with file details" "link"
     And I set the field "Select file 'empty.txt'" to "1"
-    When I click on "Delete selected" "link"
+    When I click on "Delete" "link"
     Then I should see "Are you sure you want to delete the selected 1 file(s)?"
     When I click on "OK" "button" in the "Confirm" "dialogue"
     Then I should not see "empty.txt"
@@ -40,7 +40,7 @@ Feature: Delete files and folders from the file manager
     Then I should not see "empty.txt"
     But I should see "Delete me later"
     And I set the field "Select file 'Delete me later'" to "1"
-    And I click on "Delete selected" "link"
+    And I click on "Delete" "link"
     And I click on "OK" "button" in the "Confirm" "dialogue"
     Then I should not see "Delete me later"
     When I press "Save changes"
@@ -58,7 +58,7 @@ Feature: Delete files and folders from the file manager
     And I click on "Display folder with file details" "link"
     And I set the field "Select file 'empty.txt'" to "1"
     And I set the field "Select file 'Delete me'" to "1"
-    When I click on "Delete selected" "link"
+    When I click on "Delete" "link"
     Then I should see "Are you sure you want to delete the selected 2 file(s)?"
     When I click on "OK" "button" in the "Confirm" "dialogue"
     Then I should not see "Delete me"
@@ -84,7 +84,7 @@ Feature: Delete files and folders from the file manager
       | Select file 'empty.txt' | 1 |
       | Select file 'Delete me' | 1 |
       | Select file 'Delete me too' | 1 |
-    When I click on "Delete selected" "link"
+    When I click on "Delete" "link"
     Then I should see "Are you sure you want to delete the selected 3 file(s)?"
     When I click on "OK" "button" in the "Confirm" "dialogue"
     Then I should not see "Delete me"
index b4db9f2..a3ddda2 100644 (file)
@@ -60,3 +60,23 @@ p.arrow_button {
         }
     }
 }
+
+@mixin button-focus($background, $innershadow) {
+    &:focus,
+    &.focus {
+        outline: $btn-focus-width solid darken($background, 40%);
+        box-shadow: inset 0 0 0 2px $innershadow;
+    }
+}
+
+@each $color, $value in $theme-colors {
+    .btn-#{$color} {
+        @include button-focus($value, $white);
+    }
+}
+
+@each $color, $value in $theme-colors {
+    .btn-outline-#{$color} {
+        @include button-focus($value, $gray-800);
+    }
+}
\ No newline at end of file
index 1065a70..5d1f0bd 100644 (file)
@@ -138,6 +138,66 @@ a.dimmed_text:visited,
     color: $text-muted;
 }
 
+// Accessible focus styling for links, add .aalink to links with custom css classes to get
+// accessible focus styles.
+.aalink,
+#page-footer a:not([class]),
+.arrow_link,
+a:not([class]),
+.activityinstance > a {
+    &.focus,
+    &:focus {
+        outline: $btn-focus-width solid transparent;
+        color: $gray-900;
+        background-color: lighten($primary, 50%);
+        box-shadow: 0 -#{$btn-focus-width} lighten($primary, 50%), 0 $btn-focus-width $gray-800;
+    }
+    &:focus:hover {
+        text-decoration: none;
+    }
+}
+
+// Accessible focus styling for buttons like elements that do not use the .btn class. Add
+// .aabtn to you element if you need the same focus styles.
+.aabtn,
+.btn-link,
+.nav-link,
+.atto_link_button,
+[role="button"],
+.list-group-item-action,
+input[type="checkbox"],
+input[type="radio"],
+input[type="file"],
+input[type="image"],
+.sr-only-focusable,
+a.dropdown-item,
+a.dropdown-toggle,
+.modal-dialog[tabindex="0"],
+.moodle-dialogue-base .closebutton,
+button.close {
+    &.focus,
+    &:focus {
+        outline: 0;
+        box-shadow: $input-btn-focus-box-shadow;
+    }
+    &:focus:hover {
+        text-decoration: none;
+    }
+}
+
+.usermenu,
+div.dropdown-item {
+    a,
+    a[role="button"] {
+        outline: 0;
+        box-shadow: none;
+    }
+    &:focus-within {
+        outline: 0;
+        box-shadow: $input-btn-focus-box-shadow;
+    }
+}
+
 .unlist,
 .unlist li,
 .inline-list,
@@ -1956,6 +2016,7 @@ nav.navbar .logo img {
 
     // Do not remove these rules.
     overflow: hidden;
+    padding: 0.25rem;
 
     .page-header-image,
     .page-header-headings {
@@ -1967,6 +2028,9 @@ nav.navbar .logo img {
     .page-header-image {
         margin-right: 1em;
         margin-bottom: 1em;
+        & > a {
+            display: inline-block;
+        }
     }
 
     .page-header-headings,
@@ -1979,13 +2043,6 @@ nav.navbar .logo img {
     .header-button-group {
         display: block;
         float: left;
-
-        a {
-            position: relative;
-
-            // Don't touch it unless you know exactly what you are doing.
-            top: -0.4em;
-        }
     }
 }
 
@@ -2218,6 +2275,9 @@ $footer-link-color: $bg-inverse-link-color !default;
     .icon {
         color: $footer-link-color;
     }
+    &:focus .icon {
+        color: $body-color;
+    }
 }
 
 .bg-inverse a {
@@ -2402,6 +2462,12 @@ body.h5p-embed {
     height: 1.5rem;
 }
 
+.border-radius {
+    @if $enable-rounded {
+        @include border-radius($card-border-radius);
+    }
+}
+
 // Emoji picker.
 $picker-width: 350px !default;
 $picker-width-xs: 320px !default;
index 8a9297f..e548f33 100644 (file)
@@ -89,7 +89,7 @@ body:not(.editing)  .sitetopic ul.section {
             min-height: 2em;
 
             > a {
-                display: block;
+                display: inline-block;
                 text-indent: -31px;
                 padding-left: 31px;
             }
index c1f03d9..347004d 100644 (file)
 
 .fp-iconview .fp-filename-field {
     height: 33px;
+    margin-top: 3px;
     word-wrap: break-word;
     overflow: hidden;
     position: absolute;
@@ -736,10 +737,12 @@ a.ygtvspacer:hover {
 }
 
 .filemanager .fp-iconview .fp-folder.fp-hascontextmenu .fp-contextmenu {
-    display: block;
     position: absolute;
-    right: 7px;
-    bottom: 5px;
+    right: 0;
+    bottom: 0;
+    display: flex;
+    align-items: center;
+    justify-content: center;
 }
 
 .filemanager .fp-treeview .fp-folder.fp-hascontextmenu .fp-contextmenu,
index 0281eca..04d6f61 100644 (file)
             @include border-radius($border-radius);
         }
 
-        tr .cell {
+        tr .cell,
+        .floater .cell {
             background-color: $pagination-bg;
         }
 
             border-color: $table-border-color;
         }
 
-        tr:nth-of-type(even) .cell,
-        .floater .cell,
-        .avg {
+        .userrow.odd .cell,
+        .floater .cell.odd,
+        .heading .cell,
+        .cell.category,
+        .avg .cell {
             background-color: $gray-lightest;
         }
 
index f961a82..62f7862 100644 (file)
@@ -1059,15 +1059,6 @@ div#dock {
 .usermenu {
     .login {
         line-height: 2.25rem;
-
-        a {
-            color: $link-color;
-            &:hover,
-            &:focus {
-                color: $link-hover-color;
-                text-decoration: underline;
-            }
-        }
     }
 }
 
index 2f847c6..bfbbfe0 100644 (file)
 .user-enroller-panel {
     width: 600px;
 }
+
+[data-filterverbfor],
+[data-filterregion="filter"]:last-child [data-filterregion="joinadverb"] {
+    display: none;
+}
+
+[data-filterverb="0"] [data-filterverbfor="0"],
+[data-filterverb="1"] [data-filterverbfor="1"],
+[data-filterverb="2"] [data-filterverbfor="2"] {
+    display: block;
+}
index 01ef061..280f192 100644 (file)
@@ -68,6 +68,8 @@ $card-group-margin: .25rem;
 // Custom control size
 $custom-control-indicator-size: 1.25rem;
 
+$input-btn-focus-color: rgba($primary, .75) !default;
+
 // stylelint-disable
 $theme-colors: () !default;
 $theme-colors: map-merge((
index 9d14485..7f75b4c 100644 (file)
@@ -3798,7 +3798,7 @@ pre {
     background-color: #fff;
     border-color: #6eb5f3;
     outline: 0;
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
   .form-control::placeholder {
     color: #6c757d;
     opacity: 1; }
@@ -4154,7 +4154,7 @@ textarea.form-control {
     text-decoration: none; }
   .btn:focus, .btn.focus {
     outline: 0;
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
   .btn.disabled, .btn:disabled {
     opacity: 0.65; }
   .btn:not(:disabled):not(.disabled) {
@@ -5018,7 +5018,7 @@ input[type="button"].btn-block {
     border-color: #1177d1;
     background-color: #1177d1; }
   .custom-control-input:focus ~ .custom-control-label::before {
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
   .custom-control-input:focus:not(:checked) ~ .custom-control-label::before {
     border-color: #6eb5f3; }
   .custom-control-input:not(:disabled):active ~ .custom-control-label::before {
@@ -5124,7 +5124,7 @@ input[type="button"].btn-block {
   .custom-select:focus {
     border-color: #6eb5f3;
     outline: 0;
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
     .custom-select:focus::-ms-value {
       color: #495057;
       background-color: #fff; }
@@ -5174,7 +5174,7 @@ input[type="button"].btn-block {
   opacity: 0; }
   .custom-file-input:focus ~ .custom-file-label {
     border-color: #6eb5f3;
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
   .custom-file-input[disabled] ~ .custom-file-label,
   .custom-file-input:disabled ~ .custom-file-label {
     background-color: #e9ecef; }
@@ -5220,11 +5220,11 @@ input[type="button"].btn-block {
   .custom-range:focus {
     outline: none; }
     .custom-range:focus::-webkit-slider-thumb {
-      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
     .custom-range:focus::-moz-range-thumb {
-      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
     .custom-range:focus::-ms-thumb {
-      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
   .custom-range::-moz-focus-outer {
     border: 0; }
   .custom-range::-webkit-slider-thumb {
@@ -5809,7 +5809,7 @@ input[type="button"].btn-block {
   .page-link:focus {
     z-index: 3;
     outline: 0;
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
 
 .page-item:first-child .page-link {
   margin-left: 0; }
@@ -9742,6 +9742,91 @@ a.dimmed_text:visited,
 .dimmed_category a {
   color: #6c757d; }
 
+.aalink.focus, .aalink:focus,
+#page-footer a:not([class]).focus,
+#page-footer a:not([class]):focus,
+.arrow_link.focus,
+.arrow_link:focus,
+a:not([class]).focus,
+a:not([class]):focus,
+.activityinstance > a.focus,
+.activityinstance > a:focus {
+  outline: 0.2rem solid transparent;
+  color: #212529;
+  background-color: #e4f1fd;
+  box-shadow: 0 -0.2rem #e4f1fd, 0 0.2rem #343a40; }
+
+.aalink:focus:hover,
+#page-footer a:not([class]):focus:hover,
+.arrow_link:focus:hover,
+a:not([class]):focus:hover,
+.activityinstance > a:focus:hover {
+  text-decoration: none; }
+
+.aabtn.focus, .aabtn:focus,
+.btn-link.focus,
+.btn-link:focus,
+.nav-link.focus,
+.nav-link:focus,
+.atto_link_button.focus,
+.atto_link_button:focus,
+[role="button"].focus,
+[role="button"]:focus,
+.list-group-item-action.focus,
+.list-group-item-action:focus,
+input[type="checkbox"].focus,
+input[type="checkbox"]:focus,
+input[type="radio"].focus,
+input[type="radio"]:focus,
+input[type="file"].focus,
+input[type="file"]:focus,
+input[type="image"].focus,
+input[type="image"]:focus,
+.sr-only-focusable.focus,
+.sr-only-focusable:focus,
+a.dropdown-item.focus,
+a.dropdown-item:focus,
+a.dropdown-toggle.focus,
+a.dropdown-toggle:focus,
+.modal-dialog[tabindex="0"].focus,
+.modal-dialog[tabindex="0"]:focus,
+.moodle-dialogue-base .closebutton.focus,
+.moodle-dialogue-base .closebutton:focus,
+button.close.focus,
+button.close:focus {
+  outline: 0;
+  box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
+
+.aabtn:focus:hover,
+.btn-link:focus:hover,
+.nav-link:focus:hover,
+.atto_link_button:focus:hover,
+[role="button"]:focus:hover,
+.list-group-item-action:focus:hover,
+input[type="checkbox"]:focus:hover,
+input[type="radio"]:focus:hover,
+input[type="file"]:focus:hover,
+input[type="image"]:focus:hover,
+.sr-only-focusable:focus:hover,
+a.dropdown-item:focus:hover,
+a.dropdown-toggle:focus:hover,
+.modal-dialog[tabindex="0"]:focus:hover,
+.moodle-dialogue-base .closebutton:focus:hover,
+button.close:focus:hover {
+  text-decoration: none; }
+
+.usermenu a,
+.usermenu a[role="button"],
+div.dropdown-item a,
+div.dropdown-item a[role="button"] {
+  outline: 0;
+  box-shadow: none; }
+
+.usermenu:focus-within,
+div.dropdown-item:focus-within {
+  outline: 0;
+  box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
+
 .unlist,
 .unlist li,
 .inline-list,
@@ -11208,7 +11293,8 @@ nav.navbar .logo img {
 
 /** Header-bar styles **/
 .page-context-header {
-  overflow: hidden; }
+  overflow: hidden;
+  padding: 0.25rem; }
   .page-context-header .page-header-image,
   .page-context-header .page-header-headings {
     float: left;
@@ -11217,6 +11303,8 @@ nav.navbar .logo img {
   .page-context-header .page-header-image {
     margin-right: 1em;
     margin-bottom: 1em; }
+    .page-context-header .page-header-image > a {
+      display: inline-block; }
   .page-context-header .page-header-headings,
   .page-context-header .header-button-group {
     position: relative;
@@ -11225,9 +11313,6 @@ nav.navbar .logo img {
   .page-context-header .header-button-group {
     display: block;
     float: left; }
-    .page-context-header .header-button-group a {
-      position: relative;
-      top: -0.4em; }
 
 ul.dragdrop-keyboard-drag li {
   list-style-type: none; }
@@ -11400,6 +11485,8 @@ ul {
   text-decoration: underline; }
   #page-footer a .icon {
     color: #fff; }
+  #page-footer a:focus .icon {
+    color: #343a40; }
 
 .bg-inverse a {
   color: #fff;
@@ -13015,7 +13102,7 @@ body:not(.editing) .sitetopic ul.section {
     min-height: 2em; }
     .section .activity .contentwithoutlink > a,
     .section .activity .activityinstance > a {
-      display: block;
+      display: inline-block;
       text-indent: -31px;
       padding-left: 31px; }
     .section .activity .contentwithoutlink .dimmed .activityicon,
@@ -14102,6 +14189,7 @@ body.drawer-ease {
 
 .fp-iconview .fp-filename-field {
   height: 33px;
+  margin-top: 3px;
   word-wrap: break-word;
   overflow: hidden;
   position: absolute; }
@@ -14560,10 +14648,12 @@ a.ygtvspacer:hover {
   display: none; }
 
 .filemanager .fp-iconview .fp-folder.fp-hascontextmenu .fp-contextmenu {
-  display: block;
   position: absolute;
-  right: 7px;
-  bottom: 5px; }
+  right: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center; }
 
 .filemanager .fp-treeview .fp-folder.fp-hascontextmenu .fp-contextmenu,
 .filemanager .fp-tableview .fp-folder.fp-hascontextmenu .fp-contextmenu {
@@ -15952,6 +16042,15 @@ body.path-question-type .mform fieldset.hidden {
 .user-enroller-panel {
   width: 600px; }
 
+[data-filterverbfor],
+[data-filterregion="filter"]:last-child [data-filterregion="joinadverb"] {
+  display: none; }
+
+[data-filterverb="0"] [data-filterverbfor="0"],
+[data-filterverb="1"] [data-filterverbfor="1"],
+[data-filterverb="2"] [data-filterverbfor="2"] {
+  display: block; }
+
 .search-results .result {
   margin-left: 0;
   margin-right: 0; }
@@ -17145,11 +17244,6 @@ div#dock {
 
 .usermenu .login {
   line-height: 2.25rem; }
-  .usermenu .login a {
-    color: #1177d1; }
-    .usermenu .login a:hover, .usermenu .login a:focus {
-      color: #0b4f8a;
-      text-decoration: underline; }
 
 .userloggedinas .usermenu .usertext,
 .userswitchedrole .usermenu .usertext,
@@ -17780,6 +17874,70 @@ p.arrow_button {
     line-height: 48px !important;
     /* stylelint-disable-line declaration-no-important */ }
 
+.btn-primary:focus, .btn-primary.focus {
+  outline: 0.2rem solid #020c14;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-secondary:focus, .btn-secondary.focus {
+  outline: 0.2rem solid #5f6e7d;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-success:focus, .btn-success.focus {
+  outline: 0.2rem solid black;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-info:focus, .btn-info.focus {
+  outline: 0.2rem solid #124a5b;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-warning:focus, .btn-warning.focus {
+  outline: 0.2rem solid #694109;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-danger:focus, .btn-danger.focus {
+  outline: 0.2rem solid #360d0c;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-light:focus, .btn-light.focus {
+  outline: 0.2rem solid #8193a5;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-dark:focus, .btn-dark.focus {
+  outline: 0.2rem solid black;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-outline-primary:focus, .btn-outline-primary.focus {
+  outline: 0.2rem solid #020c14;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-secondary:focus, .btn-outline-secondary.focus {
+  outline: 0.2rem solid #5f6e7d;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-success:focus, .btn-outline-success.focus {
+  outline: 0.2rem solid black;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-info:focus, .btn-outline-info.focus {
+  outline: 0.2rem solid #124a5b;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-warning:focus, .btn-outline-warning.focus {
+  outline: 0.2rem solid #694109;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-danger:focus, .btn-outline-danger.focus {
+  outline: 0.2rem solid #360d0c;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-light:focus, .btn-outline-light.focus {
+  outline: 0.2rem solid #8193a5;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-dark:focus, .btn-outline-dark.focus {
+  outline: 0.2rem solid black;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
 .gradetreebox h4 {
   font-size: 0.9375rem; }
 
@@ -18018,16 +18176,19 @@ p.arrow_button {
 /**
  * Grader report.
  */
-.path-grade-report-grader .gradeparent tr .cell {
+.path-grade-report-grader .gradeparent tr .cell,
+.path-grade-report-grader .gradeparent .floater .cell {
   background-color: #fff; }
 
 .path-grade-report-grader .gradeparent table,
 .path-grade-report-grader .gradeparent .cell {
   border-color: #dee2e6; }
 
-.path-grade-report-grader .gradeparent tr:nth-of-type(even) .cell,
-.path-grade-report-grader .gradeparent .floater .cell,
-.path-grade-report-grader .gradeparent .avg {
+.path-grade-report-grader .gradeparent .userrow.odd .cell,
+.path-grade-report-grader .gradeparent .floater .cell.odd,
+.path-grade-report-grader .gradeparent .heading .cell,
+.path-grade-report-grader .gradeparent .cell.category,
+.path-grade-report-grader .gradeparent .avg .cell {
   background-color: #f8f9fa; }
 
 .path-grade-report-grader .gradeparent table .clickable {
index e3067d9..c93e68f 100644 (file)
@@ -23,7 +23,7 @@
             <button aria-expanded="{{#navdraweropen}}true{{/navdraweropen}}{{^navdraweropen}}false{{/navdraweropen}}" aria-controls="nav-drawer" type="button" class="btn nav-link float-sm-left mr-1 btn-light bg-gray" data-action="toggle-drawer" data-side="left" data-preference="drawer-open-nav">{{#pix}}i/menubars{{/pix}}<span class="sr-only">{{#str}}sidepanel, core{{/str}}</span></button>
         </div>
 
-        <a href="{{{ config.wwwroot }}}" class="navbar-brand {{# output.should_display_navbar_logo }}has-logo{{/ output.should_display_navbar_logo }}
+        <a href="{{{ config.wwwroot }}}" class="navbar-brand aabtn {{# output.should_display_navbar_logo }}has-logo{{/ output.should_display_navbar_logo }}
             {{^ output.should_display_navbar_logo }}
                 d-none d-sm-inline
             {{/ output.should_display_navbar_logo }}
index 858f81d..c5f2517 100644 (file)
@@ -62,6 +62,8 @@ $alert-border-width:                0 !default;
 
 $card-group-margin: .25rem;
 
+$input-btn-focus-color: rgba($primary, .75) !default;
+
 // stylelint-disable
 $theme-colors: () !default;
 $theme-colors: map-merge((
index e131ec7..d89bccb 100644 (file)
@@ -3800,7 +3800,7 @@ pre {
     background-color: #fff;
     border-color: #6eb5f3;
     outline: 0;
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
   .form-control::placeholder {
     color: #6c757d;
     opacity: 1; }
@@ -4160,7 +4160,7 @@ textarea.form-control {
     text-decoration: none; }
   .btn:focus, .btn.focus {
     outline: 0;
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
   .btn.disabled, .btn:disabled {
     opacity: 0.65; }
   .btn:not(:disabled):not(.disabled) {
@@ -5079,7 +5079,7 @@ input[type="button"].btn-block {
     border-color: #1177d1;
     background-color: #1177d1; }
   .custom-control-input:focus ~ .custom-control-label::before {
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
   .custom-control-input:focus:not(:checked) ~ .custom-control-label::before {
     border-color: #6eb5f3; }
   .custom-control-input:not(:disabled):active ~ .custom-control-label::before {
@@ -5188,7 +5188,7 @@ input[type="button"].btn-block {
   .custom-select:focus {
     border-color: #6eb5f3;
     outline: 0;
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
     .custom-select:focus::-ms-value {
       color: #495057;
       background-color: #fff; }
@@ -5238,7 +5238,7 @@ input[type="button"].btn-block {
   opacity: 0; }
   .custom-file-input:focus ~ .custom-file-label {
     border-color: #6eb5f3;
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
   .custom-file-input[disabled] ~ .custom-file-label,
   .custom-file-input:disabled ~ .custom-file-label {
     background-color: #e9ecef; }
@@ -5286,11 +5286,11 @@ input[type="button"].btn-block {
   .custom-range:focus {
     outline: none; }
     .custom-range:focus::-webkit-slider-thumb {
-      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
     .custom-range:focus::-moz-range-thumb {
-      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
     .custom-range:focus::-ms-thumb {
-      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+      box-shadow: 0 0 0 1px #fff, 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
   .custom-range::-moz-focus-outer {
     border: 0; }
   .custom-range::-webkit-slider-thumb {
@@ -5935,7 +5935,7 @@ input[type="button"].btn-block {
   .page-link:focus {
     z-index: 3;
     outline: 0;
-    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.25); }
+    box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
 
 .page-item:first-child .page-link {
   margin-left: 0;
@@ -9947,6 +9947,91 @@ a.dimmed_text:visited,
 .dimmed_category a {
   color: #6c757d; }
 
+.aalink.focus, .aalink:focus,
+#page-footer a:not([class]).focus,
+#page-footer a:not([class]):focus,
+.arrow_link.focus,
+.arrow_link:focus,
+a:not([class]).focus,
+a:not([class]):focus,
+.activityinstance > a.focus,
+.activityinstance > a:focus {
+  outline: 0.2rem solid transparent;
+  color: #212529;
+  background-color: #e4f1fd;
+  box-shadow: 0 -0.2rem #e4f1fd, 0 0.2rem #343a40; }
+
+.aalink:focus:hover,
+#page-footer a:not([class]):focus:hover,
+.arrow_link:focus:hover,
+a:not([class]):focus:hover,
+.activityinstance > a:focus:hover {
+  text-decoration: none; }
+
+.aabtn.focus, .aabtn:focus,
+.btn-link.focus,
+.btn-link:focus,
+.nav-link.focus,
+.nav-link:focus,
+.atto_link_button.focus,
+.atto_link_button:focus,
+[role="button"].focus,
+[role="button"]:focus,
+.list-group-item-action.focus,
+.list-group-item-action:focus,
+input[type="checkbox"].focus,
+input[type="checkbox"]:focus,
+input[type="radio"].focus,
+input[type="radio"]:focus,
+input[type="file"].focus,
+input[type="file"]:focus,
+input[type="image"].focus,
+input[type="image"]:focus,
+.sr-only-focusable.focus,
+.sr-only-focusable:focus,
+a.dropdown-item.focus,
+a.dropdown-item:focus,
+a.dropdown-toggle.focus,
+a.dropdown-toggle:focus,
+.modal-dialog[tabindex="0"].focus,
+.modal-dialog[tabindex="0"]:focus,
+.moodle-dialogue-base .closebutton.focus,
+.moodle-dialogue-base .closebutton:focus,
+button.close.focus,
+button.close:focus {
+  outline: 0;
+  box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
+
+.aabtn:focus:hover,
+.btn-link:focus:hover,
+.nav-link:focus:hover,
+.atto_link_button:focus:hover,
+[role="button"]:focus:hover,
+.list-group-item-action:focus:hover,
+input[type="checkbox"]:focus:hover,
+input[type="radio"]:focus:hover,
+input[type="file"]:focus:hover,
+input[type="image"]:focus:hover,
+.sr-only-focusable:focus:hover,
+a.dropdown-item:focus:hover,
+a.dropdown-toggle:focus:hover,
+.modal-dialog[tabindex="0"]:focus:hover,
+.moodle-dialogue-base .closebutton:focus:hover,
+button.close:focus:hover {
+  text-decoration: none; }
+
+.usermenu a,
+.usermenu a[role="button"],
+div.dropdown-item a,
+div.dropdown-item a[role="button"] {
+  outline: 0;
+  box-shadow: none; }
+
+.usermenu:focus-within,
+div.dropdown-item:focus-within {
+  outline: 0;
+  box-shadow: 0 0 0 0.2rem rgba(17, 119, 209, 0.75); }
+
 .unlist,
 .unlist li,
 .inline-list,
@@ -11419,7 +11504,8 @@ nav.navbar .logo img {
 
 /** Header-bar styles **/
 .page-context-header {
-  overflow: hidden; }
+  overflow: hidden;
+  padding: 0.25rem; }
   .page-context-header .page-header-image,
   .page-context-header .page-header-headings {
     float: left;
@@ -11428,6 +11514,8 @@ nav.navbar .logo img {
   .page-context-header .page-header-image {
     margin-right: 1em;
     margin-bottom: 1em; }
+    .page-context-header .page-header-image > a {
+      display: inline-block; }
   .page-context-header .page-header-headings,
   .page-context-header .header-button-group {
     position: relative;
@@ -11436,9 +11524,6 @@ nav.navbar .logo img {
   .page-context-header .header-button-group {
     display: block;
     float: left; }
-    .page-context-header .header-button-group a {
-      position: relative;
-      top: -0.4em; }
 
 ul.dragdrop-keyboard-drag li {
   list-style-type: none; }
@@ -11611,6 +11696,8 @@ ul {
   text-decoration: underline; }
   #page-footer a .icon {
     color: #fff; }
+  #page-footer a:focus .icon {
+    color: #343a40; }
 
 .bg-inverse a {
   color: #fff;
@@ -11772,6 +11859,9 @@ body.h5p-embed .h5pmessages {
   color: #343a40;
   height: 1.5rem; }
 
+.border-radius {
+  border-radius: 0.25rem; }
+
 .emoji-picker {
   width: 350px;
   height: 400px; }
@@ -13227,7 +13317,7 @@ body:not(.editing) .sitetopic ul.section {
     min-height: 2em; }
     .section .activity .contentwithoutlink > a,
     .section .activity .activityinstance > a {
-      display: block;
+      display: inline-block;
       text-indent: -31px;
       padding-left: 31px; }
     .section .activity .contentwithoutlink .dimmed .activityicon,
@@ -14317,6 +14407,7 @@ body.drawer-ease {
 
 .fp-iconview .fp-filename-field {
   height: 33px;
+  margin-top: 3px;
   word-wrap: break-word;
   overflow: hidden;
   position: absolute; }
@@ -14775,10 +14866,12 @@ a.ygtvspacer:hover {
   display: none; }
 
 .filemanager .fp-iconview .fp-folder.fp-hascontextmenu .fp-contextmenu {
-  display: block;
   position: absolute;
-  right: 7px;
-  bottom: 5px; }
+  right: 0;
+  bottom: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center; }
 
 .filemanager .fp-treeview .fp-folder.fp-hascontextmenu .fp-contextmenu,
 .filemanager .fp-tableview .fp-folder.fp-hascontextmenu .fp-contextmenu {
@@ -16176,6 +16269,15 @@ body.path-question-type .mform fieldset.hidden {
 .user-enroller-panel {
   width: 600px; }
 
+[data-filterverbfor],
+[data-filterregion="filter"]:last-child [data-filterregion="joinadverb"] {
+  display: none; }
+
+[data-filterverb="0"] [data-filterverbfor="0"],
+[data-filterverb="1"] [data-filterverbfor="1"],
+[data-filterverb="2"] [data-filterverbfor="2"] {
+  display: block; }
+
 .search-results .result {
   margin-left: 0;
   margin-right: 0; }
@@ -17371,11 +17473,6 @@ div#dock {
 
 .usermenu .login {
   line-height: 2.25rem; }
-  .usermenu .login a {
-    color: #1177d1; }
-    .usermenu .login a:hover, .usermenu .login a:focus {
-      color: #0b4f8a;
-      text-decoration: underline; }
 
 .userloggedinas .usermenu .usertext,
 .userswitchedrole .usermenu .usertext,
@@ -18011,6 +18108,70 @@ p.arrow_button {
     line-height: 48px !important;
     /* stylelint-disable-line declaration-no-important */ }
 
+.btn-primary:focus, .btn-primary.focus {
+  outline: 0.2rem solid #020c14;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-secondary:focus, .btn-secondary.focus {
+  outline: 0.2rem solid #5f6e7d;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-success:focus, .btn-success.focus {
+  outline: 0.2rem solid black;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-info:focus, .btn-info.focus {
+  outline: 0.2rem solid #124a5b;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-warning:focus, .btn-warning.focus {
+  outline: 0.2rem solid #694109;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-danger:focus, .btn-danger.focus {
+  outline: 0.2rem solid #360d0c;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-light:focus, .btn-light.focus {
+  outline: 0.2rem solid #8193a5;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-dark:focus, .btn-dark.focus {
+  outline: 0.2rem solid black;
+  box-shadow: inset 0 0 0 2px #fff; }
+
+.btn-outline-primary:focus, .btn-outline-primary.focus {
+  outline: 0.2rem solid #020c14;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-secondary:focus, .btn-outline-secondary.focus {
+  outline: 0.2rem solid #5f6e7d;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-success:focus, .btn-outline-success.focus {
+  outline: 0.2rem solid black;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-info:focus, .btn-outline-info.focus {
+  outline: 0.2rem solid #124a5b;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-warning:focus, .btn-outline-warning.focus {
+  outline: 0.2rem solid #694109;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-danger:focus, .btn-outline-danger.focus {
+  outline: 0.2rem solid #360d0c;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-light:focus, .btn-outline-light.focus {
+  outline: 0.2rem solid #8193a5;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
+.btn-outline-dark:focus, .btn-outline-dark.focus {
+  outline: 0.2rem solid black;
+  box-shadow: inset 0 0 0 2px #343a40; }
+
 .gradetreebox h4 {
   font-size: 0.9375rem; }
 
@@ -18252,16 +18413,19 @@ p.arrow_button {
 .path-grade-report-grader .gradeparent table {
   border-radius: 0.25rem; }
 
-.path-grade-report-grader .gradeparent tr .cell {
+.path-grade-report-grader .gradeparent tr .cell,
+.path-grade-report-grader .gradeparent .floater .cell {
   background-color: #fff; }
 
 .path-grade-report-grader .gradeparent table,
 .path-grade-report-grader .gradeparent .cell {
   border-color: #dee2e6; }
 
-.path-grade-report-grader .gradeparent tr:nth-of-type(even) .cell,
-.path-grade-report-grader .gradeparent .floater .cell,
-.path-grade-report-grader .gradeparent .avg {
+.path-grade-report-grader .gradeparent .userrow.odd .cell,
+.path-grade-report-grader .gradeparent .floater .cell.odd,
+.path-grade-report-grader .gradeparent .heading .cell,
+.path-grade-report-grader .gradeparent .cell.category,
+.path-grade-report-grader .gradeparent .avg .cell {
   background-color: #f8f9fa; }
 
 .path-grade-report-grader .gradeparent table .clickable {
index c5cd4ab..5966aab 100644 (file)
@@ -38,7 +38,7 @@
 }}
 <nav class="fixed-top navbar navbar-bootswatch navbar-expand moodle-has-zindex">
 
-        <a href="{{{ config.wwwroot }}}" class="navbar-brand {{# output.should_display_navbar_logo }}has-logo{{/ output.should_display_navbar_logo }}
+        <a href="{{{ config.wwwroot }}}" class="navbar-brand aalink {{# output.should_display_navbar_logo }}has-logo{{/ output.should_display_navbar_logo }}
             {{^ output.should_display_navbar_logo }}
                 d-none d-sm-inline
             {{/ output.should_display_navbar_logo }}
diff --git a/user/amd/build/local/participantsfilter/filter.min.js b/user/amd/build/local/participantsfilter/filter.min.js
new file mode 100644 (file)
index 0000000..3ef1d57
Binary files /dev/null and b/user/amd/build/local/participantsfilter/filter.min.js differ
diff --git a/user/amd/build/local/participantsfilter/filter.min.js.map b/user/amd/build/local/participantsfilter/filter.min.js.map
new file mode 100644 (file)
index 0000000..e694bbf
Binary files /dev/null and b/user/amd/build/local/participantsfilter/filter.min.js.map differ
diff --git a/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js b/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js
new file mode 100644 (file)
index 0000000..61445dc
Binary files /dev/null and b/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js differ
diff --git a/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js.map b/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js.map
new file mode 100644 (file)
index 0000000..273a359
Binary files /dev/null and b/user/amd/build/local/participantsfilter/filtertypes/courseid.min.js.map differ
diff --git a/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js b/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js
new file mode 100644 (file)
index 0000000..4e178f8
Binary files /dev/null and b/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js differ
diff --git a/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js.map b/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js.map
new file mode 100644 (file)
index 0000000..0c000b7
Binary files /dev/null and b/user/amd/build/local/participantsfilter/filtertypes/keyword.min.js.map differ
diff --git a/user/amd/build/local/participantsfilter/selectors.min.js b/user/amd/build/local/participantsfilter/selectors.min.js
new file mode 100644 (file)
index 0000000..21be665
Binary files /dev/null and b/user/amd/build/local/participantsfilter/selectors.min.js differ
diff --git a/user/amd/build/local/participantsfilter/selectors.min.js.map b/user/amd/build/local/participantsfilter/selectors.min.js.map
new file mode 100644 (file)
index 0000000..023c1a6
Binary files /dev/null and b/user/amd/build/local/participantsfilter/selectors.min.js.map differ
diff --git a/user/amd/build/participantsfilter.min.js b/user/amd/build/participantsfilter.min.js
new file mode 100644 (file)
index 0000000..bbb20c6
Binary files /dev/null and b/user/amd/build/participantsfilter.min.js differ
diff --git a/user/amd/build/participantsfilter.min.js.map b/user/amd/build/participantsfilter.min.js.map
new file mode 100644 (file)
index 0000000..214f7dc
Binary files /dev/null and b/user/amd/build/participantsfilter.min.js.map differ
diff --git a/user/amd/src/local/participantsfilter/filter.js b/user/amd/src/local/participantsfilter/filter.js
new file mode 100644 (file)
index 0000000..7895ca0
--- /dev/null
@@ -0,0 +1,205 @@
+// 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/>.
+
+/**
+ * Base Filter class for a filter type in the participants filter UI.
+ *
+ * @module     core_user/local/participantsfilter/filter
+ * @package    core_user
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import Autocomplete from 'core/form-autocomplete';
+import Selectors from './selectors';
+import {get_string as getString} from 'core/str';
+
+/**
+ * Fetch all checked options in the select.
+ *
+ * This is a poor-man's polyfill for select.selectedOptions, which is not available in IE11.
+ *
+ * @param {HTMLSelectElement} select
+ * @returns {HTMLOptionElement[]} All selected options
+ */
+const getOptionsForSelect = select => {
+    return select.querySelectorAll(':checked');
+};
+
+export default class {
+
+    /**
+     * Constructor for a new filter.
+     *
+     * @param {String} filterType The type of filter that this relates to
+     * @param {HTMLElement} rootNode The root node for the participants filterset
+     */
+    constructor(filterType, rootNode) {
+        this.filterType = filterType;
+        this.rootNode = rootNode;
+
+        this.addValueSelector();
+    }
+
+    /**
+     * Perform any tear-down for this filter type.
+     */
+    tearDown() {
+        // eslint-disable-line no-empty-function
+    }
+
+    /**
+     * Get the placeholder to use when showing the value selector.
+     *
+     * @return {Promise} Resolving to a String
+     */
+    get placeholder() {
+        return getString('placeholdertypeorselect', 'core_user');
+    }
+
+    /**
+     * Whether to show suggestions in the autocomplete.
+     *
+     * @return {Boolean}
+     */
+    get showSuggestions() {
+        return true;
+    }
+
+    /**
+     * Add the value selector to the filter row.
+     */
+    async addValueSelector() {
+        const filterValueNode = this.getFilterValueNode();
+
+        // Copy the data in place.
+        filterValueNode.innerHTML = this.getSourceDataForFilter().outerHTML;
+
+        const dataSource = filterValueNode.querySelector('select');
+
+        Autocomplete.enhance(
+            // The source select element.
+            dataSource,
+
+            // Whether to allow 'tags' (custom entries).
+            dataSource.dataset.allowCustom == "1",
+
+            // We do not require AJAX at all as standard.
+            null,
+
+            // The string to use as a placeholder.
+            await this.placeholder,
+
+            // Disable case sensitivity on searches.
+            false,
+
+            // Show suggestions.
+            this.showSuggestions,
+
+            // Do not override the 'no suggestions' string.
+            null,
+
+            // Close the suggestions if this is not a multi-select.
+            !dataSource.multiple,
+
+            // Template overrides.
+            {
+                items: 'core_user/local/participantsfilter/autocomplete_selection_items',
+                layout: 'core_user/local/participantsfilter/autocomplete_layout',
+                selection: 'core_user/local/participantsfilter/autocomplete_selection',
+            }
+        );
+    }
+
+    /**
+     * Get the root node for this filter.
+     *
+     * @returns {HTMLElement}
+     */
+    get filterRoot() {
+        return this.rootNode.querySelector(Selectors.filter.byName(this.filterType));
+    }
+
+    /**
+     * Get the possible data for this filter type.
+     *
+     * @returns {Array}
+     */
+    getSourceDataForFilter() {
+        const filterDataNode = this.rootNode.querySelector(Selectors.filterset.regions.datasource);
+
+        return filterDataNode.querySelector(Selectors.data.fields.byName(this.filterType));
+    }
+
+    /**
+     * Get the HTMLElement which contains the value selector.
+     *
+     * @returns {HTMLElement}
+     */
+    getFilterValueNode() {
+        return this.filterRoot.querySelector(Selectors.filter.regions.values);
+    }
+
+    /**
+     * Get the name of this filter.
+     *
+     * @returns {String}
+     */
+    get name() {
+        return this.filterType;
+    }
+
+    /**
+     * Get the type of join specified.
+     *
+     * @returns {Number}
+     */
+    get jointype() {
+        return this.filterRoot.querySelector(Selectors.filter.fields.join).value;
+    }
+
+    /**
+     * Get the list of raw values for this filter type.
+     *
+     * @returns {Array}
+     */
+    get rawValues() {
+        const filterValueNode = this.getFilterValueNode();
+        const filterValueSelect = filterValueNode.querySelector('select');
+
+        return Object.values(getOptionsForSelect(filterValueSelect)).map(option => option.value);
+    }
+
+    /**
+     * Get the list of values for this filter type.
+     *
+     * @returns {Array}
+     */
+    get values() {
+        return this.rawValues.map(option => parseInt(option, 10));
+    }
+
+    /**
+     * Get the composed value for this filter.
+     *
+     * @returns {Object}
+     */
+    get filterValue() {
+        return {
+            name: this.name,
+            jointype: this.jointype,
+            values: this.values,
+        };
+    }
+}
diff --git a/user/amd/src/local/participantsfilter/filtertypes/courseid.js b/user/amd/src/local/participantsfilter/filtertypes/courseid.js
new file mode 100644 (file)
index 0000000..4969884
--- /dev/null
@@ -0,0 +1,47 @@
+// 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/>.
+
+/**
+ * Course ID filter.
+ *
+ * @module     core_user/local/participantsfilter/filtertypes/courseid
+ * @package    core_user
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import Filter from '../filter';
+
+export default class extends Filter {
+    constructor(filterType, filterSet) {
+        super(filterType, filterSet);
+    }
+
+    async addValueSelector() {
+        // eslint-disable-line no-empty-function
+    }
+
+    /**
+     * Get the composed value for this filter.
+     *
+     * @returns {Object}
+     */
+    get filterValue() {
+        return {
+            name: this.name,
+            jointype: 1,
+            values: [parseInt(this.rootNode.dataset.tableCourseId, 10)],
+        };
+    }
+}
diff --git a/user/amd/src/local/participantsfilter/filtertypes/keyword.js b/user/amd/src/local/participantsfilter/filtertypes/keyword.js
new file mode 100644 (file)
index 0000000..c7b7872
--- /dev/null
@@ -0,0 +1,58 @@
+// 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/>.
+
+/**
+ * Keyword filter.
+ *
+ * @module     core_user/local/participantsfilter/filtertypes/keyword
+ * @package    core_user
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import Filter from '../filter';
+import {get_string as getString} from 'core/str';
+
+export default class extends Filter {
+    constructor(filterType, filterSet) {
+        super(filterType, filterSet);
+    }
+
+    /**
+     * For keywords the final value is an Array of strings.
+     *
+     * @returns {Object}
+     */
+    get values() {
+        return this.rawValues;
+    }
+
+    /**
+     * Get the placeholder to use when showing the value selector.
+     *
+     * @return {Promise} Resolving to a String
+     */
+    get placeholder() {
+        return getString('placeholdertype', 'core_user');
+    }
+
+    /**
+     * Whether to show suggestions in the autocomplete.
+     *
+     * @return {Boolean}
+     */
+    get showSuggestions() {
+        return false;
+    }
+}
diff --git a/user/amd/src/local/participantsfilter/selectors.js b/user/amd/src/local/participantsfilter/selectors.js
new file mode 100644 (file)
index 0000000..3d0ef29
--- /dev/null
@@ -0,0 +1,67 @@
+// 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/>.
+
+/**
+ * Module containing the selectors for user filters.
+ *
+ * @module     core_user/local/user_filter/selectors
+ * @package    core_user
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+const getFilterRegion = region => `[data-filterregion="${region}"]`;
+const getFilterAction = action => `[data-filteraction="${action}"]`;
+const getFilterField = field => `[data-filterfield="${field}"]`;
+
+export default {
+    filter: {
+        region: getFilterRegion('filter'),
+        actions: {
+            remove: getFilterAction('remove'),
+        },
+        fields: {
+            join: getFilterField('join'),
+            type: getFilterField('type'),
+        },
+        regions: {
+            values: getFilterRegion('value'),
+        },
+        byName: name => `${getFilterRegion('filter')}[data-filter-type="${name}"]`,
+    },
+    filterset: {
+        region: getFilterRegion('actions'),
+        actions: {
+            addRow: getFilterAction('add'),
+            applyFilters: getFilterAction('apply'),
+            resetFilters: getFilterAction('reset'),
+        },
+        regions: {
+            filtermatch: getFilterRegion('filtermatch'),
+            filterlist: getFilterRegion('filters'),
+            datasource: getFilterRegion('filtertypedata'),
+        },
+        fields: {
+            join: `${getFilterRegion('filtermatch')} ${getFilterField('join')}`,
+        },
+    },
+    data: {
+        fields: {
+            byName: name => `[data-field-name="${name}"]`,
+            all: `${getFilterRegion('filtertypedata')} [data-field-name]`,
+        },
+        typeList: getFilterRegion('filtertypelist'),
+    },
+};
diff --git a/user/amd/src/participantsfilter.js b/user/amd/src/participantsfilter.js
new file mode 100644 (file)
index 0000000..3dcdb88
--- /dev/null
@@ -0,0 +1,348 @@
+// 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/>.
+
+/**
+ * Participants filter managemnet.
+ *
+ * @module     core_user/participants_filter
+ * @package    core_user
+ * @copyright  2020 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import CourseFilter from './local/participantsfilter/filtertypes/courseid';
+import * as DynamicTable from 'core_table/dynamic';
+import GenericFilter from './local/participantsfilter/filter';
+import Notification from 'core/notification';
+import Selectors from './local/participantsfilter/selectors';
+import Templates from 'core/templates';
+
+/**
+ * Initialise the participants filter on the element with the given id.
+ *
+ * @param {String} participantsRegionId
+ */
+export const init = participantsRegionId => {
+    // Keep a reference to the filterset.
+    const filterSet = document.querySelector(`#${participantsRegionId}`);
+
+    // Keep a reference to all of the active filters.
+    const activeFilters = {
+        courseid: new CourseFilter('courseid', filterSet),
+    };
+
+    /**
+     * Get the filter list region.
+     *
+     * @return {HTMLElement}
+     */
+    const getFilterRegion = () => filterSet.querySelector(Selectors.filterset.regions.filterlist);
+
+    /**
+     * Add an unselected filter row.
+     *
+     * @return {Promise}
+     */
+    const addFilterRow = () => {
+        return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})
+        .then(({html, js}) => {
+            const newContentNodes = Templates.appendNodeContents(getFilterRegion(), html, js);
+
+            return newContentNodes;
+        })
+        .then(filterRow => {
+            // Note: This is a nasty hack.
+            // We should try to find a better way of doing this.
+            // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
+            // it in place.
+            const typeList = filterSet.querySelector(Selectors.data.typeList);
+
+            filterRow.forEach(contentNode => {
+                const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
+
+                if (contentTypeList) {
+                    contentTypeList.innerHTML = typeList.innerHTML;
+                }
+            });
+
+            return filterRow;
+        })
+        .then(filterRow => {
+            updateFiltersOptions();
+
+            return filterRow;
+        })
+        .catch(Notification.exception);
+    };
+
+    /**
+     * Get the filter data source node fro the specified filter type.
+     *
+     * @param {String} filterType
+     * @return {HTMLElement}
+     */
+    const getFilterDataSource = filterType => {
+        const filterDataNode = filterSet.querySelector(Selectors.filterset.regions.datasource);
+
+        return filterDataNode.querySelector(Selectors.data.fields.byName(filterType));
+    };
+
+    /**
+     * Add a filter to the list of active filters, performing any necessary setup.
+     *
+     * @param {HTMLElement} filterRow
+     * @param {String} filterType
+     */
+    const addFilter = async(filterRow, filterType) => {
+        // Name the filter on the filter row.
+        filterRow.dataset.filterType = filterType;
+
+        const filterDataNode = getFilterDataSource(filterType);
+
+        // Instantiate the Filter class.
+        let Filter = GenericFilter;
+        if (filterDataNode.dataset.filterTypeClass) {
+            Filter = await import(filterDataNode.dataset.filterTypeClass);
+        }
+        activeFilters[filterType] = new Filter(filterType, filterSet);
+
+        // Disable the select.
+        const typeField = filterRow.querySelector(Selectors.filter.fields.type);
+        typeField.disabled = 'disabled';
+
+        // Update the list of available filter types.
+        updateFiltersOptions();
+    };
+
+    /**
+     * Get the registered filter class for the named filter.
+     *
+     * @param {String} name
+     * @return {Object} See the Filter class.
+     */
+    const getFilterObject = name => {
+        return activeFilters[name];
+    };
+
+    /**
+     * Remove or replace the specified filter row and associated class, ensuring that if there is only one filter row,
+     * that it is replaced instead of being removed.
+     *
+     * @param {HTMLElement} filterRow
+     */
+    const removeOrReplaceFilterRow = filterRow => {
+        const filterCount = getFilterRegion().querySelectorAll(Selectors.filter.region).length;
+
+        if (filterCount === 1) {
+            replaceFilterRow(filterRow);
+        } else {
+            removeFilterRow(filterRow);
+        }
+    };
+
+    /**
+     * Remove the specified filter row and associated class.
+     *
+     * @param {HTMLElement} filterRow
+     */
+    const removeFilterRow = filterRow => {
+        // Remove the filter object.
+        removeFilterObject(filterRow.dataset.filterType);
+
+        // Remove the actual filter HTML.
+        filterRow.remove();
+
+        // Refresh the table.
+        updateTableFromFilter();
+
+        // Update the list of available filter types.
+        updateFiltersOptions();
+    };
+
+    /**
+     * Replace the specified filter row with a new one.
+     *
+     * @param {HTMLElement} filterRow
+     * @return {Promise}
+     */
+    const replaceFilterRow = filterRow => {
+        // Remove the filter object.
+        removeFilterObject(filterRow.dataset.filterType);
+
+        return Templates.renderForPromise('core_user/local/participantsfilter/filterrow', {})
+        .then(({html, js}) => {
+            const newContentNodes = Templates.replaceNode(filterRow, html, js);
+
+            return newContentNodes;
+        })
+        .then(filterRow => {
+            // Note: This is a nasty hack.
+            // We should try to find a better way of doing this.
+            // We do not have the list of types in a readily consumable format, so we take the pre-rendered one and copy
+            // it in place.
+            const typeList = filterSet.querySelector(Selectors.data.typeList);
+
+            filterRow.forEach(contentNode => {
+                const contentTypeList = contentNode.querySelector(Selectors.filter.fields.type);
+
+                if (contentTypeList) {
+                    contentTypeList.innerHTML = typeList.innerHTML;
+                }
+            });
+
+            return filterRow;
+        })
+        .then(filterRow => {
+            updateFiltersOptions();
+
+            return filterRow;
+        })
+        .then(filterRow => {
+            // Refresh the table.
+            updateTableFromFilter();
+
+            return filterRow;
+        })
+        .catch(Notification.exception);
+    };
+
+    /**
+     * Remove the Filter Object from the register.
+     *
+     * @param {string} filterName The name of the filter to be removed
+     */
+    const removeFilterObject = filterName => {
+        if (filterName) {
+            const filter = getFilterObject(filterName);
+            if (filter) {
+                filter.tearDown();
+
+                // Remove from the list of active filters.
+                delete activeFilters[filterName];
+            }
+        }
+    };
+
+    /**
+     * Remove all filters.
+     */
+    const removeAllFilters = async() => {
+        const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
+        filters.forEach((filterRow) => {
+            removeOrReplaceFilterRow(filterRow);
+        });
+
+        // Refresh the table.
+        updateTableFromFilter();
+    };
+
+    /**
+     * Update the list of filter types to filter out those already selected.
+     */
+    const updateFiltersOptions = () => {
+        const filters = getFilterRegion().querySelectorAll(Selectors.filter.region);
+        filters.forEach(filterRow => {
+            const options = filterRow.querySelectorAll(Selectors.filter.fields.type + ' option');
+            options.forEach(option => {
+                if (option.value === filterRow.dataset.filterType) {
+                    option.classList.remove('hidden');
+                    option.disabled = false;
+                } else if (activeFilters[option.value]) {
+                    option.classList.add('hidden');
+                    option.disabled = true;
+                } else {
+                    option.classList.remove('hidden');
+                    option.disabled = false;
+                }
+            });
+        });
+
+        // Configure the state of the "Add row" button.
+        // This button is disabled when there is a filter row available for each condition.
+        const addRowButton = filterSet.querySelector(Selectors.filterset.actions.addRow);
+        const filterDataNode = filterSet.querySelectorAll(Selectors.data.fields.all);
+        if (filterDataNode.length <= filters.length) {
+            addRowButton.setAttribute('disabled', 'disabled');
+        } else {
+            addRowButton.removeAttribute('disabled');
+        }
+
+        if (filters.length === 1) {
+            filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.add('hidden');
+            filterSet.querySelector(Selectors.filterset.fields.join).value = 1;
+        } else {
+            filterSet.querySelector(Selectors.filterset.regions.filtermatch).classList.remove('hidden');
+        }
+    };
+
+    /**
+     * Update the Dynamic table based upon the current filter.
+     *
+     * @return {Promise}
+     */
+    const updateTableFromFilter = () => {
+        return DynamicTable.setFilters(
+            DynamicTable.getTableFromId(filterSet.dataset.tableRegion),
+            {
+                filters: Object.values(activeFilters).map(filter => filter.filterValue),
+                jointype: filterSet.querySelector(Selectors.filterset.fields.join).value,
+            }
+        );
+    };
+
+    // Add listeners for the main actions.
+    filterSet.querySelector(Selectors.filterset.region).addEventListener('click', e => {
+        if (e.target.closest(Selectors.filterset.actions.addRow)) {
+            e.preventDefault();
+
+            addFilterRow();
+        }
+
+        if (e.target.closest(Selectors.filterset.actions.applyFilters)) {
+            e.preventDefault();
+
+            updateTableFromFilter();
+        }
+
+        if (e.target.closest(Selectors.filterset.actions.resetFilters)) {
+            e.preventDefault();
+
+            removeAllFilters();
+        }
+    });
+
+    // Add the listener to remove a single filter.
+    filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('click', e => {
+        if (e.target.closest(Selectors.filter.actions.remove)) {
+            e.preventDefault();
+
+            removeOrReplaceFilterRow(e.target.closest(Selectors.filter.region));
+        }
+    });
+
+    // Add listeners for the filter type selection.
+    filterSet.querySelector(Selectors.filterset.regions.filterlist).addEventListener('change', e => {
+        const typeField = e.target.closest(Selectors.filter.fields.type);
+        if (typeField && typeField.value) {
+            const filter = e.target.closest(Selectors.filter.region);
+
+            addFilter(filter, typeField.value);
+        }
+    });
+
+    filterSet.querySelector(Selectors.filterset.fields.join).addEventListener('change', e => {
+        filterSet.dataset.filterverb = e.target.value;
+    });
+};
diff --git a/user/classes/output/participants_filter.php b/user/classes/output/participants_filter.php
new file mode 100644 (file)
index 0000000..c98fccb
--- /dev/null
@@ -0,0 +1,393 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Class for rendering user filters on the course participants page.
+ *
+ * @package    core_user
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace core_user\output;
+
+use context_course;
+use renderable;
+use renderer_base;
+use stdClass;
+use templatable;
+
+/**
+ * Class for rendering user filters on the course participants page.
+ *
+ * @copyright  2020 Michael Hawkins <michaelh@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class participants_filter implements renderable, templatable {
+
+    /** @var context_course $context The context where the filters are being rendered. */
+    protected $context;
+
+    /** @var string $tableregionid The table to be updated by this filter */
+    protected $tableregionid;
+
+    /** @var stdClass $course The course shown */
+    protected $course;
+
+    /**
+     * Participants filter constructor.
+     *
+     * @param context_course $context The context where the filters are being rendered.
+     * @param string $tableregionid The table to be updated by this filter
+     */
+    public function __construct(context_course $context, string $tableregionid) {
+        $this->context = $context;
+        $this->tableregionid = $tableregionid;
+
+        $this->course = get_course($context->instanceid);
+    }
+
+    /**
+     * Get data for all filter types.
+     *
+     * @return array
+     */
+    protected function get_filtertypes(): array {
+        $filtertypes = [];
+
+        $filtertypes[] = $this->get_keyword_filter();
+
+        if ($filtertype = $this->get_enrolmentstatus_filter()) {
+            $filtertypes[] = $filtertype;
+        }
+
+        if ($filtertype = $this->get_roles_filter()) {
+            $filtertypes[] = $filtertype;
+        }
+
+        if ($filtertype = $this->get_enrolments_filter()) {
+            $filtertypes[] = $filtertype;
+        }
+
+        if ($filtertype = $this->get_groups_filter()) {
+            $filtertypes[] = $filtertype;
+        }
+
+        if ($filtertype = $this->get_accesssince_filter()) {
+            $filtertypes[] = $filtertype;
+        }
+
+        return $filtertypes;
+    }
+
+    /**
+     * Get data for the enrolment status filter.
+     *
+     * @return stdClass|null
+     */
+    protected function get_enrolmentstatus_filter(): ?stdClass {
+        if (!has_capability('moodle/course:enrolreview', $this->context)) {
+            return null;
+        }
+
+        return $this->get_filter_object(
+            'status',
+            get_string('participationstatus', 'core_enrol'),
+            false,
+            true,
+            null,
+            [
+                (object) [
+                    'value' => ENROL_USER_ACTIVE,
+                    'title' => get_string('active'),
+                ],
+                (object) [
+                    'value' => ENROL_USER_SUSPENDED,
+                    'title'  => get_string('inactive'),
+                ],
+            ]
+        );
+    }
+
+    /**
+     * Get data for the roles filter.
+     *
+     * @return stdClass|null
+     */
+    protected function get_roles_filter(): ?stdClass {
+        $roles = [];
+        $roles += [-1 => get_string('noroles', 'role')];
+        $roles += get_viewable_roles($this->context);
+
+        if (has_capability('moodle/role:assign', $this->context)) {
+            $roles += get_assignable_roles($this->context, ROLENAME_ALIAS);
+        }
+
+        return $this->get_filter_object(
+            'roles',
+            get_string('roles', 'core_role'),
+            false,
+            true,
+            null,
+            array_map(function($id, $title) {
+                return (object) [
+                    'value' => $id,
+                    'title' => $title,
+                ];
+            }, array_keys($roles), array_values($roles))
+        );
+    }
+
+    /**
+     * Get data for the roles filter.
+     *
+     * @return stdClass|null
+     */
+    protected function get_enrolments_filter(): ?stdClass {
+        if (!has_capability('moodle/course:enrolreview', $this->context)) {
+            return null;
+        }
+
+        if ($this->course->id == SITEID) {
+            // No enrolment methods for the site.
+            return null;
+        }
+
+        $instances = enrol_get_instances($this->course->id, true);
+        $plugins = enrol_get_plugins(false);
+
+        return $this->get_filter_object(
+            'enrolments',
+            get_string('enrolmentinstances', 'core_enrol'),
+            false,
+            true,
+            null,
+            array_filter(array_map(function($instance) use ($plugins): ?stdClass {
+                if (!array_key_exists($instance->enrol, $plugins)) {
+                    return null;
+                }
+
+                return (object) [
+                    'value' => $instance->id,
+                    'title' => $plugins[$instance->enrol]->get_instance_name($instance),
+                ];
+            }, array_values($instances)))
+        );
+    }
+
+    /**
+     * Get data for the groups filter.
+     *
+     * @return stdClass|null
+     */
+    protected function get_groups_filter(): ?stdClass {
+        global $USER;
+
+        // Filter options for groups, if available.
+        $seeallgroups = has_capability('moodle/site:accessallgroups', $this->context);
+        $seeallgroups = $seeallgroups || ($this->course->groupmode != SEPARATEGROUPS);
+        if ($seeallgroups) {
+            $groups = [];
+            $groups += [USERSWITHOUTGROUP => (object) [
+                    'id' => USERSWITHOUTGROUP,
+                    'name' => get_string('nogroup', 'group'),
+                ]];
+            $groups += groups_get_all_groups($this->course->id);
+        } else {
+            // Otherwise, just list the groups the user belongs to.
+            $groups = groups_get_all_groups($this->course->id, $USER->id);
+        }
+
+        if (empty($groups)) {
+            return null;
+        }
+
+        return $this->get_filter_object(
+            'groups',
+            get_string('groups', 'core_group'),
+            false,
+            true,
+            null,
+            array_map(function($group) {
+                return (object) [
+                    'value' => $group->id,
+                    'title' => $group->name,
+                ];
+            }, array_values($groups))
+        );
+    }
+
+    /**
+     * Get data for the accesssince filter.
+     *
+     * @return stdClass|null
+     */
+    protected function get_accesssince_filter(): ?stdClass {
+        global $CFG, $DB;
+
+        $hiddenfields = [];
+        if (!has_capability('moodle/course:viewhiddenuserfields', $this->context)) {
+            $hiddenfields = array_flip(explode(',', $CFG->hiddenuserfields));
+        }
+
+        if (array_key_exists('lastaccess', $hiddenfields)) {
+            return null;
+        }
+
+        // Get minimum lastaccess for this course and display a dropbox to filter by lastaccess going back this far.
+        // We need to make it diferently for normal courses and site course.
+        if (!$this->course->id == SITEID) {
+            // Regular course.
+            $params = [
+                'courseid' => $this->course->id,
+                'timeaccess' => 0,
+            ];
+            $select = 'courseid = :courseid AND timeaccess != :timeaccess';
+            $minlastaccess = $DB->get_field_select('user_lastaccess', 'MIN(timeaccess)', $select, $params);
+            $lastaccess0exists = $DB->record_exists('user_lastaccess', $params);
+        } else {
+            // Front page.
+            $params = ['lastaccess' => 0];
+            $select = 'lastaccess != :lastaccess';
+            $minlastaccess = $DB->get_field_select('user', 'MIN(lastaccess)', $select, $params);
+            $lastaccess0exists = $DB->record_exists('user', $params);
+        }
+
+        $now = usergetmidnight(time());
+        $timeoptions = [];
+        $criteria = get_string('usersnoaccesssince');
+
+        $getoptions = function(int $count, string $singletype, string $type) use ($now, $minlastaccess): array {
+            $values = [];
+            for ($i = 1; $i <= $count; $i++) {
+                $timestamp = strtotime("-{$i} {$type}", $now);
+                if ($timestamp < $minlastaccess) {
+                    break;
+                }
+
+                if ($i === 1) {
+                    $title = get_string("num{$singletype}", 'moodle', $i);
+                } else {
+                    $title = get_string("num{$type}", 'moodle', $i);
+                }
+
+                $values[] = [
+                    'value' => $timestamp,
+                    'title' => $title,
+                ];
+            }
+
+            return $values;
+        };
+
+        $values = array_merge(
+            $getoptions(6, 'day', 'days'),
+            $getoptions(10, 'week', 'weeks'),
+            $getoptions(11, 'month', 'months'),
+            $getoptions(1, 'year', 'years')
+        );
+
+        if ($lastaccess0exists) {
+            $values[] = [
+                'value' => time(),
+                'title' => get_string('never', 'moodle'),
+            ];
+        }
+
+        if (count($values) <= 1) {
+            // Nothing to show.
+            return null;
+        }
+
+        return $this->get_filter_object(
+            'accesssince',
+            get_string('usersnoaccesssince'),
+            false,
+            false,
+            null,
+            $values
+        );
+    }
+
+    /**
+     * Get data for the keywords filter.
+     *
+     * @return stdClass|null
+     */
+    protected function get_keyword_filter(): ?stdClass {
+        return $this->get_filter_object(
+            'keywords',
+            get_string('filterbykeyword', 'core_user'),
+            true,
+            true,
+            'core_user/local/participantsfilter/filtertypes/keyword',
+            [],
+            true
+        );
+    }
+
+    /**
+     * Export the renderer data in a mustache template friendly format.
+     *
+     * @param renderer_base $output Unused.
+     * @return stdClass Data in a format compatible with a mustache template.
+     */
+    public function export_for_template(renderer_base $output): stdClass {
+        return (object) [
+            'tableregionid' => $this->tableregionid,
+            'courseid' => $this->context->instanceid,
+            'filtertypes' => $this->get_filtertypes(),
+        ];
+
+        return $data;
+    }
+
+    /**
+     * Get a standardised filter object.
+     *
+     * @param string $name
+     * @param string $title
+     * @param bool $custom
+     * @param bool $multiple
+     * @param string|null $filterclass
+     * @param array $values
+     * @param bool $allowempty
+     * @return stdClass|null
+     */
+    protected function get_filter_object(
+        string $name,
+        string $title,
+        bool $custom,
+        bool $multiple,
+        ?string $filterclass,
+        array $values,
+        bool $allowempty = false
+    ): ?stdClass {
+
+        if (!$allowempty && empty($values)) {
+            // Do not show empty filters.
+            return null;
+        }
+
+        return (object) [
+            'name' => $name,
+            'title' => $title,
+            'allowcustom' => $custom,
+            'allowmultiple' => $multiple,
+            'filtertypeclass' => $filterclass,
+            'values' => $values,
+        ];
+    }
+}
index 744d35f..d9aaccf 100644 (file)
@@ -95,14 +95,26 @@ class participants_search {
         global $DB;
 
         [
-            'select' => $select,
-            'from' => $from,
-            'where' => $where,
+            'subqueryalias' => $subqueryalias,
+            'outerselect' => $outerselect,
+            'innerselect' => $innerselect,
+            'outerjoins' => $outerjoins,
+            'innerjoins' => $innerjoins,
+            'outerwhere' => $outerwhere,
+            'innerwhere' => $innerwhere,
             'params' => $params,
-            'groupby' => $groupby,
         ] = $this->get_participants_sql($additionalwhere, $additionalparams);
 
-        return $DB->get_recordset_sql("{$select} {$from} {$where} {$groupby} {$sort}", $params, $limitfrom, $limitnum);
+        $sql = "{$outerselect}
+                          FROM ({$innerselect}
+                                          FROM {$innerjoins}
+                                 {$innerwhere}
+                               ) {$subqueryalias}
+                 {$outerjoins}
+                 {$outerwhere}
+                       {$sort}";
+
+        return $DB->get_recordset_sql($sql, $params, $limitfrom, $limitnum);
     }
 
     /**
@@ -116,12 +128,24 @@ class participants_search {
         global $DB;
 
         [
-            'from' => $from,
-            'where' => $where,
+            'subqueryalias' => $subqueryalias,
+            'innerselect' => $innerselect,
+            'outerjoins' => $outerjoins,
+            'innerjoins' => $innerjoins,
+            'outerwhere' => $outerwhere,
+            'innerwhere' => $innerwhere,
             'params' => $params,
         ] = $this->get_participants_sql($additionalwhere, $additionalparams);
 
-        return $DB->count_records_sql("SELECT COUNT(DISTINCT(u.id)) {$from} {$where}", $params);
+        $sql = "SELECT COUNT(u.id)
+                  FROM ({$innerselect}
+                                  FROM {$innerjoins}
+                         {$innerwhere}
+                       ) {$subqueryalias}
+         {$outerjoins}
+         {$outerwhere}";
+
+        return $DB->count_records_sql($sql, $params);
     }
 
     /**
@@ -137,6 +161,19 @@ class participants_search {
         // Whether to match on users who HAVE accessed since the given time (ie false is 'inactive for more than x').
         $matchaccesssince = false;
 
+        // The alias for the subquery that fetches all distinct course users.
+        $usersubqueryalias = 'targetusers';
+        // The alias for {user} within the distinct user subquery.
+        $inneruseralias = 'udistinct';
+        // Inner query that selects distinct users in a course who are not deleted.
+        // Note: This ensures the outer (filtering) query joins on distinct users, avoiding the need for GROUP BY.
+        $innerselect = "SELECT DISTINCT {$inneruseralias}.id";
+        $innerjoins = ["{user} {$inneruseralias}"];
+        $innerwhere = "WHERE {$inneruseralias}.deleted = 0";
+
+        $outerjoins = ["JOIN {user} u ON u.id = {$usersubqueryalias}.id"];
+        $wheres = [];
+
         if ($this->filterset->has_filter('accesssince')) {
             $accesssince = $this->filterset->get_filter('accesssince')->current();
 
@@ -155,53 +192,46 @@ class participants_search {
             'params' => $params,
         ] = $this->get_enrolled_sql();
 
-        $joins = ['FROM {user} u'];
-        $wheres = [];
-        // Set where statement(s) that must always be included (outside of filter wheres).
-        $forcedwhere = "u.deleted = 0";
-
         $userfieldssql = user_picture::fields('u', $this->userfields);
 
         // Include any compulsory enrolment SQL (eg capability related filtering that must be applied).
         if (!empty($esqlforced)) {
-            $joins[] = "JOIN ({$esqlforced}) fef ON fef.id = u.id";
+            $outerjoins[] = "JOIN ({$esqlforced}) fef ON fef.id = u.id";
         }
 
         // Include any enrolment related filtering.
         if (!empty($esql)) {
-            $joins[] = "LEFT JOIN ({$esql}) ef ON ef.id = u.id";
+            $outerjoins[] = "LEFT JOIN ({$esql}) ef ON ef.id = u.id";
             $wheres[] = 'ef.id IS NOT NULL';
         }
 
         if ($isfrontpage) {
-            $select = "SELECT {$userfieldssql}, u.lastaccess";
+            $outerselect = "SELECT {$userfieldssql}, u.lastaccess";
             if ($accesssince) {
                 $wheres[] = user_get_user_lastaccess_sql($accesssince, 'u', $matchaccesssince);
             }
-            $groupby = ' GROUP BY u.id, u.lastaccess, ctx.id';
         } else {
-            $select = "SELECT {$userfieldssql}, COALESCE(ul.timeaccess, 0) AS lastaccess";
+            $outerselect = "SELECT {$userfieldssql}, COALESCE(ul.timeaccess, 0) AS lastaccess";
             // Not everybody has accessed the course yet.
-            $joins[] = 'LEFT JOIN {user_lastaccess} ul ON (ul.userid = u.id AND ul.courseid = :courseid2)';
+            $outerjoins[] = 'LEFT JOIN {user_lastaccess} ul ON (ul.userid = u.id AND ul.courseid = :courseid2)';
             $params['courseid2'] = $this->course->id;
             if ($accesssince) {
                 $wheres[] = user_get_course_lastaccess_sql($accesssince, 'ul', $matchaccesssince);
             }
 
             // Make sure we only ever fetch users in the course (regardless of enrolment filters).
-            $joins[] = 'JOIN {user_enrolments} ue ON ue.userid = u.id';
-            $joins[] = 'JOIN {enrol} e ON e.id = ue.enrolid
+            $innerjoins[] = "JOIN {user_enrolments} ue ON ue.userid = {$inneruseralias}.id";
+            $innerjoins[] = 'JOIN {enrol} e ON e.id = ue.enrolid
                                       AND e.courseid = :courseid1';
             $params['courseid1'] = $this->course->id;
-            $groupby = ' GROUP BY u.id, ul.timeaccess, ctx.id';
         }
 
         // Performance hacks - we preload user contexts together with accounts.
         $ccselect = ', ' . context_helper::get_preload_record_columns_sql('ctx');
         $ccjoin = 'LEFT JOIN {context} ctx ON (ctx.instanceid = u.id AND ctx.contextlevel = :contextlevel)';
         $params['contextlevel'] = CONTEXT_USER;
-        $select .= $ccselect;
-        $joins[] = $ccjoin;
+        $outerselect .= $ccselect;
+        $outerjoins[] = $ccjoin;
 
         // Apply any role filtering.
         if ($this->filterset->has_filter('roles')) {
@@ -242,35 +272,39 @@ class participants_search {
         }
 
         // Prepare final values.
-        $from = implode("\n", $joins);
+        $outerjoinsstring = implode("\n", $outerjoins);
+        $innerjoinsstring = implode("\n", $innerjoins);
         if ($wheres) {
             switch ($this->filterset->get_join_type()) {
                 case $this->filterset::JOINTYPE_ALL:
-                    $firstjoin = ' AND ';
+                    $wherenot = '';
                     $wheresjoin = ' AND ';
                     break;
                 case $this->filterset::JOINTYPE_NONE:
-                    $firstjoin = ' AND NOT ';
+                    $wherenot = ' NOT ';
                     $wheresjoin = ' AND NOT ';
                     break;
                 default:
                     // Default to 'Any' jointype.
-                    $firstjoin = ' AND ';
+                    $wherenot = '';
                     $wheresjoin = ' OR ';
                     break;
             }
 
-            $where = "WHERE ({$forcedwhere}) {$firstjoin}" . implode($wheresjoin, $wheres);
+            $outerwhere = 'WHERE ' . $wherenot . implode($wheresjoin, $wheres);
         } else {
-            $where = '';
+            $outerwhere = '';
         }
 
         return [
-            'select' => $select,
-            'from' => $from,
-            'where' => $where,
+            'subqueryalias' => $usersubqueryalias,
+            'outerselect' => $outerselect,
+            'innerselect' => $innerselect,
+            'outerjoins' => $outerjoinsstring,
+            'innerjoins' => $innerjoinsstring,
+            'outerwhere' => $outerwhere,
+            'innerwhere' => $innerwhere,
             'params' => $params,
-            'groupby' => $groupby,
         ];
     }
 
index 3078724..3a4ddf1 100644 (file)
@@ -143,6 +143,8 @@ $lastaccess = 0;
 $searchkeywords = [];
 $enrolid = 0;
 
+$participanttable = new \core_user\table\participants("user-index-participants-{$course->id}");
+
 $filterset = new \core_user\table\participants_filterset();
 $filterset->add_filter(new integer_filter('courseid', filter::JOINTYPE_DEFAULT, [(int)$course->id]));
 $enrolfilter = new integer_filter('enrolments');
@@ -240,7 +242,7 @@ $enrolbuttonsout = '';
 foreach ($enrolbuttons as $enrolbutton) {
     $enrolbuttonsout .= $enrolrenderer->render($enrolbutton);
 }
-echo html_writer::div($enrolbuttonsout, 'float-right', [
+echo html_writer::div($enrolbuttonsout, 'd-flex justify-content-end', [
     'data-region' => 'wrapper',
     'data-table-uniqueid' => $participanttable->uniqueid,
 ]);
@@ -249,6 +251,10 @@ echo html_writer::div($enrolbuttonsout, 'float-right', [
 $renderer = $PAGE->get_renderer('core_user');
 echo $renderer->unified_filter($course, $context, $filtersapplied, $baseurl);
 
+// Render the user filters.
+$userrenderer = $PAGE->get_renderer('core_user');
+echo $userrenderer->participants_filter($context, $participanttable->uniqueid);
+
 echo '<div class="userlist">';
 
 // Add filters to the baseurl after creating unified_filter to avoid losing them.
index 11f2190..7be0ce5 100644 (file)
@@ -259,6 +259,20 @@ class core_user_renderer extends plugin_renderer_base {
         return $this->output->render_from_template('core_user/unified_filter', $context);
     }
 
+    /**
+     * Render the data required for the participants filter on the course participants page.
+     *
+     * @param context $context The context of the course being displayed
+     * @param string $tableregionid The table to be updated by this filter
+     * @return string
+     */
+    public function participants_filter(context $context, string $tableregionid): string {
+        $renderable = new \core_user\output\participants_filter($context, $tableregionid);
+        $templatecontext = $renderable->export_for_template($this->output);
+
+        return $this->output->render_from_template('core_user/participantsfilter', $templatecontext);
+    }
+
     /**
      * Returns a formatted filter option.
      *
diff --git a/user/templates/local/participantsfilter/autocomplete_layout.mustache b/user/templates/local/participantsfilter/autocomplete_layout.mustache
new file mode 100644 (file)
index 0000000..4c9665f
--- /dev/null
@@ -0,0 +1,38 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_user/local/participantsfilter/autocomplete_layout
+
+    Moodle template for the layout of autocomplete elements.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * data-region="form_autocomplete-input"
+    * data-region="form_autocomplete-suggestions"
+    * data-region="form_autocomplete-selection"
+
+    Context variables required for this template:
+    * none
+
+    Example context (json):
+    {}
+}}
+<div data-region="form_autocomplete-input"></div>
+<div data-region="form_autocomplete-suggestions"></div>
+<div data-region="form_autocomplete-selection"></div>
diff --git a/user/templates/local/participantsfilter/autocomplete_selection.mustache b/user/templates/local/participantsfilter/autocomplete_selection.mustache
new file mode 100644 (file)
index 0000000..4f7db3c
--- /dev/null
@@ -0,0 +1,49 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_user/local/participantsfilter/autocomplete_selection
+
+    Moodle template for the wrapper of currently selected items in an autocomplate form element.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * multiple True if this field allows multiple selections
+    * selectionId The dom id of the current selection list.
+    * items List of items with label and value fields (used by the partial).
+    * noSelectionString String to use when no items are selected (used by the partial).
+
+    Example context (json):
+    { "multiple": true, "selectionId": 1, "items": [
+        { "label": "Item label with <strong>tags</strong>", "value": "5" },
+        { "label": "Another item label with <strong>tags</strong>", "value": "4" }
+    ], "noSelectionString": "No selection" }
+}}
+<div{{!
+    }} class="d-inline-block mb-0{{#multiple}} form-autocomplete-multiple h5{{/multiple}}"{{!
+    }} id="{{selectionId}}"{{!
+    }} role="list"{{!
+    }} aria-atomic="true"{{!
+    }}{{#multiple}} tabindex="0" {{/multiple}}{{!
+    }}>
+<span class="accesshide">{{#str}}selecteditems, form{{/str}}</span>
+    {{> core/form_autocomplete_selection_items }}
+</div>
diff --git a/user/templates/local/participantsfilter/autocomplete_selection_items.mustache b/user/templates/local/participantsfilter/autocomplete_selection_items.mustache
new file mode 100644 (file)
index 0000000..9f358b6
--- /dev/null
@@ -0,0 +1,51 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_user/local/participantsfilter/autocomplete_selection_items
+
+    Moodle template for the currently selected items in an autocomplete form element.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * data-value
+
+    Context variables required for this template:
+    * items List of items with label and value fields.
+    *   - value Value of the selected item.
+    *   - label HTML representing the value.
+    * noSelectionString String to use when no items are selected
+
+    Example context (json):
+    {
+        "items": [
+            { "label": "Item label with <strong>tags</strong>", "value": "5" },
+            { "label": "Another item label with <strong>tags</strong>", "value": "4" }
+        ],
+        "noSelectionString": "No selection"
+    }
+}}
+{{#items}}
+    <span role="listitem" data-value="{{value}}" aria-selected="true"
+            class="badge badge-secondary clickable text-wrap text-break line-height-4 mr-2 my-1">
+        {{label}}<i class="icon fa fa-times pl-2 mr-0"></i>
+    </span>
+{{/items}}
+{{^items}}
+    <span class="mb-3 mr-1">{{noSelectionString}}</span>
+{{/items}}
diff --git a/user/templates/local/participantsfilter/filterrow.mustache b/user/templates/local/participantsfilter/filterrow.mustache
new file mode 100644 (file)
index 0000000..9d2bdc6
--- /dev/null
@@ -0,0 +1,65 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_user/local/participantsfilter/filterrow
+
+    Template for use by each filter condition.
+
+    Context variables required for this template:
+      * filtertypes - Array of filter types available.
+
+    Example context (json):
+    {
+        "filtertypes": [
+            {
+                "name": "status",
+                "title": "Status"
+            }
+        ]
+    }
+}}
+<div data-filterregion="filter">
+    <div class="border-radius my-2 p-2 bg-white border d-flex flex-column flex-md-row align-items-md-start">
+        <div class="d-flex flex-column flex-md-row align-items-md-center">
+            <label for="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}" class="mr-md-2 mb-md-0">{{#str}}match, core_user{{/str}}</label>
+            <select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="join" id="core_user-local-participantsfilter-filterrow-jointype-{{uniqid}}">
+                <option value="0">{{#str}}none{{/str}}</option>
+                <option selected=selected value="1">{{#str}}any{{/str}}</option>
+                <option value="2">{{#str}}all{{/str}}</option>
+            </select>
+        </div>
+
+        <label class="sr-only pt-2" for="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">filtertype</label>
+        <select class="custom-select mb-1 mb-md-0 mr-md-2" data-filterfield="type" id="core_user-local-participantsfilter-filterrow-filtertype-{{uniqid}}">
+            <option value="">{{#str}}selectfiltertype, core_user{{/str}}</option>
+            {{#filtertypes}}
+            <option value="{{name}}">{{title}}</option>
+            {{/filtertypes}}
+        </select>
+
+        <div data-filterregion="value" class="d-md-flex flex-column align-items-start flex-lg-row"></div>
+
+        <button data-filteraction="remove" class="ml-auto icon-no-margin icon-size-4 btn text-reset" aria-label="{{#str}}clearfilterrow, core_user{{/str}}">
+            <i class="icon fa fa-times-circle"></i>
+        </button>
+    </div>
+    <div data-filterregion="joinadverb" class="pl-1 text-uppercase font-weight-bold">
+        <div data-filterverbfor="0">{{#str}}adverbfor_andnot, core_user{{/str}}</div>
+        <div data-filterverbfor="1">{{#str}}adverbfor_or, core_user{{/str}}</div>
+        <div data-filterverbfor="2">{{#str}}adverbfor_and, core_user{{/str}}</div>
+    </div>
+</div>
diff --git a/user/templates/local/participantsfilter/filtertype.mustache b/user/templates/local/participantsfilter/filtertype.mustache
new file mode 100644 (file)
index 0000000..f38eb93
--- /dev/null
@@ -0,0 +1,61 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_user/local/participantsfilter/filtertype
+
+    Filter type data, not shown to users but used as a source of data for form autocompletion.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    * filtertypes
+
+    Example context (json):
+    {
+        "name": "status",
+        "title": "Enrolment Status",
+        "allowcustom": "0",
+        "allowmultiple" false,
+        "filtertypeclass": "core_user/local/participantsfilter/filtertypes/courseid",
+        "values": [
+            {
+                "value": "0",
+                "title": "Inactive"
+            },
+            {
+                "value": "1",
+                "title": "Active"
+            }
+        ]
+    }
+}}
+<select {{!
+    }}{{#allowmultiple}}multiple="multiple"{{/allowmultiple}} {{!
+    }}data-field-name="{{name}}" {{!
+    }}data-field-title="{{title}}" {{!
+    }}data-allow-custom="{{allowcustom}}" {{!
+    }}class="hidden" {{!
+    }}{{#filtertypeclass}}data-filter-type-class="{{filtertypeclass}}" {{/filtertypeclass}}{{!
+}}>
+    {{#values}}
+        <option value="{{value}}">{{title}}</option>
+    {{/values}}
+</select>
diff --git a/user/templates/local/participantsfilter/filtertypes.mustache b/user/templates/local/participantsfilter/filtertypes.mustache
new file mode 100644 (file)
index 0000000..b8aea7c
--- /dev/null
@@ -0,0 +1,64 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_user/local/participantsfilter/filtertypes
+
+    Placeholder to fetch all filter types.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * data-filterregion="filtertypedata"
+
+    Context variables required for this template:
+    * filtertypes
+
+    Example context (json):
+    {
+        "filtertypes": [
+            {
+                "name": "status",
+                "title": "Enrolment Status",
+                "allowcustom": "0",
+                "values": [
+                    {
+                        "value": "0",
+                        "title": "Inactive"
+                    },
+                    {
+                        "value": "1",
+                        "title": "Active"
+                    }
+                ]
+            }
+        ]
+    }
+}}
+<div class="hidden" data-filterregion="filtertypedata">
+    {{#filtertypes}}
+        {{> core_user/local/participantsfilter/filtertype}}
+    {{/filtertypes}}
+</div>
+<div class="hidden">
+    <select disabled="disabled" data-filterfield="type" data-filterregion="filtertypelist">
+        <option value="">{{#str}}selectfiltertype, core_user{{/str}}</option>
+        {{#filtertypes}}
+        <option value="{{name}}">{{title}}</option>
+        {{/filtertypes}}
+    </select>
+</div>
diff --git a/user/templates/participantsfilter.mustache b/user/templates/participantsfilter.mustache
new file mode 100644 (file)
index 0000000..e9315f8
--- /dev/null
@@ -0,0 +1,77 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_user/participantsfilter
+
+    Template for the form containing one or more filter rows.
+
+    Example context (json):
+    {
+        "filtertypes": [
+            {
+                "name": "status",
+                "title": "Status",
+                "values": [
+                    {
+                        "value": 1,
+                        "title": "Active"
+                    },
+                    {
+                        "value": 0,
+                        "title": "Suspended"
+                    }
+                ]
+            }
+        ]
+    }
+}}
+
+<div id="core_user-participantsfilter-{{uniqid}}" class="filter-group my-2 p-2 bg-light border-radius border" data-table-region="{{tableregionid}}" data-table-course-id="{{courseid}}" data-filterverb="1">
+    <div data-filterregion="filtermatch" class="hidden">
+        <label for="core_user-local-participantsfilter-jointype-{{uniqid}}" class="my-0" aria-hidden="true">
+            {{#str}}match, core_user{{/str}}
+        </label>
+        <select class="custom-select" data-filterfield="join" id="core_user-local-participantsfilter-jointype-{{uniqid}}"
+                aria-label="{{#str}}filtersetmatchdescription, core_user{{/str}}">
+            <option value="0">{{#str}}none{{/str}}</option>
+            <option selected=selected value="1">{{#str}}any{{/str}}</option>
+            <option value="2">{{#str}}all{{/str}}</option>
+        </select>
+        <span aria-hidden="true">{{#str}}matchofthefollowing, core_user{{/str}}</span>
+    </div>
+
+    <div data-filterregion="filters">
+        {{> core_user/local/participantsfilter/filterrow }}
+    </div>
+
+    <div class="d-flex" data-filterregion="actions">
+        &nbsp;
+        <button type="button" class="btn btn-link text-reset" data-filteraction="add">
+            <i class="fa fa-plus"></i><span class="pl-3">{{#str}}addcondition, core_user{{/str}}</span>
+        </button>
+        <button data-filteraction="reset" type="button" class="btn btn-secondary ml-auto mr-2">{{#str}}clearfilters, core_user{{/str}}</button>
+        <button data-filteraction="apply" type="button" class="btn btn-primary">{{#str}}applyfilters, core_user{{/str}}</button>
+    </div>
+
+    {{> core_user/local/participantsfilter/filtertypes}}
+</div>
+
+{{#js}}
+require(['core_user/participantsfilter'], function(ParticipantsFilter) {
+    ParticipantsFilter.init('core_user-participantsfilter-{{uniqid}}');
+});
+{{/js}}