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>
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 ""
......@@ -11347,6 +11350,9 @@ msgstr ""
msgid "First seen"
msgstr ""
msgid "Fixed burndown chart"
msgstr ""
msgid "Fixed date"
msgstr ""
......@@ -15013,6 +15019,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