Commit 7b1d1016 authored by Alexandru Croitor's avatar Alexandru Croitor

Scope board to current iteration

Allow for issue boards scoping to an iteration. For that we need to
store the iteration to which the board is scoped into iteration_id
on boards table. This change also allows issue filtering on CURRENT
iteration, that is being calculated based on Date.today
parent f3eb4750
...@@ -257,6 +257,10 @@ class IssuableFinder ...@@ -257,6 +257,10 @@ class IssuableFinder
params.merge!(other) params.merge!(other)
end end
def parent
project || group
end
private private
def projects_public_or_visible_to_user def projects_public_or_visible_to_user
......
...@@ -24,6 +24,8 @@ module Timebox ...@@ -24,6 +24,8 @@ module Timebox
Any = TimeboxStruct.new('Any Timebox', '', -1) Any = TimeboxStruct.new('Any Timebox', '', -1)
Upcoming = TimeboxStruct.new('Upcoming', '#upcoming', -2) Upcoming = TimeboxStruct.new('Upcoming', '#upcoming', -2)
Started = TimeboxStruct.new('Started', '#started', -3) Started = TimeboxStruct.new('Started', '#started', -3)
# For Iteration
Current = TimeboxStruct.new('Current', '#current', -4)
included do included do
# Defines the same constants above, but inside the including class. # Defines the same constants above, but inside the including class.
...@@ -31,6 +33,7 @@ module Timebox ...@@ -31,6 +33,7 @@ module Timebox
const_set :Any, TimeboxStruct.new("Any #{self.name}", '', -1) const_set :Any, TimeboxStruct.new("Any #{self.name}", '', -1)
const_set :Upcoming, TimeboxStruct.new('Upcoming', '#upcoming', -2) const_set :Upcoming, TimeboxStruct.new('Upcoming', '#upcoming', -2)
const_set :Started, TimeboxStruct.new('Started', '#started', -3) const_set :Started, TimeboxStruct.new('Started', '#started', -3)
const_set :Current, TimeboxStruct.new('Current', '#current', -4)
alias_method :timebox_id, :id alias_method :timebox_id, :id
......
...@@ -32,9 +32,9 @@ class Iteration < ApplicationRecord ...@@ -32,9 +32,9 @@ class Iteration < ApplicationRecord
scope :closed, -> { with_state(:closed) } scope :closed, -> { with_state(:closed) }
scope :within_timeframe, -> (start_date, end_date) do scope :within_timeframe, -> (start_date, end_date) do
where('start_date is not NULL or due_date is not NULL') where('start_date IS NOT NULL OR due_date IS NOT NULL')
.where('start_date is NULL or start_date <= ?', end_date) .where('start_date IS NULL OR start_date <= ?', end_date)
.where('due_date is NULL or due_date >= ?', start_date) .where('due_date IS NULL OR due_date >= ?', start_date)
end end
scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date >= ?', Date.current) } scope :start_date_passed, -> { where('start_date <= ?', Date.current).where('due_date >= ?', Date.current) }
......
---
title: Add filtering by current iteration to issue lists and issue boards
merge_request: 48040
author:
type: changed
...@@ -9,3 +9,4 @@ Grape::Validations.register_validator(:array_none_any, ::API::Validations::Valid ...@@ -9,3 +9,4 @@ Grape::Validations.register_validator(:array_none_any, ::API::Validations::Valid
Grape::Validations.register_validator(:check_assignees_count, ::API::Validations::Validators::CheckAssigneesCount) Grape::Validations.register_validator(:check_assignees_count, ::API::Validations::Validators::CheckAssigneesCount)
Grape::Validations.register_validator(:untrusted_regexp, ::API::Validations::Validators::UntrustedRegexp) Grape::Validations.register_validator(:untrusted_regexp, ::API::Validations::Validators::UntrustedRegexp)
Grape::Validations.register_validator(:email_or_email_list, ::API::Validations::Validators::EmailOrEmailList) Grape::Validations.register_validator(:email_or_email_list, ::API::Validations::Validators::EmailOrEmailList)
Grape::Validations.register_validator(:iteration_id, ::API::Validations::Validators::IntegerOrCustomValue)
# frozen_string_literal: true
class AddIterationIdToBoardsTable < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def up
with_lock_retries do
add_column :boards, :iteration_id, :bigint
end
end
def down
with_lock_retries do
remove_column :boards, :iteration_id
end
end
end
# frozen_string_literal: true
class AddIterationIdIndexToBoardsTable < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'index_boards_on_iteration_id'
disable_ddl_transaction!
def up
add_concurrent_index :boards, :iteration_id, name: INDEX_NAME
end
def down
remove_concurrent_index :boards, :iteration_id, name: INDEX_NAME
end
end
4c66fd85d6c219d9bedb06c3a38610ecd2c2b1fcb668b132624d7bb76ae2a1ee
\ No newline at end of file
13b30e906a473ead632b808dca2dea2f9fff63920c4e55b97c43d2b30955c0c2
\ No newline at end of file
...@@ -9846,7 +9846,8 @@ CREATE TABLE boards ( ...@@ -9846,7 +9846,8 @@ CREATE TABLE boards (
group_id integer, group_id integer,
weight integer, weight integer,
hide_backlog_list boolean DEFAULT false NOT NULL, hide_backlog_list boolean DEFAULT false NOT NULL,
hide_closed_list boolean DEFAULT false NOT NULL hide_closed_list boolean DEFAULT false NOT NULL,
iteration_id bigint
); );
CREATE TABLE boards_epic_board_labels ( CREATE TABLE boards_epic_board_labels (
...@@ -20594,6 +20595,8 @@ CREATE INDEX index_boards_epic_user_preferences_on_user_id ON boards_epic_user_p ...@@ -20594,6 +20595,8 @@ CREATE INDEX index_boards_epic_user_preferences_on_user_id ON boards_epic_user_p
CREATE INDEX index_boards_on_group_id ON boards USING btree (group_id); CREATE INDEX index_boards_on_group_id ON boards USING btree (group_id);
CREATE INDEX index_boards_on_iteration_id ON boards USING btree (iteration_id);
CREATE INDEX index_boards_on_milestone_id ON boards USING btree (milestone_id); CREATE INDEX index_boards_on_milestone_id ON boards USING btree (milestone_id);
CREATE INDEX index_boards_on_project_id ON boards USING btree (project_id); CREATE INDEX index_boards_on_project_id ON boards USING btree (project_id);
......
...@@ -11880,6 +11880,11 @@ enum IterationWildcardId { ...@@ -11880,6 +11880,11 @@ enum IterationWildcardId {
""" """
ANY ANY
"""
Current iteration
"""
CURRENT
""" """
No iteration is assigned No iteration is assigned
""" """
......
...@@ -32580,6 +32580,12 @@ ...@@ -32580,6 +32580,12 @@
"description": "An iteration is assigned", "description": "An iteration is assigned",
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
},
{
"name": "CURRENT",
"description": "Current iteration",
"isDeprecated": false,
"deprecationReason": null
} }
], ],
"possibleTypes": null "possibleTypes": null
...@@ -4220,6 +4220,7 @@ Iteration ID wildcard values. ...@@ -4220,6 +4220,7 @@ Iteration ID wildcard values.
| Value | Description | | Value | Description |
| ----- | ----------- | | ----- | ----------- |
| `ANY` | An iteration is assigned | | `ANY` | An iteration is assigned |
| `CURRENT` | Current iteration |
| `NONE` | No iteration is assigned | | `NONE` | No iteration is assigned |
### JobArtifactFileType ### JobArtifactFileType
......
...@@ -9,6 +9,7 @@ export const EpicFilterType = { ...@@ -9,6 +9,7 @@ export const EpicFilterType = {
export const IterationFilterType = { export const IterationFilterType = {
any: 'Any', any: 'Any',
none: 'None', none: 'None',
current: 'Current',
}; };
export const GroupByParamType = { export const GroupByParamType = {
......
...@@ -98,7 +98,8 @@ export default { ...@@ -98,7 +98,8 @@ export default {
if ( if (
filters.iterationId === IterationFilterType.any || filters.iterationId === IterationFilterType.any ||
filters.iterationId === IterationFilterType.none filters.iterationId === IterationFilterType.none ||
filters.iterationId === IterationFilterType.current
) { ) {
filterParams.iterationWildcardId = filters.iterationId.toUpperCase(); filterParams.iterationWildcardId = filters.iterationId.toUpperCase();
} }
......
...@@ -12,6 +12,9 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; ...@@ -12,6 +12,9 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import { parseBoolean } from '~/lib/utils/common_utils'; import { parseBoolean } from '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
const NO_ITERATION_TITLE = 'No+Iteration';
const NO_MILESTONE_TITLE = 'No+Milestone';
class BoardsStoreEE { class BoardsStoreEE {
initEESpecific(boardsStore) { initEESpecific(boardsStore) {
this.$boardApp = document.getElementById('board-app'); this.$boardApp = document.getElementById('board-app');
...@@ -39,6 +42,8 @@ class BoardsStoreEE { ...@@ -39,6 +42,8 @@ class BoardsStoreEE {
dataset: { dataset: {
boardMilestoneId, boardMilestoneId,
boardMilestoneTitle, boardMilestoneTitle,
boardIterationTitle,
boardIterationId,
boardAssigneeUsername, boardAssigneeUsername,
labels, labels,
boardWeight, boardWeight,
...@@ -49,6 +54,8 @@ class BoardsStoreEE { ...@@ -49,6 +54,8 @@ class BoardsStoreEE {
this.store.boardConfig = { this.store.boardConfig = {
milestoneId: parseInt(boardMilestoneId, 10), milestoneId: parseInt(boardMilestoneId, 10),
milestoneTitle: boardMilestoneTitle || '', milestoneTitle: boardMilestoneTitle || '',
iterationId: parseInt(boardIterationId, 10),
iterationTitle: boardIterationTitle || '',
assigneeUsername: boardAssigneeUsername, assigneeUsername: boardAssigneeUsername,
labels: JSON.parse(labels || []), labels: JSON.parse(labels || []),
weight: parseInt(boardWeight, 10), weight: parseInt(boardWeight, 10),
...@@ -101,8 +108,7 @@ class BoardsStoreEE { ...@@ -101,8 +108,7 @@ class BoardsStoreEE {
let { milestoneTitle } = this.store.boardConfig; let { milestoneTitle } = this.store.boardConfig;
if (this.store.boardConfig.milestoneId === 0) { if (this.store.boardConfig.milestoneId === 0) {
/* eslint-disable-next-line @gitlab/require-i18n-strings */ milestoneTitle = NO_MILESTONE_TITLE;
milestoneTitle = 'No+Milestone';
} else { } else {
milestoneTitle = encodeURIComponent(milestoneTitle); milestoneTitle = encodeURIComponent(milestoneTitle);
} }
...@@ -111,6 +117,18 @@ class BoardsStoreEE { ...@@ -111,6 +117,18 @@ class BoardsStoreEE {
this.store.cantEdit.push('milestone'); this.store.cantEdit.push('milestone');
} }
let { iterationTitle } = this.store.boardConfig;
if (this.store.boardConfig.iterationId === 0) {
iterationTitle = NO_ITERATION_TITLE;
} else {
iterationTitle = encodeURIComponent(iterationTitle);
}
if (iterationTitle) {
updateFilterPath('iteration_id', iterationTitle);
this.store.cantEdit.push('iteration');
}
let { weight } = this.store.boardConfig; let { weight } = this.store.boardConfig;
if (weight !== -1) { if (weight !== -1) {
if (weight === 0) { if (weight === 0) {
......
...@@ -101,6 +101,18 @@ export const iterationConditions = [ ...@@ -101,6 +101,18 @@ export const iterationConditions = [
tokenKey: 'iteration', tokenKey: 'iteration',
value: __('Any'), value: __('Any'),
}, },
{
url: 'iteration_id=Current',
operator: '=',
tokenKey: 'iteration',
value: __('Current'),
},
{
url: 'not[iteration_id]=Current',
operator: '!=',
tokenKey: 'iteration',
value: __('Current'),
},
]; ];
/** /**
......
...@@ -75,6 +75,8 @@ module EE ...@@ -75,6 +75,8 @@ module EE
items.no_iteration items.no_iteration
elsif params.filter_by_any_iteration? elsif params.filter_by_any_iteration?
items.any_iteration items.any_iteration
elsif params.filter_by_current_iteration? && get_current_iteration
items.in_iterations(get_current_iteration)
elsif params.filter_by_iteration_title? elsif params.filter_by_iteration_title?
items.with_iteration_title(params[:iteration_title]) items.with_iteration_title(params[:iteration_title])
else else
...@@ -97,9 +99,28 @@ module EE ...@@ -97,9 +99,28 @@ module EE
end end
def by_negated_iteration(items) def by_negated_iteration(items)
return items unless not_params[:iteration_title].present? return items unless not_params.by_iteration?
items.without_iteration_title(not_params[:iteration_title]) if not_params.filter_by_current_iteration?
items.not_in_iterations(get_current_iteration)
else
items.without_iteration_title(not_params[:iteration_title])
end
end
def get_current_iteration
strong_memoize(:current_iteration) do
next unless params.parent
IterationsFinder.new(current_user, iterations_finder_params).execute.first
end
end
def iterations_finder_params
IterationsFinder.params_for_parent(params.parent, include_ancestors: true).merge!(
state: 'opened',
start_date: Date.today,
end_date: Date.today)
end end
end end
end end
...@@ -63,6 +63,10 @@ module EE ...@@ -63,6 +63,10 @@ module EE
params[:iteration_id].to_s.downcase == ::IssuableFinder::Params::FILTER_ANY params[:iteration_id].to_s.downcase == ::IssuableFinder::Params::FILTER_ANY
end end
def filter_by_current_iteration?
params[:iteration_id].to_s.casecmp(::Iteration::Current.title) == 0
end
def filter_by_iteration_title? def filter_by_iteration_title?
params[:iteration_title].present? params[:iteration_title].present?
end end
......
...@@ -8,6 +8,7 @@ module Types ...@@ -8,6 +8,7 @@ module Types
value 'NONE', 'No iteration is assigned' value 'NONE', 'No iteration is assigned'
value 'ANY', 'An iteration is assigned' value 'ANY', 'An iteration is assigned'
value 'CURRENT', 'Current iteration'
end end
end end
end end
...@@ -18,6 +18,8 @@ module EE ...@@ -18,6 +18,8 @@ module EE
data = { data = {
board_milestone_title: board.milestone&.name, board_milestone_title: board.milestone&.name,
board_milestone_id: board.milestone_id, board_milestone_id: board.milestone_id,
board_iteration_title: board.iteration&.title,
board_iteration_id: board.iteration_id,
board_assignee_username: board.assignee&.username, board_assignee_username: board.assignee&.username,
label_ids: board.label_ids, label_ids: board.label_ids,
labels: board.labels.to_json(only: [:id, :title, :color, :text_color] ), labels: board.labels.to_json(only: [:id, :title, :color, :text_color] ),
......
...@@ -10,6 +10,7 @@ module EE ...@@ -10,6 +10,7 @@ module EE
prepended do prepended do
belongs_to :milestone belongs_to :milestone
belongs_to :iteration
has_many :board_labels has_many :board_labels
has_many :user_preferences, class_name: 'BoardUserPreference', inverse_of: :board has_many :user_preferences, class_name: 'BoardUserPreference', inverse_of: :board
...@@ -50,5 +51,20 @@ module EE ...@@ -50,5 +51,20 @@ module EE
super super
end end
end end
def iteration
return unless resource_parent&.feature_available?(:scoped_issue_board)
case iteration_id
when ::Iteration::None.id
::Iteration::None
when ::Iteration::Any.id
::Iteration::Any
when ::Iteration::Current.id
::Iteration::Current
else
super
end
end
end end
end end
...@@ -32,6 +32,7 @@ module EE ...@@ -32,6 +32,7 @@ module EE
scope :no_iteration, -> { where(sprint_id: nil) } scope :no_iteration, -> { where(sprint_id: nil) }
scope :any_iteration, -> { where.not(sprint_id: nil) } scope :any_iteration, -> { where.not(sprint_id: nil) }
scope :in_iterations, ->(iterations) { where(sprint_id: iterations) } scope :in_iterations, ->(iterations) { where(sprint_id: iterations) }
scope :not_in_iterations, ->(iterations) { where(sprint_id: nil).or(where.not(sprint_id: iterations)) }
scope :with_iteration_title, ->(iteration_title) { joins(:iteration).where(sprints: { title: iteration_title }) } scope :with_iteration_title, ->(iteration_title) { joins(:iteration).where(sprints: { title: iteration_title }) }
scope :without_iteration_title, ->(iteration_title) { left_outer_joins(:iteration).where('sprints.title != ? OR sprints.id IS NULL', iteration_title) } scope :without_iteration_title, ->(iteration_title) { left_outer_joins(:iteration).where('sprints.title != ? OR sprints.id IS NULL', iteration_title) }
scope :on_status_page, -> do scope :on_status_page, -> do
......
...@@ -5,8 +5,8 @@ module EE ...@@ -5,8 +5,8 @@ module EE
extend ActiveSupport::Concern extend ActiveSupport::Concern
prepended do prepended do
expose :name expose :milestone, using: EE::TimeboxSimpleEntity, if: ->(board, _) { board&.milestone_id }
expose :milestone, using: EE::MilestoneSimple, if: ->(board, _) { board&.milestone_id } expose :iteration, using: EE::TimeboxSimpleEntity, if: ->(board, _) { board&.iteration_id }
end end
end end
end end
# frozen_string_literal: true # frozen_string_literal: true
module EE module EE
class MilestoneSimple < Grape::Entity class TimeboxSimpleEntity < Grape::Entity
expose :id expose :id
expose :title expose :title
end end
......
...@@ -9,6 +9,9 @@ ...@@ -9,6 +9,9 @@
%li.filter-dropdown-item{ 'data-value' => 'Any' } %li.filter-dropdown-item{ 'data-value' => 'Any' }
%button.btn.gl-button.btn-link %button.btn.gl-button.btn-link
= _('Any') = _('Any')
%li.filter-dropdown-item{ 'data-value' => 'Current' }
%button.btn.gl-button.btn-link
= _('Current')
%li.divider.droplab-item-ignore %li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } } %ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item %li.filter-dropdown-item
......
...@@ -15,7 +15,8 @@ module EE ...@@ -15,7 +15,8 @@ module EE
end end
params :negatable_issue_filter_params_ee do params :negatable_issue_filter_params_ee do
optional :iteration_id, types: [Integer, String], integer_none_any: true, optional :iteration_id, types: [Integer, String],
integer_or_custom_value: [IssuableFinder::Params::FILTER_NONE, IssuableFinder::Params::FILTER_ANY, ::Iteration::Current.title.downcase],
desc: 'Return issues which are assigned to the iteration with the given ID' desc: 'Return issues which are assigned to the iteration with the given ID'
optional :iteration_title, type: String, optional :iteration_title, type: String,
desc: 'Return issues which are assigned to the iteration with the given title' desc: 'Return issues which are assigned to the iteration with the given title'
......
...@@ -16,14 +16,20 @@ RSpec.describe BoardsResponses do ...@@ -16,14 +16,20 @@ RSpec.describe BoardsResponses do
end end
describe '#serialize_as_json' do describe '#serialize_as_json' do
let!(:board) { create(:board, milestone: milestone) } let(:milestone) { nil }
let(:iteration) { nil }
let(:board) { create(:board, milestone: milestone, iteration: iteration) }
context 'with milestone' do context 'without milestone or iteration' do
let(:milestone) { create(:milestone) } it 'serialises properly' do
expected = { id: board.id, name: board.name }.as_json
before do expect(subject.serialize_as_json(board)).to match(expected)
board.update_attribute(:milestone_id, milestone.id)
end end
end
context 'with milestone' do
let_it_be(:milestone) { build_stubbed(:milestone) }
it 'serialises properly' do it 'serialises properly' do
expected = { id: board.id, name: board.name, milestone: { id: milestone.id, title: milestone.title } }.as_json expected = { id: board.id, name: board.name, milestone: { id: milestone.id, title: milestone.title } }.as_json
...@@ -32,11 +38,11 @@ RSpec.describe BoardsResponses do ...@@ -32,11 +38,11 @@ RSpec.describe BoardsResponses do
end end
end end
context 'without milestone' do context 'with iteration' do
let(:milestone) { nil } let_it_be(:iteration) { build_stubbed(:iteration) }
it 'serialises properly' do it 'serialises properly' do
expected = { id: board.id, name: board.name }.as_json expected = { id: board.id, name: board.name, iteration: { id: iteration.id, title: iteration.title } }.as_json
expect(subject.serialize_as_json(board)).to match(expected) expect(subject.serialize_as_json(board)).to match(expected)
end end
......
...@@ -8,7 +8,7 @@ RSpec.describe 'Filter issues by iteration', :js do ...@@ -8,7 +8,7 @@ RSpec.describe 'Filter issues by iteration', :js do
let_it_be(:group) { create(:group, :public) } let_it_be(:group) { create(:group, :public) }
let_it_be(:project) { create(:project, :public, group: group) } let_it_be(:project) { create(:project, :public, group: group) }
let_it_be(:iteration_1) { create(:iteration, group: group) } let_it_be(:iteration_1) { create(:iteration, group: group, start_date: Date.today) }
let_it_be(:iteration_2) { create(:iteration, group: group) } let_it_be(:iteration_2) { create(:iteration, group: group) }
let_it_be(:iteration_1_issue) { create(:issue, project: project, iteration: iteration_1) } let_it_be(:iteration_1_issue) { create(:issue, project: project, iteration: iteration_1) }
...@@ -41,31 +41,65 @@ RSpec.describe 'Filter issues by iteration', :js do ...@@ -41,31 +41,65 @@ RSpec.describe 'Filter issues by iteration', :js do
page.has_content?(no_iteration_issue.title) page.has_content?(no_iteration_issue.title)
end end
it 'filters by iteration' do shared_examples 'filters issues by iteration' do
input_filtered_search("iteration:=\"#{iteration_1.title}\"") it 'filters correct issues' do
aggregate_failures do
expect(page).to have_content(iteration_1_issue.title)
expect(page).not_to have_content(iteration_2_issue.title)
expect(page).not_to have_content(no_iteration_issue.title)
end
end
end
shared_examples 'filters issues by negated iteration' do
it 'filters by negated iteration' do
aggregate_failures do
expect(page).not_to have_content(iteration_1_issue.title)
expect(page).to have_content(iteration_2_issue.title)
expect(page).to have_content(no_iteration_issue.title)
end
end
end
context 'when passing specific iteration by title' do
before do
input_filtered_search("iteration:=\"#{iteration_1.title}\"")
end
aggregate_failures do it_behaves_like 'filters issues by iteration'
expect(page).to have_content(iteration_1_issue.title) end
expect(page).not_to have_content(iteration_2_issue.title)
expect(page).not_to have_content(no_iteration_issue.title) context 'when passing Current iteration' do
before do
input_filtered_search("iteration:=Current", extra_space: false)
end end
it_behaves_like 'filters issues by iteration'
end end
it 'filters by negated iteration' do context 'when filtering by negated iteration' do
page.within('.filtered-search-wrapper') do before do
find('.filtered-search').set('iter') page.within('.filtered-search-wrapper') do
click_button('Iteration') find('.filtered-search').set('iter')
click_button('Iteration')
find('.btn-helptext', text: 'is not').click find('.btn-helptext', text: 'is not').click
click_button(iteration_1.title) click_button(iteration_title)
find('.filtered-search').send_keys(:enter) find('.filtered-search').send_keys(:enter)
end
end end
aggregate_failures do context 'with specific iteration' do
expect(page).not_to have_content(iteration_1_issue.title) let(:iteration_title) { iteration_1.title }
expect(page).to have_content(iteration_2_issue.title)
expect(page).to have_content(no_iteration_issue.title) it_behaves_like 'filters issues by negated iteration'
end
context 'with current iteration' do
let(:iteration_title) { 'Current' }
it_behaves_like 'filters issues by negated iteration'
end end
end end
end end
......
...@@ -8,8 +8,6 @@ RSpec.describe IssuesFinder do ...@@ -8,8 +8,6 @@ RSpec.describe IssuesFinder do
include_context 'IssuesFinder#execute context' include_context 'IssuesFinder#execute context'
context 'scope: all' do context 'scope: all' do
let_it_be(:group) { create(:group) }
let(:scope) { 'all' } let(:scope) { 'all' }
describe 'filter by weight' do describe 'filter by weight' do
...@@ -132,8 +130,8 @@ RSpec.describe IssuesFinder do ...@@ -132,8 +130,8 @@ RSpec.describe IssuesFinder do
end end
context 'filter by iteration' do context 'filter by iteration' do
let_it_be(:iteration_1) { create(:iteration, group: group) } let_it_be(:iteration_1) { create(:iteration, group: group, start_date: 2.days.from_now, due_date: 3.days.from_now) }
let_it_be(:iteration_2) { create(:iteration, group: group) } let_it_be(:iteration_2) { create(:iteration, group: group, start_date: 4.days.from_now, due_date: 5.days.from_now) }
let_it_be(:iteration_1_issue) { create(:issue, project: project1, iteration: iteration_1) } let_it_be(:iteration_1_issue) { create(:issue, project: project1, iteration: iteration_1) }
let_it_be(:iteration_2_issue) { create(:issue, project: project1, iteration: iteration_2) } let_it_be(:iteration_2_issue) { create(:issue, project: project1, iteration: iteration_2) }
...@@ -154,6 +152,34 @@ RSpec.describe IssuesFinder do ...@@ -154,6 +152,34 @@ RSpec.describe IssuesFinder do
end end
end end
context 'filter issues by current iteration' do
let(:current_iteration) { nil }
let(:params) { { group_id: group, iteration_id: ::Iteration::Current.title } }
let!(:current_iteration_issue) { create(:issue, project: project1, iteration: current_iteration) }
context 'when no current iteration is found' do
it 'returns no issues' do
expect(issues).to be_empty
end
end
context 'when current iteration exists' do
let(:current_iteration) { create(:iteration, :started, group: group, start_date: Date.today, due_date: 1.day.from_now) }
it 'returns filtered issues' do
expect(issues).to contain_exactly(current_iteration_issue)
end
context 'filter by negated current iteration' do
let(:params) { { group_id: group, not: { iteration_id: ::Iteration::Current.title } } }
it 'returns filtered issues' do
expect(issues).to contain_exactly(issue1, iteration_1_issue, iteration_2_issue)
end
end
end
end
context 'filter issues by iteration' do context 'filter issues by iteration' do
let(:params) { { iteration_id: iteration_1.id } } let(:params) { { iteration_id: iteration_1.id } }
......
...@@ -3,7 +3,8 @@ ...@@ -3,7 +3,8 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe BoardsHelper do RSpec.describe BoardsHelper do
let(:project) { create(:project) } let_it_be(:project) { create(:project) }
let_it_be(:group) { create(:group) }
describe '#board_list_data' do describe '#board_list_data' do
let(:results) { helper.board_list_data } let(:results) { helper.board_list_data }
...@@ -31,4 +32,39 @@ RSpec.describe BoardsHelper do ...@@ -31,4 +32,39 @@ RSpec.describe BoardsHelper do
expect(board_json).to match_schema('current-board', dir: 'ee') expect(board_json).to match_schema('current-board', dir: 'ee')
end end
end end
describe '#board_data' do
let_it_be(:user) { create(:user) }
let_it_be(:board) { create(:board, project: project) }
let(:board_data) { helper.board_data }
before do
assign(:board, board)
assign(:project, project)
allow(helper).to receive(:current_user) { user }
allow(helper).to receive(:can?).with(user, :create_non_backlog_issues, board).and_return(true)
allow(helper).to receive(:can?).with(user, :admin_issue, board).and_return(true)
end
context 'when no iteration', :aggregate_failures do
it 'serializes board without iteration' do
expect(board_data[:board_iteration_title]).to be_nil
expect(board_data[:board_iteration_id]).to be_nil
end
end
context 'when board is scoped to an iteration' do
let_it_be(:iteration) { create(:iteration, group: group) }
before do
board.update!(iteration: iteration)
end
it 'serializes board with iteration' do
expect(board_data[:board_iteration_title]).to eq(iteration.title)
expect(board_data[:board_iteration_id]).to eq(iteration.id)
end
end
end
end end
...@@ -9,6 +9,7 @@ RSpec.describe Board do ...@@ -9,6 +9,7 @@ RSpec.describe Board do
describe 'relationships' do describe 'relationships' do
it { is_expected.to belong_to(:milestone) } it { is_expected.to belong_to(:milestone) }
it { is_expected.to belong_to(:iteration) }
it { is_expected.to have_one(:board_assignee) } it { is_expected.to have_one(:board_assignee) }
it { is_expected.to have_one(:assignee).through(:board_assignee) } it { is_expected.to have_one(:assignee).through(:board_assignee) }
it { is_expected.to have_many(:board_labels) } it { is_expected.to have_many(:board_labels) }
...@@ -78,6 +79,55 @@ RSpec.describe Board do ...@@ -78,6 +79,55 @@ RSpec.describe Board do
end end
end end
describe 'iteration' do
let_it_be(:group) { create(:group) }
it 'returns nil when the feature is not available' do
stub_licensed_features(scoped_issue_board: false)
iteration = create(:iteration, group: group)
board.iteration_id = iteration.id
expect(board.iteration).to be_nil
end
context 'when the feature is available' do
before do
stub_licensed_features(scoped_issue_board: true)
end
it 'returns Iteration::None, when iteration_id is None.id' do
board.iteration_id = Iteration::None.id
expect(board.iteration).to eq Iteration::None
end
it 'returns Iteration::Any, when iteration_id is Any.id' do
board.iteration_id = Iteration::Any.id
expect(board.iteration).to eq Iteration::Any
end
it 'returns Iteration::Current, when iteration_id is Current.id' do
board.iteration_id = Iteration::Current.id
expect(board.iteration).to eq Iteration::Current
end
it 'returns iteration for valid iteration id' do
iteration = create(:iteration)
board.iteration_id = iteration.id
expect(board.iteration).to eq iteration
end
it 'returns nil for invalid iteration id' do
board.iteration_id = -2
expect(board.iteration).to be_nil
end
end
end
describe '#scoped?' do describe '#scoped?' do
before do before do
stub_licensed_features(scoped_issue_board: true) stub_licensed_features(scoped_issue_board: true)
......
...@@ -211,6 +211,13 @@ RSpec.describe Issue do ...@@ -211,6 +211,13 @@ RSpec.describe Issue do
end end
end end
describe '.not_in_iterations' do
it 'returns issues not in selected iterations' do
expect(described_class.count).to eq 3
expect(described_class.not_in_iterations([iteration1])).to eq [iteration2_issue, issue_no_iteration]
end
end
describe '.with_iteration_title' do describe '.with_iteration_title' do
it 'returns only issues with iterations that match the title' do it 'returns only issues with iterations that match the title' do
expect(described_class.with_iteration_title(iteration1.title)).to eq [iteration1_issue] expect(described_class.with_iteration_title(iteration1.title)).to eq [iteration1_issue]
......
...@@ -182,7 +182,7 @@ RSpec.describe API::Issues, :mailer do ...@@ -182,7 +182,7 @@ RSpec.describe API::Issues, :mailer do
end end
context 'filtering by iteration' do context 'filtering by iteration' do
let_it_be(:iteration_1) { create(:iteration, group: group) } let_it_be(:iteration_1) { create(:iteration, group: group, start_date: Date.today) }
let_it_be(:iteration_2) { create(:iteration, group: group) } let_it_be(:iteration_2) { create(:iteration, group: group) }
let_it_be(:iteration_1_issue) { create(:issue, project: group_project, iteration: iteration_1) } let_it_be(:iteration_1_issue) { create(:issue, project: group_project, iteration: iteration_1) }
let_it_be(:iteration_2_issue) { create(:issue, project: group_project, iteration: iteration_2) } let_it_be(:iteration_2_issue) { create(:issue, project: group_project, iteration: iteration_2) }
...@@ -206,6 +206,12 @@ RSpec.describe API::Issues, :mailer do ...@@ -206,6 +206,12 @@ RSpec.describe API::Issues, :mailer do
expect_response_contain_exactly(iteration_1_issue.id, iteration_2_issue.id) expect_response_contain_exactly(iteration_1_issue.id, iteration_2_issue.id)
end end
it 'returns no issues on user dashboard issues list' do
get api('/issues', user), params: { iteration_id: 'Current' }
expect(json_response).to be_empty
end
it 'returns issues with a specific iteration title' do it 'returns issues with a specific iteration title' do
get api('/issues', user), params: { iteration_title: iteration_1.title } get api('/issues', user), params: { iteration_title: iteration_1.title }
...@@ -243,6 +249,20 @@ RSpec.describe API::Issues, :mailer do ...@@ -243,6 +249,20 @@ RSpec.describe API::Issues, :mailer do
it_behaves_like 'exposes epic' do it_behaves_like 'exposes epic' do
let!(:issue_with_epic) { create(:issue, project: group_project, epic: epic) } let!(:issue_with_epic) { create(:issue, project: group_project, epic: epic) }
end end
context 'filtering by iteration' do
let_it_be(:iteration_1) { create(:iteration, group: group, start_date: Date.today) }
let_it_be(:iteration_2) { create(:iteration, group: group) }
let_it_be(:iteration_1_issue) { create(:issue, project: group_project, iteration: iteration_1) }
let_it_be(:iteration_2_issue) { create(:issue, project: group_project, iteration: iteration_2) }
let_it_be(:no_iteration_issue) { create(:issue, project: group_project) }
it 'returns issues with Current iteration' do
get api("/groups/#{group.id}/issues", user), params: { iteration_id: 'Current', scope: 'all' }
expect_response_contain_exactly(iteration_1_issue.id)
end
end
end end
describe "GET /projects/:id/issues" do describe "GET /projects/:id/issues" do
...@@ -292,6 +312,20 @@ RSpec.describe API::Issues, :mailer do ...@@ -292,6 +312,20 @@ RSpec.describe API::Issues, :mailer do
it_behaves_like 'exposes epic' it_behaves_like 'exposes epic'
end end
context 'filtering by iteration' do
let_it_be(:iteration_1) { create(:iteration, group: group, start_date: Date.today) }
let_it_be(:iteration_2) { create(:iteration, group: group) }
let_it_be(:iteration_1_issue) { create(:issue, project: group_project, iteration: iteration_1) }
let_it_be(:iteration_2_issue) { create(:issue, project: group_project, iteration: iteration_2) }
let_it_be(:no_iteration_issue) { create(:issue, project: group_project) }
it 'returns issues with Current iteration' do
get api("/projects/#{group_project.id}/issues", user), params: { iteration_id: 'Current', scope: 'all' }
expect_response_contain_exactly(iteration_1_issue.id)
end
end
end end
describe 'GET /project/:id/issues/:issue_id' do describe 'GET /project/:id/issues/:issue_id' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BoardSimpleEntity do
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project) }
let_it_be(:board) { create(:board, project: project) }
subject { described_class.new(board).as_json }
describe '#milestone' do
let_it_be(:milestone) { create(:milestone) }
it 'has no `milestone` attribute' do
expect(subject).not_to include(:milestone)
end
it 'has `milestone` attribute' do
board.milestone_id = milestone.id
expect(subject).to include(:milestone)
expect(subject[:milestone]).to eq({ id: milestone.id, title: milestone.title })
end
end
describe '#iteration' do
let_it_be(:iteration) { create(:iteration, group: group) }
it 'has no `iteration` attribute' do
expect(subject).not_to include(:iteration)
end
it 'has `iteration` attribute' do
board.iteration_id = iteration.id
expect(subject).to include(:iteration)
expect(subject[:iteration]).to eq({ id: iteration.id, title: iteration.title })
end
end
end
...@@ -3,15 +3,11 @@ ...@@ -3,15 +3,11 @@
module API module API
module Validations module Validations
module Validators module Validators
class IntegerNoneAny < Grape::Validations::Base class IntegerNoneAny < IntegerOrCustomValue
def validate_param!(attr_name, params) private
value = params[attr_name]
return if value.is_a?(Integer) || def extract_custom_values(options)
[IssuableFinder::Params::FILTER_NONE, IssuableFinder::Params::FILTER_ANY].include?(value.to_s.downcase) [IssuableFinder::Params::FILTER_NONE, IssuableFinder::Params::FILTER_ANY]
raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)],
message: "should be an integer, 'None' or 'Any'"
end end
end end
end end
......
# frozen_string_literal: true
module API
module Validations
module Validators
class IntegerOrCustomValue < Grape::Validations::Base
def initialize(attrs, options, required, scope, **opts)
@custom_values = extract_custom_values(options)
super
end
def validate_param!(attr_name, params)
value = params[attr_name]
return if value.is_a?(Integer)
return if @custom_values.map(&:downcase).include?(value.to_s.downcase)
valid_options = Gitlab::Utils.to_exclusive_sentence(['an integer'] + @custom_values)
raise Grape::Exceptions::Validation,
params: [@scope.full_name(attr_name)],
message: "should be #{valid_options}, however got #{value}"
end
private
def extract_custom_values(options)
options.is_a?(Hash) ? options[:values] : options
end
end
end
end
end
...@@ -342,6 +342,7 @@ excluded_attributes: ...@@ -342,6 +342,7 @@ excluded_attributes:
- :protected_environment_id - :protected_environment_id
boards: boards:
- :milestone_id - :milestone_id
- :iteration_id
lists: lists:
- :board_id - :board_id
- :label_id - :label_id
......
...@@ -8144,6 +8144,9 @@ msgstr "" ...@@ -8144,6 +8144,9 @@ msgstr ""
msgid "Crossplane" msgid "Crossplane"
msgstr "" msgstr ""
msgid "Current"
msgstr ""
msgid "Current Branch" msgid "Current Branch"
msgstr "" msgstr ""
......
...@@ -22,7 +22,7 @@ RSpec.describe 'Database schema' do ...@@ -22,7 +22,7 @@ RSpec.describe 'Database schema' do
audit_events_part_5fc467ac26: %w[author_id entity_id target_id], audit_events_part_5fc467ac26: %w[author_id entity_id target_id],
award_emoji: %w[awardable_id user_id], award_emoji: %w[awardable_id user_id],
aws_roles: %w[role_external_id], aws_roles: %w[role_external_id],
boards: %w[milestone_id], boards: %w[milestone_id iteration_id],
chat_names: %w[chat_id team_id user_id], chat_names: %w[chat_id team_id user_id],
chat_teams: %w[team_id], chat_teams: %w[team_id],
ci_builds: %w[erased_by_id runner_id trigger_request_id user_id], ci_builds: %w[erased_by_id runner_id trigger_request_id user_id],
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Validations::Validators::IntegerOrCustomValue do
include ApiValidatorsHelpers
let(:custom_values) { %w[None Any Started Current] }
subject { described_class.new(['test'], { values: custom_values }, false, scope.new) }
context 'valid parameters' do
it 'does not raise a validation error' do
expect_no_validation_error('test' => 2)
expect_no_validation_error('test' => 100)
expect_no_validation_error('test' => 'None')
expect_no_validation_error('test' => 'Any')
expect_no_validation_error('test' => 'none')
expect_no_validation_error('test' => 'any')
expect_no_validation_error('test' => 'started')
expect_no_validation_error('test' => 'CURRENT')
end
context 'when custom values is empty and value is an integer' do
let(:custom_values) { [] }
it 'does not raise a validation error' do
expect_no_validation_error({ 'test' => 5 })
end
end
end
context 'invalid parameters' do
it 'raises a validation error' do
expect_validation_error({ 'test' => 'Upcomming' })
end
context 'when custom values is empty and value is not an integer' do
let(:custom_values) { [] }
it 'raises a validation error' do
expect_validation_error({ 'test' => '5' })
end
end
end
end
...@@ -645,6 +645,7 @@ boards: ...@@ -645,6 +645,7 @@ boards:
- lists - lists
- destroyable_lists - destroyable_lists
- milestone - milestone
- iteration
- board_labels - board_labels
- board_assignee - board_assignee
- assignee - assignee
......
...@@ -743,6 +743,7 @@ Board: ...@@ -743,6 +743,7 @@ Board:
- updated_at - updated_at
- group_id - group_id
- milestone_id - milestone_id
- iteration_id
- weight - weight
- name - name
- hide_backlog_list - hide_backlog_list
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe BoardSimpleEntity do
let_it_be(:project) { create(:project) }
let_it_be(:board) { create(:board, project: project) }
subject { described_class.new(board).as_json }
describe '#name' do
it 'has `name` attribute' do
is_expected.to include(:name)
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