diff options
| -rw-r--r-- | app/assets/javascripts/dispatcher.js.coffee | 8 | ||||
| -rw-r--r-- | app/assets/javascripts/gl_dropdown.js.coffee | 103 | ||||
| -rw-r--r-- | app/assets/javascripts/search_autocomplete.js.coffee | 279 | ||||
| -rw-r--r-- | app/assets/stylesheets/framework/dropdowns.scss | 60 | ||||
| -rw-r--r-- | app/assets/stylesheets/framework/forms.scss | 34 | ||||
| -rw-r--r-- | app/assets/stylesheets/framework/header.scss | 20 | ||||
| -rw-r--r-- | app/assets/stylesheets/framework/variables.scss | 18 | ||||
| -rw-r--r-- | app/assets/stylesheets/pages/search.scss | 142 | ||||
| -rw-r--r-- | app/helpers/search_helper.rb | 57 | ||||
| -rw-r--r-- | app/views/layouts/_search.html.haml | 40 | ||||
| -rw-r--r-- | features/steps/dashboard/issues.rb | 9 |
11 files changed, 613 insertions, 157 deletions
diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index f5e1ca9860d..70fd6f50e9c 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -146,15 +146,11 @@ class Dispatcher when 'project_members', 'deploy_keys', 'hooks', 'services', 'protected_branches' shortcut_handler = new ShortcutsNavigation() - # If we haven't installed a custom shortcut handler, install the default one if not shortcut_handler new Shortcuts() initSearch: -> - opts = $('.search-autocomplete-opts') - path = opts.data('autocomplete-path') - project_id = opts.data('autocomplete-project-id') - project_ref = opts.data('autocomplete-project-ref') - new SearchAutocomplete(path, project_id, project_ref) + # Only when search form is present + new SearchAutocomplete() if $('.search').length diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index 2b56ab2e6de..85dcd493e53 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -3,6 +3,10 @@ class GitLabDropdownFilter HAS_VALUE_CLASS = "has-value" constructor: (@input, @options) -> + { + @filterInputBlur = true + } = @options + $inputContainer = @input.parent() $clearButton = $inputContainer.find('.js-dropdown-input-clear') @@ -33,7 +37,7 @@ class GitLabDropdownFilter blur_field = @shouldBlur e.keyCode search_text = @input.val() - if blur_field + if blur_field and @filterInputBlur @input.blur() if @options.remote @@ -93,27 +97,48 @@ class GitLabDropdown PAGE_TWO_CLASS = "is-page-two" ACTIVE_CLASS = "is-active" + FILTER_INPUT = '.dropdown-input .dropdown-input-field' + constructor: (@el, @options) -> - self = @ @dropdown = $(@el).parent() + + # Set Defaults + { + # If no input is passed create a default one + @filterInput = @getElement(FILTER_INPUT) + @highlight = false + @filterInputBlur = true + @enterCallback = true + } = @options + + self = @ + + # If selector was passed + if _.isString(@filterInput) + @filterInput = @getElement(@filterInput) + search_fields = if @options.search then @options.search.fields else []; if @options.data - # Remote data - @remote = new GitLabDropdownRemote @options.data, { - dataType: @options.dataType, - beforeSend: @toggleLoading.bind(@) - success: (data) => - @fullData = data + # If data is an array + if _.isArray @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 - @parseData @fullData - } + @parseData @fullData + } - # Init filiterable + # Init filterable if @options.filterable - @input = @dropdown.find('.dropdown-input .dropdown-input-field') - - @filter = new GitLabDropdownFilter @input, + @filter = new GitLabDropdownFilter @filterInput, + filterInputBlur: @filterInputBlur remote: @options.filterRemote query: @options.data keys: @options.search.fields @@ -123,7 +148,8 @@ class GitLabDropdown @parseData data @highlightRow 1 enterCallback: => - @selectFirstRow() + if @enterCallback + @selectFirstRow() # Event listeners @@ -151,6 +177,10 @@ class GitLabDropdown if self.options.clicked self.options.clicked.call(@,e) + # Finds an element inside wrapper element + getElement: (selector) -> + @dropdown.find selector + toggleLoading: -> $('.dropdown-menu', @dropdown).toggleClass LOADING_CLASS @@ -194,7 +224,7 @@ class GitLabDropdown @remote.execute() if @options.filterable - @dropdown.find(".dropdown-input-field").focus() + @filterInput.focus() hidden: (e) => if @options.filterable @@ -234,13 +264,19 @@ class GitLabDropdown renderItem: (data) -> 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 "<li class='dropdown-header'>#{data.header}</li>" if data.header? + if @options.renderRow # Call the render function html = @options.renderRow(data) else - selected = if @options.isSelected then @options.isSelected(data) else false if not selected value = if @options.id then @options.id(data) else data.id fieldName = @options.fieldName @@ -248,13 +284,26 @@ class GitLabDropdown if field.length selected = true - url = if @options.url then @options.url(data) else "#" - text = if @options.text then @options.text(data) else "" + # 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()) + html = "<li>" html += "<a href='#{url}' class='#{cssClass}'>" html += text @@ -263,20 +312,26 @@ class GitLabDropdown 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>" - html += "<a href='#' class='dropdown-menu-empty-link is-focused'>" + html += "<a class='dropdown-menu-empty-link is-focused'>" html += "No matching results." html += "</a>" html += "</li>" highlightRow: (index) -> - if @input.val() isnt "" + if @filterInput.val() isnt "" selector = '.dropdown-content li:first-child a' if @dropdown.find(".dropdown-toggle-page").length selector = ".dropdown-page-one .dropdown-content li:first-child a" - $(selector).addClass 'is-focused' + @getElement(selector).addClass 'is-focused' rowClicked: (el) -> fieldName = @options.fieldName @@ -328,4 +383,6 @@ class GitLabDropdown $.fn.glDropdown = (opts) -> return @.each -> - new GitLabDropdown @, opts + if (!$.data @, 'glDropdown') + $.data(@, 'glDropdown', new GitLabDropdown @, opts) + diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index c1801365266..030655491bf 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -1,11 +1,270 @@ class @SearchAutocomplete - constructor: (search_autocomplete_path, project_id, project_ref) -> - project_id = '' unless project_id - project_ref = '' unless project_ref - query = "?project_id=" + project_id + "&project_ref=" + project_ref - - $("#search").autocomplete - source: search_autocomplete_path + query - minLength: 1 - select: (event, ui) -> - location.href = ui.item.url + + KEYCODE = + ESCAPE: 27 + BACKSPACE: 8 + ENTER: 13 + + 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('.search-location-badge') + @locationText = @getElement('.location-text') + @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(@) + + getData: (term, callback) -> + _this = @ + + # Do not trigger request if input is empty + return if @searchInput.val() is '' + + # 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 + 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 + + 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: @locationText.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', @onRemoveLocationClick + + enableAutocomplete: -> + # No need to enable anything if user is not logged in + return if !gon.current_user_id + + _this = @ + @loadingSuggestions = false + + @dropdown.addClass('open') + @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() + + 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() + + # Avoid falsy value to be returned + return + + onSearchInputClick: (e) => + # Prevents closing the dropdown menu + e.stopImmediatePropagation() + + onSearchInputFocus: => + @wrap.addClass('search-active') + + onRemoveLocationClick: (e) => + e.preventDefault() + @removeLocationBadge() + @searchInput.val('').focus() + @skipBlurEvent = true + + onSearchInputBlur: (e) => + @skipBlurEvent = false + + # We should wait to make sure we are not clearing the input instead + setTimeout( => + return if @skipBlurEvent + + @wrap.removeClass('search-active') + + # If input is blank then restore state + if @searchInput.val() is '' + @restoreOriginalState() + , 150) + + addLocationBadge: (item) -> + category = if item.category? then "#{item.category}: " else '' + value = if item.value? then item.value else '' + + html = "<span class='location-badge'> + <i class='location-text'>#{category}#{value}</i> + </span>" + @locationBadgeEl.html(html) + @wrap.addClass('has-location-badge') + + restoreOriginalState: -> + inputs = Object.keys @originalState + + for input in inputs + @getElement("##{input}").val(@originalState[input]) + + + if @originalState._location is '' + @locationBadgeEl.empty() + else + @addLocationBadge( + value: @originalState._location + ) + + @dropdown.removeClass 'open' + + badgePresent: -> + @locationBadgeEl.children().length + + resetSearchState: -> + inputs = Object.keys @originalState + + for input in inputs + + # _location isnt a input + break if input is '_location' + + @getElement("##{input}").val('') + + removeLocationBadge: -> + @locationBadgeEl.empty() + + # Reset state + @resetSearchState() + + @wrap.removeClass('has-location-badge') + + disableAutocomplete: -> + @searchInput.addClass('disabled') + @dropdown.removeClass('open') + @restoreMenu() + + restoreMenu: -> + html = "<ul> + <li><a class='dropdown-menu-empty-link is-focused'>Loading...</a></li> + </ul>" + @dropdownContent.html(html) diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 6c870ed927e..82dc1acbd01 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -42,7 +42,7 @@ font-size: 15px; text-align: left; border: 1px solid $dropdown-toggle-border-color; - border-radius: 2px; + border-radius: $dropdown-border-radius; outline: 0; text-overflow: ellipsis; white-space: nowrap; @@ -75,12 +75,12 @@ width: 240px; margin-top: 2px; margin-bottom: 0; - padding: 10px; - font-size: 14px; + font-size: 15px; font-weight: normal; + padding: 10px 0; background-color: $dropdown-bg; border: 1px solid $dropdown-border-color; - border-radius: $border-radius-base; + border-radius: $dropdown-border-radius; box-shadow: 0 2px 4px $dropdown-shadow-color; &.is-loading { @@ -101,9 +101,17 @@ li { text-align: left; list-style: none; + padding: 0 10px; } .divider { + height: 1px; + margin: 8px 10px; + padding: 0; + background-color: $dropdown-divider-color; + } + + .separator { width: 100%; height: 1px; margin-top: 8px; @@ -141,6 +149,17 @@ line-height: 16px; } } + + .dropdown-header { + color: $dropdown-header-color; + font-size: 13px; + line-height: 22px; + padding: 0 10px 10px; + } + + .separator + .dropdown-header { + padding-top: 2px; + } } .dropdown-menu-paging { @@ -158,6 +177,10 @@ .dropdown-menu-back { display: block; } + + .dropdown-content { + padding: 0 10px; + } } } @@ -193,7 +216,7 @@ } .dropdown-select { - width: 300px; + width: $dropdown-width; } .dropdown-menu-align-right { @@ -222,20 +245,11 @@ } } -.dropdown-header { - padding-left: 5px; - padding-right: 5px; - color: $dropdown-header-color; - font-size: 13px; - line-height: 22px; -} .dropdown-title { position: relative; - margin-bottom: 10px; - padding-left: 30px; - padding-right: 30px; - padding-bottom: 10px; + padding: 0 0 15px; + margin: 0 10px 10px; font-weight: 600; line-height: 1; text-align: center; @@ -261,21 +275,26 @@ } .dropdown-menu-close { - right: 0; + right: 7px; + width: 20px; + height: 20px; + top: -1px; } .dropdown-menu-back { - left: 0; + left: 7px; + top: 2px; } .dropdown-input { position: relative; margin-bottom: 10px; + padding: 0 10px; .fa { position: absolute; top: 10px; - right: 10px; + right: 20px; color: #c7c7c7; font-size: 12px; pointer-events: none; @@ -285,6 +304,9 @@ display: none; cursor: pointer; pointer-events: all; + right: 22px; + top: 9px; + font-size: 14px; } &.has-value { diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 4cb4129b71b..91b6451e68a 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -6,40 +6,6 @@ input { border-radius: $border-radius-base; } -input[type='search'] { - background-color: white; - padding-left: 10px; -} - -input[type='search'].search-input { - background-repeat: no-repeat; - background-position: 10px; - background-size: 16px; - background-position-x: 30%; - padding-left: 10px; - background-color: $gray-light; - - &.search-input[value=""] { - background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABwAAAAcCAYAAAByDd+UAAAFu0lEQVRIia1WTahkVxH+quqce7vf6zdvJpHoIlkYJ2SiJiIokmQjgoGgIAaEIYuYXWICgojiwkmC4taFwhjcyIDusogEIwwiSSCKPwsdwzAg0SjJ9Izzk5n3+nXfe8+pqizOvd395scfsJqi6dPnnDr11Vc/NJ1OwUTosqJLCmYCHCAC2mSHs+ojZv6AO46Y+20AhIneJsafhPhXVZSXDk7qi+aOLhtQNuBmQtcarAKjTXpn2+l3u2yPunvZSABRucjcAV/eMZuM48/Go/g1d19kc4wq+e8MZjWkbI/P5t2P3RFFbv7SQdyBlBUx8N8OTuqjMcof+N94yMPrY2DMm/ytnb32J0QrY+6AqsHM4Q64O9SKDmerKDD3Oy/tNL9vk342CC8RuU6n0ymCMHb22scu7zQngtASOjUHE1BX4UUAv4b7Ow6qiXCXuz/UdvogAAweDY943/b4cAz0ZlYHXeMsnT07RVb7wMUr8ykI4H5HVkMd5Rcb4/jNURVOL5qErAaAUUdCCIJ5kx5q2nw8m39ImEAAsjpE6PStB0YfMcd1wqqG3Xn7A3PfZyyKnNjaqD4fmE/fCNKshirIyY1xvI+Av6g5QIAIIWX7cJPssboSiBBEeKmsZne0Sb8kzAUWNYyq8NvbDo0fZ6beqxuLmqOOMr/lwOh+YXpXtbjERGja9JyZ9+HxpXKb9Gj5oywRESbj+Cj1ENG1QViTGBl1FbC1We1tbVRfHWIoQkhqH9xbpE92XUbb6VJZ1R4crjRz1JWcDMJvLdoMcyAEhjuwHo8Bfndg3mbszhOY+adVlMtD3po51OwzIQiEaams7oeJhxRw1FFOVpFRRUYIhMBAFRnjOsC8IFHHUA4TQQhgAqpAiIFfGbxkIqj54ayGbL7UoOqHCniAEKHLNr26l+D9wQJzeUwMAnfHvEnLECzZRwRV++d60ptjW9VLZeolEJG6GwCCE0CFVNB+Ay0NEqoQYG4YYFu7B8IEVRt3uRzy/osIoLV9QZimWXGHUMFdmI6M64DUF2Je88R9VZqCSP+QlcF5k+4tCzSsXaqjINuK6UyE0+s/mk6/qFq8oAIL9pqMLhkGsNrOyoOIlszust3aJv0U9+kFdwjTGwWl1YdF+KWlQSZ0Se/psj8yGVdg5tJyfH96EBWmLtoEMwMzMFt031NzGWLLzKhC+KV7H5ZeeaMOPxemma2x68puc0LN3+/u6LJiePS6MKHvn4wu6cPzJj0hsioeMfDrEvjv5r6W9gBvjKJujuKzQ0URIZj75NylvT+mbHfXQa4rwAMaVRTMm/SFyzvNy0yF6+4AM+1ubcSnqkAIUjQKl1RKSbE5jt+vovx1MBqF0WW7/d1Z80ab9BtmuJ3Xk5cJKds9TZt/uLPXvtiTrQ+dIwqfAejUvM1os6FNikXKUHfQ+ekUsXT5u85enJ0CaBSkkGEo1syUQ+DfMdE/4GA1uzupf9zdbzhOmLsF4efHVXjaHHAzmDtGdQRd/Nc5wAEJjNki3XfhyvwVNz80xANrht3LsENY9cBBdN1L9GUyyvFRFZ42t75sBvCQRykbRlU4tT2pPxoCvzx09d4GmPs200M6wKdWSDGK8mppYSWdhAlt0qeaLv+IadXU9/Evq4FAZ8ej+LmtcTxaRX4NWI0Uag5Vg1p5MYg8BnlhXIdPHDow+vTWZvVMVttXDLqkTzZdPj6Qii6cP1cSvIdl3iQkNYyi9HH0I22y+93tY3DcQkTZgQtM+POoCr8x97eylkmtrgKuztrvXJ21x/aNKuqIkZ/fntRfCdcTfhUTAIhRzoDojJD0aSNLLwMzmpT7+JaLtyf1MwDo6qz9djFaUq3t9MlFmy/c1OCSceY9fMsVaL9mvH9ocXdkdWxv1scAePG0THAhMOaLdOw/Gvxfxb1w4eCapyIENUcV5M3/u8FitAxZ25P6GAHT3UX39Srw+QOb1ZffA98Dl2Wy1BYkAAAAAElFTkSuQmCC'); - } - - &.search-input::-webkit-input-placeholder { - text-align: center; - } - - &.search-input:-moz-placeholder { /* Firefox 18- */ - text-align: center; - } - - &.search-input::-moz-placeholder { /* Firefox 19+ */ - text-align: center; - } - - &.search-input:-ms-input-placeholder { - text-align: center; - } -} - input[type='text'].danger { background: #f2dede!important; border-color: #d66; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 6a68bb5c115..a450167b04f 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -117,26 +117,6 @@ header { } } - .search { - margin-right: 10px; - margin-left: 10px; - margin-top: ($header-height - 36) / 2; - - form { - margin: 0; - padding: 0; - } - - .search-input { - width: 220px; - - &:focus { - @include box-shadow(none); - outline: none; - } - } - } - .impersonation i { color: $red-normal; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 61e0dd4d672..bb49ae396c7 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -66,7 +66,7 @@ $header-height: 58px; $fixed-layout-width: 1280px; $gl-avatar-size: 40px; $error-exclamation-point: #e62958; -$border-radius-default: 3px; +$border-radius-default: 2px; $btn-transparent-color: #8f8f8f; $ssh-key-icon-color: #8f8f8f; $ssh-key-icon-size: 18px; @@ -166,6 +166,8 @@ $regular_font: 'Source Sans Pro', "Helvetica Neue", Helvetica, Arial, sans-serif /* * Dropdowns */ +$dropdown-border-radius: 2px; +$dropdown-width: 300px; $dropdown-bg: #fff; $dropdown-link-color: #555; $dropdown-link-hover-bg: $row-hover; @@ -177,7 +179,7 @@ $dropdown-header-color: #959494; $dropdown-title-btn-color: #bfbfbf; $dropdown-input-color: #555; $dropdown-input-focus-border: rgb(58, 171, 240); -$dropdown-input-focus-shadow: rgba(#000, .2); +$dropdown-input-focus-shadow: rgba($dropdown-input-focus-border, .4); $dropdown-loading-bg: rgba(#fff, .6); $dropdown-toggle-bg: #fff; @@ -193,3 +195,15 @@ $dropdown-toggle-hover-icon-color: $dropdown-toggle-hover-border-color; $award-emoji-menu-bg: #fff; $award-emoji-menu-border: #f1f2f4; $award-emoji-new-btn-icon-color: #dcdcdc; + +/* + * Search Box + */ +$search-input-border-color: $dropdown-input-focus-border; +$search-input-focus-shadow-color: $dropdown-input-focus-shadow; +$search-input-width: $dropdown-width; +$location-badge-color: #aaa; +$location-badge-bg: $gray-normal; +$location-icon-color: #e7e9ed; +$location-active-color: $gl-text-color; +$location-active-bg: $search-input-border-color; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index b6e45024644..3c74d25beb0 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -21,3 +21,145 @@ } } +.search { + margin-right: 10px; + margin-left: 10px; + margin-top: ($header-height - 35) / 2; + + form { + @extend .form-control; + margin: 0; + padding: 4px; + width: $search-input-width; + line-height: 24px; + } + + .location-text { + font-style: normal; + } + + .search-input { + border: none; + font-size: 14px; + outline: none; + padding: 0; + margin-left: 5px; + line-height: 25px; + width: 98%; + } + + .location-badge { + line-height: 25px; + padding: 0 5px; + border-radius: $border-radius-default; + font-size: 14px; + font-style: normal; + color: $location-badge-color; + display: inline-block; + background-color: $location-badge-bg; + vertical-align: top; + } + + .search-input-container { + display: -webkit-flex; + display: flex; + position: relative; + } + + .search-location-badge, .search-input-wrap { + // Fallback if flexbox is not supported + display: inline-block; + } + + .search-input-wrap { + width: 100%; + + .search-icon, .clear-icon { + position: absolute; + right: 5px; + top: 0; + color: $location-icon-color; + + &:before { + font-family: FontAwesome; + font-weight: normal; + font-style: normal; + } + } + + .search-icon { + @extend .fa-search; + @include transition(color .15s); + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + } + + .clear-icon { + @extend .fa-times; + display: none; + } + + // Rewrite position. Dropdown menu should be relative to .search-input-container + .dropdown { + position: static; + } + + .dropdown-header { + text-transform: uppercase; + font-size: 11px; + } + + // Custom dropdown positioning + .dropdown-menu { + top: 30px; + left: -5px; + padding: 0; + + ul { + padding: 10px 0; + } + } + + .dropdown-content { + max-height: 350px; + } + } + + &.search-active { + form { + @extend .form-control:focus; + border-color: $dropdown-input-focus-border; + box-shadow: 0 0 4px $search-input-focus-shadow-color; + } + + .location-badge { + @include transition(all .15s); + background-color: $location-active-bg; + color: $white-light; + } + + .search-input-wrap { + i { + color: $location-active-color; + } + } + + &.has-location-badge { + .search-icon { + display: none; + } + + .clear-icon { + cursor: pointer; + display: block; + } + } + } + + &.has-location-badge { + .search-input-wrap { + width: 78%; + } + } +} diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 494dad0b41e..8a97a74ad73 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -1,4 +1,5 @@ module SearchHelper + def search_autocomplete_opts(term) return unless current_user @@ -23,45 +24,44 @@ module SearchHelper # Autocomplete results for various settings pages def default_autocomplete [ - { label: "Profile settings", url: profile_path }, - { label: "SSH Keys", url: profile_keys_path }, - { label: "Dashboard", url: root_path }, - { label: "Admin Section", url: admin_root_path }, + { category: "Settings", label: "Profile settings", url: profile_path }, + { category: "Settings", label: "SSH Keys", url: profile_keys_path }, + { category: "Settings", label: "Dashboard", url: root_path }, + { category: "Settings", label: "Admin Section", url: admin_root_path }, ] end # Autocomplete results for internal help pages def help_autocomplete [ - { label: "help: API Help", url: help_page_path("api", "README") }, - { label: "help: Markdown Help", url: help_page_path("markdown", "markdown") }, - { label: "help: Permissions Help", url: help_page_path("permissions", "permissions") }, - { label: "help: Public Access Help", url: help_page_path("public_access", "public_access") }, - { label: "help: Rake Tasks Help", url: help_page_path("raketasks", "README") }, - { label: "help: SSH Keys Help", url: help_page_path("ssh", "README") }, - { label: "help: System Hooks Help", url: help_page_path("system_hooks", "system_hooks") }, - { label: "help: Webhooks Help", url: help_page_path("web_hooks", "web_hooks") }, - { label: "help: Workflow Help", url: help_page_path("workflow", "README") }, + { category: "Help", label: "API Help", url: help_page_path("api", "README") }, + { category: "Help", label: "Markdown Help", url: help_page_path("markdown", "markdown") }, + { category: "Help", label: "Permissions Help", url: help_page_path("permissions", "permissions") }, + { category: "Help", label: "Public Access Help", url: help_page_path("public_access", "public_access") }, + { category: "Help", label: "Rake Tasks Help", url: help_page_path("raketasks", "README") }, + { category: "Help", label: "SSH Keys Help", url: help_page_path("ssh", "README") }, + { category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks", "system_hooks") }, + { category: "Help", label: "Webhooks Help", url: help_page_path("web_hooks", "web_hooks") }, + { category: "Help", label: "Workflow Help", url: help_page_path("workflow", "README") }, ] end # Autocomplete results for the current project, if it's defined def project_autocomplete if @project && @project.repository.exists? && @project.repository.root_ref - prefix = search_result_sanitize(@project.name_with_namespace) ref = @ref || @project.repository.root_ref [ - { label: "#{prefix} - Files", url: namespace_project_tree_path(@project.namespace, @project, ref) }, - { label: "#{prefix} - Commits", url: namespace_project_commits_path(@project.namespace, @project, ref) }, - { label: "#{prefix} - Network", url: namespace_project_network_path(@project.namespace, @project, ref) }, - { label: "#{prefix} - Graph", url: namespace_project_graph_path(@project.namespace, @project, ref) }, - { label: "#{prefix} - Issues", url: namespace_project_issues_path(@project.namespace, @project) }, - { label: "#{prefix} - Merge Requests", url: namespace_project_merge_requests_path(@project.namespace, @project) }, - { label: "#{prefix} - Milestones", url: namespace_project_milestones_path(@project.namespace, @project) }, - { label: "#{prefix} - Snippets", url: namespace_project_snippets_path(@project.namespace, @project) }, - { label: "#{prefix} - Members", url: namespace_project_project_members_path(@project.namespace, @project) }, - { label: "#{prefix} - Wiki", url: namespace_project_wikis_path(@project.namespace, @project) }, + { category: "Current Project", label: "Files", url: namespace_project_tree_path(@project.namespace, @project, ref) }, + { category: "Current Project", label: "Commits", url: namespace_project_commits_path(@project.namespace, @project, ref) }, + { category: "Current Project", label: "Network", url: namespace_project_network_path(@project.namespace, @project, ref) }, + { category: "Current Project", label: "Graph", url: namespace_project_graph_path(@project.namespace, @project, ref) }, + { category: "Current Project", label: "Issues", url: namespace_project_issues_path(@project.namespace, @project) }, + { category: "Current Project", label: "Merge Requests", url: namespace_project_merge_requests_path(@project.namespace, @project) }, + { category: "Current Project", label: "Milestones", url: namespace_project_milestones_path(@project.namespace, @project) }, + { category: "Current Project", label: "Snippets", url: namespace_project_snippets_path(@project.namespace, @project) }, + { category: "Current Project", label: "Members", url: namespace_project_project_members_path(@project.namespace, @project) }, + { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) }, ] else [] @@ -72,7 +72,9 @@ module SearchHelper def groups_autocomplete(term, limit = 5) current_user.authorized_groups.search(term).limit(limit).map do |group| { - label: "group: #{search_result_sanitize(group.name)}", + category: "Groups", + id: group.id, + label: "#{search_result_sanitize(group.name)}", url: group_path(group) } end @@ -83,7 +85,10 @@ module SearchHelper current_user.authorized_projects.search_by_title(term). sorted_by_stars.non_archived.limit(limit).map do |p| { - label: "project: #{search_result_sanitize(p.name_with_namespace)}", + category: "Projects", + id: p.id, + value: "#{search_result_sanitize(p.name)}", + label: "#{search_result_sanitize(p.name_with_namespace)}", url: namespace_project_path(p.namespace, p) } end diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 54af2c3063c..9d4ab9847a8 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -1,10 +1,33 @@ -.search - = form_tag search_path, method: :get, class: 'navbar-form pull-left' do |f| - = search_field_tag "search", nil, placeholder: 'Search', class: "search-input form-control", spellcheck: false, tabindex: "1" +- if controller.controller_path =~ /^groups/ + - label = 'This group' +- if controller.controller_path =~ /^projects/ + - label = 'This project' + +.search.search-form{class: "#{'has-location-badge' if label.present?}"} + = form_tag search_path, method: :get, class: 'navbar-form' do |f| + .search-input-container + .search-location-badge + - if label.present? + %span.location-badge + %i.location-text + = label + .search-input-wrap + .dropdown{ data: {url: search_autocomplete_path } } + = search_field_tag "search", nil, placeholder: 'Search', class: "search-input dropdown-menu-toggle", spellcheck: false, tabindex: "1", autocomplete: 'off', data: { toggle: 'dropdown' } + .dropdown-menu.dropdown-select + = dropdown_content do + %ul + %li + %a.is-focused.dropdown-menu-empty-link + Loading... + = dropdown_loading + %i.search-icon + %i.clear-icon.js-clear-input + = hidden_field_tag :group_id, @group.try(:id) - - if @project && @project.persisted? - = hidden_field_tag :project_id, @project.id + = hidden_field_tag :project_id, @project && @project.persisted? ? @project.id : '', id: 'search_project_id' + - if @project && @project.persisted? - if current_controller?(:issues) = hidden_field_tag :scope, 'issues' - elsif current_controller?(:merge_requests) @@ -21,10 +44,3 @@ = hidden_field_tag :repository_ref, @ref = button_tag 'Go' if ENV['RAILS_ENV'] == 'test' .search-autocomplete-opts.hide{:'data-autocomplete-path' => search_autocomplete_path, :'data-autocomplete-project-id' => @project.try(:id), :'data-autocomplete-project-ref' => @ref } - -:javascript - $('.search-input').on('keyup', function(e) { - if (e.keyCode == 27) { - $('.search-input').blur(); - } - }); diff --git a/features/steps/dashboard/issues.rb b/features/steps/dashboard/issues.rb index 93aa77589be..e21af72a777 100644 --- a/features/steps/dashboard/issues.rb +++ b/features/steps/dashboard/issues.rb @@ -42,11 +42,10 @@ class Spinach::Features::DashboardIssues < Spinach::FeatureSteps end step 'I click "All" link' do - find('.js-author-search').click - find('.dropdown-content a', match: :first).click - - find('.js-assignee-search').click - find('.dropdown-content a', match: :first).click + find(".js-author-search").click + find(".dropdown-menu-author li a", match: :first).click + find(".js-assignee-search").click + find(".dropdown-menu-assignee li a", match: :first).click end def should_see(issue) |
