MDL-67584 core_course: Activity chooser favouriting frontend
authorMathew May <mathewm@hotmail.co.nz>
Thu, 13 Feb 2020 04:15:24 +0000 (12:15 +0800)
committerMathew May <mathewm@hotmail.co.nz>
Thu, 27 Feb 2020 04:13:35 +0000 (12:13 +0800)
18 files changed:
course/amd/build/activitychooser.min.js
course/amd/build/activitychooser.min.js.map
course/amd/build/local/activitychooser/dialogue.min.js
course/amd/build/local/activitychooser/dialogue.min.js.map
course/amd/build/local/activitychooser/repository.min.js
course/amd/build/local/activitychooser/repository.min.js.map
course/amd/build/local/activitychooser/selectors.min.js
course/amd/build/local/activitychooser/selectors.min.js.map
course/amd/src/activitychooser.js
course/amd/src/local/activitychooser/dialogue.js
course/amd/src/local/activitychooser/repository.js
course/amd/src/local/activitychooser/selectors.js
course/templates/chooser.mustache
course/templates/chooser_favourites.mustache [new file with mode: 0644]
course/templates/chooser_item.mustache
course/tests/behat/activity_chooser.feature
lang/en/course.php
lib/classes/output/icon_system_fontawesome.php

index 8d1704d..114a575 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js and b/course/amd/build/activitychooser.min.js differ
index 45c977b..5dc3ca7 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js.map and b/course/amd/build/activitychooser.min.js.map differ
index b28b3db..03dfa69 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js and b/course/amd/build/local/activitychooser/dialogue.min.js differ
index 5ca8140..cdc15bc 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js.map and b/course/amd/build/local/activitychooser/dialogue.min.js.map differ
index a821358..e7e7505 100644 (file)
Binary files a/course/amd/build/local/activitychooser/repository.min.js and b/course/amd/build/local/activitychooser/repository.min.js differ
index 0d35705..e7bb504 100644 (file)
Binary files a/course/amd/build/local/activitychooser/repository.min.js.map and b/course/amd/build/local/activitychooser/repository.min.js.map differ
index d826dbf..73c917e 100644 (file)
Binary files a/course/amd/build/local/activitychooser/selectors.min.js and b/course/amd/build/local/activitychooser/selectors.min.js differ
index 229f9dd..b3e6a0c 100644 (file)
Binary files a/course/amd/build/local/activitychooser/selectors.min.js.map and b/course/amd/build/local/activitychooser/selectors.min.js.map differ
index 709cd91..6565e72 100644 (file)
@@ -78,11 +78,13 @@ const registerListenerEvents = (courseId) => {
     events.forEach((event) => {
         document.addEventListener(event, async(e) => {
             if (e.target.closest(selectors.elements.sectionmodchooser)) {
+                const data = await fetchModuleData();
                 const caller = e.target.closest(selectors.elements.sectionmodchooser);
-                const builtModuleData = sectionIdMapper(await fetchModuleData(), caller.dataset.sectionid);
+                const favouriteFunction = partiallyAppliedFavouriteManager(data, caller.dataset.sectionid);
+                const builtModuleData = sectionIdMapper(data, caller.dataset.sectionid);
                 const sectionModal = await modalBuilder(builtModuleData);
 
-                ChooserDialogue.displayChooser(caller, sectionModal, builtModuleData);
+                ChooserDialogue.displayChooser(caller, sectionModal, builtModuleData, favouriteFunction);
             }
         });
     });
@@ -111,7 +113,7 @@ const sectionIdMapper = (webServiceData, id) => {
  *
  * @method modalBuilder
  * @param {Map} data our map of section ID's & modules to generate modals for
- * @return {Object} TODO
+ * @return {Object} Our modal that we are going to show the user
  */
 const modalBuilder = data => buildModal(templateDataBuilder(data));
 
@@ -124,7 +126,7 @@ const modalBuilder = data => buildModal(templateDataBuilder(data));
  */
 const templateDataBuilder = (data) => {
     // Filter the incoming data to find favourite & recommended modules.
-    const favourites = [];
+    const favourites = data.filter(mod => mod.favourite === true);
     const recommended = data.filter(mod => mod.recommended === true);
 
     // Given the results of the above filters lets figure out what tab to set active.
@@ -164,3 +166,105 @@ const buildModal = data => {
         }
     });
 };
