Commit a56335b8 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '267538-mlunoe-add-general-instance-statistics-chart-component' into 'master'

Instance Analytics: Add general count chart component

See merge request gitlab-org/gitlab!45561
parents 5b57bea1 b8f91478
<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 };
}, {});
}
/**
......
......@@ -14149,15 +14149,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