Commit c0458a68 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'mw-cr-new-filtered-search-bar' into 'master'

Code Review Analytics: Add new filtered search bar

See merge request gitlab-org/gitlab!29669
parents 7ffb9d78 2c023838
...@@ -50,6 +50,7 @@ ...@@ -50,6 +50,7 @@
.border-color-default { border-color: $border-color; } .border-color-default { border-color: $border-color; }
.border-bottom-color-default { border-bottom-color: $border-color; } .border-bottom-color-default { border-bottom-color: $border-color; }
.border-radius-default { border-radius: $border-radius-default; } .border-radius-default { border-radius: $border-radius-default; }
.border-radius-small { border-radius: $border-radius-small; }
.box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; } .box-shadow-default { box-shadow: 0 2px 4px 0 $black-transparent; }
.gl-children-ml-sm-3 > * { .gl-children-ml-sm-3 > * {
......
...@@ -4,7 +4,13 @@ import CodeAnalyticsApp from './components/app.vue'; ...@@ -4,7 +4,13 @@ import CodeAnalyticsApp from './components/app.vue';
export default () => { export default () => {
const container = document.getElementById('js-code-review-analytics'); const container = document.getElementById('js-code-review-analytics');
const { projectId, newMergeRequestUrl, emptyStateSvgPath } = container.dataset; const {
projectId,
newMergeRequestUrl,
emptyStateSvgPath,
milestonePath,
labelsPath,
} = container.dataset;
if (!container) return; if (!container) return;
...@@ -18,6 +24,8 @@ export default () => { ...@@ -18,6 +24,8 @@ export default () => {
projectId: Number(projectId), projectId: Number(projectId),
newMergeRequestUrl, newMergeRequestUrl,
emptyStateSvgPath, emptyStateSvgPath,
milestonePath,
labelsPath,
}, },
}); });
}, },
......
<script> <script>
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { GlBadge, GlLoadingIcon, GlEmptyState, GlPagination } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { GlBadge, GlLoadingIcon, GlEmptyState, GlPagination } from '@gitlab/ui';
import MergeRequestTable from './merge_request_table.vue'; import MergeRequestTable from './merge_request_table.vue';
import FilterBar from './filter_bar.vue';
import FilteredSearchCodeReviewAnalytics from '../filtered_search_code_review_analytics'; import FilteredSearchCodeReviewAnalytics from '../filtered_search_code_review_analytics';
export default { export default {
...@@ -11,6 +12,7 @@ export default { ...@@ -11,6 +12,7 @@ export default {
GlLoadingIcon, GlLoadingIcon,
GlPagination, GlPagination,
GlEmptyState, GlEmptyState,
FilterBar,
MergeRequestTable, MergeRequestTable,
}, },
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
...@@ -27,6 +29,14 @@ export default { ...@@ -27,6 +29,14 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
milestonePath: {
type: String,
required: true,
},
labelsPath: {
type: String,
required: true,
},
}, },
computed: { computed: {
...mapState('mergeRequests', { ...mapState('mergeRequests', {
...@@ -52,12 +62,16 @@ export default { ...@@ -52,12 +62,16 @@ export default {
if (!this.codeReviewAnalyticsHasNewSearch) { if (!this.codeReviewAnalyticsHasNewSearch) {
this.filterManager = new FilteredSearchCodeReviewAnalytics(); this.filterManager = new FilteredSearchCodeReviewAnalytics();
this.filterManager.setup(); this.filterManager.setup();
} else {
this.setMilestonesEndpoint(this.milestonePath);
this.setLabelsEndpoint(this.labelsPath);
} }
this.setProjectId(this.projectId); this.setProjectId(this.projectId);
this.fetchMergeRequests(); this.fetchMergeRequests();
}, },
methods: { methods: {
...mapActions('filters', ['setMilestonesEndpoint', 'setLabelsEndpoint']),
...mapActions('mergeRequests', ['setProjectId', 'fetchMergeRequests', 'setPage']), ...mapActions('mergeRequests', ['setProjectId', 'fetchMergeRequests', 'setPage']),
}, },
}; };
...@@ -65,10 +79,7 @@ export default { ...@@ -65,10 +79,7 @@ export default {
<template> <template>
<div> <div>
<div <filter-bar v-if="codeReviewAnalyticsHasNewSearch" />
v-if="codeReviewAnalyticsHasNewSearch"
class="bg-secondary-50 p-3 border-top border-bottom"
></div>
<div class="mt-2"> <div class="mt-2">
<gl-loading-icon v-show="isLoading" size="md" class="mt-3" /> <gl-loading-icon v-show="isLoading" size="md" class="mt-3" />
<template v-if="!isLoading"> <template v-if="!isLoading">
......
<script>
import { mapState, mapActions } from 'vuex';
import { GlFilteredSearch, GlFilteredSearchToken } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlFilteredSearch,
},
data() {
return {
searchTerms: [],
};
},
computed: {
...mapState('filters', {
milestonePath: 'milestonePath',
labelsPath: 'labelsPath',
milestones: state => state.milestones.data,
}),
tokens() {
return [
{
icon: 'clock',
title: __('Milestone'),
type: 'milestone',
token: GlFilteredSearchToken,
options: this.milestones,
unique: true,
},
];
},
},
created() {
this.fetchMilestones();
},
methods: {
...mapActions('filters', ['fetchMilestones', 'setFilters']),
filteredSearchSubmit(filters) {
const result = filters.reduce((acc, item) => {
const {
type,
value: { data },
} = item;
if (!acc[type]) {
acc[type] = [];
}
acc[type].push(data);
return acc;
}, {});
this.setFilters({ label_name: result.label, milestone_title: result.milestone });
},
},
};
</script>
<template>
<div class="bg-secondary-50 p-3 border-top border-bottom">
<gl-filtered-search
:v-model="searchTerms"
:placeholder="__('Filter results')"
:clear-button-title="__('Clear')"
:close-button-title="__('Close')"
:available-tokens="tokens"
@submit="filteredSearchSubmit"
/>
</div>
</template>
...@@ -7,4 +7,4 @@ ...@@ -7,4 +7,4 @@
%span.text-secondary= _('Review time is defined as the time it takes from first comment until merged.') %span.text-secondary= _('Review time is defined as the time it takes from first comment until merged.')
- if Feature.disabled?(:code_review_analytics_has_new_search) - if Feature.disabled?(:code_review_analytics_has_new_search)
= render 'shared/issuable/search_bar', type: :issues_analytics, show_sorting_dropdown: false = render 'shared/issuable/search_bar', type: :issues_analytics, show_sorting_dropdown: false
#js-code-review-analytics{ data: { project_id: @project.id, new_merge_request_url: namespace_project_new_merge_request_path(@project.namespace), empty_state_svg_path: image_path('illustrations/merge_requests.svg') } } #js-code-review-analytics{ data: { project_id: @project.id, new_merge_request_url: namespace_project_new_merge_request_path(@project.namespace), empty_state_svg_path: image_path('illustrations/merge_requests.svg'), milestone_path: project_milestones_path(@project), labels_path: project_labels_path(@project) } }
...@@ -3,11 +3,15 @@ import Vuex from 'vuex'; ...@@ -3,11 +3,15 @@ import Vuex from 'vuex';
import { GlLoadingIcon, GlEmptyState, GlBadge, GlPagination } from '@gitlab/ui'; import { GlLoadingIcon, GlEmptyState, GlBadge, GlPagination } from '@gitlab/ui';
import CodeReviewAnalyticsApp from 'ee/analytics/code_review_analytics/components/app.vue'; import CodeReviewAnalyticsApp from 'ee/analytics/code_review_analytics/components/app.vue';
import MergeRequestTable from 'ee/analytics/code_review_analytics/components/merge_request_table.vue'; import MergeRequestTable from 'ee/analytics/code_review_analytics/components/merge_request_table.vue';
import createState from 'ee/analytics/code_review_analytics/store/modules/merge_requests/state'; import FilterBar from 'ee/analytics/code_review_analytics/components/filter_bar.vue';
import createMergeRequestsState from 'ee/analytics/code_review_analytics/store/modules/merge_requests/state';
import createFiltersState from 'ee/analytics/code_review_analytics/store/modules/filters/state';
import { TEST_HOST } from 'helpers/test_constants';
const mockFilterManagerSetup = jest.fn();
jest.mock('ee/analytics/code_review_analytics/filtered_search_code_review_analytics', () => jest.mock('ee/analytics/code_review_analytics/filtered_search_code_review_analytics', () =>
jest.fn().mockImplementation(() => ({ jest.fn().mockImplementation(() => ({
setup: jest.fn(), setup: mockFilterManagerSetup,
})), })),
); );
...@@ -20,6 +24,8 @@ describe('CodeReviewAnalyticsApp component', () => { ...@@ -20,6 +24,8 @@ describe('CodeReviewAnalyticsApp component', () => {
let setPage; let setPage;
let fetchMergeRequests; let fetchMergeRequests;
let setMilestonesEndpoint;
let setLabelsEndpoint;
const pageInfo = { const pageInfo = {
page: 1, page: 1,
...@@ -33,8 +39,8 @@ describe('CodeReviewAnalyticsApp component', () => { ...@@ -33,8 +39,8 @@ describe('CodeReviewAnalyticsApp component', () => {
mergeRequests: { mergeRequests: {
namespaced: true, namespaced: true,
state: { state: {
...createState(), ...createMergeRequestsState(),
...initialState, ...initialState.mergeRequests,
}, },
actions: { actions: {
setProjectId: jest.fn(), setProjectId: jest.fn(),
...@@ -46,10 +52,21 @@ describe('CodeReviewAnalyticsApp component', () => { ...@@ -46,10 +52,21 @@ describe('CodeReviewAnalyticsApp component', () => {
...getters, ...getters,
}, },
}, },
filters: {
namespaced: true,
state: {
...createFiltersState(),
...initialState.filters,
},
actions: {
setMilestonesEndpoint,
setLabelsEndpoint,
},
},
}, },
}); });
const createComponent = store => const createComponent = (store, codeReviewAnalyticsHasNewSearch = false) =>
shallowMount(CodeReviewAnalyticsApp, { shallowMount(CodeReviewAnalyticsApp, {
localVue, localVue,
store, store,
...@@ -57,10 +74,12 @@ describe('CodeReviewAnalyticsApp component', () => { ...@@ -57,10 +74,12 @@ describe('CodeReviewAnalyticsApp component', () => {
projectId: 1, projectId: 1,
newMergeRequestUrl: 'new_merge_request', newMergeRequestUrl: 'new_merge_request',
emptyStateSvgPath: 'svg', emptyStateSvgPath: 'svg',
milestonePath: `${TEST_HOST}/milestones`,
labelsPath: `${TEST_HOST}/labels`,
}, },
provide: { provide: {
glFeatures: { glFeatures: {
codeReviewAnalyticsHasNewSearch: false, codeReviewAnalyticsHasNewSearch,
}, },
}, },
}); });
...@@ -68,12 +87,15 @@ describe('CodeReviewAnalyticsApp component', () => { ...@@ -68,12 +87,15 @@ describe('CodeReviewAnalyticsApp component', () => {
beforeEach(() => { beforeEach(() => {
setPage = jest.fn(); setPage = jest.fn();
fetchMergeRequests = jest.fn(); fetchMergeRequests = jest.fn();
setMilestonesEndpoint = jest.fn();
setLabelsEndpoint = jest.fn();
}); });
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
const findFilterBar = () => wrapper.find(FilterBar);
const findEmptyState = () => wrapper.find(GlEmptyState); const findEmptyState = () => wrapper.find(GlEmptyState);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findBadge = () => wrapper.find(GlBadge); const findBadge = () => wrapper.find(GlBadge);
...@@ -81,9 +103,57 @@ describe('CodeReviewAnalyticsApp component', () => { ...@@ -81,9 +103,57 @@ describe('CodeReviewAnalyticsApp component', () => {
const findPagination = () => wrapper.find(GlPagination); const findPagination = () => wrapper.find(GlPagination);
describe('template', () => { describe('template', () => {
describe('when "codeReviewAnalyticsHasNewSearch" is disabled', () => {
beforeEach(() => {
vuexStore = createStore();
wrapper = createComponent(vuexStore);
});
it('does not render the filter bar component', () => {
expect(findFilterBar().exists()).toBe(false);
});
it("calls the filterManager's setup method", () => {
expect(mockFilterManagerSetup).toHaveBeenCalled();
});
it('does not call setMilestonesEndpoint action', () => {
expect(setMilestonesEndpoint).not.toHaveBeenCalled();
});
it('does not call setLabelsEndpoint action', () => {
expect(setLabelsEndpoint).not.toHaveBeenCalled();
});
});
describe('when "codeReviewAnalyticsHasNewSearch" is enabled', () => {
describe('when the feature is enabled', () => {
beforeEach(() => {
vuexStore = createStore();
wrapper = createComponent(vuexStore, true);
});
it('renders the filter bar component', () => {
expect(findFilterBar().exists()).toBe(true);
});
it("does not call the filterManager's setup method", () => {
expect(mockFilterManagerSetup).not.toHaveBeenCalled();
});
it('calls setMilestonesEndpoint action', () => {
expect(setMilestonesEndpoint).toHaveBeenCalled();
});
it('calls setLabelsEndpoint action', () => {
expect(setLabelsEndpoint).toHaveBeenCalled();
});
});
});
describe('while loading', () => { describe('while loading', () => {
beforeEach(() => { beforeEach(() => {
vuexStore = createStore({ isLoading: true }); vuexStore = createStore({ mergeRequests: { isLoading: true } });
wrapper = createComponent(vuexStore); wrapper = createComponent(vuexStore);
}); });
...@@ -108,7 +178,7 @@ describe('CodeReviewAnalyticsApp component', () => { ...@@ -108,7 +178,7 @@ describe('CodeReviewAnalyticsApp component', () => {
describe('and there are no merge requests', () => { describe('and there are no merge requests', () => {
beforeEach(() => { beforeEach(() => {
vuexStore = createStore( vuexStore = createStore(
{ isLoading: false, pageInfo: { page: 0, perPage: 0, total: 0 } }, { mergeRequests: { isLoading: false, pageInfo: { page: 0, perPage: 0, total: 0 } } },
{ showMrCount: () => true }, { showMrCount: () => true },
); );
wrapper = createComponent(vuexStore); wrapper = createComponent(vuexStore);
...@@ -137,7 +207,10 @@ describe('CodeReviewAnalyticsApp component', () => { ...@@ -137,7 +207,10 @@ describe('CodeReviewAnalyticsApp component', () => {
describe('and there are merge requests', () => { describe('and there are merge requests', () => {
beforeEach(() => { beforeEach(() => {
vuexStore = createStore({ isLoading: false, pageInfo }, { showMrCount: () => true }); vuexStore = createStore(
{ mergeRequests: { isLoading: false, pageInfo } },
{ showMrCount: () => true },
);
wrapper = createComponent(vuexStore); wrapper = createComponent(vuexStore);
}); });
...@@ -167,7 +240,10 @@ describe('CodeReviewAnalyticsApp component', () => { ...@@ -167,7 +240,10 @@ describe('CodeReviewAnalyticsApp component', () => {
describe('changing the page', () => { describe('changing the page', () => {
beforeEach(() => { beforeEach(() => {
vuexStore = createStore({ isLoading: false, pageInfo }, { showMrCount: () => true }); vuexStore = createStore(
{ mergeRequests: { isLoading: false, pageInfo } },
{ showMrCount: () => true },
);
wrapper = createComponent(vuexStore); wrapper = createComponent(vuexStore);
wrapper.vm.currentPage = 2; wrapper.vm.currentPage = 2;
}); });
......
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlFilteredSearch } from '@gitlab/ui';
import FilterBar from 'ee/analytics/code_review_analytics/components/filter_bar.vue';
import createFiltersState from 'ee/analytics/code_review_analytics/store/modules/filters/state';
import { mockMilestones } from '../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
const milestoneTokenType = 'milestone';
describe('FilteredSearchBar', () => {
let wrapper;
let vuexStore;
let setFiltersMock;
const createStore = (initialState = {}) => {
setFiltersMock = jest.fn();
return new Vuex.Store({
modules: {
filters: {
namespaced: true,
state: {
...createFiltersState(),
...initialState,
},
actions: {
fetchMilestones: jest.fn(),
setFilters: setFiltersMock,
},
},
},
});
};
const createComponent = store =>
shallowMount(FilterBar, {
localVue,
store,
});
afterEach(() => {
wrapper.destroy();
});
const findFilteredSearch = () => wrapper.find(GlFilteredSearch);
const getSearchToken = type =>
findFilteredSearch()
.props('availableTokens')
.filter(token => token.type === type)[0];
it('renders GlFilteredSearch component', () => {
vuexStore = createStore();
wrapper = createComponent(vuexStore);
expect(findFilteredSearch().exists()).toBe(true);
});
describe('when the state has data', () => {
beforeEach(() => {
vuexStore = createStore({ milestones: { data: mockMilestones } });
wrapper = createComponent(vuexStore);
});
it('displays the milestone token', () => {
const tokens = findFilteredSearch().props('availableTokens');
expect(tokens).toHaveLength(1);
expect(tokens[0].type).toBe(milestoneTokenType);
});
it('displays options in the milestone token', () => {
const { options } = getSearchToken(milestoneTokenType);
expect(options).toHaveLength(mockMilestones.length);
});
});
describe('when the user interacts', () => {
beforeEach(() => {
vuexStore = createStore({ milestones: { data: mockMilestones } });
wrapper = createComponent(vuexStore);
});
it('clicks on the search button, setFilters is dispatched', () => {
findFilteredSearch().vm.$emit('submit', [
{ type: 'milestone', value: { data: 'my-milestone', operator: '=' } },
]);
expect(setFiltersMock).toHaveBeenCalledWith(
expect.anything(),
{
label_name: undefined,
milestone_title: ['my-milestone'],
},
undefined,
);
});
});
});
...@@ -9233,6 +9233,9 @@ msgstr "" ...@@ -9233,6 +9233,9 @@ msgstr ""
msgid "Filter projects" msgid "Filter projects"
msgstr "" msgstr ""
msgid "Filter results"
msgstr ""
msgid "Filter results by group" msgid "Filter results by group"
msgstr "" msgstr ""
......
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