gl_dropdown.js.coffee 12.7 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 61 62 63
    results = data

    if search_text isnt ""
      results = fuzzaldrinPlus.filter(data, search_text,
64
        key: @options.keys
Phil Hughes's avatar
Phil Hughes committed
65
      )
Phil Hughes's avatar
Phil Hughes committed
66

67
    @options.callback results
Phil Hughes's avatar
Phil Hughes committed
68 69 70 71 72 73 74 75 76 77 78 79

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
80
      @dataEndpoint "", (data) =>
Phil Hughes's avatar
Phil Hughes committed
81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
        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"
102
  PAGE_TWO_CLASS = "is-page-two"
103
  ACTIVE_CLASS = "is-active"
104
  currentIndex = -1
Phil Hughes's avatar
Phil Hughes committed
105

106 107
  FILTER_INPUT = '.dropdown-input .dropdown-input-field'

Phil Hughes's avatar
Phil Hughes committed
108 109
  constructor: (@el, @options) ->
    @dropdown = $(@el).parent()
110 111 112 113

    # Set Defaults
    {
      # If no input is passed create a default one
Alfredo Sumaran's avatar
Alfredo Sumaran committed
114
      @filterInput = @getElement(FILTER_INPUT)
115
      @highlight = false
116
      @filterInputBlur = true
117
      @enterCallback = true
118 119 120 121 122 123
    } = @options

    self = @

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

Phil Hughes's avatar
Phil Hughes committed
126 127 128
    search_fields = if @options.search then @options.search.fields else [];

    if @options.data
129 130
      # If data is an array
      if _.isArray @options.data
131
        @fullData = @options.data
132 133 134 135 136 137 138 139
        @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
140

141 142
            @parseData @fullData
        }
Phil Hughes's avatar
Phil Hughes committed
143

144
    # Init filterable
Phil Hughes's avatar
Phil Hughes committed
145
    if @options.filterable
146
      @filter = new GitLabDropdownFilter @filterInput,
147
        filterInputBlur: @filterInputBlur
148 149 150 151 152 153
        remote: @options.filterRemote
        query: @options.data
        keys: @options.search.fields
        data: =>
          return @fullData
        callback: (data) =>
154
          currentIndex = -1
155 156
          @parseData data
        enterCallback: =>
157
          if @enterCallback
158
            @selectRowAtIndex 0
Phil Hughes's avatar
Phil Hughes committed
159 160

    # Event listeners
161

162 163
    @dropdown.on "shown.bs.dropdown", @opened
    @dropdown.on "hidden.bs.dropdown", @hidden
164
    @dropdown.on "click", ".dropdown-menu, .dropdown-menu-close", @shouldPropagate
165 166 167 168 169 170 171

    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
172 173

    if @options.selectable
174
      selector = ".dropdown-content a"
175 176

      if @dropdown.find(".dropdown-toggle-page").length
177
        selector = ".dropdown-page-one .dropdown-content a"
178 179

      @dropdown.on "click", selector, (e) ->
180 181
        $el = $(@)
        selected = self.rowClicked $el
Phil Hughes's avatar
Phil Hughes committed
182 183

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

Alfredo Sumaran's avatar
Alfredo Sumaran committed
186 187 188
  # Finds an element inside wrapper element
  getElement: (selector) ->
    @dropdown.find selector
189

Phil Hughes's avatar
Phil Hughes committed
190 191 192
  toggleLoading: ->
    $('.dropdown-menu', @dropdown).toggleClass LOADING_CLASS

193 194 195 196 197 198 199 200 201
  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
202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217
  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)

218 219
  shouldPropagate: (e) =>
    if @options.multiSelect
220 221 222 223 224 225
      $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
226

Phil Hughes's avatar
Phil Hughes committed
227
  opened: =>
228 229
    @addArrowKeyEvent()

230 231
    contentHtml = $('.dropdown-content', @dropdown).html()
    if @remote && contentHtml is ""
Phil Hughes's avatar
Phil Hughes committed
232 233
      @remote.execute()

