Commit 85d3a8b5 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch '36758-graphql-query-one-or-all-lists-in-an-issue-board' into 'master'

GraphQL: Query one or all lists in an issue board

Closes #36758

See merge request gitlab-org/gitlab!24812
parents db42ce92 778e5ff3
# frozen_string_literal: true
module Resolvers
class BoardListsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
type Types::BoardListType, null: true
alias_method :board, :object
def resolve(lookahead: nil)
authorize!(board)
lists = board_lists
if load_preferences?(lookahead)
List.preload_preferences_for_user(lists, context[:current_user])
end
Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(lists)
end
private
def board_lists
service = Boards::Lists::ListService.new(board.resource_parent, context[:current_user])
service.execute(board, create_default_lists: false)
end
def authorized_resource?(board)
Ability.allowed?(context[:current_user], :read_list, board)
end
def load_preferences?(lookahead)
lookahead&.selection(:edges)&.selection(:node)&.selects?(:collapsed)
end
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class BoardListType < BaseObject
graphql_name 'BoardList'
description 'Represents a list for an issue board'
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID (global ID) of the list'
field :title, GraphQL::STRING_TYPE, null: false,
description: 'Title of the list'
field :list_type, GraphQL::STRING_TYPE, null: false,
description: 'Type of the list'
field :position, GraphQL::INT_TYPE, null: true,
description: 'Position of list within the board'
field :label, Types::LabelType, null: true,
description: 'Label of the list'
field :collapsed, GraphQL::BOOLEAN_TYPE, null: true,
description: 'Indicates if list is collapsed for this user',
resolve: -> (list, _args, ctx) { list.collapsed?(ctx[:current_user]) }
end
# rubocop: enable Graphql/AuthorizeTypes
end
Types::BoardListType.prepend_if_ee('::EE::Types::BoardListType')
...@@ -11,6 +11,13 @@ module Types ...@@ -11,6 +11,13 @@ module Types
description: 'ID (global ID) of the board' description: 'ID (global ID) of the board'
field :name, type: GraphQL::STRING_TYPE, null: true, field :name, type: GraphQL::STRING_TYPE, null: true,
description: 'Name of the board' description: 'Name of the board'
field :lists,
Types::BoardListType.connection_type,
null: true,
description: 'Lists of the project board',
resolver: Resolvers::BoardListsResolver,
extras: [:lookahead]
end end
end end
......
...@@ -10,6 +10,8 @@ module Types ...@@ -10,6 +10,8 @@ module Types
expose_permissions Types::PermissionTypes::User expose_permissions Types::PermissionTypes::User
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the user'
field :name, GraphQL::STRING_TYPE, null: false, field :name, GraphQL::STRING_TYPE, null: false,
description: 'Human-readable name of the user' description: 'Human-readable name of the user'
field :username, GraphQL::STRING_TYPE, null: false, field :username, GraphQL::STRING_TYPE, null: false,
......
...@@ -74,14 +74,18 @@ class List < ApplicationRecord ...@@ -74,14 +74,18 @@ class List < ApplicationRecord
label? ? label.name : list_type.humanize label? ? label.name : list_type.humanize
end end
def collapsed?(user)
preferences = preferences_for(user)
preferences.collapsed?
end
def as_json(options = {}) def as_json(options = {})
super(options).tap do |json| super(options).tap do |json|
json[:collapsed] = false json[:collapsed] = false
if options.key?(:collapsed) if options.key?(:collapsed)
preferences = preferences_for(options[:current_user]) json[:collapsed] = collapsed?(options[:current_user])
json[:collapsed] = preferences.collapsed?
end end
if options.key?(:label) if options.key?(:label)
......
...@@ -3,8 +3,10 @@ ...@@ -3,8 +3,10 @@
module Boards module Boards
module Lists module Lists
class ListService < Boards::BaseService class ListService < Boards::BaseService
def execute(board) def execute(board, create_default_lists: true)
board.lists.create(list_type: :backlog) unless board.lists.backlog.exists? if create_default_lists && !board.lists.backlog.exists?
board.lists.create(list_type: :backlog)
end
board.lists.preload_associated_models board.lists.preload_associated_models
end end
......
---
title: "Add GraphQL support for querying a board's lists"
merge_request: 24812
author:
type: added
...@@ -245,6 +245,31 @@ type Board { ...@@ -245,6 +245,31 @@ type Board {
""" """
id: ID! id: ID!
"""
Lists of the project board
"""
lists(
"""
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
): BoardListConnection
""" """
Name of the board Name of the board
""" """
...@@ -291,6 +316,96 @@ type BoardEdge { ...@@ -291,6 +316,96 @@ type BoardEdge {
node: Board node: Board
} }
"""
Represents a list for an issue board
"""
type BoardList {
"""
Assignee in the list
"""
assignee: User
"""
Indicates if list is collapsed for this user
"""
collapsed: Boolean
"""
ID (global ID) of the list
"""
id: ID!
"""
Label of the list
"""
label: Label
"""
Type of the list
"""
listType: String!
"""
Maximum number of issues in the list
"""
maxIssueCount: Int
"""
Maximum weight of issues in the list
"""
maxIssueWeight: Int
"""
Milestone of the list
"""
milestone: Milestone
"""
Position of list within the board
"""
position: Int
"""
Title of the list
"""
title: String!
}
"""
The connection type for BoardList.
"""
type BoardListConnection {
"""
A list of edges.
"""
edges: [BoardListEdge]
"""
A list of nodes.
"""
nodes: [BoardList]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type BoardListEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: BoardList
}
type Commit { type Commit {
""" """
Author of the commit Author of the commit
...@@ -9472,6 +9587,11 @@ type User { ...@@ -9472,6 +9587,11 @@ type User {
""" """
avatarUrl: String avatarUrl: String
"""
ID of the user
"""
id: ID!
""" """
Human-readable name of the user Human-readable name of the user
""" """
......
...@@ -79,6 +79,23 @@ Represents a project or group board ...@@ -79,6 +79,23 @@ Represents a project or group board
| `name` | String | Name of the board | | `name` | String | Name of the board |
| `weight` | Int | Weight of the board | | `weight` | Int | Weight of the board |
## BoardList
Represents a list for an issue board
| Name | Type | Description |
| --- | ---- | ---------- |
| `assignee` | User | Assignee in the list |
| `collapsed` | Boolean | Indicates if list is collapsed for this user |
| `id` | ID! | ID (global ID) of the list |
| `label` | Label | Label of the list |
| `listType` | String! | Type of the list |
| `maxIssueCount` | Int | Maximum number of issues in the list |
| `maxIssueWeight` | Int | Maximum weight of issues in the list |
| `milestone` | Milestone | Milestone of the list |
| `position` | Int | Position of list within the board |
| `title` | String! | Title of the list |
## Commit ## Commit
| Name | Type | Description | | Name | Type | Description |
...@@ -1492,6 +1509,7 @@ Autogenerated return type of UpdateSnippet ...@@ -1492,6 +1509,7 @@ Autogenerated return type of UpdateSnippet
| Name | Type | Description | | Name | Type | Description |
| --- | ---- | ---------- | | --- | ---- | ---------- |
| `avatarUrl` | String | URL of the user's avatar | | `avatarUrl` | String | URL of the user's avatar |
| `id` | ID! | ID of the user |
| `name` | String! | Human-readable name of the user | | `name` | String! | Human-readable name of the user |
| `userPermissions` | UserPermissions! | Permissions for the current user on the resource | | `userPermissions` | UserPermissions! | Permissions for the current user on the resource |
| `username` | String! | Username of the user. Unique within this instance of GitLab | | `username` | String! | Username of the user. Unique within this instance of GitLab |
......
# frozen_string_literal: true
module EE
module Types
module BoardListType
extend ActiveSupport::Concern
prepended do
field :milestone, ::Types::MilestoneType, null: true,
description: 'Milestone of the list'
field :max_issue_count, GraphQL::INT_TYPE, null: true,
description: 'Maximum number of issues in the list'
field :max_issue_weight, GraphQL::INT_TYPE, null: true,
description: 'Maximum weight of issues in the list'
field :assignee, ::Types::UserType, null: true,
description: 'Assignee in the list'
def milestone
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Milestone, object.milestone_id).find
end
def assignee
object.assignee? ? object.user : nil
end
end
end
end
end
...@@ -11,7 +11,7 @@ module EE ...@@ -11,7 +11,7 @@ module EE
LICENSED_LIST_TYPES = %i[assignee milestone].freeze LICENSED_LIST_TYPES = %i[assignee milestone].freeze
override :execute override :execute
def execute(board) def execute(board, create_default_lists: true)
not_available_lists = list_type_features_availability(board) not_available_lists = list_type_features_availability(board)
.select { |_, available| !available } .select { |_, available| !available }
......
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::BoardListsResolver do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, creator_id: user.id, namespace: user.namespace ) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:project_milestone) { create(:milestone, project: project) }
let_it_be(:group_milestone) { create(:milestone, group: group) }
before do
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true)
end
shared_examples_for 'group and project board lists resolver' do
let(:board) { create(:board, resource_parent: board_parent) }
let!(:user_list) { create(:user_list, board: board, user: user) }
let!(:milestone_list) { create(:milestone_list, board: board, milestone: milestone) }
before do
board_parent.add_developer(user)
end
it 'returns a list of board lists' do
lists = resolve_board_lists.items
expect(lists.count).to eq 3
expect(lists.map(&:list_type)).to eq %w(closed assignee milestone)
end
end
describe '#resolve' do
context 'when project boards' do
let(:board_parent) { project }
let(:milestone) { project_milestone }
it_behaves_like 'group and project board lists resolver'
end
context 'when group boards' do
let(:board_parent) { group }
let(:milestone) { group_milestone }
it_behaves_like 'group and project board lists resolver'
end
end
def resolve_board_lists(args: {}, current_user: user)
resolve(described_class, obj: board, args: args, ctx: { current_user: current_user })
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['BoardList'] do
it 'has specific fields' do
expected_fields = %w[milestone max_issue_count max_issue_weight assignee]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'get board lists' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, creator_id: user.id, namespace: user.namespace ) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:project_milestone) { create(:milestone, project: project) }
let_it_be(:project_milestone2) { create(:milestone, project: project) }
let_it_be(:group_milestone) { create(:milestone, group: group) }
let_it_be(:group_milestone2) { create(:milestone, group: group) }
let_it_be(:assignee) { create(:assignee) }
let_it_be(:assignee2) { create(:assignee) }
let(:params) { '' }
let(:board) { }
let(:board_parent_type) { board_parent.class.to_s.downcase }
let(:board_data) { graphql_data[board_parent_type]['boards']['edges'].first['node'] }
let(:lists_data) { board_data['lists']['edges'] }
let(:start_cursor) { board_data['lists']['pageInfo']['startCursor'] }
let(:end_cursor) { board_data['lists']['pageInfo']['endCursor'] }
before do
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true)
end
def query(list_params = params)
graphql_query_for(
board_parent_type,
{ 'fullPath' => board_parent.full_path },
<<~BOARDS
boards(first: 1) {
edges {
node {
#{field_with_params('lists', list_params)} {
pageInfo {
startCursor
endCursor
}
edges {
node {
#{all_graphql_fields_for('board_lists'.classify)}
}
}
}
}
}
}
BOARDS
)
end
shared_examples 'group and project board lists query' do
let!(:board) { create(:board, resource_parent: board_parent) }
context 'when user can read the board' do
before do
board_parent.add_reporter(user)
end
describe 'sorting and pagination' do
context 'when using default sorting' do
let!(:milestone_list) { create(:milestone_list, board: board, milestone: milestone, position: 10) }
let!(:milestone_list2) { create(:milestone_list, board: board, milestone: milestone2, position: 2) }
let!(:assignee_list) { create(:user_list, board: board, user: assignee, position: 5) }
let!(:assignee_list2) { create(:user_list, board: board, user: assignee2, position: 1) }
let(:closed_list) { board.lists.find_by(list_type: :closed) }
before do
post_graphql(query, current_user: user)
end
it_behaves_like 'a working graphql query'
context 'when ascending' do
let(:lists) { [closed_list, assignee_list2, assignee_list, milestone_list2, milestone_list] }
let(:expected_list_gids) do
lists.map { |list| list.to_global_id.to_s }
end
it 'sorts lists' do
expect(grab_ids).to eq expected_list_gids
end
context 'when paginating' do
let(:params) { 'first: 2' }
it 'sorts boards' do
expect(grab_ids).to eq expected_list_gids.first(2)
cursored_query = query("after: \"#{end_cursor}\"")
post_graphql(cursored_query, current_user: user)
response_data = grab_list_data(response.body)
expect(grab_ids(response_data)).to eq expected_list_gids.drop(2).first(3)
end
end
end
end
end
end
end
describe 'for a project' do
let(:board_parent) { project }
let(:milestone) { project_milestone }
let(:milestone2) { project_milestone2 }
it_behaves_like 'group and project board lists query'
end
describe 'for a group' do
let(:board_parent) { group }
let(:milestone) { group_milestone }
let(:milestone2) { group_milestone2 }
before do
allow(board_parent).to receive(:multiple_issue_boards_available?).and_return(false)
end
it_behaves_like 'group and project board lists query'
end
def grab_ids(data = lists_data)
data.map { |list| list.dig('node', 'id') }
end
def grab_list_data(response_body)
JSON.parse(response_body)['data'][board_parent_type]['boards']['edges'][0]['node']['lists']['edges']
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::BoardListsResolver do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:unauth_user) { create(:user) }
let_it_be(:project) { create(:project, creator_id: user.id, namespace: user.namespace ) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:project_label) { create(:label, project: project, name: 'Development') }
let_it_be(:group_label) { create(:group_label, group: group, name: 'Development') }
shared_examples_for 'group and project board lists resolver' do
let(:board) { create(:board, resource_parent: board_parent) }
before do
board_parent.add_developer(user)
end
it 'does not create the backlog list' do
lists = resolve_board_lists.items
expect(lists.count).to eq 1
expect(lists[0].list_type).to eq 'closed'
end
context 'with unauthorized user' do
it 'raises an error' do
expect do
resolve_board_lists(current_user: unauth_user)
end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
end
context 'when authorized' do
let!(:label_list) { create(:list, board: board, label: label) }
let!(:backlog_list) { create(:backlog_list, board: board) }
it 'returns a list of board lists' do
lists = resolve_board_lists.items
expect(lists.count).to eq 3
expect(lists.map(&:list_type)).to eq %w(backlog label closed)
end
context 'when another user has list preferences' do
before do
board.lists.first.update_preferences_for(guest, collapsed: true)
end
it 'returns the complete list of board lists for this user' do
lists = resolve_board_lists.items
expect(lists.count).to eq 3
end
end
end
end
describe '#resolve' do
context 'when project boards' do
let(:board_parent) { project }
let(:label) { project_label }
it_behaves_like 'group and project board lists resolver'
end
context 'when group boards' do
let(:board_parent) { group }
let(:label) { group_label }
it_behaves_like 'group and project board lists resolver'
end
end
def resolve_board_lists(args: {}, current_user: user)
resolve(described_class, obj: board, args: args, ctx: { current_user: current_user })
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['BoardList'] do
it { expect(described_class.graphql_name).to eq('BoardList') }
it 'has specific fields' do
expected_fields = %w[id list_type position label]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end
...@@ -9,7 +9,7 @@ describe GitlabSchema.types['User'] do ...@@ -9,7 +9,7 @@ describe GitlabSchema.types['User'] do
it 'has the expected fields' do it 'has the expected fields' do
expected_fields = %w[ expected_fields = %w[
user_permissions snippets name username avatarUrl webUrl todos id user_permissions snippets name username avatarUrl webUrl todos
] ]
expect(described_class).to have_graphql_fields(*expected_fields) expect(described_class).to have_graphql_fields(*expected_fields)
......
# frozen_string_literal: true
require 'spec_helper'
describe 'get board lists' do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:unauth_user) { create(:user) }
let_it_be(:project) { create(:project, creator_id: user.id, namespace: user.namespace ) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:project_label) { create(:label, project: project, name: 'Development') }
let_it_be(:project_label2) { create(:label, project: project, name: 'Testing') }
let_it_be(:group_label) { create(:group_label, group: group, name: 'Development') }
let_it_be(:group_label2) { create(:group_label, group: group, name: 'Testing') }
let(:params) { '' }
let(:board) { }
let(:board_parent_type) { board_parent.class.to_s.downcase }
let(:board_data) { graphql_data[board_parent_type]['boards']['edges'].first['node'] }
let(:lists_data) { board_data['lists']['edges'] }
let(:start_cursor) { board_data['lists']['pageInfo']['startCursor'] }
let(:end_cursor) { board_data['lists']['pageInfo']['endCursor'] }
def query(list_params = params)
graphql_query_for(
board_parent_type,
{ 'fullPath' => board_parent.full_path },
<<~BOARDS
boards(first: 1) {
edges {
node {
#{field_with_params('lists', list_params)} {
pageInfo {
startCursor
endCursor
}
edges {
node {
#{all_graphql_fields_for('board_lists'.classify)}
}
}
}
}
}
}
BOARDS
)
end
shared_examples 'group and project board lists query' do
let!(:board) { create(:board, resource_parent: board_parent) }
context 'when the user does not have access to the board' do
it 'returns nil' do
post_graphql(query, current_user: unauth_user)
expect(graphql_data[board_parent_type]).to be_nil
end
end
context 'when user can read the board' do
before do
board_parent.add_reporter(user)
end
describe 'sorting and pagination' do
context 'when using default sorting' do
let!(:label_list) { create(:list, board: board, label: label, position: 10) }
let!(:label_list2) { create(:list, board: board, label: label2, position: 2) }
let!(:backlog_list) { create(:backlog_list, board: board) }
let(:closed_list) { board.lists.find_by(list_type: :closed) }
before do
post_graphql(query, current_user: user)
end
it_behaves_like 'a working graphql query'
context 'when ascending' do
let(:lists) { [backlog_list, label_list2, label_list, closed_list] }
let(:expected_list_gids) do
lists.map { |list| list.to_global_id.to_s }
end
it 'sorts lists' do
expect(grab_ids).to eq expected_list_gids
end
context 'when paginating' do
let(:params) { 'first: 2' }
it 'sorts boards' do
expect(grab_ids).to eq expected_list_gids.first(2)
cursored_query = query("after: \"#{end_cursor}\"")
post_graphql(cursored_query, current_user: user)
response_data = grab_list_data(response.body)
expect(grab_ids(response_data)).to eq expected_list_gids.drop(2).first(2)
end
end
end
end
end
end
end
describe 'for a project' do
let(:board_parent) { project }
let(:label) { project_label }
let(:label2) { project_label2 }
it_behaves_like 'group and project board lists query'
end
describe 'for a group' do
let(:board_parent) { group }
let(:label) { group_label }
let(:label2) { group_label2 }
before do
allow(board_parent).to receive(:multiple_issue_boards_available?).and_return(false)
end
it_behaves_like 'group and project board lists query'
end
def grab_ids(data = lists_data)
data.map { |list| list.dig('node', 'id') }
end
def grab_list_data(response_body)
JSON.parse(response_body)['data'][board_parent_type]['boards']['edges'][0]['node']['lists']['edges']
end
end
...@@ -18,6 +18,10 @@ RSpec.shared_examples 'lists list service' do ...@@ -18,6 +18,10 @@ RSpec.shared_examples 'lists list service' do
expect { service.execute(board) }.to change(board.lists, :count).by(1) expect { service.execute(board) }.to change(board.lists, :count).by(1)
end end
it 'does not create a backlog list when create_default_lists is false' do
expect { service.execute(board, create_default_lists: false) }.not_to change(board.lists, :count)
end
it "returns board's lists" do it "returns board's lists" do
expect(service.execute(board)).to eq [board.backlog_list, list, board.closed_list] expect(service.execute(board)).to eq [board.backlog_list, list, board.closed_list]
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