gl_dropdown.js.coffee 13.2 KB
Newer Older
Phil Hughes's avatar
Phil Hughes committed
1 2
class GitLabDropdownFilter
  BLUR_KEYCODES = [27, 40]
3
  ARROW_KEY_CODES = [38, 40]
4
  HAS_VALUE_CLASS = "has-value"
Phil Hughes's avatar
Phil Hughes committed
5

6
  constructor: (@input, @options) ->
7
    {
8
      @filterInputBlur = true
9
    } = @options
Phil Hughes's avatar
Phil Hughes committed
10

11 12 13 14 15 16 17 18 19 20 21
    $inputContainer = @input.parent()
    $clearButton = $inputContainer.find('.js-dropdown-input-clear')

    # Clear click
    $clearButton.on 'click', (e) =>
      e.preventDefault()
      e.stopPropagation()
      @input
        .val('')
        .trigger('keyup')
        .focus()
Phil Hughes's avatar
Phil Hughes committed
22 23

    # Key events
Phil Hughes's avatar
Phil Hughes committed
24
    timeout = ""
Phil Hughes's avatar
Phil Hughes committed
25
    @input.on "keyup", (e) =>
26 27 28 29
      keyCode = e.which

      return if ARROW_KEY_CODES.indexOf(keyCode) >= 0

30 31 32 33 34
      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

35
      if keyCode is 13 and @input.val() isnt ""
36 37 38 39
        if @options.enterCallback
          @options.enterCallback()
        return

Phil Hughes's avatar
Phil Hughes committed
40 41
      clearTimeout timeout
      timeout = setTimeout =>
42
        blur_field = @shouldBlur keyCode
Phil Hughes's avatar
Phil Hughes committed
43
        search_text = @input.val()
Phil Hughes's avatar
Phil Hughes committed
44

Alfredo Sumaran's avatar
Alfredo Sumaran committed
45
        if blur_field and @filterInputBlur
Phil Hughes's avatar
Phil Hughes committed
46
          @input.blur()
Phil Hughes's avatar
Phil Hughes committed
47

48 49 50
        if @options.remote
          @options.query search_text, (data) =>
            @options.callback(data)
Phil Hughes's avatar
Phil Hughes committed
51 52 53
        else
          @filter search_text
      , 250
Phil Hughes's avatar
Phil Hughes committed
54 55 56 57 58

  shouldBlur: (keyCode) ->
    return BLUR_KEYCODES.indexOf(keyCode) >= 0

  filter: (search_text) ->
59
    data = @options.data()
Phil Hughes's avatar
Phil Hughes committed
60

Phil Hughes's avatar
Phil Hughes committed
61 62
    if data?
      results = data
Phil Hughes's avatar
Phil Hughes committed
63

Phil Hughes's avatar
Phil Hughes committed
64
      if search_text isnt ''
Phil Hughes's avatar
Phil Hughes committed
65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
        results = fuzzaldrinPlus.filter(data, search_text,
          key: @options.keys
        )

      @options.callback results
    else
      elements = @options.elements()

      if search_text
        elements.each ->
          $el = $(@)
          matches = fuzzaldrinPlus.match($el.text().trim(), search_text)

          if matches.length
            $el.show()
          else
            $el.hide()
      else
        elements.show()
Phil Hughes's avatar
Phil Hughes committed
84 85 86 87 88 89 90 91 92 93 94 95

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
Phil Hughes's avatar
Phil Hughes committed
96
      @dataEndpoint "", (data) =>
Phil Hughes's avatar
Phil Hughes committed
97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117
        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"
118
  PAGE_TWO_CLASS = "is-page-two"
119
  ACTIVE_CLASS = "is-active"
120
  currentIndex = -1
Phil Hughes's avatar
Phil Hughes committed
121

122 123
  FILTER_INPUT = '.dropdown-input .dropdown-input-field'

Phil Hughes's avatar
Phil Hughes committed
124 125
  constructor: (@el, @options) ->
    @dropdown = $(@el).parent()
126 127 128 129

    # Set Defaults
    {
      # If no input is passed create a default one
Alfredo Sumaran's avatar
Alfredo Sumaran committed
130
      @filterInput = @getElement(FILTER_INPUT)
131
      @highlight = false
132
      @filterInputBlur = true
133
      @enterCallback = true
134 135 136 137 138 139
    } = @options

    self = @

    # If selector was passed
    if _.isString(@filterInput)
Alfredo Sumaran's avatar
Alfredo Sumaran committed
140
      @filterInput = @getElement(@filterInput)
141

Phil Hughes's avatar
Phil Hughes committed
142
    searchFields = if @options.search then @options.search.fields else [];
