diff options
Diffstat (limited to 'app/assets/javascripts/gl_dropdown.js.coffee')
-rw-r--r-- | app/assets/javascripts/gl_dropdown.js.coffee | 657 |
1 files changed, 657 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) |