Commit b901713d authored by Jan Provaznik's avatar Jan Provaznik

Added listing of epics

* exposes epics on an epic board list
* adds an index for epic board position table
* abstracts logic in issues list service so it can be reused by issue
  and epic lists
parent 5f003f9d
# frozen_string_literal: true
module Boards
class BaseItemsListService < Boards::BaseService
include Gitlab::Utils::StrongMemoize
include ActiveRecord::ConnectionAdapters::Quoting
def execute
return items.order_closed_date_desc if list&.closed?
ordered_items
end
private
def ordered_items
raise NotImplementedError
end
def finder
raise NotImplementedError
end
def board
raise NotImplementedError
end
def item_model
raise NotImplementedError
end
# We memoize the query here since the finder methods we use are quite complex. This does not memoize the result of the query.
# rubocop: disable CodeReuse/ActiveRecord
def items
strong_memoize(:items) do
filter(finder.execute).reorder(nil)
end
end
# rubocop: enable CodeReuse/ActiveRecord
def filter(items)
# when grouping board issues by epics (used in board swimlanes)
# we need to get all issues in the board
# TODO: ignore hidden columns -
# https://gitlab.com/gitlab-org/gitlab/-/issues/233870
return items if params[:all_lists]
items = without_board_labels(items) unless list&.movable? || list&.closed?
items = with_list_label(items) if list&.label?
items
end
def list
return unless params.key?(:id)
strong_memoize(:list) do
id = params[:id]
if board.lists.loaded?
board.lists.find { |l| l.id == id }
else
board.lists.find(id)
end
end
end
def filter_params
set_parent
set_state
set_attempt_search_optimizations
params
end
def set_parent
if parent.is_a?(Group)
params[:group_id] = parent.id
else
params[:project_id] = parent.id
end
end
def set_state
return if params[:all_lists]
params[:state] = list && list.closed? ? 'closed' : 'opened'
end
def set_attempt_search_optimizations
return unless params[:search].present?
if board.group_board?
params[:attempt_group_search_optimizations] = true
else
params[:attempt_project_search_optimizations] = true
end
end
# rubocop: disable CodeReuse/ActiveRecord
def board_label_ids
@board_label_ids ||= board.lists.movable.pluck(:label_id)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def without_board_labels(items)
return items unless board_label_ids.any?
items.where.not('EXISTS (?)', label_links(board_label_ids).limit(1))
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def label_links(label_ids)
LabelLink
.where('label_links.target_type = ?', item_model)
.where(item_model.arel_table[:id].eq(LabelLink.arel_table[:target_id]).to_sql)
.where(label_id: label_ids)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def with_list_label(items)
items.where('EXISTS (?)', label_links(list.label_id).limit(1))
end
# rubocop: enable CodeReuse/ActiveRecord
end
end
...@@ -2,26 +2,20 @@ ...@@ -2,26 +2,20 @@
module Boards module Boards
module Issues module Issues
class ListService < Boards::BaseService class ListService < Boards::BaseItemsListService
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
def self.valid_params def self.valid_params
IssuesFinder.valid_params IssuesFinder.valid_params
end end
def execute
return fetch_issues.order_closed_date_desc if list&.closed?
fetch_issues.order_by_position_and_priority(with_cte: params[:search].present?)
end
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def metadata def metadata
issues = Issue.arel_table issues = Issue.arel_table
keys = metadata_fields.keys keys = metadata_fields.keys
# TODO: eliminate need for SQL literal fragment # TODO: eliminate need for SQL literal fragment
columns = Arel.sql(metadata_fields.values_at(*keys).join(', ')) columns = Arel.sql(metadata_fields.values_at(*keys).join(', '))
results = Issue.where(id: fetch_issues.select(issues[:id])).pluck(columns) results = Issue.where(id: items.select(issues[:id])).pluck(columns)
Hash[keys.zip(results.flatten)] Hash[keys.zip(results.flatten)]
end end
...@@ -29,74 +23,28 @@ module Boards ...@@ -29,74 +23,28 @@ module Boards
private private
def metadata_fields def ordered_items
{ size: 'COUNT(*)' } items.order_by_position_and_priority(with_cte: params[:search].present?)
end
# We memoize the query here since the finder methods we use are quite complex. This does not memoize the result of the query.
# rubocop: disable CodeReuse/ActiveRecord
def fetch_issues
strong_memoize(:fetch_issues) do
issues = IssuesFinder.new(current_user, filter_params).execute
filter(issues).reorder(nil)
end
end end
# rubocop: enable CodeReuse/ActiveRecord
def filter(issues) def finder
# when grouping board issues by epics (used in board swimlanes) IssuesFinder.new(current_user, filter_params)
# we need to get all issues in the board
# TODO: ignore hidden columns -
# https://gitlab.com/gitlab-org/gitlab/-/issues/233870
return issues if params[:all_lists]
issues = without_board_labels(issues) unless list&.movable? || list&.closed?
issues = with_list_label(issues) if list&.label?
issues
end end
def board def board
@board ||= parent.boards.find(params[:board_id]) @board ||= parent.boards.find(params[:board_id])
end end
def list def metadata_fields
return unless params.key?(:id) { size: 'COUNT(*)' }
strong_memoize(:list) do
id = params[:id]
if board.lists.loaded?
board.lists.find { |l| l.id == id }
else
board.lists.find(id)
end
end
end end
def filter_params def filter_params
set_parent
set_state
set_scope set_scope
set_non_archived set_non_archived
set_attempt_search_optimizations
set_issue_types set_issue_types
params super
end
def set_parent
if parent.is_a?(Group)
params[:group_id] = parent.id
else
params[:project_id] = parent.id
end
end
def set_state
return if params[:all_lists]
params[:state] = list && list.closed? ? 'closed' : 'opened'
end end
def set_scope def set_scope
...@@ -107,49 +55,12 @@ module Boards ...@@ -107,49 +55,12 @@ module Boards
params[:non_archived] = parent.is_a?(Group) params[:non_archived] = parent.is_a?(Group)
end end
def set_attempt_search_optimizations
return unless params[:search].present?
if board.group_board?
params[:attempt_group_search_optimizations] = true
else
params[:attempt_project_search_optimizations] = true
end
end
def set_issue_types def set_issue_types
params[:issue_types] = Issue::TYPES_FOR_LIST params[:issue_types] = Issue::TYPES_FOR_LIST
end end
# rubocop: disable CodeReuse/ActiveRecord def item_model
def board_label_ids Issue
@board_label_ids ||= board.lists.movable.pluck(:label_id)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def without_board_labels(issues)
return issues unless board_label_ids.any?
issues.where.not('EXISTS (?)', issues_label_links.limit(1))
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def issues_label_links
LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id").where(label_id: board_label_ids)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def with_list_label(issues)
issues.where('EXISTS (?)', LabelLink.where("label_links.target_type = 'Issue' AND label_links.target_id = issues.id")
.where("label_links.label_id = ?", list.label_id).limit(1))
end
# rubocop: enable CodeReuse/ActiveRecord
def board_group
board.group_board? ? parent : parent.group
end end
end end
end end
......
---
title: Added epic board position database index
merge_request: 50277
author:
type: added
# frozen_string_literal: true
class AddEpicBoardPositionIndex < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'index_boards_epic_board_positions_on_scoped_relative_position'
disable_ddl_transaction!
def up
add_concurrent_index :boards_epic_board_positions, [:epic_board_id, :epic_id, :relative_position], name: INDEX_NAME
end
def down
remove_concurrent_index_by_name :boards_epic_board_positions, INDEX_NAME
end
end
37aa0564d2ade1cab56a669facccbaaf08e4d9856c7a4cc120968d33cff161bd
\ No newline at end of file
...@@ -20906,6 +20906,8 @@ CREATE UNIQUE INDEX index_boards_epic_board_positions_on_epic_board_id_and_epic_ ...@@ -20906,6 +20906,8 @@ CREATE UNIQUE INDEX index_boards_epic_board_positions_on_epic_board_id_and_epic_
CREATE INDEX index_boards_epic_board_positions_on_epic_id ON boards_epic_board_positions USING btree (epic_id); CREATE INDEX index_boards_epic_board_positions_on_epic_id ON boards_epic_board_positions USING btree (epic_id);
CREATE INDEX index_boards_epic_board_positions_on_scoped_relative_position ON boards_epic_board_positions USING btree (epic_board_id, epic_id, relative_position);
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 INDEX index_boards_epic_lists_on_epic_board_id ON boards_epic_lists USING btree (epic_board_id);
......
...@@ -8459,6 +8459,11 @@ type EpicBoard { ...@@ -8459,6 +8459,11 @@ type EpicBoard {
""" """
first: Int first: Int
"""
Find an epic board list by ID.
"""
id: BoardsEpicListID
""" """
Returns the last _n_ elements from the list. Returns the last _n_ elements from the list.
""" """
...@@ -9070,6 +9075,31 @@ type EpicIssueEdge { ...@@ -9070,6 +9075,31 @@ type EpicIssueEdge {
Represents an epic board list Represents an epic board list
""" """
type EpicList { type EpicList {
"""
List epics.
"""
epics(
"""
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
): EpicConnection
""" """
Global ID of the board list. Global ID of the board list.
""" """
......
...@@ -23390,6 +23390,16 @@ ...@@ -23390,6 +23390,16 @@
"name": "lists", "name": "lists",
"description": "Epic board lists.", "description": "Epic board lists.",
"args": [ "args": [
{
"name": "id",
"description": "Find an epic board list by ID.",
"type": {
"kind": "SCALAR",
"name": "BoardsEpicListID",
"ofType": null
},
"defaultValue": null
},
{ {
"name": "after", "name": "after",
"description": "Returns the elements in the list that come after the specified cursor.", "description": "Returns the elements in the list that come after the specified cursor.",
...@@ -25184,6 +25194,59 @@ ...@@ -25184,6 +25194,59 @@
"name": "EpicList", "name": "EpicList",
"description": "Represents an epic board list", "description": "Represents an epic board list",
"fields": [ "fields": [
{
"name": "epics",
"description": "List epics.",
"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": "EpicConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "id", "name": "id",
"description": "Global ID of the board list.", "description": "Global ID of the board list.",
...@@ -1481,6 +1481,7 @@ Represents an epic board list. ...@@ -1481,6 +1481,7 @@ Represents an epic board list.
| Field | Type | Description | | Field | Type | Description |
| ----- | ---- | ----------- | | ----- | ---- | ----------- |
| `epics` | EpicConnection | List epics. |
| `id` | BoardsEpicListID! | Global ID of the board list. | | `id` | BoardsEpicListID! | Global ID of the board list. |
| `label` | Label | Label of the list. | | `label` | Label | Label of the list. |
| `listType` | String! | Type of the list. | | `listType` | String! | Type of the list. |
......
# frozen_string_literal: true
module Resolvers
module Boards
class BoardListEpicsResolver < BaseResolver
type Types::EpicType.connection_type, null: true
alias_method :list, :object
def resolve(**args)
filter_params = { board_id: list.epic_board.id, id: list.id }
service = ::Boards::Epics::ListService.new(list.epic_board.group, context[:current_user], filter_params)
offset_pagination(service.execute)
end
end
end
end
...@@ -8,11 +8,9 @@ module Resolvers ...@@ -8,11 +8,9 @@ module Resolvers
type Types::Boards::EpicListType.connection_type, null: true type Types::Boards::EpicListType.connection_type, null: true
when_single do argument :id, ::Types::GlobalIDType[::Boards::EpicList],
argument :id, ::Types::GlobalIDType[::Boards::EpicList], required: false,
required: true, description: 'Find an epic board list by ID.'
description: 'Find an epic board list by ID.'
end
alias_method :epic_board, :object alias_method :epic_board, :object
......
...@@ -23,6 +23,10 @@ module Types ...@@ -23,6 +23,10 @@ module Types
field :label, Types::LabelType, null: true, field :label, Types::LabelType, null: true,
description: 'Label of the list.' description: 'Label of the list.'
field :epics, Types::EpicType.connection_type, null: true,
resolver: Resolvers::Boards::BoardListEpicsResolver,
description: 'List epics.'
end end
# rubocop: enable Graphql/AuthorizeTypes # rubocop: enable Graphql/AuthorizeTypes
end end
......
...@@ -10,5 +10,9 @@ module Boards ...@@ -10,5 +10,9 @@ module Boards
validates :name, length: { maximum: 255 } validates :name, length: { maximum: 255 }
scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc).order(id: :asc) } scope :order_by_name_asc, -> { order(arel_table[:name].lower.asc).order(id: :asc) }
def lists
epic_lists
end
end end
end end
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
module Boards module Boards
class EpicList < ApplicationRecord class EpicList < ApplicationRecord
# TODO: we can move logic shared with with List model to
# a module. https://gitlab.com/gitlab-org/gitlab/-/issues/296559
belongs_to :epic_board, optional: false, inverse_of: :epic_lists belongs_to :epic_board, optional: false, inverse_of: :epic_lists
belongs_to :label, inverse_of: :epic_lists belongs_to :label, inverse_of: :epic_lists
...@@ -12,9 +14,18 @@ module Boards ...@@ -12,9 +14,18 @@ module Boards
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label? validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, if: :label?
scope :ordered, -> { order(:list_type, :position) } scope :ordered, -> { order(:list_type, :position) }
scope :movable, -> { where(list_type: list_types.slice(*movable_types).values) }
def self.movable_types
[:label]
end
def title def title
label? ? label.name : list_type.humanize label? ? label.name : list_type.humanize
end end
def movable?
label?
end
end end
end end
...@@ -57,6 +57,7 @@ module EE ...@@ -57,6 +57,7 @@ module EE
has_many :issues, through: :epic_issues has_many :issues, through: :epic_issues
has_many :user_mentions, class_name: "EpicUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent has_many :user_mentions, class_name: "EpicUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :boards_epic_user_preferences, class_name: 'Boards::EpicUserPreference', inverse_of: :epic has_many :boards_epic_user_preferences, class_name: 'Boards::EpicUserPreference', inverse_of: :epic
has_many :epic_board_positions, class_name: 'Boards::EpicBoardPosition', inverse_of: :epic_board
validates :group, presence: true validates :group, presence: true
validate :validate_parent, on: :create validate :validate_parent, on: :create
...@@ -103,10 +104,18 @@ module EE ...@@ -103,10 +104,18 @@ module EE
reorder(::Gitlab::Database.nulls_last_order('start_date', 'DESC'), 'id DESC') reorder(::Gitlab::Database.nulls_last_order('start_date', 'DESC'), 'id DESC')
end end
scope :order_closed_date_desc, -> { reorder(closed_at: :desc) }
scope :order_relative_position, -> do scope :order_relative_position, -> do
reorder('relative_position ASC', 'id DESC') reorder('relative_position ASC', 'id DESC')
end end
scope :order_relative_position_on_board, ->(board_id) do
left_joins(:epic_board_positions)
.where(boards_epic_board_positions: { epic_board_id: [nil, board_id] })
.reorder(::Gitlab::Database.nulls_last_order('boards_epic_board_positions.relative_position', 'ASC'), 'epics.id DESC')
end
scope :with_api_entity_associations, -> { preload(:author, :labels, group: :route) } scope :with_api_entity_associations, -> { preload(:author, :labels, group: :route) }
scope :start_date_inherited, -> { where(start_date_is_fixed: [nil, false]) } scope :start_date_inherited, -> { where(start_date_is_fixed: [nil, false]) }
scope :due_date_inherited, -> { where(due_date_is_fixed: [nil, false]) } scope :due_date_inherited, -> { where(due_date_is_fixed: [nil, false]) }
......
# frozen_string_literal: true
module Boards
module Epics
class ListService < Boards::BaseItemsListService
private
def finder
EpicsFinder.new(current_user, filter_params.merge(group_id: parent.id))
end
def board
@board ||= parent.epic_boards.find(params[:board_id])
end
def ordered_items
items.order_relative_position_on_board(board.id)
end
def item_model
::Epic
end
end
end
end
...@@ -28,9 +28,9 @@ module EE ...@@ -28,9 +28,9 @@ module EE
end end
end end
override :issues_label_links override :label_links
# rubocop: disable CodeReuse/ActiveRecord # rubocop: disable CodeReuse/ActiveRecord
def issues_label_links def label_links(label_ids)
if has_valid_milestone? if has_valid_milestone?
super.where("issues.milestone_id = ?", board.milestone_id) super.where("issues.milestone_id = ?", board.milestone_id)
else else
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
FactoryBot.define do FactoryBot.define do
factory :epic_list, class: 'Boards::EpicList' do factory :epic_list, class: 'Boards::EpicList' do
epic_board epic_board
label association :label, factory: :group_label
list_type { :label } list_type { :label }
sequence(:position) sequence(:position)
end end
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::Boards::BoardListEpicsResolver do
include GraphqlHelpers
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:development) { create(:group_label, group: group, name: 'Development') }
let_it_be(:testing) { create(:group_label, group: group, name: 'Testing') }
let_it_be(:board) { create(:epic_board, group: group) }
let_it_be(:list1) { create(:epic_list, epic_board: board, label: development, position: 0) }
let_it_be(:list2) { create(:epic_list, epic_board: board, label: testing, position: 0) }
let_it_be(:list1_epic1) { create(:labeled_epic, group: group, labels: [development]) }
let_it_be(:list1_epic2) { create(:labeled_epic, group: group, labels: [development]) }
let_it_be(:list2_epic1) { create(:labeled_epic, group: group, labels: [testing]) }
let_it_be(:epic_pos1) { create(:epic_board_position, epic: list1_epic1, epic_board: board, relative_position: 20) }
let_it_be(:epic_pos2) { create(:epic_board_position, epic: list1_epic2, epic_board: board, relative_position: 10) }
let_it_be(:epic_pos3) { create(:epic_board_position, epic: list1_epic1, relative_position: 30) }
specify do
expect(described_class).to have_nullable_graphql_type(Types::EpicType.connection_type)
end
describe '#resolve' do
let(:args) { {} }
subject(:result) { resolve(described_class, ctx: { current_user: user }, obj: list1, args: args) }
before do
stub_licensed_features(epics: true)
group.add_reporter(user)
end
it 'returns epics on the board list ordered by position on the board' do
expect(result.items).to eq([list1_epic2, list1_epic1])
end
end
end
...@@ -6,7 +6,7 @@ RSpec.describe GitlabSchema.types['EpicList'] do ...@@ -6,7 +6,7 @@ RSpec.describe GitlabSchema.types['EpicList'] do
specify { expect(described_class.graphql_name).to eq('EpicList') } specify { expect(described_class.graphql_name).to eq('EpicList') }
it 'has specific fields' do it 'has specific fields' do
expected_fields = %w[id title list_type position label] expected_fields = %w[id title list_type position label epics]
expect(described_class).to include_graphql_fields(*expected_fields) expect(described_class).to include_graphql_fields(*expected_fields)
end end
......
...@@ -19,7 +19,8 @@ RSpec.describe Epic do ...@@ -19,7 +19,8 @@ RSpec.describe Epic do
it { is_expected.to have_many(:epic_issues) } it { is_expected.to have_many(:epic_issues) }
it { is_expected.to have_many(:children) } it { is_expected.to have_many(:children) }
it { is_expected.to have_many(:user_mentions).class_name('EpicUserMention') } it { is_expected.to have_many(:user_mentions).class_name('EpicUserMention') }
it { is_expected.to have_many(:boards_epic_user_preferences).class_name('Boards::EpicUserPreference') } it { is_expected.to have_many(:boards_epic_user_preferences).class_name('Boards::EpicUserPreference').inverse_of(:epic) }
it { is_expected.to have_many(:epic_board_positions).class_name('Boards::EpicBoardPosition').inverse_of(:epic_board) }
end end
describe 'scopes' do describe 'scopes' do
...@@ -45,6 +46,21 @@ RSpec.describe Epic do ...@@ -45,6 +46,21 @@ RSpec.describe Epic do
expect(described_class.not_confidential_or_in_groups(group)).to match_array([confidential_epic, public_epic]) expect(described_class.not_confidential_or_in_groups(group)).to match_array([confidential_epic, public_epic])
end end
end end
describe '.order_relative_position_on_board' do
let_it_be(:board) { create(:epic_board) }
let_it_be(:epic1) { create(:epic) }
let_it_be(:epic2) { create(:epic) }
let_it_be(:epic3) { create(:epic) }
it 'returns epics ordered by position on the board, null last' do
create(:epic_board_position, epic: epic2, epic_board: board, relative_position: 10)
create(:epic_board_position, epic: epic1, epic_board: board, relative_position: 20)
create(:epic_board_position, epic: epic3, epic_board: board, relative_position: 20)
expect(described_class.order_relative_position_on_board(board.id)).to eq([epic2, epic3, epic1, public_epic, confidential_epic])
end
end
end end
describe 'validations' do describe 'validations' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'get list of epics for an epic board list' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:group) { create(:group, :private) }
let_it_be(:development) { create(:group_label, group: group, name: 'Development') }
let_it_be(:board) { create(:epic_board, group: group) }
let_it_be(:list) { create(:epic_list, epic_board: board, label: development) }
let_it_be(:epic1) { create(:labeled_epic, group: group, labels: [development]) }
let_it_be(:epic2) { create(:labeled_epic, group: group, labels: [development]) }
let_it_be(:epic3) { create(:labeled_epic, group: group, labels: [development]) }
let_it_be(:epic4) { create(:labeled_epic, group: group) }
let_it_be(:epic_pos1) { create(:epic_board_position, epic: epic1, epic_board: board, relative_position: 20) }
let_it_be(:epic_pos2) { create(:epic_board_position, epic: epic2, epic_board: board, relative_position: 10) }
def pagination_query(params = {})
graphql_query_for(:group, { full_path: group.full_path },
<<~BOARDS
epicBoard(id: "#{board.to_global_id}") {
lists(id: "#{list.to_global_id}") {
nodes {
#{query_nodes(:epics, all_graphql_fields_for('epics'.classify), include_pagination_info: true, args: params)}
}
}
}
BOARDS
)
end
before do
stub_licensed_features(epics: true)
group.add_developer(current_user)
end
describe 'sorting and pagination' do
let(:data_path) { [:group, :epicBoard, :lists, :nodes, 0, :epics] }
let(:expected_results) { [epic2.to_global_id.to_s, epic1.to_global_id.to_s, epic3.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
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Boards::Epics::ListService do
describe '#execute' do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:board) { create(:epic_board, group: group) }
let_it_be(:development) { create(:group_label, group: group, name: 'Development') }
let_it_be(:testing) { create(:group_label, group: group, name: 'Testing') }
let_it_be(:backlog) { create(:epic_list, epic_board: board, list_type: :backlog) }
let_it_be(:list1) { create(:epic_list, epic_board: board, label: development, position: 0) }
let_it_be(:list2) { create(:epic_list, epic_board: board, label: testing, position: 1) }
let_it_be(:closed) { create(:epic_list, epic_board: board, list_type: :closed) }
let_it_be(:backlog_epic1) { create(:epic, group: group) }
let_it_be(:list1_epic1) { create(:labeled_epic, group: group, labels: [development]) }
let_it_be(:list1_epic2) { create(:labeled_epic, group: group, labels: [development]) }
let_it_be(:list1_epic3) { create(:labeled_epic, group: group, labels: [development]) }
let_it_be(:list2_epic1) { create(:labeled_epic, group: group, labels: [testing]) }
let_it_be(:closed_epic1) { create(:labeled_epic, :closed, group: group, labels: [development], closed_at: 1.day.ago) }
let_it_be(:closed_epic2) { create(:labeled_epic, :closed, group: group, labels: [testing], closed_at: 2.days.ago) }
let_it_be(:closed_epic3) { create(:epic, :closed, group: group, closed_at: 1.week.ago) }
before do
stub_licensed_features(epics: true)
group.add_developer(user)
end
it_behaves_like 'items list service' do
let(:parent) { group }
let(:backlog_items) { [backlog_epic1] }
let(:list1_items) { [list1_epic1, list1_epic2, list1_epic3] }
let(:closed_items) { [closed_epic1, closed_epic2, closed_epic3] }
let(:all_items) { backlog_items + list1_items + closed_items + [list2_epic1] }
let(:list_factory) { :epic_list }
let(:new_list) { create(:epic_list, epic_board: board) }
end
it 'returns epics sorted by position on the board' do
create(:epic_board_position, epic: list1_epic1, epic_board: board, relative_position: 20)
create(:epic_board_position, epic: list1_epic2, epic_board: board, relative_position: 10)
create(:epic_board_position, epic: list1_epic1, relative_position: 30)
epics = described_class.new(group, user, { board_id: board.id, id: list1.id }).execute
expect(epics).to eq([list1_epic2, list1_epic1, list1_epic3])
end
end
end
...@@ -723,6 +723,7 @@ epic: ...@@ -723,6 +723,7 @@ epic:
- user_mentions - user_mentions
- note_authors - note_authors
- boards_epic_user_preferences - boards_epic_user_preferences
- epic_board_positions
epic_issue: epic_issue:
- epic - epic
- issue - issue
......
...@@ -19,78 +19,12 @@ RSpec.shared_examples 'issues list service' do ...@@ -19,78 +19,12 @@ RSpec.shared_examples 'issues list service' do
end end
end end
it 'avoids N+1' do it_behaves_like 'items list service' do
params = { board_id: board.id } let(:backlog_items) { [opened_issue2, reopened_issue1, opened_issue1] }
control = ActiveRecord::QueryRecorder.new { described_class.new(parent, user, params).execute } let(:list1_items) { [list1_issue3, list1_issue1, list1_issue2] }
let(:closed_items) { [closed_issue1, closed_issue2, closed_issue3, closed_issue4, closed_issue5] }
create(:list, board: board) let(:all_items) { backlog_items + list1_items + closed_items + [list2_issue1] }
let(:list_factory) { :list }
expect { described_class.new(parent, user, params).execute }.not_to exceed_query_limit(control) let(:new_list) { create(:list, board: board) }
end
context 'issues are ordered by priority' do
it 'returns opened issues when list_id is missing' do
params = { board_id: board.id }
issues = described_class.new(parent, user, params).execute
expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
end
it 'returns opened issues when listing issues from Backlog' do
params = { board_id: board.id, id: backlog.id }
issues = described_class.new(parent, user, params).execute
expect(issues).to eq [opened_issue2, reopened_issue1, opened_issue1]
end
it 'returns opened issues that have label list applied when listing issues from a label list' do
params = { board_id: board.id, id: list1.id }
issues = described_class.new(parent, user, params).execute
expect(issues).to eq [list1_issue3, list1_issue1, list1_issue2]
end
end
context 'issues are ordered by date of closing' do
it 'returns closed issues when listing issues from Closed' do
params = { board_id: board.id, id: closed.id }
issues = described_class.new(parent, user, params).execute
expect(issues).to eq [closed_issue1, closed_issue2, closed_issue3, closed_issue4, closed_issue5]
end
end
context 'with list that does not belong to the board' do
it 'raises an error' do
list = create(:list)
service = described_class.new(parent, user, board_id: board.id, id: list.id)
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'with invalid list id' do
it 'raises an error' do
service = described_class.new(parent, user, board_id: board.id, id: nil)
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when :all_lists is used' do
it 'returns issues from all lists' do
params = { board_id: board.id, all_lists: true }
issues = described_class.new(parent, user, params).execute
expected = [opened_issue2, reopened_issue1, opened_issue1, list1_issue1,
list1_issue2, list1_issue3, list2_issue1, closed_issue1,
closed_issue2, closed_issue3, closed_issue4, closed_issue5]
expect(issues).to match_array(expected)
end
end end
end end
# frozen_string_literal: true
RSpec.shared_examples 'items list service' do
it 'avoids N+1' do
params = { board_id: board.id }
control = ActiveRecord::QueryRecorder.new { described_class.new(parent, user, params).execute }
new_list
expect { described_class.new(parent, user, params).execute }.not_to exceed_query_limit(control)
end
it 'returns opened items when list_id is missing' do
params = { board_id: board.id }
items = described_class.new(parent, user, params).execute
expect(items).to match_array(backlog_items)
end
it 'returns opened items when listing items from Backlog' do
params = { board_id: board.id, id: backlog.id }
items = described_class.new(parent, user, params).execute
expect(items).to match_array(backlog_items)
end
it 'returns opened items that have label list applied when listing items from a label list' do
params = { board_id: board.id, id: list1.id }
items = described_class.new(parent, user, params).execute
expect(items).to match_array(list1_items)
end
it 'returns closed items when listing items from Closed sorted by closed_at in descending order' do
params = { board_id: board.id, id: closed.id }
items = described_class.new(parent, user, params).execute
expect(items).to eq(closed_items)
end
it 'raises an error if the list does not belong to the board' do
list = create(list_factory) # rubocop:disable Rails/SaveBang
service = described_class.new(parent, user, board_id: board.id, id: list.id)
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'raises an error if list id is invalid' do
service = described_class.new(parent, user, board_id: board.id, id: nil)
expect { service.execute }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'returns items from all lists if :all_list is used' do
params = { board_id: board.id, all_lists: true }
items = described_class.new(parent, user, params).execute
expect(items).to match_array(all_items)
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