diff options
-rw-r--r-- | app/assets/stylesheets/sections/groups.scss | 4 | ||||
-rw-r--r-- | app/controllers/groups/milestones_controller.rb | 54 | ||||
-rw-r--r-- | app/helpers/groups_helper.rb | 11 | ||||
-rw-r--r-- | app/models/group_milestone.rb | 95 | ||||
-rw-r--r-- | app/services/milestones/group_service.rb | 26 | ||||
-rw-r--r-- | app/views/groups/_filter.html.haml | 12 | ||||
-rw-r--r-- | app/views/groups/milestones/_issue.html.haml | 10 | ||||
-rw-r--r-- | app/views/groups/milestones/_issues.html.haml | 6 | ||||
-rw-r--r-- | app/views/groups/milestones/_merge_request.html.haml | 10 | ||||
-rw-r--r-- | app/views/groups/milestones/_merge_requests.html.haml | 6 | ||||
-rw-r--r-- | app/views/groups/milestones/index.html.haml | 49 | ||||
-rw-r--r-- | app/views/groups/milestones/show.html.haml | 76 | ||||
-rw-r--r-- | app/views/layouts/nav/_group.html.haml | 3 | ||||
-rw-r--r-- | config/routes.rb | 2 | ||||
-rw-r--r-- | features/group.feature | 19 | ||||
-rw-r--r-- | features/steps/group/group.rb | 94 | ||||
-rw-r--r-- | spec/services/milestones/group_service_spec.rb | 70 |
17 files changed, 547 insertions, 0 deletions
diff --git a/app/assets/stylesheets/sections/groups.scss b/app/assets/stylesheets/sections/groups.scss index 60ec79acadb..e49fe1a9dd6 100644 --- a/app/assets/stylesheets/sections/groups.scss +++ b/app/assets/stylesheets/sections/groups.scss @@ -7,3 +7,7 @@ .member-search-form { float: left; } + +.milestone-row { + @include str-truncated(90%); +} diff --git a/app/controllers/groups/milestones_controller.rb b/app/controllers/groups/milestones_controller.rb new file mode 100644 index 00000000000..8dde8e91468 --- /dev/null +++ b/app/controllers/groups/milestones_controller.rb @@ -0,0 +1,54 @@ +class Groups::MilestonesController < ApplicationController + layout 'group' + + before_filter :authorize_group_milestone!, only: :update + + def index + project_milestones = Milestone.where(project_id: group.projects) + @group_milestones = Milestones::GroupService.new(project_milestones).execute + @group_milestones = case params[:status] + when 'all'; @group_milestones + when 'closed'; status('closed') + else status('active') + end + end + + def show + project_milestones = Milestone.where(project_id: group.projects) + @group_milestone = Milestones::GroupService.new(project_milestones).milestone(title) + end + + def update + project_milestones = Milestone.where(project_id: group.projects) + @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) + end + + respond_to do |format| + format.js + format.html do + redirect_to group_milestones_path(group) + end + end + end + + private + + def group + @group ||= Group.find_by(path: params[:group_id]) + end + + def title + params[:title] + end + + def status(state) + @group_milestones.map{ |milestone| next if milestone.state != state; milestone }.compact + end + + def authorize_group_milestone! + return render_404 unless can?(current_user, :manage_group, group) + end +end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index cfc9a572cac..0dc53dedeb7 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -31,6 +31,17 @@ module GroupsHelper end title + end + + def group_filter_path(entity, options={}) + exist_opts = { + status: params[:status] + } + + options = exist_opts.merge(options) + path = request.path + path << "?#{options.to_param}" + path end end diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb new file mode 100644 index 00000000000..013f8db00a7 --- /dev/null +++ b/app/models/group_milestone.rb @@ -0,0 +1,95 @@ +class GroupMilestone + + def initialize(title, milestones) + @title = title + @milestones = milestones + end + + def title + @title + end + + def safe_title + @title.parameterize + end + + def milestones + @milestones + end + + def projects + milestones.map { |milestone| milestone.project } + end + + def issue_count + milestones.map { |milestone| milestone.issues.count }.sum + end + + def merge_requests_count + milestones.map { |milestone| milestone.merge_requests.count }.sum + end + + def open_items_count + milestones.map { |milestone| milestone.open_items_count }.sum + end + + def closed_items_count + milestones.map { |milestone| milestone.closed_items_count }.sum + end + + def total_items_count + milestones.map { |milestone| milestone.total_items_count }.sum + end + + def percent_complete + ((closed_items_count * 100) / total_items_count).abs + rescue ZeroDivisionError + 100 + end + + def state + state = milestones.map { |milestone| milestone.state } + + if state.count('active') == state.size + 'active' + else + 'closed' + end + end + + def active? + state == 'active' + end + + def closed? + state == 'closed' + end + + def issues + @group_issues ||= milestones.map { |milestone| milestone.issues }.flatten.group_by(&:state) + end + + def merge_requests + @group_merge_requests ||= milestones.map { |milestone| milestone.merge_requests }.flatten.group_by(&:state) + end + + def participants + milestones.map { |milestone| milestone.participants.uniq }.reject(&:empty?).flatten + end + + def opened_issues + issues.values_at("opened", "reopened").compact.flatten + end + + def closed_issues + issues['closed'] + end + + def opened_merge_requests + merge_requests.values_at("opened", "reopened").compact.flatten + end + + def closed_merge_requests + merge_requests.values_at("closed", "merged", "locked").compact.flatten + end +end diff --git a/app/services/milestones/group_service.rb b/app/services/milestones/group_service.rb new file mode 100644 index 00000000000..11d702f1e7b --- /dev/null +++ b/app/services/milestones/group_service.rb @@ -0,0 +1,26 @@ +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 diff --git a/app/views/groups/_filter.html.haml b/app/views/groups/_filter.html.haml new file mode 100644 index 00000000000..393be3f1d12 --- /dev/null +++ b/app/views/groups/_filter.html.haml @@ -0,0 +1,12 @@ += form_tag group_filter_path(entity), method: 'get' do + %fieldset + %ul.nav.nav-pills.nav-stacked + %li{class: ("active" if (params[:status] == 'active' || !params[:status]))} + = link_to group_filter_path(entity, status: 'active') do + Active + %li{class: ("active" if params[:status] == 'closed')} + = link_to group_filter_path(entity, status: 'closed') do + Closed + %li{class: ("active" if params[:status] == 'all')} + = link_to group_filter_path(entity, status: 'all') do + All diff --git a/app/views/groups/milestones/_issue.html.haml b/app/views/groups/milestones/_issue.html.haml new file mode 100644 index 00000000000..c0cf56941f5 --- /dev/null +++ b/app/views/groups/milestones/_issue.html.haml @@ -0,0 +1,10 @@ +%li{ id: dom_id(issue, 'sortable'), class: 'issue-row', 'data-iid' => issue.iid } + %span.milestone-row + - project = issue.project + %strong #{project.name} · + = link_to [project, issue] do + %span.cgray ##{issue.iid} + = link_to_gfm issue.title, [project, issue] + .pull-right.assignee-icon + - if issue.assignee + = image_tag avatar_icon(issue.assignee.email, 16), class: "avatar s16" diff --git a/app/views/groups/milestones/_issues.html.haml b/app/views/groups/milestones/_issues.html.haml new file mode 100644 index 00000000000..9f350b772bd --- /dev/null +++ b/app/views/groups/milestones/_issues.html.haml @@ -0,0 +1,6 @@ +.panel.panel-default + .panel-heading= title + %ul{ class: "well-list issues-sortable-list" } + - if issues + - issues.each do |issue| + = render 'issue', issue: issue diff --git a/app/views/groups/milestones/_merge_request.html.haml b/app/views/groups/milestones/_merge_request.html.haml new file mode 100644 index 00000000000..037162a20e0 --- /dev/null +++ b/app/views/groups/milestones/_merge_request.html.haml @@ -0,0 +1,10 @@ +%li{ id: dom_id(merge_request, 'sortable'), class: 'mr-row', 'data-iid' => merge_request.iid } + %span.milestone-row + - project = merge_request.project + %strong #{project.name} · + = link_to [project, merge_request] do + %span.cgray ##{merge_request.iid} + = link_to_gfm merge_request.title, [project, merge_request] + .pull-right.assignee-icon + - if merge_request.assignee + = image_tag avatar_icon(merge_request.assignee.email, 16), class: "avatar s16" diff --git a/app/views/groups/milestones/_merge_requests.html.haml b/app/views/groups/milestones/_merge_requests.html.haml new file mode 100644 index 00000000000..50057e2c636 --- /dev/null +++ b/app/views/groups/milestones/_merge_requests.html.haml @@ -0,0 +1,6 @@ +.panel.panel-default + .panel-heading= title + %ul{ class: "well-list merge_requests-sortable-list" } + - if merge_requests + - merge_requests.each do |merge_request| + = render 'merge_request', merge_request: merge_request diff --git a/app/views/groups/milestones/index.html.haml b/app/views/groups/milestones/index.html.haml new file mode 100644 index 00000000000..9dd8fa96640 --- /dev/null +++ b/app/views/groups/milestones/index.html.haml @@ -0,0 +1,49 @@ +%h3.page-title + Milestones + %span.pull-right #{@group_milestones.count} milestones + +%p.light + Only milestones from + %strong #{@group.name} + group are listed here. + +%hr + +.row + .fixed.sidebar-expand-button.hidden-lg.hidden-md + %i.icon-list.icon-2x + .col-md-3.responsive-side + = render 'groups/filter', entity: 'milestone' + .col-md-9 + .panel.panel-default + %ul.well-list + - if @group_milestones.blank? + %li + .nothing-here-block No milestones to show + - else + - @group_milestones.each do |milestone| + %li{class: "milestone milestone-#{milestone.closed? ? 'closed' : 'open'}", id: dom_id(milestone.milestones.first) } + .pull-right + - if can?(current_user, :manage_group, @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-small btn-grouped" + - else + = link_to 'Close Milestone', group_milestone_path(@group, milestone.safe_title, title: milestone.title, milestone: {state_event: :close }), method: :put, class: "btn btn-small btn-remove" + %h4 + = link_to_gfm truncate(milestone.title, length: 100), group_milestone_path(@group, milestone.safe_title, title: milestone.title) + %div + %div + = link_to group_milestone_path(@group, milestone.safe_title, title: milestone.title) do + = pluralize milestone.issue_count, 'Issue' + + = link_to group_milestone_path(@group, milestone.safe_title, title: milestone.title) do + = pluralize milestone.merge_requests_count, 'Merge Request' + + %span.light #{milestone.percent_complete}% complete + .progress.progress-info + .progress-bar{style: "width: #{milestone.percent_complete}%;"} + %div + %br + - milestone.projects.each do |project| + %span.label.label-default + = project.name diff --git a/app/views/groups/milestones/show.html.haml b/app/views/groups/milestones/show.html.haml new file mode 100644 index 00000000000..d39870b032c --- /dev/null +++ b/app/views/groups/milestones/show.html.haml @@ -0,0 +1,76 @@ +%h3.page-title + Milestone #{@group_milestone.title} + .pull-right + - if can?(current_user, :manage_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-small btn-remove" + - 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-small btn-grouped" + +- if (@group_milestone.total_items_count == @group_milestone.closed_items_count) && @group_milestone.active? + .alert.alert-success + %span All issues for this milestone are closed. You may close the milestone now. + +.back-link + = link_to group_milestones_path(@group) do + ← To milestones list + +.issue-box{ class: "issue-box-#{@group_milestone.closed? ? 'closed' : 'open'}" } + .state.clearfix + .state-label + - if @group_milestone.closed? + Closed + - else + Open + + %h4.title + = gfm escape_once(@group_milestone.title) + + .context + %p + Progress: + #{@group_milestone.closed_items_count} closed + – + #{@group_milestone.open_items_count} open + + .progress.progress-info + .progress-bar{style: "width: #{@group_milestone.percent_complete}%;"} + +%ul.nav.nav-tabs + %li.active + = link_to '#tab-issues', 'data-toggle' => 'tab' do + Issues + %span.badge= @group_milestone.issue_count + %li + = link_to '#tab-merge-requests', 'data-toggle' => 'tab' do + Merge Requests + %span.badge= @group_milestone.merge_requests_count + %li + = link_to '#tab-participants', 'data-toggle' => 'tab' do + Participants + %span.badge= @group_milestone.participants.count + +.tab-content + .tab-pane.active#tab-issues + .row + .col-md-6 + = render 'issues', title: "Open", issues: @group_milestone.opened_issues + .col-md-6 + = render 'issues', title: "Closed", issues: @group_milestone.closed_issues + + .tab-pane#tab-merge-requests + .row + .col-md-6 + = render 'merge_requests', title: "Open", merge_requests: @group_milestone.opened_merge_requests + .col-md-6 + = render 'merge_requests', title: "Closed", merge_requests: @group_milestone.closed_merge_requests + + .tab-pane#tab-participants + %ul.bordered-list + - @group_milestone.participants.each do |user| + %li + = link_to user, title: user.name, class: "darken" do + = image_tag avatar_icon(user.email, 32), class: "avatar s32" + %strong= truncate(user.name, lenght: 40) + %br + %small.cgray= user.username diff --git a/app/views/layouts/nav/_group.html.haml b/app/views/layouts/nav/_group.html.haml index d306e1eeb54..5d161a17bfc 100644 --- a/app/views/layouts/nav/_group.html.haml +++ b/app/views/layouts/nav/_group.html.haml @@ -2,6 +2,9 @@ = nav_link(path: 'groups#show', html_options: {class: 'home'}) do = link_to group_path(@group), title: "Home" do Activity + = nav_link(path: 'groups#milestones') do + = link_to group_milestones_path(@group) do + Milestones = nav_link(path: 'groups#issues') do = link_to issues_group_path(@group) do Issues diff --git a/config/routes.rb b/config/routes.rb index 14ff52f387a..244cb339898 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -151,8 +151,10 @@ Gitlab::Application.routes.draw do end resources :users_groups, only: [:create, :update, :destroy] + scope module: :groups do resource :avatar, only: [:destroy] + resources :milestones end end diff --git a/features/group.feature b/features/group.feature index 71c28c07a3c..0c70e5b915c 100644 --- a/features/group.feature +++ b/features/group.feature @@ -120,3 +120,22 @@ Feature: Groups When I search for 'Mary' member Then I should see user "Mary Jane" in team list Then I should not see user "John Doe" in team list + + + Scenario: I should see group "Owned" milestone index page with no milestones + When I visit group "Owned" page + And I click on group milestones + Then I should see group milestones index page has no milestones + + Scenario: I should see group "Owned" milestone index page with milestones + Given Group has projects with milestones + When I visit group "Owned" page + And I click on group milestones + Then I should see group milestones index page with milestones + + Scenario: I should see group "Owned" milestone show page + Given Group has projects with milestones + When I visit group "Owned" page + And I click on group milestones + And I click on one group milestone + Then I should see group milestone with all issues and MRs assigned to that milestone diff --git a/features/steps/group/group.rb b/features/steps/group/group.rb index f321428592f..85276f04dd2 100644 --- a/features/steps/group/group.rb +++ b/features/steps/group/group.rb @@ -164,6 +164,36 @@ class Groups < Spinach::FeatureSteps end end + step 'I click on group milestones' do + click_link 'Milestones' + end + + step 'I should see group milestones index page has no milestones' do + page.should have_content('No milestones to show') + end + + step 'Group has projects with milestones' do + group_milestone + end + + step 'I should see group milestones index page with milestones' do + page.should have_content('Version 7.2') + page.should have_content('GL-113') + page.should have_link('2 Issues', href: group_milestone_path("owned", "version-7-2", title: "Version 7.2")) + page.should have_link('3 Merge Requests', href: group_milestone_path("owned", "gl-113", title: "GL-113")) + end + + step 'I click on one group milestone' do + click_link 'GL-113' + end + + step 'I should see group milestone with all issues and MRs assigned to that milestone' do + page.should have_content('Milestone GL-113') + page.should have_content('Progress: 0 closed – 4 open') + page.should have_link(@issue1.title, href: project_issue_path(@project1, @issue1)) + page.should have_link(@mr3.title, href: project_merge_request_path(@project3, @mr3)) + end + protected def assigned_to_me key @@ -173,4 +203,68 @@ class Groups < Spinach::FeatureSteps def project Group.find_by(name: "Owned").projects.first end + + def group_milestone + group = Group.find_by(name: "Owned") + + @project1 = create :project, + group: group + project2 = create :project, + path: 'gitlab-ci', + group: group + @project3 = create :project, + path: 'cookbook-gitlab', + group: group + milestone1_project1 = create :milestone, + title: "Version 7.2", + project: @project1 + milestone1_project2 = create :milestone, + title: "Version 7.2", + project: project2 + milestone1_project3 = create :milestone, + title: "Version 7.2", + project: @project3 + milestone2_project1 = create :milestone, + title: "GL-113", + project: @project1 + milestone2_project2 = create :milestone, + title: "GL-113", + project: project2 + milestone2_project3 = create :milestone, + title: "GL-113", + project: @project3 + @issue1 = create :issue, + project: @project1, + assignee: current_user, + author: current_user, + milestone: milestone2_project1 + issue2 = create :issue, + project: project2, + assignee: current_user, + author: current_user, + milestone: milestone1_project2 + issue3 = create :issue, + project: @project3, + assignee: current_user, + author: current_user, + milestone: milestone1_project1 + mr1 = create :merge_request, + source_project: @project1, + target_project: @project1, + assignee: current_user, + author: current_user, + milestone: milestone2_project1 + mr2 = create :merge_request, + source_project: project2, + target_project: project2, + assignee: current_user, + author: current_user, + milestone: milestone2_project2 + @mr3 = create :merge_request, + source_project: @project3, + target_project: @project3, + assignee: current_user, + author: current_user, + milestone: milestone2_project3 + end end diff --git a/spec/services/milestones/group_service_spec.rb b/spec/services/milestones/group_service_spec.rb new file mode 100644 index 00000000000..74eb0f99e0f --- /dev/null +++ b/spec/services/milestones/group_service_spec.rb @@ -0,0 +1,70 @@ +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 |