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 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: {
type: Object,
headerText: {
type: String,
required: false,
default: () => ({}),
default: "__('Filter')",
},
selectedDisplayValue: {
type: String,
required: false,
default: 'name',
},
data() {
return {
groupSearch: '',
};
itemsDisplayValue: {
type: String,
required: false,
default: 'name',
},
computed: {
...mapState(['groups', 'fetchingGroups']),
selectedGroup: {
get() {
return isEmpty(this.initialGroup) ? ANY_GROUP : this.initialGroup;
loading: {
type: Boolean,
required: false,
default: false,
},
set(group) {
visitUrl(setUrlParams({ [GROUP_QUERY_PARAM]: group.id, [PROJECT_QUERY_PARAM]: null }));
selectedItem: {
type: Object,
required: true,
},
items: {
type: Array,
required: false,
default: () => [],
},
},
data() {
return {
searchText: '',
};
},
methods: {
...mapActions(['fetchGroups']),
isGroupSelected(group) {
return group.id === this.selectedGroup.id;
isSelected(selected) {
return selected.id === this.selectedItem.id;
},
openDropdown() {
this.$emit('search', this.searchText);
},
handleGroupChange(group) {
this.selectedGroup = group;
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