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