Commit 88d762ca authored by Simon Knox's avatar Simon Knox Committed by Miguel Rincon

Use burnup graphQL endpoint for milestone charts

Requires padding data as endpoint only returns changes
parent ffea1ee9
......@@ -642,6 +642,16 @@ export const secondsToMilliseconds = seconds => seconds * 1000;
*/
export const secondsToDays = seconds => Math.round(seconds / 86400);
/**
* Returns the date n days after the date provided
*
* @param {Date} date the initial date
* @param {Number} numberOfDays number of days after
* @return {Date} the date following the date provided
*/
export const nDaysAfter = (date, numberOfDays) =>
new Date(newDate(date)).setDate(date.getDate() + numberOfDays);
/**
* Returns the date after the date provided
*
......
<script>
import { GlButton, GlButtonGroup } from '@gitlab/ui';
import { GlAlert, GlButton, GlButtonGroup } from '@gitlab/ui';
import dateFormat from 'dateformat';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '~/locale';
import { getDayDifference, nDaysAfter, newDateAsLocaleTime } from '~/lib/utils/datetime_utility';
import BurndownChart from './burndown_chart.vue';
import BurnupChart from './burnup_chart.vue';
import BurnupQuery from '../queries/burnup.query.graphql';
export default {
components: {
GlAlert,
GlButton,
GlButtonGroup,
BurndownChart,
......@@ -32,15 +36,38 @@ export default {
required: false,
default: () => [],
},
burnupScope: {
type: Array,
milestoneId: {
type: String,
required: false,
default: () => [],
default: '',
},
},
apollo: {
burnupData: {
skip() {
return !this.glFeatures.burnupCharts || !this.milestoneId;
},
query: BurnupQuery,
variables() {
return {
milestoneId: this.milestoneId,
};
},
update(data) {
const sparseBurnupData = data?.milestone?.burnupTimeSeries || [];
return this.padSparseBurnupData(sparseBurnupData);
},
error() {
this.error = __('Error fetching burnup chart data');
},
},
},
data() {
return {
issuesSelected: true,
burnupData: [],
error: '',
};
},
computed: {
......@@ -58,6 +85,79 @@ export default {
setIssueSelected(selected) {
this.issuesSelected = selected;
},
padSparseBurnupData(sparseBurnupData) {
// if we don't have data for the startDate, we still want to draw a point at 0
// on the chart, so add an item to the start of the array
const hasDataForStartDate = sparseBurnupData.find(d => d.date === this.startDate);
if (!hasDataForStartDate) {
sparseBurnupData.unshift({
date: this.startDate,
completedCount: 0,
completedWeight: 0,
scopeCount: 0,
scopeWeight: 0,
});
}
// chart runs to dueDate or the current date, whichever is earlier
const lastDate = dateFormat(
Math.min(Date.parse(this.dueDate), Date.parse(new Date())),
'yyyy-mm-dd',
);
// similar to the startDate padding, if we don't have a value for the
// last item in the array, we should add one. If no events occur on
// a day then we don't get any data for that day in the response
const hasDataForLastDate = sparseBurnupData.find(d => d.date === lastDate);
if (!hasDataForLastDate) {
const lastItem = sparseBurnupData[sparseBurnupData.length - 1];
sparseBurnupData.push({
...lastItem,
date: lastDate,
});
}
return sparseBurnupData.reduce(this.addMissingDates, []);
},
addMissingDates(acc, current) {
const { date } = current;
// we might not have data for every day in the timebox, as graphql
// endpoint only returns days when events have happened
// if the previous array item is >1 day, then fill in the gap
// using the data from the previous entry.
// example: [
// { date: '2020-08-01', count: 10 }
// { date: '2020-08-04', count: 12 }
// ]
// should be transformed to
// example: [
// { date: '2020-08-01', count: 10 }
// { date: '2020-08-02', count: 10 }
// { date: '2020-08-03', count: 10 }
// { date: '2020-08-04', count: 12 }
// ]
// skip the start date since we have no previous values
if (date !== this.startDate) {
const { date: prevDate, ...previousValues } = acc[acc.length - 1] || {};
const currentDateUTC = newDateAsLocaleTime(date);
const prevDateUTC = newDateAsLocaleTime(prevDate);
const gap = getDayDifference(prevDateUTC, currentDateUTC);
for (let i = 1; i < gap; i += 1) {
acc.push({
date: dateFormat(nDaysAfter(prevDateUTC, i), 'yyyy-mm-dd'),
...previousValues,
});
}
}
acc.push(current);
return acc;
},
},
};
</script>
......@@ -89,6 +189,9 @@ export default {
</gl-button-group>
</div>
<div v-if="glFeatures.burnupCharts" class="row">
<gl-alert v-if="error" variant="danger" class="col-12" @dismiss="error = ''">
{{ error }}
</gl-alert>
<burndown-chart
:start-date="startDate"
:due-date="dueDate"
......@@ -100,7 +203,8 @@ export default {
<burnup-chart
:start-date="startDate"
:due-date="dueDate"
:scope="burnupScope"
:burnup-data="burnupData"
:issues-selected="issuesSelected"
class="col-md-6"
/>
</div>
......
......@@ -3,7 +3,7 @@ import { merge } from 'lodash';
import { GlLineChart } from '@gitlab/ui/dist/charts';
import dateFormat from 'dateformat';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import { s__, __, sprintf } from '~/locale';
import { __, n__, s__, sprintf } from '~/locale';
import commonChartOptions from './common_chart_options';
export default {
......@@ -103,12 +103,14 @@ export default {
methods: {
formatTooltipText(params) {
const [seriesData] = params.seriesData;
if (!seriesData) {
return;
}
this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy');
if (this.issuesSelected) {
this.tooltip.content = sprintf(__('%{total} open issues'), {
total: seriesData.value[1],
});
this.tooltip.content = n__('%d open issue', '%d open issues', seriesData.value[1]);
} else {
this.tooltip.content = sprintf(__('%{total} open issue weight'), {
total: seriesData.value[1],
......
......@@ -3,7 +3,7 @@ import { merge } from 'lodash';
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';
import { __, n__, sprintf } from '~/locale';
import commonChartOptions from './common_chart_options';
export default {
......@@ -20,7 +20,12 @@ export default {
type: String,
required: true,
},
scope: {
issuesSelected: {
type: Boolean,
required: false,
default: true,
},
burnupData: {
type: Array,
required: false,
default: () => [],
......@@ -35,11 +40,27 @@ export default {
};
},
computed: {
scopeCount() {
return this.transform('scopeCount');
},
completedCount() {
return this.transform('completedCount');
},
scopeWeight() {
return this.transform('scopeWeight');
},
completedWeight() {
return this.transform('completedWeight');
},
dataSeries() {
const series = [
{
name: __('Total'),
data: this.scope,
data: this.issuesSelected ? this.scopeCount : this.scopeWeight,
},
{
name: __('Completed'),
data: this.issuesSelected ? this.completedCount : this.completedWeight,
},
];
......@@ -58,15 +79,31 @@ export default {
},
},
methods: {
// transform the object to a chart-friendly array of date + value
transform(key) {
return this.burnupData.map(val => [val.date, val[key]]);
},
formatTooltipText(params) {
const [seriesData] = params.seriesData;
const [total, completed] = params.seriesData;
if (!total || !completed) {
return;
}
this.tooltip.title = dateFormat(params.value, 'dd mmm yyyy');
const text = __('%{total} open issues');
const count = total.value[1];
const completedCount = completed.value[1];
this.tooltip.content = sprintf(text, {
total: seriesData.value[1],
});
let totalText = n__('%d open issue', '%d open issues', count);
let completedText = n__('%d completed issue', '%d completed issues', completedCount);
if (!this.issuesSelected) {
totalText = sprintf(__('%{count} total weight'), { count });
completedText = sprintf(__('%{completedCount} completed weight'), { completedCount });
}
this.tooltip.total = totalText;
this.tooltip.completed = completedText;
},
},
};
......@@ -85,7 +122,10 @@ export default {
:include-legend-avg-max="false"
>
<template slot="tooltipTitle">{{ tooltip.title }}</template>
<template slot="tooltipContent">{{ tooltip.content }}</template>
<template slot="tooltipContent">
<div>{{ tooltip.total }}</div>
<div>{{ tooltip.completed }}</div>
</template>
</gl-line-chart>
</resizable-chart-container>
</div>
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import $ from 'jquery';
import Cookies from 'js-cookie';
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);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => {
// handle hint dismissal
const hint = $('.burndown-hint');
......@@ -24,16 +32,10 @@ export default () => {
const dueDate = $chartEl.data('dueDate');
const milestoneId = $chartEl.data('milestoneId');
const burndownEventsPath = $chartEl.data('burndownEventsPath');
const burnupEventsPath = $chartEl.data('burnupEventsPath');
const fetchData = [axios.get(burndownEventsPath)];
if (gon.features.burnupCharts) {
fetchData.push(axios.get(burnupEventsPath));
}
Promise.all(fetchData)
.then(([burndownResponse, burnupResponse]) => {
axios
.get(burndownEventsPath)
.then(burndownResponse => {
const burndownEvents = burndownResponse.data;
const burndownChartData = new BurndownChartData(
burndownEvents,
......@@ -41,13 +43,6 @@ export default () => {
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]]);
......@@ -56,6 +51,7 @@ export default () => {
components: {
BurnCharts,
},
apolloProvider,
render(createElement) {
return createElement('burn-charts', {
props: {
......@@ -63,7 +59,7 @@ export default () => {
dueDate,
openIssuesCount,
openIssuesWeight,
burnupScope,
milestoneId,
},
});
},
......
query IterationBurnupTimesSeriesData($milestoneId: MilestoneID!) {
milestone(id: $milestoneId) {
title
id
burnupTimeSeries {
date
scopeCount
scopeWeight
completedCount
completedWeight
}
}
}
......@@ -9,7 +9,7 @@
- if can_generate_chart?(milestone, burndown)
.burndown-chart.mb-2{ data: { start_date: burndown.start_date.strftime("%Y-%m-%d"),
due_date: burndown.due_date.strftime("%Y-%m-%d"),
milestone_id: milestone.id,
milestone_id: milestone.to_global_id,
burndown_events_path: expose_url(burndown_endpoint), burnup_events_path: expose_url(burnup_endpoint) } }
- elsif show_burndown_placeholder?(milestone, warning)
......
......@@ -2,6 +2,9 @@ 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';
describe('burndown_chart', () => {
let wrapper;
......@@ -12,10 +15,11 @@ describe('burndown_chart', () => {
const findActiveButtons = () =>
wrapper.findAll(GlButton).filter(button => button.attributes().category === 'primary');
const findBurndownChart = () => wrapper.find(BurndownChart);
const findBurnupChart = () => wrapper.find(BurnupChart);
const defaultProps = {
startDate: '2019-08-07T00:00:00.000Z',
dueDate: '2019-09-09T00:00:00.000Z',
startDate: '2019-08-07',
dueDate: '2019-09-09',
openIssuesCount: [],
openIssuesWeight: [],
};
......@@ -48,22 +52,23 @@ describe('burndown_chart', () => {
.at(0)
.text(),
).toBe('Issues');
expect(findBurndownChart().props().issuesSelected).toBe(true);
expect(findBurndownChart().props('issuesSelected')).toBe(true);
});
it('toggles Issue weight', () => {
it('toggles Issue weight', async () => {
createComponent();
findWeightButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
await wrapper.vm.$nextTick();
expect(findActiveButtons()).toHaveLength(1);
expect(
findActiveButtons()
.at(0)
.text(),
).toBe('Issue weight');
});
expect(findBurndownChart().props('issuesSelected')).toBe(false);
});
describe('feature disabled', () => {
......@@ -94,5 +99,84 @@ describe('burndown_chart', () => {
expect(findChartsTitle().text()).toBe('Charts');
expect(findBurndownChart().props().showTitle).toBe(true);
});
it('sets weight prop of burnup chart', async () => {
findWeightButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(findBurnupChart().props('issuesSelected')).toBe(false);
});
});
// 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 },
featureEnabled: true,
});
fakeDate(day4);
});
it('pads data from startDate if no startDate values', () => {
const result = wrapper.vm.padSparseBurnupData([day2, day3, day4]);
expect(result.length).toBe(4);
expect(result[0]).toEqual({
date: day1.date,
completedCount: 0,
completedWeight: 0,
scopeCount: 0,
scopeWeight: 0,
});
});
it('if dueDate is in the past, pad data using last existing value', () => {
const result = wrapper.vm.padSparseBurnupData([day1, day2]);
expect(result.length).toBe(4);
expect(result[2]).toEqual({
...day2,
date: day3.date,
});
expect(result[3]).toEqual({
...day2,
date: day4.date,
});
});
it('if dueDate is in the future, pad data up to current date using last existing value', () => {
fakeDate(day3);
const result = wrapper.vm.padSparseBurnupData([day1, day2]);
expect(result.length).toBe(3);
expect(result[2]).toEqual({
...day2,
date: day3.date,
});
});
it('pads missing days with data from previous days', () => {
const result = wrapper.vm.padSparseBurnupData([day1, day4]);
expect(result.length).toBe(4);
expect(result[1]).toEqual({
...day1,
date: day2.date,
});
expect(result[2]).toEqual({
...day1,
date: day3.date,
});
});
});
});
......@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import { GlLineChart } from '@gitlab/ui/dist/charts';
import BurnupChart from 'ee/burndown_chart/components/burnup_chart.vue';
import ResizableChartContainer from '~/vue_shared/components/resizable_chart/resizable_chart_container.vue';
import { day1, day2, day3 } from '../mock_data';
describe('Burnup chart', () => {
let wrapper;
......@@ -25,18 +26,31 @@ describe('Burnup chart', () => {
});
};
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 });
it('renders the lineChart correctly', () => {
const burnupData = [day1, day2, day3];
const expectedScopeCount = [
[day1.date, day1.scopeCount],
[day2.date, day2.scopeCount],
[day3.date, day3.scopeCount],
];
const expectedCompletedCount = [
[day1.date, day1.completedCount],
[day2.date, day2.completedCount],
[day3.date, day3.completedCount],
];
createComponent({ burnupData });
const chartData = findChart().props('data');
expect(chartData).toEqual([
{
name: 'Total',
data: scope,
data: expectedScopeCount,
},
{
name: 'Completed',
data: expectedCompletedCount,
},
]);
});
......
export const day1 = {
date: '2020-08-08',
completedCount: 0,
completedWeight: 0,
scopeCount: 10,
scopeWeight: 20,
};
export const day2 = {
date: '2020-08-09',
completedCount: 1,
completedWeight: 1,
scopeCount: 11,
scopeWeight: 20,
};
export const day3 = {
date: '2020-08-10',
completedCount: 2,
completedWeight: 4,
scopeCount: 11,
scopeWeight: 22,
};
export const day4 = {
date: '2020-08-11',
completedCount: 3,
completedWeight: 5,
scopeCount: 11,
scopeWeight: 22,
};
......@@ -8,6 +8,8 @@ msgid ""
msgstr ""
"Project-Id-Version: gitlab 1.0.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2020-09-11 14:23+1000\n"
"PO-Revision-Date: 2020-09-11 14:23+1000\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
......@@ -140,6 +142,11 @@ msgstr[1] ""
msgid "%d commits"
msgstr ""
msgid "%d completed issue"
msgid_plural "%d completed issues"
msgstr[0] ""
msgstr[1] ""
msgid "%d contribution"
msgid_plural "%d contributions"
msgstr[0] ""
......@@ -240,6 +247,11 @@ msgid_plural "%d more comments"
msgstr[0] ""
msgstr[1] ""
msgid "%d open issue"
msgid_plural "%d open issues"
msgstr[0] ""
msgstr[1] ""
msgid "%d personal project will be removed and cannot be restored."
msgid_plural "%d personal projects will be removed and cannot be restored."
msgstr[0] ""
......@@ -333,6 +345,9 @@ msgstr ""
msgid "%{commit_author_link} authored %{commit_timeago}"
msgstr ""
msgid "%{completedCount} completed weight"
msgstr ""
msgid "%{completedWeight} of %{totalWeight} weight completed"
msgstr ""
......@@ -389,6 +404,9 @@ msgstr[1] ""
msgid "%{count} related %{pluralized_subject}: %{links}"
msgstr ""
msgid "%{count} total weight"
msgstr ""
msgid "%{dashboard_path} could not be found."
msgstr ""
......@@ -806,9 +824,6 @@ msgstr ""
msgid "%{total} open issue weight"
msgstr ""
msgid "%{total} open issues"
msgstr ""
msgid "%{usage_ping_link_start}Learn more%{usage_ping_link_end} about what information is shared with GitLab Inc."
msgstr ""
......@@ -9996,6 +10011,9 @@ msgstr ""
msgid "Error deleting project. Check logs for error details."
msgstr ""
msgid "Error fetching burnup chart data"
msgstr ""
msgid "Error fetching diverging counts for branches. Please try again."
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