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: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> See merge request !1797
This commit is contained in:
commit
0061143ccd
37 changed files with 403 additions and 263 deletions
|
@ -43,6 +43,8 @@ v 8.2.0 (unreleased)
|
|||
- 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
|
||||
- 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
|
||||
- Fix bug where manually merged branches in a MR would end up with an empty diff (Stan Hu)
|
||||
|
|
|
@ -28,6 +28,8 @@ class Dispatcher
|
|||
when 'projects:milestones:new', 'projects:milestones:edit'
|
||||
new ZenMode()
|
||||
new DropzoneInput($('.milestone-form'))
|
||||
when 'groups:milestones:new'
|
||||
new ZenMode()
|
||||
when 'projects:compare:show'
|
||||
new Diff()
|
||||
when 'projects:issues:new','projects:issues:edit'
|
||||
|
|
19
app/controllers/concerns/global_milestones.rb
Normal file
19
app/controllers/concerns/global_milestones.rb
Normal file
|
@ -0,0 +1,19 @@
|
|||
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
|
|
@ -1,34 +1,19 @@
|
|||
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
|
||||
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
|
||||
|
||||
def show
|
||||
project_milestones = Milestone.where(project_id: @projects).order("due_date ASC")
|
||||
@dashboard_milestone = Milestones::GroupService.new(project_milestones).milestone(title)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_projects
|
||||
@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")
|
||||
def projects
|
||||
@projects ||= current_user.authorized_projects.sorted_by_activity.non_archived
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
class DashboardController < Dashboard::ApplicationController
|
||||
before_action :event_filter, only: :activity
|
||||
before_action :projects, only: [:issues, :merge_requests]
|
||||
|
||||
respond_to :html
|
||||
|
||||
|
@ -47,4 +48,8 @@ class DashboardController < Dashboard::ApplicationController
|
|||
@events = @event_filter.apply_filter(@events).with_associations
|
||||
@events = @events.limit(20).offset(params[:offset] || 0)
|
||||
end
|
||||
|
||||
def projects
|
||||
@projects ||= current_user.authorized_projects.sorted_by_activity.non_archived
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,8 +1,13 @@
|
|||
class Groups::ApplicationController < ApplicationController
|
||||
layout 'group'
|
||||
before_action :group
|
||||
|
||||
private
|
||||
|
||||
|
||||
def group
|
||||
@group ||= Group.find_by(path: params[:group_id])
|
||||
end
|
||||
|
||||
def authorize_read_group!
|
||||
unless @group and can?(current_user, :read_group, @group)
|
||||
if current_user.nil?
|
||||
|
@ -12,13 +17,13 @@ class Groups::ApplicationController < ApplicationController
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def authorize_admin_group!
|
||||
unless can?(current_user, :admin_group, group)
|
||||
return render_404
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
def authorize_admin_group_member!
|
||||
unless can?(current_user, :admin_group_member, group)
|
||||
return render_403
|
||||
|
|
|
@ -1,8 +1,6 @@
|
|||
class Groups::AvatarsController < ApplicationController
|
||||
class Groups::AvatarsController < Groups::ApplicationController
|
||||
def destroy
|
||||
@group = Group.find_by(path: params[:group_id])
|
||||
@group.remove_avatar!
|
||||
|
||||
@group.save
|
||||
|
||||
redirect_to edit_group_path(@group)
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
class Groups::GroupMembersController < Groups::ApplicationController
|
||||
skip_before_action :authenticate_user!, only: [:index]
|
||||
before_action :group
|
||||
|
||||
# Authorize
|
||||
before_action :authorize_read_group!
|
||||
|
@ -80,10 +79,6 @@ class Groups::GroupMembersController < Groups::ApplicationController
|
|||
|
||||
protected
|
||||
|
||||
def group
|
||||
@group ||= Group.find_by(path: params[:group_id])
|
||||
end
|
||||
|
||||
def member_params
|
||||
params.require(:group_member).permit(:access_level, :user_id)
|
||||
end
|
||||
|
|
|
@ -1,54 +1,55 @@
|
|||
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
|
||||
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
|
||||
|
||||
def new
|
||||
@milestone = Milestone.new
|
||||
end
|
||||
|
||||
def create
|
||||
project_ids = params[:milestone][:project_ids]
|
||||
title = milestone_params[:title]
|
||||
|
||||
@group.projects.where(id: project_ids).each do |project|
|
||||
Milestones::CreateService.new(project, current_user, milestone_params).execute
|
||||
end
|
||||
|
||||
redirect_to milestone_path(title)
|
||||
end
|
||||
|
||||
def show
|
||||
project_milestones = Milestone.where(project_id: group.projects).order("due_date ASC")
|
||||
@group_milestone = Milestones::GroupService.new(project_milestones).milestone(title)
|
||||
end
|
||||
|
||||
def update
|
||||
project_milestones = Milestone.where(project_id: group.projects).order("due_date ASC")
|
||||
@group_milestones = Milestones::GroupService.new(project_milestones).milestone(title)
|
||||
|
||||
@group_milestones.milestones.each do |milestone|
|
||||
Milestones::UpdateService.new(milestone.project, current_user, params[:milestone]).execute(milestone)
|
||||
@milestone.milestones.each do |milestone|
|
||||
Milestones::UpdateService.new(milestone.project, current_user, milestone_params).execute(milestone)
|
||||
end
|
||||
|
||||
respond_to do |format|
|
||||
format.js
|
||||
format.html do
|
||||
redirect_to group_milestones_path(group)
|
||||
end
|
||||
end
|
||||
redirect_back_or_default(default: milestone_path(@milestone.title))
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def group
|
||||
@group ||= Group.find_by(path: params[:group_id])
|
||||
end
|
||||
|
||||
def title
|
||||
params[:title]
|
||||
end
|
||||
|
||||
def state(state = nil)
|
||||
conditions = { project_id: group.projects }
|
||||
conditions.reverse_merge!(state: state) if state
|
||||
Milestone.where(conditions).order("title ASC")
|
||||
end
|
||||
|
||||
def authorize_group_milestone!
|
||||
return render_404 unless can?(current_user, :admin_group, group)
|
||||
return render_404 unless can?(current_user, :admin_milestones, group)
|
||||
end
|
||||
|
||||
def milestone_params
|
||||
params.require(:milestone).permit(:title, :description, :due_date, :state_event)
|
||||
end
|
||||
|
||||
def milestone_path(title)
|
||||
group_milestone_path(@group, title.parameterize, title: title)
|
||||
end
|
||||
|
||||
def projects
|
||||
@projects ||= @group.projects
|
||||
end
|
||||
end
|
||||
|
|
12
app/finders/milestones_finder.rb
Normal file
12
app/finders/milestones_finder.rb
Normal file
|
@ -0,0 +1,12 @@
|
|||
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
|
|||
Label.where(project_id: @projects)
|
||||
end
|
||||
|
||||
grouped_labels = Labels::GroupService.new(labels).execute
|
||||
grouped_labels = GlobalLabel.build_collection(labels)
|
||||
grouped_labels.unshift(Label::None)
|
||||
grouped_labels.unshift(Label::Any)
|
||||
|
||||
|
|
|
@ -28,7 +28,7 @@ module MilestonesHelper
|
|||
Milestone.where(project_id: @projects)
|
||||
end.active
|
||||
|
||||
grouped_milestones = Milestones::GroupService.new(milestones).execute
|
||||
grouped_milestones = GlobalMilestone.build_collection(milestones)
|
||||
grouped_milestones.unshift(Milestone::None)
|
||||
grouped_milestones.unshift(Milestone::Any)
|
||||
|
||||
|
|
|
@ -233,6 +233,7 @@ class Ability
|
|||
if group.has_master?(user) || group.has_owner?(user) || user.admin?
|
||||
rules.push(*[
|
||||
:create_projects,
|
||||
:admin_milestones
|
||||
])
|
||||
end
|
||||
|
||||
|
|
17
app/models/global_label.rb
Normal file
17
app/models/global_label.rb
Normal file
|
@ -0,0 +1,17 @@
|
|||
class GlobalLabel
|
||||
attr_accessor :title, :labels
|
||||
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)
|
||||
@title = title
|
||||
@labels = labels
|
||||
end
|
||||
end
|
|
@ -1,7 +1,15 @@
|
|||
class GroupMilestone
|
||||
class GlobalMilestone
|
||||
attr_accessor :title, :milestones
|
||||
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)
|
||||
@title = title
|
||||
@milestones = milestones
|
||||
|
@ -10,7 +18,7 @@ class GroupMilestone
|
|||
def safe_title
|
||||
@title.parameterize
|
||||
end
|
||||
|
||||
|
||||
def projects
|
||||
milestones.map { |milestone| milestone.project }
|
||||
end
|
||||
|
@ -60,15 +68,15 @@ class GroupMilestone
|
|||
end
|
||||
|
||||
def issues
|
||||
@group_issues ||= milestones.map(&:issues).flatten.group_by(&:state)
|
||||
@issues ||= milestones.map(&:issues).flatten.group_by(&:state)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def participants
|
||||
@group_participants ||= milestones.map(&:participants).flatten.compact.uniq
|
||||
@participants ||= milestones.map(&:participants).flatten.compact.uniq
|
||||
end
|
||||
|
||||
def opened_issues
|
||||
|
@ -86,4 +94,8 @@ class GroupMilestone
|
|||
def closed_merge_requests
|
||||
merge_requests.values_at("closed", "merged", "locked").compact.flatten
|
||||
end
|
||||
|
||||
def complete?
|
||||
total_items_count == closed_items_count
|
||||
end
|
||||
end
|
|
@ -1,9 +0,0 @@
|
|||
class GroupLabel
|
||||
attr_accessor :title, :labels
|
||||
alias_attribute :name, :title
|
||||
|
||||
def initialize(title, labels)
|
||||
@title = title
|
||||
@labels = labels
|
||||
end
|
||||
end
|
|
@ -1,26 +0,0 @@
|
|||
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
|
|
@ -1,26 +0,0 @@
|
|||
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 @@
|
|||
|
||||
.milestones
|
||||
%ul.content-list
|
||||
- if @dashboard_milestones.blank?
|
||||
- if @milestones.blank?
|
||||
%li
|
||||
.nothing-here-block No milestones to show
|
||||
- else
|
||||
- @dashboard_milestones.each do |milestone|
|
||||
- @milestones.each do |milestone|
|
||||
= render 'milestone', milestone: milestone
|
||||
= paginate @dashboard_milestones, theme: "gitlab"
|
||||
= paginate @milestones, theme: "gitlab"
|
||||
|
|
|
@ -1,14 +1,14 @@
|
|||
- page_title @dashboard_milestone.title, "Milestones"
|
||||
- page_title @milestone.title, "Milestones"
|
||||
%h4.page-title
|
||||
.issue-box{ class: "issue-box-#{@dashboard_milestone.closed? ? 'closed' : 'open'}" }
|
||||
- if @dashboard_milestone.closed?
|
||||
.issue-box{ class: "issue-box-#{@milestone.closed? ? 'closed' : 'open'}" }
|
||||
- if @milestone.closed?
|
||||
Closed
|
||||
- else
|
||||
Open
|
||||
Milestone #{@dashboard_milestone.title}
|
||||
Milestone #{@milestone.title}
|
||||
|
||||
%hr
|
||||
- if (@dashboard_milestone.total_items_count == @dashboard_milestone.closed_items_count) && @dashboard_milestone.active?
|
||||
- if @milestone.complete? && @milestone.active?
|
||||
.alert.alert-success
|
||||
%span All issues for this milestone are closed. You may close the milestone now.
|
||||
|
||||
|
@ -22,7 +22,7 @@
|
|||
%th Open issues
|
||||
%th State
|
||||
%th Due date
|
||||
- @dashboard_milestone.milestones.each do |milestone|
|
||||
- @milestone.milestones.each do |milestone|
|
||||
%tr
|
||||
%td
|
||||
= link_to "#{milestone.project.name_with_namespace}", namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone)
|
||||
|
@ -39,46 +39,46 @@
|
|||
.context
|
||||
%p.lead
|
||||
Progress:
|
||||
#{@dashboard_milestone.closed_items_count} closed
|
||||
#{@milestone.closed_items_count} closed
|
||||
–
|
||||
#{@dashboard_milestone.open_items_count} open
|
||||
= milestone_progress_bar(@dashboard_milestone)
|
||||
#{@milestone.open_items_count} open
|
||||
= milestone_progress_bar(@milestone)
|
||||
|
||||
%ul.nav.nav-tabs
|
||||
%li.active
|
||||
= link_to '#tab-issues', 'data-toggle' => 'tab' do
|
||||
Issues
|
||||
%span.badge= @dashboard_milestone.issue_count
|
||||
%span.badge= @milestone.issue_count
|
||||
%li
|
||||
= link_to '#tab-merge-requests', 'data-toggle' => 'tab' do
|
||||
Merge Requests
|
||||
%span.badge= @dashboard_milestone.merge_requests_count
|
||||
%span.badge= @milestone.merge_requests_count
|
||||
%li
|
||||
= link_to '#tab-participants', 'data-toggle' => 'tab' do
|
||||
Participants
|
||||
%span.badge= @dashboard_milestone.participants.count
|
||||
%span.badge= @milestone.participants.count
|
||||
|
||||
.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-pane.active#tab-issues
|
||||
.row
|
||||
.col-md-6
|
||||
= render 'issues', title: "Open", issues: @dashboard_milestone.opened_issues
|
||||
= render 'issues', title: "Open", issues: @milestone.opened_issues
|
||||
.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
|
||||
.row
|
||||
.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
|
||||
= 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
|
||||
%ul.bordered-list
|
||||
- @dashboard_milestone.participants.each do |user|
|
||||
- @milestone.participants.each do |user|
|
||||
%li
|
||||
= link_to user, title: user.name, class: "darken" do
|
||||
= image_tag avatar_icon(user, 32), class: "avatar s32"
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
%span.label.label-gray
|
||||
= milestone.project.name
|
||||
.col-sm-6
|
||||
- if can?(current_user, :admin_group, @group)
|
||||
- if can?(current_user, :admin_milestones, @group)
|
||||
- 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
|
||||
|
|
|
@ -3,15 +3,22 @@
|
|||
|
||||
= render 'shared/milestones_filter'
|
||||
.gray-content-block
|
||||
Only milestones from
|
||||
%strong #{@group.name}
|
||||
group are listed here.
|
||||
- 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
|
||||
%strong #{@group.name}
|
||||
group are listed here.
|
||||
.milestones
|
||||
%ul.content-list
|
||||
- if @group_milestones.blank?
|
||||
- if @milestones.blank?
|
||||
%li
|
||||
.nothing-here-block No milestones to show
|
||||
- else
|
||||
- @group_milestones.each do |milestone|
|
||||
- @milestones.each do |milestone|
|
||||
= render 'milestone', milestone: milestone
|
||||
= paginate @group_milestones, theme: "gitlab"
|
||||
= paginate @milestones, theme: "gitlab"
|
||||
|
|
48
app/views/groups/milestones/new.html.haml
Normal file
48
app/views/groups/milestones/new.html.haml
Normal file
|
@ -0,0 +1,48 @@
|
|||
- 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()));
|
|
@ -1,22 +1,22 @@
|
|||
- page_title @group_milestone.title, "Milestones"
|
||||
- page_title @milestone.title, "Milestones"
|
||||
= render "header_title"
|
||||
|
||||
%h4.page-title
|
||||
.issue-box{ class: "issue-box-#{@group_milestone.closed? ? 'closed' : 'open'}" }
|
||||
- if @group_milestone.closed?
|
||||
.issue-box{ class: "issue-box-#{@milestone.closed? ? 'closed' : 'open'}" }
|
||||
- if @milestone.closed?
|
||||
Closed
|
||||
- else
|
||||
Open
|
||||
Milestone #{@group_milestone.title}
|
||||
Milestone #{@milestone.title}
|
||||
.pull-right
|
||||
- if can?(current_user, :admin_group, @group)
|
||||
- if @group_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"
|
||||
- if can?(current_user, :admin_milestones, @group)
|
||||
- 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-sm btn-close"
|
||||
- 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
|
||||
- if (@group_milestone.total_items_count == @group_milestone.closed_items_count) && @group_milestone.active?
|
||||
- if @milestone.complete? && @milestone.active?
|
||||
.alert.alert-success
|
||||
%span All issues for this milestone are closed. You may close the milestone now.
|
||||
|
||||
|
@ -30,7 +30,7 @@
|
|||
%th Open issues
|
||||
%th State
|
||||
%th Due date
|
||||
- @group_milestone.milestones.each do |milestone|
|
||||
- @milestone.milestones.each do |milestone|
|
||||
%tr
|
||||
%td
|
||||
= link_to "#{milestone.project.name}", namespace_project_milestone_path(milestone.project.namespace, milestone.project, milestone)
|
||||
|
@ -47,46 +47,46 @@
|
|||
.context
|
||||
%p.lead
|
||||
Progress:
|
||||
#{@group_milestone.closed_items_count} closed
|
||||
#{@milestone.closed_items_count} closed
|
||||
–
|
||||
#{@group_milestone.open_items_count} open
|
||||
= milestone_progress_bar(@group_milestone)
|
||||
#{@milestone.open_items_count} open
|
||||
= milestone_progress_bar(@milestone)
|
||||
|
||||
%ul.nav.nav-tabs
|
||||
%li.active
|
||||
= link_to '#tab-issues', 'data-toggle' => 'tab' do
|
||||
Issues
|
||||
%span.badge= @group_milestone.issue_count
|
||||
%span.badge= @milestone.issue_count
|
||||
%li
|
||||
= link_to '#tab-merge-requests', 'data-toggle' => 'tab' do
|
||||
Merge Requests
|
||||
%span.badge= @group_milestone.merge_requests_count
|
||||
%span.badge= @milestone.merge_requests_count
|
||||
%li
|
||||
= link_to '#tab-participants', 'data-toggle' => 'tab' do
|
||||
Participants
|
||||
%span.badge= @group_milestone.participants.count
|
||||
%span.badge= @milestone.participants.count
|
||||
|
||||
.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-pane.active#tab-issues
|
||||
.row
|
||||
.col-md-6
|
||||
= render 'issues', title: "Open", issues: @group_milestone.opened_issues
|
||||
= render 'issues', title: "Open", issues: @milestone.opened_issues
|
||||
.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
|
||||
.row
|
||||
.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
|
||||
= 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
|
||||
%ul.bordered-list
|
||||
- @group_milestone.participants.each do |user|
|
||||
- @milestone.participants.each do |user|
|
||||
%li
|
||||
= link_to user, title: user.name, class: "darken" do
|
||||
= image_tag avatar_icon(user, 32), class: "avatar s32"
|
||||
|
|
|
@ -23,9 +23,7 @@
|
|||
.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'
|
||||
.hint
|
||||
.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' }.
|
||||
= render 'projects/notes/hints'
|
||||
.clearfix
|
||||
.error-alert
|
||||
.col-md-6
|
||||
|
@ -45,7 +43,7 @@
|
|||
|
||||
|
||||
:javascript
|
||||
$( ".datepicker" ).datepicker({
|
||||
$(".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()));
|
||||
|
|
|
@ -368,7 +368,7 @@ Gitlab::Application.routes.draw do
|
|||
end
|
||||
|
||||
resource :avatar, only: [:destroy]
|
||||
resources :milestones, only: [:index, :show, :update]
|
||||
resources :milestones, only: [:index, :show, :update, :new, :create]
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -14,5 +14,6 @@
|
|||
- [Protected branches](protected_branches.md)
|
||||
- [Web Editor](web_editor.md)
|
||||
- [Releases](releases.md)
|
||||
- [Milestones](milestones.md)
|
||||
- [Merge Requests](merge_requests.md)
|
||||
- ["Work In Progress" Merge Requests](wip_merge_requests.md)
|
||||
|
|
13
doc/workflow/milestones.md
Normal file
13
doc/workflow/milestones.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
# 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)
|
BIN
doc/workflow/milestones/form.png
Normal file
BIN
doc/workflow/milestones/form.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 86 KiB |
BIN
doc/workflow/milestones/group_form.png
Normal file
BIN
doc/workflow/milestones/group_form.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 75 KiB |
|
@ -153,6 +153,13 @@ Feature: Groups
|
|||
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
|
||||
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
|
||||
Scenario: I should see all projects in the project list in settings
|
||||
Given Group "Owned" has archived project
|
||||
|
@ -169,4 +176,4 @@ Feature: Groups
|
|||
When I visit group "Owned" page
|
||||
Then I should see group "Owned"
|
||||
Then I should see project "Public-project"
|
||||
|
||||
|
||||
|
|
|
@ -255,6 +255,28 @@ class Spinach::Features::Groups < Spinach::FeatureSteps
|
|||
expect(page).to have_xpath("//span[@class='label label-warning']", text: 'archived')
|
||||
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
|
||||
|
||||
def assigned_to_me(key)
|
||||
|
|
|
@ -31,6 +31,10 @@ module SharedPaths
|
|||
visit merge_requests_group_path(Group.find_by(name: "Owned"))
|
||||
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
|
||||
visit group_group_members_path(Group.find_by(name: "Owned"))
|
||||
end
|
||||
|
|
65
spec/models/global_milestone_spec.rb
Normal file
65
spec/models/global_milestone_spec.rb
Normal file
|
@ -0,0 +1,65 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe GlobalMilestone do
|
||||
let(:user) { create(:user) }
|
||||
let(:user2) { create(:user) }
|
||||
let(:group) { create(:group) }
|
||||
let(:project1) { create(:project, group: group) }
|
||||
let(:project2) { create(:project, path: 'gitlab-ci', group: group) }
|
||||
let(:project3) { create(:project, path: 'cookbook-gitlab', group: group) }
|
||||
let(:milestone1_project1) { create(:milestone, title: "Milestone v1.2", project: project1) }
|
||||
let(:milestone1_project2) { create(:milestone, title: "Milestone v1.2", project: project2) }
|
||||
let(:milestone1_project3) { create(:milestone, title: "Milestone v1.2", project: project3) }
|
||||
let(:milestone2_project1) { create(:milestone, title: "VD-123", project: project1) }
|
||||
let(:milestone2_project2) { create(:milestone, title: "VD-123", project: project2) }
|
||||
let(:milestone2_project3) { create(:milestone, title: "VD-123", project: project3) }
|
||||
|
||||
describe :build_collection do
|
||||
before do
|
||||
milestones =
|
||||
[
|
||||
milestone1_project1,
|
||||
milestone1_project2,
|
||||
milestone1_project3,
|
||||
milestone2_project1,
|
||||
milestone2_project2,
|
||||
milestone2_project3
|
||||
]
|
||||
|
||||
@global_milestones = GlobalMilestone.build_collection(milestones)
|
||||
end
|
||||
|
||||
it 'should have all project milestones' do
|
||||
expect(@global_milestones.count).to eq(2)
|
||||
end
|
||||
|
||||
it 'should have all project milestones titles' do
|
||||
expect(@global_milestones.map(&:title)).to match_array(['Milestone v1.2', 'VD-123'])
|
||||
end
|
||||
|
||||
it 'should have all project milestones' do
|
||||
expect(@global_milestones.map { |group_milestone| group_milestone.milestones.count }.sum).to eq(6)
|
||||
end
|
||||
end
|
||||
|
||||
describe :initialize do
|
||||
before do
|
||||
milestones =
|
||||
[
|
||||
milestone1_project1,
|
||||
milestone1_project2,
|
||||
milestone1_project3,
|
||||
]
|
||||
|
||||
@global_milestone = GlobalMilestone.new(milestone1_project1.title, milestones)
|
||||
end
|
||||
|
||||
it 'should have exactly one group milestone' do
|
||||
expect(@global_milestone.title).to eq('Milestone v1.2')
|
||||
end
|
||||
|
||||
it 'should have all project milestones with the same title' do
|
||||
expect(@global_milestone.milestones.count).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
28
spec/services/milestones/close_service_spec.rb
Normal file
28
spec/services/milestones/close_service_spec.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
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
|
24
spec/services/milestones/create_service_spec.rb
Normal file
24
spec/services/milestones/create_service_spec.rb
Normal file
|
@ -0,0 +1,24 @@
|
|||
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
|
|
@ -1,70 +0,0 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Milestones::GroupService do
|
||||
let(:user) { create(:user) }
|
||||
let(:user2) { create(:user) }
|
||||
let(:group) { create(:group) }
|
||||
let(:project1) { create(:project, group: group) }
|
||||
let(:project2) { create(:project, path: 'gitlab-ci', group: group) }
|
||||
let(:project3) { create(:project, path: 'cookbook-gitlab', group: group) }
|
||||
let(:milestone1_project1) { create(:milestone, title: "Milestone v1.2", project: project1) }
|
||||
let(:milestone1_project2) { create(:milestone, title: "Milestone v1.2", project: project2) }
|
||||
let(:milestone1_project3) { create(:milestone, title: "Milestone v1.2", project: project3) }
|
||||
let(:milestone2_project1) { create(:milestone, title: "VD-123", project: project1) }
|
||||
let(:milestone2_project2) { create(:milestone, title: "VD-123", project: project2) }
|
||||
let(:milestone2_project3) { create(:milestone, title: "VD-123", project: project3) }
|
||||
|
||||
describe 'execute' do
|
||||
context 'with valid projects' do
|
||||
before do
|
||||
milestones =
|
||||
[
|
||||
milestone1_project1,
|
||||
milestone1_project2,
|
||||
milestone1_project3,
|
||||
milestone2_project1,
|
||||
milestone2_project2,
|
||||
milestone2_project3
|
||||
]
|
||||
@group_milestones = Milestones::GroupService.new(milestones).execute
|
||||
end
|
||||
|
||||
it 'should have all project milestones' do
|
||||
expect(@group_milestones.count).to eq(2)
|
||||
end
|
||||
|
||||
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'])
|
||||
end
|
||||
|
||||
it 'should have all project milestones' do
|
||||
expect(@group_milestones.map { |group_milestone| group_milestone.milestones.count }.sum).to eq(6)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'milestone' do
|
||||
context 'with valid title' do
|
||||
before do
|
||||
milestones =
|
||||
[
|
||||
milestone1_project1,
|
||||
milestone1_project2,
|
||||
milestone1_project3,
|
||||
milestone2_project1,
|
||||
milestone2_project2,
|
||||
milestone2_project3
|
||||
]
|
||||
@group_milestones = Milestones::GroupService.new(milestones).milestone('Milestone v1.2')
|
||||
end
|
||||
|
||||
it 'should have exactly one group milestone' do
|
||||
expect(@group_milestones.title).to eq('Milestone v1.2')
|
||||
end
|
||||
|
||||
it 'should have all project milestones with the same title' do
|
||||
expect(@group_milestones.milestones.count).to eq(3)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Reference in a new issue