Commit 2c023838 authored by Martin Wortschack's avatar Martin Wortschack

Add filter bar component

- Implements a filter bar component
and wires the store to the app.
parent d58353db
......@@ -50,6 +50,7 @@
.border-color-default { border-color: $border-color; }
.border-bottom-color-default { border-bottom-color: $border-color; }
.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; }
.gl-children-ml-sm-3 > * {
......
......@@ -4,7 +4,13 @@ import CodeAnalyticsApp from './components/app.vue';
export default () => {
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;
......@@ -18,6 +24,8 @@ export default () => {
projectId: Number(projectId),
newMergeRequestUrl,
emptyStateSvgPath,
milestonePath,
labelsPath,
},
});
},
......
<script>
import { mapState, mapActions } from 'vuex';
import { GlBadge, GlLoadingIcon, GlEmptyState, GlPagination } from '@gitlab/ui';
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 FilterBar from './filter_bar.vue';
import FilteredSearchCodeReviewAnalytics from '../filtered_search_code_review_analytics';
export default {
......@@ -11,6 +12,7 @@ export default {
GlLoadingIcon,
GlPagination,
GlEmptyState,
FilterBar,
MergeRequestTable,
},
mixins: [glFeatureFlagsMixin()],
......@@ -27,6 +29,14 @@ export default {
type: String,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelsPath: {
type: String,
required: true,
},
},
computed: {
...mapState('mergeRequests', {
......@@ -52,12 +62,16 @@ export default {
if (!this.codeReviewAnalyticsHasNewSearch) {
this.filterManager = new FilteredSearchCodeReviewAnalytics();
this.filterManager.setup();
} else {
this.setMilestonesEndpoint(this.milestonePath);
this.setLabelsEndpoint(this.labelsPath);
}
this.setProjectId(this.projectId);
this.fetchMergeRequests();
},
methods: {
...mapActions('filters', ['setMilestonesEndpoint', 'setLabelsEndpoint']),
...mapActions('mergeRequests', ['setProjectId', 'fetchMergeRequests', 'setPage']),
},
};
......@@ -65,10 +79,7 @@ export default {
<template>
<div>
<div
v-if="codeReviewAnalyticsHasNewSearch"
class="bg-secondary-50 p-3 border-top border-bottom"
></div>
<filter-bar v-if="codeReviewAnalyticsHasNewSearch" />
<div class="mt-2">
<gl-loading-icon v-show="isLoading" size="md" class="mt-3" />
<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 @@
%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)
= 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';
import { GlLoadingIcon, GlEmptyState, GlBadge, GlPagination } from '@gitlab/ui';
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 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.fn().mockImplementation(() => ({
setup: jest.fn(),
setup: mockFilterManagerSetup,
})),
);
......@@ -20,6 +24,8 @@ describe('CodeReviewAnalyticsApp component', () => {
let setPage;
let fetchMergeRequests;
let setMilestonesEndpoint;
let setLabelsEndpoint;
const pageInfo = {
page: 1,
......@@ -33,8 +39,8 @@ describe('CodeReviewAnalyticsApp component', () => {
mergeRequests: {
namespaced: true,
state: {
...createState(),
...initialState,
...createMergeRequestsState(),
...initialState.mergeRequests,
},
actions: {
setProjectId: jest.fn(),
......@@ -46,10 +52,21 @@ describe('CodeReviewAnalyticsApp component', () => {
...getters,
},
},
filters: {
namespaced: true,
state: {
...createFiltersState(),
...initialState.filters,
},
actions: {
setMilestonesEndpoint,
setLabelsEndpoint,
},
},
},
});
const createComponent = store =>
const createComponent = (store, codeReviewAnalyticsHasNewSearch = false) =>
shallowMount(CodeReviewAnalyticsApp, {
localVue,
store,
......@@ -57,10 +74,12 @@ describe('CodeReviewAnalyticsApp component', () => {
projectId: 1,
newMergeRequestUrl: 'new_merge_request',
emptyStateSvgPath: 'svg',
milestonePath: `${TEST_HOST}/milestones`,
labelsPath: `${TEST_HOST}/labels`,
},
provide: {
glFeatures: {
codeReviewAnalyticsHasNewSearch: false,
codeReviewAnalyticsHasNewSearch,
},
},
});
......@@ -68,12 +87,15 @@ describe('CodeReviewAnalyticsApp component', () => {
beforeEach(() => {
setPage = jest.fn();
fetchMergeRequests = jest.fn();
setMilestonesEndpoint = jest.fn();
setLabelsEndpoint = jest.fn();
});
afterEach(() => {
wrapper.destroy();
});
const findFilterBar = () => wrapper.find(FilterBar);
const findEmptyState = () => wrapper.find(GlEmptyState);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findBadge = () => wrapper.find(GlBadge);
......@@ -81,9 +103,57 @@ describe('CodeReviewAnalyticsApp component', () => {
const findPagination = () => wrapper.find(GlPagination);
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', () => {
beforeEach(() => {
vuexStore = createStore({ isLoading: true });
vuexStore = createStore({ mergeRequests: { isLoading: true } });
wrapper = createComponent(vuexStore);
});
......@@ -108,7 +178,7 @@ describe('CodeReviewAnalyticsApp component', () => {
describe('and there are no merge requests', () => {
beforeEach(() => {
vuexStore = createStore(
{ isLoading: false, pageInfo: { page: 0, perPage: 0, total: 0 } },
{ mergeRequests: { isLoading: false, pageInfo: { page: 0, perPage: 0, total: 0 } } },
{ showMrCount: () => true },
);
wrapper = createComponent(vuexStore);
......@@ -137,7 +207,10 @@ describe('CodeReviewAnalyticsApp component', () => {
describe('and there are merge requests', () => {
beforeEach(() => {
vuexStore = createStore({ isLoading: false, pageInfo }, { showMrCount: () => true });
vuexStore = createStore(
{ mergeRequests: { isLoading: false, pageInfo } },
{ showMrCount: () => true },
);
wrapper = createComponent(vuexStore);
});
......@@ -167,7 +240,10 @@ describe('CodeReviewAnalyticsApp component', () => {
describe('changing the page', () => {
beforeEach(() => {
vuexStore = createStore({ isLoading: false, pageInfo }, { showMrCount: () => true });
vuexStore = createStore(
{ mergeRequests: { isLoading: false, pageInfo } },
{ showMrCount: () => true },
);
wrapper = createComponent(vuexStore);
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,
);
});
});
});
......@@ -9227,6 +9227,9 @@ msgstr ""
msgid "Filter projects"
msgstr ""
msgid "Filter results"
msgstr ""
msgid "Filter results by group"
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