diff --git a/app/graphql/resolvers/board_list_issues_resolver.rb b/app/graphql/resolvers/board_list_issues_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..2c3308498f387c38ad282b3a398221e8ef215f61 --- /dev/null +++ b/app/graphql/resolvers/board_list_issues_resolver.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Resolvers + class BoardListIssuesResolver < BaseResolver + type Types::IssueType, null: true + + alias_method :list, :object + + def resolve(**args) + service = Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], { board_id: list.board.id, id: list.id }) + Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(service.execute) + end + end +end diff --git a/app/graphql/types/board_list_type.rb b/app/graphql/types/board_list_type.rb index e94ff89880767ae6e38e20e761827d799e11005b..781fd55c41e6f8e753511e0f3f171c30907a3a24 100644 --- a/app/graphql/types/board_list_type.rb +++ b/app/graphql/types/board_list_type.rb @@ -19,6 +19,10 @@ class BoardListType < BaseObject 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]) } + + field :issues, ::Types::IssueType.connection_type, null: true, + description: 'Board issues', + resolver: ::Resolvers::BoardListIssuesResolver end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/app/graphql/types/issuable_connection_type.rb b/app/graphql/types/issuable_connection_type.rb index a228b44f6242af335c0592c117d60c21ffe30811..ec180cc1576b98c960bd094f83a6f0ceca6c8cce 100644 --- a/app/graphql/types/issuable_connection_type.rb +++ b/app/graphql/types/issuable_connection_type.rb @@ -7,7 +7,17 @@ class IssuableConnectionType < GraphQL::Types::Relay::BaseConnection description: 'Total count of collection' def count - object.items.size + # rubocop: disable CodeReuse/ActiveRecord + relation = object.items + # sometimes relation is an Array + relation = relation.reorder(nil) if relation.respond_to?(:reorder) + # rubocop: enable CodeReuse/ActiveRecord + + if relation.try(:group_values)&.present? + relation.size.keys.size + else + relation.size + end end end end diff --git a/changelogs/unreleased/swimlanes-expose-issues.yml b/changelogs/unreleased/swimlanes-expose-issues.yml new file mode 100644 index 0000000000000000000000000000000000000000..b58c140f971610f87ea04bad1b3c55e824b2bbeb --- /dev/null +++ b/changelogs/unreleased/swimlanes-expose-issues.yml @@ -0,0 +1,5 @@ +--- +title: Expose board list issues via GraphQL +merge_request: 36259 +author: +type: added diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index 8f08d326356590c51a3594ef2beb84c810f8d526..e7abc021b234c72341057d827cdf346c34b13c92 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -1087,6 +1087,31 @@ type BoardList { """ id: ID! + """ + Board issues + """ + issues( + """ + 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 + ): IssueConnection + """ Label of the list """ diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index 405a06dbf6b4049959f1f45a56a5cb4ef38306a9..c40abb81937b97cd227a20bd664edbd1f6f3aa55 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -2927,6 +2927,59 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "issues", + "description": "Board issues", + "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 + } + ], + "type": { + "kind": "OBJECT", + "name": "IssueConnection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "label", "description": "Label of the list", diff --git a/spec/graphql/resolvers/board_list_issues_resolver_spec.rb b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e23a37b3d696bbdc3dfc4983ab5b15066b84af17 --- /dev/null +++ b/spec/graphql/resolvers/board_list_issues_resolver_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::BoardListIssuesResolver do + include GraphqlHelpers + + let_it_be(:user) { create(:user) } + let_it_be(:unauth_user) { create(:user) } + let_it_be(:user_project) { create(:project, creator_id: user.id, namespace: user.namespace ) } + let_it_be(:group) { create(:group, :private) } + + shared_examples_for 'group and project board list issues resolver' do + let!(:board) { create(:board, resource_parent: board_parent) } + + before do + board_parent.add_developer(user) + end + + # auth is handled by the parent object + context 'when authorized' do + let!(:list) { create(:list, board: board, label: label) } + + it 'returns the issues in the correct order' do + issue1 = create(:issue, project: project, labels: [label], relative_position: 10) + issue2 = create(:issue, project: project, labels: [label], relative_position: 12) + issue3 = create(:issue, project: project, labels: [label], relative_position: 10) + + # by relative_position and then ID + issues = resolve_board_list_issues.items + + expect(issues.map(&:id)).to eq [issue3.id, issue1.id, issue2.id] + end + end + end + + describe '#resolve' do + context 'when project boards' do + let(:board_parent) { user_project } + let!(:label) { create(:label, project: project, name: 'project label') } + let(:project) { user_project } + + it_behaves_like 'group and project board list issues resolver' + end + + context 'when group boards' do + let(:board_parent) { group } + let!(:label) { create(:group_label, group: group, name: 'group label') } + let!(:project) { create(:project, :private, group: group) } + + it_behaves_like 'group and project board list issues resolver' + end + end + + def resolve_board_list_issues(args: {}, current_user: user) + resolve(described_class, obj: list, args: args, ctx: { current_user: current_user }) + end +end diff --git a/spec/graphql/types/board_list_type_spec.rb b/spec/graphql/types/board_list_type_spec.rb index 046d1e92bfafb8ffaba019ac3ced81162693f700..66ea003d175926bc93fb6ff8bf39364e2a8c669a 100644 --- a/spec/graphql/types/board_list_type_spec.rb +++ b/spec/graphql/types/board_list_type_spec.rb @@ -6,7 +6,7 @@ specify { expect(described_class.graphql_name).to eq('BoardList') } it 'has specific fields' do - expected_fields = %w[id list_type position label] + expected_fields = %w[id list_type position label issues] expect(described_class).to include_graphql_fields(*expected_fields) end diff --git a/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb b/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ae1abb50a40e9e77b9a333d3b1d9c1ea0cc3b270 --- /dev/null +++ b/spec/requests/api/graphql/boards/board_list_issues_query_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.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']['nodes'][0] } + let(:lists_data) { board_data['lists']['nodes'][0] } + let(:issues_data) { lists_data['issues']['nodes'] } + + def query(list_params = params) + graphql_query_for( + board_parent_type, + { 'fullPath' => board_parent.full_path }, + <<~BOARDS + boards(first: 1) { + nodes { + lists { + nodes { + issues { + count + nodes { + #{all_graphql_fields_for('issues'.classify)} + } + } + } + } + } + } + BOARDS + ) + end + + def issue_titles + issues_data.map { |i| i['title'] } + end + + shared_examples 'group and project board list issues query' do + let!(:board) { create(:board, resource_parent: board_parent) } + let!(:label_list) { create(:list, board: board, label: label, position: 10) } + let!(:issue1) { create(:issue, project: issue_project, labels: [label], relative_position: 9) } + let!(:issue2) { create(:issue, project: issue_project, labels: [label], relative_position: 2) } + let!(:issue3) { create(:issue, project: issue_project, labels: [label], relative_position: 9) } + let!(:issue4) { create(:issue, project: issue_project, labels: [label2], relative_position: 432) } + + 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 + + it 'can access the issues' do + post_graphql(query("id: \"#{global_id_of(label_list)}\""), current_user: user) + + expect(issue_titles).to eq([issue2.title, issue3.title, issue1.title]) + end + end + end + + describe 'for a project' do + let(:board_parent) { project } + let(:label) { project_label } + let(:label2) { project_label2 } + let(:issue_project) { project } + + it_behaves_like 'group and project board list issues query' + end + + describe 'for a group' do + let(:board_parent) { group } + let(:label) { group_label } + let(:label2) { group_label2 } + let(:issue_project) { create(:project, :private, group: group) } + + before do + allow(board_parent).to receive(:multiple_issue_boards_available?).and_return(false) + end + + it_behaves_like 'group and project board list issues query' + end +end