Commit 3741fdaf authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera

Merge branch '297396_04-gldropdown-topbar-autocomplete-search' into 'master'

Global Search - Header Search Autocomplete

See merge request gitlab-org/gitlab!70016
parents 50b2bf56 84e30a1a
...@@ -3,6 +3,7 @@ import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui'; ...@@ -3,6 +3,7 @@ import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex'; import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl } from '~/lib/utils/url_utility'; import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale'; import { __ } from '~/locale';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue'; import HeaderSearchDefaultItems from './header_search_default_items.vue';
import HeaderSearchScopedItems from './header_search_scoped_items.vue'; import HeaderSearchScopedItems from './header_search_scoped_items.vue';
...@@ -16,6 +17,7 @@ export default { ...@@ -16,6 +17,7 @@ export default {
GlSearchBoxByType, GlSearchBoxByType,
HeaderSearchDefaultItems, HeaderSearchDefaultItems,
HeaderSearchScopedItems, HeaderSearchScopedItems,
HeaderSearchAutocompleteItems,
}, },
data() { data() {
return { return {
...@@ -41,7 +43,7 @@ export default { ...@@ -41,7 +43,7 @@ export default {
}, },
}, },
methods: { methods: {
...mapActions(['setSearch']), ...mapActions(['setSearch', 'fetchAutocompleteOptions']),
openDropdown() { openDropdown() {
this.showDropdown = true; this.showDropdown = true;
}, },
...@@ -51,6 +53,13 @@ export default { ...@@ -51,6 +53,13 @@ export default {
submitSearch() { submitSearch() {
return visitUrl(this.searchQuery); return visitUrl(this.searchQuery);
}, },
getAutocompleteOptions(searchTerm) {
if (!searchTerm) {
return;
}
this.fetchAutocompleteOptions();
},
}, },
}; };
</script> </script>
...@@ -64,18 +73,20 @@ export default { ...@@ -64,18 +73,20 @@ export default {
:placeholder="$options.i18n.searchPlaceholder" :placeholder="$options.i18n.searchPlaceholder"
@focus="openDropdown" @focus="openDropdown"
@click="openDropdown" @click="openDropdown"
@input="getAutocompleteOptions"
@keydown.enter="submitSearch" @keydown.enter="submitSearch"
@keydown.esc="closeDropdown" @keydown.esc="closeDropdown"
/> />
<div <div
v-if="showSearchDropdown" v-if="showSearchDropdown"
data-testid="header-search-dropdown-menu" data-testid="header-search-dropdown-menu"
class="header-search-dropdown-menu gl-overflow-y-auto gl-absolute gl-left-0 gl-z-index-1 gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0" class="header-search-dropdown-menu gl-absolute gl-w-full gl-bg-white gl-border-1 gl-rounded-base gl-border-solid gl-border-gray-200 gl-shadow-x0-y2-b4-s0"
> >
<div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2"> <div class="header-search-dropdown-content gl-overflow-y-auto gl-py-2">
<header-search-default-items v-if="showDefaultItems" /> <header-search-default-items v-if="showDefaultItems" />
<template v-else> <template v-else>
<header-search-scoped-items /> <header-search-scoped-items />
<header-search-autocomplete-items />
</template> </template>
</div> </div>
</div> </div>
......
<script>
import {
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
GlAvatar,
GlLoadingIcon,
GlSafeHtmlDirective as SafeHtml,
} from '@gitlab/ui';
import { mapState, mapGetters } from 'vuex';
import highlight from '~/lib/utils/highlight';
import { GROUPS_CATEGORY, PROJECTS_CATEGORY, LARGE_AVATAR_PX, SMALL_AVATAR_PX } from '../constants';
export default {
name: 'HeaderSearchAutocompleteItems',
components: {
GlDropdownItem,
GlDropdownSectionHeader,
GlDropdownDivider,
GlAvatar,
GlLoadingIcon,
},
directives: {
SafeHtml,
},
computed: {
...mapState(['search', 'loading']),
...mapGetters(['autocompleteGroupedSearchOptions']),
},
methods: {
highlightedName(val) {
return highlight(val, this.search);
},
avatarSize(data) {
if (data.category === GROUPS_CATEGORY || data.category === PROJECTS_CATEGORY) {
return LARGE_AVATAR_PX;
}
return SMALL_AVATAR_PX;
},
},
};
</script>
<template>
<div>
<template v-if="!loading">
<div v-for="option in autocompleteGroupedSearchOptions" :key="option.category">
<gl-dropdown-divider />
<gl-dropdown-section-header>{{ option.category }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="(data, index) in option.data"
:id="`autocomplete-${option.category}-${index}`"
:key="index"
tabindex="-1"
:href="data.url"
>
<div class="gl-display-flex gl-align-items-center">
<gl-avatar
v-if="data.avatar_url !== undefined"
:src="data.avatar_url"
:entity-id="data.id"
:entity-name="data.label"
:size="avatarSize(data)"
shape="square"
/>
<span v-safe-html="highlightedName(data.label)"></span>
</div>
</gl-dropdown-item>
</div>
</template>
<gl-loading-icon v-else size="lg" class="my-4" />
</div>
</template>
...@@ -15,3 +15,11 @@ export const MSG_IN_ALL_GITLAB = __('in all GitLab'); ...@@ -15,3 +15,11 @@ export const MSG_IN_ALL_GITLAB = __('in all GitLab');
export const MSG_IN_GROUP = __('in group'); export const MSG_IN_GROUP = __('in group');
export const MSG_IN_PROJECT = __('in project'); export const MSG_IN_PROJECT = __('in project');
export const GROUPS_CATEGORY = 'Groups';
export const PROJECTS_CATEGORY = 'Projects';
export const LARGE_AVATAR_PX = 32;
export const SMALL_AVATAR_PX = 16;
...@@ -12,13 +12,13 @@ export const initHeaderSearchApp = () => { ...@@ -12,13 +12,13 @@ export const initHeaderSearchApp = () => {
return false; return false;
} }
const { searchPath, issuesPath, mrPath } = el.dataset; const { searchPath, issuesPath, mrPath, autocompletePath } = el.dataset;
let { searchContext } = el.dataset; let { searchContext } = el.dataset;
searchContext = JSON.parse(searchContext); searchContext = JSON.parse(searchContext);
return new Vue({ return new Vue({
el, el,
store: createStore({ searchPath, issuesPath, mrPath, searchContext }), store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
render(createElement) { render(createElement) {
return createElement(HeaderSearchApp); return createElement(HeaderSearchApp);
}, },
......
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
import * as types from './mutation_types'; import * as types from './mutation_types';
export const fetchAutocompleteOptions = ({ commit, getters }) => {
commit(types.REQUEST_AUTOCOMPLETE);
return axios
.get(getters.autocompleteQuery)
.then(({ data }) => commit(types.RECEIVE_AUTOCOMPLETE_SUCCESS, data))
.catch(() => {
commit(types.RECEIVE_AUTOCOMPLETE_ERROR);
createFlash({ message: __('There was an error fetching search autocomplete suggestions') });
});
};
export const setSearch = ({ commit }, value) => { export const setSearch = ({ commit }, value) => {
commit(types.SET_SEARCH, value); commit(types.SET_SEARCH, value);
}; };
...@@ -23,6 +23,16 @@ export const searchQuery = (state) => { ...@@ -23,6 +23,16 @@ export const searchQuery = (state) => {
return `${state.searchPath}?${objectToQuery(query)}`; return `${state.searchPath}?${objectToQuery(query)}`;
}; };
export const autocompleteQuery = (state) => {
const query = {
term: state.search,
project_id: state.searchContext.project?.id,
project_ref: state.searchContext.ref,
};
return `${state.autocompletePath}?${objectToQuery(query)}`;
};
export const scopedIssuesPath = (state) => { export const scopedIssuesPath = (state) => {
return ( return (
state.searchContext.project_metadata?.issues_path || state.searchContext.project_metadata?.issues_path ||
...@@ -133,3 +143,25 @@ export const scopedSearchOptions = (state, getters) => { ...@@ -133,3 +143,25 @@ export const scopedSearchOptions = (state, getters) => {
return options; return options;
}; };
export const autocompleteGroupedSearchOptions = (state) => {
const groupedOptions = {};
const results = [];
state.autocompleteOptions.forEach((option) => {
const category = groupedOptions[option.category];
if (category) {
category.data.push(option);
} else {
groupedOptions[option.category] = {
category: option.category,
data: [option],
};
results.push(groupedOptions[option.category]);
}
});
return results;
};
...@@ -7,11 +7,17 @@ import createState from './state'; ...@@ -7,11 +7,17 @@ import createState from './state';
Vue.use(Vuex); Vue.use(Vuex);
export const getStoreConfig = ({ searchPath, issuesPath, mrPath, searchContext }) => ({ export const getStoreConfig = ({
searchPath,
issuesPath,
mrPath,
autocompletePath,
searchContext,
}) => ({
actions, actions,
getters, getters,
mutations, mutations,
state: createState({ searchPath, issuesPath, mrPath, searchContext }), state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
}); });
const createStore = (config) => new Vuex.Store(getStoreConfig(config)); const createStore = (config) => new Vuex.Store(getStoreConfig(config));
......
export const REQUEST_AUTOCOMPLETE = 'REQUEST_AUTOCOMPLETE';
export const RECEIVE_AUTOCOMPLETE_SUCCESS = 'RECEIVE_AUTOCOMPLETE_SUCCESS';
export const RECEIVE_AUTOCOMPLETE_ERROR = 'RECEIVE_AUTOCOMPLETE_ERROR';
export const SET_SEARCH = 'SET_SEARCH'; export const SET_SEARCH = 'SET_SEARCH';
import * as types from './mutation_types'; import * as types from './mutation_types';
export default { export default {
[types.REQUEST_AUTOCOMPLETE](state) {
state.loading = true;
state.autocompleteOptions = [];
},
[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
state.loading = false;
state.autocompleteOptions = data;
},
[types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
state.loading = false;
state.autocompleteOptions = [];
},
[types.SET_SEARCH](state, value) { [types.SET_SEARCH](state, value) {
state.search = value; state.search = value;
}, },
......
const createState = ({ searchPath, issuesPath, mrPath, searchContext }) => ({ const createState = ({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }) => ({
searchPath, searchPath,
issuesPath, issuesPath,
mrPath, mrPath,
autocompletePath,
searchContext, searchContext,
search: '', search: '',
autocompleteOptions: [],
loading: false,
}); });
export default createState; export default createState;
...@@ -34,7 +34,8 @@ ...@@ -34,7 +34,8 @@
#js-header-search.header-search{ data: { 'search-context' => search_context.to_json, #js-header-search.header-search{ data: { 'search-context' => search_context.to_json,
'search-path' => search_path, 'search-path' => search_path,
'issues-path' => issues_dashboard_path, 'issues-path' => issues_dashboard_path,
'mr-path' => merge_requests_dashboard_path } } 'mr-path' => merge_requests_dashboard_path,
'autocomplete-path' => search_autocomplete_path } }
%input{ type: "text", placeholder: _('Search or jump to...'), class: 'form-control gl-form-input' } %input{ type: "text", placeholder: _('Search or jump to...'), class: 'form-control gl-form-input' }
- else - else
= render 'layouts/search' = render 'layouts/search'
......
...@@ -34157,6 +34157,9 @@ msgstr "" ...@@ -34157,6 +34157,9 @@ msgstr ""
msgid "There was an error fetching projects" msgid "There was an error fetching projects"
msgstr "" msgstr ""
msgid "There was an error fetching search autocomplete suggestions"
msgstr ""
msgid "There was an error fetching stage total counts" msgid "There was an error fetching stage total counts"
msgstr "" msgstr ""
......
...@@ -3,6 +3,7 @@ import Vue from 'vue'; ...@@ -3,6 +3,7 @@ import Vue from 'vue';
import Vuex from 'vuex'; import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import HeaderSearchApp from '~/header_search/components/app.vue'; import HeaderSearchApp from '~/header_search/components/app.vue';
import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue'; import HeaderSearchDefaultItems from '~/header_search/components/header_search_default_items.vue';
import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue'; import HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys'; import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys';
...@@ -20,6 +21,7 @@ describe('HeaderSearchApp', () => { ...@@ -20,6 +21,7 @@ describe('HeaderSearchApp', () => {
const actionSpies = { const actionSpies = {
setSearch: jest.fn(), setSearch: jest.fn(),
fetchAutocompleteOptions: jest.fn(),
}; };
const createComponent = (initialState) => { const createComponent = (initialState) => {
...@@ -46,6 +48,8 @@ describe('HeaderSearchApp', () => { ...@@ -46,6 +48,8 @@ describe('HeaderSearchApp', () => {
const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu'); const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu');
const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems); const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems);
const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems);
const findHeaderSearchAutocompleteItems = () =>
wrapper.findComponent(HeaderSearchAutocompleteItems);
describe('template', () => { describe('template', () => {
it('always renders Header Search Input', () => { it('always renders Header Search Input', () => {
...@@ -74,11 +78,11 @@ describe('HeaderSearchApp', () => { ...@@ -74,11 +78,11 @@ describe('HeaderSearchApp', () => {
}); });
describe.each` describe.each`
search | showDefault | showScoped search | showDefault | showScoped | showAutocomplete
${null} | ${true} | ${false} ${null} | ${true} | ${false} | ${false}
${''} | ${true} | ${false} ${''} | ${true} | ${false} | ${false}
${MOCK_SEARCH} | ${false} | ${true} ${MOCK_SEARCH} | ${false} | ${true} | ${true}
`('Header Search Dropdown Items', ({ search, showDefault, showScoped }) => { `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => {
describe(`when search is ${search}`, () => { describe(`when search is ${search}`, () => {
beforeEach(() => { beforeEach(() => {
createComponent({ search }); createComponent({ search });
...@@ -93,6 +97,10 @@ describe('HeaderSearchApp', () => { ...@@ -93,6 +97,10 @@ describe('HeaderSearchApp', () => {
it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => { it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
expect(findHeaderSearchScopedItems().exists()).toBe(showScoped); expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
}); });
it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => {
expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete);
});
}); });
}); });
}); });
...@@ -139,12 +147,18 @@ describe('HeaderSearchApp', () => { ...@@ -139,12 +147,18 @@ describe('HeaderSearchApp', () => {
}); });
}); });
it('calls setSearch when search input event is fired', async () => { describe('onInput', () => {
findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); beforeEach(() => {
findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH);
});
await wrapper.vm.$nextTick(); it('calls setSearch with search term', () => {
expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH);
});
expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); it('calls fetchAutocompleteOptions', () => {
expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled();
});
}); });
it('submits a search onKey-Enter', async () => { it('submits a search onKey-Enter', async () => {
......
import { GlDropdownItem, GlLoadingIcon, GlAvatar } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import HeaderSearchAutocompleteItems from '~/header_search/components/header_search_autocomplete_items.vue';
import {
GROUPS_CATEGORY,
LARGE_AVATAR_PX,
PROJECTS_CATEGORY,
SMALL_AVATAR_PX,
} from '~/header_search/constants';
import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data';
Vue.use(Vuex);
describe('HeaderSearchAutocompleteItems', () => {
let wrapper;
const createComponent = (initialState, mockGetters) => {
const store = new Vuex.Store({
state: {
loading: false,
...initialState,
},
getters: {
autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
...mockGetters,
},
});
wrapper = shallowMount(HeaderSearchAutocompleteItems, {
store,
});
};
afterEach(() => {
wrapper.destroy();
});
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text());
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findGlAvatar = () => wrapper.findComponent(GlAvatar);
describe('template', () => {
describe('when loading is true', () => {
beforeEach(() => {
createComponent({ loading: true });
});
it('renders GlLoadingIcon', () => {
expect(findGlLoadingIcon().exists()).toBe(true);
});
it('does not render autocomplete options', () => {
expect(findDropdownItems()).toHaveLength(0);
});
});
describe('when loading is false', () => {
beforeEach(() => {
createComponent({ loading: false });
});
it('does not render GlLoadingIcon', () => {
expect(findGlLoadingIcon().exists()).toBe(false);
});
describe('Dropdown items', () => {
it('renders item for each option in autocomplete option', () => {
expect(findDropdownItems()).toHaveLength(MOCK_AUTOCOMPLETE_OPTIONS.length);
});
it('renders titles correctly', () => {
const expectedTitles = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.label);
expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
});
it('renders links correctly', () => {
const expectedLinks = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.url);
expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
});
});
describe.each`
item | showAvatar | avatarSize
${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)}
${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)}
${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)}
${{ data: [{ category: 'Settings' }] }} | ${false} | ${false}
`('GlAvatar', ({ item, showAvatar, avatarSize }) => {
describe(`when category is ${item.data[0].category} and avatar_url is ${item.data[0].avatar_url}`, () => {
beforeEach(() => {
createComponent({}, { autocompleteGroupedSearchOptions: () => [item] });
});
it(`should${showAvatar ? '' : ' not'} render`, () => {
expect(findGlAvatar().exists()).toBe(showAvatar);
});
it(`should set avatarSize to ${avatarSize}`, () => {
expect(findGlAvatar().exists() && findGlAvatar().attributes('size')).toBe(avatarSize);
});
});
});
});
});
});
...@@ -19,6 +19,8 @@ export const MOCK_MR_PATH = '/dashboard/merge_requests'; ...@@ -19,6 +19,8 @@ export const MOCK_MR_PATH = '/dashboard/merge_requests';
export const MOCK_ALL_PATH = '/'; export const MOCK_ALL_PATH = '/';
export const MOCK_AUTOCOMPLETE_PATH = '/autocomplete';
export const MOCK_PROJECT = { export const MOCK_PROJECT = {
id: 123, id: 123,
name: 'MockProject', name: 'MockProject',
...@@ -81,3 +83,70 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [ ...@@ -81,3 +83,70 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [
url: MOCK_ALL_PATH, url: MOCK_ALL_PATH,
}, },
]; ];
export const MOCK_AUTOCOMPLETE_OPTIONS = [
{
category: 'Projects',
id: 1,
label: 'MockProject1',
url: 'project/1',
},
{
category: 'Projects',
id: 2,
label: 'MockProject2',
url: 'project/2',
},
{
category: 'Groups',
id: 1,
label: 'MockGroup1',
url: 'group/1',
},
{
category: 'Help',
label: 'GitLab Help',
url: 'help/gitlab',
},
];
export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
{
category: 'Projects',
data: [
{
category: 'Projects',
id: 1,
label: 'MockProject1',
url: 'project/1',
},
{
category: 'Projects',
id: 2,
label: 'MockProject2',
url: 'project/2',
},
],
},
{
category: 'Groups',
data: [
{
category: 'Groups',
id: 1,
label: 'MockGroup1',
url: 'group/1',
},
],
},
{
category: 'Help',
data: [
{
category: 'Help',
label: 'GitLab Help',
url: 'help/gitlab',
},
],
},
];
import MockAdapter from 'axios-mock-adapter';
import testAction from 'helpers/vuex_action_helper'; import testAction from 'helpers/vuex_action_helper';
import createFlash from '~/flash';
import * as actions from '~/header_search/store/actions'; import * as actions from '~/header_search/store/actions';
import * as types from '~/header_search/store/mutation_types'; import * as types from '~/header_search/store/mutation_types';
import createState from '~/header_search/store/state'; import createState from '~/header_search/store/state';
import { MOCK_SEARCH } from '../mock_data'; import axios from '~/lib/utils/axios_utils';
import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data';
jest.mock('~/flash');
describe('Header Search Store Actions', () => { describe('Header Search Store Actions', () => {
let state; let state;
let mock;
const flashCallback = (callCount) => {
expect(createFlash).toHaveBeenCalledTimes(callCount);
createFlash.mockClear();
};
beforeEach(() => { beforeEach(() => {
state = createState({}); state = createState({});
mock = new MockAdapter(axios);
}); });
afterEach(() => { afterEach(() => {
state = null; state = null;
mock.restore();
});
describe.each`
axiosMock | type | expectedMutations | flashCallCount
${{ method: 'onGet', code: 200, res: MOCK_AUTOCOMPLETE_OPTIONS }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS }]} | ${0}
${{ method: 'onGet', code: 500, res: null }} | ${'error'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_ERROR }]} | ${1}
`('fetchAutocompleteOptions', ({ axiosMock, type, expectedMutations, flashCallCount }) => {
describe(`on ${type}`, () => {
beforeEach(() => {
mock[axiosMock.method]().replyOnce(axiosMock.code, axiosMock.res);
});
it(`should dispatch the correct mutations`, () => {
return testAction({
action: actions.fetchAutocompleteOptions,
state,
expectedMutations,
}).then(() => flashCallback(flashCallCount));
});
});
}); });
describe('setSearch', () => { describe('setSearch', () => {
......
...@@ -5,6 +5,7 @@ import { ...@@ -5,6 +5,7 @@ import {
MOCK_SEARCH_PATH, MOCK_SEARCH_PATH,
MOCK_ISSUE_PATH, MOCK_ISSUE_PATH,
MOCK_MR_PATH, MOCK_MR_PATH,
MOCK_AUTOCOMPLETE_PATH,
MOCK_SEARCH_CONTEXT, MOCK_SEARCH_CONTEXT,
MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_DEFAULT_SEARCH_OPTIONS,
MOCK_SCOPED_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS,
...@@ -12,6 +13,8 @@ import { ...@@ -12,6 +13,8 @@ import {
MOCK_GROUP, MOCK_GROUP,
MOCK_ALL_PATH, MOCK_ALL_PATH,
MOCK_SEARCH, MOCK_SEARCH,
MOCK_AUTOCOMPLETE_OPTIONS,
MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
} from '../mock_data'; } from '../mock_data';
describe('Header Search Store Getters', () => { describe('Header Search Store Getters', () => {
...@@ -22,6 +25,7 @@ describe('Header Search Store Getters', () => { ...@@ -22,6 +25,7 @@ describe('Header Search Store Getters', () => {
searchPath: MOCK_SEARCH_PATH, searchPath: MOCK_SEARCH_PATH,
issuesPath: MOCK_ISSUE_PATH, issuesPath: MOCK_ISSUE_PATH,
mrPath: MOCK_MR_PATH, mrPath: MOCK_MR_PATH,
autocompletePath: MOCK_AUTOCOMPLETE_PATH,
searchContext: MOCK_SEARCH_CONTEXT, searchContext: MOCK_SEARCH_CONTEXT,
...initialState, ...initialState,
}); });
...@@ -55,6 +59,29 @@ describe('Header Search Store Getters', () => { ...@@ -55,6 +59,29 @@ describe('Header Search Store Getters', () => {
}); });
}); });
describe.each`
project | ref | expectedPath
${null} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=undefined&project_ref=null`}
${MOCK_PROJECT} | ${null} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=null`}
${MOCK_PROJECT} | ${MOCK_PROJECT.id} | ${`${MOCK_AUTOCOMPLETE_PATH}?term=${MOCK_SEARCH}&project_id=${MOCK_PROJECT.id}&project_ref=${MOCK_PROJECT.id}`}
`('autocompleteQuery', ({ project, ref, expectedPath }) => {
describe(`when project is ${project?.name} and project ref is ${ref}`, () => {
beforeEach(() => {
createState({
searchContext: {
project,
ref,
},
});
state.search = MOCK_SEARCH;
});
it(`should return ${expectedPath}`, () => {
expect(getters.autocompleteQuery(state)).toBe(expectedPath);
});
});
});
describe.each` describe.each`
group | group_metadata | project | project_metadata | expectedPath group | group_metadata | project | project_metadata | expectedPath
${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH} ${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH}
...@@ -208,4 +235,17 @@ describe('Header Search Store Getters', () => { ...@@ -208,4 +235,17 @@ describe('Header Search Store Getters', () => {
); );
}); });
}); });
describe('autocompleteGroupedSearchOptions', () => {
beforeEach(() => {
createState();
state.autocompleteOptions = MOCK_AUTOCOMPLETE_OPTIONS;
});
it('returns the correct grouped array', () => {
expect(getters.autocompleteGroupedSearchOptions(state)).toStrictEqual(
MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
);
});
});
}); });
import * as types from '~/header_search/store/mutation_types'; import * as types from '~/header_search/store/mutation_types';
import mutations from '~/header_search/store/mutations'; import mutations from '~/header_search/store/mutations';
import createState from '~/header_search/store/state'; import createState from '~/header_search/store/state';
import { MOCK_SEARCH } from '../mock_data'; import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data';
describe('Header Search Store Mutations', () => { describe('Header Search Store Mutations', () => {
let state; let state;
...@@ -10,6 +10,33 @@ describe('Header Search Store Mutations', () => { ...@@ -10,6 +10,33 @@ describe('Header Search Store Mutations', () => {
state = createState({}); state = createState({});
}); });
describe('REQUEST_AUTOCOMPLETE', () => {
it('sets loading to true and empties autocompleteOptions array', () => {
mutations[types.REQUEST_AUTOCOMPLETE](state);
expect(state.loading).toBe(true);
expect(state.autocompleteOptions).toStrictEqual([]);
});
});
describe('RECEIVE_AUTOCOMPLETE_SUCCESS', () => {
it('sets loading to false and sets autocompleteOptions array', () => {
mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS);
expect(state.loading).toBe(false);
expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS);
});
});
describe('RECEIVE_AUTOCOMPLETE_ERROR', () => {
it('sets loading to false and empties autocompleteOptions array', () => {
mutations[types.RECEIVE_AUTOCOMPLETE_ERROR](state);
expect(state.loading).toBe(false);
expect(state.autocompleteOptions).toStrictEqual([]);
});
});
describe('SET_SEARCH', () => { describe('SET_SEARCH', () => {
it('sets search to value', () => { it('sets search to value', () => {
mutations[types.SET_SEARCH](state, MOCK_SEARCH); mutations[types.SET_SEARCH](state, MOCK_SEARCH);
......
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