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 '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: '/',
......
......@@ -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
......
......@@ -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])
def labels_as_hash(target = nil)
available_labels = LabelsFinder.new(
current_user,
project_id: project.id,
include_ancestor_groups: true
).execute
return labels unless target&.respond_to?(:labels)
label_hashes = available_labels.as_json(only: [:title, :color])
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
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)
......
......@@ -90,6 +90,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
resources :autocomplete_sources, only: [] do
collection do
get 'members'
get 'labels'
get 'epics'
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
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'
......
......@@ -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
......
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
class ParticipantsService < BaseService
class ParticipantsService < Groups::BaseService
include Users::ParticipableService
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'
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
......
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
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
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