Commit 593e6edd authored by Kushal Pandya's avatar Kushal Pandya

Merge branch '262056-dropdown-filter-rewrite' into 'master'

Global Search - Dropdown Filters

See merge request gitlab-org/gitlab!44511
parents cc864263 ee0e24e9
import Search from './search'; import Search from './search';
import initStateFilter from '~/search/state_filter'; import initSearchApp from '~/search';
import initConfidentialFilter from '~/search/confidential_filter';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initStateFilter(); initSearchApp();
initConfidentialFilter();
return new Search(); return new Search();
}); });
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import DropdownFilter from '../components/dropdown_filter.vue';
import {
FILTER_HEADER,
FILTER_PARAM,
FILTER_STATES_BY_SCOPE,
FILTER_STATES,
SCOPES,
} from './constants';
Vue.use(Translate);
export default () => {
const el = document.getElementById('js-search-filter-by-confidential');
if (!el) return false;
return new Vue({
el,
data() {
return { ...el.dataset };
},
render(createElement) {
return createElement(DropdownFilter, {
props: {
initialFilter: this.filter,
filtersArray: FILTER_STATES_BY_SCOPE[this.scope],
filters: FILTER_STATES,
header: FILTER_HEADER,
param: FILTER_PARAM,
scope: this.scope,
supportedScopes: Object.values(SCOPES),
},
});
},
});
};
<script> <script>
import { mapState } from 'vuex';
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { setUrlParams, visitUrl } from '~/lib/utils/url_utility'; import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '~/locale'; import { sprintf, s__ } from '~/locale';
...@@ -11,50 +12,27 @@ export default { ...@@ -11,50 +12,27 @@ export default {
GlDropdownDivider, GlDropdownDivider,
}, },
props: { props: {
initialFilter: { filterData: {
type: String,
required: false,
default: null,
},
filters: {
type: Object, type: Object,
required: true, required: true,
}, },
filtersArray: { },
type: Array, computed: {
required: true, ...mapState(['query']),
}, scope() {
header: { return this.query.scope;
type: String,
required: true,
}, },
param: { supportedScopes() {
type: String, return Object.values(this.filterData.scopes);
required: true,
}, },
scope: { initialFilter() {
type: String, return this.query[this.filterData.filterParam];
required: true,
}, },
supportedScopes: {
type: Array,
required: true,
},
},
computed: {
filter() { filter() {
return this.initialFilter || this.filters.ANY.value; return this.initialFilter || this.filterData.filters.ANY.value;
}, },
selectedFilterText() { filtersArray() {
const f = this.filtersArray.find(({ value }) => value === this.selectedFilter); return this.filterData.filterByScope[this.scope];
if (!f || f === this.filters.ANY) {
return sprintf(s__('Any %{header}'), { header: this.header });
}
return f.label;
},
showDropdown() {
return this.supportedScopes.includes(this.scope);
}, },
selectedFilter: { selectedFilter: {
get() { get() {
...@@ -62,18 +40,29 @@ export default { ...@@ -62,18 +40,29 @@ export default {
return this.filter; return this.filter;
} }
return this.filters.ANY.value; return this.filterData.filters.ANY.value;
}, },
set(filter) { set(filter) {
visitUrl(setUrlParams({ [this.param]: filter })); visitUrl(setUrlParams({ [this.filterData.filterParam]: filter }));
}, },
}, },
selectedFilterText() {
const f = this.filtersArray.find(({ value }) => value === this.selectedFilter);
if (!f || f === this.filterData.filters.ANY) {
return sprintf(s__('Any %{header}'), { header: this.filterData.header });
}
return f.label;
},
showDropdown() {
return this.supportedScopes.includes(this.scope);
},
}, },
methods: { methods: {
dropDownItemClass(filter) { dropDownItemClass(filter) {
return { return {
'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2': 'gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2':
filter === this.filters.ANY, filter === this.filterData.filters.ANY,
}; };
}, },
isFilterSelected(filter) { isFilterSelected(filter) {
...@@ -94,7 +83,7 @@ export default { ...@@ -94,7 +83,7 @@ export default {
menu-class="gl-w-full! gl-pl-0" menu-class="gl-w-full! gl-pl-0"
> >
<header class="gl-text-center gl-font-weight-bold gl-font-lg"> <header class="gl-text-center gl-font-weight-bold gl-font-lg">
{{ header }} {{ filterData.header }}
</header> </header>
<gl-dropdown-divider /> <gl-dropdown-divider />
<gl-dropdown-item <gl-dropdown-item
......
import { __ } from '~/locale'; import { __ } from '~/locale';
export const FILTER_HEADER = __('Confidentiality'); const header = __('Confidentiality');
export const FILTER_STATES = { const filters = {
ANY: { ANY: {
label: __('Any'), label: __('Any'),
value: null, value: null,
...@@ -17,12 +17,20 @@ export const FILTER_STATES = { ...@@ -17,12 +17,20 @@ export const FILTER_STATES = {
}, },
}; };
export const SCOPES = { const scopes = {
ISSUES: 'issues', ISSUES: 'issues',
}; };
export const FILTER_STATES_BY_SCOPE = { const filterByScope = {
[SCOPES.ISSUES]: [FILTER_STATES.ANY, FILTER_STATES.CONFIDENTIAL, FILTER_STATES.NOT_CONFIDENTIAL], [scopes.ISSUES]: [filters.ANY, filters.CONFIDENTIAL, filters.NOT_CONFIDENTIAL],
}; };
export const FILTER_PARAM = 'confidential'; const filterParam = 'confidential';
export default {
header,
filters,
scopes,
filterByScope,
filterParam,
};
import { __ } from '~/locale'; import { __ } from '~/locale';
export const FILTER_HEADER = __('Status'); const header = __('Status');
export const FILTER_STATES = { const filters = {
ANY: { ANY: {
label: __('Any'), label: __('Any'),
value: 'all', value: 'all',
...@@ -21,19 +21,22 @@ export const FILTER_STATES = { ...@@ -21,19 +21,22 @@ export const FILTER_STATES = {
}, },
}; };
export const SCOPES = { const scopes = {
ISSUES: 'issues', ISSUES: 'issues',
MERGE_REQUESTS: 'merge_requests', MERGE_REQUESTS: 'merge_requests',
}; };
export const FILTER_STATES_BY_SCOPE = { const filterByScope = {
[SCOPES.ISSUES]: [FILTER_STATES.ANY, FILTER_STATES.OPEN, FILTER_STATES.CLOSED], [scopes.ISSUES]: [filters.ANY, filters.OPEN, filters.CLOSED],
[SCOPES.MERGE_REQUESTS]: [ [scopes.MERGE_REQUESTS]: [filters.ANY, filters.OPEN, filters.MERGED, filters.CLOSED],
FILTER_STATES.ANY,
FILTER_STATES.OPEN,
FILTER_STATES.MERGED,
FILTER_STATES.CLOSED,
],
}; };
export const FILTER_PARAM = 'state'; const filterParam = 'state';
export default {
header,
filters,
scopes,
filterByScope,
filterParam,
};
import Vue from 'vue'; import Vue from 'vue';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import DropdownFilter from '../components/dropdown_filter.vue'; import DropdownFilter from './components/dropdown_filter.vue';
import { import stateFilterData from './constants/state_filter_data';
FILTER_HEADER, import confidentialFilterData from './constants/confidential_filter_data';
FILTER_PARAM,
FILTER_STATES_BY_SCOPE,
FILTER_STATES,
SCOPES,
} from './constants';
Vue.use(Translate); Vue.use(Translate);
export default () => { const mountDropdownFilter = (store, { id, filterData }) => {
const el = document.getElementById('js-search-filter-by-state'); const el = document.getElementById(id);
if (!el) return false; if (!el) return false;
return new Vue({ return new Vue({
el, el,
data() { store,
return { ...el.dataset };
},
render(createElement) { render(createElement) {
return createElement(DropdownFilter, { return createElement(DropdownFilter, {
props: { props: {
initialFilter: this.filter, filterData,
filtersArray: FILTER_STATES_BY_SCOPE[this.scope],
filters: FILTER_STATES,
header: FILTER_HEADER,
param: FILTER_PARAM,
scope: this.scope,
supportedScopes: Object.values(SCOPES),
}, },
}); });
}, },
}); });
}; };
const dropdownFilters = [
{
id: 'js-search-filter-by-state',
filterData: stateFilterData,
},
{
id: 'js-search-filter-by-confidential',
filterData: confidentialFilterData,
},
];
export default store => [...dropdownFilters].map(filter => mountDropdownFilter(store, filter));
import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store';
import initDropdownFilters from './dropdown_filter';
export default () => {
const store = createStore({ query: queryToObject(window.location.search) });
initDropdownFilters(store);
};
import Vue from 'vue';
import Vuex from 'vuex';
import createState from './state';
Vue.use(Vuex);
export const getStoreConfig = ({ query }) => ({
state: createState({ query }),
});
const createStore = config => new Vuex.Store(getStoreConfig(config));
export default createStore;
const createState = ({ query }) => ({
query,
});
export default createState;
.d-lg-flex.align-items-end .d-lg-flex.align-items-end
#js-search-filter-by-state{ 'v-cloak': true, data: { scope: @scope, filter: params[:state]} } #js-search-filter-by-state{ 'v-cloak': true }
- if Feature.enabled?(:search_filter_by_confidential, @group) - if Feature.enabled?(:search_filter_by_confidential, @group)
#js-search-filter-by-confidential{ 'v-cloak': true, data: { scope: @scope, filter: params[:confidential] } } #js-search-filter-by-confidential{ 'v-cloak': true }
- if %w(issues merge_requests).include?(@scope) - if %w(issues merge_requests).include?(@scope)
%hr.gl-mt-4.gl-mb-4 %hr.gl-mt-4.gl-mb-4
import { shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import DropdownFilter from '~/search/components/dropdown_filter.vue';
import {
FILTER_STATES,
FILTER_STATES_BY_SCOPE,
FILTER_HEADER,
SCOPES,
} from '~/search/state_filter/constants';
import * as urlUtils from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
setUrlParams: jest.fn(),
}));
function createComponent(props = { scope: 'issues' }) {
return shallowMount(DropdownFilter, {
propsData: {
filtersArray: FILTER_STATES_BY_SCOPE.issues,
filters: FILTER_STATES,
header: FILTER_HEADER,
param: 'state',
supportedScopes: Object.values(SCOPES),
...props,
},
});
}
describe('DropdownFilter', () => {
let wrapper;
beforeEach(() => {
wrapper = createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGlDropdown = () => wrapper.find(GlDropdown);
const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem);
const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text());
const firstDropDownItem = () => findGlDropdownItems().at(0);
describe('template', () => {
describe.each`
scope | showDropdown
${'issues'} | ${true}
${'merge_requests'} | ${true}
${'projects'} | ${false}
${'milestones'} | ${false}
${'users'} | ${false}
${'notes'} | ${false}
${'wiki_blobs'} | ${false}
${'blobs'} | ${false}
`(`dropdown`, ({ scope, showDropdown }) => {
beforeEach(() => {
wrapper = createComponent({ scope });
});
it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
expect(findGlDropdown().exists()).toBe(showDropdown);
});
});
describe.each`
initialFilter | label
${FILTER_STATES.ANY.value} | ${`Any ${FILTER_HEADER}`}
${FILTER_STATES.OPEN.value} | ${FILTER_STATES.OPEN.label}
${FILTER_STATES.CLOSED.value} | ${FILTER_STATES.CLOSED.label}
`(`filter text`, ({ initialFilter, label }) => {
describe(`when initialFilter is ${initialFilter}`, () => {
beforeEach(() => {
wrapper = createComponent({ scope: 'issues', initialFilter });
});
it(`sets dropdown label to ${label}`, () => {
expect(findGlDropdown().attributes('text')).toBe(label);
});
});
});
describe('Filter options', () => {
it('renders a dropdown item for each filterOption', () => {
expect(findDropdownItemsText()).toStrictEqual(
FILTER_STATES_BY_SCOPE[SCOPES.ISSUES].map(v => {
return v.label;
}),
);
});
it('clicking a dropdown item calls setUrlParams', () => {
const state = FILTER_STATES[Object.keys(FILTER_STATES)[0]].value;
firstDropDownItem().vm.$emit('click');
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ state });
});
it('clicking a dropdown item calls visitUrl', () => {
firstDropDownItem().vm.$emit('click');
expect(urlUtils.visitUrl).toHaveBeenCalled();
});
});
});
});
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import * as urlUtils from '~/lib/utils/url_utility';
import initStore from '~/search/store';
import DropdownFilter from '~/search/dropdown_filter/components/dropdown_filter.vue';
import stateFilterData from '~/search/dropdown_filter/constants/state_filter_data';
import confidentialFilterData from '~/search/dropdown_filter/constants/confidential_filter_data';
import { MOCK_QUERY } from '../mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
setUrlParams: jest.fn(),
}));
const localVue = createLocalVue();
localVue.use(Vuex);
describe('DropdownFilter', () => {
let wrapper;
let store;
const createStore = options => {
store = initStore({ query: MOCK_QUERY, ...options });
};
const createComponent = (props = { filterData: stateFilterData }) => {
wrapper = shallowMount(DropdownFilter, {
localVue,
store,
propsData: {
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
store = null;
});
const findGlDropdown = () => wrapper.find(GlDropdown);
const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem);
const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text());
const firstDropDownItem = () => findGlDropdownItems().at(0);
describe('StatusFilter', () => {
describe('template', () => {
describe.each`
scope | showDropdown
${'issues'} | ${true}
${'merge_requests'} | ${true}
${'projects'} | ${false}
${'milestones'} | ${false}
${'users'} | ${false}
${'notes'} | ${false}
${'wiki_blobs'} | ${false}
${'blobs'} | ${false}
`(`dropdown`, ({ scope, showDropdown }) => {
beforeEach(() => {
createStore({ query: { ...MOCK_QUERY, scope } });
createComponent();
});
it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
expect(findGlDropdown().exists()).toBe(showDropdown);
});
});
describe.each`
initialFilter | label
${stateFilterData.filters.ANY.value} | ${`Any ${stateFilterData.header}`}
${stateFilterData.filters.OPEN.value} | ${stateFilterData.filters.OPEN.label}
${stateFilterData.filters.CLOSED.value} | ${stateFilterData.filters.CLOSED.label}
`(`filter text`, ({ initialFilter, label }) => {
describe(`when initialFilter is ${initialFilter}`, () => {
beforeEach(() => {
createStore({ query: { ...MOCK_QUERY, [stateFilterData.filterParam]: initialFilter } });
createComponent();
});
it(`sets dropdown label to ${label}`, () => {
expect(findGlDropdown().attributes('text')).toBe(label);
});
});
});
});
describe('Filter options', () => {
beforeEach(() => {
createStore();
createComponent();
});
it('renders a dropdown item for each filterOption', () => {
expect(findDropdownItemsText()).toStrictEqual(
stateFilterData.filterByScope[stateFilterData.scopes.ISSUES].map(v => {
return v.label;
}),
);
});
it('clicking a dropdown item calls setUrlParams', () => {
const filter = stateFilterData.filters[Object.keys(stateFilterData.filters)[0]].value;
firstDropDownItem().vm.$emit('click');
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
[stateFilterData.filterParam]: filter,
});
});
it('clicking a dropdown item calls visitUrl', () => {
firstDropDownItem().vm.$emit('click');
expect(urlUtils.visitUrl).toHaveBeenCalled();
});
});
});
describe('ConfidentialFilter', () => {
describe('template', () => {
describe.each`
scope | showDropdown
${'issues'} | ${true}
${'merge_requests'} | ${false}
${'projects'} | ${false}
${'milestones'} | ${false}
${'users'} | ${false}
${'notes'} | ${false}
${'wiki_blobs'} | ${false}
${'blobs'} | ${false}
`(`dropdown`, ({ scope, showDropdown }) => {
beforeEach(() => {
createStore({ query: { ...MOCK_QUERY, scope } });
createComponent({ filterData: confidentialFilterData });
});
it(`does${showDropdown ? '' : ' not'} render when scope is ${scope}`, () => {
expect(findGlDropdown().exists()).toBe(showDropdown);
});
});
describe.each`
initialFilter | label
${confidentialFilterData.filters.ANY.value} | ${`Any ${confidentialFilterData.header}`}
${confidentialFilterData.filters.CONFIDENTIAL.value} | ${confidentialFilterData.filters.CONFIDENTIAL.label}
${confidentialFilterData.filters.NOT_CONFIDENTIAL.value} | ${confidentialFilterData.filters.NOT_CONFIDENTIAL.label}
`(`filter text`, ({ initialFilter, label }) => {
describe(`when initialFilter is ${initialFilter}`, () => {
beforeEach(() => {
createStore({
query: { ...MOCK_QUERY, [confidentialFilterData.filterParam]: initialFilter },
});
createComponent({ filterData: confidentialFilterData });
});
it(`sets dropdown label to ${label}`, () => {
expect(findGlDropdown().attributes('text')).toBe(label);
});
});
});
});
});
describe('Filter options', () => {
beforeEach(() => {
createStore();
createComponent({ filterData: confidentialFilterData });
});
it('renders a dropdown item for each filterOption', () => {
expect(findDropdownItemsText()).toStrictEqual(
confidentialFilterData.filterByScope[confidentialFilterData.scopes.ISSUES].map(v => {
return v.label;
}),
);
});
it('clicking a dropdown item calls setUrlParams', () => {
const filter =
confidentialFilterData.filters[Object.keys(confidentialFilterData.filters)[0]].value;
firstDropDownItem().vm.$emit('click');
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
[confidentialFilterData.filterParam]: filter,
});
});
it('clicking a dropdown item calls visitUrl', () => {
firstDropDownItem().vm.$emit('click');
expect(urlUtils.visitUrl).toHaveBeenCalled();
});
});
});
export const MOCK_QUERY = {
scope: 'issues',
state: 'all',
confidential: null,
};
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