Commit a9575aaa authored by Mark Chao's avatar Mark Chao

Merge branch 'ajk-graphql-milestone-filters' into 'master'

Add filters to the milestones resolver

See merge request gitlab-org/gitlab!44208
parents d6c28501 82a70da6
......@@ -11,4 +11,11 @@ module TimeFrameFilter
rescue ArgumentError
items
end
def containing_date(items)
return items unless params[:containing_date]
date = params[:containing_date].to_date
items.within_timeframe(date, date)
end
end
......@@ -9,6 +9,8 @@
# order - Orders by field default due date asc.
# title - filter by title.
# state - filters by state.
# start_date & end_date - filters by timeframe (see TimeFrameFilter)
# containing_date - filters by point in time (see TimeFrameFilter)
class MilestonesFinder
include FinderMethods
......@@ -28,6 +30,7 @@ class MilestonesFinder
items = by_search_title(items)
items = by_state(items)
items = by_timeframe(items)
items = containing_date(items)
order(items)
end
......
......@@ -6,6 +6,8 @@ module Resolvers
include ::Gitlab::Utils::StrongMemoize
include ::Gitlab::Graphql::GlobalIDCompatibility
argument_class ::Types::BaseArgument
def self.single
@single ||= Class.new(self) do
def ready?(**args)
......
......@@ -3,21 +3,33 @@
module TimeFrameArguments
extend ActiveSupport::Concern
OVERLAPPING_TIMEFRAME_DESC = 'List items overlapping a time frame defined by startDate..endDate (if one date is provided, both must be present)'
included do
argument :start_date, Types::TimeType,
required: false,
description: 'List items within a time frame where items.start_date is between startDate and endDate parameters (endDate parameter must be present)'
description: OVERLAPPING_TIMEFRAME_DESC,
deprecated: { reason: 'Use timeframe.start', milestone: '14.0' }
argument :end_date, Types::TimeType,
required: false,
description: 'List items within a time frame where items.end_date is between startDate and endDate parameters (startDate parameter must be present)'
description: OVERLAPPING_TIMEFRAME_DESC,
deprecated: { reason: 'Use timeframe.end', milestone: '14.0' }
argument :timeframe, Types::TimeframeInputType,
required: false,
description: 'List items overlapping the given timeframe'
end
# TODO: remove when the start_date and end_date arguments are removed
def validate_timeframe_params!(args)
return unless args[:start_date].present? || args[:end_date].present?
return unless %i[start_date end_date timeframe].any? { |k| args[k].present? }
return if args[:timeframe] && %i[start_date end_date].all? { |k| args[k].nil? }
error_message =
if args[:start_date].nil? || args[:end_date].nil?
if args[:timeframe].present?
"startDate and endDate are deprecated in favor of timeframe. Please use only timeframe."
elsif args[:start_date].nil? || args[:end_date].nil?
"Both startDate and endDate must be present."
elsif args[:start_date] > args[:end_date]
"startDate is after endDate"
......
......@@ -13,6 +13,18 @@ module Resolvers
required: false,
description: 'Filter milestones by state'
argument :title, GraphQL::STRING_TYPE,
required: false,
description: 'The title of the milestone'
argument :search_title, GraphQL::STRING_TYPE,
required: false,
description: 'A search string for the title'
argument :containing_date, Types::TimeType,
required: false,
description: 'A date that the milestone contains'
type Types::MilestoneType, null: true
def resolve(**args)
......@@ -29,9 +41,18 @@ module Resolvers
{
ids: parse_gids(args[:ids]),
state: args[:state] || 'all',
start_date: args[:start_date],
end_date: args[:end_date]
}.merge(parent_id_parameters(args))
title: args[:title],
search_title: args[:search_title],
containing_date: args[:containing_date]
}.merge!(timeframe_parameters(args)).merge!(parent_id_parameters(args))
end
def timeframe_parameters(args)
if args[:timeframe]
args[:timeframe].transform_keys { |k| :"#{k}_date" }
else
args.slice(:start_date, :end_date)
end
end
def parent
......
# frozen_string_literal: true
module Types
class BaseArgument < GraphQL::Schema::Argument
include GitlabStyleDeprecations
def initialize(*args, **kwargs, &block)
kwargs = gitlab_deprecation(kwargs)
kwargs.delete(:deprecation_reason)
super(*args, **kwargs, &block)
end
end
end
......@@ -5,6 +5,8 @@ module Types
prepend Gitlab::Graphql::Authorize
include GitlabStyleDeprecations
argument_class ::Types::BaseArgument
DEFAULT_COMPLEXITY = 1
def initialize(*args, **kwargs, &block)
......
# frozen_string_literal: true
module Types
class DateType < BaseScalar
graphql_name 'Date'
description 'Date represented in ISO 8601'
def self.coerce_input(value, ctx)
return if value.nil?
Date.iso8601(value)
rescue ArgumentError, TypeError => e
raise GraphQL::CoercionError, e.message
end
def self.coerce_result(value, ctx)
return if value.nil?
value.to_date.iso8601
end
end
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class RangeInputType < BaseInputObject
def self.[](type, closed = true)
@subtypes ||= {}
@subtypes[[type, closed]] ||= Class.new(self) do
argument :start, type,
required: closed,
description: 'The start of the range'
argument :end, type,
required: closed,
description: 'The end of the range'
end
end
def prepare
if self[:end] && self[:start] && self[:end] < self[:start]
raise ::Gitlab::Graphql::Errors::ArgumentError, 'start must be before end'
end
to_h
end
end
# rubocop: enable Graphql/AuthorizeTypes
end
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class TimeframeInputType < RangeInputType[::Types::DateType]
graphql_name 'Timeframe'
description 'A time-frame defined as a closed inclusive range of two dates'
end
# rubocop: enable Graphql/AuthorizeTypes
end
......@@ -73,6 +73,32 @@ module Timebox
end
end
# A timebox is within the timeframe (start_date, end_date) if it overlaps
# with that timeframe:
#
# [ timeframe ]
# ----| ................ # Not overlapping
# |--| ................ # Not overlapping
# ------|............... # Overlapping
# -----------------------| # Overlapping
# ---------|............ # Overlapping
# |-----|............ # Overlapping
# |--------------| # Overlapping
# |--------------------| # Overlapping
# ...|-----|...... # Overlapping
# .........|-----| # Overlapping
# .........|--------- # Overlapping
# |-------------------- # Overlapping
# .........|--------| # Overlapping
# ...............|--| # Overlapping
# ............... |-| # Not Overlapping
# ............... |-- # Not Overlapping
#
# where: . = in timeframe
# ---| no start
# |--- no end
# |--| defined start and end
#
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)
......
......@@ -46,6 +46,10 @@ class Milestone < ApplicationRecord
state :active
end
def self.min_chars_for_partial_matching
2
end
def self.reference_prefix
'%'
end
......
---
title: Add filters on Milestone title in the GraphQL API
merge_request: 44208
author:
type: changed
......@@ -1222,8 +1222,8 @@ type BoardEpic implements CurrentUserTodos & Noteable {
before: String
"""
List items within a time frame where items.end_date is between startDate and
endDate parameters (startDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
"""
endDate: Time
......@@ -1273,8 +1273,9 @@ type BoardEpic implements CurrentUserTodos & Noteable {
sort: EpicSort
"""
List items within a time frame where items.start_date is between startDate
and endDate parameters (endDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
"""
startDate: Time
......@@ -1282,6 +1283,11 @@ type BoardEpic implements CurrentUserTodos & Noteable {
Filter epics by state
"""
state: EpicState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
): EpicConnection
"""
......@@ -4278,6 +4284,11 @@ enum DastSiteProfileValidationStatusEnum {
PENDING_VALIDATION
}
"""
Date represented in ISO 8601
"""
scalar Date
"""
Autogenerated input type of DeleteAnnotation
"""
......@@ -5893,8 +5904,8 @@ type Epic implements CurrentUserTodos & Noteable {
before: String
"""
List items within a time frame where items.end_date is between startDate and
endDate parameters (startDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
"""
endDate: Time
......@@ -5944,8 +5955,9 @@ type Epic implements CurrentUserTodos & Noteable {
sort: EpicSort
"""
List items within a time frame where items.start_date is between startDate
and endDate parameters (endDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
"""
startDate: Time
......@@ -5953,6 +5965,11 @@ type Epic implements CurrentUserTodos & Noteable {
Filter epics by state
"""
state: EpicState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
): EpicConnection
"""
......@@ -7381,8 +7398,8 @@ type Group {
authorUsername: String
"""
List items within a time frame where items.end_date is between startDate and
endDate parameters (startDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
"""
endDate: Time
......@@ -7422,8 +7439,9 @@ type Group {
sort: EpicSort
"""
List items within a time frame where items.start_date is between startDate
and endDate parameters (endDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
"""
startDate: Time
......@@ -7431,6 +7449,11 @@ type Group {
Filter epics by state
"""
state: EpicState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
): Epic
"""
......@@ -7453,8 +7476,8 @@ type Group {
before: String
"""
List items within a time frame where items.end_date is between startDate and
endDate parameters (startDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
"""
endDate: Time
......@@ -7504,8 +7527,9 @@ type Group {
sort: EpicSort
"""
List items within a time frame where items.start_date is between startDate
and endDate parameters (endDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
"""
startDate: Time
......@@ -7513,6 +7537,11 @@ type Group {
Filter epics by state
"""
state: EpicState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
): EpicConnection
"""
......@@ -7715,8 +7744,8 @@ type Group {
before: String
"""
List items within a time frame where items.end_date is between startDate and
endDate parameters (startDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
"""
endDate: Time
......@@ -7746,8 +7775,9 @@ type Group {
last: Int
"""
List items within a time frame where items.start_date is between startDate
and endDate parameters (endDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
"""
startDate: Time
......@@ -7756,6 +7786,11 @@ type Group {
"""
state: IterationState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
"""
Fuzzy search by title
"""
......@@ -7912,8 +7947,13 @@ type Group {
before: String
"""
List items within a time frame where items.end_date is between startDate and
endDate parameters (startDate parameter must be present)
A date that the milestone contains
"""
containingDate: Time
"""
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
"""
endDate: Time
......@@ -7938,8 +7978,14 @@ type Group {
last: Int
"""
List items within a time frame where items.start_date is between startDate
and endDate parameters (endDate parameter must be present)
A search string for the title
"""
searchTitle: String
"""
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
"""
startDate: Time
......@@ -7947,6 +7993,16 @@ type Group {
Filter milestones by state
"""
state: MilestoneStateEnum
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
"""
The title of the milestone
"""
title: String
): MilestoneConnection
"""
......@@ -13755,8 +13811,8 @@ type Project {
before: String
"""
List items within a time frame where items.end_date is between startDate and
endDate parameters (startDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
"""
endDate: Time
......@@ -13786,8 +13842,9 @@ type Project {
last: Int
"""
List items within a time frame where items.start_date is between startDate
and endDate parameters (endDate parameter must be present)
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
"""
startDate: Time
......@@ -13796,6 +13853,11 @@ type Project {
"""
state: IterationState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
"""
Fuzzy search by title
"""
......@@ -14004,8 +14066,13 @@ type Project {
before: String
"""
List items within a time frame where items.end_date is between startDate and
endDate parameters (startDate parameter must be present)
A date that the milestone contains
"""
containingDate: Time
"""
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
"""
endDate: Time
......@@ -14030,8 +14097,14 @@ type Project {
last: Int
"""
List items within a time frame where items.start_date is between startDate
and endDate parameters (endDate parameter must be present)
A search string for the title
"""
searchTitle: String
"""
List items overlapping a time frame defined by startDate..endDate (if one
date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
"""
startDate: Time
......@@ -14039,6 +14112,16 @@ type Project {
Filter milestones by state
"""
state: MilestoneStateEnum
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
"""
The title of the milestone
"""
title: String
): MilestoneConnection
"""
......@@ -18336,6 +18419,21 @@ interface TimeboxBurnupTimeSeriesInterface {
burnupTimeSeries: [BurnupChartDailyTotals!]
}
"""
A time-frame defined as a closed inclusive range of two dates
"""
input Timeframe {
"""
The end of the range
"""
end: Date!
"""
The start of the range
"""
start: Date!
}
type Timelog {
"""
Timestamp of when the time tracked was spent at. Deprecated in 12.10: Use `spentAt`
......
......@@ -9,5 +9,10 @@ RSpec.describe Iteration do
it_behaves_like 'a timebox', :iteration do
let(:timebox_args) { [:skip_project_validation] }
let(:timebox_table_name) { described_class.table_name.to_sym }
# Overrides used during .within_timeframe
let(:mid_point) { 1.year.from_now.to_date }
let(:open_on_left) { min_date - 100.days }
let(:open_on_right) { max_date + 100.days }
end
end
......@@ -15,6 +15,12 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
let_it_be(:now) { Time.now }
let_it_be(:group) { create(:group, :private) }
def args(**arguments)
satisfy("contain only #{arguments.inspect}") do |passed|
expect(passed.compact).to match(arguments)
end
end
before_all do
group.add_developer(current_user)
end
......@@ -30,7 +36,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
context 'without parameters' do
it 'calls MilestonesFinder to retrieve all milestones' do
expect(MilestonesFinder).to receive(:new)
.with(ids: nil, group_ids: group.id, state: 'all', start_date: nil, end_date: nil)
.with(args(group_ids: group.id, state: 'all'))
.and_call_original
resolve_group_milestones
......@@ -43,11 +49,22 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
end_date = start_date + 1.hour
expect(MilestonesFinder).to receive(:new)
.with(ids: nil, group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date)
.with(args(group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date))
.and_call_original
resolve_group_milestones(start_date: start_date, end_date: end_date, state: 'closed')
end
it 'understands the timeframe argument' do
start_date = now
end_date = start_date + 1.hour
expect(MilestonesFinder).to receive(:new)
.with(args(group_ids: group.id, state: 'closed', start_date: start_date, end_date: end_date))
.and_call_original
resolve_group_milestones(timeframe: { start: start_date, end: end_date }, state: 'closed')
end
end
context 'by ids' do
......@@ -55,7 +72,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
milestone = create(:milestone, group: group)
expect(MilestonesFinder).to receive(:new)
.with(ids: [milestone.id.to_s], group_ids: group.id, state: 'all', start_date: nil, end_date: nil)
.with(args(ids: [milestone.id.to_s], group_ids: group.id, state: 'all'))
.and_call_original
resolve_group_milestones(ids: [milestone.to_global_id])
......
......@@ -13,13 +13,19 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
project.add_developer(current_user)
end
def args(**arguments)
satisfy("contain only #{arguments.inspect}") do |passed|
expect(passed.compact).to match(arguments)
end
end
def resolve_project_milestones(args = {}, context = { current_user: current_user })
resolve(described_class, obj: project, args: args, ctx: context)
end
it 'calls MilestonesFinder to retrieve all milestones' do
expect(MilestonesFinder).to receive(:new)
.with(ids: nil, project_ids: project.id, state: 'all', start_date: nil, end_date: nil)
.with(args(project_ids: project.id, state: 'all'))
.and_call_original
resolve_project_milestones
......@@ -36,7 +42,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
it 'calls MilestonesFinder with correct parameters' do
expect(MilestonesFinder).to receive(:new)
.with(ids: nil, project_ids: project.id, group_ids: contain_exactly(group, parent_group), state: 'all', start_date: nil, end_date: nil)
.with(args(project_ids: project.id, group_ids: contain_exactly(group, parent_group), state: 'all'))
.and_call_original
resolve_project_milestones(include_ancestors: true)
......@@ -48,7 +54,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
milestone = create(:milestone, project: project)
expect(MilestonesFinder).to receive(:new)
.with(ids: [milestone.id.to_s], project_ids: project.id, state: 'all', start_date: nil, end_date: nil)
.with(args(ids: [milestone.id.to_s], project_ids: project.id, state: 'all'))
.and_call_original
resolve_project_milestones(ids: [milestone.to_global_id])
......@@ -58,7 +64,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
context 'by state' do
it 'calls MilestonesFinder with correct parameters' do
expect(MilestonesFinder).to receive(:new)
.with(ids: nil, project_ids: project.id, state: 'closed', start_date: nil, end_date: nil)
.with(args(project_ids: project.id, state: 'closed'))
.and_call_original
resolve_project_milestones(state: 'closed')
......@@ -72,7 +78,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
end_date = Time.now + 5.days
expect(MilestonesFinder).to receive(:new)
.with(ids: nil, project_ids: project.id, state: 'all', start_date: start_date, end_date: end_date)
.with(args(project_ids: project.id, state: 'all', start_date: start_date, end_date: end_date))
.and_call_original
resolve_project_milestones(start_date: start_date, end_date: end_date)
......@@ -102,6 +108,51 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/)
end
end
context 'when passing a timeframe' do
it 'calls MilestonesFinder with correct parameters' do
start_date = Time.now
end_date = Time.now + 5.days
expect(MilestonesFinder).to receive(:new)
.with(args(project_ids: project.id, state: 'all', start_date: start_date, end_date: end_date))
.and_call_original
resolve_project_milestones(timeframe: { start: start_date, end: end_date })
end
end
end
context 'when title is present' do
it 'calls MilestonesFinder with correct parameters' do
expect(MilestonesFinder).to receive(:new)
.with(args(title: '13.5', state: 'all', project_ids: project.id))
.and_call_original
resolve_project_milestones(title: '13.5')
end
end
context 'when search_title is present' do
it 'calls MilestonesFinder with correct parameters' do
expect(MilestonesFinder).to receive(:new)
.with(args(search_title: '13', state: 'all', project_ids: project.id))
.and_call_original
resolve_project_milestones(search_title: '13')
end
end
context 'when containing date is present' do
it 'calls MilestonesFinder with correct parameters' do
t = Time.now
expect(MilestonesFinder).to receive(:new)
.with(args(containing_date: t, state: 'all', project_ids: project.id))
.and_call_original
resolve_project_milestones(containing_date: t)
end
end
context 'when user cannot read milestones' do
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Types::RangeInputType do
let(:of_integer) { ::GraphQL::INT_TYPE }
context 'parameterized on Integer' do
let(:type) { described_class[of_integer] }
it 'accepts start and end' do
input = { start: 1, end: 10 }
output = { start: 1, end: 10 }
expect(type.coerce_isolated_input(input)).to eq(output)
end
it 'rejects inverted ranges' do
input = { start: 10, end: 1 }
expect { type.coerce_isolated_input(input) }.to raise_error(Gitlab::Graphql::Errors::ArgumentError)
end
end
it 'follows expected subtyping relationships for instances' do
context = GraphQL::Query::Context.new(
query: OpenStruct.new(schema: nil),
values: {},
object: nil
)
instance = described_class[of_integer].new(context: context, defaults_used: [], ruby_kwargs: {})
expect(instance).to be_a_kind_of(described_class)
expect(instance).to be_a_kind_of(described_class[of_integer])
expect(instance).not_to be_a_kind_of(described_class[GraphQL::ID_TYPE])
end
it 'follows expected subtyping relationships for classes' do
expect(described_class[of_integer]).to be < described_class
expect(described_class[of_integer]).not_to be < described_class[GraphQL::ID_TYPE]
expect(described_class[of_integer]).not_to be < described_class[of_integer, false]
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['Timeframe'] do
let(:input) { { start: "2018-06-04", end: "2020-10-06" } }
let(:output) { { start: Date.parse(input[:start]), end: Date.parse(input[:end]) } }
it 'coerces ISO-dates into Time objects' do
expect(described_class.coerce_isolated_input(input)).to eq(output)
end
it 'rejects invalid input' do
input[:start] = 'foo'
expect { described_class.coerce_isolated_input(input) }
.to raise_error(GraphQL::CoercionError)
end
it 'accepts times as input' do
with_time = input.merge(start: '2018-06-04T13:48:14Z')
expect(described_class.coerce_isolated_input(with_time)).to eq(output)
end
it 'requires both ends of the range' do
types = described_class.arguments.slice('start', 'end').values.map(&:type)
expect(types).to all(be_non_null)
end
it 'rejects invalid range' do
input.merge!(start: input[:end], end: input[:start])
expect { described_class.coerce_isolated_input(input) }
.to raise_error(::Gitlab::Graphql::Errors::ArgumentError, 'start must be before end')
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'getting milestone listings nested in a project' do
include GraphqlHelpers
let_it_be(:today) { Time.now.utc.to_date }
let_it_be(:project) { create(:project, :repository, :public) }
let_it_be(:current_user) { create(:user) }
let_it_be(:no_dates) { create(:milestone, project: project, title: 'no dates') }
let_it_be(:no_end) { create(:milestone, project: project, title: 'no end', start_date: today - 10.days) }
let_it_be(:no_start) { create(:milestone, project: project, title: 'no start', due_date: today - 5.days) }
let_it_be(:fully_past) { create(:milestone, project: project, title: 'past', start_date: today - 10.days, due_date: today - 5.days) }
let_it_be(:covers_today) { create(:milestone, project: project, title: 'present', start_date: today - 5.days, due_date: today + 5.days) }
let_it_be(:fully_future) { create(:milestone, project: project, title: 'future', start_date: today + 5.days, due_date: today + 10.days) }
let_it_be(:closed) { create(:milestone, :closed, project: project) }
let(:results) { graphql_data_at(:project, :milestones, :nodes) }
let(:search_params) { nil }
def query_milestones(fields)
graphql_query_for(
:project,
{ full_path: project.full_path },
query_graphql_field(:milestones, search_params, [
query_graphql_field(:nodes, nil, %i[id title])
])
)
end
def result_list(expected)
expected.map do |milestone|
a_hash_including('id' => global_id_of(milestone))
end
end
let(:query) do
query_milestones(all_graphql_fields_for('Milestone', max_depth: 1))
end
let(:all_milestones) do
[no_dates, no_end, no_start, fully_past, fully_future, covers_today, closed]
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
shared_examples 'searching with parameters' do
it 'finds the right milestones' do
post_graphql(query, current_user: current_user)
expect(results).to match_array(result_list(expected))
end
end
context 'there are no search params' do
let(:search_params) { nil }
let(:expected) { all_milestones }
it_behaves_like 'searching with parameters'
end
context 'the search params do not match anything' do
let(:search_params) { { title: 'wibble' } }
let(:expected) { [] }
it_behaves_like 'searching with parameters'
end
context 'searching by state:closed' do
let(:search_params) { { state: :closed } }
let(:expected) { [closed] }
it_behaves_like 'searching with parameters'
end
context 'searching by state:active' do
let(:search_params) { { state: :active } }
let(:expected) { all_milestones - [closed] }
it_behaves_like 'searching with parameters'
end
context 'searching by title' do
let(:search_params) { { title: 'no start' } }
let(:expected) { [no_start] }
it_behaves_like 'searching with parameters'
end
context 'searching by search_title' do
let(:search_params) { { search_title: 'no' } }
let(:expected) { [no_dates, no_start, no_end] }
it_behaves_like 'searching with parameters'
end
context 'searching by containing_date' do
let(:search_params) { { containing_date: (today - 7.days).iso8601 } }
let(:expected) { [no_start, no_end, fully_past] }
it_behaves_like 'searching with parameters'
end
context 'searching by containing_date = today' do
let(:search_params) { { containing_date: today.iso8601 } }
let(:expected) { [no_end, covers_today] }
it_behaves_like 'searching with parameters'
end
context 'searching by custom range' do
let(:expected) { [no_end, fully_future] }
let(:search_params) do
{
start_date: (today + 6.days).iso8601,
end_date: (today + 7.days).iso8601
}
end
it_behaves_like 'searching with parameters'
end
context 'using timeframe argument' do
let(:expected) { [no_end, fully_future] }
let(:search_params) do
{
timeframe: {
start: (today + 6.days).iso8601,
end: (today + 7.days).iso8601
}
}
end
it_behaves_like 'searching with parameters'
end
describe 'timeframe validations' do
let(:vars) do
{
path: project.full_path,
start: (today + 6.days).iso8601,
end: (today + 7.days).iso8601
}
end
it_behaves_like 'a working graphql query' do
before do
query = <<~GQL
query($path: ID!, $start: Date!, $end: Date!) {
project(fullPath: $path) {
milestones(timeframe: { start: $start, end: $end }) {
nodes { id }
}
}
}
GQL
post_graphql(query, current_user: current_user, variables: vars)
end
end
it 'is invalid to provide timeframe and start_date/end_date' do
query = <<~GQL
query($path: ID!, $tstart: Date!, $tend: Date!, $start: Time!, $end: Time!) {
project(fullPath: $path) {
milestones(timeframe: { start: $tstart, end: $tend }, startDate: $start, endDate: $end) {
nodes { id }
}
}
}
GQL
post_graphql(query, current_user: current_user,
variables: vars.merge(vars.transform_keys { |k| :"t#{k}" }))
expect(graphql_errors).to contain_exactly(a_hash_including('message' => include('deprecated in favor of timeframe')))
end
it 'is invalid to invert the timeframe arguments' do
query = <<~GQL
query($path: ID!, $start: Date!, $end: Date!) {
project(fullPath: $path) {
milestones(timeframe: { start: $end, end: $start }) {
nodes { id }
}
}
}
GQL
post_graphql(query, current_user: current_user, variables: vars)
expect(graphql_errors).to contain_exactly(a_hash_including('message' => include('start must be before end')))
end
end
end
......@@ -219,7 +219,7 @@ module GraphqlHelpers
def as_graphql_literal(value)
case value
when Array then "[#{value.map { |v| as_graphql_literal(v) }.join(',')}]"
when Hash then "{#{value.map { |k, v| "#{k}:#{as_graphql_literal(v)}" }.join(',')}}"
when Hash then "{#{attributes_to_graphql(value)}}"
when Integer, Float then value.to_s
when String then "\"#{value.gsub(/"/, '\\"')}\""
when Symbol then value
......
......@@ -9,6 +9,11 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
let(:user) { create(:user) }
let(:timebox_table_name) { timebox_type.to_s.pluralize.to_sym }
# Values implementions can override
let(:mid_point) { Time.now.utc.to_date }
let(:open_on_left) { nil }
let(:open_on_right) { nil }
describe 'modules' do
context 'with a project' do
it_behaves_like 'AtomicInternalId' do
......@@ -240,4 +245,85 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
expect(timebox.to_ability_name).to eq(timebox_type.to_s)
end
end
describe '.within_timeframe' do
let(:factory) { timebox_type }
let(:min_date) { mid_point - 10.days }
let(:max_date) { mid_point + 10.days }
def box(from, to)
create(factory, *timebox_args,
start_date: from || open_on_left,
due_date: to || open_on_right)
end
it 'can find overlapping timeboxes' do
fully_open = box(nil, nil)
# ----| ................ # Not overlapping
non_overlapping_open_on_left = box(nil, min_date - 1.day)
# |--| ................ # Not overlapping
non_overlapping_closed_on_left = box(min_date - 2.days, min_date - 1.day)
# ------|............... # Overlapping
overlapping_open_on_left_just = box(nil, min_date)
# -----------------------| # Overlapping
overlapping_open_on_left_fully = box(nil, max_date + 1.day)
# ---------|............ # Overlapping
overlapping_open_on_left_partial = box(nil, min_date + 1.day)
# |-----|............ # Overlapping
overlapping_closed_partial = box(min_date - 1.day, min_date + 1.day)
# |--------------| # Overlapping
exact_match = box(min_date, max_date)
# |--------------------| # Overlapping
larger = box(min_date - 1.day, max_date + 1.day)
# ...|-----|...... # Overlapping
smaller = box(min_date + 1.day, max_date - 1.day)
# .........|-----| # Overlapping
at_end = box(max_date - 1.day, max_date)
# .........|--------- # Overlapping
at_end_open = box(max_date - 1.day, nil)
# |-------------------- # Overlapping
cover_from_left = box(min_date - 1.day, nil)
# .........|--------| # Overlapping
cover_from_middle_closed = box(max_date - 1.day, max_date + 1.day)
# ...............|--| # Overlapping
overlapping_at_end_just = box(max_date, max_date + 1.day)
# ............... |-| # Not Overlapping
not_overlapping_at_right_closed = box(max_date + 1.day, max_date + 2.days)
# ............... |-- # Not Overlapping
not_overlapping_at_right_open = box(max_date + 1.day, nil)
matches = described_class.within_timeframe(min_date, max_date)
expect(matches).to include(
overlapping_open_on_left_just,
overlapping_open_on_left_fully,
overlapping_open_on_left_partial,
overlapping_closed_partial,
exact_match,
larger,
smaller,
at_end,
at_end_open,
cover_from_left,
cover_from_middle_closed,
overlapping_at_end_just
)
expect(matches).not_to include(
non_overlapping_open_on_left,
non_overlapping_closed_on_left,
not_overlapping_at_right_closed,
not_overlapping_at_right_open
)
# Whether we match the 'fully-open' range depends on whether
# it is in fact open (i.e. whether the class allows infinite
# ranges)
if open_on_left.nil? && open_on_right.nil?
expect(matches).not_to include(fully_open)
else
expect(matches).to include(fully_open)
end
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