Commit bfbca693 authored by Zack Cuddy's avatar Zack Cuddy Committed by Mark Chao

Global Search - Radio Filters

This change is behind a feature
flag :search_facets.

This was split off for the sake
of MVC and the final product
can be seen here:
https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46595

This change replaces the dropdown
filter with radio buttons.

These will then be used as
the components in the
sidebar in the next MR.
parent 35a91936
import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store';
import initDropdownFilters from './dropdown_filter';
import { initSidebar } from './sidebar';
import initGroupFilter from './group_filter';
export default () => {
const store = createStore({ query: queryToObject(window.location.search) });
initDropdownFilters(store);
if (gon.features.searchFacets) {
initSidebar(store);
} else {
initDropdownFilters(store);
}
initGroupFilter(store);
};
<script>
import { mapState } from 'vuex';
import { confidentialFilterData } from '../constants/confidential_filter_data';
import RadioFilter from './radio_filter.vue';
export default {
name: 'ConfidentialityFilter',
components: {
RadioFilter,
},
computed: {
...mapState(['query']),
showDropdown() {
return Object.values(confidentialFilterData.scopes).includes(this.query.scope);
},
},
confidentialFilterData,
};
</script>
<template>
<div v-if="showDropdown">
<radio-filter :filter-data="$options.confidentialFilterData" />
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
import { sprintf, s__ } from '~/locale';
export default {
name: 'RadioFilter',
components: {
GlFormRadioGroup,
GlFormRadio,
},
props: {
filterData: {
type: Object,
required: true,
},
},
computed: {
...mapState(['query']),
ANY() {
return this.filterData.filters.ANY;
},
scope() {
return this.query.scope;
},
initialFilter() {
return this.query[this.filterData.filterParam];
},
filter() {
return this.initialFilter || this.ANY.value;
},
filtersArray() {
return this.filterData.filterByScope[this.scope];
},
selectedFilter: {
get() {
if (this.filtersArray.some(({ value }) => value === this.filter)) {
return this.filter;
}
return this.ANY.value;
},
set(value) {
this.setQuery({ key: this.filterData.filterParam, value });
},
},
},
methods: {
...mapActions(['setQuery']),
radioLabel(filter) {
return filter.value === this.ANY.value
? sprintf(s__('Any %{header}'), { header: this.filterData.header.toLowerCase() })
: filter.label;
},
},
};
</script>
<template>
<div>
<h5 class="gl-mt-0">{{ filterData.header }}</h5>
<gl-form-radio-group v-model="selectedFilter">
<gl-form-radio v-for="f in filtersArray" :key="f.value" :value="f.value">
{{ radioLabel(f) }}
</gl-form-radio>
</gl-form-radio-group>
</div>
</template>
<script>
import { mapState } from 'vuex';
import { stateFilterData } from '../constants/state_filter_data';
import RadioFilter from './radio_filter.vue';
export default {
name: 'StatusFilter',
components: {
RadioFilter,
},
computed: {
...mapState(['query']),
showDropdown() {
return Object.values(stateFilterData.scopes).includes(this.query.scope);
},
},
stateFilterData,
};
</script>
<template>
<div v-if="showDropdown">
<radio-filter :filter-data="$options.stateFilterData" />
</div>
</template>
import { __ } from '~/locale';
const header = __('Confidentiality');
const filters = {
ANY: {
label: __('Any'),
value: null,
},
CONFIDENTIAL: {
label: __('Confidential'),
value: 'yes',
},
NOT_CONFIDENTIAL: {
label: __('Not confidential'),
value: 'no',
},
};
const scopes = {
ISSUES: 'issues',
};
const filterByScope = {
[scopes.ISSUES]: [filters.ANY, filters.CONFIDENTIAL, filters.NOT_CONFIDENTIAL],
};
const filterParam = 'confidential';
export const confidentialFilterData = {
header,
filters,
scopes,
filterByScope,
filterParam,
};
import { __ } from '~/locale';
const header = __('Status');
const filters = {
ANY: {
label: __('Any'),
value: 'all',
},
OPEN: {
label: __('Open'),
value: 'opened',
},
CLOSED: {
label: __('Closed'),
value: 'closed',
},
MERGED: {
label: __('Merged'),
value: 'merged',
},
};
const scopes = {
ISSUES: 'issues',
MERGE_REQUESTS: 'merge_requests',
};
const filterByScope = {
[scopes.ISSUES]: [filters.ANY, filters.OPEN, filters.CLOSED],
[scopes.MERGE_REQUESTS]: [filters.ANY, filters.OPEN, filters.MERGED, filters.CLOSED],
};
const filterParam = 'state';
export const stateFilterData = {
header,
filters,
scopes,
filterByScope,
filterParam,
};
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import StatusFilter from './components/status_filter.vue';
import ConfidentialityFilter from './components/confidentiality_filter.vue';
Vue.use(Translate);
const mountRadioFilters = (store, { id, component }) => {
const el = document.getElementById(id);
if (!el) return false;
return new Vue({
el,
store,
render(createElement) {
return createElement(component);
},
});
};
const radioFilters = [
{
id: 'js-search-filter-by-state',
component: StatusFilter,
},
{
id: 'js-search-filter-by-confidential',
component: ConfidentialityFilter,
},
];
export const initSidebar = store =>
[...radioFilters].map(filter => mountRadioFilters(store, filter));
......@@ -14,3 +14,7 @@ export const fetchGroups = ({ commit }, search) => {
commit(types.RECEIVE_GROUPS_ERROR);
});
};
export const setQuery = ({ commit }, { key, value }) => {
commit(types.SET_QUERY, { key, value });
};
export const REQUEST_GROUPS = 'REQUEST_GROUPS';
export const RECEIVE_GROUPS_SUCCESS = 'RECEIVE_GROUPS_SUCCESS';
export const RECEIVE_GROUPS_ERROR = 'RECEIVE_GROUPS_ERROR';
export const SET_QUERY = 'SET_QUERY';
......@@ -12,4 +12,7 @@ export default {
state.fetchingGroups = false;
state.groups = [];
},
[types.SET_QUERY](state, { key, value }) {
state.query[key] = value;
},
};
......@@ -23,6 +23,10 @@ class SearchController < ApplicationController
search_term_present && !params[:project_id].present?
end
before_action do
push_frontend_feature_flag(:search_facets)
end
layout 'search'
feature_category :global_search
......
---
name: search_facets
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46809
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46595
group: group::global search
type: development
default_enabled: false
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { MOCK_QUERY } from 'jest/search/mock_data';
import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue';
import RadioFilter from '~/search/sidebar/components/radio_filter.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('ConfidentialityFilter', () => {
let wrapper;
const actionSpies = {
applyQuery: jest.fn(),
resetQuery: jest.fn(),
};
const createComponent = initialState => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(ConfidentialityFilter, {
localVue,
store,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findRadioFilter = () => wrapper.find(RadioFilter);
describe('template', () => {
beforeEach(() => {
createComponent();
});
describe.each`
scope | showFilter
${'issues'} | ${true}
${'merge_requests'} | ${false}
${'projects'} | ${false}
${'milestones'} | ${false}
${'users'} | ${false}
${'notes'} | ${false}
${'wiki_blobs'} | ${false}
${'blobs'} | ${false}
`(`dropdown`, ({ scope, showFilter }) => {
beforeEach(() => {
createComponent({ query: { scope } });
});
it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => {
expect(findRadioFilter().exists()).toBe(showFilter);
});
});
});
});
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlFormRadioGroup, GlFormRadio } from '@gitlab/ui';
import { MOCK_QUERY } from 'jest/search/mock_data';
import RadioFilter from '~/search/sidebar/components/radio_filter.vue';
import { stateFilterData } from '~/search/sidebar/constants/state_filter_data';
import { confidentialFilterData } from '~/search/sidebar/constants/confidential_filter_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('RadioFilter', () => {
let wrapper;
const actionSpies = {
setQuery: jest.fn(),
};
const defaultProps = {
filterData: stateFilterData,
};
const createComponent = (initialState, props = {}) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(RadioFilter, {
localVue,
store,
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGlRadioButtonGroup = () => wrapper.find(GlFormRadioGroup);
const findGlRadioButtons = () => findGlRadioButtonGroup().findAll(GlFormRadio);
const findGlRadioButtonsText = () => findGlRadioButtons().wrappers.map(w => w.text());
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders GlRadioButtonGroup always', () => {
expect(findGlRadioButtonGroup().exists()).toBe(true);
});
describe('Radio Buttons', () => {
describe('Status Filter', () => {
it('renders a radio button for each filterOption', () => {
expect(findGlRadioButtonsText()).toStrictEqual(
stateFilterData.filterByScope[stateFilterData.scopes.ISSUES].map(f => {
return f.value === stateFilterData.filters.ANY.value
? `Any ${stateFilterData.header.toLowerCase()}`
: f.label;
}),
);
});
it('clicking a radio button item calls setQuery', () => {
const filter = stateFilterData.filters[Object.keys(stateFilterData.filters)[0]].value;
findGlRadioButtonGroup().vm.$emit('input', filter);
expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), {
key: stateFilterData.filterParam,
value: filter,
});
});
});
describe('Confidentiality Filter', () => {
beforeEach(() => {
createComponent({}, { filterData: confidentialFilterData });
});
it('renders a radio button for each filterOption', () => {
expect(findGlRadioButtonsText()).toStrictEqual(
confidentialFilterData.filterByScope[confidentialFilterData.scopes.ISSUES].map(f => {
return f.value === confidentialFilterData.filters.ANY.value
? `Any ${confidentialFilterData.header.toLowerCase()}`
: f.label;
}),
);
});
it('clicking a radio button item calls setQuery', () => {
const filter =
confidentialFilterData.filters[Object.keys(confidentialFilterData.filters)[0]].value;
findGlRadioButtonGroup().vm.$emit('input', filter);
expect(actionSpies.setQuery).toHaveBeenCalledWith(expect.any(Object), {
key: confidentialFilterData.filterParam,
value: filter,
});
});
});
});
});
});
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { MOCK_QUERY } from 'jest/search/mock_data';
import StatusFilter from '~/search/sidebar/components/status_filter.vue';
import RadioFilter from '~/search/sidebar/components/radio_filter.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('StatusFilter', () => {
let wrapper;
const actionSpies = {
applyQuery: jest.fn(),
resetQuery: jest.fn(),
};
const createComponent = initialState => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(StatusFilter, {
localVue,
store,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findRadioFilter = () => wrapper.find(RadioFilter);
describe('template', () => {
beforeEach(() => {
createComponent();
});
describe.each`
scope | showFilter
${'issues'} | ${true}
${'merge_requests'} | ${true}
${'projects'} | ${false}
${'milestones'} | ${false}
${'users'} | ${false}
${'notes'} | ${false}
${'wiki_blobs'} | ${false}
${'blobs'} | ${false}
`(`dropdown`, ({ scope, showFilter }) => {
beforeEach(() => {
createComponent({ query: { scope } });
});
it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => {
expect(findRadioFilter().exists()).toBe(showFilter);
});
});
});
});
......@@ -43,3 +43,11 @@ describe('Global Search Store Actions', () => {
});
});
});
describe('setQuery', () => {
const payload = { key: 'key1', value: 'value1' };
it('calls the SET_QUERY mutation', done => {
testAction(actions.setQuery, payload, state, [{ type: types.SET_QUERY, payload }], [], done);
});
});
......@@ -35,4 +35,14 @@ describe('Global Search Store Mutations', () => {
expect(state.groups).toEqual([]);
});
});
describe('SET_QUERY', () => {
const payload = { key: 'key1', value: 'value1' };
it('sets query key to value', () => {
mutations[types.SET_QUERY](state, payload);
expect(state.query[payload.key]).toBe(payload.value);
});
});
});
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