Commit 9098031d authored by Zack Cuddy's avatar Zack Cuddy Committed by Bob Van Landuyt

Global Search - Left Sidebar

This creates a search facet sidebar
for issues and merge requests.

This also converts the
status and confidential
filter from a dropdown
to a radio button.
parent c67a1159
<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';
export default {
name: 'DropdownFilter',
components: {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
},
props: {
filterData: {
type: Object,
required: true,
},
},
computed: {
...mapState(['query']),
scope() {
return this.query.scope;
},
supportedScopes() {
return Object.values(this.filterData.scopes);
},
initialFilter() {
return this.query[this.filterData.filterParam];
},
filter() {
return this.initialFilter || this.filterData.filters.ANY.value;
},
filtersArray() {
return this.filterData.filterByScope[this.scope];
},
selectedFilter: {
get() {
if (this.filtersArray.some(({ value }) => value === this.filter)) {
return this.filter;
}
return this.filterData.filters.ANY.value;
},
set(filter) {
// we need to remove the pagination cursor to ensure the
// relevancy of the new results
visitUrl(
setUrlParams({
page: null,
[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.filterData.filters.ANY,
};
},
isFilterSelected(filter) {
return filter === this.selectedFilter;
},
handleFilterChange(filter) {
this.selectedFilter = filter;
},
},
};
</script>
<template>
<gl-dropdown
v-if="showDropdown"
:text="selectedFilterText"
class="col-3 gl-pt-4 gl-pl-0 gl-pr-0 gl-mr-4"
menu-class="gl-w-full! gl-pl-0"
>
<header class="gl-text-center gl-font-weight-bold gl-font-lg">
{{ filterData.header }}
</header>
<gl-dropdown-divider />
<gl-dropdown-item
v-for="f in filtersArray"
:key="f.value"
:is-check-item="true"
:is-checked="isFilterSelected(f.value)"
:class="dropDownItemClass(f)"
@click="handleFilterChange(f.value)"
>
{{ f.label }}
</gl-dropdown-item>
</gl-dropdown>
</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 default {
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 default {
header,
filters,
scopes,
filterByScope,
filterParam,
};
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import DropdownFilter from './components/dropdown_filter.vue';
import stateFilterData from './constants/state_filter_data';
import confidentialFilterData from './constants/confidential_filter_data';
Vue.use(Translate);
const mountDropdownFilter = (store, { id, filterData }) => {
const el = document.getElementById(id);
if (!el) return false;
return new Vue({
el,
store,
render(createElement) {
return createElement(DropdownFilter, {
props: {
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';
import { initSidebar } from './sidebar';
import initGroupFilter from './group_filter';
export default () => {
const store = createStore({ query: queryToObject(window.location.search) });
if (gon.features.searchFacets) {
initSidebar(store);
} else {
initDropdownFilters(store);
}
initSidebar(store);
initGroupFilter(store);
};
<script>
import { mapActions, mapState } from 'vuex';
import { GlButton, GlLink } from '@gitlab/ui';
import StatusFilter from './status_filter.vue';
import ConfidentialityFilter from './confidentiality_filter.vue';
export default {
name: 'GlobalSearchSidebar',
components: {
GlButton,
GlLink,
StatusFilter,
ConfidentialityFilter,
},
computed: {
...mapState(['query']),
showReset() {
return this.query.state || this.query.confidential;
},
},
methods: {
...mapActions(['applyQuery', 'resetQuery']),
},
};
</script>
<template>
<form
class="gl-display-flex gl-flex-direction-column col-md-3 gl-mr-4 gl-mb-6 gl-mb gl-mt-5"
@submit.prevent="applyQuery"
>
<status-filter />
<confidentiality-filter />
<div class="gl-display-flex gl-align-items-center gl-mt-3">
<gl-button variant="success" type="submit">{{ __('Apply') }}</gl-button>
<gl-link v-if="showReset" class="gl-ml-auto" @click="resetQuery">{{
__('Reset filters')
}}</gl-link>
</div>
</form>
</template>
......@@ -21,5 +21,6 @@ export default {
<template>
<div v-if="showDropdown">
<radio-filter :filter-data="$options.confidentialFilterData" />
<hr class="gl-my-5 gl-border-gray-100" />
</div>
</template>
......@@ -21,5 +21,6 @@ export default {
<template>
<div v-if="showDropdown">
<radio-filter :filter-data="$options.stateFilterData" />
<hr class="gl-my-5 gl-border-gray-100" />
</div>
</template>
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import StatusFilter from './components/status_filter.vue';
import ConfidentialityFilter from './components/confidentiality_filter.vue';
import GlobalSearchSidebar from './components/app.vue';
Vue.use(Translate);
const mountRadioFilters = (store, { id, component }) => {
const el = document.getElementById(id);
export const initSidebar = store => {
const el = document.getElementById('js-search-sidebar');
if (!el) return false;
......@@ -14,21 +13,7 @@ const mountRadioFilters = (store, { id, component }) => {
el,
store,
render(createElement) {
return createElement(component);
return createElement(GlobalSearchSidebar);
},
});
};
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));
import Api from '~/api';
import createFlash from '~/flash';
import { __ } from '~/locale';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import * as types from './mutation_types';
export const fetchGroups = ({ commit }, search) => {
......@@ -18,3 +19,11 @@ export const fetchGroups = ({ commit }, search) => {
export const setQuery = ({ commit }, { key, value }) => {
commit(types.SET_QUERY, { key, value });
};
export const applyQuery = ({ state }) => {
visitUrl(setUrlParams({ ...state.query, page: null }));
};
export const resetQuery = ({ state }) => {
visitUrl(setUrlParams({ ...state.query, page: null, state: null, confidential: null }));
};
......@@ -23,10 +23,6 @@ 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
......
- if @search_objects.to_a.empty?
= render partial: "search/results/filters"
= render partial: "search/results/empty"
= render_if_exists 'shared/promotions/promote_advanced_search'
.gl-display-md-flex
- if %w(issues merge_requests).include?(@scope)
#js-search-sidebar.gl-display-flex.gl-flex-direction-column.col-md-3.gl-mr-4{ }
.gl-w-full
= render partial: "search/results/empty"
= render_if_exists 'shared/promotions/promote_advanced_search'
- else
.search-results-status
.row-content-block.gl-display-flex
......@@ -24,19 +27,21 @@
.gl-display-md-flex.gl-flex-direction-column
= render partial: 'search/sort_dropdown'
= render_if_exists 'shared/promotions/promote_advanced_search'
= render partial: "search/results/filters"
.results.gl-mt-3
- if @scope == 'commits'
%ul.content-list.commit-list
= render partial: "search/results/commit", collection: @search_objects
- else
.search-results
- if @scope == 'projects'
.term
= render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- else
= render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
.results.gl-display-md-flex.gl-mt-3
- if %w(issues merge_requests).include?(@scope)
#js-search-sidebar{ }
.gl-w-full
- if @scope == 'commits'
%ul.content-list.commit-list
= render partial: "search/results/commit", collection: @search_objects
- else
.search-results
- if @scope == 'projects'
.term
= render 'shared/projects/list', projects: @search_objects, pipeline_status: false
- else
= render_if_exists partial: "search/results/#{@scope.singularize}", collection: @search_objects
- if @scope != 'projects'
= paginate_collection(@search_objects)
- if @scope != 'projects'
= paginate_collection(@search_objects)
.d-lg-flex.align-items-end
#js-search-filter-by-state{ 'v-cloak': true }
#js-search-filter-by-confidential{ 'v-cloak': true }
- if %w(issues merge_requests).include?(@scope)
%hr.gl-mt-4.gl-mb-4
---
title: Global Search - Left Sidebar
merge_request: 46595
author:
type: added
---
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
......@@ -22935,6 +22935,9 @@ msgstr ""
msgid "Reset authorization key?"
msgstr ""
msgid "Reset filters"
msgstr ""
msgid "Reset health check access token"
msgstr ""
......
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { MOCK_QUERY } from 'jest/search/mock_data';
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';
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({
page: null,
[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({
page: null,
[confidentialFilterData.filterParam]: filter,
});
});
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 { GlButton, GlLink } from '@gitlab/ui';
import { MOCK_QUERY } from 'jest/search/mock_data';
import GlobalSearchSidebar from '~/search/sidebar/components/app.vue';
import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue';
import StatusFilter from '~/search/sidebar/components/status_filter.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('GlobalSearchSidebar', () => {
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(GlobalSearchSidebar, {
localVue,
store,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findSidebarForm = () => wrapper.find('form');
const findStatusFilter = () => wrapper.find(StatusFilter);
const findConfidentialityFilter = () => wrapper.find(ConfidentialityFilter);
const findApplyButton = () => wrapper.find(GlButton);
const findResetLinkButton = () => wrapper.find(GlLink);
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders StatusFilter always', () => {
expect(findStatusFilter().exists()).toBe(true);
});
it('renders ConfidentialityFilter always', () => {
expect(findConfidentialityFilter().exists()).toBe(true);
});
it('renders ApplyButton always', () => {
expect(findApplyButton().exists()).toBe(true);
});
describe('ResetLinkButton', () => {
describe('with no filter selected', () => {
beforeEach(() => {
createComponent({ query: {} });
});
it('does not render', () => {
expect(findResetLinkButton().exists()).toBe(false);
});
});
describe('with filter selected', () => {
it('does render when a filter selected', () => {
expect(findResetLinkButton().exists()).toBe(true);
});
});
});
});
describe('actions', () => {
beforeEach(() => {
createComponent();
});
it('clicking ApplyButton calls applyQuery', () => {
findSidebarForm().trigger('submit');
expect(actionSpies.applyQuery).toHaveBeenCalled();
});
it('clicking ResetLinkButton calls resetQuery', () => {
findResetLinkButton().vm.$emit('click');
expect(actionSpies.resetQuery).toHaveBeenCalled();
});
});
});
......@@ -2,6 +2,7 @@ import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper';
import * as actions from '~/search/store/actions';
import * as types from '~/search/store/mutation_types';
import * as urlUtils from '~/lib/utils/url_utility';
import state from '~/search/store/state';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
......@@ -42,6 +43,47 @@ 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);
});
});
describe('applyQuery', () => {
beforeEach(() => {
urlUtils.setUrlParams = jest.fn();
urlUtils.visitUrl = jest.fn();
});
it('calls visitUrl and setParams with the state.query', () => {
testAction(actions.applyQuery, null, state, [], [], () => {
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ ...state.query, page: null });
expect(urlUtils.visitUrl).toHaveBeenCalled();
});
});
});
describe('resetQuery', () => {
beforeEach(() => {
urlUtils.setUrlParams = jest.fn();
urlUtils.visitUrl = jest.fn();
});
it('calls visitUrl and setParams with empty values', () => {
testAction(actions.resetQuery, null, state, [], [], () => {
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
...state.query,
page: null,
state: null,
confidential: null,
});
expect(urlUtils.visitUrl).toHaveBeenCalled();
});
});
});
});
describe('setQuery', () => {
......
......@@ -43,7 +43,7 @@ RSpec.describe 'search/_results' do
let_it_be(:wiki_blob) { create(:wiki_page, project: project, content: '*') }
let_it_be(:user) { create(:admin) }
%w[issues blobs notes wiki_blobs merge_requests milestones].each do |search_scope|
%w[issues merge_requests].each do |search_scope|
context "when scope is #{search_scope}" do
let(:scope) { search_scope }
let(:search_objects) { Gitlab::ProjectSearchResults.new(user, '*', project: project).objects(scope) }
......@@ -55,16 +55,30 @@ RSpec.describe 'search/_results' do
expect(rendered).to have_selector('[data-track-property=search_result]')
end
it 'renders the state filter drop down' do
it 'does render the sidebar' do
render
expect(rendered).to have_selector('#js-search-filter-by-state')
expect(rendered).to have_selector('#js-search-sidebar')
end
end
end
%w[blobs notes wiki_blobs milestones].each do |search_scope|
context "when scope is #{search_scope}" do
let(:scope) { search_scope }
let(:search_objects) { Gitlab::ProjectSearchResults.new(user, '*', project: project).objects(scope) }
it 'renders the click text event tracking attributes' do
render
expect(rendered).to have_selector('[data-track-event=click_text]')
expect(rendered).to have_selector('[data-track-property=search_result]')
end
it 'renders the confidential drop down' do
it 'does not render the sidebar' do
render
expect(rendered).to have_selector('#js-search-filter-by-confidential')
expect(rendered).not_to have_selector('#js-search-sidebar')
end
end
end
......
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