diff options
Diffstat (limited to 'spec/frontend/super_sidebar/components')
26 files changed, 1468 insertions, 684 deletions
diff --git a/spec/frontend/super_sidebar/components/context_switcher_spec.js b/spec/frontend/super_sidebar/components/context_switcher_spec.js index 538e87cf843..11da6ef1243 100644 --- a/spec/frontend/super_sidebar/components/context_switcher_spec.js +++ b/spec/frontend/super_sidebar/components/context_switcher_spec.js @@ -1,10 +1,11 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import { GlSearchBoxByType } from '@gitlab/ui'; +import { GlSearchBoxByType, GlLoadingIcon, GlAlert } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { s__ } from '~/locale'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ContextSwitcher from '~/super_sidebar/components/context_switcher.vue'; +import NavItem from '~/super_sidebar/components/nav_item.vue'; import ProjectsList from '~/super_sidebar/components/projects_list.vue'; import GroupsList from '~/super_sidebar/components/groups_list.vue'; import createMockApollo from 'helpers/mock_apollo_helper'; @@ -22,7 +23,11 @@ jest.mock('~/super_sidebar/utils', () => ({ .formatContextSwitcherItems, trackContextAccess: jest.fn(), })); +const focusInputMock = jest.fn(); +const persistentLinks = [ + { title: 'Explore', link: '/explore', icon: 'compass', link_classes: 'persistent-link-class' }, +]; const username = 'root'; const projectsPath = 'projectsPath'; const groupsPath = 'groupsPath'; @@ -33,9 +38,12 @@ describe('ContextSwitcher component', () => { let wrapper; let mockApollo; + const findNavItems = () => wrapper.findAllComponents(NavItem); const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); const findProjectsList = () => wrapper.findComponent(ProjectsList); const findGroupsList = () => wrapper.findComponent(GroupsList); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); + const findAlert = () => wrapper.findComponent(GlAlert); const triggerSearchQuery = async () => { findSearchBox().vm.$emit('input', 'foo'); @@ -60,6 +68,7 @@ describe('ContextSwitcher component', () => { wrapper = shallowMountExtended(ContextSwitcher, { apolloProvider: mockApollo, propsData: { + persistentLinks, username, projectsPath, groupsPath, @@ -68,6 +77,7 @@ describe('ContextSwitcher component', () => { stubs: { GlSearchBoxByType: stubComponent(GlSearchBoxByType, { props: ['placeholder'], + methods: { focusInput: focusInputMock }, }), ProjectsList: stubComponent(ProjectsList, { props: ['username', 'viewAllLink', 'isSearch', 'searchResults'], @@ -84,9 +94,20 @@ describe('ContextSwitcher component', () => { createWrapper(); }); + it('renders the persistent links', () => { + const navItems = findNavItems(); + const firstNavItem = navItems.at(0); + + expect(navItems.length).toBe(persistentLinks.length); + expect(firstNavItem.props('item')).toBe(persistentLinks[0]); + expect(firstNavItem.props('linkClasses')).toEqual({ + [persistentLinks[0].link_classes]: persistentLinks[0].link_classes, + }); + }); + it('passes the placeholder to the search box', () => { expect(findSearchBox().props('placeholder')).toBe( - s__('Navigation|Search for projects or groups'), + s__('Navigation|Search your projects or groups'), ); }); @@ -108,9 +129,22 @@ describe('ContextSwitcher component', () => { }); }); + it('focuses the search input when focusInput is called', () => { + wrapper.vm.focusInput(); + + expect(focusInputMock).toHaveBeenCalledTimes(1); + }); + it('does not trigger the search query on mount', () => { expect(searchUserProjectsAndGroupsHandlerSuccess).not.toHaveBeenCalled(); }); + + it('shows a loading spinner when search query is typed in', async () => { + findSearchBox().vm.$emit('input', 'foo'); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + }); }); describe('item access tracking', () => { @@ -138,10 +172,18 @@ describe('ContextSwitcher component', () => { return triggerSearchQuery(); }); + it('hides persistent links', () => { + expect(findNavItems().length).toBe(0); + }); + it('triggers the search query on search', () => { expect(searchUserProjectsAndGroupsHandlerSuccess).toHaveBeenCalled(); }); + it('hides the loading spinner', () => { + expect(findLoadingIcon().exists()).toBe(false); + }); + it('passes the projects to the frequent projects list', () => { expect(findProjectsList().props('isSearch')).toBe(true); expect(findProjectsList().props('searchResults')).toEqual( @@ -192,7 +234,7 @@ describe('ContextSwitcher component', () => { jest.spyOn(Sentry, 'captureException'); }); - it('captures exception if response is formatted incorrectly', async () => { + it('captures exception and shows an alert if response is formatted incorrectly', async () => { createWrapper({ requestHandlers: { searchUserProjectsAndGroupsQueryHandler: jest.fn().mockResolvedValue({ @@ -203,9 +245,10 @@ describe('ContextSwitcher component', () => { await triggerSearchQuery(); expect(Sentry.captureException).toHaveBeenCalled(); + expect(findAlert().exists()).toBe(true); }); - it('captures exception if query fails', async () => { + it('captures exception and shows an alert if query fails', async () => { createWrapper({ requestHandlers: { searchUserProjectsAndGroupsQueryHandler: jest.fn().mockRejectedValue(), @@ -214,6 +257,7 @@ describe('ContextSwitcher component', () => { await triggerSearchQuery(); expect(Sentry.captureException).toHaveBeenCalled(); + expect(findAlert().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/super_sidebar/components/create_menu_spec.js b/spec/frontend/super_sidebar/components/create_menu_spec.js index b24c6b8de7f..e05b5d30e69 100644 --- a/spec/frontend/super_sidebar/components/create_menu_spec.js +++ b/spec/frontend/super_sidebar/components/create_menu_spec.js @@ -1,3 +1,4 @@ +import { nextTick } from 'vue'; import { GlDisclosureDropdown, GlTooltip } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { __ } from '~/locale'; @@ -23,6 +24,14 @@ describe('CreateMenu component', () => { createWrapper(); }); + it('passes popper options to the dropdown', () => { + createWrapper(); + + expect(findGlDisclosureDropdown().props('popperOptions')).toEqual({ + modifiers: [{ name: 'offset', options: { offset: [-147, 4] } }], + }); + }); + it("sets the toggle's label", () => { expect(findGlDisclosureDropdown().props('toggleText')).toBe(__('Create new...')); }); @@ -35,5 +44,20 @@ describe('CreateMenu component', () => { expect(findGlDisclosureDropdown().props('toggleId')).toBe(wrapper.vm.$options.toggleId); expect(findGlTooltip().props('target')).toBe(`#${wrapper.vm.$options.toggleId}`); }); + + it('hides the tooltip when the dropdown is opened', async () => { + findGlDisclosureDropdown().vm.$emit('shown'); + await nextTick(); + + expect(findGlTooltip().exists()).toBe(false); + }); + + it('shows the tooltip when the dropdown is closed', async () => { + findGlDisclosureDropdown().vm.$emit('shown'); + findGlDisclosureDropdown().vm.$emit('hidden'); + await nextTick(); + + expect(findGlTooltip().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js index 1e98db091f2..86cec3f3d13 100644 --- a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js +++ b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js @@ -1,3 +1,4 @@ +import { GlIcon, GlButton } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { s__ } from '~/locale'; import FrequentItemsList from '~/super_sidebar/components//frequent_items_list.vue'; @@ -16,6 +17,7 @@ describe('FrequentItemsList component', () => { let wrapper; const findListTitle = () => wrapper.findByTestId('list-title'); + const findListEditButton = () => findListTitle().findComponent(GlButton); const findItemsList = () => wrapper.findComponent(ItemsList); const findEmptyText = () => wrapper.findByTestId('empty-text'); @@ -64,5 +66,38 @@ describe('FrequentItemsList component', () => { it('does not render the empty text slot', () => { expect(findEmptyText().exists()).toBe(false); }); + + describe('items editing', () => { + it('renders edit button within header', () => { + const itemsEditButton = findListEditButton(); + + expect(itemsEditButton.exists()).toBe(true); + expect(itemsEditButton.attributes('title')).toBe('Toggle edit mode'); + expect(itemsEditButton.findComponent(GlIcon).props('name')).toBe('pencil'); + }); + + it('clicking edit button makes items list editable', async () => { + // Off by default + expect(findItemsList().props('editable')).toBe(false); + + // On when clicked + await findListEditButton().vm.$emit('click'); + expect(findItemsList().props('editable')).toBe(true); + + // Off when clicked again + await findListEditButton().vm.$emit('click'); + expect(findItemsList().props('editable')).toBe(false); + }); + + it('remove-item event emission from items-list causes list item to be removed', async () => { + const localStorageProjects = findItemsList().props('items'); + await findListEditButton().vm.$emit('click'); + + await findItemsList().vm.$emit('remove-item', localStorageProjects[0]); + + expect(findItemsList().props('items')).toHaveLength(maxItems - 1); + expect(findItemsList().props('items')).not.toContain(localStorageProjects[0]); + }); + }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js index e5ba1c63996..aac321bd8e0 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_autocomplete_items_spec.js @@ -1,31 +1,24 @@ -import { GlDropdownItem, GlLoadingIcon, GlAvatar, GlAlert, GlDropdownDivider } from '@gitlab/ui'; +import { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + GlLoadingIcon, + GlAvatar, + GlAlert, +} from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; import Vuex from 'vuex'; -import HeaderSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; -import { - LARGE_AVATAR_PX, - SMALL_AVATAR_PX, -} from '~/super_sidebar/components/global_search/constants'; -import { - PROJECTS_CATEGORY, - GROUPS_CATEGORY, - ISSUES_CATEGORY, - MERGE_REQUEST_CATEGORY, - RECENT_EPICS_CATEGORY, -} from '~/vue_shared/global_search/constants'; +import GlobalSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; + import { MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP, - MOCK_SEARCH, - MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2, } from '../mock_data'; Vue.use(Vuex); -describe('HeaderSearchAutocompleteItems', () => { +describe('GlobalSearchAutocompleteItems', () => { let wrapper; const createComponent = (initialState, mockGetters, props) => { @@ -36,30 +29,34 @@ describe('HeaderSearchAutocompleteItems', () => { }, getters: { autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, + scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, ...mockGetters, }, }); - wrapper = shallowMount(HeaderSearchAutocompleteItems, { + wrapper = shallowMount(GlobalSearchAutocompleteItems, { store, propsData: { ...props, }, + stubs: { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + }, }); }; - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findGlDropdownDividers = () => wrapper.findAllComponents(GlDropdownDivider); - const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => - findDropdownItems().wrappers.map((w) => w.findAll('span').at(1).text()); - const findDropdownItemSubTitles = () => - findDropdownItems() - .wrappers.filter((w) => w.findAll('span').length > 2) - .map((w) => w.findAll('span').at(2).text()); - const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); + const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findItemTitles = () => + findItems().wrappers.map((w) => w.find('[data-testid="autocomplete-item-name"]').text()); + const findItemSubTitles = () => + findItems() + .wrappers.map((w) => w.find('[data-testid="autocomplete-item-namespace"]')) + .filter((w) => w.exists()) + .map((w) => w.text()); + const findItemLinks = () => findItems().wrappers.map((w) => w.find('a').attributes('href')); const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findGlAvatar = () => wrapper.findComponent(GlAvatar); + const findAvatars = () => wrapper.findAllComponents(GlAvatar).wrappers.map((w) => w.props('src')); const findGlAlert = () => wrapper.findComponent(GlAlert); describe('template', () => { @@ -73,7 +70,7 @@ describe('HeaderSearchAutocompleteItems', () => { }); it('does not render autocomplete options', () => { - expect(findDropdownItems()).toHaveLength(0); + expect(findItems()).toHaveLength(0); }); }); @@ -86,6 +83,7 @@ describe('HeaderSearchAutocompleteItems', () => { expect(findGlAlert().exists()).toBe(true); }); }); + describe('when loading is false', () => { beforeEach(() => { createComponent({ loading: false }); @@ -95,143 +93,35 @@ describe('HeaderSearchAutocompleteItems', () => { expect(findGlLoadingIcon().exists()).toBe(false); }); - describe('Dropdown items', () => { + describe('Search results items', () => { it('renders item for each option in autocomplete option', () => { - expect(findDropdownItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length); + expect(findItems()).toHaveLength(MOCK_SORTED_AUTOCOMPLETE_OPTIONS.length); }); it('renders titles correctly', () => { - const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.label); - expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + const expectedTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.value || o.text); + expect(findItemTitles()).toStrictEqual(expectedTitles); }); it('renders sub-titles correctly', () => { const expectedSubTitles = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.filter((o) => o.value).map( - (o) => o.label, + (o) => o.namespace, ); - expect(findDropdownItemSubTitles()).toStrictEqual(expectedSubTitles); - }); - - it('renders links correctly', () => { - const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); - }); - }); - - describe.each` - item | showAvatar | avatarSize | searchContext | entityId | entityName - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 29 } }} | ${'29'} | ${''} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: '/123' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 12 } }} | ${'12'} | ${''} - ${{ data: [{ category: 'Help', avatar_url: '' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'0'} | ${''} - ${{ data: [{ category: 'Settings' }] }} | ${false} | ${false} | ${null} | ${false} | ${false} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'1'} | ${'test1'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'2'} | ${'test2'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'3'} | ${'test3'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'4'} | ${'test4'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'5'} | ${'test5'} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 6, group_name: 'test6' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'6'} | ${'test6'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 7, project_name: 'test7' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${null} | ${'7'} | ${'test7'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 8, project_name: 'test8' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'8'} | ${'test8'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 9, project_name: 'test9' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'9'} | ${'test9'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 10, group_name: 'test10' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${null} | ${'10'} | ${'test10'} - ${{ data: [{ category: GROUPS_CATEGORY, avatar_url: null, group_id: 11, group_name: 'test11' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ group: { id: 1, name: 'test1' } }} | ${'11'} | ${'test11'} - ${{ data: [{ category: PROJECTS_CATEGORY, avatar_url: null, project_id: 12, project_name: 'test12' }] }} | ${true} | ${String(LARGE_AVATAR_PX)} | ${{ project: { id: 2, name: 'test2' } }} | ${'12'} | ${'test12'} - ${{ data: [{ category: ISSUES_CATEGORY, avatar_url: null, project_id: 13, project_name: 'test13' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 3, name: 'test3' } }} | ${'13'} | ${'test13'} - ${{ data: [{ category: MERGE_REQUEST_CATEGORY, avatar_url: null, project_id: 14, project_name: 'test14' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ project: { id: 4, name: 'test4' } }} | ${'14'} | ${'test14'} - ${{ data: [{ category: RECENT_EPICS_CATEGORY, avatar_url: null, group_id: 15, group_name: 'test15' }] }} | ${true} | ${String(SMALL_AVATAR_PX)} | ${{ group: { id: 5, name: 'test5' } }} | ${'15'} | ${'test15'} - `('GlAvatar', ({ item, showAvatar, avatarSize, searchContext, entityId, entityName }) => { - describe(`when category is ${item.data[0].category} and avatar_url is ${item.data[0].avatar_url}`, () => { - beforeEach(() => { - createComponent({ searchContext }, { 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); - }); - - it(`should set avatar entityId to ${entityId}`, () => { - expect(findGlAvatar().exists() && findGlAvatar().attributes('entityid')).toBe(entityId); - }); - - it(`should set avatar entityName to ${entityName}`, () => { - expect(findGlAvatar().exists() && findGlAvatar().attributes('entityname')).toBe( - entityName, - ); - }); - }); - }); - }); - - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_SORTED_AUTOCOMPLETE_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - 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); + expect(findItemSubTitles()).toStrictEqual(expectedSubTitles); }); - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); + it('renders links correctly', () => { + const expectedLinks = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.href); + expect(findItemLinks()).toStrictEqual(expectedLinks); }); - }); - }); - describe.each` - search | items | dividerCount - ${null} | ${[]} | ${0} - ${''} | ${[]} | ${0} - ${'1'} | ${[]} | ${0} - ${')'} | ${[]} | ${0} - ${'t'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_SETTINGS_HELP} | ${1} - ${'te'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP} | ${0} - ${'tes'} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} - ${MOCK_SEARCH} | ${MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_2} | ${1} - `('Header Search Dropdown Dividers', ({ search, items, dividerCount }) => { - describe(`when search is ${search}`, () => { - beforeEach(() => { - createComponent( - { search }, - { - autocompleteGroupedSearchOptions: () => items, - }, - {}, + it('renders avatars', () => { + const expectedAvatars = MOCK_SORTED_AUTOCOMPLETE_OPTIONS.map((o) => o.avatar_url).filter( + Boolean, ); + expect(findAvatars()).toStrictEqual(expectedAvatars); }); - - it(`component should have ${dividerCount} dividers`, () => { - expect(findGlDropdownDividers()).toHaveLength(dividerCount); - }); - }); - }); - }); - - 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 nextTick(); - - expect(scrollSpy).toHaveBeenCalledWith(false); - scrollSpy.mockRestore(); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js index 132f8e60598..52e9aa52c14 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_items_spec.js @@ -1,13 +1,13 @@ -import { GlDropdownItem, GlDropdownSectionHeader } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; -import HeaderSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import GlobalSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS } from '../mock_data'; Vue.use(Vuex); -describe('HeaderSearchDefaultItems', () => { +describe('GlobalSearchDefaultItems', () => { let wrapper; const createComponent = (initialState, props) => { @@ -21,19 +21,19 @@ describe('HeaderSearchDefaultItems', () => { }, }); - wrapper = shallowMount(HeaderSearchDefaultItems, { + wrapper = shallowMountExtended(GlobalSearchDefaultItems, { store, propsData: { ...props, }, + stubs: { + GlDisclosureDropdownGroup, + }, }); }; - 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')); + const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findItemsData = () => findItems().wrappers.map((w) => w.props('item')); describe('template', () => { describe('Dropdown items', () => { @@ -42,26 +42,20 @@ describe('HeaderSearchDefaultItems', () => { }); it('renders item for each option in defaultSearchOptions', () => { - expect(findDropdownItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length); - }); - - it('renders titles correctly', () => { - const expectedTitles = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.title); - expect(findDropdownItemTitles()).toStrictEqual(expectedTitles); + expect(findItems()).toHaveLength(MOCK_DEFAULT_SEARCH_OPTIONS.length); }); - it('renders links correctly', () => { - const expectedLinks = MOCK_DEFAULT_SEARCH_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + it('provides the `item` prop to the `GlDisclosureDropdownItem` component', () => { + expect(findItemsData()).toStrictEqual(MOCK_DEFAULT_SEARCH_OPTIONS); }); }); describe.each` - group | project | dropdownTitle + group | project | groupHeader ${null} | ${null} | ${'All GitLab'} ${{ name: 'Test Group' }} | ${null} | ${'Test Group'} ${{ name: 'Test Group' }} | ${{ name: 'Test Project' }} | ${'Test Project'} - `('Dropdown Header', ({ group, project, dropdownTitle }) => { + `('Group Header', ({ group, project, groupHeader }) => { describe(`when group is ${group?.name} and project is ${project?.name}`, () => { beforeEach(() => { createComponent({ @@ -72,29 +66,8 @@ describe('HeaderSearchDefaultItems', () => { }); }); - it(`should render as ${dropdownTitle}`, () => { - expect(findDropdownHeader().text()).toBe(dropdownTitle); - }); - }); - }); - - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_DEFAULT_SEARCH_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - 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); - }); - - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); + it(`should render as ${groupHeader}`, () => { + expect(wrapper.text()).toContain(groupHeader); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js index fa91ef43ced..4976f3be4cd 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_scoped_items_spec.js @@ -1,21 +1,21 @@ -import { GlDropdownItem, GlToken, GlIcon } from '@gitlab/ui'; +import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlToken, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; import { trimText } from 'helpers/text_helper'; -import HeaderSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; +import GlobalSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; import { truncate } from '~/lib/utils/text_utility'; import { SCOPE_TOKEN_MAX_LENGTH } from '~/super_sidebar/components/global_search/constants'; import { MSG_IN_ALL_GITLAB } from '~/vue_shared/global_search/constants'; import { MOCK_SEARCH, - MOCK_SCOPED_SEARCH_OPTIONS, + MOCK_SCOPED_SEARCH_GROUP, MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, } from '../mock_data'; Vue.use(Vuex); -describe('HeaderSearchScopedItems', () => { +describe('GlobalSearchScopedItems', () => { let wrapper; const createComponent = (initialState, mockGetters, props) => { @@ -25,96 +25,67 @@ describe('HeaderSearchScopedItems', () => { ...initialState, }, getters: { - scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, + scopedSearchGroup: () => MOCK_SCOPED_SEARCH_GROUP, autocompleteGroupedSearchOptions: () => MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, ...mockGetters, }, }); - wrapper = shallowMount(HeaderSearchScopedItems, { + wrapper = shallowMount(GlobalSearchScopedItems, { store, propsData: { ...props, }, + stubs: { + GlDisclosureDropdownGroup, + GlDisclosureDropdownItem, + }, }); }; - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findFirstDropdownItem = () => findDropdownItems().at(0); - const findDropdownItemTitles = () => findDropdownItems().wrappers.map((w) => trimText(w.text())); + const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findItemsText = () => findItems().wrappers.map((w) => trimText(w.text())); const findScopeTokens = () => wrapper.findAllComponents(GlToken); const findScopeTokensText = () => findScopeTokens().wrappers.map((w) => trimText(w.text())); const findScopeTokensIcons = () => findScopeTokens().wrappers.map((w) => w.findAllComponents(GlIcon)); - const findDropdownItemAriaLabels = () => - findDropdownItems().wrappers.map((w) => trimText(w.attributes('aria-label'))); - const findDropdownItemLinks = () => findDropdownItems().wrappers.map((w) => w.attributes('href')); - - describe('template', () => { - describe('Dropdown items', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders item for each option in scopedSearchOptions', () => { - expect(findDropdownItems()).toHaveLength(MOCK_SCOPED_SEARCH_OPTIONS.length); - }); + const findItemLinks = () => findItems().wrappers.map((w) => w.find('a').attributes('href')); - it('renders titles correctly', () => { - findDropdownItemTitles().forEach((title) => expect(title).toContain(MOCK_SEARCH)); - }); - - it('renders scope names correctly', () => { - const expectedTitles = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => - truncate(trimText(`in ${o.description || o.scope}`), SCOPE_TOKEN_MAX_LENGTH), - ); + describe('Search results scoped items', () => { + beforeEach(() => { + createComponent(); + }); - expect(findScopeTokensText()).toStrictEqual(expectedTitles); - }); + it('renders item for each item in scopedSearchGroup', () => { + expect(findItems()).toHaveLength(MOCK_SCOPED_SEARCH_GROUP.items.length); + }); - it('renders scope icons correctly', () => { - findScopeTokensIcons().forEach((icon, i) => { - const w = icon.wrappers[0]; - expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_OPTIONS[i].icon); - }); - }); + it('renders titles correctly', () => { + findItemsText().forEach((title) => expect(title).toContain(MOCK_SEARCH)); + }); - it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => { - expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false); - }); + it('renders scope names correctly', () => { + const expectedTitles = MOCK_SCOPED_SEARCH_GROUP.items.map((o) => + truncate(trimText(`in ${o.scope || o.description}`), SCOPE_TOKEN_MAX_LENGTH), + ); - it('renders aria-labels correctly', () => { - const expectedLabels = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => - trimText(`${MOCK_SEARCH} ${o.description || o.icon} ${o.scope || ''}`), - ); - expect(findDropdownItemAriaLabels()).toStrictEqual(expectedLabels); - }); + expect(findScopeTokensText()).toStrictEqual(expectedTitles); + }); - it('renders links correctly', () => { - const expectedLinks = MOCK_SCOPED_SEARCH_OPTIONS.map((o) => o.url); - expect(findDropdownItemLinks()).toStrictEqual(expectedLinks); + it('renders scope icons correctly', () => { + findScopeTokensIcons().forEach((icon, i) => { + const w = icon.wrappers[0]; + expect(w?.attributes('name')).toBe(MOCK_SCOPED_SEARCH_GROUP.items[i].icon); }); }); - describe.each` - currentFocusedOption | isFocused | ariaSelected - ${null} | ${false} | ${undefined} - ${{ html_id: 'not-a-match' }} | ${false} | ${undefined} - ${MOCK_SCOPED_SEARCH_OPTIONS[0]} | ${true} | ${'true'} - `('isOptionFocused', ({ currentFocusedOption, isFocused, ariaSelected }) => { - 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); - }); + it(`renders scope ${MSG_IN_ALL_GITLAB} correctly`, () => { + expect(findScopeTokens().at(-1).findComponent(GlIcon).exists()).toBe(false); + }); - it(`sets "aria-selected to ${ariaSelected}`, () => { - expect(findFirstDropdownItem().attributes('aria-selected')).toBe(ariaSelected); - }); - }); + it('renders links correctly', () => { + const expectedLinks = MOCK_SCOPED_SEARCH_GROUP.items.map((o) => o.href); + expect(findItemLinks()).toStrictEqual(expectedLinks); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js index 0dcfc448125..eb8801f68c6 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_spec.js @@ -1,30 +1,25 @@ import { GlSearchBoxByType, GlToken, GlIcon } from '@gitlab/ui'; -import Vue, { nextTick } from 'vue'; +import Vue 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 '~/super_sidebar/components/global_search/components/global_search.vue'; -import HeaderSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; -import HeaderSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; -import HeaderSearchScopedItems from '~/super_sidebar/components/global_search/components/global_search_scoped_items.vue'; +import GlobalSearchModal from '~/super_sidebar/components/global_search/components/global_search.vue'; +import GlobalSearchAutocompleteItems from '~/super_sidebar/components/global_search/components/global_search_autocomplete_items.vue'; +import GlobalSearchDefaultItems from '~/super_sidebar/components/global_search/components/global_search_default_items.vue'; +import GlobalSearchScopedItems from '~/super_sidebar/components/global_search/components/global_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 '~/super_sidebar/components/global_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 { visitUrl } from '~/lib/utils/url_utility'; +import { ENTER_KEY } from '~/lib/utils/keys'; import { MOCK_SEARCH, MOCK_SEARCH_QUERY, @@ -32,6 +27,8 @@ import { MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, MOCK_SEARCH_CONTEXT_FULL, + MOCK_PROJECT, + MOCK_GROUP, } from '../mock_data'; Vue.use(Vuex); @@ -40,7 +37,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn(), })); -describe('HeaderSearchApp', () => { +describe('GlobalSearchModal', () => { let wrapper; const actionSpies = { @@ -49,21 +46,31 @@ describe('HeaderSearchApp', () => { clearAutocomplete: jest.fn(), }; - const createComponent = (initialState, mockGetters) => { + const deafaultMockState = { + searchContext: { + project: MOCK_PROJECT, + group: MOCK_GROUP, + }, + }; + + const createComponent = (initialState, mockGetters, stubs) => { const store = new Vuex.Store({ state: { + ...deafaultMockState, ...initialState, }, actions: actionSpies, getters: { searchQuery: () => MOCK_SEARCH_QUERY, searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, + scopedSearchOptions: () => MOCK_SCOPED_SEARCH_OPTIONS, ...mockGetters, }, }); - wrapper = shallowMountExtended(HeaderSearchApp, { + wrapper = shallowMountExtended(GlobalSearchModal, { store, + stubs, }); }; @@ -80,16 +87,13 @@ describe('HeaderSearchApp', () => { ); }; - const findHeaderSearchForm = () => wrapper.findByTestId('header-search-form'); - const findHeaderSearchInput = () => wrapper.findComponent(GlSearchBoxByType); + const findGlobalSearchForm = () => wrapper.findByTestId('global-search-form'); + const findGlobalSearchInput = () => 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 findGlobalSearchDefaultItems = () => wrapper.findComponent(GlobalSearchDefaultItems); + const findGlobalSearchScopedItems = () => wrapper.findComponent(GlobalSearchScopedItems); + const findGlobalSearchAutocompleteItems = () => + wrapper.findComponent(GlobalSearchAutocompleteItems); const findSearchInputDescription = () => wrapper.find(`#${SEARCH_INPUT_DESCRIPTION}`); const findSearchResultsDescription = () => wrapper.findByTestId(SEARCH_RESULTS_DESCRIPTION); @@ -99,16 +103,8 @@ describe('HeaderSearchApp', () => { 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 <kbd>/</kbd> to start a search', - ); + it('Global Search Input', () => { + expect(findGlobalSearchInput().exists()).toBe(true); }); it('Search Input Description', () => { @@ -121,26 +117,6 @@ describe('HeaderSearchApp', () => { }); 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} @@ -148,71 +124,40 @@ describe('HeaderSearchApp', () => { ${'te'} | ${false} | ${false} | ${true} ${'tes'} | ${false} | ${true} | ${true} ${MOCK_SEARCH} | ${false} | ${true} | ${true} - `('Header Search Dropdown Items', ({ search, showDefault, showScoped, showAutocomplete }) => { + `('Global Search Result 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); + findGlobalSearchInput().vm.$emit('click'); }); - 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); + it(`should${showDefault ? '' : ' not'} render the Default Items`, () => { + expect(findGlobalSearchDefaultItems().exists()).toBe(showDefault); }); - }); - }); - describe.each` - username | showDropdown | expectedDesc - ${null} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN} - ${null} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_NO_DROPDOWN} - ${MOCK_USERNAME} | ${false} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN} - ${MOCK_USERNAME} | ${true} | ${HeaderSearchApp.i18n.SEARCH_INPUT_DESCRIBE_BY_WITH_DROPDOWN} - `('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(`should${showScoped ? '' : ' not'} render the Scoped Items`, () => { + expect(findGlobalSearchScopedItems().exists()).toBe(showScoped); }); - it(`sets description to ${expectedDesc}`, () => { - expect(findSearchInputDescription().text()).toBe(expectedDesc); + it(`should${showAutocomplete ? '' : ' not'} render the Autocomplete Items`, () => { + expect(findGlobalSearchAutocompleteItems().exists()).toBe(showAutocomplete); }); }); }); 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.SEARCH_RESULTS_LOADING} + username | search | loading | searchOptions | expectedDesc + ${null} | ${'gi'} | ${false} | ${[]} | ${GlobalSearchModal.i18n.MIN_SEARCH_TERM} + ${MOCK_USERNAME} | ${'gi'} | ${false} | ${[]} | ${GlobalSearchModal.i18n.MIN_SEARCH_TERM} + ${MOCK_USERNAME} | ${''} | ${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} | ${MOCK_SEARCH} | ${true} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${GlobalSearchModal.i18n.SEARCH_RESULTS_LOADING} + ${MOCK_USERNAME} | ${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} | ${MOCK_SEARCH} | ${true} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${GlobalSearchModal.i18n.SEARCH_RESULTS_LOADING} `( 'Search Results Description', - ({ username, showDropdown, search, loading, searchOptions, expectedDesc }) => { - describe(`search is "${search}", loading is ${loading}, and showSearchDropdown is ${showDropdown}`, () => { + ({ username, search, loading, searchOptions, expectedDesc }) => { + describe(`search is "${search}" and loading is ${loading}`, () => { beforeEach(() => { window.gon.current_username = username; createComponent( @@ -224,7 +169,6 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit(showDropdown ? 'click' : ''); }); it(`sets description to ${expectedDesc}`, () => { @@ -253,7 +197,7 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit('click'); + findGlobalSearchInput().vm.$emit('click'); }); it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${ @@ -274,42 +218,31 @@ describe('HeaderSearchApp', () => { 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 }) => { + searchContext | search | searchOptions + ${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]} + ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]} + ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS} + ${null} | ${null} | ${[]} + `('wrapper', ({ searchContext, search, searchOptions }) => { 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); + expect(findGlobalSearchForm().classes()).toContain(IS_SEARCHING); return; } if (!isSearching) { - expect(findHeaderSearchForm().classes()).not.toContain(IS_SEARCHING); + expect(findGlobalSearchForm().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, - ); - }); }); }); @@ -328,7 +261,7 @@ describe('HeaderSearchApp', () => { searchOptions: () => searchOptions, }, ); - findHeaderSearchInput().vm.$emit('click'); + findGlobalSearchInput().vm.$emit('click'); }); it(`icon for data set type "${searchOptions[0]?.html_id}" ${ @@ -350,56 +283,15 @@ describe('HeaderSearchApp', () => { describe('events', () => { beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; 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('Global Search Input', () => { describe('onInput', () => { describe('when search has text', () => { beforeEach(() => { - findHeaderSearchInput().vm.$emit('input', MOCK_SEARCH); + findGlobalSearchInput().vm.$emit('input', MOCK_SEARCH); }); it('calls setSearch with search term', () => { @@ -417,7 +309,7 @@ describe('HeaderSearchApp', () => { describe('when search is emptied', () => { beforeEach(() => { - findHeaderSearchInput().vm.$emit('input', ''); + findGlobalSearchInput().vm.$emit('input', ''); }); it('calls setSearch with empty term', () => { @@ -433,83 +325,29 @@ describe('HeaderSearchApp', () => { }); }); }); - }); - - 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(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent({ search }); - 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 })); + describe('Submitting a search', () => { + beforeEach(() => { + createComponent(); + }); - expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY); - }); - }); + it('onKey-enter submits a search', () => { + findGlobalSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - describe('with currentFocusedOption', () => { - const MOCK_INDEX = 1; + expect(visitUrl).toHaveBeenCalledWith(MOCK_SEARCH_QUERY); + }); - beforeEach(() => { - window.gon.current_username = MOCK_USERNAME; - createComponent(); - findHeaderSearchInput().vm.$emit('click'); - }); + describe('with less than min characters', () => { + beforeEach(() => { + createComponent({ search: 'x' }); + }); - it('onKey-enter clicks the selected dropdown item rather than submitting a search', () => { - findDropdownKeyboardNavigation().vm.$emit('change', MOCK_INDEX); + it('onKey-enter will NOT submit a search', () => { + findGlobalSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - expect(visitUrl).toHaveBeenCalledWith(MOCK_DEFAULT_SEARCH_OPTIONS[MOCK_INDEX].url); + expect(visitUrl).not.toHaveBeenCalledWith(MOCK_SEARCH_QUERY); + }); + }); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/mock_data.js b/spec/frontend/super_sidebar/components/global_search/mock_data.js index 58e578e4c4c..0884fce567c 100644 --- a/spec/frontend/super_sidebar/components/global_search/mock_data.js +++ b/spec/frontend/super_sidebar/components/global_search/mock_data.js @@ -3,6 +3,7 @@ import { ICON_GROUP, ICON_SUBGROUP, } from '~/super_sidebar/components/global_search/constants'; + import { PROJECTS_CATEGORY, GROUPS_CATEGORY, @@ -77,90 +78,107 @@ export const MOCK_SEARCH_CONTEXT_FULL = { 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}`, + text: MSG_ISSUES_ASSIGNED_TO_ME, + href: `${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}`, + text: MSG_ISSUES_IVE_CREATED, + href: `${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}`, + text: MSG_MR_ASSIGNED_TO_ME, + href: `${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}`, + text: MSG_MR_IM_REVIEWER, + href: `${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}`, + text: MSG_MR_IVE_CREATED, + href: `${MOCK_MR_PATH}/?author_username=${MOCK_USERNAME}`, }, ]; - -export const MOCK_SCOPED_SEARCH_OPTIONS = [ +export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [ { - html_id: 'scoped-in-project', + text: 'scoped-in-project', scope: MOCK_PROJECT.name, scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, - url: MOCK_PROJECT.path, - }, - { - html_id: 'scoped-in-project-long', - scope: MOCK_PROJECT_LONG.name, - scopeCategory: PROJECTS_CATEGORY, - icon: ICON_PROJECT, - url: MOCK_PROJECT_LONG.path, + href: MOCK_PROJECT.path, }, { - html_id: 'scoped-in-group', + text: 'scoped-in-group', scope: MOCK_GROUP.name, scopeCategory: GROUPS_CATEGORY, icon: ICON_GROUP, - url: MOCK_GROUP.path, - }, - { - html_id: 'scoped-in-subgroup', - scope: MOCK_SUBGROUP.name, - scopeCategory: GROUPS_CATEGORY, - icon: ICON_SUBGROUP, - url: MOCK_SUBGROUP.path, + href: MOCK_GROUP.path, }, { - html_id: 'scoped-in-all', + text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, - url: MOCK_ALL_PATH, + href: MOCK_ALL_PATH, }, ]; - -export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [ +export const MOCK_SCOPED_SEARCH_OPTIONS = [ { - html_id: 'scoped-in-project', + text: 'scoped-in-project', scope: MOCK_PROJECT.name, scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, url: MOCK_PROJECT.path, }, { - html_id: 'scoped-in-group', + text: 'scoped-in-project-long', + scope: MOCK_PROJECT_LONG.name, + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, + url: MOCK_PROJECT_LONG.path, + }, + { + text: 'scoped-in-group', scope: MOCK_GROUP.name, scopeCategory: GROUPS_CATEGORY, icon: ICON_GROUP, url: MOCK_GROUP.path, }, { - html_id: 'scoped-in-all', + text: 'scoped-in-subgroup', + scope: MOCK_SUBGROUP.name, + scopeCategory: GROUPS_CATEGORY, + icon: ICON_SUBGROUP, + url: MOCK_SUBGROUP.path, + }, + { + text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, url: MOCK_ALL_PATH, }, ]; +export const MOCK_SCOPED_SEARCH_GROUP = { + items: [ + { + text: 'scoped-in-project', + scope: MOCK_PROJECT.name, + scopeCategory: PROJECTS_CATEGORY, + icon: ICON_PROJECT, + href: MOCK_PROJECT.path, + }, + { + text: 'scoped-in-group', + scope: MOCK_GROUP.name, + scopeCategory: GROUPS_CATEGORY, + icon: ICON_GROUP, + href: MOCK_GROUP.path, + }, + { + text: 'scoped-in-all', + description: MSG_IN_ALL_GITLAB, + href: MOCK_ALL_PATH, + }, + ], +}; + export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ { category: 'Projects', @@ -168,8 +186,10 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ label: 'Gitlab Org / MockProject1', value: 'MockProject1', url: 'project/1', + avatar_url: '/project/avatar/1/avatar.png', }, { + avatar_url: '/groups/avatar/1/avatar.png', category: 'Groups', id: 1, label: 'Gitlab Org / MockGroup1', @@ -177,6 +197,7 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ url: 'group/1', }, { + avatar_url: '/project/avatar/2/avatar.png', category: 'Projects', id: 2, label: 'Gitlab Org / MockProject2', @@ -193,31 +214,30 @@ export const MOCK_AUTOCOMPLETE_OPTIONS_RES = [ export const MOCK_AUTOCOMPLETE_OPTIONS = [ { category: 'Projects', - html_id: 'autocomplete-Projects-0', id: 1, label: 'Gitlab Org / MockProject1', value: 'MockProject1', url: 'project/1', + avatar_url: '/project/avatar/1/avatar.png', }, { category: 'Groups', - html_id: 'autocomplete-Groups-1', id: 1, label: 'Gitlab Org / MockGroup1', value: 'MockGroup1', url: 'group/1', + avatar_url: '/groups/avatar/1/avatar.png', }, { category: 'Projects', - html_id: 'autocomplete-Projects-2', id: 2, label: 'Gitlab Org / MockProject2', value: 'MockProject2', url: 'project/2', + avatar_url: '/project/avatar/2/avatar.png', }, { category: 'Help', - html_id: 'autocomplete-Help-3', label: 'GitLab Help', url: 'help/gitlab', }, @@ -225,51 +245,64 @@ export const MOCK_AUTOCOMPLETE_OPTIONS = [ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ { - category: 'Groups', - data: [ + name: 'Groups', + items: [ { category: 'Groups', - html_id: 'autocomplete-Groups-1', - id: 1, label: 'Gitlab Org / MockGroup1', + namespace: 'Gitlab Org / MockGroup1', value: 'MockGroup1', - url: 'group/1', + text: 'MockGroup1', + href: 'group/1', + avatar_url: '/groups/avatar/1/avatar.png', + avatar_size: 32, + entity_id: 1, + entity_name: 'MockGroup1', }, ], }, { - category: 'Projects', - data: [ + name: 'Projects', + items: [ { category: 'Projects', - html_id: 'autocomplete-Projects-0', - id: 1, label: 'Gitlab Org / MockProject1', + namespace: 'Gitlab Org / MockProject1', value: 'MockProject1', - url: 'project/1', + text: 'MockProject1', + href: 'project/1', + avatar_url: '/project/avatar/1/avatar.png', + avatar_size: 32, + entity_id: 1, + entity_name: 'MockProject1', }, { category: 'Projects', - html_id: 'autocomplete-Projects-2', - id: 2, - label: 'Gitlab Org / MockProject2', value: 'MockProject2', - url: 'project/2', + label: 'Gitlab Org / MockProject2', + namespace: 'Gitlab Org / MockProject2', + text: 'MockProject2', + href: 'project/2', + avatar_url: '/project/avatar/2/avatar.png', + avatar_size: 32, + entity_id: 2, + entity_name: 'MockProject2', }, ], }, { - category: 'Help', - data: [ + name: 'Help', + items: [ { category: 'Help', - html_id: 'autocomplete-Help-3', - label: 'GitLab Help', - url: 'help/gitlab', + text: 'GitLab Help', + href: 'help/gitlab', + avatar_size: 16, + entity_name: 'GitLab Help', }, ], }, @@ -278,33 +311,50 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ { category: 'Groups', - html_id: 'autocomplete-Groups-1', id: 1, label: 'Gitlab Org / MockGroup1', value: 'MockGroup1', - url: 'group/1', + text: 'MockGroup1', + href: 'group/1', + namespace: 'Gitlab Org / MockGroup1', + avatar_url: '/groups/avatar/1/avatar.png', + avatar_size: 32, + entity_id: 1, + entity_name: 'MockGroup1', }, { + avatar_size: 32, + avatar_url: '/project/avatar/1/avatar.png', category: 'Projects', - html_id: 'autocomplete-Projects-0', + entity_id: 1, + entity_name: 'MockProject1', + href: 'project/1', id: 1, label: 'Gitlab Org / MockProject1', + namespace: 'Gitlab Org / MockProject1', + text: 'MockProject1', value: 'MockProject1', - url: 'project/1', }, { + avatar_size: 32, + avatar_url: '/project/avatar/2/avatar.png', category: 'Projects', - html_id: 'autocomplete-Projects-2', + entity_id: 2, + entity_name: 'MockProject2', + href: 'project/2', id: 2, label: 'Gitlab Org / MockProject2', + namespace: 'Gitlab Org / MockProject2', + text: 'MockProject2', value: 'MockProject2', - url: 'project/2', }, { + avatar_size: 16, + entity_name: 'GitLab Help', category: 'Help', - html_id: 'autocomplete-Help-3', label: 'GitLab Help', - url: 'help/gitlab', + text: 'GitLab Help', + href: 'help/gitlab', }, ]; @@ -315,14 +365,16 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS_HELP = [ { html_id: 'autocomplete-Help-1', category: 'Help', + text: 'Rake Tasks Help', label: 'Rake Tasks Help', - url: '/help/raketasks/index', + href: '/help/raketasks/index', }, { html_id: 'autocomplete-Help-2', category: 'Help', + text: 'System Hooks Help', label: 'System Hooks Help', - url: '/help/system_hooks/system_hooks', + href: '/help/system_hooks/system_hooks', }, ], }, diff --git a/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js b/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js index c87b4513309..f6d8e1f26eb 100644 --- a/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/store/actions_spec.js @@ -16,9 +16,7 @@ import { MOCK_ISSUE_PATH, } from '../mock_data'; -jest.mock('~/alert'); - -describe('Header Search Store Actions', () => { +describe('Global Search Store Actions', () => { let state; let mock; diff --git a/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js b/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js index dca96da01a7..68583d04b31 100644 --- a/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/store/getters_spec.js @@ -9,7 +9,7 @@ import { MOCK_SEARCH_CONTEXT, MOCK_DEFAULT_SEARCH_OPTIONS, MOCK_SCOPED_SEARCH_OPTIONS, - MOCK_SCOPED_SEARCH_OPTIONS_DEF, + MOCK_SCOPED_SEARCH_GROUP, MOCK_PROJECT, MOCK_GROUP, MOCK_ALL_PATH, @@ -17,9 +17,10 @@ import { MOCK_AUTOCOMPLETE_OPTIONS, MOCK_GROUPED_AUTOCOMPLETE_OPTIONS, MOCK_SORTED_AUTOCOMPLETE_OPTIONS, + MOCK_SCOPED_SEARCH_OPTIONS_DEF, } from '../mock_data'; -describe('Header Search Store Getters', () => { +describe('Global Search Store Getters', () => { let state; const createState = (initialState) => { @@ -288,7 +289,7 @@ 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} + ${null} | ${MOCK_DEFAULT_SEARCH_OPTIONS} | ${MOCK_SCOPED_SEARCH_GROUP} | ${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)} diff --git a/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js b/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js index d2dc484e825..4d275cf86c7 100644 --- a/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/store/mutations_spec.js @@ -29,7 +29,7 @@ describe('Header Search Store Mutations', () => { mutations[types.RECEIVE_AUTOCOMPLETE_SUCCESS](state, MOCK_AUTOCOMPLETE_OPTIONS_RES); expect(state.loading).toBe(false); - expect(state.autocompleteOptions).toStrictEqual(MOCK_AUTOCOMPLETE_OPTIONS); + expect(state.autocompleteOptions).toEqual(MOCK_AUTOCOMPLETE_OPTIONS); expect(state.autocompleteError).toBe(false); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/utils_spec.js b/spec/frontend/super_sidebar/components/global_search/utils_spec.js new file mode 100644 index 00000000000..3b12063e733 --- /dev/null +++ b/spec/frontend/super_sidebar/components/global_search/utils_spec.js @@ -0,0 +1,60 @@ +import { getFormattedItem } from '~/super_sidebar/components/global_search/utils'; +import { + LARGE_AVATAR_PX, + SMALL_AVATAR_PX, +} from '~/super_sidebar/components/global_search/constants'; +import { + GROUPS_CATEGORY, + PROJECTS_CATEGORY, + MERGE_REQUEST_CATEGORY, + ISSUES_CATEGORY, + RECENT_EPICS_CATEGORY, +} from '~/vue_shared/global_search/constants'; + +describe('getFormattedItem', () => { + describe.each` + item | avatarSize | searchContext | entityId | entityName + ${{ category: PROJECTS_CATEGORY, label: 'project1' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 29 } }} | ${29} | ${'project1'} + ${{ category: GROUPS_CATEGORY, label: 'project1' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 12 } }} | ${12} | ${'project1'} + ${{ category: 'Help', label: 'project1' }} | ${SMALL_AVATAR_PX} | ${null} | ${undefined} | ${'project1'} + ${{ category: 'Settings', label: 'project1' }} | ${SMALL_AVATAR_PX} | ${null} | ${undefined} | ${'project1'} + ${{ category: GROUPS_CATEGORY, value: 'group1', label: 'Group 1' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 1, name: 'test1' } }} | ${1} | ${'group1'} + ${{ category: PROJECTS_CATEGORY, value: 'group2', label: 'Group2' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 2, name: 'test2' } }} | ${2} | ${'group2'} + ${{ category: ISSUES_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 3, name: 'test3' } }} | ${3} | ${'test3'} + ${{ category: MERGE_REQUEST_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 4, name: 'test4' } }} | ${4} | ${'test4'} + ${{ category: RECENT_EPICS_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ group: { id: 5, name: 'test5' } }} | ${5} | ${'test5'} + ${{ category: GROUPS_CATEGORY, group_id: 6, group_name: 'test6' }} | ${LARGE_AVATAR_PX} | ${null} | ${6} | ${'test6'} + ${{ category: PROJECTS_CATEGORY, project_id: 7, project_name: 'test7' }} | ${LARGE_AVATAR_PX} | ${null} | ${7} | ${'test7'} + ${{ category: ISSUES_CATEGORY, project_id: 8, project_name: 'test8' }} | ${SMALL_AVATAR_PX} | ${null} | ${8} | ${'test8'} + ${{ category: MERGE_REQUEST_CATEGORY, project_id: 9, project_name: 'test9' }} | ${SMALL_AVATAR_PX} | ${null} | ${9} | ${'test9'} + ${{ category: RECENT_EPICS_CATEGORY, group_id: 10, group_name: 'test10' }} | ${SMALL_AVATAR_PX} | ${null} | ${10} | ${'test10'} + ${{ category: GROUPS_CATEGORY, group_id: 11, group_name: 'test11' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 1, name: 'test1' } }} | ${11} | ${'test11'} + ${{ category: PROJECTS_CATEGORY, project_id: 12, project_name: 'test12' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 2, name: 'test2' } }} | ${12} | ${'test12'} + ${{ category: ISSUES_CATEGORY, project_id: 13, project_name: 'test13' }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 3, name: 'test3' } }} | ${13} | ${'test13'} + ${{ category: MERGE_REQUEST_CATEGORY, project_id: 14, project_name: 'test14' }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 4, name: 'test4' } }} | ${14} | ${'test14'} + ${{ category: RECENT_EPICS_CATEGORY, group_id: 15, group_name: 'test15' }} | ${SMALL_AVATAR_PX} | ${{ group: { id: 5, name: 'test5' } }} | ${15} | ${'test15'} + `('formats the item', ({ item, avatarSize, searchContext, entityId, entityName }) => { + describe(`when item is ${JSON.stringify(item)}`, () => { + let formattedItem; + beforeEach(() => { + formattedItem = getFormattedItem(item, searchContext); + }); + + it(`should set text to ${item.value || item.label}`, () => { + expect(formattedItem.text).toBe(item.value || item.label); + }); + + it(`should set avatarSize to ${avatarSize}`, () => { + expect(formattedItem.avatar_size).toBe(avatarSize); + }); + + it(`should set avatar entityId to ${entityId}`, () => { + expect(formattedItem.entity_id).toBe(entityId); + }); + + it(`should set avatar entityName to ${entityName}`, () => { + expect(formattedItem.entity_name).toBe(entityName); + }); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/groups_list_spec.js b/spec/frontend/super_sidebar/components/groups_list_spec.js index 6aee895f611..4fa3303c12f 100644 --- a/spec/frontend/super_sidebar/components/groups_list_spec.js +++ b/spec/frontend/super_sidebar/components/groups_list_spec.js @@ -19,11 +19,14 @@ describe('GroupsList component', () => { const itRendersViewAllItem = () => { it('renders the "View all..." item', () => { - expect(findViewAllLink().props('item')).toEqual({ + const link = findViewAllLink(); + + expect(link.props('item')).toEqual({ icon: 'group', link: viewAllLink, - title: s__('Navigation|View all groups'), + title: s__('Navigation|View all your groups'), }); + expect(link.props('linkClasses')).toEqual({ 'dashboard-shortcuts-groups': true }); }); }; @@ -75,7 +78,7 @@ describe('GroupsList component', () => { it('passes the correct props to the frequent items list', () => { expect(findFrequentItemsList().props()).toEqual({ - title: s__('Navigation|Frequent groups'), + title: s__('Navigation|Frequently visited groups'), storageKey, maxItems: MAX_FREQUENT_GROUPS_COUNT, pristineText: s__('Navigation|Groups you visit often will appear here.'), diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js index 1d072c0ba3c..4c0e7a89a43 100644 --- a/spec/frontend/super_sidebar/components/help_center_spec.js +++ b/spec/frontend/super_sidebar/components/help_center_spec.js @@ -1,4 +1,4 @@ -import { GlDisclosureDropdownGroup } from '@gitlab/ui'; +import { GlDisclosureDropdown, GlDisclosureDropdownGroup } from '@gitlab/ui'; import { within } from '@testing-library/dom'; import toggleWhatsNewDrawer from '~/whats_new'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -7,15 +7,18 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { PROMO_URL } from 'jh_else_ce/lib/utils/url_utility'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { STORAGE_KEY } from '~/whats_new/utils/notification'; +import { mockTracking } from 'helpers/tracking_helper'; import { sidebarData } from '../mock_data'; jest.mock('~/whats_new'); describe('HelpCenter component', () => { let wrapper; + let trackingSpy; const GlEmoji = { template: '<img/>' }; + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); const findDropdownGroup = (i = 0) => { return wrapper.findAllComponents(GlDisclosureDropdownGroup).at(i); }; @@ -28,6 +31,15 @@ describe('HelpCenter component', () => { propsData: { sidebarData }, stubs: { GlEmoji }, }); + trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + }; + + const trackingAttrs = (label) => { + return { + 'data-track-action': 'click_link', + 'data-track-property': 'nav_help_menu', + 'data-track-label': label, + }; }; describe('default', () => { @@ -37,16 +49,37 @@ describe('HelpCenter component', () => { it('renders menu items', () => { expect(findDropdownGroup(0).props('group').items).toEqual([ - { text: HelpCenter.i18n.help, href: helpPagePath() }, - { text: HelpCenter.i18n.support, href: sidebarData.support_path }, - { text: HelpCenter.i18n.docs, href: 'https://docs.gitlab.com' }, - { text: HelpCenter.i18n.plans, href: `${PROMO_URL}/pricing` }, - { text: HelpCenter.i18n.forum, href: 'https://forum.gitlab.com/' }, + { text: HelpCenter.i18n.help, href: helpPagePath(), extraAttrs: trackingAttrs('help') }, + { + text: HelpCenter.i18n.support, + href: sidebarData.support_path, + extraAttrs: trackingAttrs('support'), + }, + { + text: HelpCenter.i18n.docs, + href: 'https://docs.gitlab.com', + extraAttrs: trackingAttrs('gitlab_documentation'), + }, + { + text: HelpCenter.i18n.plans, + href: `${PROMO_URL}/pricing`, + extraAttrs: trackingAttrs('compare_gitlab_plans'), + }, + { + text: HelpCenter.i18n.forum, + href: 'https://forum.gitlab.com/', + extraAttrs: trackingAttrs('community_forum'), + }, { text: HelpCenter.i18n.contribute, href: helpPagePath('', { anchor: 'contributing-to-gitlab' }), + extraAttrs: trackingAttrs('contribute_to_gitlab'), + }, + { + text: HelpCenter.i18n.feedback, + href: 'https://about.gitlab.com/submit-feedback', + extraAttrs: trackingAttrs('submit_feedback'), }, - { text: HelpCenter.i18n.feedback, href: 'https://about.gitlab.com/submit-feedback' }, ]); expect(findDropdownGroup(1).props('group').items).toEqual([ @@ -55,6 +88,12 @@ describe('HelpCenter component', () => { ]); }); + it('passes popper options to the dropdown', () => { + expect(findDropdown().props('popperOptions')).toEqual({ + modifiers: [{ name: 'offset', options: { offset: [-4, 4] } }], + }); + }); + describe('with Gitlab version check feature enabled', () => { beforeEach(() => { createWrapper({ ...sidebarData, show_version_check: true }); @@ -62,7 +101,12 @@ describe('HelpCenter component', () => { it('shows version information as first item', () => { expect(findDropdownGroup(0).props('group').items).toEqual([ - { text: HelpCenter.i18n.version, href: helpPagePath('update/index'), version: '16.0' }, + { + text: HelpCenter.i18n.version, + href: helpPagePath('update/index'), + version: '16.0', + extraAttrs: trackingAttrs('version_help_dropdown'), + }, ]); }); }); @@ -86,11 +130,24 @@ describe('HelpCenter component', () => { // ~/behaviors/shortcuts/shortcuts.js. expect(button.classList.contains('js-shortcuts-modal-trigger')).toBe(true); }); + + it('should have Snowplow tracking attributes', () => { + expect(findButton('Keyboard shortcuts ?').dataset).toEqual( + expect.objectContaining({ + trackAction: 'click_button', + trackLabel: 'keyboard_shortcuts_help', + trackProperty: 'nav_help_menu', + }), + ); + }); }); describe('showWhatsNew', () => { beforeEach(() => { jest.spyOn(wrapper.vm.$refs.dropdown, 'close'); + beforeEach(() => { + createWrapper({ ...sidebarData, show_version_check: true }); + }); findButton("What's new 5").click(); }); @@ -107,6 +164,18 @@ describe('HelpCenter component', () => { expect(toggleWhatsNewDrawer).toHaveBeenCalledTimes(2); expect(toggleWhatsNewDrawer).toHaveBeenLastCalledWith(); }); + + it('should have Snowplow tracking attributes', () => { + createWrapper({ ...sidebarData, display_whats_new: true }); + + expect(findButton("What's new 5").dataset).toEqual( + expect.objectContaining({ + trackAction: 'click_button', + trackLabel: 'whats_new', + trackProperty: 'nav_help_menu', + }), + ); + }); }); describe('shouldShowWhatsNewNotification', () => { @@ -153,5 +222,23 @@ describe('HelpCenter component', () => { }); }); }); + + describe('toggle dropdown', () => { + it('should track Snowplow event when dropdown is shown', () => { + findDropdown().vm.$emit('shown'); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', { + label: 'show_help_dropdown', + property: 'nav_help_menu', + }); + }); + + it('should track Snowplow event when dropdown is hidden', () => { + findDropdown().vm.$emit('hidden'); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_toggle', { + label: 'hide_help_dropdown', + property: 'nav_help_menu', + }); + }); + }); }); }); diff --git a/spec/frontend/super_sidebar/components/items_list_spec.js b/spec/frontend/super_sidebar/components/items_list_spec.js index 8e00984f500..d49ef35e9d8 100644 --- a/spec/frontend/super_sidebar/components/items_list_spec.js +++ b/spec/frontend/super_sidebar/components/items_list_spec.js @@ -1,4 +1,5 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { GlIcon } from '@gitlab/ui'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import ItemsList from '~/super_sidebar/components/items_list.vue'; import NavItem from '~/super_sidebar/components/nav_item.vue'; import { cachedFrequentProjects } from '../mock_data'; @@ -11,8 +12,8 @@ describe('ItemsList component', () => { const findNavItems = () => wrapper.findAllComponents(NavItem); - const createWrapper = ({ props = {}, slots = {} } = {}) => { - wrapper = shallowMountExtended(ItemsList, { + const createWrapper = ({ props = {}, slots = {}, mountFn = shallowMountExtended } = {}) => { + wrapper = mountFn(ItemsList, { propsData: { ...props, }, @@ -60,4 +61,42 @@ describe('ItemsList component', () => { expect(wrapper.findByTestId(testId).exists()).toBe(true); }); + + describe('item removal', () => { + const findRemoveButton = () => wrapper.findByTestId('item-remove'); + const mockProject = { + ...firstMockedProject, + title: firstMockedProject.name, + }; + + beforeEach(() => { + createWrapper({ + props: { + items: [mockProject], + editable: true, + }, + mountFn: mountExtended, + }); + }); + + it('renders the remove button', () => { + const itemRemoveButton = findRemoveButton(); + + expect(itemRemoveButton.exists()).toBe(true); + expect(itemRemoveButton.attributes('title')).toBe('Remove'); + expect(itemRemoveButton.findComponent(GlIcon).props('name')).toBe('close'); + }); + + it('emits `remove-item` event with item param when remove button is clicked', () => { + const itemRemoveButton = findRemoveButton(); + + itemRemoveButton.vm.$emit( + 'click', + { stopPropagation: jest.fn(), preventDefault: jest.fn() }, + mockProject, + ); + + expect(wrapper.emitted('remove-item')).toEqual([[mockProject]]); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/merge_request_menu_spec.js b/spec/frontend/super_sidebar/components/merge_request_menu_spec.js index fe87c4be9c3..9c8fd0556f1 100644 --- a/spec/frontend/super_sidebar/components/merge_request_menu_spec.js +++ b/spec/frontend/super_sidebar/components/merge_request_menu_spec.js @@ -8,7 +8,7 @@ describe('MergeRequestMenu component', () => { const findGlBadge = (at) => wrapper.findAllComponents(GlBadge).at(at); const findGlDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown); - const findLink = () => wrapper.findByRole('link'); + const findLink = (name) => wrapper.findByRole('link', { name }); const createWrapper = () => { wrapper = mountExtended(MergeRequestMenu, { @@ -27,11 +27,18 @@ describe('MergeRequestMenu component', () => { expect(findGlDisclosureDropdown().props('items')).toBe(mergeRequestMenuGroup); }); - it('renders item text and count in link', () => { - const { text, href, count } = mergeRequestMenuGroup[0].items[0]; - expect(findLink().text()).toContain(text); - expect(findLink().text()).toContain(String(count)); - expect(findLink().attributes('href')).toBe(href); + it.each(mergeRequestMenuGroup[0].items)('renders item text and count in link', (item) => { + const index = mergeRequestMenuGroup[0].items.indexOf(item); + const { text, href, count, extraAttrs } = mergeRequestMenuGroup[0].items[index]; + const link = findLink(new RegExp(text)); + + expect(link.text()).toContain(text); + expect(link.text()).toContain(String(count)); + expect(link.attributes('href')).toBe(href); + expect(link.attributes('data-track-action')).toBe(extraAttrs['data-track-action']); + expect(link.attributes('data-track-label')).toBe(extraAttrs['data-track-label']); + expect(link.attributes('data-track-property')).toBe(extraAttrs['data-track-property']); + expect(link.attributes('class')).toContain(extraAttrs.class); }); it('renders item count string in badge', () => { diff --git a/spec/frontend/super_sidebar/components/nav_item_spec.js b/spec/frontend/super_sidebar/components/nav_item_spec.js index 22989c1a5f9..d96d2b77d21 100644 --- a/spec/frontend/super_sidebar/components/nav_item_spec.js +++ b/spec/frontend/super_sidebar/components/nav_item_spec.js @@ -1,18 +1,24 @@ import { GlBadge } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import NavItem from '~/super_sidebar/components/nav_item.vue'; +import { + CLICK_MENU_ITEM_ACTION, + TRACKING_UNKNOWN_ID, + TRACKING_UNKNOWN_PANEL, +} from '~/super_sidebar/constants'; describe('NavItem component', () => { let wrapper; const findLink = () => wrapper.findByTestId('nav-item-link'); const findPill = () => wrapper.findComponent(GlBadge); - const createWrapper = (item, props = {}) => { + const createWrapper = (item, props = {}, provide = {}) => { wrapper = shallowMountExtended(NavItem, { propsData: { item, ...props, }, + provide, }); }; @@ -46,4 +52,34 @@ describe('NavItem component', () => { expect(findLink().attributes('class')).toContain(customClass); }); + + describe('Data Tracking Attributes', () => { + it('adds no labels on sections', () => { + const id = 'my-id'; + createWrapper({ title: 'Foo', id, items: [{ title: 'Baz' }] }); + + expect(findLink().attributes('data-track-action')).toBeUndefined(); + expect(findLink().attributes('data-track-label')).toBeUndefined(); + expect(findLink().attributes('data-track-property')).toBeUndefined(); + expect(findLink().attributes('data-track-extra')).toBeUndefined(); + }); + + it.each` + id | panelType | eventLabel | eventProperty | eventExtra + ${'abc'} | ${'xyz'} | ${'abc'} | ${'nav_panel_xyz'} | ${undefined} + ${undefined} | ${'xyz'} | ${TRACKING_UNKNOWN_ID} | ${'nav_panel_xyz'} | ${'{"title":"Foo"}'} + ${'abc'} | ${undefined} | ${'abc'} | ${TRACKING_UNKNOWN_PANEL} | ${'{"title":"Foo"}'} + ${undefined} | ${undefined} | ${TRACKING_UNKNOWN_ID} | ${TRACKING_UNKNOWN_PANEL} | ${'{"title":"Foo"}'} + `( + 'adds appropriate data tracking labels for id=$id and panelType=$panelType', + ({ id, eventLabel, panelType, eventProperty, eventExtra }) => { + createWrapper({ title: 'Foo', id }, {}, { panelType }); + + expect(findLink().attributes('data-track-action')).toBe(CLICK_MENU_ITEM_ACTION); + expect(findLink().attributes('data-track-label')).toBe(eventLabel); + expect(findLink().attributes('data-track-property')).toBe(eventProperty); + expect(findLink().attributes('data-track-extra')).toBe(eventExtra); + }, + ); + }); }); diff --git a/spec/frontend/super_sidebar/components/pinned_section_spec.js b/spec/frontend/super_sidebar/components/pinned_section_spec.js new file mode 100644 index 00000000000..7ead6a40895 --- /dev/null +++ b/spec/frontend/super_sidebar/components/pinned_section_spec.js @@ -0,0 +1,75 @@ +import { nextTick } from 'vue'; +import Cookies from '~/lib/utils/cookies'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import PinnedSection from '~/super_sidebar/components/pinned_section.vue'; +import NavItem from '~/super_sidebar/components/nav_item.vue'; +import { SIDEBAR_PINS_EXPANDED_COOKIE, SIDEBAR_COOKIE_EXPIRATION } from '~/super_sidebar/constants'; +import { setCookie } from '~/lib/utils/common_utils'; + +jest.mock('~/lib/utils/common_utils', () => ({ + getCookie: jest.requireActual('~/lib/utils/common_utils').getCookie, + setCookie: jest.fn(), +})); + +describe('PinnedSection component', () => { + let wrapper; + + const findToggle = () => wrapper.find('a'); + + const createWrapper = () => { + wrapper = mountExtended(PinnedSection, { + propsData: { + items: [{ title: 'Pin 1', href: '/page1' }], + }, + }); + }; + + describe('expanded', () => { + describe('when cookie is not set', () => { + it('is expanded by default', () => { + createWrapper(); + expect(wrapper.findComponent(NavItem).isVisible()).toBe(true); + }); + }); + + describe('when cookie is set to false', () => { + beforeEach(() => { + Cookies.set(SIDEBAR_PINS_EXPANDED_COOKIE, 'false'); + createWrapper(); + }); + + it('is collapsed', () => { + expect(wrapper.findComponent(NavItem).isVisible()).toBe(false); + }); + + it('updates the cookie when expanding the section', async () => { + findToggle().trigger('click'); + await nextTick(); + + expect(setCookie).toHaveBeenCalledWith(SIDEBAR_PINS_EXPANDED_COOKIE, true, { + expires: SIDEBAR_COOKIE_EXPIRATION, + }); + }); + }); + + describe('when cookie is set to true', () => { + beforeEach(() => { + Cookies.set(SIDEBAR_PINS_EXPANDED_COOKIE, 'true'); + createWrapper(); + }); + + it('is expanded', () => { + expect(wrapper.findComponent(NavItem).isVisible()).toBe(true); + }); + + it('updates the cookie when collapsing the section', async () => { + findToggle().trigger('click'); + await nextTick(); + + expect(setCookie).toHaveBeenCalledWith(SIDEBAR_PINS_EXPANDED_COOKIE, false, { + expires: SIDEBAR_COOKIE_EXPIRATION, + }); + }); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/projects_list_spec.js b/spec/frontend/super_sidebar/components/projects_list_spec.js index cdc003b14e0..93a414e1e8c 100644 --- a/spec/frontend/super_sidebar/components/projects_list_spec.js +++ b/spec/frontend/super_sidebar/components/projects_list_spec.js @@ -19,11 +19,14 @@ describe('ProjectsList component', () => { const itRendersViewAllItem = () => { it('renders the "View all..." item', () => { - expect(findViewAllLink().props('item')).toEqual({ + const link = findViewAllLink(); + + expect(link.props('item')).toEqual({ icon: 'project', link: viewAllLink, - title: s__('Navigation|View all projects'), + title: s__('Navigation|View all your projects'), }); + expect(link.props('linkClasses')).toEqual({ 'dashboard-shortcuts-projects': true }); }); }; @@ -70,7 +73,7 @@ describe('ProjectsList component', () => { it('passes the correct props to the frequent items list', () => { expect(findFrequentItemsList().props()).toEqual({ - title: s__('Navigation|Frequent projects'), + title: s__('Navigation|Frequently visited projects'), storageKey, maxItems: MAX_FREQUENT_PROJECTS_COUNT, pristineText: s__('Navigation|Projects you visit often will appear here.'), diff --git a/spec/frontend/super_sidebar/components/search_results_spec.js b/spec/frontend/super_sidebar/components/search_results_spec.js index dd48935c138..daec5c2a9b4 100644 --- a/spec/frontend/super_sidebar/components/search_results_spec.js +++ b/spec/frontend/super_sidebar/components/search_results_spec.js @@ -1,7 +1,9 @@ +import { GlCollapse } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { s__ } from '~/locale'; import SearchResults from '~/super_sidebar/components/search_results.vue'; import ItemsList from '~/super_sidebar/components/items_list.vue'; +import { stubComponent } from 'helpers/stub_component'; const title = s__('Navigation|PROJECTS'); const noResultsText = s__('Navigation|No project matches found'); @@ -9,7 +11,8 @@ const noResultsText = s__('Navigation|No project matches found'); describe('SearchResults component', () => { let wrapper; - const findListTitle = () => wrapper.findByTestId('list-title'); + const findSearchResultsToggle = () => wrapper.findByTestId('search-results-toggle'); + const findCollapsibleSection = () => wrapper.findComponent(GlCollapse); const findItemsList = () => wrapper.findComponent(ItemsList); const findEmptyText = () => wrapper.findByTestId('empty-text'); @@ -20,6 +23,11 @@ describe('SearchResults component', () => { noResultsText, ...props, }, + stubs: { + GlCollapse: stubComponent(GlCollapse, { + props: ['visible'], + }), + }, }); }; @@ -29,7 +37,11 @@ describe('SearchResults component', () => { }); it("renders the list's title", () => { - expect(findListTitle().text()).toBe(title); + expect(findSearchResultsToggle().text()).toBe(title); + }); + + it('is expanded', () => { + expect(findCollapsibleSection().props('visible')).toBe(true); }); it('renders the empty text', () => { diff --git a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js new file mode 100644 index 00000000000..26b146f0c8b --- /dev/null +++ b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js @@ -0,0 +1,151 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import SidebarMenu from '~/super_sidebar/components/sidebar_menu.vue'; +import { PANELS_WITH_PINS } from '~/super_sidebar/constants'; +import { sidebarData } from '../mock_data'; + +describe('SidebarMenu component', () => { + let wrapper; + + const createWrapper = (mockData) => { + wrapper = mountExtended(SidebarMenu, { + propsData: { + items: mockData.current_menu_items, + pinnedItemIds: mockData.pinned_items, + panelType: mockData.panel_type, + updatePinsUrl: mockData.update_pins_url, + }, + }); + }; + + describe('computed', () => { + const menuItems = [ + { id: 1, title: 'No subitems' }, + { id: 2, title: 'With subitems', items: [{ id: 21, title: 'Pinned subitem' }] }, + { id: 3, title: 'Empty subitems array', items: [] }, + { id: 4, title: 'Also with subitems', items: [{ id: 41, title: 'Subitem' }] }, + ]; + + describe('supportsPins', () => { + it('is true for the project sidebar', () => { + createWrapper({ ...sidebarData, panel_type: 'project' }); + expect(wrapper.vm.supportsPins).toBe(true); + }); + + it('is true for the group sidebar', () => { + createWrapper({ ...sidebarData, panel_type: 'group' }); + expect(wrapper.vm.supportsPins).toBe(true); + }); + + it('is false for any other sidebar', () => { + createWrapper({ ...sidebarData, panel_type: 'your_work' }); + expect(wrapper.vm.supportsPins).toEqual(false); + }); + }); + + describe('flatPinnableItems', () => { + it('returns all subitems in a flat array', () => { + createWrapper({ ...sidebarData, current_menu_items: menuItems }); + expect(wrapper.vm.flatPinnableItems).toEqual([ + { id: 21, title: 'Pinned subitem' }, + { id: 41, title: 'Subitem' }, + ]); + }); + }); + + describe('staticItems', () => { + describe('when the sidebar supports pins', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + panel_type: PANELS_WITH_PINS[0], + }); + }); + + it('makes everything that has no subitems a static item', () => { + expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([ + 'No subitems', + 'Empty subitems array', + ]); + }); + }); + + describe('when the sidebar does not support pins', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + panel_type: 'explore', + }); + }); + + it('returns an empty array', () => { + expect(wrapper.vm.staticItems.map((i) => i.title)).toEqual([]); + }); + }); + }); + + describe('nonStaticItems', () => { + describe('when the sidebar supports pins', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + panel_type: PANELS_WITH_PINS[0], + }); + }); + + it('keeps items that have subitems (aka "sections") as non-static', () => { + expect(wrapper.vm.nonStaticItems.map((i) => i.title)).toEqual([ + 'With subitems', + 'Also with subitems', + ]); + }); + }); + + describe('when the sidebar does not support pins', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + panel_type: 'explore', + }); + }); + + it('keeps all items as non-static', () => { + expect(wrapper.vm.nonStaticItems).toEqual(menuItems); + }); + }); + }); + + describe('pinnedItems', () => { + describe('when user has no pinned item ids stored', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + pinned_items: [], + }); + }); + + it('returns an empty array', () => { + expect(wrapper.vm.pinnedItems).toEqual([]); + }); + }); + + describe('when user has some pinned item ids stored', () => { + beforeEach(() => { + createWrapper({ + ...sidebarData, + current_menu_items: menuItems, + pinned_items: [21], + }); + }); + + it('returns the items matching the pinned ids', () => { + expect(wrapper.vm.pinnedItems).toEqual([{ id: 21, title: 'Pinned subitem' }]); + }); + }); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js index 32921da23aa..85f2a63943d 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js @@ -1,44 +1,57 @@ +import { nextTick } from 'vue'; +import { GlCollapse } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SuperSidebar from '~/super_sidebar/components/super_sidebar.vue'; import HelpCenter from '~/super_sidebar/components/help_center.vue'; import UserBar from '~/super_sidebar/components/user_bar.vue'; import SidebarPortalTarget from '~/super_sidebar/components/sidebar_portal_target.vue'; -import { isCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; +import ContextSwitcher from '~/super_sidebar/components/context_switcher.vue'; +import { + SUPER_SIDEBAR_PEEK_OPEN_DELAY, + SUPER_SIDEBAR_PEEK_CLOSE_DELAY, +} from '~/super_sidebar/constants'; +import { stubComponent } from 'helpers/stub_component'; import { sidebarData } from '../mock_data'; -jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager', () => ({ - isCollapsed: jest.fn(), -})); +const focusInputMock = jest.fn(); describe('SuperSidebar component', () => { let wrapper; - const findSidebar = () => wrapper.find('.super-sidebar'); + const findSidebar = () => wrapper.findByTestId('super-sidebar'); + const findHoverArea = () => wrapper.findByTestId('super-sidebar-hover-area'); const findUserBar = () => wrapper.findComponent(UserBar); const findHelpCenter = () => wrapper.findComponent(HelpCenter); const findSidebarPortalTarget = () => wrapper.findComponent(SidebarPortalTarget); - const createWrapper = (props = {}) => { + const createWrapper = ({ props = {}, provide = {}, sidebarState = {} } = {}) => { wrapper = shallowMountExtended(SuperSidebar, { + data() { + return { + ...sidebarState, + }; + }, propsData: { sidebarData, ...props, }, + stubs: { + ContextSwitcher: stubComponent(ContextSwitcher, { + methods: { focusInput: focusInputMock }, + }), + }, + provide, }); }; describe('default', () => { - it('add aria-hidden and inert attributes when collapsed', () => { - isCollapsed.mockReturnValue(true); - createWrapper(); - expect(findSidebar().attributes('aria-hidden')).toBe('true'); + it('adds inert attribute when collapsed', () => { + createWrapper({ sidebarState: { isCollapsed: true } }); expect(findSidebar().attributes('inert')).toBe('inert'); }); - it('does not add aria-hidden and inert attributes when expanded', () => { - isCollapsed.mockReturnValue(false); + it('does not add inert attribute when expanded', () => { createWrapper(); - expect(findSidebar().attributes('aria-hidden')).toBe('false'); expect(findSidebar().attributes('inert')).toBe(undefined); }); @@ -56,5 +69,120 @@ describe('SuperSidebar component', () => { createWrapper(); expect(findSidebarPortalTarget().exists()).toBe(true); }); + + it("does not call the context switcher's focusInput method initially", () => { + createWrapper(); + + expect(focusInputMock).not.toHaveBeenCalled(); + }); + + it('renders hidden shortcut links', () => { + createWrapper(); + const [linkAttrs] = sidebarData.shortcut_links; + const link = wrapper.find(`.${linkAttrs.css_class}`); + + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(linkAttrs.href); + expect(link.attributes('class')).toContain('gl-display-none'); + }); + }); + + describe('when peeking on hover', () => { + const peekClass = 'super-sidebar-peek'; + + it('updates inert attribute and peek class', async () => { + createWrapper({ + provide: { glFeatures: { superSidebarPeek: true } }, + sidebarState: { isCollapsed: true }, + }); + + findHoverArea().trigger('mouseenter'); + + jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_OPEN_DELAY - 1); + await nextTick(); + + // Not quite enough time has elapsed yet for sidebar to open + expect(findSidebar().classes()).not.toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe('inert'); + + jest.advanceTimersByTime(1); + await nextTick(); + + // Exactly enough time has elapsed to open + expect(findSidebar().classes()).toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe(undefined); + + // Important: assume the cursor enters the sidebar + findSidebar().trigger('mouseenter'); + + jest.runAllTimers(); + await nextTick(); + + // Sidebar remains peeked open indefinitely without a mouseleave + expect(findSidebar().classes()).toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe(undefined); + + findSidebar().trigger('mouseleave'); + + jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_CLOSE_DELAY - 1); + await nextTick(); + + // Not quite enough time has elapsed yet for sidebar to hide + expect(findSidebar().classes()).toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe(undefined); + + jest.advanceTimersByTime(1); + await nextTick(); + + // Exactly enough time has elapsed for sidebar to hide + expect(findSidebar().classes()).not.toContain('super-sidebar-peek'); + expect(findSidebar().attributes('inert')).toBe('inert'); + }); + + it('eventually closes the sidebar if cursor never enters sidebar', async () => { + createWrapper({ + provide: { glFeatures: { superSidebarPeek: true } }, + sidebarState: { isCollapsed: true }, + }); + + findHoverArea().trigger('mouseenter'); + + jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_OPEN_DELAY); + await nextTick(); + + // Sidebar is now open + expect(findSidebar().classes()).toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe(undefined); + + // Important: do *not* fire a mouseenter event on the sidebar here. This + // imitates what happens if the cursor moves away from the sidebar before + // it actually appears. + + jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_CLOSE_DELAY - 1); + await nextTick(); + + // Not quite enough time has elapsed yet for sidebar to hide + expect(findSidebar().classes()).toContain(peekClass); + expect(findSidebar().attributes('inert')).toBe(undefined); + + jest.advanceTimersByTime(1); + await nextTick(); + + // Exactly enough time has elapsed for sidebar to hide + expect(findSidebar().classes()).not.toContain('super-sidebar-peek'); + expect(findSidebar().attributes('inert')).toBe('inert'); + }); + }); + + describe('when opening the context switcher', () => { + beforeEach(() => { + createWrapper(); + wrapper.findComponent(GlCollapse).vm.$emit('input', true); + wrapper.findComponent(GlCollapse).vm.$emit('shown'); + }); + + it("calls the context switcher's focusInput method", () => { + expect(focusInputMock).toHaveBeenCalledTimes(1); + }); }); }); diff --git a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js new file mode 100644 index 00000000000..b9f94e662fe --- /dev/null +++ b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js @@ -0,0 +1,106 @@ +import { nextTick } from 'vue'; +import { GlButton } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { JS_TOGGLE_COLLAPSE_CLASS, JS_TOGGLE_EXPAND_CLASS } from '~/super_sidebar/constants'; +import SuperSidebarToggle from '~/super_sidebar/components/super_sidebar_toggle.vue'; +import { toggleSuperSidebarCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; + +jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager.js', () => ({ + toggleSuperSidebarCollapsed: jest.fn(), +})); + +describe('SuperSidebarToggle component', () => { + let wrapper; + + const findButton = () => wrapper.findComponent(GlButton); + const getTooltip = () => getBinding(wrapper.element, 'gl-tooltip').value; + + const createWrapper = ({ props = {}, sidebarState = {} } = {}) => { + wrapper = shallowMountExtended(SuperSidebarToggle, { + data() { + return { + ...sidebarState, + }; + }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + propsData: { + ...props, + }, + }); + }; + + describe('attributes', () => { + it('has aria-controls attribute', () => { + createWrapper(); + expect(findButton().attributes('aria-controls')).toBe('super-sidebar'); + }); + + it('has aria-expanded as true when expanded', () => { + createWrapper(); + expect(findButton().attributes('aria-expanded')).toBe('true'); + }); + + it('has aria-expanded as false when collapsed', () => { + createWrapper({ sidebarState: { isCollapsed: true } }); + expect(findButton().attributes('aria-expanded')).toBe('false'); + }); + + it('has aria-label attribute', () => { + createWrapper(); + expect(findButton().attributes('aria-label')).toBe(__('Navigation sidebar')); + }); + + it('is disabled when isPeek is true', () => { + createWrapper({ sidebarState: { isPeek: true } }); + expect(findButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('toolip', () => { + it('displays collapse when expanded', () => { + createWrapper(); + expect(getTooltip().title).toBe(__('Collapse sidebar')); + }); + + it('displays expand when collapsed', () => { + createWrapper({ sidebarState: { isCollapsed: true } }); + expect(getTooltip().title).toBe(__('Expand sidebar')); + }); + }); + + describe('toggle', () => { + beforeEach(() => { + setHTMLFixture(` + <button class="${JS_TOGGLE_COLLAPSE_CLASS}">Collapse</button> + <button class="${JS_TOGGLE_EXPAND_CLASS}">Expand</button> + `); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + it('collapses the sidebar and focuses the other toggle', async () => { + createWrapper(); + findButton().vm.$emit('click'); + await nextTick(); + expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(true, true); + expect(document.activeElement).toEqual( + document.querySelector(`.${JS_TOGGLE_COLLAPSE_CLASS}`), + ); + }); + + it('expands the sidebar and focuses the other toggle', async () => { + createWrapper({ sidebarState: { isCollapsed: true } }); + findButton().vm.$emit('click'); + await nextTick(); + expect(toggleSuperSidebarCollapsed).toHaveBeenCalledWith(false, true); + expect(document.activeElement).toEqual(document.querySelector(`.${JS_TOGGLE_EXPAND_CLASS}`)); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js index ae15dd55644..2b75fb27972 100644 --- a/spec/frontend/super_sidebar/components/user_bar_spec.js +++ b/spec/frontend/super_sidebar/components/user_bar_spec.js @@ -1,29 +1,65 @@ import { GlBadge } from '@gitlab/ui'; +import Vuex from 'vuex'; +import Vue, { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { __ } from '~/locale'; import CreateMenu from '~/super_sidebar/components/create_menu.vue'; +import SearchModal from '~/super_sidebar/components/global_search/components/global_search.vue'; import MergeRequestMenu from '~/super_sidebar/components/merge_request_menu.vue'; import Counter from '~/super_sidebar/components/counter.vue'; import UserBar from '~/super_sidebar/components/user_bar.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import waitForPromises from 'helpers/wait_for_promises'; +import { highCountTrim } from '~/lib/utils/text_utility'; import { sidebarData } from '../mock_data'; +import { MOCK_DEFAULT_SEARCH_OPTIONS } from './global_search/mock_data'; + +jest.mock('~/lib/utils/text_utility', () => ({ + highCountTrim: jest.fn().mockReturnValue('99+'), +})); describe('UserBar component', () => { let wrapper; const findCreateMenu = () => wrapper.findComponent(CreateMenu); const findCounter = (at) => wrapper.findAllComponents(Counter).at(at); + const findIssuesCounter = () => findCounter(0); + const findMRsCounter = () => findCounter(1); + const findTodosCounter = () => findCounter(2); const findMergeRequestMenu = () => wrapper.findComponent(MergeRequestMenu); const findBrandLogo = () => wrapper.findByTestId('brand-header-custom-logo'); + const findCollapseButton = () => wrapper.findByTestId('super-sidebar-collapse-button'); + const findSearchButton = () => wrapper.findByTestId('super-sidebar-search-button'); + const findSearchModal = () => wrapper.findComponent(SearchModal); + const findStopImpersonationButton = () => wrapper.findByTestId('stop-impersonation-btn'); + + Vue.use(Vuex); - const createWrapper = (extraSidebarData = {}) => { + const store = new Vuex.Store({ + getters: { + searchOptions: () => MOCK_DEFAULT_SEARCH_OPTIONS, + }, + }); + const createWrapper = ({ + hasCollapseButton = true, + extraSidebarData = {}, + provideOverrides = {}, + } = {}) => { wrapper = shallowMountExtended(UserBar, { propsData: { + hasCollapseButton, sidebarData: { ...sidebarData, ...extraSidebarData }, }, provide: { rootPath: '/', toggleNewNavEndpoint: '/-/profile/preferences', + isImpersonating: false, + ...provideOverrides, }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, + store, }); }; @@ -41,32 +77,69 @@ describe('UserBar component', () => { }); it('renders issues counter', () => { - expect(findCounter(0).props('count')).toBe(sidebarData.assigned_open_issues_count); - expect(findCounter(0).props('href')).toBe(sidebarData.issues_dashboard_path); - expect(findCounter(0).props('label')).toBe(__('Issues')); + const isuesCounter = findIssuesCounter(); + expect(isuesCounter.props('count')).toBe(sidebarData.assigned_open_issues_count); + expect(isuesCounter.props('href')).toBe(sidebarData.issues_dashboard_path); + expect(isuesCounter.props('label')).toBe(__('Issues')); + expect(isuesCounter.attributes('data-track-action')).toBe('click_link'); + expect(isuesCounter.attributes('data-track-label')).toBe('issues_link'); + expect(isuesCounter.attributes('data-track-property')).toBe('nav_core_menu'); + expect(isuesCounter.attributes('class')).toContain('dashboard-shortcuts-issues'); }); it('renders merge requests counter', () => { - expect(findCounter(1).props('count')).toBe(sidebarData.total_merge_requests_count); - expect(findCounter(1).props('label')).toBe(__('Merge requests')); + const mrsCounter = findMRsCounter(); + expect(mrsCounter.props('count')).toBe(sidebarData.total_merge_requests_count); + expect(mrsCounter.props('label')).toBe(__('Merge requests')); + expect(mrsCounter.attributes('data-track-action')).toBe('click_dropdown'); + expect(mrsCounter.attributes('data-track-label')).toBe('merge_requests_menu'); + expect(mrsCounter.attributes('data-track-property')).toBe('nav_core_menu'); }); - it('renders todos counter', () => { - expect(findCounter(2).props('count')).toBe(sidebarData.todos_pending_count); - expect(findCounter(2).props('href')).toBe('/dashboard/todos'); - expect(findCounter(2).props('label')).toBe(__('To-Do list')); + describe('Todos counter', () => { + it('renders it', () => { + const todosCounter = findTodosCounter(); + expect(todosCounter.props('href')).toBe('/dashboard/todos'); + expect(todosCounter.props('label')).toBe(__('To-Do list')); + expect(todosCounter.attributes('data-track-action')).toBe('click_link'); + expect(todosCounter.attributes('data-track-label')).toBe('todos_link'); + expect(todosCounter.attributes('data-track-property')).toBe('nav_core_menu'); + expect(todosCounter.attributes('class')).toContain('shortcuts-todos'); + }); + + it('should format and update todo counter when event is emitted', async () => { + createWrapper(); + const count = 100; + document.dispatchEvent(new CustomEvent('todo:toggle', { detail: { count } })); + await nextTick(); + expect(highCountTrim).toHaveBeenCalledWith(count); + expect(findTodosCounter().props('count')).toBe('99+'); + }); }); it('renders branding logo', () => { expect(findBrandLogo().exists()).toBe(true); expect(findBrandLogo().attributes('src')).toBe(sidebarData.logo_url); }); + + it('does not render the "Stop impersonating" button', () => { + expect(findStopImpersonationButton().exists()).toBe(false); + }); + + it('renders collapse button when hasCollapseButton is true', () => { + expect(findCollapseButton().exists()).toBe(true); + }); + + it('does not render collapse button when hasCollapseButton is false', () => { + createWrapper({ hasCollapseButton: false }); + expect(findCollapseButton().exists()).toBe(false); + }); }); describe('GitLab Next badge', () => { describe('when on canary', () => { it('should render a badge to switch off GitLab Next', () => { - createWrapper({ gitlab_com_and_canary: true }); + createWrapper({ extraSidebarData: { gitlab_com_and_canary: true } }); const badge = wrapper.findComponent(GlBadge); expect(badge.text()).toBe('Next'); expect(badge.attributes('href')).toBe(sidebarData.canary_toggle_com_url); @@ -75,10 +148,55 @@ describe('UserBar component', () => { describe('when not on canary', () => { it('should not render the GitLab Next badge', () => { - createWrapper({ gitlab_com_and_canary: false }); + createWrapper({ extraSidebarData: { gitlab_com_and_canary: false } }); const badge = wrapper.findComponent(GlBadge); expect(badge.exists()).toBe(false); }); }); }); + + describe('Search', () => { + beforeEach(async () => { + createWrapper(); + await waitForPromises(); + }); + + it('should render search button', () => { + expect(findSearchButton().exists()).toBe(true); + }); + + it('search button should have tooltip', () => { + const tooltip = getBinding(findSearchButton().element, 'gl-tooltip'); + expect(tooltip.value).toBe(`Search GitLab <kbd>/</kbd>`); + }); + + it('should render search modal', () => { + expect(findSearchModal().exists()).toBe(true); + }); + }); + + describe('While impersonating a user', () => { + beforeEach(() => { + createWrapper({ provideOverrides: { isImpersonating: true } }); + }); + + it('renders the "Stop impersonating" button', () => { + expect(findStopImpersonationButton().exists()).toBe(true); + }); + + it('sets the correct label on the button', () => { + const btn = findStopImpersonationButton(); + const label = __('Stop impersonating'); + + expect(btn.attributes('title')).toBe(label); + expect(btn.attributes('aria-label')).toBe(label); + }); + + it('sets the href and data-method attributes', () => { + const btn = findStopImpersonationButton(); + + expect(btn.attributes('href')).toBe(sidebarData.stop_impersonation_path); + expect(btn.attributes('data-method')).toBe('delete'); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js index b6231e03722..995095d0e35 100644 --- a/spec/frontend/super_sidebar/components/user_menu_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_spec.js @@ -14,7 +14,8 @@ describe('UserMenu component', () => { const GlEmoji = { template: '<img/>' }; const toggleNewNavEndpoint = invalidUrl; - const showDropdown = () => wrapper.findComponent(GlDisclosureDropdown).vm.$emit('shown'); + const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const showDropdown = () => findDropdown().vm.$emit('shown'); const createWrapper = (userDataChanges = {}) => { wrapper = mountExtended(UserMenu, { @@ -36,6 +37,14 @@ describe('UserMenu component', () => { trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); }; + it('passes popper options to the dropdown', () => { + createWrapper(); + + expect(findDropdown().props('popperOptions')).toEqual({ + modifiers: [{ name: 'offset', options: { offset: [-211, 4] } }], + }); + }); + describe('Toggle button', () => { let toggle; @@ -93,6 +102,14 @@ describe('UserMenu component', () => { expect(item.find('.js-set-status-modal-trigger').exists()).toBe(true); }); + it('should close the dropdown when status modal opened', () => { + setItem({ can_update: true }); + wrapper.vm.$refs.userDropdown.close = jest.fn(); + expect(wrapper.vm.$refs.userDropdown.close).not.toHaveBeenCalled(); + item.vm.$emit('action'); + expect(wrapper.vm.$refs.userDropdown.close).toHaveBeenCalled(); + }); + describe('renders correct label', () => { it.each` busy | customized | label @@ -117,22 +134,42 @@ describe('UserMenu component', () => { expect(findModalWrapper().exists()).toBe(true); }); - it('sets default data attributes when status is not customized', () => { - setItem({ can_update: true }); - expect(findModalWrapper().attributes()).toMatchObject({ - 'data-current-emoji': '', - 'data-current-message': '', - 'data-default-emoji': 'speech_balloon', + describe('when user cannot update status', () => { + it('sets default data attributes', () => { + setItem({ can_update: true }); + expect(findModalWrapper().attributes()).toMatchObject({ + 'data-current-emoji': '', + 'data-current-message': '', + 'data-default-emoji': 'speech_balloon', + }); }); }); - it('sets user status as data attributes when status is customized', () => { - setItem({ can_update: true, customized: true }); - expect(findModalWrapper().attributes()).toMatchObject({ - 'data-current-emoji': userMenuMockStatus.emoji, - 'data-current-message': userMenuMockStatus.message, - 'data-current-availability': userMenuMockStatus.availability, - 'data-current-clear-status-after': userMenuMockStatus.clear_after, + describe.each` + busy | customized + ${true} | ${true} + ${true} | ${false} + ${false} | ${true} + ${false} | ${false} + `(`when user can update status`, ({ busy, customized }) => { + it(`and ${busy ? 'is busy' : 'is not busy'} and status ${ + customized ? 'is' : 'is not' + } customized sets user status data attributes`, () => { + setItem({ can_update: true, busy, customized }); + if (busy || customized) { + expect(findModalWrapper().attributes()).toMatchObject({ + 'data-current-emoji': userMenuMockStatus.emoji, + 'data-current-message': userMenuMockStatus.message, + 'data-current-availability': userMenuMockStatus.availability, + 'data-current-clear-status-after': userMenuMockStatus.clear_after, + }); + } else { + expect(findModalWrapper().attributes()).toMatchObject({ + 'data-current-emoji': '', + 'data-current-message': '', + 'data-default-emoji': 'speech_balloon', + }); + } }); }); }); @@ -143,7 +180,7 @@ describe('UserMenu component', () => { let item; const setItem = ({ has_start_trial } = {}) => { - createWrapper({ trial: { has_start_trial } }); + createWrapper({ trial: { has_start_trial, url: '' } }); item = wrapper.findByTestId('start-trial-item'); }; @@ -160,6 +197,15 @@ describe('UserMenu component', () => { expect(item.exists()).toBe(true); }); }); + + it('has Snowplow tracking attributes', () => { + setItem({ has_start_trial: true }); + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'start_trial', + }); + }); }); describe('Buy Pipeline Minutes item', () => { @@ -202,17 +248,30 @@ describe('UserMenu component', () => { expect(item.exists()).toBe(true); }); - it('tracks the Sentry event', () => { - setItem({ show_buy_pipeline_minutes: true }); - showDropdown(); - expect(trackingSpy).toHaveBeenCalledWith( - undefined, - userMenuMockPipelineMinutes.tracking_attrs['track-action'], - { - label: userMenuMockPipelineMinutes.tracking_attrs['track-label'], - property: userMenuMockPipelineMinutes.tracking_attrs['track-property'], - }, - ); + describe('Snowplow tracking attributes to track item click', () => { + beforeEach(() => { + setItem({ show_buy_pipeline_minutes: true }); + }); + + it('has attributes to track item click in scope of new nav', () => { + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'buy_pipeline_minutes', + }); + }); + + it('tracks the click on the item', () => { + item.vm.$emit('action'); + expect(trackingSpy).toHaveBeenCalledWith( + undefined, + userMenuMockPipelineMinutes.tracking_attrs['track-action'], + { + label: userMenuMockPipelineMinutes.tracking_attrs['track-label'], + property: userMenuMockPipelineMinutes.tracking_attrs['track-property'], + }, + ); + }); }); describe('Callout & notification dot', () => { @@ -292,33 +351,71 @@ describe('UserMenu component', () => { }); describe('Edit profile item', () => { - it('should render a link to the profile page', () => { + let item; + + beforeEach(() => { createWrapper(); - const item = wrapper.findByTestId('edit-profile-item'); + item = wrapper.findByTestId('edit-profile-item'); + }); + + it('should render a link to the profile page', () => { expect(item.text()).toBe(UserMenu.i18n.editProfile); expect(item.find('a').attributes('href')).toBe(userMenuMockData.settings.profile_path); }); + + it('has Snowplow tracking attributes', () => { + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'user_edit_profile', + }); + }); }); describe('Preferences item', () => { - it('should render a link to the profile page', () => { + let item; + + beforeEach(() => { createWrapper(); - const item = wrapper.findByTestId('preferences-item'); + item = wrapper.findByTestId('preferences-item'); + }); + + it('should render a link to the profile page', () => { expect(item.text()).toBe(UserMenu.i18n.preferences); expect(item.find('a').attributes('href')).toBe( userMenuMockData.settings.profile_preferences_path, ); }); + + it('has Snowplow tracking attributes', () => { + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'user_preferences', + }); + }); }); describe('GitLab Next item', () => { describe('on gitlab.com', () => { - it('should render a link to switch to GitLab Next', () => { + let item; + + beforeEach(() => { createWrapper({ gitlab_com_but_not_canary: true }); - const item = wrapper.findByTestId('gitlab-next-item'); + item = wrapper.findByTestId('gitlab-next-item'); + }); + it('should render a link to switch to GitLab Next', () => { expect(item.text()).toBe(UserMenu.i18n.gitlabNext); expect(item.find('a').attributes('href')).toBe(userMenuMockData.canary_toggle_com_url); }); + + it('has Snowplow tracking attributes', () => { + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'switch_to_canary', + }); + }); }); describe('anywhere else', () => { @@ -340,10 +437,23 @@ describe('UserMenu component', () => { }); describe('Feedback item', () => { - it('should render feedback item with a link to a new GitLab issue', () => { + let item; + + beforeEach(() => { createWrapper(); - const feedbackItem = wrapper.findByTestId('feedback-item'); - expect(feedbackItem.find('a').attributes('href')).toBe(UserMenu.feedbackUrl); + item = wrapper.findByTestId('feedback-item'); + }); + + it('should render feedback item with a link to a new GitLab issue', () => { + expect(item.find('a').attributes('href')).toBe(UserMenu.feedbackUrl); + }); + + it('has Snowplow tracking attributes', () => { + expect(item.find('a').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'provide_nav_beta_feedback', + }); }); }); @@ -370,6 +480,15 @@ describe('UserMenu component', () => { ); expect(findSignOutGroup().find('a').attributes('data-method')).toBe('post'); }); + + it('should track Snowplow event on sign out', () => { + findSignOutGroup().vm.$emit('action'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', { + label: 'user_sign_out', + property: 'nav_user_menu', + }); + }); }); }); }); diff --git a/spec/frontend/super_sidebar/components/user_name_group_spec.js b/spec/frontend/super_sidebar/components/user_name_group_spec.js index c06c8c218d4..6e3b18d3107 100644 --- a/spec/frontend/super_sidebar/components/user_name_group_spec.js +++ b/spec/frontend/super_sidebar/components/user_name_group_spec.js @@ -41,10 +41,12 @@ describe('UserNameGroup component', () => { }); it('passes the item to the disclosure dropdown item', () => { - expect(findGlDisclosureDropdownItem().props('item')).toEqual({ - text: userMenuMockData.name, - href: userMenuMockData.link_to_profile, - }); + expect(findGlDisclosureDropdownItem().props('item')).toEqual( + expect.objectContaining({ + text: userMenuMockData.name, + href: userMenuMockData.link_to_profile, + }), + ); }); it("renders user's name", () => { @@ -97,4 +99,16 @@ describe('UserNameGroup component', () => { }); }); }); + + describe('Tracking', () => { + it('sets the tracking attributes', () => { + expect(findGlDisclosureDropdownItem().find('a').attributes()).toEqual( + expect.objectContaining({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'user_profile', + }), + ); + }); + }); }); |