Skip to content
Snippets Groups Projects
Commit b5f596c3 authored by Felipe Cardozo's avatar Felipe Cardozo :three: Committed by Sean McGivern
Browse files

Native group milestones

parent 1a3edcec
No related branches found
No related tags found
No related merge requests found
Showing
with 305 additions and 85 deletions
...@@ -2,13 +2,13 @@ class Groups::MilestonesController < Groups::ApplicationController ...@@ -2,13 +2,13 @@ class Groups::MilestonesController < Groups::ApplicationController
include MilestoneActions include MilestoneActions
before_action :group_projects before_action :group_projects
before_action :milestone, only: [:show, :update, :merge_requests, :participants, :labels] before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels]
before_action :authorize_admin_milestones!, only: [:new, :create, :update] before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update]
def index def index
respond_to do |format| respond_to do |format|
format.html do format.html do
@milestone_states = GlobalMilestone.states_count(@projects) @milestone_states = GlobalMilestone.states_count(group_projects, group)
@milestones = Kaminari.paginate_array(milestones).page(params[:page]) @milestones = Kaminari.paginate_array(milestones).page(params[:page])
end end
format.json do format.json do
...@@ -22,49 +22,41 @@ def new ...@@ -22,49 +22,41 @@ def new
end end
def create def create
project_ids = params[:milestone][:project_ids].reject(&:blank?) @milestone = Milestones::CreateService.new(group, current_user, milestone_params).execute
title = milestone_params[:title]
if create_milestones(project_ids) if @milestone.persisted?
redirect_to milestone_path(title) redirect_to milestone_path
else else
render_new_with_error(project_ids.empty?) render "new"
end end
end end
def show def show
end end
def update def edit
@milestone.milestones.each do |milestone| render_404 if @milestone.is_legacy_group_milestone?
Milestones::UpdateService.new(milestone.project, current_user, milestone_params).execute(milestone)
end
redirect_back_or_default(default: milestone_path(@milestone.title))
end end
private def update
# Keep this compatible with legacy group milestones where we have to update
def create_milestones(project_ids) # all projects milestones states at once.
return false unless project_ids.present? if @milestone.is_legacy_group_milestone?
update_params = milestone_params.select { |key| key == "state_event" }
milestones = @milestone.milestones
else
update_params = milestone_params
milestones = [@milestone]
end
ActiveRecord::Base.transaction do milestones.each do |milestone|
@projects.where(id: project_ids).each do |project| Milestones::UpdateService.new(milestone.parent, current_user, update_params).execute(milestone)
Milestones::CreateService.new(project, current_user, milestone_params).execute
end
end end
true redirect_to milestone_path
rescue ActiveRecord::ActiveRecordError => e
flash.now[:alert] = "An error occurred while creating the milestone: #{e.message}"
false
end end
def render_new_with_error(empty_project_ids) private
@milestone = Milestone.new(milestone_params)
@milestone.errors.add(:base, "Please select at least one project.") if empty_project_ids
render :new
end
def authorize_admin_milestones! def authorize_admin_milestones!
return render_404 unless can?(current_user, :admin_milestones, group) return render_404 unless can?(current_user, :admin_milestones, group)
...@@ -74,16 +66,31 @@ def milestone_params ...@@ -74,16 +66,31 @@ def milestone_params
params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event) params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event)
end end
def milestone_path(title) def milestone_path
group_milestone_path(@group, title.to_slug.to_s, title: title) if @milestone.is_legacy_group_milestone?
group_milestone_path(group, @milestone.safe_title, title: @milestone.title)
else
group_milestone_path(group, @milestone.iid)
end
end end
def milestones def milestones
@milestones = GroupMilestone.build_collection(@group, @projects, params) search_params = params.merge(group_ids: group.id)
milestones = MilestonesFinder.new(search_params).execute
legacy_milestones = GroupMilestone.build_collection(group, group_projects, params)
milestones + legacy_milestones
end end
def milestone def milestone
@milestone = GroupMilestone.build(@group, @projects, params[:title]) @milestone =
if params[:title]
GroupMilestone.build(group, group_projects, params[:title])
else
group.milestones.find_by_iid(params[:id])
end
render_404 unless @milestone render_404 unless @milestone
end end
end end
...@@ -13,20 +13,16 @@ class Projects::MilestonesController < Projects::ApplicationController ...@@ -13,20 +13,16 @@ class Projects::MilestonesController < Projects::ApplicationController
respond_to :html respond_to :html
def index def index
@milestones =
case params[:state]
when 'all' then @project.milestones
when 'closed' then @project.milestones.closed
else @project.milestones.active
end
@sort = params[:sort] || 'due_date_asc' @sort = params[:sort] || 'due_date_asc'
@milestones = @milestones.sort(@sort) @milestones = milestones.sort(@sort)
respond_to do |format| respond_to do |format|
format.html do format.html do
@project_namespace = @project.namespace.becomes(Namespace) @project_namespace = @project.namespace.becomes(Namespace)
@milestones = @milestones.includes(:project) # We need to show group milestones in the JSON response
# so that people can filter by and assign group milestones,
# but we don't need to show them on the project milestones page itself.
@milestones = @milestones.for_projects
@milestones = @milestones.page(params[:page]) @milestones = @milestones.page(params[:page])
end end
format.json do format.json do
...@@ -51,7 +47,7 @@ def show ...@@ -51,7 +47,7 @@ def show
def create def create
@milestone = Milestones::CreateService.new(project, current_user, milestone_params).execute @milestone = Milestones::CreateService.new(project, current_user, milestone_params).execute
if @milestone.save if @milestone.valid?
redirect_to project_milestone_path(@project, @milestone) redirect_to project_milestone_path(@project, @milestone)
else else
render "new" render "new"
...@@ -86,6 +82,18 @@ def destroy ...@@ -86,6 +82,18 @@ def destroy
protected protected
def milestones
@milestones ||= begin
if @project.group && can?(current_user, :read_group, @project.group)
group = @project.group
end
search_params = params.merge(project_ids: @project.id, group_ids: group&.id)
MilestonesFinder.new(search_params).execute
end
end
def milestone def milestone
@milestone ||= @project.milestones.find_by!(iid: params[:id]) @milestone ||= @project.milestones.find_by!(iid: params[:id])
end end
......
...@@ -147,9 +147,17 @@ def milestones ...@@ -147,9 +147,17 @@ def milestones
@milestones = @milestones =
if milestones? if milestones?
scope = Milestone.where(project_id: projects) if project?
group_id = project.group&.id
project_id = project.id
end
group_id = group.id if group
scope.where(title: params[:milestone_title]) search_params =
{ title: params[:milestone_title], project_ids: project_id, group_ids: group_id }
MilestonesFinder.new(search_params).execute
else else
Milestone.none Milestone.none
end end
...@@ -331,11 +339,6 @@ def by_milestone(items) ...@@ -331,11 +339,6 @@ def by_milestone(items)
items = items.left_joins_milestones.where('milestones.start_date <= NOW()') items = items.left_joins_milestones.where('milestones.start_date <= NOW()')
else else
items = items.with_milestone(params[:milestone_title]) items = items.with_milestone(params[:milestone_title])
items_projects = projects(items)
if items_projects
items = items.where(milestones: { project_id: items_projects })
end
end end
end end
......
# Search for milestones
#
# params - Hash
# project_ids: Array of project ids or single project id.
# group_ids: Array of group ids or single group id.
# order - Orders by field default due date asc.
# title - filter by title.
# state - filters by state.
class MilestonesFinder class MilestonesFinder
def execute(projects, params) attr_reader :params, :project_ids, :group_ids
milestones = Milestone.of_projects(projects)
milestones = milestones.reorder("due_date ASC") def initialize(params = {})
@project_ids = Array(params[:project_ids])
case params[:state] @group_ids = Array(params[:group_ids])
when 'closed' then milestones.closed @params = params
when 'all' then milestones end
else milestones.active
def execute
return Milestone.none if project_ids.empty? && group_ids.empty?
items = Milestone.all
items = by_groups_and_projects(items)
items = by_title(items)
items = by_state(items)
order(items)
end
private
def by_groups_and_projects(items)
items.for_projects_and_groups(project_ids, group_ids)
end
def by_title(items)
if params[:title]
items.where(title: params[:title])
else
items
end
end
def by_state(items)
Milestone.filter_by_state(items, params[:state])
end
def order(items)
if params.has_key?(:order)
items.reorder(params[:order])
else
items.reorder('due_date ASC')
end end
end end
end end
...@@ -54,8 +54,10 @@ def milestone_counts(milestones) ...@@ -54,8 +54,10 @@ def milestone_counts(milestones)
def milestone_class_for_state(param, check, match_blank_param = false) def milestone_class_for_state(param, check, match_blank_param = false)
if match_blank_param if match_blank_param
'active' if param.blank? || param == check 'active' if param.blank? || param == check
elsif param == check
'active'
else else
'active' if param == check check
end end
end end
...@@ -147,4 +149,14 @@ def milestone_labels_tab_path(milestone) ...@@ -147,4 +149,14 @@ def milestone_labels_tab_path(milestone)
labels_dashboard_milestone_path(milestone, title: milestone.title, format: :json) labels_dashboard_milestone_path(milestone, title: milestone.title, format: :json)
end end
end end
def group_milestone_route(milestone, params = {})
params = nil if params.empty?
if milestone.is_legacy_group_milestone?
group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: params)
else
group_milestone_path(@group, milestone.iid, milestone: params)
end
end
end end
...@@ -8,7 +8,8 @@ module InternalId ...@@ -8,7 +8,8 @@ module InternalId
def set_iid def set_iid
if iid.blank? if iid.blank?
records = project.send(self.class.name.tableize) parent = project || group
records = parent.send(self.class.name.tableize)
records = records.with_deleted if self.paranoid? records = records.with_deleted if self.paranoid?
max_iid = records.maximum(:iid) max_iid = records.maximum(:iid)
......
...@@ -30,6 +30,7 @@ module Issuable ...@@ -30,6 +30,7 @@ module Issuable
belongs_to :updated_by, class_name: "User" belongs_to :updated_by, class_name: "User"
belongs_to :last_edited_by, class_name: 'User' belongs_to :last_edited_by, class_name: 'User'
belongs_to :milestone belongs_to :milestone
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent
def authors_loaded? def authors_loaded?
# We check first if we're loaded to not load unnecessarily. # We check first if we're loaded to not load unnecessarily.
......
...@@ -70,6 +70,22 @@ def expired? ...@@ -70,6 +70,22 @@ def expired?
due_date && due_date.past? due_date && due_date.past?
end end
def is_group_milestone?
false
end
def is_project_milestone?
false
end
def is_legacy_group_milestone?
false
end
def is_dashboard_milestone?
false
end
private private
def count_issues_by_state(user) def count_issues_by_state(user)
......
...@@ -2,4 +2,8 @@ class DashboardMilestone < GlobalMilestone ...@@ -2,4 +2,8 @@ class DashboardMilestone < GlobalMilestone
def issues_finder_params def issues_finder_params
{ authorized_only: true } { authorized_only: true }
end end
def is_dashboard_milestone?
true
end
end end
...@@ -2,6 +2,7 @@ class GlobalMilestone ...@@ -2,6 +2,7 @@ class GlobalMilestone
include Milestoneish include Milestoneish
EPOCH = DateTime.parse('1970-01-01') EPOCH = DateTime.parse('1970-01-01')
STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze
attr_accessor :title, :milestones attr_accessor :title, :milestones
alias_attribute :name, :title alias_attribute :name, :title
...@@ -11,7 +12,10 @@ def for_display ...@@ -11,7 +12,10 @@ def for_display
end end
def self.build_collection(projects, params) def self.build_collection(projects, params)
child_milestones = MilestonesFinder.new.execute(projects, params) params =
{ project_ids: projects.map(&:id), state: params[:state] }
child_milestones = MilestonesFinder.new(params).execute
milestones = child_milestones.select(:id, :title).group_by(&:title).map do |title, grouped| milestones = child_milestones.select(:id, :title).group_by(&:title).map do |title, grouped|
milestones_relation = Milestone.where(id: grouped.map(&:id)) milestones_relation = Milestone.where(id: grouped.map(&:id))
...@@ -28,13 +32,42 @@ def self.build(projects, title) ...@@ -28,13 +32,42 @@ def self.build(projects, title)
new(title, child_milestones) new(title, child_milestones)
end end
def self.states_count(projects) def self.states_count(projects, group = nil)
relation = MilestonesFinder.new.execute(projects, state: 'all') legacy_group_milestones_count = legacy_group_milestone_states_count(projects)
milestones_by_state_and_title = relation.reorder(nil).group(:state, :title).count group_milestones_count = group_milestones_states_count(group)
legacy_group_milestones_count.merge(group_milestones_count) do |k, legacy_group_milestones_count, group_milestones_count|
legacy_group_milestones_count + group_milestones_count
end
end
def self.group_milestones_states_count(group)
return STATE_COUNT_HASH unless group
params = { group_ids: [group.id], state: 'all', order: nil }
relation = MilestonesFinder.new(params).execute
grouped_by_state = relation.group(:state).count
{
opened: grouped_by_state['active'] || 0,
closed: grouped_by_state['closed'] || 0,
all: grouped_by_state.values.sum
}
end
# Counts the legacy group milestones which must be grouped by title
def self.legacy_group_milestone_states_count(projects)
return STATE_COUNT_HASH unless projects
params = { project_ids: projects.map(&:id), state: 'all', order: nil }
relation = MilestonesFinder.new(params).execute
project_milestones_by_state_and_title = relation.group(:state, :title).count
opened = count_by_state(milestones_by_state_and_title, 'active') opened = count_by_state(project_milestones_by_state_and_title, 'active')
closed = count_by_state(milestones_by_state_and_title, 'closed') closed = count_by_state(project_milestones_by_state_and_title, 'closed')
all = milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count all = project_milestones_by_state_and_title.map { |(_, title), _| title }.uniq.count
{ {
opened: opened, opened: opened,
......
...@@ -18,6 +18,7 @@ class Group < Namespace ...@@ -18,6 +18,7 @@ class Group < Namespace
has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
has_many :milestones
has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :shared_projects, through: :project_group_links, source: :project has_many :shared_projects, through: :project_group_links, source: :project
has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent has_many :notification_settings, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent
......
...@@ -16,4 +16,8 @@ def self.build(group, projects, title) ...@@ -16,4 +16,8 @@ def self.build(group, projects, title)
def issues_finder_params def issues_finder_params
{ group_id: group.id } { group_id: group.id }
end end
def is_legacy_group_milestone?
true
end
end end
...@@ -18,17 +18,32 @@ class Milestone < ActiveRecord::Base ...@@ -18,17 +18,32 @@ class Milestone < ActiveRecord::Base
cache_markdown_field :description cache_markdown_field :description
belongs_to :project belongs_to :project
belongs_to :group
has_many :issues has_many :issues
has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
has_many :merge_requests has_many :merge_requests
has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
scope :of_projects, ->(ids) { where(project_id: ids) }
scope :of_groups, ->(ids) { where(group_id: ids) }
scope :active, -> { with_state(:active) } scope :active, -> { with_state(:active) }
scope :closed, -> { with_state(:closed) } scope :closed, -> { with_state(:closed) }
scope :of_projects, ->(ids) { where(project_id: ids) } scope :for_projects, -> { where(group: nil).includes(:project) }
scope :for_projects_and_groups, -> (project_ids, group_ids) do
conditions = []
conditions << arel_table[:project_id].in(project_ids) if project_ids.compact.any?
conditions << arel_table[:group_id].in(group_ids) if group_ids.compact.any?
where(conditions.reduce(:or))
end
validates :group, presence: true, unless: :project
validates :project, presence: true, unless: :group
validates :title, presence: true, uniqueness: { scope: :project_id } validate :uniqueness_of_title, if: :title_changed?
validates :project, presence: true validate :milestone_type_check
validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
strip_attributes :title strip_attributes :title
...@@ -63,6 +78,14 @@ def search(query) ...@@ -63,6 +78,14 @@ def search(query)
where(t[:title].matches(pattern).or(t[:description].matches(pattern))) where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
end end
def filter_by_state(milestones, state)
case state
when 'closed' then milestones.closed
when 'all' then milestones
else milestones.active
end
end
end end
def self.reference_prefix def self.reference_prefix
...@@ -138,6 +161,8 @@ def self.sort(method) ...@@ -138,6 +161,8 @@ def self.sort(method)
# Milestone.first.to_reference(same_namespace_project) # => "gitlab-ce%1" # Milestone.first.to_reference(same_namespace_project) # => "gitlab-ce%1"
# #
def to_reference(from_project = nil, format: :iid, full: false) def to_reference(from_project = nil, format: :iid, full: false)
return if is_group_milestone?
format_reference = milestone_format_reference(format) format_reference = milestone_format_reference(format)
reference = "#{self.class.reference_prefix}#{format_reference}" reference = "#{self.class.reference_prefix}#{format_reference}"
...@@ -152,6 +177,10 @@ def milestoneish_ids ...@@ -152,6 +177,10 @@ def milestoneish_ids
id id
end end
def for_display
self
end
def can_be_closed? def can_be_closed?
active? && issues.opened.count.zero? active? && issues.opened.count.zero?
end end
...@@ -164,8 +193,45 @@ def title=(value) ...@@ -164,8 +193,45 @@ def title=(value)
write_attribute(:title, sanitize_title(value)) if value.present? write_attribute(:title, sanitize_title(value)) if value.present?
end end
def safe_title
title.to_slug.normalize.to_s
end
def parent
group || project
end
def is_group_milestone?
group_id.present?
end
def is_project_milestone?
project_id.present?
end
private private
# Milestone titles must be unique across project milestones and group milestones
def uniqueness_of_title
if project
relation = Milestone.for_projects_and_groups([project_id], [project.group&.id])
elsif group
project_ids = group.projects.map(&:id)
relation = Milestone.for_projects_and_groups(project_ids, [group.id])
end
title_exists = relation.find_by_title(title)
errors.add(:title, "already being used for another group or project milestone.") if title_exists
end
# Milestone should be either a project milestone or a group milestone
def milestone_type_check
if group_id && project_id
field = project_id_changed? ? :project_id : :group_id
errors.add(field, "milestone should belong either to a project or a group.")
end
end
def milestone_format_reference(format = :iid) def milestone_format_reference(format = :iid)
raise ArgumentError, 'Unknown format' unless [:iid, :name].include?(format) raise ArgumentError, 'Unknown format' unless [:iid, :name].include?(format)
......
...@@ -2,8 +2,11 @@ class IssuableBaseService < BaseService ...@@ -2,8 +2,11 @@ class IssuableBaseService < BaseService
private private
def create_milestone_note(issuable) def create_milestone_note(issuable)
milestone = issuable.milestone
return if milestone && milestone.is_group_milestone?
SystemNoteService.change_milestone( SystemNoteService.change_milestone(
issuable, issuable.project, current_user, issuable.milestone) issuable, issuable.project, current_user, milestone)
end end
def create_labels_note(issuable, old_labels) def create_labels_note(issuable, old_labels)
...@@ -89,10 +92,12 @@ def filter_milestone ...@@ -89,10 +92,12 @@ def filter_milestone
milestone_id = params[:milestone_id] milestone_id = params[:milestone_id]
return unless milestone_id return unless milestone_id
if milestone_id == IssuableFinder::NONE || params[:milestone_id] = '' if milestone_id == IssuableFinder::NONE
project.milestones.find_by(id: milestone_id).nil?
params[:milestone_id] = '' milestone =
end Milestone.for_projects_and_groups([project.id], [project.group&.id]).find_by_id(milestone_id)
params[:milestone_id] = '' unless milestone
end end
def filter_labels def filter_labels
......
...@@ -61,8 +61,18 @@ def cloneable_label_ids ...@@ -61,8 +61,18 @@ def cloneable_label_ids
end end
def cloneable_milestone_id def cloneable_milestone_id
@new_project.milestones title = @old_issue.milestone&.title
.find_by(title: @old_issue.milestone.try(:title)).try(:id) return unless title
if @new_project.group && can?(current_user, :read_group, @new_project.group)
group_id = @new_project.group.id
end
params =
{ title: title, project_ids: @new_project.id, group_ids: group_id }
milestones = MilestonesFinder.new(params).execute
milestones.first&.id
end end
def rewrite_notes def rewrite_notes
......
module Milestones module Milestones
class BaseService < ::BaseService class BaseService < ::BaseService
# Parent can either a group or a project
attr_accessor :parent, :current_user, :params
def initialize(parent, user, params = {})
@parent, @current_user, @params = parent, user, params.dup
end
end end
end end
module Milestones module Milestones
class CloseService < Milestones::BaseService class CloseService < Milestones::BaseService
def execute(milestone) def execute(milestone)
if milestone.close if milestone.close && milestone.is_project_milestone?
event_service.close_milestone(milestone, current_user) event_service.close_milestone(milestone, current_user)
end end
......
module Milestones module Milestones
class CreateService < Milestones::BaseService class CreateService < Milestones::BaseService
def execute def execute
milestone = project.milestones.new(params) milestone = parent.milestones.new(params)
if milestone.save if milestone.save && milestone.is_project_milestone?
event_service.open_milestone(milestone, current_user) event_service.open_milestone(milestone, current_user)
end end
......
module Milestones module Milestones
class ReopenService < Milestones::BaseService class ReopenService < Milestones::BaseService
def execute(milestone) def execute(milestone)
if milestone.activate if milestone.activate && milestone.is_project_milestone?
event_service.reopen_milestone(milestone, current_user) event_service.reopen_milestone(milestone, current_user)
end end
......
...@@ -5,9 +5,9 @@ def execute(milestone) ...@@ -5,9 +5,9 @@ def execute(milestone)
case state case state
when 'activate' when 'activate'
Milestones::ReopenService.new(project, current_user, {}).execute(milestone) Milestones::ReopenService.new(parent, current_user, {}).execute(milestone)
when 'close' when 'close'
Milestones::CloseService.new(project, current_user, {}).execute(milestone) Milestones::CloseService.new(parent, current_user, {}).execute(milestone)
end end
if params.present? if params.present?
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment