Commit 0d29d658 authored by Heinrich Lee Yu's avatar Heinrich Lee Yu

Merge branch '250479-show-iteration-lists' into 'master'

Handle listing of issues inside iteration lists and moving of issues to / from iteration lists

See merge request gitlab-org/gitlab!49946
parents 40d9ac1f edfe463a
......@@ -2029,6 +2029,11 @@ type BoardList {
"""
issuesCount: Int
"""
Iteration of the list
"""
iteration: Iteration
"""
Label of the list
"""
......
......@@ -5351,6 +5351,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "iteration",
"description": "Iteration of the list",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Iteration",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "label",
"description": "Label of the list",
......@@ -325,6 +325,7 @@ Represents a list for an issue board.
| `id` | ID! | ID (global ID) of the list |
| `issues` | IssueConnection | Board issues |
| `issuesCount` | Int | Count of issues in the list |
| `iteration` | Iteration | Iteration of the list |
| `label` | Label | Label of the list |
| `limitMetric` | ListLimitMetric | The current limit metric for the list |
| `listType` | String! | Type of the list |
......
......@@ -8,6 +8,8 @@ module EE
prepended do
field :milestone, ::Types::MilestoneType, null: true,
description: 'Milestone of the list'
field :iteration, ::Types::IterationType, null: true,
description: 'Iteration of the list'
field :max_issue_count, GraphQL::INT_TYPE, null: true,
description: 'Maximum number of issues in the list'
field :max_issue_weight, GraphQL::INT_TYPE, null: true,
......@@ -23,6 +25,10 @@ module EE
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Milestone, object.milestone_id).find
end
def iteration
::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Iteration, object.iteration_id).find
end
def assignee
object.assignee? ? object.user : nil
end
......
......@@ -8,6 +8,10 @@ module EE
LIMIT_METRIC_TYPES = %w[all_metrics issue_count issue_weights].freeze
# When adding a new licensed type, make sure to also add
# it on license.rb with the pattern "board_<list_type>_lists"
LICENSED_LIST_TYPES = %i[assignee milestone iteration].freeze
# ActiveSupport::Concern does not prepend the ClassMethods,
# so we cannot call `super` if we use it.
def self.prepended(base)
......@@ -42,7 +46,7 @@ module EE
unless: -> { board&.resource_parent&.feature_available?(:board_milestone_lists) }
base.validates :list_type,
exclusion: { in: %w[iteration], message: -> (_object, _data) { _('Iteration lists not available with your current license') } },
unless: -> { board&.resource_parent&.feature_available?(:iterations) }
unless: -> { board&.resource_parent&.feature_available?(:board_iteration_lists) }
base.scope :without_types, ->(list_types) { where.not(list_type: list_types) }
end
......
......@@ -14,6 +14,7 @@ class License < ApplicationRecord
EES_FEATURES = %i[
audit_events
blocked_issues
board_iteration_lists
code_owners
code_review_analytics
contribution_analytics
......
......@@ -13,6 +13,7 @@ module EE
unless list&.movable? || list&.closed?
issues = without_assignees_from_lists(issues)
issues = without_milestones_from_lists(issues)
issues = without_iterations_from_lists(issues)
end
case list&.list_type
......@@ -20,6 +21,8 @@ module EE
with_assignee(super)
when 'milestone'
with_milestone(super)
when 'iteration'
with_iteration(super)
else
super
end
......@@ -58,6 +61,18 @@ module EE
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def all_iteration_lists
# Note that the names are very similar but these are different.
# One is a license name and the other is a feature flag
if parent.feature_available?(:board_iteration_lists) && ::Feature.enabled?(:iteration_board_lists, parent)
board.lists.iteration.where.not(iteration_id: nil)
else
::List.none
end
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def without_assignees_from_lists(issues)
return issues if all_assignee_lists.empty?
......@@ -85,6 +100,14 @@ module EE
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def without_iterations_from_lists(issues)
return issues if all_iteration_lists.empty?
issues.not_in_iterations(all_iteration_lists.select(:iteration_id))
end
# rubocop: enable CodeReuse/ActiveRecord
def with_assignee(issues)
issues.assigned_to(list.user)
end
......@@ -95,6 +118,10 @@ module EE
end
# rubocop: enable CodeReuse/ActiveRecord
def with_iteration(issues)
issues.in_iterations(list.iteration_id)
end
# Prevent filtering by milestone stubs
# like Milestone::Upcoming, Milestone::Started etc
def has_valid_milestone?
......
......@@ -34,19 +34,28 @@ module EE
assignee_ids = assignee_ids(issue)
milestone_id = milestone_id(issue)
{
movement_args = {
assignee_ids: assignee_ids,
milestone_id: milestone_id
}
movement_args[:sprint_id] = iteration_id(issue) if ::Feature.enabled?(:iteration_board_lists, parent)
movement_args
end
def milestone_id(issue)
# We want to nullify the issue milestone.
return if moving_to_list.backlog? && moving_from_list.milestone?
return moving_to_list.milestone_id if moving_to_list.milestone?
issue.milestone_id
end
def iteration_id(issue)
return if moving_to_list.backlog? && moving_from_list.iteration?
return moving_to_list.iteration_id if moving_to_list.iteration?
# Moving to a list which is not a 'milestone list' will keep
# the already existent milestone.
[issue.milestone_id, moving_to_list.milestone_id].compact.last
issue.sprint_id
end
def assignee_ids(issue)
......
......@@ -19,16 +19,7 @@ module EE
private
def valid_license?(parent)
license_name = case type
when :assignee
:board_assignee_lists
when :milestone
:board_milestone_lists
when :iteration
:iterations
end
license_name.nil? || parent.feature_available?(license_name)
List::LICENSED_LIST_TYPES.exclude?(type) || parent.feature_available?(:"board_#{type}_lists")
end
def license_validation_error
......
......@@ -6,10 +6,6 @@ module EE
module ListService
extend ::Gitlab::Utils::Override
# When adding a new licensed type, make sure to also add
# it on license.rb with the pattern "board_<list_type>_lists"
LICENSED_LIST_TYPES = %i[assignee milestone].freeze
override :execute
def execute(board, create_default_lists: true)
list_types = unavailable_list_types_for(board)
......@@ -20,7 +16,10 @@ module EE
private
def unavailable_list_types_for(board)
(hidden_lists_for(board) + unlicensed_lists_for(board)).uniq
list_types = hidden_lists_for(board) + unlicensed_lists_for(board)
list_types << ::List.list_types[:iteration] if ::Feature.disabled?(:iteration_board_lists, board.resource_parent)
list_types.uniq
end
def hidden_lists_for(board)
......@@ -35,7 +34,7 @@ module EE
def unlicensed_lists_for(board)
parent = board.resource_parent
LICENSED_LIST_TYPES.each_with_object([]) do |list_type, lists|
List::LICENSED_LIST_TYPES.each_with_object([]) do |list_type, lists|
list_type_key = ::List.list_types[list_type]
lists << list_type_key unless parent&.feature_available?(:"board_#{list_type}_lists")
end
......
......@@ -79,7 +79,7 @@ RSpec.describe Boards::ListsController do
context 'when license is available' do
before do
stub_licensed_features(iterations: true)
stub_licensed_features(board_iteration_lists: true)
end
it 'returns a successful 200 response' do
......@@ -92,7 +92,7 @@ RSpec.describe Boards::ListsController do
context 'when license is unavailable' do
before do
stub_licensed_features(iterations: false)
stub_licensed_features(board_iteration_lists: false)
end
it 'returns an error' do
......
......@@ -10,7 +10,12 @@ FactoryBot.define do
factory :milestone_list, parent: :list do
list_type { :milestone }
label { nil }
user { nil }
milestone
end
factory :iteration_list, parent: :list do
list_type { :iteration }
label { nil }
iteration
end
end
......@@ -21,7 +21,7 @@ RSpec.describe Mutations::Boards::Lists::Create do
end
before do
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true, iterations: true)
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true, board_iteration_lists: true)
end
subject { mutation.resolve(board_id: board.to_global_id.to_s, **list_create_params) }
......@@ -108,7 +108,7 @@ RSpec.describe Mutations::Boards::Lists::Create do
context 'when feature unavailable' do
it 'returns an error' do
stub_licensed_features(iterations: false)
stub_licensed_features(board_iteration_lists: false)
expect(subject[:errors]).to include 'Iteration lists not available with your current license'
end
......
......@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe GitlabSchema.types['BoardList'] do
it 'has specific fields' do
expected_fields = %w[milestone max_issue_count max_issue_weight assignee total_weight]
expected_fields = %w[milestone iteration max_issue_count max_issue_weight assignee total_weight]
expect(described_class).to include_graphql_fields(*expected_fields)
end
......
......@@ -88,7 +88,7 @@ RSpec.describe List do
it { is_expected.to validate_presence_of(:iteration) }
it 'is invalid when feature is not available' do
stub_licensed_features(iterations: false)
stub_licensed_features(board_iteration_lists: false)
expect(subject).to be_invalid
expect(subject.errors[:list_type])
......
......@@ -22,6 +22,7 @@ RSpec.describe Boards::Issues::ListService, services: true do
let_it_be(:p3) { create(:group_label, title: 'P3', group: group) }
let_it_be(:milestone) { create(:milestone, group: group) }
let_it_be(:iteration) { create(:iteration, group: group) }
let_it_be(:opened_issue1) { create(:labeled_issue, project: project, milestone: m1, weight: 9, title: 'Issue 1', labels: [bug]) }
let_it_be(:opened_issue2) { create(:labeled_issue, project: project, milestone: m2, weight: 1, title: 'Issue 2', labels: [p2]) }
......@@ -42,15 +43,16 @@ RSpec.describe Boards::Issues::ListService, services: true do
let(:parent) { group }
before do
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true)
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true, board_iteration_lists: true)
parent.add_developer(user)
opened_issue3.assignees.push(user_list.user)
end
context 'with assignee, milestone and label lists present' do
context 'with assignee, milestone, iteration and label lists present' do
let!(:user_list) { create(:user_list, board: board, position: 2) }
let!(:milestone_list) { create(:milestone_list, board: board, position: 3, milestone: milestone) }
let!(:iteration_list) { create(:iteration_list, board: board, position: 4, iteration: iteration) }
let!(:backlog) { create(:backlog_list, board: board) }
let!(:list1) { create(:list, board: board, label: development, position: 0) }
let!(:list2) { create(:list, board: board, label: testing, position: 1) }
......@@ -80,6 +82,46 @@ RSpec.describe Boards::Issues::ListService, services: true do
end
end
context 'iteration lists' do
let!(:iteration_issue) { create(:labeled_issue, project: project, iteration: iteration, labels: [p3]) }
let(:params) { { board_id: board.id, id: iteration_list.id } }
subject(:issues) { described_class.new(parent.class.find(parent.id), user, params).execute }
it 'returns issues from iteration persisted in the list' do
expect(issues).to contain_exactly(iteration_issue)
end
context 'backlog list' do
let(:params) { { board_id: board.id, id: backlog.id } }
it 'excludes issues in the iteration list' do
expect(issues).not_to include(iteration_issue)
end
context 'when feature is disabled' do
before do
stub_licensed_features(board_iteration_lists: false)
end
it 'includes issues in the iteration list' do
expect(issues).to include(iteration_issue)
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(iteration_board_lists: false)
end
it 'includes issues in the iteration list' do
expect(issues).to include(iteration_issue)
end
end
end
end
describe '#metadata' do
it 'returns issues count and weight for list' do
params = { board_id: board.id, id: backlog.id }
......
......@@ -103,6 +103,87 @@ RSpec.describe Boards::Issues::MoveService, services: true do
end
end
shared_examples 'moving an issue to/from iteration lists' do
context 'from backlog to iteration list' do
let!(:issue) { create(:issue, project: project) }
let(:params) { { board_id: board1.id, from_list_id: backlog.id, to_list_id: iteration_list1.id } }
it 'assigns the iteration' do
expect { described_class.new(parent, user, params).execute(issue) }
.to change { issue.reload.iteration }
.from(nil)
.to(iteration_list1.iteration)
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(iteration_board_lists: false)
end
it 'does not assign the iteration' do
expect { described_class.new(parent, user, params).execute(issue) }
.not_to change { issue.reload.iteration }
end
end
end
context 'from iteration to backlog list' do
let!(:issue) { create(:issue, project: project, iteration: iteration_list1.iteration) }
it 'removes the iteration' do
params = { board_id: board1.id, from_list_id: iteration_list1.id, to_list_id: backlog.id }
expect { described_class.new(parent, user, params).execute(issue) } .to change { issue.reload.iteration }
.from(iteration_list1.iteration)
.to(nil)
end
end
context 'from label to iteration list' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development]) }
it 'assigns the iteration and keeps labels' do
params = { board_id: board1.id, from_list_id: label_list1.id, to_list_id: iteration_list1.id }
expect { described_class.new(parent, user, params).execute(issue) }
.to change { issue.reload.iteration }
.from(nil)
.to(iteration_list1.iteration)
expect(issue.labels).to contain_exactly(bug, development)
end
end
context 'from iteration to label list' do
let!(:issue) do
create(:labeled_issue, project: project,
iteration: iteration_list1.iteration,
labels: [bug, development])
end
it 'adds labels and keeps iteration' do
params = { board_id: board1.id, from_list_id: iteration_list1.id, to_list_id: label_list2.id }
expect { described_class.new(parent, user, params).execute(issue) }
.not_to change { issue.reload.iteration }
expect(issue.labels).to contain_exactly(bug, development, testing)
end
end
context 'between iteration lists' do
let!(:issue) { create(:issue, project: project, iteration: iteration_list1.iteration) }
it 'replaces previous list iteration to targeting list iteration' do
params = { board_id: board1.id, from_list_id: iteration_list1.id, to_list_id: iteration_list2.id }
expect { described_class.new(parent, user, params).execute(issue) }
.to change { issue.reload.iteration }
.from(iteration_list1.iteration)
.to(iteration_list2.iteration)
end
end
end
shared_examples 'moving an issue to/from assignee lists' do
let(:issue) { create(:labeled_issue, project: project, labels: [bug, development], milestone: milestone1) }
let(:params) { { board_id: board1.id, from_list_id: label_list1.id, to_list_id: label_list2.id } }
......@@ -193,15 +274,20 @@ RSpec.describe Boards::Issues::MoveService, services: true do
let(:user_list2) { create(:user_list, board: board1, user: user, position: 3) }
let(:milestone_list1) { create(:milestone_list, board: board1, milestone: milestone1, position: 4) }
let(:milestone_list2) { create(:milestone_list, board: board1, milestone: milestone2, position: 5) }
let(:iteration_list1) { create(:iteration_list, board: board1, iteration: iteration1, position: 6) }
let(:iteration_list2) { create(:iteration_list, board: board1, iteration: iteration2, position: 7) }
let(:closed) { create(:closed_list, board: board1) }
let(:backlog) { create(:backlog_list, board: board1) }
context 'when parent is a project' do
let(:project) { create(:project) }
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:parent_attr) { { project: project } }
let(:parent) { project }
let(:milestone1) { create(:milestone, project: project) }
let(:milestone2) { create(:milestone, project: project) }
let(:iteration1) { create(:iteration, group: group) }
let(:iteration2) { create(:iteration, group: group) }
let(:bug) { create(:label, project: project, name: 'Bug') }
let(:development) { create(:label, project: project, name: 'Development') }
......@@ -209,13 +295,14 @@ RSpec.describe Boards::Issues::MoveService, services: true do
let(:regression) { create(:label, project: project, name: 'Regression') }
before do
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true)
stub_licensed_features(board_assignee_lists: true, board_milestone_lists: true, board_iteration_lists: true)
parent.add_developer(user)
parent.add_developer(user_list1.user)
end
it_behaves_like 'moving an issue to/from assignee lists'
it_behaves_like 'moving an issue to/from milestone lists'
it_behaves_like 'moving an issue to/from iteration lists'
end
context 'when parent is a group' do
......@@ -225,6 +312,8 @@ RSpec.describe Boards::Issues::MoveService, services: true do
let(:parent) { group }
let(:milestone1) { create(:milestone, group: group) }
let(:milestone2) { create(:milestone, group: group) }
let(:iteration1) { create(:iteration, group: group) }
let(:iteration2) { create(:iteration, group: group) }
let(:bug) { create(:group_label, group: group, name: 'Bug') }
let(:development) { create(:group_label, group: group, name: 'Development') }
......@@ -239,6 +328,7 @@ RSpec.describe Boards::Issues::MoveService, services: true do
it_behaves_like 'moving an issue to/from assignee lists'
it_behaves_like 'moving an issue to/from milestone lists'
it_behaves_like 'moving an issue to/from iteration lists'
context 'when moving to same list' do
let(:subgroup) { create(:group, parent: group) }
......
......@@ -62,7 +62,7 @@ RSpec.describe Boards::Lists::CreateService do
subject(:service) { described_class.new(project, user, 'iteration_id' => iteration.id) }
before do
stub_licensed_features(iterations: true)
stub_licensed_features(board_iteration_lists: true)
end
it 'creates an iteration list when param is valid' do
......@@ -93,7 +93,7 @@ RSpec.describe Boards::Lists::CreateService do
end
it 'returns an error when license is unavailable' do
stub_licensed_features(iterations: false)
stub_licensed_features(board_iteration_lists: false)
response = service.execute(board)
......
......@@ -4,6 +4,14 @@ require 'spec_helper'
RSpec.describe Boards::Lists::ListService do
describe '#execute' do
before do
stub_licensed_features(board_assignee_lists: false, board_milestone_lists: false, board_iteration_lists: false)
end
def execute_service
service.execute(Board.find(board.id))
end
shared_examples 'list service for board with assignee lists' do
let!(:assignee_list) { build(:user_list, board: board).tap { |l| l.save(validate: false) } }
let!(:backlog_list) { create(:backlog_list, board: board) }
......@@ -11,18 +19,17 @@ RSpec.describe Boards::Lists::ListService do
context 'when the feature is enabled' do
before do
allow(board.resource_parent).to receive(:feature_available?).with(:board_assignee_lists).and_return(true)
allow(board.resource_parent).to receive(:feature_available?).with(:board_milestone_lists).and_return(false)
stub_licensed_features(board_assignee_lists: true)
end
it 'returns all lists' do
expect(service.execute(board)).to match_array [backlog_list, list, assignee_list, board.closed_list]
expect(execute_service).to match_array [backlog_list, list, assignee_list, board.closed_list]
end
end
context 'when the feature is disabled' do
it 'filters out assignee lists that might have been created while subscribed' do
expect(service.execute(board)).to match_array [backlog_list, list, board.closed_list]
expect(execute_service).to match_array [backlog_list, list, board.closed_list]
end
end
end
......@@ -34,19 +41,51 @@ RSpec.describe Boards::Lists::ListService do
context 'when the feature is enabled' do
before do
allow(board.resource_parent).to receive(:feature_available?).with(:board_assignee_lists).and_return(false)
allow(board.resource_parent).to receive(:feature_available?).with(:board_milestone_lists).and_return(true)
stub_licensed_features(board_milestone_lists: true)
end
it 'returns all lists' do
expect(service.execute(board))
expect(execute_service)
.to match_array([backlog_list, list, milestone_list, board.closed_list])
end
end
context 'when the feature is disabled' do
it 'filters out assignee lists that might have been created while subscribed' do
expect(service.execute(board)).to match_array [backlog_list, list, board.closed_list]
expect(execute_service).to match_array [backlog_list, list, board.closed_list]
end
end
end
shared_examples 'list service for board with iteration lists' do
let!(:iteration_list) { build(:iteration_list, board: board).tap { |l| l.save(validate: false) } }
let!(:backlog_list) { create(:backlog_list, board: board) }
let!(:list) { create(:list, board: board, label: label) }
context 'when the feature is enabled' do
before do
stub_licensed_features(board_iteration_lists: true)
end
it 'returns all lists' do
expect(execute_service)
.to match_array([backlog_list, list, iteration_list, board.closed_list])
end
context 'when the feature flag is disabled' do
before do
stub_feature_flags(iteration_board_lists: false)
end
it 'filters out iteration lists that might have been created while subscribed' do
expect(execute_service).to match_array [backlog_list, list, board.closed_list]
end
end
end
context 'when feature is disabled' do
it 'filters out iteration lists that might have been created while subscribed' do
expect(execute_service).to match_array [backlog_list, list, board.closed_list]
end
end
end
......@@ -58,7 +97,7 @@ RSpec.describe Boards::Lists::ListService do
it 'hides backlog list' do
board.update(hide_backlog_list: true)
expect(service.execute(board)).to match_array([board.closed_list, list])
expect(execute_service).to match_array([board.closed_list, list])
end
end
......@@ -66,7 +105,7 @@ RSpec.describe Boards::Lists::ListService do
it 'hides closed list' do
board.update(hide_closed_list: true)
expect(service.execute(board)).to match_array([board.backlog_list, list])
expect(execute_service).to match_array([board.backlog_list, list])
end
end
end
......@@ -80,6 +119,7 @@ RSpec.describe Boards::Lists::ListService do
it_behaves_like 'list service for board with assignee lists'
it_behaves_like 'list service for board with milestone lists'
it_behaves_like 'list service for board with iteration lists'
it_behaves_like 'hidden lists'
end
......@@ -92,6 +132,7 @@ RSpec.describe Boards::Lists::ListService do
it_behaves_like 'list service for board with assignee lists'
it_behaves_like 'list service for board with milestone lists'
it_behaves_like 'list service for board with iteration lists'
it_behaves_like 'hidden lists'
end
end
......
......@@ -2,7 +2,7 @@
RSpec.shared_examples 'iteration board list' do
before do
stub_licensed_features(iterations: true)
stub_licensed_features(board_iteration_lists: true)
end
context 'when iteration_id is sent' do
......@@ -24,7 +24,7 @@ RSpec.shared_examples 'iteration board list' do
end
it 'returns 400 if not licensed' do
stub_licensed_features(iterations: false)
stub_licensed_features(board_iteration_lists: false)
post api(url, user), params: { iteration_id: iteration.id }
......
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