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
This diff is collapsed.
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