Commit 65e7b302 authored by Martin Wortschack's avatar Martin Wortschack

Merge branch '229266-mlunoe-add-filter-bar-to-merge-request-analytics' into 'master'

Add filter controls to the Merge Request Analytics page

Closes #230287

See merge request gitlab-org/gitlab!41112
parents 4d878242 565bbf57
<script> <script>
import { getDateInPast } from '~/lib/utils/datetime_utility'; import { getDateInPast } from '~/lib/utils/datetime_utility';
import { DEFAULT_NUMBER_OF_DAYS } from '../constants'; import { DEFAULT_NUMBER_OF_DAYS } from '../constants';
import FilterBar from './filter_bar.vue';
import ThroughputChart from './throughput_chart.vue'; import ThroughputChart from './throughput_chart.vue';
import ThroughputTable from './throughput_table.vue'; import ThroughputTable from './throughput_table.vue';
export default { export default {
name: 'MergeRequestAnalyticsApp', name: 'MergeRequestAnalyticsApp',
components: { components: {
FilterBar,
ThroughputChart, ThroughputChart,
ThroughputTable, ThroughputTable,
}, },
...@@ -21,6 +23,7 @@ export default { ...@@ -21,6 +23,7 @@ export default {
<template> <template>
<div class="merge-request-analytics-wrapper"> <div class="merge-request-analytics-wrapper">
<h3 data-testid="pageTitle" class="gl-mb-5">{{ __('Merge Request Analytics') }}</h3> <h3 data-testid="pageTitle" class="gl-mb-5">{{ __('Merge Request Analytics') }}</h3>
<filter-bar />
<throughput-chart :start-date="startDate" :end-date="endDate" /> <throughput-chart :start-date="startDate" :end-date="endDate" />
<throughput-table :start-date="startDate" :end-date="endDate" class="gl-mt-6" /> <throughput-table :start-date="startDate" :end-date="endDate" class="gl-mt-6" />
</div> </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> <script>
import { mapState } from 'vuex';
import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { GlAreaChart } from '@gitlab/ui/dist/charts';
import { GlAlert } from '@gitlab/ui'; import { GlAlert } from '@gitlab/ui';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; 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 throughputChartQueryBuilder from '../graphql/throughput_chart_query_builder';
import { THROUGHPUT_CHART_STRINGS } from '../constants'; import { THROUGHPUT_CHART_STRINGS } from '../constants';
...@@ -35,8 +37,16 @@ export default { ...@@ -35,8 +37,16 @@ export default {
return throughputChartQueryBuilder(this.startDate, this.endDate); return throughputChartQueryBuilder(this.startDate, this.endDate);
}, },
variables() { variables() {
const options = filterToQueryObject({
labels: this.selectedLabelList,
authorUsername: this.selectedAuthor,
assigneeUsername: this.selectedAssignee,
milestoneTitle: this.selectedMilestone,
});
return { return {
fullPath: this.fullPath, fullPath: this.fullPath,
...options,
}; };
}, },
error() { error() {
...@@ -48,6 +58,12 @@ export default { ...@@ -48,6 +58,12 @@ export default {
}, },
}, },
computed: { computed: {
...mapState('filters', {
selectedMilestone: state => state.milestones.selected,
selectedAuthor: state => state.authors.selected,
selectedLabelList: state => state.labels.selectedList,
selectedAssignee: state => state.assignees.selected,
}),
chartOptions() { chartOptions() {
return { return {
xAxis: { xAxis: {
......
<script> <script>
import { mapState } from 'vuex';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
import { import {
GlTable, GlTable,
...@@ -13,6 +14,7 @@ import { ...@@ -13,6 +14,7 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import { approximateDuration, differenceInSeconds } from '~/lib/utils/datetime_utility'; 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 { dateFormats } from '../../shared/constants';
import throughputTableQuery from '../graphql/queries/throughput_table.query.graphql'; import throughputTableQuery from '../graphql/queries/throughput_table.query.graphql';
import { import {
...@@ -114,11 +116,19 @@ export default { ...@@ -114,11 +116,19 @@ export default {
throughputTableData: { throughputTableData: {
query: throughputTableQuery, query: throughputTableQuery,
variables() { variables() {
const options = filterToQueryObject({
labels: this.selectedLabelList,
authorUsername: this.selectedAuthor,
assigneeUsername: this.selectedAssignee,
milestoneTitle: this.selectedMilestone,
});
return { return {
fullPath: this.fullPath, fullPath: this.fullPath,
limit: MAX_RECORDS, limit: MAX_RECORDS,
startDate: dateFormat(this.startDate, dateFormats.isoDate), startDate: dateFormat(this.startDate, dateFormats.isoDate),
endDate: dateFormat(this.endDate, dateFormats.isoDate), endDate: dateFormat(this.endDate, dateFormats.isoDate),
...options,
}; };
}, },
update: data => data.project.mergeRequests.nodes, update: data => data.project.mergeRequests.nodes,
...@@ -131,6 +141,12 @@ export default { ...@@ -131,6 +141,12 @@ export default {
}, },
}, },
computed: { computed: {
...mapState('filters', {
selectedMilestone: state => state.milestones.selected,
selectedAuthor: state => state.authors.selected,
selectedLabelList: state => state.labels.selectedList,
selectedAssignee: state => state.assignees.selected,
}),
tableDataAvailable() { tableDataAvailable() {
return this.throughputTableData.length; 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) { project(fullPath: $fullPath) {
mergeRequests( mergeRequests(
first: $limit first: $limit
mergedAfter: $startDate mergedAfter: $startDate
mergedBefore: $endDate mergedBefore: $endDate
sort: MERGED_AT_DESC sort: MERGED_AT_DESC
labels: $labels
authorUsername: $authorUsername
assigneeUsername: $assigneeUsername
milestoneTitle: $milestoneTitle
) { ) {
nodes { nodes {
iid iid
......
...@@ -21,11 +21,11 @@ export default (startDate = null, endDate = null) => { ...@@ -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). // 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). // 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` return gql`
query($fullPath: ID!) { query($fullPath: ID!, $labels: [String!], $authorUsername: String, $assigneeUsername: String, $milestoneTitle: String) {
throughputChartData: project(fullPath: $fullPath) { throughputChartData: project(fullPath: $fullPath) {
${computedMonthData} ${computedMonthData}
} }
......
import Vue from 'vue'; import Vue from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql'; 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 MergeRequestAnalyticsApp from './components/app.vue';
import { ITEM_TYPE } from '~/groups/constants';
Vue.use(VueApollo); Vue.use(VueApollo);
...@@ -14,14 +17,36 @@ export default () => { ...@@ -14,14 +17,36 @@ export default () => {
if (!el) return false; 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({ return new Vue({
el, el,
apolloProvider, apolloProvider,
store,
name: 'MergeRequestAnalyticsApp', name: 'MergeRequestAnalyticsApp',
provide: { provide: {
fullPath, fullPath,
type,
}, },
render: createElement => createElement(MergeRequestAnalyticsApp), 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'; ...@@ -4,10 +4,12 @@ import { __ } from '~/locale';
import Api from '~/api'; import Api from '~/api';
import * as types from './mutation_types'; 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_MILESTONES_ENDPOINT, milestonesEndpoint);
commit(types.SET_LABELS_ENDPOINT, labelsEndpoint); commit(types.SET_LABELS_ENDPOINT, labelsEndpoint);
commit(types.SET_GROUP_ENDPOINT, groupEndpoint); commit(types.SET_GROUP_ENDPOINT, groupEndpoint);
commit(types.SET_PROJECT_ENDPOINT, projectEndpoint);
}; };
export const fetchMilestones = ({ commit, state }, search_title = '') => { export const fetchMilestones = ({ commit, state }, search_title = '') => {
...@@ -43,10 +45,18 @@ export const fetchLabels = ({ commit, state }, search = '') => { ...@@ -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}`); 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 => { .then(response => {
commit(`RECEIVE_${action}_SUCCESS`, response.data); commit(`RECEIVE_${action}_SUCCESS`, response.data);
return response; return response;
...@@ -56,25 +66,29 @@ const fetchUser = ({ commit, endpoint, query, action, errorMessage }) => { ...@@ -56,25 +66,29 @@ const fetchUser = ({ commit, endpoint, query, action, errorMessage }) => {
commit(`RECEIVE_${action}_ERROR`, status); commit(`RECEIVE_${action}_ERROR`, status);
createFlash(errorMessage); createFlash(errorMessage);
}); });
}; }
export const fetchAuthors = ({ commit, state }, query = '') => { export const fetchAuthors = ({ commit, state }, query = '') => {
const { groupEndpoint } = state; const { projectEndpoint, groupEndpoint } = state;
return fetchUser({ return fetchUser({
commit, commit,
query, query,
endpoint: groupEndpoint, projectEndpoint,
groupEndpoint,
action: 'AUTHORS', action: 'AUTHORS',
errorMessage: __('Failed to load authors. Please try again.'), errorMessage: __('Failed to load authors. Please try again.'),
}); });
}; };
export const fetchAssignees = ({ commit, state }, query = '') => { export const fetchAssignees = ({ commit, state }, query = '') => {
const { groupEndpoint } = state; const { projectEndpoint, groupEndpoint } = state;
return fetchUser({ return fetchUser({
commit, commit,
query, query,
endpoint: groupEndpoint, projectEndpoint,
groupEndpoint,
action: 'ASSIGNEES', action: 'ASSIGNEES',
errorMessage: __('Failed to load assignees. Please try again.'), errorMessage: __('Failed to load assignees. Please try again.'),
}); });
......
export const SET_MILESTONES_ENDPOINT = 'SET_MILESTONES_ENDPOINT'; export const SET_MILESTONES_ENDPOINT = 'SET_MILESTONES_ENDPOINT';
export const SET_LABELS_ENDPOINT = 'SET_LABELS_ENDPOINT'; export const SET_LABELS_ENDPOINT = 'SET_LABELS_ENDPOINT';
export const SET_GROUP_ENDPOINT = 'SET_GROUP_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 REQUEST_MILESTONES = 'REQUEST_MILESTONES';
export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS'; export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
......
...@@ -30,6 +30,9 @@ export default { ...@@ -30,6 +30,9 @@ export default {
[types.SET_GROUP_ENDPOINT](state, groupEndpoint) { [types.SET_GROUP_ENDPOINT](state, groupEndpoint) {
state.groupEndpoint = groupEndpoint; state.groupEndpoint = groupEndpoint;
}, },
[types.SET_PROJECT_ENDPOINT](state, projectEndpoint) {
state.projectEndpoint = projectEndpoint;
},
[types.REQUEST_MILESTONES](state) { [types.REQUEST_MILESTONES](state) {
state.milestones.isLoading = true; state.milestones.isLoading = true;
}, },
......
...@@ -2,6 +2,7 @@ export default () => ({ ...@@ -2,6 +2,7 @@ export default () => ({
milestonesEndpoint: '', milestonesEndpoint: '',
labelsEndpoint: '', labelsEndpoint: '',
groupEndpoint: '', groupEndpoint: '',
projectEndpoint: '',
milestones: { milestones: {
isLoading: false, isLoading: false,
errorCode: null, errorCode: null,
......
- page_title _('Merge Request Analytics') - 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") - 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 { shallowMount } from '@vue/test-utils';
import MergeRequestAnalyticsApp from 'ee/analytics/merge_request_analytics/components/app.vue'; 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 ThroughputChart from 'ee/analytics/merge_request_analytics/components/throughput_chart.vue';
import ThroughputTable from 'ee/analytics/merge_request_analytics/components/throughput_table.vue'; import ThroughputTable from 'ee/analytics/merge_request_analytics/components/throughput_table.vue';
describe('MergeRequestAnalyticsApp', () => { describe('MergeRequestAnalyticsApp', () => {
let wrapper; let wrapper;
const createComponent = () => { function createComponent() {
wrapper = shallowMount(MergeRequestAnalyticsApp); wrapper = shallowMount(MergeRequestAnalyticsApp);
}; }
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -25,6 +26,10 @@ describe('MergeRequestAnalyticsApp', () => { ...@@ -25,6 +26,10 @@ describe('MergeRequestAnalyticsApp', () => {
expect(pageTitle).toBe('Merge Request Analytics'); 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', () => { it('displays the throughput chart component', () => {
expect(wrapper.find(ThroughputChart).exists()).toBe(true); 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 { GlAlert } from '@gitlab/ui';
import { GlAreaChart } from '@gitlab/ui/dist/charts'; 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 ThroughputChart from 'ee/analytics/merge_request_analytics/components/throughput_chart.vue';
import { THROUGHPUT_CHART_STRINGS } from 'ee/analytics/merge_request_analytics/constants'; import { THROUGHPUT_CHART_STRINGS } from 'ee/analytics/merge_request_analytics/constants';
import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue';
import { throughputChartData, startDate, endDate, fullPath } from '../mock_data'; 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', () => { describe('ThroughputChart', () => {
let wrapper; let wrapper;
const displaysComponent = (component, visible) => { function displaysComponent(component, visible) {
const element = wrapper.find(component); const element = wrapper.find(component);
expect(element.exists()).toBe(visible); expect(element.exists()).toBe(visible);
}; }
const createComponent = ({ loading = false, data = {} } = {}) => { function createComponent(options = {}) {
const $apollo = { const { mocks = defaultMocks } = options;
queries: { return shallowMount(ThroughputChart, {
throughputChartData: { localVue,
loading, store,
}, mocks,
},
};
wrapper = shallowMount(ThroughputChart, {
mocks: { $apollo },
provide: { provide: {
fullPath, fullPath,
}, },
...@@ -34,9 +49,7 @@ describe('ThroughputChart', () => { ...@@ -34,9 +49,7 @@ describe('ThroughputChart', () => {
endDate, endDate,
}, },
}); });
}
wrapper.setData(data);
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -45,7 +58,7 @@ describe('ThroughputChart', () => { ...@@ -45,7 +58,7 @@ describe('ThroughputChart', () => {
describe('default state', () => { describe('default state', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); wrapper = createComponent();
}); });
it('displays the chart title', () => { it('displays the chart title', () => {
...@@ -77,8 +90,16 @@ describe('ThroughputChart', () => { ...@@ -77,8 +90,16 @@ describe('ThroughputChart', () => {
}); });
describe('while loading', () => { describe('while loading', () => {
const apolloLoading = {
queries: {
throughputChartData: {
loading: true,
},
},
};
beforeEach(() => { beforeEach(() => {
createComponent({ loading: true }); wrapper = createComponent({ mocks: { ...defaultMocks, $apollo: apolloLoading } });
}); });
it('displays a skeleton loader', () => { it('displays a skeleton loader', () => {
...@@ -96,7 +117,8 @@ describe('ThroughputChart', () => { ...@@ -96,7 +117,8 @@ describe('ThroughputChart', () => {
describe('with data', () => { describe('with data', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ data: { throughputChartData } }); wrapper = createComponent();
wrapper.setData({ throughputChartData });
}); });
it('displays the chart', () => { it('displays the chart', () => {
...@@ -114,7 +136,8 @@ describe('ThroughputChart', () => { ...@@ -114,7 +136,8 @@ describe('ThroughputChart', () => {
describe('with errors', () => { describe('with errors', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ data: { hasError: true } }); wrapper = createComponent();
wrapper.setData({ hasError: true });
}); });
it('does not display the chart', () => { it('does not display the chart', () => {
...@@ -132,4 +155,40 @@ describe('ThroughputChart', () => { ...@@ -132,4 +155,40 @@ describe('ThroughputChart', () => {
expect(alert.text()).toBe(THROUGHPUT_CHART_STRINGS.ERROR_FETCHING_DATA); 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 { 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 ThroughputTable from 'ee/analytics/merge_request_analytics/components/throughput_table.vue';
import { import {
THROUGHPUT_TABLE_STRINGS, THROUGHPUT_TABLE_STRINGS,
...@@ -13,20 +15,33 @@ import { ...@@ -13,20 +15,33 @@ import {
throughputTableHeaders, throughputTableHeaders,
} from '../mock_data'; } 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', () => { describe('ThroughputTable', () => {
let wrapper; let wrapper;
const createComponent = ({ loading = false, data = {} } = {}) => { function createComponent(options = {}) {
const $apollo = { const { mocks = defaultMocks, func = shallowMount } = options;
queries: { return func(ThroughputTable, {
throughputTableData: { localVue,
loading, store,
}, mocks,
},
};
wrapper = mount(ThroughputTable, {
mocks: { $apollo },
provide: { provide: {
fullPath, fullPath,
}, },
...@@ -35,9 +50,7 @@ describe('ThroughputTable', () => { ...@@ -35,9 +50,7 @@ describe('ThroughputTable', () => {
endDate, endDate,
}, },
}); });
}
wrapper.setData(data);
};
const displaysComponent = (component, visible) => { const displaysComponent = (component, visible) => {
expect(wrapper.find(component).exists()).toBe(visible); expect(wrapper.find(component).exists()).toBe(visible);
...@@ -71,7 +84,7 @@ describe('ThroughputTable', () => { ...@@ -71,7 +84,7 @@ describe('ThroughputTable', () => {
describe('default state', () => { describe('default state', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); wrapper = createComponent();
}); });
it('displays an empty state message when there is no data', () => { it('displays an empty state message when there is no data', () => {
...@@ -91,8 +104,16 @@ describe('ThroughputTable', () => { ...@@ -91,8 +104,16 @@ describe('ThroughputTable', () => {
}); });
describe('while loading', () => { describe('while loading', () => {
const apolloLoading = {
queries: {
throughputTableData: {
loading: true,
},
},
};
beforeEach(() => { beforeEach(() => {
createComponent({ loading: true }); wrapper = createComponent({ mocks: { ...defaultMocks, $apollo: apolloLoading } });
}); });
it('displays a loading icon', () => { it('displays a loading icon', () => {
...@@ -110,7 +131,8 @@ describe('ThroughputTable', () => { ...@@ -110,7 +131,8 @@ describe('ThroughputTable', () => {
describe('with data', () => { describe('with data', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ data: { throughputTableData } }); wrapper = createComponent({ func: mount });
wrapper.setData({ throughputTableData });
}); });
it('displays the table', () => { it('displays the table', () => {
...@@ -275,7 +297,8 @@ describe('ThroughputTable', () => { ...@@ -275,7 +297,8 @@ describe('ThroughputTable', () => {
describe('with errors', () => { describe('with errors', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ data: { hasError: true } }); wrapper = createComponent();
wrapper.setData({ hasError: true });
}); });
it('does not display the table', () => { it('does not display the table', () => {
...@@ -293,4 +316,40 @@ describe('ThroughputTable', () => { ...@@ -293,4 +316,40 @@ describe('ThroughputTable', () => {
expect(alert.text()).toBe(THROUGHPUT_TABLE_STRINGS.ERROR_FETCHING_DATA); 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 = [ ...@@ -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) { 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 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 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 count
} }
} }
......
...@@ -11,6 +11,7 @@ import { filterMilestones, filterUsers, filterLabels } from './mock_data'; ...@@ -11,6 +11,7 @@ import { filterMilestones, filterUsers, filterLabels } from './mock_data';
const milestonesEndpoint = 'fake_milestones_endpoint'; const milestonesEndpoint = 'fake_milestones_endpoint';
const labelsEndpoint = 'fake_labels_endpoint'; const labelsEndpoint = 'fake_labels_endpoint';
const groupEndpoint = 'fake_group_endpoint'; const groupEndpoint = 'fake_group_endpoint';
const projectEndpoint = 'fake_project_endpoint';
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -37,6 +38,7 @@ describe('Filters actions', () => { ...@@ -37,6 +38,7 @@ describe('Filters actions', () => {
milestonesEndpoint, milestonesEndpoint,
labelsEndpoint, labelsEndpoint,
groupEndpoint, groupEndpoint,
projectEndpoint,
selectedAuthor: 'Mr cool', selectedAuthor: 'Mr cool',
selectedMilestone: 'NEXT', selectedMilestone: 'NEXT',
}; };
...@@ -98,12 +100,13 @@ describe('Filters actions', () => { ...@@ -98,12 +100,13 @@ describe('Filters actions', () => {
it('sets the api paths', () => { it('sets the api paths', () => {
return testAction( return testAction(
actions.setEndpoints, actions.setEndpoints,
{ milestonesEndpoint, labelsEndpoint, groupEndpoint }, { milestonesEndpoint, labelsEndpoint, groupEndpoint, projectEndpoint },
state, state,
[ [
{ payload: 'fake_milestones_endpoint', type: types.SET_MILESTONES_ENDPOINT }, { payload: 'fake_milestones_endpoint', type: types.SET_MILESTONES_ENDPOINT },
{ payload: 'fake_labels_endpoint', type: types.SET_LABELS_ENDPOINT }, { payload: 'fake_labels_endpoint', type: types.SET_LABELS_ENDPOINT },
{ payload: 'fake_group_endpoint', type: types.SET_GROUP_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', () => { ...@@ -111,22 +114,49 @@ describe('Filters actions', () => {
}); });
describe('fetchAuthors', () => { describe('fetchAuthors', () => {
let restoreVersion;
beforeEach(() => {
restoreVersion = gon.api_version;
gon.api_version = 'v1';
});
afterEach(() => {
gon.api_version = restoreVersion;
});
describe('success', () => { describe('success', () => {
beforeEach(() => { beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers); 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( return testAction(
actions.fetchAuthors, actions.fetchAuthors,
null, 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.REQUEST_AUTHORS },
{ type: types.RECEIVE_AUTHORS_SUCCESS, payload: filterUsers }, { type: types.RECEIVE_AUTHORS_SUCCESS, payload: filterUsers },
], ],
[], [],
).then(({ data }) => { ).then(({ data }) => {
expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users');
expect(data).toBe(filterUsers); expect(data).toBe(filterUsers);
}); });
}); });
...@@ -137,11 +167,11 @@ describe('Filters actions', () => { ...@@ -137,11 +167,11 @@ describe('Filters actions', () => {
mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
}); });
it('dispatches RECEIVE_AUTHORS_ERROR', () => { it('dispatches RECEIVE_AUTHORS_ERROR and groupEndpoint set', () => {
return testAction( return testAction(
actions.fetchAuthors, actions.fetchAuthors,
null, null,
state, { ...state, groupEndpoint },
[ [
{ type: types.REQUEST_AUTHORS }, { type: types.REQUEST_AUTHORS },
{ {
...@@ -150,7 +180,29 @@ describe('Filters actions', () => { ...@@ -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', () => { ...@@ -202,36 +254,67 @@ describe('Filters actions', () => {
describe('fetchAssignees', () => { describe('fetchAssignees', () => {
describe('success', () => { describe('success', () => {
let restoreVersion;
beforeEach(() => { beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers); 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( return testAction(
actions.fetchAssignees, actions.fetchAssignees,
null, 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.REQUEST_ASSIGNEES },
{ type: types.RECEIVE_ASSIGNEES_SUCCESS, payload: filterUsers }, { type: types.RECEIVE_ASSIGNEES_SUCCESS, payload: filterUsers },
], ],
[], [],
).then(({ data }) => { ).then(({ data }) => {
expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users');
expect(data).toBe(filterUsers); expect(data).toBe(filterUsers);
}); });
}); });
}); });
describe('error', () => { describe('error', () => {
let restoreVersion;
beforeEach(() => { beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE); 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( return testAction(
actions.fetchAssignees, actions.fetchAssignees,
null, null,
state, { ...state, groupEndpoint },
[ [
{ type: types.REQUEST_ASSIGNEES }, { type: types.REQUEST_ASSIGNEES },
{ {
...@@ -240,7 +323,29 @@ describe('Filters actions', () => { ...@@ -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