Phil Hughes's avatar
Phil Hughes committed
143 144

    if @options.data
145 146
      # If data is an array
      if _.isArray @options.data
147
        @fullData = @options.data
148 149 150 151 152 153 154 155
        @parseData @options.data
      else
        # Remote data
        @remote = new GitLabDropdownRemote @options.data, {
          dataType: @options.dataType,
          beforeSend: @toggleLoading.bind(@)
          success: (data) =>
            @fullData = data
Phil Hughes's avatar
Phil Hughes committed
156

157 158
            @parseData @fullData
        }
Phil Hughes's avatar
Phil Hughes committed
159

160
    # Init filterable
Phil Hughes's avatar
Phil Hughes committed
161
    if @options.filterable
162
      @filter = new GitLabDropdownFilter @filterInput,
163
        filterInputBlur: @filterInputBlur
164 165
        remote: @options.filterRemote
        query: @options.data
Phil Hughes's avatar
Phil Hughes committed
166
        keys: searchFields
Phil Hughes's avatar
Phil Hughes committed
167
        elements: =>
Phil Hughes's avatar
Phil Hughes committed
168
          selector = '.dropdown-content li:not(.divider)'
Phil Hughes's avatar
Phil Hughes committed
169

Phil Hughes's avatar
Phil Hughes committed
170
          if @dropdown.find('.dropdown-toggle-page').length
Phil Hughes's avatar
Phil Hughes committed
171 172 173
            selector = ".dropdown-page-one #{selector}"

          return $(selector)
174 175 176
        data: =>
          return @fullData
        callback: (data) =>
177
          currentIndex = -1
178 179
          @parseData data
        enterCallback: =>
180
          if @enterCallback
181
            @selectRowAtIndex 0
Phil Hughes's avatar
Phil Hughes committed
182 183

    # Event listeners
184

185 186
    @dropdown.on "shown.bs.dropdown", @opened
    @dropdown.on "hidden.bs.dropdown", @hidden
187
    @dropdown.on "click", ".dropdown-menu, .dropdown-menu-close", @shouldPropagate
188 189 190 191 192 193 194

    if @dropdown.find(".dropdown-toggle-page").length
      @dropdown.find(".dropdown-toggle-page, .dropdown-menu-back").on "click", (e) =>
        e.preventDefault()
        e.stopPropagation()

        @togglePage()
Phil Hughes's avatar
Phil Hughes committed
195 196

    if @options.selectable
197
      selector = ".dropdown-content a"
198 199

      if @dropdown.find(".dropdown-toggle-page").length
200
        selector = ".dropdown-page-one .dropdown-content a"
201 202

      @dropdown.on "click", selector, (e) ->
203 204
        $el = $(@)
        selected = self.rowClicked $el
Phil Hughes's avatar
Phil Hughes committed
205 206

        if self.options.clicked
207
          self.options.clicked(selected, $el, e)
Phil Hughes's avatar
Phil Hughes committed
208

Alfredo Sumaran's avatar
Alfredo Sumaran committed
209 210 211
  # Finds an element inside wrapper element
  getElement: (selector) ->
    @dropdown.find selector
212

Phil Hughes's avatar
Phil Hughes committed
213 214 215
  toggleLoading: ->
    $('.dropdown-menu', @dropdown).toggleClass LOADING_CLASS

216 217 218 219 220 221 222 223 224
  togglePage: ->
    menu = $('.dropdown-menu', @dropdown)

    if menu.hasClass(PAGE_TWO_CLASS)
      if @remote
        @remote.execute()

    menu.toggleClass PAGE_TWO_CLASS

Phil Hughes's avatar
Phil Hughes committed
225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240
  parseData: (data) ->
    @renderedData = data

    # Render each row
    html = $.map data, (obj) =>
      return @renderItem(obj)

    if @options.filterable and data.length is 0
      # render no matching results
      html = [@noResults()]

    # Render the full menu
    full_html = @renderMenu(html.join(""))

    @appendMenu(full_html)

241 242
  shouldPropagate: (e) =>
    if @options.multiSelect
243 244 245 246 247 248
      $target = $(e.target)
      if not $target.hasClass('dropdown-menu-close') and not $target.hasClass('dropdown-menu-close-icon')
        e.stopPropagation()
        return false
      else
        return true
249

Phil Hughes's avatar
Phil Hughes committed
250
  opened: =>
251 252
    @addArrowKeyEvent()

253 254
    contentHtml = $('.dropdown-content', @dropdown).html()
    if @remote && contentHtml is ""
Phil Hughes's avatar
Phil Hughes committed
255 256
      @remote.execute()

Phil Hughes's avatar
Phil Hughes committed
257
    if @options.filterable
258
      @filterInput.focus()
