From b4398de5c5381a81f225c390e32f99d4e0e7d627 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Sun, 15 May 2016 00:50:15 +0100 Subject: Added new non-selectable selector exclusions to fix arrow key events, fixed the simulated clicking of a row and fixed the conflict between enter key form submit and enter key row selection Added bootstrap dropdown event triggers to invoke the open and close methods of the dropdown, allowing for the binding of array key events Added #17465 fix entry to CHANGELOG Fixed multi-dropdown selected row index conflict Fixed whitespace diff Added padding to the dropdown content iterative scroll as well as new conditional scrolls to scroll all the way to the top when the first item of a list is selected and to scroll all the way to the bottom when the last item of a list is selected Added conditionals to the enable and disable autocomplete methods to stop multiple invocations without any enabled/disabled state change Fixes some incorrect firing of requests. The dropdown box was invoking a new query every time it closed and the GitLabDropdownRemote callback was invoking a new query which was causing the dropdown double render issue. Added .selectable css class to dropdown list items that are not dividers or headers and altered selectors to account for that. Moved scroll padding Number to variable. Removed unused method Started Dropdown tests Added fixture and began first test Almost finished, navigation done, action and close needed YAY. TESTS DONE. Altered test and fixed click started removing selectable class use Fixed as reviewed altered selection method Fixed autocomplete shutting dropdown on arrow key use patched XSS vulns updated tests f Added click fixes --- app/assets/javascripts/gl_dropdown.js.coffee | 657 +++++++++++++++++++++ .../javascripts/search_autocomplete.js.coffee | 349 +++++++++++ spec/javascripts/fixtures/gl_dropdown.html.haml | 16 + spec/javascripts/gl_dropdown_spec.js.coffee | 96 +++ 4 files changed, 1118 insertions(+) create mode 100644 app/assets/javascripts/gl_dropdown.js.coffee create mode 100644 app/assets/javascripts/search_autocomplete.js.coffee create mode 100644 spec/javascripts/fixtures/gl_dropdown.html.haml create mode 100644 spec/javascripts/gl_dropdown_spec.js.coffee diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee new file mode 100644 index 00000000000..e096effaade --- /dev/null +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -0,0 +1,657 @@ +class GitLabDropdownFilter + BLUR_KEYCODES = [27, 40] + ARROW_KEY_CODES = [38, 40] + HAS_VALUE_CLASS = "has-value" + + constructor: (@input, @options) -> + { + @filterInputBlur = true + } = @options + + $inputContainer = @input.parent() + $clearButton = $inputContainer.find('.js-dropdown-input-clear') + + @indeterminateIds = [] + + # Clear click + $clearButton.on 'click', (e) => + e.preventDefault() + e.stopPropagation() + @input + .val('') + .trigger('keyup') + .focus() + + # Key events + timeout = "" + @input.on "keyup", (e) => + keyCode = e.which + + return if ARROW_KEY_CODES.indexOf(keyCode) >= 0 + + if @input.val() isnt "" and !$inputContainer.hasClass HAS_VALUE_CLASS + $inputContainer.addClass HAS_VALUE_CLASS + else if @input.val() is "" and $inputContainer.hasClass HAS_VALUE_CLASS + $inputContainer.removeClass HAS_VALUE_CLASS + + if keyCode is 13 + return false + + # Only filter asynchronously only if option remote is set + if @options.remote + clearTimeout timeout + timeout = setTimeout => + blur_field = @shouldBlur keyCode + + if blur_field and @filterInputBlur + @input.blur() + + @options.query @input.val(), (data) => + @options.callback(data) + , 250 + else + @filter @input.val() + + shouldBlur: (keyCode) -> + return BLUR_KEYCODES.indexOf(keyCode) >= 0 + + filter: (search_text) -> + @options.onFilter(search_text) if @options.onFilter + data = @options.data() + + if data? and not @options.filterByText + results = data + + if search_text isnt '' + # When data is an array of objects therefore [object Array] e.g. + # [ + # { prop: 'foo' }, + # { prop: 'baz' } + # ] + if _.isArray(data) + results = fuzzaldrinPlus.filter(data, search_text, + key: @options.keys + ) + else + # If data is grouped therefore an [object Object]. e.g. + # { + # groupName1: [ + # { prop: 'foo' }, + # { prop: 'baz' } + # ], + # groupName2: [ + # { prop: 'abc' }, + # { prop: 'def' } + # ] + # } + if gl.utils.isObject data + results = {} + for key, group of data + tmp = fuzzaldrinPlus.filter(group, search_text, + key: @options.keys + ) + + if tmp.length + results[key] = tmp.map (item) -> item + + @options.callback results + else + elements = @options.elements() + + if search_text + elements.each -> + $el = $(@) + matches = fuzzaldrinPlus.match($el.text().trim(), search_text) + + unless $el.is('.dropdown-header') + if matches.length + $el.show() + else + $el.hide() + else + elements.show() + +class GitLabDropdownRemote + constructor: (@dataEndpoint, @options) -> + + execute: -> + if typeof @dataEndpoint is "string" + @fetchData() + else if typeof @dataEndpoint is "function" + if @options.beforeSend + @options.beforeSend() + + # Fetch the data by calling the data funcfion + @dataEndpoint "", (data) => + if @options.success + @options.success(data) + + if @options.beforeSend + @options.beforeSend() + + # Fetch the data through ajax if the data is a string + fetchData: -> + $.ajax( + url: @dataEndpoint, + dataType: @options.dataType, + beforeSend: => + if @options.beforeSend + @options.beforeSend() + success: (data) => + if @options.success + @options.success(data) + ) + +class GitLabDropdown + LOADING_CLASS = "is-loading" + PAGE_TWO_CLASS = "is-page-two" + ACTIVE_CLASS = "is-active" + INDETERMINATE_CLASS = "is-indeterminate" + NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link' + SELECTABLE_CLASSES = ".dropdown-content li:not(#{NON_SELECTABLE_CLASSES})" + FILTER_INPUT = '.dropdown-input .dropdown-input-field' + currentIndex = -1 + CURSOR_SELECT_SCROLL_PADDING = 5 + + constructor: (@el, @options) -> + self = @ + selector = $(@el).data "target" + @dropdown = if selector? then $(selector) else $(@el).parent() + + # Set Defaults + { + # If no input is passed create a default one + @filterInput = @getElement(FILTER_INPUT) + @highlight = false + @filterInputBlur = true + } = @options + + self = @ + + # If selector was passed + if _.isString(@filterInput) + @filterInput = @getElement(@filterInput) + + searchFields = if @options.search then @options.search.fields else [] + + if @options.data + # If we provided data + # data could be an array of objects or a group of arrays + if _.isObject(@options.data) and not _.isFunction(@options.data) + @fullData = @options.data + @parseData @options.data + else + # Remote data + @remote = new GitLabDropdownRemote @options.data, { + dataType: @options.dataType, + beforeSend: @toggleLoading.bind(@) + success: (data) => + @fullData = data + + # Reset selected row index on new data + currentIndex = -1 + @parseData @fullData + + @filter.input.trigger('keyup') if @options.filterable and @filter and @filter.input + } + + # Init filterable + if @options.filterable + @filter = new GitLabDropdownFilter @filterInput, + filterInputBlur: @filterInputBlur + filterByText: @options.filterByText + onFilter: @options.onFilter + remote: @options.filterRemote + query: @options.data + keys: searchFields + elements: => + selector = SELECTABLE_CLASSES + + if @dropdown.find('.dropdown-toggle-page').length + selector = ".dropdown-page-one #{selector}" + + return $(selector) + data: => + return @fullData + callback: (data) => + @parseData data + + unless @filterInput.val() is '' + selector = '.dropdown-content li:not(.divider):visible' + + if @dropdown.find('.dropdown-toggle-page').length + selector = ".dropdown-page-one #{selector}" + + $(selector, @dropdown) + .first() + .find('a') + .addClass('is-focused') + + currentIndex = 0 + + + # Event listeners + + @dropdown.on "shown.bs.dropdown", @opened + @dropdown.on "hidden.bs.dropdown", @hidden + $(@el).on "update.label", @updateLabel + @dropdown.on "click", ".dropdown-menu, .dropdown-menu-close", @shouldPropagate + @dropdown.on 'keyup', (e) => + if e.which is 27 # Escape key + $('.dropdown-menu-close', @dropdown).trigger 'click' + @dropdown.on 'blur', 'a', (e) => + if e.relatedTarget? + $relatedTarget = $(e.relatedTarget) + $dropdownMenu = $relatedTarget.closest('.dropdown-menu') + + if $dropdownMenu.length is 0 + @dropdown.removeClass('open') + + if @dropdown.find(".dropdown-toggle-page").length + @dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on "click", (e) => + e.preventDefault() + e.stopPropagation() + + @togglePage() + + if @options.selectable + selector = ".dropdown-content a" + + if @dropdown.find(".dropdown-toggle-page").length + selector = ".dropdown-page-one .dropdown-content a" + + @dropdown.on "click", selector, (e) -> + $el = $(@) + selected = self.rowClicked $el + + if self.options.clicked + self.options.clicked(selected, $el, e) + + $el.trigger('blur') + + # Finds an element inside wrapper element + getElement: (selector) -> + @dropdown.find selector + + toggleLoading: -> + $('.dropdown-menu', @dropdown).toggleClass LOADING_CLASS + + togglePage: -> + menu = $('.dropdown-menu', @dropdown) + + if menu.hasClass(PAGE_TWO_CLASS) + if @remote + @remote.execute() + + menu.toggleClass PAGE_TWO_CLASS + + # Focus first visible input on active page + @dropdown.find('[class^="dropdown-page-"]:visible :text:visible:first').focus() + + parseData: (data) -> + @renderedData = data + + if @options.filterable and data.length is 0 + # render no matching results + html = [@noResults()] + else + # Handle array groups + if gl.utils.isObject data + html = [] + for name, groupData of data + # Add header for each group + html.push(@renderItem(header: name, name)) + + @renderData(groupData, name) + .map (item) -> + html.push item + else + # Render each row + html = @renderData(data) + + # Render the full menu + full_html = @renderMenu(html) + + @appendMenu(full_html) + + renderData: (data, group = false) -> + data.map (obj, index) => + return @renderItem(obj, group, index) + + shouldPropagate: (e) => + if @options.multiSelect + $target = $(e.target) + + if not $target.hasClass('dropdown-menu-close') and not $target.hasClass('dropdown-menu-close-icon') and not $target.data('is-link') + e.stopPropagation() + return false + else + return true + + opened: => + @resetRows() + @addArrowKeyEvent() + + if @options.setIndeterminateIds + @options.setIndeterminateIds.call(@) + + if @options.setActiveIds + @options.setActiveIds.call(@) + + # Makes indeterminate items effective + if @fullData and @dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update') + @parseData @fullData + + contentHtml = $('.dropdown-content', @dropdown).html() + if @remote && contentHtml is "" + @remote.execute() + + if @options.filterable + @filterInput.focus() + + @dropdown.trigger('shown.gl.dropdown') + + hidden: (e) => + @resetRows() + @removeArrayKeyEvent() + + $input = @dropdown.find(".dropdown-input-field") + + if @options.filterable + $input + .blur() + .val("") + + # Triggering 'keyup' will re-render the dropdown which is not always required + # specially if we want to keep the state of the dropdown needed for bulk-assignment + if not @options.persistWhenHide + $input.trigger("keyup") + + if @dropdown.find(".dropdown-toggle-page").length + $('.dropdown-menu', @dropdown).removeClass PAGE_TWO_CLASS + + if @options.hidden + @options.hidden.call(@,e) + + @dropdown.trigger('hidden.gl.dropdown') + + + # Render the full menu + renderMenu: (html) -> + menu_html = "" + + if @options.renderMenu + menu_html = @options.renderMenu(html) + else + menu_html = $('