Commit b61147ee authored by Robert Speicher's avatar Robert Speicher

Merge branch '5606-issues-and-mr-autocomplete-in-epics' into 'master'

Add autocomplete for issues and MRs in epics

Closes #5606

See merge request gitlab-org/gitlab-ee!8936
parents e68f6665 abf448fb
...@@ -228,13 +228,13 @@ class GfmAutoComplete { ...@@ -228,13 +228,13 @@ class GfmAutoComplete {
displayTpl(value) { displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template; let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) { if (value.title != null) {
tmpl = GfmAutoComplete.Issues.templateFunction(value.id, value.title); tmpl = GfmAutoComplete.Issues.templateFunction(value);
} }
return tmpl; return tmpl;
}, },
data: GfmAutoComplete.defaultLoadingData, data: GfmAutoComplete.defaultLoadingData,
// eslint-disable-next-line no-template-curly-in-string insertTpl: GfmAutoComplete.Issues.insertTemplateFunction,
insertTpl: '${atwho-at}${id}', skipSpecialCharacterTest: true,
callbacks: { callbacks: {
...this.getDefaultCallbacks(), ...this.getDefaultCallbacks(),
beforeSave(issues) { beforeSave(issues) {
...@@ -245,6 +245,7 @@ class GfmAutoComplete { ...@@ -245,6 +245,7 @@ class GfmAutoComplete {
return { return {
id: i.iid, id: i.iid,
title: sanitize(i.title), title: sanitize(i.title),
reference: i.reference,
search: `${i.iid} ${i.title}`, search: `${i.iid} ${i.title}`,
}; };
}); });
...@@ -294,13 +295,13 @@ class GfmAutoComplete { ...@@ -294,13 +295,13 @@ class GfmAutoComplete {
displayTpl(value) { displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template; let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) { if (value.title != null) {
tmpl = GfmAutoComplete.Issues.templateFunction(value.id, value.title); tmpl = GfmAutoComplete.Issues.templateFunction(value);
} }
return tmpl; return tmpl;
}, },
data: GfmAutoComplete.defaultLoadingData, data: GfmAutoComplete.defaultLoadingData,
// eslint-disable-next-line no-template-curly-in-string insertTpl: GfmAutoComplete.Issues.insertTemplateFunction,
insertTpl: '${atwho-at}${id}', skipSpecialCharacterTest: true,
callbacks: { callbacks: {
...this.getDefaultCallbacks(), ...this.getDefaultCallbacks(),
beforeSave(merges) { beforeSave(merges) {
...@@ -311,6 +312,7 @@ class GfmAutoComplete { ...@@ -311,6 +312,7 @@ class GfmAutoComplete {
return { return {
id: m.iid, id: m.iid,
title: sanitize(m.title), title: sanitize(m.title),
reference: m.reference,
search: `${m.iid} ${m.title}`, search: `${m.iid} ${m.title}`,
}; };
}); });
...@@ -404,7 +406,7 @@ class GfmAutoComplete { ...@@ -404,7 +406,7 @@ class GfmAutoComplete {
displayTpl(value) { displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template; let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) { if (value.title != null) {
tmpl = GfmAutoComplete.Issues.templateFunction(value.id, value.title); tmpl = GfmAutoComplete.Issues.templateFunction(value);
} }
return tmpl; return tmpl;
}, },
...@@ -603,8 +605,12 @@ GfmAutoComplete.Labels = { ...@@ -603,8 +605,12 @@ GfmAutoComplete.Labels = {
}; };
// Issues, MergeRequests and Snippets // Issues, MergeRequests and Snippets
GfmAutoComplete.Issues = { GfmAutoComplete.Issues = {
templateFunction(id, title) { insertTemplateFunction(value) {
return `<li><small>${id}</small> ${_.escape(title)}</li>`; // eslint-disable-next-line no-template-curly-in-string
return value.reference || '${atwho-at}${id}';
},
templateFunction({ id, title, reference }) {
return `<li><small>${reference || id}</small> ${_.escape(title)}</li>`;
}, },
}; };
// Milestones // Milestones
......
...@@ -9,7 +9,7 @@ const setupAutoCompleteEpics = ($input, defaultCallbacks) => { ...@@ -9,7 +9,7 @@ const setupAutoCompleteEpics = ($input, defaultCallbacks) => {
displayTpl(value) { displayTpl(value) {
let tmpl = GfmAutoComplete.Loading.template; let tmpl = GfmAutoComplete.Loading.template;
if (value.title != null) { if (value.title != null) {
tmpl = GfmAutoComplete.Issues.templateFunction(value.id, value.title); tmpl = GfmAutoComplete.Issues.templateFunction(value);
} }
return tmpl; return tmpl;
}, },
......
...@@ -7,6 +7,14 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController ...@@ -7,6 +7,14 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController
render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target) render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target)
end end
def issues
render json: issuable_serializer.represent(@autocomplete_service.issues, parent_group: @group)
end
def merge_requests
render json: issuable_serializer.represent(@autocomplete_service.merge_requests, parent_group: @group)
end
def labels def labels
render json: @autocomplete_service.labels_as_hash(target) render json: @autocomplete_service.labels_as_hash(target)
end end
...@@ -29,6 +37,10 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController ...@@ -29,6 +37,10 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController
@autocomplete_service = ::Groups::AutocompleteService.new(@group, current_user) @autocomplete_service = ::Groups::AutocompleteService.new(@group, current_user)
end end
def issuable_serializer
GroupIssuableAutocompleteSerializer.new
end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def target def target
case params[:type]&.downcase case params[:type]&.downcase
......
...@@ -75,6 +75,8 @@ module EE ...@@ -75,6 +75,8 @@ module EE
{ {
members: members_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), members: members_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
labels: labels_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), labels: labels_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
issues: issues_group_autocomplete_sources_path(object),
mergeRequests: merge_requests_group_autocomplete_sources_path(object),
epics: epics_group_autocomplete_sources_path(object), epics: epics_group_autocomplete_sources_path(object),
commands: commands_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]), commands: commands_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
milestones: milestones_group_autocomplete_sources_path(object) milestones: milestones_group_autocomplete_sources_path(object)
......
# frozen_string_literal: true
class GroupIssuableAutocompleteEntity < Grape::Entity
expose :iid
expose :title
expose :reference do |issuable, options|
issuable.to_reference(options[:parent_group])
end
end
# frozen_string_literal: true
class GroupIssuableAutocompleteSerializer < BaseSerializer
entity GroupIssuableAutocompleteEntity
end
...@@ -3,12 +3,31 @@ ...@@ -3,12 +3,31 @@
module Groups module Groups
class AutocompleteService < Groups::BaseService class AutocompleteService < Groups::BaseService
include LabelsAsHash include LabelsAsHash
# rubocop: disable CodeReuse/ActiveRecord
def issues
IssuesFinder.new(current_user, group_id: group.id, include_subgroups: true, state: 'opened')
.execute
.preload(project: :namespace)
.select(:iid, :title, :project_id)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def merge_requests
MergeRequestsFinder.new(current_user, group_id: group.id, include_subgroups: true, state: 'opened')
.execute
.preload(target_project: :namespace)
.select(:iid, :title, :target_project_id)
end
# rubocop: enable CodeReuse/ActiveRecord
def epics def epics
# TODO: change to EpicsFinder once frontend supports epics from external groups. # TODO: change to EpicsFinder once frontend supports epics from external groups.
# See https://gitlab.com/gitlab-org/gitlab-ee/issues/6837 # See https://gitlab.com/gitlab-org/gitlab-ee/issues/6837
DeclarativePolicy.user_scope do DeclarativePolicy.user_scope do
if Ability.allowed?(current_user, :read_epic, group) if Ability.allowed?(current_user, :read_epic, group)
group.epics group.epics.select(:iid, :title)
else else
[] []
end end
...@@ -19,7 +38,7 @@ module Groups ...@@ -19,7 +38,7 @@ module Groups
group_ids = group_ids =
ParentGroupsFinder.new(current_user, group).execute.map(&:id) ParentGroupsFinder.new(current_user, group).execute.map(&:id)
MilestonesFinder.new(group_ids: group_ids).execute MilestonesFinder.new(group_ids: group_ids).execute.select(:iid, :title)
end end
def labels_as_hash(target) def labels_as_hash(target)
......
---
title: Autocomplete issues and MRs in epics
merge_request: 8936
author:
type: added
...@@ -41,6 +41,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -41,6 +41,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resources :autocomplete_sources, only: [] do resources :autocomplete_sources, only: [] do
collection do collection do
get 'members' get 'members'
get 'issues'
get 'merge_requests'
get 'labels' get 'labels'
get 'epics' get 'epics'
get 'commands' get 'commands'
......
...@@ -25,7 +25,7 @@ describe Groups::AutocompleteSourcesController do ...@@ -25,7 +25,7 @@ describe Groups::AutocompleteSourcesController do
expect(json_response).to be_an(Array) expect(json_response).to be_an(Array)
expect(json_response.first).to include( expect(json_response.first).to include(
'id' => epic.id, 'iid' => epic.iid, 'title' => epic.title 'iid' => epic.iid, 'title' => epic.title
) )
end end
end end
...@@ -43,9 +43,8 @@ describe Groups::AutocompleteSourcesController do ...@@ -43,9 +43,8 @@ describe Groups::AutocompleteSourcesController do
expect(response).to have_gitlab_http_status(200) expect(response).to have_gitlab_http_status(200)
expect(json_response.count).to eq(1) expect(json_response.count).to eq(1)
expect(response).to match_response_schema('public_api/v4/milestones')
expect(json_response.first).to include( expect(json_response.first).to include(
'id' => group_milestone.id, 'iid' => group_milestone.iid, 'title' => group_milestone.title 'iid' => group_milestone.iid, 'title' => group_milestone.title
) )
end end
end end
......
...@@ -15,6 +15,32 @@ describe 'GFM autocomplete', :js do ...@@ -15,6 +15,32 @@ describe 'GFM autocomplete', :js do
wait_for_requests wait_for_requests
end end
context 'issuables' do
let(:project) { create(:project, :repository, namespace: group) }
context 'issues' do
it 'shows issues of group' do
issue_1 = create(:issue, project: project)
issue_2 = create(:issue, project: project)
type(find('#note-body'), '#')
expect_resources(shown: [issue_1, issue_2])
end
end
context 'merge requests' do
it 'shows merge requests of group' do
mr_1 = create(:merge_request, source_project: project)
mr_2 = create(:merge_request, source_project: project, source_branch: 'other-branch')
type(find('#note-body'), '!')
expect_resources(shown: [mr_1, mr_2])
end
end
end
context 'epics' do context 'epics' do
let!(:epic2) { create(:epic, group: group, title: 'make tea') } let!(:epic2) { create(:epic, group: group, title: 'make tea') }
......
...@@ -62,7 +62,7 @@ describe ApplicationHelper do ...@@ -62,7 +62,7 @@ describe ApplicationHelper do
let(:noteable_type) { Epic } let(:noteable_type) { Epic }
it 'returns paths for autocomplete_sources_controller' do it 'returns paths for autocomplete_sources_controller' do
expect_autocomplete_data_sources(object, noteable_type, [:members, :labels, :epics, :commands, :milestones]) expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :epics, :commands, :milestones])
end end
end end
......
# frozen_string_literal: true
require 'spec_helper'
describe GroupIssuableAutocompleteEntity do
let(:group) { build_stubbed(:group) }
let(:project) { build_stubbed(:project, group: group) }
let(:issue) { build_stubbed(:issue, project: project) }
subject { described_class.new(issue, parent_group: group).as_json }
describe '#represent' do
it 'includes the iid, title, and reference' do
expect(subject).to include(:iid, :title, :reference)
end
end
end
...@@ -54,6 +54,32 @@ describe Groups::AutocompleteService do ...@@ -54,6 +54,32 @@ describe Groups::AutocompleteService do
end end
end end
describe '#issues', :nested_groups do
let(:project) { create(:project, group: group) }
let(:sub_group_project) { create(:project, group: sub_group) }
let!(:project_issue) { create(:issue, project: project) }
let!(:sub_group_project_issue) { create(:issue, project: sub_group_project) }
it 'returns issues in group and subgroups' do
expect(subject.issues.map(&:iid)).to contain_exactly(project_issue.iid, sub_group_project_issue.iid)
expect(subject.issues.map(&:title)).to contain_exactly(project_issue.title, sub_group_project_issue.title)
end
end
describe '#merge_requests', :nested_groups do
let(:project) { create(:project, :repository, group: group) }
let(:sub_group_project) { create(:project, :repository, group: sub_group) }
let!(:project_mr) { create(:merge_request, source_project: project) }
let!(:sub_group_project_mr) { create(:merge_request, source_project: sub_group_project) }
it 'returns merge requests in group and subgroups' do
expect(subject.merge_requests.map(&:iid)).to contain_exactly(project_mr.iid, sub_group_project_mr.iid)
expect(subject.merge_requests.map(&:title)).to contain_exactly(project_mr.title, sub_group_project_mr.title)
end
end
describe '#epics' do describe '#epics' do
it 'returns nothing if not allowed' do it 'returns nothing if not allowed' do
allow(Ability).to receive(:allowed?).with(user, :read_epic, group).and_return(false) allow(Ability).to receive(:allowed?).with(user, :read_epic, group).and_return(false)
...@@ -64,7 +90,7 @@ describe Groups::AutocompleteService do ...@@ -64,7 +90,7 @@ describe Groups::AutocompleteService do
it 'returns epics from group' do it 'returns epics from group' do
allow(Ability).to receive(:allowed?).with(user, :read_epic, group).and_return(true) allow(Ability).to receive(:allowed?).with(user, :read_epic, group).and_return(true)
expect(subject.epics).to contain_exactly(epic) expect(subject.epics.map(&:iid)).to contain_exactly(epic.iid)
end end
end end
...@@ -92,28 +118,35 @@ describe Groups::AutocompleteService do ...@@ -92,28 +118,35 @@ describe Groups::AutocompleteService do
end end
context 'when group is public' do context 'when group is public' do
it 'returns milestones from groups', :nested_groups do let(:public_group) { create(:group, :public) }
group = create(:group, :public) let(:public_subgroup) { create(:group, :public, parent: public_group) }
subgroup = create(:group, :public, parent: group)
group_milestone = create(:milestone, group: group) before do
subgroup_milestone = create(:milestone, group: subgroup) public_subgroup.add_guest(user)
subgroup.add_guest(user) public_group.add_guest(user)
group.add_guest(user)
subject = described_class.new(subgroup, user) group_milestone.update(group: public_group)
subgroup_milestone.update(group: public_subgroup)
end
it 'returns milestones from groups and subgroups', :nested_groups do
subject = described_class.new(public_subgroup, user)
expect(subject.milestones).to match_array([group_milestone, subgroup_milestone]) expect(subject.milestones.map(&:iid)).to contain_exactly(group_milestone.iid, subgroup_milestone.iid)
expect(subject.milestones.map(&:title)).to contain_exactly(group_milestone.title, subgroup_milestone.title)
end end
end end
it 'returns milestones from group' do it 'returns milestones from group' do
expect(subject.milestones).to include(group_milestone) expect(subject.milestones.map(&:iid)).to contain_exactly(group_milestone.iid)
expect(subject.milestones.map(&:title)).to contain_exactly(group_milestone.title)
end end
it 'returns milestones from groups and subgroups', :nested_groups do it 'returns milestones from groups and subgroups', :nested_groups do
milestones = described_class.new(sub_group, user).milestones milestones = described_class.new(sub_group, user).milestones
expect(milestones).to include(group_milestone, subgroup_milestone) expect(milestones.map(&:iid)).to contain_exactly(group_milestone.iid, subgroup_milestone.iid)
expect(milestones.map(&:title)).to contain_exactly(group_milestone.title, subgroup_milestone.title)
end end
it 'returns only milestones that user can read', :nested_groups do it 'returns only milestones that user can read', :nested_groups do
...@@ -122,7 +155,8 @@ describe Groups::AutocompleteService do ...@@ -122,7 +155,8 @@ describe Groups::AutocompleteService do
milestones = described_class.new(sub_group, user).milestones milestones = described_class.new(sub_group, user).milestones
expect(milestones).to match_array([subgroup_milestone]) expect(milestones.map(&:iid)).to contain_exactly(subgroup_milestone.iid)
expect(milestones.map(&:title)).to contain_exactly(subgroup_milestone.title)
end end
end end
end end
...@@ -205,4 +205,40 @@ describe('GfmAutoComplete', function() { ...@@ -205,4 +205,40 @@ describe('GfmAutoComplete', function() {
expect(GfmAutoComplete.isLoading({ title: 'Foo' })).toBe(false); expect(GfmAutoComplete.isLoading({ title: 'Foo' })).toBe(false);
}); });
}); });
describe('Issues.insertTemplateFunction', function() {
it('should return default template', function() {
expect(GfmAutoComplete.Issues.insertTemplateFunction({ id: 5, title: 'Some Issue' })).toBe(
'${atwho-at}${id}', // eslint-disable-line no-template-curly-in-string
);
});
it('should return reference when reference is set', function() {
expect(
GfmAutoComplete.Issues.insertTemplateFunction({
id: 5,
title: 'Some Issue',
reference: 'grp/proj#5',
}),
).toBe('grp/proj#5');
});
});
describe('Issues.templateFunction', function() {
it('should return html with id and title', function() {
expect(GfmAutoComplete.Issues.templateFunction({ id: 5, title: 'Some Issue' })).toBe(
'<li><small>5</small> Some Issue</li>',
);
});
it('should replace id with reference if reference is set', function() {
expect(
GfmAutoComplete.Issues.templateFunction({
id: 5,
title: 'Some Issue',
reference: 'grp/proj#5',
}),
).toBe('<li><small>grp/proj#5</small> Some Issue</li>');
});
});
}); });
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