MDL-68483 contentbank: filter contents by name
authorSara Arjona <sara@moodle.com>
Tue, 21 Apr 2020 12:55:51 +0000 (14:55 +0200)
committerSara Arjona <sara@moodle.com>
Wed, 13 May 2020 11:31:29 +0000 (13:31 +0200)
Credits to Bas and Rafa for helping us to improve the UX.
Also to Amaia with her help with Behat tests.

16 files changed:
contentbank/amd/build/search.min.js [new file with mode: 0644]
contentbank/amd/build/search.min.js.map [new file with mode: 0644]
contentbank/amd/build/selectors.min.js [new file with mode: 0644]
contentbank/amd/build/selectors.min.js.map [new file with mode: 0644]
contentbank/amd/src/search.js [new file with mode: 0644]
contentbank/amd/src/selectors.js [new file with mode: 0644]
contentbank/classes/output/bankcontent.php
contentbank/templates/bankcontent.mustache
contentbank/templates/bankcontent/search.mustache [new file with mode: 0644]
contentbank/templates/bankcontent/toolbar.mustache [moved from contentbank/templates/toolbar.mustache with 91% similarity]
contentbank/tests/behat/search_content.feature [new file with mode: 0644]
lang/en/contentbank.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/message.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css

diff --git a/contentbank/amd/build/search.min.js b/contentbank/amd/build/search.min.js
new file mode 100644 (file)
index 0000000..9090c9b
Binary files /dev/null and b/contentbank/amd/build/search.min.js differ
diff --git a/contentbank/amd/build/search.min.js.map b/contentbank/amd/build/search.min.js.map
new file mode 100644 (file)
index 0000000..a3ddca1
Binary files /dev/null and b/contentbank/amd/build/search.min.js.map differ
diff --git a/contentbank/amd/build/selectors.min.js b/contentbank/amd/build/selectors.min.js
new file mode 100644 (file)
index 0000000..c7322b9
Binary files /dev/null and b/contentbank/amd/build/selectors.min.js differ
diff --git a/contentbank/amd/build/selectors.min.js.map b/contentbank/amd/build/selectors.min.js.map
new file mode 100644 (file)
index 0000000..99b3b56
Binary files /dev/null and b/contentbank/amd/build/selectors.min.js.map differ
diff --git a/contentbank/amd/src/search.js b/contentbank/amd/src/search.js
new file mode 100644 (file)
index 0000000..bd5cb55
--- /dev/null
@@ -0,0 +1,160 @@
+// 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/>.
+
+/**
+ * Search methods for finding contents in the content bank.
+ *
+ * @module     core_contentbank/search
+ * @package    core_contentbank
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import $ from 'jquery';
+import selectors from 'core_contentbank/selectors';
+import {get_string as getString} from 'core/str';
+import Pending from 'core/pending';
+import {debounce} from 'core/utils';
+
+/**
+ * Set up the search.
+ *
+ * @method init
+ */
+export const init = () => {
+    const pendingPromise = new Pending();
+
+    const root = $(selectors.elements.main);
+    registerListenerEvents(root);
+
+    pendingPromise.resolve();
+};
+
+/**
+ * Register contentbank search related event listeners.
+ *
+ * @method registerListenerEvents
+ * @param {Object} root The root element for the contentbank.
+ */
+const registerListenerEvents = (root) => {
+
+    const searchInput = root.find(selectors.elements.searchinput)[0];
+
+    root.on('click', selectors.actions.search, function(e) {
+        e.preventDefault();
+        toggleSearchResultsView(root, searchInput.value);
+    });
+
+    root.on('click', selectors.actions.clearSearch, function(e) {
+        e.preventDefault();
+        searchInput.value = "";
+        searchInput.focus();
+        toggleSearchResultsView(root, searchInput.value);
+    });
+
+    // The search input is also triggered.
+    searchInput.addEventListener('input', debounce(() => {
+        // Display the search results.
+        toggleSearchResultsView(root, searchInput.value);
+    }, 300));
+
+};
+
+/**
+ * Toggle (display/hide) the search results depending on the value of the search query.
+ *
+ * @method toggleSearchResultsView
+ * @param {HTMLElement} body The root element for the contentbank.
+ * @param {String} searchQuery The search query.
+ */
+const toggleSearchResultsView = async(body, searchQuery) => {
+    const clearSearchButton = body.find(selectors.elements.clearsearch)[0];
+    const searchIcon = body.find(selectors.elements.searchicon)[0];
+
+    const navbarBreadcrumb = body.find(selectors.elements.cbnavbarbreadcrumb)[0];
+    const navbarTotal = body.find(selectors.elements.cbnavbartotalsearch)[0];
+    // Update the results.
+    const filteredContents = filterContents(body, searchQuery);
+    if (searchQuery.length > 0) {
+        // As the search query is present, search results should be displayed.
+
+        // Display the "clear" search button in the activity chooser search bar.
+        searchIcon.classList.add('d-none');
+        clearSearchButton.classList.remove('d-none');
+
+        // Change the cb-navbar to display total items found.
+        navbarBreadcrumb.classList.add('d-none');
+        navbarTotal.innerHTML = await getString('itemsfound', 'core_contentbank', filteredContents.length);
+        navbarTotal.classList.remove('d-none');
+    } else {
+        // As search query is not present, the search results should be removed.
+
+        // Hide the "clear" search button in the activity chooser search bar.
+        clearSearchButton.classList.add('d-none');
+        searchIcon.classList.remove('d-none');
+
+        // Display again the breadcrumb in the navbar.
+        navbarBreadcrumb.classList.remove('d-none');
+        navbarTotal.classList.add('d-none');
+    }
+};
+
+/**
+ * Return the list of contents which have a name that matches the given search term.
+ *
+ * @method filterContents
+ * @param {HTMLElement} body The root element for the contentbank.
+ * @param {String} searchTerm The search term to match.
+ * @return {Array}
+ */
+const filterContents = (body, searchTerm) => {
+    const contents = Array.from(body.find(selectors.elements.cbfile));
+    const searchResults = [];
+    contents.forEach((content) => {
+        const contentName = content.getAttribute('data-file');
+        if (searchTerm === '' || contentName.toLowerCase().includes(searchTerm.toLowerCase())) {
+            // The content matches the search criteria so it should be displayed and hightlighted.
+            searchResults.push(content);
+            const contentNameElement = content.querySelector(selectors.regions.cbcontentname);
+            contentNameElement.innerHTML = highlight(contentName, searchTerm);
+            content.classList.remove('d-none');
+        } else {
+            content.classList.add('d-none');
+        }
+    });
+
+    return searchResults;
+};
+
+/**
+ * Highlight a given string in a text.
+ *
+ * @method highlight
+ * @param  {String} text The whole text.
+ * @param  {String} highlightText The piece of text to highlight.
+ * @return {String}
+ */
+const highlight = (text, highlightText) => {
+    let result = text;
+    if (highlightText !== '') {
+        const pos = text.toLowerCase().indexOf(highlightText.toLowerCase());
+        if (pos > -1) {
+            result = text.substr(0, pos) + '<span class="matchtext">' + text.substr(pos, highlightText.length) + '</span>' +
+                text.substr(pos + highlightText.length);
+        }
+    }
+
+    return result;
+};
diff --git a/contentbank/amd/src/selectors.js b/contentbank/amd/src/selectors.js
new file mode 100644 (file)
index 0000000..080f85f
--- /dev/null
@@ -0,0 +1,54 @@
+// 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/>.
+
+/**
+ * Define all of the selectors we will be using on the contentbank interface.
+ *
+ * @module     core_contentbank/selectors
+ * @package    core_contentbank
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * A small helper function to build queryable data selectors.
+ *
+ * @method getDataSelector
+ * @param {String} name
+ * @param {String} value
+ * @return {string}
+ */
+const getDataSelector = (name, value) => {
+    return `[data-${name}="${value}"]`;
+};
+
+export default {
+    regions: {
+        cbcontentname: getDataSelector('region', 'cb-content-name'),
+    },
+    actions: {
+        search: getDataSelector('action', 'searchcontent'),
+        clearSearch: getDataSelector('action', 'clearsearchcontent'),
+    },
+    elements: {
+        cbfile: '.cb-file',
+        cbnavbarbreadcrumb: '.cb-navbar-breadbrumb',
+        cbnavbartotalsearch: '.cb-navbar-totalsearch',
+        clearsearch: '.input-group-append .clear-icon',
+        main: '#region-main',
+        searchicon: '.input-group-append .search-icon',
+        searchinput: '#searchinput',
+    },
+};
index ac1a855..fe76d0c 100644 (file)
@@ -72,6 +72,10 @@ class bankcontent implements renderable, templatable {
      * @return stdClass
      */
     public function export_for_template(renderer_base $output): stdClass {
+        global $PAGE;
+
+        $PAGE->requires->js_call_amd('core_contentbank/search', 'init');
+
         $data = new stdClass();
         $contentdata = array();
         foreach ($this->contents as $content) {
index 2212b81..396c297 100644 (file)
     }
 
 }}
