diff options
Diffstat (limited to 'spec/frontend/super_sidebar')
31 files changed, 774 insertions, 1139 deletions
diff --git a/spec/frontend/super_sidebar/components/context_header_spec.js b/spec/frontend/super_sidebar/components/context_header_spec.js deleted file mode 100644 index 943b659c997..00000000000 --- a/spec/frontend/super_sidebar/components/context_header_spec.js +++ /dev/null @@ -1,50 +0,0 @@ -import { GlAvatar } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ContextHeader from '~/super_sidebar/components/context_header.vue'; - -describe('ContextHeader component', () => { - let wrapper; - - const context = { - id: 1, - title: 'Title', - avatar: '/path/to/avatar.png', - }; - - const findGlAvatar = () => wrapper.getComponent(GlAvatar); - - const createWrapper = (props = {}) => { - wrapper = shallowMountExtended(ContextHeader, { - propsData: { - context, - expanded: false, - ...props, - }, - }); - }; - - describe('with an avatar', () => { - it('passes the correct props to GlAvatar', () => { - createWrapper(); - const avatar = findGlAvatar(); - - expect(avatar.props('shape')).toBe('rect'); - expect(avatar.props('entityName')).toBe(context.title); - expect(avatar.props('entityId')).toBe(context.id); - expect(avatar.props('src')).toBe(context.avatar); - }); - - it('renders the avatar with a custom shape', () => { - const customShape = 'circle'; - createWrapper({ - context: { - ...context, - avatar_shape: customShape, - }, - }); - const avatar = findGlAvatar(); - - expect(avatar.props('shape')).toBe(customShape); - }); - }); -}); diff --git a/spec/frontend/super_sidebar/components/context_switcher_spec.js b/spec/frontend/super_sidebar/components/context_switcher_spec.js deleted file mode 100644 index dd8f39e7cb7..00000000000 --- a/spec/frontend/super_sidebar/components/context_switcher_spec.js +++ /dev/null @@ -1,302 +0,0 @@ -import Vue, { nextTick } from 'vue'; -import VueApollo from 'vue-apollo'; -import { GlDisclosureDropdown, 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 ContextSwitcherToggle from '~/super_sidebar/components/context_switcher_toggle.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'; -import searchUserProjectsAndGroupsQuery from '~/super_sidebar/graphql/queries/search_user_groups_and_projects.query.graphql'; -import { trackContextAccess, formatContextSwitcherItems } from '~/super_sidebar/utils'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import waitForPromises from 'helpers/wait_for_promises'; -import { stubComponent } from 'helpers/stub_component'; -import { contextSwitcherLinks, searchUserProjectsAndGroupsResponseMock } from '../mock_data'; - -jest.mock('~/super_sidebar/utils', () => ({ - getStorageKeyFor: jest.requireActual('~/super_sidebar/utils').getStorageKeyFor, - getTopFrequentItems: jest.requireActual('~/super_sidebar/utils').getTopFrequentItems, - formatContextSwitcherItems: jest.requireActual('~/super_sidebar/utils') - .formatContextSwitcherItems, - trackContextAccess: jest.fn(), -})); -const focusInputMock = jest.fn(); - -const username = 'root'; -const projectsPath = 'projectsPath'; -const groupsPath = 'groupsPath'; -const contextHeader = { avatar_shape: 'circle' }; - -Vue.use(VueApollo); - -describe('ContextSwitcher component', () => { - let wrapper; - let mockApollo; - - const findDisclosureDropdown = () => wrapper.findComponent(GlDisclosureDropdown); - const findContextSwitcherToggle = () => wrapper.findComponent(ContextSwitcherToggle); - 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'); - await nextTick(); - jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - return waitForPromises(); - }; - - const searchUserProjectsAndGroupsHandlerSuccess = jest - .fn() - .mockResolvedValue(searchUserProjectsAndGroupsResponseMock); - - const createWrapper = ({ props = {}, requestHandlers = {} } = {}) => { - mockApollo = createMockApollo([ - [ - searchUserProjectsAndGroupsQuery, - requestHandlers.searchUserProjectsAndGroupsQueryHandler ?? - searchUserProjectsAndGroupsHandlerSuccess, - ], - ]); - - wrapper = shallowMountExtended(ContextSwitcher, { - apolloProvider: mockApollo, - provide: { - contextSwitcherLinks, - }, - propsData: { - username, - projectsPath, - groupsPath, - contextHeader, - ...props, - }, - stubs: { - GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, { - template: ` - <div> - <slot name="toggle" /> - <slot /> - </div> - `, - }), - GlSearchBoxByType: stubComponent(GlSearchBoxByType, { - props: ['placeholder'], - methods: { focusInput: focusInputMock }, - }), - ProjectsList: stubComponent(ProjectsList, { - props: ['username', 'viewAllLink', 'isSearch', 'searchResults'], - }), - GroupsList: stubComponent(GroupsList, { - props: ['username', 'viewAllLink', 'isSearch', 'searchResults'], - }), - }, - }); - }; - - describe('default', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders the context switcher links', () => { - const navItems = findNavItems(); - const firstNavItem = navItems.at(0); - - expect(navItems.length).toBe(contextSwitcherLinks.length); - expect(firstNavItem.props('item')).toBe(contextSwitcherLinks[0]); - expect(firstNavItem.props('linkClasses')).toEqual({ - [contextSwitcherLinks[0].link_classes]: contextSwitcherLinks[0].link_classes, - }); - }); - - it('passes the placeholder to the search box', () => { - expect(findSearchBox().props('placeholder')).toBe( - s__('Navigation|Search your projects or groups'), - ); - }); - - it('passes the correct props to the frequent projects list', () => { - expect(findProjectsList().props()).toEqual({ - username, - viewAllLink: projectsPath, - isSearch: false, - searchResults: [], - }); - }); - - it('passes the correct props to the frequent groups list', () => { - expect(findGroupsList().props()).toEqual({ - username, - viewAllLink: groupsPath, - isSearch: false, - searchResults: [], - }); - }); - - 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); - }); - - it('passes the correct props to the toggle', () => { - expect(findContextSwitcherToggle().props('context')).toEqual(contextHeader); - expect(findContextSwitcherToggle().props('expanded')).toEqual(false); - }); - - it('does not emit the `toggle` event initially', () => { - expect(wrapper.emitted('toggle')).toBe(undefined); - }); - }); - - describe('visibility changes', () => { - beforeEach(() => { - createWrapper(); - findDisclosureDropdown().vm.$emit('shown'); - }); - - it('emits the `toggle` event, focuses the search input and puts the toggle in the expanded state when opened', () => { - expect(wrapper.emitted('toggle')).toHaveLength(1); - expect(wrapper.emitted('toggle')[0]).toEqual([true]); - expect(focusInputMock).toHaveBeenCalledTimes(1); - expect(findContextSwitcherToggle().props('expanded')).toBe(true); - }); - - it("emits the `toggle` event, does not attempt to focus the input, and resets the toggle's `expanded` props to `false` when closed", async () => { - findDisclosureDropdown().vm.$emit('hidden'); - await nextTick(); - - expect(wrapper.emitted('toggle')).toHaveLength(2); - expect(wrapper.emitted('toggle')[1]).toEqual([false]); - expect(focusInputMock).toHaveBeenCalledTimes(1); - expect(findContextSwitcherToggle().props('expanded')).toBe(false); - }); - }); - - describe('item access tracking', () => { - it('does not track anything if not within a trackable context', () => { - createWrapper(); - - expect(trackContextAccess).not.toHaveBeenCalled(); - }); - - it('tracks item access if within a trackable context', () => { - const currentContext = { namespace: 'groups' }; - createWrapper({ - props: { - currentContext, - }, - }); - - expect(trackContextAccess).toHaveBeenCalledWith(username, currentContext); - }); - }); - - describe('on search', () => { - beforeEach(() => { - createWrapper(); - 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( - formatContextSwitcherItems(searchUserProjectsAndGroupsResponseMock.data.projects.nodes), - ); - }); - - it('passes the groups to the frequent groups list', () => { - expect(findGroupsList().props('isSearch')).toBe(true); - expect(findGroupsList().props('searchResults')).toEqual( - formatContextSwitcherItems(searchUserProjectsAndGroupsResponseMock.data.user.groups.nodes), - ); - }); - }); - - describe('when search query does not match any items', () => { - beforeEach(() => { - createWrapper({ - requestHandlers: { - searchUserProjectsAndGroupsQueryHandler: jest.fn().mockResolvedValue({ - data: { - projects: { - nodes: [], - }, - user: { - id: '1', - groups: { - nodes: [], - }, - }, - }, - }), - }, - }); - return triggerSearchQuery(); - }); - - it('passes empty results to the lists', () => { - expect(findProjectsList().props('isSearch')).toBe(true); - expect(findProjectsList().props('searchResults')).toEqual([]); - expect(findGroupsList().props('isSearch')).toBe(true); - expect(findGroupsList().props('searchResults')).toEqual([]); - }); - }); - - describe('when search query fails', () => { - beforeEach(() => { - jest.spyOn(Sentry, 'captureException'); - }); - - it('captures exception and shows an alert if response is formatted incorrectly', async () => { - createWrapper({ - requestHandlers: { - searchUserProjectsAndGroupsQueryHandler: jest.fn().mockResolvedValue({ - data: {}, - }), - }, - }); - await triggerSearchQuery(); - - expect(Sentry.captureException).toHaveBeenCalled(); - expect(findAlert().exists()).toBe(true); - }); - - it('captures exception and shows an alert if query fails', async () => { - createWrapper({ - requestHandlers: { - searchUserProjectsAndGroupsQueryHandler: jest.fn().mockRejectedValue(), - }, - }); - await triggerSearchQuery(); - - expect(Sentry.captureException).toHaveBeenCalled(); - expect(findAlert().exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/super_sidebar/components/context_switcher_toggle_spec.js b/spec/frontend/super_sidebar/components/context_switcher_toggle_spec.js deleted file mode 100644 index c20d3c2745f..00000000000 --- a/spec/frontend/super_sidebar/components/context_switcher_toggle_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import { GlIcon } from '@gitlab/ui'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ContextSwitcherToggle from '~/super_sidebar/components/context_switcher_toggle.vue'; - -describe('ContextSwitcherToggle component', () => { - let wrapper; - - const context = { - id: 1, - title: 'Title', - avatar: '/path/to/avatar.png', - }; - - const findGlIcon = () => wrapper.getComponent(GlIcon); - - const createWrapper = (props = {}) => { - wrapper = shallowMountExtended(ContextSwitcherToggle, { - propsData: { - context, - expanded: false, - ...props, - }, - }); - }; - - it('renders "chevron-down" icon when not expanded', () => { - createWrapper(); - - expect(findGlIcon().props('name')).toBe('chevron-down'); - }); - - it('renders "chevron-up" icon when expanded', () => { - createWrapper({ - expanded: true, - }); - - expect(findGlIcon().props('name')).toBe('chevron-up'); - }); -}); diff --git a/spec/frontend/super_sidebar/components/create_menu_spec.js b/spec/frontend/super_sidebar/components/create_menu_spec.js index 510a3f5b913..b967fb18a39 100644 --- a/spec/frontend/super_sidebar/components/create_menu_spec.js +++ b/spec/frontend/super_sidebar/components/create_menu_spec.js @@ -1,7 +1,6 @@ import { nextTick } from 'vue'; import { GlDisclosureDropdown, - GlTooltip, GlDisclosureDropdownGroup, GlDisclosureDropdownItem, } from '@gitlab/ui'; @@ -9,6 +8,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import InviteMembersTrigger from '~/invite_members/components/invite_members_trigger.vue'; import { __ } from '~/locale'; import CreateMenu from '~/super_sidebar/components/create_menu.vue'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createNewMenuGroups } from '../mock_data'; describe('CreateMenu component', () => { @@ -18,7 +18,6 @@ describe('CreateMenu component', () => { const findGlDisclosureDropdownGroups = () => wrapper.findAllComponents(GlDisclosureDropdownGroup); const findGlDisclosureDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); const findInviteMembersTrigger = () => wrapper.findComponent(InviteMembersTrigger); - const findGlTooltip = () => wrapper.findComponent(GlTooltip); const createWrapper = ({ provide = {} } = {}) => { wrapper = shallowMountExtended(CreateMenu, { @@ -33,6 +32,9 @@ describe('CreateMenu component', () => { InviteMembersTrigger, GlDisclosureDropdown, }, + directives: { + GlTooltip: createMockDirective('gl-tooltip'), + }, }); }; @@ -45,7 +47,7 @@ describe('CreateMenu component', () => { createWrapper(); expect(findGlDisclosureDropdown().props('dropdownOffset')).toEqual({ - crossAxis: -147, + crossAxis: -179, mainAxis: 4, }); }); @@ -74,16 +76,12 @@ describe('CreateMenu component', () => { expect(findInviteMembersTrigger().exists()).toBe(true); }); - it("sets the toggle ID and tooltip's target", () => { - 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); + const tooltip = getBinding(findGlDisclosureDropdown().element, 'gl-tooltip'); + expect(tooltip.value).toBe(''); }); it('shows the tooltip when the dropdown is closed', async () => { @@ -91,7 +89,8 @@ describe('CreateMenu component', () => { findGlDisclosureDropdown().vm.$emit('hidden'); await nextTick(); - expect(findGlTooltip().exists()).toBe(true); + const tooltip = getBinding(findGlDisclosureDropdown().element, 'gl-tooltip'); + expect(tooltip.value).toBe('Create new...'); }); }); @@ -99,7 +98,7 @@ describe('CreateMenu component', () => { createWrapper({ provide: { isImpersonating: true } }); expect(findGlDisclosureDropdown().props('dropdownOffset')).toEqual({ - crossAxis: -115, + crossAxis: -147, mainAxis: 4, }); }); diff --git a/spec/frontend/super_sidebar/components/flyout_menu_spec.js b/spec/frontend/super_sidebar/components/flyout_menu_spec.js index b894d29c875..bf24de870d9 100644 --- a/spec/frontend/super_sidebar/components/flyout_menu_spec.js +++ b/spec/frontend/super_sidebar/components/flyout_menu_spec.js @@ -1,16 +1,26 @@ -import { shallowMount } from '@vue/test-utils'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import FlyoutMenu from '~/super_sidebar/components/flyout_menu.vue'; jest.mock('@floating-ui/dom'); describe('FlyoutMenu', () => { let wrapper; + let dummySection; const createComponent = () => { - wrapper = shallowMount(FlyoutMenu, { + dummySection = document.createElement('section'); + dummySection.addEventListener = jest.fn(); + + dummySection.getBoundingClientRect = jest.fn(); + dummySection.getBoundingClientRect.mockReturnValue({ top: 0, bottom: 5, width: 10 }); + + document.querySelector = jest.fn(); + document.querySelector.mockReturnValue(dummySection); + + wrapper = mountExtended(FlyoutMenu, { propsData: { targetId: 'section-1', - items: [], + items: [{ id: 1, title: 'item 1', link: 'https://example.com' }], }, }); }; diff --git a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js b/spec/frontend/super_sidebar/components/frequent_items_list_spec.js deleted file mode 100644 index 63dd941974a..00000000000 --- a/spec/frontend/super_sidebar/components/frequent_items_list_spec.js +++ /dev/null @@ -1,85 +0,0 @@ -import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; -import { s__ } from '~/locale'; -import FrequentItemsList from '~/super_sidebar/components//frequent_items_list.vue'; -import ItemsList from '~/super_sidebar/components/items_list.vue'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import { cachedFrequentProjects } from '../mock_data'; - -const title = s__('Navigation|FREQUENT PROJECTS'); -const pristineText = s__('Navigation|Projects you visit often will appear here.'); -const storageKey = 'storageKey'; -const maxItems = 5; - -describe('FrequentItemsList component', () => { - useLocalStorageSpy(); - - let wrapper; - - const findListTitle = () => wrapper.findByTestId('list-title'); - const findItemsList = () => wrapper.findComponent(ItemsList); - const findEmptyText = () => wrapper.findByTestId('empty-text'); - const findRemoveItemButton = () => wrapper.findByTestId('item-remove'); - - const createWrapperFactory = (mountFn = shallowMountExtended) => () => { - wrapper = mountFn(FrequentItemsList, { - propsData: { - title, - pristineText, - storageKey, - maxItems, - }, - }); - }; - const createWrapper = createWrapperFactory(); - const createFullWrapper = createWrapperFactory(mountExtended); - - describe('default', () => { - beforeEach(() => { - createWrapper(); - }); - - it("renders the list's title", () => { - expect(findListTitle().text()).toBe(title); - }); - - it('renders the empty text', () => { - expect(findEmptyText().exists()).toBe(true); - expect(findEmptyText().text()).toBe(pristineText); - }); - }); - - describe('when there are cached frequent items', () => { - beforeEach(() => { - window.localStorage.setItem(storageKey, cachedFrequentProjects); - createWrapper(); - }); - - it('attempts to retrieve the items from the local storage', () => { - expect(window.localStorage.getItem).toHaveBeenCalledTimes(1); - expect(window.localStorage.getItem).toHaveBeenCalledWith(storageKey); - }); - - it('renders the maximum amount of items', () => { - expect(findItemsList().props('items').length).toBe(maxItems); - }); - - it('does not render the empty text slot', () => { - expect(findEmptyText().exists()).toBe(false); - }); - }); - - describe('items editing', () => { - beforeEach(() => { - window.localStorage.setItem(storageKey, cachedFrequentProjects); - createFullWrapper(); - }); - - it('remove-item event emission from items-list causes list item to be removed', async () => { - const localStorageProjects = findItemsList().props('items'); - await findRemoveItemButton().trigger('click'); - - 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/command_palette/__snapshots__/search_item_spec.js.snap b/spec/frontend/super_sidebar/components/global_search/command_palette/__snapshots__/search_item_spec.js.snap index d16d137db2f..e6635672ccf 100644 --- a/spec/frontend/super_sidebar/components/global_search/command_palette/__snapshots__/search_item_spec.js.snap +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/__snapshots__/search_item_spec.js.snap @@ -2,7 +2,7 @@ exports[`SearchItem should render the item 1`] = ` <div - class="gl-display-flex gl-align-items-center" + class="gl-align-items-center gl-display-flex" > <gl-avatar-stub alt="avatar" @@ -14,33 +14,25 @@ exports[`SearchItem should render the item 1`] = ` size="16" src="https://www.gravatar.com/avatar/a9638f4ec70148d51e56bf05ad41e993?s=80&d=identicon" /> - - <!----> - <span class="gl-display-flex gl-flex-direction-column" > <span class="gl-text-gray-900" /> - - <!----> </span> </div> `; exports[`SearchItem should render the item 2`] = ` <div - class="gl-display-flex gl-align-items-center" + class="gl-align-items-center gl-display-flex" > - <!----> - <gl-icon-stub class="gl-mr-3" name="users" size="16" /> - <span class="gl-display-flex gl-flex-direction-column" > @@ -49,15 +41,13 @@ exports[`SearchItem should render the item 2`] = ` > Manage > Activity </span> - - <!----> </span> </div> `; exports[`SearchItem should render the item 3`] = ` <div - class="gl-display-flex gl-align-items-center" + class="gl-align-items-center gl-display-flex" > <gl-avatar-stub alt="avatar" @@ -69,9 +59,6 @@ exports[`SearchItem should render the item 3`] = ` size="32" src="/project/avatar/1/avatar.png" /> - - <!----> - <span class="gl-display-flex gl-flex-direction-column" > @@ -80,7 +67,6 @@ exports[`SearchItem should render the item 3`] = ` > MockProject1 </span> - <span class="gl-font-sm gl-text-gray-500" > @@ -92,7 +78,7 @@ exports[`SearchItem should render the item 3`] = ` exports[`SearchItem should render the item 4`] = ` <div - class="gl-display-flex gl-align-items-center" + class="gl-align-items-center gl-display-flex" > <gl-avatar-stub alt="avatar" @@ -104,9 +90,6 @@ exports[`SearchItem should render the item 4`] = ` size="16" src="" /> - - <!----> - <span class="gl-display-flex gl-flex-direction-column" > @@ -115,8 +98,6 @@ exports[`SearchItem should render the item 4`] = ` > Dismiss Cipher with no integrity </span> - - <!----> </span> </div> `; diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js index 85eb7e2e241..7d85dbcbdd3 100644 --- a/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/command_palette_items_spec.js @@ -9,6 +9,7 @@ import { PATH_GROUP_TITLE, USER_HANDLE, PATH_HANDLE, + PROJECT_HANDLE, SEARCH_SCOPE, MAX_ROWS, } from '~/super_sidebar/components/global_search/command_palette/constants'; @@ -20,6 +21,7 @@ import { import { getFormattedItem } from '~/super_sidebar/components/global_search/utils'; import axios from '~/lib/utils/axios_utils'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { mockTracking } from 'helpers/tracking_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { COMMANDS, LINKS, USERS, FILES } from './mock_data'; @@ -32,7 +34,7 @@ describe('CommandPaletteItems', () => { const projectFilesPath = 'project/files/path'; const projectBlobPath = '/blob/main'; - const createComponent = (props) => { + const createComponent = (props, options = {}) => { wrapper = shallowMount(CommandPaletteItems, { propsData: { handle: COMMAND_HANDLE, @@ -51,6 +53,7 @@ describe('CommandPaletteItems', () => { projectFilesPath, projectBlobPath, }, + ...options, }); }; @@ -227,4 +230,41 @@ describe('CommandPaletteItems', () => { expect(axios.get).toHaveBeenCalledTimes(1); }); }); + + describe('Tracking', () => { + let trackingSpy; + let mockAxios; + + beforeEach(() => { + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); + mockAxios = new MockAdapter(axios); + createComponent({ attachTo: document.body }); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + it('tracks event immediately', () => { + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'activate_command_palette', { + label: 'command', + }); + }); + + it.each` + handle | label + ${USER_HANDLE} | ${'user'} + ${PROJECT_HANDLE} | ${'project'} + ${PATH_HANDLE} | ${'path'} + `('tracks changing the handle to "$handle"', async ({ handle, label }) => { + trackingSpy.mockClear(); + + await wrapper.setProps({ handle }); + expect(trackingSpy).toHaveBeenCalledTimes(1); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'activate_command_palette', { + label, + }); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js b/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js index d01e5c85741..25a23433b1e 100644 --- a/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/mock_data.js @@ -69,24 +69,41 @@ export const TRANSFORMED_LINKS = [ icon: 'users', keywords: 'Manage', text: 'Manage', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'item_without_id', + 'data-track-extra': '{"title":"Manage"}', + }, }, { href: '/flightjs/Flight/activity', icon: 'users', keywords: 'Activity', text: 'Manage > Activity', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'activity', + }, }, { href: '/flightjs/Flight/-/project_members', icon: 'users', keywords: 'Members', text: 'Manage > Members', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'members', + }, }, { href: '/flightjs/Flight/-/labels', icon: 'users', keywords: 'Labels', text: 'Manage > Labels', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'labels', + }, }, ]; diff --git a/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js b/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js index ebc52e2d910..76768bd8da9 100644 --- a/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/command_palette/utils_spec.js @@ -26,6 +26,10 @@ describe('fileMapper', () => { icon: 'doc-code', text: file, href: `${projectBlobPath}/${file}`, + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'file', + }, }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/global_search_default_places_spec.js b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_places_spec.js index c6126a348f5..f91c8034fe9 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/global_search_default_places_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/global_search_default_places_spec.js @@ -67,10 +67,26 @@ describe('GlobalSearchDefaultPlaces', () => { { text: 'Explore', href: '/explore', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-extra': '{"title":"Explore"}', + 'data-track-label': 'item_without_id', + 'data-track-property': 'nav_panel_unknown', + 'data-testid': 'places-item-link', + 'data-qa-places-item': 'Explore', + }, }, { text: 'Admin area', href: '/admin', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-extra': '{"title":"Admin area"}', + 'data-track-label': 'item_without_id', + 'data-track-property': 'nav_panel_unknown', + 'data-testid': 'places-item-link', + 'data-qa-places-item': 'Admin area', + }, }, ]); }); 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 f9a6690a391..038c7a96adc 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 @@ -23,7 +23,6 @@ import { ICON_SUBGROUP, SCOPE_TOKEN_MAX_LENGTH, } from '~/super_sidebar/components/global_search/constants'; -import { SEARCH_GITLAB } from '~/vue_shared/global_search/constants'; import { truncate } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; import { ENTER_KEY } from '~/lib/utils/keys'; @@ -52,7 +51,7 @@ describe('GlobalSearchModal', () => { clearAutocomplete: jest.fn(), }; - const deafaultMockState = { + const defaultMockState = { searchContext: { project: MOCK_PROJECT, group: MOCK_GROUP, @@ -66,15 +65,14 @@ describe('GlobalSearchModal', () => { }; const createComponent = ({ - initialState = deafaultMockState, + initialState = defaultMockState, mockGetters = defaultMockGetters, stubs, - glFeatures = { commandPalette: false }, ...mountOptions } = {}) => { const store = new Vuex.Store({ state: { - ...deafaultMockState, + ...defaultMockState, ...initialState, }, actions: actionSpies, @@ -89,7 +87,6 @@ describe('GlobalSearchModal', () => { wrapper = shallowMountExtended(GlobalSearchModal, { store, stubs, - provide: { glFeatures }, ...mountOptions, }); }; @@ -271,49 +268,28 @@ describe('GlobalSearchModal', () => { }); describe('Command palette', () => { - describe('when FF `command_palette` is disabled', () => { + describe.each([...COMMON_HANDLES, PATH_HANDLE])('when search handle is %s', (handle) => { beforeEach(() => { - createComponent(); + createComponent({ + initialState: { search: handle }, + }); }); - it('should not render command mode components', () => { - expect(findCommandPaletteItems().exists()).toBe(false); - expect(findFakeSearchInput().exists()).toBe(false); + it('should render command mode components', () => { + expect(findCommandPaletteItems().exists()).toBe(true); + expect(findFakeSearchInput().exists()).toBe(true); }); - it('should provide default placeholder to the search input', () => { - expect(findGlobalSearchInput().attributes('placeholder')).toBe(SEARCH_GITLAB); + it('should provide an alternative placeholder to the search input', () => { + expect(findGlobalSearchInput().attributes('placeholder')).toBe( + SEARCH_OR_COMMAND_MODE_PLACEHOLDER, + ); }); - }); - - describe.each([...COMMON_HANDLES, PATH_HANDLE])( - 'when FF `command_palette` is enabled and search handle is %s', - (handle) => { - beforeEach(() => { - createComponent({ - initialState: { search: handle }, - glFeatures: { - commandPalette: true, - }, - }); - }); - it('should render command mode components', () => { - expect(findCommandPaletteItems().exists()).toBe(true); - expect(findFakeSearchInput().exists()).toBe(true); - }); - - it('should provide an alternative placeholder to the search input', () => { - expect(findGlobalSearchInput().attributes('placeholder')).toBe( - SEARCH_OR_COMMAND_MODE_PLACEHOLDER, - ); - }); - - it('should not render the scope token', () => { - expect(findScopeToken().exists()).toBe(false); - }); - }, - ); + it('should not render the scope token', () => { + expect(findScopeToken().exists()).toBe(false); + }); + }); }); }); @@ -373,9 +349,6 @@ describe('GlobalSearchModal', () => { beforeEach(() => { createComponent({ initialState: { search: '>' }, - glFeatures: { - commandPalette: true, - }, }); submitSearch(); }); 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 dfa8b458844..61ddfb6cae1 100644 --- a/spec/frontend/super_sidebar/components/global_search/mock_data.js +++ b/spec/frontend/super_sidebar/components/global_search/mock_data.js @@ -109,6 +109,10 @@ export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [ scopeCategory: PROJECTS_CATEGORY, icon: ICON_PROJECT, href: MOCK_PROJECT.path, + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'scoped_in_project', + }, }, { text: 'scoped-in-group', @@ -116,11 +120,19 @@ export const MOCK_SCOPED_SEARCH_OPTIONS_DEF = [ scopeCategory: GROUPS_CATEGORY, icon: ICON_GROUP, href: MOCK_GROUP.path, + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'scoped_in_group', + }, }, { text: 'scoped-in-all', description: MSG_IN_ALL_GITLAB, href: MOCK_ALL_PATH, + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'scoped_in_all', + }, }, ]; export const MOCK_SCOPED_SEARCH_OPTIONS = [ @@ -263,6 +275,10 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ avatar_size: 32, entity_id: 1, entity_name: 'MockGroup1', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'groups', + }, }, ], }, @@ -281,6 +297,10 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ avatar_size: 32, entity_id: 1, entity_name: 'MockProject1', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'projects', + }, }, { category: 'Projects', @@ -294,6 +314,10 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ avatar_size: 32, entity_id: 2, entity_name: 'MockProject2', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'projects', + }, }, ], }, @@ -307,6 +331,10 @@ export const MOCK_GROUPED_AUTOCOMPLETE_OPTIONS = [ href: 'help/gitlab', avatar_size: 16, entity_name: 'GitLab Help', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'help', + }, }, ], }, @@ -325,6 +353,10 @@ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ avatar_size: 32, entity_id: 1, entity_name: 'MockGroup1', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'groups', + }, }, { avatar_size: 32, @@ -338,6 +370,10 @@ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ namespace: 'Gitlab Org / MockProject1', text: 'MockProject1', value: 'MockProject1', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'projects', + }, }, { avatar_size: 32, @@ -351,6 +387,10 @@ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ namespace: 'Gitlab Org / MockProject2', text: 'MockProject2', value: 'MockProject2', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'projects', + }, }, { avatar_size: 16, @@ -359,6 +399,10 @@ export const MOCK_SORTED_AUTOCOMPLETE_OPTIONS = [ label: 'GitLab Help', text: 'GitLab Help', href: 'help/gitlab', + extraAttrs: { + 'data-track-action': 'click_command_palette_item', + 'data-track-label': 'help', + }, }, ]; diff --git a/spec/frontend/super_sidebar/components/global_search/utils_spec.js b/spec/frontend/super_sidebar/components/global_search/utils_spec.js index 3b12063e733..3c30445e936 100644 --- a/spec/frontend/super_sidebar/components/global_search/utils_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/utils_spec.js @@ -13,48 +13,58 @@ import { 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); - }); + item | avatarSize | searchContext | entityId | entityName | trackingLabel + ${{ category: PROJECTS_CATEGORY, label: 'project1' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 29 } }} | ${29} | ${'project1'} | ${'projects'} + ${{ category: GROUPS_CATEGORY, label: 'project1' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 12 } }} | ${12} | ${'project1'} | ${'groups'} + ${{ category: 'Help', label: 'project1' }} | ${SMALL_AVATAR_PX} | ${null} | ${undefined} | ${'project1'} | ${'help'} + ${{ category: 'Settings', label: 'project1' }} | ${SMALL_AVATAR_PX} | ${null} | ${undefined} | ${'project1'} | ${'settings'} + ${{ category: GROUPS_CATEGORY, value: 'group1', label: 'Group 1' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 1, name: 'test1' } }} | ${1} | ${'group1'} | ${'groups'} + ${{ category: PROJECTS_CATEGORY, value: 'group2', label: 'Group2' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 2, name: 'test2' } }} | ${2} | ${'group2'} | ${'projects'} + ${{ category: ISSUES_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 3, name: 'test3' } }} | ${3} | ${'test3'} | ${'recent_issues'} + ${{ category: MERGE_REQUEST_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 4, name: 'test4' } }} | ${4} | ${'test4'} | ${'recent_merge_requests'} + ${{ category: RECENT_EPICS_CATEGORY }} | ${SMALL_AVATAR_PX} | ${{ group: { id: 5, name: 'test5' } }} | ${5} | ${'test5'} | ${'recent_epics'} + ${{ category: GROUPS_CATEGORY, group_id: 6, group_name: 'test6' }} | ${LARGE_AVATAR_PX} | ${null} | ${6} | ${'test6'} | ${'groups'} + ${{ category: PROJECTS_CATEGORY, project_id: 7, project_name: 'test7' }} | ${LARGE_AVATAR_PX} | ${null} | ${7} | ${'test7'} | ${'projects'} + ${{ category: ISSUES_CATEGORY, project_id: 8, project_name: 'test8' }} | ${SMALL_AVATAR_PX} | ${null} | ${8} | ${'test8'} | ${'recent_issues'} + ${{ category: MERGE_REQUEST_CATEGORY, project_id: 9, project_name: 'test9' }} | ${SMALL_AVATAR_PX} | ${null} | ${9} | ${'test9'} | ${'recent_merge_requests'} + ${{ category: RECENT_EPICS_CATEGORY, group_id: 10, group_name: 'test10' }} | ${SMALL_AVATAR_PX} | ${null} | ${10} | ${'test10'} | ${'recent_epics'} + ${{ category: GROUPS_CATEGORY, group_id: 11, group_name: 'test11' }} | ${LARGE_AVATAR_PX} | ${{ group: { id: 1, name: 'test1' } }} | ${11} | ${'test11'} | ${'groups'} + ${{ category: PROJECTS_CATEGORY, project_id: 12, project_name: 'test12' }} | ${LARGE_AVATAR_PX} | ${{ project: { id: 2, name: 'test2' } }} | ${12} | ${'test12'} | ${'projects'} + ${{ category: ISSUES_CATEGORY, project_id: 13, project_name: 'test13' }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 3, name: 'test3' } }} | ${13} | ${'test13'} | ${'recent_issues'} + ${{ category: MERGE_REQUEST_CATEGORY, project_id: 14, project_name: 'test14' }} | ${SMALL_AVATAR_PX} | ${{ project: { id: 4, name: 'test4' } }} | ${14} | ${'test14'} | ${'recent_merge_requests'} + ${{ category: RECENT_EPICS_CATEGORY, group_id: 15, group_name: 'test15' }} | ${SMALL_AVATAR_PX} | ${{ group: { id: 5, name: 'test5' } }} | ${15} | ${'test15'} | ${'recent_epics'} + `( + 'formats the item', + ({ item, avatarSize, searchContext, entityId, entityName, trackingLabel }) => { + 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 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 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 entityId to ${entityId}`, () => { + expect(formattedItem.entity_id).toBe(entityId); + }); + + it(`should set avatar entityName to ${entityName}`, () => { + expect(formattedItem.entity_name).toBe(entityName); + }); - it(`should set avatar entityName to ${entityName}`, () => { - expect(formattedItem.entity_name).toBe(entityName); + it('should add tracking label', () => { + expect(formattedItem.extraAttrs).toEqual({ + 'data-track-action': 'click_command_palette_item', + 'data-track-label': trackingLabel, + }); + }); }); - }); - }); + }, + ); }); diff --git a/spec/frontend/super_sidebar/components/groups_list_spec.js b/spec/frontend/super_sidebar/components/groups_list_spec.js deleted file mode 100644 index 4fa3303c12f..00000000000 --- a/spec/frontend/super_sidebar/components/groups_list_spec.js +++ /dev/null @@ -1,90 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { s__ } from '~/locale'; -import GroupsList from '~/super_sidebar/components/groups_list.vue'; -import SearchResults from '~/super_sidebar/components/search_results.vue'; -import FrequentItemsList from '~/super_sidebar/components/frequent_items_list.vue'; -import NavItem from '~/super_sidebar/components/nav_item.vue'; -import { MAX_FREQUENT_GROUPS_COUNT } from '~/super_sidebar/constants'; - -const username = 'root'; -const viewAllLink = '/path/to/groups'; -const storageKey = `${username}/frequent-groups`; - -describe('GroupsList component', () => { - let wrapper; - - const findSearchResults = () => wrapper.findComponent(SearchResults); - const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList); - const findViewAllLink = () => wrapper.findComponent(NavItem); - - const itRendersViewAllItem = () => { - it('renders the "View all..." item', () => { - const link = findViewAllLink(); - - expect(link.props('item')).toEqual({ - icon: 'group', - link: viewAllLink, - title: s__('Navigation|View all your groups'), - }); - expect(link.props('linkClasses')).toEqual({ 'dashboard-shortcuts-groups': true }); - }); - }; - - const createWrapper = (props = {}) => { - wrapper = shallowMountExtended(GroupsList, { - propsData: { - username, - viewAllLink, - ...props, - }, - }); - }; - - describe('when displaying search results', () => { - const searchResults = ['A search result']; - - beforeEach(() => { - createWrapper({ - isSearch: true, - searchResults, - }); - }); - - it('renders the search results component', () => { - expect(findSearchResults().exists()).toBe(true); - expect(findFrequentItemsList().exists()).toBe(false); - }); - - it('passes the correct props to the search results component', () => { - expect(findSearchResults().props()).toEqual({ - title: s__('Navigation|Groups'), - noResultsText: s__('Navigation|No group matches found'), - searchResults, - }); - }); - - itRendersViewAllItem(); - }); - - describe('when displaying frequent groups', () => { - beforeEach(() => { - createWrapper(); - }); - - it('renders the frequent items list', () => { - expect(findFrequentItemsList().exists()).toBe(true); - expect(findSearchResults().exists()).toBe(false); - }); - - it('passes the correct props to the frequent items list', () => { - expect(findFrequentItemsList().props()).toEqual({ - title: s__('Navigation|Frequently visited groups'), - storageKey, - maxItems: MAX_FREQUENT_GROUPS_COUNT, - pristineText: s__('Navigation|Groups you visit often will appear here.'), - }); - }); - - itRendersViewAllItem(); - }); -}); diff --git a/spec/frontend/super_sidebar/components/items_list_spec.js b/spec/frontend/super_sidebar/components/items_list_spec.js deleted file mode 100644 index 8e00984f500..00000000000 --- a/spec/frontend/super_sidebar/components/items_list_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -import { shallowMountExtended } 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'; - -const mockItems = JSON.parse(cachedFrequentProjects); -const [firstMockedProject] = mockItems; - -describe('ItemsList component', () => { - let wrapper; - - const findNavItems = () => wrapper.findAllComponents(NavItem); - - const createWrapper = ({ props = {}, slots = {} } = {}) => { - wrapper = shallowMountExtended(ItemsList, { - propsData: { - ...props, - }, - slots, - }); - }; - - it('does not render nav items when there are no items', () => { - createWrapper(); - - expect(findNavItems().length).toBe(0); - }); - - it('renders one nav item per item', () => { - createWrapper({ - props: { - items: mockItems, - }, - }); - - expect(findNavItems().length).not.toBe(0); - expect(findNavItems().length).toBe(mockItems.length); - }); - - it('passes the correct props to the nav items', () => { - createWrapper({ - props: { - items: mockItems, - }, - }); - const firstNavItem = findNavItems().at(0); - - expect(firstNavItem.props('item')).toEqual(firstMockedProject); - }); - - it('renders the `view-all-items` slot', () => { - const testId = 'view-all-items'; - createWrapper({ - slots: { - 'view-all-items': { - template: `<div data-testid="${testId}" />`, - }, - }, - }); - - expect(wrapper.findByTestId(testId).exists()).toBe(true); - }); -}); diff --git a/spec/frontend/super_sidebar/components/menu_section_spec.js b/spec/frontend/super_sidebar/components/menu_section_spec.js index 288e317d4c6..e76bb699301 100644 --- a/spec/frontend/super_sidebar/components/menu_section_spec.js +++ b/spec/frontend/super_sidebar/components/menu_section_spec.js @@ -79,39 +79,55 @@ describe('MenuSection component', () => { }); describe('when hasFlyout is true', () => { - it('is rendered', () => { + it('is not yet rendered', () => { createWrapper({ title: 'Asdf' }, { 'has-flyout': true }); - expect(findFlyout().exists()).toBe(true); + expect(findFlyout().exists()).toBe(false); }); describe('on mouse hover', () => { describe('when section is expanded', () => { - it('is not shown', async () => { + it('is not rendered', async () => { createWrapper({ title: 'Asdf' }, { 'has-flyout': true, expanded: true }); await findButton().trigger('pointerover', { pointerType: 'mouse' }); - expect(findFlyout().isVisible()).toBe(false); + expect(findFlyout().exists()).toBe(false); }); }); describe('when section is not expanded', () => { - it('is shown', async () => { - createWrapper({ title: 'Asdf' }, { 'has-flyout': true, expanded: false }); - await findButton().trigger('pointerover', { pointerType: 'mouse' }); - expect(findFlyout().isVisible()).toBe(true); + describe('when section has no items', () => { + it('is not rendered', async () => { + createWrapper({ title: 'Asdf' }, { 'has-flyout': true, expanded: false }); + await findButton().trigger('pointerover', { pointerType: 'mouse' }); + expect(findFlyout().exists()).toBe(false); + }); + }); + + describe('when section has items', () => { + it('is rendered and shown', async () => { + createWrapper( + { title: 'Asdf', items: [{ title: 'Item1', href: '/item1' }] }, + { 'has-flyout': true, expanded: false }, + ); + await findButton().trigger('pointerover', { pointerType: 'mouse' }); + expect(findFlyout().isVisible()).toBe(true); + }); }); }); }); describe('when section gets closed', () => { beforeEach(async () => { - createWrapper({ title: 'Asdf' }, { expanded: true, 'has-flyout': true }); + createWrapper( + { title: 'Asdf', items: [{ title: 'Item1', href: '/item1' }] }, + { expanded: true, 'has-flyout': true }, + ); await findButton().trigger('click'); await findButton().trigger('pointerover', { pointerType: 'mouse' }); }); it('shows the flyout only after section title gets hovered out and in again', async () => { expect(findCollapse().props('visible')).toBe(false); - expect(findFlyout().isVisible()).toBe(false); + expect(findFlyout().exists()).toBe(false); await findButton().trigger('pointerleave'); await findButton().trigger('pointerover', { pointerType: 'mouse' }); diff --git a/spec/frontend/super_sidebar/components/nav_item_spec.js b/spec/frontend/super_sidebar/components/nav_item_spec.js index f41f6954ed1..89d774c4b43 100644 --- a/spec/frontend/super_sidebar/components/nav_item_spec.js +++ b/spec/frontend/super_sidebar/components/nav_item_spec.js @@ -1,5 +1,6 @@ -import { GlBadge } from '@gitlab/ui'; +import { GlBadge, GlButton, GlAvatar } from '@gitlab/ui'; import { RouterLinkStub } from '@vue/test-utils'; +import { nextTick } from 'vue'; import { mountExtended, extendedWrapper } from 'helpers/vue_test_utils_helper'; import NavItem from '~/super_sidebar/components/nav_item.vue'; import NavItemRouterLink from '~/super_sidebar/components/nav_item_router_link.vue'; @@ -13,8 +14,10 @@ import { describe('NavItem component', () => { let wrapper; + const findAvatar = () => wrapper.findComponent(GlAvatar); const findLink = () => wrapper.findByTestId('nav-item-link'); const findPill = () => wrapper.findComponent(GlBadge); + const findPinButton = () => wrapper.findComponent(GlButton); const findNavItemRouterLink = () => extendedWrapper(wrapper.findComponent(NavItemRouterLink)); const findNavItemLink = () => extendedWrapper(wrapper.findComponent(NavItemLink)); @@ -59,6 +62,66 @@ describe('NavItem component', () => { ); }); + describe('pins', () => { + describe('when pins are not supported', () => { + it('does not render pin button', () => { + createWrapper({ + item: { title: 'Foo' }, + provide: { + panelSupportsPins: false, + }, + }); + + expect(findPinButton().exists()).toBe(false); + }); + }); + + describe('when pins are supported', () => { + beforeEach(() => { + createWrapper({ + item: { title: 'Foo' }, + provide: { + panelSupportsPins: true, + }, + }); + }); + + it('renders pin button', () => { + expect(findPinButton().exists()).toBe(true); + }); + + it('contains an aria-label', () => { + expect(findPinButton().attributes('aria-label')).toBe('Pin Foo'); + }); + + it('toggles pointer events on after CSS fade-in', async () => { + const pinButton = findPinButton(); + + expect(pinButton.classes()).toContain('gl-pointer-events-none'); + + wrapper.trigger('mouseenter'); + pinButton.vm.$emit('transitionend'); + await nextTick(); + + expect(pinButton.classes()).not.toContain('gl-pointer-events-none'); + }); + + it('does not toggle pointer events if mouse leaves before CSS fade-in ends', async () => { + const pinButton = findPinButton(); + + expect(pinButton.classes()).toContain('gl-pointer-events-none'); + + wrapper.trigger('mouseenter'); + wrapper.trigger('mousemove'); + wrapper.trigger('mouseleave'); + pinButton.vm.$emit('transitionend'); + await nextTick(); + + expect(pinButton.classes()).toContain('gl-pointer-events-none'); + }); + }); + }); + it('applies custom link classes', () => { const customClass = 'customClass'; createWrapper({ @@ -153,4 +216,36 @@ describe('NavItem component', () => { }); }); }); + + describe('when `item` prop has `entity_id` attribute', () => { + it('renders an avatar', () => { + createWrapper({ + item: { title: 'Foo', entity_id: 123, avatar: '/avatar.png', avatar_shape: 'circle' }, + }); + + expect(findAvatar().props()).toMatchObject({ + entityId: 123, + shape: 'circle', + src: '/avatar.png', + }); + }); + }); + + describe('when `item.is_active` is true', () => { + it('scrolls into view', () => { + createWrapper({ + item: { is_active: true }, + }); + expect(wrapper.element.scrollIntoView).toHaveBeenNthCalledWith(1, false); + }); + }); + + describe('when `item.is_active` is false', () => { + it('scrolls not into view', () => { + createWrapper({ + item: { is_active: false }, + }); + expect(wrapper.element.scrollIntoView).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/pinned_section_spec.js b/spec/frontend/super_sidebar/components/pinned_section_spec.js index 00cc7cf29c9..fe1653f1177 100644 --- a/spec/frontend/super_sidebar/components/pinned_section_spec.js +++ b/spec/frontend/super_sidebar/components/pinned_section_spec.js @@ -87,4 +87,33 @@ describe('PinnedSection component', () => { }); }); }); + + describe('ambiguous settings names', () => { + it('get renamed to be unambiguous', () => { + createWrapper({ + items: [ + { title: 'CI/CD', id: 'ci_cd' }, + { title: 'Merge requests', id: 'merge_request_settings' }, + { title: 'Monitor', id: 'monitor' }, + { title: 'Repository', id: 'repository' }, + { title: 'Repository', id: 'code' }, + { title: 'Something else', id: 'not_a_setting' }, + ], + }); + + expect( + wrapper + .findComponent(MenuSection) + .props('item') + .items.map((i) => i.title), + ).toEqual([ + 'CI/CD settings', + 'Merge requests settings', + 'Monitor settings', + 'Repository settings', + 'Repository', + 'Something else', + ]); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/projects_list_spec.js b/spec/frontend/super_sidebar/components/projects_list_spec.js deleted file mode 100644 index 93a414e1e8c..00000000000 --- a/spec/frontend/super_sidebar/components/projects_list_spec.js +++ /dev/null @@ -1,85 +0,0 @@ -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { s__ } from '~/locale'; -import ProjectsList from '~/super_sidebar/components/projects_list.vue'; -import SearchResults from '~/super_sidebar/components/search_results.vue'; -import FrequentItemsList from '~/super_sidebar/components/frequent_items_list.vue'; -import NavItem from '~/super_sidebar/components/nav_item.vue'; -import { MAX_FREQUENT_PROJECTS_COUNT } from '~/super_sidebar/constants'; - -const username = 'root'; -const viewAllLink = '/path/to/projects'; -const storageKey = `${username}/frequent-projects`; - -describe('ProjectsList component', () => { - let wrapper; - - const findSearchResults = () => wrapper.findComponent(SearchResults); - const findFrequentItemsList = () => wrapper.findComponent(FrequentItemsList); - const findViewAllLink = () => wrapper.findComponent(NavItem); - - const itRendersViewAllItem = () => { - it('renders the "View all..." item', () => { - const link = findViewAllLink(); - - expect(link.props('item')).toEqual({ - icon: 'project', - link: viewAllLink, - title: s__('Navigation|View all your projects'), - }); - expect(link.props('linkClasses')).toEqual({ 'dashboard-shortcuts-projects': true }); - }); - }; - - const createWrapper = (props = {}) => { - wrapper = shallowMountExtended(ProjectsList, { - propsData: { - username, - viewAllLink, - ...props, - }, - }); - }; - - describe('when displaying search results', () => { - const searchResults = ['A search result']; - - beforeEach(() => { - createWrapper({ - isSearch: true, - searchResults, - }); - }); - - it('renders the search results component', () => { - expect(findSearchResults().exists()).toBe(true); - expect(findFrequentItemsList().exists()).toBe(false); - }); - - it('passes the correct props to the search results component', () => { - expect(findSearchResults().props()).toEqual({ - title: s__('Navigation|Projects'), - noResultsText: s__('Navigation|No project matches found'), - searchResults, - }); - }); - - itRendersViewAllItem(); - }); - - describe('when displaying frequent projects', () => { - beforeEach(() => { - createWrapper(); - }); - - it('passes the correct props to the frequent items list', () => { - expect(findFrequentItemsList().props()).toEqual({ - title: s__('Navigation|Frequently visited projects'), - storageKey, - maxItems: MAX_FREQUENT_PROJECTS_COUNT, - pristineText: s__('Navigation|Projects you visit often will appear here.'), - }); - }); - - itRendersViewAllItem(); - }); -}); diff --git a/spec/frontend/super_sidebar/components/search_results_spec.js b/spec/frontend/super_sidebar/components/search_results_spec.js deleted file mode 100644 index daec5c2a9b4..00000000000 --- a/spec/frontend/super_sidebar/components/search_results_spec.js +++ /dev/null @@ -1,69 +0,0 @@ -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'); - -describe('SearchResults component', () => { - let wrapper; - - const findSearchResultsToggle = () => wrapper.findByTestId('search-results-toggle'); - const findCollapsibleSection = () => wrapper.findComponent(GlCollapse); - const findItemsList = () => wrapper.findComponent(ItemsList); - const findEmptyText = () => wrapper.findByTestId('empty-text'); - - const createWrapper = ({ props = {} } = {}) => { - wrapper = shallowMountExtended(SearchResults, { - propsData: { - title, - noResultsText, - ...props, - }, - stubs: { - GlCollapse: stubComponent(GlCollapse, { - props: ['visible'], - }), - }, - }); - }; - - describe('default state', () => { - beforeEach(() => { - createWrapper(); - }); - - it("renders the list's title", () => { - expect(findSearchResultsToggle().text()).toBe(title); - }); - - it('is expanded', () => { - expect(findCollapsibleSection().props('visible')).toBe(true); - }); - - it('renders the empty text', () => { - expect(findEmptyText().exists()).toBe(true); - expect(findEmptyText().text()).toBe(noResultsText); - }); - }); - - describe('when displaying search results', () => { - it('shows search results', () => { - const searchResults = [{ id: 1 }]; - createWrapper({ props: { isSearch: true, searchResults } }); - - expect(findItemsList().props('items')[0]).toEqual(searchResults[0]); - }); - - it('shows the no results text if search results are empty', () => { - const searchResults = []; - createWrapper({ props: { isSearch: true, searchResults } }); - - expect(findItemsList().props('items').length).toEqual(0); - expect(findEmptyText().text()).toBe(noResultsText); - }); - }); -}); diff --git a/spec/frontend/super_sidebar/components/sidebar_hover_peek_behavior_spec.js b/spec/frontend/super_sidebar/components/sidebar_hover_peek_behavior_spec.js new file mode 100644 index 00000000000..75b834ee7c9 --- /dev/null +++ b/spec/frontend/super_sidebar/components/sidebar_hover_peek_behavior_spec.js @@ -0,0 +1,213 @@ +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { + SUPER_SIDEBAR_PEEK_OPEN_DELAY, + SUPER_SIDEBAR_PEEK_CLOSE_DELAY, + JS_TOGGLE_EXPAND_CLASS, + SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED, + SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN, + SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN, + SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE, +} from '~/super_sidebar/constants'; +import SidebarHoverPeek from '~/super_sidebar/components/sidebar_hover_peek_behavior.vue'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { moveMouse, mouseEnter, mouseLeave, moveMouseOutOfDocument } from '../mocks'; + +// This is measured at runtime in the browser, but statically defined here +// since Jest does not do layout/styling. +const X_SIDEBAR_EDGE = 10; + +jest.mock('~/lib/utils/css_utils', () => ({ + getCssClassDimensions: () => ({ width: X_SIDEBAR_EDGE }), +})); + +describe('SidebarHoverPeek component', () => { + let wrapper; + let toggle; + let trackingSpy = null; + + const createComponent = (props = { isMouseOverSidebar: false }) => { + wrapper = mount(SidebarHoverPeek, { + propsData: props, + }); + + return nextTick(); + }; + + const lastNChangeEvents = (n = 1) => wrapper.emitted('change').slice(-n).flat(); + + beforeEach(() => { + toggle = document.createElement('button'); + toggle.classList.add(JS_TOGGLE_EXPAND_CLASS); + document.body.appendChild(toggle); + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); + }); + + afterEach(() => { + unmockTracking(); + // We destroy the wrapper ourselves as that needs to happen before the toggle is removed. + // eslint-disable-next-line @gitlab/vtu-no-explicit-wrapper-destroy + wrapper.destroy(); + toggle?.remove(); + }); + + it('begins in the closed state', async () => { + await createComponent(); + + expect(lastNChangeEvents(Infinity)).toEqual([STATE_CLOSED]); + }); + + describe('when mouse enters the toggle', () => { + beforeEach(async () => { + await createComponent(); + mouseEnter(toggle); + }); + + it('does not emit duplicate events in a region', () => { + mouseEnter(toggle); + + expect(lastNChangeEvents(Infinity)).toEqual([STATE_CLOSED, STATE_WILL_OPEN]); + }); + + it('transitions to will-open when hovering the toggle', () => { + expect(lastNChangeEvents(1)).toEqual([STATE_WILL_OPEN]); + }); + + describe('when transitioning away from the will-open state', () => { + beforeEach(() => { + jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_OPEN_DELAY - 1); + }); + + it('transitions to open after delay', () => { + expect(lastNChangeEvents(1)).toEqual([STATE_WILL_OPEN]); + + jest.advanceTimersByTime(1); + + expect(lastNChangeEvents(2)).toEqual([STATE_WILL_OPEN, STATE_OPEN]); + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'nav_hover_peek', { + label: 'nav_sidebar_toggle', + property: 'nav_sidebar', + }); + }); + + it('cancels transition to open if mouse out of toggle', () => { + mouseLeave(toggle); + jest.runOnlyPendingTimers(); + + expect(lastNChangeEvents(3)).toEqual([STATE_WILL_OPEN, STATE_WILL_CLOSE, STATE_CLOSED]); + }); + + it('transitions to closed if cursor leaves document', () => { + moveMouseOutOfDocument(); + + expect(lastNChangeEvents(2)).toEqual([STATE_WILL_OPEN, STATE_CLOSED]); + }); + }); + + describe('when transitioning away from the will-close state', () => { + beforeEach(() => { + jest.runOnlyPendingTimers(); + moveMouse(X_SIDEBAR_EDGE); + jest.advanceTimersByTime(SUPER_SIDEBAR_PEEK_CLOSE_DELAY - 1); + }); + + it('transitions to closed after delay', () => { + expect(lastNChangeEvents(1)).toEqual([STATE_WILL_CLOSE]); + + jest.advanceTimersByTime(1); + + expect(lastNChangeEvents(2)).toEqual([STATE_WILL_CLOSE, STATE_CLOSED]); + }); + + it('cancels transition to close if mouse moves back to toggle', () => { + expect(lastNChangeEvents(1)).toEqual([STATE_WILL_CLOSE]); + + mouseEnter(toggle); + jest.runOnlyPendingTimers(); + + expect(lastNChangeEvents(4)).toEqual([ + STATE_OPEN, + STATE_WILL_CLOSE, + STATE_WILL_OPEN, + STATE_OPEN, + ]); + }); + }); + + describe('when transitioning away from the open state', () => { + beforeEach(() => { + jest.runOnlyPendingTimers(); + }); + + it('transitions to will-close if mouse out of sidebar region', () => { + expect(lastNChangeEvents(1)).toEqual([STATE_OPEN]); + + moveMouse(X_SIDEBAR_EDGE); + + expect(lastNChangeEvents(2)).toEqual([STATE_OPEN, STATE_WILL_CLOSE]); + }); + + it('transitions to will-close if cursor leaves document', () => { + moveMouseOutOfDocument(); + + expect(lastNChangeEvents(2)).toEqual([STATE_OPEN, STATE_WILL_CLOSE]); + }); + }); + + it('cleans up its mouseleave listener before destroy', () => { + jest.runOnlyPendingTimers(); + + expect(lastNChangeEvents(1)).toEqual([STATE_OPEN]); + + wrapper.destroy(); + mouseLeave(toggle); + + expect(lastNChangeEvents(1)).toEqual([STATE_OPEN]); + }); + + it('cleans up its timers before destroy', () => { + wrapper.destroy(); + jest.runOnlyPendingTimers(); + + expect(lastNChangeEvents(1)).toEqual([STATE_WILL_OPEN]); + }); + + it('cleans up document mouseleave listener before destroy', () => { + mouseEnter(toggle); + + wrapper.destroy(); + + moveMouseOutOfDocument(); + + expect(lastNChangeEvents(1)).not.toEqual([STATE_CLOSED]); + }); + }); + + describe('when mouse is over sidebar child element', () => { + beforeEach(async () => { + await createComponent({ isMouseOverSidebar: true }); + }); + + it('does not transition to will-close or closed when mouse is over sidebar child element', () => { + mouseEnter(toggle); + jest.runOnlyPendingTimers(); + mouseLeave(toggle); + + expect(lastNChangeEvents(1)).toEqual([STATE_OPEN]); + }); + }); + + it('cleans up its mouseenter listener before destroy', async () => { + await createComponent(); + + mouseLeave(toggle); + jest.runOnlyPendingTimers(); + + expect(lastNChangeEvents(1)).toEqual([STATE_CLOSED]); + + wrapper.destroy(); + mouseEnter(toggle); + + expect(lastNChangeEvents(1)).toEqual([STATE_CLOSED]); + }); +}); diff --git a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js index 5d9a35fbf70..c85a6609e6f 100644 --- a/spec/frontend/super_sidebar/components/sidebar_menu_spec.js +++ b/spec/frontend/super_sidebar/components/sidebar_menu_spec.js @@ -16,13 +16,8 @@ const menuItems = [ describe('Sidebar Menu', () => { let wrapper; - let flyoutFlag = false; - const createWrapper = (extraProps = {}) => { wrapper = shallowMountExtended(SidebarMenu, { - provide: { - glFeatures: { superSidebarFlyoutMenus: flyoutFlag }, - }, propsData: { items: sidebarData.current_menu_items, isLoggedIn: sidebarData.is_logged_in, @@ -125,8 +120,11 @@ describe('Sidebar Menu', () => { }); describe('flyout menus', () => { - describe('when feature is disabled', () => { + describe('when screen width is smaller than "md" breakpoint', () => { beforeEach(() => { + jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => { + return 767; + }); createWrapper({ items: menuItems, }); @@ -140,59 +138,27 @@ describe('Sidebar Menu', () => { }); }); - describe('when feature is enabled', () => { + describe('when screen width is equal or larger than "md" breakpoint', () => { beforeEach(() => { - flyoutFlag = true; - }); - - describe('when screen width is smaller than "md" breakpoint', () => { - beforeEach(() => { - jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => { - return 767; - }); - createWrapper({ - items: menuItems, - }); + jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => { + return 768; }); - - it('does not add flyout menus to sections', () => { - expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([ - false, - false, - ]); + createWrapper({ + items: menuItems, }); }); - describe('when screen width is equal or larger than "md" breakpoint', () => { - beforeEach(() => { - jest.spyOn(GlBreakpointInstance, 'windowWidth').mockImplementation(() => { - return 768; - }); - createWrapper({ - items: menuItems, - }); - }); - - it('adds flyout menus to sections', () => { - expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([ - true, - true, - ]); - }); + it('adds flyout menus to sections', () => { + expect(findNonStaticSectionItems().wrappers.map((w) => w.props('hasFlyout'))).toEqual([ + true, + true, + ]); }); }); }); }); describe('Separators', () => { - it('should add the separator above pinned section', () => { - createWrapper({ - items: menuItems, - panelType: 'project', - }); - expect(findPinnedSection().props('separated')).toBe(true); - }); - it('should add the separator above main menu items when there is a pinned section', () => { createWrapper({ items: menuItems, @@ -209,11 +175,4 @@ describe('Sidebar Menu', () => { expect(findMainMenuSeparator().exists()).toBe(false); }); }); - - describe('ARIA attributes', () => { - it('adds aria-label attribute to nav element', () => { - createWrapper(); - expect(wrapper.find('nav').attributes('aria-label')).toBe('Main navigation'); - }); - }); }); diff --git a/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js b/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js index 94ef072a951..90a950c5f35 100644 --- a/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js +++ b/spec/frontend/super_sidebar/components/sidebar_peek_behavior_spec.js @@ -2,14 +2,14 @@ import { mount } from '@vue/test-utils'; import { SUPER_SIDEBAR_PEEK_OPEN_DELAY, SUPER_SIDEBAR_PEEK_CLOSE_DELAY, + SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED, + SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN, + SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN, + SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE, } from '~/super_sidebar/constants'; -import SidebarPeek, { - STATE_CLOSED, - STATE_WILL_OPEN, - STATE_OPEN, - STATE_WILL_CLOSE, -} from '~/super_sidebar/components/sidebar_peek_behavior.vue'; +import SidebarPeek from '~/super_sidebar/components/sidebar_peek_behavior.vue'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { moveMouse, moveMouseOutOfDocument } from '../mocks'; // These are measured at runtime in the browser, but statically defined here // since Jest does not do layout/styling. @@ -41,19 +41,6 @@ describe('SidebarPeek component', () => { }); }; - const moveMouse = (clientX) => { - const event = new MouseEvent('mousemove', { - clientX, - }); - - document.dispatchEvent(event); - }; - - const moveMouseOutOfDocument = () => { - const event = new MouseEvent('mouseleave'); - document.documentElement.dispatchEvent(event); - }; - const lastNChangeEvents = (n = 1) => wrapper.emitted('change').slice(-n).flat(); beforeEach(() => { diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js index 7b7b8a7be13..1371f8f00a7 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js @@ -4,29 +4,32 @@ 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 SidebarPeekBehavior, { - STATE_CLOSED, - STATE_WILL_OPEN, - STATE_OPEN, - STATE_WILL_CLOSE, -} from '~/super_sidebar/components/sidebar_peek_behavior.vue'; +import SidebarPeekBehavior from '~/super_sidebar/components/sidebar_peek_behavior.vue'; +import SidebarHoverPeekBehavior from '~/super_sidebar/components/sidebar_hover_peek_behavior.vue'; import SidebarPortalTarget from '~/super_sidebar/components/sidebar_portal_target.vue'; -import ContextHeader from '~/super_sidebar/components/context_header.vue'; -import ContextSwitcher from '~/super_sidebar/components/context_switcher.vue'; import SidebarMenu from '~/super_sidebar/components/sidebar_menu.vue'; -import { sidebarState } from '~/super_sidebar/constants'; +import { + sidebarState, + SUPER_SIDEBAR_PEEK_STATE_CLOSED as STATE_CLOSED, + SUPER_SIDEBAR_PEEK_STATE_WILL_OPEN as STATE_WILL_OPEN, + SUPER_SIDEBAR_PEEK_STATE_OPEN as STATE_OPEN, + SUPER_SIDEBAR_PEEK_STATE_WILL_CLOSE as STATE_WILL_CLOSE, +} from '~/super_sidebar/constants'; import { toggleSuperSidebarCollapsed, isCollapsed, } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; -import { stubComponent } from 'helpers/stub_component'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { trackContextAccess } from '~/super_sidebar/utils'; import { sidebarData as mockSidebarData, loggedOutSidebarData } from '../mock_data'; const initialSidebarState = { ...sidebarState }; jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager'); -const closeContextSwitcherMock = jest.fn(); +jest.mock('~/super_sidebar/utils', () => ({ + ...jest.requireActual('~/super_sidebar/utils'), + trackContextAccess: jest.fn(), +})); const trialStatusWidgetStubTestId = 'trial-status-widget'; const TrialStatusWidgetStub = { template: `<div data-testid="${trialStatusWidgetStubTestId}" />` }; @@ -36,6 +39,7 @@ const TrialStatusPopoverStub = { }; const peekClass = 'super-sidebar-peek'; +const hasPeekedClass = 'super-sidebar-has-peeked'; const peekHintClass = 'super-sidebar-peek-hint'; describe('SuperSidebar component', () => { @@ -43,12 +47,11 @@ describe('SuperSidebar component', () => { const findSidebar = () => wrapper.findByTestId('super-sidebar'); const findUserBar = () => wrapper.findComponent(UserBar); - const findContextHeader = () => wrapper.findComponent(ContextHeader); - const findContextSwitcher = () => wrapper.findComponent(ContextSwitcher); const findNavContainer = () => wrapper.findByTestId('nav-container'); const findHelpCenter = () => wrapper.findComponent(HelpCenter); const findSidebarPortalTarget = () => wrapper.findComponent(SidebarPortalTarget); const findPeekBehavior = () => wrapper.findComponent(SidebarPeekBehavior); + const findHoverPeekBehavior = () => wrapper.findComponent(SidebarHoverPeekBehavior); const findTrialStatusWidget = () => wrapper.findByTestId(trialStatusWidgetStubTestId); const findTrialStatusPopover = () => wrapper.findByTestId(trialStatusPopoverStubTestId); const findSidebarMenu = () => wrapper.findComponent(SidebarMenu); @@ -70,9 +73,6 @@ describe('SuperSidebar component', () => { sidebarData, }, stubs: { - ContextSwitcher: stubComponent(ContextSwitcher, { - methods: { close: closeContextSwitcherMock }, - }), TrialStatusWidget: TrialStatusWidgetStub, TrialStatusPopover: TrialStatusPopoverStub, }, @@ -128,12 +128,6 @@ describe('SuperSidebar component', () => { expect(findSidebarPortalTarget().exists()).toBe(true); }); - it("does not call the context switcher's close method initially", () => { - createWrapper(); - - expect(closeContextSwitcherMock).not.toHaveBeenCalled(); - }); - it('renders hidden shortcut links', () => { createWrapper(); const [linkAttrs] = mockSidebarData.shortcut_links; @@ -181,21 +175,43 @@ describe('SuperSidebar component', () => { expect(findTrialStatusPopover().exists()).toBe(false); }); - it('does not have peek behavior', () => { + it('does not have peek behaviors', () => { createWrapper(); expect(findPeekBehavior().exists()).toBe(false); + expect(findHoverPeekBehavior().exists()).toBe(false); }); - }); - describe('on collapse', () => { - beforeEach(() => { + it('renders the context header', () => { createWrapper(); - sidebarState.isCollapsed = true; + + expect(wrapper.text()).toContain('Your work'); }); - it('closes the context switcher', () => { - expect(closeContextSwitcherMock).toHaveBeenCalled(); + describe('item access tracking', () => { + it('does not track anything if logged out', () => { + createWrapper({ sidebarData: loggedOutSidebarData }); + + expect(trackContextAccess).not.toHaveBeenCalled(); + }); + + it('does not track anything if logged in and not within a trackable context', () => { + createWrapper(); + + expect(trackContextAccess).not.toHaveBeenCalled(); + }); + + it('tracks item access if logged in within a trackable context', () => { + const currentContext = { namespace: 'groups' }; + createWrapper({ + sidebarData: { + ...mockSidebarData, + current_context: currentContext, + }, + }); + + expect(trackContextAccess).toHaveBeenCalledWith('root', currentContext, '/-/track_visits'); + }); }); }); @@ -205,6 +221,7 @@ describe('SuperSidebar component', () => { expect(findSidebar().attributes('inert')).toBe('inert'); expect(findSidebar().classes()).not.toContain(peekHintClass); + expect(findSidebar().classes()).not.toContain(hasPeekedClass); expect(findSidebar().classes()).not.toContain(peekClass); }); @@ -216,6 +233,7 @@ describe('SuperSidebar component', () => { expect(findSidebar().attributes('inert')).toBe('inert'); expect(findSidebar().classes()).toContain(peekHintClass); + expect(findSidebar().classes()).toContain(hasPeekedClass); expect(findSidebar().classes()).not.toContain(peekClass); }); @@ -230,9 +248,23 @@ describe('SuperSidebar component', () => { expect(findSidebar().attributes('inert')).toBe(undefined); expect(findSidebar().classes()).toContain(peekClass); expect(findSidebar().classes()).not.toContain(peekHintClass); + expect(findHoverPeekBehavior().exists()).toBe(false); }, ); + it(`makes sidebar interactive and visible when hover peek state is ${STATE_OPEN}`, async () => { + createWrapper({ sidebarState: { isCollapsed: true, isPeekable: true } }); + + findHoverPeekBehavior().vm.$emit('change', STATE_OPEN); + await nextTick(); + + expect(findSidebar().attributes('inert')).toBe(undefined); + expect(findSidebar().classes()).toContain(peekClass); + expect(findSidebar().classes()).toContain(hasPeekedClass); + expect(findSidebar().classes()).not.toContain(peekHintClass); + expect(findPeekBehavior().exists()).toBe(false); + }); + it('keeps track of if sidebar has mouseover or not', async () => { createWrapper({ sidebarState: { isCollapsed: false, isPeekable: true } }); expect(findPeekBehavior().props('isMouseOverSidebar')).toBe(false); @@ -248,16 +280,9 @@ describe('SuperSidebar component', () => { createWrapper(); }); - it('allows overflow while the context switcher is closed', () => { + it('allows overflow', () => { expect(findNavContainer().classes()).toContain('gl-overflow-auto'); }); - - it('hides overflow when context switcher is opened', async () => { - findContextSwitcher().vm.$emit('toggle', true); - await nextTick(); - - expect(findNavContainer().classes()).not.toContain('gl-overflow-auto'); - }); }); describe('when a trial is active', () => { @@ -271,14 +296,10 @@ describe('SuperSidebar component', () => { }); }); - describe('Logged out', () => { - beforeEach(() => { - createWrapper({ sidebarData: loggedOutSidebarData }); - }); - - it('renders context header instead of context switcher', () => { - expect(findContextHeader().exists()).toBe(true); - expect(findContextSwitcher().exists()).toBe(false); + describe('ARIA attributes', () => { + it('adds aria-label attribute to nav element', () => { + createWrapper(); + expect(wrapper.find('nav').attributes('aria-label')).toBe('Primary'); }); }); }); diff --git a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js index 23b735c2773..1f2e5602d10 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_toggle_spec.js @@ -1,6 +1,5 @@ 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'; @@ -46,31 +45,29 @@ describe('SuperSidebarToggle component', () => { 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.each(['isCollapsed', 'isPeek', 'isHoverPeek'])( + 'has aria-expanded as false when %s is `true`', + (stateProp) => { + createWrapper({ sidebarState: { [stateProp]: 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')).toBeDefined(); + expect(findButton().attributes('aria-label')).toBe('Primary navigation sidebar'); }); }); describe('tooltip', () => { it('displays collapse when expanded', () => { createWrapper(); - expect(getTooltip().title).toBe(__('Hide sidebar')); + expect(getTooltip().title).toBe('Hide sidebar'); }); it('displays expand when collapsed', () => { createWrapper({ sidebarState: { isCollapsed: true } }); - expect(getTooltip().title).toBe(__('Show sidebar')); + expect(getTooltip().title).toBe('Keep sidebar visible'); }); }); diff --git a/spec/frontend/super_sidebar/components/user_bar_spec.js b/spec/frontend/super_sidebar/components/user_bar_spec.js index c6dd8441094..b58b65f09f5 100644 --- a/spec/frontend/super_sidebar/components/user_bar_spec.js +++ b/spec/frontend/super_sidebar/components/user_bar_spec.js @@ -65,8 +65,20 @@ describe('UserBar component', () => { createWrapper(); }); - it('passes the "Create new..." menu groups to the create-menu component', () => { - expect(findCreateMenu().props('groups')).toBe(mockSidebarData.create_new_menu_groups); + describe('"Create new..." menu', () => { + describe('when there are no menu items for it', () => { + // This scenario usually happens for an "External" user. + it('does not render it', () => { + createWrapper({ sidebarData: { ...mockSidebarData, create_new_menu_groups: [] } }); + expect(findCreateMenu().exists()).toBe(false); + }); + }); + + describe('when there are menu items for it', () => { + it('passes the "Create new..." menu groups to the create-menu component', () => { + expect(findCreateMenu().props('groups')).toBe(mockSidebarData.create_new_menu_groups); + }); + }); }); it('passes the "Merge request" menu groups to the merge_request_menu component', () => { @@ -165,7 +177,7 @@ describe('UserBar component', () => { it('search button should have tooltip', () => { const tooltip = getBinding(findSearchButton().element, 'gl-tooltip'); - expect(tooltip.value).toBe(`Search GitLab <kbd>/</kbd>`); + expect(tooltip.value).toBe(`Type <kbd>/</kbd> to search`); }); it('should render search modal', () => { @@ -184,7 +196,7 @@ describe('UserBar component', () => { findSearchModal().vm.$emit('hidden'); await nextTick(); const tooltip = getBinding(findSearchButton().element, 'gl-tooltip'); - expect(tooltip.value).toBe(`Search GitLab <kbd>/</kbd>`); + expect(tooltip.value).toBe(`Type <kbd>/</kbd> to search`); }); }); }); diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js index 662677be40f..bcc3383bcd4 100644 --- a/spec/frontend/super_sidebar/components/user_menu_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_spec.js @@ -468,27 +468,6 @@ describe('UserMenu component', () => { }); }); - describe('Feedback item', () => { - let item; - - beforeEach(() => { - createWrapper(); - 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_feedback', - }); - }); - }); - describe('Sign out group', () => { const findSignOutGroup = () => wrapper.findByTestId('sign-out-group'); diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js index 6fb9715824f..d464ce372ed 100644 --- a/spec/frontend/super_sidebar/mock_data.js +++ b/spec/frontend/super_sidebar/mock_data.js @@ -79,10 +79,8 @@ export const contextSwitcherLinks = [ export const sidebarData = { is_logged_in: true, current_menu_items: [], - current_context_header: { - title: 'Your Work', - icon: 'work', - }, + current_context: {}, + current_context_header: 'Your work', name: 'Administrator', username: 'root', avatar_url: 'path/to/img_administrator', @@ -124,15 +122,14 @@ export const sidebarData = { css_class: 'shortcut-link-class', }, ], + track_visits_path: '/-/track_visits', }; export const loggedOutSidebarData = { is_logged_in: false, current_menu_items: [], - current_context_header: { - title: 'Your Work', - icon: 'work', - }, + current_context: {}, + current_context_header: 'Your work', support_path: '/support', display_whats_new: true, whats_new_most_recent_release_items_count: 5, @@ -285,36 +282,3 @@ export const cachedFrequentGroups = JSON.stringify([ frequency: 3, }, ]); - -export const searchUserProjectsAndGroupsResponseMock = { - data: { - projects: { - nodes: [ - { - id: 'gid://gitlab/Project/2', - name: 'Gitlab Shell', - namespace: 'Gitlab Org / Gitlab Shell', - webUrl: 'http://gdk.test:3000/gitlab-org/gitlab-shell', - avatarUrl: null, - __typename: 'Project', - }, - ], - }, - - user: { - id: 'gid://gitlab/User/1', - groups: { - nodes: [ - { - id: 'gid://gitlab/Group/60', - name: 'GitLab Instance', - namespace: 'gitlab-instance-2e4abb29', - webUrl: 'http://gdk.test:3000/groups/gitlab-instance-2e4abb29', - avatarUrl: null, - __typename: 'Group', - }, - ], - }, - }, - }, -}; diff --git a/spec/frontend/super_sidebar/mocks.js b/spec/frontend/super_sidebar/mocks.js new file mode 100644 index 00000000000..d13e5f1f361 --- /dev/null +++ b/spec/frontend/super_sidebar/mocks.js @@ -0,0 +1,24 @@ +export const moveMouse = (clientX) => { + const event = new MouseEvent('mousemove', { + clientX, + }); + + document.dispatchEvent(event); +}; + +export const mouseEnter = (el) => { + const event = new MouseEvent('mouseenter'); + + el.dispatchEvent(event); +}; + +export const mouseLeave = (el) => { + const event = new MouseEvent('mouseleave'); + + el.dispatchEvent(event); +}; + +export const moveMouseOutOfDocument = () => { + const event = new MouseEvent('mouseleave'); + document.documentElement.dispatchEvent(event); +}; diff --git a/spec/frontend/super_sidebar/utils_spec.js b/spec/frontend/super_sidebar/utils_spec.js index 536599e6c12..85f45de06ba 100644 --- a/spec/frontend/super_sidebar/utils_spec.js +++ b/spec/frontend/super_sidebar/utils_spec.js @@ -1,17 +1,20 @@ import * as Sentry from '@sentry/browser'; +import MockAdapter from 'axios-mock-adapter'; import { getTopFrequentItems, trackContextAccess, - formatContextSwitcherItems, getItemsFromLocalStorage, removeItemFromLocalStorage, ariaCurrent, } from '~/super_sidebar/utils'; +import axios from '~/lib/utils/axios_utils'; import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import AccessorUtilities from '~/lib/utils/accessor'; import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants'; +import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import waitForPromises from 'helpers/wait_for_promises'; import { unsortedFrequentItems, sortedFrequentItems } from '../frequent_items/mock_data'; -import { cachedFrequentProjects, searchUserProjectsAndGroupsResponseMock } from './mock_data'; +import { cachedFrequentProjects } from './mock_data'; jest.mock('@sentry/browser'); @@ -42,13 +45,29 @@ describe('Super sidebar utils spec', () => { }); describe('trackContextAccess', () => { + useLocalStorageSpy(); + + let axiosMock; + const username = 'root'; + const trackVisitsPath = '/-/track_visits'; const context = { namespace: 'groups', item: { id: 1 }, }; const storageKey = `${username}/frequent-${context.namespace}`; + beforeEach(() => { + gon.features = { serverSideFrecentNamespaces: true }; + axiosMock = new MockAdapter(axios); + axiosMock.onPost(trackVisitsPath).reply(HTTP_STATUS_OK); + }); + + afterEach(() => { + gon.features = {}; + axiosMock.restore(); + }); + it('returns `false` if local storage is not available', () => { jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); @@ -56,7 +75,7 @@ describe('Super sidebar utils spec', () => { }); it('creates a new item if it does not exist in the local storage', () => { - trackContextAccess(username, context); + trackContextAccess(username, context, trackVisitsPath); expect(window.localStorage.setItem).toHaveBeenCalledWith( storageKey, @@ -70,6 +89,24 @@ describe('Super sidebar utils spec', () => { ); }); + it('sends a POST request to persist the visit in the DB', async () => { + expect(axiosMock.history.post).toHaveLength(0); + + trackContextAccess(username, context, trackVisitsPath); + await waitForPromises(); + + expect(axiosMock.history.post).toHaveLength(1); + expect(axiosMock.history.post[0].url).toBe(trackVisitsPath); + }); + + it('does not send a POST request when the serverSideFrecentNamespaces feature flag is disabled', async () => { + gon.features = { serverSideFrecentNamespaces: false }; + trackContextAccess(username, context, trackVisitsPath); + await waitForPromises(); + + expect(axiosMock.history.post).toHaveLength(0); + }); + it('updates existing item frequency/access time if it was persisted to the local storage over 15 minutes ago', () => { window.localStorage.setItem( storageKey, @@ -81,7 +118,7 @@ describe('Super sidebar utils spec', () => { }, ]), ); - trackContextAccess(username, context); + trackContextAccess(username, context, trackVisitsPath); expect(window.localStorage.setItem).toHaveBeenCalledWith( storageKey, @@ -95,7 +132,7 @@ describe('Super sidebar utils spec', () => { ); }); - it('leaves item frequency/access time as is if it was persisted to the local storage under 15 minutes ago', () => { + it('leaves item frequency/access time as is if it was persisted to the local storage under 15 minutes ago, and does not send a POST request', () => { const jsonString = JSON.stringify([ { id: 1, @@ -108,10 +145,12 @@ describe('Super sidebar utils spec', () => { expect(window.localStorage.setItem).toHaveBeenCalledTimes(1); expect(window.localStorage.setItem).toHaveBeenCalledWith(storageKey, jsonString); - trackContextAccess(username, context); + trackContextAccess(username, context, trackVisitsPath); expect(window.localStorage.setItem).toHaveBeenCalledTimes(3); expect(window.localStorage.setItem).toHaveBeenLastCalledWith(storageKey, jsonString); + + expect(axiosMock.history.post).toHaveLength(0); }); it('always updates stored item metadata', () => { @@ -163,10 +202,14 @@ describe('Super sidebar utils spec', () => { const newItem = { id: FREQUENT_ITEMS.MAX_COUNT + 1, }; - trackContextAccess(username, { - namespace: 'groups', - item: newItem, - }); + trackContextAccess( + username, + { + namespace: 'groups', + item: newItem, + }, + trackVisitsPath, + ); // Finally, retrieve the final data from the local storage const finallyStoredItems = JSON.parse(window.localStorage.getItem(storageKey)); @@ -182,21 +225,6 @@ describe('Super sidebar utils spec', () => { }); }); - describe('formatContextSwitcherItems', () => { - it('returns the formatted items', () => { - const projects = searchUserProjectsAndGroupsResponseMock.data.projects.nodes; - expect(formatContextSwitcherItems(projects)).toEqual([ - { - id: projects[0].id, - avatar: null, - title: projects[0].name, - subtitle: 'Gitlab Org', - link: projects[0].webUrl, - }, - ]); - }); - }); - describe('getItemsFromLocalStorage', () => { const storageKey = 'mockStorageKey'; const maxItems = 5; |