Commit bd4c643c authored by Ezekiel Kigbo's avatar Ezekiel Kigbo Committed by Martin Wortschack

Adds the project and groups chart

Adds a line chart with 2 metrics for total
average projects and groups

Update specs for the instance statistics app
parent 11e5fcac
...@@ -5,6 +5,7 @@ import InstanceStatisticsCountChart from './instance_statistics_count_chart.vue' ...@@ -5,6 +5,7 @@ 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 pipelinesStatsQuery from '../graphql/queries/pipeline_stats.query.graphql';
import issuesAndMergeRequestsQuery from '../graphql/queries/issues_and_merge_requests.query.graphql'; import issuesAndMergeRequestsQuery from '../graphql/queries/issues_and_merge_requests.query.graphql';
import ProjectsAndGroupsChart from './projects_and_groups_chart.vue';
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 = { const PIPELINES_KEY_TO_NAME_MAP = {
...@@ -32,6 +33,7 @@ export default { ...@@ -32,6 +33,7 @@ export default {
InstanceCounts, InstanceCounts,
InstanceStatisticsCountChart, InstanceStatisticsCountChart,
UsersChart, UsersChart,
ProjectsAndGroupsChart,
}, },
TOTAL_DAYS_TO_SHOW, TOTAL_DAYS_TO_SHOW,
START_DATE, START_DATE,
...@@ -69,6 +71,11 @@ export default { ...@@ -69,6 +71,11 @@ export default {
:end-date="$options.TODAY" :end-date="$options.TODAY"
:total-data-points="$options.TOTAL_DAYS_TO_SHOW" :total-data-points="$options.TOTAL_DAYS_TO_SHOW"
/> />
<projects-and-groups-chart
:start-date="$options.START_DATE"
:end-date="$options.TODAY"
:total-data-points="$options.TOTAL_DAYS_TO_SHOW"
/>
<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"
......
<script>
import { GlAlert } from '@gitlab/ui';
import { GlLineChart } from '@gitlab/ui/dist/charts';
import produce from 'immer';
import { sortBy } from 'lodash';
import * as Sentry from '~/sentry/wrapper';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { s__, __ } from '~/locale';
import { formatDateAsMonth } from '~/lib/utils/datetime_utility';
import latestGroupsQuery from '../graphql/queries/groups.query.graphql';
import latestProjectsQuery from '../graphql/queries/projects.query.graphql';
import { getAverageByMonth } from '../utils';
const sortByDate = data => sortBy(data, item => new Date(item[0]).getTime());
const averageAndSortData = (data = [], maxDataPoints) => {
const averaged = getAverageByMonth(
data.length > maxDataPoints ? data.slice(0, maxDataPoints) : data,
{ shouldRound: true },
);
return sortByDate(averaged);
};
export default {
name: 'ProjectsAndGroupsChart',
components: { GlAlert, GlLineChart, ChartSkeletonLoader },
props: {
startDate: {
type: Date,
required: true,
},
endDate: {
type: Date,
required: true,
},
totalDataPoints: {
type: Number,
required: true,
},
},
data() {
return {
loadingError: false,
errorMessage: '',
groups: [],
projects: [],
groupsPageInfo: null,
projectsPageInfo: null,
};
},
apollo: {
groups: {
query: latestGroupsQuery,
variables() {
return {
first: this.totalDataPoints,
after: null,
};
},
update(data) {
return data.groups?.nodes || [];
},
result({ data }) {
const {
groups: { pageInfo },
} = data;
this.groupsPageInfo = pageInfo;
this.fetchNextPage({
query: this.$apollo.queries.groups,
pageInfo: this.groupsPageInfo,
dataKey: 'groups',
errorMessage: this.$options.i18n.loadGroupsDataError,
});
},
error(error) {
this.handleError({
message: this.$options.i18n.loadGroupsDataError,
error,
dataKey: 'groups',
});
},
},
projects: {
query: latestProjectsQuery,
variables() {
return {
first: this.totalDataPoints,
after: null,
};
},
update(data) {
return data.projects?.nodes || [];
},
result({ data }) {
const {
projects: { pageInfo },
} = data;
this.projectsPageInfo = pageInfo;
this.fetchNextPage({
query: this.$apollo.queries.projects,
pageInfo: this.projectsPageInfo,
dataKey: 'projects',
errorMessage: this.$options.i18n.loadProjectsDataError,
});
},
error(error) {
this.handleError({
message: this.$options.i18n.loadProjectsDataError,
error,
dataKey: 'projects',
});
},
},
},
i18n: {
yAxisTitle: s__('InstanceStatistics|Total projects & groups'),
xAxisTitle: __('Month'),
loadChartError: s__(
'InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again.',
),
loadProjectsDataError: s__('InstanceStatistics|There was an error while loading the projects'),
loadGroupsDataError: s__('InstanceStatistics|There was an error while loading the groups'),
noDataMessage: s__('InstanceStatistics|No data available.'),
},
computed: {
isLoadingGroups() {
return this.$apollo.queries.groups.loading || this.groupsPageInfo?.hasNextPage;
},
isLoadingProjects() {
return this.$apollo.queries.projects.loading || this.projectsPageInfo?.hasNextPage;
},
isLoading() {
return this.isLoadingProjects && this.isLoadingGroups;
},
groupChartData() {
return averageAndSortData(this.groups, this.totalDataPoints);
},
projectChartData() {
return averageAndSortData(this.projects, this.totalDataPoints);
},
hasNoData() {
const { projectChartData, groupChartData } = this;
return Boolean(!projectChartData.length && !groupChartData.length);
},
options() {
return {
xAxis: {
name: this.$options.i18n.xAxisTitle,
type: 'category',
axisLabel: {
formatter: value => {
return formatDateAsMonth(value);
},
},
},
yAxis: {
name: this.$options.i18n.yAxisTitle,
},
};
},
chartData() {
return [
{
name: s__('InstanceStatistics|Total projects'),
data: this.projectChartData,
},
{
name: s__('InstanceStatistics|Total groups'),
data: this.groupChartData,
},
];
},
},
methods: {
handleError({ error, message = this.$options.i18n.loadChartError, dataKey = null }) {
this.loadingError = true;
this.errorMessage = message;
if (!dataKey) {
this.projects = [];
this.groups = [];
} else {
this[dataKey] = [];
}
Sentry.captureException(error);
},
fetchNextPage({ pageInfo, query, dataKey, errorMessage }) {
if (pageInfo?.hasNextPage) {
query
.fetchMore({
variables: { first: this.totalDataPoints, after: pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
const results = produce(fetchMoreResult, newData => {
// eslint-disable-next-line no-param-reassign
newData[dataKey].nodes = [
...previousResult[dataKey].nodes,
...newData[dataKey].nodes,
];
});
return results;
},
})
.catch(error => {
this.handleError({ error, message: errorMessage, dataKey });
});
}
},
},
};
</script>
<template>
<div>
<h3>{{ $options.i18n.yAxisTitle }}</h3>
<chart-skeleton-loader v-if="isLoading" />
<gl-alert v-else-if="hasNoData" variant="info" :dismissible="false" class="gl-mt-3">
{{ $options.i18n.noDataMessage }}
</gl-alert>
<div v-else>
<gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">{{
errorMessage
}}</gl-alert>
<gl-line-chart :option="options" :include-legend-avg-max="true" :data="chartData" />
</div>
</div>
</template>
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/count.fragment.graphql"
query getGroupsCount($first: Int, $after: String) {
groups: instanceStatisticsMeasurements(identifier: GROUPS, first: $first, after: $after) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/count.fragment.graphql"
query getProjectsCount($first: Int, $after: String) {
projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: $first, after: $after) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
}
...@@ -14331,6 +14331,9 @@ msgstr "" ...@@ -14331,6 +14331,9 @@ msgstr ""
msgid "InstanceAnalytics|Total" msgid "InstanceAnalytics|Total"
msgstr "" msgstr ""
msgid "InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again."
msgstr ""
msgid "InstanceStatistics|Groups" msgid "InstanceStatistics|Groups"
msgstr "" msgstr ""
...@@ -14340,12 +14343,30 @@ msgstr "" ...@@ -14340,12 +14343,30 @@ msgstr ""
msgid "InstanceStatistics|Merge Requests" msgid "InstanceStatistics|Merge Requests"
msgstr "" msgstr ""
msgid "InstanceStatistics|No data available."
msgstr ""
msgid "InstanceStatistics|Pipelines" msgid "InstanceStatistics|Pipelines"
msgstr "" msgstr ""
msgid "InstanceStatistics|Projects" msgid "InstanceStatistics|Projects"
msgstr "" msgstr ""
msgid "InstanceStatistics|There was an error while loading the groups"
msgstr ""
msgid "InstanceStatistics|There was an error while loading the projects"
msgstr ""
msgid "InstanceStatistics|Total groups"
msgstr ""
msgid "InstanceStatistics|Total projects"
msgstr ""
msgid "InstanceStatistics|Total projects & groups"
msgstr ""
msgid "InstanceStatistics|Users" msgid "InstanceStatistics|Users"
msgstr "" msgstr ""
......
const defaultPageInfo = { hasPreviousPage: false, startCursor: null, endCursor: null }; const defaultPageInfo = {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
};
export function getApolloResponse(options = {}) { export function getApolloResponse(options = {}) {
const { const {
...@@ -28,3 +33,38 @@ export function getApolloResponse(options = {}) { ...@@ -28,3 +33,38 @@ export function getApolloResponse(options = {}) {
}, },
}; };
} }
const mockApolloResponse = ({ hasNextPage = false, key, data }) => ({
data: {
[key]: {
pageInfo: { ...defaultPageInfo, hasNextPage },
nodes: data,
},
},
});
export const mockQueryResponse = ({
key,
data = [],
loading = false,
hasNextPage = false,
additionalData = [],
}) => {
const response = mockApolloResponse({ hasNextPage, key, data });
if (loading) {
return jest.fn().mockReturnValue(new Promise(() => {}));
}
if (hasNextPage) {
return jest
.fn()
.mockResolvedValueOnce(response)
.mockResolvedValueOnce(
mockApolloResponse({
hasNextPage: false,
key,
data: additionalData,
}),
);
}
return jest.fn().mockResolvedValue(response);
};
...@@ -3,6 +3,7 @@ import InstanceStatisticsApp from '~/analytics/instance_statistics/components/ap ...@@ -3,6 +3,7 @@ import InstanceStatisticsApp from '~/analytics/instance_statistics/components/ap
import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue'; import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue';
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 UsersChart from '~/analytics/instance_statistics/components/users_chart.vue'; import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue';
import ProjectsAndGroupsChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue';
describe('InstanceStatisticsApp', () => { describe('InstanceStatisticsApp', () => {
let wrapper; let wrapper;
...@@ -34,4 +35,8 @@ describe('InstanceStatisticsApp', () => { ...@@ -34,4 +35,8 @@ describe('InstanceStatisticsApp', () => {
it('displays the users chart component', () => { it('displays the users chart component', () => {
expect(wrapper.find(UsersChart).exists()).toBe(true); expect(wrapper.find(UsersChart).exists()).toBe(true);
}); });
it('displays the projects and groups chart component', () => {
expect(wrapper.find(ProjectsAndGroupsChart).exists()).toBe(true);
});
}); });
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlLineChart } from '@gitlab/ui/dist/charts';
import { GlAlert } from '@gitlab/ui';
import VueApollo from 'vue-apollo';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import { useFakeDate } from 'helpers/fake_date';
import ProjectsAndGroupChart from '~/analytics/instance_statistics/components/projects_and_groups_chart.vue';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import projectsQuery from '~/analytics/instance_statistics/graphql/queries/projects.query.graphql';
import groupsQuery from '~/analytics/instance_statistics/graphql/queries/groups.query.graphql';
import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data';
import { mockQueryResponse } from '../apollo_mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('ProjectsAndGroupChart', () => {
let wrapper;
let queryResponses = { projects: null, groups: null };
const mockAdditionalData = [{ recordedAt: '2020-07-21', count: 5 }];
const createComponent = ({
loadingError = false,
projects = [],
groups = [],
projectsLoading = false,
groupsLoading = false,
projectsHasNextPage = false,
groupsHasNextPage = false,
} = {}) => {
queryResponses = {
projects: mockQueryResponse({
key: 'projects',
data: projects,
loading: projectsLoading,
hasNextPage: projectsHasNextPage,
additionalData: mockAdditionalData,
}),
groups: mockQueryResponse({
key: 'groups',
data: groups,
loading: groupsLoading,
hasNextPage: groupsHasNextPage,
additionalData: mockAdditionalData,
}),
};
return shallowMount(ProjectsAndGroupChart, {
props: {
startDate: useFakeDate(2020, 9, 26),
endDate: useFakeDate(2020, 10, 1),
totalDataPoints: mockCountsData2.length,
},
localVue,
apolloProvider: createMockApollo([
[projectsQuery, queryResponses.projects],
[groupsQuery, queryResponses.groups],
]),
data() {
return { loadingError };
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
queryResponses = {
projects: null,
groups: null,
};
});
const findLoader = () => wrapper.find(ChartSkeletonLoader);
const findAlert = () => wrapper.find(GlAlert);
const findChart = () => wrapper.find(GlLineChart);
describe('while loading', () => {
beforeEach(() => {
wrapper = createComponent({ projectsLoading: true, groupsLoading: true });
});
it('displays the skeleton loader', () => {
expect(findLoader().exists()).toBe(true);
});
it('hides the chart', () => {
expect(findChart().exists()).toBe(false);
});
});
describe('while loading 1 data set', () => {
beforeEach(async () => {
wrapper = createComponent({
projects: mockCountsData2,
groupsLoading: true,
});
await wrapper.vm.$nextTick();
});
it('hides the skeleton loader', () => {
expect(findLoader().exists()).toBe(false);
});
it('renders the chart', () => {
expect(findChart().exists()).toBe(true);
});
});
describe('without data', () => {
beforeEach(async () => {
wrapper = createComponent({ projects: [] });
await wrapper.vm.$nextTick();
});
it('renders a no data message', () => {
expect(findAlert().text()).toBe('No data available.');
});
it('hides the skeleton loader', () => {
expect(findLoader().exists()).toBe(false);
});
it('does not render the chart', () => {
expect(findChart().exists()).toBe(false);
});
});
describe('with data', () => {
beforeEach(async () => {
wrapper = createComponent({ projects: mockCountsData2 });
await wrapper.vm.$nextTick();
});
it('hides the skeleton loader', () => {
expect(findLoader().exists()).toBe(false);
});
it('renders the chart', () => {
expect(findChart().exists()).toBe(true);
});
it('passes the data to the line chart', () => {
expect(findChart().props('data')).toEqual([
{ data: roundedSortedCountsMonthlyChartData2, name: 'Total projects' },
{ data: [], name: 'Total groups' },
]);
});
});
describe('with errors', () => {
beforeEach(async () => {
wrapper = createComponent({ loadingError: true });
await wrapper.vm.$nextTick();
});
it('renders an error message', () => {
expect(findAlert().text()).toBe('No data available.');
});
it('hides the skeleton loader', () => {
expect(findLoader().exists()).toBe(false);
});
it('hides the chart', () => {
expect(findChart().exists()).toBe(false);
});
});
describe.each`
metric | loadingState | newData
${'projects'} | ${{ projectsHasNextPage: true }} | ${{ projects: mockCountsData2 }}
${'groups'} | ${{ groupsHasNextPage: true }} | ${{ groups: mockCountsData2 }}
`('$metric - fetchMore', ({ metric, loadingState, newData }) => {
describe('when the fetchMore query returns data', () => {
beforeEach(async () => {
wrapper = createComponent({
...loadingState,
...newData,
});
jest.spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore');
await wrapper.vm.$nextTick();
});
it('requests data twice', () => {
expect(queryResponses[metric]).toBeCalledTimes(2);
});
it('calls fetchMore', () => {
expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1);
});
});
describe('when the fetchMore query throws an error', () => {
beforeEach(() => {
wrapper = createComponent({
...loadingState,
...newData,
});
jest
.spyOn(wrapper.vm.$apollo.queries[metric], 'fetchMore')
.mockImplementation(jest.fn().mockRejectedValue());
return wrapper.vm.$nextTick();
});
it('calls fetchMore', () => {
expect(wrapper.vm.$apollo.queries[metric].fetchMore).toHaveBeenCalledTimes(1);
});
it('renders an error message', () => {
expect(findAlert().text()).toBe('No data available.');
});
});
});
});
...@@ -7,7 +7,8 @@ import { useFakeDate } from 'helpers/fake_date'; ...@@ -7,7 +7,8 @@ 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, mockPageInfo } from '../mock_data'; import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data';
import { mockQueryResponse } from '../apollo_mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
...@@ -16,43 +17,13 @@ describe('UsersChart', () => { ...@@ -16,43 +17,13 @@ describe('UsersChart', () => {
let wrapper; let wrapper;
let queryHandler; let queryHandler;
const mockApolloResponse = ({ loading = false, hasNextPage = false, users }) => ({
data: {
users: {
pageInfo: { ...mockPageInfo, hasNextPage },
nodes: users,
loading,
},
},
});
const mockQueryResponse = ({ users, loading = false, hasNextPage = false }) => {
const apolloQueryResponse = mockApolloResponse({ loading, hasNextPage, users });
if (loading) {
return jest.fn().mockReturnValue(new Promise(() => {}));
}
if (hasNextPage) {
return jest
.fn()
.mockResolvedValueOnce(apolloQueryResponse)
.mockResolvedValueOnce(
mockApolloResponse({
loading,
hasNextPage: false,
users: [{ recordedAt: '2020-07-21', count: 5 }],
}),
);
}
return jest.fn().mockResolvedValue(apolloQueryResponse);
};
const createComponent = ({ const createComponent = ({
loadingError = false, loadingError = false,
loading = false, loading = false,
users = [], users = [],
hasNextPage = false, hasNextPage = false,
} = {}) => { } = {}) => {
queryHandler = mockQueryResponse({ users, loading, hasNextPage }); queryHandler = mockQueryResponse({ key: 'users', data: users, loading, hasNextPage });
return shallowMount(UsersChart, { return shallowMount(UsersChart, {
props: { props: {
......
...@@ -33,10 +33,3 @@ export const roundedSortedCountsMonthlyChartData2 = [ ...@@ -33,10 +33,3 @@ export const roundedSortedCountsMonthlyChartData2 = [
['2020-06-01', 21], // average of 2020-06-x items ['2020-06-01', 21], // average of 2020-06-x items
['2020-07-01', 10], // average of 2020-07-x items ['2020-07-01', 10], // average of 2020-07-x items
]; ];
export const mockPageInfo = {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
};
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