Commit 60039b36 authored by Sean McGivern's avatar Sean McGivern

Merge branch '3064-group-burndown-chart' into 'master'

Resolve "Burndown chart for group milestone"

Closes #3064

See merge request gitlab-org/gitlab-ee!5354
parents 03e16029 0c1cd35b
...@@ -39,27 +39,15 @@ export default { ...@@ -39,27 +39,15 @@ export default {
groupName: this.groupName, groupName: this.groupName,
}, },
); );
const missingFeatureWarn = sprintf(
s__(`Milestones|Group milestones are currently %{linkStart} missing features such as burndown charts. %{linkEnd}
You will not have these features once you've promoted a project milestone.
They will be available in future releases.`),
{
linkStart: `<a href="https://docs.gitlab.com/ee/user/project/milestones/"
target="_blank" rel="noopener noreferrer">`,
linkEnd: '</a>',
},
false,
);
const finalWarning = s__('Milestones|This action cannot be reversed.'); const finalWarning = s__('Milestones|This action cannot be reversed.');
return sprintf( return sprintf(
s__( s__(
`Milestones|<p>%{milestonePromotion}</p> `Milestones|<p>%{milestonePromotion}</p>
<p>%{missingFeatureWarn}</p>%{finalWarning}`, %{finalWarning}`,
), ),
{ {
milestonePromotion, milestonePromotion,
missingFeatureWarn,
finalWarning, finalWarning,
}, },
false, false,
......
...@@ -43,11 +43,6 @@ class Projects::MilestonesController < Projects::ApplicationController ...@@ -43,11 +43,6 @@ class Projects::MilestonesController < Projects::ApplicationController
def show def show
@project_namespace = @project.namespace.becomes(Namespace) @project_namespace = @project.namespace.becomes(Namespace)
if @project.feature_available?(:burndown_charts, current_user) &&
@project.feature_available?(:issue_weights, current_user)
@burndown = Burndown.new(@milestone)
end
respond_to do |format| respond_to do |format|
format.html format.html
end end
......
module MilestonesHelper module MilestonesHelper
prepend EE::MilestonesHelper
include EntityDateHelper include EntityDateHelper
def milestones_filter_path(opts = {}) def milestones_filter_path(opts = {})
...@@ -194,39 +195,6 @@ module MilestonesHelper ...@@ -194,39 +195,6 @@ module MilestonesHelper
end end
end end
def data_warning_for(burndown)
return unless burndown
message =
if burndown.empty?
"The burndown chart can’t be shown, as all issues assigned to this milestone were closed on an older GitLab version before data was recorded. "
elsif !burndown.accurate?
"Some issues can’t be shown in the burndown chart, as they were closed on an older GitLab version before data was recorded. "
end
if message
message += link_to "About burndown charts", help_page_path('user/project/milestones/index', anchor: 'burndown-charts'), class: 'burndown-docs-link'
content_tag(:div, message.html_safe, id: "data-warning", class: "settings-message prepend-top-20")
end
end
def can_generate_chart?(burndown)
return unless @project.feature_available?(:burndown_charts, current_user) &&
@project.feature_available?(:issue_weights, current_user)
burndown&.valid? && !burndown&.empty?
end
def show_burndown_placeholder?(warning)
return false if cookies['hide_burndown_message'].present?
return false unless @project.feature_available?(:burndown_charts, current_user) &&
@project.feature_available?(:issue_weights, current_user)
warning.nil? && can?(current_user, :admin_milestone, @project)
end
def milestone_merge_request_tab_path(milestone) def milestone_merge_request_tab_path(milestone)
if @project if @project
merge_requests_project_milestone_path(@project, milestone, format: :json) merge_requests_project_milestone_path(@project, milestone, format: :json)
......
...@@ -69,7 +69,7 @@ ...@@ -69,7 +69,7 @@
.wiki .wiki
= markdown_field(@milestone, :description) = markdown_field(@milestone, :description)
= render 'shared/milestones/burndown', milestone: @milestone, project: @project, burndown: @burndown = render 'shared/milestones/burndown', milestone: @milestone, project: @project
- if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero? - if can?(current_user, :read_issue, @project) && @milestone.total_items_count(current_user).zero?
.alert.alert-success.prepend-top-default .alert.alert-success.prepend-top-default
......
...@@ -48,6 +48,8 @@ ...@@ -48,6 +48,8 @@
- close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.' - close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.'
%span All issues for this milestone are closed. #{close_msg} %span All issues for this milestone are closed. #{close_msg}
= render 'shared/milestones/burndown', milestone: @milestone, project: @project
- if is_dynamic_milestone - if is_dynamic_milestone
.table-holder .table-holder
%table.table %table.table
......
# Burndown Charts **[STARTER]** # Burndown Charts **[STARTER]**
>**Notes:** >**Notes:**
- [Introduced][ee-1540] in [GitLab Starter 9.1][ee-9.1]. - [Introduced][ee-1540] in [GitLab Starter 9.1][ee-9.1] for project milestones.
- [Introduced][ee-5354] in [GitLab Silver 10.8][ee-10.8] for group milestones.
- Closed or reopened issues prior to GitLab 9.1 won't have a `closed_at` - Closed or reopened issues prior to GitLab 9.1 won't have a `closed_at`
value, so the burndown chart considers them as closed on the milestone value, so the burndown chart considers them as closed on the milestone
`start_date`. In that case, a warning will be displayed. `start_date`. In that case, a warning will be displayed.
...@@ -42,13 +43,16 @@ it was taken care of closely throughout the whole quarter ...@@ -42,13 +43,16 @@ it was taken care of closely throughout the whole quarter
## How it works ## How it works
>**Note:** Burndown charts are only available for project milestones. They will be available for group milestones [in the future](https://gitlab.com/gitlab-org/gitlab-ee/issues/3064). A Burndown Chart is available for every project or group milestone that has been attributed a **start
A Burndown Chart is available for every project milestone that has been attributed a **start
date** and a **due date**. date** and a **due date**.
Find your project's **Burndown Chart** under **Project > Issues > Milestones**, Find your project's **Burndown Chart** under **Project > Issues > Milestones**,
and select a milestone from your current ones. and select a milestone from your current ones, while for group's, access the **Groups** dashboard,
select a group, and go through **Issues > Milestones** on the sidebar.
>
**Note:** You're able to [promote project][promote-milestone] to group milestones and still
see the **Burndown Chart** for them, respecting license limitations.
The chart indicates the project's progress throughout that milestone (for issues assigned to it). The chart indicates the project's progress throughout that milestone (for issues assigned to it).
...@@ -73,3 +77,6 @@ cumulative value. ...@@ -73,3 +77,6 @@ cumulative value.
[ee-1540]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1540 [ee-1540]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1540
[ee-9.1]: https://about.gitlab.com/2017/04/22/gitlab-9-1-released/#burndown-charts-ees-eep [ee-9.1]: https://about.gitlab.com/2017/04/22/gitlab-9-1-released/#burndown-charts-ees-eep
[ee-5354]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/5354
[ee-10.8]: https://about.gitlab.com/2017/04/22/gitlab-10-8-released/#burndown-charts-eep-eeu
[promote-milestone]: https://docs.gitlab.com/ee/user/project/milestones/#promoting-project-milestones-to-group-milestones
import '~/pages/groups/milestones/show/index';
import UserCallout from '~/user_callout';
import initBurndownChart from 'ee/burndown_chart';
document.addEventListener('DOMContentLoaded', () => {
new UserCallout(); // eslint-disable-line no-new
initBurndownChart();
});
module EE
module MilestonesHelper
def burndown_chart(milestone)
Burndown.new(milestone) if milestone.supports_burndown_charts?
end
def can_generate_chart?(milestone, burndown)
return false unless milestone.supports_burndown_charts?
burndown&.valid? && !burndown&.empty?
end
def show_burndown_placeholder?(milestone, warning)
return false if cookies['hide_burndown_message'].present?
return false unless milestone.supports_burndown_charts?
warning.nil? && can_admin_milestone?(milestone)
end
def data_warning_for(burndown)
return unless burndown
message =
if burndown.empty?
"The burndown chart can’t be shown, as all issues assigned to this milestone were closed on an older GitLab version before data was recorded. "
elsif !burndown.accurate?
"Some issues can’t be shown in the burndown chart, as they were closed on an older GitLab version before data was recorded. "
end
if message
message += link_to "About burndown charts", help_page_path('user/project/milestones/index', anchor: 'burndown-charts'), class: 'burndown-docs-link'
content_tag(:div, message.html_safe, id: "data-warning", class: "settings-message prepend-top-20")
end
end
private
def can_admin_milestone?(milestone)
policy_name = milestone.group_milestone? ? :admin_milestones : :admin_milestone
can?(current_user, policy_name, milestone.parent)
end
end
end
...@@ -96,10 +96,6 @@ module LicenseHelper ...@@ -96,10 +96,6 @@ module LicenseHelper
end end
end end
def show_project_feature_promotion?(project_feature, callout_id = nil)
!@project.feature_available?(project_feature) && show_promotions? && (callout_id.nil? || show_callout?(callout_id))
end
def show_advanced_search_promotion? def show_advanced_search_promotion?
!Gitlab::CurrentSettings.should_check_namespace_plan? && show_promotions? && show_callout?('promote_advanced_search_dismissed') && !License.feature_available?(:elastic_search) !Gitlab::CurrentSettings.should_check_namespace_plan? && show_promotions? && show_callout?('promote_advanced_search_dismissed') && !License.feature_available?(:elastic_search)
end end
......
...@@ -3,5 +3,12 @@ module EE ...@@ -3,5 +3,12 @@ module EE
def supports_weight? def supports_weight?
false false
end end
# Legacy group milestones or dashboard milestones (grouped by title)
# can't present Burndown charts since they don't have
# proper limits set.
def supports_burndown_charts?
false
end
end end
end end
module EE module EE
module Milestone module Milestone
def supports_weight? def supports_weight?
project&.feature_available?(:issue_weights) parent&.feature_available?(:issue_weights)
end
def supports_burndown_charts?
feature_name = group_milestone? ? :group_burndown_charts : :burndown_charts
parent&.feature_available?(feature_name) && supports_weight?
end end
end end
end end
...@@ -59,6 +59,7 @@ class License < ActiveRecord::Base ...@@ -59,6 +59,7 @@ class License < ActiveRecord::Base
commit_committer_check commit_committer_check
external_authorization_service external_authorization_service
ci_cd_projects ci_cd_projects
group_burndown_charts
].freeze ].freeze
EEU_FEATURES = EEP_FEATURES + %i[ EEU_FEATURES = EEP_FEATURES + %i[
......
- page_title "Audit Events" - page_title "Audit Events"
- feature_available = @project.feature_available?(:audit_events)
- if show_project_feature_promotion?(:audit_events) - if !feature_available && show_promotions?
= render 'shared/promotions/promote_audit_events' = render 'shared/promotions/promote_audit_events'
%h3.page-title Project Audit Events %h3.page-title Project Audit Events
%p.light Events in #{@project.full_path} %p.light Events in #{@project.full_path}
- if @project.feature_available?(:audit_events) - if feature_available
= render 'shared/audit_events/event_table', events: @events = render 'shared/audit_events/event_table', events: @events
- milestone = local_assigns[:milestone] - milestone = local_assigns[:milestone]
- project = local_assigns[:project] - project = local_assigns[:project]
- burndown = local_assigns[:burndown] - burndown = burndown_chart(milestone)
- warning = data_warning_for(burndown) - warning = data_warning_for(burndown)
= warning = warning
- if can_generate_chart?(burndown) - if can_generate_chart?(milestone, burndown)
.burndown-header .burndown-header
%h3 %h3
Burndown chart Burndown chart
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
Issue weight Issue weight
.burndown-chart{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"), due_date: burndown.due_date.strftime("%Y-%m-%d"), chart_data: burndown.to_json } } .burndown-chart{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"), due_date: burndown.due_date.strftime("%Y-%m-%d"), chart_data: burndown.to_json } }
- elsif show_burndown_placeholder?(warning) - elsif show_burndown_placeholder?(milestone, warning)
.burndown-hint.content-block.container-fluid .burndown-hint.content-block.container-fluid
= icon("times", class: "dismiss-icon") = icon("times", class: "dismiss-icon")
.row .row
...@@ -29,5 +29,5 @@ ...@@ -29,5 +29,5 @@
View your milestone's progress as a burndown chart. Add both a start and a due date to View your milestone's progress as a burndown chart. Add both a start and a due date to
this milestone and the chart will appear here, always up-to-date. this milestone and the chart will appear here, always up-to-date.
= link_to "Add start and due date", edit_project_milestone_path(project, milestone), class: 'btn' = link_to "Add start and due date", edit_milestone_path(milestone), class: 'btn'
= render 'shared/promotions/promote_burndown_charts' = render 'shared/promotions/promote_burndown_charts', milestone: milestone
- if show_project_feature_promotion?(:burndown_charts, 'promote_burndown_charts_dismissed') - callout_id = 'promote_burndown_charts_dismissed'
.user-callout.promotion-callout#promote_burndown_charts{ data: { uid: 'promote_burndown_charts_dismissed' } }
- if !milestone.supports_burndown_charts? && show_promotions? && show_callout?(callout_id)
.user-callout.promotion-callout#promote_burndown_charts{ data: { uid: callout_id } }
.bordered-box.content-block .bordered-box.content-block
%button.btn.btn-default.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss burndown charts promotion' } %button.btn.btn-default.close.js-close-callout{ type: 'button', 'aria-label' => 'Dismiss burndown charts promotion' }
= icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true') = icon('times', class: 'dismiss-icon', 'aria-hidden' => 'true')
......
---
title: Present Burndown charts for group milestones
merge_request: 5354
author:
type: added
...@@ -2,105 +2,155 @@ require 'spec_helper' ...@@ -2,105 +2,155 @@ require 'spec_helper'
describe Burndown do describe Burndown do
let(:start_date) { "2017-03-01" } let(:start_date) { "2017-03-01" }
let(:due_date) { "2017-03-05" } let(:due_date) { "2017-03-05" }
let(:milestone) { create(:milestone, start_date: start_date, due_date: due_date) } let(:user) { create(:user) }
let(:project) { milestone.project }
let(:user) { create(:user) }
let(:issue_params) do
{
title: FFaker::Lorem.sentence(6),
description: FFaker::Lorem.sentence,
state: 'opened',
milestone: milestone,
weight: 2,
project_id: project.id
}
end
around do |example| shared_examples 'burndown for milestone' do
Timecop.travel(due_date) do before do
example.run scope.add_master(user)
build_sample(milestone, issue_params)
end end
end
before do around do |example|
project.add_master(user) Timecop.travel(due_date) do
build_sample example.run
end end
end
subject { described_class.new(milestone).to_json } subject { described_class.new(milestone).to_json }
it "generates an array with date, issue count and weight" do it "generates an array with date, issue count and weight" do
expect(subject).to eq([ expect(subject).to eq([
["2017-03-01", 33, 66], ["2017-03-01", 33, 66],
["2017-03-02", 35, 70], ["2017-03-02", 35, 70],
["2017-03-03", 28, 56], ["2017-03-03", 28, 56],
["2017-03-04", 32, 64], ["2017-03-04", 32, 64],
["2017-03-05", 21, 42] ["2017-03-05", 21, 42]
].to_json) ].to_json)
end end
it "returns empty array if milestone start date is nil" do it "returns empty array if milestone start date is nil" do
milestone.update(start_date: nil) milestone.update(start_date: nil)
expect(subject).to eq([].to_json) expect(subject).to eq([].to_json)
end end
it "returns empty array if milestone due date is nil" do
milestone.update(due_date: nil)
expect(subject).to eq([].to_json) it "returns empty array if milestone due date is nil" do
end milestone.update(due_date: nil)
it "it counts until today if milestone due date > Date.today" do expect(subject).to eq([].to_json)
Timecop.travel(milestone.due_date - 1.day) do
expect(JSON.parse(subject).last[0]).to eq(Time.now.strftime("%Y-%m-%d"))
end end
end
it "sets attribute accurate to true" do it "it counts until today if milestone due date > Date.today" do
burndown = described_class.new(milestone) Timecop.travel(milestone.due_date - 1.day) do
expect(JSON.parse(subject).last[0]).to eq(Time.now.strftime("%Y-%m-%d"))
end
end
expect(burndown).to be_accurate it "sets attribute accurate to true" do
end burndown = described_class.new(milestone)
context "when all closed issues does not have closed events" do expect(burndown).to be_accurate
before do
Event.where(target: milestone.issues, action: Event::CLOSED).destroy_all
end end
it "considers closed_at as milestone start date" do context "when all closed issues does not have closed events" do
expect(subject).to eq([ before do
["2017-03-01", 27, 54], Event.where(target: milestone.issues, action: Event::CLOSED).destroy_all
["2017-03-02", 27, 54], end
["2017-03-03", 27, 54],
["2017-03-04", 27, 54], it "considers closed_at as milestone start date" do
["2017-03-05", 27, 54] expect(subject).to eq([
].to_json) ["2017-03-01", 27, 54],
["2017-03-02", 27, 54],
["2017-03-03", 27, 54],
["2017-03-04", 27, 54],
["2017-03-05", 27, 54]
].to_json)
end
it "sets attribute empty to true" do
burndown = described_class.new(milestone)
expect(burndown).to be_empty
end
end end
it "sets attribute empty to true" do context "when one or more closed issues does not have a closed event" do
burndown = described_class.new(milestone) before do
Event.where(target: milestone.issues.closed.first, action: Event::CLOSED).destroy_all
end
it "sets attribute accurate to false" do
burndown = described_class.new(milestone)
expect(burndown).to be_empty expect(burndown).not_to be_accurate
end
end end
end end
context "when one or more closed issues does not have a closed event" do describe 'project milestone burndown' do
before do it_behaves_like 'burndown for milestone' do
Event.where(target: milestone.issues.closed.first, action: Event::CLOSED).destroy_all let(:milestone) { create(:milestone, start_date: start_date, due_date: due_date) }
let(:project) { milestone.project }
let(:issue_params) do
{
title: FFaker::Lorem.sentence(6),
description: FFaker::Lorem.sentence,
state: 'opened',
milestone: milestone,
weight: 2,
project_id: project.id
}
end
let(:scope) { project }
end end
end
it "sets attribute accurate to false" do describe 'group milestone burndown' do
burndown = described_class.new(milestone) let(:group) { create(:group) }
let(:nested_group) { create(:group, parent: group) }
let(:group_project) { create(:project, group: group) }
let(:nested_group_project) { create(:project, group: nested_group) }
let(:group_milestone) { create(:milestone, project: nil, group: group, start_date: start_date, due_date: due_date) }
let(:nested_group_milestone) { create(:milestone, group: nested_group, start_date: start_date, due_date: due_date) }
context 'when nested group milestone', :nested_groups do
it_behaves_like 'burndown for milestone' do
let(:milestone) { nested_group_milestone }
let(:issue_params) do
{
title: FFaker::Lorem.sentence(6),
description: FFaker::Lorem.sentence,
state: 'opened',
milestone: milestone,
weight: 2,
project_id: nested_group_project.id
}
end
let(:scope) { group }
end
end
expect(burndown).not_to be_accurate context 'when non-nested group milestone' do
it_behaves_like 'burndown for milestone' do
let(:milestone) { group_milestone }
let(:issue_params) do
{
title: FFaker::Lorem.sentence(6),
description: FFaker::Lorem.sentence,
state: 'opened',
milestone: milestone,
weight: 2,
project_id: group_project.id
}
end
let(:scope) { group }
end
end end
end end
# Creates, closes and reopens issues only for odd days numbers # Creates, closes and reopens issues only for odd days numbers
def build_sample def build_sample(milestone, issue_params)
milestone.start_date.upto(milestone.due_date) do |date| milestone.start_date.upto(milestone.due_date) do |date|
day = date.day day = date.day
next if day.even? next if day.even?
......
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