Commit 565bbf57 authored by Michael Lunøe's avatar Michael Lunøe Committed by Martin Wortschack

Feat(Merge Request Analytics): add filter bar

This MR adds a filter bar with filter controls
to the Merge Request Analytics page, so the user
can filter the throughput data in the chart and
the data table
parent a4fe586d
<script>
import { getDateInPast } from '~/lib/utils/datetime_utility';
import { DEFAULT_NUMBER_OF_DAYS } from '../constants';
import FilterBar from './filter_bar.vue';
import ThroughputChart from './throughput_chart.vue';
import ThroughputTable from './throughput_table.vue';
export default {
name: 'MergeRequestAnalyticsApp',
components: {
FilterBar,
ThroughputChart,
ThroughputTable,
},
......@@ -21,6 +23,7 @@ export default {
<template>
<div class="merge-request-analytics-wrapper">
<h3 data-testid="pageTitle" class="gl-mb-5">{{ __('Merge Request Analytics') }}</h3>
<filter-bar />
<throughput-chart :start-date="startDate" :end-date="endDate" />
<throughput-table :start-date="startDate" :end-date="endDate" class="gl-mt-6" />
</div>
......
<script>
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import {
DEFAULT_LABEL_NONE,
DEFAULT_LABEL_ANY,
} from '~/vue_shared/components/filtered_search_bar/constants';
import {
prepareTokens,
processFilters,
filterToQueryObject,
} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
export default {
name: 'FilterBar',
components: {
FilteredSearchBar,
UrlSync,
},
inject: ['fullPath', 'type'],
computed: {
...mapState('filters', {
selectedMilestone: state => state.milestones.selected,
selectedAuthor: state => state.authors.selected,
selectedLabelList: state => state.labels.selectedList,
selectedAssignee: state => state.assignees.selected,
milestonesData: state => state.milestones.data,
labelsData: state => state.labels.data,
authorsData: state => state.authors.data,
assigneesData: state => state.assignees.data,
}),
tokens() {
return [
{
icon: 'clock',
title: __('Milestone'),
type: 'milestone',
token: MilestoneToken,
initialMilestones: this.milestonesData,
unique: true,
symbol: '%',
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchMilestones: this.fetchMilestones,
},
{
icon: 'labels',
title: __('Label'),
type: 'labels',
token: LabelToken,
defaultLabels: [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY],
initialLabels: this.labelsData,
unique: false,
symbol: '~',
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchLabels: this.fetchLabels,
},
{
icon: 'pencil',
title: __('Author'),
type: 'author',
token: AuthorToken,
defaultAuthors: [],
initialAuthors: this.authorsData,
unique: true,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchAuthors: this.fetchAuthors,
},
{
icon: 'user',
title: __('Assignee'),
type: 'assignee',
token: AuthorToken,
defaultAuthors: [],
initialAuthors: this.assigneesData,
unique: false,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchAuthors: this.fetchAssignees,
},
];
},
query() {
return filterToQueryObject({
milestone_title: this.selectedMilestone,
label_name: this.selectedLabelList,
author_username: this.selectedAuthor,
assignee_username: this.selectedAssignee,
});
},
initialFilterValue() {
return prepareTokens({
milestone: this.selectedMilestone,
author: this.selectedAuthor,
assignee: this.selectedAssignee,
labels: this.selectedLabelList,
});
},
},
methods: {
...mapActions('filters', [
'setFilters',
'fetchMilestones',
'fetchLabels',
'fetchAuthors',
'fetchAssignees',
]),
handleFilter(filters) {
const { labels, milestone, author, assignee } = processFilters(filters);
this.setFilters({
selectedAuthor: author ? author[0] : null,
selectedMilestone: milestone ? milestone[0] : null,
selectedAssignee: assignee ? assignee[0] : null,
selectedLabelList: labels || [],
});
},
},
};
</script>
<template>
<div>
<filtered-search-bar
class="gl-flex-grow-1"
:namespace="fullPath"
recent-searches-storage-key="merge-request-analytics"
:search-input-placeholder="__('Filter results')"
:tokens="tokens"
:initial-filter-value="initialFilterValue"
@onFilter="handleFilter"
/>
<url-sync :query="query" />
</div>
</template>
<script>
import { mapState } from 'vuex';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { GlAlert } from '@gitlab/ui';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import throughputChartQueryBuilder from '../graphql/throughput_chart_query_builder';
import { THROUGHPUT_CHART_STRINGS } from '../constants';
......@@ -35,8 +37,16 @@ export default {
return throughputChartQueryBuilder(this.startDate, this.endDate);
},
variables() {
const options = filterToQueryObject({
labels: this.selectedLabelList,
authorUsername: this.selectedAuthor,
assigneeUsername: this.selectedAssignee,
milestoneTitle: this.selectedMilestone,
});
return {
fullPath: this.fullPath,
...options,
};
},
error() {
......@@ -48,6 +58,12 @@ export default {
},
},
computed: {
...mapState('filters', {
selectedMilestone: state => state.milestones.selected,
selectedAuthor: state => state.authors.selected,
selectedLabelList: state => state.labels.selectedList,
selectedAssignee: state => state.assignees.selected,
}),
chartOptions() {
return {
xAxis: {
......
<script>
import { mapState } from 'vuex';
import dateFormat from 'dateformat';
import {
GlTable,
......@@ -13,6 +14,7 @@ import {
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { approximateDuration, differenceInSeconds } from '~/lib/utils/datetime_utility';
import { filterToQueryObject } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import { dateFormats } from '../../shared/constants';
import throughputTableQuery from '../graphql/queries/throughput_table.query.graphql';
import {
......@@ -114,11 +116,19 @@ export default {
throughputTableData: {
query: throughputTableQuery,
variables() {
const options = filterToQueryObject({
labels: this.selectedLabelList,
authorUsername: this.selectedAuthor,
assigneeUsername: this.selectedAssignee,
milestoneTitle: this.selectedMilestone,
});
return {
fullPath: this.fullPath,
limit: MAX_RECORDS,
startDate: dateFormat(this.startDate, dateFormats.isoDate),
endDate: dateFormat(this.endDate, dateFormats.isoDate),
...options,
};
},
update: data => data.project.mergeRequests.nodes,
......@@ -131,6 +141,12 @@ export default {
},
},
computed: {
...mapState('filters', {
selectedMilestone: state => state.milestones.selected,
selectedAuthor: state => state.authors.selected,
selectedLabelList: state => state.labels.selectedList,
selectedAssignee: state => state.assignees.selected,
}),
tableDataAvailable() {
return this.throughputTableData.length;
},
......
query($fullPath: ID!, $startDate: Time!, $endDate: Time!, $limit: Int!) {
query(
$fullPath: ID!
$startDate: Time!
$endDate: Time!
$limit: Int!
$labels: [String!]
$authorUsername: String
$assigneeUsername: String
$milestoneTitle: String
) {
project(fullPath: $fullPath) {
mergeRequests(
first: $limit
mergedAfter: $startDate
mergedBefore: $endDate
sort: MERGED_AT_DESC
labels: $labels
authorUsername: $authorUsername
assigneeUsername: $assigneeUsername
milestoneTitle: $milestoneTitle
) {
nodes {
iid
......
......@@ -21,11 +21,11 @@ export default (startDate = null, endDate = null) => {
// first: 0 is an optimization which makes sure we don't load merge request objects into memory (backend).
// Currently when requesting counts we also load the first 100 records (preloader problem).
return `${month}_${year}: mergeRequests(first: 0, mergedBefore: "${mergedBefore}", mergedAfter: "${mergedAfter}") { count }`;
return `${month}_${year}: mergeRequests(first: 0, mergedBefore: "${mergedBefore}", mergedAfter: "${mergedAfter}", labels: $labels, authorUsername: $authorUsername, assigneeUsername: $assigneeUsername, milestoneTitle: $milestoneTitle) { count }`;
});
return gql`
query($fullPath: ID!) {
query($fullPath: ID!, $labels: [String!], $authorUsername: String, $assigneeUsername: String, $milestoneTitle: String) {
throughputChartData: project(fullPath: $fullPath) {
${computedMonthData}
}
......
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import { urlQueryToFilter } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import createStore from './store';
import MergeRequestAnalyticsApp from './components/app.vue';
import { ITEM_TYPE } from '~/groups/constants';
Vue.use(VueApollo);
......@@ -14,14 +17,36 @@ export default () => {
if (!el) return false;
const { fullPath } = el.dataset;
const { type, fullPath, milestonePath, labelsPath } = el.dataset;
const store = createStore();
store.dispatch('filters/setEndpoints', {
milestonesEndpoint: milestonePath,
labelsEndpoint: labelsPath,
groupEndpoint: type === ITEM_TYPE.GROUP ? fullPath : null,
projectEndpoint: type === ITEM_TYPE.PROJECT ? fullPath : null,
});
const {
assignee_username = null,
author_username = null,
milestone_title = null,
label_name = [],
} = urlQueryToFilter(window.location.search);
store.dispatch('filters/initialize', {
selectedAssignee: assignee_username,
selectedAuthor: author_username,
selectedMilestone: milestone_title,
selectedLabelList: label_name,
});
return new Vue({
el,
apolloProvider,
store,
name: 'MergeRequestAnalyticsApp',
provide: {
fullPath,
type,
},
render: createElement => createElement(MergeRequestAnalyticsApp),
});
......
export function setFilters() {
return Promise.resolve();
}
import Vue from 'vue';
import Vuex from 'vuex';
import filters from 'ee/analytics/shared/store/modules/filters';
import * as actions from './actions';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
actions,
modules: { filters },
});
......@@ -4,10 +4,12 @@ import { __ } from '~/locale';
import Api from '~/api';
import * as types from './mutation_types';
export const setEndpoints = ({ commit }, { milestonesEndpoint, labelsEndpoint, groupEndpoint }) => {
export const setEndpoints = ({ commit }, params) => {
const { milestonesEndpoint, labelsEndpoint, groupEndpoint, projectEndpoint } = params;
commit(types.SET_MILESTONES_ENDPOINT, milestonesEndpoint);
commit(types.SET_LABELS_ENDPOINT, labelsEndpoint);
commit(types.SET_GROUP_ENDPOINT, groupEndpoint);
commit(types.SET_PROJECT_ENDPOINT, projectEndpoint);
};
export const fetchMilestones = ({ commit, state }, search_title = '') => {
......@@ -43,10 +45,18 @@ export const fetchLabels = ({ commit, state }, search = '') => {
});
};
const fetchUser = ({ commit, endpoint, query, action, errorMessage }) => {
function fetchUser(options = {}) {
const { commit, projectEndpoint, groupEndpoint, query, action, errorMessage } = options;
commit(`REQUEST_${action}`);
return Api.groupMembers(endpoint, { query })
let fetchUserPromise;
if (projectEndpoint) {
fetchUserPromise = Api.projectUsers(projectEndpoint, query).then(data => ({ data }));
} else {
fetchUserPromise = Api.groupMembers(groupEndpoint, { query });
}
return fetchUserPromise
.then(response => {
commit(`RECEIVE_${action}_SUCCESS`, response.data);
return response;
......@@ -56,25 +66,29 @@ const fetchUser = ({ commit, endpoint, query, action, errorMessage }) => {
commit(`RECEIVE_${action}_ERROR`, status);
createFlash(errorMessage);
});
};
}
export const fetchAuthors = ({ commit, state }, query = '') => {
const { groupEndpoint } = state;
const { projectEndpoint, groupEndpoint } = state;
return fetchUser({
commit,
query,
endpoint: groupEndpoint,
projectEndpoint,
groupEndpoint,
action: 'AUTHORS',
errorMessage: __('Failed to load authors. Please try again.'),
});
};
export const fetchAssignees = ({ commit, state }, query = '') => {
const { groupEndpoint } = state;
const { projectEndpoint, groupEndpoint } = state;
return fetchUser({
commit,
query,
endpoint: groupEndpoint,
projectEndpoint,
groupEndpoint,
action: 'ASSIGNEES',
errorMessage: __('Failed to load assignees. Please try again.'),
});
......
export const SET_MILESTONES_ENDPOINT = 'SET_MILESTONES_ENDPOINT';
export const SET_LABELS_ENDPOINT = 'SET_LABELS_ENDPOINT';
export const SET_GROUP_ENDPOINT = 'SET_GROUP_ENDPOINT';
export const SET_PROJECT_ENDPOINT = 'SET_PROJECT_ENDPOINT';
export const REQUEST_MILESTONES = 'REQUEST_MILESTONES';
export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
......
......@@ -30,6 +30,9 @@ export default {
[types.SET_GROUP_ENDPOINT](state, groupEndpoint) {
state.groupEndpoint = groupEndpoint;
},
[types.SET_PROJECT_ENDPOINT](state, projectEndpoint) {
state.projectEndpoint = projectEndpoint;
},
[types.REQUEST_MILESTONES](state) {
state.milestones.isLoading = true;
},
......
......@@ -2,6 +2,7 @@ export default () => ({
milestonesEndpoint: '',
labelsEndpoint: '',
groupEndpoint: '',
projectEndpoint: '',
milestones: {
isLoading: false,
errorCode: null,
......
- page_title _('Merge Request Analytics')
#js-merge-request-analytics-app
#js-merge-request-analytics-app{ data: { type: 'group', full_path: @group.full_path, milestones_path: group_milestones_path(@group), labels_path: group_labels_path(@group) } }
- page_title _("Merge Request Analytics")
#js-merge-request-analytics-app{ data: { full_path: @project.full_path } }
#js-merge-request-analytics-app{ data: { type: 'project', full_path: @project.full_path, milestone_path: project_milestones_path(@project), labels_path: project_labels_path(@project) } }
import { shallowMount } from '@vue/test-utils';
import MergeRequestAnalyticsApp from 'ee/analytics/merge_request_analytics/components/app.vue';
import FilterBar from 'ee/analytics/merge_request_analytics/components/filter_bar.vue';
import ThroughputChart from 'ee/analytics/merge_request_analytics/components/throughput_chart.vue';
import ThroughputTable from 'ee/analytics/merge_request_analytics/components/throughput_table.vue';
describe('MergeRequestAnalyticsApp', () => {
let wrapper;
const createComponent = () => {
function createComponent() {
wrapper = shallowMount(MergeRequestAnalyticsApp);
};
}
beforeEach(() => {
createComponent();
......@@ -25,6 +26,10 @@ describe('MergeRequestAnalyticsApp', () => {
expect(pageTitle).toBe('Merge Request Analytics');
});
it('displays the filter bar component', () => {
expect(wrapper.find(FilterBar).exists()).toBe(true);
});
it('displays the throughput chart component', () => {
expect(wrapper.find(ThroughputChart).exists()).toBe(true);
});
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
import storeConfig from 'ee/analytics/merge_request_analytics/store';
import FilterBar from 'ee/analytics/merge_request_analytics/components/filter_bar.vue';
import initialFiltersState from 'ee/analytics/shared/store/modules/filters/state';
import * as utils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import UrlSync from '~/vue_shared/components/url_sync.vue';
import {
filterMilestones,
filterLabels,
filterUsers,
} from '../../shared/store/modules/filters/mock_data';
import * as commonUtils from '~/lib/utils/common_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import { ITEM_TYPE } from '~/groups/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
const milestoneTokenType = 'milestone';
const labelsTokenType = 'labels';
const authorTokenType = 'author';
const assigneeTokenType = 'assignee';
const initialFilterBarState = {
selectedMilestone: null,
selectedAuthor: null,
selectedAssignee: null,
selectedLabelList: null,
};
const defaultParams = {
milestone_title: null,
'not[milestone_title]': null,
author_username: null,
'not[author_username]': null,
assignee_username: null,
'not[assignee_username]': null,
label_name: null,
'not[label_name]': null,
};
async function shouldMergeUrlParams(wrapper, result) {
await wrapper.vm.$nextTick();
expect(urlUtils.mergeUrlParams).toHaveBeenCalledWith(result, window.location.href, {
spreadArrays: true,
});
expect(commonUtils.historyPushState).toHaveBeenCalled();
}
function getFilterParams(tokens, options = {}) {
const { key = 'value', operator = '=', prop = 'title' } = options;
return tokens.map(token => {
return { [key]: token[prop], operator };
});
}
function getFilterValues(tokens, options = {}) {
const { prop = 'title' } = options;
return tokens.map(token => token[prop]);
}
const selectedMilestoneParams = getFilterParams(filterMilestones);
const selectedLabelParams = getFilterParams(filterLabels);
const selectedUserParams = getFilterParams(filterUsers, { prop: 'name' });
const milestoneValues = getFilterValues(filterMilestones);
const labelValues = getFilterValues(filterLabels);
const userValues = getFilterValues(filterUsers, { prop: 'name' });
describe('Filter bar', () => {
let wrapper;
let vuexStore;
let mock;
let setFiltersMock;
const createStore = (initialState = {}) => {
setFiltersMock = jest.fn();
return new Vuex.Store({
modules: {
filters: {
namespaced: true,
state: {
...initialFiltersState(),
...initialState,
},
actions: {
setFilters: setFiltersMock,
},
},
},
});
};
function createComponent(initialStore, options = {}) {
const { type = ITEM_TYPE.PROJECT } = options;
return shallowMount(FilterBar, {
localVue,
store: initialStore,
provide: () => ({
fullPath: 'foo',
type,
}),
stubs: {
UrlSync,
},
});
}
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
const findFilteredSearch = () => wrapper.find(FilteredSearchBar);
const getSearchToken = type =>
findFilteredSearch()
.props('tokens')
.find(token => token.type === type);
describe('default', () => {
beforeEach(() => {
vuexStore = createStore();
wrapper = createComponent(vuexStore);
});
it('renders FilteredSearchBar component', () => {
expect(findFilteredSearch().exists()).toBe(true);
});
});
describe('when the state has data', () => {
beforeEach(() => {
vuexStore = createStore({
milestones: { data: filterMilestones },
labels: { data: filterLabels },
authors: { data: userValues },
assignees: { data: userValues },
});
wrapper = createComponent(vuexStore);
});
it('displays the milestone, label, author and assignee tokens', () => {
const tokens = findFilteredSearch().props('tokens');
expect(tokens).toHaveLength(4);
expect(tokens[0].type).toBe(milestoneTokenType);
expect(tokens[1].type).toBe(labelsTokenType);
expect(tokens[2].type).toBe(authorTokenType);
expect(tokens[3].type).toBe(assigneeTokenType);
});
it('provides the initial milestone token', () => {
const { initialMilestones: milestoneToken } = getSearchToken(milestoneTokenType);
expect(milestoneToken).toHaveLength(filterMilestones.length);
});
it('provides the initial label token', () => {
const { initialLabels: labelToken } = getSearchToken(labelsTokenType);
expect(labelToken).toHaveLength(filterLabels.length);
});
it('provides the initial author token', () => {
const { initialAuthors: authorToken } = getSearchToken(authorTokenType);
expect(authorToken).toHaveLength(filterUsers.length);
});
it('provides the initial assignee token', () => {
const { initialAuthors: assigneeToken } = getSearchToken(assigneeTokenType);
expect(assigneeToken).toHaveLength(filterUsers.length);
});
});
describe('when the user interacts', () => {
beforeEach(() => {
vuexStore = createStore({
milestones: { data: filterMilestones },
labels: { data: filterLabels },
});
wrapper = createComponent(vuexStore);
jest.spyOn(utils, 'processFilters');
});
it('clicks on the search button, setFilters is dispatched', () => {
const filters = [
{ type: 'milestone', value: getFilterParams(filterMilestones, { key: 'data' })[2] },
{ type: 'labels', value: getFilterParams(filterLabels, { key: 'data' })[2] },
{ type: 'labels', value: getFilterParams(filterLabels, { key: 'data' })[4] },
{ type: 'assignee', value: getFilterParams(filterUsers, { key: 'data', prop: 'name' })[2] },
{ type: 'author', value: getFilterParams(filterUsers, { key: 'data', prop: 'name' })[1] },
];
findFilteredSearch().vm.$emit('onFilter', filters);
expect(utils.processFilters).toHaveBeenCalledWith(filters);
expect(setFiltersMock).toHaveBeenCalledWith(expect.anything(), {
selectedMilestone: selectedMilestoneParams[2],
selectedLabelList: [selectedLabelParams[2], selectedLabelParams[4]],
selectedAssignee: selectedUserParams[2],
selectedAuthor: selectedUserParams[1],
});
});
});
describe.each`
stateKey | payload | paramKey | value
${'selectedMilestone'} | ${selectedMilestoneParams[3]} | ${'milestone_title'} | ${milestoneValues[3]}
${'selectedMilestone'} | ${selectedMilestoneParams[0]} | ${'milestone_title'} | ${milestoneValues[0]}
${'selectedLabelList'} | ${selectedLabelParams} | ${'label_name'} | ${labelValues}
${'selectedLabelList'} | ${selectedLabelParams} | ${'label_name'} | ${labelValues}
${'selectedAuthor'} | ${selectedUserParams[0]} | ${'author_username'} | ${userValues[0]}
${'selectedAssignee'} | ${selectedUserParams[1]} | ${'assignee_username'} | ${userValues[1]}
`(
'with a $stateKey updates the $paramKey url parameter',
({ stateKey, payload, paramKey, value }) => {
beforeEach(() => {
commonUtils.historyPushState = jest.fn();
urlUtils.mergeUrlParams = jest.fn();
mock = new MockAdapter(axios);
wrapper = createComponent(storeConfig);
wrapper.vm.$store.dispatch('filters/setFilters', {
...initialFilterBarState,
[stateKey]: payload,
});
});
it(`sets the ${paramKey} url parameter`, async () => {
await shouldMergeUrlParams(wrapper, {
...defaultParams,
[paramKey]: value,
});
});
},
);
});
import { shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlAlert } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts';
import store from 'ee/analytics/merge_request_analytics/store';
import ThroughputChart from 'ee/analytics/merge_request_analytics/components/throughput_chart.vue';
import { THROUGHPUT_CHART_STRINGS } from 'ee/analytics/merge_request_analytics/constants';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { throughputChartData, startDate, endDate, fullPath } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const defaultQueryVariables = {
assigneeUsername: null,
authorUsername: null,
milestoneTitle: null,
labels: null,
};
const defaultMocks = {
$apollo: {
queries: {
throughputChartData: {},
},
},
};
describe('ThroughputChart', () => {
let wrapper;
const displaysComponent = (component, visible) => {
function displaysComponent(component, visible) {
const element = wrapper.find(component);
expect(element.exists()).toBe(visible);
};
const createComponent = ({ loading = false, data = {} } = {}) => {
const $apollo = {
queries: {
throughputChartData: {
loading,
},
},
};
wrapper = shallowMount(ThroughputChart, {
mocks: { $apollo },
}
function createComponent(options = {}) {
const { mocks = defaultMocks } = options;
return shallowMount(ThroughputChart, {
localVue,
store,
mocks,
provide: {
fullPath,
},
......@@ -34,9 +49,7 @@ describe('ThroughputChart', () => {
endDate,
},
});
wrapper.setData(data);
};
}
afterEach(() => {
wrapper.destroy();
......@@ -45,7 +58,7 @@ describe('ThroughputChart', () => {
describe('default state', () => {
beforeEach(() => {
createComponent();
wrapper = createComponent();
});
it('displays the chart title', () => {
......@@ -77,8 +90,16 @@ describe('ThroughputChart', () => {
});
describe('while loading', () => {
const apolloLoading = {
queries: {
throughputChartData: {
loading: true,
},
},
};
beforeEach(() => {
createComponent({ loading: true });
wrapper = createComponent({ mocks: { ...defaultMocks, $apollo: apolloLoading } });
});
it('displays a skeleton loader', () => {
......@@ -96,7 +117,8 @@ describe('ThroughputChart', () => {
describe('with data', () => {
beforeEach(() => {
createComponent({ data: { throughputChartData } });
wrapper = createComponent();
wrapper.setData({ throughputChartData });
});
it('displays the chart', () => {
......@@ -114,7 +136,8 @@ describe('ThroughputChart', () => {
describe('with errors', () => {
beforeEach(() => {
createComponent({ data: { hasError: true } });
wrapper = createComponent();
wrapper.setData({ hasError: true });
});
it('does not display the chart', () => {
......@@ -132,4 +155,40 @@ describe('ThroughputChart', () => {
expect(alert.text()).toBe(THROUGHPUT_CHART_STRINGS.ERROR_FETCHING_DATA);
});
});
describe('when fetching data', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('has initial variables set', () => {
expect(
wrapper.vm.$options.apollo.throughputChartData.variables.bind(wrapper.vm)(),
).toMatchObject(defaultQueryVariables);
});
it('gets filter variables from store', async () => {
const operator = '=';
const assigneeUsername = 'foo';
const authorUsername = 'bar';
const milestoneTitle = 'baz';
const labels = ['quis', 'quux'];
wrapper.vm.$store.dispatch('filters/initialize', {
selectedAssignee: { value: assigneeUsername, operator },
selectedAuthor: { value: authorUsername, operator },
selectedMilestone: { value: milestoneTitle, operator },
selectedLabelList: [{ value: labels[0], operator }, { value: labels[1], operator }],
});
await wrapper.vm.$nextTick();
expect(
wrapper.vm.$options.apollo.throughputChartData.variables.bind(wrapper.vm)(),
).toMatchObject({
assigneeUsername,
authorUsername,
milestoneTitle,
labels,
});
});
});
});
import { mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import { GlAlert, GlLoadingIcon, GlTable, GlIcon, GlAvatarsInline } from '@gitlab/ui';
import store from 'ee/analytics/merge_request_analytics/store';
import ThroughputTable from 'ee/analytics/merge_request_analytics/components/throughput_table.vue';
import {
THROUGHPUT_TABLE_STRINGS,
......@@ -13,20 +15,33 @@ import {
throughputTableHeaders,
} from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const defaultQueryVariables = {
assigneeUsername: null,
authorUsername: null,
milestoneTitle: null,
labels: null,
};
const defaultMocks = {
$apollo: {
queries: {
throughputTableData: {},
},
},
};
describe('ThroughputTable', () => {
let wrapper;
const createComponent = ({ loading = false, data = {} } = {}) => {
const $apollo = {
queries: {
throughputTableData: {
loading,
},
},
};
wrapper = mount(ThroughputTable, {
mocks: { $apollo },
function createComponent(options = {}) {
const { mocks = defaultMocks, func = shallowMount } = options;
return func(ThroughputTable, {
localVue,
store,
mocks,
provide: {
fullPath,
},
......@@ -35,9 +50,7 @@ describe('ThroughputTable', () => {
endDate,
},
});
wrapper.setData(data);
};
}
const displaysComponent = (component, visible) => {
expect(wrapper.find(component).exists()).toBe(visible);
......@@ -71,7 +84,7 @@ describe('ThroughputTable', () => {
describe('default state', () => {
beforeEach(() => {
createComponent();
wrapper = createComponent();
});
it('displays an empty state message when there is no data', () => {
......@@ -91,8 +104,16 @@ describe('ThroughputTable', () => {
});
describe('while loading', () => {
const apolloLoading = {
queries: {
throughputTableData: {
loading: true,
},
},
};
beforeEach(() => {
createComponent({ loading: true });
wrapper = createComponent({ mocks: { ...defaultMocks, $apollo: apolloLoading } });
});
it('displays a loading icon', () => {
......@@ -110,7 +131,8 @@ describe('ThroughputTable', () => {
describe('with data', () => {
beforeEach(() => {
createComponent({ data: { throughputTableData } });
wrapper = createComponent({ func: mount });
wrapper.setData({ throughputTableData });
});
it('displays the table', () => {
......@@ -275,7 +297,8 @@ describe('ThroughputTable', () => {
describe('with errors', () => {
beforeEach(() => {
createComponent({ data: { hasError: true } });
wrapper = createComponent();
wrapper.setData({ hasError: true });
});
it('does not display the table', () => {
......@@ -293,4 +316,40 @@ describe('ThroughputTable', () => {
expect(alert.text()).toBe(THROUGHPUT_TABLE_STRINGS.ERROR_FETCHING_DATA);
});
});
describe('when fetching data', () => {
beforeEach(() => {
wrapper = createComponent();
});
it('has initial variables set', () => {
expect(
wrapper.vm.$options.apollo.throughputTableData.variables.bind(wrapper.vm)(),
).toMatchObject(defaultQueryVariables);
});
it('gets filter variables from store', async () => {
const operator = '=';
const assigneeUsername = 'foo';
const authorUsername = 'bar';
const milestoneTitle = 'baz';
const labels = ['quis', 'quux'];
wrapper.vm.$store.dispatch('filters/initialize', {
selectedAssignee: { value: assigneeUsername, operator },
selectedAuthor: { value: authorUsername, operator },
selectedMilestone: { value: milestoneTitle, operator },
selectedLabelList: [{ value: labels[0], operator }, { value: labels[1], operator }],
});
await wrapper.vm.$nextTick();
expect(
wrapper.vm.$options.apollo.throughputTableData.variables.bind(wrapper.vm)(),
).toMatchObject({
assigneeUsername,
authorUsername,
milestoneTitle,
labels,
});
});
});
});
......@@ -31,15 +31,15 @@ export const expectedMonthData = [
},
];
export const throughputChartQuery = `query ($fullPath: ID!) {
export const throughputChartQuery = `query ($fullPath: ID!, $labels: [String!], $authorUsername: String, $assigneeUsername: String, $milestoneTitle: String) {
throughputChartData: project(fullPath: $fullPath) {
May_2020: mergeRequests(first: 0, mergedBefore: "2020-06-01", mergedAfter: "2020-05-01") {
May_2020: mergeRequests(first: 0, mergedBefore: "2020-06-01", mergedAfter: "2020-05-01", labels: $labels, authorUsername: $authorUsername, assigneeUsername: $assigneeUsername, milestoneTitle: $milestoneTitle) {
count
}
Jun_2020: mergeRequests(first: 0, mergedBefore: "2020-07-01", mergedAfter: "2020-06-01") {
Jun_2020: mergeRequests(first: 0, mergedBefore: "2020-07-01", mergedAfter: "2020-06-01", labels: $labels, authorUsername: $authorUsername, assigneeUsername: $assigneeUsername, milestoneTitle: $milestoneTitle) {
count
}
Jul_2020: mergeRequests(first: 0, mergedBefore: "2020-08-01", mergedAfter: "2020-07-01") {
Jul_2020: mergeRequests(first: 0, mergedBefore: "2020-08-01", mergedAfter: "2020-07-01", labels: $labels, authorUsername: $authorUsername, assigneeUsername: $assigneeUsername, milestoneTitle: $milestoneTitle) {
count
}
}
......
......@@ -11,6 +11,7 @@ import { filterMilestones, filterUsers, filterLabels } from './mock_data';
const milestonesEndpoint = 'fake_milestones_endpoint';
const labelsEndpoint = 'fake_labels_endpoint';
const groupEndpoint = 'fake_group_endpoint';
const projectEndpoint = 'fake_project_endpoint';
jest.mock('~/flash');
......@@ -37,6 +38,7 @@ describe('Filters actions', () => {
milestonesEndpoint,
labelsEndpoint,
groupEndpoint,
projectEndpoint,
selectedAuthor: 'Mr cool',
selectedMilestone: 'NEXT',
};
......@@ -98,12 +100,13 @@ describe('Filters actions', () => {
it('sets the api paths', () => {
return testAction(
actions.setEndpoints,
{ milestonesEndpoint, labelsEndpoint, groupEndpoint },
{ milestonesEndpoint, labelsEndpoint, groupEndpoint, projectEndpoint },
state,
[
{ payload: 'fake_milestones_endpoint', type: types.SET_MILESTONES_ENDPOINT },
{ payload: 'fake_labels_endpoint', type: types.SET_LABELS_ENDPOINT },
{ payload: 'fake_group_endpoint', type: types.SET_GROUP_ENDPOINT },
{ payload: 'fake_project_endpoint', type: types.SET_PROJECT_ENDPOINT },
],
[],
);
......@@ -111,22 +114,49 @@ describe('Filters actions', () => {
});
describe('fetchAuthors', () => {
let restoreVersion;
beforeEach(() => {
restoreVersion = gon.api_version;
gon.api_version = 'v1';
});
afterEach(() => {
gon.api_version = restoreVersion;
});
describe('success', () => {
beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers);
});
it('dispatches RECEIVE_AUTHORS_SUCCESS with received data', () => {
it('dispatches RECEIVE_AUTHORS_SUCCESS with received data and groupEndpoint set', () => {
return testAction(
actions.fetchAuthors,
null,
state,
{ ...state, groupEndpoint },
[
{ type: types.REQUEST_AUTHORS },
{ type: types.RECEIVE_AUTHORS_SUCCESS, payload: filterUsers },
],
[],
).then(({ data }) => {
expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members');
expect(data).toBe(filterUsers);
});
});
it('dispatches RECEIVE_AUTHORS_SUCCESS with received data and projectEndpoint set', () => {
return testAction(
actions.fetchAuthors,
null,
{ ...state, projectEndpoint },
[
{ type: types.REQUEST_AUTHORS },
{ type: types.RECEIVE_AUTHORS_SUCCESS, payload: filterUsers },
],
[],
).then(({ data }) => {
expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users');
expect(data).toBe(filterUsers);
});
});
......@@ -137,11 +167,11 @@ describe('Filters actions', () => {
mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
});
it('dispatches RECEIVE_AUTHORS_ERROR', () => {
it('dispatches RECEIVE_AUTHORS_ERROR and groupEndpoint set', () => {
return testAction(
actions.fetchAuthors,
null,
state,
{ ...state, groupEndpoint },
[
{ type: types.REQUEST_AUTHORS },
{
......@@ -150,7 +180,29 @@ describe('Filters actions', () => {
},
],
[],
).then(() => expect(createFlash).toHaveBeenCalled());
).then(() => {
expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members');
expect(createFlash).toHaveBeenCalled();
});
});
it('dispatches RECEIVE_AUTHORS_ERROR and projectEndpoint set', () => {
return testAction(
actions.fetchAuthors,
null,
{ ...state, projectEndpoint },
[
{ type: types.REQUEST_AUTHORS },
{
type: types.RECEIVE_AUTHORS_ERROR,
payload: httpStatusCodes.SERVICE_UNAVAILABLE,
},
],
[],
).then(() => {
expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users');
expect(createFlash).toHaveBeenCalled();
});
});
});
});
......@@ -202,36 +254,67 @@ describe('Filters actions', () => {
describe('fetchAssignees', () => {
describe('success', () => {
let restoreVersion;
beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers);
restoreVersion = gon.api_version;
gon.api_version = 'v1';
});
it('dispatches RECEIVE_ASSIGNEES_SUCCESS with received data', () => {
afterEach(() => {
gon.api_version = restoreVersion;
});
it('dispatches RECEIVE_ASSIGNEES_SUCCESS with received data and groupEndpoint set', () => {
return testAction(
actions.fetchAssignees,
null,
{ ...state, milestonesEndpoint },
{ ...state, milestonesEndpoint, groupEndpoint },
[
{ type: types.REQUEST_ASSIGNEES },
{ type: types.RECEIVE_ASSIGNEES_SUCCESS, payload: filterUsers },
],
[],
).then(({ data }) => {
expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members');
expect(data).toBe(filterUsers);
});
});
it('dispatches RECEIVE_ASSIGNEES_SUCCESS with received data and projectEndpoint set', () => {
return testAction(
actions.fetchAssignees,
null,
{ ...state, milestonesEndpoint, projectEndpoint },
[
{ type: types.REQUEST_ASSIGNEES },
{ type: types.RECEIVE_ASSIGNEES_SUCCESS, payload: filterUsers },
],
[],
).then(({ data }) => {
expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users');
expect(data).toBe(filterUsers);
});
});
});
describe('error', () => {
let restoreVersion;
beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
restoreVersion = gon.api_version;
gon.api_version = 'v1';
});
afterEach(() => {
gon.api_version = restoreVersion;
});
it('dispatches RECEIVE_ASSIGNEES_ERROR', () => {
it('dispatches RECEIVE_ASSIGNEES_ERROR and groupEndpoint set', () => {
return testAction(
actions.fetchAssignees,
null,
state,
{ ...state, groupEndpoint },
[
{ type: types.REQUEST_ASSIGNEES },
{
......@@ -240,7 +323,29 @@ describe('Filters actions', () => {
},
],
[],
).then(() => expect(createFlash).toHaveBeenCalled());
).then(() => {
expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members');
expect(createFlash).toHaveBeenCalled();
});
});
it('dispatches RECEIVE_ASSIGNEES_ERROR and projectEndpoint set', () => {
return testAction(
actions.fetchAssignees,
null,
{ ...state, projectEndpoint },
[
{ type: types.REQUEST_ASSIGNEES },
{
type: types.RECEIVE_ASSIGNEES_ERROR,
payload: httpStatusCodes.SERVICE_UNAVAILABLE,
},
],
[],
).then(() => {
expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users');
expect(createFlash).toHaveBeenCalled();
});
});
});
});
......
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