Commit 497143d0 authored by Brandon Labuschagne's avatar Brandon Labuschagne

Merge branch 'ek-merge-stats' into 'master'

Merge instance stats queries and use generic component

See merge request gitlab-org/gitlab!45885
parents 2c4cdee7 dce5c53b
<script> <script>
import { s__ } from '~/locale';
import InstanceCounts from './instance_counts.vue'; import InstanceCounts from './instance_counts.vue';
import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue'; import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue';
import UsersChart from './users_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 ProjectsAndGroupsChart from './projects_and_groups_chart.vue'; import ProjectsAndGroupsChart from './projects_and_groups_chart.vue';
import ChartsConfig from './charts_config';
import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants'; 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 { export default {
name: 'InstanceStatisticsApp', name: 'InstanceStatisticsApp',
components: { components: {
...@@ -38,28 +17,7 @@ export default { ...@@ -38,28 +17,7 @@ export default {
TOTAL_DAYS_TO_SHOW, TOTAL_DAYS_TO_SHOW,
START_DATE, START_DATE,
TODAY, TODAY,
configs: [ configs: ChartsConfig,
{
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> </script>
...@@ -79,9 +37,7 @@ export default { ...@@ -79,9 +37,7 @@ export default {
<instance-statistics-count-chart <instance-statistics-count-chart
v-for="chartOptions in $options.configs" v-for="chartOptions in $options.configs"
:key="chartOptions.chartTitle" :key="chartOptions.chartTitle"
:prefix="chartOptions.prefix" :queries="chartOptions.queries"
:key-to-name-map="chartOptions.keyToNameMap"
:query="chartOptions.query"
:x-axis-title="chartOptions.xAxisTitle" :x-axis-title="chartOptions.xAxisTitle"
:y-axis-title="chartOptions.yAxisTitle" :y-axis-title="chartOptions.yAxisTitle"
:load-chart-error-message="chartOptions.loadChartError" :load-chart-error-message="chartOptions.loadChartError"
......
import { s__, __, sprintf } from '~/locale';
import query from '../graphql/queries/instance_count.query.graphql';
const noDataMessage = s__('InstanceStatistics|No data available.');
export default [
{
loadChartError: sprintf(
s__(
'InstanceStatistics|Could not load the pipelines chart. Please refresh the page to try again.',
),
),
noDataMessage,
chartTitle: s__('InstanceStatistics|Pipelines'),
yAxisTitle: s__('InstanceStatistics|Items'),
xAxisTitle: s__('InstanceStatistics|Month'),
queries: [
{
query,
title: s__('InstanceStatistics|Pipelines total'),
identifier: 'PIPELINES',
loadError: sprintf(
s__('InstanceStatistics|There was an error fetching the total pipelines'),
),
},
{
query,
title: s__('InstanceStatistics|Pipelines succeeded'),
identifier: 'PIPELINES_SUCCEEDED',
loadError: sprintf(
s__('InstanceStatistics|There was an error fetching the successful pipelines'),
),
},
{
query,
title: s__('InstanceStatistics|Pipelines failed'),
identifier: 'PIPELINES_FAILED',
loadError: sprintf(
s__('InstanceStatistics|There was an error fetching the failed pipelines'),
),
},
{
query,
title: s__('InstanceStatistics|Pipelines canceled'),
identifier: 'PIPELINES_CANCELED',
loadError: sprintf(
s__('InstanceStatistics|There was an error fetching the cancelled pipelines'),
),
},
{
query,
title: s__('InstanceStatistics|Pipelines skipped'),
identifier: 'PIPELINES_SKIPPED',
loadError: sprintf(
s__('InstanceStatistics|There was an error fetching the skipped pipelines'),
),
},
],
},
{
loadChartError: sprintf(
s__(
'InstanceStatistics|Could not load the issues and merge requests chart. Please refresh the page to try again.',
),
),
noDataMessage,
chartTitle: s__('InstanceStatistics|Issues & Merge Requests'),
yAxisTitle: s__('InstanceStatistics|Items'),
xAxisTitle: s__('InstanceStatistics|Month'),
queries: [
{
query,
title: __('Issues'),
identifier: 'ISSUES',
loadError: sprintf(s__('InstanceStatistics|There was an error fetching the issues')),
},
{
query,
title: __('Merge requests'),
identifier: 'MERGE_REQUESTS',
loadError: sprintf(
s__('InstanceStatistics|There was an error fetching the merge requests'),
),
},
],
},
];
<script> <script>
import { GlLineChart } from '@gitlab/ui/dist/charts'; import { GlLineChart } from '@gitlab/ui/dist/charts';
import { GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import { mapValues, some, sum } from 'lodash'; import { some, every } from 'lodash';
import * as Sentry from '~/sentry/wrapper';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { import {
differenceInMonths, differenceInMonths,
formatDateAsMonth, formatDateAsMonth,
getDayDifference, getDayDifference,
} from '~/lib/utils/datetime_utility'; } from '~/lib/utils/datetime_utility';
import { convertToTitleCase } from '~/lib/utils/text_utility'; import { getAverageByMonth, getEarliestDate, generateDataKeys } from '../utils';
import { getAverageByMonth, sortByDate, extractValues } from '../utils';
import { TODAY, START_DATE } from '../constants'; import { TODAY, START_DATE } from '../constants';
const QUERY_DATA_KEY = 'instanceStatisticsMeasurements';
export default { export default {
name: 'InstanceStatisticsCountChart', name: 'InstanceStatisticsCountChart',
components: { components: {
...@@ -21,18 +23,7 @@ export default { ...@@ -21,18 +23,7 @@ export default {
}, },
startDate: START_DATE, startDate: START_DATE,
endDate: TODAY, endDate: TODAY,
dataKey: 'nodes',
pageInfoKey: 'pageInfo',
firstKey: 'first',
props: { props: {
prefix: {
type: String,
required: true,
},
keyToNameMap: {
type: Object,
required: true,
},
chartTitle: { chartTitle: {
type: String, type: String,
required: true, required: true,
...@@ -53,112 +44,46 @@ export default { ...@@ -53,112 +44,46 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
query: { queries: {
type: Object, type: Array,
required: true, required: true,
}, },
}, },
data() { data() {
return { return {
loading: true, errors: { ...generateDataKeys(this.queries, '') },
loadingError: null, ...generateDataKeys(this.queries, []),
}; };
}, },
apollo: {
pipelineStats: {
query() {
return this.query;
},
variables() {
return this.nameKeys.reduce((memo, key) => {
const firstKey = `${this.$options.firstKey}${convertToTitleCase(key)}`;
return { ...memo, [firstKey]: this.totalDaysToShow };
}, {});
},
update(data) {
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),
...allPageInfo,
};
},
result() {
if (this.hasNextPage) {
this.fetchNextPage();
}
},
error() {
this.handleError();
},
},
},
computed: { computed: {
nameKeys() { errorMessages() {
return Object.keys(this.keyToNameMap); return Object.values(this.errors);
}, },
isLoading() { isLoading() {
return this.$apollo.queries.pipelineStats.loading; return some(this.$apollo.queries, query => query?.loading);
}, },
totalDaysToShow() { allQueriesFailed() {
return getDayDifference(this.$options.startDate, this.$options.endDate); return every(this.errorMessages, message => message.length);
}, },
firstVariables() { hasLoadingErrors() {
const firstDataPoints = extractValues( return some(this.errorMessages, message => message.length);
this.pipelineStats, },
this.nameKeys, errorMessage() {
this.$options.dataKey, // show the generic loading message if all requests fail
'[0].recordedAt', return this.allQueriesFailed ? this.loadChartErrorMessage : this.errorMessages.join('\n\n');
{ renameKey: this.$options.firstKey },
);
return Object.keys(firstDataPoints).reduce((memo, name) => {
const recordedAt = firstDataPoints[name];
if (!recordedAt) {
return { ...memo, [name]: 0 };
}
const numberOfDays = Math.max(
0,
getDayDifference(this.$options.startDate, new Date(recordedAt)),
);
return { ...memo, [name]: numberOfDays };
}, {});
},
cursorVariables() {
return extractValues(
this.pipelineStats,
this.nameKeys,
this.$options.pageInfoKey,
'endCursor',
);
},
hasNextPage() {
return (
sum(Object.values(this.firstVariables)) > 0 &&
some(this.pipelineStats, ({ hasNextPage }) => hasNextPage)
);
}, },
hasEmptyDataSet() { hasEmptyDataSet() {
return this.chartData.every(({ data }) => data.length === 0); return this.chartData.every(({ data }) => data.length === 0);
}, },
totalDaysToShow() {
return getDayDifference(this.$options.startDate, this.$options.endDate);
},
chartData() { chartData() {
const options = { shouldRound: true }; const options = { shouldRound: true };
return this.queries.map(({ identifier, title }) => ({
return this.nameKeys.map(key => { name: title,
const dataKey = `${this.$options.dataKey}${convertToTitleCase(key)}`; data: getAverageByMonth(this[identifier]?.nodes, options),
return { }));
name: this.keyToNameMap[key],
data: getAverageByMonth(this.pipelineStats?.[dataKey], options),
};
});
}, },
range() { range() {
return { return {
...@@ -188,26 +113,73 @@ export default { ...@@ -188,26 +113,73 @@ export default {
}; };
}, },
}, },
created() {
this.queries.forEach(({ query, identifier, loadError }) => {
this.$apollo.addSmartQuery(identifier, {
query,
variables() {
return {
identifier,
first: this.totalDaysToShow,
after: null,
};
},
update(data) {
const { nodes = [], pageInfo } = data[QUERY_DATA_KEY] || {};
return {
nodes,
pageInfo,
};
},
result() {
const { pageInfo, nodes } = this[identifier];
if (pageInfo?.hasNextPage && this.calculateDaysToFetch(getEarliestDate(nodes)) > 0) {
this.fetchNextPage({
query: this.$apollo.queries[identifier],
errorMessage: loadError,
pageInfo,
identifier,
});
}
},
error(error) {
this.handleError({
message: loadError,
identifier,
error,
});
},
});
});
},
methods: { methods: {
handleError() { calculateDaysToFetch(firstDataPointDate = null) {
return firstDataPointDate
? Math.max(0, getDayDifference(this.$options.startDate, new Date(firstDataPointDate)))
: 0;
},
handleError({ identifier, error, message }) {
this.loadingError = true; this.loadingError = true;
this.errors = { ...this.errors, [identifier]: message };
Sentry.captureException(error);
}, },
fetchNextPage() { fetchNextPage({ query, pageInfo, identifier, errorMessage }) {
this.$apollo.queries.pipelineStats query
.fetchMore({ .fetchMore({
variables: { variables: {
...this.firstVariables, identifier,
...this.cursorVariables, first: this.calculateDaysToFetch(getEarliestDate(this[identifier].nodes)),
after: pageInfo.endCursor,
}, },
updateQuery: (previousResult, { fetchMoreResult }) => { updateQuery: (previousResult, { fetchMoreResult }) => {
return Object.keys(fetchMoreResult).reduce((memo, key) => { const { nodes, ...rest } = fetchMoreResult[QUERY_DATA_KEY];
const { nodes, ...rest } = fetchMoreResult[key]; const { nodes: previousNodes } = previousResult[QUERY_DATA_KEY];
const previousNodes = previousResult[key].nodes; return {
return { ...memo, [key]: { ...rest, nodes: [...previousNodes, ...nodes] } }; [QUERY_DATA_KEY]: { ...rest, nodes: [...previousNodes, ...nodes] },
}, {}); };
}, },
}) })
.catch(this.handleError); .catch(error => this.handleError({ identifier, error, message: errorMessage }));
}, },
}, },
}; };
...@@ -215,13 +187,20 @@ export default { ...@@ -215,13 +187,20 @@ export default {
<template> <template>
<div> <div>
<h3>{{ chartTitle }}</h3> <h3>{{ chartTitle }}</h3>
<gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3"> <gl-alert v-if="hasLoadingErrors" variant="danger" :dismissible="false" class="gl-mt-3">
{{ loadChartErrorMessage }} {{ errorMessage }}
</gl-alert>
<chart-skeleton-loader v-else-if="isLoading" />
<gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3">
{{ noDataMessage }}
</gl-alert> </gl-alert>
<gl-line-chart v-else :option="chartOptions" :include-legend-avg-max="true" :data="chartData" /> <div v-if="!allQueriesFailed">
<chart-skeleton-loader v-if="isLoading" />
<gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3">
{{ noDataMessage }}
</gl-alert>
<gl-line-chart
v-else
:option="chartOptions"
:include-legend-avg-max="true"
:data="chartData"
/>
</div>
</div> </div>
</template> </template>
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/count.fragment.graphql"
query getCount($identifier: MeasurementIdentifier!, $first: Int, $after: String) {
instanceStatisticsMeasurements(identifier: $identifier, first: $first, after: $after) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
}
#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 "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "./count.fragment.graphql"
query pipelineStats(
$firstTotal: Int
$firstSucceeded: Int
$firstFailed: Int
$firstCanceled: Int
$firstSkipped: Int
$endCursorTotal: String
$endCursorSucceeded: String
$endCursorFailed: String
$endCursorCanceled: String
$endCursorSkipped: String
) {
pipelinesTotal: instanceStatisticsMeasurements(
identifier: PIPELINES
first: $firstTotal
after: $endCursorTotal
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
pipelinesSucceeded: instanceStatisticsMeasurements(
identifier: PIPELINES_SUCCEEDED
first: $firstSucceeded
after: $endCursorSucceeded
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
pipelinesFailed: instanceStatisticsMeasurements(
identifier: PIPELINES_FAILED
first: $firstFailed
after: $endCursorFailed
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
pipelinesCanceled: instanceStatisticsMeasurements(
identifier: PIPELINES_CANCELED
first: $firstCanceled
after: $endCursorCanceled
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
pipelinesSkipped: instanceStatisticsMeasurements(
identifier: PIPELINES_SKIPPED
first: $firstSkipped
after: $endCursorSkipped
) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
}
import { masks } from 'dateformat'; import { masks } from 'dateformat';
import { get, sortBy } from 'lodash'; import { get } from 'lodash';
import { formatDate } from '~/lib/utils/datetime_utility'; import { formatDate } from '~/lib/utils/datetime_utility';
import { convertToTitleCase } from '~/lib/utils/text_utility';
const { isoDate } = masks; const { isoDate } = masks;
...@@ -42,38 +41,28 @@ export function getAverageByMonth(items = [], options = {}) { ...@@ -42,38 +41,28 @@ export function getAverageByMonth(items = [], options = {}) {
} }
/** /**
* Extracts values given a data set and a set of keys * Takes an array of instance counts and returns the last item in the list
* @example * @param {Array} arr array of instance counts in the form { count: Number, recordedAt: date String }
* const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' }; * @return {String} the 'recordedAt' value of the earliest item
* extractValues(data, ['fooBar'], 'foo', 'baz') => { bazBar: 'quis' }
* @param {Object} data set to extract values from
* @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, nameKeys = [], dataPrefix, nestedKey, options = {}) { export const getEarliestDate = (arr = []) => {
const { renameKey = nestedKey } = options; const len = arr.length;
return get(arr, `[${len - 1}].recordedAt`, null);
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 };
}, {});
}
/** /**
* Creates a new array of items sorted by the date string of each item * Takes an array of queries and produces an object with the query identifier as key
* @param {Array} items [description] * and a supplied defaultValue as its value
* @param {String} items[0] date string * @param {Array} queries array of chart query configs,
* @return {Array} the new sorted array. * see ./analytics/instance_statistics/components/charts_config.js
* @param {any} defaultValue value to set each identifier to
* @return {Object} key value pair of the form { queryIdentifier: defaultValue }
*/ */
export function sortByDate(items = []) { export const generateDataKeys = (queries, defaultValue) =>
return sortBy(items, ({ recordedAt }) => new Date(recordedAt).getTime()); queries.reduce(
} (acc, { identifier }) => ({
...acc,
[identifier]: defaultValue,
}),
{},
);
...@@ -14416,67 +14416,76 @@ msgstr "" ...@@ -14416,67 +14416,76 @@ msgstr ""
msgid "Instance administrators group already exists" msgid "Instance administrators group already exists"
msgstr "" msgstr ""
msgid "InstanceAnalytics|Canceled" msgid "InstanceStatistics|Could not load the issues and merge requests chart. Please refresh the page to try again."
msgstr "" msgstr ""
msgid "InstanceAnalytics|Could not load the issues and merge requests chart. Please refresh the page to try again." msgid "InstanceStatistics|Could not load the pipelines chart. Please refresh the page to try again."
msgstr "" msgstr ""
msgid "InstanceAnalytics|Could not load the pipelines chart. Please refresh the page to try again." msgid "InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again."
msgstr "" msgstr ""
msgid "InstanceAnalytics|Failed" msgid "InstanceStatistics|Groups"
msgstr "" msgstr ""
msgid "InstanceAnalytics|Issues" msgid "InstanceStatistics|Issues"
msgstr "" msgstr ""
msgid "InstanceAnalytics|Issues & Merge Requests" msgid "InstanceStatistics|Issues & Merge Requests"
msgstr "" msgstr ""
msgid "InstanceAnalytics|Items" msgid "InstanceStatistics|Items"
msgstr "" msgstr ""
msgid "InstanceAnalytics|Merge Requests" msgid "InstanceStatistics|Merge Requests"
msgstr "" msgstr ""
msgid "InstanceAnalytics|Month" msgid "InstanceStatistics|Month"
msgstr "" msgstr ""
msgid "InstanceAnalytics|Pipelines" msgid "InstanceStatistics|No data available."
msgstr "" msgstr ""
msgid "InstanceAnalytics|Skipped" msgid "InstanceStatistics|Pipelines"
msgstr "" msgstr ""
msgid "InstanceAnalytics|Succeeded" msgid "InstanceStatistics|Pipelines canceled"
msgstr "" msgstr ""
msgid "InstanceAnalytics|There is no data available." msgid "InstanceStatistics|Pipelines failed"
msgstr "" msgstr ""
msgid "InstanceAnalytics|Total" msgid "InstanceStatistics|Pipelines skipped"
msgstr "" msgstr ""
msgid "InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again." msgid "InstanceStatistics|Pipelines succeeded"
msgstr "" msgstr ""
msgid "InstanceStatistics|Groups" msgid "InstanceStatistics|Pipelines total"
msgstr "" msgstr ""
msgid "InstanceStatistics|Issues" msgid "InstanceStatistics|Projects"
msgstr "" msgstr ""
msgid "InstanceStatistics|Merge Requests" msgid "InstanceStatistics|There was an error fetching the cancelled pipelines"
msgstr "" msgstr ""
msgid "InstanceStatistics|No data available." msgid "InstanceStatistics|There was an error fetching the failed pipelines"
msgstr "" msgstr ""
msgid "InstanceStatistics|Pipelines" msgid "InstanceStatistics|There was an error fetching the issues"
msgstr "" msgstr ""
msgid "InstanceStatistics|Projects" msgid "InstanceStatistics|There was an error fetching the merge requests"
msgstr ""
msgid "InstanceStatistics|There was an error fetching the skipped pipelines"
msgstr ""
msgid "InstanceStatistics|There was an error fetching the successful pipelines"
msgstr ""
msgid "InstanceStatistics|There was an error fetching the total pipelines"
msgstr "" msgstr ""
msgid "InstanceStatistics|There was an error while loading the groups" msgid "InstanceStatistics|There was an error while loading the groups"
......
...@@ -5,36 +5,7 @@ const defaultPageInfo = { ...@@ -5,36 +5,7 @@ const defaultPageInfo = {
endCursor: null, endCursor: null,
}; };
export function getApolloResponse(options = {}) { export const mockApolloResponse = ({ hasNextPage = false, key, data }) => ({
const {
pipelinesTotal = [],
pipelinesSucceeded = [],
pipelinesFailed = [],
pipelinesCanceled = [],
pipelinesSkipped = [],
hasNextPage = false,
} = options;
return {
data: {
pipelinesTotal: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesTotal },
pipelinesSucceeded: {
pageInfo: { ...defaultPageInfo, hasNextPage },
nodes: pipelinesSucceeded,
},
pipelinesFailed: { pageInfo: { ...defaultPageInfo, hasNextPage }, nodes: pipelinesFailed },
pipelinesCanceled: {
pageInfo: { ...defaultPageInfo, hasNextPage },
nodes: pipelinesCanceled,
},
pipelinesSkipped: {
pageInfo: { ...defaultPageInfo, hasNextPage },
nodes: pipelinesSkipped,
},
},
};
}
const mockApolloResponse = ({ hasNextPage = false, key, data }) => ({
data: { data: {
[key]: { [key]: {
pageInfo: { ...defaultPageInfo, hasNextPage }, pageInfo: { ...defaultPageInfo, hasNextPage },
...@@ -43,13 +14,8 @@ const mockApolloResponse = ({ hasNextPage = false, key, data }) => ({ ...@@ -43,13 +14,8 @@ const mockApolloResponse = ({ hasNextPage = false, key, data }) => ({
}, },
}); });
export const mockQueryResponse = ({ export const mockQueryResponse = ({ key, data = [], loading = false, additionalData = [] }) => {
key, const hasNextPage = Boolean(additionalData.length);
data = [],
loading = false,
hasNextPage = false,
additionalData = [],
}) => {
const response = mockApolloResponse({ hasNextPage, key, data }); const response = mockApolloResponse({ hasNextPage, key, data });
if (loading) { if (loading) {
return jest.fn().mockReturnValue(new Promise(() => {})); return jest.fn().mockReturnValue(new Promise(() => {}));
......
...@@ -4,88 +4,20 @@ exports[`InstanceStatisticsCountChart when fetching more data when the fetchMore ...@@ -4,88 +4,20 @@ exports[`InstanceStatisticsCountChart when fetching more data when the fetchMore
Array [ Array [
Object { Object {
"data": Array [ "data": Array [
Array [
"2020-06-01",
21,
],
Array [
"2020-07-01",
10,
],
Array [
"2020-08-01",
5,
],
],
"name": "Total",
},
Object {
"data": Array [
Array [
"2020-06-01",
21,
],
Array [
"2020-07-01",
10,
],
Array [
"2020-08-01",
5,
],
],
"name": "Succeeded",
},
Object {
"data": Array [
Array [
"2020-06-01",
22,
],
Array [ Array [
"2020-07-01", "2020-07-01",
41, 41,
], ],
Array [
"2020-08-01",
5,
],
],
"name": "Failed",
},
Object {
"data": Array [
Array [
"2020-06-01",
21,
],
Array [
"2020-07-01",
10,
],
Array [
"2020-08-01",
5,
],
],
"name": "Canceled",
},
Object {
"data": Array [
Array [ Array [
"2020-06-01", "2020-06-01",
21, 22,
],
Array [
"2020-07-01",
10,
], ],
Array [ Array [
"2020-08-01", "2020-08-01",
5, 5,
], ],
], ],
"name": "Skipped", "name": "Mock Query",
}, },
] ]
`; `;
...@@ -94,68 +26,16 @@ exports[`InstanceStatisticsCountChart with data passes the data to the line char ...@@ -94,68 +26,16 @@ exports[`InstanceStatisticsCountChart with data passes the data to the line char
Array [ Array [
Object { Object {
"data": Array [ "data": Array [
Array [
"2020-06-01",
22,
],
Array [ Array [
"2020-07-01", "2020-07-01",
41, 41,
], ],
],
"name": "Total",
},
Object {
"data": Array [
Array [
"2020-06-01",
21,
],
Array [
"2020-07-01",
10,
],
],
"name": "Succeeded",
},
Object {
"data": Array [
Array [
"2020-06-01",
21,
],
Array [
"2020-07-01",
10,
],
],
"name": "Failed",
},
Object {
"data": Array [
Array [ Array [
"2020-06-01", "2020-06-01",
22, 22,
], ],
Array [
"2020-07-01",
41,
],
],
"name": "Canceled",
},
Object {
"data": Array [
Array [
"2020-06-01",
22,
],
Array [
"2020-07-01",
41,
],
], ],
"name": "Skipped", "name": "Mock Query",
}, },
] ]
`; `;
...@@ -25,11 +25,14 @@ describe('InstanceStatisticsApp', () => { ...@@ -25,11 +25,14 @@ describe('InstanceStatisticsApp', () => {
expect(wrapper.find(InstanceCounts).exists()).toBe(true); expect(wrapper.find(InstanceCounts).exists()).toBe(true);
}); });
it('displays the instance statistics count chart component', () => { ['Pipelines', 'Issues & Merge Requests'].forEach(instance => {
const allCharts = wrapper.findAll(InstanceStatisticsCountChart); it(`displays the ${instance} chart`, () => {
expect(allCharts).toHaveLength(2); const chartTitles = wrapper
expect(allCharts.at(0).exists()).toBe(true); .findAll(InstanceStatisticsCountChart)
expect(allCharts.at(1).exists()).toBe(true); .wrappers.map(chartComponent => chartComponent.props('chartTitle'));
expect(chartTitles).toContain(instance);
});
}); });
it('displays the users chart component', () => { it('displays the users chart component', () => {
......
...@@ -4,46 +4,44 @@ import { GlAlert } from '@gitlab/ui'; ...@@ -4,46 +4,44 @@ import { GlAlert } from '@gitlab/ui';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'jest/helpers/mock_apollo_helper'; import createMockApollo from 'jest/helpers/mock_apollo_helper';
import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_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 statsQuery from '~/analytics/instance_statistics/graphql/queries/instance_count.query.graphql';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { mockCountsData1, mockCountsData2 } from '../mock_data'; import { mockCountsData1 } from '../mock_data';
import { getApolloResponse } from '../apollo_mock_data'; import { mockQueryResponse, mockApolloResponse } from '../apollo_mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
const PIPELINES_KEY_TO_NAME_MAP = {
total: 'Total',
succeeded: 'Succeeded',
failed: 'Failed',
canceled: 'Canceled',
skipped: 'Skipped',
};
const loadChartErrorMessage = 'My load error message'; const loadChartErrorMessage = 'My load error message';
const noDataMessage = 'My no data message'; const noDataMessage = 'My no data message';
const queryResponseDataKey = 'instanceStatisticsMeasurements';
const identifier = 'MOCK_QUERY';
const mockQueryConfig = {
identifier,
title: 'Mock Query',
query: statsQuery,
loadError: 'Failed to load mock query data',
};
const mockChartConfig = {
loadChartErrorMessage,
noDataMessage,
chartTitle: 'Foo',
yAxisTitle: 'Bar',
xAxisTitle: 'Baz',
queries: [mockQueryConfig],
};
describe('InstanceStatisticsCountChart', () => { describe('InstanceStatisticsCountChart', () => {
let wrapper; let wrapper;
let queryHandler; let queryHandler;
const createApolloProvider = pipelineStatsHandler => { const createComponent = ({ responseHandler }) => {
return createMockApollo([[pipelinesStatsQuery, pipelineStatsHandler]]);
};
const createComponent = apolloProvider => {
return shallowMount(InstanceStatisticsCountChart, { return shallowMount(InstanceStatisticsCountChart, {
localVue, localVue,
apolloProvider, apolloProvider: createMockApollo([[statsQuery, responseHandler]]),
propsData: { propsData: { ...mockChartConfig },
keyToNameMap: PIPELINES_KEY_TO_NAME_MAP,
prefix: 'pipelines',
loadChartErrorMessage,
noDataMessage,
chartTitle: 'Foo',
yAxisTitle: 'Bar',
xAxisTitle: 'Baz',
query: pipelinesStatsQuery,
},
}); });
}; };
...@@ -58,9 +56,8 @@ describe('InstanceStatisticsCountChart', () => { ...@@ -58,9 +56,8 @@ describe('InstanceStatisticsCountChart', () => {
describe('while loading', () => { describe('while loading', () => {
beforeEach(() => { beforeEach(() => {
queryHandler = jest.fn().mockReturnValue(new Promise(() => {})); queryHandler = mockQueryResponse({ key: queryResponseDataKey, loading: true });
const apolloProvider = createApolloProvider(queryHandler); wrapper = createComponent({ responseHandler: queryHandler });
wrapper = createComponent(apolloProvider);
}); });
it('requests data', () => { it('requests data', () => {
...@@ -82,10 +79,8 @@ describe('InstanceStatisticsCountChart', () => { ...@@ -82,10 +79,8 @@ describe('InstanceStatisticsCountChart', () => {
describe('without data', () => { describe('without data', () => {
beforeEach(() => { beforeEach(() => {
const emptyResponse = getApolloResponse(); queryHandler = mockQueryResponse({ key: queryResponseDataKey, data: [] });
queryHandler = jest.fn().mockResolvedValue(emptyResponse); wrapper = createComponent({ responseHandler: queryHandler });
const apolloProvider = createApolloProvider(queryHandler);
wrapper = createComponent(apolloProvider);
}); });
it('renders an no data message', () => { it('renders an no data message', () => {
...@@ -103,16 +98,8 @@ describe('InstanceStatisticsCountChart', () => { ...@@ -103,16 +98,8 @@ describe('InstanceStatisticsCountChart', () => {
describe('with data', () => { describe('with data', () => {
beforeEach(() => { beforeEach(() => {
const response = getApolloResponse({ queryHandler = mockQueryResponse({ key: queryResponseDataKey, data: mockCountsData1 });
pipelinesTotal: mockCountsData1, wrapper = createComponent({ responseHandler: queryHandler });
pipelinesSucceeded: mockCountsData2,
pipelinesFailed: mockCountsData2,
pipelinesCanceled: mockCountsData1,
pipelinesSkipped: mockCountsData1,
});
queryHandler = jest.fn().mockResolvedValue(response);
const apolloProvider = createApolloProvider(queryHandler);
wrapper = createComponent(apolloProvider);
}); });
it('requests data', () => { it('requests data', () => {
...@@ -140,30 +127,14 @@ describe('InstanceStatisticsCountChart', () => { ...@@ -140,30 +127,14 @@ describe('InstanceStatisticsCountChart', () => {
const recordedAt = '2020-08-01'; const recordedAt = '2020-08-01';
describe('when the fetchMore query returns data', () => { describe('when the fetchMore query returns data', () => {
beforeEach(async () => { beforeEach(async () => {
const newData = { recordedAt, count: 5 }; const newData = [{ recordedAt, count: 5 }];
const firstResponse = getApolloResponse({ queryHandler = mockQueryResponse({
pipelinesTotal: mockCountsData2, key: queryResponseDataKey,
pipelinesSucceeded: mockCountsData2, data: mockCountsData1,
pipelinesFailed: mockCountsData1, additionalData: newData,
pipelinesCanceled: mockCountsData2,
pipelinesSkipped: mockCountsData2,
hasNextPage: true,
});
const secondResponse = getApolloResponse({
pipelinesTotal: [newData],
pipelinesSucceeded: [newData],
pipelinesFailed: [newData],
pipelinesCanceled: [newData],
pipelinesSkipped: [newData],
hasNextPage: false,
}); });
queryHandler = jest
.fn()
.mockResolvedValueOnce(firstResponse)
.mockResolvedValueOnce(secondResponse);
const apolloProvider = createApolloProvider(queryHandler);
wrapper = createComponent(apolloProvider);
wrapper = createComponent({ responseHandler: queryHandler });
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
}); });
...@@ -178,25 +149,24 @@ describe('InstanceStatisticsCountChart', () => { ...@@ -178,25 +149,24 @@ describe('InstanceStatisticsCountChart', () => {
describe('when the fetchMore query throws an error', () => { describe('when the fetchMore query throws an error', () => {
beforeEach(async () => { beforeEach(async () => {
const response = getApolloResponse({ queryHandler = jest.fn().mockResolvedValueOnce(
pipelinesTotal: mockCountsData2, mockApolloResponse({
pipelinesSucceeded: mockCountsData2, key: queryResponseDataKey,
pipelinesFailed: mockCountsData1, data: mockCountsData1,
pipelinesCanceled: mockCountsData2, hasNextPage: true,
pipelinesSkipped: mockCountsData2, }),
hasNextPage: true, );
});
queryHandler = jest.fn().mockResolvedValue(response); wrapper = createComponent({ responseHandler: queryHandler });
const apolloProvider = createApolloProvider(queryHandler);
wrapper = createComponent(apolloProvider);
jest jest
.spyOn(wrapper.vm.$apollo.queries.pipelineStats, 'fetchMore') .spyOn(wrapper.vm.$apollo.queries[identifier], 'fetchMore')
.mockImplementation(jest.fn().mockRejectedValue()); .mockImplementation(jest.fn().mockRejectedValue());
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
}); });
it('calls fetchMore', () => { it('calls fetchMore', () => {
expect(wrapper.vm.$apollo.queries.pipelineStats.fetchMore).toHaveBeenCalledTimes(1); expect(wrapper.vm.$apollo.queries[identifier].fetchMore).toHaveBeenCalledTimes(1);
}); });
it('show an error message', () => { it('show an error message', () => {
......
...@@ -25,23 +25,21 @@ describe('ProjectsAndGroupChart', () => { ...@@ -25,23 +25,21 @@ describe('ProjectsAndGroupChart', () => {
groups = [], groups = [],
projectsLoading = false, projectsLoading = false,
groupsLoading = false, groupsLoading = false,
projectsHasNextPage = false, projectsAdditionalData = [],
groupsHasNextPage = false, groupsAdditionalData = [],
} = {}) => { } = {}) => {
queryResponses = { queryResponses = {
projects: mockQueryResponse({ projects: mockQueryResponse({
key: 'projects', key: 'projects',
data: projects, data: projects,
loading: projectsLoading, loading: projectsLoading,
hasNextPage: projectsHasNextPage, additionalData: projectsAdditionalData,
additionalData: mockAdditionalData,
}), }),
groups: mockQueryResponse({ groups: mockQueryResponse({
key: 'groups', key: 'groups',
data: groups, data: groups,
loading: groupsLoading, loading: groupsLoading,
hasNextPage: groupsHasNextPage, additionalData: groupsAdditionalData,
additionalData: mockAdditionalData,
}), }),
}; };
...@@ -169,9 +167,9 @@ describe('ProjectsAndGroupChart', () => { ...@@ -169,9 +167,9 @@ describe('ProjectsAndGroupChart', () => {
}); });
describe.each` describe.each`
metric | loadingState | newData metric | loadingState | newData
${'projects'} | ${{ projectsHasNextPage: true }} | ${{ projects: mockCountsData2 }} ${'projects'} | ${{ projectsAdditionalData: mockAdditionalData }} | ${{ projects: mockCountsData2 }}
${'groups'} | ${{ groupsHasNextPage: true }} | ${{ groups: mockCountsData2 }} ${'groups'} | ${{ groupsAdditionalData: mockAdditionalData }} | ${{ groups: mockCountsData2 }}
`('$metric - fetchMore', ({ metric, loadingState, newData }) => { `('$metric - fetchMore', ({ metric, loadingState, newData }) => {
describe('when the fetchMore query returns data', () => { describe('when the fetchMore query returns data', () => {
beforeEach(async () => { beforeEach(async () => {
......
...@@ -7,7 +7,11 @@ import { useFakeDate } from 'helpers/fake_date'; ...@@ -7,7 +7,11 @@ import { useFakeDate } from 'helpers/fake_date';
import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import usersQuery from '~/analytics/instance_statistics/graphql/queries/users.query.graphql'; import usersQuery from '~/analytics/instance_statistics/graphql/queries/users.query.graphql';
import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data'; import {
mockCountsData1,
mockCountsData2,
roundedSortedCountsMonthlyChartData2,
} from '../mock_data';
import { mockQueryResponse } from '../apollo_mock_data'; import { mockQueryResponse } from '../apollo_mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
...@@ -21,9 +25,9 @@ describe('UsersChart', () => { ...@@ -21,9 +25,9 @@ describe('UsersChart', () => {
loadingError = false, loadingError = false,
loading = false, loading = false,
users = [], users = [],
hasNextPage = false, additionalData = [],
} = {}) => { } = {}) => {
queryHandler = mockQueryResponse({ key: 'users', data: users, loading, hasNextPage }); queryHandler = mockQueryResponse({ key: 'users', data: users, loading, additionalData });
return shallowMount(UsersChart, { return shallowMount(UsersChart, {
props: { props: {
...@@ -128,7 +132,7 @@ describe('UsersChart', () => { ...@@ -128,7 +132,7 @@ describe('UsersChart', () => {
beforeEach(async () => { beforeEach(async () => {
wrapper = createComponent({ wrapper = createComponent({
users: mockCountsData2, users: mockCountsData2,
hasNextPage: true, additionalData: mockCountsData1,
}); });
jest.spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore'); jest.spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore');
...@@ -148,7 +152,7 @@ describe('UsersChart', () => { ...@@ -148,7 +152,7 @@ describe('UsersChart', () => {
beforeEach(() => { beforeEach(() => {
wrapper = createComponent({ wrapper = createComponent({
users: mockCountsData2, users: mockCountsData2,
hasNextPage: true, additionalData: mockCountsData1,
}); });
jest jest
......
import { import {
getAverageByMonth, getAverageByMonth,
extractValues, getEarliestDate,
sortByDate, generateDataKeys,
} from '~/analytics/instance_statistics/utils'; } from '~/analytics/instance_statistics/utils';
import { import {
mockCountsData1, mockCountsData1,
...@@ -44,55 +44,38 @@ describe('getAverageByMonth', () => { ...@@ -44,55 +44,38 @@ describe('getAverageByMonth', () => {
}); });
}); });
describe('extractValues', () => { describe('getEarliestDate', () => {
it('extracts only requested values', () => { it('returns the date of the final item in the array', () => {
const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' }; expect(getEarliestDate(mockCountsData1)).toBe('2020-06-12');
expect(extractValues(data, ['bar'], 'foo', 'baz')).toEqual({ bazBar: 'quis' });
}); });
it('it renames with the `renameKey` if provided', () => { it('returns null for an empty array', () => {
const data = { fooBar: { baz: 'quis' }, ignored: 'ignored' }; expect(getEarliestDate([])).toBeNull();
expect(extractValues(data, ['bar'], 'foo', 'baz', { renameKey: 'renamed' })).toEqual({
renamedBar: 'quis',
});
}); });
it('is able to get nested data', () => { it("returns null if the array has data but `recordedAt` isn't defined", () => {
const data = { fooBar: { even: [{ further: 'nested' }] }, ignored: 'ignored' }; expect(
expect(extractValues(data, ['bar'], 'foo', 'even[0].further')).toEqual({ getEarliestDate(mockCountsData1.map(({ recordedAt: date, ...rest }) => ({ date, ...rest }))),
'even[0].furtherBar': 'nested', ).toBeNull();
});
});
it('is able to extract multiple values', () => {
const data = {
fooBar: { baz: 'quis' },
fooBaz: { baz: 'quis' },
fooQuis: { baz: 'quis' },
};
expect(extractValues(data, ['bar', 'baz', 'quis'], 'foo', 'baz')).toEqual({
bazBar: 'quis',
bazBaz: 'quis',
bazQuis: 'quis',
});
}); });
});
it('returns empty data set when keys are not found', () => { describe('generateDataKeys', () => {
const data = { foo: { baz: 'quis' }, ignored: 'ignored' }; const fakeQueries = [
expect(extractValues(data, ['bar'], 'foo', 'baz')).toEqual({}); { identifier: 'from' },
}); { identifier: 'first' },
{ identifier: 'to' },
{ identifier: 'last' },
];
it('returns empty data when params are missing', () => { const defaultValue = 'default value';
expect(extractValues()).toEqual({}); const res = generateDataKeys(fakeQueries, defaultValue);
});
});
describe('sortByDate', () => { it('extracts each query identifier and sets them as object keys', () => {
it('sorts the array by date', () => { expect(Object.keys(res)).toEqual(['from', 'first', 'to', 'last']);
expect(sortByDate(mockCountsData1)).toStrictEqual([...mockCountsData1].reverse());
}); });
it('does not modify the original array', () => { it('sets every value to the `defaultValue` provided', () => {
expect(sortByDate(countsMonthlyChartData1)).not.toBe(countsMonthlyChartData1); expect(Object.values(res)).toEqual(Array(fakeQueries.length).fill(defaultValue));
}); });
}); });
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