Commit 2c908cc1 authored by Bob Van Landuyt's avatar Bob Van Landuyt

Merge branch 'swimlanes_collapse_api' into 'master'

Expose board epic user preferences

See merge request gitlab-org/gitlab!42569
parents 4b9426ed 4beef405
......@@ -14,7 +14,7 @@ module Resolvers
def resolve(**args)
filter_params = issue_filters(args[:filters]).merge(board_id: list.board.id, id: list.id)
service = Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
Gitlab::Graphql::Pagination::OffsetActiveRecordRelationConnection.new(service.execute)
end
......
......@@ -27,7 +27,7 @@ module Resolvers
private
def board_lists(id)
service = Boards::Lists::ListService.new(
service = ::Boards::Lists::ListService.new(
board.resource_parent,
context[:current_user],
list_id: extract_list_id(id)
......
......@@ -16,7 +16,7 @@ module Resolvers
return Board.none unless parent
Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false)
::Boards::ListService.new(parent, context[:current_user], board_id: extract_board_id(id)).execute(create_default_board: false)
rescue ActiveRecord::RecordNotFound
Board.none
end
......
......@@ -1055,7 +1055,7 @@ type Board {
Returns the last _n_ elements from the list.
"""
last: Int
): EpicConnection
): BoardEpicConnection
"""
Whether or not backlog list is hidden.
......@@ -1153,6 +1153,478 @@ type BoardEdge {
node: Board
}
"""
Represents an epic on an issue board
"""
type BoardEpic implements CurrentUserTodos & Noteable {
"""
Author of the epic
"""
author: User!
"""
Children (sub-epics) of the epic
"""
children(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Filter epics by author
"""
authorUsername: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
List items within a time frame where items.end_date is between startDate and
endDate parameters (startDate parameter must be present)
"""
endDate: Time
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
IID of the epic, e.g., "1"
"""
iid: ID
"""
Filter epics by iid for autocomplete
"""
iidStartsWith: String
"""
List of IIDs of epics, e.g., [1, 2]
"""
iids: [ID!]
"""
Filter epics by labels
"""
labelName: [String!]
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
Filter epics by milestone title, computed from epic's issues
"""
milestoneTitle: String
"""
Search query for epic title or description
"""
search: String
"""
List epics by sort order
"""
sort: EpicSort
"""
List items within a time frame where items.start_date is between startDate
and endDate parameters (endDate parameter must be present)
"""
startDate: Time
"""
Filter epics by state
"""
state: EpicState
): EpicConnection
"""
Timestamp of when the epic was closed
"""
closedAt: Time
"""
Indicates if the epic is confidential
"""
confidential: Boolean
"""
Timestamp of when the epic was created
"""
createdAt: Time
"""
Todos for the current user
"""
currentUserTodos(
"""
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
"""
State of the todos
"""
state: TodoStateEnum
): TodoConnection!
"""
Number of open and closed descendant epics and issues
"""
descendantCounts: EpicDescendantCount
"""
Total weight of open and closed issues in the epic and its descendants
"""
descendantWeightSum: EpicDescendantWeights
"""
Description of the epic
"""
description: String
"""
All discussions on this noteable
"""
discussions(
"""
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
): DiscussionConnection!
"""
Number of downvotes the epic has received
"""
downvotes: Int!
"""
Due date of the epic
"""
dueDate: Time
"""
Fixed due date of the epic
"""
dueDateFixed: Time
"""
Inherited due date of the epic from milestones
"""
dueDateFromMilestones: Time
"""
Indicates if the due date has been manually set
"""
dueDateIsFixed: Boolean
"""
Group to which the epic belongs
"""
group: Group!
"""
Indicates if the epic has children
"""
hasChildren: Boolean!
"""
Indicates if the epic has direct issues
"""
hasIssues: Boolean!
"""
Indicates if the epic has a parent epic
"""
hasParent: Boolean!
"""
Current health status of the epic
"""
healthStatus: EpicHealthStatus
"""
ID of the epic
"""
id: ID!
"""
Internal ID of the epic
"""
iid: ID!
"""
A list of issues associated with the epic
"""
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
): EpicIssueConnection
"""
Labels assigned to the epic
"""
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
): LabelConnection
"""
All notes on this noteable
"""
notes(
"""
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
): NoteConnection!
"""
Parent epic of the epic
"""
parent: Epic
"""
List of participants for the epic
"""
participants(
"""
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
): UserConnection
"""
Internal reference of the epic. Returned in shortened format by default
"""
reference(
"""
Indicates if the reference should be returned in full
"""
full: Boolean = false
): String!
"""
URI path of the epic-issue relationship
"""
relationPath: String
"""
The relative position of the epic in the epic tree
"""
relativePosition: Int
"""
Start date of the epic
"""
startDate: Time
"""
Fixed start date of the epic
"""
startDateFixed: Time
"""
Inherited start date of the epic from milestones
"""
startDateFromMilestones: Time
"""
Indicates if the start date has been manually set
"""
startDateIsFixed: Boolean
"""
State of the epic
"""
state: EpicState!
"""
Indicates the currently logged in user is subscribed to the epic
"""
subscribed: Boolean!
"""
Title of the epic
"""
title: String
"""
Timestamp of when the epic was updated
"""
updatedAt: Time
"""
Number of upvotes the epic has received
"""
upvotes: Int!
"""
Permissions for the current user on the resource
"""
userPermissions: EpicPermissions!
"""
User preferences for the epic on the issue board
"""
userPreferences: BoardEpicUserPreferences
"""
Web path of the epic
"""
webPath: String!
"""
Web URL of the epic
"""
webUrl: String!
}
"""
The connection type for BoardEpic.
"""
type BoardEpicConnection {
"""
A list of edges.
"""
edges: [BoardEpicEdge]
"""
A list of nodes.
"""
nodes: [BoardEpic]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type BoardEpicEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: BoardEpic
}
"""
Represents user preferences for a board epic
"""
type BoardEpicUserPreferences {
"""
Indicates epic should be displayed as collapsed
"""
collapsed: Boolean!
}
"""
Identifier of Board
"""
......@@ -5277,7 +5749,7 @@ type Epic implements CurrentUserTodos & Noteable {
): EpicConnection
"""
Timestamp of the epic's closure
Timestamp of when the epic was closed
"""
closedAt: Time
......@@ -5287,7 +5759,7 @@ type Epic implements CurrentUserTodos & Noteable {
confidential: Boolean
"""
Timestamp of the epic's creation
Timestamp of when the epic was created
"""
createdAt: Time
......@@ -5582,7 +6054,7 @@ type Epic implements CurrentUserTodos & Noteable {
title: String
"""
Timestamp of the epic's last activity
Timestamp of when the epic was updated
"""
updatedAt: Time
......
......@@ -209,6 +209,57 @@ Represents a project or group board.
| `name` | String | Name of the board |
| `weight` | Int | Weight of the board. |
### BoardEpic
Represents an epic on an issue board.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `author` | User! | Author of the epic |
| `closedAt` | Time | Timestamp of when the epic was closed |
| `confidential` | Boolean | Indicates if the epic is confidential |
| `createdAt` | Time | Timestamp of when the epic was created |
| `descendantCounts` | EpicDescendantCount | Number of open and closed descendant epics and issues |
| `descendantWeightSum` | EpicDescendantWeights | Total weight of open and closed issues in the epic and its descendants |
| `description` | String | Description of the epic |
| `downvotes` | Int! | Number of downvotes the epic has received |
| `dueDate` | Time | Due date of the epic |
| `dueDateFixed` | Time | Fixed due date of the epic |
| `dueDateFromMilestones` | Time | Inherited due date of the epic from milestones |
| `dueDateIsFixed` | Boolean | Indicates if the due date has been manually set |
| `group` | Group! | Group to which the epic belongs |
| `hasChildren` | Boolean! | Indicates if the epic has children |
| `hasIssues` | Boolean! | Indicates if the epic has direct issues |
| `hasParent` | Boolean! | Indicates if the epic has a parent epic |
| `healthStatus` | EpicHealthStatus | Current health status of the epic |
| `id` | ID! | ID of the epic |
| `iid` | ID! | Internal ID of the epic |
| `parent` | Epic | Parent epic of the epic |
| `reference` | String! | Internal reference of the epic. Returned in shortened format by default |
| `relationPath` | String | URI path of the epic-issue relationship |
| `relativePosition` | Int | The relative position of the epic in the epic tree |
| `startDate` | Time | Start date of the epic |
| `startDateFixed` | Time | Fixed start date of the epic |
| `startDateFromMilestones` | Time | Inherited start date of the epic from milestones |
| `startDateIsFixed` | Boolean | Indicates if the start date has been manually set |
| `state` | EpicState! | State of the epic |
| `subscribed` | Boolean! | Indicates the currently logged in user is subscribed to the epic |
| `title` | String | Title of the epic |
| `updatedAt` | Time | Timestamp of when the epic was updated |
| `upvotes` | Int! | Number of upvotes the epic has received |
| `userPermissions` | EpicPermissions! | Permissions for the current user on the resource |
| `userPreferences` | BoardEpicUserPreferences | User preferences for the epic on the issue board |
| `webPath` | String! | Web path of the epic |
| `webUrl` | String! | Web URL of the epic |
### BoardEpicUserPreferences
Represents user preferences for a board epic.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `collapsed` | Boolean! | Indicates epic should be displayed as collapsed |
### BoardList
Represents a list for an issue board.
......@@ -889,9 +940,9 @@ Represents an epic.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `author` | User! | Author of the epic |
| `closedAt` | Time | Timestamp of the epic's closure |
| `closedAt` | Time | Timestamp of when the epic was closed |
| `confidential` | Boolean | Indicates if the epic is confidential |
| `createdAt` | Time | Timestamp of the epic's creation |
| `createdAt` | Time | Timestamp of when the epic was created |
| `descendantCounts` | EpicDescendantCount | Number of open and closed descendant epics and issues |
| `descendantWeightSum` | EpicDescendantWeights | Total weight of open and closed issues in the epic and its descendants |
| `description` | String | Description of the epic |
......@@ -918,7 +969,7 @@ Represents an epic.
| `state` | EpicState! | State of the epic |
| `subscribed` | Boolean! | Indicates the currently logged in user is subscribed to the epic |
| `title` | String | Title of the epic |
| `updatedAt` | Time | Timestamp of the epic's last activity |
| `updatedAt` | Time | Timestamp of when the epic was updated |
| `upvotes` | Int! | Number of upvotes the epic has received |
| `userPermissions` | EpicPermissions! | Permissions for the current user on the resource |
| `webPath` | String! | Web path of the epic |
......
fragment BoardEpicNode on BoardEpic {
id
iid
title
state
reference
webUrl
createdAt
closedAt
}
#import "ee_else_ce/boards/queries/board_list.fragment.graphql"
#import "~/graphql_shared/fragments/epic.fragment.graphql"
#import "./board_epic.fragment.graphql"
query BoardEE(
$fullPath: ID!
......@@ -18,7 +18,7 @@ query BoardEE(
}
epics(issueFilters: $issueFilters) {
nodes {
...EpicNode
...BoardEpicNode
}
}
}
......@@ -32,7 +32,7 @@ query BoardEE(
}
epics(issueFilters: $issueFilters) {
nodes {
...EpicNode
...BoardEpicNode
}
}
}
......
......@@ -21,7 +21,7 @@ module EE
field :weight, type: GraphQL::INT_TYPE, null: true,
description: 'Weight of the board.'
field :epics, ::Types::EpicType.connection_type, null: true,
field :epics, ::Types::Boards::BoardEpicType.connection_type, null: true,
description: 'Epics associated with board issues.',
resolver: ::Resolvers::BoardGroupings::EpicsResolver,
complexity: 5
......
......@@ -11,13 +11,15 @@ module Resolvers
required: false,
description: 'Filters applied when selecting issues on the board'
type Types::EpicType, null: true
type Types::Boards::BoardEpicType, null: true
def resolve(**args)
return Epic.none unless board.present?
return Epic.none unless group.present?
return unless ::Feature.enabled?(:boards_with_swimlanes, group)
context.scoped_set!(:board, board)
Epic.for_ids(board_epic_ids(args[:issue_filters]))
end
......
# frozen_string_literal: true
module Types
module Boards
# rubocop: disable Graphql/AuthorizeTypes
class BoardEpicType < EpicType
graphql_name 'BoardEpic'
description 'Represents an epic on an issue board'
field :user_preferences, Types::Boards::EpicUserPreferencesType, null: true,
description: 'User preferences for the epic on the issue board'
def user_preferences
return unless current_user
board = context[:board]
raise ::Gitlab::Graphql::Errors::BaseError 'Board is not set' unless board
BatchLoader::GraphQL.for(object.id).batch(key: board) do |epic_ids, loader, args|
current_user
.boards_epic_user_preferences
.for_boards_and_epics(args[:key].id, epic_ids)
.each { |user_pref| loader.call(user_pref.epic_id, user_pref) }
end
end
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
# frozen_string_literal: true
module Types
module Boards
# rubocop: disable Graphql/AuthorizeTypes
class EpicUserPreferencesType < BaseObject
graphql_name 'BoardEpicUserPreferences'
description 'Represents user preferences for a board epic'
field :collapsed, GraphQL::BOOLEAN_TYPE, null: false,
description: 'Indicates epic should be displayed as collapsed'
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
......@@ -69,11 +69,11 @@ module Types
description: 'Number of downvotes the epic has received'
field :closed_at, Types::TimeType, null: true,
description: "Timestamp of the epic's closure"
description: 'Timestamp of when the epic was closed'
field :created_at, Types::TimeType, null: true,
description: "Timestamp of the epic's creation"
description: 'Timestamp of when the epic was created'
field :updated_at, Types::TimeType, null: true,
description: "Timestamp of the epic's last activity"
description: 'Timestamp of when the epic was updated'
field :children, ::Types::EpicType.connection_type, null: true,
description: 'Children (sub-epics) of the epic',
......
......@@ -9,5 +9,9 @@ module Boards
belongs_to :epic, inverse_of: :boards_epic_user_preferences
validates :user, uniqueness: { scope: [:board_id, :epic_id] }
scope :for_boards_and_epics, -> (board_ids, epic_ids) do
where(board_id: board_ids, epic_id: epic_ids)
end
end
end
---
title: Expose board epic user preferences in GraphQL
merge_request: 42569
author:
type: added
......@@ -29,6 +29,14 @@ RSpec.describe Resolvers::BoardGroupings::EpicsResolver do
let_it_be(:epic_issue2) { create(:epic_issue, epic: epic2, issue: issue2) }
let_it_be(:epic_issue3) { create(:epic_issue, epic: epic3, issue: issue3) }
let_it_be(:context) do
GraphQL::Query::Context.new(
query: OpenStruct.new(schema: nil),
values: { current_user: current_user },
object: nil
)
end
describe '#resolve' do
before do
stub_licensed_features(epics: true)
......@@ -117,7 +125,7 @@ RSpec.describe Resolvers::BoardGroupings::EpicsResolver do
end
end
def resolve_board_epics(object, args = {}, context = { current_user: current_user })
def resolve_board_epics(object, args = {})
resolve(described_class, obj: object, args: args, ctx: context)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['BoardEpic'] do
it { expect(described_class.graphql_name).to eq('BoardEpic') }
it 'has specific fields' do
expect(described_class).to have_graphql_field(:user_preferences)
end
describe '#user_preferences' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, group: group) }
let_it_be(:issue) { create(:issue, project: project) }
let_it_be(:board) { create(:board, group: group) }
let_it_be(:epic) { create(:epic, group: group) }
let_it_be(:epic_issue) { create(:epic_issue, epic: epic, issue: issue) }
let(:query) do
%(
{
group(fullPath: "#{group.full_path}") {
board(id: "#{board.to_global_id}") {
epics {
nodes {
id
userPreferences {
collapsed
}
}
}
}
}
}
)
end
subject(:result) { GitlabSchema.execute(query, context: context).as_json }
let(:epics) { result['data']['group']['board']['epics']['nodes'] }
let(:epic_preferences) { epics.first['userPreferences'] }
before do
stub_licensed_features(epics: true)
group.add_developer(user)
end
context 'when user is not set' do
let(:context) { { board: board } }
it 'does not return any epics' do
expect(epics).to be_empty
end
end
context 'when user and board is set' do
let(:context) { { board: board, current_user: user } }
it 'returns nil if there are not preferences' do
expect(epics).not_to be_empty
expect(epic_preferences).to be_nil
end
context 'when user preferences are set' do
let_it_be(:epic_user_preference) { create(:epic_user_preference, board: board, epic: epic, user: user) }
it 'returns user preferences' do
expect(epics).not_to be_empty
expect(epic_preferences['collapsed']).to eq(false)
end
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['BoardEpicUserPreferences'] do
it { expect(described_class.graphql_name).to eq('BoardEpicUserPreferences') }
it 'has specific fields' do
expect(described_class).to have_graphql_field(:collapsed)
end
end
......@@ -14,4 +14,20 @@ RSpec.describe Boards::EpicUserPreference do
describe 'validations' do
it { is_expected.to validate_uniqueness_of(:user).scoped_to([:board_id, :epic_id]) }
end
describe 'scopes' do
describe '.for_boards_and_epics' do
it 'returns user board epic preferences for the given boards and epics' do
user = create(:user)
board = create(:board)
user_pref1 = create(:epic_user_preference, user: user, board: board)
user_pref2 = create(:epic_user_preference, user: user, board: board)
user_pref3 = create(:epic_user_preference, board: board, epic: user_pref1.epic)
create(:epic_user_preference, user: user, board: board)
result = described_class.for_boards_and_epics(board.id, [user_pref1.epic_id, user_pref2.epic_id])
expect(result).to match_array([user_pref1, user_pref2, user_pref3])
end
end
end
end
......@@ -25,6 +25,9 @@ RSpec.describe 'get list of boards' do
nodes {
id
title
userPreferences {
collapsed
}
}
}
EPIC
......@@ -42,24 +45,38 @@ RSpec.describe 'get list of boards' do
it 'returns open epics referenced by issues in the board' do
board = create(:board, resource_parent: board_parent)
issue_project = board_parent.is_a?(Project) ? board_parent : create(:project, group: board_parent)
# matches filters:
issue1 = create(:issue, project: issue_project, labels: [label])
# matches filters, but is assigned to the same epic as issue1:
issue2 = create(:issue, project: issue_project, labels: [label])
# doesn't match labelName filter:
issue3 = create(:issue, project: issue_project)
# matches filters, but its epic is closed:
issue4 = create(:issue, project: issue_project, labels: [label])
# doesn't match negated authorUsername filter:
issue5 = create(:issue, project: issue_project, labels: [label], author: current_user)
epic1 = create(:epic, group: parent_group)
epic2 = create(:epic, group: parent_group)
epic3 = create(:epic, :closed, group: parent_group)
create(:epic_issue, issue: issue1, epic: epic1)
create(:epic_issue, issue: issue2, epic: epic1)
create(:epic_issue, issue: issue3, epic: epic2)
create(:epic_issue, issue: issue4, epic: epic3)
create(:epic_issue, issue: issue5, epic: epic2)
create(:epic_user_preference, board: board, epic: epic1, user: current_user, collapsed: true)
post_graphql(board_epic_query(board), current_user: current_user)
board_titles = board_data['epics']['nodes'].map { |node| node['title'] }
expect(board_titles).to match_array [epic1.title]
aggregate_failures 'board epics response' do
epics = board_data['epics']['nodes']
expect(epics.size).to eq(1)
expect(epics.first['title']).to eq(epic1.title)
expect(epics.first['userPreferences']['collapsed']).to eq(true)
end
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