MDL-62514 behat: Rewrite handling of autocomplete
authorAndrew Nicols <andrew@nicols.co.uk>
Mon, 21 May 2018 23:49:14 +0000 (07:49 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 30 Jan 2019 00:24:32 +0000 (08:24 +0800)
This includes a minor restructure of the autocomplete JS to make use of
promises and improve tracking of pending JS.

In particular it improves the way in which throttled text input is
handled to ensure that the behat does not continue until:
- typing is fully complete; and
- all possible ajax requests have been sent; and
- all possible ajax requests complete; and
- the suggestions are updated.

A number of conditions existed where behat would move on to the next
step too early in a race condition effect between Behat and Autocomplete.

lib/amd/build/form-autocomplete.min.js
lib/amd/src/form-autocomplete.js
lib/behat/form_field/behat_form_autocomplete.php
lib/templates/form_autocomplete_input.mustache
mod/feedback/tests/behat/coursemapping.feature
theme/boost/templates/core/form_autocomplete_input.mustache
theme/upgrade.txt

index e66e5ff..830d6fb 100644 (file)
Binary files a/lib/amd/build/form-autocomplete.min.js and b/lib/amd/build/form-autocomplete.min.js differ
index c63e561..90275cb 100644 (file)
@@ -46,6 +46,7 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @private
      * @param {Number} index The index in the current (visible) list of selection.
      * @param {Object} state State variables for this autocomplete element.
+     * @return {Promise}
      */
     var activateSelection = function(index, state) {
         // Find the elements in the DOM.
@@ -69,6 +70,8 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         element.attr('data-active-selection', true).attr('id', itemId);
         // Tell the input field it has a new active descendant so the item is announced.
         selectionElement.attr('aria-activedescendant', itemId);
+
+        return $.Deferred().resolve();
     };
 
     /**
@@ -79,8 +82,12 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @param {Object} options Original options for this autocomplete element.
      * @param {Object} state State variables for this autocomplete element.
      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
+     * @return {Promise}
      */
     var updateSelectionList = function(options, state, originalSelect) {
+        var pendingKey = 'form-autocomplete-updateSelectionList-' + state.inputId;
+        M.util.js_pending(pendingKey);
+
         // Build up a valid context to re-render the template.
         var items = [];
         var newSelection = $(document.getElementById(state.selectionId));
@@ -104,9 +111,10 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         var context = $.extend({items: items}, options, state);
 
         // Render the template.
-        templates.render('core/form_autocomplete_selection', context).done(function(newHTML) {
+        return templates.render('core/form_autocomplete_selection', context)
+        .then(function(html, js) {
             // Add it to the page.
-            newSelection.empty().append($(newHTML).html());
+            templates.replaceNodeContents(newSelection, html, js);
 
             if (activeValue !== false) {
                 // Reselect any previously selected item.
@@ -116,7 +124,13 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
                     }
                 });
             }
-        }).fail(notification.exception);
+
+            return activeValue;
+        })
+        .then(function() {
+            return M.util.js_complete(pendingKey);
+        })
+        .catch(notification.exception);
     };
 
     /**
@@ -140,6 +154,7 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @param {Object} state State variables for this autocomplete element.
      * @param {Element} item The item to be deselected.
      * @param {Element} originalSelect The original select list.
+     * @return {Promise}
      */
     var deselectItem = function(options, state, item, originalSelect) {
         var selectedItemValue = $(item).attr('data-value');
@@ -158,9 +173,13 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
             });
         }
         // Rerender the selection list.
-        updateSelectionList(options, state, originalSelect);
-        // Notifiy that the selection changed.
-        notifyChange(originalSelect);
+        return updateSelectionList(options, state, originalSelect)
+        .then(function() {
+            // Notify that the selection changed.
+            notifyChange(originalSelect);
+
+            return;
+        });
     };
 
     /**
@@ -170,6 +189,7 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @private
      * @param {Number} index The index in the current (visible) list of suggestions.
      * @param {Object} state State variables for this instance of autocomplete.
+     * @return {Promise}
      */
     var activateItem = function(index, state) {
         // Find the elements in the DOM.
@@ -202,9 +222,9 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
                        - suggestionsElement.offset().top
                        + suggestionsElement.scrollTop()
                        - (suggestionsElement.height() / 2);
-        suggestionsElement.animate({
+        return suggestionsElement.animate({
             scrollTop: scrollPos
-        }, 100);
+        }, 100).promise();
     };
 
     /**
@@ -213,6 +233,7 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @method activateNextItem
      * @private
      * @param {Object} state State variable for this auto complete element.
+     * @return {Promise}
      */
     var activateNextItem = function(state) {
         // Find the list of suggestions.
@@ -222,7 +243,7 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         // Find it's index.
         var current = suggestionsElement.children('[aria-hidden=false]').index(element);
         // Activate the next one.
-        activateItem(current + 1, state);
+        return activateItem(current + 1, state);
     };
 
     /**
@@ -231,6 +252,7 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @method activatePreviousSelection
      * @private
      * @param {Object} state State variables for this instance of autocomplete.
+     * @return {Promise}
      */
     var activatePreviousSelection = function(state) {
         // Find the list of selections.
@@ -238,34 +260,40 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         // Find the active one.
         var element = selectionsElement.children('[data-active-selection=true]');
         if (!element) {
-            activateSelection(0, state);
-            return;
+            return activateSelection(0, state);
         }
         // Find it's index.
         var current = selectionsElement.children('[aria-selected=true]').index(element);
         // Activate the next one.
-        activateSelection(current - 1, state);
+        return activateSelection(current - 1, state);
     };
+
     /**
      * Find the index of the current active selection, and activate the next one.
      *
      * @method activateNextSelection
      * @private
      * @param {Object} state State variables for this instance of autocomplete.
+     * @return {Promise}
      */
     var activateNextSelection = function(state) {
         // Find the list of selections.
         var selectionsElement = $(document.getElementById(state.selectionId));
+
         // Find the active one.
         var element = selectionsElement.children('[data-active-selection=true]');
-        if (!element) {
-            activateSelection(0, state);
-            return;
+        var current = 0;
+
+        if (element) {
+            // The element was found. Determine the index and move to the next one.
+            current = selectionsElement.children('[aria-selected=true]').index(element);
+            current = current + 1;
+        } else {
+            // No selected item found. Move to the first.
+            current = 0;
         }
-        // Find it's index.
-        var current = selectionsElement.children('[aria-selected=true]').index(element);
-        // Activate the next one.
-        activateSelection(current + 1, state);
+
+        return activateSelection(current, state);
     };
 
     /**
@@ -274,16 +302,20 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @method activatePreviousItem
      * @private
      * @param {Object} state State variables for this autocomplete element.
+     * @return {Promise}
      */
     var activatePreviousItem = function(state) {
         // Find the list of suggestions.
         var suggestionsElement = $(document.getElementById(state.suggestionsId));
+
         // Find the active one.
         var element = suggestionsElement.children('[aria-selected=true]');
+
         // Find it's index.
         var current = suggestionsElement.children('[aria-hidden=false]').index(element);
-        // Activate the next one.
-        activateItem(current - 1, state);
+
+        // Activate the previous one.
+        return activateItem(current - 1, state);
     };
 
     /**
@@ -292,6 +324,7 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @method closeSuggestions
      * @private
      * @param {Object} state State variables for this autocomplete element.
+     * @return {Promise}
      */
     var closeSuggestions = function(state) {
         // Find the elements in the DOM.
@@ -300,8 +333,11 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
 
         // Announce the list of suggestions was closed, and read the current list of selections.
         inputElement.attr('aria-expanded', false).attr('aria-activedescendant', state.selectionId);
+
         // Hide the suggestions list (from screen readers too).
         suggestionsElement.hide().attr('aria-hidden', true);
+
+        return $.Deferred().resolve();
     };
 
     /**
@@ -313,8 +349,12 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @param {Object} state The state variables for this autocomplete.
      * @param {String} query The current text for the search string.
      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
+     * @return {Promise}
      */
     var updateSuggestions = function(options, state, query, originalSelect) {
+        var pendingKey = 'form-autocomplete-updateSuggestions-' + state.inputId;
+        M.util.js_pending(pendingKey);
+
         // Find the elements in the DOM.
         var inputElement = $(document.getElementById(state.inputId));
         var suggestionsElement = $(document.getElementById(state.suggestionsId));
@@ -332,12 +372,14 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         // Re-render the list of suggestions.
         var searchquery = state.caseSensitive ? query : query.toLocaleLowerCase();
         var context = $.extend({options: suggestions}, options, state);
-        templates.render(
+        var returnVal = templates.render(
             'core/form_autocomplete_suggestions',
             context
-        ).done(function(newHTML) {
+        )
+        .then(function(html, js) {
             // We have the new template, insert it in the page.
-            suggestionsElement.replaceWith(newHTML);
+            templates.replaceNode(suggestionsElement, html, js);
+
             // Get the element again.
             suggestionsElement = $(document.getElementById(state.suggestionsId));
             // Show it if it is hidden.
@@ -371,8 +413,15 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
                     suggestionsElement.html(nosuggestionsstr);
                 });
             }
-        }).fail(notification.exception);
 
+            return suggestionsElement;
+        })
+        .then(function() {
+            return M.util.js_complete(pendingKey);
+        })
+        .catch(notification.exception);
+
+        return returnVal;
     };
 
     /**
@@ -383,6 +432,7 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @param {Object} options The original options for the autocomplete.
      * @param {Object} state State variables for the autocomplete.
      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
+     * @return {Promise}
      */
     var createItem = function(options, state, originalSelect) {
         // Find the element in the DOM.
@@ -419,13 +469,23 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
             }
         });
 