Phil Hughes's avatar
Phil Hughes committed
259

260 261
    @dropdown.trigger('shown.gl.dropdown')

262
  hidden: (e) =>
263
    @removeArrayKeyEvent()
Phil Hughes's avatar
Phil Hughes committed
264
    if @options.filterable
265 266 267 268 269
      @dropdown
        .find(".dropdown-input-field")
        .blur()
        .val("")
        .trigger("keyup")
Phil Hughes's avatar
Phil Hughes committed
270

271 272 273
    if @dropdown.find(".dropdown-toggle-page").length
      $('.dropdown-menu', @dropdown).removeClass PAGE_TWO_CLASS

274 275 276
    if @options.hidden
      @options.hidden.call(@,e)

277 278
    @dropdown.trigger('hidden.gl.dropdown')

279

Phil Hughes's avatar
Phil Hughes committed
280 281 282 283 284 285 286 287 288 289 290 291 292
  # Render the full menu
  renderMenu: (html) ->
    menu_html = ""

    if @options.renderMenu
      menu_html = @options.renderMenu(html)
    else
      menu_html = "<ul>#{html}</ul>"

    return menu_html

  # Append the menu into the dropdown
  appendMenu: (html) ->
293 294 295 296 297
    selector = '.dropdown-content'
    if @dropdown.find(".dropdown-toggle-page").length
      selector = ".dropdown-page-one .dropdown-content"

    $(selector, @dropdown).html html
Phil Hughes's avatar
Phil Hughes committed
298 299 300 301 302

  # Render the row
  renderItem: (data) ->
    html = ""

303
    # Divider
304 305
    return "<li class='divider'></li>" if data is "divider"

306 307 308
    # Separator is a full-width divider
    return "<li class='separator'></li>" if data is "separator"

309 310 311
    # Header
    return "<li class='dropdown-header'>#{data.header}</li>" if data.header?

Phil Hughes's avatar
Phil Hughes committed
312 313 314 315
    if @options.renderRow
      # Call the render function
      html = @options.renderRow(data)
    else
316 317 318 319 320 321 322
      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

323 324 325 326
      # Set URL
      if @options.url?
        url = @options.url(data)
      else
Alfredo Sumaran's avatar
Alfredo Sumaran committed
327
        url = if data.url? then data.url else '#'
328 329

      # Set Text
330 331 332
      if @options.text?
        text = @options.text(data)
      else
333
        text = if data.text? then data.text else ''
334

Phil Hughes's avatar
Phil Hughes committed
335 336 337 338 339
      cssClass = "";

      if selected
        cssClass = "is-active"

340 341
      if @highlight
        text = @highlightTextMatches(text, @filterInput.val())
342

343 344 345 346 347
      html = "<li>
        <a href='#{url}' class='#{cssClass}'>
          #{text}
        </a>
      </li>"
Phil Hughes's avatar
Phil Hughes committed
348 349 350

    return html

351 352
  highlightTextMatches: (text, term) ->
    occurrences = fuzzaldrinPlus.match(text, term)
Alfredo Sumaran's avatar
Alfredo Sumaran committed
353 354 355
    text.split('').map((character, i) ->
      if i in occurrences then "<b>#{character}</b>" else character
    ).join('')
356

Phil Hughes's avatar
Phil Hughes committed
357
  noResults: ->
Phil Hughes's avatar
Phil Hughes committed
358 359 360 361 362
    html = "<li class='dropdown-menu-empty-link'>
      <a href='#' class='is-focused'>
        No matching results.
      </a>
    </li>"
Phil Hughes's avatar
Phil Hughes committed
363

364
  highlightRow: (index) ->
Alfredo Sumaran's avatar
Alfredo Sumaran committed
365
    if @filterInput.val() isnt ""
366 367 368 369
      selector = '.dropdown-content li:first-child a'
      if @dropdown.find(".dropdown-toggle-page").length
        selector = ".dropdown-page-one .dropdown-content li:first-child a"

370
      @getElement(selector).addClass 'is-focused'
371

Phil Hughes's avatar
Phil Hughes committed
372 373
  rowClicked: (el) ->
    fieldName = @options.fieldName
374 375 376 377 378
    selectedIndex = el.parent().index()
    if @renderedData
      selectedObject = @renderedData[selectedIndex]
    value = if @options.id then @options.id(selectedObject, el) else selectedObject.id
    field = @dropdown.parent().find("input[name='#{fieldName}'][value='#{value}']")
379

380
    if el.hasClass(ACTIVE_CLASS)
381
      el.removeClass(ACTIVE_CLASS)
382
      field.remove()
Phil Hughes's avatar
Phil Hughes committed
383 384 385 386

      # Toggle the dropdown label
      if @options.toggleLabel
        $(@el).find(".dropdown-toggle-text").text @options.toggleLabel