+
+/**
+ * A small helper function to handle the case where there are no more favourites
+ * and we need to mess a bit with the available tabs in the chooser
+ *
+ * @method nullFavouriteDomManager
+ * @param {HTMLElement} favouriteTabNav Dom node of the favourite tab nav
+ * @param {HTMLElement} modalBody Our current modals' body
+ */
+const nullFavouriteDomManager = (favouriteTabNav, modalBody) => {
+    favouriteTabNav.classList.add('d-none');
+    // Need to set active to an available tab.
+    if (favouriteTabNav.classList.contains('active')) {
+        favouriteTabNav.classList.remove('active');
+        const favouriteTab = modalBody.querySelector(selectors.regions.favouriteTab);
+        favouriteTab.classList.remove('active');
+        const recommendedTabNav = modalBody.querySelector(selectors.regions.recommendedTabNav);
+        const defaultTabNav = modalBody.querySelector(selectors.regions.defaultTabNav);
+        if (recommendedTabNav.classList.contains('d-none') === false) {
+            recommendedTabNav.classList.add('active');
+            const recommendedTab = modalBody.querySelector(selectors.regions.recommendedTab);
+            recommendedTab.classList.add('active');
+        } else {
+            defaultTabNav.classList.add('active');
+            const defaultTab = modalBody.querySelector(selectors.regions.defaultTab);
+            defaultTab.classList.add('active');
+        }
+
+    }
+};
+
+/**
+ * Export a curried function where the builtModules has been applied.
+ * We have our array of modules so we can rerender the favourites area and have all of the items sorted.
+ *
+ * @method partiallyAppliedFavouriteManager
+ * @param {Array} moduleData This is our raw WS data that we need to manipulate
+ * @param {Number} sectionId We need this to add the sectionID to the URL's in the faves area after rerender
+ * @return {Function} partially applied function so we can manipulate DOM nodes easily & update our internal array
+ */
+const partiallyAppliedFavouriteManager = (moduleData, sectionId) => {
+    /**
+     * Curried function that is being returned.
+     *
+     * @param {String} internal Internal name of the module to manage
+     * @param {Boolean} favourite Is the caller adding a favourite or removing one?
+     * @param {HTMLElement} modalBody What we need to update whilst we are here
+     */
+    return async(internal, favourite, modalBody) => {
+        const favouriteArea = modalBody.querySelector(selectors.render.favourites);
+
+        // eslint-disable-next-line max-len
+        const favouriteButtons = modalBody.querySelectorAll(`[data-internal="${internal}"] ${selectors.actions.optionActions.manageFavourite}`);
+        const favouriteTabNav = modalBody.querySelector(selectors.regions.favouriteTabNav);
+        const result = moduleData.content_items.find(({name}) => name === internal);
+        const newFaves = {};
+        if (result) {
+            if (favourite) {
+                result.favourite = true;
+
+                newFaves.content_items = moduleData.content_items.filter(mod => mod.favourite === true);
+
+                const builtFaves = sectionIdMapper(newFaves, sectionId);
+
+                const {html, js} = await Templates.renderForPromise('core_course/chooser_favourites', {favourites: builtFaves});
+
+                await Templates.replaceNodeContents(favouriteArea, html, js);
+
+                Array.from(favouriteButtons).forEach((element) => {
+                    element.classList.remove('text-muted');
+                    element.classList.add('text-primary');
+                    element.dataset.favourited = 'true';
+                    element.setAttribute('aria-pressed', true);
+                    element.firstElementChild.classList.remove('fa-star-o');
+                    element.firstElementChild.classList.add('fa-star');
+                });
+
+                favouriteTabNav.classList.remove('d-none');
+            } else {
+                result.favourite = false;
+
+                const nodeToRemove = favouriteArea.querySelector(`[data-internal="${internal}"]`);
+
+                nodeToRemove.parentNode.removeChild(nodeToRemove);
+
+                Array.from(favouriteButtons).forEach((element) => {
+                    element.classList.add('text-muted');
+                    element.classList.remove('text-primary');
+                    element.dataset.favourited = 'false';
+                    element.setAttribute('aria-pressed', false);
+                    element.firstElementChild.classList.remove('fa-star');
+                    element.firstElementChild.classList.add('fa-star-o');
+                });
+                const newFaves = moduleData.content_items.filter(mod => mod.favourite === true);
+
+                if (newFaves.length === 0) {
+                    nullFavouriteDomManager(favouriteTabNav, modalBody);
+                }
+            }
+        }
+    };
+};
index f443d9a..9d7b73c 100644 (file)
@@ -28,6 +28,8 @@ import selectors from 'core_course/local/activitychooser/selectors';
 import * as Templates from 'core/templates';
 import {end, arrowLeft, arrowRight, home, enter, space} from 'core/key_codes';
 import {addIconToContainer} from 'core/loadingicon';
