diff options
author | Felipe Artur <felipefac@gmail.com> | 2017-06-27 15:16:44 -0300 |
---|---|---|
committer | Felipe Artur <felipefac@gmail.com> | 2017-07-05 11:24:31 -0300 |
commit | 1684a975671359ebc81eaa384f5acadffe57640d (patch) | |
tree | 3e89eadf7dda1eef9b43c4310a12b71f107e0205 | |
parent | 6eb57c42a466d85d677679f3a8ff63b67336bfae (diff) | |
download | gitlab-ce-1684a975671359ebc81eaa384f5acadffe57640d.tar.gz |
Native group milestones
53 files changed, 694 insertions, 357 deletions
diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb index 97eed419595..f33989cddff 100644 --- a/app/controllers/groups/milestones_controller.rb +++ b/app/controllers/groups/milestones_controller.rb @@ -2,13 +2,13 @@ class Groups::MilestonesController < Groups::ApplicationController include MilestoneActions before_action :group_projects - before_action :milestone, only: [:show, :update, :merge_requests, :participants, :labels] - before_action :authorize_admin_milestones!, only: [:new, :create, :update] + before_action :milestone, only: [:edit, :show, :update, :merge_requests, :participants, :labels] + before_action :authorize_admin_milestones!, only: [:edit, :new, :create, :update] def index respond_to do |format| format.html do - @milestone_states = GlobalMilestone.states_count(@projects, group) + @milestone_states = GlobalMilestone.states_count(group_projects, group) @milestones = Kaminari.paginate_array(milestones).page(params[:page]) end format.json do @@ -18,7 +18,7 @@ class Groups::MilestonesController < Groups::ApplicationController end def new - @milestone = GroupMilestone.new + @milestone = Milestone.new end def create @@ -35,12 +35,21 @@ class Groups::MilestonesController < Groups::ApplicationController def show end + def edit + render_404 if @milestone.is_legacy_group_milestone? + end + def update - @milestone.milestones.each do |milestone| - Milestones::UpdateService.new(milestone.project, current_user, milestone_params).execute(milestone) + milestones = @milestone.milestones if @milestone.is_legacy_group_milestone? + # Keep this compatible with legacy group milestones where we have to update + # all projects milestones at once. + milestones ||= Array(@milestone) + + milestones.each do |milestone| + Milestones::UpdateService.new(milestone.parent, current_user, milestone_params).execute(milestone) end - redirect_back_or_default(default: milestone_path(@milestone.title)) + redirect_to milestone_path(@milestone.title) end private @@ -50,23 +59,23 @@ class Groups::MilestonesController < Groups::ApplicationController end def milestone_params - params.require(:group_milestone).permit(:title, :description, :start_date, :due_date, :state_event) + params.require(:milestone).permit(:title, :description, :start_date, :due_date, :state_event) end def milestone_path(title) - group_milestone_path(@group, title.to_slug.to_s, title: title) + group_milestone_path(group, title.to_slug.to_s, title: title) end def milestones - @group_milestones = GroupMilestonesFinder.new(group, params).execute - @project_milestones = ProjectMilestonesFinder.new(@projects, params).execute + milestones = MilestonesFinder.new(groups: group, params: params).execute + legacy_milestones = GroupMilestone.build_collection(group, group_projects, params) || [] - @group_milestones + @project_milestones + milestones + legacy_milestones end def milestone @milestone = - @group.milestones.find_by_title(params[:title]) || GroupMilestone.build(@group, @projects, params[:title]) + group.milestones.find_by_title(params[:title]) || GroupMilestone.build(group, group_projects, params[:title]) render_404 unless @milestone end diff --git a/app/controllers/projects/milestones_controller.rb b/app/controllers/projects/milestones_controller.rb index ea9891697b7..884bc8e6fea 100644 --- a/app/controllers/projects/milestones_controller.rb +++ b/app/controllers/projects/milestones_controller.rb @@ -13,20 +13,15 @@ class Projects::MilestonesController < Projects::ApplicationController respond_to :html 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' - @milestones = @milestones.sort(@sort) + @milestones = milestones.sort(@sort) respond_to do |format| format.html do @project_namespace = @project.namespace.becomes(Namespace) - @milestones = @milestones.includes(:project) + # Shows only projects milestones on list + # although we need to show them in the dropdown. + @milestones = @milestones.where(group: nil).includes(:project) @milestones = @milestones.page(params[:page]) end format.json do @@ -87,6 +82,16 @@ class Projects::MilestonesController < Projects::ApplicationController protected + def milestones + @milestones ||= begin + if @project.group && can?(current_user, :read_group, @project.group) + group = @project.group + end + + MilestonesFinder.new(projects: @project, groups: group, params: params).execute + end + end + def milestone @milestone ||= @project.milestones.find_by!(iid: params[:id]) end diff --git a/app/finders/group_milestones_finder.rb b/app/finders/group_milestones_finder.rb deleted file mode 100644 index d5bf810c41b..00000000000 --- a/app/finders/group_milestones_finder.rb +++ /dev/null @@ -1,12 +0,0 @@ -class GroupMilestonesFinder - attr_reader :group, :params - - def initialize(group, params) - @group = group - @params = params - end - - def execute - GroupMilestone.filter_by_state(group.milestones, params[:state]) - end -end diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 7bc2117f61e..8d40f40d33e 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -330,12 +330,7 @@ class IssuableFinder elsif filter_by_started_milestone? items = items.left_joins_milestones.where('milestones.start_date <= NOW()') else - items = items.with_milestone(params[:milestone_title]) - items_projects = projects(items) - - if items_projects - items = items.where(milestones: { project_id: items_projects }) - end + items = filter_by_project_and_group_milestones(items) end end @@ -432,4 +427,17 @@ class IssuableFinder ['issuables_count', klass.to_ability_name, opts.sort] end + + def filter_by_project_and_group_milestones(items) + items = items.with_milestone(params[:milestone_title]) + + items_projects = projects(items) + + if items_projects + items_group = project? ? project.group : Group.find_by_id(items_projects.group_ids) + + milestones = MilestonesFinder.new(projects: items_projects, groups: items_group, params: { state: 'all' }).execute + items.where(milestone: milestones) + end + end end diff --git a/app/finders/milestones_finder.rb b/app/finders/milestones_finder.rb new file mode 100644 index 00000000000..7f18e889ad3 --- /dev/null +++ b/app/finders/milestones_finder.rb @@ -0,0 +1,29 @@ +# Searchs for group milestones and project milestones. +# +# Parameters +# projects: array of projects or single project +# groups: array of groups or single group +# params: Search params + +class MilestonesFinder + attr_reader :projects, :groups, :params + + def initialize(projects: nil, groups: nil, params: {}) + @projects = Array(projects) + @groups = Array(groups) + @params = params + end + + def execute + conditions = [] + table = Milestone.arel_table + project_ids = projects&.map(&:id) + group_ids = groups&.map(&:id) + + conditions << table[:group_id].in(group_ids) if group_ids + conditions << table[:project_id].in(project_ids) if project_ids + + milestones = Milestone.where(conditions.reduce(:or)).reorder("due_date ASC") + Milestone.filter_by_state(milestones, params[:state]) + end +end diff --git a/app/finders/project_milestones_finder.rb b/app/finders/project_milestones_finder.rb deleted file mode 100644 index a9ffbc9de55..00000000000 --- a/app/finders/project_milestones_finder.rb +++ /dev/null @@ -1,14 +0,0 @@ -class ProjectMilestonesFinder - attr_reader :projects, :params - - def initialize(projects, params) - @projects = projects - @params = params - end - - def execute - milestones = Milestone.of_projects(projects) - - Milestone.filter_by_state(milestones, params[:state]) - end -end diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index f346e20e807..ae70172973b 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -54,8 +54,10 @@ module MilestonesHelper def milestone_class_for_state(param, check, match_blank_param = false) if match_blank_param 'active' if param.blank? || param == check + elsif param == check + 'active' else - 'active' if param == check + check end end diff --git a/app/models/concerns/internal_id.rb b/app/models/concerns/internal_id.rb index 5382dde6765..67a0adfcd56 100644 --- a/app/models/concerns/internal_id.rb +++ b/app/models/concerns/internal_id.rb @@ -8,7 +8,8 @@ module InternalId def set_iid 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? max_iid = records.maximum(:iid) diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index cc39fe6eb8e..c4fcab17f9f 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -30,7 +30,6 @@ module Issuable belongs_to :updated_by, class_name: "User" belongs_to :last_edited_by, class_name: 'User' belongs_to :milestone - belongs_to :group_milestone has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do def authors_loaded? diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index 60aeab38118..6475390edae 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -40,7 +40,7 @@ module Milestoneish def issues_visible_to_user(user) memoize_per_user(user, :issues_visible_to_user) do IssuesFinder.new(user, issues_finder_params) - .execute.preload(:assignees).where(issues_finder_conditions) + .execute.preload(:assignees).where(milestone_id: milestoneish_ids) end end @@ -71,23 +71,19 @@ module Milestoneish end def is_group_milestone? - respond_to?(:group_id) + false end def is_project_milestone? - respond_to?(:project_id) + false end - def title=(value) - write_attribute(:title, sanitize_title(value)) if value.present? + def is_legacy_group_milestone? + false end private - def sanitize_title(value) - CGI.unescape_html(Sanitize.clean(value.to_s)) - end - def count_issues_by_state(user) memoize_per_user(user, :count_issues_by_state) do issues_visible_to_user(user).reorder(nil).group(:state).count @@ -105,8 +101,4 @@ module Milestoneish def issues_finder_params {} end - - def issues_finder_conditions - { milestone_id: milestoneish_ids } - end end diff --git a/app/models/concerns/shared_milestone_properties.rb b/app/models/concerns/shared_milestone_properties.rb deleted file mode 100644 index 77a8da9fd93..00000000000 --- a/app/models/concerns/shared_milestone_properties.rb +++ /dev/null @@ -1,74 +0,0 @@ -module SharedMilestoneProperties - extend ActiveSupport::Concern - - include StripAttribute - include CacheMarkdownField - - included do - has_many :issues - has_many :merge_requests - has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues - - validate :uniqueness_of_title, if: :title_changed? - - scope :active, -> { with_state(:active) } - scope :closed, -> { with_state(:closed) } - - validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } - strip_attributes :title - alias_attribute :name, :title - - state_machine :state, initial: :active do - event :close do - transition active: :closed - end - - event :activate do - transition closed: :active - end - - state :closed - - state :active - end - - alias_attribute :name, :title - - cache_markdown_field :title, pipeline: :single_line - cache_markdown_field :description - end - - module ClassMethods - def filter_by_state(milestones, state) - case state - when 'closed' then milestones.closed - when 'all' then milestones - else milestones.active - end - end - end - - def start_date_should_be_less_than_due_date - if due_date <= start_date - errors.add(:start_date, "Can't be greater than due date") - end - end - - def safe_title - title.to_slug.normalize.to_s - end - - private - - # Milestone titles must be unique across project milestones and group milestones - def uniqueness_of_title(group, project = nil) - if group - title_exists = group.milestones.find_by_title(title).present? - title_exists ||= Milestone.where(project: group.projects).find_by_title(title).present? - elsif project - title_exists = project.milestones.find_by_title(title).present? - end - - errors.add(:title, "Must be unique across project milestones and group milestones.") if title_exists - end -end diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb index 8ef4546ee24..d1d90ab638c 100644 --- a/app/models/global_milestone.rb +++ b/app/models/global_milestone.rb @@ -2,7 +2,7 @@ class GlobalMilestone include Milestoneish EPOCH = DateTime.parse('1970-01-01') - STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 } + STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze attr_accessor :title, :milestones alias_attribute :name, :title @@ -12,7 +12,7 @@ class GlobalMilestone end def self.build_collection(projects, params) - child_milestones = ProjectMilestonesFinder.new(projects, params).execute + child_milestones = MilestonesFinder.new(projects: projects, params: params).execute milestones = child_milestones.select(:id, :title).group_by(&:title).map do |title, grouped| milestones_relation = Milestone.where(id: grouped.map(&:id)) @@ -30,18 +30,18 @@ class GlobalMilestone end def self.states_count(projects, group = nil) - projects_milestones_count = legacy_group_milestone_states_count(projects) + legacy_group_milestones_count = legacy_group_milestone_states_count(projects) group_milestones_count = group_milestones_states_count(group) - projects_milestones_count.merge(group_milestones_count) do |k, project_milestones_count, group_milestones_count| - project_milestones_count + group_milestones_count + 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 - relation = GroupMilestonesFinder.new(group, state: 'all').execute + relation = MilestonesFinder.new(groups: group, params: { state: 'all' }).execute grouped_by_state = relation.reorder(nil).group(:state).count { @@ -51,10 +51,11 @@ class GlobalMilestone } 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 - relation = ProjectMilestonesFinder.new(projects, state: 'all').execute + relation = MilestonesFinder.new(projects: projects, params: { state: 'all' }).execute project_milestones_by_state_and_title = relation.reorder(nil).group(:state, :title).count opened = count_by_state(project_milestones_by_state_and_title, 'active') diff --git a/app/models/group.rb b/app/models/group.rb index 93e381712bf..78566285159 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -18,7 +18,7 @@ class Group < Namespace has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' - has_many :milestones, class_name: 'GroupMilestone' + has_many :milestones has_many :project_group_links, dependent: :destroy has_many :shared_projects, through: :project_group_links, source: :project has_many :notification_settings, dependent: :destroy, as: :source diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb index c518fe430bd..65249bd7bfc 100644 --- a/app/models/group_milestone.rb +++ b/app/models/group_milestone.rb @@ -1,35 +1,23 @@ -class GroupMilestone < ActiveRecord::Base - include SharedMilestoneProperties - include Milestoneish - include CacheMarkdownField +class GroupMilestone < GlobalMilestone + attr_accessor :group - belongs_to :group - - class << self - # Build legacy group milestone which consists on all project milestones - # with the same title. - def build(group, projects, title) - GlobalMilestone.build(projects, title).tap do |milestone| - milestone&.group = group - end + def self.build_collection(group, projects, params) + super(projects, params).each do |milestone| + milestone.group = group end end - def milestoneish_ids - id - end - - def participants - User.joins(assigned_issues: :group_milestone).where("group_milestones.id = ?", id).uniq + def self.build(group, projects, title) + super(projects, title).tap do |milestone| + milestone&.group = group + end end - private - - def uniqueness_of_title - super(group) + def issues_finder_params + { group_id: group.id } end - def issues_finder_conditions - { group_milestone_id: milestoneish_ids } + def is_legacy_group_milestone? + true end end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 601346be318..7936fd8a9f2 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -7,17 +7,53 @@ class Milestone < ActiveRecord::Base Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2) Started = MilestoneStruct.new('Started', '#started', -3) - include SharedMilestoneProperties include InternalId include Sortable include Referable include Milestoneish + include StripAttribute + include CacheMarkdownField + + cache_markdown_field :title, pipeline: :single_line + cache_markdown_field :description belongs_to :project + belongs_to :group + has_many :issues + has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues + has_many :merge_requests has_many :events, as: :target, dependent: :destroy scope :of_projects, ->(ids) { where(project_id: ids) } + scope :of_groups, ->(ids) { where(group_id: ids) } + scope :active, -> { with_state(:active) } + scope :closed, -> { with_state(:closed) } + + validates :group, presence: true, unless: :project + validates :project, presence: true, unless: :group + + validate :uniqueness_of_title, if: :title_changed? + validate :milestone_type_check + validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? } + + strip_attributes :title + + state_machine :state, initial: :active do + event :close do + transition active: :closed + end + + event :activate do + transition closed: :active + end + + state :closed + + state :active + end + + alias_attribute :name, :title class << self # Searches for milestones matching the given query. @@ -33,6 +69,14 @@ class Milestone < ActiveRecord::Base where(t[:title].matches(pattern).or(t[:description].matches(pattern))) end + + def filter_by_state(milestones, state) + case state + when 'closed' then milestones.closed + when 'all' then milestones + else milestones.active + end + end end def self.reference_prefix @@ -111,13 +155,21 @@ class Milestone < ActiveRecord::Base format_reference = milestone_format_reference(format) reference = "#{self.class.reference_prefix}#{format_reference}" - "#{project.to_reference(from_project, full: full)}#{reference}" + if project + "#{project.to_reference(from_project, full: full)}#{reference}" + elsif group + "#{group.to_reference}#{reference}" + end end def reference_link_text(from_project = nil) self.title end + def title=(value) + write_attribute(:title, sanitize_title(value)) if value.present? + end + def milestoneish_ids id end @@ -130,10 +182,43 @@ class Milestone < ActiveRecord::Base nil 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 + # Milestone titles must be unique across project milestones and group milestones def uniqueness_of_title - super(project.group, project) + if project + title_exists = project.milestones.find_by_title(title) + title_exists ||= project.group.milestones.find_by_title(title) if project.group + elsif group + title_exists = group.milestones.find_by_title(title) + title_exists ||= Milestone.where(project: group.projects).find_by_title(title) + end + + 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) @@ -146,6 +231,16 @@ class Milestone < ActiveRecord::Base end end + def sanitize_title(value) + CGI.unescape_html(Sanitize.clean(value.to_s)) + end + + def start_date_should_be_less_than_due_date + if due_date <= start_date + errors.add(:start_date, "Can't be greater than due date") + end + end + def issues_finder_params { project_id: project_id } end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index a25234b886b..672eab94c07 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -16,7 +16,6 @@ class Namespace < ActiveRecord::Base cache_markdown_field :description, pipeline: :description has_many :projects, dependent: :destroy - has_many :groups, dependent: :destroy has_many :project_statistics belongs_to :owner, class_name: "User" diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 8dd0846f3bc..dbbc9ae273a 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -89,8 +89,10 @@ class IssuableBaseService < BaseService milestone_id = params[:milestone_id] return unless milestone_id - if milestone_id == IssuableFinder::NONE || - project.milestones.find_by(id: milestone_id).nil? + params[:milestone_id] = '' if milestone_id == IssuableFinder::NONE + + if project.milestones.find_by(id: milestone_id).nil? && + project.group&.milestones&.find(milestone_id).nil? params[:milestone_id] = '' end end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 711f4035c55..020365fa19a 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -61,8 +61,16 @@ module Issues end def cloneable_milestone_id - @new_project.milestones - .find_by(title: @old_issue.milestone.try(:title)).try(:id) + title = @old_issue.milestone&.title + return unless title + + milestone_id = @new_project.milestones.find_by(title: title).try(:id) + + if milestone_id.nil? && @new_project.group && can?(current_user, :read_group, @new_project.group) + milestone_id = @new_project.group.milestones.find_by(title: title).try(:id) + end + + milestone_id end def rewrite_notes diff --git a/app/services/milestones/close_service.rb b/app/services/milestones/close_service.rb index 608fc49d766..776ec4b287b 100644 --- a/app/services/milestones/close_service.rb +++ b/app/services/milestones/close_service.rb @@ -1,7 +1,7 @@ module Milestones class CloseService < Milestones::BaseService def execute(milestone) - if milestone.close + if milestone.close && milestone.is_project_milestone? event_service.close_milestone(milestone, current_user) end diff --git a/app/services/milestones/reopen_service.rb b/app/services/milestones/reopen_service.rb index 573f9ee5c21..5b8b682caaf 100644 --- a/app/services/milestones/reopen_service.rb +++ b/app/services/milestones/reopen_service.rb @@ -1,7 +1,7 @@ module Milestones class ReopenService < Milestones::BaseService def execute(milestone) - if milestone.activate + if milestone.activate && milestone.is_project_milestone? event_service.reopen_milestone(milestone, current_user) end diff --git a/app/views/groups/milestones/_form.html.haml b/app/views/groups/milestones/_form.html.haml new file mode 100644 index 00000000000..2715b708a9f --- /dev/null +++ b/app/views/groups/milestones/_form.html.haml @@ -0,0 +1,29 @@ += 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) + + = hidden_field_tag :title, @milestone&.title + + .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" + diff --git a/app/views/groups/milestones/edit.html.haml b/app/views/groups/milestones/edit.html.haml new file mode 100644 index 00000000000..5f6d7d209d0 --- /dev/null +++ b/app/views/groups/milestones/edit.html.haml @@ -0,0 +1,7 @@ +- page_title "Milestones" +- render "header_title" + +%h3.page-title + Edit Milestone + += render "form" diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml index f91bee0b610..6ceb4092307 100644 --- a/app/views/groups/milestones/index.html.haml +++ b/app/views/groups/milestones/index.html.haml @@ -9,11 +9,6 @@ = link_to new_group_milestone_path(@group), class: "btn btn-new" do New milestone -.row-content-block - Only milestones from - %strong= @group.name - group are listed here. - .milestones %ul.content-list - if @milestones.blank? diff --git a/app/views/groups/milestones/new.html.haml b/app/views/groups/milestones/new.html.haml index 7c7573862d0..e24844661ee 100644 --- a/app/views/groups/milestones/new.html.haml +++ b/app/views/groups/milestones/new.html.haml @@ -4,40 +4,4 @@ %h3.page-title New Milestone -%p.light - 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 "form" diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml index 33e68bc766e..54b1b7a734a 100644 --- a/app/views/groups/milestones/show.html.haml +++ b/app/views/groups/milestones/show.html.haml @@ -1,4 +1,4 @@ = render "header_title" = 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 diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index d0acf575cbe..2294d37aa4d 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -1,10 +1,15 @@ - dashboard = local_assigns[:dashboard] -- custom_dom_id = "milestone_#{milestone.title}" +- custom_dom_id = dom_id(milestone.try(:milestones) ? milestone.milestones.first : milestone) %li{ class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: custom_dom_id } .row .col-sm-6 %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 .pull-right.light #{milestone.percent_complete(current_user)}% complete .row @@ -13,26 +18,32 @@ · = link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path .col-sm-6= milestone_progress_bar(milestone) - - if milestone.is_a?(GlobalMilestone) + - if milestone.is_a?(GlobalMilestone) || milestone.is_group_milestone? .row .col-sm-6 - .expiration= render('shared/milestone_expired', milestone: milestone) - .projects - - milestone.milestones.each do |milestone| - = link_to milestone_path(milestone) do - %span.label.label-gray - = dashboard ? milestone.project.name_with_namespace : milestone.project.name + - if milestone.is_legacy_group_milestone? + .expiration= render('shared/milestone_expired', milestone: milestone) + .projects + - milestone.milestones.each do |milestone| + = link_to milestone_path(milestone) do + %span.label.label-gray + = dashboard ? milestone.project.name_with_namespace : milestone.project.name - if @group - .col-sm-6 + .col-sm-6.milestone-actions - if can?(current_user, :admin_milestones, @group) + - if milestone.is_group_milestone? + = link_to edit_group_milestone_path(@group, milestone.safe_title, title: milestone.title), class: "btn btn-xs btn-grouped" do + Edit + \ - 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" - 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_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-xs btn-grouped btn-close" - if @project .row - .col-sm-6= render('shared/milestone_expired', milestone: milestone) + .col-sm-6 + = render('shared/milestone_expired', milestone: milestone) .col-sm-6.milestone-actions - if can?(current_user, :admin_milestone, milestone.project) and milestone.active? = link_to edit_namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone), class: "btn btn-xs btn-grouped" do diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 58ea0a356ae..149dfdb9158 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -22,6 +22,9 @@ - if group .pull-right - if can?(current_user, :admin_milestones, group) + - if milestone.is_group_milestone? + = link_to edit_group_milestone_path(group, milestone.safe_title, title: milestone.title), class: "btn btn btn-grouped" do + Edit - 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" - else @@ -30,32 +33,44 @@ .detail-page-description.milestone-detail %h2.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? .alert.alert-success.prepend-top-default - 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} -%h2 Only link to group milestone issues instead of list(for now) -/ .table-holder -/ %table.table -/ %thead -/ %tr -/ %th Project -/ %th Open issues -/ %th State -/ %th Due date -/ - milestone.milestones.each do |ms| -/ %tr -/ %td -/ - project_name = group ? ms.project.name : ms.project.name_with_namespace -/ = link_to project_name, namespace_project_milestone_path(ms.project.namespace, ms.project, ms) -/ %td -/ = ms.issues_visible_to_user(current_user).opened.count -/ %td -/ - if ms.closed? -/ Closed -/ - else -/ Open -/ %td -/ = ms.expires_at +- if @milestone.is_legacy_group_milestone? + .table-holder + %table.table + %thead + %tr + %th Project + %th Open issues + %th State + %th Due date + - milestone.milestones.each do |ms| + %tr + %td + - project_name = group ? ms.project.name : ms.project.name_with_namespace + = link_to project_name, namespace_project_milestone_path(ms.project.namespace, ms.project, ms) + %td + = ms.issues_visible_to_user(current_user).opened.count + %td + - if ms.closed? + Closed + - else + Open + %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 diff --git a/changelogs/unreleased/issue_30126_be.yml b/changelogs/unreleased/issue_30126_be.yml new file mode 100644 index 00000000000..96bb8d9574b --- /dev/null +++ b/changelogs/unreleased/issue_30126_be.yml @@ -0,0 +1,4 @@ +--- +title: Add native group milestones +merge_request: +author: diff --git a/config/routes/group.rb b/config/routes/group.rb index 11cdff55ed8..47010401191 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -12,7 +12,7 @@ scope(path: 'groups/*group_id', end 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 get :merge_requests get :participants diff --git a/db/migrate/20170619183807_create_group_milestones.rb b/db/migrate/20170619183807_create_group_milestones.rb deleted file mode 100644 index 9ce287bb306..00000000000 --- a/db/migrate/20170619183807_create_group_milestones.rb +++ /dev/null @@ -1,23 +0,0 @@ -class CreateGroupMilestones < ActiveRecord::Migration - DOWNTIME = false - - def change - create_table :group_milestones do |t| - t.integer :group_id - t.string :title - t.text :description - t.date :start_date - t.date :due_date - t.string :state - t.string :title_html - t.string :description_html - t.integer :cached_markdown_version, limit: 4 - end - - add_column :issues, :group_milestone_id, :integer - add_column :merge_requests, :group_milestone_id, :integer - - add_foreign_key :group_milestones, :namespaces, column: :group_id, null: false # rubocop: disable Migration/AddConcurrentForeignKey - add_index :group_milestones, :group_id - end -end diff --git a/db/migrate/20170619184243_add_group_milestone_group_id_indexes.rb b/db/migrate/20170619184243_add_group_milestone_group_id_indexes.rb new file mode 100644 index 00000000000..2bdea582b86 --- /dev/null +++ b/db/migrate/20170619184243_add_group_milestone_group_id_indexes.rb @@ -0,0 +1,19 @@ +class AddGroupMilestoneGroupIdIndexes < 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 diff --git a/db/migrate/20170623183807_add_group_id_to_milestones.rb b/db/migrate/20170623183807_add_group_id_to_milestones.rb new file mode 100644 index 00000000000..ab98905ac53 --- /dev/null +++ b/db/migrate/20170623183807_add_group_id_to_milestones.rb @@ -0,0 +1,18 @@ +class AddGroupIdToMilestones < ActiveRecord::Migration + DOWNTIME = false + + def up + change_column_null :milestones, :project_id, true + + add_column :milestones, :group_id, :integer + end + + def down + remove_column :milestones, :group_id + change_column_null :milestones, :project_id, true + + # We cannot rollback project_id not null constraint if there are records + # with null values. + execute "DELETE from milestones WHERE project_id IS NULL" + end +end diff --git a/db/migrate/20170624184243_add_group_milestone_id_indexes.rb b/db/migrate/20170624184243_add_group_milestone_id_indexes.rb index 836d39e6e6d..6289d6d894f 100644 --- a/db/migrate/20170624184243_add_group_milestone_id_indexes.rb +++ b/db/migrate/20170624184243_add_group_milestone_id_indexes.rb @@ -6,18 +6,14 @@ class AddGroupMilestoneIdIndexes < ActiveRecord::Migration DOWNTIME = false def up - add_foreign_key :issues, :namespaces, column: :group_milestone_id - add_foreign_key :merge_requests, :namespaces, column: :group_milestone_id + add_concurrent_foreign_key :milestones, :namespaces, column: :group_id - add_concurrent_index :issues, :group_milestone_id - add_concurrent_index :merge_requests, :group_milestone_id + add_concurrent_index :milestones, :group_id end def down - remove_foreign_key :issues, column: :group_milestone_id - remove_foreign_key :merge_requests, column: :group_milestone_id + remove_foreign_key :milestones, column: :group_id - remove_concurrent_index :issues, :group_milestone_id - remove_concurrent_index :merge_requests, :group_milestone_id + remove_concurrent_index :milestones, :group_id end end diff --git a/db/schema.rb b/db/schema.rb index eec2e91c3c6..6d2fe1e47ea 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -771,14 +771,7 @@ ActiveRecord::Schema.define(version: 20170624184243) do t.datetime "last_edited_at" t.integer "last_edited_by_id" t.integer "head_pipeline_id" -<<<<<<< bce6d12bb9365ec2e8ffd5f40f0d1f03f88b315a -<<<<<<< 5af1fcd6f329858d757bab0d67cb50af6c820160 t.boolean "ref_fetched" -======= - t.integer "group_milestone_id" ->>>>>>> Add group milestones table -======= ->>>>>>> Native group milestones end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree @@ -786,14 +779,7 @@ ActiveRecord::Schema.define(version: 20170624184243) do add_index "merge_requests", ["created_at"], name: "index_merge_requests_on_created_at", using: :btree add_index "merge_requests", ["deleted_at"], name: "index_merge_requests_on_deleted_at", using: :btree add_index "merge_requests", ["description"], name: "index_merge_requests_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} -<<<<<<< bce6d12bb9365ec2e8ffd5f40f0d1f03f88b315a -<<<<<<< 5af1fcd6f329858d757bab0d67cb50af6c820160 add_index "merge_requests", ["head_pipeline_id"], name: "index_merge_requests_on_head_pipeline_id", using: :btree -======= - add_index "merge_requests", ["group_milestone_id"], name: "index_merge_requests_on_group_milestone_id", using: :btree ->>>>>>> Add group milestones table -======= ->>>>>>> Native group milestones add_index "merge_requests", ["milestone_id"], name: "index_merge_requests_on_milestone_id", using: :btree add_index "merge_requests", ["source_branch"], name: "index_merge_requests_on_source_branch", using: :btree add_index "merge_requests", ["source_project_id"], name: "index_merge_requests_on_source_project_id", using: :btree diff --git a/features/group/milestones.feature b/features/group/milestones.feature index 1c1539b3e12..2211acfee20 100644 --- a/features/group/milestones.feature +++ b/features/group/milestones.feature @@ -22,12 +22,12 @@ Feature: Group Milestones 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 - Scenario: Create multiple milestones with one form + Scenario: Create group milestones Given I visit group "Owned" milestones page And I click new milestone button And I fill milestone name 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 Given Group has projects with milestones diff --git a/features/steps/group/milestones.rb b/features/steps/group/milestones.rb index 0542b06c0ab..03910c00729 100644 --- a/features/steps/group/milestones.rb +++ b/features/steps/group/milestones.rb @@ -54,14 +54,9 @@ class Spinach::Features::GroupMilestones < Spinach::FeatureSteps click_button "Create milestone" end - step 'milestone in each project should be created' do + step 'group milestone should be created' do group = Group.find_by(name: 'Owned') - expect(page).to have_content "Milestone v2.9.0" - expect(group.projects).to be_present - - group.projects.each do |project| - expect(page).to have_content project.name - end + expect(page).to have_content group.milestones.find_by_title('v2.9.0').title end step 'I should see the "bug" label' do diff --git a/lib/api/entities.rb b/lib/api/entities.rb index cef5a0abe12..d1068fff03b 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -266,7 +266,12 @@ module API expose :deleted_file?, as: :deleted_file 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 :start_date end diff --git a/spec/controllers/groups/milestones_controller_spec.rb b/spec/controllers/groups/milestones_controller_spec.rb index c6e5fb61cf9..67c70af9ad1 100644 --- a/spec/controllers/groups/milestones_controller_spec.rb +++ b/spec/controllers/groups/milestones_controller_spec.rb @@ -17,6 +17,14 @@ describe Groups::MilestonesController do end 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 sign_in(user) group.add_owner(user) @@ -44,16 +52,31 @@ describe Groups::MilestonesController do it "creates group milestone with Chinese title" do post :create, group_id: group.to_param, - milestone: { project_ids: [project.id, project2.id], title: title } + milestone: milestone_params + milestone = Milestone.find_by_title(title) expect(response).to redirect_to(group_milestone_path(group, title.to_slug.to_s, title: title)) - expect(Milestone.where(title: title).count).to eq(2) + 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.title.to_slug.to_s, + group_id: group.to_param, + milestone: milestone_params, + title: milestone.title - it "redirects to new when there are no project ids" do - post :create, group_id: group.to_param, milestone: { title: title, project_ids: [""] } - expect(response).to render_template :new - expect(assigns(:milestone).errors).not_to be_nil + milestone.reload + expect(response).to redirect_to(group_milestone_path(group, milestone.title.to_slug.to_s, title: milestone.title)) + expect(milestone.title).to eq("title changed") end end @@ -156,7 +179,7 @@ describe Groups::MilestonesController do it 'does not 404' do post :create, 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) end @@ -164,7 +187,7 @@ describe Groups::MilestonesController do it 'does not redirect to the correct casing' do post :create, 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) end @@ -176,7 +199,7 @@ describe Groups::MilestonesController do it 'returns not found' do post :create, group_id: redirect_route.path, - milestone: { project_ids: [project.id, project2.id], title: title } + milestone: { title: title } expect(response).to have_http_status(404) end diff --git a/spec/controllers/projects/milestones_controller_spec.rb b/spec/controllers/projects/milestones_controller_spec.rb index 84a61b2784e..5af36df7405 100644 --- a/spec/controllers/projects/milestones_controller_spec.rb +++ b/spec/controllers/projects/milestones_controller_spec.rb @@ -31,6 +31,39 @@ describe Projects::MilestonesController do 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 it "removes milestone" do expect(issue.milestone_id).to eq(milestone.id) diff --git a/spec/factories/milestones.rb b/spec/factories/milestones.rb index 841ab3c73b8..113665ff11b 100644 --- a/spec/factories/milestones.rb +++ b/spec/factories/milestones.rb @@ -1,7 +1,13 @@ FactoryGirl.define do factory :milestone do title - project factory: :empty_project + + transient do + project nil + group nil + project_id nil + group_id nil + end trait :active do state "active" @@ -11,6 +17,20 @@ FactoryGirl.define do state "closed" 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 :closed_milestone, traits: [:closed] end diff --git a/spec/features/groups/milestone_spec.rb b/spec/features/groups/milestone_spec.rb index 330310eae6b..9b6eb946f4b 100644 --- a/spec/features/groups/milestone_spec.rb +++ b/spec/features/groups/milestone_spec.rb @@ -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')) 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 diff --git a/spec/features/milestone_spec.rb b/spec/features/milestone_spec.rb index 58989581ffe..4a3ce55c71a 100644 --- a/spec/features/milestone_spec.rb +++ b/spec/features/milestone_spec.rb @@ -1,7 +1,8 @@ require 'rails_helper' 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) } before do @@ -37,8 +38,8 @@ feature 'Milestone', feature: true do end end - feature 'Open a milestone with an existing title' do - scenario 'displays validation message' do + feature 'Open a project milestone with an existing title' do + scenario 'displays validation message when there is a project milestone with same title' do milestone = create(:milestone, project: project, title: 8.7) visit new_namespace_project_milestone_path(project.namespace, project) @@ -47,7 +48,19 @@ feature 'Milestone', feature: true do end 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_namespace_project_milestone_path(project.namespace, project) + 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 diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index 4a52f0d5c58..bef4fd44331 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -59,6 +59,23 @@ describe IssuesFinder do 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 let(:params) { { milestone_title: Milestone::None.title } } diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index 5eb26de6c92..b46218bf72e 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -47,6 +47,25 @@ describe MergeRequestsFinder do expect(merge_requests).to contain_exactly(merge_request1) 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 let(:project4) { create(:empty_project, forked_from_project: project1) } diff --git a/spec/finders/milestones_finder_spec.rb b/spec/finders/milestones_finder_spec.rb new file mode 100644 index 00000000000..e81a5bae9a9 --- /dev/null +++ b/spec/finders/milestones_finder_spec.rb @@ -0,0 +1,48 @@ +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) } + let!(:milestone_2) { create(:milestone, group: group) } + let!(:milestone_3) { create(:milestone, project: project_1, state: 'active') } + let!(:milestone_4) { create(:milestone, project: project_2, state: 'active') } + + it 'it returns milestones for projects' do + result = described_class.new(projects: [project_1, project_2], params: { state: 'all' }).execute + + expect(result).to contain_exactly(milestone_3, milestone_4) + end + + it 'returns milestones for groups' do + result = described_class.new(groups: group, params: { 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(projects: [project_1, project_2], groups: group, params: { state: 'all' }).execute + + expect(result).to contain_exactly(milestone_1, milestone_2, milestone_3, milestone_4) + end + + context 'state filtering' do + before do + milestone_1.close + milestone_3.close + end + + it 'filters by active state' do + result = described_class.new(projects: [project_1, project_2], groups: group, params: { state: 'active' }).execute + + expect(result).to contain_exactly(milestone_2, milestone_4) + end + + it 'filters by closed state' do + result = described_class.new(projects: [project_1, project_2], groups: group, params: { state: 'closed' }).execute + + expect(result).to contain_exactly(milestone_1, milestone_3) + end + end +end diff --git a/spec/fixtures/api/schemas/public_api/v3/issues.json b/spec/fixtures/api/schemas/public_api/v3/issues.json index f2ee9c925ae..51b0822bc66 100644 --- a/spec/fixtures/api/schemas/public_api/v3/issues.json +++ b/spec/fixtures/api/schemas/public_api/v3/issues.json @@ -22,7 +22,8 @@ "properties": { "id": { "type": "integer" }, "iid": { "type": "integer" }, - "project_id": { "type": "integer" }, + "project_id": { "type": ["integer", "null"] }, + "group_id": { "type": ["integer", "null"] }, "title": { "type": "string" }, "description": { "type": ["string", "null"] }, "state": { "type": "string" }, diff --git a/spec/fixtures/api/schemas/public_api/v3/merge_requests.json b/spec/fixtures/api/schemas/public_api/v3/merge_requests.json index 01f9fbb2c89..b5c74bcc26e 100644 --- a/spec/fixtures/api/schemas/public_api/v3/merge_requests.json +++ b/spec/fixtures/api/schemas/public_api/v3/merge_requests.json @@ -53,7 +53,8 @@ "properties": { "id": { "type": "integer" }, "iid": { "type": "integer" }, - "project_id": { "type": "integer" }, + "project_id": { "type": ["integer", "null"] }, + "group_id": { "type": ["integer", "null"] }, "title": { "type": "string" }, "description": { "type": ["string", "null"] }, "state": { "type": "string" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/issues.json b/spec/fixtures/api/schemas/public_api/v4/issues.json index 2d1c84ee93d..bd6bfc03199 100644 --- a/spec/fixtures/api/schemas/public_api/v4/issues.json +++ b/spec/fixtures/api/schemas/public_api/v4/issues.json @@ -22,7 +22,8 @@ "properties": { "id": { "type": "integer" }, "iid": { "type": "integer" }, - "project_id": { "type": "integer" }, + "project_id": { "type": ["integer", "null"] }, + "group_id": { "type": ["integer", "null"] }, "title": { "type": "string" }, "description": { "type": ["string", "null"] }, "state": { "type": "string" }, diff --git a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json index 51642e8cbb8..60aa47c1259 100644 --- a/spec/fixtures/api/schemas/public_api/v4/merge_requests.json +++ b/spec/fixtures/api/schemas/public_api/v4/merge_requests.json @@ -53,7 +53,8 @@ "properties": { "id": { "type": "integer" }, "iid": { "type": "integer" }, - "project_id": { "type": "integer" }, + "project_id": { "type": ["integer", "null"] }, + "group_id": { "type": ["integer", "null"] }, "title": { "type": "string" }, "description": { "type": ["string", "null"] }, "state": { "type": "string" }, diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index a5f09f1856e..5df4fc2de84 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -45,6 +45,7 @@ label: - merge_requests - priorities milestone: +- group - project - issues - labels diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index fadd3ad1330..1c40984e863 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -82,6 +82,7 @@ Milestone: - id - title - project_id +- group_id - description - due_date - start_date diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index 45953023a36..6c06c10e7ce 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -6,9 +6,6 @@ describe Milestone, models: true do allow(subject).to receive(:set_iid).and_return(false) end - it { is_expected.to validate_presence_of(:title) } - it { is_expected.to validate_presence_of(:project) } - describe 'start_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) @@ -37,17 +34,42 @@ describe Milestone, models: true do end end - describe "unique milestone title per project" do - it "does not accept the same title in a project twice" do - new_milestone = Milestone.new(project: milestone.project, title: milestone.title) - expect(new_milestone).not_to be_valid + describe "unique milestone title" do + context "per project" do + it "does not accept the same title in a project twice" do + 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 = build(:empty_project) + new_milestone = Milestone.new(project: project, title: milestone.title) + + expect(new_milestone).to be_valid + end end - it "accepts the same title in another project" do - project = build(:empty_project) - new_milestone = Milestone.new(project: project, title: milestone.title) + context "per group" do + let(:group) { create(:group) } + 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 diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index d1dd1466d95..36d5038fb95 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -37,9 +37,6 @@ describe Issues::MoveService, services: true do describe '#execute' 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!(:new_issue) { move_service.execute(old_issue, new_project) } @@ -48,6 +45,63 @@ describe Issues::MoveService, services: true do context 'issue movable' do 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 include_context 'issue move executed' @@ -55,11 +109,6 @@ describe Issues::MoveService, services: true do expect(new_issue.project).to eq new_project 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 expected_label_titles = new_issue.reload.labels.map(&:title) expect(expected_label_titles).to include 'label1' |