Commit 2362e500 authored by Sean McGivern's avatar Sean McGivern

Merge branch '5605-epic-autocomplete' into 'master'

Add support for autocompleting Epics and Labels within Epics

Closes #5604 and #5605

See merge request gitlab-org/gitlab-ee!6195
parents 94d5adec 8f14f931
import $ from 'jquery'; import $ from 'jquery';
import _ from 'underscore'; import _ from 'underscore';
// EE-specific
import setupAutoCompleteEpics from 'ee/gfm_auto_complete_ee';
import glRegexp from './lib/utils/regexp'; import glRegexp from './lib/utils/regexp';
import AjaxCache from './lib/utils/ajax_cache'; import AjaxCache from './lib/utils/ajax_cache';
...@@ -51,6 +55,9 @@ class GfmAutoComplete { ...@@ -51,6 +55,9 @@ class GfmAutoComplete {
if (this.enableMap.mergeRequests) this.setupMergeRequests($input); if (this.enableMap.mergeRequests) this.setupMergeRequests($input);
if (this.enableMap.labels) this.setupLabels($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 // We don't instantiate the quick actions autocomplete for note and issue/MR edit forms
$input.filter('[data-supports-quick-actions="true"]').atwho({ $input.filter('[data-supports-quick-actions="true"]').atwho({
at: '/', at: '/',
......
...@@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController ...@@ -14,7 +14,7 @@ class Projects::AutocompleteSourcesController < Projects::ApplicationController
end end
def labels def labels
render json: @autocomplete_service.labels(target) render json: @autocomplete_service.labels_as_hash(target)
end end
def milestones def milestones
......
...@@ -20,24 +20,28 @@ module Projects ...@@ -20,24 +20,28 @@ module Projects
MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title]) MergeRequestsFinder.new(current_user, project_id: project.id, state: 'opened').execute.select([:iid, :title])
end end
def labels(target = nil) def labels_as_hash(target = nil)
labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true) available_labels = LabelsFinder.new(
.execute.select([:color, :title]) current_user,
project_id: project.id,
return labels unless target&.respond_to?(:labels) include_ancestor_groups: true
).execute
issuable_label_titles = target.labels.pluck(:title)
label_hashes = available_labels.as_json(only: [:title, :color])
if issuable_label_titles
labels = labels.as_json(only: [:title, :color]) if target&.respond_to?(:labels)
already_set_labels = available_labels & target.labels
issuable_label_titles.each do |issuable_label_title| if already_set_labels.present?
found_label = labels.find { |label| label['title'] == issuable_label_title } titles = already_set_labels.map(&:title)
found_label[:set] = true if found_label label_hashes.each do |hash|
if titles.include?(hash['title'])
hash[:set] = true
end
end
end end
end end
labels label_hashes
end end
def commands(noteable, type) def commands(noteable, type)
......
...@@ -90,6 +90,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -90,6 +90,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 'labels'
get 'epics'
end end
end end
......
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;
class Groups::AutocompleteSourcesController < Groups::ApplicationController class Groups::AutocompleteSourcesController < Groups::ApplicationController
before_action :load_autocomplete_service, except: [:members]
def members def members
render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target) render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target)
end end
def labels
render json: @autocomplete_service.labels_as_hash(target)
end
def epics
render json: @autocomplete_service.epics
end
private private
def load_autocomplete_service
@autocomplete_service = ::Groups::AutocompleteService.new(@group, current_user)
end
def target def target
case params[:type]&.downcase case params[:type]&.downcase
when 'epic' when 'epic'
......
...@@ -66,7 +66,9 @@ module EE ...@@ -66,7 +66,9 @@ module EE
return super unless object.is_a?(Group) 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 end
......
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
module Groups module Groups
class ParticipantsService < BaseService class ParticipantsService < Groups::BaseService
include Users::ParticipableService include Users::ParticipableService
def execute(noteable) def execute(noteable)
......
---
title: Add support for autocompleting Epics and Labels within Epics
merge_request: 6195
author:
type: added
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
...@@ -2,15 +2,31 @@ require 'spec_helper' ...@@ -2,15 +2,31 @@ require 'spec_helper'
describe ApplicationHelper do describe ApplicationHelper do
describe '#autocomplete_data_sources' do describe '#autocomplete_data_sources' do
let(:object) { create(:group) } def expect_autocomplete_data_sources(object, noteable_type, source_keys)
let(:noteable_type) { Epic }
it 'returns paths for autocomplete_sources_controller' do
sources = helper.autocomplete_data_sources(object, noteable_type) 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| sources.keys.each do |key|
expect(sources[key]).not_to be_nil expect(sources[key]).not_to be_nil
end end
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 end
context 'when both CE and EE has partials with the same name' do context 'when both CE and EE has partials with the same name' do
......
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
...@@ -131,4 +131,58 @@ describe Projects::AutocompleteService do ...@@ -131,4 +131,58 @@ describe Projects::AutocompleteService do
end end
end 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 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