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 initStateFilter from '~/search/state_filter';
import initConfidentialFilter from '~/search/confidential_filter';
import initSearchApp from '~/search';
document.addEventListener('DOMContentLoaded', () => {
initStateFilter();
initConfidentialFilter();
initSearchApp();
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>
import { mapState } from 'vuex';
import { GlDropdown, GlDropdownItem, GlDropdownDivider } from '@gitlab/ui';
import { setUrlParams, visitUrl } from '~/lib/utils/url_utility';
import { sprintf, s__ } from '~/locale';
......@@ -11,50 +12,27 @@ export default {
GlDropdownDivider,
},
props: {
initialFilter: {
type: String,
required: false,
default: null,
},
filters: {
filterData: {
type: Object,
required: true,
},
filtersArray: {
type: Array,
required: true,
},
header: {
type: String,
required: true,
},
computed: {
...mapState(['query']),
scope() {
return this.query.scope;
},
param: {
type: String,
required: true,
supportedScopes() {
return Object.values(this.filterData.scopes);
},
scope: {
type: String,
required: true,
initialFilter() {
return this.query[this.filterData.filterParam];
},
supportedScopes: {
type: Array,
required: true,
},
},
computed: {
filter() {
return this.initialFilter || this.filters.ANY.value;
return this.initialFilter || this.filterData.filters.ANY.value;
},
selectedFilterText() {
const f = this.filtersArray.find(({ value }) => value === this.selectedFilter);
if (!f || f === this.filters.ANY) {
return sprintf(s__('Any %{header}'), { header: this.header });
}
return f.label;
},
showDropdown() {
return this.supportedScopes.includes(this.scope);
filtersArray() {
return this.filterData.filterByScope[this.scope];
},
selectedFilter: {
get() {
......@@ -62,18 +40,29 @@ export default {
return this.filter;
}
return this.filters.ANY.value;
return this.filterData.filters.ANY.value;
},
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: {
dropDownItemClass(filter) {
return {
'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) {
......@@ -94,7 +83,7 @@ export default {
menu-class="gl-w-full! gl-pl-0"
>
<header class="gl-text-center gl-font-weight-bold gl-font-lg">
{{ header }}
{{ filterData.header }}
</header>
<gl-dropdown-divider />
<gl-dropdown-item
......
import { __ } from '~/locale';
export const FILTER_HEADER = __('Confidentiality');
const header = __('Confidentiality');
export const FILTER_STATES = {
const filters = {
ANY: {
label: __('Any'),
value: null,
......@@ -17,12 +17,20 @@ export const FILTER_STATES = {
},
};
export const SCOPES = {
const scopes = {
ISSUES: 'issues',
};
export const FILTER_STATES_BY_SCOPE = {
[SCOPES.ISSUES]: [FILTER_STATES.ANY, FILTER_STATES.CONFIDENTIAL, FILTER_STATES.NOT_CONFIDENTIAL],
const filterByScope = {
[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';
export const FILTER_HEADER = __('Status');
const header = __('Status');
export const FILTER_STATES = {
const filters = {
ANY: {
label: __('Any'),
value: 'all',
......@@ -21,19 +21,22 @@ export const FILTER_STATES = {
},
};
export const SCOPES = {
const scopes = {
ISSUES: 'issues',
MERGE_REQUESTS: 'merge_requests',
};
export const FILTER_STATES_BY_SCOPE = {
[SCOPES.ISSUES]: [FILTER_STATES.ANY, FILTER_STATES.OPEN, FILTER_STATES.CLOSED],
[SCOPES.MERGE_REQUESTS]: [
FILTER_STATES.ANY,
FILTER_STATES.OPEN,
FILTER_STATES.MERGED,
FILTER_STATES.CLOSED,
],
const filterByScope = {
[scopes.ISSUES]: [filters.ANY, filters.OPEN, filters.CLOSED],
[scopes.MERGE_REQUESTS]: [filters.ANY, filters.OPEN, filters.MERGED, filters.CLOSED],
};
export const FILTER_PARAM = 'state';
const filterParam = 'state';
export default {
header,
filters,
scopes,
filterByScope,
filterParam,
};
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';
import DropdownFilter from './components/dropdown_filter.vue';
import stateFilterData from './constants/state_filter_data';
import confidentialFilterData from './constants/confidential_filter_data';
Vue.use(Translate);
export default () => {
const el = document.getElementById('js-search-filter-by-state');
const mountDropdownFilter = (store, { id, filterData }) => {
const el = document.getElementById(id);
if (!el) return false;
return new Vue({
el,
data() {
return { ...el.dataset };
},
store,
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),
filterData,
},
});
},
});
};
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
#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)
#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)
%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