Commit 7032fc65 authored by Douwe Maan's avatar Douwe Maan

Merge branch '4084-epics-username-autocomplete' into 'master'

autocomplete usernames in Epic comments/description

Closes #4084

See merge request gitlab-org/gitlab-ee!5475
parents a3741154 d54abf6d
...@@ -408,7 +408,10 @@ class GfmAutoComplete { ...@@ -408,7 +408,10 @@ class GfmAutoComplete {
fetchData($input, at) { fetchData($input, at) {
if (this.isLoadingData[at]) return; if (this.isLoadingData[at]) return;
this.isLoadingData[at] = true; this.isLoadingData[at] = true;
const dataSource = this.dataSources[GfmAutoComplete.atTypeMap[at]];
if (this.cachedData[at]) { if (this.cachedData[at]) {
this.loadData($input, at, this.cachedData[at]); this.loadData($input, at, this.cachedData[at]);
} else if (GfmAutoComplete.atTypeMap[at] === 'emojis') { } else if (GfmAutoComplete.atTypeMap[at] === 'emojis') {
...@@ -418,12 +421,14 @@ class GfmAutoComplete { ...@@ -418,12 +421,14 @@ class GfmAutoComplete {
GfmAutoComplete.glEmojiTag = glEmojiTag; GfmAutoComplete.glEmojiTag = glEmojiTag;
}) })
.catch(() => { this.isLoadingData[at] = false; }); .catch(() => { this.isLoadingData[at] = false; });
} else { } else if (dataSource) {
AjaxCache.retrieve(this.dataSources[GfmAutoComplete.atTypeMap[at]], true) AjaxCache.retrieve(dataSource, true)
.then((data) => { .then((data) => {
this.loadData($input, at, data); this.loadData($input, at, data);
}) })
.catch(() => { this.isLoadingData[at] = false; }); .catch(() => { this.isLoadingData[at] = false; });
} else {
this.isLoadingData[at] = false;
} }
} }
......
...@@ -99,10 +99,6 @@ export default { ...@@ -99,10 +99,6 @@ export default {
'js-note-target-reopen': !this.isOpen, 'js-note-target-reopen': !this.isOpen,
}; };
}, },
supportQuickActions() {
// Disable quick actions support for Epics
return this.noteableType !== constants.EPIC_NOTEABLE_TYPE;
},
markdownDocsPath() { markdownDocsPath() {
return this.getNotesData.markdownDocsPath; return this.getNotesData.markdownDocsPath;
}, },
...@@ -359,7 +355,7 @@ Please check your network connection and try again.`; ...@@ -359,7 +355,7 @@ Please check your network connection and try again.`;
name="note[note]" name="note[note]"
class="note-textarea js-vue-comment-form class="note-textarea js-vue-comment-form
js-gfm-input js-autosize markdown-area js-vue-textarea" js-gfm-input js-autosize markdown-area js-vue-textarea"
:data-supports-quick-actions="supportQuickActions" data-supports-quick-actions="true"
aria-label="Description" aria-label="Description"
v-model="note" v-model="note"
ref="textarea" ref="textarea"
......
...@@ -264,4 +264,17 @@ module ApplicationHelper ...@@ -264,4 +264,17 @@ module ApplicationHelper
_('You are on a read-only GitLab instance.') _('You are on a read-only GitLab instance.')
end end
def autocomplete_data_sources(object, noteable_type)
return {} unless object && noteable_type
{
members: members_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
issues: issues_project_autocomplete_sources_path(object),
merge_requests: merge_requests_project_autocomplete_sources_path(object),
labels: labels_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id]),
milestones: milestones_project_autocomplete_sources_path(object),
commands: commands_project_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id])
}
end
end end
...@@ -10,6 +10,14 @@ class Ability ...@@ -10,6 +10,14 @@ class Ability
end end
end end
# Given a list of users and a group this method returns the users that can
# read the given group.
def users_that_can_read_group(users, group)
DeclarativePolicy.subject_scope do
users.select { |u| allowed?(u, :read_group, group) }
end
end
# Given a list of users and a snippet this method returns the users that can # Given a list of users and a snippet this method returns the users that can
# read the given snippet. # read the given snippet.
def users_that_can_read_personal_snippet(users, snippet) def users_that_can_read_personal_snippet(users, snippet)
......
...@@ -23,6 +23,7 @@ ...@@ -23,6 +23,7 @@
# users = issue.participants # users = issue.participants
module Participable module Participable
extend ActiveSupport::Concern extend ActiveSupport::Concern
prepend EE::Participable
module ClassMethods module ClassMethods
# Adds a list of participant attributes. Attributes can either be symbols or # Adds a list of participant attributes. Attributes can either be symbols or
...@@ -98,6 +99,10 @@ module Participable ...@@ -98,6 +99,10 @@ module Participable
participants.merge(ext.users) participants.merge(ext.users)
filter_by_ability(participants)
end
def filter_by_ability(participants)
case self case self
when PersonalSnippet when PersonalSnippet
Ability.users_that_can_read_personal_snippet(participants.to_a, self) Ability.users_that_can_read_personal_snippet(participants.to_a, self)
......
...@@ -285,6 +285,13 @@ class Group < Namespace ...@@ -285,6 +285,13 @@ class Group < Namespace
.where(source_id: self_and_descendants.reorder(nil).select(:id)) .where(source_id: self_and_descendants.reorder(nil).select(:id))
end end
# Returns all members that are part of the group, it's subgroups, and ancestor groups
def direct_and_indirect_members
GroupMember
.active_without_invites_and_requests
.where(source_id: self_and_hierarchy.reorder(nil).select(:id))
end
def users_with_parents def users_with_parents
User User
.where(id: members_with_parents.select(:user_id)) .where(id: members_with_parents.select(:user_id))
...@@ -297,6 +304,30 @@ class Group < Namespace ...@@ -297,6 +304,30 @@ class Group < Namespace
.reorder(nil) .reorder(nil)
end end
# Returns all users that are members of the group because:
# 1. They belong to the group
# 2. They belong to a project that belongs to the group
# 3. They belong to a sub-group or project in such sub-group
# 4. They belong to an ancestor group
def direct_and_indirect_users
union = Gitlab::SQL::Union.new([
User
.where(id: direct_and_indirect_members.select(:user_id))
.reorder(nil),
project_users_with_descendants
])
User.from("(#{union.to_sql}) #{User.table_name}")
end
# Returns all users that are members of projects
# belonging to the current group or sub-groups
def project_users_with_descendants
User
.joins(projects: :group)
.where(namespaces: { id: self_and_descendants.select(:id) })
end
def max_member_access_for_user(user) def max_member_access_for_user(user)
return GroupMember::OWNER if user.admin? return GroupMember::OWNER if user.admin?
......
...@@ -172,6 +172,13 @@ class Namespace < ActiveRecord::Base ...@@ -172,6 +172,13 @@ class Namespace < ActiveRecord::Base
projects.with_shared_runners.any? projects.with_shared_runners.any?
end end
# Returns all ancestors, self, and descendants of the current namespace.
def self_and_hierarchy
Gitlab::GroupHierarchy
.new(self.class.where(id: id))
.all_groups
end
# Returns all the ancestors of the current namespaces. # Returns all the ancestors of the current namespaces.
def ancestors def ancestors
return self.class.none unless parent_id return self.class.none unless parent_id
......
module Users
module ParticipableService
extend ActiveSupport::Concern
included do
attr_reader :noteable
end
def noteable_owner
return [] unless noteable && noteable.author.present?
[as_hash(noteable.author)]
end
def participants_in_noteable
return [] unless noteable
users = noteable.participants(current_user)
sorted(users)
end
def sorted(users)
users.uniq.to_a.compact.sort_by(&:username).map do |user|
as_hash(user)
end
end
def groups
current_user.authorized_groups.sort_by(&:path).map do |group|
count = group.users.count
{ username: group.full_path, name: group.full_name, count: count, avatar_url: group.avatar_url }
end
end
private
def as_hash(user)
{ username: user.username, name: user.name, avatar_url: user.avatar_url }
end
end
end
module Projects module Projects
class ParticipantsService < BaseService class ParticipantsService < BaseService
attr_reader :noteable include Users::ParticipableService
def execute(noteable) def execute(noteable)
@noteable = noteable @noteable = noteable
...@@ -10,36 +10,6 @@ module Projects ...@@ -10,36 +10,6 @@ module Projects
participants.uniq participants.uniq
end end
def noteable_owner
return [] unless noteable && noteable.author.present?
[{
name: noteable.author.name,
username: noteable.author.username,
avatar_url: noteable.author.avatar_url
}]
end
def participants_in_noteable
return [] unless noteable
users = noteable.participants(current_user)
sorted(users)
end
def sorted(users)
users.uniq.to_a.compact.sort_by(&:username).map do |user|
{ username: user.username, name: user.name, avatar_url: user.avatar_url }
end
end
def groups
current_user.authorized_groups.sort_by(&:path).map do |group|
count = group.users.count
{ username: group.full_path, name: group.full_name, count: count, avatar_url: group.avatar_url }
end
end
def all_members def all_members
count = project.team.members.flatten.count count = project.team.members.flatten.count
[{ username: "all", name: "All Project and Group Members", count: count }] [{ username: "all", name: "All Project and Group Members", count: count }]
......
- project = @target_project || @project - object = @target_project || @project || @group
- noteable_type = @noteable.class if @noteable.present? - noteable_type = @noteable.class if @noteable.present?
- if project - datasources = autocomplete_data_sources(object, noteable_type)
- if object
-# haml-lint:disable InlineJavaScript -# haml-lint:disable InlineJavaScript
:javascript :javascript
gl = window.gl || {}; gl = window.gl || {};
gl.GfmAutoComplete = gl.GfmAutoComplete || {}; gl.GfmAutoComplete = gl.GfmAutoComplete || {};
gl.GfmAutoComplete.dataSources = { gl.GfmAutoComplete.dataSources = #{datasources.to_json};
members: "#{members_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
issues: "#{issues_project_autocomplete_sources_path(project)}",
mergeRequests: "#{merge_requests_project_autocomplete_sources_path(project)}",
labels: "#{labels_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}",
milestones: "#{milestones_project_autocomplete_sources_path(project)}",
commands: "#{commands_project_autocomplete_sources_path(project, type: noteable_type, type_id: params[:id])}"
};
...@@ -84,6 +84,12 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -84,6 +84,12 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
end end
end end
resources :autocomplete_sources, only: [] do
collection do
get 'members'
end
end
resources :billings, only: [:index] resources :billings, only: [:index]
resources :epics, concerns: :awardable, constraints: { id: /\d+/ } do resources :epics, concerns: :awardable, constraints: { id: /\d+/ } do
member do member do
......
...@@ -154,7 +154,7 @@ ...@@ -154,7 +154,7 @@
:project-namespace="projectNamespace" :project-namespace="projectNamespace"
:show-inline-edit-button="true" :show-inline-edit-button="true"
:show-delete-button="false" :show-delete-button="false"
:enable-autocomplete="false" :enable-autocomplete="true"
/> />
</div> </div>
<epic-sidebar <epic-sidebar
......
class Groups::AutocompleteSourcesController < Groups::ApplicationController
def members
render json: ::Groups::ParticipantsService.new(@group, current_user).execute(target)
end
private
def target
case params[:type]&.downcase
when 'epic'
EpicsFinder.new(current_user, group_id: @group.id).find_by(iid: params[:type_id])
end
end
end
...@@ -47,7 +47,7 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -47,7 +47,7 @@ class Groups::EpicsController < Groups::ApplicationController
return render_404 unless can?(current_user, :read_epic, @epic) return render_404 unless can?(current_user, :read_epic, @epic)
@epic @noteable = @epic
end end
alias_method :issuable, :epic alias_method :issuable, :epic
alias_method :awardable, :epic alias_method :awardable, :epic
......
...@@ -28,6 +28,17 @@ module EE ...@@ -28,6 +28,17 @@ module EE
class_names class_names
end end
override :autocomplete_data_sources
def autocomplete_data_sources(object, noteable_type)
return {} unless object && noteable_type
return super unless object.is_a?(Group)
{
members: members_group_autocomplete_sources_path(object, type: noteable_type, type_id: params[:id])
}
end
private private
def appearance def appearance
......
module EE
module Participable
extend ::Gitlab::Utils::Override
override :filter_by_ability
def filter_by_ability(participants)
return super unless self.is_a?(Epic)
Ability.users_that_can_read_group(participants.to_a, self.group)
end
end
end
module Groups
class ParticipantsService < BaseService
include Users::ParticipableService
def execute(noteable)
@noteable = noteable
participants = noteable_owner + participants_in_noteable + all_members + groups + group_members
participants.uniq
end
def all_members
count = group_members.count
[{ username: "all", name: "All Group Members", count: count }]
end
def group_members
return [] unless noteable
@group_members ||= sorted(noteable.group.direct_and_indirect_users)
end
end
end
---
title: Enable username autocomplete inside Epics
merge_request: 5475
author:
type: added
...@@ -83,11 +83,10 @@ feature 'Update Epic', :js do ...@@ -83,11 +83,10 @@ feature 'Update Epic', :js do
expect(link).to match(link_match) expect(link).to match(link_match)
end end
# Autocomplete is disabled for epics until #4084 is resolved describe 'autocomplete enabled' do
describe 'autocomplete disabled' do it 'opens atwho container' do
it 'does not open atwho container' do
find('#issue-description').native.send_keys('@') find('#issue-description').native.send_keys('@')
expect(page).not_to have_selector('.atwho-container') expect(page).to have_selector('.atwho-container')
end end
end end
end end
......
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
sources = helper.autocomplete_data_sources(object, noteable_type)
expect(sources.keys).to match_array([:members])
sources.keys.each do |key|
expect(sources[key]).not_to be_nil
end
end
end
end
require 'spec_helper'
describe Groups::ParticipantsService do
let(:group) { create(:group, avatar: fixture_file_upload(Rails.root + 'spec/fixtures/dk.png')) }
let(:user) { create(:user) }
let(:epic) { create(:epic, group: group, author: user) }
before do
create(:group_member, group: group, user: user)
end
def user_to_autocompletable(user)
{
username: user.username,
name: user.name,
avatar_url: user.avatar_url
}
end
describe '#execute' do
it 'should add the owner to the list' do
expect(described_class.new(group, user).execute(epic).first).to eq(user_to_autocompletable(user))
end
end
describe '#participants_in_noteable' do
before do
@users = []
5.times do
other_user = create(:user)
create(:group_member, group: group, user: other_user)
@users << other_user
end
create(:note, author: user, project: nil, noteable: epic, note: @users.map { |u| u.to_reference }.join(' '))
end
it 'should return all participants' do
service = described_class.new(group, user)
service.instance_variable_set(:@noteable, epic)
result = service.participants_in_noteable
expected_users = (@users + [user]).map(&method(:user_to_autocompletable))
expect(result).to match_array(expected_users)
end
end
describe '#group_members', :nested_groups do
let(:parent_group) { create(:group) }
let(:group) { create(:group, parent: parent_group) }
let(:subgroup) { create(:group_with_members, parent: group) }
let(:subproject) { create(:project, group: subgroup) }
it 'should return all members in parent groups, sub-groups, and sub-projects' do
parent_group.add_developer(create(:user))
subgroup.add_developer(create(:user))
subproject.add_developer(create(:user))
service = described_class.new(group, user)
service.instance_variable_set(:@noteable, epic)
result = service.group_members
expected_users = (group.self_and_hierarchy.map(&:users).flatten + subproject.users)
.map(&method(:user_to_autocompletable))
expect(expected_users.count).to eq(5)
expect(result).to match_array(expected_users)
end
end
describe '#groups' do
describe 'avatar_url' do
let(:groups) { described_class.new(group, user).groups }
it 'should return an url for the avatar' do
expect(groups.size).to eq 1
expect(groups.first[:avatar_url]).to eq("/uploads/-/system/group/avatar/#{group.id}/dk.png")
end
it 'should return an url for the avatar with relative url' do
stub_config_setting(relative_url_root: '/gitlab')
stub_config_setting(url: Settings.send(:build_gitlab_url))
expect(groups.size).to eq 1
expect(groups.first[:avatar_url]).to eq("/gitlab/uploads/-/system/group/avatar/#{group.id}/dk.png")
end
end
end
end
...@@ -151,4 +151,16 @@ describe ApplicationHelper do ...@@ -151,4 +151,16 @@ describe ApplicationHelper do
end end
end end
end end
describe '#autocomplete_data_sources' do
let(:project) { create(:project) }
let(:noteable_type) { Issue }
it 'returns paths for autocomplete_sources_controller' do
sources = helper.autocomplete_data_sources(project, noteable_type)
expect(sources.keys).to match_array([:members, :issues, :merge_requests, :labels, :milestones, :commands])
sources.keys.each do |key|
expect(sources[key]).not_to be_nil
end
end
end
end end
...@@ -425,6 +425,23 @@ describe Group do ...@@ -425,6 +425,23 @@ describe Group do
end end
end end
describe '#direct_and_indirect_members', :nested_groups do
let!(:group) { create(:group, :nested) }
let!(:sub_group) { create(:group, parent: group) }
let!(:master) { group.parent.add_user(create(:user), GroupMember::MASTER) }
let!(:developer) { group.add_user(create(:user), GroupMember::DEVELOPER) }
let!(:other_developer) { group.add_user(create(:user), GroupMember::DEVELOPER) }
it 'returns parents members' do
expect(group.direct_and_indirect_members).to include(developer)
expect(group.direct_and_indirect_members).to include(master)
end
it 'returns descendant members' do
expect(group.direct_and_indirect_members).to include(other_developer)
end
end
describe '#users_with_descendants', :nested_groups do describe '#users_with_descendants', :nested_groups do
let(:user_a) { create(:user) } let(:user_a) { create(:user) }
let(:user_b) { create(:user) } let(:user_b) { create(:user) }
...@@ -444,6 +461,59 @@ describe Group do ...@@ -444,6 +461,59 @@ describe Group do
end end
end end
describe '#direct_and_indirect_users', :nested_groups do
let(:user_a) { create(:user) }
let(:user_b) { create(:user) }
let(:user_c) { create(:user) }
let(:user_d) { create(:user) }
let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
let(:deep_nested_group) { create(:group, parent: nested_group) }
let(:project) { create(:project, namespace: group) }
before do
group.add_developer(user_a)
group.add_developer(user_c)
nested_group.add_developer(user_b)
deep_nested_group.add_developer(user_a)
project.add_developer(user_d)
end
it 'returns member users on every nest level without duplication' do
expect(group.direct_and_indirect_users).to contain_exactly(user_a, user_b, user_c, user_d)
expect(nested_group.direct_and_indirect_users).to contain_exactly(user_a, user_b, user_c)
expect(deep_nested_group.direct_and_indirect_users).to contain_exactly(user_a, user_b, user_c)
end
it 'does not return members of projects belonging to ancestor groups' do
expect(nested_group.direct_and_indirect_users).not_to include(user_d)
end
end
describe '#project_users_with_descendants', :nested_groups do
let(:user_a) { create(:user) }
let(:user_b) { create(:user) }
let(:user_c) { create(:user) }
let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
let(:deep_nested_group) { create(:group, parent: nested_group) }
let(:project_a) { create(:project, namespace: group) }
let(:project_b) { create(:project, namespace: nested_group) }
let(:project_c) { create(:project, namespace: deep_nested_group) }
it 'returns members of all projects in group and subgroups' do
project_a.add_developer(user_a)
project_b.add_developer(user_b)
project_c.add_developer(user_c)
expect(group.project_users_with_descendants).to contain_exactly(user_a, user_b, user_c)
expect(nested_group.project_users_with_descendants).to contain_exactly(user_b, user_c)
expect(deep_nested_group.project_users_with_descendants).to contain_exactly(user_c)
end
end
describe '#user_ids_for_project_authorizations' do describe '#user_ids_for_project_authorizations' do
it 'returns the user IDs for which to refresh authorizations' do it 'returns the user IDs for which to refresh authorizations' do
master = create(:user) master = create(:user)
......
...@@ -411,6 +411,21 @@ describe Namespace do ...@@ -411,6 +411,21 @@ describe Namespace do
end end
end end
describe '#self_and_hierarchy', :nested_groups do
let!(:group) { create(:group, path: 'git_lab') }
let!(:nested_group) { create(:group, parent: group) }
let!(:deep_nested_group) { create(:group, parent: nested_group) }
let!(:very_deep_nested_group) { create(:group, parent: deep_nested_group) }
let!(:another_group) { create(:group, path: 'gitllab') }
let!(:another_group_nested) { create(:group, path: 'foo', parent: another_group) }
it 'returns the correct tree' do
expect(group.self_and_hierarchy).to contain_exactly(group, nested_group, deep_nested_group, very_deep_nested_group)
expect(nested_group.self_and_hierarchy).to contain_exactly(group, nested_group, deep_nested_group, very_deep_nested_group)
expect(very_deep_nested_group.self_and_hierarchy).to contain_exactly(group, nested_group, deep_nested_group, very_deep_nested_group)
end
end
describe '#ancestors', :nested_groups do describe '#ancestors', :nested_groups do
let(:group) { create(:group) } let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) } let(:nested_group) { create(:group, parent: group) }
......
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