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
end
else
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))
end
......@@ -123,7 +123,7 @@ class LabelsFinder < UnionFinder
end
def group?
params[:group_id].present?
params[:group].present? || params[:group_id].present?
end
def project?
......
......@@ -12,5 +12,9 @@ module Types
def id
GitlabSchema.id_from_object(object)
end
def current_user
context[:current_user]
end
end
end
......@@ -65,6 +65,45 @@ module Types
null: true,
description: 'A single board of the group',
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
......
......@@ -242,6 +242,45 @@ module Types
Types::ContainerExpirationPolicyType,
null: true,
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
......
---
title: Add GraphQL support for project and group labels
merge_request: 32113
author:
type: added
......@@ -4652,6 +4652,46 @@ type Group {
title: String
): 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
"""
......@@ -8363,6 +8403,46 @@ type Project {
"""
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
"""
......
......@@ -12820,6 +12820,96 @@
"isDeprecated": false,
"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",
"description": "Indicates if Large File Storage (LFS) is enabled for namespace",
......@@ -24632,6 +24722,96 @@
"isDeprecated": false,
"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",
"description": "Timestamp of the project last activity",
......@@ -706,6 +706,7 @@ Autogenerated return type of EpicTreeReorder
| `fullPath` | ID! | Full path of the namespace |
| `groupTimelogsEnabled` | Boolean | Indicates if Group timelogs are enabled for 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 |
| `mentionsDisabled` | Boolean | Indicates if a group is disabled from getting mentioned |
| `name` | String! | Name of the namespace |
......@@ -1219,6 +1220,7 @@ Information about pagination in a connection.
| `issuesEnabled` | Boolean | Indicates if Issues are enabled for the current user |
| `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 |
| `label` | Label | Labels available on this project |
| `lastActivityAt` | Time | Timestamp of the project last activity |
| `lfsEnabled` | Boolean | Indicates if the project has Large File Storage (LFS) enabled |
| `mergeRequest` | MergeRequest | A single merge request of the project |
......
......@@ -6,6 +6,18 @@ FactoryBot.define do
color { "#990000" }
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
project
......
......@@ -29,4 +29,6 @@ describe GitlabSchema.types['Group'] do
is_expected.to have_graphql_type(Types::BoardType.connection_type)
end
end
it_behaves_like 'a GraphQL type with labels'
end
......@@ -132,4 +132,6 @@ describe GitlabSchema.types['Project'] do
it { is_expected.to have_graphql_type(Types::ContainerExpirationPolicyType) }
end
it_behaves_like 'a GraphQL type with labels'
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
let(:user) { create(:user) }
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) }
route_types = [:deprecated, :rest]
......@@ -219,7 +219,7 @@ describe API::Labels do
'closed_issues_count' => 1,
'open_merge_requests_count' => 0,
'name' => label1.name,
'description' => nil,
'description' => 'the best label',
'color' => a_string_matching(/^#\h{6}$/),
'text_color' => a_string_matching(/^#\h{6}$/),
'priority' => nil,
......
......@@ -82,19 +82,30 @@ RSpec::Matchers.define :have_graphql_mutation do |mutation_class|
end
end
# note: connection arguments do not have to be named, they will be inferred.
RSpec::Matchers.define :have_graphql_arguments do |*expected|
include GraphqlHelpers
def expected_names
def expected_names(field)
@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
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
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
......
# 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