Commit 587e2130 authored by mbergeron's avatar mbergeron

Enable the `in this group` action in the Search dropdown

Previously, you would have to be on a Group's page to see this action
available.

With this commit, the action will also be present when a
project is owned by a Group.

This also fixes an issue where the dropdown would close whenever there
are no results from the autocomplete, which defeated the purpose of
having these actions in the first place.
parent 7b3b88dd
......@@ -31,7 +31,7 @@ export const getProjectSlug = () => {
};
export const getGroupSlug = () => {
if (isInGroupsPage()) {
if (isInProjectPage() || isInGroupsPage()) {
return $('body').data('group');
}
return null;
......
......@@ -2,7 +2,7 @@
import $ from 'jquery';
import { escape, throttle } from 'lodash';
import { s__, __ } from '~/locale';
import { s__, __, sprintf } from '~/locale';
import { getIdenticonBackgroundClass, getIdenticonTitle } from '~/helpers/avatar_helper';
import axios from './lib/utils/axios_utils';
import {
......@@ -170,33 +170,24 @@ export class SearchAutocomplete {
},
})
.then(response => {
// Hide dropdown menu if no suggestions returns
if (!response.data.length) {
this.disableAutocomplete();
return;
}
const options = this.scopedSearchOptions(term);
const data = [];
// List results
let firstCategory = true;
let lastCategory;
let lastCategory = null;
for (let i = 0, len = response.data.length; i < len; i += 1) {
const suggestion = response.data[i];
// Add group header before list each group
if (lastCategory !== suggestion.category) {
if (!firstCategory) {
data.push({ type: 'separator' });
}
if (firstCategory) {
firstCategory = false;
}
data.push({
options.push({ type: 'separator' });
options.push({
type: 'header',
content: suggestion.category,
});
lastCategory = suggestion.category;
}
data.push({
// Add the suggestion
options.push({
id: `${suggestion.category.toLowerCase()}-${suggestion.id}`,
icon: this.getAvatar(suggestion),
category: suggestion.category,
......@@ -204,39 +195,8 @@ export class SearchAutocomplete {
url: suggestion.url,
});
}
// Add option to proceed with the search
if (data.length) {
const icon = spriteIcon('search', 's16 inline-search-icon');
let template;
if (this.projectInputEl.val()) {
template = s__('SearchAutocomplete|in this project');
}
if (this.groupInputEl.val()) {
template = s__('SearchAutocomplete|in this group');
}
data.unshift({ type: 'separator' });
data.unshift({
icon,
text: term,
template: s__('SearchAutocomplete|in all GitLab'),
url: `${gon.relative_url_root}/search?search=${term}`,
});
if (template) {
data.unshift({
icon,
text: term,
template,
url: `${
gon.relative_url_root
}/search?search=${term}&project_id=${this.projectInputEl.val()}&group_id=${this.groupInputEl.val()}`,
});
}
}
callback(data);
callback(options);
this.loadingSuggestions = false;
this.highlightFirstRow();
......@@ -253,10 +213,10 @@ export class SearchAutocomplete {
// Get options
let options;
if (isInGroupsPage() && groupOptions) {
options = groupOptions[getGroupSlug()];
} else if (isInProjectPage() && projectOptions) {
if (isInProjectPage() && projectOptions) {
options = projectOptions[getProjectSlug()];
} else if (isInGroupsPage() && groupOptions) {
options = groupOptions[getGroupSlug()];
} else if (dashboardOptions) {
options = dashboardOptions;
}
......@@ -301,6 +261,64 @@ export class SearchAutocomplete {
return items;
}
// Add option to proceed with the search for each
// scope that is currently available, namely:
//
// - Search in this project
// - Search in this group (or project's group)
// - Search in all GitLab
scopedSearchOptions(term) {
const icon = spriteIcon('search', 's16 inline-search-icon');
const projectId = this.projectInputEl.val();
const groupId = this.groupInputEl.val();
const options = [];
if (projectId) {
const projectOptions = gl.projectOptions[getProjectSlug()];
const url = groupId
? `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}&group_id=${groupId}`
: `${gon.relative_url_root}/search?search=${term}&project_id=${projectId}`;
options.push({
icon,
text: term,
template: sprintf(
s__(`SearchAutocomplete|in project %{projectName}`),
{
projectName: `<i>${projectOptions.name}</i>`,
},
false,
),
url,
});
}
if (groupId) {
const groupOptions = gl.groupOptions[getGroupSlug()];
options.push({
icon,
text: term,
template: sprintf(
s__(`SearchAutocomplete|in group %{groupName}`),
{
groupName: `<i>${groupOptions.name}</i>`,
},
false,
),
url: `${gon.relative_url_root}/search?search=${term}&group_id=${groupId}`,
});
}
options.push({
icon,
text: term,
template: s__('SearchAutocomplete|in all GitLab'),
url: `${gon.relative_url_root}/search?search=${term}`,
});
return options;
}
serializeState() {
return {
// Search Criteria
......
......@@ -12,6 +12,7 @@ class ApplicationController < ActionController::Base
include WorkhorseHelper
include EnforcesTwoFactorAuthentication
include WithPerformanceBar
include Gitlab::SearchContext::ControllerConcern
include SessionlessAuthentication
include SessionsHelper
include ConfirmEmailWarning
......
......@@ -103,7 +103,7 @@ module ApplicationHelper
page: body_data_page,
page_type_id: controller.params[:id],
find_file: find_file_path,
group: "#{@group&.path}"
group: @group&.path
}.merge(project_data)
end
......@@ -113,6 +113,7 @@ module ApplicationHelper
{
project_id: @project.id,
project: @project.path,
group: @project.group&.path,
namespace_id: @project.namespace&.id
}
end
......
......@@ -104,6 +104,16 @@ module PageLayoutHelper
end
end
# This helper ensures there is always a default `Gitlab::SearchContext` available
# to all controller that use the application layout.
def search_context
strong_memoize(:search_context) do
next super if defined?(super)
Gitlab::SearchContext::Builder.new(controller.view_context).build!
end
end
def fluid_layout
current_user && current_user.layout == "fluid"
end
......
- if @group && @group.persisted? && @group.path
- group_data_attrs = { group_path: j(@group.path), name: j(@group.name), issues_path: issues_group_path(@group), mr_path: merge_requests_group_path(@group) }
- if @project && @project.persisted?
- project_data_attrs = { project_path: j(@project.path), name: j(@project.name), issues_path: project_issues_path(@project), mr_path: project_merge_requests_path(@project), issues_disabled: !@project.issues_enabled? }
.search.search-form{ data: { track_label: "navbar_search", track_event: "activate_form_input", track_value: "" } }
= form_tag search_path, method: :get, class: 'form-inline' do |f|
.search-input-container
......@@ -27,27 +23,20 @@
= sprite_icon('search', size: 16, css_class: 'search-icon')
= sprite_icon('close', size: 16, css_class: 'clear-icon js-clear-input')
= hidden_field_tag :group_id, @group.try(:id), class: 'js-search-group-options', data: group_data_attrs
= hidden_field_tag :group_id, search_context.for_group? ? search_context.group.id : '', class: 'js-search-group-options', data: search_context.group_metadata
= hidden_field_tag :project_id, search_context.for_project? ? search_context.project.id : '', id: 'search_project_id', class: 'js-search-project-options', data: search_context.project_metadata
= hidden_field_tag :project_id, @project && @project.persisted? ? @project.id : '', id: 'search_project_id', class: 'js-search-project-options', data: project_data_attrs
- if search_context.for_project?
= hidden_field_tag :scope, search_context.scope
= hidden_field_tag :search_code, search_context.code_search?
- if @project && @project.persisted?
- if current_controller?(:issues)
= hidden_field_tag :scope, 'issues'
- elsif current_controller?(:merge_requests)
= hidden_field_tag :scope, 'merge_requests'
- elsif current_controller?(:wikis)
= hidden_field_tag :scope, 'wiki_blobs'
- elsif current_controller?(:commits)
= hidden_field_tag :scope, 'commits'
- else
= hidden_field_tag :search_code, true
- if @snippet || @snippets
= hidden_field_tag :snippets, true
= hidden_field_tag :repository_ref, @ref
= hidden_field_tag :snippets, search_context.for_snippets?
= hidden_field_tag :repository_ref, search_context.ref
= hidden_field_tag :nav_source, 'navbar'
-# workaround for non-JS feature specs, see spec/support/helpers/search_helpers.rb
- if ENV['RAILS_ENV'] == 'test'
%noscript= button_tag 'Search'
.search-autocomplete-opts.hide{ :'data-autocomplete-path' => search_autocomplete_path, :'data-autocomplete-project-id' => @project.try(:id), :'data-autocomplete-project-ref' => @ref }
.search-autocomplete-opts.hide{ :'data-autocomplete-path' => search_autocomplete_path,
:'data-autocomplete-project-id' => search_context.project.try(:id),
:'data-autocomplete-project-ref' => search_context.ref }
- if project
- search_path_url = search_path(project_id: project.id)
- elsif group
- search_path_url = search_path(group_id: group.id)
- else
- search_path_url = search_path
- has_impersonation_link = header_link?(:admin_impersonation)
%header.navbar.navbar-gitlab.navbar-expand-sm.js-navbar{ data: { qa_selector: 'navbar' } }
......@@ -36,7 +30,7 @@
%li.nav-item.d-none.d-lg-block.m-auto
= render 'layouts/search' unless current_controller?(:search)
%li.nav-item.d-inline-block.d-lg-none
= link_to search_path_url, title: _('Search'), aria: { label: _('Search') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= link_to search_context.search_url, title: _('Search'), aria: { label: _('Search') }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= sprite_icon('search', size: 16)
- if header_link?(:issues)
= nav_link(path: 'dashboard#issues', html_options: { class: "user-counter" }) do
......
---
title: Enable the `in this group` action in the Search dropdown
merge_request: 31939
author:
type: changed
# frozen_string_literal: true
module Gitlab
# Holds the contextual data used by navbar search component to
# determine the search scope, whether to search for code, or if
# a search should target snippets.
#
# Use the SearchContext::Builder to create an instance of this class
class SearchContext
attr_accessor :project, :project_metadata, :ref,
:group, :group_metadata,
:snippets,
:scope, :search_url
def initialize
@ref = nil
@project = nil
@project_metadata = {}
@group = nil
@group_metadata = {}
@snippets = []
@scope = nil
@search_url = nil
end
def for_project?
project.present? && project.persisted?
end
def for_group?
group.present? && group.persisted?
end
def for_snippets?
snippets.any?
end
def code_search?
project.present? && scope.nil?
end
class Builder
def initialize(view_context)
@view_context = view_context
@snippets = []
end
def with_snippet(snippet)
@snippets << snippet
self
end
def with_project(project)
@project = project
with_group(project&.group)
self
end
def with_group(group)
@group = group
self
end
def with_ref(ref)
@ref = ref
self
end
def build!
SearchContext.new.tap do |context|
context.project = @project
context.group = @group
context.ref = @ref
context.snippets = @snippets.dup
context.scope = search_scope
context.search_url = search_url
context.group_metadata = group_search_metadata(@group)
context.project_metadata = project_search_metadata(@project)
end
end
private
attr_accessor :view_context
def project_search_metadata(project)
return {} unless project
{
project_path: project.path,
name: project.name,
issues_path: view_context.project_issues_path(project),
mr_path: view_context.project_merge_requests_path(project),
issues_disabled: !project.issues_enabled?
}
end
def group_search_metadata(group)
return {} unless group
{
group_path: group.path,
name: group.name,
issues_path: view_context.issues_group_path(group),
mr_path: view_context.merge_requests_group_path(group)
}
end
def search_url
if @project.present?
view_context.search_path(project_id: @project.id)
elsif @group.present?
view_context.search_path(group_id: @group.id)
else
view_context.search_path
end
end
def search_scope
if view_context.current_controller?(:issues)
'issues'
elsif view_context.current_controller?(:merge_requests)
'merge_requests'
elsif view_context.current_controller?(:wikis)
'wiki_blobs'
elsif view_context.current_controller?(:commits)
'commits'
else nil
end
end
end
module ControllerConcern
extend ActiveSupport::Concern
included do
helper_method :search_context
end
# rubocop:disable Gitlab/ModuleWithInstanceVariables
#
# Introspect the current controller's assignments and
# and builds the proper SearchContext object for it.
def search_context
builder = Builder.new(view_context)
builder.with_snippet(@snippet) if @snippet.present?
@snippets.each(&builder.method(:with_snippet)) if @snippets.present?
builder.with_project(@project) if @project.present? && @project.persisted?
builder.with_group(@group) if @group.present? && @group.persisted?
builder.with_ref(@ref) if @ref.present?
builder.build!
end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
end
end
end
......@@ -18904,10 +18904,10 @@ msgstr ""
msgid "SearchAutocomplete|in all GitLab"
msgstr ""
msgid "SearchAutocomplete|in this group"
msgid "SearchAutocomplete|in group %{groupName}"
msgstr ""
msgid "SearchAutocomplete|in this project"
msgid "SearchAutocomplete|in project %{projectName}"
msgstr ""
msgid "SearchCodeResults|in"
......
......@@ -332,8 +332,7 @@ describe Projects::IssuesController do
end
before do
allow(controller).to receive(:find_routable!)
.with(Project, project.full_path, any_args).and_return(project)
allow(controller).to receive(:find_routable!).and_return(project)
allow(project).to receive(:default_branch).and_return(master_branch)
allow_next_instance_of(Issues::RelatedBranchesService) do |service|
allow(service).to receive(:execute).and_return(related_branches)
......
......@@ -95,14 +95,6 @@ describe 'User uses header search field', :js do
expect(page).not_to have_selector('.dropdown-header', text: /#{scope_name}/i)
end
it 'hides the dropdown when there are no results' do
page.within('.search-input-wrap') do
fill_in('search', with: 'a_search_term_with_no_results')
end
expect(page).not_to have_selector('.dropdown-menu')
end
end
end
......
......@@ -278,7 +278,7 @@ describe ApplicationHelper do
page: 'application',
page_type_id: nil,
find_file: nil,
group: ''
group: nil
}
)
end
......@@ -317,7 +317,7 @@ describe ApplicationHelper do
page: 'application',
page_type_id: nil,
find_file: nil,
group: '',
group: nil,
project_id: project.id,
project: project.name,
namespace_id: project.namespace.id
......@@ -325,6 +325,25 @@ describe ApplicationHelper do
)
end
context 'when @project is owned by a group' do
let_it_be(:project) { create(:project, :repository, group: create(:group)) }
it 'includes all possible body data elements and associates the project elements with project' do
expect(helper).to receive(:can?).with(nil, :download_code, project)
expect(helper.body_data).to eq(
{
page: 'application',
page_type_id: nil,
find_file: nil,
group: project.group.name,
project_id: project.id,
project: project.name,
namespace_id: project.namespace.id
}
)
end
end
context 'when controller is issues' do
before do
stub_controller_method(:controller_path, 'projects:issues')
......@@ -342,7 +361,7 @@ describe ApplicationHelper do
page: 'projects:issues:show',
page_type_id: issue.id,
find_file: nil,
group: '',
group: nil,
project_id: issue.project.id,
project: issue.project.name,
namespace_id: issue.project.namespace.id
......
......@@ -117,4 +117,19 @@ describe PageLayoutHelper do
expect(tags).to include(%q{content="foo&quot; http-equiv=&quot;refresh"})
end
end
describe '#search_context' do
subject(:search_context) { helper.search_context }
describe 'a bare controller' do
it 'returns an empty context' do
expect(search_context).to have_attributes(project: nil,
group: nil,
snippets: [],
project_metadata: {},
group_metadata: {},
search_url: '/search')
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::SearchContext::Builder, type: :controller do
controller(ApplicationController) { }
subject(:builder) { described_class.new(controller.view_context) }
shared_examples "has a fluid interface" do
it { is_expected.to be_instance_of(described_class) }
end
def expected_project_metadata(project)
return {} if project.nil?
a_hash_including(project_path: project.path,
name: project.name,
issues_path: a_string_including("/issues"),
mr_path: a_string_including("/merge_requests"),
issues_disabled: !project.issues_enabled?)
end
def expected_group_metadata(group)
return {} if group.nil?
a_hash_including(group_path: group.path,
name: group.name,
issues_path: a_string_including("/issues"),
mr_path: a_string_including("/merge_requests"))
end
def expected_search_url(project, group)
if project
search_path(project_id: project.id)
elsif group
search_path(group_id: group.id)
else
search_path
end
end
def be_search_context(project: nil, group: nil, snippets: [], ref: nil)
group = project ? project.group : group
snippets.compact!
ref = ref
have_attributes(
project: project,
group: group,
ref: ref,
snippets: snippets,
project_metadata: expected_project_metadata(project),
group_metadata: expected_group_metadata(group),
search_url: expected_search_url(project, group)
)
end
describe '#with_project' do
let(:project) { create(:project) }
subject { builder.with_project(project) }
it_behaves_like "has a fluid interface"
describe '#build!' do
subject(:context) { builder.with_project(project).build! }
context 'when a project is not owned by a group' do
it { is_expected.to be_for_project }
it { is_expected.to be_search_context(project: project) }
end
context 'when a project is owned by a group' do
let(:project) { create(:project, group: create(:group)) }
it 'delegates to `#with_group`' do
expect(builder).to receive(:with_group).with(project.group)
expect(context).to be
end
it { is_expected.to be_search_context(project: project, group: project.group) }
end
end
end
describe '#with_snippet' do
context 'when there is a single snippet' do
let(:snippet) { create(:snippet) }
subject { builder.with_snippet(snippet) }
it_behaves_like "has a fluid interface"
describe '#build!' do
subject(:context) { builder.with_snippet(snippet).build! }
it { is_expected.to be_for_snippet }
it { is_expected.to be_search_context(snippets: [snippet]) }
end
end
context 'when there are multiple snippets' do
let(:snippets) { create_list(:snippet, 3) }
describe '#build!' do
subject(:context) do
snippets.each(&builder.method(:with_snippet))
builder.build!
end
it { is_expected.to be_for_snippet }
it { is_expected.to be_search_context(snippets: snippets) }
end
end
end
describe '#with_group' do
let(:group) { create(:group) }
subject { builder.with_group(group) }
it_behaves_like "has a fluid interface"
describe '#build!' do
subject(:context) { builder.with_group(group).build! }
it { is_expected.to be_for_group }
it { is_expected.to be_search_context(group: group) }
end
end
describe '#with_ref' do
let(:ref) { Gitlab::Git::EMPTY_TREE_ID }
subject { builder.with_ref(ref) }
it_behaves_like "has a fluid interface"
describe '#build!' do
subject(:context) { builder.with_ref(ref).build! }
it { is_expected.to be_search_context(ref: ref) }
end
end
describe '#build!' do
subject(:context) { builder.build! }
it { is_expected.to be_a(Gitlab::SearchContext) }
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Gitlab::SearchContext::ControllerConcern, type: :controller do
controller(ApplicationController) do
include Gitlab::SearchContext::ControllerConcern
end
let(:project) { nil }
let(:group) { nil }
let(:snippet) { nil }
let(:snippets) { [] }
let(:ref) { nil }
let(:builder) { Gitlab::SearchContext::Builder.new(controller.view_context) }
subject(:search_context) { controller.search_context }
def weak_assign(ivar, value)
return if value.nil?
controller.instance_variable_set(ivar.to_sym, value)
end
before do
weak_assign(:@project, project)
weak_assign(:@group, group)
weak_assign(:@ref, ref)
weak_assign(:@snippet, snippet)
weak_assign(:@snippets, snippets)
allow(Gitlab::SearchContext::Builder).to receive(:new).and_return(builder)
end
shared_examples 'has the proper context' do
it :aggregate_failures do
expected_group = project ? project.group : group
expected_snippets = [snippet, *snippets].compact
expect(builder).to receive(:with_project).with(project).and_call_original if project
expect(builder).to receive(:with_group).with(expected_group).and_call_original if expected_group
expect(builder).to receive(:with_ref).with(ref).and_call_original if ref
expected_snippets.each do |snippet|
expect(builder).to receive(:with_snippet).with(snippet).and_call_original
end
is_expected.to be_a(Gitlab::SearchContext)
end
end
context 'exposing @project' do
let(:project) { create(:project) }
it_behaves_like 'has the proper context'
context 'when the project is owned by a group' do
let(:project) { create(:project, group: create(:group)) }
it_behaves_like 'has the proper context'
end
end
context 'exposing @group' do
let(:group) { create(:group) }
it_behaves_like 'has the proper context'
end
context 'exposing @snippet, @snippets' do
let(:snippet) { create(:snippet) }
let(:snippets) { create_list(:snippet, 3) }
it_behaves_like 'has the proper context'
end
context 'exposing @ref' do
let(:ref) { Gitlab::Git::EMPTY_TREE_ID }
it_behaves_like 'has the proper context'
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