-        updateSelectionList(options, state, originalSelect);
-        // Notifiy that the selection changed.
-        notifyChange(originalSelect);
-        // Clear the input field.
-        inputElement.val('');
-        // Close the suggestions list.
-        closeSuggestions(state);
+        return updateSelectionList(options, state, originalSelect)
+        .then(function() {
+            // Notify that the selection changed.
+            notifyChange(originalSelect);
+
+            return;
+        })
+        .then(function() {
+            // Clear the input field.
+            inputElement.val('');
+
+            return;
+        })
+        .then(function() {
+            // Close the suggestions list.
+            return closeSuggestions(state);
+        });
     };
 
     /**
@@ -436,6 +496,7 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @param {Object} options The original options for the autocomplete.
      * @param {Object} state State variables for the autocomplete.
      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
+     * @return {Promise}
      */
     var selectCurrentItem = function(options, state, originalSelect) {
         // Find the elements in the page.
@@ -458,22 +519,26 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
             }
         });
 
-        // Rerender the selection list.
-        updateSelectionList(options, state, originalSelect);
-        // Notifiy that the selection changed.
-        notifyChange(originalSelect);
+        return updateSelectionList(options, state, originalSelect)
+        .then(function() {
+            // Notify that the selection changed.
+            notifyChange(originalSelect);
 
-        if (options.closeSuggestionsOnSelect) {
-            // Clear the input element.
-            inputElement.val('');
-            // Close the list of suggestions.
-            closeSuggestions(state);
-        } else {
-            // Focus on the input element so the suggestions does not auto-close.
-            inputElement.focus();
-            // Remove the last selected item from the suggestions list.
-            updateSuggestions(options, state, inputElement.val(), originalSelect);
-        }
+            return;
+        })
+        .then(function() {
+            if (options.closeSuggestionsOnSelect) {
+                // Clear the input element.
+                inputElement.val('');
+                // Close the list of suggestions.
+                return closeSuggestions(state);
+            } else {
+                // Focus on the input element so the suggestions does not auto-close.
+                inputElement.focus();
+                // Remove the last selected item from the suggestions list.
+                return updateSuggestions(options, state, inputElement.val(), originalSelect);
+            }
+        });
     };
 
     /**
@@ -486,10 +551,11 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
      * @param {Object} state The state variables for the autocomplete.
      * @param {JQuery} originalSelect The JQuery object matching the hidden select list.
      * @param {Object} ajaxHandler This is a module that does the ajax fetch and translates the results.
+     * @return {Promise}
      */
     var updateAjax = function(e, options, state, originalSelect, ajaxHandler) {
-        var pendingKey = 'form-autocomplete-updateajax';
-        M.util.js_pending(pendingKey);
+        var pendingPromise = addPendingJSPromise('updateAjax');
+
         // Get the query to pass to the ajax function.
         var query = $(e.currentTarget).val();
         // Call the transport function to do the ajax (name taken from Select2).
@@ -531,12 +597,12 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
                 originalSelect.attr('data-notice', processedResults);
             }
             // Update the list of suggestions now from the new values in the select list.
