Commit b5f596c3 authored by Felipe Artur's avatar Felipe Artur Committed by Sean McGivern

Native group milestones

parent 1a3edcec
...@@ -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 @@ class Groups::MilestonesController < Groups::ApplicationController ...@@ -22,49 +22,41 @@ class Groups::MilestonesController < Groups::ApplicationController
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 @@ class Groups::MilestonesController < Groups::ApplicationController ...@@ -74,16 +66,31 @@ class Groups::MilestonesController < Groups::ApplicationController
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 @@ class Projects::MilestonesController < Projects::ApplicationController ...@@ -51,7 +47,7 @@ class Projects::MilestonesController < Projects::ApplicationController
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 @@ class Projects::MilestonesController < Projects::ApplicationController ...@@ -86,6 +82,18 @@ class Projects::MilestonesController < Projects::ApplicationController
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 @@ class IssuableFinder ...@@ -147,9 +147,17 @@ class IssuableFinder
@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 @@ class IssuableFinder ...@@ -331,11 +339,6 @@ class IssuableFinder
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 @@ module MilestonesHelper ...@@ -54,8 +54,10 @@ module MilestonesHelper
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 @@ module MilestonesHelper ...@@ -147,4 +149,14 @@ module MilestonesHelper
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 @@ module Milestoneish ...@@ -70,6 +70,22 @@ module Milestoneish
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 @@ class GlobalMilestone ...@@ -11,7 +12,10 @@ class GlobalMilestone
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 @@ class GlobalMilestone ...@@ -28,13 +32,42 @@ class GlobalMilestone
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 @@ class GroupMilestone < GlobalMilestone ...@@ -16,4 +16,8 @@ class GroupMilestone < GlobalMilestone
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 @@ class Milestone < ActiveRecord::Base ...@@ -63,6 +78,14 @@ class Milestone < ActiveRecord::Base
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 @@ class Milestone < ActiveRecord::Base ...@@ -138,6 +161,8 @@ class Milestone < ActiveRecord::Base
# 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 @@ class Milestone < ActiveRecord::Base ...@@ -152,6 +177,10 @@ class Milestone < ActiveRecord::Base
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 @@ class Milestone < ActiveRecord::Base ...@@ -164,8 +193,45 @@ class Milestone < ActiveRecord::Base
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 @@ class IssuableBaseService < BaseService ...@@ -89,10 +92,12 @@ class IssuableBaseService < BaseService
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 @@ module Issues ...@@ -61,8 +61,18 @@ module Issues
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 @@ module Milestones ...@@ -5,9 +5,9 @@ module Milestones
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?
......
= form_for [@group, @milestone], html: { class: 'form-horizontal milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
.row
= form_errors(@milestone)
.col-md-6
.form-group
= f.label :title, "Title", class: "control-label"
.col-sm-10
= f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
.form-group.milestone-description
= f.label :description, "Description", class: "control-label"
.col-sm-10
= render layout: 'projects/md_preview', locals: { url: '' } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
.clearfix
.error-alert
= render "shared/milestones/form_dates", f: f
.form-actions
- if @milestone.new_record?
= f.submit 'Create milestone', class: "btn-create btn"
= link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
- else
= f.submit 'Update milestone', class: "btn-create btn"
= link_to "Cancel", group_milestone_path(@group, @milestone), class: "btn btn-cancel"
= render 'shared/milestones/milestone', = render 'shared/milestones/milestone',
milestone_path: group_milestone_path(@group, milestone.safe_title, title: milestone.title), milestone_path: group_milestone_route(milestone),
issues_path: issues_group_path(@group, milestone_title: milestone.title), issues_path: issues_group_path(@group, milestone_title: milestone.title),
merge_requests_path: merge_requests_group_path(@group, milestone_title: milestone.title), merge_requests_path: merge_requests_group_path(@group, milestone_title: milestone.title),
milestone: milestone milestone: milestone
- page_title "Milestones"
- render "header_title"
%h3.page-title
Edit Milestone
= render "form"
...@@ -9,11 +9,6 @@ ...@@ -9,11 +9,6 @@
= link_to new_group_milestone_path(@group), class: "btn btn-new" do = link_to new_group_milestone_path(@group), class: "btn btn-new" do
New milestone New milestone
.row-content-block
Only milestones from
%strong= @group.name
group are listed here.
.milestones .milestones
%ul.content-list %ul.content-list
- if @milestones.blank? - if @milestones.blank?
......
...@@ -4,40 +4,4 @@ ...@@ -4,40 +4,4 @@
%h3.page-title %h3.page-title
New Milestone New Milestone
%p.light = render "form"
This will create milestone in every selected project
%hr
= form_for @milestone, url: group_milestones_path(@group), html: { class: 'form-horizontal milestone-form common-note-form js-quick-submit js-requires-input' } do |f|
.row
- if @milestone.errors.any?
#error_explanation
.alert.alert-danger
%ul
- @milestone.errors.full_messages.each do |msg|
%li
= msg
.col-md-6
.form-group
= f.label :title, "Title", class: "control-label"
.col-sm-10
= f.text_field :title, maxlength: 255, class: "form-control", required: true, autofocus: true
.form-group.milestone-description
= f.label :description, "Description", class: "control-label"
.col-sm-10
= render layout: 'projects/md_preview', locals: { url: '' } do
= render 'projects/zen', f: f, attr: :description, classes: 'note-textarea', placeholder: 'Write milestone description...'
.clearfix
.error-alert
.form-group
= f.label :projects, "Projects", class: "control-label"
.col-sm-10
= f.collection_select :project_ids, @group.projects.non_archived, :id, :name,
{ selected: @group.projects.non_archived.pluck(:id) }, required: true, multiple: true, class: 'select2'
= render "shared/milestones/form_dates", f: f
.form-actions
= f.submit 'Create milestone', class: "btn-create btn"
= link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
= render "header_title" = render "header_title"
= render 'shared/milestones/top', milestone: @milestone, group: @group = render 'shared/milestones/top', milestone: @milestone, group: @group
= render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true = render 'shared/milestones/tabs', milestone: @milestone, show_project_name: true if @milestone.is_legacy_group_milestone?
= render 'shared/milestones/sidebar', milestone: @milestone, affix_offset: 102 = render 'shared/milestones/sidebar', milestone: @milestone, affix_offset: 102
- dashboard = local_assigns[:dashboard] - dashboard = local_assigns[:dashboard]
- custom_dom_id = dom_id(@project ? milestone : milestone.milestones.first) - custom_dom_id = dom_id(milestone.try(:milestones) ? milestone.milestones.first : milestone)
%li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id } %li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id }
.row .row
.col-sm-6 .col-sm-6
%strong= link_to truncate(milestone.title, length: 100), milestone_path %strong= link_to truncate(milestone.title, length: 100), milestone_path
- if milestone.is_group_milestone?
%span - Group Milestone
- else
%span - Project Milestone
.col-sm-6 .col-sm-6
.pull-right.light #{milestone.percent_complete(current_user)}% complete .pull-right.light #{milestone.percent_complete(current_user)}% complete
.row .row
...@@ -13,26 +18,32 @@ ...@@ -13,26 +18,32 @@
&middot; &middot;
= link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path = link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path
.col-sm-6= milestone_progress_bar(milestone) .col-sm-6= milestone_progress_bar(milestone)
- if milestone.is_a?(GlobalMilestone) - if milestone.is_a?(GlobalMilestone) || milestone.is_group_milestone?
.row .row
.col-sm-6 .col-sm-6
.expiration= render('shared/milestone_expired', milestone: milestone) - if milestone.is_legacy_group_milestone?
.projects .expiration= render('shared/milestone_expired', milestone: milestone)
- milestone.milestones.each do |milestone| .projects
= link_to milestone_path(milestone) do - milestone.milestones.each do |milestone|
%span.label.label-gray = link_to milestone_path(milestone) do
= dashboard ? milestone.project.name_with_namespace : milestone.project.name %span.label.label-gray
= dashboard ? milestone.project.name_with_namespace : milestone.project.name
- if @group - if @group
.col-sm-6 .col-sm-6.milestone-actions
- if can?(current_user, :admin_milestones, @group) - if can?(current_user, :admin_milestones, @group)
- if milestone.is_group_milestone?
= link_to edit_group_milestone_path(@group, milestone.id), class: "btn btn-xs btn-grouped" do
Edit
\
- if milestone.closed? - if milestone.closed?
= link_to 'Reopen Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-xs btn-grouped btn-reopen" = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-xs btn-grouped btn-reopen"
- else - else
= link_to 'Close Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-xs btn-close" = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-xs btn-grouped btn-close"
- if @project - if @project
.row .row
.col-sm-6= render('shared/milestone_expired', milestone: milestone) .col-sm-6
= render('shared/milestone_expired', milestone: milestone)
.col-sm-6.milestone-actions .col-sm-6.milestone-actions
- if can?(current_user, :admin_milestone, milestone.project) and milestone.active? - if can?(current_user, :admin_milestone, milestone.project) and milestone.active?
= link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do = link_to edit_project_milestone_path(milestone.project, milestone), class: "btn btn-xs btn-grouped" do
......
...@@ -22,39 +22,55 @@ ...@@ -22,39 +22,55 @@
- if group - if group
.pull-right .pull-right
- if can?(current_user, :admin_milestones, group) - if can?(current_user, :admin_milestones, group)
- if milestone.is_group_milestone?
= link_to edit_group_milestone_path(group, milestone.iid), class: "btn btn btn-grouped" do
Edit
- if milestone.active? - if milestone.active?
= link_to 'Close Milestone', group_milestone_path(group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-grouped btn-close" = link_to 'Close Milestone', group_milestone_route(milestone, {state_event: :close }), method: :put, class: "btn btn-grouped btn-close"
- else - else
= link_to 'Reopen Milestone', group_milestone_path(group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen" = link_to 'Reopen Milestone', group_milestone_route(milestone, {state_event: :activate }), method: :put, class: "btn btn-grouped btn-reopen"
.detail-page-description.milestone-detail .detail-page-description.milestone-detail
%h2.title %h2.title
= markdown_field(milestone, :title) = markdown_field(milestone, :title)
- if @milestone.is_group_milestone? && @milestone.description.present?
%div
.description
.wiki
= markdown_field(@milestone, :description)
- if milestone.complete?(current_user) && milestone.active? - if milestone.complete?(current_user) && milestone.active?
.alert.alert-success.prepend-top-default .alert.alert-success.prepend-top-default
- close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.' - close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.'
%span All issues for this milestone are closed. #{close_msg} %span All issues for this milestone are closed. #{close_msg}
.table-holder - if @milestone.is_legacy_group_milestone? || @milestone.is_dashboard_milestone?
%table.table .table-holder
%thead %table.table
%tr %thead
%th Project %tr
%th Open issues %th Project
%th State %th Open issues
%th Due date %th State
- milestone.milestones.each do |ms| %th Due date
%tr - milestone.milestones.each do |ms|
%td %tr
- project_name = group ? ms.project.name : ms.project.name_with_namespace %td
= link_to project_name, project_milestone_path(ms.project, ms) - project_name = group ? ms.project.name : ms.project.name_with_namespace
%td = link_to project_name, project_milestone_path(ms.project, ms)
= ms.issues_visible_to_user(current_user).opened.count %td
%td = ms.issues_visible_to_user(current_user).opened.count
- if ms.closed? %td
Closed - if ms.closed?
- else Closed
Open - else
%td Open
= ms.expires_at %td
= ms.expires_at
- elsif @milestone.is_group_milestone?
%br
View
= link_to 'Issues', issues_group_path(@group, milestone_title: milestone.title)
or
= link_to 'Merge Requests', merge_requests_group_path(@group, milestone_title: milestone.title)
in this milestone
---
title: Add native group milestones
merge_request:
author:
...@@ -12,7 +12,7 @@ scope(path: 'groups/*group_id', ...@@ -12,7 +12,7 @@ scope(path: 'groups/*group_id',
end end
resource :avatar, only: [:destroy] resource :avatar, only: [:destroy]
resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :update, :new, :create] do resources :milestones, constraints: { id: /[^\/]+/ }, only: [:index, :show, :edit, :update, :new, :create] do
member do member do
get :merge_requests get :merge_requests
get :participants get :participants
......
class AddGroupIdToMilestones < ActiveRecord::Migration
DOWNTIME = false
def up
change_column_null :milestones, :project_id, true
add_column :milestones, :group_id, :integer
end
def down
# We cannot rollback project_id not null constraint if there are records
# with null values.
execute "DELETE from milestones WHERE project_id IS NULL"
remove_column :milestones, :group_id
change_column :milestones, :project_id, :integer, null: false
end
end
class AddGroupMilestoneIdIndexes < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = false
def up
add_concurrent_foreign_key :milestones, :namespaces, column: :group_id, on_delete: :cascade
add_concurrent_index :milestones, :group_id
end
def down
remove_foreign_key :milestones, column: :group_id
remove_concurrent_index :milestones, :group_id
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170703102400) do ActiveRecord::Schema.define(version: 20170724184243) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -829,7 +829,7 @@ ActiveRecord::Schema.define(version: 20170703102400) do ...@@ -829,7 +829,7 @@ ActiveRecord::Schema.define(version: 20170703102400) do
create_table "milestones", force: :cascade do |t| create_table "milestones", force: :cascade do |t|
t.string "title", null: false t.string "title", null: false
t.integer "project_id", null: false t.integer "project_id"
t.text "description" t.text "description"
t.date "due_date" t.date "due_date"
t.datetime "created_at" t.datetime "created_at"
...@@ -840,10 +840,12 @@ ActiveRecord::Schema.define(version: 20170703102400) do ...@@ -840,10 +840,12 @@ ActiveRecord::Schema.define(version: 20170703102400) do
t.text "description_html" t.text "description_html"
t.date "start_date" t.date "start_date"
t.integer "cached_markdown_version" t.integer "cached_markdown_version"
t.integer "group_id"
end end
add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"}
add_index "milestones", ["due_date"], name: "index_milestones_on_due_date", using: :btree add_index "milestones", ["due_date"], name: "index_milestones_on_due_date", using: :btree
add_index "milestones", ["group_id"], name: "index_milestones_on_group_id", using: :btree
add_index "milestones", ["project_id", "iid"], name: "index_milestones_on_project_id_and_iid", unique: true, using: :btree add_index "milestones", ["project_id", "iid"], name: "index_milestones_on_project_id_and_iid", unique: true, using: :btree
add_index "milestones", ["title"], name: "index_milestones_on_title", using: :btree add_index "milestones", ["title"], name: "index_milestones_on_title", using: :btree
add_index "milestones", ["title"], name: "index_milestones_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} add_index "milestones", ["title"], name: "index_milestones_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"}
...@@ -1601,6 +1603,7 @@ ActiveRecord::Schema.define(version: 20170703102400) do ...@@ -1601,6 +1603,7 @@ ActiveRecord::Schema.define(version: 20170703102400) do
add_foreign_key "merge_requests", "projects", column: "target_project_id", name: "fk_a6963e8447", on_delete: :cascade add_foreign_key "merge_requests", "projects", column: "target_project_id", name: "fk_a6963e8447", on_delete: :cascade
add_foreign_key "merge_requests_closing_issues", "issues", on_delete: :cascade add_foreign_key "merge_requests_closing_issues", "issues", on_delete: :cascade
add_foreign_key "merge_requests_closing_issues", "merge_requests", on_delete: :cascade add_foreign_key "merge_requests_closing_issues", "merge_requests", on_delete: :cascade
add_foreign_key "milestones", "namespaces", column: "group_id", name: "fk_95650a40d4", on_delete: :cascade
add_foreign_key "milestones", "projects", name: "fk_9bd0a0c791", on_delete: :cascade add_foreign_key "milestones", "projects", name: "fk_9bd0a0c791", on_delete: :cascade
add_foreign_key "notes", "projects", name: "fk_99e097b079", on_delete: :cascade add_foreign_key "notes", "projects", name: "fk_99e097b079", on_delete: :cascade
add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", name: "fk_oauth_openid_requests_oauth_access_grants_access_grant_id" add_foreign_key "oauth_openid_requests", "oauth_access_grants", column: "access_grant_id", name: "fk_oauth_openid_requests_oauth_access_grants_access_grant_id"
......
...@@ -21,14 +21,11 @@ Once you fill in all the details, hit the **Create milestone** button. ...@@ -21,14 +21,11 @@ Once you fill in all the details, hit the **Create milestone** button.
>**Note:** >**Note:**
You need [Master permissions](../../permissions.md) in order to create a milestone. You need [Master permissions](../../permissions.md) in order to create a milestone.
You can create a milestone for several projects in the same group simultaneously. You can create a milestone for a group that will be shared across group projects.
On the group's **Issues ➔ Milestones** page, you will be able to see the status On the group's **Issues ➔ Milestones** page, you will be able to see the state
of that milestone across all of the selected projects. To create a new milestone of that milestone and the issues/merge requests count that it shares across the group projects. To create a new milestone click the **New milestone** button. The form is the same as when creating a milestone for a specific project which you can find in the previous item.
for selected projects in the group, click the **New milestone** button. The
form is the same as when creating a milestone for a specific project with the In addition to that you will be able to filter issues or merge requests by group milestones in all projects that belongs to the milestone group.
addition of the selection of the projects you want to inherit this milestone.
![Creating a group milestone](img/milestone_group_create.png)
## Special milestone filters ## Special milestone filters
......
...@@ -22,12 +22,12 @@ Feature: Group Milestones ...@@ -22,12 +22,12 @@ Feature: Group Milestones
Then I should see group milestone with descriptions and expiry date Then I should see group milestone with descriptions and expiry date
And I should see group milestone with all issues and MRs assigned to that milestone And I should see group milestone with all issues and MRs assigned to that milestone
Scenario: Create multiple milestones with one form Scenario: Create group milestones
Given I visit group "Owned" milestones page Given I visit group "Owned" milestones page
And I click new milestone button And I click new milestone button
And I fill milestone name And I fill milestone name
When I press create mileston button When I press create mileston button
Then milestone in each project should be created Then group milestone should be created
Scenario: I should see Issues listed with labels Scenario: I should see Issues listed with labels
Given Group has projects with milestones Given Group has projects with milestones
......
...@@ -54,14 +54,9 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps ...@@ -54,14 +54,9 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps
click_button "Create milestone" click_button "Create milestone"
end end
step 'milestone in each project should be created' do step 'group milestone should be created' do
group = Group.find_by(name: 'Owned') group = Group.find_by(name: 'Owned')
expect(page).to have_content "Milestone v2.9.0" expect(page).to have_content group.milestones.find_by_title('v2.9.0').title
expect(group.projects).to be_present
group.projects.each do |project|
expect(page).to have_content project.name
end
end end
step 'I should see the "bug" label' do step 'I should see the "bug" label' do
......
...@@ -255,7 +255,7 @@ module API ...@@ -255,7 +255,7 @@ module API
class ProjectEntity < Grape::Entity class ProjectEntity < Grape::Entity
expose :id, :iid expose :id, :iid
expose(:project_id) { |entity| entity.project.id } expose(:project_id) { |entity| entity&.project.try(:id) }
expose :title, :description expose :title, :description
expose :state, :created_at, :updated_at expose :state, :created_at, :updated_at
end end
...@@ -267,7 +267,12 @@ module API ...@@ -267,7 +267,12 @@ module API
expose :deleted_file?, as: :deleted_file expose :deleted_file?, as: :deleted_file
end end
class Milestone < ProjectEntity class Milestone < Grape::Entity
expose :id, :iid
expose(:project_id) { |entity| entity&.project_id }
expose(:group_id) { |entity| entity&.group_id }
expose :title, :description
expose :state, :created_at, :updated_at
expose :due_date expose :due_date
expose :start_date expose :start_date
end end
......
...@@ -2,8 +2,8 @@ require 'spec_helper' ...@@ -2,8 +2,8 @@ require 'spec_helper'
describe Groups::MilestonesController do describe Groups::MilestonesController do
let(:group) { create(:group) } let(:group) { create(:group) }
let(:project) { create(:empty_project, group: group) } let!(:project) { create(:empty_project, group: group) }
let(:project2) { create(:empty_project, group: group) } let!(:project2) { create(:empty_project, group: group) }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:title) { '肯定不是中文的问题' } let(:title) { '肯定不是中文的问题' }
let(:milestone) do let(:milestone) do
...@@ -17,24 +17,67 @@ describe Groups::MilestonesController do ...@@ -17,24 +17,67 @@ describe Groups::MilestonesController do
end end
let(:milestone_path) { group_milestone_path(group, milestone.safe_title, title: milestone.title) } let(:milestone_path) { group_milestone_path(group, milestone.safe_title, title: milestone.title) }
let(:milestone_params) do
{
title: title,
start_date: Date.today,
due_date: 1.month.from_now.to_date
}
end
before do before do
sign_in(user) sign_in(user)
group.add_owner(user) group.add_owner(user)
project.team << [user, :master] project.team << [user, :master]
end end
describe "#index" do describe '#index' do
it 'shows group milestones page' do it 'shows group milestones page' do
get :index, group_id: group.to_param get :index, group_id: group.to_param
expect(response).to have_http_status(200) expect(response).to have_http_status(200)
end end
it 'shows group milestones JSON' do context 'as JSON' do
get :index, group_id: group.to_param, format: :json let!(:milestone) { create(:milestone, group: group, title: 'group milestone') }
let!(:legacy_milestone1) { create(:milestone, project: project, title: 'legacy') }
let!(:legacy_milestone2) { create(:milestone, project: project2, title: 'legacy') }
expect(response).to have_http_status(200) it 'lists legacy group milestones and group milestones' do
expect(response.content_type).to eq 'application/json' get :index, group_id: group.to_param, format: :json
milestones = JSON.parse(response.body)
expect(milestones.count).to eq(2)
expect(milestones.first["title"]).to eq("group milestone")
expect(milestones.second["title"]).to eq("legacy")
expect(response).to have_http_status(200)
expect(response.content_type).to eq 'application/json'
end
end
end
describe '#show' do
let(:milestone1) { create(:milestone, project: project, title: 'legacy') }
let(:milestone2) { create(:milestone, project: project, title: 'legacy') }
let(:group_milestone) { create(:milestone, group: group) }
context 'when there is a title parameter' do
it 'searchs for a legacy group milestone' do
expect(GlobalMilestone).to receive(:build)
expect(Milestone).not_to receive(:find_by_iid)
get :show, group_id: group.to_param, id: title, title: milestone1.safe_title
end
end
context 'when there is not a title parameter' do
it 'searchs for a group milestone' do
expect(GlobalMilestone).not_to receive(:build)
expect(Milestone).to receive(:find_by_iid)
get :show, group_id: group.to_param, id: group_milestone.id
end
end end
end end
...@@ -44,16 +87,57 @@ describe Groups::MilestonesController do ...@@ -44,16 +87,57 @@ describe Groups::MilestonesController do
it "creates group milestone with Chinese title" do it "creates group milestone with Chinese title" do
post :create, post :create,
group_id: group.to_param, group_id: group.to_param,
milestone: { project_ids: [project.id, project2.id], title: title } milestone: milestone_params
expect(response).to redirect_to(group_milestone_path(group, title.to_slug.to_s, title: title)) milestone = Milestone.find_by_title(title)
expect(Milestone.where(title: title).count).to eq(2)
expect(response).to redirect_to(group_milestone_path(group, milestone.iid))
expect(milestone.group_id).to eq(group.id)
expect(milestone.due_date).to eq(milestone_params[:due_date])
expect(milestone.start_date).to eq(milestone_params[:start_date])
end
end
describe "#update" do
let(:milestone) { create(:milestone, group: group) }
it "updates group milestone" do
milestone_params[:title] = "title changed"
put :update,
id: milestone.iid,
group_id: group.to_param,
milestone: milestone_params
milestone.reload
expect(response).to redirect_to(group_milestone_path(group, milestone.iid))
expect(milestone.title).to eq("title changed")
end end
it "redirects to new when there are no project ids" do context "legacy group milestones" do
post :create, group_id: group.to_param, milestone: { title: title, project_ids: [""] } let!(:milestone1) { create(:milestone, project: project, title: 'legacy milestone', description: "old description") }
expect(response).to render_template :new let!(:milestone2) { create(:milestone, project: project2, title: 'legacy milestone', description: "old description") }
expect(assigns(:milestone).errors).not_to be_nil
it "updates only group milestones state" do
milestone_params[:title] = "title changed"
milestone_params[:description] = "description changed"
milestone_params[:state_event] = "close"
put :update,
id: milestone1.title.to_slug.to_s,
group_id: group.to_param,
milestone: milestone_params,
title: milestone1.title
expect(response).to redirect_to(group_milestone_path(group, milestone1.safe_title, title: milestone1.title))
[milestone1, milestone2].each do |milestone|
milestone.reload
expect(milestone.title).to eq("legacy milestone")
expect(milestone.description).to eq("old description")
expect(milestone.state).to eq("closed")
end
end
end end
end end
...@@ -156,7 +240,7 @@ describe Groups::MilestonesController do ...@@ -156,7 +240,7 @@ describe Groups::MilestonesController do
it 'does not 404' do it 'does not 404' do
post :create, post :create,
group_id: group.to_param, group_id: group.to_param,
milestone: { project_ids: [project.id, project2.id], title: title } milestone: { title: title }
expect(response).not_to have_http_status(404) expect(response).not_to have_http_status(404)
end end
...@@ -164,7 +248,7 @@ describe Groups::MilestonesController do ...@@ -164,7 +248,7 @@ describe Groups::MilestonesController do
it 'does not redirect to the correct casing' do it 'does not redirect to the correct casing' do
post :create, post :create,
group_id: group.to_param, group_id: group.to_param,
milestone: { project_ids: [project.id, project2.id], title: title } milestone: { title: title }
expect(response).not_to have_http_status(301) expect(response).not_to have_http_status(301)
end end
...@@ -176,7 +260,7 @@ describe Groups::MilestonesController do ...@@ -176,7 +260,7 @@ describe Groups::MilestonesController do
it 'returns not found' do it 'returns not found' do
post :create, post :create,
group_id: redirect_route.path, group_id: redirect_route.path,
milestone: { project_ids: [project.id, project2.id], title: title } milestone: { title: title }
expect(response).to have_http_status(404) expect(response).to have_http_status(404)
end end
......
...@@ -31,6 +31,40 @@ describe Projects::MilestonesController do ...@@ -31,6 +31,40 @@ describe Projects::MilestonesController do
end end
end end
describe "#index" do
context "as html" do
before do
get :index, namespace_id: project.namespace.id, project_id: project.id
end
it "queries only projects milestones" do
milestones = assigns(:milestones)
expect(milestones.count).to eq(1)
expect(milestones.where(project_id: nil)).to be_empty
end
end
context "as json" do
let!(:group) { create(:group, :public) }
let!(:group_milestone) { create(:milestone, group: group) }
let!(:group_member) { create(:group_member, group: group, user: user) }
before do
project.update(namespace: group)
get :index, namespace_id: project.namespace.id, project_id: project.id, format: :json
end
it "queries projects milestones and groups milestones" do
milestones = assigns(:milestones)
expect(milestones.count).to eq(2)
expect(milestones.where(project_id: nil).first).to eq(group_milestone)
expect(milestones.where(group_id: nil).first).to eq(milestone)
end
end
end
describe "#destroy" do describe "#destroy" do
it "removes milestone" do it "removes milestone" do
expect(issue.milestone_id).to eq(milestone.id) expect(issue.milestone_id).to eq(milestone.id)
......
FactoryGirl.define do FactoryGirl.define do
factory :milestone do factory :milestone do
title title
project factory: :empty_project
transient do
project nil
group nil
project_id nil
group_id nil
end
trait :active do trait :active do
state "active" state "active"
...@@ -11,6 +17,20 @@ FactoryGirl.define do ...@@ -11,6 +17,20 @@ FactoryGirl.define do
state "closed" state "closed"
end end
after(:build) do |milestone, evaluator|
if evaluator.group
milestone.group = evaluator.group
elsif evaluator.group_id
milestone.group_id = evaluator.group_id
elsif evaluator.project
milestone.project = evaluator.project
elsif evaluator.project_id
milestone.project_id = evaluator.project_id
else
milestone.project = create(:empty_project)
end
end
factory :active_milestone, traits: [:active] factory :active_milestone, traits: [:active]
factory :closed_milestone, traits: [:closed] factory :closed_milestone, traits: [:closed]
end end
......
...@@ -33,4 +33,32 @@ feature 'Group milestones', :feature, :js do ...@@ -33,4 +33,32 @@ feature 'Group milestones', :feature, :js do
expect(find('.start_date')).to have_content(Date.today.at_beginning_of_month.strftime('%b %-d, %Y')) expect(find('.start_date')).to have_content(Date.today.at_beginning_of_month.strftime('%b %-d, %Y'))
end end
end end
context 'milestones list' do
let!(:other_project) { create(:project_empty_repo, group: group) }
let!(:active_group_milestone) { create(:milestone, group: group, state: 'active') }
let!(:active_project_milestone1) { create(:milestone, project: project, state: 'active', title: 'v1.0') }
let!(:active_project_milestone2) { create(:milestone, project: other_project, state: 'active', title: 'v1.0') }
let!(:closed_group_milestone) { create(:milestone, group: group, state: 'closed') }
let!(:closed_project_milestone1) { create(:milestone, project: project, state: 'closed', title: 'v2.0') }
let!(:closed_project_milestone2) { create(:milestone, project: other_project, state: 'closed', title: 'v2.0') }
before do
visit group_milestones_path(group)
end
it 'counts milestones correctly' do
expect(find('.top-area .active .badge').text).to eq("2")
expect(find('.top-area .closed .badge').text).to eq("2")
expect(find('.top-area .all .badge').text).to eq("4")
end
it 'lists legacy group milestones and group milestones' do
legacy_milestone = GroupMilestone.build_collection(group, group.projects, { state: 'active' }).first
expect(page).to have_selector("#milestone_#{active_group_milestone.id}", count: 1)
expect(page).to have_selector("#milestone_#{legacy_milestone.milestones.first.id}", count: 1)
end
end
end end
require 'rails_helper' require 'rails_helper'
feature 'Milestone', feature: true do feature 'Milestone', feature: true do
let(:project) { create(:empty_project, :public) } let(:group) { create(:group, :public) }
let(:project) { create(:empty_project, :public, namespace: group) }
let(:user) { create(:user) } let(:user) { create(:user) }
before do before do
create(:group_member, group: group, user: user)
project.team << [user, :master] project.team << [user, :master]
gitlab_sign_in(user) gitlab_sign_in(user)
end end
...@@ -37,8 +39,8 @@ feature 'Milestone', feature: true do ...@@ -37,8 +39,8 @@ feature 'Milestone', feature: true do
end end
end end
feature 'Open a milestone with an existing title' do feature 'Open a project milestone with an existing title' do
scenario 'displays validation message' do scenario 'displays validation message when there is a project milestone with same title' do
milestone = create(:milestone, project: project, title: 8.7) milestone = create(:milestone, project: project, title: 8.7)
visit new_project_milestone_path(project) visit new_project_milestone_path(project)
...@@ -47,7 +49,20 @@ feature 'Milestone', feature: true do ...@@ -47,7 +49,20 @@ feature 'Milestone', feature: true do
end end
find('input[name="commit"]').click find('input[name="commit"]').click
expect(find('.alert-danger')).to have_content('Title has already been taken') expect(find('.alert-danger')).to have_content('already being used for another group or project milestone.')
end
scenario 'displays validation message when there is a group milestone with same title' do
milestone = create(:milestone, project_id: nil, group: project.group, title: 8.7)
visit new_group_milestone_path(project.group)
page.within '.milestone-form' do
fill_in "milestone_title", with: milestone.title
end
find('input[name="commit"]').click
expect(find('.alert-danger')).to have_content('already being used for another group or project milestone.')
end end
end end
end end
...@@ -59,6 +59,23 @@ describe IssuesFinder do ...@@ -59,6 +59,23 @@ describe IssuesFinder do
end end
end end
context 'filtering by group milestone' do
let!(:group) { create(:group, :public) }
let(:group_milestone) { create(:milestone, group: group) }
let!(:group_member) { create(:group_member, group: group, user: user) }
let(:params) { { milestone_title: group_milestone.title } }
before do
project2.update(namespace: group)
issue2.update(milestone: group_milestone)
issue3.update(milestone: group_milestone)
end
it 'returns issues assigned to that group milestone' do
expect(issues).to contain_exactly(issue2, issue3)
end
end
context 'filtering by no milestone' do context 'filtering by no milestone' do
let(:params) { { milestone_title: Milestone::None.title } } let(:params) { { milestone_title: Milestone::None.title } }
......
...@@ -47,6 +47,25 @@ describe MergeRequestsFinder do ...@@ -47,6 +47,25 @@ describe MergeRequestsFinder do
expect(merge_requests).to contain_exactly(merge_request1) expect(merge_requests).to contain_exactly(merge_request1)
end end
context 'filtering by group milestone' do
let!(:group) { create(:group, :public) }
let(:group_milestone) { create(:milestone, group: group) }
let!(:group_member) { create(:group_member, group: group, user: user) }
let(:params) { { milestone_title: group_milestone.title } }
before do
project2.update(namespace: group)
merge_request2.update(milestone: group_milestone)
merge_request3.update(milestone: group_milestone)
end
it 'returns issues assigned to that group milestone' do
merge_requests = described_class.new(user, params).execute
expect(merge_requests).to contain_exactly(merge_request2, merge_request3)
end
end
context 'with created_after and created_before params' do context 'with created_after and created_before params' do
let(:project4) { create(:empty_project, forked_from_project: project1) } let(:project4) { create(:empty_project, forked_from_project: project1) }
......
require 'spec_helper'
describe MilestonesFinder do
let(:group) { create(:group) }
let(:project_1) { create(:empty_project, namespace: group) }
let(:project_2) { create(:empty_project, namespace: group) }
let!(:milestone_1) { create(:milestone, group: group, title: 'one test', due_date: Date.today) }
let!(:milestone_2) { create(:milestone, group: group) }
let!(:milestone_3) { create(:milestone, project: project_1, state: 'active', due_date: Date.tomorrow) }
let!(:milestone_4) { create(:milestone, project: project_2, state: 'active') }
it 'it returns milestones for projects' do
result = described_class.new(project_ids: [project_1.id, project_2.id], state: 'all').execute
expect(result).to contain_exactly(milestone_3, milestone_4)
end
it 'returns milestones for groups' do
result = described_class.new(group_ids: group.id, state: 'all').execute
expect(result).to contain_exactly(milestone_1, milestone_2)
end
it 'returns milestones for groups and projects' do
result = described_class.new(project_ids: [project_1.id, project_2.id], group_ids: group.id, state: 'all').execute
expect(result).to contain_exactly(milestone_1, milestone_2, milestone_3, milestone_4)
end
context 'with filters' do
let(:params) do
{
project_ids: [project_1.id, project_2.id],
group_ids: group.id,
state: 'all'
}
end
before do
milestone_1.close
milestone_3.close
end
it 'filters by active state' do
params[:state] = 'active'
result = described_class.new(params).execute
expect(result).to contain_exactly(milestone_2, milestone_4)
end
it 'filters by closed state' do
params[:state] = 'closed'
result = described_class.new(params).execute
expect(result).to contain_exactly(milestone_1, milestone_3)
end
it 'filters by title' do
result = described_class.new(params.merge(title: 'one test')).execute
expect(result.to_a).to contain_exactly(milestone_1)
end
end
context 'with order' do
let(:params) do
{
project_ids: [project_1.id, project_2.id],
group_ids: group.id,
state: 'all'
}
end
it "default orders by due date" do
result = described_class.new(params).execute
expect(result.first).to eq(milestone_1)
expect(result.second).to eq(milestone_3)
end
it "orders by parameter" do
result = described_class.new(params.merge(order: 'id DESC')).execute
expect(result.first).to eq(milestone_4)
expect(result.second).to eq(milestone_3)
expect(result.third).to eq(milestone_2)
expect(result.fourth).to eq(milestone_1)
end
end
end
...@@ -22,7 +22,8 @@ ...@@ -22,7 +22,8 @@
"properties": { "properties": {
"id": { "type": "integer" }, "id": { "type": "integer" },
"iid": { "type": "integer" }, "iid": { "type": "integer" },
"project_id": { "type": "integer" }, "project_id": { "type": ["integer", "null"] },
"group_id": { "type": ["integer", "null"] },
"title": { "type": "string" }, "title": { "type": "string" },
"description": { "type": ["string", "null"] }, "description": { "type": ["string", "null"] },
"state": { "type": "string" }, "state": { "type": "string" },
......
...@@ -53,7 +53,8 @@ ...@@ -53,7 +53,8 @@
"properties": { "properties": {
"id": { "type": "integer" }, "id": { "type": "integer" },
"iid": { "type": "integer" }, "iid": { "type": "integer" },
"project_id": { "type": "integer" }, "project_id": { "type": ["integer", "null"] },
"group_id": { "type": ["integer", "null"] },
"title": { "type": "string" }, "title": { "type": "string" },
"description": { "type": ["string", "null"] }, "description": { "type": ["string", "null"] },
"state": { "type": "string" }, "state": { "type": "string" },
......
...@@ -22,7 +22,8 @@ ...@@ -22,7 +22,8 @@
"properties": { "properties": {
"id": { "type": "integer" }, "id": { "type": "integer" },
"iid": { "type": "integer" }, "iid": { "type": "integer" },
"project_id": { "type": "integer" }, "project_id": { "type": ["integer", "null"] },
"group_id": { "type": ["integer", "null"] },
"title": { "type": "string" }, "title": { "type": "string" },
"description": { "type": ["string", "null"] }, "description": { "type": ["string", "null"] },
"state": { "type": "string" }, "state": { "type": "string" },
......
...@@ -53,7 +53,8 @@ ...@@ -53,7 +53,8 @@
"properties": { "properties": {
"id": { "type": "integer" }, "id": { "type": "integer" },
"iid": { "type": "integer" }, "iid": { "type": "integer" },
"project_id": { "type": "integer" }, "project_id": { "type": ["integer", "null"] },
"group_id": { "type": ["integer", "null"] },
"title": { "type": "string" }, "title": { "type": "string" },
"description": { "type": ["string", "null"] }, "description": { "type": ["string", "null"] },
"state": { "type": "string" }, "state": { "type": "string" },
......
...@@ -45,6 +45,7 @@ label: ...@@ -45,6 +45,7 @@ label:
- merge_requests - merge_requests
- priorities - priorities
milestone: milestone:
- group
- project - project
- issues - issues
- labels - labels
......
...@@ -82,6 +82,7 @@ Milestone: ...@@ -82,6 +82,7 @@ Milestone:
- id - id
- title - title
- project_id - project_id
- group_id
- description - description
- due_date - due_date
- start_date - start_date
......
...@@ -6,9 +6,6 @@ describe Milestone, models: true do ...@@ -6,9 +6,6 @@ describe Milestone, models: true do
allow(subject).to receive(:set_iid).and_return(false) allow(subject).to receive(:set_iid).and_return(false)
end end
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:project) }
describe 'start_date' do describe 'start_date' do
it 'adds an error when start_date is greated then due_date' do it 'adds an error when start_date is greated then due_date' do
milestone = build(:milestone, start_date: Date.tomorrow, due_date: Date.yesterday) milestone = build(:milestone, start_date: Date.tomorrow, due_date: Date.yesterday)
...@@ -37,17 +34,42 @@ describe Milestone, models: true do ...@@ -37,17 +34,42 @@ describe Milestone, models: true do
end end
end end
describe "unique milestone title per project" do describe "unique milestone title" do
it "does not accept the same title in a project twice" do context "per project" do
new_milestone = Milestone.new(project: milestone.project, title: milestone.title) it "does not accept the same title in a project twice" do
expect(new_milestone).not_to be_valid new_milestone = Milestone.new(project: milestone.project, title: milestone.title)
expect(new_milestone).not_to be_valid
end
it "accepts the same title in another project" do
project = create(:empty_project)
new_milestone = Milestone.new(project: project, title: milestone.title)
expect(new_milestone).to be_valid
end
end end
it "accepts the same title in another project" do context "per group" do
project = build(:empty_project) let(:group) { create(:group) }
new_milestone = Milestone.new(project: project, title: milestone.title) let(:milestone) { create(:milestone, group: group) }
before do
project.update(group: group)
end
it "does not accept the same title in a group twice" do
new_milestone = Milestone.new(group: group, title: milestone.title)
expect(new_milestone).not_to be_valid
end
expect(new_milestone).to be_valid it "does not accept the same title of a child project milestone" do
create(:milestone, project: group.projects.first)
new_milestone = Milestone.new(group: group, title: milestone.title)
expect(new_milestone).not_to be_valid
end
end end
end end
......
...@@ -37,9 +37,6 @@ describe Issues::MoveService, services: true do ...@@ -37,9 +37,6 @@ describe Issues::MoveService, services: true do
describe '#execute' do describe '#execute' do
shared_context 'issue move executed' do shared_context 'issue move executed' do
let!(:milestone2) do
create(:milestone, project_id: new_project.id, title: 'v9.0')
end
let!(:award_emoji) { create(:award_emoji, awardable: old_issue) } let!(:award_emoji) { create(:award_emoji, awardable: old_issue) }
let!(:new_issue) { move_service.execute(old_issue, new_project) } let!(:new_issue) { move_service.execute(old_issue, new_project) }
...@@ -48,6 +45,63 @@ describe Issues::MoveService, services: true do ...@@ -48,6 +45,63 @@ describe Issues::MoveService, services: true do
context 'issue movable' do context 'issue movable' do
include_context 'user can move issue' include_context 'user can move issue'
context 'move to new milestone' do
let(:new_issue) { move_service.execute(old_issue, new_project) }
context 'project milestone' do
let!(:milestone2) do
create(:milestone, project_id: new_project.id, title: 'v9.0')
end
it 'assigns milestone to new issue' do
expect(new_issue.reload.milestone.title).to eq 'v9.0'
expect(new_issue.reload.milestone).to eq(milestone2)
end
end
context 'group milestones' do
let!(:group) { create(:group, :private) }
let!(:group_milestone_1) do
create(:milestone, group_id: group.id, title: 'v9.0_group')
end
before do
old_issue.update(milestone: group_milestone_1)
old_project.update(namespace: group)
new_project.update(namespace: group)
group.add_users([user], GroupMember::DEVELOPER)
end
context 'when moving to a project of the same group' do
it 'keeps the same group milestone' do
expect(new_issue.reload.project).to eq(new_project)
expect(new_issue.reload.milestone).to eq(group_milestone_1)
end
end
context 'when moving to a project of a different group' do
let!(:group_2) { create(:group, :private) }
let!(:group_milestone_2) do
create(:milestone, group_id: group_2.id, title: 'v9.0_group')
end
before do
old_issue.update(milestone: group_milestone_1)
new_project.update(namespace: group_2)
group_2.add_users([user], GroupMember::DEVELOPER)
end
it 'assigns to new group milestone of same title' do
expect(new_issue.reload.project).to eq(new_project)
expect(new_issue.reload.milestone).to eq(group_milestone_2)
end
end
end
end
context 'generic issue' do context 'generic issue' do
include_context 'issue move executed' include_context 'issue move executed'
...@@ -55,11 +109,6 @@ describe Issues::MoveService, services: true do ...@@ -55,11 +109,6 @@ describe Issues::MoveService, services: true do
expect(new_issue.project).to eq new_project expect(new_issue.project).to eq new_project
end end
it 'assigns milestone to new issue' do
expect(new_issue.reload.milestone.title).to eq 'v9.0'
expect(new_issue.reload.milestone).to eq(milestone2)
end
it 'assign labels to new issue' do it 'assign labels to new issue' do
expected_label_titles = new_issue.reload.labels.map(&:title) expected_label_titles = new_issue.reload.labels.map(&:title)
expect(expected_label_titles).to include 'label1' expect(expected_label_titles).to include 'label1'
......
...@@ -253,13 +253,13 @@ describe Issues::UpdateService, services: true do ...@@ -253,13 +253,13 @@ describe Issues::UpdateService, services: true do
end end
context 'when the milestone change' do context 'when the milestone change' do
before do it 'marks todos as done' do
update_issue(milestone: create(:milestone)) update_issue(milestone: create(:milestone))
end
it 'marks todos as done' do
expect(todo.reload.done?).to eq true expect(todo.reload.done?).to eq true
end end
it_behaves_like 'system notes for milestones'
end end
context 'when the labels change' do context 'when the labels change' do
......
...@@ -296,13 +296,13 @@ describe MergeRequests::UpdateService, services: true do ...@@ -296,13 +296,13 @@ describe MergeRequests::UpdateService, services: true do
end end
context 'when the milestone change' do context 'when the milestone change' do
before do it 'marks pending todos as done' do
update_merge_request({ milestone: create(:milestone) }) update_merge_request({ milestone: create(:milestone) })
end
it 'marks pending todos as done' do
expect(pending_todo.reload).to be_done expect(pending_todo.reload).to be_done
end end
it_behaves_like 'system notes for milestones'
end end
context 'when the labels change' do context 'when the labels change' do
......
...@@ -5,3 +5,34 @@ shared_examples 'cache counters invalidator' do ...@@ -5,3 +5,34 @@ shared_examples 'cache counters invalidator' do
described_class.new(project, user, {}).execute(merge_request) described_class.new(project, user, {}).execute(merge_request)
end end
end end
shared_examples 'system notes for milestones' do
def update_issuable(opts)
issuable = try(:issue) || try(:merge_request)
described_class.new(project, user, opts).execute(issuable)
end
context 'group milestones' do
let(:group) { create(:group) }
let(:group_milestone) { create(:milestone, group: group) }
before do
project.update(namespace: group)
create(:group_member, group: group, user: user)
end
it 'does not create system note' do
expect do
update_issuable(milestone: group_milestone)
end.not_to change { Note.system.count }
end
end
context 'project milestones' do
it 'creates system note' do
expect do
update_issuable(milestone: create(:milestone))
end.to change { Note.system.count }.by(1)
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