Commit c6253a8d authored by Alex Kalderimis's avatar Alex Kalderimis

Add filters to the milestones resolver

This adds title filters to the milestones resolver, and a simplified
form of the date search, which makes it easier to search for milestones
overlapping a given date.

Tests are added for `within_timeframe`. (this involves subtle
differences for Milestone and Iteration, since they have different
validations)

To improve types and error handling for clients, the separate
startDate/endDate arguments are deprecated in favor of a custom time
range type.
parent 4c1c6240
...@@ -11,4 +11,11 @@ module TimeFrameFilter ...@@ -11,4 +11,11 @@ module TimeFrameFilter
rescue ArgumentError rescue ArgumentError
items items
end end
def containing_date(items)
return items unless params[:containing_date]
date = params[:containing_date].to_date
items.within_timeframe(date, date)
end
end end
...@@ -28,6 +28,7 @@ class MilestonesFinder ...@@ -28,6 +28,7 @@ class MilestonesFinder
items = by_search_title(items) items = by_search_title(items)
items = by_state(items) items = by_state(items)
items = by_timeframe(items) items = by_timeframe(items)
items = containing_date(items)
order(items) order(items)
end end
......
...@@ -3,21 +3,33 @@ ...@@ -3,21 +3,33 @@
module TimeFrameArguments module TimeFrameArguments
extend ActiveSupport::Concern 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 included do
argument :start_date, Types::TimeType, argument :start_date, Types::TimeType,
required: false, 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, argument :end_date, Types::TimeType,
required: false, 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 end
# TODO: remove when the start_date and end_date arguments are removed
def validate_timeframe_params!(args) 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 = 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." "Both startDate and endDate must be present."
elsif args[:start_date] > args[:end_date] elsif args[:start_date] > args[:end_date]
"startDate is after endDate" "startDate is after endDate"
......
...@@ -13,6 +13,18 @@ module Resolvers ...@@ -13,6 +13,18 @@ module Resolvers
required: false, required: false,
description: 'Filter milestones by state' 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 type Types::MilestoneType, null: true
def resolve(**args) def resolve(**args)
...@@ -29,9 +41,18 @@ module Resolvers ...@@ -29,9 +41,18 @@ module Resolvers
{ {
ids: parse_gids(args[:ids]), ids: parse_gids(args[:ids]),
state: args[:state] || 'all', state: args[:state] || 'all',
start_date: args[:start_date], title: args[:title],
end_date: args[:end_date] search_title: args[:search_title],
}.merge(parent_id_parameters(args)) 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 end
def parent def parent
......
# frozen_string_literal: true
module Types
# rubocop: disable Graphql/AuthorizeTypes
class TimeframeInputType < BaseInputObject
graphql_name 'Timeframe'
description 'A time-frame defined as a closed inclusive range of two points in time'
argument :start, Types::TimeType,
required: true,
description: 'The start of the range'
argument :end, Types::TimeType,
required: true,
description: 'The end of the range'
def prepare
if self[:end] < self[:start]
raise ::Gitlab::Graphql::Errors::ArgumentError, 'start must be before end'
end
to_h
end
end
# rubocop: enable Graphql/AuthorizeTypes
end
...@@ -73,6 +73,32 @@ module Timebox ...@@ -73,6 +73,32 @@ module Timebox
end end
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 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)
...@@ -126,6 +152,10 @@ module Timebox ...@@ -126,6 +152,10 @@ module Timebox
def predefined?(timebox) def predefined?(timebox)
predefined_id?(timebox&.id) predefined_id?(timebox&.id)
end end
def min_chars_for_partial_matching
2
end
end end
## ##
......
---
title: Add filters to the milestones resolver
merge_request: 44208
author:
type: changed
...@@ -1222,8 +1222,8 @@ type BoardEpic implements CurrentUserTodos & Noteable { ...@@ -1222,8 +1222,8 @@ type BoardEpic implements CurrentUserTodos & Noteable {
before: String before: String
""" """
List items within a time frame where items.end_date is between startDate and List items overlapping a time frame defined by startDate..endDate (if one
endDate parameters (startDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
""" """
endDate: Time endDate: Time
...@@ -1273,8 +1273,9 @@ type BoardEpic implements CurrentUserTodos & Noteable { ...@@ -1273,8 +1273,9 @@ type BoardEpic implements CurrentUserTodos & Noteable {
sort: EpicSort sort: EpicSort
""" """
List items within a time frame where items.start_date is between startDate List items overlapping a time frame defined by startDate..endDate (if one
and endDate parameters (endDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
""" """
startDate: Time startDate: Time
...@@ -1282,6 +1283,11 @@ type BoardEpic implements CurrentUserTodos & Noteable { ...@@ -1282,6 +1283,11 @@ type BoardEpic implements CurrentUserTodos & Noteable {
Filter epics by state Filter epics by state
""" """
state: EpicState state: EpicState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
): EpicConnection ): EpicConnection
""" """
...@@ -5878,8 +5884,8 @@ type Epic implements CurrentUserTodos & Noteable { ...@@ -5878,8 +5884,8 @@ type Epic implements CurrentUserTodos & Noteable {
before: String before: String
""" """
List items within a time frame where items.end_date is between startDate and List items overlapping a time frame defined by startDate..endDate (if one
endDate parameters (startDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
""" """
endDate: Time endDate: Time
...@@ -5929,8 +5935,9 @@ type Epic implements CurrentUserTodos & Noteable { ...@@ -5929,8 +5935,9 @@ type Epic implements CurrentUserTodos & Noteable {
sort: EpicSort sort: EpicSort
""" """
List items within a time frame where items.start_date is between startDate List items overlapping a time frame defined by startDate..endDate (if one
and endDate parameters (endDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
""" """
startDate: Time startDate: Time
...@@ -5938,6 +5945,11 @@ type Epic implements CurrentUserTodos & Noteable { ...@@ -5938,6 +5945,11 @@ type Epic implements CurrentUserTodos & Noteable {
Filter epics by state Filter epics by state
""" """
state: EpicState state: EpicState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
): EpicConnection ): EpicConnection
""" """
...@@ -7361,8 +7373,8 @@ type Group { ...@@ -7361,8 +7373,8 @@ type Group {
authorUsername: String authorUsername: String
""" """
List items within a time frame where items.end_date is between startDate and List items overlapping a time frame defined by startDate..endDate (if one
endDate parameters (startDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
""" """
endDate: Time endDate: Time
...@@ -7402,8 +7414,9 @@ type Group { ...@@ -7402,8 +7414,9 @@ type Group {
sort: EpicSort sort: EpicSort
""" """
List items within a time frame where items.start_date is between startDate List items overlapping a time frame defined by startDate..endDate (if one
and endDate parameters (endDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
""" """
startDate: Time startDate: Time
...@@ -7411,6 +7424,11 @@ type Group { ...@@ -7411,6 +7424,11 @@ type Group {
Filter epics by state Filter epics by state
""" """
state: EpicState state: EpicState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
): Epic ): Epic
""" """
...@@ -7433,8 +7451,8 @@ type Group { ...@@ -7433,8 +7451,8 @@ type Group {
before: String before: String
""" """
List items within a time frame where items.end_date is between startDate and List items overlapping a time frame defined by startDate..endDate (if one
endDate parameters (startDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
""" """
endDate: Time endDate: Time
...@@ -7484,8 +7502,9 @@ type Group { ...@@ -7484,8 +7502,9 @@ type Group {
sort: EpicSort sort: EpicSort
""" """
List items within a time frame where items.start_date is between startDate List items overlapping a time frame defined by startDate..endDate (if one
and endDate parameters (endDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
""" """
startDate: Time startDate: Time
...@@ -7493,6 +7512,11 @@ type Group { ...@@ -7493,6 +7512,11 @@ type Group {
Filter epics by state Filter epics by state
""" """
state: EpicState state: EpicState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
): EpicConnection ): EpicConnection
""" """
...@@ -7695,8 +7719,8 @@ type Group { ...@@ -7695,8 +7719,8 @@ type Group {
before: String before: String
""" """
List items within a time frame where items.end_date is between startDate and List items overlapping a time frame defined by startDate..endDate (if one
endDate parameters (startDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
""" """
endDate: Time endDate: Time
...@@ -7726,8 +7750,9 @@ type Group { ...@@ -7726,8 +7750,9 @@ type Group {
last: Int last: Int
""" """
List items within a time frame where items.start_date is between startDate List items overlapping a time frame defined by startDate..endDate (if one
and endDate parameters (endDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
""" """
startDate: Time startDate: Time
...@@ -7736,6 +7761,11 @@ type Group { ...@@ -7736,6 +7761,11 @@ type Group {
""" """
state: IterationState state: IterationState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
""" """
Fuzzy search by title Fuzzy search by title
""" """
...@@ -7892,8 +7922,13 @@ type Group { ...@@ -7892,8 +7922,13 @@ type Group {
before: String before: String
""" """
List items within a time frame where items.end_date is between startDate and A date that the milestone contains
endDate parameters (startDate parameter must be present) """
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 endDate: Time
...@@ -7918,8 +7953,14 @@ type Group { ...@@ -7918,8 +7953,14 @@ type Group {
last: Int last: Int
""" """
List items within a time frame where items.start_date is between startDate A search string for the title
and endDate parameters (endDate parameter must be present) """
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 startDate: Time
...@@ -7927,6 +7968,16 @@ type Group { ...@@ -7927,6 +7968,16 @@ type Group {
Filter milestones by state Filter milestones by state
""" """
state: MilestoneStateEnum state: MilestoneStateEnum
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
"""
The title of the milestone
"""
title: String
): MilestoneConnection ): MilestoneConnection
""" """
...@@ -13730,8 +13781,8 @@ type Project { ...@@ -13730,8 +13781,8 @@ type Project {
before: String before: String
""" """
List items within a time frame where items.end_date is between startDate and List items overlapping a time frame defined by startDate..endDate (if one
endDate parameters (startDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use timeframe.end
""" """
endDate: Time endDate: Time
...@@ -13761,8 +13812,9 @@ type Project { ...@@ -13761,8 +13812,9 @@ type Project {
last: Int last: Int
""" """
List items within a time frame where items.start_date is between startDate List items overlapping a time frame defined by startDate..endDate (if one
and endDate parameters (endDate parameter must be present) date is provided, both must be present). Deprecated in 14.0: Use
timeframe.start
""" """
startDate: Time startDate: Time
...@@ -13771,6 +13823,11 @@ type Project { ...@@ -13771,6 +13823,11 @@ type Project {
""" """
state: IterationState state: IterationState
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
""" """
Fuzzy search by title Fuzzy search by title
""" """
...@@ -13979,8 +14036,13 @@ type Project { ...@@ -13979,8 +14036,13 @@ type Project {
before: String before: String
""" """
List items within a time frame where items.end_date is between startDate and A date that the milestone contains
endDate parameters (startDate parameter must be present) """
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 endDate: Time
...@@ -14005,8 +14067,14 @@ type Project { ...@@ -14005,8 +14067,14 @@ type Project {
last: Int last: Int
""" """
List items within a time frame where items.start_date is between startDate A search string for the title
and endDate parameters (endDate parameter must be present) """
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 startDate: Time
...@@ -14014,6 +14082,16 @@ type Project { ...@@ -14014,6 +14082,16 @@ type Project {
Filter milestones by state Filter milestones by state
""" """
state: MilestoneStateEnum state: MilestoneStateEnum
"""
List items overlapping the given timeframe
"""
timeframe: Timeframe
"""
The title of the milestone
"""
title: String
): MilestoneConnection ): MilestoneConnection
""" """
...@@ -18311,6 +18389,21 @@ interface TimeboxBurnupTimeSeriesInterface { ...@@ -18311,6 +18389,21 @@ interface TimeboxBurnupTimeSeriesInterface {
burnupTimeSeries: [BurnupChartDailyTotals!] burnupTimeSeries: [BurnupChartDailyTotals!]
} }
"""
A time-frame defined as a closed inclusive range of two points in time
"""
input Timeframe {
"""
The end of the range
"""
end: Time!
"""
The start of the range
"""
start: Time!
}
type Timelog { type Timelog {
""" """
Timestamp of when the time tracked was spent at. Deprecated in 12.10: Use `spentAt` Timestamp of when the time tracked was spent at. Deprecated in 12.10: Use `spentAt`
......
...@@ -9,5 +9,10 @@ RSpec.describe Iteration do ...@@ -9,5 +9,10 @@ RSpec.describe Iteration do
it_behaves_like 'a timebox', :iteration do it_behaves_like 'a timebox', :iteration do
let(:timebox_args) { [:skip_project_validation] } let(:timebox_args) { [:skip_project_validation] }
let(:timebox_table_name) { described_class.table_name.to_sym } 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
end end
...@@ -15,6 +15,12 @@ RSpec.describe Resolvers::GroupMilestonesResolver do ...@@ -15,6 +15,12 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
let_it_be(:now) { Time.now } let_it_be(:now) { Time.now }
let_it_be(:group) { create(:group, :private) } 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 before_all do
group.add_developer(current_user) group.add_developer(current_user)
end end
...@@ -30,7 +36,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do ...@@ -30,7 +36,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
context 'without parameters' do context 'without parameters' do
it 'calls MilestonesFinder to retrieve all milestones' do it 'calls MilestonesFinder to retrieve all milestones' do
expect(MilestonesFinder).to receive(:new) 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 .and_call_original
resolve_group_milestones resolve_group_milestones
...@@ -43,11 +49,22 @@ RSpec.describe Resolvers::GroupMilestonesResolver do ...@@ -43,11 +49,22 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
end_date = start_date + 1.hour end_date = start_date + 1.hour
expect(MilestonesFinder).to receive(:new) 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 .and_call_original
resolve_group_milestones(start_date: start_date, end_date: end_date, state: 'closed') resolve_group_milestones(start_date: start_date, end_date: end_date, state: 'closed')
end 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 end
context 'by ids' do context 'by ids' do
...@@ -55,7 +72,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do ...@@ -55,7 +72,7 @@ RSpec.describe Resolvers::GroupMilestonesResolver do
milestone = create(:milestone, group: group) milestone = create(:milestone, group: group)
expect(MilestonesFinder).to receive(:new) 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 .and_call_original
resolve_group_milestones(ids: [milestone.to_global_id]) resolve_group_milestones(ids: [milestone.to_global_id])
......
...@@ -13,13 +13,19 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do ...@@ -13,13 +13,19 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
project.add_developer(current_user) project.add_developer(current_user)
end 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 }) def resolve_project_milestones(args = {}, context = { current_user: current_user })
resolve(described_class, obj: project, args: args, ctx: context) resolve(described_class, obj: project, args: args, ctx: context)
end end
it 'calls MilestonesFinder to retrieve all milestones' do it 'calls MilestonesFinder to retrieve all milestones' do
expect(MilestonesFinder).to receive(:new) 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 .and_call_original
resolve_project_milestones resolve_project_milestones
...@@ -36,7 +42,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do ...@@ -36,7 +42,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
it 'calls MilestonesFinder with correct parameters' do it 'calls MilestonesFinder with correct parameters' do
expect(MilestonesFinder).to receive(:new) 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 .and_call_original
resolve_project_milestones(include_ancestors: true) resolve_project_milestones(include_ancestors: true)
...@@ -48,7 +54,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do ...@@ -48,7 +54,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
milestone = create(:milestone, project: project) milestone = create(:milestone, project: project)
expect(MilestonesFinder).to receive(:new) 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 .and_call_original
resolve_project_milestones(ids: [milestone.to_global_id]) resolve_project_milestones(ids: [milestone.to_global_id])
...@@ -58,7 +64,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do ...@@ -58,7 +64,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
context 'by state' do context 'by state' do
it 'calls MilestonesFinder with correct parameters' do it 'calls MilestonesFinder with correct parameters' do
expect(MilestonesFinder).to receive(:new) 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 .and_call_original
resolve_project_milestones(state: 'closed') resolve_project_milestones(state: 'closed')
...@@ -72,7 +78,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do ...@@ -72,7 +78,7 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
end_date = Time.now + 5.days end_date = Time.now + 5.days
expect(MilestonesFinder).to receive(:new) 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 .and_call_original
resolve_project_milestones(start_date: start_date, end_date: end_date) resolve_project_milestones(start_date: start_date, end_date: end_date)
...@@ -102,6 +108,51 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do ...@@ -102,6 +108,51 @@ RSpec.describe Resolvers::ProjectMilestonesResolver do
end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/) end.to raise_error(Gitlab::Graphql::Errors::ArgumentError, /Both startDate and endDate/)
end end
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 end
context 'when user cannot read milestones' do context 'when user cannot read milestones' do
......
# 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 mrs' 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: Time!, $end: Time!) {
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!, $start: Time!, $end: Time!) {
project(fullPath: $path) {
milestones(timeframe: { start: $start, end: $end }, startDate: $start, endDate: $end) {
nodes { id }
}
}
}
GQL
post_graphql(query, current_user: current_user, variables: vars)
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: Time!, $end: Time!) {
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
...@@ -9,6 +9,11 @@ RSpec.shared_examples 'a timebox' do |timebox_type| ...@@ -9,6 +9,11 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
let(:user) { create(:user) } let(:user) { create(:user) }
let(:timebox_table_name) { timebox_type.to_s.pluralize.to_sym } 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 describe 'modules' do
context 'with a project' do context 'with a project' do
it_behaves_like 'AtomicInternalId' do it_behaves_like 'AtomicInternalId' do
...@@ -240,4 +245,85 @@ RSpec.shared_examples 'a timebox' do |timebox_type| ...@@ -240,4 +245,85 @@ RSpec.shared_examples 'a timebox' do |timebox_type|
expect(timebox.to_ability_name).to eq(timebox_type.to_s) expect(timebox.to_ability_name).to eq(timebox_type.to_s)
end end
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 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