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