Commit 61616728 authored by Kushal Pandya's avatar Kushal Pandya

Merge branch 'psi-burnup' into 'master'

Add burnup chart FE

See merge request gitlab-org/gitlab!29652
parents 54f0fd8c b281d49c
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 dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import timezoneMock from 'timezone-mock'; import timezoneMock from 'timezone-mock';
import BurndownChartData from 'ee/burndown_chart/burndown_chart_data'; import BurndownChartData from 'ee/burndown_chart/burn_chart_data';
describe('BurndownChartData', () => { describe('BurndownChartData', () => {
const startDate = '2017-03-01'; const startDate = '2017-03-01';
const dueDate = '2017-03-03'; const dueDate = '2017-03-03';
const milestoneEvents = [ const issueStateEvents = [
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' }, { created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' }, { created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'created' }, { created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'created' },
...@@ -16,15 +16,15 @@ describe('BurndownChartData', () => { ...@@ -16,15 +16,15 @@ describe('BurndownChartData', () => {
{ created_at: '2017-03-03T00:00:00.000Z', weight: 2, action: 'reopened' }, { created_at: '2017-03-03T00:00:00.000Z', weight: 2, action: 'reopened' },
]; ];
let burndownChartData; describe('generateBurndownTimeseries', () => {
let burndownChartData;
beforeEach(() => { beforeEach(() => {
burndownChartData = new BurndownChartData(milestoneEvents, startDate, dueDate); burndownChartData = new BurndownChartData(issueStateEvents, startDate, dueDate);
}); });
describe('generate', () => {
it('generates an array of arrays with date, issue count and weight', () => { it('generates an array of arrays with date, issue count and weight', () => {
expect(burndownChartData.generate()).toEqual([ expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 2, 4], ['2017-03-01', 2, 4],
['2017-03-02', 1, 2], ['2017-03-02', 1, 2],
['2017-03-03', 3, 6], ['2017-03-03', 3, 6],
...@@ -41,7 +41,7 @@ describe('BurndownChartData', () => { ...@@ -41,7 +41,7 @@ describe('BurndownChartData', () => {
}); });
it('has the right start and end dates', () => { it('has the right start and end dates', () => {
expect(burndownChartData.generate()).toEqual([ expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 1, 2], ['2017-03-01', 1, 2],
['2017-03-02', 3, 6], ['2017-03-02', 3, 6],
['2017-03-03', 3, 6], ['2017-03-03', 3, 6],
...@@ -51,7 +51,7 @@ describe('BurndownChartData', () => { ...@@ -51,7 +51,7 @@ describe('BurndownChartData', () => {
describe('when issues are created before start date', () => { describe('when issues are created before start date', () => {
beforeAll(() => { beforeAll(() => {
milestoneEvents.push({ issueStateEvents.push({
created_at: '2017-02-28T00:00:00.000Z', created_at: '2017-02-28T00:00:00.000Z',
weight: 2, weight: 2,
action: 'created', action: 'created',
...@@ -59,7 +59,7 @@ describe('BurndownChartData', () => { ...@@ -59,7 +59,7 @@ describe('BurndownChartData', () => {
}); });
it('generates an array of arrays with date, issue count and weight', () => { it('generates an array of arrays with date, issue count and weight', () => {
expect(burndownChartData.generate()).toEqual([ expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 3, 6], ['2017-03-01', 3, 6],
['2017-03-02', 2, 4], ['2017-03-02', 2, 4],
['2017-03-03', 4, 8], ['2017-03-03', 4, 8],
...@@ -80,7 +80,7 @@ describe('BurndownChartData', () => { ...@@ -80,7 +80,7 @@ describe('BurndownChartData', () => {
}); });
it('counts until today if milestone due date > date today', () => { it('counts until today if milestone due date > date today', () => {
const chartData = burndownChartData.generate(); const chartData = burndownChartData.generateBurndownTimeseries();
expect(dateFormat(Date.now(), 'yyyy-mm-dd')).toEqual('2017-03-02'); expect(dateFormat(Date.now(), 'yyyy-mm-dd')).toEqual('2017-03-02');
expect(chartData[chartData.length - 1][0]).toEqual('2017-03-02'); expect(chartData[chartData.length - 1][0]).toEqual('2017-03-02');
...@@ -90,8 +90,8 @@ describe('BurndownChartData', () => { ...@@ -90,8 +90,8 @@ describe('BurndownChartData', () => {
describe('when days in milestone have negative counts', () => { describe('when days in milestone have negative counts', () => {
describe('and the first two days have a negative count', () => { describe('and the first two days have a negative count', () => {
beforeAll(() => { beforeAll(() => {
milestoneEvents.length = 0; issueStateEvents.length = 0;
milestoneEvents.push( issueStateEvents.push(
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'closed' }, { created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'closed' }, { created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'created' }, { created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'created' },
...@@ -101,7 +101,7 @@ describe('BurndownChartData', () => { ...@@ -101,7 +101,7 @@ describe('BurndownChartData', () => {
}); });
it('generates an array where the first two days counts are zero', () => { it('generates an array where the first two days counts are zero', () => {
expect(burndownChartData.generate()).toEqual([ expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 0, 0], ['2017-03-01', 0, 0],
['2017-03-02', 0, 0], ['2017-03-02', 0, 0],
['2017-03-03', 1, 2], ['2017-03-03', 1, 2],
...@@ -115,8 +115,8 @@ describe('BurndownChartData', () => { ...@@ -115,8 +115,8 @@ describe('BurndownChartData', () => {
// potential edge case. // potential edge case.
beforeAll(() => { beforeAll(() => {
milestoneEvents.length = 0; issueStateEvents.length = 0;
milestoneEvents.push( issueStateEvents.push(
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' }, { created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'closed' }, { created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'closed' }, { created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'closed' },
...@@ -126,7 +126,7 @@ describe('BurndownChartData', () => { ...@@ -126,7 +126,7 @@ describe('BurndownChartData', () => {
}); });
it('generates an array where the middle day count is zero', () => { it('generates an array where the middle day count is zero', () => {
expect(burndownChartData.generate()).toEqual([ expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 1, 2], ['2017-03-01', 1, 2],
['2017-03-02', 0, 0], ['2017-03-02', 0, 0],
['2017-03-03', 1, 2], ['2017-03-03', 1, 2],
...@@ -140,8 +140,8 @@ describe('BurndownChartData', () => { ...@@ -140,8 +140,8 @@ describe('BurndownChartData', () => {
// potential edge case. // potential edge case.
beforeAll(() => { beforeAll(() => {
milestoneEvents.length = 0; issueStateEvents.length = 0;
milestoneEvents.push( issueStateEvents.push(
{ created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'closed' }, { created_at: '2017-03-01T00:00:00.000Z', weight: 2, action: 'closed' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'created' }, { created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'created' },
{ created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'closed' }, { created_at: '2017-03-02T00:00:00.000Z', weight: 2, action: 'closed' },
...@@ -151,7 +151,7 @@ describe('BurndownChartData', () => { ...@@ -151,7 +151,7 @@ describe('BurndownChartData', () => {
}); });
it('generates an array where all counts are zero', () => { it('generates an array where all counts are zero', () => {
expect(burndownChartData.generate()).toEqual([ expect(burndownChartData.generateBurndownTimeseries()).toEqual([
['2017-03-01', 0, 0], ['2017-03-01', 0, 0],
['2017-03-02', 0, 0], ['2017-03-02', 0, 0],
['2017-03-03', 0, 0], ['2017-03-03', 0, 0],
...@@ -160,4 +160,192 @@ describe('BurndownChartData', () => { ...@@ -160,4 +160,192 @@ describe('BurndownChartData', () => {
}); });
}); });
}); });
describe('generateBurnupTimeseries', () => {
const milestoneId = 400;
const milestoneEvents = [
// day 1: add two issues to the milestone
{
action: 'add',
created_at: '2017-03-01T00:00:00.000Z',
event_type: 'milestone',
issue_id: 1,
milestone_id: milestoneId,
weight: null,
},
{
action: 'add',
created_at: '2017-03-01T00:00:00.000Z',
event_type: 'milestone',
issue_id: 2,
milestone_id: milestoneId,
weight: null,
},
// day 2: remove both issues we added yesterday, add a different issue
{
action: 'remove',
created_at: '2017-03-02T00:00:00.000Z',
event_type: 'milestone',
issue_id: 2,
milestone_id: milestoneId,
weight: null,
},
{
action: 'add',
created_at: '2017-03-02T00:00:00.000Z',
event_type: 'milestone',
issue_id: 3,
milestone_id: milestoneId,
weight: null,
},
{
action: 'remove',
created_at: '2017-03-02T00:00:00.000Z',
event_type: 'milestone',
issue_id: 1,
milestone_id: milestoneId,
weight: null,
},
// day 3: remove yesterday's issue, also remove an issue that didn't have an `add` event
{
action: 'remove',
created_at: '2017-03-03T00:00:00.000Z',
event_type: 'milestone',
issue_id: 2,
milestone_id: milestoneId,
weight: null,
},
{
action: 'remove',
created_at: '2017-03-03T00:00:00.000Z',
event_type: 'milestone',
issue_id: 4,
milestone_id: milestoneId,
weight: null,
},
];
const burndownChartData = (events = milestoneEvents) => {
return new BurndownChartData(events, startDate, dueDate);
};
it('generates an array of arrays with date and issue count', () => {
const { burnupScope } = burndownChartData().generateBurnupTimeseries({ milestoneId });
expect(burnupScope).toEqual([['2017-03-01', 2], ['2017-03-02', 1], ['2017-03-03', 0]]);
});
it('starts from 0', () => {
const { burnupScope } = burndownChartData([]).generateBurnupTimeseries({
milestoneId,
});
expect(burnupScope[0]).toEqual(['2017-03-01', 0], ['2017-03-01', 0], ['2017-03-01', 0]);
});
it('does not go below zero with extra remove events', () => {
const { burnupScope } = burndownChartData([
{
action: 'remove',
created_at: '2017-03-02T00:00:00.000Z',
event_type: 'milestone',
issue_id: 2,
milestone_id: milestoneId,
weight: null,
},
{
action: 'remove',
created_at: '2017-03-02T00:00:00.000Z',
event_type: 'milestone',
issue_id: 1,
milestone_id: milestoneId,
weight: null,
},
]).generateBurnupTimeseries({
milestoneId,
});
expect(burnupScope).toEqual([['2017-03-01', 0], ['2017-03-02', 0], ['2017-03-03', 0]]);
});
it('ignores removed from other milestones', () => {
const differentMilestoneId = 600;
const events = [
{
created_at: '2017-03-01T00:00:00.000Z',
action: 'add',
event_type: 'milestone',
milestone_id: milestoneId,
issue_id: 1,
},
{
created_at: '2017-03-01T00:00:00.000Z',
action: 'remove',
event_type: 'milestone',
milestone_id: differentMilestoneId,
issue_id: 1,
},
];
const { burnupScope } = burndownChartData(events).generateBurnupTimeseries({ milestoneId });
expect(burnupScope).toEqual([['2017-03-01', 1], ['2017-03-02', 1], ['2017-03-03', 1]]);
});
it('only adds milestone event_type', () => {
const events = [
{
created_at: '2017-03-01T00:00:00.000Z',
action: 'add',
event_type: 'weight',
milestone_id: milestoneId,
issue_id: 1,
weight: 2,
},
{
created_at: '2017-03-02T00:00:00.000Z',
action: 'add',
event_type: 'milestone',
milestone_id: milestoneId,
issue_id: 1,
weight: null,
},
];
const { burnupScope } = burndownChartData(events).generateBurnupTimeseries({ milestoneId });
expect(burnupScope).toEqual([['2017-03-01', 0], ['2017-03-02', 1], ['2017-03-03', 1]]);
});
it('only removes milestone event_type', () => {
const events = [
{
created_at: '2017-03-01T00:00:00.000Z',
action: 'add',
event_type: 'milestone',
milestone_id: milestoneId,
issue_id: 1,
},
{
created_at: '2017-03-02T00:00:00.000Z',
action: 'remove',
event_type: 'weight',
milestone_id: milestoneId,
issue_id: 1,
weight: 2,
},
{
created_at: '2017-03-03T00:00:00.000Z',
action: 'remove',
event_type: 'milestone',
milestone_id: milestoneId,
issue_id: 1,
},
];
const { burnupScope } = burndownChartData(events).generateBurnupTimeseries({ milestoneId });
expect(burnupScope).toEqual([['2017-03-01', 1], ['2017-03-02', 1], ['2017-03-03', 0]]);
});
});
}); });
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,
},
]);
});
});
...@@ -3506,6 +3506,9 @@ msgstr "" ...@@ -3506,6 +3506,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