+import * as Repository from 'core_course/local/activitychooser/repository';
+import Notification from 'core/notification';
 
 /**
  * Given an event from the main module 'page' navigate to it's help section via a carousel.
@@ -69,14 +71,42 @@ const showModuleHelp = (carousel, moduleData) => {
     carousel.carousel('next');
 };
 
+/**
+ * Given a user wants to change the favourite state of a module we either add or remove the status.
+ * We also propergate this change across our map of modals.
+ *
+ * @method manageFavouriteState
+ * @param {HTMLElement} modalBody The DOM node of the modal to manipulate
+ * @param {HTMLElement} caller
+ * @param {Function} partialFavourite Partially applied function we need to manage favourite status
+ */
+const manageFavouriteState = async(modalBody, caller, partialFavourite) => {
+    const isFavourite = caller.dataset.favourited;
+    const id = caller.dataset.id;
+    const name = caller.dataset.name;
+    const internal = caller.dataset.internal;
+    // Switch on fave or not.
+    if (isFavourite === 'true') {
+        await Repository.unfavouriteModule(name, id);
+
+        partialFavourite(internal, false, modalBody);
+    } else {
+        await Repository.favouriteModule(name, id);
+
+        partialFavourite(internal, true, modalBody);
+    }
+
+};
+
 /**
  * Register chooser related event listeners.
  *
  * @method registerListenerEvents
  * @param {Promise} modal Our modal that we are working with
  * @param {Map} mappedModules A map of all of the modules we are working with with K: mod_name V: {Object}
+ * @param {Function} partialFavourite Partially applied function we need to manage favourite status
  */
