Commit 698f21e8 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch 'psi-burnup-burndown' into 'master'

Use burnup data to generate burndown charts

See merge request gitlab-org/gitlab!42314
parents 908e9be2 2e6ef7a2
<script> <script>
import { GlAlert, GlButton, GlButtonGroup } from '@gitlab/ui'; import { GlAlert, GlButton, GlButtonGroup, GlSprintf } from '@gitlab/ui';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
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';
...@@ -7,6 +7,9 @@ import { getDayDifference, nDaysAfter, newDateAsLocaleTime } from '~/lib/utils/d ...@@ -7,6 +7,9 @@ import { getDayDifference, nDaysAfter, newDateAsLocaleTime } from '~/lib/utils/d
import BurndownChart from './burndown_chart.vue'; import BurndownChart from './burndown_chart.vue';
import BurnupChart from './burnup_chart.vue'; import BurnupChart from './burnup_chart.vue';
import BurnupQuery from '../queries/burnup.query.graphql'; 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 { export default {
components: { components: {
...@@ -15,6 +18,7 @@ export default { ...@@ -15,6 +18,7 @@ export default {
GlButtonGroup, GlButtonGroup,
BurndownChart, BurndownChart,
BurnupChart, BurnupChart,
GlSprintf,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
props: { props: {
...@@ -26,17 +30,12 @@ export default { ...@@ -26,17 +30,12 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
openIssuesCount: { milestoneId: {
type: Array, type: String,
required: false,
default: () => [],
},
openIssuesWeight: {
type: Array,
required: false, required: false,
default: () => [], default: '',
}, },
milestoneId: { burndownEventsPath: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
...@@ -65,8 +64,12 @@ export default { ...@@ -65,8 +64,12 @@ export default {
}, },
data() { data() {
return { return {
openIssuesCount: [],
openIssuesWeight: [],
issuesSelected: true, issuesSelected: true,
burnupData: [], burnupData: [],
useLegacyBurndown: !this.glFeatures.burnupCharts,
showInfo: true,
error: '', error: '',
}; };
}, },
...@@ -80,8 +83,57 @@ export default { ...@@ -80,8 +83,57 @@ export default {
weightButtonCategory() { weightButtonCategory() {
return this.issuesSelected ? 'secondary' : 'primary'; 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: { 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) { setIssueSelected(selected) {
this.issuesSelected = selected; this.issuesSelected = selected;
}, },
...@@ -164,9 +216,27 @@ export default { ...@@ -164,9 +216,27 @@ export default {
<template> <template>
<div> <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> <h3 ref="chartsTitle">{{ title }}</h3>
<gl-button-group class="ml-3 js-burndown-data-selector"> <gl-button-group>
<gl-button <gl-button
ref="totalIssuesButton" ref="totalIssuesButton"
:category="issueButtonCategory" :category="issueButtonCategory"
...@@ -187,6 +257,27 @@ export default { ...@@ -187,6 +257,27 @@ export default {
{{ __('Issue weight') }} {{ __('Issue weight') }}
</gl-button> </gl-button>
</gl-button-group> </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>
<div v-if="glFeatures.burnupCharts" class="row"> <div v-if="glFeatures.burnupCharts" class="row">
<gl-alert v-if="error" variant="danger" class="col-12" @dismiss="error = ''"> <gl-alert v-if="error" variant="danger" class="col-12" @dismiss="error = ''">
...@@ -195,8 +286,8 @@ export default { ...@@ -195,8 +286,8 @@ export default {
<burndown-chart <burndown-chart
:start-date="startDate" :start-date="startDate"
:due-date="dueDate" :due-date="dueDate"
:open-issues-count="openIssuesCount" :open-issues-count="issuesCount"
:open-issues-weight="openIssuesWeight" :open-issues-weight="issuesWeight"
:issues-selected="issuesSelected" :issues-selected="issuesSelected"
class="col-md-6" class="col-md-6"
/> />
......
...@@ -2,12 +2,9 @@ import Vue from 'vue'; ...@@ -2,12 +2,9 @@ import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import $ from 'jquery'; import $ from 'jquery';
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import BurnCharts from './components/burn_charts.vue'; 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); Vue.use(VueApollo);
...@@ -33,40 +30,24 @@ export default () => { ...@@ -33,40 +30,24 @@ export default () => {
const milestoneId = $chartEl.data('milestoneId'); const milestoneId = $chartEl.data('milestoneId');
const burndownEventsPath = $chartEl.data('burndownEventsPath'); const burndownEventsPath = $chartEl.data('burndownEventsPath');
axios // eslint-disable-next-line no-new
.get(burndownEventsPath) new Vue({
.then(burndownResponse => { el: container,
const burndownEvents = burndownResponse.data; components: {
const burndownChartData = new BurndownChartData( BurnCharts,
burndownEvents, },
startDate, mixins: [glFeatureFlagsMixin()],
dueDate, apolloProvider,
).generateBurndownTimeseries(); render(createElement) {
return createElement('burn-charts', {
const openIssuesCount = burndownChartData.map(d => [d[0], d[1]]); props: {
const openIssuesWeight = burndownChartData.map(d => [d[0], d[2]]); burndownEventsPath,
startDate,
return new Vue({ dueDate,
el: container, milestoneId,
components: {
BurnCharts,
},
apolloProvider,
render(createElement) {
return createElement('burn-charts', {
props: {
startDate,
dueDate,
openIssuesCount,
openIssuesWeight,
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 { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui'; import { GlButton } from '@gitlab/ui';
import BurnCharts from 'ee/burndown_chart/components/burn_charts.vue'; import BurnCharts from 'ee/burndown_chart/components/burn_charts.vue';
import BurndownChart from 'ee/burndown_chart/components/burndown_chart.vue'; import BurndownChart from 'ee/burndown_chart/components/burndown_chart.vue';
import BurnupChart from 'ee/burndown_chart/components/burnup_chart.vue'; import BurnupChart from 'ee/burndown_chart/components/burnup_chart.vue';
import { useFakeDate } from 'helpers/fake_date'; 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', () => { describe('burndown_chart', () => {
let wrapper; let wrapper;
let mock;
const findChartsTitle = () => wrapper.find({ ref: 'chartsTitle' }); const findChartsTitle = () => wrapper.find({ ref: 'chartsTitle' });
const findIssuesButton = () => wrapper.find({ ref: 'totalIssuesButton' }); const findIssuesButton = () => wrapper.find({ ref: 'totalIssuesButton' });
...@@ -16,26 +26,40 @@ describe('burndown_chart', () => { ...@@ -16,26 +26,40 @@ describe('burndown_chart', () => {
wrapper.findAll(GlButton).filter(button => button.attributes().category === 'primary'); wrapper.findAll(GlButton).filter(button => button.attributes().category === 'primary');
const findBurndownChart = () => wrapper.find(BurndownChart); const findBurndownChart = () => wrapper.find(BurndownChart);
const findBurnupChart = () => wrapper.find(BurnupChart); const findBurnupChart = () => wrapper.find(BurnupChart);
const findOldBurndownChartButton = () => wrapper.find({ ref: 'oldBurndown' });
const findNewBurndownChartButton = () => wrapper.find({ ref: 'newBurndown' });
const defaultProps = { const defaultProps = {
startDate: '2019-08-07', startDate: '2020-08-07',
dueDate: '2019-09-09', dueDate: '2020-09-09',
openIssuesCount: [], openIssuesCount: [],
openIssuesWeight: [], openIssuesWeight: [],
burndownEventsPath: '/api/v4/projects/1234/milestones/1/burndown_events',
}; };
const createComponent = ({ props = {}, featureEnabled = false } = {}) => { const createComponent = ({ props = {}, featureEnabled = false, data = {} } = {}) => {
wrapper = shallowMount(BurnCharts, { wrapper = shallowMount(BurnCharts, {
propsData: { propsData: {
...defaultProps, ...defaultProps,
...props, ...props,
}, },
data() {
return data;
},
provide: { provide: {
glFeatures: { burnupCharts: featureEnabled }, glFeatures: { burnupCharts: featureEnabled },
}, },
}); });
}; };
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
it('includes Issues and Issue weight buttons', () => { it('includes Issues and Issue weight buttons', () => {
createComponent(); createComponent();
...@@ -73,9 +97,34 @@ describe('burndown_chart', () => { ...@@ -73,9 +97,34 @@ describe('burndown_chart', () => {
describe('feature disabled', () => { describe('feature disabled', () => {
beforeEach(() => { beforeEach(() => {
fakeDate(day4);
mock.onGet(defaultProps.burndownEventsPath).reply(200, legacyBurndownEvents);
createComponent({ featureEnabled: false }); 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', () => { it('does not reduce width of burndown chart', () => {
expect(findBurndownChart().classes()).toEqual([]); expect(findBurndownChart().classes()).toEqual([]);
}); });
...@@ -84,6 +133,33 @@ describe('burndown_chart', () => { ...@@ -84,6 +133,33 @@ describe('burndown_chart', () => {
expect(findChartsTitle().text()).toBe('Burndown chart'); expect(findChartsTitle().text()).toBe('Burndown chart');
expect(findBurndownChart().props().showTitle).toBe(false); 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', () => { describe('feature enabled', () => {
...@@ -107,16 +183,42 @@ describe('burndown_chart', () => { ...@@ -107,16 +183,42 @@ describe('burndown_chart', () => {
expect(findBurnupChart().props('issuesSelected')).toBe(false); 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 // some separate tests for the update function since it has a bunch of logic
describe('padSparseBurnupData function', () => { describe('padSparseBurnupData function', () => {
function fakeDate({ date }) {
const [year, month, day] = date.split('-');
useFakeDate(year, month - 1, day);
}
beforeEach(() => { beforeEach(() => {
createComponent({ createComponent({
props: { startDate: day1.date, dueDate: day4.date }, props: { startDate: day1.date, dueDate: day4.date },
......
...@@ -29,3 +29,26 @@ export const day4 = { ...@@ -29,3 +29,26 @@ export const day4 = {
scopeCount: 11, scopeCount: 11,
scopeWeight: 22, 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 "" ...@@ -4338,6 +4338,9 @@ msgstr ""
msgid "Burndown chart" msgid "Burndown chart"
msgstr "" 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" msgid "BurndownChartLabel|Open issue weight"
msgstr "" msgstr ""
...@@ -11347,6 +11350,9 @@ msgstr "" ...@@ -11347,6 +11350,9 @@ msgstr ""
msgid "First seen" msgid "First seen"
msgstr "" msgstr ""
msgid "Fixed burndown chart"
msgstr ""
msgid "Fixed date" msgid "Fixed date"
msgstr "" msgstr ""
...@@ -15013,6 +15019,9 @@ msgstr "" ...@@ -15013,6 +15019,9 @@ msgstr ""
msgid "Leave zen mode" msgid "Leave zen mode"
msgstr "" msgstr ""
msgid "Legacy burndown chart"
msgstr ""
msgid "Let's Encrypt does not accept emails on example.com" msgid "Let's Encrypt does not accept emails on example.com"
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