Commit b281d49c authored by Simon Knox's avatar Simon Knox

Add burnup chart for tracking milestone scope

Feature flag disabled by default for now
Uses same license as burndown charts
Mostly copy things from burndown charts, with some slightly
different interals
parent 7c9f1195
import dateFormat from 'dateformat';
export default class BurndownChartData {
constructor(burndownEvents, startDate, dueDate) {
export default class BurnChartData {
constructor(events, startDate, dueDate) {
this.dateFormatMask = 'yyyy-mm-dd';
this.startDate = startDate;
this.dueDate = dueDate;
this.burndownEvents = this.processRawEvents(burndownEvents);
// determine when to stop burndown chart
const today = dateFormat(Date.now(), this.dateFormatMask);
......@@ -15,11 +14,63 @@ export default class BurndownChartData {
// and dateFormat() both convert the date at midnight UTC to the browser's
// timezone, leading to incorrect chart start and end points. Using
// new Date('YYYY-MM-DDTHH:MM:SS') gets the user's local date at midnight.
this.localStartDate = new Date(`${this.startDate}T00:00:00`);
this.localEndDate = new Date(`${this.endDate}T00:00:00`);
this.events = this.processRawEvents(events);
}
generateBurnupTimeseries({ milestoneId } = {}) {
const chartData = [];
let openIssuesCount = 0;
let carriedIssuesCount = 0;
for (
let date = this.localStartDate;
date <= this.localEndDate;
date.setDate(date.getDate() + 1)
) {
const dateString = dateFormat(date, this.dateFormatMask);
const openedIssuesToday = this.filterAndSummarizeBurndownEvents(
event =>
event.created_at === dateString &&
event.event_type === 'milestone' &&
event.milestone_id === milestoneId &&
event.action === 'add',
);
const closedIssuesToday = this.filterAndSummarizeBurndownEvents(
event =>
event.created_at === dateString &&
event.event_type === 'milestone' &&
((event.action === 'remove' && event.milestone_id === milestoneId) ||
(event.action === 'add' && event.milestone_id !== milestoneId)),
);
openIssuesCount += openedIssuesToday.count - closedIssuesToday.count;
if (openIssuesCount + carriedIssuesCount < 0) {
carriedIssuesCount += openIssuesCount;
openIssuesCount = 0;
} else {
openIssuesCount += carriedIssuesCount;
carriedIssuesCount = 0;
}
chartData.push([dateString, openIssuesCount]);
}
return {
burnupScope: chartData,
};
}
generate() {
generateBurndownTimeseries() {
let openIssuesCount = 0;
let openIssuesWeight = 0;
......@@ -87,7 +138,7 @@ export default class BurndownChartData {
}
filterAndSummarizeBurndownEvents(filter) {
const issues = this.burndownEvents.filter(filter);
const issues = this.events.filter(filter);
return {
count: issues.length,
......
......@@ -3,12 +3,14 @@ import { GlButton, GlButtonGroup } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale';
import BurndownChart from './burndown_chart.vue';
import BurnupChart from './burnup_chart.vue';
export default {
components: {
GlButton,
GlButtonGroup,
BurndownChart,
BurnupChart,
},
mixins: [glFeatureFlagsMixin()],
props: {
......@@ -30,6 +32,11 @@ export default {
required: false,
default: () => [],
},
burnupScope: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
......@@ -90,6 +97,12 @@ export default {
:issues-selected="issuesSelected"
class="col-md-6"
/>
<burnup-chart
:start-date="startDate"
:due-date="dueDate"
:scope="burnupScope"
class="col-md-6"
/>
</div>
<burndown-chart
v-else
......
......@@ -63,13 +63,13 @@ export default {
const series = [
{
name,
data: data.map(d => [new Date(d[0]), d[1]]),
data,
},
];
if (series[0] && series[0].data.length >= 2) {
const idealStart = [new Date(this.startDate), data[0][1]];
const idealEnd = [new Date(this.dueDate), 0];
const idealStart = [this.startDate, data[0][1]];
const idealEnd = [this.dueDate, 0];
const idealData = [idealStart, idealEnd];
series.push({
......@@ -91,6 +91,8 @@ export default {
xAxis: {
name: '',
type: 'time',
min: this.startDate,
max: this.dueDate,
axisLine: {
show: true,
},
......@@ -141,7 +143,7 @@ export default {
<div v-if="showTitle" class="burndown-header d-flex align-items-center">
<h3>{{ __('Burndown chart') }}</h3>
</div>
<resizable-chart-container class="burndown-chart">
<resizable-chart-container class="burndown-chart js-burndown-chart">
<gl-line-chart
slot-scope="{ width }"
:width="width"
......
<script>
import { GlLineChart } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import { __, sprintf } from '~/locale';
export default {
components: {
GlLineChart,
ResizableChartContainer,
},
props: {
startDate: {
type: String,
required: true,
},
dueDate: {
type: String,
required: true,
},
scope: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
tooltip: {
title: '',
content: '',
},
};
},
computed: {
dataSeries() {
const series = [
{
name: __('Total'),
data: this.scope,
},
];
return series;
},
options() {
return {
xAxis: {
name: '',
type: 'time',
min: this.startDate,
max: this.dueDate,
axisLine: {
show: true,
},
},
yAxis: {
name: __('Total issues'),
axisLine: {
show: true,
},
splitLine: {
show: false,
},
},
tooltip: {
trigger: 'item',
formatter: () => '',
},
};
},
},
methods: {
formatTooltipText(params) {
const [seriesData] = params.seriesData;
this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy');
const text = __('%{total} open issues');
this.tooltip.content = sprintf(text, {
total: seriesData.value[1],
});
},
},
};
</script>
<template>
<div data-qa-selector="burnup_chart">
<div class="burndown-header d-flex align-items-center">
<h3>{{ __('Burnup chart') }}</h3>
</div>
<resizable-chart-container class="js-burnup-chart">
<gl-line-chart :data="dataSeries" :option="options" :format-tooltip-text="formatTooltipText">
<template slot="tooltipTitle">{{ tooltip.title }}</template>
<template slot="tooltipContent">{{ tooltip.content }}</template>
</gl-line-chart>
</resizable-chart-container>
</div>
</template>
......@@ -2,8 +2,8 @@ import Vue from 'vue';
import $ from 'jquery';
import Cookies from 'js-cookie';
import BurnCharts from './components/burn_charts.vue';
import BurndownChartData from './burndown_chart_data';
import Flash from '~/flash';
import BurndownChartData from './burn_chart_data';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
......@@ -22,16 +22,34 @@ export default () => {
if ($chartEl.length) {
const startDate = $chartEl.data('startDate');
const dueDate = $chartEl.data('dueDate');
const milestoneId = $chartEl.data('milestoneId');
const burndownEventsPath = $chartEl.data('burndownEventsPath');
const burnupEventsPath = $chartEl.data('burnupEventsPath');
axios
.get(burndownEventsPath)
.then(response => {
const burndownEvents = response.data;
const chartData = new BurndownChartData(burndownEvents, startDate, dueDate).generate();
const fetchData = [axios.get(burndownEventsPath)];
const openIssuesCount = chartData.map(d => [d[0], d[1]]);
const openIssuesWeight = chartData.map(d => [d[0], d[2]]);
if (gon.features.burnupCharts) {
fetchData.push(axios.get(burnupEventsPath));
}
Promise.all(fetchData)
.then(([burndownResponse, burnupResponse]) => {
const burndownEvents = burndownResponse.data;
const burndownChartData = new BurndownChartData(
burndownEvents,
startDate,
dueDate,
).generateBurndownTimeseries();
const burnupEvents = burnupResponse?.data || [];
const { burnupScope } =
new BurndownChartData(burnupEvents, startDate, dueDate).generateBurnupTimeseries({
milestoneId,
}) || {};
const openIssuesCount = burndownChartData.map(d => [d[0], d[1]]);
const openIssuesWeight = burndownChartData.map(d => [d[0], d[2]]);
return new Vue({
el: container,
......@@ -45,11 +63,14 @@ export default () => {
dueDate,
openIssuesCount,
openIssuesWeight,
burnupScope,
},
});
},
});
})
.catch(() => new Flash(__('Error loading burndown chart data')));
.catch(() => {
createFlash(__('Error loading burndown chart data'));
});
}
};
......@@ -2,13 +2,15 @@
- burndown = burndown_chart(milestone)
- warning = data_warning_for(burndown)
- burndown_endpoint = milestone.group_milestone? ? api_v4_groups_milestones_burndown_events_path(id: milestone.group.id, milestone_id: milestone.id) : api_v4_projects_milestones_burndown_events_path(id: milestone.project.id, milestone_id: milestone.timebox_id)
- burnup_endpoint = milestone.group_milestone? ? api_v4_groups_milestones_burnup_events_path(id: milestone.group.id, milestone_id: milestone.id) : api_v4_projects_milestones_burnup_events_path(id: milestone.project.id, milestone_id: milestone.timebox_id)
= warning
- if can_generate_chart?(milestone, burndown)
.burndown-chart.mb-2{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"),
due_date: burndown.due_date.strftime("%Y-%m-%d"),
burndown_events_path: expose_url(burndown_endpoint), qa_selector: 'burndown_chart' } }
milestone_id: milestone.id,
burndown_events_path: expose_url(burndown_endpoint), burnup_events_path: expose_url(burnup_endpoint), qa_selector: 'burndown_chart' } }
- elsif show_burndown_placeholder?(milestone, warning)
.burndown-hint.content-block.container-fluid
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Burnup charts', :js do
let_it_be(:burnup_chart_selector) { '.js-burnup-chart' }
let_it_be(:burndown_chart_selector) { '.js-burndown-chart' }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:milestone) { create(:milestone, :with_dates, group: group, title: 'January Milestone', description: 'Cut scope from milestone', start_date: '2020-01-30', due_date: '2020-02-10') }
let_it_be(:other_milestone) { create(:milestone, :with_dates, group: group, title: 'February Milestone', description: 'burnup sample', start_date: '2020-02-11', due_date: '2020-02-28') }
let_it_be(:issue_1) { create(:issue, created_at: '2020-01-30', project: project, milestone: milestone, weight: 2) }
let_it_be(:issue_2) { create(:issue, created_at: '2020-01-30', project: project, milestone: milestone, weight: 3) }
let_it_be(:issue_3) { create(:issue, created_at: '2020-01-30', project: project, milestone: milestone, weight: 2) }
let_it_be(:event1) { create(:resource_milestone_event, issue: issue_1, milestone: milestone, action: 'add', created_at: '2020-01-30') }
let_it_be(:event2) { create(:resource_milestone_event, issue: issue_2, milestone: milestone, action: 'add', created_at: '2020-01-30') }
let_it_be(:event3) { create(:resource_milestone_event, issue: issue_3, milestone: milestone, action: 'add', created_at: '2020-01-30') }
let_it_be(:event4) { create(:resource_milestone_event, issue: issue_2, milestone: milestone, action: 'remove', created_at: '2020-02-06') }
let_it_be(:event5) { create(:resource_milestone_event, issue: issue_3, milestone: other_milestone, action: 'add', created_at: '2020-02-06') }
before do
group.add_developer(user)
sign_in(user)
end
describe 'licensed feature available' do
before do
stub_licensed_features(group_burndown_charts: true)
end
it 'shows burnup chart, with a point per day' do
visit group_milestone_path(milestone.group, milestone)
expect(burnup_chart_points.count).to be(12)
end
end
describe 'licensed feature not available' do
before do
stub_licensed_features(group_burndown_charts: false)
end
it 'does not show burnup chart' do
visit group_milestone_path(milestone.group, milestone)
expect(page).not_to have_selector(burnup_chart_selector)
end
end
describe 'feature flag disabled' do
before do
stub_licensed_features(group_burndown_charts: true)
stub_feature_flags(burnup_charts: false)
end
it 'only shows burndown chart' do
visit group_milestone_path(milestone.group, milestone)
expect(page).to have_selector(burndown_chart_selector)
expect(page).not_to have_selector(burnup_chart_selector)
end
end
def burnup_chart_points
fill_color = "#5772ff"
burnup_chart.all("path[fill='#{fill_color}']", count: 12)
end
def burnup_chart
page.find(burnup_chart_selector)
end
end
import { shallowMount } from '@vue/test-utils';
import { GlLineChart } from '@gitlab/ui/dist/charts';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import BurnupChart from 'ee/burndown_chart/components/burnup_chart.vue';
describe('Burnup chart', () => {
let wrapper;
const defaultProps = {
startDate: '2019-08-07T00:00:00.000Z',
dueDate: '2019-09-09T00:00:00.000Z',
};
const createComponent = (props = {}) => {
wrapper = shallowMount(BurnupChart, {
propsData: {
...defaultProps,
...props,
},
stubs: {
ResizableChartContainer,
},
});
};
it.each`
scope
${[{ '2019-08-07T00:00:00.000Z': 100 }]}
${[{ '2019-08-07T00:00:00.000Z': 100 }, { '2019-08-08T00:00:00.000Z': 99 }, { '2019-09-08T00:00:00.000Z': 1 }]}
`('renders the lineChart correctly', ({ scope }) => {
createComponent({ scope });
const chartData = wrapper.find(GlLineChart).props('data');
expect(chartData).toEqual([
{
name: 'Total',
data: scope,
},
]);
});
});
......@@ -3503,6 +3503,9 @@ msgstr ""
msgid "BurndownChartLabel|Open issues"
msgstr ""
msgid "Burnup chart"
msgstr ""
msgid "Business"
msgstr ""
......
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