Commit c2d49a63 authored by Illya Klymov's avatar Illya Klymov

Merge branch '262060_03-searchable-dropdown' into 'master'

Global Search - Searchable Dropdown

See merge request gitlab-org/gitlab!48186
parents ba58e078 dd1d2a37
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: 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, type: Object,
required: true,
},
items: {
type: Array,
required: false, required: false,
default: () => ({}), default: () => [],
}, },
}, },
data() { data() {
return { 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: { methods: {
...mapActions(['fetchGroups']), isSelected(selected) {
isGroupSelected(group) { return selected.id === this.selectedItem.id;
return group.id === this.selectedGroup.id;
}, },
handleGroupChange(group) { openDropdown() {
this.selectedGroup = group; this.$emit('search', this.searchText);
},
resetDropdown() {
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