Commit dd1d2a37 authored by Zack Cuddy's avatar Zack Cuddy Committed by Illya Klymov

Global Search - Searchable Dropdown

This creates a generic
searchable dropdown
for the Group and
Project Filters on
the Global Search.

This will both clean up
duplicated code as well
as keep them both aligned
in style moving forward.
parent f0e61d9b
import { __ } from '~/locale';
export const ANY_GROUP = Object.freeze({
id: null,
name: __('Any'),
});
export const GROUP_QUERY_PARAM = 'group_id';
export const PROJECT_QUERY_PARAM = 'project_id';
import { queryToObject } from '~/lib/utils/url_utility'; import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store'; import createStore from './store';
import { initTopbar } from './topbar';
import { initSidebar } from './sidebar'; import { initSidebar } from './sidebar';
import initGroupFilter from './group_filter';
export const initSearchApp = () => { export const initSearchApp = () => {
// Similar to url_utility.decodeUrlParameter // Similar to url_utility.decodeUrlParameter
...@@ -9,6 +9,6 @@ export const initSearchApp = () => { ...@@ -9,6 +9,6 @@ export const initSearchApp = () => {
const sanitizedSearch = window.location.search.replace(/\+/g, '%20'); const sanitizedSearch = window.location.search.replace(/\+/g, '%20');
const store = createStore({ query: queryToObject(sanitizedSearch) }); const store = createStore({ query: queryToObject(sanitizedSearch) });
initTopbar(store);
initSidebar(store); initSidebar(store);
initGroupFilter(store);
}; };
<script>
import { mapState, mapActions } from 'vuex';
import { isEmpty } from 'lodash';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import SearchableDropdown from './searchable_dropdown.vue';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '../constants';
export default {
name: 'GroupFilter',
components: {
SearchableDropdown,
},
props: {
initialData: {
type: Object,
required: false,
default: () => ({}),
},
},
computed: {
...mapState(['groups', 'fetchingGroups']),
selectedGroup() {
return isEmpty(this.initialData) ? ANY_OPTION : this.initialData;
},
},
methods: {
...mapActions(['fetchGroups']),
handleGroupChange(group) {
visitUrl(
setUrlParams({ [GROUP_DATA.queryParam]: group.id, [PROJECT_DATA.queryParam]: null }),
);
},
},
GROUP_DATA,
};
</script>
<template>
<searchable-dropdown
:header-text="$options.GROUP_DATA.headerText"
:selected-display-value="$options.GROUP_DATA.selectedDisplayValue"
:items-display-value="$options.GROUP_DATA.itemsDisplayValue"
:loading="fetchingGroups"
:selected-item="selectedGroup"
:items="groups"
@search="fetchGroups"
@change="handleGroupChange"
/>
</template>
...@@ -5,115 +5,135 @@ import { ...@@ -5,115 +5,135 @@ import {
GlSearchBoxByType, GlSearchBoxByType,
GlLoadingIcon, GlLoadingIcon,
GlIcon, GlIcon,
GlButton,
GlSkeletonLoader, GlSkeletonLoader,
GlTooltipDirective, GlTooltipDirective,
} from '@gitlab/ui'; } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { isEmpty } from 'lodash'; import { ANY_OPTION } from '../constants';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import { ANY_GROUP, GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM } from '../constants';
export default { export default {
name: 'GroupFilter', name: 'SearchableDropdown',
components: { components: {
GlDropdown, GlDropdown,
GlDropdownItem, GlDropdownItem,
GlSearchBoxByType, GlSearchBoxByType,
GlLoadingIcon, GlLoadingIcon,
GlIcon, GlIcon,
GlButton,
GlSkeletonLoader, GlSkeletonLoader,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
props: { props: {
initialGroup: { headerText: {
type: Object, type: String,
required: false, required: false,
default: () => ({}), default: "__('Filter')",
}, },
selectedDisplayValue: {
type: String,
required: false,
default: 'name',
}, },
data() { itemsDisplayValue: {
return { type: String,
groupSearch: '', required: false,
}; default: 'name',
}, },
computed: { loading: {
...mapState(['groups', 'fetchingGroups']), type: Boolean,
selectedGroup: { required: false,
get() { default: false,
return isEmpty(this.initialGroup) ? ANY_GROUP : this.initialGroup;
}, },
set(group) { selectedItem: {
visitUrl(setUrlParams({ [GROUP_QUERY_PARAM]: group.id, [PROJECT_QUERY_PARAM]: null })); type: Object,
required: true,
},
items: {
type: Array,
required: false,
default: () => [],
}, },
}, },
data() {
return {
searchText: '',
};
}, },
methods: { methods: {
...mapActions(['fetchGroups']), isSelected(selected) {
isGroupSelected(group) { return selected.id === this.selectedItem.id;
return group.id === this.selectedGroup.id; },
openDropdown() {
this.$emit('search', this.searchText);
}, },
handleGroupChange(group) { resetDropdown() {
this.selectedGroup = group; this.$emit('change', ANY_OPTION);
}, },
}, },
ANY_GROUP, ANY_OPTION,
}; };
</script> </script>
<template> <template>
<gl-dropdown <gl-dropdown
ref="groupFilter"
class="gl-w-full" class="gl-w-full"
menu-class="gl-w-full!" menu-class="gl-w-full!"
toggle-class="gl-text-truncate gl-reset-line-height!" toggle-class="gl-text-truncate gl-reset-line-height!"
:header-text="__('Filter results by group')" :header-text="headerText"
@show="fetchGroups(groupSearch)" @show="$emit('search', searchText)"
@shown="$refs.searchBox.focusInput()"
> >
<template #button-content> <template #button-content>
<span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate"> <span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate">
{{ selectedGroup.name }} {{ selectedItem[selectedDisplayValue] }}
</span> </span>
<gl-loading-icon v-if="fetchingGroups" inline class="mr-2" /> <gl-loading-icon v-if="loading" inline class="gl-mr-3" />
<gl-icon <gl-button
v-if="!isGroupSelected($options.ANY_GROUP)" v-if="!isSelected($options.ANY_OPTION)"
v-gl-tooltip v-gl-tooltip
name="clear" name="clear"
category="tertiary"
:title="__('Clear')" :title="__('Clear')"
class="gl-text-gray-200! gl-hover-text-blue-800!" class="gl-p-0! gl-mr-2"
@click.stop="handleGroupChange($options.ANY_GROUP)" @keydown.enter.stop="resetDropdown"
/> @click.stop="resetDropdown"
>
<gl-icon name="clear" class="gl-text-gray-200! gl-hover-text-blue-800!" />
</gl-button>
<gl-icon name="chevron-down" /> <gl-icon name="chevron-down" />
</template> </template>
<div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white"> <div class="gl-sticky gl-top-0 gl-z-index-1 gl-bg-white">
<gl-search-box-by-type <gl-search-box-by-type
v-model="groupSearch" ref="searchBox"
class="m-2" v-model="searchText"
class="gl-m-3"
:debounce="500" :debounce="500"
@input="fetchGroups" @input="$emit('search', searchText)"
/> />
<gl-dropdown-item <gl-dropdown-item
class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2" class="gl-border-b-solid gl-border-b-gray-100 gl-border-b-1 gl-pb-2! gl-mb-2"
:is-check-item="true" :is-check-item="true"
:is-checked="isGroupSelected($options.ANY_GROUP)" :is-checked="isSelected($options.ANY_OPTION)"
@click="handleGroupChange($options.ANY_GROUP)" @click="resetDropdown"
> >
{{ $options.ANY_GROUP.name }} {{ $options.ANY_OPTION.name }}
</gl-dropdown-item> </gl-dropdown-item>
</div> </div>
<div v-if="!fetchingGroups"> <div v-if="!loading">
<gl-dropdown-item <gl-dropdown-item
v-for="group in groups" v-for="item in items"
:key="group.id" :key="item.id"
:is-check-item="true" :is-check-item="true"
:is-checked="isGroupSelected(group)" :is-checked="isSelected(item)"
@click="handleGroupChange(group)" @click="$emit('change', item)"
> >
{{ group.full_name }} {{ item[itemsDisplayValue] }}
</gl-dropdown-item> </gl-dropdown-item>
</div> </div>
<div v-if="fetchingGroups" class="mx-3 mt-2"> <div v-if="loading" class="gl-mx-4 gl-mt-3">
<gl-skeleton-loader :height="100"> <gl-skeleton-loader :height="100">
<rect y="0" width="90%" height="20" rx="4" /> <rect y="0" width="90%" height="20" rx="4" />
<rect y="40" width="70%" height="20" rx="4" /> <rect y="40" width="70%" height="20" rx="4" />
......
import { __ } from '~/locale';
export const ANY_OPTION = Object.freeze({
id: null,
name: __('Any'),
name_with_namespace: __('Any'),
});
export const GROUP_DATA = {
headerText: __('Filter results by group'),
queryParam: 'group_id',
selectedDisplayValue: 'name',
itemsDisplayValue: 'full_name',
};
export const PROJECT_DATA = {
headerText: __('Filter results by project'),
queryParam: 'project_id',
selectedDisplayValue: 'name_with_namespace',
itemsDisplayValue: 'name_with_namespace',
};
import Vue from 'vue'; import Vue from 'vue';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import GroupFilter from './components/group_filter.vue'; import GroupFilter from './components/group_filter.vue';
Vue.use(Translate); Vue.use(Translate);
export default store => { const mountSearchableDropdown = (store, { id, component }) => {
let initialGroup; const el = document.getElementById(id);
const el = document.getElementById('js-search-group-dropdown');
const { initialGroupData } = el.dataset; if (!el) {
return false;
}
initialGroup = JSON.parse(initialGroupData); let { initialData } = el.dataset;
initialGroup = convertObjectPropsToCamelCase(initialGroup, { deep: true });
initialData = JSON.parse(initialData);
return new Vue({ return new Vue({
el, el,
store, store,
render(createElement) { render(createElement) {
return createElement(GroupFilter, { return createElement(component, {
props: { props: {
initialGroup, initialData,
}, },
}); });
}, },
}); });
}; };
const searchableDropdowns = [
{
id: 'js-search-group-dropdown',
component: GroupFilter,
},
];
export const initTopbar = store =>
searchableDropdowns.map(dropdown => mountSearchableDropdown(store, dropdown));
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
.dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "group-filter" } } .dropdown.form-group.mb-lg-0.mx-lg-1.gl-p-0{ data: { testid: "group-filter" } }
%label.d-block{ for: "dashboard_search_group" } %label.d-block{ for: "dashboard_search_group" }
= _("Group") = _("Group")
%input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-group-data": @group.to_json } } %input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-data": @group.to_json } }
.dropdown.form-group.mb-lg-0.mx-lg-1{ data: { testid: "project-filter" } } .dropdown.form-group.mb-lg-0.mx-lg-1{ data: { testid: "project-filter" } }
%label.d-block{ for: "dashboard_search_project" } %label.d-block{ for: "dashboard_search_project" }
= _("Project") = _("Project")
......
...@@ -2,8 +2,8 @@ import { initSearchApp } from '~/search'; ...@@ -2,8 +2,8 @@ import { initSearchApp } from '~/search';
import createStore from '~/search/store'; import createStore from '~/search/store';
jest.mock('~/search/store'); jest.mock('~/search/store');
jest.mock('~/search/topbar');
jest.mock('~/search/sidebar'); jest.mock('~/search/sidebar');
jest.mock('~/search/group_filter');
describe('initSearchApp', () => { describe('initSearchApp', () => {
let defaultLocation; let defaultLocation;
......
import Vuex from 'vuex';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
import { visitUrl, setUrlParams } from '~/lib/utils/url_utility';
import GroupFilter from '~/search/topbar/components/group_filter.vue';
import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
import { ANY_OPTION, GROUP_DATA, PROJECT_DATA } from '~/search/topbar/constants';
const localVue = createLocalVue();
localVue.use(Vuex);
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
setUrlParams: jest.fn(),
}));
describe('GroupFilter', () => {
let wrapper;
const actionSpies = {
fetchGroups: jest.fn(),
};
const defaultProps = {
initialData: null,
};
const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = shallowMount(GroupFilter, {
localVue,
store,
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findSearchableDropdown = () => wrapper.find(SearchableDropdown);
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders SearchableDropdown always', () => {
expect(findSearchableDropdown().exists()).toBe(true);
});
});
describe('events', () => {
describe('when @search is emitted', () => {
const search = 'test';
beforeEach(() => {
createComponent();
findSearchableDropdown().vm.$emit('search', search);
});
it('calls fetchGroups with the search paramter', () => {
expect(actionSpies.fetchGroups).toHaveBeenCalledTimes(1);
expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), search);
});
});
describe('when @change is emitted', () => {
beforeEach(() => {
createComponent();
findSearchableDropdown().vm.$emit('change', MOCK_GROUP);
});
it('calls calls setUrlParams with group id, project id null, and visitUrl', () => {
expect(setUrlParams).toHaveBeenCalledWith({
[GROUP_DATA.queryParam]: MOCK_GROUP.id,
[PROJECT_DATA.queryParam]: null,
});
expect(visitUrl).toHaveBeenCalled();
});
});
});
describe('computed', () => {
describe('selectedGroup', () => {
describe('when initialData is null', () => {
beforeEach(() => {
createComponent();
});
it('sets selectedGroup to ANY_OPTION', () => {
expect(wrapper.vm.selectedGroup).toBe(ANY_OPTION);
});
});
describe('when initialData is set', () => {
beforeEach(() => {
createComponent({}, { initialData: MOCK_GROUP });
});
it('sets selectedGroup to ANY_OPTION', () => {
expect(wrapper.vm.selectedGroup).toBe(MOCK_GROUP);
});
});
});
});
});
import Vuex from 'vuex'; import Vuex from 'vuex';
import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils';
import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui'; import { GlDropdown, GlDropdownItem, GlSearchBoxByType, GlSkeletonLoader } from '@gitlab/ui';
import * as urlUtils from '~/lib/utils/url_utility'; import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from 'jest/search/mock_data';
import GroupFilter from '~/search/group_filter/components/group_filter.vue'; import SearchableDropdown from '~/search/topbar/components/searchable_dropdown.vue';
import { GROUP_QUERY_PARAM, PROJECT_QUERY_PARAM, ANY_GROUP } from '~/search/group_filter/constants'; import { ANY_OPTION, GROUP_DATA } from '~/search/topbar/constants';
import { MOCK_GROUPS, MOCK_GROUP, MOCK_QUERY } from '../../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
jest.mock('~/flash'); describe('Global Search Searchable Dropdown', () => {
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
setUrlParams: jest.fn(),
}));
describe('Global Search Group Filter', () => {
let wrapper; let wrapper;
const actionSpies = {
fetchGroups: jest.fn(),
};
const defaultProps = { const defaultProps = {
initialGroup: null, headerText: GROUP_DATA.headerText,
selectedDisplayValue: GROUP_DATA.selectedDisplayValue,
itemsDisplayValue: GROUP_DATA.itemsDisplayValue,
loading: false,
selectedItem: ANY_OPTION,
items: [],
}; };
const createComponent = (initialState, props = {}, mountFn = shallowMount) => { const createComponent = (initialState, props, mountFn = shallowMount) => {
const store = new Vuex.Store({ const store = new Vuex.Store({
state: { state: {
query: MOCK_QUERY, query: MOCK_QUERY,
...initialState, ...initialState,
}, },
actions: actionSpies,
}); });
wrapper = mountFn(GroupFilter, { wrapper = mountFn(SearchableDropdown, {
localVue, localVue,
store, store,
propsData: { propsData: {
...@@ -78,22 +71,22 @@ describe('Global Search Group Filter', () => { ...@@ -78,22 +71,22 @@ describe('Global Search Group Filter', () => {
}); });
describe('onSearch', () => { describe('onSearch', () => {
const groupSearch = 'test search'; const search = 'test search';
beforeEach(() => { beforeEach(() => {
findGlDropdownSearch().vm.$emit('input', groupSearch); findGlDropdownSearch().vm.$emit('input', search);
}); });
it('calls fetchGroups when input event is fired from GlSearchBoxByType', () => { it('$emits @search when input event is fired from GlSearchBoxByType', () => {
expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), groupSearch); expect(wrapper.emitted('search')[0]).toEqual([search]);
}); });
}); });
}); });
describe('findDropdownItems', () => { describe('findDropdownItems', () => {
describe('when fetchingGroups is false', () => { describe('when loading is false', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ groups: MOCK_GROUPS }); createComponent({}, { items: MOCK_GROUPS });
}); });
it('does not render loader', () => { it('does not render loader', () => {
...@@ -101,14 +94,14 @@ describe('Global Search Group Filter', () => { ...@@ -101,14 +94,14 @@ describe('Global Search Group Filter', () => {
}); });
it('renders an instance for each namespace', () => { it('renders an instance for each namespace', () => {
const groupsIncludingAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name)); const resultsIncludeAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name));
expect(findDropdownItemsText()).toStrictEqual(groupsIncludingAny); expect(findDropdownItemsText()).toStrictEqual(resultsIncludeAny);
}); });
}); });
describe('when fetchingGroups is true', () => { describe('when loading is true', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ fetchingGroups: true, groups: MOCK_GROUPS }); createComponent({}, { loading: true, items: MOCK_GROUPS });
}); });
it('does render loader', () => { it('does render loader', () => {
...@@ -119,26 +112,36 @@ describe('Global Search Group Filter', () => { ...@@ -119,26 +112,36 @@ describe('Global Search Group Filter', () => {
expect(findDropdownItemsText()).toStrictEqual(['Any']); expect(findDropdownItemsText()).toStrictEqual(['Any']);
}); });
}); });
describe('when item is selected', () => {
beforeEach(() => {
createComponent({}, { items: MOCK_GROUPS, selectedItem: MOCK_GROUPS[0] });
});
it('marks the dropdown as checked', () => {
expect(findFirstGroupDropdownItem().attributes('ischecked')).toBe('true');
});
});
}); });
describe('Dropdown Text', () => { describe('Dropdown Text', () => {
describe('when initialGroup is null', () => { describe('when selectedItem is any', () => {
beforeEach(() => { beforeEach(() => {
createComponent({}, {}, mount); createComponent({}, {}, mount);
}); });
it('sets dropdown text to Any', () => { it('sets dropdown text to Any', () => {
expect(findDropdownText().text()).toBe(ANY_GROUP.name); expect(findDropdownText().text()).toBe(ANY_OPTION.name);
}); });
}); });
describe('initialGroup is set', () => { describe('selectedItem is set', () => {
beforeEach(() => { beforeEach(() => {
createComponent({}, { initialGroup: MOCK_GROUP }, mount); createComponent({}, { selectedItem: MOCK_GROUP }, mount);
}); });
it('sets dropdown text to group name', () => { it('sets dropdown text to the selectedItem selectedDisplayValue', () => {
expect(findDropdownText().text()).toBe(MOCK_GROUP.name); expect(findDropdownText().text()).toBe(MOCK_GROUP[GROUP_DATA.selectedDisplayValue]);
}); });
}); });
}); });
...@@ -146,27 +149,19 @@ describe('Global Search Group Filter', () => { ...@@ -146,27 +149,19 @@ describe('Global Search Group Filter', () => {
describe('actions', () => { describe('actions', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ groups: MOCK_GROUPS }); createComponent({}, { items: MOCK_GROUPS });
}); });
it('clicking "Any" dropdown item calls setUrlParams with group id null, project id null,and visitUrl', () => { it('clicking "Any" dropdown item $emits @change with ANY_OPTION', () => {
findAnyDropdownItem().vm.$emit('click'); findAnyDropdownItem().vm.$emit('click');
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ expect(wrapper.emitted('change')[0]).toEqual([ANY_OPTION]);
[GROUP_QUERY_PARAM]: ANY_GROUP.id,
[PROJECT_QUERY_PARAM]: null,
});
expect(urlUtils.visitUrl).toHaveBeenCalled();
}); });
it('clicking group dropdown item calls setUrlParams with group id, project id null, and visitUrl', () => { it('clicking result dropdown item $emits @change with result', () => {
findFirstGroupDropdownItem().vm.$emit('click'); findFirstGroupDropdownItem().vm.$emit('click');
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ expect(wrapper.emitted('change')[0]).toEqual([MOCK_GROUPS[0]]);
[GROUP_QUERY_PARAM]: MOCK_GROUPS[0].id,
[PROJECT_QUERY_PARAM]: null,
});
expect(urlUtils.visitUrl).toHaveBeenCalled();
}); });
}); });
}); });
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