import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mockTracking } from 'helpers/tracking_helper'; import { s__, sprintf } from '~/locale'; 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 { SEARCH_INPUT_DESCRIPTION, SEARCH_RESULTS_DESCRIPTION, SEARCH_BOX_INDEX, ICON_PROJECT, ICON_GROUP, ICON_SUBGROUP, SCOPE_TOKEN_MAX_LENGTH, IS_SEARCHING, IS_NOT_FOCUSED, IS_FOCUSED, SEARCH_SHORTCUTS_MIN_CHARACTERS, } from '~/header_search/constants'; 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 { truncate } from '~/lib/utils/text_utility'; import { MOCK_SEARCH, MOCK_SEARCH_QUERY, MOCK_USERNAME, MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, MOCK_SEARCH_CONTEXT_FULL, } from '../mock_data'; Vue.use(Vuex); jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), })); describe('HeaderSearchApp', () => { let wrapper; const actionSpies = { setSearch: jest.fn(), fetchAutocompleteOptions: jest.fn(), clearAutocomplete: jest.fn(), }; const createComponent = (initialState, mockGetters) => { const store = new Vuex.Store({ state: { ...initialState, }, actions: actionSpies, getters: { searchQuery: () => MOCK_SEARCH_QUERY, searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, ...mockGetters, }, }); wrapper = shallowMountExtended(HeaderSearchApp, { store, }); }; const formatScopeName = (scopeName) => { if (!scopeName) { return false; } const searchResultsScope = s__('GlobalSearch|in %{scope}'); return truncate( sprintf(searchResultsScope, { scope: scopeName, }), SCOPE_TOKEN_MAX_LENGTH, ); }; afterEach(() => { wrapper.destroy(); }); const findHeaderSearchForm = () => wrapper.findByTestId('header-search-form'); const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType); const findScopeToken = () => wrapper.findComponent(GlToken); const findHeaderSearchInputKBD = () => wrapper.find('.keyboard-shortcut-helper'); const findHeaderSearchDropdown = () => wrapper.findByTestId('header-search-dropdown-menu'); const findHeaderSearchDefaultItems = () => wrapper.findComponent(HeaderSearchDefaultItems); const findHeaderSearchScopedItems = () => wrapper.findComponent(HeaderSearchScopedItems); const findHeaderSearchAutocompleteItems = () => wrapper.findComponent(HeaderSearchAutocompleteItems); const findDropdownKeyboardNavigation = () => wrapper.findComponent(DropdownKeyboardNavigation); const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`); const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION); describe('template', () => { describe('always renders', () => { beforeEach(() => { createComponent(); }); it('Header Search Input', () => { expect(findHeaderSearchInput().exists()).toBe(true); }); it('Header Search Input KBD hint', () => { expect(findHeaderSearchInputKBD().exists()).toBe(true); expect(findHeaderSearchInputKBD().text()).toContain('/'); expect(findHeaderSearchInputKBD().attributes('title')).toContain( 'Use the shortcut key / to start a search', ); }); it('Search Input Description', () => { expect(findSearchInputDescription().exists()).toBe(true); }); it('Search Results Description', () => { expect(findSearchResultsDescription().exists()).toBe(true); }); }); describe.each` showDropdown | username | showSearchDropdown ${false} | ${null} | ${false} ${false} | ${MOCK_USERNAME} | ${false} ${true} | ${null} | ${false} ${true} | ${MOCK_USERNAME} | ${true} `('Header Search Dropdown', ({ showDropdown, username, showSearchDropdown }) => { describe(`when showDropdown is ${showDropdown} and current_username is ${username}`, () => { beforeEach(() => { window.gon.current_username = username; createComponent(); findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); }); it(`should${showSearchDropdown ? '' : ' not'} render`, () => { expect(findHeaderSearchDropdown().exists()).toBe(showSearchDropdown); }); }); }); describe.each` search | showDefault | showScoped | showAutocomplete ${null} | ${true} | ${false} | ${false} ${''} | ${true} | ${false} | ${false} ${'t'} | ${false} | ${false} | ${true} ${'te'} | ${false} | ${false} | ${true} ${'tes'} | ${false} | ${true} | ${true} ${MOCK_SEARCH} | ${false} | ${true} | ${true} `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => { 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 render the Dropdown Navigation Component`, () => { expect(findDropdownKeyboardNavigation().exists()).toBe(true); }); it(`should close the dropdown when press escape key`, async () => { findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: 27 })); await nextTick(); expect(findHeaderSearchDropdown().exists()).toBe(false); expect(wrapper.emitted().expandSearchBar.length).toBe(1); }); }); }); describe.each` username | showDropdown | expectedDesc ${null} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown} ${null} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByNoDropdown} ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown} ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.searchInputDescribeByWithDropdown} `('Search Input Description', ({ username, showDropdown, expectedDesc }) => { describe(`current_username is ${username} and showDropdown is ${showDropdown}`, () => { beforeEach(() => { window.gon.current_username = username; createComponent(); findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); }); it(`sets description to ${expectedDesc}`, () => { expect(findSearchInputDescription().text()).toBe(expectedDesc); }); }); }); describe.each` username | showDropdown | search | loading | searchOptions | expectedDesc ${null} | ${true} | ${''} | ${false} | ${[]} | ${''} ${MOCK_USERNAME} | ${false} | ${''} | ${false} | ${[]} | ${''} ${MOCK_USERNAME} | ${true} | ${''} | ${false} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} ${MOCK_USERNAME} | ${true} | ${''} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${`${MOCK_DEFAULT_SEARCH_OPTIONS.length} default results provided. Use the up and down arrow keys to navigate search results list.`} ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${false} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${`Results updated. ${MOCK_SCOPED_SEARCH_OPTIONS.length} results available. Use the up and down arrow keys to navigate search results list, or ENTER to submit.`} ${MOCK_USERNAME} | ${true} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${HeaderSearchApp.i18n.searchResultsLoading} `( 'Search Results Description', ({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => { describe(`search is "${search}", loading is ${loading}, and showSearchDropdown is ${showDropdown}`, () => { beforeEach(() => { window.gon.current_username = username; createComponent( { search, loading, }, { searchOptions: () => searchOptions, }, ); findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); }); it(`sets description to ${expectedDesc}`, () => { expect(findSearchResultsDescription().text()).toBe(expectedDesc); }); }); }, ); describe('input box', () => { describe.each` search | searchOptions | hasToken ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[1]]} | ${true} ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${true} ${'te'} | ${[MOCK_SCOPED_SEARCH_OPTIONS[5]]} | ${false} ${'x'} | ${[]} | ${false} `('token', ({ search, searchOptions, hasToken }) => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; createComponent( { search }, { searchOptions: () => searchOptions, }, ); findHeaderSearchInput().vm.$emit('click'); }); it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${ searchOptions[0]?.html_id }"`, () => { expect(findScopeToken().exists()).toBe(hasToken); }); it(`text ${hasToken ? 'is correctly' : 'is NOT'} rendered when text is "${ searchOptions[0]?.scope || searchOptions[0]?.description }"`, () => { expect(findScopeToken().exists() && findScopeToken().text()).toBe( formatScopeName(searchOptions[0]?.scope || searchOptions[0]?.description), ); }); }); }); describe('form', () => { describe.each` searchContext | search | searchOptions | isFocused ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]} | ${true} ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]} | ${true} ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${false} ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true} ${null} | ${null} | ${[]} | ${true} `('wrapper', ({ searchContext, search, searchOptions, isFocused }) => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; createComponent({ search, searchContext }, { searchOptions: () => searchOptions }); if (isFocused) { findHeaderSearchInput().vm.$emit('click'); } }); const isSearching = search?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS; it(`classes ${isSearching ? 'contain' : 'do not contain'} "${IS_SEARCHING}"`, () => { if (isSearching) { expect(findHeaderSearchForm().classes()).toContain(IS_SEARCHING); return; } if (!isSearching) { expect(findHeaderSearchForm().classes()).not.toContain(IS_SEARCHING); } }); it(`classes ${isSearching ? 'contain' : 'do not contain'} "${ isFocused ? IS_FOCUSED : IS_NOT_FOCUSED }"`, () => { expect(findHeaderSearchForm().classes()).toContain( isFocused ? IS_FOCUSED : IS_NOT_FOCUSED, ); }); }); }); describe.each` search | searchOptions | hasIcon | iconName ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[0]]} | ${true} | ${ICON_PROJECT} ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[2]]} | ${true} | ${ICON_GROUP} ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[3]]} | ${true} | ${ICON_SUBGROUP} ${MOCK_SEARCH} | ${[MOCK_SCOPED_SEARCH_OPTIONS[4]]} | ${false} | ${false} `('token', ({ search, searchOptions, hasIcon, iconName }) => { beforeEach(() => { window.gon.current_username = MOCK_USERNAME; createComponent( { search }, { searchOptions: () => searchOptions, }, ); findHeaderSearchInput().vm.$emit('click'); }); it(`icon for data set type "${searchOptions[0]?.html_id}" ${ hasIcon ? 'is' : 'is NOT' } rendered`, () => { expect(findScopeToken().findComponent(GlIcon).exists()).toBe(hasIcon); }); it(`render ${iconName ? `"${iconName}"` : 'NO'} icon for data set type "${ searchOptions[0]?.html_id }"`, () => { expect( findScopeToken().findComponent(GlIcon).exists() && findScopeToken().findComponent(GlIcon).attributes('name'), ).toBe(iconName); }); }); }); describe('events', () => { beforeEach(() => { createComponent(); window.gon.current_username = MOCK_USERNAME; }); describe('Header Search Input', () => { describe('when dropdown is closed', () => { let trackingSpy; beforeEach(() => { trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }); it('onFocus opens dropdown and triggers snowplow event', async () => { expect(findHeaderSearchDropdown().exists()).toBe(false); findHeaderSearchInput().vm.$emit('focus'); await nextTick(); expect(findHeaderSearchDropdown().exists()).toBe(true); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { label: 'global_search', property: 'navigation_top', }); }); it('onClick opens dropdown and triggers snowplow event', async () => { expect(findHeaderSearchDropdown().exists()).toBe(false); findHeaderSearchInput().vm.$emit('click'); await nextTick(); expect(findHeaderSearchDropdown().exists()).toBe(true); expect(trackingSpy).toHaveBeenCalledWith(undefined, 'focus_input', { label: 'global_search', property: 'navigation_top', }); }); it('onClick followed by onFocus only triggers a single snowplow event', async () => { findHeaderSearchInput().vm.$emit('click'); findHeaderSearchInput().vm.$emit('focus'); expect(trackingSpy).toHaveBeenCalledTimes(1); }); }); describe('onInput', () => { describe('when search has text', () => { beforeEach(() => { findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); }); it('calls setSearch with search term', () => { expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), MOCK_SEARCH); }); it('calls fetchAutocompleteOptions', () => { expect(actionSpies.fetchAutocompleteOptions).toHaveBeenCalled(); }); it('does not call clearAutocomplete', () => { expect(actionSpies.clearAutocomplete).not.toHaveBeenCalled(); }); }); describe('when search is emptied', () => { beforeEach(() => { findHeaderSearchInput().vm.$emit('input', ''); }); it('calls setSearch with empty term', () => { expect(actionSpies.setSearch).toHaveBeenCalledWith(expect.any(Object), ''); }); it('does not call fetchAutocompleteOptions', () => { expect(actionSpies.fetchAutocompleteOptions).not.toHaveBeenCalled(); }); it('calls clearAutocomplete', () => { expect(actionSpies.clearAutocomplete).toHaveBeenCalled(); }); }); }); }); describe('Dropdown Keyboard Navigation', () => { beforeEach(() => { findHeaderSearchInput().vm.$emit('click'); }); it('closes dropdown when @tab is emitted', async () => { expect(findHeaderSearchDropdown().exists()).toBe(true); findDropdownKeyboardNavigation().vm.$emit('tab'); await nextTick(); expect(findHeaderSearchDropdown().exists()).toBe(false); }); }); }); describe('computed', () => { describe.each` MOCK_INDEX | search ${1} | ${null} ${SEARCH_BOX_INDEX} | ${'test'} ${2} | ${'test1'} `('currentFocusedOption', ({ MOCK_INDEX, search }) => { beforeEach(() => { createComponent({ search }); window.gon.current_username = MOCK_USERNAME; findHeaderSearchInput().vm.$emit('click'); }); it(`when currentFocusIndex changes to ${MOCK_INDEX} updates the data to searchOptions[${MOCK_INDEX}]`, () => { findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); 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', () => { findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); }); }); describe('with less than min characters and no dropdown results', () => { beforeEach(() => { createComponent({ search: 'x' }); }); it('onKey-enter will NOT submit a search', () => { findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); expect(visitUrl).not.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', () => { findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url); }); }); }); });