Commit f12fdf16 authored by Dmitriy Zaporozhets's avatar Dmitriy Zaporozhets

Merge branch 'improve-search-page' into 'master'

Improve search page

Fixes #1270

* added pagination support
* filter results by type
* when switch between context - search params stays
* redesign comment search results
* search Issues and MR by description too

## Screenshots:

![Screenshot 2014-08-27 09.56.18](https://dev.gitlab.org/uploads/gitlab/gitlabhq/2053220cd4/Screenshot_2014-08-27_09.56.18.png)

![Screenshot 2014-08-27 09.56.00](https://dev.gitlab.org/uploads/gitlab/gitlabhq/726c764da4/Screenshot_2014-08-27_09.56.00.png)

See merge request !1043
parents 67d96b0f c5c906fe
...@@ -128,7 +128,7 @@ p.time { ...@@ -128,7 +128,7 @@ p.time {
} }
.highlight_word { .highlight_word {
border-bottom: 2px solid #F90; background: #fafe3d;
} }
.thin_area{ .thin_area{
......
...@@ -20,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -20,7 +20,7 @@ class Projects::IssuesController < Projects::ApplicationController
terms = params['issue_search'] terms = params['issue_search']
@issues = issues_filtered @issues = issues_filtered
@issues = @issues.where("title LIKE ? OR description LIKE ?", "%#{terms}%", "%#{terms}%") if terms.present? @issues = @issues.full_search(terms) if terms.present?
@issues = @issues.page(params[:page]).per(20) @issues = @issues.page(params[:page]).per(20)
assignee_id, milestone_id = params[:assignee_id], params[:milestone_id] assignee_id, milestone_id = params[:assignee_id], params[:milestone_id]
......
...@@ -4,14 +4,25 @@ class SearchController < ApplicationController ...@@ -4,14 +4,25 @@ class SearchController < ApplicationController
def show def show
@project = Project.find_by(id: params[:project_id]) if params[:project_id].present? @project = Project.find_by(id: params[:project_id]) if params[:project_id].present?
@group = Group.find_by(id: params[:group_id]) if params[:group_id].present? @group = Group.find_by(id: params[:group_id]) if params[:group_id].present?
@scope = params[:scope]
if @project @search_results = if @project
return access_denied! unless can?(current_user, :download_code, @project) return access_denied! unless can?(current_user, :download_code, @project)
@search_results = Search::ProjectService.new(@project, current_user, params).execute unless %w(blobs notes issues merge_requests).include?(@scope)
else @scope = 'blobs'
@search_results = Search::GlobalService.new(current_user, params).execute end
end
Search::ProjectService.new(@project, current_user, params).execute
else
unless %w(projects issues merge_requests).include?(@scope)
@scope = 'projects'
end
Search::GlobalService.new(current_user, params).execute
end
@objects = @search_results.objects(@scope, params[:page])
end end
def autocomplete def autocomplete
......
...@@ -91,4 +91,21 @@ module SearchHelper ...@@ -91,4 +91,21 @@ module SearchHelper
def search_result_sanitize(str) def search_result_sanitize(str)
Sanitize.clean(str) Sanitize.clean(str)
end end
def search_filter_path(options={})
exist_opts = {
search: params[:search],
project_id: params[:project_id],
group_id: params[:group_id],
scope: params[:scope]
}
options = exist_opts.merge(options)
search_path(options)
end
# Sanitize html generated after parsing markdown from issue description or comment
def search_md_sanitize(html)
sanitize(html, tags: %w(a p ul li pre code))
end
end end
...@@ -49,6 +49,10 @@ module Issuable ...@@ -49,6 +49,10 @@ module Issuable
where("LOWER(title) like :query", query: "%#{query.downcase}%") where("LOWER(title) like :query", query: "%#{query.downcase}%")
end end
def full_search(query)
where("LOWER(title) like :query OR LOWER(description) like :query", query: "%#{query.downcase}%")
end
def sort(method) def sort(method)
case method.to_s case method.to_s
when 'newest' then reorder("#{table_name}.created_at DESC") when 'newest' then reorder("#{table_name}.created_at DESC")
......
...@@ -146,6 +146,10 @@ class Note < ActiveRecord::Base ...@@ -146,6 +146,10 @@ class Note < ActiveRecord::Base
def cross_reference_exists?(noteable, mentioner) def cross_reference_exists?(noteable, mentioner)
where(noteable_id: noteable.id, system: true, note: "_mentioned in #{mentioner.gfm_reference}_").any? where(noteable_id: noteable.id, system: true, note: "_mentioned in #{mentioner.gfm_reference}_").any?
end end
def search(query)
where("note like :query", query: "%#{query}%")
end
end end
def commit_author def commit_author
......
...@@ -177,11 +177,11 @@ class Project < ActiveRecord::Base ...@@ -177,11 +177,11 @@ class Project < ActiveRecord::Base
joins(:issues, :notes, :merge_requests).order("issues.created_at, notes.created_at, merge_requests.created_at DESC") joins(:issues, :notes, :merge_requests).order("issues.created_at, notes.created_at, merge_requests.created_at DESC")
end end
def search query def search(query)
joins(:namespace).where("projects.archived = ?", false).where("projects.name LIKE :query OR projects.path LIKE :query OR namespaces.name LIKE :query OR projects.description LIKE :query", query: "%#{query}%") joins(:namespace).where("projects.archived = ?", false).where("projects.name LIKE :query OR projects.path LIKE :query OR namespaces.name LIKE :query OR projects.description LIKE :query", query: "%#{query}%")
end end
def search_by_title query def search_by_title(query)
where("projects.archived = ?", false).where("LOWER(projects.name) LIKE :query", query: "%#{query.downcase}%") where("projects.archived = ?", false).where("LOWER(projects.name) LIKE :query", query: "%#{query.downcase}%")
end end
......
...@@ -7,30 +7,12 @@ module Search ...@@ -7,30 +7,12 @@ module Search
end end
def execute def execute
query = params[:search]
query = Shellwords.shellescape(query) if query.present?
return result unless query.present?
group = Group.find_by(id: params[:group_id]) if params[:group_id].present? group = Group.find_by(id: params[:group_id]) if params[:group_id].present?
projects = ProjectsFinder.new.execute(current_user) projects = ProjectsFinder.new.execute(current_user)
projects = projects.where(namespace_id: group.id) if group projects = projects.where(namespace_id: group.id) if group
project_ids = projects.pluck(:id) project_ids = projects.pluck(:id)
result[:projects] = projects.search(query).limit(20) Gitlab::SearchResults.new(project_ids, params[:search])
result[:merge_requests] = MergeRequest.in_projects(project_ids).search(query).order('updated_at DESC').limit(20)
result[:issues] = Issue.where(project_id: project_ids).search(query).order('updated_at DESC').limit(20)
result[:total_results] = %w(projects issues merge_requests).sum { |items| result[items.to_sym].size }
result
end
def result
@result ||= {
projects: [],
merge_requests: [],
issues: [],
notes: [],
total_results: 0,
}
end end
end end
end end
...@@ -7,39 +7,9 @@ module Search ...@@ -7,39 +7,9 @@ module Search
end end
def execute def execute
query = params[:search] Gitlab::ProjectSearchResults.new(project.id,
query = Shellwords.shellescape(query) if query.present? params[:search],
return result unless query.present? params[:repository_ref])
if params[:search_code].present?
if !@project.empty_repo?
blobs = project.repository.search_files(query,
params[:repository_ref])
else
blobs = Array.new
end
blobs = Kaminari.paginate_array(blobs).page(params[:page]).per(20)
result[:blobs] = blobs
result[:total_results] = blobs.total_count
else
result[:merge_requests] = project.merge_requests.search(query).order('updated_at DESC').limit(20)
result[:issues] = project.issues.where("title like :query OR description like :query ", query: "%#{query}%").order('updated_at DESC').limit(20)
result[:notes] = Note.where(noteable_type: 'issue').where(project_id: project.id).where("note like :query", query: "%#{query}%").order('updated_at DESC').limit(20)
result[:total_results] = %w(issues merge_requests notes).sum { |items| result[items.to_sym].size }
end
result
end
def result
@result ||= {
merge_requests: [],
issues: [],
blobs: [],
notes: [],
total_results: 0,
}
end end
end end
end end
...@@ -9,14 +9,14 @@ ...@@ -9,14 +9,14 @@
%b.caret %b.caret
%ul.dropdown-menu %ul.dropdown-menu
%li %li
= link_to search_path(group_id: nil, search: params[:search]) do = link_to search_filter_path(group_id: nil) do
Any Any
- current_user.authorized_groups.sort_by(&:name).each do |group| - current_user.authorized_groups.sort_by(&:name).each do |group|
%li %li
= link_to search_path(group_id: group.id, search: params[:search]) do = link_to search_filter_path(group_id: group.id, project_id: nil) do
= group.name = group.name
.dropdown.inline.prepend-left-10 .dropdown.inline.prepend-left-10.project-filter
%a.dropdown-toggle.btn.btn-small{href: '#', "data-toggle" => "dropdown"} %a.dropdown-toggle.btn.btn-small{href: '#', "data-toggle" => "dropdown"}
%i.icon-tags %i.icon-tags
%span.light Project: %span.light Project:
...@@ -27,9 +27,9 @@ ...@@ -27,9 +27,9 @@
%b.caret %b.caret
%ul.dropdown-menu %ul.dropdown-menu
%li %li
= link_to search_path(project_id: nil, search: params[:search]) do = link_to search_filter_path(project_id: nil) do
Any Any
- current_user.authorized_projects.sort_by(&:name_with_namespace).each do |project| - current_user.authorized_projects.sort_by(&:name_with_namespace).each do |project|
%li %li
= link_to search_path(project_id: project.id, search: params[:search]) do = link_to search_filter_path(project_id: project.id, group_id: nil) do
= project.name_with_namespace = project.name_with_namespace
.search_results .row
%ul.bordered-list .col-sm-3
= render partial: "search/results/project", collection: @search_results[:projects] %ul.nav.nav-pills.nav-stacked.search-filter
= render partial: "search/results/merge_request", collection: @search_results[:merge_requests] %li{class: ("active" if @scope == 'projects')}
= render partial: "search/results/issue", collection: @search_results[:issues] = link_to search_filter_path(scope: 'projects') do
Projects
.pull-right
= @search_results.projects_count
%li{class: ("active" if @scope == 'issues')}
= link_to search_filter_path(scope: 'issues') do
Issues
.pull-right
= @search_results.issues_count
%li{class: ("active" if @scope == 'merge_requests')}
= link_to search_filter_path(scope: 'merge_requests') do
Merge requests
.pull-right
= @search_results.merge_requests_count
.col-sm-9
.search_results
- if @search_results.empty?
= render partial: "search/results/empty", locals: { message: "We couldn't find any matching results" }
%ul.bordered-list.top-list
= render partial: "search/results/#{@scope.singularize}", collection: @objects
= paginate @objects, theme: 'gitlab'
%ul.nav.nav-tabs .row
%li{class: ("active" if params[:search_code].present?)} .col-sm-3
= link_to search_path(params.merge(search_code: true)) do %ul.nav.nav-pills.nav-stacked.search-filter
Repository Code %li{class: ("active" if @scope == 'blobs')}
%li{class: ("active" if params[:search_code].blank?)} = link_to search_filter_path(scope: 'blobs') do
= link_to search_path(params.merge(search_code: nil)) do %i.icon-code
Issues and Merge requests Code
.pull-right
= @search_results.blobs_count
%li{class: ("active" if @scope == 'issues')}
= link_to search_filter_path(scope: 'issues') do
%i.icon-exclamation-sign
Issues
.pull-right
= @search_results.issues_count
%li{class: ("active" if @scope == 'merge_requests')}
= link_to search_filter_path(scope: 'merge_requests') do
%i.icon-code-fork
Merge requests
.pull-right
= @search_results.merge_requests_count
%li{class: ("active" if @scope == 'notes')}
= link_to search_filter_path(scope: 'notes') do
%i.icon-comments
Comments
.pull-right
= @search_results.notes_count
.search_results .col-sm-9
- if params[:search_code].present? .search_results
.blob-results - if @search_results.empty?
- if !@search_results[:blobs].empty? = render partial: "search/results/empty", locals: { message: "We couldn't find any matching results" }
= render partial: "search/results/blob", collection: @search_results[:blobs]
= paginate @search_results[:blobs], theme: 'gitlab' %ul.bordered-list.top-list
- else = render partial: "search/results/#{@scope.singularize}", collection: @objects
= render partial: "search/results/empty", :locals => { message: "We couldn't find any matching code" } = paginate @objects, theme: 'gitlab'
- else
- if @search_results[:merge_requests].present? || @search_results[:issues].present? || @search_results[:notes].present?
%ul.bordered-list
= render partial: "search/results/merge_request", collection: @search_results[:merge_requests]
= render partial: "search/results/issue", collection: @search_results[:issues]
= render partial: "search/results/note", collection: @search_results[:notes]
- else
= render partial: "search/results/empty", locals: { message: "We couldn't find any issues, merge requests or notes" }
%h4 %h4
#{@search_results[:total_results]} results found #{@search_results.total_count} results found
- if @project - if @project
for #{link_to @project.name_with_namespace, @project} for #{link_to @project.name_with_namespace, @project}
- elsif @group - elsif @group
...@@ -14,4 +14,3 @@ ...@@ -14,4 +14,3 @@
:javascript :javascript
$(".search_results .term").highlight("#{escape_javascript(params[:search])}"); $(".search_results .term").highlight("#{escape_javascript(params[:search])}");
%li %li
issue: %h4
= link_to [issue.project, issue] do = link_to [issue.project, issue] do
%span ##{issue.iid} %span.term.str-truncated= issue.title
%strong.term .pull-right ##{issue.iid}
= truncate issue.title, length: 50 - if issue.description.present?
%span.light (#{issue.project.name_with_namespace}) .description.term
= preserve do
= search_md_sanitize(markdown(issue.description))
%span.light
#{issue.project.name_with_namespace}
- if issue.closed? - if issue.closed?
%span.label.label-danger Closed .pull-right
%span.label.label-danger Closed
%li %li
merge request: %h4
= link_to [merge_request.target_project, merge_request] do = link_to [merge_request.target_project, merge_request] do
%span ##{merge_request.iid} %span.term.str-truncated= merge_request.title
%strong.term .pull-right ##{merge_request.iid}
= truncate merge_request.title, length: 50 - if merge_request.description.present?
- if merge_request.for_fork? .description.term
%span.light (#{merge_request.source_project.name_with_namespace}:#{merge_request.source_branch} &rarr; #{merge_request.target_project.name_with_namespace}:#{merge_request.target_branch}) = preserve do
- else = search_md_sanitize(markdown(merge_request.description))
%span.light (#{merge_request.source_branch} &rarr; #{merge_request.target_branch}) %span.light
- if merge_request.merged? #{merge_request.project.name_with_namespace}
%span.label.label-primary Merged .pull-right
- elsif merge_request.closed? - if merge_request.merged?
%span.label.label-danger Closed %span.label.label-primary Merged
- elsif merge_request.closed?
%span.label.label-danger Closed
- project = note.project
%li %li
note on issue: %h5.note-search-caption
= link_to [note.project, note.noteable] do %i.icon-comment
%span ##{note.noteable.iid} = link_to_member(project, note.author, avatar: false)
%strong.term commented on
= truncate note.noteable.title, length: 50
%span.light (#{note.project.name_with_namespace}) - if note.for_commit?
- if note.noteable.closed? = link_to project do
%span.label Closed = project.name_with_namespace
&middot;
= link_to project_commit_path(project, note.commit_id, anchor: dom_id(note)) do
Commit #{note.commit_id[0..8]}
- else
= link_to project do
= project.name_with_namespace
&middot;
%span #{note.noteable_type.titleize} ##{note.noteable.iid}
&middot;
= link_to [project, note.noteable, anchor: dom_id(note)] do
= note.noteable.title
.note-search-result
.term
= preserve do
= search_md_sanitize(markdown(note.note, {no_header_anchors: true}))
%li %li
project: %h4
= link_to project do = link_to project do
%strong.term= project.name_with_namespace %span.term= project.name_with_namespace
- if project.description.present? - if project.description.present?
&ndash;
%span.light.term= project.description %span.light.term= project.description
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
= render 'filter', f: f = render 'filter', f: f
= hidden_field_tag :project_id, params[:project_id] = hidden_field_tag :project_id, params[:project_id]
= hidden_field_tag :group_id, params[:group_id] = hidden_field_tag :group_id, params[:group_id]
= hidden_field_tag :search_code, params[:search_code] = hidden_field_tag :scope, params[:scope]
.results.prepend-top-10 .results.prepend-top-10
- if params[:search].present? - if params[:search].present?
......
@dashboard
Feature: Dashboard Search
Background:
Given I sign in as a user
And I own project "Shop"
And I visit dashboard search page
Scenario: I should see project I am looking for
Given I search for "Sho"
Then I should see "Shop" project link
@dashboard
Feature: Search
Background:
Given I sign in as a user
And I own project "Shop"
And I visit dashboard search page
Scenario: I should see project I am looking for
Given I search for "Sho"
Then I should see "Shop" project link
Scenario: I should see issues I am looking for
And project has issues
When I search for "Foo"
And I click "Issues" link
Then I should see "Foo" link
And I should not see "Bar" link
Scenario: I should see merge requests I am looking for
And project has merge requests
When I search for "Foo"
When I click "Merge requests" link
Then I should see "Foo" link
And I should not see "Bar" link
Scenario: I should see project code I am looking for
When I click project "Shop" link
And I search for "rspec"
Then I should see code results for project "Shop"
Scenario: I should see project issues
And project has issues
When I click project "Shop" link
And I search for "Foo"
And I click "Issues" link
Then I should see "Foo" link
And I should not see "Bar" link
Scenario: I should see project merge requests
And project has merge requests
When I click project "Shop" link
And I search for "Foo"
And I click "Merge requests" link
Then I should see "Foo" link
And I should not see "Bar" link
class DashboardSearch < Spinach::FeatureSteps
include SharedAuthentication
include SharedPaths
include SharedProject
Given 'I search for "Sho"' do
fill_in "dashboard_search", with: "Sho"
click_button "Search"
end
Then 'I should see "Shop" project link' do
page.should have_link "Shop"
end
Given 'I search for "Contibuting"' do
fill_in "dashboard_search", with: "Contibuting"
click_button "Search"
end
end
...@@ -6,7 +6,6 @@ class ProjectSearchCode < Spinach::FeatureSteps ...@@ -6,7 +6,6 @@ class ProjectSearchCode < Spinach::FeatureSteps
step 'I search for term "coffee"' do step 'I search for term "coffee"' do
fill_in "search", with: "coffee" fill_in "search", with: "coffee"
click_button "Go" click_button "Go"
click_link 'Repository Code'
end end
step 'I should see files from repository containing "coffee"' do step 'I should see files from repository containing "coffee"' do
...@@ -15,6 +14,6 @@ class ProjectSearchCode < Spinach::FeatureSteps ...@@ -15,6 +14,6 @@ class ProjectSearchCode < Spinach::FeatureSteps
end end
step 'I should see empty result' do step 'I should see empty result' do
page.should have_content "We couldn't find any matching code" page.should have_content "We couldn't find any matching"
end end
end end
class Spinach::Features::Search < Spinach::FeatureSteps
include SharedAuthentication
include SharedPaths
include SharedProject
step 'I search for "Sho"' do
fill_in "dashboard_search", with: "Sho"
click_button "Search"
end
step 'I search for "Foo"' do
fill_in "dashboard_search", with: "Foo"
click_button "Search"
end
step 'I search for "rspec"' do
fill_in "dashboard_search", with: "rspec"
click_button "Search"
end
step 'I click "Issues" link' do
within '.search-filter' do
click_link 'Issues'
end
end
step 'I click project "Shop" link' do
within '.project-filter' do
click_link project.name_with_namespace
end
end
step 'I click "Merge requests" link' do
within '.search-filter' do
click_link 'Merge requests'
end
end
step 'I should see "Shop" project link' do
page.should have_link "Shop"
end
step 'I should see code results for project "Shop"' do
page.should have_content 'Update capybara, rspec-rails, poltergeist to recent versions'
end
step 'I search for "Contibuting"' do
fill_in "dashboard_search", with: "Contibuting"
click_button "Search"
end
step 'project has issues' do
create(:issue, title: "Foo", project: project)
create(:issue, title: "Bar", project: project)
end
step 'project has merge requests' do
create(:merge_request, title: "Foo", source_project: project, target_project: project)
create(:merge_request, :simple, title: "Bar", source_project: project, target_project: project)
end
step 'I should see "Foo" link' do
page.should have_link "Foo"
end
step 'I should not see "Bar" link' do
page.should_not have_link "Bar"
end
def project
@project ||= Project.find_by(name: "Shop")
end
end
module Gitlab
class ProjectSearchResults < SearchResults
attr_reader :project, :repository_ref
def initialize(project_id, query, repository_ref = nil)
@project = Project.find(project_id)
@repository_ref = repository_ref
@query = Shellwords.shellescape(query) if query.present?
end
def objects(scope, page = nil)
case scope
when 'notes'
notes.page(page).per(per_page)
when 'blobs'
Kaminari.paginate_array(blobs).page(page).per(per_page)
else
super
end
end
def total_count
@total_count ||= issues_count + merge_requests_count + blobs_count + notes_count
end
def blobs_count
@blobs_count ||= blobs.count
end
def notes_count
@notes_count ||= notes.count
end
private
def blobs
if project.empty_repo?
[]
else
project.repository.search_files(query, repository_ref)
end
end
def notes
Note.where(project_id: limit_project_ids).search(query).order('updated_at DESC')
end
def limit_project_ids
[project.id]
end
end
end
module Gitlab
class SearchResults
attr_reader :query
# Limit search results by passed project ids
# It allows us to search only for projects user has access to
attr_reader :limit_project_ids
def initialize(limit_project_ids, query)
@limit_project_ids = limit_project_ids || Project.all
@query = Shellwords.shellescape(query) if query.present?
end
def objects(scope, page = nil)
case scope
when 'projects'
projects.page(page).per(per_page)
when 'issues'
issues.page(page).per(per_page)
when 'merge_requests'
merge_requests.page(page).per(per_page)
else
Kaminari.paginate_array([]).page(page).per(per_page)
end
end
def total_count
@total_count ||= projects_count + issues_count + merge_requests_count
end
def projects_count
@projects_count ||= projects.count
end
def issues_count
@issues_count ||= issues.count
end
def merge_requests_count
@merge_requests_count ||= merge_requests.count
end
def empty?
total_count.zero?
end
private
def projects
Project.where(id: limit_project_ids).search(query)
end
def issues
Issue.where(project_id: limit_project_ids).full_search(query).order('updated_at DESC')
end
def merge_requests
MergeRequest.in_projects(limit_project_ids).full_search(query).order('updated_at DESC')
end
def default_scope
'projects'
end
def per_page
20
end
end
end
...@@ -19,7 +19,7 @@ describe 'Search::GlobalService' do ...@@ -19,7 +19,7 @@ describe 'Search::GlobalService' do
it 'should return public projects only' do it 'should return public projects only' do
context = Search::GlobalService.new(nil, search: "searchable") context = Search::GlobalService.new(nil, search: "searchable")
results = context.execute results = context.execute
results[:projects].should match_array [public_project] results.objects('projects').should match_array [public_project]
end end
end end
...@@ -27,19 +27,19 @@ describe 'Search::GlobalService' do ...@@ -27,19 +27,19 @@ describe 'Search::GlobalService' do
it 'should return public, internal and private projects' do it 'should return public, internal and private projects' do
context = Search::GlobalService.new(user, search: "searchable") context = Search::GlobalService.new(user, search: "searchable")
results = context.execute results = context.execute
results[:projects].should match_array [public_project, found_project, internal_project] results.objects('projects').should match_array [public_project, found_project, internal_project]
end end
it 'should return only public & internal projects' do it 'should return only public & internal projects' do
context = Search::GlobalService.new(internal_user, search: "searchable") context = Search::GlobalService.new(internal_user, search: "searchable")
results = context.execute results = context.execute
results[:projects].should match_array [internal_project, public_project] results.objects('projects').should match_array [internal_project, public_project]
end end
it 'namespace name should be searchable' do it 'namespace name should be searchable' do
context = Search::GlobalService.new(user, search: found_project.namespace.path) context = Search::GlobalService.new(user, search: found_project.namespace.path)
results = context.execute results = context.execute
results[:projects].should match_array [found_project] results.objects('projects').should match_array [found_project]
end end
end end
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment