Commit 23a81cc1 authored by Zack Cuddy's avatar Zack Cuddy Committed by Miguel Rincon

Global Search - Group Filter

This change replaces the deprecated
jQuery Dropdown plugin.

Instead we use gl-dropdown from
GitLab UI and a Vue component.

This uses a Vuex store to manage the
API calls and API data.  From there
the GitLab UI components take
care of the existing functionality.

Following this MR we will be moving over
the Project dropdown as well.
parent 1e0cb3d5
......@@ -3,5 +3,5 @@ import initSearchApp from '~/search';
document.addEventListener('DOMContentLoaded', () => {
initSearchApp();
return new Search();
return new Search(); // Deprecated Dropdown (Projects)
});
......@@ -5,48 +5,22 @@ import { deprecatedCreateFlash as Flash } from '~/flash';
import Api from '~/api';
import { __ } from '~/locale';
import Project from '~/pages/projects/project';
import { visitUrl } from '~/lib/utils/url_utility';
import { visitUrl, queryToObject } from '~/lib/utils/url_utility';
import refreshCounts from './refresh_counts';
export default class Search {
constructor() {
setHighlightClass();
const $groupDropdown = $('.js-search-group-dropdown');
setHighlightClass(); // Code Highlighting
const $projectDropdown = $('.js-search-project-dropdown');
this.searchInput = '.js-search-input';
this.searchClear = '.js-search-clear';
this.groupId = $groupDropdown.data('groupId');
const query = queryToObject(window.location.search);
this.groupId = query?.group_id;
this.eventListeners();
refreshCounts();
initDeprecatedJQueryDropdown($groupDropdown, {
selectable: true,
filterable: true,
filterRemote: true,
fieldName: 'group_id',
search: {
fields: ['full_name'],
},
data(term, callback) {
return Api.groups(term, {}, data => {
data.unshift({
full_name: __('Any'),
});
data.splice(1, 0, { type: 'divider' });
return callback(data);
});
},
id(obj) {
return obj.id;
},
text(obj) {
return obj.full_name;
},
clicked: () => Search.submitSearch(),
});
initDeprecatedJQueryDropdown($projectDropdown, {
selectable: true,
filterable: true,
......
<script>
import {
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
GlIcon,
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';
export default {
name: 'GroupFilter',
components: {
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlLoadingIcon,
GlIcon,
GlSkeletonLoader,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
initialGroup: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
groupSearch: '',
};
},
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;
},
handleGroupChange(group) {
this.selectedGroup = group;
},
},
ANY_GROUP,
};
</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)"
>
<template #button-content>
<span class="dropdown-toggle-text gl-flex-grow-1 gl-text-truncate">
{{ selectedGroup.name }}
</span>
<gl-loading-icon v-if="fetchingGroups" inline class="mr-2" />
<gl-icon
v-if="!isGroupSelected($options.ANY_GROUP)"
v-gl-tooltip
name="clear"
:title="__('Clear')"
class="gl-text-gray-200! gl-hover-text-blue-800!"
@click.stop="handleGroupChange($options.ANY_GROUP)"
/>
<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"
:debounce="500"
@input="fetchGroups"
/>
<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)"
>
{{ $options.ANY_GROUP.name }}
</gl-dropdown-item>
</div>
<div v-if="!fetchingGroups">
<gl-dropdown-item
v-for="group in groups"
:key="group.id"
:is-check-item="true"
:is-checked="isGroupSelected(group)"
@click="handleGroupChange(group)"
>
{{ group.full_name }}
</gl-dropdown-item>
</div>
<div v-if="fetchingGroups" class="mx-3 mt-2">
<gl-skeleton-loader :height="100">
<rect y="0" width="90%" height="20" rx="4" />
<rect y="40" width="70%" height="20" rx="4" />
<rect y="80" width="80%" height="20" rx="4" />
</gl-skeleton-loader>
</div>
</gl-dropdown>
</template>
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 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 { initialGroupData } = el.dataset;
initialGroup = JSON.parse(initialGroupData);
initialGroup = convertObjectPropsToCamelCase(initialGroup, { deep: true });
return new Vue({
el,
store,
render(createElement) {
return createElement(GroupFilter, {
props: {
initialGroup,
},
});
},
});
};
import { queryToObject } from '~/lib/utils/url_utility';
import createStore from './store';
import initDropdownFilters from './dropdown_filter';
import initGroupFilter from './group_filter';
export default () => {
const store = createStore({ query: queryToObject(window.location.search) });
initDropdownFilters(store);
initGroupFilter(store);
};
import Api from '~/api';
import createFlash from '~/flash';
import { __ } from '~/locale';
import * as types from './mutation_types';
export const fetchGroups = ({ commit }, search) => {
commit(types.REQUEST_GROUPS);
Api.groups(search)
.then(data => {
commit(types.RECEIVE_GROUPS_SUCCESS, data);
})
.catch(() => {
createFlash({ message: __('There was a problem fetching groups.') });
commit(types.RECEIVE_GROUPS_ERROR);
});
};
import Vue from 'vue';
import Vuex from 'vuex';
import * as actions from './actions';
import mutations from './mutations';
import createState from './state';
Vue.use(Vuex);
export const getStoreConfig = ({ query }) => ({
actions,
mutations,
state: createState({ query }),
});
......
export const REQUEST_GROUPS = 'REQUEST_GROUPS';
export const RECEIVE_GROUPS_SUCCESS = 'RECEIVE_GROUPS_SUCCESS';
export const RECEIVE_GROUPS_ERROR = 'RECEIVE_GROUPS_ERROR';
import * as types from './mutation_types';
export default {
[types.REQUEST_GROUPS](state) {
state.fetchingGroups = true;
},
[types.RECEIVE_GROUPS_SUCCESS](state, data) {
state.fetchingGroups = false;
state.groups = data;
},
[types.RECEIVE_GROUPS_ERROR](state) {
state.fetchingGroups = false;
state.groups = [];
},
};
const createState = ({ query }) => ({
query,
groups: [],
fetchingGroups: false,
});
export default createState;
......@@ -270,7 +270,8 @@ input[type='checkbox']:hover {
width: 100%;
}
.dropdown-menu-toggle {
.dropdown-menu-toggle,
.gl-new-dropdown {
@include media-breakpoint-up(lg) {
width: 240px;
}
......
......@@ -2,21 +2,10 @@
= hidden_field_tag :group_id, params[:group_id]
- if params[:project_id].present?
= hidden_field_tag :project_id, params[:project_id]
.dropdown.form-group.mb-lg-0.mx-lg-1{ 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" }
= _("Group")
%button.dropdown-menu-toggle.gl-display-inline-flex.js-search-group-dropdown.gl-mt-0{ type: "button", id: "dashboard_search_group", data: { toggle: "dropdown", group_id: params[:group_id] } }
%span.dropdown-toggle-text.gl-flex-grow-1.str-truncated-100
= @group&.name || _("Any")
- if @group.present?
= link_to sprite_icon("clear"), url_for(safe_params.except(:project_id, :group_id)), class: 'search-clear js-search-clear has-tooltip', title: _('Clear')
= icon("chevron-down")
.dropdown-menu.dropdown-select.dropdown-menu-selectable.dropdown-menu-right
= dropdown_title(_("Filter results by group"))
= dropdown_filter(_("Search groups"))
= dropdown_content
= dropdown_loading
%input#js-search-group-dropdown.dropdown-menu-toggle{ value: "Loading...", data: { "initial-group-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")
......
......@@ -8,11 +8,11 @@ RSpec.describe 'Group elastic search', :js, :elastic, :sidekiq_might_not_need_in
let(:project) { create(:project, :repository, :wiki_repo, namespace: group) }
def choose_group(group)
find('.js-search-group-dropdown').click
find('[data-testid="group-filter"]').click
wait_for_requests
page.within '.js-search-form' do
click_link group.name
page.within '[data-testid="group-filter"]' do
click_button group.name
end
end
......@@ -25,6 +25,9 @@ RSpec.describe 'Group elastic search', :js, :elastic, :sidekiq_might_not_need_in
sign_in(user)
visit(search_path)
wait_for_requests
choose_group(group)
end
......
......@@ -23322,9 +23322,6 @@ msgstr ""
msgid "Search forks"
msgstr ""
msgid "Search groups"
msgstr ""
msgid "Search merge requests"
msgstr ""
......
......@@ -18,15 +18,15 @@ RSpec.describe 'User uses search filters', :js do
it 'shows group projects' do
visit search_path
find('.js-search-group-dropdown').click
find('[data-testid="group-filter"]').click
wait_for_requests
page.within('.search-page-form') do
click_link(group.name)
page.within('[data-testid="group-filter"]') do
click_on(group.name)
end
expect(find('.js-search-group-dropdown')).to have_content(group.name)
expect(find('[data-testid="group-filter"]')).to have_content(group.name)
page.within('[data-testid="project-filter"]') do
find('.js-search-project-dropdown').click
......@@ -44,10 +44,11 @@ RSpec.describe 'User uses search filters', :js do
describe 'clear filter button' do
it 'removes Group and Project filters' do
link = find('[data-testid="group-filter"] .js-search-clear')
params = CGI.parse(URI.parse(link[:href]).query)
find('[data-testid="group-filter"] [data-testid="clear-icon"]').click
wait_for_requests
expect(params).not_to include(:group_id, :project_id)
expect(page).to have_current_path(search_path(search: "test"))
end
end
end
......
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';
import { MOCK_QUERY } from '../mock_data';
jest.mock('~/lib/utils/url_utility', () => ({
visitUrl: jest.fn(),
......
export const MOCK_QUERY = {
scope: 'issues',
state: 'all',
confidential: null,
};
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';
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', () => {
let wrapper;
const actionSpies = {
fetchGroups: jest.fn(),
};
const defaultProps = {
initialGroup: null,
};
const createComponent = (initialState, props = {}, mountFn = shallowMount) => {
const store = new Vuex.Store({
state: {
query: MOCK_QUERY,
...initialState,
},
actions: actionSpies,
});
wrapper = mountFn(GroupFilter, {
localVue,
store,
propsData: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const findGlDropdown = () => wrapper.find(GlDropdown);
const findGlDropdownSearch = () => findGlDropdown().find(GlSearchBoxByType);
const findDropdownText = () => findGlDropdown().find('.dropdown-toggle-text');
const findDropdownItems = () => findGlDropdown().findAll(GlDropdownItem);
const findDropdownItemsText = () => findDropdownItems().wrappers.map(w => w.text());
const findAnyDropdownItem = () => findDropdownItems().at(0);
const findFirstGroupDropdownItem = () => findDropdownItems().at(1);
const findLoader = () => wrapper.find(GlSkeletonLoader);
describe('template', () => {
beforeEach(() => {
createComponent();
});
it('renders GlDropdown', () => {
expect(findGlDropdown().exists()).toBe(true);
});
describe('findGlDropdownSearch', () => {
it('renders always', () => {
expect(findGlDropdownSearch().exists()).toBe(true);
});
it('has debounce prop', () => {
expect(findGlDropdownSearch().attributes('debounce')).toBe('500');
});
describe('onSearch', () => {
const groupSearch = 'test search';
beforeEach(() => {
findGlDropdownSearch().vm.$emit('input', groupSearch);
});
it('calls fetchGroups when input event is fired from GlSearchBoxByType', () => {
expect(actionSpies.fetchGroups).toHaveBeenCalledWith(expect.any(Object), groupSearch);
});
});
});
describe('findDropdownItems', () => {
describe('when fetchingGroups is false', () => {
beforeEach(() => {
createComponent({ groups: MOCK_GROUPS });
});
it('does not render loader', () => {
expect(findLoader().exists()).toBe(false);
});
it('renders an instance for each namespace', () => {
const groupsIncludingAny = ['Any'].concat(MOCK_GROUPS.map(n => n.full_name));
expect(findDropdownItemsText()).toStrictEqual(groupsIncludingAny);
});
});
describe('when fetchingGroups is true', () => {
beforeEach(() => {
createComponent({ fetchingGroups: true, groups: MOCK_GROUPS });
});
it('does render loader', () => {
expect(findLoader().exists()).toBe(true);
});
it('renders only Any in dropdown', () => {
expect(findDropdownItemsText()).toStrictEqual(['Any']);
});
});
});
describe('Dropdown Text', () => {
describe('when initialGroup is null', () => {
beforeEach(() => {
createComponent({}, {}, mount);
});
it('sets dropdown text to Any', () => {
expect(findDropdownText().text()).toBe(ANY_GROUP.name);
});
});
describe('initialGroup is set', () => {
beforeEach(() => {
createComponent({}, { initialGroup: MOCK_GROUP }, mount);
});
it('sets dropdown text to group name', () => {
expect(findDropdownText().text()).toBe(MOCK_GROUP.name);
});
});
});
});
describe('actions', () => {
beforeEach(() => {
createComponent({ groups: MOCK_GROUPS });
});
it('clicking "Any" dropdown item calls setUrlParams with group id null, project id null,and visitUrl', () => {
findAnyDropdownItem().vm.$emit('click');
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
[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', () => {
findFirstGroupDropdownItem().vm.$emit('click');
expect(urlUtils.setUrlParams).toHaveBeenCalledWith({
[GROUP_QUERY_PARAM]: MOCK_GROUPS[0].id,
[PROJECT_QUERY_PARAM]: null,
});
expect(urlUtils.visitUrl).toHaveBeenCalled();
});
});
});
export const MOCK_QUERY = {
scope: 'issues',
state: 'all',
confidential: null,
};
export const MOCK_GROUP = {
name: 'test group',
full_name: 'full name test group',
id: 'test_1',
};
export const MOCK_GROUPS = [
{
name: 'test group',
full_name: 'full name test group',
id: 'test_1',
},
{
name: 'test group 2',
full_name: 'full name test group 2',
id: 'test_2',
},
];
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 state from '~/search/store/state';
import axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { MOCK_GROUPS } from '../mock_data';
jest.mock('~/flash');
describe('Global Search Store Actions', () => {
let mock;
const noCallback = () => {};
const flashCallback = () => {
expect(createFlash).toHaveBeenCalledTimes(1);
createFlash.mockClear();
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe.each`
action | axiosMock | type | mutationCalls | callback
${actions.fetchGroups} | ${{ method: 'onGet', code: 200, res: MOCK_GROUPS }} | ${'success'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_SUCCESS, payload: MOCK_GROUPS }]} | ${noCallback}
${actions.fetchGroups} | ${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_GROUPS }, { type: types.RECEIVE_GROUPS_ERROR }]} | ${flashCallback}
`(`axios calls`, ({ action, axiosMock, type, mutationCalls, callback }) => {
describe(action.name, () => {
describe(`on ${type}`, () => {
beforeEach(() => {
mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res);
});
it(`should dispatch the correct mutations`, () => {
return testAction(action, null, state, mutationCalls, []).then(() => callback());
});
});
});
});
});
import mutations from '~/search/store/mutations';
import createState from '~/search/store/state';
import * as types from '~/search/store/mutation_types';
import { MOCK_QUERY, MOCK_GROUPS } from '../mock_data';
describe('Global Search Store Mutations', () => {
let state;
beforeEach(() => {
state = createState({ query: MOCK_QUERY });
});
describe('REQUEST_GROUPS', () => {
it('sets fetchingGroups to true', () => {
mutations[types.REQUEST_GROUPS](state);
expect(state.fetchingGroups).toBe(true);
});
});
describe('RECEIVE_GROUPS_SUCCESS', () => {
it('sets fetchingGroups to false and sets groups', () => {
mutations[types.RECEIVE_GROUPS_SUCCESS](state, MOCK_GROUPS);
expect(state.fetchingGroups).toBe(false);
expect(state.groups).toBe(MOCK_GROUPS);
});
});
describe('RECEIVE_GROUPS_ERROR', () => {
it('sets fetchingGroups to false and clears groups', () => {
mutations[types.RECEIVE_GROUPS_ERROR](state);
expect(state.fetchingGroups).toBe(false);
expect(state.groups).toEqual([]);
});
});
});
......@@ -36,16 +36,6 @@ describe('Search', () => {
new Search(); // eslint-disable-line no-new
});
it('requests groups from backend when filtering', () => {
jest.spyOn(Api, 'groups').mockImplementation(term => {
expect(term).toBe(searchTerm);
});
const inputElement = fillDropdownInput('.js-search-group-dropdown');
$(inputElement).trigger('input');
});
it('requests projects from backend when filtering', () => {
jest.spyOn(Api, 'projects').mockImplementation(term => {
expect(term).toBe(searchTerm);
......
......@@ -8,7 +8,7 @@ RSpec.describe 'search/_filter' do
render
expect(rendered).to have_selector('label[for="dashboard_search_group"]')
expect(rendered).to have_selector('button#dashboard_search_group')
expect(rendered).to have_selector('input#js-search-group-dropdown')
expect(rendered).to have_selector('label[for="dashboard_search_project"]')
expect(rendered).to have_selector('button#dashboard_search_project')
......
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