summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLuke Bennett <lukeeeebennettplus@gmail.com>2016-05-15 00:50:15 +0100
committerLuke Bennett <lukeeeebennettplus@gmail.com>2016-08-18 18:17:56 +0100
commitb4398de5c5381a81f225c390e32f99d4e0e7d627 (patch)
treec563e58bef6e9c59462941585f07037c3b5b517d
parent9e7231bd1f3a934614bfee23c32f19157991ff5b (diff)
downloadgitlab-ce-b4398de5c5381a81f225c390e32f99d4e0e7d627.tar.gz
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
-rw-r--r--app/assets/javascripts/gl_dropdown.js.coffee657
-rw-r--r--app/assets/javascripts/search_autocomplete.js.coffee349
-rw-r--r--spec/javascripts/fixtures/gl_dropdown.html.haml16
-rw-r--r--spec/javascripts/gl_dropdown_spec.js.coffee96
4 files changed, 1118 insertions, 0 deletions
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 = $('<ul />')
+ .append(html)
+
+ return menu_html
+
+ # Append the menu into the dropdown
+ appendMenu: (html) ->
+ selector = '.dropdown-content'
+ if @dropdown.find(".dropdown-toggle-page").length
+ selector = ".dropdown-page-one .dropdown-content"
+ $(selector, @dropdown)
+ .empty()
+ .append(html)
+
+ # Render the row
+ renderItem: (data, group = false, index = false) ->
+ html = ""
+
+ # Divider
+ return "<li class='divider'></li>" if data is "divider"
+
+ # Separator is a full-width divider
+ return "<li class='separator'></li>" if data is "separator"
+
+ # Header
+ return _.template("<li class='dropdown-header'><%- header %></li>") header: data.header if data.header?
+
+ if @options.renderRow
+ # Call the render function
+ html = @options.renderRow.call(@options, data, @)
+ else
+ if not selected
+ value = if @options.id then @options.id(data) else data.id
+ fieldName = @options.fieldName
+ field = @dropdown.parent().find("input[name='#{fieldName}'][value='#{value}']")
+ if field.length
+ selected = true
+
+ # Set URL
+ if @options.url?
+ url = @options.url(data)
+ else
+ url = if data.url? then data.url else '#'
+
+ # Set Text
+ if @options.text?
+ text = @options.text(data)
+ else
+ text = if data.text? then data.text else ''
+
+ cssClass = ""
+
+ if selected
+ cssClass = "is-active"
+
+ if @highlight
+ text = @highlightTextMatches(text, @filterInput.val())
+
+ if group
+ groupAttrs = "data-group=#{group} data-index=#{index}"
+ else
+ groupAttrs = ''
+ html = _.template("<li>
+ <a href='<%- url %>' <%- groupAttrs %> class='<%- cssClass %>'>
+ <%= text %>
+ </a>
+ </li>")({
+ url: url
+ groupAttrs: groupAttrs
+ cssClass: cssClass
+ text: text
+ })
+
+ return html
+
+ highlightTextMatches: (text, term) ->
+ occurrences = fuzzaldrinPlus.match(text, term)
+ text.split('').map((character, i) ->
+ if i in occurrences then "<b>#{character}</b>" else character
+ ).join('')
+
+ noResults: ->
+ html = "<li class='dropdown-menu-empty-link'>
+ <a href='#' class='is-focused'>
+ No matching results.
+ </a>
+ </li>"
+
+ rowClicked: (el) ->
+ fieldName = @options.fieldName
+ isInput = $(@el).is('input')
+
+ if @renderedData
+ groupName = el.data('group')
+ if groupName
+ selectedIndex = el.data('index')
+ selectedObject = @renderedData[groupName][selectedIndex]
+ else
+ selectedIndex = el.closest('li').index()
+ selectedObject = @renderedData[selectedIndex]
+
+ value = if @options.id then @options.id(selectedObject, el) else selectedObject.id
+
+ if isInput
+ field = $(@el)
+ else
+ field = @dropdown.parent().find("input[name='#{fieldName}'][value='#{value}']")
+
+ if el.hasClass(ACTIVE_CLASS)
+ el.removeClass(ACTIVE_CLASS)
+
+ if isInput
+ field.val('')
+ else
+ field.remove()
+
+ # Toggle the dropdown label
+ if @options.toggleLabel
+ @updateLabel(selectedObject, el, @)
+ else
+ selectedObject
+ else if el.hasClass(INDETERMINATE_CLASS)
+ el.addClass ACTIVE_CLASS
+ el.removeClass INDETERMINATE_CLASS
+
+ if not value?
+ field.remove()
+
+ if not field.length and fieldName
+ @addInput(fieldName, value)
+
+ return selectedObject
+ else
+ if not @options.multiSelect or el.hasClass('dropdown-clear-active')
+ @dropdown.find(".#{ACTIVE_CLASS}").removeClass ACTIVE_CLASS
+
+ unless isInput
+ @dropdown.parent().find("input[name='#{fieldName}']").remove()
+
+ if !value?
+ field.remove()
+
+ # Toggle active class for the tick mark
+ el.addClass ACTIVE_CLASS
+
+ # Toggle the dropdown label
+ if @options.toggleLabel
+ @updateLabel(selectedObject, el, @)
+ if value?
+ if !field.length and fieldName
+ @addInput(fieldName, value)
+ else
+ field
+ .val value
+ .trigger 'change'
+
+ return selectedObject
+
+ addInput: (fieldName, value)->
+ # Create hidden input for form
+ $input = $('<input>').attr('type', 'hidden')
+ .attr('name', fieldName)
+ .val(value)
+
+ if @options.inputId?
+ $input.attr('id', @options.inputId)
+
+ @dropdown.before $input
+
+ selectRowAtIndex: (e, index) ->
+ # Dropdown list item link selector, excluding non-selectable list items
+ selector = "#{SELECTABLE_CLASSES}:eq(#{index}) a"
+
+ if @dropdown.find(".dropdown-toggle-page").length
+ selector = ".dropdown-page-one #{selector}"
+
+ # simulate a click on the first link
+ $el = $(selector, @dropdown)
+ if $el.length
+ e.preventDefault()
+ e.stopImmediatePropagation()
+ $el.first().trigger 'click'
+ href = $el.attr 'href'
+ Turbolinks.visit(href) if href
+
+ addArrowKeyEvent: ->
+ ARROW_KEY_CODES = [38, 40]
+ $input = @dropdown.find(".dropdown-input-field")
+
+ # Dropdown list item selector, excluding non-selectable list items
+ selector = SELECTABLE_CLASSES
+ if @dropdown.find(".dropdown-toggle-page").length
+ selector = ".dropdown-page-one #{selector}"
+
+ $('body').on 'keydown', (e) =>
+ currentKeyCode = e.which
+
+ if ARROW_KEY_CODES.indexOf(currentKeyCode) >= 0
+ e.preventDefault()
+ e.stopImmediatePropagation()
+
+ PREV_INDEX = currentIndex
+ $listItems = $(selector, @dropdown)
+
+ # if @options.filterable
+ # $input.blur()
+
+ if currentKeyCode is 40
+ # Move down
+ currentIndex += 1 if currentIndex < ($listItems.length - 1)
+ else if currentKeyCode is 38
+ # Move up
+ currentIndex -= 1 if currentIndex > 0
+
+ @highlightRowAtIndex($listItems, currentIndex) if currentIndex isnt PREV_INDEX
+
+ return false
+
+ # If enter is pressed and a row is highlighted, select it
+ if currentKeyCode is 13 and currentIndex != -1
+ e.preventDefault()
+ e.stopImmediatePropagation()
+ @selectRowAtIndex e, currentIndex
+
+ removeArrayKeyEvent: ->
+ $('body').off 'keydown'
+
+ # Resets the currently selected item row index and removes all highlights
+ resetRows: ->
+ currentIndex = -1
+ $('.is-focused', @dropdown).removeClass 'is-focused'
+
+ highlightRowAtIndex: ($listItems, index) ->
+ # Remove the class for the previously focused row
+ $('.is-focused', @dropdown).removeClass 'is-focused'
+
+ # Update the class for the row at the specific index
+ $listItem = $listItems.eq(index)
+ $listItem.find('a:first-child').addClass "is-focused"
+
+ # Dropdown content scroll area
+ $dropdownContent = $listItem.closest('.dropdown-content')
+ dropdownScrollTop = $dropdownContent.scrollTop()
+ dropdownContentHeight = $dropdownContent.outerHeight()
+ dropdownContentTop = $dropdownContent.prop('offsetTop')
+ dropdownContentBottom = dropdownContentTop + dropdownContentHeight
+
+ # Get the offset bottom of the list item
+ listItemHeight = $listItem.outerHeight()
+ listItemTop = $listItem.prop('offsetTop')
+ listItemBottom = listItemTop + listItemHeight
+
+ if index is 0
+ # If this is the first item in the list, scroll to the top
+ $dropdownContent.scrollTop(0)
+ else if index is $listItems.length - 1
+ # If this is the last item in the list, scroll to the bottom
+ $dropdownContent.scrollTop($dropdownContent[0].scrollHeight)
+ else if listItemBottom > dropdownContentBottom + dropdownScrollTop
+ # Scroll the dropdown content down with a little padding
+ $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom + CURSOR_SELECT_SCROLL_PADDING)
+ else if listItemTop < dropdownContentTop + dropdownScrollTop
+ # Scroll the dropdown content up with a little padding
+ $dropdownContent.scrollTop(listItemTop - dropdownContentTop - CURSOR_SELECT_SCROLL_PADDING)
+
+ updateLabel: (selected = null, el = null, instance = null) =>
+ $(@el).find(".dropdown-toggle-text").text @options.toggleLabel(selected, el, instance)
+
+$.fn.glDropdown = (opts) ->
+ return @.each ->
+ if (!$.data @, 'glDropdown')
+ $.data(@, 'glDropdown', new GitLabDropdown @, opts)
diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee
new file mode 100644
index 00000000000..a3baea1ae01
--- /dev/null
+++ b/app/assets/javascripts/search_autocomplete.js.coffee
@@ -0,0 +1,349 @@
+class @SearchAutocomplete
+
+ KEYCODE =
+ ESCAPE: 27
+ BACKSPACE: 8
+ ENTER: 13
+ UP: 38
+ DOWN: 40
+
+ constructor: (opts = {}) ->
+ {
+ @wrap = $('.search')
+
+ @optsEl = @wrap.find('.search-autocomplete-opts')
+ @autocompletePath = @optsEl.data('autocomplete-path')
+ @projectId = @optsEl.data('autocomplete-project-id') || ''
+ @projectRef = @optsEl.data('autocomplete-project-ref') || ''
+
+ } = opts
+
+ # Dropdown Element
+ @dropdown = @wrap.find('.dropdown')
+ @dropdownContent = @dropdown.find('.dropdown-content')
+
+ @locationBadgeEl = @getElement('.location-badge')
+ @scopeInputEl = @getElement('#scope')
+ @searchInput = @getElement('.search-input')
+ @projectInputEl = @getElement('#search_project_id')
+ @groupInputEl = @getElement('#group_id')
+ @searchCodeInputEl = @getElement('#search_code')
+ @repositoryInputEl = @getElement('#repository_ref')
+ @clearInput = @getElement('.js-clear-input')
+
+ @saveOriginalState()
+
+ # Only when user is logged in
+ @createAutocomplete() if gon.current_user_id
+
+ @searchInput.addClass('disabled')
+
+ @saveTextLength()
+
+ @bindEvents()
+
+ # Finds an element inside wrapper element
+ getElement: (selector) ->
+ @wrap.find(selector)
+
+ saveOriginalState: ->
+ @originalState = @serializeState()
+
+ saveTextLength: ->
+ @lastTextLength = @searchInput.val().length
+
+ createAutocomplete: ->
+ @searchInput.glDropdown
+ filterInputBlur: false
+ filterable: true
+ filterRemote: true
+ highlight: true
+ enterCallback: false
+ filterInput: 'input#search'
+ search:
+ fields: ['text']
+ data: @getData.bind(@)
+ selectable: true
+ clicked: @onClick.bind(@)
+
+ getData: (term, callback) ->
+ _this = @
+
+ unless term
+ if contents = @getCategoryContents()
+ @searchInput.data('glDropdown').filter.options.callback contents
+ @enableAutocomplete()
+
+ return
+
+ # Prevent multiple ajax calls
+ return if @loadingSuggestions
+
+ @loadingSuggestions = true
+
+ jqXHR = $.get(@autocompletePath, {
+ project_id: @projectId
+ project_ref: @projectRef
+ term: term
+ }, (response) ->
+ # Hide dropdown menu if no suggestions returns
+ if !response.length
+ _this.disableAutocomplete()
+ return
+
+ data = []
+
+ # List results
+ firstCategory = true
+ for suggestion in response
+
+ # Add group header before list each group
+ if lastCategory isnt suggestion.category
+ data.push 'separator' if !firstCategory
+
+ firstCategory = false if firstCategory
+
+ data.push
+ header: suggestion.category
+
+ lastCategory = suggestion.category
+
+ data.push
+ id: "#{suggestion.category.toLowerCase()}-#{suggestion.id}"
+ category: suggestion.category
+ text: suggestion.label
+ url: suggestion.url
+
+ # Add option to proceed with the search
+ if data.length
+ data.push('separator')
+ data.push
+ text: "Result name contains \"#{term}\""
+ url: "/search?\
+ search=#{term}\
+ &project_id=#{_this.projectInputEl.val()}\
+ &group_id=#{_this.groupInputEl.val()}"
+
+ callback(data)
+ ).always ->
+ _this.loadingSuggestions = false
+
+
+ getCategoryContents: ->
+
+ userId = gon.current_user_id
+ { utils, projectOptions, groupOptions, dashboardOptions } = gl
+
+ if utils.isInGroupsPage() and groupOptions
+ options = groupOptions[utils.getGroupSlug()]
+
+ else if utils.isInProjectPage() and projectOptions
+ options = projectOptions[utils.getProjectSlug()]
+
+ else if dashboardOptions
+ options = dashboardOptions
+
+ { issuesPath, mrPath, name } = options
+
+ items = [
+ { header: "#{name}" }
+ { text: 'Issues assigned to me', url: "#{issuesPath}/?assignee_id=#{userId}" }
+ { text: "Issues I've created", url: "#{issuesPath}/?author_id=#{userId}" }
+ 'separator'
+ { text: 'Merge requests assigned to me', url: "#{mrPath}/?assignee_id=#{userId}" }
+ { text: "Merge requests I've created", url: "#{mrPath}/?author_id=#{userId}" }
+ ]
+
+ items.splice 0, 1 unless name
+
+ return items
+
+
+ serializeState: ->
+ {
+ # Search Criteria
+ search_project_id: @projectInputEl.val()
+ group_id: @groupInputEl.val()
+ search_code: @searchCodeInputEl.val()
+ repository_ref: @repositoryInputEl.val()
+ scope: @scopeInputEl.val()
+
+ # Location badge
+ _location: @locationBadgeEl.text()
+ }
+
+ bindEvents: ->
+ @searchInput.on 'keydown', @onSearchInputKeyDown
+ @searchInput.on 'keyup', @onSearchInputKeyUp
+ @searchInput.on 'click', @onSearchInputClick
+ @searchInput.on 'focus', @onSearchInputFocus
+ @searchInput.on 'blur', @onSearchInputBlur
+ @clearInput.on 'click', @onClearInputClick
+ @locationBadgeEl.on 'click', =>
+ @searchInput.focus()
+
+ enableAutocomplete: ->
+ # No need to enable anything if user is not logged in
+ return if !gon.current_user_id
+
+ unless @dropdown.hasClass('open')
+ _this = @
+ @loadingSuggestions = false
+
+ # If not enabled already, enable
+ if not @dropdown.hasClass('open')
+ # Open dropdown and invoke its opened() method
+ @dropdown.addClass('open')
+ .trigger('shown.bs.dropdown')
+ @searchInput.removeClass('disabled')
+
+ onSearchInputKeyDown: =>
+ # Saves last length of the entered text
+ @saveTextLength()
+
+ onSearchInputKeyUp: (e) =>
+ switch e.keyCode
+ when KEYCODE.BACKSPACE
+ # when trying to remove the location badge
+ if @lastTextLength is 0 and @badgePresent()
+ @removeLocationBadge()
+
+ # When removing the last character and no badge is present
+ if @lastTextLength is 1
+ @disableAutocomplete()
+
+ # When removing any character from existin value
+ if @lastTextLength > 1
+ @enableAutocomplete()
+
+ when KEYCODE.ESCAPE
+ @restoreOriginalState()
+
+ # Close autocomplete on enter
+ when KEYCODE.ENTER
+ @disableAutocomplete()
+
+ when KEYCODE.UP, KEYCODE.DOWN
+ return
+
+ else
+ # Handle the case when deleting the input value other than backspace
+ # e.g. Pressing ctrl + backspace or ctrl + x
+ if @searchInput.val() is ''
+ @disableAutocomplete()
+ else
+ # We should display the menu only when input is not empty
+ @enableAutocomplete()
+
+ @wrap.toggleClass 'has-value', !!e.target.value
+
+ # Avoid falsy value to be returned
+ return
+
+ onSearchInputClick: (e) =>
+ # Prevents closing the dropdown menu
+ e.stopImmediatePropagation()
+
+ onSearchInputFocus: =>
+ @isFocused = true
+ @wrap.addClass('search-active')
+
+ @getData() if @getValue() is ''
+
+
+ getValue: -> return @searchInput.val()
+
+
+ onClearInputClick: (e) =>
+ e.preventDefault()
+ @searchInput.val('').focus()
+
+ onSearchInputBlur: (e) =>
+ @isFocused = false
+ @wrap.removeClass('search-active')
+
+ # If input is blank then restore state
+ if @searchInput.val() is ''
+ @restoreOriginalState()
+
+ addLocationBadge: (item) ->
+ category = if item.category? then "#{item.category}: " else ''
+ value = if item.value? then item.value else ''
+
+ badgeText = "#{category}#{value}"
+ @locationBadgeEl.text(badgeText).show()
+ @wrap.addClass('has-location-badge')
+
+
+ hasLocationBadge: -> return @wrap.is '.has-location-badge'
+
+
+ restoreOriginalState: ->
+ inputs = Object.keys @originalState
+
+ for input in inputs
+ @getElement("##{input}").val(@originalState[input])
+
+ if @originalState._location is ''
+ @locationBadgeEl.hide()
+ else
+ @addLocationBadge(
+ value: @originalState._location
+ )
+
+ badgePresent: ->
+ @locationBadgeEl.length
+
+ resetSearchState: ->
+ inputs = Object.keys @originalState
+
+ for input in inputs
+
+ # _location isnt a input
+ break if input is '_location'
+
+ @getElement("##{input}").val('')
+
+
+ removeLocationBadge: ->
+
+ @locationBadgeEl.hide()
+ @resetSearchState()
+ @wrap.removeClass('has-location-badge')
+ @disableAutocomplete()
+
+
+ disableAutocomplete: ->
+ # If not disabled already, disable
+ if not @searchInput.hasClass('disabled') && @dropdown.hasClass('open')
+ @searchInput.addClass('disabled')
+ # Close dropdown and invoke its hidden() method
+ @dropdown.removeClass('open')
+ .trigger('hidden.bs.dropdown')
+ @restoreMenu()
+
+ restoreMenu: ->
+ html = "<ul>
+ <li><a class='dropdown-menu-empty-link is-focused'>Loading...</a></li>
+ </ul>"
+ @dropdownContent.html(html)
+
+ onClick: (item, $el, e) ->
+ if location.pathname.indexOf(item.url) isnt -1
+ e.preventDefault()
+ if not @badgePresent
+ if item.category is 'Projects'
+ @projectInputEl.val(item.id)
+ @addLocationBadge(
+ value: 'This project'
+ )
+
+ if item.category is 'Groups'
+ @groupInputEl.val(item.id)
+ @addLocationBadge(
+ value: 'This group'
+ )
+
+ $el.removeClass('is-active')
+ @disableAutocomplete()
+ @searchInput.val('').focus()
diff --git a/spec/javascripts/fixtures/gl_dropdown.html.haml b/spec/javascripts/fixtures/gl_dropdown.html.haml
new file mode 100644
index 00000000000..a20390c08ee
--- /dev/null
+++ b/spec/javascripts/fixtures/gl_dropdown.html.haml
@@ -0,0 +1,16 @@
+%div
+ .dropdown.inline
+ %button#js-project-dropdown.dropdown-menu-toggle{type: 'button', data: {toggle: 'dropdown'}}
+ Projects
+ %i.fa.fa-chevron-down.dropdown-toggle-caret.js-projects-dropdown-toggle
+ .dropdown-menu.dropdown-select.dropdown-menu-selectable
+ .dropdown-title
+ %span Go to project
+ %button.dropdown-title-button.dropdown-menu-close{aria: {label: 'Close'}}
+ %i.fa.fa-times.dropdown-menu-close-icon
+ .dropdown-input
+ %input.dropdown-input-field{type: 'search', placeholder: 'Filter results'}
+ %i.fa.fa-search.dropdown-input-search
+ .dropdown-content
+ .dropdown-loading
+ %i.fa.fa-spinner.fa-spin
diff --git a/spec/javascripts/gl_dropdown_spec.js.coffee b/spec/javascripts/gl_dropdown_spec.js.coffee
new file mode 100644
index 00000000000..46a0af32a7d
--- /dev/null
+++ b/spec/javascripts/gl_dropdown_spec.js.coffee
@@ -0,0 +1,96 @@
+#= require jquery
+#= require gl_dropdown
+#= require turbolinks
+#= require lib/utils/common_utils
+#= require lib/utils/type_utility
+
+NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link'
+ITEM_SELECTOR = ".dropdown-content li:not(#{NON_SELECTABLE_CLASSES})"
+FOCUSED_ITEM_SELECTOR = ITEM_SELECTOR + ' a.is-focused'
+
+ARROW_KEYS =
+ DOWN: 40
+ UP: 38
+ ENTER: 13
+ ESC: 27
+
+navigateWithKeys = (direction, steps, cb, i) ->
+ i = i || 0
+ $('body').trigger
+ type: 'keydown'
+ which: ARROW_KEYS[direction.toUpperCase()]
+ keyCode: ARROW_KEYS[direction.toUpperCase()]
+ i++
+ if i <= steps
+ navigateWithKeys direction, steps, cb, i
+ else
+ cb()
+
+initDropdown = ->
+ @dropdownContainerElement = $('.dropdown.inline')
+ @dropdownMenuElement = $('.dropdown-menu', @dropdownContainerElement)
+ @projectsData = fixture.load('projects.json')[0]
+ @dropdownButtonElement = $('#js-project-dropdown', @dropdownContainerElement).glDropdown
+ selectable: true
+ data: @projectsData
+ text: (project) ->
+ (project.name_with_namespace or project.name)
+ id: (project) ->
+ project.id
+
+describe 'Dropdown', ->
+ fixture.preload 'gl_dropdown.html'
+ fixture.preload 'projects.json'
+
+ beforeEach ->
+ fixture.load 'gl_dropdown.html'
+ initDropdown.call this
+
+ afterEach ->
+ $('body').unbind 'keydown'
+ @dropdownContainerElement.unbind 'keyup'
+
+ it 'should open on click', ->
+ expect(@dropdownContainerElement).not.toHaveClass 'open'
+ @dropdownButtonElement.click()
+ expect(@dropdownContainerElement).toHaveClass 'open'
+
+ describe 'that is open', ->
+ beforeEach ->
+ @dropdownButtonElement.click()
+
+ it 'should select a following item on DOWN keypress', ->
+ expect($(FOCUSED_ITEM_SELECTOR, @dropdownMenuElement).length).toBe 0
+ randomIndex = Math.floor(Math.random() * (@projectsData.length - 1)) + 0
+ navigateWithKeys 'down', randomIndex, =>
+ expect($(FOCUSED_ITEM_SELECTOR, @dropdownMenuElement).length).toBe 1
+ expect($("#{ITEM_SELECTOR}:eq(#{randomIndex}) a", @dropdownMenuElement)).toHaveClass 'is-focused'
+
+ it 'should select a previous item on UP keypress', ->
+ expect($(FOCUSED_ITEM_SELECTOR, @dropdownMenuElement).length).toBe 0
+ navigateWithKeys 'down', (@projectsData.length - 1), =>
+ expect($(FOCUSED_ITEM_SELECTOR, @dropdownMenuElement).length).toBe 1
+ randomIndex = Math.floor(Math.random() * (@projectsData.length - 2)) + 0
+ navigateWithKeys 'up', randomIndex, =>
+ expect($(FOCUSED_ITEM_SELECTOR, @dropdownMenuElement).length).toBe 1
+ expect($("#{ITEM_SELECTOR}:eq(#{((@projectsData.length - 2) - randomIndex)}) a", @dropdownMenuElement)).toHaveClass 'is-focused'
+
+ it 'should click the selected item on ENTER keypress', ->
+ expect(@dropdownContainerElement).toHaveClass 'open'
+ randomIndex = Math.floor(Math.random() * (@projectsData.length - 1)) + 0
+ navigateWithKeys 'down', randomIndex, =>
+ spyOn(Turbolinks, 'visit').and.stub()
+ navigateWithKeys 'enter', null, =>
+ link = $("#{ITEM_SELECTOR}:eq(#{randomIndex}) a", @dropdownMenuElement)
+ expect(link).toHaveClass 'is-active'
+ if link.attr 'href'
+ expect(Turbolinks.visit).toHaveBeenCalledWith link.attr 'href'
+ expect(@dropdownContainerElement).not.toHaveClass 'open'
+
+ it 'should close on ESC keypress', ->
+ expect(@dropdownContainerElement).toHaveClass 'open'
+ @dropdownContainerElement.trigger
+ type: 'keyup'
+ which: ARROW_KEYS.ESC
+ keyCode: ARROW_KEYS.ESC
+ expect(@dropdownContainerElement).not.toHaveClass 'open'