Commit b0014825 authored by Zack Cuddy's avatar Zack Cuddy

Global Search - Keyboard Control

This is part of a large effort
to refactor the header search.

In this MR we add full keyboard
and arrow key support for the
dropdown.

This is part 1 of 2 to add
proper a11y support for this
component.

Screen reader focus is made
in the following MR.
parent 530e5b38
......@@ -3,6 +3,8 @@ 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 DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import { FIRST_DROPDOWN_INDEX, SEARCH_BOX_INDEX } from '../constants';
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
import HeaderSearchDefaultItems from './header_search_default_items.vue';
import HeaderSearchScopedItems from './header_search_scoped_items.vue';
......@@ -18,15 +20,17 @@ export default {
HeaderSearchDefaultItems,
HeaderSearchScopedItems,
HeaderSearchAutocompleteItems,
DropdownKeyboardNavigation,
},
data() {
return {
showDropdown: false,
currentFocusIndex: SEARCH_BOX_INDEX,
};
},
computed: {
...mapState(['search']),
...mapGetters(['searchQuery']),
...mapGetters(['searchQuery', 'searchOptions']),
searchText: {
get() {
return this.search;
......@@ -35,12 +39,25 @@ export default {
this.setSearch(value);
},
},
currentFocusedOption() {
return this.searchOptions[this.currentFocusIndex];
},
isLoggedIn() {
return gon?.current_username;
},
showSearchDropdown() {
return this.showDropdown && gon?.current_username;
return this.showDropdown && this.isLoggedIn;
},
showDefaultItems() {
return !this.searchText;
},
defaultIndex() {
if (this.showDefaultItems) {
return SEARCH_BOX_INDEX;
}
return FIRST_DROPDOWN_INDEX;
},
},
methods: {
...mapActions(['setSearch', 'fetchAutocompleteOptions']),
......@@ -51,7 +68,7 @@ export default {
this.showDropdown = false;
},
submitSearch() {
return visitUrl(this.searchQuery);
return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
},
getAutocompleteOptions(searchTerm) {
if (!searchTerm) {
......@@ -61,6 +78,7 @@ export default {
this.fetchAutocompleteOptions();
},
},
SEARCH_BOX_INDEX,
};
</script>
......@@ -68,14 +86,14 @@ export default {
<section v-outside="closeDropdown" class="header-search gl-relative">
<gl-search-box-by-type
v-model="searchText"
class="gl-z-index-1"
:debounce="500"
autocomplete="off"
:placeholder="$options.i18n.searchPlaceholder"
@focus="openDropdown"
@click="openDropdown"
@input="getAutocompleteOptions"
@keydown.enter="submitSearch"
@keydown.esc="closeDropdown"
@keydown.enter.stop.prevent="submitSearch"
/>
<div
v-if="showSearchDropdown"
......@@ -83,10 +101,20 @@ export default {
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" />
<dropdown-keyboard-navigation
v-model="currentFocusIndex"
:max="searchOptions.length - 1"
:min="$options.SEARCH_BOX_INDEX"
:default-index="defaultIndex"
@tab="closeDropdown"
/>
<header-search-default-items
v-if="showDefaultItems"
:current-focused-option="currentFocusedOption"
/>
<template v-else>
<header-search-scoped-items />
<header-search-autocomplete-items />
<header-search-scoped-items :current-focused-option="currentFocusedOption" />
<header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
</template>
</div>
</div>
......
......@@ -23,10 +23,26 @@ export default {
directives: {
SafeHtml,
},
props: {
currentFocusedOption: {
type: Object,
required: false,
default: () => null,
},
},
computed: {
...mapState(['search', 'loading']),
...mapGetters(['autocompleteGroupedSearchOptions']),
},
watch: {
currentFocusedOption() {
const focusedElement = this.$refs[this.currentFocusedOption?.html_id]?.[0]?.$el;
if (focusedElement) {
focusedElement.scrollIntoView(false);
}
},
},
methods: {
highlightedName(val) {
return highlight(val, this.search);
......@@ -38,6 +54,9 @@ export default {
return SMALL_AVATAR_PX;
},
isOptionFocused(data) {
return this.currentFocusedOption?.html_id === data.html_id;
},
},
};
</script>
......@@ -49,9 +68,10 @@ export default {
<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"
v-for="data in option.data"
:ref="data.html_id"
:key="data.html_id"
:class="{ 'gl-bg-gray-50': isOptionFocused(data) }"
tabindex="-1"
:href="data.url"
>
......
......@@ -12,6 +12,13 @@ export default {
GlDropdownSectionHeader,
GlDropdownItem,
},
props: {
currentFocusedOption: {
type: Object,
required: false,
default: () => null,
},
},
computed: {
...mapState(['searchContext']),
...mapGetters(['defaultSearchOptions']),
......@@ -23,6 +30,11 @@ export default {
);
},
},
methods: {
isOptionFocused(option) {
return this.currentFocusedOption?.html_id === option.html_id;
},
},
};
</script>
......@@ -30,9 +42,10 @@ export default {
<div>
<gl-dropdown-section-header>{{ sectionHeader }}</gl-dropdown-section-header>
<gl-dropdown-item
v-for="(option, index) in defaultSearchOptions"
:id="`default-${index}`"
:key="index"
v-for="option in defaultSearchOptions"
:ref="option.html_id"
:key="option.html_id"
:class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
tabindex="-1"
:href="option.url"
>
......
......@@ -7,19 +7,32 @@ export default {
components: {
GlDropdownItem,
},
props: {
currentFocusedOption: {
type: Object,
required: false,
default: () => null,
},
},
computed: {
...mapState(['search']),
...mapGetters(['scopedSearchOptions']),
},
methods: {
isOptionFocused(option) {
return this.currentFocusedOption?.html_id === option.html_id;
},
},
};
</script>
<template>
<div>
<gl-dropdown-item
v-for="(option, index) in scopedSearchOptions"
:id="`scoped-${index}`"
:key="index"
v-for="option in scopedSearchOptions"
:ref="option.html_id"
:key="option.html_id"
:class="{ 'gl-bg-gray-50': isOptionFocused(option) }"
tabindex="-1"
:href="option.url"
>
......
......@@ -23,3 +23,7 @@ export const PROJECTS_CATEGORY = 'Projects';
export const LARGE_AVATAR_PX = 32;
export const SMALL_AVATAR_PX = 16;
export const FIRST_DROPDOWN_INDEX = 0;
export const SEARCH_BOX_INDEX = -1;
......@@ -54,22 +54,27 @@ export const defaultSearchOptions = (state, getters) => {
return [
{
html_id: 'default-issues-assigned',
title: MSG_ISSUES_ASSIGNED_TO_ME,
url: `${getters.scopedIssuesPath}/?assignee_username=${userName}`,
},
{
html_id: 'default-issues-created',
title: MSG_ISSUES_IVE_CREATED,
url: `${getters.scopedIssuesPath}/?author_username=${userName}`,
},
{
html_id: 'default-mrs-assigned',
title: MSG_MR_ASSIGNED_TO_ME,
url: `${getters.scopedMRPath}/?assignee_username=${userName}`,
},
{
html_id: 'default-mrs-reviewer',
title: MSG_MR_IM_REVIEWER,
url: `${getters.scopedMRPath}/?reviewer_username=${userName}`,
},
{
html_id: 'default-mrs-created',
title: MSG_MR_IVE_CREATED,
url: `${getters.scopedMRPath}/?author_username=${userName}`,
},
......@@ -122,6 +127,7 @@ export const scopedSearchOptions = (state, getters) => {
if (state.searchContext.project) {
options.push({
html_id: 'scoped-in-project',
scope: state.searchContext.project.name,
description: MSG_IN_PROJECT,
url: getters.projectUrl,
......@@ -130,6 +136,7 @@ export const scopedSearchOptions = (state, getters) => {
if (state.searchContext.group) {
options.push({
html_id: 'scoped-in-group',
scope: state.searchContext.group.name,
description: MSG_IN_GROUP,
url: getters.groupUrl,
......@@ -137,6 +144,7 @@ export const scopedSearchOptions = (state, getters) => {
}
options.push({
html_id: 'scoped-in-all',
description: MSG_IN_ALL_GITLAB,
url: getters.allUrl,
});
......@@ -165,3 +173,18 @@ export const autocompleteGroupedSearchOptions = (state) => {
return results;
};
export const searchOptions = (state, getters) => {
if (!state.search) {
return getters.defaultSearchOptions;
}
const sortedAutocompleteOptions = Object.values(getters.autocompleteGroupedSearchOptions).reduce(
(options, group) => {
return [...options, ...group.data];
},
[],
);
return getters.scopedSearchOptions.concat(sortedAutocompleteOptions);
};
......@@ -7,7 +7,9 @@ export default {
},
[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, data) {
state.loading = false;
state.autocompleteOptions = data;
state.autocompleteOptions = data.map((d, i) => {
return { html_id: `autocomplete-${d.category}-${i}`, ...d };
});
},
[types.RECEIVE_AUTOCOMPLETE_ERROR](state) {
state.loading = false;
......
......@@ -6,9 +6,15 @@ 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';
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
import { ENTER_KEY } from '~/lib/utils/keys';
import { visitUrl } from '~/lib/utils/url_utility';
import { MOCK_SEARCH, MOCK_SEARCH_QUERY, MOCK_USERNAME } from '../mock_data';
import {
MOCK_SEARCH,
MOCK_SEARCH_QUERY,
MOCK_USERNAME,
MOCK_DEFAULT_SEARCH_OPTIONS,
} from '../mock_data';
Vue.use(Vuex);
......@@ -24,7 +30,7 @@ describe('HeaderSearchApp', () => {
fetchAutocompleteOptions: jest.fn(),
};
const createComponent = (initialState) => {
const createComponent = (initialState, mockGetters) => {
const store = new Vuex.Store({
state: {
...initialState,
......@@ -32,6 +38,8 @@ describe('HeaderSearchApp', () => {
actions: actionSpies,
getters: {
searchQuery: () => MOCK_SEARCH_QUERY,
searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS,
...mockGetters,
},
});
......@@ -50,6 +58,7 @@ describe('HeaderSearchApp', () => {
const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems);
const findHeaderSearchAutocompleteItems = () =>
wrapper.findComponent(HeaderSearchAutocompleteItems);
const findDropdownKeyboardNavigation = () => wrapper.findComponent(DropdownKeyboardNavigation);
describe('template', () => {
it('always renders Header Search Input', () => {
......@@ -66,8 +75,8 @@ describe('HeaderSearchApp', () => {
`('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => {
describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => {
beforeEach(() => {
createComponent();
window.gon.current_username = username;
createComponent();
wrapper.setData({ showDropdown });
});
......@@ -78,31 +87,42 @@ describe('HeaderSearchApp', () => {
});
describe.each`
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 });
window.gon.current_username = MOCK_USERNAME;
wrapper.setData({ showDropdown: true });
});
it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault);
});
it(`should${showScoped ? '' : ' not'} render the Scoped Dropdown Items`, () => {
expect(findHeaderSearchScopedItems().exists()).toBe(showScoped);
search | showDefault | showScoped | showAutocomplete | showDropdownNavigation
${null} | ${true} | ${false} | ${false} | ${true}
${''} | ${true} | ${false} | ${false} | ${true}
${MOCK_SEARCH} | ${false} | ${true} | ${true} | ${true}
`(
'Header Search Dropdown Items',
({ search, showDefault, showScoped, showAutocomplete, showDropdownNavigation }) => {
describe(`when search is ${search}`, () => {
beforeEach(() => {
window.gon.current_username = MOCK_USERNAME;
createComponent({ search });
findHeaderSearchInput().vm.$emit('click');
});
it(`should${showDefault ? '' : ' not'} render the Default Dropdown Items`, () => {
expect(findHeaderSearchDefaultItems().exists()).toBe(showDefault);
});
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);
});
it(`should${
showDropdownNavigation ? '' : ' not'
} render the Dropdown Navigation Component`, () => {
expect(findDropdownKeyboardNavigation().exists()).toBe(showDropdownNavigation);
});
});
it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Dropdown Items`, () => {
expect(findHeaderSearchAutocompleteItems().exists()).toBe(showAutocomplete);
});
});
});
},
);
});
describe('events', () => {
......@@ -132,21 +152,6 @@ describe('HeaderSearchApp', () => {
});
});
describe('when dropdown is opened', () => {
beforeEach(() => {
wrapper.setData({ showDropdown: true });
});
it('onKey-Escape closes dropdown', async () => {
expect(findHeaderSearchDropdown().exists()).toBe(true);
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ESC_KEY }));
await wrapper.vm.$nextTick();
expect(findHeaderSearchDropdown().exists()).toBe(false);
});
});
describe('onInput', () => {
beforeEach(() => {
findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH);
......@@ -160,8 +165,49 @@ describe('HeaderSearchApp', () => {
expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled();
});
});
});
describe('Dropdown Keyboard Navigation', () => {
beforeEach(() => {
findHeaderSearchInput().vm.$emit('click');
});
it('submits a search onKey-Enter', async () => {
it('closes dropdown when @tab is emitted', async () => {
expect(findHeaderSearchDropdown().exists()).toBe(true);
findDropdownKeyboardNavigation().vm.$emit('tab');
await wrapper.vm.$nextTick();
expect(findHeaderSearchDropdown().exists()).toBe(false);
});
});
});
describe('computed', () => {
describe('currentFocusedOption', () => {
const MOCK_INDEX = 1;
beforeEach(() => {
createComponent();
window.gon.current_username = MOCK_USERNAME;
findHeaderSearchInput().vm.$emit('click');
});
it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, async () => {
findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
await wrapper.vm.$nextTick();
expect(wrapper.vm.currentFocusedOption).toBe(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX]);
});
});
});
describe('Submitting a search', () => {
describe('with no currentFocusedOption', () => {
beforeEach(() => {
createComponent();
});
it('onKey-enter submits a search', async () => {
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
await wrapper.vm.$nextTick();
......@@ -169,5 +215,22 @@ describe('HeaderSearchApp', () => {
expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY);
});
});
describe('with currentFocusedOption', () => {
const MOCK_INDEX = 1;
beforeEach(() => {
createComponent();
window.gon.current_username = MOCK_USERNAME;
findHeaderSearchInput().vm.$emit('click');
});
it('onKey-enter clicks the selected dropdown item rather than submitting a search', async () => {
findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX);
await wrapper.vm.$nextTick();
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY }));
expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url);
});
});
});
});
......@@ -9,14 +9,14 @@ import {
PROJECTS_CATEGORY,
SMALL_AVATAR_PX,
} from '~/header_search/constants';
import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data';
import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS } from '../mock_data';
Vue.use(Vuex);
describe('HeaderSearchAutocompleteItems', () => {
let wrapper;
const createComponent = (initialState, mockGetters) => {
const createComponent = (initialState, mockGetters, props) => {
const store = new Vuex.Store({
state: {
loading: false,
......@@ -30,6 +30,9 @@ describe('HeaderSearchAutocompleteItems', () => {
wrapper = shallowMount(HeaderSearchAutocompleteItems, {
store,
propsData: {
...props,
},
});
};
......@@ -38,6 +41,7 @@ describe('HeaderSearchAutocompleteItems', () => {
});
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text());
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
......@@ -69,16 +73,16 @@ describe('HeaderSearchAutocompleteItems', () => {
describe('Dropdown items', () => {
it('renders item for each option in autocomplete option', () => {
expect(findDropdownItems()).toHaveLength(MOCK_AUTOCOMPLETE_OPTIONS.length);
expect(findDropdownItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length);
});
it('renders titles correctly', () => {
const expectedTitles = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.label);
const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.label);
expect(findDropdownItemTitles()).toStrictEqual(expectedTitles);
});
it('renders links correctly', () => {
const expectedLinks = MOCK_AUTOCOMPLETE_OPTIONS.map((o) => o.url);
const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.url);
expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
});
});
......@@ -104,5 +108,42 @@ describe('HeaderSearchAutocompleteItems', () => {
});
});
});
describe.each`
currentFocusedOption | isFocused
${null} | ${false}
${{ html_id: 'not-a-match' }} | ${false}
${MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0]} | ${true}
`('isOptionFocused', ({ currentFocusedOption, isFocused }) => {
describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
beforeEach(() => {
createComponent({}, {}, { currentFocusedOption });
});
it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
});
});
});
});
describe('watchers', () => {
describe('currentFocusedOption', () => {
beforeEach(() => {
createComponent();
});
it('when focused changes to existing element calls scroll into view on the newly focused element', async () => {
const focusedElement = findFirstDropdownItem().element;
const scrollSpy = jest.spyOn(focusedElement, 'scrollIntoView');
wrapper.setProps({ currentFocusedOption: MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0] });
await wrapper.vm.$nextTick();
expect(scrollSpy).toHaveBeenCalledWith(false);
scrollSpy.mockRestore();
});
});
});
});
......@@ -10,7 +10,7 @@ Vue.use(Vuex);
describe('HeaderSearchDefaultItems', () => {
let wrapper;
const createComponent = (initialState) => {
const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
searchContext: MOCK_SEARCH_CONTEXT,
......@@ -23,6 +23,9 @@ describe('HeaderSearchDefaultItems', () => {
wrapper = shallowMount(HeaderSearchDefaultItems, {
store,
propsData: {
...props,
},
});
};
......@@ -32,6 +35,7 @@ describe('HeaderSearchDefaultItems', () => {
const findDropdownHeader = () => wrapper.findComponent(GlDropdownSectionHeader);
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => w.text());
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
......@@ -77,5 +81,22 @@ describe('HeaderSearchDefaultItems', () => {
});
});
});
describe.each`
currentFocusedOption | isFocused
${null} | ${false}
${{ html_id: 'not-a-match' }} | ${false}
${MOCK_DEFAULT_SEARCH_OPTIONS[0]} | ${true}
`('isOptionFocused', ({ currentFocusedOption, isFocused }) => {
describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
beforeEach(() => {
createComponent({}, { currentFocusedOption });
});
it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
});
});
});
});
});
......@@ -11,7 +11,7 @@ Vue.use(Vuex);
describe('HeaderSearchScopedItems', () => {
let wrapper;
const createComponent = (initialState) => {
const createComponent = (initialState, props) => {
const store = new Vuex.Store({
state: {
search: MOCK_SEARCH,
......@@ -24,6 +24,9 @@ describe('HeaderSearchScopedItems', () => {
wrapper = shallowMount(HeaderSearchScopedItems, {
store,
propsData: {
...props,
},
});
};
......@@ -32,6 +35,7 @@ describe('HeaderSearchScopedItems', () => {
});
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text()));
const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href'));
......@@ -57,5 +61,22 @@ describe('HeaderSearchScopedItems', () => {
expect(findDropdownItemLinks()).toStrictEqual(expectedLinks);
});
});
describe.each`
currentFocusedOption | isFocused
${null} | ${false}
${{ html_id: 'not-a-match' }} | ${false}
${MOCK_SCOPED_SEARCH_OPTIONS[0]} | ${true}
`('isOptionFocused', ({ currentFocusedOption, isFocused }) => {
describe(`when currentFocusedOption.html_id is ${currentFocusedOption?.html_id}`, () => {
beforeEach(() => {
createComponent({}, { currentFocusedOption });
});
it(`should${isFocused ? '' : ' not'} have gl-bg-gray-50 applied`, () => {
expect(findFirstDropdownItem().classes('gl-bg-gray-50')).toBe(isFocused);
});
});
});
});
});
......@@ -46,22 +46,27 @@ export const MOCK_SEARCH_CONTEXT = {
export const MOCK_DEFAULT_SEARCH_OPTIONS = [
{
html_id: 'default-issues-assigned',
title: MSG_ISSUES_ASSIGNED_TO_ME,
url: `${MOCK_ISSUE_PATH}/?assignee_username=${MOCK_USERNAME}`,
},
{
html_id: 'default-issues-created',
title: MSG_ISSUES_IVE_CREATED,
url: `${MOCK_ISSUE_PATH}/?author_username=${MOCK_USERNAME}`,
},
{
html_id: 'default-mrs-assigned',
title: MSG_MR_ASSIGNED_TO_ME,
url: `${MOCK_MR_PATH}/?assignee_username=${MOCK_USERNAME}`,
},
{
html_id: 'default-mrs-reviewer',
title: MSG_MR_IM_REVIEWER,
url: `${MOCK_MR_PATH}/?reviewer_username=${MOCK_USERNAME}`,
},
{
html_id: 'default-mrs-created',
title: MSG_MR_IVE_CREATED,
url: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`,
},
......@@ -69,42 +74,75 @@ export const MOCK_DEFAULT_SEARCH_OPTIONS = [
export const MOCK_SCOPED_SEARCH_OPTIONS = [
{
html_id: 'scoped-in-project',
scope: MOCK_PROJECT.name,
description: MSG_IN_PROJECT,
url: MOCK_PROJECT.path,
},
{
html_id: 'scoped-in-group',
scope: MOCK_GROUP.name,
description: MSG_IN_GROUP,
url: MOCK_GROUP.path,
},
{
html_id: 'scoped-in-all',
description: MSG_IN_ALL_GITLAB,
url: MOCK_ALL_PATH,
},
];
export const MOCK_AUTOCOMPLETE_OPTIONS = [
export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [
{
category: 'Projects',
id: 1,
label: 'MockProject1',
url: 'project/1',
},
{
category: 'Groups',
id: 1,
label: 'MockGroup1',
url: 'group/1',
},
{
category: 'Projects',
id: 2,
label: 'MockProject2',
url: 'project/2',
},
{
category: 'Help',
label: 'GitLab Help',
url: 'help/gitlab',
},
];
export const MOCK_AUTOCOMPLETE_OPTIONS = [
{
category: 'Projects',
html_id: 'autocomplete-Projects-0',
id: 1,
label: 'MockProject1',
url: 'project/1',
},
{
category: 'Groups',
html_id: 'autocomplete-Groups-1',
id: 1,
label: 'MockGroup1',
url: 'group/1',
},
{
category: 'Projects',
html_id: 'autocomplete-Projects-2',
id: 2,
label: 'MockProject2',
url: 'project/2',
},
{
category: 'Help',
html_id: 'autocomplete-Help-3',
label: 'GitLab Help',
url: 'help/gitlab',
},
......@@ -116,12 +154,16 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
data: [
{
category: 'Projects',
html_id: 'autocomplete-Projects-0',
id: 1,
label: 'MockProject1',
url: 'project/1',
},
{
category: 'Projects',
html_id: 'autocomplete-Projects-2',
id: 2,
label: 'MockProject2',
url: 'project/2',
......@@ -133,6 +175,8 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
data: [
{
category: 'Groups',
html_id: 'autocomplete-Groups-1',
id: 1,
label: 'MockGroup1',
url: 'group/1',
......@@ -144,9 +188,41 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [
data: [
{
category: 'Help',
html_id: 'autocomplete-Help-3',
label: 'GitLab Help',
url: 'help/gitlab',
},
],
},
];
export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [
{
category: 'Projects',
html_id: 'autocomplete-Projects-0',
id: 1,
label: 'MockProject1',
url: 'project/1',
},
{
category: 'Projects',
html_id: 'autocomplete-Projects-2',
id: 2,
label: 'MockProject2',
url: 'project/2',
},
{
category: 'Groups',
html_id: 'autocomplete-Groups-1',
id: 1,
label: 'MockGroup1',
url: 'group/1',
},
{
category: 'Help',
html_id: 'autocomplete-Help-3',
label: 'GitLab Help',
url: 'help/gitlab',
},
];
......@@ -5,7 +5,7 @@ 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 axios from '~/lib/utils/axios_utils';
import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data';
import { MOCK_SEARCH, MOCK_AUTOCOMPLETE_OPTIONS_RES } from '../mock_data';
jest.mock('~/flash');
......@@ -29,9 +29,9 @@ describe('Header Search Store Actions', () => {
});
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}
axiosMock | type | expectedMutations | flashCallCount
${{ method: 'onGet', code: 200, res: MOCK_AUTOCOMPLETE_OPTIONS_RES }} | ${'success'} | ${[{ type: types.REQUEST_AUTOCOMPLETE }, { type: types.RECEIVE_AUTOCOMPLETE_SUCCESS, payload: MOCK_AUTOCOMPLETE_OPTIONS_RES }]} | ${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(() => {
......
......@@ -15,6 +15,7 @@ import {
MOCK_SEARCH,
MOCK_AUTOCOMPLETE_OPTIONS,
MOCK_GROUPED_AUTOCOMPLETE_OPTIONS,
MOCK_SORTED_AUTOCOMPLETE_OPTIONS,
} from '../mock_data';
describe('Header Search Store Getters', () => {
......@@ -248,4 +249,44 @@ describe('Header Search Store Getters', () => {
);
});
});
describe.each`
search | defaultSearchOptions | scopedSearchOptions | autocompleteGroupedSearchOptions | expectedArray
${null} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_DEFAULT_SEARCH_OPTIONS}
${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${[]} | ${MOCK_SCOPED_SEARCH_OPTIONS}
${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${[]} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS}
${MOCK_SEARCH} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS} | ${MOCK_SCOPED_SEARCH_OPTIONS.concat(MOCK_SORTED_AUTOCOMPLETE_OPTIONS)}
`(
'searchOptions',
({
search,
defaultSearchOptions,
scopedSearchOptions,
autocompleteGroupedSearchOptions,
expectedArray,
}) => {
describe(`when search is ${search} and the defaultSearchOptions${
defaultSearchOptions.length ? '' : ' do not'
} exist, scopedSearchOptions${
scopedSearchOptions.length ? '' : ' do not'
} exist, and autocompleteGroupedSearchOptions${
autocompleteGroupedSearchOptions.length ? '' : ' do not'
} exist`, () => {
const mockGetters = {
defaultSearchOptions,
scopedSearchOptions,
autocompleteGroupedSearchOptions,
};
beforeEach(() => {
createState();
state.search = search;
});
it(`should return the correct combined array`, () => {
expect(getters.searchOptions(state, mockGetters)).toStrictEqual(expectedArray);
});
});
},
);
});
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, MOCK_AUTOCOMPLETE_OPTIONS } from '../mock_data';
import {
MOCK_SEARCH,
MOCK_AUTOCOMPLETE_OPTIONS_RES,
MOCK_AUTOCOMPLETE_OPTIONS,
} from '../mock_data';
describe('Header Search Store Mutations', () => {
let state;
......@@ -20,8 +24,8 @@ describe('Header Search Store Mutations', () => {
});
describe('RECEIVE_AUTOCOMPLETE_SUCCESS', () => {
it('sets loading to false and sets autocompleteOptions array', () => {
mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS);
it('sets loading to false and then formats and sets the autocompleteOptions array', () => {
mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS_RES);
expect(state.loading).toBe(false);
expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS);
......
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