Commit 6c138c0c authored by Brandon Labuschagne's avatar Brandon Labuschagne Committed by Natalia Tepluhina

Fetch throughput chart data

Introduce graphql into the MR analytics Vue
app in order to fetch the chart data.

This is the first analytics feature to make
use of graphql.
parent 331c5d85
......@@ -689,3 +689,24 @@ export const approximateDuration = (seconds = 0) => {
}
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>
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { GlAlert } from '@gitlab/ui';
import { __ } from '~/locale';
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
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 {
name: 'ThroughputChart',
components: {
GlAreaChart,
GlAlert,
GlLoadingIcon,
},
inject: ['fullPath'],
data() {
return {
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: {
chartOptions() {
return {
xAxis: {
name: '',
name: THROUGHPUT_STRINGS.X_AXIS_TITLE,
type: 'category',
axisLabel: {
formatter: value => {
return value.split('_')[0]; // Aug_2020 => Aug
},
},
},
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() {
return this.throughputChartData.length;
return this.formattedThroughputChartData[0].data.length;
},
},
chartTitle: __('Throughput'),
chartDescription: __('The number of merge requests merged to the master branch by month.'),
strings: {
chartTitle: THROUGHPUT_STRINGS.CHART_TITLE,
chartDescription: THROUGHPUT_STRINGS.CHART_DESCRIPTION,
noData: THROUGHPUT_STRINGS.NO_DATA,
},
};
</script>
<template>
<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">
{{ $options.chartDescription }}
{{ $options.strings.chartDescription }}
</div>
<gl-area-chart v-if="chartDataAvailable" :data="throughputChartData" :option="chartOptions" />
<gl-alert v-else :dismissible="false" class="gl-mt-4">{{
__('There is no data available.')
}}</gl-alert>
<gl-loading-icon v-if="chartDataLoading" size="md" class="gl-mt-4" />
<gl-area-chart
v-else-if="chartDataAvailable"
:data="formattedThroughputChartData"
:option="chartOptions"
/>
<gl-alert v-else :dismissible="false" class="gl-mt-4">{{ $options.strings.noData }}</gl-alert>
</div>
</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 VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import MergeRequestAnalyticsApp from './components/app.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => {
const el = document.querySelector('#js-merge-request-analytics-app');
if (!el) return false;
const { fullPath } = el.dataset;
return new Vue({
el,
apolloProvider,
name: 'MergeRequestAnalyticsApp',
provide: {
fullPath,
},
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")
#js-merge-request-analytics-app
#js-merge-request-analytics-app{ data: { full_path: @project.full_path } }
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 { THROUGHPUT_STRINGS } from 'ee/analytics/merge_request_analytics/constants';
import { throughputChartData } from '../mock_data';
const fullPath = 'gitlab-org/gitlab';
describe('ThroughputChart', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMount(ThroughputChart);
const displaysComponent = (component, visible) => {
const element = wrapper.find(component);
expect(element.exists()).toBe(visible);
};
beforeEach(() => {
createComponent();
});
const createComponent = ({ loading = false, data = {} } = {}) => {
const $apollo = {
queries: {
throughputChartData: {
loading,
},
},
};
wrapper = shallowMount(ThroughputChart, {
mocks: { $apollo },
provide: {
fullPath,
},
});
wrapper.setData(data);
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('displays the chart title', () => {
const chartTitle = wrapper.find('[data-testid="chartTitle"').text();
describe('default state', () => {
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', () => {
const chartDescription = wrapper.find('[data-testid="chartDescription"').text();
describe('while loading', () => {
beforeEach(() => {
createComponent({ loading: true });
});
expect(chartDescription).toBe(
'The number of merge requests merged to the master branch by month.',
);
it('displays a loading icon', () => {
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', () => {
const alert = wrapper.find(GlAlert);
describe('with data', () => {
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);
expect(alert.text()).toBe('There is no data available.');
it('does not display the no data message', () => {
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([]);
});
});
......@@ -14879,15 +14879,15 @@ msgstr ""
msgid "Merge Requests"
msgstr ""
msgid "Merge Requests closed"
msgstr ""
msgid "Merge Requests created"
msgstr ""
msgid "Merge Requests in Review"
msgstr ""
msgid "Merge Requests merged"
msgstr ""
msgid "Merge automatically (%{strategy})"
msgstr ""
......@@ -15657,6 +15657,9 @@ msgstr ""
msgid "Monitoring"
msgstr ""
msgid "Month"
msgstr ""
msgid "Months"
msgstr ""
......@@ -24161,7 +24164,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."
msgstr ""
msgid "The number of merge requests merged to the master branch by month."
msgid "The number of merge requests merged by month."
msgstr ""
msgid "The number of times an upload record could not find its file"
......
......@@ -628,3 +628,14 @@ describe('localTimeAgo', () => {
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