From 4b3d344688954e9c515b9bb8f26239a781fcabfb Mon Sep 17 00:00:00 2001
From: Alfredo Sumaran <alfredo@gitlab.com>
Date: Tue, 8 Mar 2016 02:56:43 -0500
Subject: [PATCH] Working version of autocomplete with categorized results

---
 app/assets/javascripts/dispatcher.js.coffee   |   7 +-
 .../lib/category_autocomplete.js.coffee       |  17 ++
 .../javascripts/search_autocomplete.js.coffee | 169 +++++++++++++++++-
 app/helpers/search_helper.rb                  |  59 +++---
 app/views/layouts/_search.html.haml           |  13 +-
 app/views/shared/_location_badge.html.haml    |  13 ++
 6 files changed, 229 insertions(+), 49 deletions(-)
 create mode 100644 app/assets/javascripts/lib/category_autocomplete.js.coffee
 create mode 100644 app/views/shared/_location_badge.html.haml

diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee
index 1be86e3b820..0aefea7d8d9 100644
--- a/app/assets/javascripts/dispatcher.js.coffee
+++ b/app/assets/javascripts/dispatcher.js.coffee
@@ -151,9 +151,4 @@ class Dispatcher
       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)
+    new SearchAutocomplete()
diff --git a/app/assets/javascripts/lib/category_autocomplete.js.coffee b/app/assets/javascripts/lib/category_autocomplete.js.coffee
new file mode 100644
index 00000000000..490032dc782
--- /dev/null
+++ b/app/assets/javascripts/lib/category_autocomplete.js.coffee
@@ -0,0 +1,17 @@
+$.widget( "custom.catcomplete",  $.ui.autocomplete,
+  _create: ->
+    @_super();
+    @widget().menu("option", "items", "> :not(.ui-autocomplete-category)")
+
+  _renderMenu: (ul, items) ->
+    currentCategory = ''
+    $.each items, (index, item) =>
+      if item.category isnt currentCategory
+        ul.append("<li class='ui-autocomplete-category'>#{item.category}</li>")
+        currentCategory = item.category
+
+      li = @_renderItemData(ul, item)
+
+      if item.category?
+        li.attr('aria-label', item.category + " : " + item.label)
+  )
diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee
index c1801365266..df31b07910c 100644
--- a/app/assets/javascripts/search_autocomplete.js.coffee
+++ b/app/assets/javascripts/search_autocomplete.js.coffee
@@ -1,11 +1,164 @@
 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
+  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
 
-    $("#search").autocomplete
-      source: search_autocomplete_path + query
+    @keyCode =
+      ESCAPE: 27
+      BACKSPACE: 8
+      TAB: 9
+      ENTER: 13
+
+    @locationBadgeEl = @$('.search-location-badge')
+    @locationText = @$('.location-text')
+    @searchInput = @$('.search-input')
+    @projectInputEl = @$('#project_id')
+    @groupInputEl = @$('#group_id')
+    @searchCodeInputEl = @$('#search_code')
+    @repositoryInputEl = @$('#repository_ref')
+    @scopeInputEl = @$('#scope')
+
+    @saveOriginalState()
+    @createAutocomplete()
+    @bindEvents()
+
+  $: (selector) ->
+    @wrap.find(selector)
+
+  saveOriginalState: ->
+    @originalState = @serializeState()
+
+  restoreOriginalState: ->
+    inputs = Object.keys @originalState
+
+    for input in inputs
+      @$("##{input}").val(@originalState[input])
+
+
+    if @originalState._location is ''
+      @locationBadgeEl.html('')
+    else
+      @addLocationBadge(
+        value: @originalState._location
+      )
+
+  serializeState: ->
+    {
+      # Search Criteria
+      project_id: @projectInputEl.val()
+      group_id: @groupInputEl.val()
+      search_code: @searchCodeInputEl.val()
+      repository_ref: @repositoryInputEl.val()
+
+      # Location badge
+      _location: $.trim(@locationText.text())
+    }
+
+  createAutocomplete: ->
+    @query = "?project_id=" + @projectId + "&project_ref=" + @projectRef
+
+    @catComplete = @searchInput.catcomplete
+      appendTo: 'form.navbar-form'
+      source: @autocompletePath + @query
       minLength: 1
