Commit 5b3e480f authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 280b3b06 abd6f3ec
......@@ -8,7 +8,6 @@
<!-- What is the user problem you are trying to solve with this issue? -->
### Proposal
<!-- Use this section to explain the feature and how it will work. It can be helpful to add technical details, design proposals, and links to related epics or issues. -->
......@@ -46,14 +45,14 @@ Personas are described at https://about.gitlab.com/handbook/marketing/product-ma
### User experience goal
What is the single user experience workflow this problem addresses?
What is the single user experience workflow this problem addresses?
For example, "The user should be able to use the UI/API/.gitlab-ci.yml with GitLab to <perform a specific task>"
https://about.gitlab.com/handbook/engineering/ux/ux-research-training/user-story-mapping/
### Further details
nclude use cases, benefits, goals, or any other details that will help us understand the problem better.
Include use cases, benefits, goals, or any other details that will help us understand the problem better.
### Permissions and Security
......@@ -75,7 +74,7 @@ Consider adding checkboxes and expectations of users with certain levels of memb
### Availability & Testing
his section needs to be retained and filled in during the workflow planning breakdown phase of this feature proposal, if not earlier.
This section needs to be retained and filled in during the workflow planning breakdown phase of this feature proposal, if not earlier.
What risks does this change pose to our availability? How might it affect the quality of the product? What additional test coverage or changes to tests will be needed? Will it require cross-browser testing?
......@@ -98,6 +97,3 @@ In which enterprise tier should this feature go? See https://about.gitlab.com/ha
### Is this a cross-stage feature?
Communicate if this change will affect multiple Stage Groups or product areas. We recommend always start with the assumption that a feature request will have an impact into another Group. Loop in the most relevant PM and Product Designer from that Group to provide strategic support to help align the Group's broader plan and vision, as well as to avoid UX and technical debt. https://about.gitlab.com/handbook/product/#cross-stage-features -->
......@@ -5,6 +5,7 @@ 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 { TODAY, TOTAL_DAYS_TO_SHOW, START_DATE } from '../constants';
const PIPELINES_KEY_TO_NAME_MAP = {
......@@ -32,6 +33,7 @@ export default {
InstanceCounts,
InstanceStatisticsCountChart,
UsersChart,
ProjectsAndGroupsChart,
},
TOTAL_DAYS_TO_SHOW,
START_DATE,
......@@ -69,6 +71,11 @@ 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"
/>
<instance-statistics-count-chart
v-for="chartOptions in $options.configs"
: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
}
}
}
......@@ -14334,6 +14334,9 @@ msgstr ""
msgid "InstanceAnalytics|Total"
msgstr ""
msgid "InstanceStatistics|Could not load the projects and groups chart. Please refresh the page to try again."
msgstr ""
msgid "InstanceStatistics|Groups"
msgstr ""
......@@ -14343,12 +14346,30 @@ msgstr ""
msgid "InstanceStatistics|Merge Requests"
msgstr ""
msgid "InstanceStatistics|No data available."
msgstr ""
msgid "InstanceStatistics|Pipelines"
msgstr ""
msgid "InstanceStatistics|Projects"
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"
msgstr ""
......
const defaultPageInfo = { hasPreviousPage: false, startCursor: null, endCursor: null };
const defaultPageInfo = {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
};
export function getApolloResponse(options = {}) {
const {
......@@ -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
import InstanceCounts from '~/analytics/instance_statistics/components//instance_counts.vue';
import InstanceStatisticsCountChart from '~/analytics/instance_statistics/components/instance_statistics_count_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', () => {
let wrapper;
......@@ -34,4 +35,8 @@ describe('InstanceStatisticsApp', () => {
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 { 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';
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';
import { mockCountsData2, roundedSortedCountsMonthlyChartData2 } from '../mock_data';
import { mockQueryResponse } from '../apollo_mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
......@@ -16,43 +17,13 @@ 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 });
queryHandler = mockQueryResponse({ key: 'users', data: users, loading, hasNextPage });
return shallowMount(UsersChart, {
props: {
......
......@@ -33,10 +33,3 @@ 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