Commit 0aeb8866 authored by Miguel Rincon's avatar Miguel Rincon

Merge branch '262060_01-group-filter-vue' into 'master'

Global Search - Group Filter

See merge request gitlab-org/gitlab!44833
parents e1ef201b 23a81cc1
......@@ -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
......
......@@ -23328,9 +23328,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