Commit 7aab0e5c authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Add autocomplete for issues and MRs in epics

References with namespace are inserted because this is at group scope
and iids are not unique
parent 8125a1a4
...@@ -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)
......
...@@ -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)
group_milestone.update(group: public_group)
subgroup_milestone.update(group: public_subgroup)
end
subject = described_class.new(subgroup, user) 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