Commit effc3ac2 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'ek-spike-instance-analytics-users-chart' into 'master'

FE instance analytics users graph

See merge request gitlab-org/gitlab!43817
parents 54b41847 0e981bc4
<script>
import InstanceCounts from './instance_counts.vue';
import PipelinesChart from './pipelines_chart.vue';
import UsersChart from './users_chart.vue';
import { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants';
export default {
name: 'InstanceStatisticsApp',
components: {
InstanceCounts,
PipelinesChart,
UsersChart,
},
TOTAL_DAYS_TO_SHOW,
START_DATE,
TODAY,
};
</script>
<template>
<div>
<instance-counts />
<users-chart
:start-date="$options.START_DATE"
:end-date="$options.TODAY"
:total-data-points="$options.TOTAL_DAYS_TO_SHOW"
/>
<pipelines-chart />
</div>
</template>
<script>
import { GlAlert } from '@gitlab/ui';
import { GlAreaChart } 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 { __ } from '~/locale';
import { formatDateAsMonth } from '~/lib/utils/datetime_utility';
import usersQuery from '../graphql/queries/users.query.graphql';
import { getAverageByMonth } from '../utils';
const sortByDate = data => sortBy(data, item => new Date(item[0]).getTime());
export default {
name: 'UsersChart',
components: { GlAlert, GlAreaChart, ChartSkeletonLoader },
props: {
startDate: {
type: Date,
required: true,
},
endDate: {
type: Date,
required: true,
},
totalDataPoints: {
type: Number,
required: true,
},
},
data() {
return {
loadingError: null,
users: [],
pageInfo: null,
};
},
apollo: {
users: {
query: usersQuery,
variables() {
return {
first: this.totalDataPoints,
after: null,
};
},
update(data) {
return data.users?.nodes || [];
},
result({ data }) {
const {
users: { pageInfo },
} = data;
this.pageInfo = pageInfo;
this.fetchNextPage();
},
error(error) {
this.handleError(error);
},
},
},
i18n: {
yAxisTitle: __('Total users'),
xAxisTitle: __('Month'),
loadUserChartError: __('Could not load the user chart. Please refresh the page to try again.'),
noDataMessage: __('There is no data available.'),
},
computed: {
isLoading() {
return this.$apollo.queries.users.loading || this.pageInfo?.hasNextPage;
},
chartUserData() {
const averaged = getAverageByMonth(
this.users.length > this.totalDataPoints
? this.users.slice(0, this.totalDataPoints)
: this.users,
{ shouldRound: true },
);
return sortByDate(averaged);
},
options() {
return {
xAxis: {
name: this.$options.i18n.xAxisTitle,
type: 'category',
axisLabel: {
formatter: formatDateAsMonth,
},
},
yAxis: {
name: this.$options.i18n.yAxisTitle,
},
};
},
},
methods: {
handleError(error) {
this.loadingError = true;
this.users = [];
Sentry.captureException(error);
},
fetchNextPage() {
if (this.pageInfo?.hasNextPage) {
this.$apollo.queries.users
.fetchMore({
variables: { first: this.totalDataPoints, after: this.pageInfo.endCursor },
updateQuery: (previousResult, { fetchMoreResult }) => {
return produce(fetchMoreResult, newUsers => {
// eslint-disable-next-line no-param-reassign
newUsers.users.nodes = [...previousResult.users.nodes, ...newUsers.users.nodes];
});
},
})
.catch(this.handleError);
}
},
},
};
</script>
<template>
<div>
<h3>{{ $options.i18n.yAxisTitle }}</h3>
<gl-alert v-if="loadingError" variant="danger" :dismissible="false" class="gl-mt-3">
{{ this.$options.i18n.loadUserChartError }}
</gl-alert>
<chart-skeleton-loader v-else-if="isLoading" />
<gl-alert v-else-if="!chartUserData.length" variant="info" :dismissible="false" class="gl-mt-3">
{{ $options.i18n.noDataMessage }}
</gl-alert>
<gl-area-chart
v-else
:option="options"
:include-legend-avg-max="true"
:data="[
{
name: $options.i18n.yAxisTitle,
data: chartUserData,
},
]"
/>
</div>
</template>
import { getDateInPast } from '~/lib/utils/datetime_utility';
const TOTAL_DAYS_TO_SHOW = 365;
export const TOTAL_DAYS_TO_SHOW = 365;
export const TODAY = new Date();
export const START_DATE = getDateInPast(TODAY, TOTAL_DAYS_TO_SHOW);
fragment Count on InstanceStatisticsMeasurement {
count
recordedAt
}
#import "../fragments/count.fragment.graphql"
query getInstanceCounts {
projects: instanceStatisticsMeasurements(identifier: PROJECTS, first: 1) {
nodes {
count
...Count
}
}
groups: instanceStatisticsMeasurements(identifier: GROUPS, first: 1) {
nodes {
count
...Count
}
}
users: instanceStatisticsMeasurements(identifier: USERS, first: 1) {
nodes {
count
...Count
}
}
issues: instanceStatisticsMeasurements(identifier: ISSUES, first: 1) {
nodes {
count
...Count
}
}
mergeRequests: instanceStatisticsMeasurements(identifier: MERGE_REQUESTS, first: 1) {
nodes {
count
...Count
}
}
pipelines: instanceStatisticsMeasurements(identifier: PIPELINES, first: 1) {
nodes {
count
...Count
}
}
}
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/count.fragment.graphql"
query getUsersCount($first: Int, $after: String) {
users: instanceStatisticsMeasurements(identifier: USERS, first: $first, after: $after) {
nodes {
...Count
}
pageInfo {
...PageInfo
}
}
}
......@@ -7513,6 +7513,9 @@ msgstr ""
msgid "Could not load instance counts. Please refresh the page to try again."
msgstr ""
msgid "Could not load the user chart. Please refresh the page to try again."
msgstr ""
msgid "Could not remove the trigger."
msgstr ""
......@@ -26510,6 +26513,9 @@ msgstr ""
msgid "There is no chart data available."
msgstr ""
msgid "There is no data available."
msgstr ""
msgid "There is no data available. Please change your selection."
msgstr ""
......@@ -27780,6 +27786,9 @@ msgstr ""
msgid "Total test time for all commits/merges"
msgstr ""
msgid "Total users"
msgstr ""
msgid "Total weight"
msgstr ""
......
......@@ -2,6 +2,7 @@ import { shallowMount } from '@vue/test-utils';
import InstanceStatisticsApp from '~/analytics/instance_statistics/components/app.vue';
import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue';
import PipelinesChart from '~/analytics/instance_statistics/components/pipelines_chart.vue';
import UsersChart from '~/analytics/instance_statistics/components/users_chart.vue';
describe('InstanceStatisticsApp', () => {
let wrapper;
......@@ -26,4 +27,8 @@ describe('InstanceStatisticsApp', () => {
it('displays the pipelines chart component', () => {
expect(wrapper.find(PipelinesChart).exists()).toBe(true);
});
it('displays the users chart component', () => {
expect(wrapper.find(UsersChart).exists()).toBe(true);
});
});
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlAreaChart } 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 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, mockPageInfo } from '../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('UsersChart', () => {
let wrapper;
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 = ({
loadingError = false,
loading = false,
users = [],
hasNextPage = false,
} = {}) => {
queryHandler = mockQueryResponse({ users, loading, hasNextPage });
return shallowMount(UsersChart, {
props: {
startDate: useFakeDate(2020, 9, 26),
endDate: useFakeDate(2020, 10, 1),
totalDataPoints: mockCountsData2.length,
},
localVue,
apolloProvider: createMockApollo([[usersQuery, queryHandler]]),
data() {
return { loadingError };
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findLoader = () => wrapper.find(ChartSkeletonLoader);
const findAlert = () => wrapper.find(GlAlert);
const findChart = () => wrapper.find(GlAreaChart);
describe('while loading', () => {
beforeEach(() => {
wrapper = createComponent({ loading: true });
});
it('displays the skeleton loader', () => {
expect(findLoader().exists()).toBe(true);
});
it('hides the chart', () => {
expect(findChart().exists()).toBe(false);
});
});
describe('without data', () => {
beforeEach(async () => {
wrapper = createComponent({ users: [] });
await wrapper.vm.$nextTick();
});
it('renders an no data message', () => {
expect(findAlert().text()).toBe('There is no data available.');
});
it('hides the skeleton loader', () => {
expect(findLoader().exists()).toBe(false);
});
it('renders the chart', () => {
expect(findChart().exists()).toBe(false);
});
});
describe('with data', () => {
beforeEach(async () => {
wrapper = createComponent({ users: 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 users' },
]);
});
});
describe('with errors', () => {
beforeEach(async () => {
wrapper = createComponent({ loadingError: true });
await wrapper.vm.$nextTick();
});
it('renders an error message', () => {
expect(findAlert().text()).toBe(
'Could not load the user chart. Please refresh the page to try again.',
);
});
it('hides the skeleton loader', () => {
expect(findLoader().exists()).toBe(false);
});
it('renders the chart', () => {
expect(findChart().exists()).toBe(false);
});
});
describe('when fetching more data', () => {
describe('when the fetchMore query returns data', () => {
beforeEach(async () => {
wrapper = createComponent({
users: mockCountsData2,
hasNextPage: true,
});
jest.spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore');
await wrapper.vm.$nextTick();
});
it('requests data twice', () => {
expect(queryHandler).toBeCalledTimes(2);
});
it('calls fetchMore', () => {
expect(wrapper.vm.$apollo.queries.users.fetchMore).toHaveBeenCalledTimes(1);
});
});
describe('when the fetchMore query throws an error', () => {
beforeEach(() => {
wrapper = createComponent({
users: mockCountsData2,
hasNextPage: true,
});
jest
.spyOn(wrapper.vm.$apollo.queries.users, 'fetchMore')
.mockImplementation(jest.fn().mockRejectedValue());
return wrapper.vm.$nextTick();
});
it('calls fetchMore', () => {
expect(wrapper.vm.$apollo.queries.users.fetchMore).toHaveBeenCalledTimes(1);
});
it('renders an error message', () => {
expect(findAlert().text()).toBe(
'Could not load the user chart. Please refresh the page to try again.',
);
});
});
});
});
......@@ -28,3 +28,15 @@ export const countsMonthlyChartData2 = [
['2020-07-01', 9.5], // average of 2020-07-x items
['2020-06-01', 20.666666666666668], // average of 2020-06-x items
];
export const roundedSortedCountsMonthlyChartData2 = [
['2020-06-01', 21], // average of 2020-06-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