Commit cd5f3d3e authored by Jarka Košanová's avatar Jarka Košanová

Merge branch '214846-sprints-services' into 'master'

Create SprintsFinder

See merge request gitlab-org/gitlab!29822
parents 55528807 6abaff70
......@@ -5,10 +5,31 @@ module Timebox
include AtomicInternalId
include CacheMarkdownField
include Gitlab::SQL::Pattern
include IidRoutes
include StripAttribute
TimeboxStruct = Struct.new(:title, :name, :id) do
# Ensure these models match the interface required for exporting
def serializable_hash(_opts = {})
{ title: title, name: name, id: id }
end
end
# Represents a "No Timebox" state used for filtering Issues and Merge
# Requests that have no timeboxes assigned.
None = TimeboxStruct.new('No Timebox', 'No Timebox', 0)
Any = TimeboxStruct.new('Any Timebox', '', -1)
Upcoming = TimeboxStruct.new('Upcoming', '#upcoming', -2)
Started = TimeboxStruct.new('Started', '#started', -3)
included do
# Defines the same constants above, but inside the including class.
const_set :None, TimeboxStruct.new("No #{self.name}", "No #{self.name}", 0)
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)
alias_method :timebox_id, :id
validates :group, presence: true, unless: :project
......@@ -35,6 +56,7 @@ module Timebox
scope :active, -> { with_state(:active) }
scope :closed, -> { with_state(:closed) }
scope :for_projects, -> { where(group: nil).includes(:project) }
scope :with_title, -> (title) { where(title: title) }
scope :for_projects_and_groups, -> (projects, groups) do
projects = projects.compact if projects.is_a? Array
......@@ -57,6 +79,50 @@ module Timebox
alias_attribute :name, :title
end
class_methods do
# Searches for timeboxes with a matching title or description.
#
# This method uses ILIKE on PostgreSQL
#
# query - The search query as a String
#
# Returns an ActiveRecord::Relation.
def search(query)
fuzzy_search(query, [:title, :description])
end
# Searches for timeboxes with a matching title.
#
# This method uses ILIKE on PostgreSQL
#
# query - The search query as a String
#
# Returns an ActiveRecord::Relation.
def search_title(query)
fuzzy_search(query, [:title])
end
def filter_by_state(timeboxes, state)
case state
when 'closed' then timeboxes.closed
when 'all' then timeboxes
else timeboxes.active
end
end
def count_by_state
reorder(nil).group(:state).count
end
def predefined_id?(id)
[Any.id, None.id, Upcoming.id, Started.id].include?(id)
end
def predefined?(timebox)
predefined_id?(timebox&.id)
end
end
def title=(value)
write_attribute(:title, sanitize_title(value)) if value.present?
end
......
# frozen_string_literal: true
class Milestone < ApplicationRecord
# Represents a "No Milestone" state used for filtering Issues and Merge
# Requests that have no milestone assigned.
MilestoneStruct = Struct.new(:title, :name, :id) do
# Ensure these models match the interface required for exporting
def serializable_hash(_opts = {})
{ title: title, name: name, id: id }
end
end
None = MilestoneStruct.new('No Milestone', 'No Milestone', 0)
Any = MilestoneStruct.new('Any Milestone', '', -1)
Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2)
Started = MilestoneStruct.new('Started', '#started', -3)
include Sortable
include Referable
include Timebox
include Milestoneish
include FromUnion
include Importable
include Gitlab::SQL::Pattern
prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
......@@ -54,50 +39,6 @@ class Milestone < ApplicationRecord
state :active
end
class << self
# Searches for milestones with a matching title or description.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
# query - The search query as a String
#
# Returns an ActiveRecord::Relation.
def search(query)
fuzzy_search(query, [:title, :description])
end
# Searches for milestones with a matching title.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
# query - The search query as a String
#
# Returns an ActiveRecord::Relation.
def search_title(query)
fuzzy_search(query, [:title])
end
def filter_by_state(milestones, state)
case state
when 'closed' then milestones.closed
when 'all' then milestones
else milestones.active
end
end
def count_by_state
reorder(nil).group(:state).count
end
def predefined_id?(id)
[Any.id, None.id, Upcoming.id, Started.id].include?(id)
end
def predefined?(milestone)
predefined_id?(milestone&.id)
end
end
def self.reference_prefix
'%'
end
......
# frozen_string_literal: true
# Search for sprints
#
# params - Hash
# project_ids: Array of project ids or single project id or ActiveRecord relation.
# group_ids: Array of group ids or single group id or ActiveRecord relation.
# order - Orders by field default due date asc.
# title - Filter by title.
# state - Filters by state.
class SprintsFinder
include FinderMethods
include TimeFrameFilter
attr_reader :params
def initialize(params = {})
@params = params
end
def execute
items = Sprint.all
items = by_groups_and_projects(items)
items = by_title(items)
items = by_search_title(items)
items = by_state(items)
items = by_timeframe(items)
order(items)
end
private
def by_groups_and_projects(items)
items.for_projects_and_groups(params[:project_ids], params[:group_ids])
end
def by_title(items)
if params[:title]
items.with_title(params[:title])
else
items
end
end
def by_search_title(items)
if params[:search_title].present?
items.search_title(params[:search_title])
else
items
end
end
def by_state(items)
Sprint.filter_by_state(items, params[:state])
end
# rubocop: disable CodeReuse/ActiveRecord
def order(items)
order_statement = Gitlab::Database.nulls_last_order('due_date', 'ASC')
items.reorder(order_statement).order(:title)
end
# rubocop: enable CodeReuse/ActiveRecord
end
# frozen_string_literal: true
require 'spec_helper'
describe SprintsFinder do
let(:now) { Time.now }
let_it_be(:group) { create(:group) }
let_it_be(:project_1) { create(:project, namespace: group) }
let_it_be(:project_2) { create(:project, namespace: group) }
let!(:started_group_sprint) { create(:sprint, group: group, title: 'one test', start_date: now - 1.day, due_date: now) }
let!(:upcoming_group_sprint) { create(:sprint, group: group, start_date: now + 1.day, due_date: now + 2.days) }
let!(:sprint_from_project_1) { create(:sprint, project: project_1, state: ::Sprint::STATE_ID_MAP[:active], start_date: now + 2.days, due_date: now + 3.days) }
let!(:sprint_from_project_2) { create(:sprint, project: project_2, state: ::Sprint::STATE_ID_MAP[:active], start_date: now + 4.days, due_date: now + 5.days) }
let(:project_ids) { [project_1.id, project_2.id] }
subject { described_class.new(params).execute }
context 'sprints for projects' do
let(:params) { { project_ids: project_ids, state: 'all' } }
it 'returns sprints for projects' do
expect(subject).to contain_exactly(sprint_from_project_1, sprint_from_project_2)
end
end
context 'sprints for groups' do
let(:params) { { group_ids: group.id, state: 'all' } }
it 'returns sprints for groups' do
expect(subject).to contain_exactly(started_group_sprint, upcoming_group_sprint)
end
end
context 'sprints for groups and project' do
let(:params) { { project_ids: project_ids, group_ids: group.id, state: 'all' } }
it 'returns sprints for groups and projects' do
expect(subject).to contain_exactly(started_group_sprint, upcoming_group_sprint, sprint_from_project_1, sprint_from_project_2)
end
it 'orders sprints by due date' do
sprint = create(:sprint, group: group, due_date: now - 2.days)
expect(subject.first).to eq(sprint)
expect(subject.second).to eq(started_group_sprint)
expect(subject.third).to eq(upcoming_group_sprint)
end
end
context 'with filters' do
let(:params) do
{
project_ids: project_ids,
group_ids: group.id,
state: 'all'
}
end
before do
started_group_sprint.close
sprint_from_project_1.close
end
it 'filters by active state' do
params[:state] = 'active'
expect(subject).to contain_exactly(upcoming_group_sprint, sprint_from_project_2)
end
it 'filters by closed state' do
params[:state] = 'closed'
expect(subject).to contain_exactly(started_group_sprint, sprint_from_project_1)
end
it 'filters by title' do
params[:title] = 'one test'
expect(subject.to_a).to contain_exactly(started_group_sprint)
end
it 'filters by search_title' do
params[:search_title] = 'one t'
expect(subject.to_a).to contain_exactly(started_group_sprint)
end
context 'by timeframe' do
it 'returns sprints with start_date and due_date between timeframe' do
params.merge!(start_date: now - 1.day, end_date: now + 3.days)
expect(subject).to match_array([started_group_sprint, upcoming_group_sprint, sprint_from_project_1])
end
it 'returns sprints which start before the timeframe' do
sprint = create(:sprint, project: project_2, start_date: now - 5.days)
params.merge!(start_date: now - 3.days, end_date: now - 2.days)
expect(subject).to match_array([sprint])
end
it 'returns sprints which end after the timeframe' do
sprint = create(:sprint, project: project_2, due_date: now + 6.days)
params.merge!(start_date: now + 6.days, end_date: now + 7.days)
expect(subject).to match_array([sprint])
end
end
end
describe '#find_by' do
it 'finds a single sprint' do
finder = described_class.new(project_ids: [project_1.id], state: 'all')
expect(finder.find_by(iid: sprint_from_project_1.iid)).to eq(sprint_from_project_1)
end
end
end
......@@ -6,7 +6,7 @@ describe Milestone do
it_behaves_like 'a timebox', :milestone
describe 'MilestoneStruct#serializable_hash' do
let(:predefined_milestone) { described_class::MilestoneStruct.new('Test Milestone', '#test', 1) }
let(:predefined_milestone) { described_class::TimeboxStruct.new('Test Milestone', '#test', 1) }
it 'presents the predefined milestone as a hash' do
expect(predefined_milestone.serializable_hash).to eq(
......
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