Commit dce5c53b authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Brandon Labuschagne

Add generic instance chart query

Add generic instance chart data query
and updates the instance statistic chart
component configs

Ensure we track # of data points fetched

Fix instance statistics count specs

Updates the broken specs for the instance
statistics chart component
parent 3f2a31ab
<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,
}),
{},
);
...@@ -14398,67 +14398,76 @@ msgstr "" ...@@ -14398,67 +14398,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