Commit 77021f04 authored by Brandon Labuschagne's avatar Brandon Labuschagne

Merge branch '276916-replace-projects-and-groups-chart-with-config' into 'master'

Use config for projects and groups usage trends

See merge request gitlab-org/gitlab!54777
parents 0d5b569d 8bbb3725
<script>
import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants';
import ChartsConfig from './charts_config';
import ProjectsAndGroupsChart from './projects_and_groups_chart.vue';
import UsageCounts from './usage_counts.vue';
import UsageTrendsCountChart from './usage_trends_count_chart.vue';
import UsersChart from './users_chart.vue';
......@@ -12,7 +11,6 @@ export default {
UsageCounts,
UsageTrendsCountChart,
UsersChart,
ProjectsAndGroupsChart,
},
TOTAL_DAYS_TO_SHOW,
START_DATE,
......@@ -29,11 +27,6 @@ export default {
:end-date="$options.TODAY"
: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"
/>
<usage-trends-count-chart
v-for="chartOptions in $options.configs"
:key="chartOptions.chartTitle"
......
import { s__, __, sprintf } from '~/locale';
import { s__, __ } from '~/locale';
import query from '../graphql/queries/usage_count.query.graphql';
const noDataMessage = s__('UsageTrends|No data available.');
export default [
{
loadChartError: sprintf(
s__('UsageTrends|Could not load the pipelines chart. Please refresh the page to try again.'),
loadChartError: s__(
'UsageTrends|Could not load the projects and groups chart. Please refresh the page to try again.',
),
noDataMessage,
chartTitle: s__('UsageTrends|Total projects & groups'),
yAxisTitle: s__('UsageTrends|Total projects & groups'),
xAxisTitle: s__('UsageTrends|Month'),
queries: [
{
query,
title: s__('UsageTrends|Total projects'),
identifier: 'PROJECTS',
loadError: s__('UsageTrends|There was an error fetching the projects. Please try again.'),
},
{
query,
title: s__('UsageTrends|Total groups'),
identifier: 'GROUPS',
loadError: s__('UsageTrends|There was an error fetching the groups. Please try again.'),
},
],
},
{
loadChartError: s__(
'UsageTrends|Could not load the pipelines chart. Please refresh the page to try again.',
),
noDataMessage,
chartTitle: s__('UsageTrends|Pipelines'),
......@@ -17,40 +40,48 @@ export default [
query,
title: s__('UsageTrends|Pipelines total'),
identifier: 'PIPELINES',
loadError: sprintf(s__('UsageTrends|There was an error fetching the total pipelines')),
loadError: s__(
'UsageTrends|There was an error fetching the total pipelines. Please try again.',
),
},
{
query,
title: s__('UsageTrends|Pipelines succeeded'),
identifier: 'PIPELINES_SUCCEEDED',
loadError: sprintf(s__('UsageTrends|There was an error fetching the successful pipelines')),
loadError: s__(
'UsageTrends|There was an error fetching the successful pipelines. Please try again.',
),
},
{
query,
title: s__('UsageTrends|Pipelines failed'),
identifier: 'PIPELINES_FAILED',
loadError: sprintf(s__('UsageTrends|There was an error fetching the failed pipelines')),
loadError: s__(
'UsageTrends|There was an error fetching the failed pipelines. Please try again.',
),
},
{
query,
title: s__('UsageTrends|Pipelines canceled'),
identifier: 'PIPELINES_CANCELED',
loadError: sprintf(s__('UsageTrends|There was an error fetching the cancelled pipelines')),
loadError: s__(
'UsageTrends|There was an error fetching the cancelled pipelines. Please try again.',
),
},
{
query,
title: s__('UsageTrends|Pipelines skipped'),
identifier: 'PIPELINES_SKIPPED',
loadError: sprintf(s__('UsageTrends|There was an error fetching the skipped pipelines')),
loadError: s__(
'UsageTrends|There was an error fetching the skipped pipelines. Please try again.',
),
},
],
},
{
loadChartError: sprintf(
s__(
loadChartError: s__(
'UsageTrends|Could not load the issues and merge requests chart. Please refresh the page to try again.',
),
),
noDataMessage,
chartTitle: s__('UsageTrends|Issues & Merge Requests'),
yAxisTitle: s__('UsageTrends|Items'),
......@@ -60,13 +91,15 @@ export default [
query,
title: __('Issues'),
identifier: 'ISSUES',
loadError: sprintf(s__('UsageTrends|There was an error fetching the issues')),
loadError: s__('UsageTrends|There was an error fetching the issues. Please try again.'),
},
{
query,
title: __('Merge requests'),
identifier: 'MERGE_REQUESTS',
loadError: sprintf(s__('UsageTrends|There was an error fetching the merge requests')),
loadError: s__(
'UsageTrends|There was an error fetching the merge requests. Please try again.',
),
},
],
},
......
<script>
import { GlAlert } from '@gitlab/ui';
import { GlLineChart } from '@gitlab/ui/dist/charts';
import * as Sentry from '@sentry/browser';
import produce from 'immer';
import { sortBy } from 'lodash';
import { formatDateAsMonth } from '~/lib/utils/datetime_utility';
import { s__, __ } from '~/locale';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
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__('UsageTrends|Total projects & groups'),
xAxisTitle: __('Month'),
loadChartError: s__(
'UsageTrends|Could not load the projects and groups chart. Please refresh the page to try again.',
),
loadProjectsDataError: s__('UsageTrends|There was an error while loading the projects'),
loadGroupsDataError: s__('UsageTrends|There was an error while loading the groups'),
noDataMessage: s__('UsageTrends|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__('UsageTrends|Total projects'),
data: this.projectChartData,
},
{
name: s__('UsageTrends|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: usageTrendsMeasurements(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: usageTrendsMeasurements(identifier: PROJECTS, first: $first, after: $after) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
}
......@@ -32142,31 +32142,31 @@ msgstr ""
msgid "UsageTrends|Projects"
msgstr ""
msgid "UsageTrends|There was an error fetching the cancelled pipelines"
msgid "UsageTrends|There was an error fetching the cancelled pipelines. Please try again."
msgstr ""
msgid "UsageTrends|There was an error fetching the failed pipelines"
msgid "UsageTrends|There was an error fetching the failed pipelines. Please try again."
msgstr ""
msgid "UsageTrends|There was an error fetching the issues"
msgid "UsageTrends|There was an error fetching the groups. Please try again."
msgstr ""
msgid "UsageTrends|There was an error fetching the merge requests"
msgid "UsageTrends|There was an error fetching the issues. Please try again."
msgstr ""
msgid "UsageTrends|There was an error fetching the skipped pipelines"
msgid "UsageTrends|There was an error fetching the merge requests. Please try again."
msgstr ""
msgid "UsageTrends|There was an error fetching the successful pipelines"
msgid "UsageTrends|There was an error fetching the projects. Please try again."
msgstr ""
msgid "UsageTrends|There was an error fetching the total pipelines"
msgid "UsageTrends|There was an error fetching the skipped pipelines. Please try again."
msgstr ""
msgid "UsageTrends|There was an error while loading the groups"
msgid "UsageTrends|There was an error fetching the successful pipelines. Please try again."
msgstr ""
msgid "UsageTrends|There was an error while loading the projects"
msgid "UsageTrends|There was an error fetching the total pipelines. Please try again."
msgstr ""
msgid "UsageTrends|Total groups"
......
import { shallowMount } from '@vue/test-utils';
import UsageTrendsApp from '~/analytics/usage_trends/components/app.vue';
import ProjectsAndGroupsChart from '~/analytics/usage_trends/components/projects_and_groups_chart.vue';
import UsageCounts from '~/analytics/usage_trends/components/usage_counts.vue';
import UsageTrendsCountChart from '~/analytics/usage_trends/components/usage_trends_count_chart.vue';
import UsersChart from '~/analytics/usage_trends/components/users_chart.vue';
......@@ -25,7 +24,7 @@ describe('UsageTrendsApp', () => {
expect(wrapper.find(UsageCounts).exists()).toBe(true);
});
['Pipelines', 'Issues & Merge Requests'].forEach((usage) => {
['Total projects & groups', 'Pipelines', 'Issues & Merge Requests'].forEach((usage) => {
it(`displays the ${usage} chart`, () => {
const chartTitles = wrapper
.findAll(UsageTrendsCountChart)
......@@ -38,8 +37,4 @@ describe('UsageTrendsApp', () => {
it('displays the users chart component', () => {
expect(wrapper.find(UsersChart).exists()).toBe(true);
});
it('displays the projects and groups chart component', () => {
expect(wrapper.find(ProjectsAndGroupsChart).exists()).toBe(true);
});
});
import { GlAlert } from '@gitlab/ui';
import { GlLineChart } from '@gitlab/ui/dist/charts';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import ProjectsAndGroupChart from '~/analytics/usage_trends/components/projects_and_groups_chart.vue';
import groupsQuery from '~/analytics/usage_trends/graphql/queries/groups.query.graphql';
import projectsQuery from '~/analytics/usage_trends/graphql/queries/projects.query.graphql';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { mockQueryResponse } from '../apollo_mock_data';
import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../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,
projectsAdditionalData = [],
groupsAdditionalData = [],
} = {}) => {
queryResponses = {
projects: mockQueryResponse({
key: 'projects',
data: projects,
loading: projectsLoading,
additionalData: projectsAdditionalData,
}),
groups: mockQueryResponse({
key: 'groups',
data: groups,
loading: groupsLoading,
additionalData: groupsAdditionalData,
}),
};
return shallowMount(ProjectsAndGroupChart, {
props: {
startDate: new Date(2020, 9, 26),
endDate: new Date(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'} | ${{ projectsAdditionalData: mockAdditionalData }} | ${{ projects: mockCountsData2 }}
${'groups'} | ${{ groupsAdditionalData: mockAdditionalData }} | ${{ 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.');
});
});
});
});
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