Commit 2e6ef7a2 authored by Simon Knox's avatar Simon Knox Committed by Andrew Fontaine

Use burnup data to generate burndown charts

Also add toggle to load old data
We now also render the charts before making the network request
for both cases, which speeds up the rendering of charts
Add a flag to ensure we only fetch burndown data once
parent d187b306
<script>
import { GlAlert, GlButton, GlButtonGroup } from '@gitlab/ui';
import { GlAlert, GlButton, GlButtonGroup, GlSprintf } from '@gitlab/ui';
import dateFormat from 'dateformat';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale';
......@@ -7,6 +7,9 @@ import { getDayDifference, nDaysAfter, newDateAsLocaleTime } from '~/lib/utils/d
import BurndownChart from './burndown_chart.vue';
import BurnupChart from './burnup_chart.vue';
import BurnupQuery from '../queries/burnup.query.graphql';
import BurndownChartData from '../burn_chart_data';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
export default {
components: {
......@@ -15,6 +18,7 @@ export default {
GlButtonGroup,
BurndownChart,
BurnupChart,
GlSprintf,
},
mixins: [glFeatureFlagsMixin()],
props: {
......@@ -26,17 +30,12 @@ export default {
type: String,
required: true,
},
openIssuesCount: {
type: Array,
required: false,
default: () => [],
},
openIssuesWeight: {
type: Array,
milestoneId: {
type: String,
required: false,
default: () => [],
default: '',
},
milestoneId: {
burndownEventsPath: {
type: String,
required: false,
default: '',
......@@ -65,8 +64,12 @@ export default {
},
data() {
return {
openIssuesCount: [],
openIssuesWeight: [],
issuesSelected: true,
burnupData: [],
useLegacyBurndown: !this.glFeatures.burnupCharts,
showInfo: true,
error: '',
};
},
......@@ -80,8 +83,57 @@ export default {
weightButtonCategory() {
return this.issuesSelected ? 'secondary' : 'primary';
},
issuesCount() {
if (this.useLegacyBurndown) {
return this.openIssuesCount;
}
return this.pluckBurnupDataProperties('scopeCount', 'completedCount');
},
issuesWeight() {
if (this.useLegacyBurndown) {
return this.openIssuesWeight;
}
return this.pluckBurnupDataProperties('scopeWeight', 'completedWeight');
},
},
mounted() {
if (!this.glFeatures.burnupCharts) {
this.fetchLegacyBurndownEvents();
}
},
methods: {
fetchLegacyBurndownEvents() {
this.fetchedLegacyData = true;
axios
.get(this.burndownEventsPath)
.then(burndownResponse => {
const burndownEvents = burndownResponse.data;
const burndownChartData = new BurndownChartData(
burndownEvents,
this.startDate,
this.dueDate,
).generateBurndownTimeseries();
this.openIssuesCount = burndownChartData.map(d => [d[0], d[1]]);
this.openIssuesWeight = burndownChartData.map(d => [d[0], d[2]]);
})
.catch(() => {
this.fetchedLegacyData = false;
createFlash(__('Error loading burndown chart data'));
});
},
pluckBurnupDataProperties(total, completed) {
return this.burnupData.map(data => {
return [data.date, data[total] - data[completed]];
});
},
toggleLegacyBurndown(enabled) {
if (!this.fetchedLegacyData) {
this.fetchLegacyBurndownEvents();
}
this.useLegacyBurndown = enabled;
},
setIssueSelected(selected) {
this.issuesSelected = selected;
},
......@@ -164,9 +216,27 @@ export default {
<template>
<div>
<div class="burndown-header d-flex align-items-center">
<gl-alert
v-if="glFeatures.burnupCharts && showInfo"
variant="info"
class="col-12 gl-mt-3"
@dismiss="showInfo = false"
>
<gl-sprintf
:message="
__(
`Burndown charts are now fixed. This means that removing issues from a milestone after it has expired won't affect the chart. You can view the old chart using the %{strongStart}Legacy burndown chart%{strongEnd} button.`,
)
"
>
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</gl-alert>
<div class="burndown-header gl-display-flex gl-align-items-center gl-flex-wrap">
<h3 ref="chartsTitle">{{ title }}</h3>
<gl-button-group class="ml-3 js-burndown-data-selector">
<gl-button-group>
<gl-button
ref="totalIssuesButton"
:category="issueButtonCategory"
......@@ -187,6 +257,27 @@ export default {
{{ __('Issue weight') }}
</gl-button>
</gl-button-group>
<gl-button-group v-if="glFeatures.burnupCharts">
<gl-button
ref="oldBurndown"
:category="useLegacyBurndown ? 'primary' : 'secondary'"
variant="info"
size="small"
@click="toggleLegacyBurndown(true)"
>
{{ __('Legacy burndown chart') }}
</gl-button>
<gl-button
ref="newBurndown"
:category="useLegacyBurndown ? 'secondary' : 'primary'"
variant="info"
size="small"
@click="toggleLegacyBurndown(false)"
>
{{ __('Fixed burndown chart') }}
</gl-button>
</gl-button-group>
</div>
<div v-if="glFeatures.burnupCharts" class="row">
<gl-alert v-if="error" variant="danger" class="col-12" @dismiss="error = ''">
......@@ -195,8 +286,8 @@ export default {
<burndown-chart
:start-date="startDate"
:due-date="dueDate"
:open-issues-count="openIssuesCount"
:open-issues-weight="openIssuesWeight"
:open-issues-count="issuesCount"
:open-issues-weight="issuesWeight"
:issues-selected="issuesSelected"
class="col-md-6"
/>
......
......@@ -2,12 +2,9 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo';
import $ from 'jquery';
import Cookies from 'js-cookie';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import createDefaultClient from '~/lib/graphql';
import BurnCharts from './components/burn_charts.vue';
import BurndownChartData from './burn_chart_data';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
Vue.use(VueApollo);
......@@ -33,40 +30,24 @@ export default () => {
const milestoneId = $chartEl.data('milestoneId');
const burndownEventsPath = $chartEl.data('burndownEventsPath');
axios
.get(burndownEventsPath)
.then(burndownResponse => {
const burndownEvents = burndownResponse.data;
const burndownChartData = new BurndownChartData(
burndownEvents,
startDate,
dueDate,
).generateBurndownTimeseries();
const openIssuesCount = burndownChartData.map(d => [d[0], d[1]]);
const openIssuesWeight = burndownChartData.map(d => [d[0], d[2]]);
return new Vue({
el: container,
components: {
BurnCharts,
},
apolloProvider,
render(createElement) {
return createElement('burn-charts', {
props: {
startDate,
dueDate,
openIssuesCount,
openIssuesWeight,
milestoneId,
},
});
// eslint-disable-next-line no-new
new Vue({
el: container,
components: {
BurnCharts,
},
mixins: [glFeatureFlagsMixin()],
apolloProvider,
render(createElement) {
return createElement('burn-charts', {
props: {
burndownEventsPath,
startDate,
dueDate,
milestoneId,
},
});
})
.catch(() => {
createFlash(__('Error loading burndown chart data'));
});
},
});
}
};
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import BurnCharts from 'ee/burndown_chart/components/burn_charts.vue';
import BurndownChart from 'ee/burndown_chart/components/burndown_chart.vue';
import BurnupChart from 'ee/burndown_chart/components/burnup_chart.vue';
import { useFakeDate } from 'helpers/fake_date';
import { day1, day2, day3, day4 } from '../mock_data';
import waitForPromises from 'helpers/wait_for_promises';
import { day1, day2, day3, day4, legacyBurndownEvents } from '../mock_data';
function fakeDate({ date }) {
const [year, month, day] = date.split('-');
useFakeDate(year, month - 1, day);
}
describe('burndown_chart', () => {
let wrapper;
let mock;
const findChartsTitle = () => wrapper.find({ ref: 'chartsTitle' });
const findIssuesButton = () => wrapper.find({ ref: 'totalIssuesButton' });
......@@ -16,26 +26,40 @@ describe('burndown_chart', () => {
wrapper.findAll(GlButton).filter(button => button.attributes().category === 'primary');
const findBurndownChart = () => wrapper.find(BurndownChart);
const findBurnupChart = () => wrapper.find(BurnupChart);
const findOldBurndownChartButton = () => wrapper.find({ ref: 'oldBurndown' });
const findNewBurndownChartButton = () => wrapper.find({ ref: 'newBurndown' });
const defaultProps = {
startDate: '2019-08-07',
dueDate: '2019-09-09',
startDate: '2020-08-07',
dueDate: '2020-09-09',
openIssuesCount: [],
openIssuesWeight: [],
burndownEventsPath: '/api/v4/projects/1234/milestones/1/burndown_events',
};
const createComponent = ({ props = {}, featureEnabled = false } = {}) => {
const createComponent = ({ props = {}, featureEnabled = false, data = {} } = {}) => {
wrapper = shallowMount(BurnCharts, {
propsData: {
...defaultProps,
...props,
},
data() {
return data;
},
provide: {
glFeatures: { burnupCharts: featureEnabled },
},
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
it('includes Issues and Issue weight buttons', () => {
createComponent();
......@@ -73,9 +97,34 @@ describe('burndown_chart', () => {
describe('feature disabled', () => {
beforeEach(() => {
fakeDate(day4);
mock.onGet(defaultProps.burndownEventsPath).reply(200, legacyBurndownEvents);
createComponent({ featureEnabled: false });
});
it('calls fetchLegacyBurndownEvents when mounted', async () => {
await waitForPromises();
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(findBurndownChart().props().openIssuesCount).toEqual([
[defaultProps.startDate, 0],
[day1.date, 1],
[day2.date, 2],
[day3.date, 3],
[day4.date, 2],
]);
expect(findBurndownChart().props().openIssuesWeight).toEqual([
[defaultProps.startDate, 0],
[day1.date, 2],
[day2.date, 3],
[day3.date, 4],
[day4.date, 2],
]);
});
it('does not reduce width of burndown chart', () => {
expect(findBurndownChart().classes()).toEqual([]);
});
......@@ -84,6 +133,33 @@ describe('burndown_chart', () => {
expect(findChartsTitle().text()).toBe('Burndown chart');
expect(findBurndownChart().props().showTitle).toBe(false);
});
it('does not show old/new burndown buttons', () => {
expect(findOldBurndownChartButton().exists()).toBe(false);
expect(findNewBurndownChartButton().exists()).toBe(false);
});
it('uses count and weight from data', () => {
const expectedCount = [day2.date, day2.scopeCount];
const expectedWeight = [day2.date, day2.scopeWeight];
createComponent({
data: {
burnupData: [day1],
openIssuesCount: [expectedCount],
openIssuesWeight: [expectedWeight],
},
props: {
milestoneId: '1234',
},
featureEnabled: false,
});
const { openIssuesCount, openIssuesWeight } = findBurndownChart().props();
expect(openIssuesCount).toEqual([expectedCount]);
expect(openIssuesWeight).toEqual([expectedWeight]);
});
});
describe('feature enabled', () => {
......@@ -107,16 +183,42 @@ describe('burndown_chart', () => {
expect(findBurnupChart().props('issuesSelected')).toBe(false);
});
it('shows old/new burndown buttons', () => {
expect(findOldBurndownChartButton().exists()).toBe(true);
expect(findNewBurndownChartButton().exists()).toBe(true);
});
it('uses burndown data computed from burnup data', () => {
createComponent({
data: {
burnupData: [day1],
},
featureEnabled: true,
});
const { openIssuesCount, openIssuesWeight } = findBurndownChart().props();
const expectedCount = [day1.date, day1.scopeCount - day1.completedCount];
const expectedWeight = [day1.date, day1.scopeWeight - day1.completedWeight];
expect(openIssuesCount).toEqual([expectedCount]);
expect(openIssuesWeight).toEqual([expectedWeight]);
});
it('calls fetchLegacyBurndownEvents, but only once', () => {
jest.spyOn(wrapper.vm, 'fetchLegacyBurndownEvents');
mock.onGet(defaultProps.burndownEventsPath).reply(200, []);
findOldBurndownChartButton().vm.$emit('click');
findOldBurndownChartButton().vm.$emit('click');
expect(wrapper.vm.fetchLegacyBurndownEvents).toHaveBeenCalledTimes(1);
});
});
// some separate tests for the update function since it has a bunch of logic
describe('padSparseBurnupData function', () => {
function fakeDate({ date }) {
const [year, month, day] = date.split('-');
useFakeDate(year, month - 1, day);
}
beforeEach(() => {
createComponent({
props: { startDate: day1.date, dueDate: day4.date },
......
......@@ -29,3 +29,26 @@ export const day4 = {
scopeCount: 11,
scopeWeight: 22,
};
export const legacyBurndownEvents = [
{
action: 'created',
created_at: day1.date,
weight: 2,
},
{
action: 'created',
created_at: day2.date,
weight: 1,
},
{
action: 'created',
created_at: day3.date,
weight: 1,
},
{
action: 'closed',
created_at: day4.date,
weight: 2,
},
];
......@@ -4338,6 +4338,9 @@ msgstr ""
msgid "Burndown chart"
msgstr ""
msgid "Burndown charts are now fixed. This means that removing issues from a milestone after it has expired won't affect the chart. You can view the old chart using the %{strongStart}Legacy burndown chart%{strongEnd} button."
msgstr ""
msgid "BurndownChartLabel|Open issue weight"
msgstr ""
......@@ -11290,6 +11293,9 @@ msgstr ""
msgid "First seen"
msgstr ""
msgid "Fixed burndown chart"
msgstr ""
msgid "Fixed date"
msgstr ""
......@@ -14896,6 +14902,9 @@ msgstr ""
msgid "Leave zen mode"
msgstr ""
msgid "Legacy burndown chart"
msgstr ""
msgid "Let's Encrypt does not accept emails on example.com"
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