diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 73b2cd0b2c7cc8dd702d29999a19ba3ac09f4138..3f7e2b0d9a128cba3fdf902b7df319f53eaa53ad 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -1,5 +1,9 @@ import $ from 'jquery'; import _ from 'underscore'; + +// EE-specific +import setupAutoCompleteEpics from 'ee/gfm_auto_complete_ee'; + import glRegexp from './lib/utils/regexp'; import AjaxCache from './lib/utils/ajax_cache'; @@ -51,6 +55,9 @@ class GfmAutoComplete { if (this.enableMap.mergeRequests) this.setupMergeRequests($input); if (this.enableMap.labels) this.setupLabels($input); + // EE-specific + if (this.enableMap.epics) setupAutoCompleteEpics($input, this.getDefaultCallbacks()); + // We don't instantiate the quick actions autocomplete for note and issue/MR edit forms $input.filter('[data-supports-quick-actions="true"]').atwho({ at: '/', diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb index 992c8ea6992d01251e98b48af167c859b3608f87..07627ffb69fd185c2942b1faa77acd237bcd4722 100644 --- a/app/controllers/projects/autocomplete_sources_controller.rb +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController end def labels - render json: @autocomplete_service.labels(target) + render json: @autocomplete_service.labels_as_hash(target) end def milestones diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index aa60661f7f25dfd9c9a33d0b7d6c205daec52bc1..9d0eaaf3152e86b2656c4015f201f0f6dd597240 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -20,24 +20,28 @@ module Projects MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title]) end - def labels(target = nil) - labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true) - .execute.select([:color, :title]) - - return labels unless target&.respond_to?(:labels) - - issuable_label_titles = target.labels.pluck(:title) - - if issuable_label_titles - labels = labels.as_json(only: [:title, :color]) - - issuable_label_titles.each do |issuable_label_title| - found_label = labels.find { |label| label['title'] == issuable_label_title } - found_label[:set] = true if found_label + def labels_as_hash(target = nil) + available_labels = LabelsFinder.new( + current_user, + project_id: project.id, + include_ancestor_groups: true + ).execute + + label_hashes = available_labels.as_json(only: [:title, :color]) + + if target&.respond_to?(:labels) + already_set_labels = available_labels & target.labels + if already_set_labels.present? + titles = already_set_labels.map(&:title) + label_hashes.each do |hash| + if titles.include?(hash['title']) + hash[:set] = true + end + end end end - labels + label_hashes end def commands(noteable, type) diff --git a/config/routes/group.rb b/config/routes/group.rb index ab7fefb3d0d6ff8f5fc633ae703e31bdd2ca5b19..abe5055cb5bb57d863fcd7d19fa18fcf601ed775 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -90,6 +90,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do resources :autocomplete_sources, only: [] do collection do get 'members' + get 'labels' + get 'epics' end end diff --git a/ee/app/assets/javascripts/gfm_auto_complete_ee.js b/ee/app/assets/javascripts/gfm_auto_complete_ee.js new file mode 100644 index 0000000000000000000000000000000000000000..c15f9f3a5066d997a3c4ddb3df8f032eb9da0c62 --- /dev/null +++ b/ee/app/assets/javascripts/gfm_auto_complete_ee.js @@ -0,0 +1,37 @@ +import $ from 'jquery'; +import GfmAutoComplete from '~/gfm_auto_complete'; + +const setupAutoCompleteEpics = ($input, defaultCallbacks) => { + $input.atwho({ + at: '&', + alias: 'epics', + searchKey: 'search', + displayTpl(value) { + let tmpl = GfmAutoComplete.Loading.template; + if (value.title != null) { + tmpl = GfmAutoComplete.Issues.template; + } + return tmpl; + }, + data: GfmAutoComplete.defaultLoadingData, + // eslint-disable-next-line no-template-curly-in-string + insertTpl: '${atwho-at}${id}', + callbacks: { + ...defaultCallbacks, + beforeSave(merges) { + return $.map(merges, (m) => { + if (m.title == null) { + return m; + } + return { + id: m.iid, + title: m.title.replace(/<(?:.|\n)*?>/gm, ''), + search: `${m.iid} ${m.title}`, + }; + }); + }, + }, + }); +}; + +export default setupAutoCompleteEpics; diff --git a/ee/app/controllers/groups/autocomplete_sources_controller.rb b/ee/app/controllers/groups/autocomplete_sources_controller.rb index ae16eb3e5da4359b63f58ef1a94630e2d65e11d3..865083796e55b0f087dc6b4a28da1f9e2613e0ec 100644 --- a/ee/app/controllers/groups/autocomplete_sources_controller.rb +++ b/ee/app/controllers/groups/autocomplete_sources_controller.rb @@ -1,10 +1,24 @@ class Groups::AutocompleteSourcesController < Groups::ApplicationController + before_action :load_autocomplete_service, except: [:members] + def members render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target) end + def labels + render json: @autocomplete_service.labels_as_hash(target) + end + + def epics + render json: @autocomplete_service.epics + end + private + def load_autocomplete_service + @autocomplete_service = ::Groups::AutocompleteService.new(@group, current_user) + end + def target case params[:type]&.downcase when 'epic' diff --git a/ee/app/helpers/ee/application_helper.rb b/ee/app/helpers/ee/application_helper.rb index 6fb9e38ff47603a75fa936e1ea4f624c7e36f8e9..193cfd74d4cfff17fe023a1b9aef623ba4e4a747 100644 --- a/ee/app/helpers/ee/application_helper.rb +++ b/ee/app/helpers/ee/application_helper.rb @@ -66,7 +66,9 @@ module EE return super unless object.is_a?(Group) { - 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), + epics: epics_group_autocomplete_sources_path(object) } end diff --git a/ee/app/services/groups/autocomplete_service.rb b/ee/app/services/groups/autocomplete_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..8a2fc7ca5bde992dc1c73171384d9f7f98a4cfc9 --- /dev/null +++ b/ee/app/services/groups/autocomplete_service.rb @@ -0,0 +1,40 @@ +module Groups + class AutocompleteService < Groups::BaseService + def labels_as_hash(target = nil) + available_labels = LabelsFinder.new( + current_user, + group_id: group.id, + include_ancestor_groups: true, + only_group_labels: true + ).execute + + label_hashes = available_labels.as_json(only: [:title, :color]) + + if target&.respond_to?(:labels) + already_set_labels = available_labels & target.labels + if already_set_labels.present? + titles = already_set_labels.map(&:title) + label_hashes.each do |hash| + if titles.include?(hash['title']) + hash[:set] = true + end + end + end + end + + label_hashes + end + + def epics + # TODO: change to EpicsFinder once frontend supports epics from external groups. + # See https://gitlab.com/gitlab-org/gitlab-ee/issues/6837 + DeclarativePolicy.user_scope do + if Ability.allowed?(current_user, :read_epic, group) + group.epics + else + [] + end + end + end + end +end diff --git a/ee/app/services/groups/participants_service.rb b/ee/app/services/groups/participants_service.rb index f13ecf0b084bbe5451107d5226d488904b6ca7c4..ef45fa581ccfd51e14b058a77e81db6467827fbf 100644 --- a/ee/app/services/groups/participants_service.rb +++ b/ee/app/services/groups/participants_service.rb @@ -1,5 +1,5 @@ module Groups - class ParticipantsService < BaseService + class ParticipantsService < Groups::BaseService include Users::ParticipableService def execute(noteable) diff --git a/ee/changelogs/unreleased/5605-epic-autocomplete.yml b/ee/changelogs/unreleased/5605-epic-autocomplete.yml new file mode 100644 index 0000000000000000000000000000000000000000..4bfe05cf5da4d752ba5630139592e553c3c619c7 --- /dev/null +++ b/ee/changelogs/unreleased/5605-epic-autocomplete.yml @@ -0,0 +1,5 @@ +--- +title: Add support for autocompleting Epics and Labels within Epics +merge_request: 6195 +author: +type: added diff --git a/ee/spec/features/epics/gfm_autocomplete_spec.rb b/ee/spec/features/epics/gfm_autocomplete_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8d6a14ec4b83de942ee5d7a849aba5e4406ad819 --- /dev/null +++ b/ee/spec/features/epics/gfm_autocomplete_spec.rb @@ -0,0 +1,162 @@ +require 'rails_helper' + +describe 'GFM autocomplete', :js do + let(:user) { create(:user, name: '💃speciąl someone💃', username: 'someone.special') } + let(:group) { create(:group) } + let(:label) { create(:group_label, group: group, title: 'special+') } + let(:epic) { create(:epic, group: group) } + + before do + stub_licensed_features(epics: true) + group.add_master(user) + sign_in(user) + visit group_epic_path(group, epic) + + wait_for_requests + end + + context 'epics' do + let!(:epic2) { create(:epic, group: group, title: 'make tea') } + + it 'shows epics' do + note = find('#note-body') + + # It should show all the epics on "&". + type(note, '&') + expect_epics(shown: [epic, epic2]) + end + end + + # This context has just one example in each contexts in order to improve spec performance. + context 'labels' do + let!(:backend) { create(:group_label, group: group, title: 'backend') } + let!(:bug) { create(:group_label, group: group, title: 'bug') } + let!(:feature_proposal) { create(:group_label, group: group, title: 'feature proposal') } + + context 'when no labels are assigned' do + it 'shows labels' do + note = find('#note-body') + + # It should show all the labels on "~". + type(note, '~') + expect_labels(shown: [backend, bug, feature_proposal]) + + # It should show all the labels on "/label ~". + type(note, '/label ~') + expect_labels(shown: [backend, bug, feature_proposal]) + + # It should show all the labels on "/relabel ~". + type(note, '/relabel ~') + expect_labels(shown: [backend, bug, feature_proposal]) + + # It should show no labels on "/unlabel ~". + type(note, '/unlabel ~') + expect_labels(not_shown: [backend, bug, feature_proposal]) + end + end + + context 'when some labels are assigned' do + before do + epic.labels << [backend] + end + + skip 'shows labels' do + note = find('#note-body') + + # It should show all the labels on "~". + type(note, '~') + expect_labels(shown: [backend, bug, feature_proposal]) + + # It should show only unset labels on "/label ~". + type(note, '/label ~') + expect_labels(shown: [bug, feature_proposal], not_shown: [backend]) + + # It should show all the labels on "/relabel ~". + type(note, '/relabel ~') + expect_labels(shown: [backend, bug, feature_proposal]) + + # It should show only set labels on "/unlabel ~". + type(note, '/unlabel ~') + expect_labels(shown: [backend], not_shown: [bug, feature_proposal]) + end + end + + context 'when all labels are assigned' do + before do + epic.labels << [backend, bug, feature_proposal] + end + + skip 'shows labels' do + note = find('#note-body') + + # It should show all the labels on "~". + type(note, '~') + expect_labels(shown: [backend, bug, feature_proposal]) + + # It should show no labels on "/label ~". + type(note, '/label ~') + expect_labels(not_shown: [backend, bug, feature_proposal]) + + # It should show all the labels on "/relabel ~". + type(note, '/relabel ~') + expect_labels(shown: [backend, bug, feature_proposal]) + + # It should show all the labels on "/unlabel ~". + type(note, '/unlabel ~') + expect_labels(shown: [backend, bug, feature_proposal]) + end + end + end + + private + + def expect_to_wrap(should_wrap, item, note, value) + expect(item).to have_content(value) + expect(item).not_to have_content("\"#{value}\"") + + item.click + + if should_wrap + expect(note.value).to include("\"#{value}\"") + else + expect(note.value).not_to include("\"#{value}\"") + end + end + + def expect_labels(shown: nil, not_shown: nil) + page.within('.atwho-container') do + if shown + expect(page).to have_selector('.atwho-view li', count: shown.size) + shown.each { |label| expect(page).to have_content(label.title) } + end + + if not_shown + expect(page).not_to have_selector('.atwho-view li') unless shown + not_shown.each { |label| expect(page).not_to have_content(label.title) } + end + end + end + + def expect_epics(shown: nil, not_shown: nil) + page.within('.atwho-container') do + if shown + expect(page).to have_selector('.atwho-view li', count: shown.size) + shown.each { |epic| expect(page).to have_content(epic.title) } + end + + if not_shown + expect(page).not_to have_selector('.atwho-view li') unless shown + not_shown.each { |epic| expect(page).not_to have_content(epic.title) } + end + end + end + + # `note` is a textarea where the given text should be typed. + # We don't want to find it each time this function gets called. + def type(note, text) + page.within('.timeline-content-form') do + note.set('') + note.native.send_keys(text) + end + end +end diff --git a/ee/spec/helpers/application_helper_spec.rb b/ee/spec/helpers/application_helper_spec.rb index 7ef32372334613a2188229acdf688b8fa2005963..22c69c319f39220ce729a324f66c65578e395cfe 100644 --- a/ee/spec/helpers/application_helper_spec.rb +++ b/ee/spec/helpers/application_helper_spec.rb @@ -2,15 +2,31 @@ require 'spec_helper' describe ApplicationHelper do describe '#autocomplete_data_sources' do - let(:object) { create(:group) } - let(:noteable_type) { Epic } - it 'returns paths for autocomplete_sources_controller' do + def expect_autocomplete_data_sources(object, noteable_type, source_keys) sources = helper.autocomplete_data_sources(object, noteable_type) - expect(sources.keys).to match_array([:members]) + expect(sources.keys).to match_array(source_keys) sources.keys.each do |key| expect(sources[key]).not_to be_nil end end + + context 'group' do + let(:object) { create(:group) } + let(:noteable_type) { Epic } + + it 'returns paths for autocomplete_sources_controller' do + expect_autocomplete_data_sources(object, noteable_type, [:members, :labels, :epics]) + end + end + + context 'project' do + let(:object) { create(:project) } + let(:noteable_type) { Issue } + + it 'returns paths for autocomplete_sources_controller' do + expect_autocomplete_data_sources(object, noteable_type, [:members, :issues, :mergeRequests, :labels, :milestones, :commands]) + end + end end context 'when both CE and EE has partials with the same name' do diff --git a/ee/spec/services/groups/autocomplete_service_spec.rb b/ee/spec/services/groups/autocomplete_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..df840964328b914c321c845946b4fc1ab860ad4f --- /dev/null +++ b/ee/spec/services/groups/autocomplete_service_spec.rb @@ -0,0 +1,71 @@ +require 'spec_helper' + +describe Groups::AutocompleteService do + let!(:group) { create(:group, :nested, avatar: fixture_file_upload('spec/fixtures/dk.png')) } + let!(:sub_group) { create(:group, parent: group) } + let(:user) { create(:user) } + let!(:epic) { create(:epic, group: group, author: user) } + + before do + create(:group_member, group: group, user: user) + end + + def expect_labels_to_equal(labels, expected_labels) + extract_title = lambda { |label| label['title'] } + expect(labels.map(&extract_title)).to eq(expected_labels.map(&extract_title)) + end + + describe '#labels_as_hash' do + let!(:label1) { create(:group_label, group: group) } + let!(:label2) { create(:group_label, group: group) } + let!(:sub_group_label) { create(:group_label, group: sub_group) } + let!(:parent_group_label) { create(:group_label, group: group.parent) } + + it 'returns labels from own group and ancestor groups' do + service = described_class.new(group, user) + results = service.labels_as_hash + expected_labels = [label1, label2, parent_group_label] + + expect_labels_to_equal(results, expected_labels) + end + + context 'some labels are already assigned' do + before do + epic.labels << label1 + end + + it 'marks already assigned as set' do + service = described_class.new(group, user) + results = service.labels_as_hash(epic) + expected_labels = [label1, label2, parent_group_label] + + expect_labels_to_equal(results, expected_labels) + + assigned_label_titles = epic.labels.map(&:title) + results.each do |hash| + if assigned_label_titles.include?(hash['title']) + expect(hash[:set]).to eq(true) + else + expect(hash.key?(:set)).to eq(false) + end + end + end + end + end + + describe '#epics' do + it 'returns nothing if not allowed' do + allow(Ability).to receive(:allowed?).with(user, :read_epic, group).and_return(false) + service = described_class.new(group, user) + + expect(service.epics).to eq([]) + end + + it 'returns epics from group' do + allow(Ability).to receive(:allowed?).with(user, :read_epic, group).and_return(true) + service = described_class.new(group, user) + + expect(service.epics).to contain_exactly(epic) + end + end +end diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb index 6fd73a505115f31856aa497b679103d767d78b44..e98df375d4817bb36e67f211764225c0400652ba 100644 --- a/spec/services/projects/autocomplete_service_spec.rb +++ b/spec/services/projects/autocomplete_service_spec.rb @@ -131,4 +131,58 @@ describe Projects::AutocompleteService do end end end + + describe '#labels_as_hash' do + def expect_labels_to_equal(labels, expected_labels) + expect(labels.size).to eq(expected_labels.size) + extract_title = lambda { |label| label['title'] } + expect(labels.map(&extract_title)).to eq(expected_labels.map(&extract_title)) + end + + let(:user) { create(:user) } + let(:group) { create(:group, :nested) } + let!(:sub_group) { create(:group, parent: group) } + let(:project) { create(:project, :public, group: group) } + let(:issue) { create(:issue, project: project) } + + let!(:label1) { create(:label, project: project) } + let!(:label2) { create(:label, project: project) } + let!(:sub_group_label) { create(:group_label, group: sub_group) } + let!(:parent_group_label) { create(:group_label, group: group.parent) } + + before do + create(:group_member, group: group, user: user) + end + + it 'returns labels from project and ancestor groups' do + service = described_class.new(project, user) + results = service.labels_as_hash + expected_labels = [label1, label2, parent_group_label] + + expect_labels_to_equal(results, expected_labels) + end + + context 'some labels are already assigned' do + before do + issue.labels << label1 + end + + it 'marks already assigned as set' do + service = described_class.new(project, user) + results = service.labels_as_hash(issue) + expected_labels = [label1, label2, parent_group_label] + + expect_labels_to_equal(results, expected_labels) + + assigned_label_titles = issue.labels.map(&:title) + results.each do |hash| + if assigned_label_titles.include?(hash['title']) + expect(hash[:set]).to eq(true) + else + expect(hash.key?(:set)).to eq(false) + end + end + end + end + end end