Commit b8f91478 authored by Michael Lunøe's avatar Michael Lunøe Committed by Jose Ivan Vargas

Feat(Instance Statistics): add general chart comp

Add general component for instance statistics
count charts to be reused, so the user can both
see issues and merge request data as well as
pipeline statistics. This will also be useful for
other charts on the same page.

Refs:
https://gitlab.com/gitlab-org/gitlab/-/issues/267538
https://gitlab.com/gitlab-org/gitlab/-/issues/246491
https://gitlab.com/gitlab-org/gitlab/-/issues/268224
https://gitlab.com/gitlab-org/gitlab/-/issues/267997
https://gitlab.com/gitlab-org/gitlab/-/issues/233854
parent 1bb87641
<script>
import { s__ } from '~/locale';
import InstanceCounts from './instance_counts.vue';
import PipelinesChart from './pipelines_chart.vue';
import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue';
import UsersChart from './users_chart.vue';
import pipelinesStatsQuery from '../graphql/queries/pipeline_stats.query.graphql';
import issuesAndMergeRequestsQuery from '../graphql/queries/issues_and_merge_requests.query.graphql';
import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants';
const PIPELINES_KEY_TO_NAME_MAP = {
total: s__('InstanceAnalytics|Total'),
succeeded: s__('InstanceAnalytics|Succeeded'),
failed: s__('InstanceAnalytics|Failed'),
canceled: s__('InstanceAnalytics|Canceled'),
skipped: s__('InstanceAnalytics|Skipped'),
};
const ISSUES_AND_MERGE_REQUESTS_KEY_TO_NAME_MAP = {
issues: s__('InstanceAnalytics|Issues'),
mergeRequests: s__('InstanceAnalytics|Merge Requests'),
};
const loadPipelineChartError = s__(
'InstanceAnalytics|Could not load the pipelines chart. Please refresh the page to try again.',
);
const loadIssuesAndMergeRequestsChartError = s__(
'InstanceAnalytics|Could not load the issues and merge requests chart. Please refresh the page to try again.',
);
const noDataMessage = s__('InstanceAnalytics|There is no data available.');
export default {
name: 'InstanceStatisticsApp',
components: {
InstanceCounts,
PipelinesChart,
InstanceStatisticsCountChart,
UsersChart,
},
TOTAL_DAYS_TO_SHOW,
START_DATE,
TODAY,
configs: [
{
keyToNameMap: PIPELINES_KEY_TO_NAME_MAP,
prefix: 'pipelines',
loadChartError: loadPipelineChartError,
noDataMessage,
chartTitle: s__('InstanceAnalytics|Pipelines'),
yAxisTitle: s__('InstanceAnalytics|Items'),
xAxisTitle: s__('InstanceAnalytics|Month'),
query: pipelinesStatsQuery,
},
{
keyToNameMap: ISSUES_AND_MERGE_REQUESTS_KEY_TO_NAME_MAP,
prefix: 'issuesAndMergeRequests',
loadChartError: loadIssuesAndMergeRequestsChartError,
noDataMessage,
chartTitle: s__('InstanceAnalytics|Issues & Merge Requests'),
yAxisTitle: s__('InstanceAnalytics|Items'),
xAxisTitle: s__('InstanceAnalytics|Month'),
query: issuesAndMergeRequestsQuery,
},
],
};
</script>
......@@ -25,6 +69,17 @@ export default {
:end-date="$options.TODAY"
:total-data-points="$options.TOTAL_DAYS_TO_SHOW"
/>
<pipelines-chart />
<instance-statistics-count-chart
v-for="chartOptions in $options.configs"
:key="chartOptions.chartTitle"
:prefix="chartOptions.prefix"
:key-to-name-map="chartOptions.keyToNameMap"
:query="chartOptions.query"
:x-axis-title="chartOptions.xAxisTitle"
:y-axis-title="chartOptions.yAxisTitle"
:load-chart-error-message="chartOptions.loadChartError"
:no-data-message="chartOptions.noDataMessage"
:chart-title="chartOptions.chartTitle"
/>
</div>
</template>
<script>
import { GlLineChart } from '@gitlab/ui/dist/charts';
import { GlAlert } from '@gitlab/ui';
import { mapKeys, mapValues, pick, some, sum } from 'lodash';
import { mapValues, some, sum } from 'lodash';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { s__ } from '~/locale';
import {
differenceInMonths,
formatDateAsMonth,
getDayDifference,
} from '~/lib/utils/datetime_utility';
import { convertToTitleCase } from '~/lib/utils/text_utility';
import { getAverageByMonth, sortByDate, extractValues } from '../utils';
import pipelineStatsQuery from '../graphql/queries/pipeline_stats.query.graphql';
import { TODAY, START_DATE } from '../constants';
const DATA_KEYS = [
'pipelinesTotal',
'pipelinesSucceeded',
'pipelinesFailed',
'pipelinesCanceled',
'pipelinesSkipped',
];
const PREFIX = 'pipelines';
export default {
name: 'PipelinesChart',
name: 'InstanceStatisticsCountChart',
components: {
GlLineChart,
GlAlert,
......@@ -31,19 +21,42 @@ export default {
},
startDate: START_DATE,
endDate: TODAY,
i18n: {
loadPipelineChartError: s__(
'InstanceAnalytics|Could not load the pipelines chart. Please refresh the page to try again.',
),
noDataMessage: s__('InstanceAnalytics|There is no data available.'),
total: s__('InstanceAnalytics|Total'),
succeeded: s__('InstanceAnalytics|Succeeded'),
failed: s__('InstanceAnalytics|Failed'),
canceled: s__('InstanceAnalytics|Canceled'),
skipped: s__('InstanceAnalytics|Skipped'),
chartTitle: s__('InstanceAnalytics|Pipelines'),
yAxisTitle: s__('InstanceAnalytics|Items'),
xAxisTitle: s__('InstanceAnalytics|Month'),
dataKey: 'nodes',
pageInfoKey: 'pageInfo',
firstKey: 'first',
props: {
prefix: {
type: String,
required: true,
},
keyToNameMap: {
type: Object,
required: true,
},
chartTitle: {
type: String,
required: true,
},
loadChartErrorMessage: {
type: String,
required: true,
},
noDataMessage: {
type: String,
required: true,
},
xAxisTitle: {
type: String,
required: true,
},
yAxisTitle: {
type: String,
required: true,
},
query: {
type: Object,
required: true,
},
},
data() {
return {
......@@ -53,19 +66,23 @@ export default {
},
apollo: {
pipelineStats: {
query: pipelineStatsQuery,
query() {
return this.query;
},
variables() {
return {
firstTotal: this.totalDaysToShow,
firstSucceeded: this.totalDaysToShow,
firstFailed: this.totalDaysToShow,
firstCanceled: this.totalDaysToShow,
firstSkipped: this.totalDaysToShow,
};
return this.nameKeys.reduce((memo, key) => {
const firstKey = `${this.$options.firstKey}${convertToTitleCase(key)}`;
return { ...memo, [firstKey]: this.totalDaysToShow };
}, {});
},
update(data) {
const allData = extractValues(data, DATA_KEYS, PREFIX, 'nodes');
const allPageInfo = extractValues(data, DATA_KEYS, PREFIX, 'pageInfo');
const allData = extractValues(data, this.nameKeys, this.prefix, this.$options.dataKey);
const allPageInfo = extractValues(
data,
this.nameKeys,
this.prefix,
this.$options.pageInfoKey,
);
return {
...mapValues(allData, sortByDate),
......@@ -83,6 +100,9 @@ export default {
},
},
computed: {
nameKeys() {
return Object.keys(this.keyToNameMap);
},
isLoading() {
return this.$apollo.queries.pipelineStats.loading;
},
......@@ -90,37 +110,35 @@ export default {
return getDayDifference(this.$options.startDate, this.$options.endDate);
},
firstVariables() {
const allData = pick(this.pipelineStats, [
'nodesTotal',
'nodesSucceeded',
'nodesFailed',
'nodesCanceled',
'nodesSkipped',
]);
const allDayDiffs = mapValues(allData, data => {
const firstdataPoint = data[0];
if (!firstdataPoint) {
return 0;
const firstDataPoints = extractValues(
this.pipelineStats,
this.nameKeys,
this.$options.dataKey,
'[0].recordedAt',
{ renameKey: this.$options.firstKey },
);
return Object.keys(firstDataPoints).reduce((memo, name) => {
const recordedAt = firstDataPoints[name];
if (!recordedAt) {
return { ...memo, [name]: 0 };
}
return Math.max(
const numberOfDays = Math.max(
0,
getDayDifference(this.$options.startDate, new Date(firstdataPoint.recordedAt)),
getDayDifference(this.$options.startDate, new Date(recordedAt)),
);
});
return mapKeys(allDayDiffs, (value, key) => key.replace('nodes', 'first'));
return { ...memo, [name]: numberOfDays };
}, {});
},
cursorVariables() {
const pageInfoKeys = [
'pageInfoTotal',
'pageInfoSucceeded',
'pageInfoFailed',
'pageInfoCanceled',
'pageInfoSkipped',
];
return extractValues(this.pipelineStats, pageInfoKeys, 'pageInfo', 'endCursor');
return extractValues(
this.pipelineStats,
this.nameKeys,
this.$options.pageInfoKey,
'endCursor',
);
},
hasNextPage() {
return (
......@@ -132,19 +150,13 @@ export default {
return this.chartData.every(({ data }) => data.length === 0);
},
chartData() {
const allData = pick(this.pipelineStats, [
'nodesTotal',
'nodesSucceeded',
'nodesFailed',
'nodesCanceled',
'nodesSkipped',
]);
const options = { shouldRound: true };
return Object.keys(allData).map(key => {
const i18nName = key.slice('nodes'.length).toLowerCase();
return this.nameKeys.map(key => {
const dataKey = `${this.$options.dataKey}${convertToTitleCase(key)}`;
return {
name: this.$options.i18n[i18nName],
data: getAverageByMonth(allData[key], options),
name: this.keyToNameMap[key],
data: getAverageByMonth(this.pipelineStats?.[dataKey], options),
};
});
},
......@@ -155,11 +167,11 @@ export default {
};
},
chartOptions() {
const { endDate, startDate, i18n } = this.$options;
const { endDate, startDate } = this.$options;
return {
xAxis: {
...this.range,
name: i18n.xAxisTitle,
name: this.xAxisTitle,
type: 'time',
splitNumber: differenceInMonths(startDate, endDate) + 1,
axisLabel: {
......@@ -171,7 +183,7 @@ export default {
},
},
yAxis: {
name: i18n.yAxisTitle,
name: this.yAxisTitle,
},
};
},
......@@ -202,13 +214,13 @@ export default {
</script>
<template>
<div>
<h3>{{ $options.i18n.chartTitle }}</h3>
<h3>{{ chartTitle }}</h3>
<gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
{{ this.$options.i18n.loadPipelineChartError }}
{{ loadChartErrorMessage }}
</gl-alert>
<chart-skeleton-loader v-else-if="isLoading" />
<gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3">
{{ $options.i18n.noDataMessage }}
{{ noDataMessage }}
</gl-alert>
<gl-line-chart v-else :option="chartOptions" :include-legend-avg-max="true" :data="chartData" />
</div>
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./count.fragment.graphql"
query issuesAndMergeRequests(
$firstIssues: Int
$firstMergeRequests: Int
$endCursorIssues: String
$endCursorMergeRequests: String
) {
issuesAndMergeRequestsIssues: instanceStatisticsMeasurements(
identifier: ISSUES
first: $firstIssues
after: $endCursorIssues
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
issuesAndMergeRequestsMergeRequests: instanceStatisticsMeasurements(
identifier: MERGE_REQUESTS
first: $firstMergeRequests
after: $endCursorMergeRequests
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
}
import { masks } from 'dateformat';
import { mapKeys, mapValues, pick, sortBy } from 'lodash';
import { get, sortBy } from 'lodash';
import { formatDate } from '~/lib/utils/datetime_utility';
import { convertToTitleCase } from '~/lib/utils/text_utility';
const { isoDate } = masks;
......@@ -46,16 +47,25 @@ export function getAverageByMonth(items = [], options = {}) {
* const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' };
* extractValues(data, ['fooBar'], 'foo', 'baz') => { bazBar: 'quis' }
* @param {Object} data set to extract values from
* @param {Array} dataKeys keys describing where to look for values in the data set
* @param {String} replaceKey name key to be replaced in the data set
* @param {Array} nameKeys keys describing where to look for values in the data set
* @param {String} dataPrefix prefix to `nameKey` on where to get the data
* @param {String} nestedKey key nested in the data set to be extracted,
* this is also used to rename the newly created data set
* @param {Object} options
* @param {String} options.renameKey? optional rename key, if not provided nestedKey will be used
* @return {Object} the newly created data set with the extracted values
*/
export function extractValues(data, dataKeys = [], replaceKey, nestedKey) {
return mapKeys(pick(mapValues(data, nestedKey), dataKeys), (value, key) =>
key.replace(replaceKey, nestedKey),
);
export function extractValues(data, nameKeys = [], dataPrefix, nestedKey, options = {}) {
const { renameKey = nestedKey } = options;
return nameKeys.reduce((memo, name) => {
const titelCaseName = convertToTitleCase(name);
const dataKey = `${dataPrefix}${titelCaseName}`;
const newKey = `${renameKey}${titelCaseName}`;
const itemData = get(data[dataKey], nestedKey);
return { ...memo, [newKey]: itemData };
}, {});
}
/**
......
......@@ -14140,15 +14140,27 @@ msgstr ""
msgid "InstanceAnalytics|Canceled"
msgstr ""
msgid "InstanceAnalytics|Could not load the issues and merge requests chart. Please refresh the page to try again."
msgstr ""
msgid "InstanceAnalytics|Could not load the pipelines chart. Please refresh the page to try again."
msgstr ""
msgid "InstanceAnalytics|Failed"
msgstr ""
msgid "InstanceAnalytics|Issues"
msgstr ""
msgid "InstanceAnalytics|Issues & Merge Requests"
msgstr ""
msgid "InstanceAnalytics|Items"
msgstr ""
msgid "InstanceAnalytics|Merge Requests"
msgstr ""
msgid "InstanceAnalytics|Month"
msgstr ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PipelinesChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = `
exports[`InstanceStatisticsCountChart when fetching more data when the fetchMore query returns data passes the data to the line chart 1`] = `
Array [
Object {
"data": Array [
......@@ -90,7 +90,7 @@ Array [
]
`;
exports[`PipelinesChart with data passes the data to the line chart 1`] = `
exports[`InstanceStatisticsCountChart with data passes the data to the line chart 1`] = `
Array [
Object {
"data": Array [
......
import { shallowMount } from '@vue/test-utils';
import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue';
import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue';
import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue';
import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue';
import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue';
describe('InstanceStatisticsApp', () => {
......@@ -24,8 +24,11 @@ describe('InstanceStatisticsApp', () => {
expect(wrapper.find(InstanceCounts).exists()).toBe(true);
});
it('displays the pipelines chart component', () => {
expect(wrapper.find(PipelinesChart).exists()).toBe(true);
it('displays the instance statistics count chart component', () => {
const allCharts = wrapper.findAll(InstanceStatisticsCountChart);
expect(allCharts).toHaveLength(2);
expect(allCharts.at(0).exists()).toBe(true);
expect(allCharts.at(1).exists()).toBe(true);
});
it('displays the users chart component', () => {
......
......@@ -3,7 +3,7 @@ import { GlLineChart } from '@gitlab/ui/dist/charts';
import { GlAlert } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue';
import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_chart.vue';
import pipelinesStatsQuery from '~/analytics/instance_statistics/graphql/queries/pipeline_stats.query.graphql';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { mockCountsData1, mockCountsData2 } from '../mock_data';
......@@ -12,7 +12,17 @@ import { getApolloResponse } from '../apollo_mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('PipelinesChart', () => {
const PIPELINES_KEY_TO_NAME_MAP = {
total: 'Total',
succeeded: 'Succeeded',
failed: 'Failed',
canceled: 'Canceled',
skipped: 'Skipped',
};
const loadChartErrorMessage = 'My load error message';
const noDataMessage = 'My no data message';
describe('InstanceStatisticsCountChart', () => {
let wrapper;
let queryHandler;
......@@ -21,9 +31,19 @@ describe('PipelinesChart', () => {
};
const createComponent = apolloProvider => {
return shallowMount(PipelinesChart, {
return shallowMount(InstanceStatisticsCountChart, {
localVue,
apolloProvider,
propsData: {
keyToNameMap: PIPELINES_KEY_TO_NAME_MAP,
prefix: 'pipelines',
loadChartErrorMessage,
noDataMessage,
chartTitle: 'Foo',
yAxisTitle: 'Bar',
xAxisTitle: 'Baz',
query: pipelinesStatsQuery,
},
});
};
......@@ -69,7 +89,7 @@ describe('PipelinesChart', () => {
});
it('renders an no data message', () => {
expect(findAlert().text()).toBe('There is no data available.');
expect(findAlert().text()).toBe(noDataMessage);
});
it('hides the skeleton loader', () => {
......@@ -180,9 +200,7 @@ describe('PipelinesChart', () => {
});
it('show an error message', () => {
expect(findAlert().text()).toBe(
'Could not load the pipelines chart. Please refresh the page to try again.',
);
expect(findAlert().text()).toBe(loadChartErrorMessage);
});
});
});
......
......@@ -47,7 +47,21 @@ describe('getAverageByMonth', () => {
describe('extractValues', () => {
it('extracts only requested values', () => {
const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' };
expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({ bazBar: 'quis' });
expect(extractValues(data, ['bar'], 'foo', 'baz')).toEqual({ bazBar: 'quis' });
});
it('it renames with the `renameKey` if provided', () => {
const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' };
expect(extractValues(data, ['bar'], 'foo', 'baz', { renameKey: 'renamed' })).toEqual({
renamedBar: 'quis',
});
});
it('is able to get nested data', () => {
const data = { fooBar: { even: [{ further: 'nested' }] }, ignored: 'ignored' };
expect(extractValues(data, ['bar'], 'foo', 'even[0].further')).toEqual({
'even[0].furtherBar': 'nested',
});
});
it('is able to extract multiple values', () => {
......@@ -56,7 +70,7 @@ describe('extractValues', () => {
fooBaz: { baz: 'quis' },
fooQuis: { baz: 'quis' },
};
expect(extractValues(data, ['fooBar', 'fooBaz', 'fooQuis'], 'foo', 'baz')).toEqual({
expect(extractValues(data, ['bar', 'baz', 'quis'], 'foo', 'baz')).toEqual({
bazBar: 'quis',
bazBaz: 'quis',
bazQuis: 'quis',
......@@ -65,7 +79,7 @@ describe('extractValues', () => {
it('returns empty data set when keys are not found', () => {
const data = { foo: { baz: 'quis' }, ignored: 'ignored' };
expect(extractValues(data, ['fooBar'], 'foo', 'baz')).toEqual({});
expect(extractValues(data, ['bar'], 'foo', 'baz')).toEqual({});
});
it('returns empty data when params are missing', () => {
......
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