-      select: (event, ui) ->
-        location.href = ui.item.url
+      close: (e) ->
+        e.preventDefault()
+
+      select: (event, ui) =>
+        # Pressing enter choses an alternative
+        if event.keyCode is @keyCode.ENTER
+          @goToResult(ui.item)
+        else
+          # Pressing tab sets the scope
+          if event.keyCode is @keyCode.TAB and ui.item.scope?
+            @setLocationBadge(ui.item)
+            @searchInput
+              .val('') # remove selected value from input
+              .focus()
+          else
+            # If option is not a scope go to page
+            @goToResult(ui.item)
+
+          # Return false to avoid focus on the next element
+          return false
+
+
+  bindEvents: ->
+    @searchInput.on 'keydown', @onSearchKeyDown
+    @wrap.on 'click', '.remove-badge', @onRemoveLocationBadgeClick
+
+  onRemoveLocationBadgeClick: (e) =>
+    e.preventDefault()
+    @removeLocationBadge()
+    @searchInput.focus()
+
+  onSearchKeyDown: (e) =>
+    # Remove tag when pressing backspace and input search is empty
+    if e.keyCode is @keyCode.BACKSPACE and e.currentTarget.value is ''
+      @removeLocationBadge()
+      @destroyAutocomplete()
+      @searchInput.focus()
+    else if e.keyCode is @keyCode.ESCAPE
+      @restoreOriginalState()
+    else
+      # Create new autocomplete instance if it's not created
+      @createAutocomplete() unless @catcomplete?
+
+  addLocationBadge: (item) ->
+    category = if item.category? then "#{item.category}: " else ''
+    value = if item.value? then item.value else ''
+
+    html = "<span class='label label-primary'>
+              <i class='location-text'>#{category}#{value}</i>
+              <a class='remove-badge' href='#'>x</a>
+            </span>"
+    @locationBadgeEl.html(html)
+
+  setLocationBadge: (item) ->
+    @addLocationBadge(item)
+
+    # Reset input states
+    @resetSearchState()
+
+    switch item.scope
+      when 'projects'
+        @projectInputEl.val(item.id)
+        # @searchCodeInputEl.val('true') # TODO: always true for projects?
+        # @repositoryInputEl.val('master') # TODO: always master?
+
+      when 'groups'
+        @groupInputEl.val(item.id)
+
+  removeLocationBadge: ->
+    @locationBadgeEl.empty()
+
+    # Reset state
+    @resetSearchState()
+
+  resetSearchState: ->
+    # Remove scope
+    @scopeInputEl.val('')
+
+    # Remove group
+    @groupInputEl.val('')
+
+    # Remove project id
+    @projectInputEl.val('')
+
+    # Remove code search
+    @searchCodeInputEl.val('')
+
+    # Remove repository ref
+    @repositoryInputEl.val('')
+
+  goToResult: (result) ->
+    location.href = result.url
+
+  destroyAutocomplete: ->
+    @catComplete.destroy() if @catcomplete?
+    @catComplete = null
diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb
index 494dad0b41e..9102fd6d501 100644
--- a/app/helpers/search_helper.rb
+++ b/app/helpers/search_helper.rb
@@ -23,45 +23,45 @@ 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)
+      prefix = "Project - " + 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: prefix, label: "Files",          url: namespace_project_tree_path(@project.namespace, @project, ref) },
+        { category: prefix, label: "Commits",        url: namespace_project_commits_path(@project.namespace, @project, ref) },
+        { category: prefix, label: "Network",        url: namespace_project_network_path(@project.namespace, @project, ref) },
+        { category: prefix, label: "Graph",          url: namespace_project_graph_path(@project.namespace, @project, ref) },
+        { category: prefix, label: "Issues",         url: namespace_project_issues_path(@project.namespace, @project) },
+        { category: prefix, label: "Merge Requests", url: namespace_project_merge_requests_path(@project.namespace, @project) },
+        { category: prefix, label: "Milestones",     url: namespace_project_milestones_path(@project.namespace, @project) },
+        { category: prefix, label: "Snippets",       url: namespace_project_snippets_path(@project.namespace, @project) },
+        { category: prefix, label: "Members",        url: namespace_project_project_members_path(@project.namespace, @project) },
+        { category: prefix, label: "Wiki",           url: namespace_project_wikis_path(@project.namespace, @project) },
       ]
     else
       []
@@ -72,7 +72,10 @@ 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",
+        scope: "groups",
+        id: group.id,
+        label: "#{search_result_sanitize(group.name)}",
         url: group_path(group)
       }
     end
@@ -83,7 +86,11 @@ 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",
+        scope: "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..c5002893831 100644
--- a/app/views/layouts/_search.html.haml
+++ b/app/views/layouts/_search.html.haml
@@ -1,10 +1,12 @@
 .search
   = form_tag search_path, method: :get, class: 'navbar-form pull-left' do |f|
+    = render 'shared/location_badge'
     = search_field_tag "search", nil, placeholder: 'Search', class: "search-input form-control", spellcheck: false, tabindex: "1"
     = 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 : ''
+
+    - if @project && @project.persisted?
       - if current_controller?(:issues)
         = hidden_field_tag :scope, 'issues'
       - elsif current_controller?(:merge_requests)
@@ -21,10 +23,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/app/views/shared/_location_badge.html.haml b/app/views/shared/_location_badge.html.haml
new file mode 100644
index 00000000000..dfe8bc010d6
--- /dev/null
+++ b/app/views/shared/_location_badge.html.haml
@@ -0,0 +1,13 @@
+- if controller.controller_path =~ /^groups/
+  - label = 'This group'
+- if controller.controller_path =~ /^projects/
+  - label = 'This project'
+
+.search-location-badge
+  - if label.present?
+    %span.label.label-primary
+      %i.location-text
+        = label
+
+      %a.remove-badge{href: '#'}
+        x
-- 
GitLab