-{{>core_contentbank/toolbar}}
+<div class="d-flex justify-content-between flex-column flex-sm-row">
+    <div class="cb-search-container mb-2">
+        {{>core_contentbank/bankcontent/search}}
+    </div>
+    <div class="cb-toolbar-container mb-2">
+        {{>core_contentbank/bankcontent/toolbar}}
+    </div>
+</div>
 <div class="content-bank-container pb-3 border">
     <div class="content-bank">
         <div class="cb-navbar bg-light p-2 border-bottom">
-            {{#pix}} i/folder {{/pix}}
+            <div class="cb-navbar-breadbrumb">
+                {{#pix}} i/folder {{/pix}}
+            </div>
+            <div class="cb-navbar-totalsearch d-none">
+            </div>
         </div>
         <div class="cb-content-wrapper d-flex flex-wrap p-2">
         {{#contents}}
-            <div class="cb-file position-relative mb-2">
+            <div class="cb-file position-relative mb-2" data-file="{{{name}}}">
                 <div class="p-2">
                     <div class="cb-thumbnail mb-1 text-center">
                         {{{ icon }}}
@@ -60,7 +71,7 @@
                     {{#link}}
                         <a href="{{{ link }}}" class="stretched-link" title="{{{name}}}">
                     {{/link}}
-                            <span class="cb-name word-break-all clamp-2 text-center" >
+                            <span class="cb-name word-break-all clamp-2 text-center" data-region="cb-content-name">
                                 {{{ name }}}
                             </span>
                     {{#link}}
diff --git a/contentbank/templates/bankcontent/search.mustache b/contentbank/templates/bankcontent/search.mustache
new file mode 100644 (file)
index 0000000..8d02863
--- /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_contentbank/bankcontent/search
+
+    Example context (json):
+    {}
+
+}}
+<div class="searchbar input-group" role="search">
+    <label for="searchinput">
+        <span class="sr-only">{{#str}} searchcontentbankbyname, contentbank {{/str}}</span>
+    </label>
+    <input type="text"
+           id="searchinput"
+           class="form-control searchinput border-right-0"
+           placeholder="{{#str}} search, core {{/str}}"
+           name="search"
+           autocomplete="off"
+    >
+    <div class="input-group-append">
+        <div class="input-group-text bg-transparent">
+            <div class="search-icon">
+                <button class="btn p-0 align-baseline icon-no-margin" data-action="searchcontent"
+                    aria-label="{{#str}} search, core {{/str}}">
+                    <span class="d-flex" aria-hidden="true">{{#pix}} a/search, core {{/pix}}</span>
+                </button>
+            </div>
+            <div class="clear-icon d-none">
+                <button class="btn p-0 align-baseline icon-no-margin" data-action="clearsearchcontent"
+                    aria-label="{{#str}} clearsearch, core {{/str}}">
+                    <span class="d-flex" aria-hidden="true">{{#pix}} e/cancel_solid_circle, core {{/pix}}</span>
+                </button>
+            </div>
+        </div>
+    </div>
+</div>
\ No newline at end of file
similarity index 91%
rename from contentbank/templates/toolbar.mustache
rename to contentbank/templates/bankcontent/toolbar.mustache
index 9ab8024..88f4a4c 100644 (file)
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template core_contentbank/toolbar
+    @template core_contentbank/bankcontent/toolbar
 
     Example context (json):
     {
@@ -38,7 +38,7 @@
         {{#tools}}
             {{#link}}<a href="{{{ link }}}" title="{{{ name }}}">{{/link}}
                 <div class="cb-tool icon-no-margin btn btn-secondary btn-lg">
-                    {{#pix}} {{{ icon }}} {{/pix}}
+                    {{#pix}} {{{ icon }}} {{/pix}} <span class="sr-only">{{{ name }}}</span>
                 </div>
             {{#link}}</a>{{/link}}
         {{/tools}}
diff --git a/contentbank/tests/behat/search_content.feature b/contentbank/tests/behat/search_content.feature
new file mode 100644 (file)
index 0000000..940415f
--- /dev/null
@@ -0,0 +1,51 @@
+@core @core_contentbank @contentbank_h5p @_file_upload @javascript
+Feature: Search content in the content bank
+  In order to find easily content in the content bank
+  As an admin
+  I need to be able to search content in the content bank
+
+  Background:
+    Given the following "contentbank content" exist:
+        | contextid | contenttype       | user  | contentname          |
+        | 1         | contenttype_h5p   | admin | santjordi.h5p        |
+        | 1         | contenttype_h5p   | admin | santjordi_rose.h5p   |
+        | 1         | contenttype_h5p   | admin | SantJordi_book       |
+        | 1         | contenttype_h5p   | admin | Dragon_santjordi.h5p |
+        | 1         | contenttype_h5p   | admin | princess.h5p         |
+        | 1         | contenttype_h5p   | admin | mathsbook.h5p        |
+        | 1         | contenttype_h5p   | admin | historybook.h5p      |
+        | 1         | contenttype_h5p   | admin | santvicenc.h5p       |
+
+  Scenario: Admins can search content in the content bank
+    Given I log in as "admin"
+    And I am on site homepage
+    And I turn editing mode on
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I should see "santjordi.h5p"
+    And "Clear search input" "button" should not exist
+    And I should not see "items found"
+    When I set the field "Search" to "book"
+    Then "Clear search input" "button" should exist
+    And I should see "3 items found"
+    And I should see "SantJordi_book"
+    And I should see "mathsbook.h5p"
+    And I should see "historybook.h5p"
+    And I set the field "Search" to "sant"
+    And "Clear search input" "button" should exist
+    And I should see "5 items found"
+    And I set the field "Search" to "santjordi"
+    And I should see "4 items found"
+    And I should see "santjordi.h5p"
+    And I should see "santjordi_rose.h5p"
+    And I should see "SantJordi_book"
+    And I should see "Dragon_santjordi.h5p"
+    And I click on "Clear search input" "button"
+    And "Clear search input" "button" should not exist
+    And I should not see "items found"
+    And I set the field "Search" to ".h5p"
+    And "Clear search input" "button" should exist
+    And I should see "7 items found"
+    And I set the field "Search" to "friend"
+    And I should see "0 items found"
index 06958d6..e111a7c 100644 (file)
@@ -37,6 +37,7 @@ $string['deletecontent'] = 'Delete content';
 $string['deletecontentconfirm'] = 'Are you sure you want to delete the content <em>\'{$a->name}\'</em> and all associated files? This action cannot be undone.';
 $string['file'] = 'Upload content';
 $string['file_help'] = 'Files may be stored in the content bank for use in courses. Only files used by content types enabled on the site may be uploaded.';
+$string['itemsfound'] = '{$a} items found';
 $string['name'] = 'Content';
 $string['nopermissiontodelete'] = 'You do not have permission to delete content.';
 $string['nopermissiontomanage'] = 'You do not have permission to manage content.';
@@ -50,6 +51,7 @@ $string['privacy:metadata:contentbankcontent'] = 'Stores the content of the cont
 $string['privacy:metadata:userid'] = 'The ID of the user creating or modifying content bank content.';
 $string['rename'] = 'Rename';
 $string['renamecontent'] = 'Rename content';
+$string['searchcontentbankbyname'] = 'Search for content by name';
 $string['timecreated'] = 'Time created';
 $string['unsupported'] = 'This content type is not supported.';
 $string['upload'] = 'Upload';
index b6f91f1..691884e 100644 (file)
@@ -2359,6 +2359,12 @@ body.h5p-embed {
     word-break: break-all;
 }
 
+.matchtext {
+    background-color: lighten($primary, 40%);
+    color: $body-color;
+    height: 1.5rem;
+}
+
 // Emoji picker.
 $picker-width: 350px !default;
 $picker-width-xs: 320px !default;
index 2d523f7..c920380 100644 (file)
@@ -477,12 +477,6 @@ $message-day-color: color-yiq($message-app-bg) !default;
         }
     }
 
-    .matchtext {
-        background-color: lighten($primary, 40%);
-        color: $body-color;
-        height: 1.5rem;
-    }
-
     .contact-status {
         position: absolute;
         left: 39px;
index 74d9792..acda3e9 100644 (file)
@@ -11313,6 +11313,11 @@ body.h5p-embed .h5pmessages {
 .word-break-all {
   word-break: break-all; }
 
+.matchtext {
+  background-color: #b5d9f9;
+  color: #343a40;
+  height: 1.5rem; }
+
 .emoji-picker {
   width: 350px;
   height: 400px; }
@@ -14545,10 +14550,6 @@ a.ygtvspacer:hover {
     flex-shrink: 0; }
     .message-app .footer-container textarea {
       direction: ltr; }
-  .message-app .matchtext {
-    background-color: #b5d9f9;
-    color: #343a40;
-    height: 1.5rem; }
   .message-app .contact-status {
     position: absolute;
     left: 39px;
index 42a7c53..805cce7 100644 (file)
@@ -11525,6 +11525,11 @@ body.h5p-embed .h5pmessages {
 .word-break-all {
   word-break: break-all; }
 
+.matchtext {
+  background-color: #b5d9f9;
+  color: #343a40;
+  height: 1.5rem; }
+
 .emoji-picker {
   width: 350px;
   height: 400px; }
@@ -14761,10 +14766,6 @@ a.ygtvspacer:hover {
     flex-shrink: 0; }
     .message-app .footer-container textarea {
       direction: ltr; }
-  .message-app .matchtext {
-    background-color: #b5d9f9;
-    color: #343a40;
-    height: 1.5rem; }
   .message-app .contact-status {
     position: absolute;
     left: 39px;