Commit b4398de5 authored by Luke Bennett's avatar Luke Bennett

Added new non-selectable selector exclusions to fix arrow key events, fixed...

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
parent 9e7231bd
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)
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()
%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
#= 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'
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment