Commit 61de4952 authored by Patrick Bair's avatar Patrick Bair

Merge branch 'epic_boards_graphql_lists' into 'master'

Add epic boards lists and expose it in GraphQL API

See merge request gitlab-org/gitlab!49728
parents 5d77c5f3 9ce799c7
---
title: Add epic board list table
merge_request: 49728
author:
type: added
# frozen_string_literal: true
class AddEpicBoardList < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
unless table_exists?(:boards_epic_lists)
with_lock_retries do
create_table :boards_epic_lists do |t|
t.timestamps_with_timezone
t.references :epic_board, index: true, foreign_key: { to_table: :boards_epic_boards, on_delete: :cascade }, null: false
t.references :label, index: true, foreign_key: { on_delete: :cascade }
t.integer :position
t.integer :list_type, default: 1, limit: 2, null: false
t.index [:epic_board_id, :label_id], unique: true, where: 'list_type = 1', name: 'index_boards_epic_lists_on_epic_board_id_and_label_id'
end
end
end
add_check_constraint :boards_epic_lists, '(list_type <> 1) OR ("position" IS NOT NULL AND "position" >= 0)', 'boards_epic_lists_position_constraint'
end
def down
with_lock_retries do
drop_table :boards_epic_lists
end
end
end
adce3c714064991e93f8a587da3d5892c47dbc14963fa49638ebbbf8b5489359
\ No newline at end of file
...@@ -9909,6 +9909,26 @@ CREATE SEQUENCE boards_epic_boards_id_seq ...@@ -9909,6 +9909,26 @@ CREATE SEQUENCE boards_epic_boards_id_seq
ALTER SEQUENCE boards_epic_boards_id_seq OWNED BY boards_epic_boards.id; ALTER SEQUENCE boards_epic_boards_id_seq OWNED BY boards_epic_boards.id;
CREATE TABLE boards_epic_lists (
id bigint NOT NULL,
created_at timestamp with time zone NOT NULL,
updated_at timestamp with time zone NOT NULL,
epic_board_id bigint NOT NULL,
label_id bigint,
"position" integer,
list_type smallint DEFAULT 1 NOT NULL,
CONSTRAINT boards_epic_lists_position_constraint CHECK (((list_type <> 1) OR (("position" IS NOT NULL) AND ("position" >= 0))))
);
CREATE SEQUENCE boards_epic_lists_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
ALTER SEQUENCE boards_epic_lists_id_seq OWNED BY boards_epic_lists.id;
CREATE TABLE boards_epic_user_preferences ( CREATE TABLE boards_epic_user_preferences (
id bigint NOT NULL, id bigint NOT NULL,
board_id bigint NOT NULL, board_id bigint NOT NULL,
...@@ -18130,6 +18150,8 @@ ALTER TABLE ONLY boards_epic_board_positions ALTER COLUMN id SET DEFAULT nextval ...@@ -18130,6 +18150,8 @@ ALTER TABLE ONLY boards_epic_board_positions ALTER COLUMN id SET DEFAULT nextval
ALTER TABLE ONLY boards_epic_boards ALTER COLUMN id SET DEFAULT nextval('boards_epic_boards_id_seq'::regclass); ALTER TABLE ONLY boards_epic_boards ALTER COLUMN id SET DEFAULT nextval('boards_epic_boards_id_seq'::regclass);
ALTER TABLE ONLY boards_epic_lists ALTER COLUMN id SET DEFAULT nextval('boards_epic_lists_id_seq'::regclass);
ALTER TABLE ONLY boards_epic_user_preferences ALTER COLUMN id SET DEFAULT nextval('boards_epic_user_preferences_id_seq'::regclass); ALTER TABLE ONLY boards_epic_user_preferences ALTER COLUMN id SET DEFAULT nextval('boards_epic_user_preferences_id_seq'::regclass);
ALTER TABLE ONLY broadcast_messages ALTER COLUMN id SET DEFAULT nextval('broadcast_messages_id_seq'::regclass); ALTER TABLE ONLY broadcast_messages ALTER COLUMN id SET DEFAULT nextval('broadcast_messages_id_seq'::regclass);
...@@ -19173,6 +19195,9 @@ ALTER TABLE ONLY boards_epic_board_positions ...@@ -19173,6 +19195,9 @@ ALTER TABLE ONLY boards_epic_board_positions
ALTER TABLE ONLY boards_epic_boards ALTER TABLE ONLY boards_epic_boards
ADD CONSTRAINT boards_epic_boards_pkey PRIMARY KEY (id); ADD CONSTRAINT boards_epic_boards_pkey PRIMARY KEY (id);
ALTER TABLE ONLY boards_epic_lists
ADD CONSTRAINT boards_epic_lists_pkey PRIMARY KEY (id);
ALTER TABLE ONLY boards_epic_user_preferences ALTER TABLE ONLY boards_epic_user_preferences
ADD CONSTRAINT boards_epic_user_preferences_pkey PRIMARY KEY (id); ADD CONSTRAINT boards_epic_user_preferences_pkey PRIMARY KEY (id);
...@@ -20824,6 +20849,12 @@ CREATE INDEX index_boards_epic_board_positions_on_epic_id ON boards_epic_board_p ...@@ -20824,6 +20849,12 @@ CREATE INDEX index_boards_epic_board_positions_on_epic_id ON boards_epic_board_p
CREATE INDEX index_boards_epic_boards_on_group_id ON boards_epic_boards USING btree (group_id); CREATE INDEX index_boards_epic_boards_on_group_id ON boards_epic_boards USING btree (group_id);
CREATE INDEX index_boards_epic_lists_on_epic_board_id ON boards_epic_lists USING btree (epic_board_id);
CREATE UNIQUE INDEX index_boards_epic_lists_on_epic_board_id_and_label_id ON boards_epic_lists USING btree (epic_board_id, label_id) WHERE (list_type = 1);
CREATE INDEX index_boards_epic_lists_on_label_id ON boards_epic_lists USING btree (label_id);
CREATE INDEX index_boards_epic_user_preferences_on_board_id ON boards_epic_user_preferences USING btree (board_id); CREATE INDEX index_boards_epic_user_preferences_on_board_id ON boards_epic_user_preferences USING btree (board_id);
CREATE UNIQUE INDEX index_boards_epic_user_preferences_on_board_user_epic_unique ON boards_epic_user_preferences USING btree (board_id, user_id, epic_id); CREATE UNIQUE INDEX index_boards_epic_user_preferences_on_board_user_epic_unique ON boards_epic_user_preferences USING btree (board_id, user_id, epic_id);
...@@ -24029,6 +24060,9 @@ ALTER TABLE ONLY user_synced_attributes_metadata ...@@ -24029,6 +24060,9 @@ ALTER TABLE ONLY user_synced_attributes_metadata
ALTER TABLE ONLY project_authorizations ALTER TABLE ONLY project_authorizations
ADD CONSTRAINT fk_rails_0f84bb11f3 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; ADD CONSTRAINT fk_rails_0f84bb11f3 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY boards_epic_lists
ADD CONSTRAINT fk_rails_0f9c7f646f FOREIGN KEY (epic_board_id) REFERENCES boards_epic_boards(id) ON DELETE CASCADE;
ALTER TABLE ONLY issue_email_participants ALTER TABLE ONLY issue_email_participants
ADD CONSTRAINT fk_rails_0fdfd8b811 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE; ADD CONSTRAINT fk_rails_0fdfd8b811 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE;
...@@ -24134,6 +24168,9 @@ ALTER TABLE ONLY boards_epic_board_positions ...@@ -24134,6 +24168,9 @@ ALTER TABLE ONLY boards_epic_board_positions
ALTER TABLE ONLY geo_repository_created_events ALTER TABLE ONLY geo_repository_created_events
ADD CONSTRAINT fk_rails_1f49e46a61 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; ADD CONSTRAINT fk_rails_1f49e46a61 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
ALTER TABLE ONLY boards_epic_lists
ADD CONSTRAINT fk_rails_1fe6b54909 FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE;
ALTER TABLE ONLY approval_merge_request_rules_groups ALTER TABLE ONLY approval_merge_request_rules_groups
ADD CONSTRAINT fk_rails_2020a7124a FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; ADD CONSTRAINT fk_rails_2020a7124a FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE;
......
...@@ -2225,6 +2225,11 @@ Identifier of Boards::EpicBoard ...@@ -2225,6 +2225,11 @@ Identifier of Boards::EpicBoard
""" """
scalar BoardsEpicBoardID scalar BoardsEpicBoardID
"""
Identifier of Boards::EpicList
"""
scalar BoardsEpicListID
type Branch { type Branch {
""" """
Commit for the branch Commit for the branch
...@@ -8093,12 +8098,37 @@ Represents an epic board ...@@ -8093,12 +8098,37 @@ Represents an epic board
""" """
type EpicBoard { type EpicBoard {
""" """
Global ID of the board Global ID of the board.
""" """
id: BoardsEpicBoardID! id: BoardsEpicBoardID!
""" """
Name of the board Epic board lists.
"""
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
): EpicListConnection
"""
Name of the board.
""" """
name: String name: String
} }
...@@ -8693,6 +8723,71 @@ type EpicIssueEdge { ...@@ -8693,6 +8723,71 @@ type EpicIssueEdge {
node: EpicIssue node: EpicIssue
} }
"""
Represents an epic board list
"""
type EpicList {
"""
Global ID of the board list.
"""
id: BoardsEpicListID!
"""
Label of the list.
"""
label: Label
"""
Type of the list.
"""
listType: String!
"""
Position of the list within the board.
"""
position: Int
"""
Title of the list.
"""
title: String!
}
"""
The connection type for EpicList.
"""
type EpicListConnection {
"""
A list of edges.
"""
edges: [EpicListEdge]
"""
A list of nodes.
"""
nodes: [EpicList]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type EpicListEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: EpicList
}
""" """
Check permissions for the current user on an epic Check permissions for the current user on an epic
""" """
......
...@@ -5899,6 +5899,16 @@ ...@@ -5899,6 +5899,16 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "SCALAR",
"name": "BoardsEpicListID",
"description": "Identifier of Boards::EpicList",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "SCALAR", "kind": "SCALAR",
"name": "Boolean", "name": "Boolean",
...@@ -22633,7 +22643,7 @@ ...@@ -22633,7 +22643,7 @@
"fields": [ "fields": [
{ {
"name": "id", "name": "id",
"description": "Global ID of the board", "description": "Global ID of the board.",
"args": [ "args": [
], ],
...@@ -22649,9 +22659,62 @@ ...@@ -22649,9 +22659,62 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "lists",
"description": "Epic board lists.",
"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": "EpicListConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "name", "name": "name",
"description": "Name of the board", "description": "Name of the board.",
"args": [ "args": [
], ],
...@@ -24375,6 +24438,213 @@ ...@@ -24375,6 +24438,213 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "EpicList",
"description": "Represents an epic board list",
"fields": [
{
"name": "id",
"description": "Global ID of the board list.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "BoardsEpicListID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "label",
"description": "Label of the list.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Label",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "listType",
"description": "Type of the list.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "position",
"description": "Position of the list within the board.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "title",
"description": "Title of the list.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EpicListConnection",
"description": "The connection type for EpicList.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "EpicListEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "EpicList",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EpicListEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "EpicList",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{ {
"kind": "OBJECT", "kind": "OBJECT",
"name": "EpicPermissions", "name": "EpicPermissions",
...@@ -1377,8 +1377,9 @@ Represents an epic board. ...@@ -1377,8 +1377,9 @@ Represents an epic board.
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `id` | BoardsEpicBoardID! | Global ID of the board | | `id` | BoardsEpicBoardID! | Global ID of the board. |
| `name` | String | Name of the board | | `lists` | EpicListConnection | Epic board lists. |
| `name` | String | Name of the board. |
### EpicDescendantCount ### EpicDescendantCount
...@@ -1472,6 +1473,18 @@ Relationship between an epic and an issue. ...@@ -1472,6 +1473,18 @@ Relationship between an epic and an issue.
| `webUrl` | String! | Web URL of the issue | | `webUrl` | String! | Web URL of the issue |
| `weight` | Int | Weight of the issue. | | `weight` | Int | Weight of the issue. |
### EpicList
Represents an epic board list.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `id` | BoardsEpicListID! | Global ID of the board list. |
| `label` | Label | Label of the list. |
| `listType` | String! | Type of the list. |
| `position` | Int | Position of the list within the board. |
| `title` | String! | Title of the list. |
### EpicPermissions ### EpicPermissions
Check permissions for the current user on an epic. Check permissions for the current user on an epic.
......
# frozen_string_literal: true
module Resolvers
module Boards
class EpicListsResolver < BaseResolver
include Gitlab::Graphql::Authorize::AuthorizeResource
include LooksAhead
type Types::Boards::EpicListType.connection_type, null: true
when_single do
argument :id, ::Types::GlobalIDType[::Boards::EpicList],
required: true,
description: 'Find an epic board list by ID.'
end
alias_method :epic_board, :object
def resolve_with_lookahead(id: nil)
authorize!
# eventually we may want to (re)use Boards::Lists::ListService
# but we don't support yet creation of default lists so at this
# point there is not reason to introduce a ListService
# https://gitlab.com/gitlab-org/gitlab/-/issues/294043
lists = epic_board.epic_lists
lists = lists.where(id: id.model_id) if id # rubocop: disable CodeReuse/ActiveRecord
offset_pagination(apply_lookahead(lists))
end
private
def authorize!
Ability.allowed?(context[:current_user], :read_epic_list, epic_board.group) || raise_resource_not_available_error!
end
def preloads
{
label: [:label]
}
end
end
end
end
...@@ -10,10 +10,17 @@ module Types ...@@ -10,10 +10,17 @@ module Types
authorize :read_epic_board authorize :read_epic_board
field :id, type: ::Types::GlobalIDType[::Boards::EpicBoard], null: false, field :id, type: ::Types::GlobalIDType[::Boards::EpicBoard], null: false,
description: 'Global ID of the board' description: '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::Boards::EpicListType.connection_type,
null: true,
description: 'Epic board lists.',
extras: [:lookahead],
resolver: Resolvers::Boards::EpicListsResolver
end end
end end
end end
# frozen_string_literal: true
module Types
module Boards
# rubocop: disable Graphql/AuthorizeTypes
class EpicListType < BaseObject
graphql_name 'EpicList'
description 'Represents an epic board list'
accepts ::Boards::EpicList
field :id, type: ::Types::GlobalIDType[::Boards::EpicList], null: false,
description: 'Global ID of the board 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 the list within the board.'
field :label, Types::LabelType, null: true,
description: 'Label of the list.'
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
...@@ -5,6 +5,7 @@ module Boards ...@@ -5,6 +5,7 @@ module Boards
belongs_to :group, optional: false, inverse_of: :epic_boards belongs_to :group, optional: false, inverse_of: :epic_boards
has_many :epic_board_labels, foreign_key: :epic_board_id, inverse_of: :epic_board has_many :epic_board_labels, foreign_key: :epic_board_id, inverse_of: :epic_board
has_many :epic_board_positions, foreign_key: :epic_board_id, inverse_of: :epic_board has_many :epic_board_positions, foreign_key: :epic_board_id, inverse_of: :epic_board
has_many :epic_lists, -> { ordered }, foreign_key: :epic_board_id, inverse_of: :epic_board
validates :name, length: { maximum: 255 } validates :name, length: { maximum: 255 }
......
# frozen_string_literal: true
module Boards
class EpicList < ApplicationRecord
belongs_to :epic_board, optional: false, inverse_of: :epic_lists
belongs_to :label, inverse_of: :epic_lists
enum list_type: { backlog: 0, label: 1, closed: 2 }
validates :label, :position, presence: true, if: :label?
validates :label_id, uniqueness: { scope: :epic_board_id }, if: :label?
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label?
scope :ordered, -> { order(:list_type, :position) }
def title
label? ? label.name : list_type.humanize
end
end
end
...@@ -9,6 +9,7 @@ module EE ...@@ -9,6 +9,7 @@ module EE
prepended do prepended do
has_many :epic_board_labels, class_name: 'Boards::EpicBoardLabel', inverse_of: :label has_many :epic_board_labels, class_name: 'Boards::EpicBoardLabel', inverse_of: :label
has_many :epic_lists, class_name: 'Boards::EpicList', inverse_of: :label
end end
def scoped_label? def scoped_label?
......
...@@ -176,6 +176,7 @@ module EE ...@@ -176,6 +176,7 @@ module EE
rule { can?(:read_group) & epics_available }.policy do rule { can?(:read_group) & epics_available }.policy do
enable :read_epic enable :read_epic
enable :read_epic_board enable :read_epic_board
enable :read_epic_list
end end
rule { can?(:read_group) & iterations_available }.enable :read_iteration rule { can?(:read_group) & iterations_available }.enable :read_iteration
......
# frozen_string_literal: true
FactoryBot.define do
factory :epic_list, class: 'Boards::EpicList' do
epic_board
label
list_type { :label }
sequence(:position)
end
end
...@@ -33,7 +33,7 @@ RSpec.describe Resolvers::Boards::EpicBoardsResolver do ...@@ -33,7 +33,7 @@ RSpec.describe Resolvers::Boards::EpicBoardsResolver do
end end
it 'raises an error if user cannot read epic boards' do it 'raises an error if user cannot read epic boards' do
expect { result}.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) expect { result }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end end
context 'when user is member of the group' do context 'when user is member of the group' do
...@@ -42,7 +42,7 @@ RSpec.describe Resolvers::Boards::EpicBoardsResolver do ...@@ -42,7 +42,7 @@ RSpec.describe Resolvers::Boards::EpicBoardsResolver do
end end
it 'returns epic boards in the group ordered by name' do it 'returns epic boards in the group ordered by name' do
expect(result).to match_array([epic_board2, epic_board1]) expect(result).to eq([epic_board2, epic_board1])
end end
context 'when epic_boards flag is disabled' do context 'when epic_boards flag is disabled' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Boards::EpicListsResolver do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be_with_refind(:group) { create(:group, :private) }
let_it_be(:epic_board) { create(:epic_board, group: group) }
let_it_be(:epic_list1) { create(:epic_list, epic_board: epic_board) }
let_it_be(:epic_list2) { create(:epic_list, epic_board: epic_board) }
specify do
expect(described_class).to have_nullable_graphql_type(Types::Boards::EpicListType.connection_type)
end
describe '#resolve' do
let(:args) { {} }
subject(:result) { resolve(described_class, ctx: { current_user: user }, obj: epic_board, args: args) }
before do
stub_licensed_features(epics: true)
end
it 'raises an error if user cannot read epic lists' do
expect { result }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable)
end
context 'when user is member of the group' do
before do
group.add_reporter(user)
end
it 'returns epic lists for the board' do
expect(result.items).to match_array([epic_list1, epic_list2])
end
context 'when list ID param is set' do
let(:args) { { id: epic_list1.to_global_id } }
it 'returns an array with single epic list' do
expect(result.items).to match_array([epic_list1])
end
end
end
end
end
...@@ -8,7 +8,7 @@ RSpec.describe GitlabSchema.types['EpicBoard'] do ...@@ -8,7 +8,7 @@ RSpec.describe GitlabSchema.types['EpicBoard'] do
specify { expect(described_class).to require_graphql_authorizations(:read_epic_board) } specify { expect(described_class).to require_graphql_authorizations(:read_epic_board) }
it 'has specific fields' do it 'has specific fields' do
expected_fields = %w[id name] expected_fields = %w[id name lists]
expect(described_class).to include_graphql_fields(*expected_fields) expect(described_class).to include_graphql_fields(*expected_fields)
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['EpicList'] do
specify { expect(described_class.graphql_name).to eq('EpicList') }
it 'has specific fields' do
expected_fields = %w[id title list_type position label]
expect(described_class).to include_graphql_fields(*expected_fields)
end
end
...@@ -7,6 +7,7 @@ RSpec.describe Boards::EpicBoard do ...@@ -7,6 +7,7 @@ RSpec.describe Boards::EpicBoard do
it { is_expected.to belong_to(:group).required.inverse_of(:epic_boards) } it { is_expected.to belong_to(:group).required.inverse_of(:epic_boards) }
it { is_expected.to have_many(:epic_board_labels).inverse_of(:epic_board) } it { is_expected.to have_many(:epic_board_labels).inverse_of(:epic_board) }
it { is_expected.to have_many(:epic_board_positions).inverse_of(:epic_board) } it { is_expected.to have_many(:epic_board_positions).inverse_of(:epic_board) }
it { is_expected.to have_many(:epic_lists).order(list_type: :asc, position: :asc).inverse_of(:epic_board) }
end end
describe 'validations' do describe 'validations' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Boards::EpicList do
describe 'associations' do
subject { build(:epic_list) }
it { is_expected.to belong_to(:epic_board).required.inverse_of(:epic_lists) }
it { is_expected.to belong_to(:label).inverse_of(:epic_lists) }
it { is_expected.to validate_presence_of(:position) }
it { is_expected.to validate_numericality_of(:position).only_integer.is_greater_than_or_equal_to(0) }
it { is_expected.to validate_uniqueness_of(:label_id).scoped_to(:epic_board_id) }
context 'when list_type is set to closed' do
subject { build(:epic_list, list_type: :closed) }
it { is_expected.not_to validate_presence_of(:label) }
it { is_expected.not_to validate_presence_of(:position) }
end
end
describe 'scopes' do
describe '.ordered' do
it 'returns lists ordered by type and position' do
list1 = create(:epic_list, list_type: :backlog)
list2 = create(:epic_list, list_type: :closed)
list3 = create(:epic_list, position: 1)
list4 = create(:epic_list, position: 2)
expect(described_class.ordered).to eq([list1, list3, list4, list2])
end
end
end
describe '#title' do
it 'returns label name for label lists' do
list = build(:epic_list)
expect(list.title).to eq(list.label.name)
end
it 'returns list type for non-label lists' do
expect(build(:epic_list, list_type: ::Boards::EpicList.list_types[:backlog]).title).to eq('Backlog')
end
end
end
...@@ -5,6 +5,7 @@ require 'spec_helper' ...@@ -5,6 +5,7 @@ require 'spec_helper'
RSpec.describe Label do RSpec.describe Label do
describe 'associations' do describe 'associations' do
it { is_expected.to have_many(:epic_board_labels).inverse_of(:label) } it { is_expected.to have_many(:epic_board_labels).inverse_of(:label) }
it { is_expected.to have_many(:epic_lists).inverse_of(:label) }
end end
describe '#scoped_label?' do describe '#scoped_label?' do
......
...@@ -5,7 +5,10 @@ require 'spec_helper' ...@@ -5,7 +5,10 @@ require 'spec_helper'
RSpec.describe GroupPolicy do RSpec.describe GroupPolicy do
include_context 'GroupPolicy context' include_context 'GroupPolicy context'
let(:epic_rules) { %i(read_epic create_epic admin_epic destroy_epic read_confidential_epic destroy_epic_link read_epic_board) } let(:epic_rules) do
%i(read_epic create_epic admin_epic destroy_epic read_confidential_epic
destroy_epic_link read_epic_board read_epic_list)
end
context 'when epics feature is disabled' do context 'when epics feature is disabled' do
let(:current_user) { owner } let(:current_user) { owner }
...@@ -55,7 +58,7 @@ RSpec.describe GroupPolicy do ...@@ -55,7 +58,7 @@ RSpec.describe GroupPolicy do
let(:current_user) { guest } let(:current_user) { guest }
it { is_expected.to be_allowed(:read_epic, :read_epic_board) } it { is_expected.to be_allowed(:read_epic, :read_epic_board) }
it { is_expected.to be_disallowed(*(epic_rules - [:read_epic, :read_epic_board])) } it { is_expected.to be_disallowed(*(epic_rules - [:read_epic, :read_epic_board, :read_epic_list])) }
end end
context 'when user is not member' do context 'when user is not member' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'get list of epic boards' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:board) { create(:epic_board, group: group) }
let_it_be(:list1) { create(:epic_list, epic_board: board) }
let_it_be(:list2) { create(:epic_list, epic_board: board, list_type: :closed) }
let_it_be(:list3) { create(:epic_list, epic_board: board, list_type: :backlog) }
def pagination_query(params = {})
graphql_query_for(:group, { full_path: group.full_path },
<<~BOARDS
epicBoard(id: "#{board.to_global_id}") {
#{query_nodes(:lists, all_graphql_fields_for('epic_lists'.classify), include_pagination_info: true, args: params)}
}
BOARDS
)
end
before do
stub_licensed_features(epics: true)
end
context 'when the user does not have access to the epic board group' do
it 'returns nil group' do
post_graphql(pagination_query, current_user: current_user)
expect(graphql_data['group']).to be_nil
end
end
context 'when user can access the epic board group' do
before do
group.add_developer(current_user)
end
describe 'sorting and pagination' do
let(:data_path) { [:group, :epicBoard, :lists] }
let(:expected_results) { [list3.to_global_id.to_s, list1.to_global_id.to_s, list2.to_global_id.to_s] }
def pagination_results_data(nodes)
nodes.map { |list| list['id'] }
end
it_behaves_like 'sorted paginated query' do
# currently we don't support custom sorting for epic lists,
# nil value will be ignored by ::Graphql::Arguments
let(:sort_param) { nil }
let(:first_param) { 2 }
end
end
end
end
...@@ -87,6 +87,7 @@ label: ...@@ -87,6 +87,7 @@ label:
- merge_requests - merge_requests
- priorities - priorities
- epic_board_labels - epic_board_labels
- epic_lists
milestone: milestone:
- group - group
- project - project
......
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