Phil Hughes's avatar
Phil Hughes committed
234
    if @options.filterable
235
      @filterInput.focus()
Phil Hughes's avatar
Phil Hughes committed
236

237 238
    @dropdown.trigger('shown.gl.dropdown')

239
  hidden: (e) =>
240
    @removeArrayKeyEvent()
Phil Hughes's avatar
Phil Hughes committed
241
    if @options.filterable
242 243 244 245 246
      @dropdown
        .find(".dropdown-input-field")
        .blur()
        .val("")
        .trigger("keyup")
Phil Hughes's avatar
Phil Hughes committed
247

248 249 250
    if @dropdown.find(".dropdown-toggle-page").length
      $('.dropdown-menu', @dropdown).removeClass PAGE_TWO_CLASS

251 252 253
    if @options.hidden
      @options.hidden.call(@,e)

254 255
    @dropdown.trigger('hidden.gl.dropdown')

256

Phil Hughes's avatar
Phil Hughes committed
257 258 259 260 261 262 263 264 265 266 267 268 269
  # 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) ->
270 271 272 273 274
    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
275 276 277 278 279

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

280
    # Divider
281 282
    return "<li class='divider'></li>" if data is "divider"

283 284 285
    # Separator is a full-width divider
    return "<li class='separator'></li>" if data is "separator"

286 287 288
    # Header
    return "<li class='dropdown-header'>#{data.header}</li>" if data.header?

Phil Hughes's avatar
Phil Hughes committed
289 290 291 292
    if @options.renderRow
      # Call the render function
      html = @options.renderRow(data)
    else
293 294 295 296 297 298 299
      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

300 301 302 303
      # Set URL
      if @options.url?
        url = @options.url(data)
      else
Alfredo Sumaran's avatar
Alfredo Sumaran committed
304
        url = if data.url? then data.url else '#'
305 306

      # Set Text
307 308 309
      if @options.text?
        text = @options.text(data)
      else
310
        text = if data.text? then data.text else ''
311

Phil Hughes's avatar
Phil Hughes committed
312 313 314 315 316
      cssClass = "";

      if selected
        cssClass = "is-active"

317 318
      if @highlight
        text = @highlightTextMatches(text, @filterInput.val())
319

320 321 322 323 324
      html = "<li>
        <a href='#{url}' class='#{cssClass}'>
          #{text}
        </a>
      </li>"
Phil Hughes's avatar
Phil Hughes committed
325 326 327

    return html

328 329
  highlightTextMatches: (text, term) ->
    occurrences = fuzzaldrinPlus.match(text, term)
Alfredo Sumaran's avatar
Alfredo Sumaran committed
330 331 332
    text.split('').map((character, i) ->
      if i in occurrences then "<b>#{character}</b>" else character
    ).join('')
333

Phil Hughes's avatar
Phil Hughes committed
334
  noResults: ->
Phil Hughes's avatar
Phil Hughes committed
335 336 337 338 339
    html = "<li class='dropdown-menu-empty-link'>
      <a href='#' class='is-focused'>
        No matching results.
      </a>
    </li>"
Phil Hughes's avatar
Phil Hughes committed
340

341
  highlightRow: (index) ->
Alfredo Sumaran's avatar
Alfredo Sumaran committed
342
    if @filterInput.val() isnt ""
343 344 345 346
      selector = '.dropdown-content li:first-child a'
      if @dropdown.find(".dropdown-toggle-page").length
        selector = ".dropdown-page-one .dropdown-content li:first-child a"

347
      @getElement(selector).addClass 'is-focused'
348

Phil Hughes's avatar
Phil Hughes committed
349 350
  rowClicked: (el) ->
    fieldName = @options.fieldName
351 352 353 354 355
    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}']")
356

357
    if el.hasClass(ACTIVE_CLASS)
358
      el.removeClass(ACTIVE_CLASS)
359
      field.remove()
Phil Hughes's avatar
Phil Hughes committed
360 361 362 363

      # Toggle the dropdown label
      if @options.toggleLabel
        $(@el).find(".dropdown-toggle-text").text @options.toggleLabel
