Commit 6744aa29 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '229048-throughput-chart-fetch-data' into 'master'

MR Analytics - FE fetch throughput chart data

See merge request gitlab-org/gitlab!38483
parents 43135747 6c138c0c
...@@ -689,3 +689,24 @@ export const approximateDuration = (seconds = 0) => { ...@@ -689,3 +689,24 @@ export const approximateDuration = (seconds = 0) => {
} }
return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days); return n__('1 day', '%d days', seconds < ONE_DAY_LIMIT ? 1 : days);
}; };
/**
* A utility function which helps creating a date object
* for a specific date. Accepts the year, month and day
* returning a date object for the given params.
*
* @param {Int} year the full year as a number i.e. 2020
* @param {Int} month the month index i.e. January => 0
* @param {Int} day the day as a number i.e. 23
*
* @return {Date} the date object from the params
*/
export const dateFromParams = (year, month, day) => {
const date = new Date();
date.setFullYear(year);
date.setMonth(month);
date.setDate(day);
return date;
};
<script> <script>
import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { GlAlert } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { __ } from '~/locale'; import { getDateInPast } from '~/lib/utils/datetime_utility';
import throughputChartQueryBuilder from '../graphql/throughput_chart_query_builder';
import { DEFAULT_NUMBER_OF_DAYS, THROUGHPUT_STRINGS } from '../constants';
export default { export default {
name: 'ThroughputChart', name: 'ThroughputChart',
components: { components: {
GlAreaChart, GlAreaChart,
GlAlert, GlAlert,
GlLoadingIcon,
}, },
inject: ['fullPath'],
data() { data() {
return { return {
throughputChartData: [], throughputChartData: [],
startDate: getDateInPast(new Date(), DEFAULT_NUMBER_OF_DAYS),
endDate: new Date(),
}; };
}, },
apollo: {
throughputChartData: {
query() {
return throughputChartQueryBuilder(this.startDate, this.endDate);
},
variables() {
return {
fullPath: this.fullPath,
};
},
},
},
computed: { computed: {
chartOptions() { chartOptions() {
return { return {
xAxis: { xAxis: {
name: '', name: THROUGHPUT_STRINGS.X_AXIS_TITLE,
type: 'category',
axisLabel: {
formatter: value => {
return value.split('_')[0]; // Aug_2020 => Aug
},
},
}, },
yAxis: { yAxis: {
name: __('Merge Requests closed'), name: THROUGHPUT_STRINGS.Y_AXIS_TITLE,
}, },
}; };
}, },
formattedThroughputChartData() {
const data = Object.keys(this.throughputChartData)
.slice(0, -1) // Remove the __typeName key
.map(value => [value, this.throughputChartData[value].count]);
return [
{
name: THROUGHPUT_STRINGS.Y_AXIS_TITLE,
data,
},
];
},
chartDataLoading() {
return this.$apollo.queries.throughputChartData.loading;
},
chartDataAvailable() { chartDataAvailable() {
return this.throughputChartData.length; return this.formattedThroughputChartData[0].data.length;
}, },
}, },
chartTitle: __('Throughput'), strings: {
chartDescription: __('The number of merge requests merged to the master branch by month.'), chartTitle: THROUGHPUT_STRINGS.CHART_TITLE,
chartDescription: THROUGHPUT_STRINGS.CHART_DESCRIPTION,
noData: THROUGHPUT_STRINGS.NO_DATA,
},
}; };
</script> </script>
<template> <template>
<div> <div>
<h4 data-testid="chartTitle">{{ $options.chartTitle }}</h4> <h4 data-testid="chartTitle">{{ $options.strings.chartTitle }}</h4>
<div class="gl-text-gray-700" data-testid="chartDescription"> <div class="gl-text-gray-700" data-testid="chartDescription">
{{ $options.chartDescription }} {{ $options.strings.chartDescription }}
</div> </div>
<gl-area-chart v-if="chartDataAvailable" :data="throughputChartData" :option="chartOptions" /> <gl-loading-icon v-if="chartDataLoading" size="md" class="gl-mt-4" />
<gl-alert v-else :dismissible="false" class="gl-mt-4">{{ <gl-area-chart
__('There is no data available.') v-else-if="chartDataAvailable"
}}</gl-alert> :data="formattedThroughputChartData"
:option="chartOptions"
/>
<gl-alert v-else :dismissible="false" class="gl-mt-4">{{ $options.strings.noData }}</gl-alert>
</div> </div>
</template> </template>
import { __ } from '~/locale';
export const DEFAULT_NUMBER_OF_DAYS = 365;
export const THROUGHPUT_STRINGS = {
CHART_TITLE: __('Throughput'),
Y_AXIS_TITLE: __('Merge Requests merged'),
X_AXIS_TITLE: __('Month'),
CHART_DESCRIPTION: __('The number of merge requests merged by month.'),
NO_DATA: __('There is no data available.'),
};
import gql from 'graphql-tag';
import { computeMonthRangeData } from '../utils';
/**
* A GraphQL query building function which accepts a
* startDate and endDate, returning a parsed query string
* which nests sub queries for each individual month.
*
* @param {Date} startDate the startDate for the data range
* @param {Date} endDate the endDate for the data range
*
* @return {String} the parsed GraphQL query string
*/
export default (startDate = null, endDate = null) => {
const monthData = computeMonthRangeData(startDate, endDate);
if (!monthData.length) return '';
const computedMonthData = monthData.map(value => {
const { year, month, mergedAfter, mergedBefore } = value;
return `${month}_${year}: mergeRequests(mergedBefore: "${mergedBefore}", mergedAfter: "${mergedAfter}") { count }`;
});
return gql`
query($fullPath: ID!) {
throughputChartData: project(fullPath: $fullPath) {
${computedMonthData}
}
}
`;
};
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import MergeRequestAnalyticsApp from './components/app.vue'; import MergeRequestAnalyticsApp from './components/app.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => { export default () => {
const el = document.querySelector('#js-merge-request-analytics-app'); const el = document.querySelector('#js-merge-request-analytics-app');
if (!el) return false; if (!el) return false;
const { fullPath } = el.dataset;
return new Vue({ return new Vue({
el, el,
apolloProvider,
name: 'MergeRequestAnalyticsApp', name: 'MergeRequestAnalyticsApp',
provide: {
fullPath,
},
render: createElement => createElement(MergeRequestAnalyticsApp), render: createElement => createElement(MergeRequestAnalyticsApp),
}); });
}; };
import { getMonthNames, dateFromParams } from '~/lib/utils/datetime_utility';
import dateFormat from 'dateformat';
/**
* A utility function which accepts a date range and returns
* computed month data which is required to build the GraphQL
* query for the Throughput Analytics chart
*
* This does not currently support days;
*
* `mergedAfter` will always be the first day of the month
* `mergedBefore` will always be the first day of the following month
*
* @param {Date} startDate the startDate for the data range
* @param {Date} endDate the endDate for the data range
* @param {String} format the date format to be used
*
* @return {Array} the computed month data
*/
// eslint-disable-next-line import/prefer-default-export
export const computeMonthRangeData = (startDate, endDate, format = 'yyyy-mm-dd') => {
const monthData = [];
const monthNames = getMonthNames(true);
for (
let dateCursor = endDate;
dateCursor >= startDate;
dateCursor.setMonth(dateCursor.getMonth() - 1)
) {
const monthIndex = dateCursor.getMonth();
const year = dateCursor.getFullYear();
const mergedAfter = dateFromParams(year, monthIndex, 1);
const mergedBefore = dateFromParams(year, monthIndex + 1, 1);
monthData.unshift({
year,
month: monthNames[monthIndex],
mergedAfter: dateFormat(mergedAfter, format),
mergedBefore: dateFormat(mergedBefore, format),
});
}
return monthData;
};
- page_title _("Merge Request Analytics") - page_title _("Merge Request Analytics")
#js-merge-request-analytics-app #js-merge-request-analytics-app{ data: { full_path: @project.full_path } }
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlAlert } from '@gitlab/ui'; import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import ThroughputChart from 'ee/analytics/merge_request_analytics/components/throughput_chart.vue'; import ThroughputChart from 'ee/analytics/merge_request_analytics/components/throughput_chart.vue';
import { THROUGHPUT_STRINGS } from 'ee/analytics/merge_request_analytics/constants';
import { throughputChartData } from '../mock_data';
const fullPath = 'gitlab-org/gitlab';
describe('ThroughputChart', () => { describe('ThroughputChart', () => {
let wrapper; let wrapper;
const createComponent = () => { const displaysComponent = (component, visible) => {
wrapper = shallowMount(ThroughputChart); const element = wrapper.find(component);
expect(element.exists()).toBe(visible);
}; };
beforeEach(() => { const createComponent = ({ loading = false, data = {} } = {}) => {
createComponent(); const $apollo = {
}); queries: {
throughputChartData: {
loading,
},
},
};
wrapper = shallowMount(ThroughputChart, {
mocks: { $apollo },
provide: {
fullPath,
},
});
wrapper.setData(data);
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null; wrapper = null;
}); });
it('displays the chart title', () => { describe('default state', () => {
const chartTitle = wrapper.find('[data-testid="chartTitle"').text(); beforeEach(() => {
createComponent();
});
it('displays the chart title', () => {
const chartTitle = wrapper.find('[data-testid="chartTitle"').text();
expect(chartTitle).toBe(THROUGHPUT_STRINGS.CHART_TITLE);
});
it('displays the chart description', () => {
const chartDescription = wrapper.find('[data-testid="chartDescription"').text();
expect(chartTitle).toBe('Throughput'); expect(chartDescription).toBe(THROUGHPUT_STRINGS.CHART_DESCRIPTION);
});
it('displays an empty state message when there is no data', () => {
const alert = wrapper.find(GlAlert);
expect(alert.exists()).toBe(true);
expect(alert.text()).toBe(THROUGHPUT_STRINGS.NO_DATA);
});
it('does not display a loading icon', () => {
displaysComponent(GlLoadingIcon, false);
});
it('does not display the chart', () => {
displaysComponent(GlAreaChart, false);
});
}); });
it('displays the chart description', () => { describe('while loading', () => {
const chartDescription = wrapper.find('[data-testid="chartDescription"').text(); beforeEach(() => {
createComponent({ loading: true });
});
expect(chartDescription).toBe( it('displays a loading icon', () => {
'The number of merge requests merged to the master branch by month.', displaysComponent(GlLoadingIcon, true);
); });
it('does not display the chart', () => {
displaysComponent(GlAreaChart, false);
});
it('does not display the no data message', () => {
displaysComponent(GlAlert, false);
});
}); });
it('displays an empty state message when there is no data', () => { describe('with data', () => {
const alert = wrapper.find(GlAlert); beforeEach(() => {
createComponent({ data: { throughputChartData } });
});
it('displays the chart', () => {
displaysComponent(GlAreaChart, true);
});
it('does not display a loading icon', () => {
displaysComponent(GlLoadingIcon, false);
});
expect(alert.exists()).toBe(true); it('does not display the no data message', () => {
expect(alert.text()).toBe('There is no data available.'); displaysComponent(GlAlert, false);
});
}); });
}); });
import { print } from 'graphql/language/printer';
import throughputChartQueryBuilder from 'ee/analytics/merge_request_analytics/graphql/throughput_chart_query_builder';
import { throughputChartQuery } from '../mock_data';
describe('throughputChartQueryBuilder', () => {
it('returns the query as expected', () => {
const startDate = new Date('2020-05-17T00:00:00.000Z');
const endDate = new Date('2020-07-17T00:00:00.000Z');
const query = throughputChartQueryBuilder(startDate, endDate);
expect(print(query)).toEqual(throughputChartQuery);
});
});
export const throughputChartData = {
May: { count: 2, __typename: 'MergeRequestConnection' },
Jun: { count: 4, __typename: 'MergeRequestConnection' },
Jul: { count: 3, __typename: 'MergeRequestConnection' },
__typename: 'Project',
};
export const expectedMonthData = [
{
year: 2020,
month: 'May',
mergedAfter: '2020-05-01',
mergedBefore: '2020-06-01',
},
{
year: 2020,
month: 'Jun',
mergedAfter: '2020-06-01',
mergedBefore: '2020-07-01',
},
{
year: 2020,
month: 'Jul',
mergedAfter: '2020-07-01',
mergedBefore: '2020-08-01',
},
];
export const throughputChartQuery = `query ($fullPath: ID!) {
throughputChartData: project(fullPath: $fullPath) {
May_2020: mergeRequests(mergedBefore: "2020-06-01", mergedAfter: "2020-05-01") {
count
}
Jun_2020: mergeRequests(mergedBefore: "2020-07-01", mergedAfter: "2020-06-01") {
count
}
Jul_2020: mergeRequests(mergedBefore: "2020-08-01", mergedAfter: "2020-07-01") {
count
}
}
}
`;
import * as utils from 'ee/analytics/merge_request_analytics/utils';
import { expectedMonthData } from './mock_data';
describe('computeMonthRangeData', () => {
it('returns the data as expected', () => {
const startDate = new Date('2020-05-17T00:00:00.000Z');
const endDate = new Date('2020-07-17T00:00:00.000Z');
const monthData = utils.computeMonthRangeData(startDate, endDate);
expect(monthData).toStrictEqual(expectedMonthData);
});
it('returns an empty array on an invalid date range', () => {
const startDate = new Date('2021-05-17T00:00:00.000Z');
const endDate = new Date('2020-07-17T00:00:00.000Z');
const monthData = utils.computeMonthRangeData(startDate, endDate);
expect(monthData).toStrictEqual([]);
});
});
...@@ -14891,15 +14891,15 @@ msgstr "" ...@@ -14891,15 +14891,15 @@ msgstr ""
msgid "Merge Requests" msgid "Merge Requests"
msgstr "" msgstr ""
msgid "Merge Requests closed"
msgstr ""
msgid "Merge Requests created" msgid "Merge Requests created"
msgstr "" msgstr ""
msgid "Merge Requests in Review" msgid "Merge Requests in Review"
msgstr "" msgstr ""
msgid "Merge Requests merged"
msgstr ""
msgid "Merge automatically (%{strategy})" msgid "Merge automatically (%{strategy})"
msgstr "" msgstr ""
...@@ -15669,6 +15669,9 @@ msgstr "" ...@@ -15669,6 +15669,9 @@ msgstr ""
msgid "Monitoring" msgid "Monitoring"
msgstr "" msgstr ""
msgid "Month"
msgstr ""
msgid "Months" msgid "Months"
msgstr "" msgstr ""
...@@ -24173,7 +24176,7 @@ msgstr "" ...@@ -24173,7 +24176,7 @@ msgstr ""
msgid "The number of changes to be fetched from GitLab when cloning a repository. This can speed up Pipelines execution. Keep empty or set to 0 to disable shallow clone by default and make GitLab CI fetch all branches and tags each time." msgid "The number of changes to be fetched from GitLab when cloning a repository. This can speed up Pipelines execution. Keep empty or set to 0 to disable shallow clone by default and make GitLab CI fetch all branches and tags each time."
msgstr "" msgstr ""
msgid "The number of merge requests merged to the master branch by month." msgid "The number of merge requests merged by month."
msgstr "" msgstr ""
msgid "The number of times an upload record could not find its file" msgid "The number of times an upload record could not find its file"
......
...@@ -628,3 +628,14 @@ describe('localTimeAgo', () => { ...@@ -628,3 +628,14 @@ describe('localTimeAgo', () => {
expect(element.getAttribute('title')).toBe(title); expect(element.getAttribute('title')).toBe(title);
}); });
}); });
describe('dateFromParams', () => {
it('returns the expected date object', () => {
const expectedDate = new Date('2019-07-17T00:00:00.000Z');
const date = datetimeUtility.dateFromParams(2019, 6, 17);
expect(date.getYear()).toBe(expectedDate.getYear());
expect(date.getMonth()).toBe(expectedDate.getMonth());
expect(date.getDate()).toBe(expectedDate.getDate());
});
});
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