Commit 182b76f2 authored by Michael Lunøe's avatar Michael Lunøe Committed by Kushal Pandya

Feat(Merge Request Analytics): add branch filter

Add source and target branch filter to the filter
controls on the Merge Request Analytics page, so
the user can see data based on these filters
parent 1584c0d1
<script>
import {
GlToken,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY } from '../constants';
export default {
components: {
GlToken,
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlDropdownDivider,
GlLoadingIcon,
},
props: {
config: {
type: Object,
required: true,
},
value: {
type: Object,
required: true,
},
},
data() {
return {
branches: this.config.initialBranches || [],
defaultBranches: this.config.defaultBranches || [],
loading: true,
};
},
computed: {
currentValue() {
return this.value.data.toLowerCase();
},
activeBranch() {
return this.branches.find(branch => branch.name.toLowerCase() === this.currentValue);
},
},
watch: {
active: {
immediate: true,
handler(newValue) {
if (!newValue && !this.branches.length) {
this.fetchBranchBySearchTerm(this.value.data);
}
},
},
},
methods: {
fetchBranchBySearchTerm(searchTerm) {
this.loading = true;
this.config
.fetchBranches(searchTerm)
.then(({ data }) => {
this.branches = data;
})
.catch(() => createFlash({ message: __('There was a problem fetching branches.') }))
.finally(() => {
this.loading = false;
});
},
searchBranches: debounce(function debouncedSearch({ data }) {
this.fetchBranchBySearchTerm(data);
}, DEBOUNCE_DELAY),
},
};
</script>
<template>
<gl-filtered-search-token
:config="config"
v-bind="{ ...$props, ...$attrs }"
v-on="$listeners"
@input="searchBranches"
>
<template #view-token="{ inputValue }">
<gl-token variant="search-value">{{
activeBranch ? activeBranch.name : inputValue
}}</gl-token>
</template>
<template #suggestions>
<gl-filtered-search-suggestion
v-for="branch in defaultBranches"
:key="branch.value"
:value="branch.value"
>
{{ branch.text }}
</gl-filtered-search-suggestion>
<gl-dropdown-divider v-if="defaultBranches.length" />
<gl-loading-icon v-if="loading" />
<template v-else>
<gl-filtered-search-suggestion
v-for="branch in branches"
:key="branch.id"
:value="branch.name"
>
<div class="gl-display-flex">
<span class="gl-display-inline-block gl-mr-3 gl-p-3"></span>
<div>{{ branch.name }}</div>
</div>
</gl-filtered-search-suggestion>
</template>
</template>
</gl-filtered-search-token>
</template>
......@@ -3,6 +3,7 @@ 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 BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.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';
......@@ -25,17 +26,40 @@ export default {
inject: ['fullPath', 'type'],
computed: {
...mapState('filters', {
selectedSourceBranch: state => state.branches.source.selected,
selectedTargetBranch: state => state.branches.target.selected,
selectedMilestone: state => state.milestones.selected,
selectedAuthor: state => state.authors.selected,
selectedLabelList: state => state.labels.selectedList,
selectedAssignee: state => state.assignees.selected,
selectedLabelList: state => state.labels.selectedList,
milestonesData: state => state.milestones.data,
labelsData: state => state.labels.data,
authorsData: state => state.authors.data,
assigneesData: state => state.assignees.data,
authorsData: state => state.authors.data,
branchesData: state => state.branches.data,
}),
tokens() {
return [
{
icon: 'branch',
title: __('Source Branch'),
type: 'source_branch',
token: BranchToken,
initialBranches: this.branchesData,
unique: true,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchBranches: this.fetchBranches,
},
{
icon: 'branch',
title: __('Target Branch'),
type: 'target_branch',
token: BranchToken,
initialBranches: this.branchesData,
unique: true,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchBranches: this.fetchBranches,
},
{
icon: 'clock',
title: __('Milestone'),
......@@ -85,6 +109,8 @@ export default {
},
query() {
return filterToQueryObject({
source_branch_name: this.selectedSourceBranch,
target_branch_name: this.selectedTargetBranch,
milestone_title: this.selectedMilestone,
label_name: this.selectedLabelList,
author_username: this.selectedAuthor,
......@@ -93,6 +119,8 @@ export default {
},
initialFilterValue() {
return prepareTokens({
source_branch: this.selectedSourceBranch,
target_branch: this.selectedTargetBranch,
milestone: this.selectedMilestone,
author: this.selectedAuthor,
assignee: this.selectedAssignee,
......@@ -103,15 +131,25 @@ export default {
methods: {
...mapActions('filters', [
'setFilters',
'fetchBranches',
'fetchMilestones',
'fetchLabels',
'fetchAuthors',
'fetchAssignees',
'fetchLabels',
]),
handleFilter(filters) {
const { labels, milestone, author, assignee } = processFilters(filters);
const {
source_branch: sourceBranch,
target_branch: targetBranch,
milestone,
author,
assignee,
labels,
} = processFilters(filters);
this.setFilters({
selectedSourceBranch: sourceBranch ? sourceBranch[0] : null,
selectedTargetBranch: targetBranch ? targetBranch[0] : null,
selectedAuthor: author ? author[0] : null,
selectedMilestone: milestone ? milestone[0] : null,
selectedAssignee: assignee ? assignee[0] : null,
......
......@@ -38,10 +38,12 @@ export default {
},
variables() {
const options = filterToQueryObject({
labels: this.selectedLabelList,
sourceBranches: this.selectedSourceBranch,
targetBranches: this.selectedTargetBranch,
milestoneTitle: this.selectedMilestone,
authorUsername: this.selectedAuthor,
assigneeUsername: this.selectedAssignee,
milestoneTitle: this.selectedMilestone,
labels: this.selectedLabelList,
});
return {
......@@ -59,10 +61,12 @@ export default {
},
computed: {
...mapState('filters', {
selectedSourceBranch: state => state.branches.source.selected,
selectedTargetBranch: state => state.branches.target.selected,
selectedMilestone: state => state.milestones.selected,
selectedAuthor: state => state.authors.selected,
selectedLabelList: state => state.labels.selectedList,
selectedAssignee: state => state.assignees.selected,
selectedLabelList: state => state.labels.selectedList,
}),
chartOptions() {
return {
......
......@@ -117,10 +117,12 @@ export default {
query: throughputTableQuery,
variables() {
const options = filterToQueryObject({
labels: this.selectedLabelList,
sourceBranches: this.selectedSourceBranch,
targetBranches: this.selectedTargetBranch,
milestoneTitle: this.selectedMilestone,
authorUsername: this.selectedAuthor,
assigneeUsername: this.selectedAssignee,
milestoneTitle: this.selectedMilestone,
labels: this.selectedLabelList,
});
return {
......@@ -142,10 +144,12 @@ export default {
},
computed: {
...mapState('filters', {
selectedSourceBranch: state => state.branches.source.selected,
selectedTargetBranch: state => state.branches.target.selected,
selectedMilestone: state => state.milestones.selected,
selectedAuthor: state => state.authors.selected,
selectedLabelList: state => state.labels.selectedList,
selectedAssignee: state => state.assignees.selected,
selectedLabelList: state => state.labels.selectedList,
}),
tableDataAvailable() {
return this.throughputTableData.length;
......
......@@ -7,6 +7,8 @@ query(
$authorUsername: String
$assigneeUsername: String
$milestoneTitle: String
$sourceBranches: [String!]
$targetBranches: [String!]
) {
project(fullPath: $fullPath) {
mergeRequests(
......@@ -18,6 +20,8 @@ query(
authorUsername: $authorUsername
assigneeUsername: $assigneeUsername
milestoneTitle: $milestoneTitle
sourceBranches: $sourceBranches
targetBranches: $targetBranches
) {
nodes {
iid
......
......@@ -21,11 +21,31 @@ 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}", labels: $labels, authorUsername: $authorUsername, assigneeUsername: $assigneeUsername, milestoneTitle: $milestoneTitle) { count }`;
return `
${month}_${year}: mergeRequests(
first: 0,
mergedBefore: "${mergedBefore}",
mergedAfter: "${mergedAfter}",
labels: $labels,
authorUsername: $authorUsername,
assigneeUsername: $assigneeUsername,
milestoneTitle: $milestoneTitle,
sourceBranches: $sourceBranches,
targetBranches: $targetBranches
) { count }
`;
});
return gql`
query($fullPath: ID!, $labels: [String!], $authorUsername: String, $assigneeUsername: String, $milestoneTitle: String) {
query(
$fullPath: ID!,
$labels: [String!],
$authorUsername: String,
$assigneeUsername: String,
$milestoneTitle: String,
$sourceBranches: [String!],
$targetBranches: [String!]
) {
throughputChartData: project(fullPath: $fullPath) {
${computedMonthData}
}
......
......@@ -27,12 +27,16 @@ export default () => {
projectEndpoint: type === ITEM_TYPE.PROJECT ? fullPath : null,
});
const {
source_branch_name = null,
target_branch_name = null,
assignee_username = null,
author_username = null,
milestone_title = null,
label_name = [],
} = urlQueryToFilter(window.location.search);
store.dispatch('filters/initialize', {
selectedSourceBranch: source_branch_name,
selectedTargetBranch: target_branch_name,
selectedAssignee: assignee_username,
selectedAuthor: author_username,
selectedMilestone: milestone_title,
......
......@@ -12,6 +12,22 @@ export const setEndpoints = ({ commit }, params) => {
commit(types.SET_PROJECT_ENDPOINT, projectEndpoint);
};
export function fetchBranches({ commit, state }, search = '') {
const { projectEndpoint } = state;
commit(types.REQUEST_BRANCHES);
return Api.branches(projectEndpoint, search)
.then(response => {
commit(types.RECEIVE_BRANCHES_SUCCESS, response.data);
return response;
})
.catch(({ response }) => {
const { status } = response;
commit(types.RECEIVE_BRANCHES_ERROR, status);
createFlash(__('Failed to load branches. Please try again.'));
});
}
export const fetchMilestones = ({ commit, state }, search_title = '') => {
commit(types.REQUEST_MILESTONES);
const { milestonesEndpoint } = state;
......
......@@ -3,6 +3,10 @@ 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_BRANCHES = 'REQUEST_BRANCHES';
export const RECEIVE_BRANCHES_SUCCESS = 'RECEIVE_BRANCHES_SUCCESS';
export const RECEIVE_BRANCHES_ERROR = 'RECEIVE_BRANCHES_ERROR';
export const REQUEST_MILESTONES = 'REQUEST_MILESTONES';
export const RECEIVE_MILESTONES_SUCCESS = 'RECEIVE_MILESTONES_SUCCESS';
export const RECEIVE_MILESTONES_ERROR = 'RECEIVE_MILESTONES_ERROR';
......
......@@ -3,6 +3,10 @@ import * as types from './mutation_types';
export default {
[types.SET_SELECTED_FILTERS](state, params) {
const {
selectedSourceBranch = null,
selectedSourceBranchList = [],
selectedTargetBranch = null,
selectedTargetBranchList = [],
selectedAuthor = null,
selectedAuthorList = [],
selectedMilestone = null,
......@@ -12,6 +16,10 @@ export default {
selectedLabel = null,
selectedLabelList = [],
} = params;
state.branches.source.selected = selectedSourceBranch;
state.branches.source.selectedList = selectedSourceBranchList;
state.branches.target.selected = selectedTargetBranch;
state.branches.target.selectedList = selectedTargetBranchList;
state.authors.selected = selectedAuthor;
state.authors.selectedList = selectedAuthorList;
state.assignees.selected = selectedAssignee;
......@@ -85,4 +93,17 @@ export default {
state.assignees.errorCode = errorCode;
state.assignees.data = [];
},
[types.REQUEST_BRANCHES](state) {
state.branches.isLoading = true;
},
[types.RECEIVE_BRANCHES_SUCCESS](state, data) {
state.branches.isLoading = false;
state.branches.data = data;
state.branches.errorCode = null;
},
[types.RECEIVE_BRANCHES_ERROR](state, errorCode) {
state.branches.isLoading = false;
state.branches.errorCode = errorCode;
state.branches.data = [];
},
};
......@@ -3,6 +3,19 @@ export default () => ({
labelsEndpoint: '',
groupEndpoint: '',
projectEndpoint: '',
branches: {
isLoading: false,
errorCode: null,
data: [],
source: {
selected: null,
selectedList: [],
},
target: {
selected: null,
selectedList: [],
},
},
milestones: {
isLoading: false,
errorCode: null,
......
......@@ -5,27 +5,32 @@ 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 { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data';
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 * as commonUtils from '~/lib/utils/common_utils';
import * as urlUtils from '~/lib/utils/url_utility';
import { ITEM_TYPE } from '~/groups/constants';
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 sourceBranchTokenType = 'source_branch';
const targetBranchTokenType = 'target_branch';
const milestoneTokenType = 'milestone';
const labelsTokenType = 'labels';
const authorTokenType = 'author';
const assigneeTokenType = 'assignee';
const initialFilterBarState = {
selectedSourceBranch: null,
selectedTargetBranch: null,
selectedMilestone: null,
selectedAuthor: null,
selectedAssignee: null,
......@@ -33,6 +38,10 @@ const initialFilterBarState = {
};
const defaultParams = {
source_branch_name: null,
'not[source_branch_name]': null,
target_branch_name: null,
'not[target_branch_name]': null,
milestone_title: null,
'not[milestone_title]': null,
author_username: null,
......@@ -63,10 +72,12 @@ function getFilterValues(tokens, options = {}) {
return tokens.map(token => token[prop]);
}
const selectedBranchParams = getFilterParams(mockBranches, { prop: 'name' });
const selectedMilestoneParams = getFilterParams(filterMilestones);
const selectedLabelParams = getFilterParams(filterLabels);
const selectedUserParams = getFilterParams(filterUsers, { prop: 'name' });
const branchValues = getFilterValues(mockBranches, { prop: 'name' });
const milestoneValues = getFilterValues(filterMilestones);
const labelValues = getFilterValues(filterLabels);
const userValues = getFilterValues(filterUsers, { prop: 'name' });
......@@ -141,6 +152,7 @@ describe('Filter bar', () => {
describe('when the state has data', () => {
beforeEach(() => {
vuexStore = createStore({
branches: { data: mockBranches, target: {}, source: {} },
milestones: { data: filterMilestones },
labels: { data: filterLabels },
authors: { data: userValues },
......@@ -151,35 +163,53 @@ describe('Filter bar', () => {
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);
expect(tokens).toHaveLength(6);
[
sourceBranchTokenType,
targetBranchTokenType,
milestoneTokenType,
labelsTokenType,
authorTokenType,
assigneeTokenType,
].forEach((tokenType, index) => {
expect(tokens[index].type).toBe(tokenType);
});
});
it('provides the initial source branch token', () => {
const { initialBranches } = getSearchToken(sourceBranchTokenType);
expect(initialBranches).toHaveLength(mockBranches.length);
});
it('provides the initial target branch token', () => {
const { initialBranches } = getSearchToken(targetBranchTokenType);
expect(initialBranches).toHaveLength(mockBranches.length);
});
it('provides the initial milestone token', () => {
const { initialMilestones: milestoneToken } = getSearchToken(milestoneTokenType);
const { initialMilestones } = getSearchToken(milestoneTokenType);
expect(milestoneToken).toHaveLength(filterMilestones.length);
expect(initialMilestones).toHaveLength(filterMilestones.length);
});
it('provides the initial label token', () => {
const { initialLabels: labelToken } = getSearchToken(labelsTokenType);
const { initialLabels } = getSearchToken(labelsTokenType);
expect(labelToken).toHaveLength(filterLabels.length);
expect(initialLabels).toHaveLength(filterLabels.length);
});
it('provides the initial author token', () => {
const { initialAuthors: authorToken } = getSearchToken(authorTokenType);
const { initialAuthors } = getSearchToken(authorTokenType);
expect(authorToken).toHaveLength(filterUsers.length);
expect(initialAuthors).toHaveLength(filterUsers.length);
});
it('provides the initial assignee token', () => {
const { initialAuthors: assigneeToken } = getSearchToken(assigneeTokenType);
const { initialAuthors: initialAssignees } = getSearchToken(assigneeTokenType);
expect(assigneeToken).toHaveLength(filterUsers.length);
expect(initialAssignees).toHaveLength(filterUsers.length);
});
});
......@@ -195,6 +225,14 @@ describe('Filter bar', () => {
it('clicks on the search button, setFilters is dispatched', () => {
const filters = [
{
type: 'source_branch',
value: getFilterParams(mockBranches, { key: 'data', prop: 'name' })[2],
},
{
type: 'target_branch',
value: getFilterParams(mockBranches, { key: 'data', prop: 'name' })[0],
},
{ type: 'milestone', value: getFilterParams(filterMilestones, { key: 'data' })[2] },
{ type: 'labels', value: getFilterParams(filterLabels, { key: 'data' })[2] },
{ type: 'labels', value: getFilterParams(filterLabels, { key: 'data' })[4] },
......@@ -207,6 +245,8 @@ describe('Filter bar', () => {
expect(utils.processFilters).toHaveBeenCalledWith(filters);
expect(setFiltersMock).toHaveBeenCalledWith(expect.anything(), {
selectedSourceBranch: selectedBranchParams[2],
selectedTargetBranch: selectedBranchParams[0],
selectedMilestone: selectedMilestoneParams[2],
selectedLabelList: [selectedLabelParams[2], selectedLabelParams[4]],
selectedAssignee: selectedUserParams[2],
......@@ -217,6 +257,8 @@ describe('Filter bar', () => {
describe.each`
stateKey | payload | paramKey | value
${'selectedSourceBranch'} | ${selectedBranchParams[1]} | ${'source_branch_name'} | ${branchValues[1]}
${'selectedTargetBranch'} | ${selectedBranchParams[2]} | ${'target_branch_name'} | ${branchValues[2]}
${'selectedMilestone'} | ${selectedMilestoneParams[3]} | ${'milestone_title'} | ${milestoneValues[3]}
${'selectedMilestone'} | ${selectedMilestoneParams[0]} | ${'milestone_title'} | ${milestoneValues[0]}
${'selectedLabelList'} | ${selectedLabelParams} | ${'label_name'} | ${labelValues}
......
......@@ -31,15 +31,15 @@ export const expectedMonthData = [
},
];
export const throughputChartQuery = `query ($fullPath: ID!, $labels: [String!], $authorUsername: String, $assigneeUsername: String, $milestoneTitle: String) {
export const throughputChartQuery = `query ($fullPath: ID!, $labels: [String!], $authorUsername: String, $assigneeUsername: String, $milestoneTitle: String, $sourceBranches: [String!], $targetBranches: [String!]) {
throughputChartData: project(fullPath: $fullPath) {
May_2020: mergeRequests(first: 0, mergedBefore: "2020-06-01", mergedAfter: "2020-05-01", labels: $labels, authorUsername: $authorUsername, assigneeUsername: $assigneeUsername, milestoneTitle: $milestoneTitle) {
May_2020: mergeRequests(first: 0, mergedBefore: "2020-06-01", mergedAfter: "2020-05-01", labels: $labels, authorUsername: $authorUsername, assigneeUsername: $assigneeUsername, milestoneTitle: $milestoneTitle, sourceBranches: $sourceBranches, targetBranches: $targetBranches) {
count
}
Jun_2020: mergeRequests(first: 0, mergedBefore: "2020-07-01", mergedAfter: "2020-06-01", labels: $labels, authorUsername: $authorUsername, assigneeUsername: $assigneeUsername, milestoneTitle: $milestoneTitle) {
Jun_2020: mergeRequests(first: 0, mergedBefore: "2020-07-01", mergedAfter: "2020-06-01", labels: $labels, authorUsername: $authorUsername, assigneeUsername: $assigneeUsername, milestoneTitle: $milestoneTitle, sourceBranches: $sourceBranches, targetBranches: $targetBranches) {
count
}
Jul_2020: mergeRequests(first: 0, mergedBefore: "2020-08-01", mergedAfter: "2020-07-01", labels: $labels, authorUsername: $authorUsername, assigneeUsername: $assigneeUsername, milestoneTitle: $milestoneTitle) {
Jul_2020: mergeRequests(first: 0, mergedBefore: "2020-08-01", mergedAfter: "2020-07-01", labels: $labels, authorUsername: $authorUsername, assigneeUsername: $assigneeUsername, milestoneTitle: $milestoneTitle, sourceBranches: $sourceBranches, targetBranches: $targetBranches) {
count
}
}
......
......@@ -4,8 +4,10 @@ import testAction from 'helpers/vuex_action_helper';
import * as actions from 'ee/analytics/shared/store/modules/filters/actions';
import * as types from 'ee/analytics/shared/store/modules/filters/mutation_types';
import initialState from 'ee/analytics/shared/store/modules/filters/state';
import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data';
import httpStatusCodes from '~/lib/utils/http_status';
import { deprecatedCreateFlash as createFlash } from '~/flash';
import Api from '~/api';
import { filterMilestones, filterUsers, filterLabels } from './mock_data';
const milestonesEndpoint = 'fake_milestones_endpoint';
......@@ -113,6 +115,55 @@ describe('Filters actions', () => {
});
});
describe('fetchBranches', () => {
describe('success', () => {
beforeEach(() => {
const url = Api.buildUrl(Api.createBranchPath).replace(
':id',
encodeURIComponent(projectEndpoint),
);
mock.onGet(url).replyOnce(httpStatusCodes.OK, mockBranches);
});
it('dispatches RECEIVE_BRANCHES_SUCCESS with received data', () => {
return testAction(
actions.fetchBranches,
null,
{ ...state, projectEndpoint },
[
{ type: types.REQUEST_BRANCHES },
{ type: types.RECEIVE_BRANCHES_SUCCESS, payload: mockBranches },
],
[],
).then(({ data }) => {
expect(data).toBe(mockBranches);
});
});
});
describe('error', () => {
beforeEach(() => {
mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
});
it('dispatches RECEIVE_BRANCHES_ERROR', () => {
return testAction(
actions.fetchBranches,
null,
state,
[
{ type: types.REQUEST_BRANCHES },
{
type: types.RECEIVE_BRANCHES_ERROR,
payload: httpStatusCodes.SERVICE_UNAVAILABLE,
},
],
[],
).then(() => expect(createFlash).toHaveBeenCalled());
});
});
});
describe('fetchAuthors', () => {
let restoreVersion;
beforeEach(() => {
......
import { get } from 'lodash';
import initialState from 'ee/analytics/shared/store/modules/filters/state';
import mutations from 'ee/analytics/shared/store/modules/filters/mutations';
import * as types from 'ee/analytics/shared/store/modules/filters/mutation_types';
import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { filterMilestones, filterUsers, filterLabels } from './mock_data';
let state = null;
const branches = mockBranches.map(convertObjectPropsToCamelCase);
const milestones = filterMilestones.map(convertObjectPropsToCamelCase);
const users = filterUsers.map(convertObjectPropsToCamelCase);
const labels = filterLabels.map(convertObjectPropsToCamelCase);
......@@ -14,12 +18,7 @@ const filterValue = { value: 'foo' };
describe('Filters mutations', () => {
const errorCode = 500;
beforeEach(() => {
state = {
authors: { selected: null, selectedList: [] },
milestones: { selected: null, selectedList: [] },
assignees: { selected: null, selectedList: [] },
labels: { selected: null, selectedList: [] },
};
state = initialState();
});
afterEach(() => {
......@@ -38,34 +37,49 @@ describe('Filters mutations', () => {
});
it.each`
mutation | stateKey | stateProp | filterName | value
${types.SET_SELECTED_FILTERS} | ${'authors'} | ${'selected'} | ${'selectedAuthor'} | ${null}
${types.SET_SELECTED_FILTERS} | ${'authors'} | ${'selected'} | ${'selectedAuthor'} | ${filterValue}
${types.SET_SELECTED_FILTERS} | ${'authors'} | ${'selectedList'} | ${'selectedAuthorList'} | ${[]}
${types.SET_SELECTED_FILTERS} | ${'authors'} | ${'selectedList'} | ${'selectedAuthorList'} | ${[filterValue]}
${types.SET_SELECTED_FILTERS} | ${'milestones'} | ${'selected'} | ${'selectedMilestone'} | ${null}
${types.SET_SELECTED_FILTERS} | ${'milestones'} | ${'selected'} | ${'selectedMilestone'} | ${filterValue}
${types.SET_SELECTED_FILTERS} | ${'milestones'} | ${'selectedList'} | ${'selectedMilestoneList'} | ${[]}
${types.SET_SELECTED_FILTERS} | ${'milestones'} | ${'selectedList'} | ${'selectedMilestoneList'} | ${[filterValue]}
${types.SET_SELECTED_FILTERS} | ${'assignees'} | ${'selected'} | ${'selectedAssignee'} | ${null}
${types.SET_SELECTED_FILTERS} | ${'assignees'} | ${'selected'} | ${'selectedAssignee'} | ${filterValue}
${types.SET_SELECTED_FILTERS} | ${'assignees'} | ${'selectedList'} | ${'selectedAssigneeList'} | ${[]}
${types.SET_SELECTED_FILTERS} | ${'assignees'} | ${'selectedList'} | ${'selectedAssigneeList'} | ${[filterValue]}
${types.SET_SELECTED_FILTERS} | ${'labels'} | ${'selected'} | ${'selectedLabel'} | ${null}
${types.SET_SELECTED_FILTERS} | ${'labels'} | ${'selected'} | ${'selectedLabel'} | ${filterValue}
${types.SET_SELECTED_FILTERS} | ${'labels'} | ${'selectedList'} | ${'selectedLabelList'} | ${[]}
${types.SET_SELECTED_FILTERS} | ${'labels'} | ${'selectedList'} | ${'selectedLabelList'} | ${[filterValue]}
mutation | stateKey | filterName | value
${types.SET_SELECTED_FILTERS} | ${'branches.source.selected'} | ${'selectedSourceBranch'} | ${null}
${types.SET_SELECTED_FILTERS} | ${'branches.source.selected'} | ${'selectedSourceBranch'} | ${filterValue}
${types.SET_SELECTED_FILTERS} | ${'branches.source.selectedList'} | ${'selectedSourceBranchList'} | ${[]}
${types.SET_SELECTED_FILTERS} | ${'branches.source.selectedList'} | ${'selectedSourceBranchList'} | ${[filterValue]}
${types.SET_SELECTED_FILTERS} | ${'branches.target.selected'} | ${'selectedTargetBranch'} | ${null}
${types.SET_SELECTED_FILTERS} | ${'branches.target.selected'} | ${'selectedTargetBranch'} | ${filterValue}
${types.SET_SELECTED_FILTERS} | ${'branches.target.selectedList'} | ${'selectedTargetBranchList'} | ${[]}
${types.SET_SELECTED_FILTERS} | ${'branches.target.selectedList'} | ${'selectedTargetBranchList'} | ${[filterValue]}
${types.SET_SELECTED_FILTERS} | ${'authors.selected'} | ${'selectedAuthor'} | ${null}
${types.SET_SELECTED_FILTERS} | ${'authors.selected'} | ${'selectedAuthor'} | ${filterValue}
${types.SET_SELECTED_FILTERS} | ${'authors.selectedList'} | ${'selectedAuthorList'} | ${[]}
${types.SET_SELECTED_FILTERS} | ${'authors.selectedList'} | ${'selectedAuthorList'} | ${[filterValue]}
${types.SET_SELECTED_FILTERS} | ${'milestones.selected'} | ${'selectedMilestone'} | ${null}
${types.SET_SELECTED_FILTERS} | ${'milestones.selected'} | ${'selectedMilestone'} | ${filterValue}
${types.SET_SELECTED_FILTERS} | ${'milestones.selectedList'} | ${'selectedMilestoneList'} | ${[]}
${types.SET_SELECTED_FILTERS} | ${'milestones.selectedList'} | ${'selectedMilestoneList'} | ${[filterValue]}
${types.SET_SELECTED_FILTERS} | ${'assignees.selected'} | ${'selectedAssignee'} | ${null}
${types.SET_SELECTED_FILTERS} | ${'assignees.selected'} | ${'selectedAssignee'} | ${filterValue}
${types.SET_SELECTED_FILTERS} | ${'assignees.selectedList'} | ${'selectedAssigneeList'} | ${[]}
${types.SET_SELECTED_FILTERS} | ${'assignees.selectedList'} | ${'selectedAssigneeList'} | ${[filterValue]}
${types.SET_SELECTED_FILTERS} | ${'labels.selected'} | ${'selectedLabel'} | ${null}
${types.SET_SELECTED_FILTERS} | ${'labels.selected'} | ${'selectedLabel'} | ${filterValue}
${types.SET_SELECTED_FILTERS} | ${'labels.selectedList'} | ${'selectedLabelList'} | ${[]}
${types.SET_SELECTED_FILTERS} | ${'labels.selectedList'} | ${'selectedLabelList'} | ${[filterValue]}
`(
'$mutation will set $stateKey with a given value',
({ mutation, stateKey, stateProp, filterName, value }) => {
({ mutation, stateKey, filterName, value }) => {
mutations[mutation](state, { [filterName]: value });
expect(state[stateKey][stateProp]).toEqual(value);
expect(get(state, stateKey)).toEqual(value);
},
);
it.each`
mutation | rootKey | stateKey | value
${types.REQUEST_BRANCHES} | ${'branches'} | ${'isLoading'} | ${true}
${types.RECEIVE_BRANCHES_SUCCESS} | ${'branches'} | ${'isLoading'} | ${false}
${types.RECEIVE_BRANCHES_SUCCESS} | ${'branches'} | ${'data'} | ${branches}
${types.RECEIVE_BRANCHES_SUCCESS} | ${'branches'} | ${'errorCode'} | ${null}
${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'isLoading'} | ${false}
${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'data'} | ${[]}
${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'errorCode'} | ${errorCode}
${types.REQUEST_MILESTONES} | ${'milestones'} | ${'isLoading'} | ${true}
${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'isLoading'} | ${false}
${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'data'} | ${milestones}
......
......@@ -10567,6 +10567,9 @@ msgstr ""
msgid "Failed to load authors. Please try again."
msgstr ""
msgid "Failed to load branches. Please try again."
msgstr ""
msgid "Failed to load emoji list."
msgstr ""
......@@ -23830,6 +23833,9 @@ msgstr ""
msgid "Source (branch or tag)"
msgstr ""
msgid "Source Branch"
msgstr ""
msgid "Source branch: %{source_branch_open}%{source_branch}%{source_branch_close}"
msgstr ""
......@@ -25391,6 +25397,9 @@ msgstr ""
msgid "There was a problem communicating with your device."
msgstr ""
msgid "There was a problem fetching branches."
msgstr ""
msgid "There was a problem fetching groups."
msgstr ""
......
import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
import Api from '~/api';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
......@@ -33,6 +34,8 @@ export const mockAuthor3 = {
export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3];
export const mockBranches = [{ name: 'Master' }, { name: 'v1.x' }, { name: 'my-Branch' }];
export const mockRegularMilestone = {
id: 1,
name: '4.0',
......@@ -55,6 +58,16 @@ export const mockMilestones = [
mockEscapedMilestone,
];
export const mockBranchToken = {
type: 'source_branch',
icon: 'branch',
title: 'Source Branch',
unique: true,
token: BranchToken,
operators: [{ value: '=', description: 'is', default: 'true' }],
fetchBranches: Api.branches.bind(Api),
};
export const mockAuthorToken = {
type: 'author_username',
icon: 'user',
......
import { mount } from '@vue/test-utils';
import {
GlFilteredSearchToken,
GlFilteredSearchSuggestion,
GlFilteredSearchTokenSegment,
GlDropdownDivider,
} from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import {
DEFAULT_LABEL_NONE,
DEFAULT_LABEL_ANY,
} from '~/vue_shared/components/filtered_search_bar/constants';
import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
import { mockBranches, mockBranchToken } from '../mock_data';
jest.mock('~/flash');
const defaultStubs = {
Portal: true,
GlFilteredSearchSuggestionList: {
template: '<div></div>',
methods: {
getValue: () => '=',
},
},
};
function createComponent(options = {}) {
const {
config = mockBranchToken,
value = { data: '' },
active = false,
stubs = defaultStubs,
} = options;
return mount(BranchToken, {
propsData: {
config,
value,
active,
},
provide: {
portalName: 'fake target',
alignSuggestions: function fakeAlignSuggestions() {},
},
stubs,
});
}
describe('BranchToken', () => {
let mock;
let wrapper;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
wrapper.destroy();
});
describe('computed', () => {
beforeEach(async () => {
wrapper = createComponent({ value: { data: mockBranches[0].name } });
wrapper.setData({
branches: mockBranches,
});
await wrapper.vm.$nextTick();
});
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
expect(wrapper.vm.currentValue).toBe('master');
});
});
describe('activeBranch', () => {
it('returns object for currently present `value.data`', () => {
expect(wrapper.vm.activeBranch).toEqual(mockBranches[0]);
});
});
});
describe('methods', () => {
beforeEach(() => {
wrapper = createComponent();
});
describe('fetchBranchBySearchTerm', () => {
it('calls `config.fetchBranches` with provided searchTerm param', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches');
wrapper.vm.fetchBranchBySearchTerm('foo');
expect(wrapper.vm.config.fetchBranches).toHaveBeenCalledWith('foo');
});
it('sets response to `branches` when request is succesful', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches').mockResolvedValue({ data: mockBranches });
wrapper.vm.fetchBranchBySearchTerm('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.branches).toEqual(mockBranches);
});
});
it('calls `createFlash` with flash error message when request fails', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({});
wrapper.vm.fetchBranchBySearchTerm('foo');
return waitForPromises().then(() => {
expect(createFlash).toHaveBeenCalledWith({
message: 'There was a problem fetching branches.',
});
});
});
it('sets `loading` to false when request completes', () => {
jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({});
wrapper.vm.fetchBranchBySearchTerm('foo');
return waitForPromises().then(() => {
expect(wrapper.vm.loading).toBe(false);
});
});
});
});
describe('template', () => {
const defaultBranches = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
async function showSuggestions() {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
await wrapper.vm.$nextTick();
}
beforeEach(async () => {
wrapper = createComponent({ value: { data: mockBranches[0].name } });
wrapper.setData({
branches: mockBranches,
});
await wrapper.vm.$nextTick();
});
it('renders gl-filtered-search-token component', () => {
expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true);
});
it('renders token item when value is selected', () => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
expect(tokenSegments).toHaveLength(3);
expect(tokenSegments.at(2).text()).toBe(mockBranches[0].name);
});
it('renders provided defaultBranches as suggestions', async () => {
wrapper = createComponent({
active: true,
config: { ...mockBranchToken, defaultBranches },
stubs: { Portal: true },
});
await showSuggestions();
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
expect(suggestions).toHaveLength(defaultBranches.length);
defaultBranches.forEach((branch, index) => {
expect(suggestions.at(index).text()).toBe(branch.text);
});
});
it('does not render divider when no defaultBranches', async () => {
wrapper = createComponent({
active: true,
config: { ...mockBranchToken, defaultBranches: [] },
stubs: { Portal: true },
});
await showSuggestions();
expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false);
expect(wrapper.contains(GlDropdownDivider)).toBe(false);
});
it('renders no suggestions as default', async () => {
wrapper = createComponent({
active: true,
config: { ...mockBranchToken },
stubs: { Portal: true },
});
await showSuggestions();
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
expect(suggestions).toHaveLength(0);
});
});
});
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