-            updateSuggestions(options, state, '', originalSelect);
-            M.util.js_complete(pendingKey);
+            pendingPromise.resolve(updateSuggestions(options, state, '', originalSelect));
         }, function(error) {
-            M.util.js_complete(pendingKey);
-            notification.exception(error);
+            pendingPromise.reject(error);
         });
+
+        return pendingPromise;
     };
 
     /**
@@ -553,132 +619,123 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         var inputElement = $(document.getElementById(state.inputId));
         // Add keyboard nav with keydown.
         inputElement.on('keydown', function(e) {
-            var pendingKey = 'form-autocomplete-addnav-' + state.inputId + '-' + e.keyCode;
-            M.util.js_pending(pendingKey);
+            var pendingJsPromise = addPendingJSPromise('addNavigation-' + state.inputId + '-' + e.keyCode);
 
             switch (e.keyCode) {
                 case KEYS.DOWN:
                     // If the suggestion list is open, move to the next item.
                     if (!options.showSuggestions) {
                         // Do not consume this event.
-                        M.util.js_complete(pendingKey);
+                        pendingJsPromise.resolve();
                         return true;
                     } else if (inputElement.attr('aria-expanded') === "true") {
-                        activateNextItem(state);
+                        pendingJsPromise.resolve(activateNextItem(state));
                     } else {
                         // Handle ajax population of suggestions.
                         if (!inputElement.val() && options.ajax) {
                             require([options.ajax], function(ajaxHandler) {
-                                updateAjax(e, options, state, originalSelect, ajaxHandler);
+                                pendingJsPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
                             });
                         } else {
                             // Open the suggestions list.
-                            updateSuggestions(options, state, inputElement.val(), originalSelect);
+                            pendingJsPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
                         }
                     }
                     // We handled this event, so prevent it.
                     e.preventDefault();
-                    M.util.js_complete(pendingKey);
                     return false;
                 case KEYS.UP:
                     // Choose the previous active item.
-                    activatePreviousItem(state);
+                    pendingJsPromise.resolve(activatePreviousItem(state));
+
                     // We handled this event, so prevent it.
                     e.preventDefault();
-                    M.util.js_complete(pendingKey);
                     return false;
                 case KEYS.ENTER:
                     var suggestionsElement = $(document.getElementById(state.suggestionsId));
                     if ((inputElement.attr('aria-expanded') === "true") &&
                             (suggestionsElement.children('[aria-selected=true]').length > 0)) {
                         // If the suggestion list has an active item, select it.
-                        selectCurrentItem(options, state, originalSelect);
+                        pendingJsPromise.resolve(selectCurrentItem(options, state, originalSelect));
                     } else if (options.tags) {
                         // If tags are enabled, create a tag.
-                        createItem(options, state, originalSelect);
+                        pendingJsPromise.resolve(createItem(options, state, originalSelect));
+                    } else {
+                        pendingJsPromise.resolve();
                     }
+
                     // We handled this event, so prevent it.
                     e.preventDefault();
-                    M.util.js_complete(pendingKey);
                     return false;
                 case KEYS.ESCAPE:
                     if (inputElement.attr('aria-expanded') === "true") {
                         // If the suggestion list is open, close it.
-                        closeSuggestions(state);
+                        pendingJsPromise.resolve(closeSuggestions(state));
+                    } else {
+                        pendingJsPromise.resolve();
                     }
                     // We handled this event, so prevent it.
                     e.preventDefault();
-                    M.util.js_complete(pendingKey);
                     return false;
             }
-            M.util.js_complete(pendingKey);
+            pendingJsPromise.resolve();
             return true;
         });
         // Support multi lingual COMMA keycode (44).
         inputElement.on('keypress', function(e) {
-            var pendingKey = 'form-autocomplete-keypress-' + e.keyCode;
-            M.util.js_pending(pendingKey);
+
             if (e.keyCode === KEYS.COMMA) {
                 if (options.tags) {
                     // If we are allowing tags, comma should create a tag (or enter).
-                    createItem(options, state, originalSelect);
+                    addPendingJSPromise('keypress-' + e.keyCode)
+                    .resolve(createItem(options, state, originalSelect));
                 }
                 // We handled this event, so prevent it.
                 e.preventDefault();
-                M.util.js_complete(pendingKey);
                 return false;
             }
-            M.util.js_complete(pendingKey);
             return true;
         });
-        // Handler used to force set the value from behat.
-        inputElement.on('behat:set-value', function() {
-            var suggestionsElement = $(document.getElementById(state.suggestionsId));
-            var pendingKey = 'form-autocomplete-behat';
-            M.util.js_pending(pendingKey);
-            if ((inputElement.attr('aria-expanded') === "true") &&
-                    (suggestionsElement.children('[aria-selected=true]').length > 0)) {
-                // If the suggestion list has an active item, select it.
-                selectCurrentItem(options, state, originalSelect);
-            } else if (options.tags) {
-                // If tags are enabled, create a tag.
-                createItem(options, state, originalSelect);
-            }
-            M.util.js_complete(pendingKey);
-        });
         inputElement.on('blur', function() {
-            var pendingKey = 'form-autocomplete-blur';
-            M.util.js_pending(pendingKey);
+            var pendingPromise = addPendingJSPromise('form-autocomplete-blur');
             window.setTimeout(function() {
                 // Get the current element with focus.
                 var focusElement = $(document.activeElement);
+
                 // Only close the menu if the input hasn't regained focus.
                 if (focusElement.attr('id') != inputElement.attr('id')) {
                     if (options.tags) {
-                        createItem(options, state, originalSelect);
+                        pendingPromise.then(function() {
+                            return createItem(options, state, originalSelect);
+                        })
+                        .catch();
                     }
-                    closeSuggestions(state);
+                    pendingPromise.then(function() {
+                        return closeSuggestions(state);
+                    })
+                    .catch();
                 }
-                M.util.js_complete(pendingKey);
+
+                pendingPromise.resolve();
             }, 500);
         });
         if (options.showSuggestions) {
             var arrowElement = $(document.getElementById(state.downArrowId));
             arrowElement.on('click', function(e) {
-                var pendingKey = 'form-autocomplete-show-suggestions';
-                M.util.js_pending(pendingKey);
+                var pendingPromise = addPendingJSPromise('form-autocomplete-show-suggestions');
+
                 // Prevent the close timer, or we will open, then close the suggestions.
                 inputElement.focus();
+
                 // Handle ajax population of suggestions.
                 if (!inputElement.val() && options.ajax) {
                     require([options.ajax], function(ajaxHandler) {
-                        updateAjax(e, options, state, originalSelect, ajaxHandler);
+                        pendingPromise.resolve(updateAjax(e, options, state, originalSelect, ajaxHandler));
                     });
                 } else {
                     // Else - open the suggestions list.
-                    updateSuggestions(options, state, inputElement.val(), originalSelect);
+                    pendingPromise.resolve(updateSuggestions(options, state, inputElement.val(), originalSelect));
                 }
-                M.util.js_complete(pendingKey);
             });
         }
 
@@ -686,63 +743,65 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         // Remove any click handler first.
         suggestionsElement.parent().prop("onclick", null).off("click");
         suggestionsElement.parent().on('click', '[role=option]', function(e) {
-            var pendingKey = 'form-autocomplete-parent';
-            M.util.js_pending(pendingKey);
+            var pendingPromise = addPendingJSPromise('form-autocomplete-parent');
             // Handle clicks on suggestions.
             var element = $(e.currentTarget).closest('[role=option]');
             var suggestionsElement = $(document.getElementById(state.suggestionsId));
             // Find the index of the clicked on suggestion.
             var current = suggestionsElement.children('[aria-hidden=false]').index(element);
+
             // Activate it.
-            activateItem(current, state);
-            // And select it.
-            selectCurrentItem(options, state, originalSelect);
-            M.util.js_complete(pendingKey);
+            activateItem(current, state)
+            .then(function() {
+                // And select it.
+                return selectCurrentItem(options, state, originalSelect);
+            })
+            .then(function() {
+                return pendingPromise.resolve();
+            })
+            .catch();
         });
         var selectionElement = $(document.getElementById(state.selectionId));
         // Handle clicks on the selected items (will unselect an item).
         selectionElement.on('click', '[role=listitem]', function(e) {
-            var pendingKey = 'form-autocomplete-clicks';
-            M.util.js_pending(pendingKey);
-            // Get the item that was clicked.
-            var item = $(e.currentTarget);
+            var pendingPromise = addPendingJSPromise('form-autocomplete-clicks');
+
             // Remove it from the selection.
-            deselectItem(options, state, item, originalSelect);
-            M.util.js_complete(pendingKey);
+            pendingPromise.resolve(deselectItem(options, state, $(e.currentTarget), originalSelect));
         });
         // Keyboard navigation for the selection list.
         selectionElement.on('keydown', function(e) {
-            var pendingKey = 'form-autocomplete-keydown-' + e.keyCode;
-            M.util.js_pending(pendingKey);
+            var pendingPromise = addPendingJSPromise('form-autocomplete-keydown-' + e.keyCode);
             switch (e.keyCode) {
                 case KEYS.DOWN:
-                    // Choose the next selection item.
-                    activateNextSelection(state);
                     // We handled this event, so prevent it.
                     e.preventDefault();
-                    M.util.js_complete(pendingKey);
+
+                    // Choose the next selection item.
+                    pendingPromise.resolve(activateNextSelection(state));
                     return false;
                 case KEYS.UP:
-                    // Choose the previous selection item.
-                    activatePreviousSelection(state);
                     // We handled this event, so prevent it.
                     e.preventDefault();
-                    M.util.js_complete(pendingKey);
+
+                    // Choose the previous selection item.
+                    pendingPromise.resolve(activatePreviousSelection(state));
                     return false;
                 case KEYS.SPACE:
                 case KEYS.ENTER:
                     // Get the item that is currently selected.
                     var selectedItem = $(document.getElementById(state.selectionId)).children('[data-active-selection=true]');
                     if (selectedItem) {
-                        // Unselect this item.
-                        deselectItem(options, state, selectedItem, originalSelect);
-                        // We handled this event, so prevent it.
                         e.preventDefault();
+
+                        // Unselect this item.
+                        pendingPromise.resolve(deselectItem(options, state, selectedItem, originalSelect));
                     }
-                    M.util.js_complete(pendingKey);
                     return false;
             }
-            M.util.js_complete(pendingKey);
+
+            // Not handled. Resolve the promise.
+            pendingPromise.resolve();
             return true;
         });
         // Whenever the input field changes, update the suggestion list.
@@ -750,24 +809,61 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
             // If this field uses ajax, set it up.
             if (options.ajax) {
                 require([options.ajax], function(ajaxHandler) {
+                    // Creating throttled handlers free of race conditions, and accurate.
+                    // This code keeps track of a throttleTimeout, which is periodically polled.
+                    // Once the throttled function is executed, the fact that it is running is noted.
+                    // If a subsequent request comes in whilst it is running, this request is re-applied.
                     var throttleTimeout = null;
+                    var inProgress = false;
                     var pendingKey = 'autocomplete-throttledhandler';
                     var handler = function(e) {
-                        updateAjax(e, options, state, originalSelect, ajaxHandler);
-                        M.util.js_complete(pendingKey);
+                        // Empty the current timeout.
+                        throttleTimeout = null;
+
+                        // Mark this request as in-progress.
+                        inProgress = true;
+
+                        // Process the request.
+                        updateAjax(e, options, state, originalSelect, ajaxHandler)
+                        .then(function() {
+                            // Check if the throttleTimeout is still empty.
+                            // There's a potential condition whereby the JS request takes long enough to complete that
+                            // another task has been queued.
+                            // In this case another task will be kicked off and we must wait for that before marking htis as
+                            // complete.
+                            if (null === throttleTimeout) {
+                                // Mark this task as complete.
+                                M.util.js_complete(pendingKey);
+                            }
+                            inProgress = false;
+
+                            return arguments[0];
+                        })
+                        .catch(notification.exception);
                     };
 
                     // For input events, we do not want to trigger many, many updates.
                     var throttledHandler = function(e) {
-                        if (throttleTimeout !== null) {
-                            window.clearTimeout(throttleTimeout);
-                            throttleTimeout = null;
-                        } else {
-                            // No existing timeout handler, so this is the start of a throttling check.
+                        window.clearTimeout(throttleTimeout);
+                        if (inProgress) {
+                            // A request is currently ongoing.
+                            // Delay this request another 100ms.
+                            throttleTimeout = window.setTimeout(throttledHandler.bind(this, e), 100);
+                            return;
+                        }
+
+                        if (throttleTimeout === null) {
+                            // There is currently no existing timeout handler, and it has not been recently cleared, so
+                            // this is the start of a throttling check.
                             M.util.js_pending(pendingKey);
                         }
+
+                        // There is currently no existing timeout handler, and it has not been recently cleared, so this
+                        // is the start of a throttling check.
+                        // Queue a call to the handler.
                         throttleTimeout = window.setTimeout(handler.bind(this, e), 300);
                     };
+
                     // Trigger an ajax update after the text field value changes.
                     inputElement.on("input", throttledHandler);
                 });
@@ -789,6 +885,30 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
         }
     };
 
+    /**
+     * Create and return an unresolved Promise for some pending JS.
+     *
+     * @param   {String} key The unique identifier for this promise
+     * @return  {Promise}
+     */
+    var addPendingJSPromise = function(key) {
+            var pendingKey = 'form-autocomplete:' + key;
+
+            M.util.js_pending(pendingKey);
+
+            var pendingPromise = $.Deferred();
+
+            pendingPromise
+            .then(function() {
+                M.util.js_complete(pendingKey);
+
+                return arguments[0];
+            })
+            .catch(notification.exception);
+
+            return pendingPromise;
+    };
+
     return /** @alias module:core/form-autocomplete */ {
         // Public variables and functions.
         /**
@@ -902,7 +1022,8 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
                 return html;
             });
 
-            return $.when(renderInput, renderDatalist, renderSelection).then(function(input, suggestions, selection) {
+            return $.when(renderInput, renderDatalist, renderSelection)
+            .then(function(input, suggestions, selection) {
                 originalSelect.hide();
                 originalSelect.after(suggestions);
                 originalSelect.after(input);
@@ -919,11 +1040,16 @@ define(['jquery', 'core/log', 'core/str', 'core/templates', 'core/notification']
                 // Hide the suggestions by default.
                 suggestionsElement.hide().attr('aria-hidden', true);
 
+                return;
+            })
+            .then(function() {
                 // Show the current values in the selection list.
-                updateSelectionList(options, state, originalSelect);
-                M.util.js_complete(pendingKey);
-                return true;
-            }).fail(function(error) {
+                return updateSelectionList(options, state, originalSelect);
+            })
+            .then(function() {
+                return M.util.js_complete(pendingKey);
+            })
+            .catch(function(error) {
                 M.util.js_complete(pendingKey);
                 notification.exception(error);
             });
index 2453d7a..873695a 100644 (file)
@@ -47,13 +47,45 @@ class behat_form_autocomplete extends behat_form_text {
         if (!$this->running_javascript()) {
             throw new coding_exception('Setting the valid of an autocomplete field requires javascript.');
         }
-        $this->field->setValue($value);
-        // After the value is set, there is a 400ms throttle and then search. So adding 2 sec. delay to ensure both
-        // throttle + search finishes.
-        sleep(2);
-        $id = $this->field->getAttribute('id');
-        $js = ' require(["jquery"], function($) { $(document.getElementById("'.$id.'")).trigger("behat:set-value"); }); ';
-        $this->session->executeScript($js);
-        $this->key_press(27);
+
+        // Set the value of the autocomplete's input.
+        // If this autocomplete offers suggestions then these should be fetched by setting the value and waiting for the
+        // JS to finish fetching those suggestions.
+
+        $istagelement = $this->field->hasAttribute('data-tags') && $this->field->getAttribute('data-tags');
+
+        if ($istagelement && false !== strpos($value, ',')) {
+            // Commas have a special meaning as a value separator in 'tag' autocomplete elements.
+            // To handle this we break the value up by comma, and enter it in chunks.
+            $values = explode(',', $value);
+
+            while ($value = array_shift($values)) {
+                $this->set_value($value);
+            }
+        } else {
+            $this->field->setValue($value);
+            $this->wait_for_pending_js();
+
+            // If the autocomplete found suggestions, then it will have:
+            // 1) marked itself as expanded; and
+            // 2) have an aria-selected suggestion in the list.
+            $expanded = $this->field->getAttribute('aria-expanded');
+            $suggestion = $this->field->getParent()->find('css', '.form-autocomplete-suggestions > [aria-selected="true"]');
+
+            if ($expanded && null !== $suggestion) {
+                // A suggestion was found.
+                // Click on the first item in the list.
+                $suggestion->click();
+            } else {
+                // Press the return key to create a new tag.
+                // Note: We cannot use $this->key_press() because the keyPress action, in combination with the keyDown
+                // submits the form.
+                $this->field->keyDown(13);
+                $this->field->keyUp(13);
+            }
+
+            $this->wait_for_pending_js();
+            $this->key_press(27);
+        }
     }
 }
index 7bf5032..38249a8 100644 (file)
@@ -36,7 +36,7 @@
     { "inputID": 1, "suggestionsId": 2, "selectionId": 3, "downArrowId": 4, "placeholder": "Select something" }
 }}
 {{#showSuggestions}}
-<input type="text" id="{{inputId}}" list="{{suggestionsId}}" placeholder="{{placeholder}}" role="combobox" aria-expanded="false" autocomplete="off" autocorrect="off" autocapitalize="off" aria-autocomplete="list" aria-owns="{{suggestionsId}} {{selectionId}}"/><span class="form-autocomplete-downarrow" id="{{downArrowId}}">&#x25BC;</span>
+<input type="text" id="{{inputId}}" 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" id="{{downArrowId}}">&#x25BC;</span>
 {{/showSuggestions}}
 {{^showSuggestions}}
 <input type="text" id="{{inputId}}" placeholder="{{placeholder}}" role="textbox" aria-owns="{{selectionId}}"/>
index c3b84d3..540efe8 100644 (file)
@@ -138,10 +138,6 @@ Feature: Mapping courses in a feedback
     And I follow "Map feedback to courses"
     And I set the field "Courses" to "Course 2"
     And I set the field "Courses" to "Course 3"
-    # Weird solution to make the editable field to lose the focus
-    # but with the focus, "save changes" uses to fail because of
-    # the suggestions hiding the button.
-    And I press key "27" in the field "Courses"
     And I press "Save changes"
     And I should see "Course mapping has been changed"
     And I log out
index 0d2b966..20e6430 100644 (file)
@@ -36,7 +36,7 @@
     { "inputID": 1, "suggestionsId": 2, "selectionId": 3, "downArrowId": 4, "placeholder": "Select something" }
 }}
 {{#showSuggestions}}
-<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}}"/><span class="form-autocomplete-downarrow" id="{{downArrowId}}">&#x25BC;</span>
+<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" id="{{downArrowId}}">&#x25BC;</span>
 {{/showSuggestions}}
 {{^showSuggestions}}
 <input type="text" id="{{inputId}}" placeholder="{{placeholder}}" role="textbox" aria-owns="{{selectionId}}"/>
index a300d62..c6dfcdf 100644 (file)
@@ -1,6 +1,9 @@
 This files describes API changes in /theme/* themes,
 information provided here is intended especially for theme designer.
 
+=== 3.7 ===
+* The core/form_autocompelte_input template now has a `data-tags` attribute.
+
 === 3.6 ===
 
 * A new callback has been added to the theme layout files allowing plugins to inject their content