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
params.merge!(other)
end
def parent
project || group
end
private
def projects_public_or_visible_to_user
......
......@@ -24,6 +24,8 @@ module Timebox
Any = TimeboxStruct.new('Any Timebox', '', -1)
Upcoming = TimeboxStruct.new('Upcoming', '#upcoming', -2)
Started = TimeboxStruct.new('Started', '#started', -3)
# For Iteration
Current = TimeboxStruct.new('Current', '#current', -4)
included do
# Defines the same constants above, but inside the including class.
......@@ -31,6 +33,7 @@ module Timebox
const_set :Any, TimeboxStruct.new("Any #{self.name}", '', -1)
const_set :Upcoming, TimeboxStruct.new('Upcoming', '#upcoming', -2)
const_set :Started, TimeboxStruct.new('Started', '#started', -3)
const_set :Current, TimeboxStruct.new('Current', '#current', -4)
alias_method :timebox_id, :id
......
......@@ -32,9 +32,9 @@ class Iteration < ApplicationRecord
scope :closed, -> { with_state(:closed) }
scope :within_timeframe, -> (start_date, end_date) do
where('start_date is not NULL or due_date is not NULL')
.where('start_date is NULL or start_date <= ?', end_date)
.where('due_date is NULL or due_date >= ?', start_date)
where('start_date IS NOT NULL OR due_date IS NOT NULL')
.where('start_date IS NULL OR start_date <= ?', end_date)
.where('due_date IS NULL OR due_date >= ?', start_date)
end
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
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(: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 (
group_id integer,
weight integer,
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 (
......@@ -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_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_project_id ON boards USING btree (project_id);
......
......@@ -11880,6 +11880,11 @@ enum IterationWildcardId {
"""
ANY
"""
Current iteration
"""
CURRENT
"""
No iteration is assigned
"""
......
......@@ -32580,6 +32580,12 @@
"description": "An iteration is assigned",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "CURRENT",
"description": "Current iteration",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
......@@ -4220,6 +4220,7 @@ Iteration ID wildcard values.
| Value | Description |
| ----- | ----------- |
| `ANY` | An iteration is assigned |
| `CURRENT` | Current iteration |
| `NONE` | No iteration is assigned |
### JobArtifactFileType
......
......@@ -9,6 +9,7 @@ export const EpicFilterType = {
export const IterationFilterType = {
any: 'Any',
none: 'None',
current: 'Current',
};
export const GroupByParamType = {
......
......@@ -98,7 +98,8 @@ export default {
if (
filters.iterationId === IterationFilterType.any ||
filters.iterationId === IterationFilterType.none
filters.iterationId === IterationFilterType.none ||
filters.iterationId === IterationFilterType.current
) {
filterParams.iterationWildcardId = filters.iterationId.toUpperCase();
}
......
......@@ -12,6 +12,9 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import { parseBoolean } from '~/lib/utils/common_utils';
import axios from '~/lib/utils/axios_utils';
const NO_ITERATION_TITLE = 'No+Iteration';
const NO_MILESTONE_TITLE = 'No+Milestone';
class BoardsStoreEE {
initEESpecific(boardsStore) {
this.$boardApp = document.getElementById('board-app');
......@@ -39,6 +42,8 @@ class BoardsStoreEE {
dataset: {
boardMilestoneId,
boardMilestoneTitle,
boardIterationTitle,
boardIterationId,
boardAssigneeUsername,
labels,
boardWeight,
......@@ -49,6 +54,8 @@ class BoardsStoreEE {
this.store.boardConfig = {
milestoneId: parseInt(boardMilestoneId, 10),
milestoneTitle: boardMilestoneTitle || '',
iterationId: parseInt(boardIterationId, 10),
iterationTitle: boardIterationTitle || '',
assigneeUsername: boardAssigneeUsername,
labels: JSON.parse(labels || []),
weight: parseInt(boardWeight, 10),
......@@ -101,8 +108,7 @@ class BoardsStoreEE {
let { milestoneTitle } = this.store.boardConfig;
if (this.store.boardConfig.milestoneId === 0) {
/* eslint-disable-next-line @gitlab/require-i18n-strings */
milestoneTitle = 'No+Milestone';
milestoneTitle = NO_MILESTONE_TITLE;
} else {
milestoneTitle = encodeURIComponent(milestoneTitle);
}
......@@ -111,6 +117,18 @@ class BoardsStoreEE {
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;
if (weight !== -1) {
if (weight === 0) {
......
......@@ -101,6 +101,18 @@ export const iterationConditions = [
tokenKey: 'iteration',
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
items.no_iteration
elsif params.filter_by_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?
items.with_iteration_title(params[:iteration_title])
else
......@@ -97,9 +99,28 @@ module EE
end
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
......@@ -63,6 +63,10 @@ module EE
params[:iteration_id].to_s.downcase == ::IssuableFinder::Params::FILTER_ANY
end
def filter_by_current_iteration?
params[:iteration_id].to_s.casecmp(::Iteration::Current.title) == 0
end
def filter_by_iteration_title?
params[:iteration_title].present?
end
......
......@@ -8,6 +8,7 @@ module Types
value 'NONE', 'No iteration is assigned'
value 'ANY', 'An iteration is assigned'
value 'CURRENT', 'Current iteration'
end
end
end
......@@ -18,6 +18,8 @@ module EE
data = {
board_milestone_title: board.milestone&.name,
board_milestone_id: board.milestone_id,
board_iteration_title: board.iteration&.title,
board_iteration_id: board.iteration_id,
board_assignee_username: board.assignee&.username,
label_ids: board.label_ids,
labels: board.labels.to_json(only: [:id, :title, :color, :text_color] ),
......
......@@ -10,6 +10,7 @@ module EE
prepended do
belongs_to :milestone
belongs_to :iteration
has_many :board_labels
has_many :user_preferences, class_name: 'BoardUserPreference', inverse_of: :board
......@@ -50,5 +51,20 @@ module EE
super
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
......@@ -32,6 +32,7 @@ module EE
scope :no_iteration, -> { where(sprint_id: nil) }
scope :any_iteration, -> { where.not(sprint_id: nil) }
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 :without_iteration_title, ->(iteration_title) { left_outer_joins(:iteration).where('sprints.title != ? OR sprints.id IS NULL', iteration_title) }
scope :on_status_page, -> do
......
......@@ -5,8 +5,8 @@ module EE
extend ActiveSupport::Concern
prepended do
expose :name
expose :milestone, using: EE::MilestoneSimple, if: ->(board, _) { board&.milestone_id }
expose :milestone, using: EE::TimeboxSimpleEntity, if: ->(board, _) { board&.milestone_id }
expose :iteration, using: EE::TimeboxSimpleEntity, if: ->(board, _) { board&.iteration_id }
end
end
end
# frozen_string_literal: true
module EE
class MilestoneSimple < Grape::Entity
class TimeboxSimpleEntity < Grape::Entity
expose :id
expose :title
end
......
......@@ -9,6 +9,9 @@
%li.filter-dropdown-item{ 'data-value' => 'Any' }
%button.btn.gl-button.btn-link
= _('Any')
%li.filter-dropdown-item{ 'data-value' => 'Current' }
%button.btn.gl-button.btn-link
= _('Current')
%li.divider.droplab-item-ignore
%ul.filter-dropdown{ data: { dropdown: true, dynamic: true } }
%li.filter-dropdown-item
......
......@@ -15,7 +15,8 @@ module EE
end
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'
optional :iteration_title, type: String,
desc: 'Return issues which are assigned to the iteration with the given title'
......
......@@ -16,14 +16,20 @@ RSpec.describe BoardsResponses do
end
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
let(:milestone) { create(:milestone) }
context 'without milestone or iteration' do
it 'serialises properly' do
expected = { id: board.id, name: board.name }.as_json
before do
board.update_attribute(:milestone_id, milestone.id)
expect(subject.serialize_as_json(board)).to match(expected)
end
end
context 'with milestone' do
let_it_be(:milestone) { build_stubbed(:milestone) }
it 'serialises properly' do
expected = { id: board.id, name: board.name, milestone: { id: milestone.id, title: milestone.title } }.as_json
......@@ -32,11 +38,11 @@ RSpec.describe BoardsResponses do
end
end
context 'without milestone' do
let(:milestone) { nil }
context 'with iteration' do
let_it_be(:iteration) { build_stubbed(:iteration) }
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)
end
......
......@@ -8,7 +8,7 @@ RSpec.describe 'Filter issues by iteration', :js do
let_it_be(:group) { create(:group, :public) }
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_1_issue) { create(:issue, project: project, iteration: iteration_1) }
......@@ -41,31 +41,65 @@ RSpec.describe 'Filter issues by iteration', :js do
page.has_content?(no_iteration_issue.title)
end
it 'filters by iteration' do
input_filtered_search("iteration:=\"#{iteration_1.title}\"")
shared_examples 'filters issues by iteration' do
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
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)
it_behaves_like 'filters issues by iteration'
end
context 'when passing Current iteration' do
before do
input_filtered_search("iteration:=Current", extra_space: false)
end
it_behaves_like 'filters issues by iteration'
end
it 'filters by negated iteration' do
page.within('.filtered-search-wrapper') do
find('.filtered-search').set('iter')
click_button('Iteration')
context 'when filtering by negated iteration' do
before do
page.within('.filtered-search-wrapper') do
find('.filtered-search').set('iter')
click_button('Iteration')
find('.btn-helptext', text: 'is not').click
click_button(iteration_1.title)
find('.btn-helptext', text: 'is not').click
click_button(iteration_title)
find('.filtered-search').send_keys(:enter)
find('.filtered-search').send_keys(:enter)
end
end
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)
context 'with specific iteration' do
let(:iteration_title) { iteration_1.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
......
......@@ -8,8 +8,6 @@ RSpec.describe IssuesFinder do
include_context 'IssuesFinder#execute context'
context 'scope: all' do
let_it_be(:group) { create(:group) }
let(:scope) { 'all' }
describe 'filter by weight' do
......@@ -132,8 +130,8 @@ RSpec.describe IssuesFinder do
end
context 'filter by iteration' do
let_it_be(:iteration_1) { create(:iteration, group: group) }
let_it_be(:iteration_2) { 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, 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_2_issue) { create(:issue, project: project1, iteration: iteration_2) }
......@@ -154,6 +152,34 @@ RSpec.describe IssuesFinder do
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
let(:params) { { iteration_id: iteration_1.id } }
......
......@@ -3,7 +3,8 @@
require 'spec_helper'
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
let(:results) { helper.board_list_data }
......@@ -31,4 +32,39 @@ RSpec.describe BoardsHelper do
expect(board_json).to match_schema('current-board', dir: 'ee')
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
......@@ -9,6 +9,7 @@ RSpec.describe Board do
describe 'relationships' do
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(:assignee).through(:board_assignee) }
it { is_expected.to have_many(:board_labels) }
......@@ -78,6 +79,55 @@ RSpec.describe Board do
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
before do
stub_licensed_features(scoped_issue_board: true)
......
......@@ -211,6 +211,13 @@ RSpec.describe Issue do
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
it 'returns only issues with iterations that match the title' do
expect(described_class.with_iteration_title(iteration1.title)).to eq [iteration1_issue]
......
......@@ -182,7 +182,7 @@ RSpec.describe API::Issues, :mailer do
end
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_1_issue) { create(:issue, project: group_project, iteration: iteration_1) }
let_it_be(:iteration_2_issue) { create(:issue, project: group_project, iteration: iteration_2) }
......@@ -206,6 +206,12 @@ RSpec.describe API::Issues, :mailer do
expect_response_contain_exactly(iteration_1_issue.id, iteration_2_issue.id)
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
get api('/issues', user), params: { iteration_title: iteration_1.title }
......@@ -243,6 +249,20 @@ RSpec.describe API::Issues, :mailer do
it_behaves_like 'exposes epic' do
let!(:issue_with_epic) { create(:issue, project: group_project, epic: epic) }
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
describe "GET /projects/:id/issues" do
......@@ -292,6 +312,20 @@ RSpec.describe API::Issues, :mailer do
it_behaves_like 'exposes epic'
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
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 @@
module API
module Validations
module Validators
class IntegerNoneAny < Grape::Validations::Base
def validate_param!(attr_name, params)
value = params[attr_name]
class IntegerNoneAny < IntegerOrCustomValue
private
return if value.is_a?(Integer) ||
[IssuableFinder::Params::FILTER_NONE, IssuableFinder::Params::FILTER_ANY].include?(value.to_s.downcase)
raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)],
message: "should be an integer, 'None' or 'Any'"
def extract_custom_values(options)
[IssuableFinder::Params::FILTER_NONE, IssuableFinder::Params::FILTER_ANY]
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:
- :protected_environment_id
boards:
- :milestone_id
- :iteration_id
lists:
- :board_id
- :label_id
......
......@@ -8144,6 +8144,9 @@ msgstr ""
msgid "Crossplane"
msgstr ""
msgid "Current"
msgstr ""
msgid "Current Branch"
msgstr ""
......
......@@ -22,7 +22,7 @@ RSpec.describe 'Database schema' do
audit_events_part_5fc467ac26: %w[author_id entity_id target_id],
award_emoji: %w[awardable_id user_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_teams: %w[team_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:
- lists
- destroyable_lists
- milestone
- iteration
- board_labels
- board_assignee
- assignee
......
......@@ -743,6 +743,7 @@ Board:
- updated_at
- group_id
- milestone_id
- iteration_id
- weight
- name
- 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