Commit 0061143c authored by Dmitriy Zaporozhets's avatar Dmitriy Zaporozhets

Merge branch 'global-milestones' into 'master'

Create milestones in the group

When you work with groups its quite often you want to create same milestone in multiple projects. This MR allows you to do so

For #3488 
Signed-off-by: default avatarDmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com>

See merge request !1797
parents 66c76053 b093f509
...@@ -43,6 +43,8 @@ v 8.2.0 (unreleased) ...@@ -43,6 +43,8 @@ v 8.2.0 (unreleased)
- Ability to add release notes (markdown text and attachments) to git tags (aka Releases) - Ability to add release notes (markdown text and attachments) to git tags (aka Releases)
- Relative links from a repositories README.md now link to the default branch - Relative links from a repositories README.md now link to the default branch
- Fix trailing whitespace issue in merge request/issue title - Fix trailing whitespace issue in merge request/issue title
- Fix bug when milestone/label filter was empty for dashboard issues page
- Add ability to create milestone in group projects from single form
v 8.1.4 v 8.1.4
- Fix bug where manually merged branches in a MR would end up with an empty diff (Stan Hu) - Fix bug where manually merged branches in a MR would end up with an empty diff (Stan Hu)
......
...@@ -28,6 +28,8 @@ class Dispatcher ...@@ -28,6 +28,8 @@ class Dispatcher
when 'projects:milestones:new', 'projects:milestones:edit' when 'projects:milestones:new', 'projects:milestones:edit'
new ZenMode() new ZenMode()
new DropzoneInput($('.milestone-form')) new DropzoneInput($('.milestone-form'))
when 'groups:milestones:new'
new ZenMode()
when 'projects:compare:show' when 'projects:compare:show'
new Diff() new Diff()
when 'projects:issues:new','projects:issues:edit' when 'projects:issues:new','projects:issues:edit'
......
module GlobalMilestones
extend ActiveSupport::Concern
def milestones
@milestones = MilestonesFinder.new.execute(@projects, params)
@milestones = GlobalMilestone.build_collection(@milestones)
@milestones = Kaminari.paginate_array(@milestones).page(params[:page]).per(ApplicationController::PER_PAGE)
end
def milestone
milestones = Milestone.of_projects(@projects).where(title: params[:title])
if milestones.present?
@milestone = GlobalMilestone.new(params[:title], milestones)
else
render_404
end
end
end
class Dashboard::MilestonesController < Dashboard::ApplicationController class Dashboard::MilestonesController < Dashboard::ApplicationController
before_action :load_projects include GlobalMilestones
before_action :projects
before_action :milestones, only: [:index]
before_action :milestone, only: [:show]
def index def index
project_milestones = case params[:state]
when 'all'; state
when 'closed'; state('closed')
else state('active')
end
@dashboard_milestones = Milestones::GroupService.new(project_milestones).execute
@dashboard_milestones = Kaminari.paginate_array(@dashboard_milestones).page(params[:page]).per(PER_PAGE)
end end
def show def show
project_milestones = Milestone.where(project_id: @projects).order("due_date ASC")
@dashboard_milestone = Milestones::GroupService.new(project_milestones).milestone(title)
end end
private private
def load_projects def projects
@projects = current_user.authorized_projects.sorted_by_activity.non_archived @projects ||= current_user.authorized_projects.sorted_by_activity.non_archived
end
def title
params[:title]
end
def state(state = nil)
conditions = { project_id: @projects }
conditions.reverse_merge!(state: state) if state
Milestone.where(conditions).order("title ASC")
end end
end end
class DashboardController < Dashboard::ApplicationController class DashboardController < Dashboard::ApplicationController
before_action :event_filter, only: :activity before_action :event_filter, only: :activity
before_action :projects, only: [:issues, :merge_requests]
respond_to :html respond_to :html
...@@ -47,4 +48,8 @@ class DashboardController < Dashboard::ApplicationController ...@@ -47,4 +48,8 @@ class DashboardController < Dashboard::ApplicationController
@events = @event_filter.apply_filter(@events).with_associations @events = @event_filter.apply_filter(@events).with_associations
@events = @events.limit(20).offset(params[:offset] || 0) @events = @events.limit(20).offset(params[:offset] || 0)
end end
def projects
@projects ||= current_user.authorized_projects.sorted_by_activity.non_archived
end
end end
class Groups::ApplicationController < ApplicationController class Groups::ApplicationController < ApplicationController
layout 'group' layout 'group'
before_action :group
private private
def group
@group ||= Group.find_by(path: params[:group_id])
end
def authorize_read_group! def authorize_read_group!
unless @group and can?(current_user, :read_group, @group) unless @group and can?(current_user, :read_group, @group)
if current_user.nil? if current_user.nil?
......
class Groups::AvatarsController < ApplicationController class Groups::AvatarsController < Groups::ApplicationController
def destroy def destroy
@group = Group.find_by(path: params[:group_id])
@group.remove_avatar! @group.remove_avatar!
@group.save @group.save
redirect_to edit_group_path(@group) redirect_to edit_group_path(@group)
......
class Groups::GroupMembersController < Groups::ApplicationController class Groups::GroupMembersController < Groups::ApplicationController
skip_before_action :authenticate_user!, only: [:index] skip_before_action :authenticate_user!, only: [:index]
before_action :group
# Authorize # Authorize
before_action :authorize_read_group! before_action :authorize_read_group!
...@@ -80,10 +79,6 @@ class Groups::GroupMembersController < Groups::ApplicationController ...@@ -80,10 +79,6 @@ class Groups::GroupMembersController < Groups::ApplicationController
protected protected
def group
@group ||= Group.find_by(path: params[:group_id])
end
def member_params def member_params
params.require(:group_member).permit(:access_level, :user_id) params.require(:group_member).permit(:access_level, :user_id)
end end
......
class Groups::MilestonesController < Groups::ApplicationController class Groups::MilestonesController < Groups::ApplicationController
before_action :authorize_group_milestone!, only: :update include GlobalMilestones
before_action :projects
before_action :milestones, only: [:index]
before_action :milestone, only: [:show, :update]
before_action :authorize_group_milestone!, only: [:create, :update]
def index def index
project_milestones = case params[:state]
when 'all'; state
when 'closed'; state('closed')
else state('active')
end
@group_milestones = Milestones::GroupService.new(project_milestones).execute
@group_milestones = Kaminari.paginate_array(@group_milestones).page(params[:page]).per(PER_PAGE)
end end
def show def new
project_milestones = Milestone.where(project_id: group.projects).order("due_date ASC") @milestone = Milestone.new
@group_milestone = Milestones::GroupService.new(project_milestones).milestone(title)
end end
def update def create
project_milestones = Milestone.where(project_id: group.projects).order("due_date ASC") project_ids = params[:milestone][:project_ids]
@group_milestones = Milestones::GroupService.new(project_milestones).milestone(title) title = milestone_params[:title]
@group.projects.where(id: project_ids).each do |project|
Milestones::CreateService.new(project, current_user, milestone_params).execute
end
@group_milestones.milestones.each do |milestone| redirect_to milestone_path(title)
Milestones::UpdateService.new(milestone.project, current_user, params[:milestone]).execute(milestone)
end end
respond_to do |format| def show
format.js
format.html do
redirect_to group_milestones_path(group)
end end
def update
@milestone.milestones.each do |milestone|
Milestones::UpdateService.new(milestone.project, current_user, milestone_params).execute(milestone)
end end
redirect_back_or_default(default: milestone_path(@milestone.title))
end end
private private
def group def authorize_group_milestone!
@group ||= Group.find_by(path: params[:group_id]) return render_404 unless can?(current_user, :admin_milestones, group)
end end
def title def milestone_params
params[:title] params.require(:milestone).permit(:title, :description, :due_date, :state_event)
end end
def state(state = nil) def milestone_path(title)
conditions = { project_id: group.projects } group_milestone_path(@group, title.parameterize, title: title)
conditions.reverse_merge!(state: state) if state
Milestone.where(conditions).order("title ASC")
end end
def authorize_group_milestone! def projects
return render_404 unless can?(current_user, :admin_group, group) @projects ||= @group.projects
end end
end end
class MilestonesFinder
def execute(projects, params)
milestones = Milestone.of_projects(projects)
milestones = milestones.order("due_date ASC")
case params[:state]
when 'closed' then milestones.closed
when 'all' then milestones
else milestones.active
end
end
end
...@@ -100,7 +100,7 @@ module LabelsHelper ...@@ -100,7 +100,7 @@ module LabelsHelper
Label.where(project_id: @projects) Label.where(project_id: @projects)
end end
grouped_labels = Labels::GroupService.new(labels).execute grouped_labels = GlobalLabel.build_collection(labels)
grouped_labels.unshift(Label::None) grouped_labels.unshift(Label::None)
grouped_labels.unshift(Label::Any) grouped_labels.unshift(Label::Any)
......
...@@ -28,7 +28,7 @@ module MilestonesHelper ...@@ -28,7 +28,7 @@ module MilestonesHelper
Milestone.where(project_id: @projects) Milestone.where(project_id: @projects)
end.active end.active
grouped_milestones = Milestones::GroupService.new(milestones).execute grouped_milestones = GlobalMilestone.build_collection(milestones)
grouped_milestones.unshift(Milestone::None) grouped_milestones.unshift(Milestone::None)
grouped_milestones.unshift(Milestone::Any) grouped_milestones.unshift(Milestone::Any)
......
...@@ -233,6 +233,7 @@ class Ability ...@@ -233,6 +233,7 @@ class Ability
if group.has_master?(user) || group.has_owner?(user) || user.admin? if group.has_master?(user) || group.has_owner?(user) || user.admin?
rules.push(*[ rules.push(*[
:create_projects, :create_projects,
:admin_milestones
]) ])
end end
......
class GroupLabel class GlobalLabel
attr_accessor :title, :labels attr_accessor :title, :labels
alias_attribute :name, :title alias_attribute :name, :title
def self.build_collection(labels)
labels = labels.group_by(&:title)
labels.map do |title, label|
new(title, label)
end
end
def initialize(title, labels) def initialize(title, labels)
@title = title @title = title
@labels = labels @labels = labels
......
class GroupMilestone class GlobalMilestone
attr_accessor :title, :milestones attr_accessor :title, :milestones
alias_attribute :name, :title alias_attribute :name, :title
def self.build_collection(milestones)
milestones = milestones.group_by(&:title)
milestones.map do |title, milestones|
new(title, milestones)
end
end
def initialize(title, milestones) def initialize(title, milestones)
@title = title @title = title
@milestones = milestones @milestones = milestones
...@@ -60,15 +68,15 @@ class GroupMilestone ...@@ -60,15 +68,15 @@ class GroupMilestone
end end
def issues def issues
@group_issues ||= milestones.map(&:issues).flatten.group_by(&:state) @issues ||= milestones.map(&:issues).flatten.group_by(&:state)
end end
def merge_requests def merge_requests
@group_merge_requests ||= milestones.map(&:merge_requests).flatten.group_by(&:state) @merge_requests ||= milestones.map(&:merge_requests).flatten.group_by(&:state)
end end
def participants def participants
@group_participants ||= milestones.map(&:participants).flatten.compact.uniq @participants ||= milestones.map(&:participants).flatten.compact.uniq
end end
def opened_issues def opened_issues
...@@ -86,4 +94,8 @@ class GroupMilestone ...@@ -86,4 +94,8 @@ class GroupMilestone
def closed_merge_requests def closed_merge_requests
merge_requests.values_at("closed", "merged", "locked").compact.flatten merge_requests.values_at("closed", "merged", "locked").compact.flatten
end end
def complete?
total_items_count == closed_items_count
end
end end
module Labels
class GroupService < ::BaseService
def initialize(project_labels)
@project_labels = project_labels.group_by(&:title)
end
def execute
build(@project_labels)
end
def label(title)
if title
group_label = @project_labels[title].group_by(&:title)
build(group_label).first
else
nil
end
end
private
def build(label)
label.map { |title, labels| GroupLabel.new(title, labels) }
end
end
end
module Milestones
class GroupService < Milestones::BaseService
def initialize(project_milestones)
@project_milestones = project_milestones.group_by(&:title)
end
def execute
build(@project_milestones)
end
def milestone(title)
if title
group_milestone = @project_milestones[title].group_by(&:title)
build(group_milestone).first
else
nil
end
end
private
def build(milestone)
milestone.map{ |title, milestones| GroupMilestone.new(title, milestones) }
end
end
end
...@@ -10,10 +10,10 @@ ...@@ -10,10 +10,10 @@
.milestones .milestones
%ul.content-list %ul.content-list
- if @dashboard_milestones.blank? - if @milestones.blank?
%li %li
.nothing-here-block No milestones to show .nothing-here-block No milestones to show
- else - else
- @dashboard_milestones.each do |milestone| - @milestones.each do |milestone|
= render 'milestone', milestone: milestone = render 'milestone', milestone: milestone
= paginate @dashboard_milestones, theme: "gitlab" = paginate @milestones, theme: "gitlab"
- page_title @dashboard_milestone.title, "Milestones" - page_title @milestone.title, "Milestones"
%h4.page-title %h4.page-title
.issue-box{ class: "issue-box-#{@dashboard_milestone.closed? ? 'closed' : 'open'}" } .issue-box{ class: "issue-box-#{@milestone.closed? ? 'closed' : 'open'}" }
- if @dashboard_milestone.closed? - if @milestone.closed?
Closed Closed
- else - else
Open Open
Milestone #{@dashboard_milestone.title} Milestone #{@milestone.title}
%hr %hr
- if (@dashboard_milestone.total_items_count == @dashboard_milestone.closed_items_count) && @dashboard_milestone.active? - if @milestone.complete? && @milestone.active?
.alert.alert-success .alert.alert-success
%span All issues for this milestone are closed. You may close the milestone now. %span All issues for this milestone are closed. You may close the milestone now.
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
%th Open issues %th Open issues
%th State %th State
%th Due date %th Due date
- @dashboard_milestone.milestones.each do |milestone| - @milestone.milestones.each do |milestone|
%tr %tr
%td %td
= link_to "#{milestone.project.name_with_namespace}", namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone) = link_to "#{milestone.project.name_with_namespace}", namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone)
...@@ -39,46 +39,46 @@ ...@@ -39,46 +39,46 @@
.context .context
%p.lead %p.lead
Progress: Progress:
#{@dashboard_milestone.closed_items_count} closed #{@milestone.closed_items_count} closed
&ndash; &ndash;
#{@dashboard_milestone.open_items_count} open #{@milestone.open_items_count} open
= milestone_progress_bar(@dashboard_milestone) = milestone_progress_bar(@milestone)
%ul.nav.nav-tabs %ul.nav.nav-tabs
%li.active %li.active
= link_to '#tab-issues', 'data-toggle' => 'tab' do = link_to '#tab-issues', 'data-toggle' => 'tab' do
Issues Issues
%span.badge= @dashboard_milestone.issue_count %span.badge= @milestone.issue_count
%li %li
= link_to '#tab-merge-requests', 'data-toggle' => 'tab' do = link_to '#tab-merge-requests', 'data-toggle' => 'tab' do
Merge Requests Merge Requests
%span.badge= @dashboard_milestone.merge_requests_count %span.badge= @milestone.merge_requests_count
%li %li
= link_to '#tab-participants', 'data-toggle' => 'tab' do = link_to '#tab-participants', 'data-toggle' => 'tab' do
Participants Participants
%span.badge= @dashboard_milestone.participants.count %span.badge= @milestone.participants.count
.pull-right .pull-right
= link_to 'Browse Issues', issues_dashboard_path(milestone_title: @dashboard_milestone.title), class: "btn edit-milestone-link btn-grouped" = link_to 'Browse Issues', issues_dashboard_path(milestone_title: @milestone.title), class: "btn edit-milestone-link btn-grouped"
.tab-content .tab-content
.tab-pane.active#tab-issues .tab-pane.active#tab-issues
.row .row
.col-md-6 .col-md-6
= render 'issues', title: "Open", issues: @dashboard_milestone.opened_issues = render 'issues', title: "Open", issues: @milestone.opened_issues
.col-md-6 .col-md-6
= render 'issues', title: "Closed", issues: @dashboard_milestone.closed_issues = render 'issues', title: "Closed", issues: @milestone.closed_issues
.tab-pane#tab-merge-requests .tab-pane#tab-merge-requests
.row .row
.col-md-6 .col-md-6
= render 'merge_requests', title: "Open", merge_requests: @dashboard_milestone.opened_merge_requests = render 'merge_requests', title: "Open", merge_requests: @milestone.opened_merge_requests
.col-md-6 .col-md-6
= render 'merge_requests', title: "Closed", merge_requests: @dashboard_milestone.closed_merge_requests = render 'merge_requests', title: "Closed", merge_requests: @milestone.closed_merge_requests
.tab-pane#tab-participants .tab-pane#tab-participants
%ul.bordered-list %ul.bordered-list
- @dashboard_milestone.participants.each do |user| - @milestone.participants.each do |user|
%li %li
= link_to user, title: user.name, class: "darken" do = link_to user, title: user.name, class: "darken" do
= image_tag avatar_icon(user, 32), class: "avatar s32" = image_tag avatar_icon(user, 32), class: "avatar s32"
......
...@@ -22,7 +22,7 @@ ...@@ -22,7 +22,7 @@
%span.label.label-gray %span.label.label-gray
= milestone.project.name = milestone.project.name
.col-sm-6 .col-sm-6
- if can?(current_user, :admin_group, @group) - if can?(current_user, :admin_milestones, @group)
- 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_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-xs btn-grouped btn-reopen"
- else - else
......
...@@ -3,15 +3,22 @@ ...@@ -3,15 +3,22 @@
= render 'shared/milestones_filter' = render 'shared/milestones_filter'
.gray-content-block .gray-content-block
- if can?(current_user, :admin_milestones, @group)
.pull-right
%span.pull-right.hidden-xs
= link_to new_group_milestone_path(@group), class: "btn btn-new" do
New Milestone
.oneline
Only milestones from Only milestones from
%strong #{@group.name} %strong #{@group.name}
group are listed here. group are listed here.
.milestones .milestones
%ul.content-list %ul.content-list
- if @group_milestones.blank? - if @milestones.blank?
%li %li
.nothing-here-block No milestones to show .nothing-here-block No milestones to show
- else - else
- @group_milestones.each do |milestone| - @milestones.each do |milestone|
= render 'milestone', milestone: milestone = render 'milestone', milestone: milestone
= paginate @group_milestones, theme: "gitlab" = paginate @milestones, theme: "gitlab"
- page_title "Milestones"
- header_title group_title(@group, "Milestones", group_milestones_path(@group))
%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 gfm-form js-requires-input' } do |f|
.row
.col-md-6
.form-group
= f.label :title, "Title", class: "control-label"
.col-sm-10
= f.text_field :title, maxlength: 255, class: "form-control js-quick-submit", required: true
%p.hint Required
.form-group.milestone-description
= f.label :description, "Description", class: "control-label"
.col-sm-10
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do
= render 'projects/zen', f: f, attr: :description, classes: 'description form-control js-quick-submit'
.clearfix
.error-alert
.form-group
= f.label :projects, "Projects", class: "control-label"
.col-sm-10
= f.collection_select :project_ids, @group.projects, :id, :name,
{ selected: @group.projects.map(&:id) }, multiple: true, class: 'select2'
.col-md-6
.form-group
= f.label :due_date, "Due Date", class: "control-label"
.col-sm-10= f.hidden_field :due_date
.col-sm-10
.datepicker
.form-actions
= f.submit 'Create Milestone', class: "btn-create btn"
= link_to "Cancel", group_milestones_path(@group), class: "btn btn-cancel"
:javascript
$(".datepicker").datepicker({
dateFormat: "yy-mm-dd",
onSelect: function(dateText, inst) { $("#milestone_due_date").val(dateText) }
}).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#milestone_due_date').val()));
- page_title @group_milestone.title, "Milestones" - page_title @milestone.title, "Milestones"
= render "header_title" = render "header_title"
%h4.page-title %h4.page-title
.issue-box{ class: "issue-box-#{@group_milestone.closed? ? 'closed' : 'open'}" } .issue-box{ class: "issue-box-#{@milestone.closed? ? 'closed' : 'open'}" }
- if @group_milestone.closed? - if @milestone.closed?
Closed Closed
- else - else
Open Open
Milestone #{@group_milestone.title} Milestone #{@milestone.title}
.pull-right .pull-right
- if can?(current_user, :admin_group, @group) - if can?(current_user, :admin_milestones, @group)
- if @group_milestone.active? - if @milestone.active?
= link_to 'Close Milestone', group_milestone_path(@group, @group_milestone.safe_title, title: @group_milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-sm 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-sm btn-close"
- else - else
= link_to 'Reopen Milestone', group_milestone_path(@group, @group_milestone.safe_title, title: @group_milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen" = link_to 'Reopen Milestone', group_milestone_path(@group, @milestone.safe_title, title: @milestone.title, milestone: {state_event: :activate }), method: :put, class: "btn btn-sm btn-grouped btn-reopen"
%hr %hr
- if (@group_milestone.total_items_count == @group_milestone.closed_items_count) && @group_milestone.active? - if @milestone.complete? && @milestone.active?
.alert.alert-success .alert.alert-success
%span All issues for this milestone are closed. You may close the milestone now. %span All issues for this milestone are closed. You may close the milestone now.
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
%th Open issues %th Open issues
%th State %th State
%th Due date %th Due date
- @group_milestone.milestones.each do |milestone| - @milestone.milestones.each do |milestone|
%tr %tr
%td %td
= link_to "#{milestone.project.name}", namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone) = link_to "#{milestone.project.name}", namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone)
...@@ -47,46 +47,46 @@ ...@@ -47,46 +47,46 @@
.context .context
%p.lead %p.lead
Progress: Progress:
#{@group_milestone.closed_items_count} closed #{@milestone.closed_items_count} closed
&ndash; &ndash;
#{@group_milestone.open_items_count} open #{@milestone.open_items_count} open
= milestone_progress_bar(@group_milestone) = milestone_progress_bar(@milestone)
%ul.nav.nav-tabs %ul.nav.nav-tabs
%li.active %li.active
= link_to '#tab-issues', 'data-toggle' => 'tab' do = link_to '#tab-issues', 'data-toggle' => 'tab' do
Issues Issues
%span.badge= @group_milestone.issue_count %span.badge= @milestone.issue_count
%li %li
= link_to '#tab-merge-requests', 'data-toggle' => 'tab' do = link_to '#tab-merge-requests', 'data-toggle' => 'tab' do
Merge Requests Merge Requests
%span.badge= @group_milestone.merge_requests_count %span.badge= @milestone.merge_requests_count
%li %li
= link_to '#tab-participants', 'data-toggle' => 'tab' do = link_to '#tab-participants', 'data-toggle' => 'tab' do
Participants Participants
%span.badge= @group_milestone.participants.count %span.badge= @milestone.participants.count
.pull-right .pull-right
= link_to 'Browse Issues', issues_group_path(@group, milestone_title: @group_milestone.title), class: "btn edit-milestone-link btn-grouped" = link_to 'Browse Issues', issues_group_path(@group, milestone_title: @milestone.title), class: "btn edit-milestone-link btn-grouped"
.tab-content .tab-content
.tab-pane.active#tab-issues .tab-pane.active#tab-issues
.row .row
.col-md-6 .col-md-6
= render 'issues', title: "Open", issues: @group_milestone.opened_issues = render 'issues', title: "Open", issues: @milestone.opened_issues
.col-md-6 .col-md-6
= render 'issues', title: "Closed", issues: @group_milestone.closed_issues = render 'issues', title: "Closed", issues: @milestone.closed_issues
.tab-pane#tab-merge-requests .tab-pane#tab-merge-requests
.row .row
.col-md-6 .col-md-6
= render 'merge_requests', title: "Open", merge_requests: @group_milestone.opened_merge_requests = render 'merge_requests', title: "Open", merge_requests: @milestone.opened_merge_requests
.col-md-6 .col-md-6
= render 'merge_requests', title: "Closed", merge_requests: @group_milestone.closed_merge_requests = render 'merge_requests', title: "Closed", merge_requests: @milestone.closed_merge_requests
.tab-pane#tab-participants .tab-pane#tab-participants
%ul.bordered-list %ul.bordered-list
- @group_milestone.participants.each do |user| - @milestone.participants.each do |user|
%li %li
= link_to user, title: user.name, class: "darken" do = link_to user, title: user.name, class: "darken" do
= image_tag avatar_icon(user, 32), class: "avatar s32" = image_tag avatar_icon(user, 32), class: "avatar s32"
......
...@@ -23,9 +23,7 @@ ...@@ -23,9 +23,7 @@
.col-sm-10 .col-sm-10
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do = render layout: 'projects/md_preview', locals: { preview_class: "md-preview" } do
= render 'projects/zen', f: f, attr: :description, classes: 'description form-control js-quick-submit' = render 'projects/zen', f: f, attr: :description, classes: 'description form-control js-quick-submit'
.hint = render 'projects/notes/hints'
.pull-left Milestones are parsed with #{link_to "GitLab Flavored Markdown", help_page_path("markdown", "markdown"), target: '_blank'}.
.pull-left Attach files by dragging & dropping or #{link_to "selecting them", '#', class: 'markdown-selector' }.
.clearfix .clearfix
.error-alert .error-alert
.col-md-6 .col-md-6
...@@ -45,7 +43,7 @@ ...@@ -45,7 +43,7 @@
:javascript :javascript
$( ".datepicker" ).datepicker({ $(".datepicker").datepicker({
dateFormat: "yy-mm-dd", dateFormat: "yy-mm-dd",
onSelect: function(dateText, inst) { $("#milestone_due_date").val(dateText) } onSelect: function(dateText, inst) { $("#milestone_due_date").val(dateText) }
}).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#milestone_due_date').val())); }).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#milestone_due_date').val()));
...@@ -368,7 +368,7 @@ Gitlab::Application.routes.draw do ...@@ -368,7 +368,7 @@ Gitlab::Application.routes.draw do
end end
resource :avatar, only: [:destroy] resource :avatar, only: [:destroy]
resources :milestones, only: [:index, :show, :update] resources :milestones, only: [:index, :show, :update, :new, :create]
end end
end end
......
...@@ -14,5 +14,6 @@ ...@@ -14,5 +14,6 @@
- [Protected branches](protected_branches.md) - [Protected branches](protected_branches.md)
- [Web Editor](web_editor.md) - [Web Editor](web_editor.md)
- [Releases](releases.md) - [Releases](releases.md)
- [Milestones](milestones.md)
- [Merge Requests](merge_requests.md) - [Merge Requests](merge_requests.md)
- ["Work In Progress" Merge Requests](wip_merge_requests.md) - ["Work In Progress" Merge Requests](wip_merge_requests.md)
# Milestones
Milestones allow you to organize issues and merge requests into a cohesive group, optionally setting a due date.
A common use is keeping track of an upcoming software version. Milestones are created per-project.
![milestone form](milestones/form.png)
## Groups and milestones
You can create a milestone for several projects in the same group simultaneously.
On the group's milestones page, you will be able to see the status of that milestone across all of the selected projects.
![group milestone form](milestones/group_form.png)
...@@ -153,6 +153,13 @@ Feature: Groups ...@@ -153,6 +153,13 @@ Feature: Groups
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
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
# Group projects in settings # Group projects in settings
Scenario: I should see all projects in the project list in settings Scenario: I should see all projects in the project list in settings
Given Group "Owned" has archived project Given Group "Owned" has archived project
......
...@@ -255,6 +255,28 @@ class Spinach::Features::Groups < Spinach::FeatureSteps ...@@ -255,6 +255,28 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
expect(page).to have_xpath("//span[@class='label label-warning']", text: 'archived') expect(page).to have_xpath("//span[@class='label label-warning']", text: 'archived')
end end
step 'I fill milestone name' do
fill_in 'milestone_title', with: 'v2.9.0'
end
step 'I click new milestone button' do
click_link "New Milestone"
end
step 'I press create mileston button' do
click_button "Create Milestone"
end
step 'milestone in each project 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
end
protected protected
def assigned_to_me(key) def assigned_to_me(key)
......
...@@ -31,6 +31,10 @@ module SharedPaths ...@@ -31,6 +31,10 @@ module SharedPaths
visit merge_requests_group_path(Group.find_by(name: "Owned")) visit merge_requests_group_path(Group.find_by(name: "Owned"))
end end
step 'I visit group "Owned" milestones page' do
visit group_milestones_path(Group.find_by(name: "Owned"))
end
step 'I visit group "Owned" members page' do step 'I visit group "Owned" members page' do
visit group_group_members_path(Group.find_by(name: "Owned")) visit group_group_members_path(Group.find_by(name: "Owned"))
end end
......
require 'spec_helper' require 'spec_helper'
describe Milestones::GroupService do describe GlobalMilestone do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:user2) { create(:user) } let(:user2) { create(:user) }
let(:group) { create(:group) } let(:group) { create(:group) }
...@@ -14,8 +14,7 @@ describe Milestones::GroupService do ...@@ -14,8 +14,7 @@ describe Milestones::GroupService do
let(:milestone2_project2) { create(:milestone, title: "VD-123", project: project2) } let(:milestone2_project2) { create(:milestone, title: "VD-123", project: project2) }
let(:milestone2_project3) { create(:milestone, title: "VD-123", project: project3) } let(:milestone2_project3) { create(:milestone, title: "VD-123", project: project3) }
describe 'execute' do describe :build_collection do
context 'with valid projects' do
before do before do
milestones = milestones =
[ [
...@@ -26,45 +25,41 @@ describe Milestones::GroupService do ...@@ -26,45 +25,41 @@ describe Milestones::GroupService do
milestone2_project2, milestone2_project2,
milestone2_project3 milestone2_project3
] ]
@group_milestones = Milestones::GroupService.new(milestones).execute
@global_milestones = GlobalMilestone.build_collection(milestones)
end end
it 'should have all project milestones' do it 'should have all project milestones' do
expect(@group_milestones.count).to eq(2) expect(@global_milestones.count).to eq(2)
end end
it 'should have all project milestones titles' do it 'should have all project milestones titles' do
expect(@group_milestones.map { |group_milestone| group_milestone.title }).to match_array(['Milestone v1.2', 'VD-123']) expect(@global_milestones.map(&:title)).to match_array(['Milestone v1.2', 'VD-123'])
end end
it 'should have all project milestones' do it 'should have all project milestones' do
expect(@group_milestones.map { |group_milestone| group_milestone.milestones.count }.sum).to eq(6) expect(@global_milestones.map { |group_milestone| group_milestone.milestones.count }.sum).to eq(6)
end
end end
end end
describe 'milestone' do describe :initialize do
context 'with valid title' do
before do before do
milestones = milestones =
[ [
milestone1_project1, milestone1_project1,
milestone1_project2, milestone1_project2,
milestone1_project3, milestone1_project3,
milestone2_project1,
milestone2_project2,
milestone2_project3
] ]
@group_milestones = Milestones::GroupService.new(milestones).milestone('Milestone v1.2')
@global_milestone = GlobalMilestone.new(milestone1_project1.title, milestones)
end end
it 'should have exactly one group milestone' do it 'should have exactly one group milestone' do
expect(@group_milestones.title).to eq('Milestone v1.2') expect(@global_milestone.title).to eq('Milestone v1.2')
end end
it 'should have all project milestones with the same title' do it 'should have all project milestones with the same title' do
expect(@group_milestones.milestones.count).to eq(3) expect(@global_milestone.milestones.count).to eq(3)
end
end end
end end
end end
require 'spec_helper'
describe Milestones::CloseService do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:milestone) { create(:milestone, title: "Milestone v1.2", project: project) }
before do
project.team << [user, :master]
end
describe :execute do
before do
Milestones::CloseService.new(project, user, {}).execute(milestone)
end
it { expect(milestone).to be_valid }
it { expect(milestone).to be_closed }
describe :event do
let(:event) { Event.first }
it { expect(event.milestone).to be_truthy }
it { expect(event.target).to eq(milestone) }
it { expect(event.action_name).to eq('closed') }
end
end
end
require 'spec_helper'
describe Milestones::CreateService do
let(:project) { create(:empty_project) }
let(:user) { create(:user) }
describe :execute do
context "valid params" do
before do
project.team << [user, :master]
opts = {
title: 'v2.1.9',
description: 'Patch release to fix security issue'
}
@milestone = Milestones::CreateService.new(project, user, opts).execute
end
it { expect(@milestone).to be_valid }
it { expect(@milestone.title).to eq('v2.1.9') }
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