387 388
      else
        selectedObject
Phil Hughes's avatar
Phil Hughes committed
389
    else
390 391 392
      if !value?
        field.remove()

393
      if not @options.multiSelect
394
        @dropdown.find(".#{ACTIVE_CLASS}").removeClass ACTIVE_CLASS
395
        @dropdown.parent().find("input[name='#{fieldName}']").remove()
396 397

      # Toggle active class for the tick mark
Phil Hughes's avatar
Phil Hughes committed
398
      el.addClass ACTIVE_CLASS
399

400 401
      # Toggle the dropdown label
      if @options.toggleLabel
402
        $(@el).find(".dropdown-toggle-text").text @options.toggleLabel(selectedObject, el)
403
      if value?
404
        if !field.length and fieldName
405
          # Create hidden input for form
406
          input = "<input type='hidden' name='#{fieldName}' value='#{value}' />"
Phil Hughes's avatar
Phil Hughes committed
407
          if @options.inputId?
408 409
            input = $(input)
                      .attr('id', @options.inputId)
410
          @dropdown.before input
411 412
        else
          field.val value
Phil Hughes's avatar
Phil Hughes committed
413

Phil Hughes's avatar
Phil Hughes committed
414 415
      return selectedObject

416 417 418
  selectRowAtIndex: (index) ->
    selector = ".dropdown-content li:not(.divider):eq(#{index}) a"

419
    if @dropdown.find(".dropdown-toggle-page").length
420
      selector = ".dropdown-page-one #{selector}"
421

422
    # simulate a click on the first link
423
    $(selector, @dropdown).trigger "click"
424

425 426 427 428 429 430 431 432 433
  addArrowKeyEvent: ->
    ARROW_KEY_CODES = [38, 40]
    $input = @dropdown.find(".dropdown-input-field")

    selector = '.dropdown-content li:not(.divider)'
    if @dropdown.find(".dropdown-toggle-page").length
      selector = ".dropdown-page-one #{selector}"

    $('body').on 'keydown', (e) =>
434
      currentKeyCode = e.which
435 436 437

      if ARROW_KEY_CODES.indexOf(currentKeyCode) >= 0
        e.preventDefault()
438
        e.stopImmediatePropagation()
439

440
        PREV_INDEX = currentIndex
441 442
        $listItems = $(selector, @dropdown)

443 444
        # if @options.filterable
        #   $input.blur()
445 446 447

        if currentKeyCode is 40
          # Move down
448
          currentIndex += 1 if currentIndex < ($listItems.length - 1)
449 450
        else if currentKeyCode is 38
          # Move up
451
          currentIndex -= 1 if currentIndex > 0
452

453
        @highlightRowAtIndex($listItems, currentIndex) if currentIndex isnt PREV_INDEX
454 455 456

        return false

457
      if currentKeyCode is 13
458
        @selectRowAtIndex currentIndex
459

460 461 462
  removeArrayKeyEvent: ->
    $('body').off 'keydown'

463
  highlightRowAtIndex: ($listItems, index) ->
464 465 466 467
    # Remove the class for the previously focused row
    $('.is-focused', @dropdown).removeClass 'is-focused'

    # Update the class for the row at the specific index
468
    $listItem = $listItems.eq(index)
Phil Hughes's avatar
Phil Hughes committed
469
    $listItem.find('a:first-child').addClass "is-focused"
470

471 472
    # Dropdown content scroll area
    $dropdownContent = $listItem.closest('.dropdown-content')
473 474
    dropdownScrollTop = $dropdownContent.scrollTop()
    dropdownContentHeight = $dropdownContent.outerHeight()
475 476
    dropdownContentTop = $dropdownContent.prop('offsetTop')
    dropdownContentBottom = dropdownContentTop + dropdownContentHeight
477 478

    # Get the offset bottom of the list item
479
    listItemHeight = $listItem.outerHeight()
480 481
    listItemTop = $listItem.prop('offsetTop')
    listItemBottom = listItemTop + listItemHeight
482

483
    if listItemBottom > dropdownContentBottom + dropdownScrollTop
484 485
      # Scroll the dropdown content down
      $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom)
486 487 488
    else if listItemTop < dropdownContentTop + dropdownScrollTop
      # Scroll the dropdown content up
      $dropdownContent.scrollTop(listItemTop - dropdownContentTop)
489

Phil Hughes's avatar
Phil Hughes committed
490 491
$.fn.glDropdown = (opts) ->
  return @.each ->
492 493
    if (!$.data @, 'glDropdown')
      $.data(@, 'glDropdown', new GitLabDropdown @, opts)