Commit b0f56e73 authored by Alex Kalderimis's avatar Alex Kalderimis

Add support for project and group label queries

This adds GraphQL query support for project and group labels, by title
and by search string.
parent 8fc733e6
...@@ -46,7 +46,7 @@ class LabelsFinder < UnionFinder ...@@ -46,7 +46,7 @@ class LabelsFinder < UnionFinder
end end
else else
if group? if group?
group = Group.find(params[:group_id]) group = params[:group] || Group.find(params[:group_id])
label_ids << Label.where(group_id: group_ids_for(group)) label_ids << Label.where(group_id: group_ids_for(group))
end end
...@@ -123,7 +123,7 @@ class LabelsFinder < UnionFinder ...@@ -123,7 +123,7 @@ class LabelsFinder < UnionFinder
end end
def group? def group?
params[:group_id].present? params[:group].present? || params[:group_id].present?
end end
def project? def project?
......
...@@ -12,5 +12,9 @@ module Types ...@@ -12,5 +12,9 @@ module Types
def id def id
GitlabSchema.id_from_object(object) GitlabSchema.id_from_object(object)
end end
def current_user
context[:current_user]
end
end end
end end
...@@ -65,6 +65,45 @@ module Types ...@@ -65,6 +65,45 @@ module Types
null: true, null: true,
description: 'A single board of the group', description: 'A single board of the group',
resolver: Resolvers::BoardsResolver.single resolver: Resolvers::BoardsResolver.single
field :label,
Types::LabelType,
null: true,
description: 'Labels available on this group' do
argument :title, GraphQL::STRING_TYPE,
required: true,
description: 'The title of the label'
end
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: group) do |titles, loader, args|
LabelsFinder
.new(current_user, group: args[:key], title: titles)
.execute
.each { |label| loader.call(label.title, label) }
end
end
field :labels,
Types::LabelType.connection_type,
null: true,
description: 'Labels available on this group' do
argument :search_term, GraphQL::STRING_TYPE,
required: false,
description: 'A search term to find labels with'
end
def labels(search_term: nil)
LabelsFinder
.new(current_user, group: group, search: search_term)
.execute
end
private
def group
object.respond_to?(:sync) ? object.sync : object
end
end end
end end
......
...@@ -242,6 +242,45 @@ module Types ...@@ -242,6 +242,45 @@ module Types
Types::ContainerExpirationPolicyType, Types::ContainerExpirationPolicyType,
null: true, null: true,
description: 'The container expiration policy of the project' description: 'The container expiration policy of the project'
field :label,
Types::LabelType,
null: true,
description: 'Labels available on this project' do
argument :title, GraphQL::STRING_TYPE,
required: true,
description: 'The title of the label'
end
def label(title:)
BatchLoader::GraphQL.for(title).batch(key: project) do |titles, loader, args|
LabelsFinder
.new(current_user, project: args[:key], title: titles)
.execute
.each { |label| loader.call(label.title, label) }
end
end
field :labels,
Types::LabelType.connection_type,
null: true,
description: 'Labels available on this project' do
argument :search_term, GraphQL::STRING_TYPE,
required: false,
description: 'A search term to find labels with'
end
def labels(search_term: nil)
LabelsFinder
.new(current_user, project: project, search: search_term)
.execute
end
private
def project
@project ||= object.respond_to?(:sync) ? object.sync : object
end
end end
end end
......
---
title: Add GraphQL support for project and group labels
merge_request: 32113
author:
type: added
...@@ -4652,6 +4652,46 @@ type Group { ...@@ -4652,6 +4652,46 @@ type Group {
title: String title: String
): IterationConnection ): IterationConnection
"""
Labels available on this group
"""
label(
"""
The title of the label
"""
title: String!
): Label
"""
Labels available on this group
"""
labels(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
A search term to find labels with
"""
searchTerm: String
): LabelConnection
""" """
Indicates if Large File Storage (LFS) is enabled for namespace Indicates if Large File Storage (LFS) is enabled for namespace
""" """
...@@ -8363,6 +8403,46 @@ type Project { ...@@ -8363,6 +8403,46 @@ type Project {
""" """
jobsEnabled: Boolean jobsEnabled: Boolean
"""
Labels available on this project
"""
label(
"""
The title of the label
"""
title: String!
): Label
"""
Labels available on this project
"""
labels(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
A search term to find labels with
"""
searchTerm: String
): LabelConnection
""" """
Timestamp of the project last activity Timestamp of the project last activity
""" """
......
...@@ -12820,6 +12820,96 @@ ...@@ -12820,6 +12820,96 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "label",
"description": "Labels available on this group",
"args": [
{
"name": "title",
"description": "The title of the label",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "Label",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "labels",
"description": "Labels available on this group",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "searchTerm",
"description": "A search term to find labels with",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "LabelConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "lfsEnabled", "name": "lfsEnabled",
"description": "Indicates if Large File Storage (LFS) is enabled for namespace", "description": "Indicates if Large File Storage (LFS) is enabled for namespace",
...@@ -24632,6 +24722,96 @@ ...@@ -24632,6 +24722,96 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "label",
"description": "Labels available on this project",
"args": [
{
"name": "title",
"description": "The title of the label",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "Label",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "labels",
"description": "Labels available on this project",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "searchTerm",
"description": "A search term to find labels with",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "LabelConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "lastActivityAt", "name": "lastActivityAt",
"description": "Timestamp of the project last activity", "description": "Timestamp of the project last activity",
...@@ -706,6 +706,7 @@ Autogenerated return type of EpicTreeReorder ...@@ -706,6 +706,7 @@ Autogenerated return type of EpicTreeReorder
| `fullPath` | ID! | Full path of the namespace | | `fullPath` | ID! | Full path of the namespace |
| `groupTimelogsEnabled` | Boolean | Indicates if Group timelogs are enabled for namespace | | `groupTimelogsEnabled` | Boolean | Indicates if Group timelogs are enabled for namespace |
| `id` | ID! | ID of the namespace | | `id` | ID! | ID of the namespace |
| `label` | Label | Labels available on this group |
| `lfsEnabled` | Boolean | Indicates if Large File Storage (LFS) is enabled for namespace | | `lfsEnabled` | Boolean | Indicates if Large File Storage (LFS) is enabled for namespace |
| `mentionsDisabled` | Boolean | Indicates if a group is disabled from getting mentioned | | `mentionsDisabled` | Boolean | Indicates if a group is disabled from getting mentioned |
| `name` | String! | Name of the namespace | | `name` | String! | Name of the namespace |
...@@ -1219,6 +1220,7 @@ Information about pagination in a connection. ...@@ -1219,6 +1220,7 @@ Information about pagination in a connection.
| `issuesEnabled` | Boolean | Indicates if Issues are enabled for the current user | | `issuesEnabled` | Boolean | Indicates if Issues are enabled for the current user |
| `jiraImportStatus` | String | Status of Jira import background job of the project | | `jiraImportStatus` | String | Status of Jira import background job of the project |
| `jobsEnabled` | Boolean | Indicates if CI/CD pipeline jobs are enabled for the current user | | `jobsEnabled` | Boolean | Indicates if CI/CD pipeline jobs are enabled for the current user |
| `label` | Label | Labels available on this project |
| `lastActivityAt` | Time | Timestamp of the project last activity | | `lastActivityAt` | Time | Timestamp of the project last activity |
| `lfsEnabled` | Boolean | Indicates if the project has Large File Storage (LFS) enabled | | `lfsEnabled` | Boolean | Indicates if the project has Large File Storage (LFS) enabled |
| `mergeRequest` | MergeRequest | A single merge request of the project | | `mergeRequest` | MergeRequest | A single merge request of the project |
......
...@@ -6,6 +6,18 @@ FactoryBot.define do ...@@ -6,6 +6,18 @@ FactoryBot.define do
color { "#990000" } color { "#990000" }
end end
trait :described do
description { "Description of #{title}" }
end
trait :scoped do
transient do
prefix { 'scope' }
end
title { "#{prefix}::#{generate(:label_title)}" }
end
factory :label, traits: [:base_label], class: 'ProjectLabel' do factory :label, traits: [:base_label], class: 'ProjectLabel' do
project project
......
...@@ -29,4 +29,6 @@ describe GitlabSchema.types['Group'] do ...@@ -29,4 +29,6 @@ describe GitlabSchema.types['Group'] do
is_expected.to have_graphql_type(Types::BoardType.connection_type) is_expected.to have_graphql_type(Types::BoardType.connection_type)
end end
end end
it_behaves_like 'a GraphQL type with labels'
end end
...@@ -132,4 +132,6 @@ describe GitlabSchema.types['Project'] do ...@@ -132,4 +132,6 @@ describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_type(Types::ContainerExpirationPolicyType) } it { is_expected.to have_graphql_type(Types::ContainerExpirationPolicyType) }
end end
it_behaves_like 'a GraphQL type with labels'
end end
# frozen_string_literal: true
require 'spec_helper'
describe 'getting group label information' do
include GraphqlHelpers
let_it_be(:group) { create(:group, :public) }
let_it_be(:label_factory) { :group_label }
let_it_be(:label_attrs) { { group: group } }
it_behaves_like 'querying a GraphQL type with labels' do
let(:path_prefix) { ['group'] }
def make_query(fields)
graphql_query_for('group', { full_path: group.full_path }, fields)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'getting project label information' do
include GraphqlHelpers
let_it_be(:project) { create(:project, :public) }
let_it_be(:label_factory) { :label }
let_it_be(:label_attrs) { { project: project } }
it_behaves_like 'querying a GraphQL type with labels' do
let(:path_prefix) { ['project'] }
def make_query(fields)
graphql_query_for('project', { full_path: project.full_path }, fields)
end
end
end
...@@ -17,7 +17,7 @@ describe API::Labels do ...@@ -17,7 +17,7 @@ describe API::Labels do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, creator_id: user.id, namespace: user.namespace) } let(:project) { create(:project, creator_id: user.id, namespace: user.namespace) }
let!(:label1) { create(:label, title: 'label1', project: project) } let!(:label1) { create(:label, description: 'the best label', title: 'label1', project: project) }
let!(:priority_label) { create(:label, title: 'bug', project: project, priority: 3) } let!(:priority_label) { create(:label, title: 'bug', project: project, priority: 3) }
route_types = [:deprecated, :rest] route_types = [:deprecated, :rest]
...@@ -219,7 +219,7 @@ describe API::Labels do ...@@ -219,7 +219,7 @@ describe API::Labels do
'closed_issues_count' => 1, 'closed_issues_count' => 1,
'open_merge_requests_count' => 0, 'open_merge_requests_count' => 0,
'name' => label1.name, 'name' => label1.name,
'description' => nil, 'description' => 'the best label',
'color' => a_string_matching(/^#\h{6}$/), 'color' => a_string_matching(/^#\h{6}$/),
'text_color' => a_string_matching(/^#\h{6}$/), 'text_color' => a_string_matching(/^#\h{6}$/),
'priority' => nil, 'priority' => nil,
......
...@@ -82,19 +82,30 @@ RSpec::Matchers.define :have_graphql_mutation do |mutation_class| ...@@ -82,19 +82,30 @@ RSpec::Matchers.define :have_graphql_mutation do |mutation_class|
end end
end end
# note: connection arguments do not have to be named, they will be inferred.
RSpec::Matchers.define :have_graphql_arguments do |*expected| RSpec::Matchers.define :have_graphql_arguments do |*expected|
include GraphqlHelpers include GraphqlHelpers
def expected_names def expected_names(field)
@names ||= Array.wrap(expected).map { |name| GraphqlHelpers.fieldnamerize(name) } @names ||= Array.wrap(expected).map { |name| GraphqlHelpers.fieldnamerize(name) }
if field.type.try(:ancestors)&.include?(GraphQL::Types::Relay::BaseConnection)
@names | %w(after before first last)
else
@names
end
end end
match do |field| match do |field|
expect(field.arguments.keys).to contain_exactly(*expected_names) names = expected_names(field)
expect(field.arguments.keys).to contain_exactly(*names)
end end
failure_message do |field| failure_message do |field|
"expected that #{field.name} would have the following fields: #{expected_names.inspect}, but it has #{field.arguments.keys.inspect}." names = expected_names(field)
"expected that #{field.name} would have the following fields: #{names.inspect}, but it has #{field.arguments.keys.inspect}."
end end
end end
......
# frozen_string_literal: true
RSpec.shared_examples 'a GraphQL type with labels' do
it 'has label fields' do
expected_fields = %w[label labels]
expect(described_class).to include_graphql_fields(*expected_fields)
end
describe 'label field' do
subject { described_class.fields['label'] }
it { is_expected.to have_graphql_type(Types::LabelType) }
it { is_expected.to have_graphql_arguments(:title) }
end
describe 'labels field' do
subject { described_class.fields['labels'] }
it { is_expected.to have_graphql_type(Types::LabelType.connection_type) }
it { is_expected.to have_graphql_arguments(:search_term) }
end
end
RSpec.shared_examples 'querying a GraphQL type with labels' do
let_it_be(:current_user) { create(:user) }
let_it_be(:label_a) { create(label_factory, :described, **label_attrs) }
let_it_be(:label_b) { create(label_factory, :described, **label_attrs) }
let_it_be(:label_c) { create(label_factory, :described, :scoped, prefix: 'matching', **label_attrs) }
let_it_be(:label_d) { create(label_factory, :described, :scoped, prefix: 'matching', **label_attrs) }
let(:label_title) { label_b.title }
let(:label_params) { { title: label_title } }
let(:labels_params) { nil }
let(:label_response) { graphql_data.dig(*path_prefix, 'label') }
let(:labels_response) { graphql_data.dig(*path_prefix, 'labels', 'nodes') }
let(:query) do
make_query(
[
query_graphql_field(:label, label_params, all_graphql_fields_for(Label)),
query_graphql_field(:labels, labels_params, [
query_graphql_field(:nodes, nil, all_graphql_fields_for(Label))
])
]
)
end
context 'running a query' do
before do
run_query(query)
end
context 'minimum required arguments' do
it 'returns the label information' do
expect(label_response).to include(
'title' => label_title,
'description' => label_b.description
)
end
it 'returns the labels information' do
expect(labels_response.pluck('title')).to contain_exactly(
label_a.title,
label_b.title,
label_c.title,
label_d.title
)
end
end
context 'with a search param' do
let(:labels_params) { { search_term: 'matching' } }
it 'finds the matching labels' do
expect(labels_response.pluck('title')).to contain_exactly(
label_c.title,
label_d.title
)
end
end
context 'the label does not exist' do
let(:label_title) { 'not-a-label' }
it 'returns nil' do
expect(label_response).to be_nil
end
end
end
describe 'performance' do
def query_for(*labels)
selections = labels.map do |label|
%Q[#{label.title.gsub(/:+/, '_')}: label(title: "#{label.title}") { description }]
end
make_query(selections)
end
before do
run_query(query_for(label_a))
end
it 'batches queries for labels by title' do
pending('Making type authorization fully lazy')
multi_selection = query_for(label_b, label_c)
single_selection = query_for(label_d)
expect { run_query(multi_selection) }
.to issue_same_number_of_queries_as { run_query(single_selection) }
end
end
# Run a known good query with the current user
def run_query(query)
post_graphql(query, current_user: current_user)
expect(graphql_errors).not_to be_present
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