-const registerListenerEvents = (modal, mappedModules) => {
+const registerListenerEvents = (modal, mappedModules, partialFavourite) => {
     const bodyClickListener = e => {
         if (e.target.closest(selectors.actions.optionActions.showSummary)) {
             const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
@@ -87,6 +117,11 @@ const registerListenerEvents = (modal, mappedModules) => {
             showModuleHelp(carousel, moduleData);
         }
 
+        if (e.target.closest(selectors.actions.optionActions.manageFavourite)) {
+            const caller = e.target.closest(selectors.actions.optionActions.manageFavourite);
+            manageFavouriteState(modal.getBody()[0], caller, partialFavourite);
+        }
+
         // From the help screen go back to the module overview.
         if (e.target.matches(selectors.actions.closeOption)) {
             const carousel = $(modal.getBody()[0].querySelector(selectors.regions.carousel));
@@ -273,18 +308,22 @@ const focusChooserOption = (currentChooserOption, previousChooserOption = false)
     if (previousChooserOption !== false) {
         const previousChooserOptionLink = previousChooserOption.querySelector(selectors.actions.addChooser);
         const previousChooserOptionHelp = previousChooserOption.querySelector(selectors.actions.optionActions.showSummary);
+        const previousChooserOptionFavourite = previousChooserOption.querySelector(selectors.actions.optionActions.manageFavourite);
         // Set tabindex to -1 to remove the previous chooser option element from the focus order.
         previousChooserOption.tabIndex = -1;
         previousChooserOptionLink.tabIndex = -1;
         previousChooserOptionHelp.tabIndex = -1;
+        previousChooserOptionFavourite.tabIndex = -1;
     }
 
     const currentChooserOptionLink = currentChooserOption.querySelector(selectors.actions.addChooser);
     const currentChooserOptionHelp = currentChooserOption.querySelector(selectors.actions.optionActions.showSummary);
+    const currentChooserOptionFavourite = currentChooserOption.querySelector(selectors.actions.optionActions.manageFavourite);
     // Set tabindex to 0 to add current chooser option element to the focus order.
     currentChooserOption.tabIndex = 0;
     currentChooserOptionLink.tabIndex = 0;
     currentChooserOptionHelp.tabIndex = 0;
+    currentChooserOptionFavourite.tabIndex = 0;
     // Focus the current chooser option element.
     currentChooserOption.focus();
 };
@@ -312,8 +351,9 @@ const clickErrorHandler = (item, fallback) => {
  * @param {HTMLElement} origin The calling button
  * @param {Object} modal Our created modal for the section
  * @param {Array} sectionModules An array of all of the built module information
+ * @param {Function} partialFavourite Partially applied function we need to manage favourite status
  */
-export const displayChooser = (origin, modal, sectionModules) => {
+export const displayChooser = (origin, modal, sectionModules, partialFavourite) => {
 
     // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
     const mappedModules = new Map();
@@ -322,7 +362,7 @@ export const displayChooser = (origin, modal, sectionModules) => {
     });
 
     // Register event listeners.
-    registerListenerEvents(modal, mappedModules);
+    registerListenerEvents(modal, mappedModules, partialFavourite);
 
     // We want to focus on the action select when the dialog is closed.
     modal.getRoot().on(ModalEvents.hidden, () => {
index e6a09c5..52c5444 100644 (file)
@@ -38,3 +38,43 @@ export const activityModules = (courseid) => {
     };
     return ajax.call([request])[0];
 };
+
+/**
+ * Given a module name, module ID & the current course we want to specify that the module
+ * is a users' favourite.
+ *
+ * @method favouriteModule
+ * @param {String} modName Frankenstyle name of the component to add favourite
+ * @param {int} modID ID of the module. Mainly for LTI cases where they have same / similar names
+ * @return {object} jQuery promise
+ */
+export const favouriteModule = (modName, modID) => {
+    const request = {
+        methodname: 'core_course_add_content_item_to_user_favourites',
+        args: {
+            componentname: modName,
+            contentitemid: modID,
+        },
+    };
+    return ajax.call([request])[0];
+};
+
+/**
+ * Given a module name, module ID & the current course we want to specify that the module
+ * is no longer a users' favourite.
+ *
+ * @method unfavouriteModule
+ * @param {String} modName Frankenstyle name of the component to add favourite
+ * @param {int} modID ID of the module. Mainly for LTI cases where they have same / similar names
+ * @return {object} jQuery promise
+ */
+export const unfavouriteModule = (modName, modID) => {
+    const request = {
+        methodname: 'core_course_remove_content_item_from_user_favourites',
+        args: {
+            componentname: modName,
+            contentitemid: modID,
+        },
+    };
+    return ajax.call([request])[0];
+};
index f00b620..f2f10ea 100644 (file)
@@ -62,11 +62,15 @@ export default {
     actions: {
         optionActions: {
             showSummary: getDataSelector('action', 'show-option-summary'),
+            manageFavourite: getDataSelector('action', 'manage-module-favourite'),
         },
         addChooser: getDataSelector('action', 'add-chooser-option'),
         closeOption: getDataSelector('action', 'close-chooser-option-summary'),
         hide: getDataSelector('action', 'hide')
     },
+    render: {
+        favourites: getDataSelector('render', 'favourites-area'),
+    },
     elements: {
         section: '.section',
         sectionmodchooser: 'button.section-modchooser-link',
index 46c0762..14667b9 100644 (file)
@@ -85,9 +85,7 @@
                 <div class="tab-pane {{#favouritesFirst}}active{{/favouritesFirst}}" id="starred-{{uniqid}}" data-region="favourites" role="tabpanel" aria-labelledby="starred-tab-{{uniqid}}">
                     <div class="modchoosercontainer" data-region="chooser-container" aria-label="{{#str}} activitymodules {{/str}}">
                         <div class="optionscontainer d-flex flex-wrap mw-100 p-3 position-relative" role="menubar" data-region="chooser-options-container" data-render="favourites-area">
-                            {{#favourites}}
-                                {{>core_course/chooser_item}}
-                            {{/favourites}}
+                            {{>core_course/chooser_favourites}}
                         </div>
                     </div>
                 </div>
diff --git a/course/templates/chooser_favourites.mustache b/course/templates/chooser_favourites.mustache
new file mode 100644 (file)
index 0000000..9d4db58
--- /dev/null
@@ -0,0 +1,36 @@
+{{!
+    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_course/chooser_favourites
+
+    Chooser favourite template partial.
+
+    Example context (json):
+    {
+        "favourites": {
+            "label": "Option name",
+            "description": "Option description",
+            "urls": {
+                "addoption": "http://addoptionurl.com"
+            },
+            "icon": "<img class='icon' src='http://urltooptionicon'>"
+        }
+    }
+}}
+{{#favourites}}
+    {{>core_course/chooser_item}}
+{{/favourites}}
index dd75b6a..105717a 100644 (file)
@@ -29,7 +29,7 @@
         "icon": "<img class='icon' src='http://urltooptionicon'>"
     }
 }}
-<div role="menuitem" tabindex="-1" aria-label="{{label}}" class="option d-block text-center py-3 px-2" data-region="chooser-option-container" data-modname="{{componentname}}_{{link}}">
+<div role="menuitem" tabindex="-1" aria-label="{{title}}" class="option d-block text-center py-3 px-2" 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">
             <span class="optionname d-block">{{title}}</span>
         </a>
         <div class="optionactions btn-group" role="group" data-region="chooser-option-actions-container">
+            {{^legacyitem}}
+                <button class="btn btn-icon icon-no-margin icon-size-3 m-0 optionaction {{#favourite}}text-primary{{/favourite}}{{^favourite}}text-muted{{/favourite}}"
+                        data-action="manage-module-favourite"
+                        data-favourited="{{favourite}}"
+                        data-id="{{id}}"
+                        data-name="{{componentname}}"
+                        data-internal="{{name}}"
+                        {{^favourite}}
+                            aria-pressed="false"
+                        {{/favourite}}
+                        {{#favourite}}
+                            aria-pressed="true"
+                        {{/favourite}}
+                        aria-label="{{#str}} aria:modulefavourite, core_course, {{title}} {{/str}}"
+                        tabindex="-1"
+                >
+                    {{#favourite}}
+                        {{#pix}} i/star, core {{/pix}}
+                    {{/favourite}}
+                    {{^favourite}}
+                        {{#pix}} i/star-o, core {{/pix}}
+                    {{/favourite}}
+                </button>
+            {{/legacyitem}}
             <button class="btn btn-icon icon-no-margin icon-size-3 m-0 optionaction" data-action="show-option-summary" tabindex="-1">
                 <span aria-hidden="true">{{#pix}} docs, core {{/pix}}</span>
                 <span class="sr-only">{{#str}} informationformodule, core_course, {{title}} {{/str}}</span>
index 9fdf2af..5ad55b1 100644 (file)
@@ -68,3 +68,30 @@ Feature: Display and choose from the available activities in course
     Then I should see "Recommended" in the "Add an activity or resource" "dialogue"
     And I click on "Recommended" "link" in the "Add an activity or resource" "dialogue"
     And I should see "Book" in the "recommended" "core_course > Activity chooser tab"
+
+  Scenario: Favourite a module in the activity chooser
+    Given I open the activity chooser
+    And I should not see "Starred" in the "Add an activity or resource" "dialogue"
+    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
+    And I should see "Starred" in the "Add an activity or resource" "dialogue"
+    When I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
+    Then I should see "Assignment" in the "favourites" "core_course > Activity chooser tab"
+    And I click on "Information about the Assignment activity" "button" in the "favourites" "core_course > Activity chooser tab"
+    And I should see "The assignment activity module enables a teacher to communicate tasks, collect work and provide grades and feedback."
+
+  Scenario: Add a favourite module and check it exists when reopening the chooser
+    Given I open the activity chooser
+    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
+    And I click on "Star Forum module" "button" in the "Add an activity or resource" "dialogue"
+    And I should see "Starred" in the "Add an activity or resource" "dialogue"
+    And I click on "Close" "button" in the "Add an activity or resource" "dialogue"
+    When I click on "Add an activity or resource" "button" in the "Topic 3" "section"
+    And I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
+    Then I should see "Forum" in the "favourites" "core_course > Activity chooser tab"
+
+  Scenario: Add a favourite and then remove it whilst checking the tabs work as expected
+    Given I open the activity chooser
+    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
+    And I click on "Starred" "link" in the "Add an activity or resource" "dialogue"
+    And I click on "Star Assignment module" "button" in the "Add an activity or resource" "dialogue"
+    Then I should not see "Starred" in the "Add an activity or resource" "dialogue"
index 6a9c3b1..b4c1969 100644 (file)
@@ -31,6 +31,7 @@ $string['aria:defaulttab'] = 'The default modules';
 $string['aria:favourite'] = 'Course is starred';
 $string['aria:favouritestab'] = 'Your starred modules';
 $string['aria:recommendedtab'] = 'The recommended modules';
+$string['aria:modulefavourite'] = 'Star {$a} module';
 $string['coursealreadyfinished'] = 'Course already finished';
 $string['coursenotyetstarted'] = 'The course has not yet started';
 $string['coursenotyetfinished'] = 'The course has not yet finished';
index c9dca91..74fab98 100644 (file)
@@ -320,6 +320,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:i/show' => 'fa-eye-slash',
             'core:i/siteevent' => 'fa-globe',
             'core:i/star' => 'fa-star',
+            'core:i/star-o' => 'fa-star-o',
             'core:i/star-rating' => 'fa-star',
             'core:i/stats' => 'fa-line-chart',
             'core:i/switch' => 'fa-exchange',