Commit a3e82755 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'issue_40915' into 'master'

Allow assigning and filtering issuables by ancestor group labels

Closes #40915

See merge request gitlab-org/gitlab-ce!17873
parents aff9bf11 ad7148d9
......@@ -26,8 +26,8 @@ export default class FilteredSearchDropdownManager {
this.filteredSearchInput = this.container.querySelector('.filtered-search');
this.page = page;
this.groupsOnly = isGroup;
this.groupAncestor = isGroupAncestor;
this.isGroupDecendent = isGroupDecendent;
this.includeAncestorGroups = isGroupAncestor;
this.includeDescendantGroups = isGroupDecendent;
this.setupMapping();
......@@ -108,7 +108,19 @@ export default class FilteredSearchDropdownManager {
}
getLabelsEndpoint() {
const endpoint = `${this.baseEndpoint}/labels.json`;
let endpoint = `${this.baseEndpoint}/labels.json?`;
if (this.groupsOnly) {
endpoint = `${endpoint}only_group_labels=true&`;
}
if (this.includeAncestorGroups) {
endpoint = `${endpoint}include_ancestor_groups=true&`;
}
if (this.includeDescendantGroups) {
endpoint = `${endpoint}include_descendant_groups=true`;
}
return endpoint;
}
......
......@@ -21,7 +21,7 @@ export default class FilteredSearchManager {
constructor({
page,
isGroup = false,
isGroupAncestor = false,
isGroupAncestor = true,
isGroupDecendent = false,
filteredSearchTokenKeys = FilteredSearchTokenKeys,
stateFiltersSelector = '.issues-state-filters',
......@@ -86,6 +86,7 @@ export default class FilteredSearchManager {
page: this.page,
isGroup: this.isGroup,
isGroupAncestor: this.isGroupAncestor,
isGroupDecendent: this.isGroupDecendent,
filteredSearchTokenKeys: this.filteredSearchTokenKeys,
});
......
......@@ -5,6 +5,7 @@ import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: FILTERED_SEARCH.ISSUES,
isGroupDecendent: true,
});
projectSelect();
});
......@@ -5,6 +5,7 @@ import { FILTERED_SEARCH } from '~/pages/constants';
document.addEventListener('DOMContentLoaded', () => {
initFilteredSearch({
page: FILTERED_SEARCH.MERGE_REQUESTS,
isGroupDecendent: true,
});
projectSelect();
});
......@@ -150,7 +150,8 @@ class Projects::LabelsController < Projects::ApplicationController
end
def find_labels
@available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute
@available_labels ||=
LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: params[:include_ancestor_groups]).execute
end
def authorize_admin_labels!
......
......@@ -28,9 +28,10 @@ class LabelsFinder < UnionFinder
if project
if project.group.present?
labels_table = Label.arel_table
group_ids = group_ids_for(project.group)
label_ids << Label.where(
labels_table[:type].eq('GroupLabel').and(labels_table[:group_id].eq(project.group.id)).or(
labels_table[:type].eq('GroupLabel').and(labels_table[:group_id].in(group_ids)).or(
labels_table[:type].eq('ProjectLabel').and(labels_table[:project_id].eq(project.id))
)
)
......@@ -38,11 +39,14 @@ class LabelsFinder < UnionFinder
label_ids << project.labels
end
end
elsif only_group_labels?
label_ids << Label.where(group_id: group_ids)
else
if group?
group = Group.find(params[:group_id])
label_ids << Label.where(group_id: group_ids_for(group))
end
label_ids << Label.where(group_id: projects.group_ids)
label_ids << Label.where(project_id: projects.select(:id))
label_ids << Label.where(project_id: projects.select(:id)) unless only_group_labels?
end
label_ids
......@@ -59,22 +63,33 @@ class LabelsFinder < UnionFinder
items.where(title: title)
end
def group_ids
# Gets redacted array of group ids
# which can include the ancestors and descendants of the requested group.
def group_ids_for(group)
strong_memoize(:group_ids) do
groups_user_can_read_labels(groups_to_include).map(&:id)
groups = groups_to_include(group)
groups_user_can_read_labels(groups).map(&:id)
end
end
def groups_to_include
group = Group.find(params[:group_id])
def groups_to_include(group)
groups = [group]
groups += group.ancestors if params[:include_ancestor_groups].present?
groups += group.descendants if params[:include_descendant_groups].present?
groups += group.ancestors if include_ancestor_groups?
groups += group.descendants if include_descendant_groups?
groups
end
def include_ancestor_groups?
params[:include_ancestor_groups]
end
def include_descendant_groups?
params[:include_descendant_groups]
end
def group?
params[:group_id].present?
end
......
......@@ -53,10 +53,12 @@ module BoardsHelper
end
def board_list_data
include_descendant_groups = @group&.present?
{
toggle: "dropdown",
list_labels_path: labels_filter_path(true),
labels: labels_filter_path(true),
list_labels_path: labels_filter_path(true, include_ancestor_groups: true),
labels: labels_filter_path(true, include_descendant_groups: include_descendant_groups),
labels_endpoint: @labels_endpoint,
namespace_path: @namespace_path,
project_path: @project&.path,
......
......@@ -129,13 +129,17 @@ module LabelsHelper
end
end
def labels_filter_path(only_group_labels = false)
def labels_filter_path(only_group_labels = false, include_ancestor_groups: true, include_descendant_groups: false)
project = @target_project || @project
options = {}
options[:include_ancestor_groups] = include_ancestor_groups if include_ancestor_groups
options[:include_descendant_groups] = include_descendant_groups if include_descendant_groups
if project
project_labels_path(project, :json)
project_labels_path(project, :json, options)
elsif @group
options = { only_group_labels: only_group_labels } if only_group_labels
options[:only_group_labels] = only_group_labels if only_group_labels
group_labels_path(@group, :json, options)
else
dashboard_labels_path(:json)
......
......@@ -12,11 +12,15 @@ module Boards
private
def available_labels_for(board)
options = { include_ancestor_groups: true }
if board.group_board?
parent.labels
options.merge!(group_id: parent.id, only_group_labels: true)
else
LabelsFinder.new(current_user, project_id: parent.id).execute
options[:project_id] = parent.id
end
LabelsFinder.new(current_user, options).execute
end
def next_position(board)
......
......@@ -106,7 +106,7 @@ class IssuableBaseService < BaseService
end
def available_labels
@available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute
@available_labels ||= LabelsFinder.new(current_user, project_id: @project.id, include_ancestor_groups: true).execute
end
def handle_quick_actions_on_create(issuable)
......
......@@ -21,7 +21,8 @@ module Projects
end
def labels(target = nil)
labels = LabelsFinder.new(current_user, project_id: project.id).execute.select([:color, :title])
labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true)
.execute.select([:color, :title])
return labels unless target&.respond_to?(:labels)
......
......@@ -200,7 +200,7 @@ module QuickActions
end
params '~label1 ~"label 2"'
condition do
available_labels = LabelsFinder.new(current_user, project_id: project.id).execute
available_labels = LabelsFinder.new(current_user, project_id: project.id, include_ancestor_groups: true).execute
current_user.can?(:"admin_#{issuable.to_ability_name}", project) &&
available_labels.any?
......@@ -562,7 +562,7 @@ module QuickActions
def find_labels(labels_param)
extract_references(labels_param, :label) |
LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute
LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split, include_ancestor_groups: true).execute
end
def find_label_references(labels_param)
......@@ -593,6 +593,7 @@ module QuickActions
def extract_references(arg, type)
ext = Gitlab::ReferenceExtractor.new(project, current_user)
ext.analyze(arg, author: current_user)
ext.references(type)
......
......@@ -107,7 +107,7 @@
- selected_labels.each do |label|
= hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil
.dropdown
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (project_labels_path(@project, :json) if @project) } }
%button.dropdown-menu-toggle.js-label-select.js-multiselect.js-label-sidebar-dropdown{ type: "button", data: {toggle: "dropdown", default_label: "Labels", field_name: "#{issuable.to_ability_name}[label_names][]", ability_name: issuable.to_ability_name, show_no: "true", show_any: "true", namespace_path: @project.try(:namespace).try(:full_path), project_path: @project.try(:path), issue_update: issuable_json_path(issuable), labels: (labels_filter_path(false) if @project) } }
%span.dropdown-toggle-text{ class: ("is-default" if selected_labels.empty?) }
= multi_label_name(selected_labels, "Labels")
= icon('chevron-down', 'aria-hidden': 'true')
......
---
title: Allow assigning and filtering issuables by ancestor group labels
merge_request:
author:
type: added
......@@ -9,7 +9,7 @@ Labels allow you to categorize issues or merge requests using descriptive titles
In GitLab, you can create project and group labels:
- **Project labels** can be assigned to issues or merge requests in that project only.
- **Group labels** can be assigned to any issue or merge request of any project in that group.
- **Group labels** can be assigned to any issue or merge request of any project in that group or subgroup.
- In the [future](https://gitlab.com/gitlab-org/gitlab-ce/issues/40915), you will be able to assign group labels to issues and merge reqeusts of projects in [subgroups](../group/subgroups/index.md).
## Creating labels
......@@ -74,9 +74,9 @@ Every issue and merge request can be assigned any number of labels. The labels a
### Filtering in list pages
From the project issue list page and the project merge request list page, you can [filter](../search/index.md#issues-and-merge-requests) by both group labels and project labels.
From the project issue list page and the project merge request list page, you can [filter](../search/index.md#issues-and-merge-requests) by both group (including subgroup ancestors) labels and project labels.
From the group issue list page and the group merge request list page, you can [filter](../search/index.md#issues-and-merge-requests) by both group labels and project labels.
From the group issue list page and the group merge request list page, you can [filter](../search/index.md#issues-and-merge-requests) by both group labels (including subgroup ancestors and subgroup descendants) and project labels.
![Labels group issues](img/labels_group_issues.png)
......
......@@ -83,11 +83,12 @@ module API
end
def available_labels_for(label_parent)
search_params =
search_params = { include_ancestor_groups: true }
if label_parent.is_a?(Project)
{ project_id: label_parent.id }
search_params[:project_id] = label_parent.id
else
{ group_id: label_parent.id, only_group_labels: true }
search_params.merge!(group_id: label_parent.id, only_group_labels: true)
end
LabelsFinder.new(current_user, search_params).execute
......
......@@ -41,7 +41,7 @@ module Banzai
end
def find_labels(project)
LabelsFinder.new(nil, project_id: project.id).execute(skip_authorization: true)
LabelsFinder.new(nil, project_id: project.id, include_ancestor_groups: true).execute(skip_authorization: true)
end
# Parameters to pass to `Label.find_by` based on the given arguments
......
......@@ -22,15 +22,6 @@ describe 'Filter issues', :js do
end
end
def expect_issues_list_count(open_count, closed_count = 0)
all_count = open_count + closed_count
expect(page).to have_issuable_counts(open: open_count, closed: closed_count, all: all_count)
page.within '.issues-list' do
expect(page).to have_selector('.issue', count: open_count)
end
end
before do
project.add_master(user)
......
require 'spec_helper'
feature 'Labels Hierarchy', :js, :nested_groups do
include FilteredSearchHelpers
let!(:user) { create(:user) }
let!(:grandparent) { create(:group) }
let!(:parent) { create(:group, parent: grandparent) }
let!(:child) { create(:group, parent: parent) }
let!(:project_1) { create(:project, namespace: parent) }
let!(:grandparent_group_label) { create(:group_label, group: grandparent, title: 'Label_1') }
let!(:parent_group_label) { create(:group_label, group: parent, title: 'Label_2') }
let!(:child_group_label) { create(:group_label, group: child, title: 'Label_3') }
let!(:project_label_1) { create(:label, project: project_1, title: 'Label_4') }
before do
grandparent.add_owner(user)
sign_in(user)
end
shared_examples 'assigning labels from sidebar' do
it 'can assign all ancestors labels' do
[grandparent_group_label, parent_group_label, project_label_1].each do |label|
page.within('.block.labels') do
find('.edit-link').click
end
wait_for_requests
find('a.label-item', text: label.title).click
find('.dropdown-menu-close-icon').click
wait_for_requests
expect(page).to have_selector('span.label', text: label.title)
end
end
it 'does not find child group labels on dropdown' do
page.within('.block.labels') do
find('.edit-link').click
end
wait_for_requests
expect(page).not_to have_selector('span.label', text: child_group_label.title)
end
end
shared_examples 'filtering by ancestor labels for projects' do |board = false|
it 'filters by ancestor labels' do
[grandparent_group_label, parent_group_label, project_label_1].each do |label|
select_label_on_dropdown(label.title)
wait_for_requests
if board
expect(page).to have_selector('.card-title') do |card|
expect(card).to have_selector('a', text: labeled_issue.title)
end
else
expect_issues_list_count(1)
expect(page).to have_selector('span.issue-title-text', text: labeled_issue.title)
end
end
end
it 'does not filter by descendant group labels' do
filtered_search.set("label:")
wait_for_requests
expect(page).not_to have_selector('.btn-link', text: child_group_label.title)
end
end
shared_examples 'filtering by ancestor labels for groups' do |board = false|
let(:project_2) { create(:project, namespace: parent) }
let!(:project_label_2) { create(:label, project: project_2, title: 'Label_4') }
let(:project_3) { create(:project, namespace: child) }
let!(:group_label_3) { create(:group_label, group: child, title: 'Label_5') }
let!(:project_label_3) { create(:label, project: project_3, title: 'Label_6') }
let!(:labeled_issue_2) { create(:labeled_issue, project: project_2, labels: [grandparent_group_label, parent_group_label, project_label_2]) }
let!(:labeled_issue_3) { create(:labeled_issue, project: project_3, labels: [grandparent_group_label, parent_group_label, group_label_3]) }
let!(:issue_2) { create(:issue, project: project_2) }
it 'filters by ancestors and current group labels' do
[grandparent_group_label, parent_group_label].each do |label|
select_label_on_dropdown(label.title)
wait_for_requests
if board
expect(page).to have_selector('.card-title') do |card|
expect(card).to have_selector('a', text: labeled_issue.title)
end
expect(page).to have_selector('.card-title') do |card|
expect(card).to have_selector('a', text: labeled_issue_2.title)
end
else
expect_issues_list_count(3)
expect(page).to have_selector('span.issue-title-text', text: labeled_issue.title)
expect(page).to have_selector('span.issue-title-text', text: labeled_issue_2.title)
expect(page).to have_selector('span.issue-title-text', text: labeled_issue_3.title)
end
end
end
it 'filters by descendant group labels' do
wait_for_requests
if board
pending("Waiting for https://gitlab.com/gitlab-org/gitlab-ce/issues/44270")
select_label_on_dropdown(group_label_3.title)
expect(page).to have_selector('.card-title') do |card|
expect(card).to have_selector('a', text: labeled_issue_3.title)
end
else
select_label_on_dropdown(group_label_3.title)
expect_issues_list_count(1)
expect(page).to have_selector('span.issue-title-text', text: labeled_issue_3.title)
end
end
it 'does not filter by descendant group project labels' do
filtered_search.set("label:")
wait_for_requests
expect(page).not_to have_selector('.btn-link', text: project_label_3.title)
end
end
context 'when creating new issuable' do
before do
visit new_project_issue_path(project_1)
end
it 'should be able to assign ancestor group labels' do
fill_in 'issue_title', with: 'new created issue'
fill_in 'issue_description', with: 'new issue description'
find(".js-label-select").click
wait_for_requests
find('a.label-item', text: grandparent_group_label.title).click
find('a.label-item', text: parent_group_label.title).click
find('a.label-item', text: project_label_1.title).click
find('.btn-create').click
expect(page.find('.issue-details h2.title')).to have_content('new created issue')
expect(page).to have_selector('span.label', text: grandparent_group_label.title)
expect(page).to have_selector('span.label', text: parent_group_label.title)
expect(page).to have_selector('span.label', text: project_label_1.title)
end
end
context 'issuable sidebar' do
let!(:issue) { create(:issue, project: project_1) }
context 'on issue sidebar' do
before do
visit project_issue_path(project_1, issue)
end
it_behaves_like 'assigning labels from sidebar'
end
context 'on project board issue sidebar' do
let(:board) { create(:board, project: project_1) }
before do
visit project_board_path(project_1, board)
wait_for_requests
find('.card').click
end
it_behaves_like 'assigning labels from sidebar'
end
context 'on group board issue sidebar' do
let(:board) { create(:board, group: parent) }
before do
visit group_board_path(parent, board)
wait_for_requests
find('.card').click
end
it_behaves_like 'assigning labels from sidebar'
end
end
context 'issuable filtering' do
let!(:labeled_issue) { create(:labeled_issue, project: project_1, labels: [grandparent_group_label, parent_group_label, project_label_1]) }
let!(:issue) { create(:issue, project: project_1) }
context 'on project issuable list' do
before do
visit project_issues_path(project_1)
end
it_behaves_like 'filtering by ancestor labels for projects'
it 'does not filter by descendant group labels' do
filtered_search.set("label:")
wait_for_requests
expect(page).not_to have_selector('.btn-link', text: child_group_label.title)
end
end
context 'on group issuable list' do
before do
visit issues_group_path(parent)
end
it_behaves_like 'filtering by ancestor labels for groups'
end
context 'on project boards filter' do
let(:board) { create(:board, project: project_1) }
before do
visit project_board_path(project_1, board)
end
it_behaves_like 'filtering by ancestor labels for projects', true
end
context 'on group boards filter' do
let(:board) { create(:board, group: parent) }
before do
visit group_board_path(parent, board)
end
it_behaves_like 'filtering by ancestor labels for groups', true
end
end
context 'creating boards lists' do
context 'on project boards' do
let(:board) { create(:board, project: project_1) }
before do
visit project_board_path(project_1, board)
find('.js-new-board-list').click
wait_for_requests
end
it 'creates lists from all ancestor labels' do
[grandparent_group_label, parent_group_label, project_label_1].each do |label|
find('a', text: label.title).click
end
wait_for_requests
expect(page).to have_selector('.board-title-text', text: grandparent_group_label.title)
expect(page).to have_selector('.board-title-text', text: parent_group_label.title)
expect(page).to have_selector('.board-title-text', text: project_label_1.title)
end
end
context 'on group boards' do
let(:board) { create(:board, group: parent) }
before do
visit group_board_path(parent, board)
find('.js-new-board-list').click
wait_for_requests
end
it 'creates lists from all ancestor group labels' do
[grandparent_group_label, parent_group_label].each do |label|
find('a', text: label.title).click
end
wait_for_requests
expect(page).to have_selector('.board-title-text', text: grandparent_group_label.title)
expect(page).to have_selector('.board-title-text', text: parent_group_label.title)
end
it 'does not create lists from descendant groups' do
expect(page).not_to have_selector('a', text: child_group_label.title)
end
end
end
end
......@@ -71,6 +71,24 @@ describe LabelsFinder do
end
end
context 'when group has no projects' do
let(:empty_group) { create(:group) }
let!(:empty_group_label_1) { create(:group_label, group: empty_group, title: 'Label 1 (empty group)') }
let!(:empty_group_label_2) { create(:group_label, group: empty_group, title: 'Label 2 (empty group)') }
before do
empty_group.add_developer(user)
end
context 'when only group labels is false' do
it 'returns group labels' do
finder = described_class.new(user, group_id: empty_group.id)
expect(finder.execute).to eq [empty_group_label_1, empty_group_label_2]
end
end
end
context 'when including labels from group ancestors', :nested_groups do
it 'returns labels from group and its ancestors' do
private_group_1.add_developer(user)
......@@ -110,7 +128,21 @@ describe LabelsFinder do
end
end
context 'filtering by project_id' do
context 'filtering by project_id', :nested_groups do
context 'when include_ancestor_groups is true' do
let!(:sub_project) { create(:project, namespace: private_subgroup_1 ) }
let!(:project_label) { create(:label, project: sub_project, title: 'Label 5') }
let(:finder) { described_class.new(user, project_id: sub_project.id, include_ancestor_groups: true) }
before do
private_group_1.add_developer(user)
end
it 'returns all ancestor labels' do
expect(finder.execute).to match_array([private_subgroup_label_1, private_group_label_1, project_label])
end
end
it 'returns labels available for the project' do
finder = described_class.new(user, project_id: project_1.id)
......
......@@ -48,5 +48,36 @@ describe API::Boards do
expect(json_response['label']['name']).to eq(group_label.title)
expect(json_response['position']).to eq(3)
end
it 'creates a new board list for ancestor group labels' do
group = create(:group)
sub_group = create(:group, parent: group)
group_label = create(:group_label, group: group)
board_parent.update(group: sub_group)
group.add_developer(user)
sub_group.add_developer(user)
post api(url, user), label_id: group_label.id
expect(response).to have_gitlab_http_status(201)
expect(json_response['label']['name']).to eq(group_label.title)
end
end
describe "POST /groups/:id/boards/lists", :nested_groups do
set(:group) { create(:group) }
set(:board_parent) { create(:group, parent: group ) }
let(:url) { "/groups/#{board_parent.id}/boards/#{board.id}/lists" }
set(:board) { create(:board, group: board_parent) }
it 'creates a new board list for ancestor group labels' do
group.add_developer(user)
group_label = create(:group_label, group: group)
post api(url, user), label_id: group_label.id
expect(response).to have_gitlab_http_status(201)
expect(json_response['label']['name']).to eq(group_label.title)
end
end
end
......@@ -21,6 +21,29 @@ module FilteredSearchHelpers
end
end
# Select a label clicking in the search dropdown instead
# of entering label names on the input.
def select_label_on_dropdown(label_title)
input_filtered_search("label:", submit: false)
within('#js-dropdown-label') do
wait_for_requests
find('li', text: label_title).click
end
filtered_search.send_keys(:enter)
end
def expect_issues_list_count(open_count, closed_count = 0)
all_count = open_count + closed_count
expect(page).to have_issuable_counts(open: open_count, closed: closed_count, all: all_count)
page.within '.issues-list' do
expect(page).to have_selector('.issue', count: open_count)
end
end
# Enables input to be added character by character
def input_filtered_search_keys(search_term)
# Add an extra space to engage visual tokens
......
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