define('jira/autocomplete/autocomplete', ['jira/ajs/ajax/smart-ajax', 'jira/util/key-code', 'jira/util/objects', 'jquery'], function (SmartAjax, keyCodes, Objects, jQuery) { /** * @class AutoComplete * @requires jQuery.aop */ return function () { var inFocus; /** * Calls a callback after specified delay * @memberof AutoComplete.prototype * @param {Number} l - length of delay in seconds * @param {Function} callback - function to call after delay */ var delay = function delay(callback, l) { if (delay.t) { clearTimeout(delay.t); delay.t = undefined; } delay.t = setTimeout(callback, l * 1000); }; var INVALID_KEYS = { 9: true, 13: true, 14: true, 25: true, 27: true, 38: true, 40: true, 224: true }; return (/** @lends AutoComplete.prototype */{ /** * Checks whether a saved version (cached) of the request exists, if not performs a request and saves response, * then dispatches saved response to renderSuggestions method. * * @public */ dispatcher: function dispatcher() {}, /** * Gets cached response * * @public * @param {String} val * @returns {Object} */ getSavedResponse: function getSavedResponse() {}, /** * Saves response * * @public * @param {String} val * @param {Object} response */ saveResponse: function saveResponse() {}, /** * Called to render suggestions. Used to define interface only. * Rendering is difficult to make generic, best to leave this to extending prototypes. * * @public * @param {Object} res - results object */ renderSuggestions: function renderSuggestions() {}, /** * Disables autocomplete. Useful for shared inputs. * i.e The selection of a radio button may disable the instance * @Public */ disable: function disable() { this.disabled = true; }, /** * Enables autocomplete. Useful for shared inputs. * i.e The selection of a radio button may disable the instance * @Public */ enable: function enable() { this.disabled = false; }, /** * Sets instance variables from options object * to do: make function create getters and setters * @public * @param {Object} options */ set: function set(options) { for (var name in options) { // safeguard to stop looping up the inheritance chain if (options.hasOwnProperty(name)) { this[name] = options[name]; } } }, /** * Adds value to input field * @public * @param {String} value */ completeField: function completeField(value) { if (value) { this.field.val(value).focus(); this.field.trigger("change"); } }, /** * Returns the text from the start of the field up to the end of * the position where suggestions are generated from. */ textToSuggestionCursorPosition: function textToSuggestionCursorPosition() { return this.field.val(); }, /** * An ajax request filter that only allows one request at a time. If there is another it will abort then issue * the new request. * * @param options - jQuery formatted ajax options */ _makeRequest: function _makeRequest(options) { var that = this; var requestParams = Objects.copyObject(options); // if we have we are still waiting for an old request, lets abort it as we are firing a new if (this.pendingRequest) { this.pendingRequest.abort(); } requestParams.complete = function () { that.pendingRequest = null; }; requestParams.error = function (xhr) { // We abort stale requests and this subsequently throws an error so we need to check if the request is aborted first. // We detect this using xhr.aborted property for native XHR requests but for "Microsoft.XMLHTTP" we use the status code, which is 0. // Status code is set to 0 when it is an unknown error so sense to fail silently. if (!xhr.aborted && xhr.status !== 0 && options.error) { options.error.apply(this, arguments); } }; return this.pendingRequest = SmartAjax.makeRequest(requestParams); }, /** * Allows users to navigate/select suggestions using the keyboard * @public */ addSuggestionControls: function addSuggestionControls(suggestionNodes) { // reference to this for closures var that = this; /** * Make sure the index is within the threshold * Looks ugly! Has to be a better way. * @private * @param {Integer} idx * @param {Integer} max * @return {Integer} valid threshold */ var evaluateIndex = function evaluateIndex(idx, max) { var minBoundary = that.autoSelectFirst === false ? -1 : 0; if (that.allowArrowCarousel) { if (idx > max) { return minBoundary; } else if (idx < minBoundary) { return max; } else { return idx; } } else { if (idx > max) { return max; } else if (idx < minBoundary) { that.responseContainer.scrollTop(0); return minBoundary; } else { return idx; } } }; /** * Highlights focused node and removes highlight from previous. * Actual highlight styles to come from css, adding and removing classes here. * @private * @param {Integer} idx - Index of node to be highlighted */ var setActive = function setActive(idx) { // if nothing is selected, select the first suggestion if (that.selectedIndex !== undefined && that.selectedIndex > -1) { that.suggestionNodes[that.selectedIndex][0].removeClass("active"); } that.selectedIndex = evaluateIndex(idx, that.suggestionNodes.length - 1); if (that.selectedIndex > -1) { that.suggestionNodes[that.selectedIndex][0].addClass("active"); } }; /** * Checks to see if there is actually a suggestion in focus before attempting to use it * @private * @returns {boolean} */ var evaluateIfActive = function evaluateIfActive() { return that.suggestionNodes && that.suggestionNodes[that.selectedIndex] && that.suggestionNodes[that.selectedIndex][0].hasClass("active"); }; /** * When the responseContainer (dropdown) is visible listen for keyboard events * that represent focus or selection. * @private * @param {Object} e - event object */ var keyPressHandler = function keyPressHandler(e) { // only use keyboard events if dropdown is visible if (that.responseContainer.is(":visible")) { // if enter key is pressed check that there is a node selected, then hide dropdown and complete field if (e.keyCode === keyCodes.ENTER) { if (evaluateIfActive() && !that.pendingRequest) { that.completeField(that.suggestionNodes[that.selectedIndex][1]); } e.preventDefault(); // hack - stop propagation to prevent dialog from submitting. Looking for eg JIRA.Dropdown.current doesn't work. e.stopPropagation(); } } }; /** * sets focus on suggestion nodes using the "up" and "down" arrows * These events need to be fired on mouseup as modifier keys don't register on keypress * @private * @param {Object} e - event object */ var keyboardNavigateHandler = function keyboardNavigateHandler(e) { // only use keyboard events if dropdown is visible if (that.responseContainer.is(":visible")) { // keep cursor inside input field if (that.field[0] !== document.activeElement) { that.field.focus(); } // move selection down when down arrow is pressed if (e.keyCode === keyCodes.DOWN) { setActive(that.selectedIndex + 1); if (that.selectedIndex >= 0) { // move selection up when up arrow is pressed var containerHeight = that.responseContainer.height(); var bottom = that.suggestionNodes[that.selectedIndex][0].position().top + that.suggestionNodes[that.selectedIndex][0].outerHeight(); if (bottom - containerHeight > 0) { that.responseContainer.scrollTop(that.responseContainer.scrollTop() + bottom - containerHeight + 2); } } else { that.responseContainer.scrollTop(0); } e.preventDefault(); } else if (e.keyCode === keyCodes.UP) { setActive(that.selectedIndex - 1); if (that.selectedIndex >= 0) { // if tab key is pressed check that there is a node selected, then hide dropdown and complete field var top = that.suggestionNodes[that.selectedIndex][0].position().top; if (top < 0) { that.responseContainer.scrollTop(that.responseContainer.scrollTop() + top - 2); } } e.preventDefault(); } else if (e.keyCode === keyCodes.TAB) { if (evaluateIfActive()) { that.completeField(that.suggestionNodes[that.selectedIndex][1]); e.preventDefault(); } else { that.dropdownController.hideDropdown(); } } } }; if (suggestionNodes.length) { this.selectedIndex = 0; this.suggestionNodes = suggestionNodes; for (var i = 0; i < that.suggestionNodes.length; i++) { var eventData = { instance: this, index: i }; this.suggestionNodes[i][0].bind("mouseover", eventData, activate).bind("mouseout", eventData, deactivate).bind("click", eventData, complete).bind("mousedown", function (e) { e.preventDefault(); }); } // make sure we don't bind more than once if (!this.keyboardHandlerBinded) { jQuery(this.field).keypress(keyPressHandler); jQuery(this.field).keydown(keyboardNavigateHandler); this.keyboardHandlerBinded = true; } // automatically select the first in the list if (that.autoSelectFirst === false) { setActive(-1); } else { setActive(0); } // sets the autocomplete singleton infocus var to this instance // is used to toggle event propagation. In short, the instance that it is set to will not hide the // dropdown each time you click the input field inFocus = this; } function activate(event) { if (that.dropdownController.displayed) { setActive(event.data.index); } } function deactivate(event) { if (event.data.index === 0) { that.selectedIndex = -1; } jQuery(this).removeClass("active"); } function complete(event) { that.completeField(that.suggestionNodes[event.data.index][1]); } }, /** * Uses jquery empty command, this is VERY important as it unassigns handlers * used for mouseover, click events which expose an opportunity for memory leaks * @public */ clearResponseContainer: function clearResponseContainer() { this.responseContainer.empty(); this.suggestionNodes = undefined; }, delay: delay, /** * Builds HTML container for suggestions. * Positions container top position to be that of the field height * @public */ buildResponseContainer: function buildResponseContainer() { var inputParent = this.field.parent().addClass('atlassian-autocomplete'); this.responseContainer = jQuery(document.createElement("div")); this.responseContainer.addClass("suggestions"); this.positionResponseContainer(); this.responseContainer.appendTo(inputParent); }, positionResponseContainer: function positionResponseContainer() { this.responseContainer.css({ top: this.field.outerHeight() }); }, /** * Validates the keypress by making sure the field value is beyond the set threshold and the key was either an * up or down arrow * @public * @param {Object} e - event object */ keyUpHandler: function () { function callback() { if (!this.responseContainer) { this.buildResponseContainer(); } // send value to dispatcher to check if we have already got the response or if we need to go // back to the server this.dispatcher(this.field.val()); } return function (e) { // only initialises once the field length is past set length if (this.field.val().length >= this.minQueryLength) { // don't do anything if the key pressed is "enter" or "down" or "up" or "right" "left" if (!(e.keyCode in INVALID_KEYS) || this.responseContainer && !this.responseContainer.is(":visible") && (e.keyCode === keyCodes.UP || e.keyCode === keyCodes.DOWN)) { callback.call(this); } } return e; }; }(), /** * Adds in methods via AOP to handle multiple selections * @Public */ addMultiSelectAdvice: function addMultiSelectAdvice(delim) { // reference to this for closures var that = this; /** * Alerts user if value already exists * @private * @param {String} val - value that already exists, will be displayed in message to user. */ var alertUserValueAlreadyExists = function alertUserValueAlreadyExists(val) { // check if there is an existing alert before adding another if (!alertUserValueAlreadyExists.isAlerting) { alertUserValueAlreadyExists.isAlerting = true; // create alert node and append it to the input field's parent, fade it in then out with a short // delay in between. //TODO: JRA-1800 - Needs i18n! var userAlert = jQuery(document.createElement("div")).css({ "float": "left", display: "none" }).addClass("warningBox").html("Oops! You have already entered the value " + val + "").appendTo(that.field.parent()).show("fast", function () { // display message for 4 seconds before fading out that.delay(function () { userAlert.hide("fast", function () { // removes element from dom userAlert.remove(); alertUserValueAlreadyExists.isAlerting = false; }); }, 4); }); } }; // rather than request the entire field return the last comma seperated value jQuery.aop.before({ target: this, method: "dispatcher" }, function (innvocation) { // matches everything after last comma var val = this.field.val(); innvocation[0] = jQuery.trim(val.slice(val.lastIndexOf(delim) + 1)); return innvocation; }); // rather than replacing this field just append the new value jQuery.aop.before({ target: this, method: "completeField" }, function (args) { var valueToAdd = args[0]; // create array of values var untrimmedVals = this.field.val().split(delim); // trim the values in the array so we avoid extra spaces being appended to the usernames - see JRA-20657 var trimmedVals = jQuery(untrimmedVals).map(function () { return jQuery.trim(this); }).get(); // check if the value to append already exists. If it does then call alert to to tell user and sets // the last value to "". The value to add will either appear: // 1) at the start of the string // 2) after some whitespace; or // 3) directly after the delimiter // It is assumed that the value is delimited by the delimiter character surrounded by any number of spaces. if (!this.allowDuplicates && new RegExp("(?:^|[\\s" + delim + "])" + valueToAdd + "\\s*" + delim).test(this.field.val())) { alertUserValueAlreadyExists(valueToAdd); trimmedVals[trimmedVals.length - 1] = ""; } else { // add the new value to the end of the array and then an empty value so we // can get an extra delimiter at the end of the joined string trimmedVals[trimmedVals.length - 1] = valueToAdd; trimmedVals[trimmedVals.length] = ""; } // join the array of values with the delimiter plus an extra space to make the list of values readable args[0] = trimmedVals.join(delim.replace(/([^\s]$)/, "$1 ")); return args; }); }, /** * Adds and manages state of dropdown control * @Public */ addDropdownAdvice: function addDropdownAdvice() { var that = this; // add dropdown functionality to response container jQuery.aop.after({ target: this, method: "buildResponseContainer" }, function (args) { this.dropdownController = JIRA.Dropdown.AutoComplete({ target: this, method: "renderSuggestions" }, this.responseContainer); jQuery.aop.after({ target: this.dropdownController, method: "hideDropdown" }, function () { this.dropdown.removeClass("dropdown-ready"); clearTimeout(delay.t); }); return args; }); // display dropdown afer suggestions are updated jQuery.aop.after({ target: this, method: "renderSuggestions" }, function (args) { if (args && args.length > 0) { this.dropdownController.displayDropdown(); if (this.maxHeight && this.dropdownController.dropdown.prop("scrollHeight") > this.maxHeight) { this.dropdownController.dropdown.css({ height: this.maxHeight, overflowX: "visible", overflowY: "scroll" }); } else if (this.maxHeight) { this.dropdownController.dropdown.css({ height: "", overflowX: "", overflowY: "" }); } this.dropdownController.dropdown.addClass("dropdown-ready"); } else { this.dropdownController.hideDropdown(); } return args; }); // hide dropdown after suggestion value is applied to field jQuery.aop.after({ target: this, method: "completeField" }, this._afterCompleteField); jQuery.aop.after({ target: this, method: "keyUpHandler" }, function (e) { // only initialises once the field length is past set length if ((!(this.field.val().length >= this.minQueryLength) || e.keyCode === keyCodes.ESCAPE) && this.dropdownController && this.dropdownController.displayed) { this.dropdownController.hideDropdown(); if (e.keyCode === keyCodes.ESCAPE) { e.stopPropagation(); } } return e; }); }, _afterCompleteField: function _afterCompleteField(args) { this.dropdownController.hideDropdown(); return args; }, /** * Initialises autocomplete by setting options, and assigning event handler to input field. * @param {Object} options * @constructs */ init: function init(options) { var that = this; this.set(options); this.field = this.field || jQuery("#" + this.fieldID); // turn off browser default autocomplete this.field.attr("autocomplete", "off").keyup(function (e) { if (!that.disabled) { that.keyUpHandler(e); } }).keydown(function (e) { // do not clear field in IE if (e.keyCode === keyCodes.ESCAPE && that.responseContainer && that.responseContainer.is(":visible")) { e.preventDefault(); } }).click(function () { if (inFocus === that) { setTimeout(function () { that.dispatcher(that.field.val()); }, 0); } }).blur(function () { // we don't want the request to come back and show suggestions if we have already moved away from field if (that.pendingRequest) { that.pendingRequest.abort(); } }).focus(function (e) { // We need to handle only focus events triggered by tab button, because focus event triggered by mouse click // is handled in click handler (Autocomplete.js#click) // e.originalEvent.relatedTarget transitively shows that focus was triggered by keyboard if (!that.disabled) { if (e.originalEvent && e.originalEvent.relatedTarget) { setTimeout(function () { that.dispatcher(that.field.val()); }, 0); } inFocus = that; } }); this.addDropdownAdvice(); if (options.delimChar) { this.addMultiSelectAdvice(options.delimChar); } } } ); }(); }); /** Preserve legacy namespace @deprecated jira.widget.autocomplete */ AJS.namespace("jira.widget.autocomplete", null, require('jira/autocomplete/autocomplete')); AJS.namespace('JIRA.AutoComplete', null, require('jira/autocomplete/autocomplete'));