Commit e994dea0 authored by Andrew Nicols's avatar Andrew Nicols
Browse files

MDL-62514 behat: Rewrite handling of autocomplete

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.
parent 7bfb575f
File suppressed by a .gitattributes entry or the file's encoding is unsupported.
......@@ -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.
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.
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.
closeSuggestions(state);
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.
return updateSelectionList(options, state, originalSelect)
.then(function() {
// Notify that the selection changed.
notifyChange(originalSelect);
return;
})
.then(function() {
if (options.closeSuggestionsOnSelect) {
// Clear the input element.
inputElement.val('');
// Close the list of suggestions.
closeSuggestions(state);
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.
updateSuggestions(options, state, inputElement.val(), originalSelect);
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);
activateItem(current, state)
.then(function() {
// And select it.
selectCurrentItem(options, state, originalSelect);
M.util.js_complete(pendingKey);
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));