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'; import dateFormat from 'dateformat';
export default class BurndownChartData { export default class BurnChartData {
constructor(burndownEvents, startDate, dueDate) { constructor(events, startDate, dueDate) {
this.dateFormatMask = 'yyyy-mm-dd'; this.dateFormatMask = 'yyyy-mm-dd';
this.startDate = startDate; this.startDate = startDate;
this.dueDate = dueDate; this.dueDate = dueDate;
this.burndownEvents = this.processRawEvents(burndownEvents);
// determine when to stop burndown chart // determine when to stop burndown chart
const today = dateFormat(Date.now(), this.dateFormatMask); const today = dateFormat(Date.now(), this.dateFormatMask);
...@@ -15,11 +14,63 @@ export default class BurndownChartData { ...@@ -15,11 +14,63 @@ export default class BurndownChartData {
// and dateFormat() both convert the date at midnight UTC to the browser's // and dateFormat() both convert the date at midnight UTC to the browser's
// timezone, leading to incorrect chart start and end points. Using // 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. // 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.localStartDate = new Date(`${this.startDate}T00:00:00`);
this.localEndDate = new Date(`${this.endDate}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 openIssuesCount = 0;
let openIssuesWeight = 0; let openIssuesWeight = 0;
...@@ -87,7 +138,7 @@ export default class BurndownChartData { ...@@ -87,7 +138,7 @@ export default class BurndownChartData {
} }
filterAndSummarizeBurndownEvents(filter) { filterAndSummarizeBurndownEvents(filter) {
const issues = this.burndownEvents.filter(filter); const issues = this.events.filter(filter);
return { return {
count: issues.length, count: issues.length,
......
...@@ -3,12 +3,14 @@ import { GlButton, GlButtonGroup } from '@gitlab/ui'; ...@@ -3,12 +3,14 @@ import { GlButton, GlButtonGroup } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale'; import { __ } from '~/locale';
import BurndownChart from './burndown_chart.vue'; import BurndownChart from './burndown_chart.vue';
import BurnupChart from './burnup_chart.vue';
export default { export default {
components: { components: {
GlButton, GlButton,
GlButtonGroup, GlButtonGroup,
BurndownChart, BurndownChart,
BurnupChart,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
props: { props: {
...@@ -30,6 +32,11 @@ export default { ...@@ -30,6 +32,11 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
burnupScope: {
type: Array,
required: false,
default: () => [],
},
}, },
data() { data() {
return { return {
...@@ -90,6 +97,12 @@ export default { ...@@ -90,6 +97,12 @@ export default {
:issues-selected="issuesSelected" :issues-selected="issuesSelected"
class="col-md-6" class="col-md-6"
/> />
<burnup-chart
:start-date="startDate"
:due-date="dueDate"
:scope="burnupScope"
class="col-md-6"
/>
</div> </div>
<burndown-chart <burndown-chart
v-else v-else
......
...@@ -63,13 +63,13 @@ export default { ...@@ -63,13 +63,13 @@ export default {
const series = [ const series = [
{ {
name, name,
data: data.map(d => [new Date(d[0]), d[1]]), data,
}, },
]; ];
if (series[0] && series[0].data.length >= 2) { if (series[0] && series[0].data.length >= 2) {
const idealStart = [new Date(this.startDate), data[0][1]]; const idealStart = [this.startDate, data[0][1]];
const idealEnd = [new Date(this.dueDate), 0]; const idealEnd = [this.dueDate, 0];
const idealData = [idealStart, idealEnd]; const idealData = [idealStart, idealEnd];
series.push({ series.push({
...@@ -91,6 +91,8 @@ export default { ...@@ -91,6 +91,8 @@ export default {
xAxis: { xAxis: {
name: '', name: '',
type: 'time', type: 'time',
min: this.startDate,
max: this.dueDate,
axisLine: { axisLine: {
show: true, show: true,
}, },
...@@ -141,7 +143,7 @@ export default { ...@@ -141,7 +143,7 @@ export default {
<div v-if="showTitle" class="burndown-header d-flex align-items-center"> <div v-if="showTitle" class="burndown-header d-flex align-items-center">
<h3>{{ __('Burndown chart') }}</h3> <h3>{{ __('Burndown chart') }}</h3>
</div> </div>
<resizable-chart-container class="burndown-chart"> <resizable-chart-container class="burndown-chart js-burndown-chart">
<gl-line-chart <gl-line-chart
slot-scope="{ width }" slot-scope="{ width }"
:width="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'; ...@@ -2,8 +2,8 @@ import Vue from 'vue';
import $ from 'jquery'; import $ from 'jquery';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import BurnCharts from './components/burn_charts.vue'; import BurnCharts from './components/burn_charts.vue';
import BurndownChartData from './burndown_chart_data'; import BurndownChartData from './burn_chart_data';
import Flash from '~/flash'; import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale'; import { __ } from '~/locale';
...@@ -22,16 +22,34 @@ export default () => { ...@@ -22,16 +22,34 @@ export default () => {
if ($chartEl.length) { if ($chartEl.length) {
const startDate = $chartEl.data('startDate'); const startDate = $chartEl.data('startDate');
const dueDate = $chartEl.data('dueDate'); const dueDate = $chartEl.data('dueDate');
const milestoneId = $chartEl.data('milestoneId');
const burndownEventsPath = $chartEl.data('burndownEventsPath'); const burndownEventsPath = $chartEl.data('burndownEventsPath');
const burnupEventsPath = $chartEl.data('burnupEventsPath');
axios const fetchData = [axios.get(burndownEventsPath)];
.get(burndownEventsPath)
.then(response => {
const burndownEvents = response.data;
const chartData = new BurndownChartData(burndownEvents, startDate, dueDate).generate();
const openIssuesCount = chartData.map(d => [d[0], d[1]]); if (gon.features.burnupCharts) {
const openIssuesWeight = chartData.map(d => [d[0], d[2]]); 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({ return new Vue({
el: container, el: container,
...@@ -45,11 +63,14 @@ export default () => { ...@@ -45,11 +63,14 @@ export default () => {
dueDate, dueDate,
openIssuesCount, openIssuesCount,
openIssuesWeight, openIssuesWeight,
burnupScope,
}, },
}); });
}, },
}); });
}) })
.catch(() => new Flash(__('Error loading burndown chart data'))); .catch(() => {
createFlash(__('Error loading burndown chart data'));
});
} }
}; };
...@@ -2,13 +2,15 @@ ...@@ -2,13 +2,15 @@
- burndown = burndown_chart(milestone) - burndown = burndown_chart(milestone)
- warning = data_warning_for(burndown) - 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) - 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 = warning
- if can_generate_chart?(milestone, burndown) - if can_generate_chart?(milestone, burndown)
.burndown-chart.mb-2{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"), .burndown-chart.mb-2{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"),
due_date: burndown.due_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) - elsif show_burndown_placeholder?(milestone, warning)
.burndown-hint.content-block.container-fluid .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 "" ...@@ -3503,6 +3503,9 @@ msgstr ""
msgid "BurndownChartLabel|Open issues" msgid "BurndownChartLabel|Open issues"
msgstr "" msgstr ""
msgid "Burnup chart"
msgstr ""
msgid "Business" msgid "Business"
msgstr "" 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