364 365
      else
        selectedObject
Phil Hughes's avatar
Phil Hughes committed
366
    else
367 368 369
      if !value?
        field.remove()

370
      if not @options.multiSelect
371
        @dropdown.find(".#{ACTIVE_CLASS}").removeClass ACTIVE_CLASS
372
        @dropdown.parent().find("input[name='#{fieldName}']").remove()
373 374

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

377 378 379
      # Toggle the dropdown label
      if @options.toggleLabel
        $(@el).find(".dropdown-toggle-text").text @options.toggleLabel(selectedObject)
380
      if value?
381
        if !field.length and fieldName
382
          # Create hidden input for form
383
          input = "<input type='hidden' name='#{fieldName}' value='#{value}' />"
Phil Hughes's avatar
Phil Hughes committed
384
          if @options.inputId?
385 386
            input = $(input)
                      .attr('id', @options.inputId)
387
          @dropdown.before input
388 389
        else
          field.val value
Phil Hughes's avatar
Phil Hughes committed
390

Phil Hughes's avatar
Phil Hughes committed
391 392
      return selectedObject

393 394 395
  selectRowAtIndex: (index) ->
    selector = ".dropdown-content li:not(.divider):eq(#{index}) a"

396
    if @dropdown.find(".dropdown-toggle-page").length
397
      selector = ".dropdown-page-one #{selector}"
398

399
    # simulate a click on the first link
400
    $(selector, @dropdown).trigger "click"
401

402 403 404 405 406 407 408 409 410
  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) =>
411
      currentKeyCode = e.which
412 413 414

      if ARROW_KEY_CODES.indexOf(currentKeyCode) >= 0
        e.preventDefault()
415
        e.stopImmediatePropagation()
416

417
        PREV_INDEX = currentIndex
418 419
        $listItems = $(selector, @dropdown)

420 421
        # if @options.filterable
        #   $input.blur()
422 423 424

        if currentKeyCode is 40
          # Move down
425
          currentIndex += 1 if currentIndex < ($listItems.length - 1)
426 427
        else if currentKeyCode is 38
          # Move up
428
          currentIndex -= 1 if currentIndex > 0
429

430
        @highlightRowAtIndex($listItems, currentIndex) if currentIndex isnt PREV_INDEX
431 432 433

        return false

434
      if currentKeyCode is 13
435
        @selectRowAtIndex currentIndex
436

437 438 439
  removeArrayKeyEvent: ->
    $('body').off 'keydown'

440
  highlightRowAtIndex: ($listItems, index) ->
441 442 443 444
    # Remove the class for the previously focused row
    $('.is-focused', @dropdown).removeClass 'is-focused'

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

448 449
    # Dropdown content scroll area
    $dropdownContent = $listItem.closest('.dropdown-content')
450 451
    dropdownScrollTop = $dropdownContent.scrollTop()
    dropdownContentHeight = $dropdownContent.outerHeight()
452 453
    dropdownContentTop = $dropdownContent.prop('offsetTop')
    dropdownContentBottom = dropdownContentTop + dropdownContentHeight
454 455

    # Get the offset bottom of the list item
456
    listItemHeight = $listItem.outerHeight()
457 458
    listItemTop = $listItem.prop('offsetTop')
    listItemBottom = listItemTop + listItemHeight
459

460
    if listItemBottom > dropdownContentBottom + dropdownScrollTop
461 462
      # Scroll the dropdown content down
      $dropdownContent.scrollTop(listItemBottom - dropdownContentBottom)
463 464 465
    else if listItemTop < dropdownContentTop + dropdownScrollTop
      # Scroll the dropdown content up
      $dropdownContent.scrollTop(listItemTop - dropdownContentTop)
466

Phil Hughes's avatar
Phil Hughes committed
467 468
$.fn.glDropdown = (opts) ->
  return @.each ->
469 470
    if (!$.data @, 'glDropdown')
      $.data(@, 'glDropdown', new GitLabDropdown @, opts)