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>
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,
errors: { ...generateDataKeys(this.queries, '') },
...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: {
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>
<chart-skeleton-loader v-else-if="isLoading" />
<gl-alert v-else-if="hasEmptyDataSet" variant="info" :dismissible="false" class="gl-mt-3">
{{ noDataMessage }}
<gl-alert v-if="hasLoadingErrors" variant="danger" :dismissible="false" class="gl-mt-3">
{{ errorMessage }}
</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>
</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,
}),
{},
);
......@@ -14416,67 +14416,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';
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', () => {
let wrapper;
let queryHandler;
const createApolloProvider = pipelineStatsHandler => {
return createMockApollo([[pipelinesStatsQuery, pipelineStatsHandler]]);
};
const createComponent = apolloProvider => {
const createComponent = ({ responseHandler }) => {
return shallowMount(InstanceStatisticsCountChart, {
localVue,
apolloProvider,
propsData: {
keyToNameMap: PIPELINES_KEY_TO_NAME_MAP,
prefix: 'pipelines',
loadChartErrorMessage,
noDataMessage,
chartTitle: 'Foo',
yAxisTitle: 'Bar',
xAxisTitle: 'Baz',
query: pipelinesStatsQuery,
},
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 secondResponse = getApolloResponse({
pipelinesTotal: [newData],
pipelinesSucceeded: [newData],
pipelinesFailed: [newData],
pipelinesCanceled: [newData],
pipelinesSkipped: [newData],
hasNextPage: false,
const newData = [{ recordedAt, count: 5 }];
queryHandler = mockQueryResponse({
key: queryResponseDataKey,
data: mockCountsData1,
additionalData: newData,
});
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,
hasNextPage: true,
});
queryHandler = jest.fn().mockResolvedValue(response);
const apolloProvider = createApolloProvider(queryHandler);
wrapper = createComponent(apolloProvider);
queryHandler = jest.fn().mockResolvedValueOnce(
mockApolloResponse({
key: queryResponseDataKey,
data: mockCountsData1,
hasNextPage: true,
}),
);
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,
}),
};
......@@ -169,9 +167,9 @@ describe('ProjectsAndGroupChart', () => {
});
describe.each`
metric | loadingState | newData
${'projects'} | ${{ projectsHasNextPage: true }} | ${{ projects: mockCountsData2 }}
${'groups'} | ${{ groupsHasNextPage: true }} | ${{ groups: mockCountsData2 }}
metric | loadingState | newData
${'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