Commit cf337d65 authored by Luke Duncalfe's avatar Luke Duncalfe

Merge branch 'add-be-for-scoping-issue-boards-to-current-iteration' into 'master'

Filter issue lists and issue boards by current iteration

See merge request gitlab-org/gitlab!48040
parents cdd241d2 7b1d1016
......@@ -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
......@@ -9847,7 +9847,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 (
......@@ -20627,6 +20628,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);
......
......@@ -12025,6 +12025,11 @@ enum IterationWildcardId {
"""
ANY
"""
Current iteration
"""
CURRENT
"""
No iteration is assigned
"""
......
......@@ -33058,6 +33058,12 @@
"description": "An iteration is assigned",
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "CURRENT",
"description": "Current iteration",
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
......@@ -4272,6 +4272,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?
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
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
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)
click_button(iteration_title)
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
......
......@@ -8156,6 +8156,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