Commit 84e30a1a authored by Zack Cuddy's avatar Zack Cuddy Committed by Nicolò Maria Mezzopera

Global Search - Header Search Autocomplete

parent f051dd16
......@@ -3,6 +3,7 @@ import { GlSearchBoxByType, GlOutsideDirective as Outside } from '@gitlab/ui';
import { mapState, mapActions, mapGetters } from 'vuex';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
import HeaderSearchScopedItems from './header_search_scoped_items.vue';
......@@ -16,6 +17,7 @@ export default {
GlSearchBoxByType,
HeaderSearchDefaultItems,
HeaderSearchScopedItems,
HeaderSearchAutocompleteItems,
},
data() {
return {
......@@ -41,7 +43,7 @@ export default {
},
},
methods: {
...mapActions(['setSearch']),
...mapActions(['setSearch', 'fetchAutocompleteOptions']),
openDropdown() {
this.showDropdown = true;
},
......@@ -51,6 +53,13 @@ export default {
submitSearch() {
return visitUrl(this.searchQuery);
},
getAutocompleteOptions(searchTerm) {
if (!searchTerm) {
return;
}
this.fetchAutocompleteOptions();
},
},
};
</script>
......@@ -64,18 +73,20 @@ export default {
:placeholder="$options.i18n.searchPlaceholder"
@focus="openDropdown"
@click="openDropdown"
@input="getAutocompleteOptions"
@keydown.enter="submitSearch"
@keydown.esc="closeDropdown"
/>
<div
v-if="showSearchDropdown"
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">
<header-search-default-items v-if="showDefaultItems" />
<template v-else>
<header-search-scoped-items />
<header-search-autocomplete-items />
</template>
</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');
export const MSG_IN_GROUP = __('in group');
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 = () => {
return false;
}
const { searchPath, issuesPath, mrPath } = el.dataset;
const { searchPath, issuesPath, mrPath, autocompletePath } = el.dataset;
let { searchContext } = el.dataset;
searchContext = JSON.parse(searchContext);
return new Vue({
el,
store: createStore({ searchPath, issuesPath, mrPath, searchContext }),
store: createStore({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
render(createElement) {
return createElement(HeaderSearchApp);
},
......
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { __ } from '~/locale';
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) => {
commit(types.SET_SEARCH, value);
};
......@@ -23,6 +23,16 @@ export const searchQuery = (state) => {
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) => {
return (
state.searchContext.project_metadata?.issues_path ||
......@@ -133,3 +143,25 @@ export const scopedSearchOptions = (state, getters) => {
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';
Vue.use(Vuex);
export const getStoreConfig = ({ searchPath, issuesPath, mrPath, searchContext }) => ({
export const getStoreConfig = ({
searchPath,
issuesPath,
mrPath,
autocompletePath,
searchContext,
}) => ({
actions,
getters,
mutations,
state: createState({ searchPath, issuesPath, mrPath, searchContext }),
state: createState({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }),
});
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';
import * as types from './mutation_types';
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) {
state.search = value;
},
......
const createState = ({ searchPath, issuesPath, mrPath, searchContext }) => ({
const createState = ({ searchPath, issuesPath, mrPath, autocompletePath, searchContext }) => ({
searchPath,
issuesPath,
mrPath,
autocompletePath,
searchContext,
search: '',
autocompleteOptions: [],
loading: false,
});
export default createState;
......@@ -34,7 +34,8 @@
#js-header-search.header-search{ data: { 'search-context' => search_context.to_json,
'search-path' => search_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' }
- else
= render 'layouts/search'
......
......@@ -34157,6 +34157,9 @@ msgstr ""
msgid "There was an error fetching projects"
msgstr ""
msgid "There was an error fetching search autocomplete suggestions"
msgstr ""
msgid "There was an error fetching stage total counts"
msgstr ""
......
......@@ -3,6 +3,7 @@ import Vue from 'vue';
import Vuex from 'vuex';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
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 HeaderSearchScopedItems from '~/header_search/components/header_search_scoped_items.vue';
import { ENTER_KEY, ESC_KEY } from '~/lib/utils/keys';
......@@ -20,6 +21,7 @@ describe('HeaderSearchApp', () => {
const actionSpies = {
setSearch: jest.fn(),
fetchAutocompleteOptions: jest.fn(),
};
const createComponent = (initialState) => {
......@@ -46,6 +48,8 @@ describe('HeaderSearchApp', () => {
const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu');
const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems);
const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems);
const findHeaderSearchAutocompleteItems = () =>
wrapper.findComponent(HeaderSearchAutocompleteItems);
describe('template', () => {
it('always renders Header Search Input', () => {
......@@ -74,11 +78,11 @@ describe('HeaderSearchApp', () => {
});
describe.each`
search | showDefault | showScoped
${null} | ${true} | ${false}
${''} | ${true} | ${false}
${MOCK_SEARCH} | ${false} | ${true}
`('Header Search Dropdown Items', ({ search, showDefault, showScoped }) => {
search | showDefault | showScoped | showAutocomplete
${null} | ${true} | ${false} | ${false}
${''} | ${true} | ${false} | ${false}
${MOCK_SEARCH} | ${false} | ${true} | ${true}
`('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => {
describe(`when search is ${search}`, () => {
beforeEach(() => {
createComponent({ search });
......@@ -93,6 +97,10 @@ describe('HeaderSearchApp', () => {
it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
});
it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => {
expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete);
});
});
});
});
......@@ -139,14 +147,20 @@ describe('HeaderSearchApp', () => {
});
});
it('calls setSearch when search input event is fired', async () => {
describe('onInput', () => {
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);
});
it('calls fetchAutocompleteOptions', () => {
expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled();
});
});
it('submits a search onKey-Enter', async () => {
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
......
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';
export const MOCK_ALL_PATH = '/';
export const MOCK_AUTOCOMPLETE_PATH = '/autocomplete';
export const MOCK_PROJECT = {
id: 123,
name: 'MockProject',
......@@ -81,3 +83,70 @@ export const MOCK_SCOPED_SEARCH_OPTIONS = [
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 createFlash from '~/flash';
import * as actions from '~/header_search/store/actions';
import * as types from '~/header_search/store/mutation_types';
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', () => {
let state;
let mock;
const flashCallback = (callCount) => {
expect(createFlash).toHaveBeenCalledTimes(callCount);
createFlash.mockClear();
};
beforeEach(() => {
state = createState({});
mock = new MockAdapter(axios);
});
afterEach(() => {
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', () => {
......
......@@ -5,6 +5,7 @@ import {
MOCK_SEARCH_PATH,
MOCK_ISSUE_PATH,
MOCK_MR_PATH,
MOCK_AUTOCOMPLETE_PATH,
MOCK_SEARCH_CONTEXT,
MOCK_DEFAULT_SEARCH_OPTIONS,
MOCK_SCOPED_SEARCH_OPTIONS,
......@@ -12,6 +13,8 @@ import {
MOCK_GROUP,
MOCK_ALL_PATH,
MOCK_SEARCH,
MOCK_AUTOCOMPLETE_OPTIONS,
MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
} from '../mock_data';
describe('Header Search Store Getters', () => {
......@@ -22,6 +25,7 @@ describe('Header Search Store Getters', () => {
searchPath: MOCK_SEARCH_PATH,
issuesPath: MOCK_ISSUE_PATH,
mrPath: MOCK_MR_PATH,
autocompletePath: MOCK_AUTOCOMPLETE_PATH,
searchContext: MOCK_SEARCH_CONTEXT,
...initialState,
});
......@@ -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`
group | group_metadata | project | project_metadata | expectedPath
${null} | ${null} | ${null} | ${null} | ${MOCK_ISSUE_PATH}
......@@ -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 mutations from '~/header_search/store/mutations';
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', () => {
let state;
......@@ -10,6 +10,33 @@ describe('Header Search Store Mutations', () => {
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', () => {
it('sets search to value', () => {
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