diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-12-19 14:01:45 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-12-19 14:01:45 +0300 |
commit | 9297025d0b7ddf095eb618dfaaab2ff8f2018d8b (patch) | |
tree | 865198c01d1824a9b098127baa3ab980c9cd2c06 /spec/frontend/super_sidebar | |
parent | 6372471f43ee03c05a7c1f8b0c6ac6b8a7431dbe (diff) |
Add latest changes from gitlab-org/gitlab@16-7-stable-eev16.7.0-rc42
Diffstat (limited to 'spec/frontend/super_sidebar')
13 files changed, 432 insertions, 348 deletions
diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js index e63768a03c0..38e1baabf41 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_groups_spec.js @@ -1,14 +1,32 @@ import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import FrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue'; import FrequentGroups from '~/super_sidebar/components/global_search/components/frequent_groups.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import currentUserFrecentGroupsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_groups.query.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; +import { frecentGroupsMock } from '../../../mock_data'; + +Vue.use(VueApollo); describe('FrequentlyVisitedGroups', () => { let wrapper; const groupsPath = '/mock/group/path'; + const currentUserFrecentGroupsQueryHandler = jest.fn().mockResolvedValue({ + data: { + frecentGroups: frecentGroupsMock, + }, + }); const createComponent = (options) => { + const mockApollo = createMockApollo([ + [currentUserFrecentGroupsQuery, currentUserFrecentGroupsQueryHandler], + ]); + wrapper = shallowMount(FrequentGroups, { + apolloProvider: mockApollo, provide: { groupsPath, }, @@ -28,19 +46,25 @@ describe('FrequentlyVisitedGroups', () => { expect(findFrequentItems().props()).toMatchObject({ emptyStateText: 'Groups you visit often will appear here.', groupName: 'Frequently visited groups', - maxItems: 3, - storageKey: null, viewAllItemsIcon: 'group', viewAllItemsText: 'View all my groups', viewAllItemsPath: groupsPath, }); }); - it('with a user, passes a storage key string to FrequentItems', () => { - gon.current_username = 'test_user'; + it('loads frecent groups', () => { + createComponent(); + + expect(currentUserFrecentGroupsQueryHandler).toHaveBeenCalled(); + expect(findFrequentItems().props('loading')).toBe(true); + }); + + it('passes fetched groups to FrequentItems', async () => { createComponent(); + await waitForPromises(); - expect(findFrequentItems().props('storageKey')).toBe('test_user/frequent-groups'); + expect(findFrequentItems().props('items')).toEqual(frecentGroupsMock); + expect(findFrequentItems().props('loading')).toBe(false); }); it('passes attrs to FrequentItems', () => { diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js index aae1fc543f9..b48a9ca6457 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_item_spec.js @@ -28,7 +28,6 @@ describe('FrequentlyVisitedItem', () => { }; const findProjectAvatar = () => wrapper.findComponent(ProjectAvatar); - const findRemoveButton = () => wrapper.findByRole('button'); const findSubtitle = () => wrapper.findByTestId('subtitle'); beforeEach(() => { @@ -53,46 +52,4 @@ describe('FrequentlyVisitedItem', () => { await wrapper.setProps({ item: { ...mockItem, subtitle: null } }); expect(findSubtitle().exists()).toBe(false); }); - - describe('clicking the remove button', () => { - const bubbledClickSpy = jest.fn(); - const clickSpy = jest.fn(); - - beforeEach(() => { - wrapper.element.addEventListener('click', bubbledClickSpy); - const button = findRemoveButton(); - button.element.addEventListener('click', clickSpy); - button.trigger('click'); - }); - - it('emits a remove event on clicking the remove button', () => { - expect(wrapper.emitted('remove')).toEqual([[mockItem]]); - }); - - it('stops the native event from bubbling and prevents its default behavior', () => { - expect(bubbledClickSpy).not.toHaveBeenCalled(); - expect(clickSpy.mock.calls[0][0].defaultPrevented).toBe(true); - }); - }); - - describe('pressing enter on the remove button', () => { - const bubbledKeydownSpy = jest.fn(); - const keydownSpy = jest.fn(); - - beforeEach(() => { - wrapper.element.addEventListener('keydown', bubbledKeydownSpy); - const button = findRemoveButton(); - button.element.addEventListener('keydown', keydownSpy); - button.trigger('keydown.enter'); - }); - - it('emits a remove event on clicking the remove button', () => { - expect(wrapper.emitted('remove')).toEqual([[mockItem]]); - }); - - it('stops the native event from bubbling and prevents its default behavior', () => { - expect(bubbledKeydownSpy).not.toHaveBeenCalled(); - expect(keydownSpy.mock.calls[0][0].defaultPrevented).toBe(true); - }); - }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js index 4700e9c7e10..7876dd92701 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_items_spec.js @@ -2,28 +2,14 @@ import { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlIcon } from '@gi import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import GlobalSearchFrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue'; import FrequentItem from '~/super_sidebar/components/global_search/components/frequent_item.vue'; -import { getItemsFromLocalStorage, removeItemFromLocalStorage } from '~/super_sidebar/utils'; -import { cachedFrequentProjects } from 'jest/super_sidebar/mock_data'; - -jest.mock('~/super_sidebar/utils', () => { - const original = jest.requireActual('~/super_sidebar/utils'); - - return { - ...original, - getItemsFromLocalStorage: jest.fn(), - removeItemFromLocalStorage: jest.fn(), - }; -}); +import FrequentItemSkeleton from '~/super_sidebar/components/global_search/components/frequent_item_skeleton.vue'; +import { frecentGroupsMock } from 'jest/super_sidebar/mock_data'; describe('FrequentlyVisitedItems', () => { let wrapper; - const storageKey = 'mockStorageKey'; - const mockStoredItems = JSON.parse(cachedFrequentProjects); const mockProps = { emptyStateText: 'mock empty state text', groupName: 'mock group name', - maxItems: 42, - storageKey, viewAllItemsText: 'View all items', viewAllItemsIcon: 'question-o', viewAllItemsPath: '/mock/all_items', @@ -42,118 +28,97 @@ describe('FrequentlyVisitedItems', () => { }; const findItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); + const findSkeleton = () => wrapper.findComponent(FrequentItemSkeleton); const findItemRenderer = (root) => root.findComponent(FrequentItem); - const setStoredItems = (items) => { - getItemsFromLocalStorage.mockReturnValue(items); - }; + describe('common behavior', () => { + beforeEach(() => { + createComponent({ + items: frecentGroupsMock, + }); + }); - beforeEach(() => { - setStoredItems(mockStoredItems); + it('renders the group name', () => { + expect(wrapper.text()).toContain(mockProps.groupName); + }); + + it('renders the view all items link', () => { + const lastItem = findItems().at(-1); + expect(lastItem.props('item')).toMatchObject({ + text: mockProps.viewAllItemsText, + href: mockProps.viewAllItemsPath, + }); + + const icon = lastItem.findComponent(GlIcon); + expect(icon.props('name')).toBe(mockProps.viewAllItemsIcon); + }); }); - describe('without a storage key', () => { + describe('while items are being fetched', () => { beforeEach(() => { - createComponent({ storageKey: null }); + createComponent({ + loading: true, + }); }); - it('does not render anything', () => { - expect(wrapper.html()).toBe(''); + it('shows the loading state', () => { + expect(findSkeleton().exists()).toBe(true); }); - it('emits a nothing-to-render event', () => { - expect(wrapper.emitted('nothing-to-render')).toEqual([[]]); + it('does not show the empty state', () => { + expect(wrapper.text()).not.toContain(mockProps.emptyStateText); }); }); - describe('with a storageKey', () => { + describe('when there are no items', () => { beforeEach(() => { createComponent(); }); - describe('common behavior', () => { - it('calls getItemsFromLocalStorage', () => { - expect(getItemsFromLocalStorage).toHaveBeenCalledWith({ - storageKey, - maxItems: mockProps.maxItems, - }); - }); - - it('renders the group name', () => { - expect(wrapper.text()).toContain(mockProps.groupName); - }); - - it('renders the view all items link', () => { - const lastItem = findItems().at(-1); - expect(lastItem.props('item')).toMatchObject({ - text: mockProps.viewAllItemsText, - href: mockProps.viewAllItemsPath, - }); - - const icon = lastItem.findComponent(GlIcon); - expect(icon.props('name')).toBe(mockProps.viewAllItemsIcon); - }); + it('does not show the loading state', () => { + expect(findSkeleton().exists()).toBe(false); }); - describe('with stored items', () => { - it('renders the items', () => { - const items = findItems(); - - mockStoredItems.forEach((storedItem, index) => { - const dropdownItem = items.at(index); - - // Check GlDisclosureDropdownItem's item has the right structure - expect(dropdownItem.props('item')).toMatchObject({ - text: storedItem.name, - href: storedItem.webUrl, - }); - - // Check FrequentItem's item has the right structure - expect(findItemRenderer(dropdownItem).props('item')).toMatchObject({ - id: storedItem.id, - title: storedItem.name, - subtitle: expect.any(String), - avatar: storedItem.avatarUrl, - }); - }); - }); + it('shows the empty state', () => { + expect(wrapper.text()).toContain(mockProps.emptyStateText); + }); + }); - it('does not render the empty state text', () => { - expect(wrapper.text()).not.toContain('mock empty state text'); + describe('when there are items', () => { + beforeEach(() => { + createComponent({ + items: frecentGroupsMock, }); + }); - describe('removing an item', () => { - let itemToRemove; + it('renders the items', () => { + const items = findItems(); - beforeEach(() => { - const itemRenderer = findItemRenderer(findItems().at(0)); - itemToRemove = itemRenderer.props('item'); - itemRenderer.vm.$emit('remove', itemToRemove); - }); + frecentGroupsMock.forEach((item, index) => { + const dropdownItem = items.at(index); - it('calls removeItemFromLocalStorage when an item emits a remove event', () => { - expect(removeItemFromLocalStorage).toHaveBeenCalledWith({ - storageKey, - item: itemToRemove, - }); + // Check GlDisclosureDropdownItem's item has the right structure + expect(dropdownItem.props('item')).toMatchObject({ + text: item.name, + href: item.webUrl, }); - it('no longer renders that item', () => { - const renderedItemTexts = findItems().wrappers.map((item) => item.props('item').text); - expect(renderedItemTexts).not.toContain(itemToRemove.text); + // Check FrequentItem's item has the right structure + expect(findItemRenderer(dropdownItem).props('item')).toMatchObject({ + id: item.id, + title: item.name, + subtitle: expect.any(String), + avatar: item.avatarUrl, }); }); }); - }); - describe('with no stored items', () => { - beforeEach(() => { - setStoredItems([]); - createComponent(); + it('does not show the loading state', () => { + expect(findSkeleton().exists()).toBe(false); }); - it('renders the empty state text', () => { - expect(wrapper.text()).toContain(mockProps.emptyStateText); + it('does not show the empty state', () => { + expect(wrapper.text()).not.toContain(mockProps.emptyStateText); }); }); }); diff --git a/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js b/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js index 7554c123574..b7123f295f7 100644 --- a/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js +++ b/spec/frontend/super_sidebar/components/global_search/components/frequent_projects_spec.js @@ -1,14 +1,32 @@ import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import FrequentItems from '~/super_sidebar/components/global_search/components/frequent_items.vue'; import FrequentProjects from '~/super_sidebar/components/global_search/components/frequent_projects.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import currentUserFrecentProjectsQuery from '~/super_sidebar/graphql/queries/current_user_frecent_projects.query.graphql'; +import waitForPromises from 'helpers/wait_for_promises'; +import { frecentProjectsMock } from '../../../mock_data'; + +Vue.use(VueApollo); describe('FrequentlyVisitedProjects', () => { let wrapper; const projectsPath = '/mock/project/path'; + const currentUserFrecentProjectsQueryHandler = jest.fn().mockResolvedValue({ + data: { + frecentProjects: frecentProjectsMock, + }, + }); const createComponent = (options) => { + const mockApollo = createMockApollo([ + [currentUserFrecentProjectsQuery, currentUserFrecentProjectsQueryHandler], + ]); + wrapper = shallowMount(FrequentProjects, { + apolloProvider: mockApollo, provide: { projectsPath, }, @@ -28,19 +46,25 @@ describe('FrequentlyVisitedProjects', () => { expect(findFrequentItems().props()).toMatchObject({ emptyStateText: 'Projects you visit often will appear here.', groupName: 'Frequently visited projects', - maxItems: 5, - storageKey: null, viewAllItemsIcon: 'project', viewAllItemsText: 'View all my projects', viewAllItemsPath: projectsPath, }); }); - it('with a user, passes a storage key string to FrequentItems', () => { - gon.current_username = 'test_user'; + it('loads frecent projects', () => { + createComponent(); + + expect(currentUserFrecentProjectsQueryHandler).toHaveBeenCalled(); + expect(findFrequentItems().props('loading')).toBe(true); + }); + + it('passes fetched projects to FrequentItems', async () => { createComponent(); + await waitForPromises(); - expect(findFrequentItems().props('storageKey')).toBe('test_user/frequent-projects'); + expect(findFrequentItems().props('items')).toEqual(frecentProjectsMock); + expect(findFrequentItems().props('loading')).toBe(false); }); it('passes attrs to FrequentItems', () => { diff --git a/spec/frontend/super_sidebar/components/help_center_spec.js b/spec/frontend/super_sidebar/components/help_center_spec.js index 39537b65fa5..8e9e3e8ba20 100644 --- a/spec/frontend/super_sidebar/components/help_center_spec.js +++ b/spec/frontend/super_sidebar/components/help_center_spec.js @@ -94,7 +94,6 @@ describe('HelpCenter component', () => { it('passes custom offset to the dropdown', () => { expect(findDropdown().props('dropdownOffset')).toEqual({ - crossAxis: -4, mainAxis: 4, }); }); @@ -169,14 +168,13 @@ describe('HelpCenter component', () => { describe('showWhatsNew', () => { beforeEach(() => { - beforeEach(() => { - createWrapper({ ...sidebarData, show_version_check: true }); - }); + createWrapper({ ...sidebarData, show_version_check: true }); + findButton("What's new 5").click(); }); it('shows the "What\'s new" slideout', () => { - expect(toggleWhatsNewDrawer).toHaveBeenCalledWith(expect.any(Object)); + expect(toggleWhatsNewDrawer).toHaveBeenCalledWith(sidebarData.whats_new_version_digest); }); it('shows the existing "What\'s new" slideout instance on subsequent clicks', () => { diff --git a/spec/frontend/super_sidebar/components/nav_item_link_spec.js b/spec/frontend/super_sidebar/components/nav_item_link_spec.js index 5cc1bd01d0f..59fa6d022ae 100644 --- a/spec/frontend/super_sidebar/components/nav_item_link_spec.js +++ b/spec/frontend/super_sidebar/components/nav_item_link_spec.js @@ -29,7 +29,7 @@ describe('NavItemLink component', () => { expect(wrapper.attributes()).toEqual({ href: '/foo', - class: 'gl-bg-t-gray-a-08', + class: 'super-sidebar-nav-item-current', 'aria-current': 'page', }); }); diff --git a/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js b/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js index a7ca56325fe..dfae5e96cd8 100644 --- a/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js +++ b/spec/frontend/super_sidebar/components/nav_item_router_link_spec.js @@ -45,7 +45,9 @@ describe('NavItemRouterLink component', () => { routerLinkSlotProps: { isActive: true }, }); - expect(wrapper.findComponent(RouterLinkStub).props('activeClass')).toBe('gl-bg-t-gray-a-08'); + expect(wrapper.findComponent(RouterLinkStub).props('activeClass')).toBe( + 'super-sidebar-nav-item-current', + ); expect(wrapper.attributes()).toEqual({ href: '/foo', 'aria-current': 'page', diff --git a/spec/frontend/super_sidebar/components/scroll_scrim_spec.js b/spec/frontend/super_sidebar/components/scroll_scrim_spec.js new file mode 100644 index 00000000000..ff1e9968f9b --- /dev/null +++ b/spec/frontend/super_sidebar/components/scroll_scrim_spec.js @@ -0,0 +1,60 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ScrollScrim from '~/super_sidebar/components/scroll_scrim.vue'; +import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; + +describe('ScrollScrim', () => { + let wrapper; + const { trigger: triggerIntersection } = useMockIntersectionObserver(); + + const createWrapper = () => { + wrapper = shallowMountExtended(ScrollScrim, {}); + }; + + beforeEach(() => { + createWrapper(); + }); + + const findTopBoundary = () => wrapper.vm.$refs['top-boundary']; + const findBottomBoundary = () => wrapper.vm.$refs['bottom-boundary']; + + describe('top scrim', () => { + describe('when top boundary is visible', () => { + it('does not show', async () => { + triggerIntersection(findTopBoundary(), { entry: { isIntersecting: true } }); + await nextTick(); + + expect(wrapper.classes()).not.toContain('top-scrim-visible'); + }); + }); + + describe('when top boundary is not visible', () => { + it('does show', async () => { + triggerIntersection(findTopBoundary(), { entry: { isIntersecting: false } }); + await nextTick(); + + expect(wrapper.classes()).toContain('top-scrim-visible'); + }); + }); + }); + + describe('bottom scrim', () => { + describe('when bottom boundary is visible', () => { + it('does not show', async () => { + triggerIntersection(findBottomBoundary(), { entry: { isIntersecting: true } }); + await nextTick(); + + expect(wrapper.classes()).not.toContain('bottom-scrim-visible'); + }); + }); + + describe('when bottom boundary is not visible', () => { + it('does show', async () => { + triggerIntersection(findBottomBoundary(), { entry: { isIntersecting: false } }); + await nextTick(); + + expect(wrapper.classes()).toContain('bottom-scrim-visible'); + }); + }); + }); +}); diff --git a/spec/frontend/super_sidebar/components/super_sidebar_spec.js b/spec/frontend/super_sidebar/components/super_sidebar_spec.js index 92736b99e14..9718cb7ad15 100644 --- a/spec/frontend/super_sidebar/components/super_sidebar_spec.js +++ b/spec/frontend/super_sidebar/components/super_sidebar_spec.js @@ -1,4 +1,7 @@ import { nextTick } from 'vue'; +import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; +import sidebarEventHub from '~/super_sidebar/event_hub'; +import ExtraInfo from 'jh_else_ce/super_sidebar/components/extra_info.vue'; import { Mousetrap } from '~/lib/mousetrap'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import SuperSidebar from '~/super_sidebar/components/super_sidebar.vue'; @@ -23,6 +26,7 @@ import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { trackContextAccess } from '~/super_sidebar/utils'; import { sidebarData as mockSidebarData, loggedOutSidebarData } from '../mock_data'; +const { lg, xl } = breakpoints; const initialSidebarState = { ...sidebarState }; jest.mock('~/super_sidebar/super_sidebar_collapsed_state_manager'); @@ -56,6 +60,8 @@ describe('SuperSidebar component', () => { const findTrialStatusWidget = () => wrapper.findByTestId(trialStatusWidgetStubTestId); const findTrialStatusPopover = () => wrapper.findByTestId(trialStatusPopoverStubTestId); const findSidebarMenu = () => wrapper.findComponent(SidebarMenu); + const findAdminLink = () => wrapper.findByTestId('sidebar-admin-link'); + const findContextHeader = () => wrapper.findComponent('#super-sidebar-context-header'); let trackingSpy = null; const createWrapper = ({ @@ -128,6 +134,11 @@ describe('SuperSidebar component', () => { expect(findHelpCenter().props('sidebarData')).toBe(mockSidebarData); }); + it('renders extra info section', () => { + createWrapper(); + expect(wrapper.findComponent(ExtraInfo).exists()).toBe(true); + }); + it('does not render SidebarMenu when items are empty', () => { createWrapper(); expect(findSidebarMenu().exists()).toBe(false); @@ -207,6 +218,15 @@ describe('SuperSidebar component', () => { expect(wrapper.text()).toContain('Your work'); }); + it('handles event toggle-menu-header correctly', async () => { + createWrapper(); + + sidebarEventHub.$emit('toggle-menu-header', false); + + await nextTick(); + expect(findContextHeader().exists()).toBe(false); + }); + describe('item access tracking', () => { it('does not track anything if logged out', () => { createWrapper({ sidebarData: loggedOutSidebarData }); @@ -299,8 +319,8 @@ describe('SuperSidebar component', () => { createWrapper(); }); - it('allows overflow', () => { - expect(findNavContainer().classes()).toContain('gl-overflow-auto'); + it('allows overflow with scroll scrim', () => { + expect(findNavContainer().element.tagName).toContain('SCROLL-SCRIM'); }); }); @@ -314,4 +334,46 @@ describe('SuperSidebar component', () => { expect(findTrialStatusPopover().exists()).toBe(true); }); }); + + describe('keyboard interactivity', () => { + it('does not bind keydown events on screens xl and above', async () => { + jest.spyOn(document, 'addEventListener'); + jest.spyOn(bp, 'windowWidth').mockReturnValue(xl); + createWrapper(); + + isCollapsed.mockReturnValue(false); + await nextTick(); + + expect(document.addEventListener).not.toHaveBeenCalled(); + }); + + it('binds keydown events on screens below xl', () => { + jest.spyOn(document, 'addEventListener'); + jest.spyOn(bp, 'windowWidth').mockReturnValue(lg); + createWrapper(); + + expect(document.addEventListener).toHaveBeenCalledWith('keydown', wrapper.vm.focusTrap); + }); + }); + + describe('link to Admin area', () => { + describe('when user is admin', () => { + it('renders', () => { + createWrapper({ + sidebarData: { + ...mockSidebarData, + is_admin: true, + }, + }); + expect(findAdminLink().attributes('href')).toBe(mockSidebarData.admin_url); + }); + }); + + describe('when user is not admin', () => { + it('renders', () => { + createWrapper(); + expect(findAdminLink().exists()).toBe(false); + }); + }); + }); }); diff --git a/spec/frontend/super_sidebar/components/user_menu_spec.js b/spec/frontend/super_sidebar/components/user_menu_spec.js index 45a60fce00a..4af3247693b 100644 --- a/spec/frontend/super_sidebar/components/user_menu_spec.js +++ b/spec/frontend/super_sidebar/components/user_menu_spec.js @@ -1,8 +1,10 @@ import { GlAvatar, GlDisclosureDropdown } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { stubComponent } from 'helpers/stub_component'; import UserMenu from '~/super_sidebar/components/user_menu.vue'; import UserMenuProfileItem from '~/super_sidebar/components/user_menu_profile_item.vue'; +import SetStatusModal from '~/set_status_modal/set_status_modal_wrapper.vue'; import { mockTracking } from 'helpers/tracking_helper'; import PersistentUserCallout from '~/persistent_user_callout'; import { userMenuMockData, userMenuMockStatus, userMenuMockPipelineMinutes } from '../mock_data'; @@ -13,6 +15,7 @@ describe('UserMenu component', () => { const GlEmoji = { template: '<img/>' }; const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown); + const findSetStatusModal = () => wrapper.findComponent(SetStatusModal); const showDropdown = () => findDropdown().vm.$emit('shown'); const closeDropdownSpy = jest.fn(); @@ -28,6 +31,7 @@ describe('UserMenu component', () => { stubs: { GlEmoji, GlAvatar: true, + SetStatusModal: stubComponent(SetStatusModal), ...stubs, }, provide: { @@ -74,6 +78,20 @@ describe('UserMenu component', () => { }); }); + it('updates avatar url on custom avatar update event', async () => { + const url = `${userMenuMockData.avatar_url}-new-avatar`; + + document.dispatchEvent(new CustomEvent('userAvatar:update', { detail: { url } })); + await nextTick(); + + const avatar = toggle.findComponent(GlAvatar); + expect(avatar.exists()).toBe(true); + expect(avatar.props()).toMatchObject({ + entityName: userMenuMockData.name, + src: url, + }); + }); + it('renders screen reader text', () => { expect(toggle.find('.gl-sr-only').text()).toBe(`${userMenuMockData.name} user’s menu`); }); @@ -91,31 +109,46 @@ describe('UserMenu component', () => { describe('User status item', () => { let item; - const setItem = ({ can_update, busy, customized, stubs } = {}) => { - createWrapper({ status: { ...userMenuMockStatus, can_update, busy, customized } }, stubs); + const setItem = async ({ + can_update: canUpdate = false, + busy = false, + customized = false, + stubs, + } = {}) => { + createWrapper( + { status: { ...userMenuMockStatus, can_update: canUpdate, busy, customized } }, + stubs, + ); + // Mock mounting the modal if we can update + if (canUpdate) { + expect(wrapper.vm.setStatusModalReady).toEqual(false); + findSetStatusModal().vm.$emit('mounted'); + await nextTick(); + expect(wrapper.vm.setStatusModalReady).toEqual(true); + } item = wrapper.findByTestId('status-item'); }; describe('When user cannot update the status', () => { - it('does not render the status menu item', () => { - setItem(); + it('does not render the status menu item', async () => { + await setItem(); expect(item.exists()).toBe(false); }); }); describe('When user can update the status', () => { - it('renders the status menu item', () => { - setItem({ can_update: true }); + it('renders the status menu item', async () => { + await setItem({ can_update: true }); expect(item.exists()).toBe(true); + expect(item.find('button').attributes()).toMatchObject({ + 'data-track-property': 'nav_user_menu', + 'data-track-action': 'click_link', + 'data-track-label': 'user_edit_status', + }); }); - it('should set the CSS class for triggering status update modal', () => { - setItem({ can_update: true }); - expect(item.find('.js-set-status-modal-trigger').exists()).toBe(true); - }); - - it('should close the dropdown when status modal opened', () => { - setItem({ + it('should close the dropdown when status modal opened', async () => { + await setItem({ can_update: true, stubs: { GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, { @@ -139,57 +172,75 @@ describe('UserMenu component', () => { ${true} | ${true} | ${'Edit status'} `( 'when busy is "$busy" and customized is "$customized" the label is "$label"', - ({ busy, customized, label }) => { - setItem({ can_update: true, busy, customized }); + async ({ busy, customized, label }) => { + await setItem({ can_update: true, busy, customized }); expect(item.text()).toBe(label); }, ); }); + }); + }); + + describe('set status modal', () => { + describe('when the user cannot update the status', () => { + it('should not render the modal', () => { + createWrapper({ + status: { ...userMenuMockStatus, can_update: false }, + }); - describe('Status update modal wrapper', () => { - const findModalWrapper = () => wrapper.find('.js-set-status-modal-wrapper'); + expect(findSetStatusModal().exists()).toBe(false); + }); + }); - it('renders the modal wrapper', () => { - setItem({ can_update: true }); - expect(findModalWrapper().exists()).toBe(true); + describe('when the user can update the status', () => { + describe.each` + busy | customized + ${true} | ${true} + ${true} | ${false} + ${false} | ${true} + `('and the status is busy or customized', ({ busy, customized }) => { + it('should pass the current status to the modal', () => { + createWrapper({ + status: { ...userMenuMockStatus, can_update: true, busy, customized }, + }); + + expect(findSetStatusModal().exists()).toBe(true); + expect(findSetStatusModal().props()).toMatchObject({ + defaultEmoji: 'speech_balloon', + currentEmoji: userMenuMockStatus.emoji, + currentMessage: userMenuMockStatus.message, + currentAvailability: userMenuMockStatus.availability, + currentClearStatusAfter: userMenuMockStatus.clear_after, + }); }); - describe('when user cannot update status', () => { - it('sets default data attributes', () => { - setItem({ can_update: true }); - expect(findModalWrapper().attributes()).toMatchObject({ - 'data-current-emoji': '', - 'data-current-message': '', - 'data-default-emoji': 'speech_balloon', - }); + it('casts falsey values to empty strings', () => { + createWrapper({ + status: { can_update: true, busy, customized }, + }); + + expect(findSetStatusModal().exists()).toBe(true); + expect(findSetStatusModal().props()).toMatchObject({ + defaultEmoji: 'speech_balloon', + currentEmoji: '', + currentMessage: '', + currentAvailability: '', + currentClearStatusAfter: '', }); }); + }); + + describe('and the status is neither busy nor customized', () => { + it('should pass an empty status to the modal', () => { + createWrapper({ + status: { ...userMenuMockStatus, can_update: true, busy: false, customized: false }, + }); - describe.each` - busy | customized - ${true} | ${true} - ${true} | ${false} - ${false} | ${true} - ${false} | ${false} - `(`when user can update status`, ({ busy, customized }) => { - it(`and ${busy ? 'is busy' : 'is not busy'} and status ${ - customized ? 'is' : 'is not' - } customized sets user status data attributes`, () => { - setItem({ can_update: true, busy, customized }); - if (busy || customized) { - expect(findModalWrapper().attributes()).toMatchObject({ - 'data-current-emoji': userMenuMockStatus.emoji, - 'data-current-message': userMenuMockStatus.message, - 'data-current-availability': userMenuMockStatus.availability, - 'data-current-clear-status-after': userMenuMockStatus.clear_after, - }); - } else { - expect(findModalWrapper().attributes()).toMatchObject({ - 'data-current-emoji': '', - 'data-current-message': '', - 'data-default-emoji': 'speech_balloon', - }); - } + expect(findSetStatusModal().exists()).toBe(true); + expect(findSetStatusModal().props()).toMatchObject({ + defaultEmoji: 'speech_balloon', + currentEmoji: '', + currentMessage: '', }); }); }); diff --git a/spec/frontend/super_sidebar/mock_data.js b/spec/frontend/super_sidebar/mock_data.js index d2d2faedbf8..fc264ad5e0a 100644 --- a/spec/frontend/super_sidebar/mock_data.js +++ b/spec/frontend/super_sidebar/mock_data.js @@ -79,6 +79,8 @@ export const contextSwitcherLinks = [ export const sidebarData = { is_logged_in: true, + is_admin: false, + admin_url: '/admin', current_menu_items: [], current_context: {}, current_context_header: 'Your work', @@ -188,6 +190,26 @@ export const userMenuMockData = { canary_toggle_com_url: 'https://next.gitlab.com', }; +export const frecentGroupsMock = [ + { + id: 'gid://gitlab/Group/1', + name: 'Frecent group 1', + namespace: 'Frecent Namespace 1', + webUrl: '/frecent-namespace-1/frecent-group-1', + avatarUrl: '/uploads/-/avatar1.png', + }, +]; + +export const frecentProjectsMock = [ + { + id: 'gid://gitlab/Project/1', + name: 'Frecent project 1', + namespace: 'Frecent Namespace 1 / Frecent project 1', + webUrl: '/frecent-namespace-1/frecent-project-1', + avatarUrl: '/uploads/-/avatar1.png', + }, +]; + export const cachedFrequentProjects = JSON.stringify([ { id: 1, @@ -283,3 +305,32 @@ export const cachedFrequentGroups = JSON.stringify([ frequency: 3, }, ]); + +export const unsortedFrequentItems = [ + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, +]; + +/** + * This const has a specific order which tests authenticity + * of `getTopFrequentItems` method so + * DO NOT change order of items in this const. + */ +export const sortedFrequentItems = [ + { id: 10, frequency: 46, lastAccessedOn: 1483251641543 }, + { id: 3, frequency: 44, lastAccessedOn: 1497675908472 }, + { id: 7, frequency: 42, lastAccessedOn: 1486815299875 }, + { id: 5, frequency: 34, lastAccessedOn: 1488089211943 }, + { id: 8, frequency: 33, lastAccessedOn: 1500762279114 }, + { id: 6, frequency: 14, lastAccessedOn: 1493517292488 }, + { id: 2, frequency: 14, lastAccessedOn: 1488240890738 }, + { id: 1, frequency: 12, lastAccessedOn: 1491400843391 }, + { id: 4, frequency: 8, lastAccessedOn: 1497979281815 }, +]; diff --git a/spec/frontend/super_sidebar/user_counts_manager_spec.js b/spec/frontend/super_sidebar/user_counts_manager_spec.js index b5074620195..3b2ee5b0991 100644 --- a/spec/frontend/super_sidebar/user_counts_manager_spec.js +++ b/spec/frontend/super_sidebar/user_counts_manager_spec.js @@ -6,6 +6,7 @@ import { userCounts, destroyUserCountsManager, } from '~/super_sidebar/user_counts_manager'; +import { fetchUserCounts } from '~/super_sidebar/user_counts_fetch'; jest.mock('~/api'); @@ -118,15 +119,30 @@ describe('User Merge Requests', () => { createUserCountsManager(); }); - it('fetches counts from API, stores and rebroadcasts them', async () => { - expect(userCounts).toMatchObject(userCountDefaults); + describe('manually created event', () => { + it('fetches counts from API, stores and rebroadcasts them', async () => { + expect(userCounts).toMatchObject(userCountDefaults); + + document.dispatchEvent(new CustomEvent('userCounts:fetch')); + await waitForPromises(); + + expect(UserApi.getUserCounts).toHaveBeenCalled(); + expect(userCounts).toMatchObject(userCountUpdate); + expect(channelMock.postMessage).toHaveBeenLastCalledWith(userCounts); + }); + }); + + describe('fetchUserCounts helper', () => { + it('fetches counts from API, stores and rebroadcasts them', async () => { + expect(userCounts).toMatchObject(userCountDefaults); - document.dispatchEvent(new CustomEvent('userCounts:fetch')); - await waitForPromises(); + fetchUserCounts(); + await waitForPromises(); - expect(UserApi.getUserCounts).toHaveBeenCalled(); - expect(userCounts).toMatchObject(userCountUpdate); - expect(channelMock.postMessage).toHaveBeenLastCalledWith(userCounts); + expect(UserApi.getUserCounts).toHaveBeenCalled(); + expect(userCounts).toMatchObject(userCountUpdate); + expect(channelMock.postMessage).toHaveBeenLastCalledWith(userCounts); + }); }); }); diff --git a/spec/frontend/super_sidebar/utils_spec.js b/spec/frontend/super_sidebar/utils_spec.js index 43eb82f5928..a9e4345f9cc 100644 --- a/spec/frontend/super_sidebar/utils_spec.js +++ b/spec/frontend/super_sidebar/utils_spec.js @@ -1,20 +1,13 @@ import MockAdapter from 'axios-mock-adapter'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; -import { - getTopFrequentItems, - trackContextAccess, - getItemsFromLocalStorage, - removeItemFromLocalStorage, - ariaCurrent, -} from '~/super_sidebar/utils'; +import { getTopFrequentItems, trackContextAccess, 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 { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/super_sidebar/constants'; import { HTTP_STATUS_OK, HTTP_STATUS_INTERNAL_SERVER_ERROR } from '~/lib/utils/http_status'; import waitForPromises from 'helpers/wait_for_promises'; -import { unsortedFrequentItems, sortedFrequentItems } from '../frequent_items/mock_data'; -import { cachedFrequentProjects } from './mock_data'; +import { unsortedFrequentItems, sortedFrequentItems } from './mock_data'; jest.mock('~/sentry/sentry_browser_wrapper'); @@ -24,7 +17,7 @@ describe('Super sidebar utils spec', () => { describe('getTopFrequentItems', () => { const maxItems = 3; - it.each([undefined, null])('returns empty array if `items` is %s', (items) => { + it.each([undefined, null, []])('returns empty array if `items` is %s', (items) => { const result = getTopFrequentItems(items); expect(result.length).toBe(0); @@ -224,125 +217,6 @@ describe('Super sidebar utils spec', () => { }); }); - describe('getItemsFromLocalStorage', () => { - const storageKey = 'mockStorageKey'; - const maxItems = 5; - const storedItems = JSON.parse(cachedFrequentProjects); - - beforeEach(() => { - window.localStorage.setItem(storageKey, cachedFrequentProjects); - }); - - describe('when localStorage cannot be accessed', () => { - beforeEach(() => { - jest.spyOn(AccessorUtilities, 'canUseLocalStorage').mockReturnValue(false); - }); - - it('returns an empty array', () => { - const items = getItemsFromLocalStorage({ storageKey, maxItems }); - expect(items).toEqual([]); - }); - }); - - describe('when localStorage contains parseable data', () => { - it('returns an array of items limited by max items', () => { - const items = getItemsFromLocalStorage({ storageKey, maxItems }); - expect(items.length).toEqual(maxItems); - - items.forEach((item) => { - expect(storedItems).toContainEqual(item); - }); - }); - - it('returns all items if max items is large', () => { - const items = getItemsFromLocalStorage({ storageKey, maxItems: 1 }); - expect(items.length).toEqual(1); - - expect(storedItems).toContainEqual(items[0]); - }); - }); - - describe('when localStorage contains unparseable data', () => { - let items; - - beforeEach(() => { - window.localStorage.setItem(storageKey, 'unparseable'); - items = getItemsFromLocalStorage({ storageKey, maxItems }); - }); - - it('logs an error to Sentry', () => { - expect(Sentry.captureException).toHaveBeenCalled(); - }); - - it('returns an empty array', () => { - expect(items).toEqual([]); - }); - }); - }); - - describe('removeItemFromLocalStorage', () => { - const storageKey = 'mockStorageKey'; - const originalStoredItems = JSON.parse(cachedFrequentProjects); - - beforeEach(() => { - window.localStorage.setItem(storageKey, cachedFrequentProjects); - }); - - describe('when given an item to delete', () => { - let items; - let modifiedStoredItems; - - beforeEach(() => { - items = removeItemFromLocalStorage({ storageKey, item: { id: 3 } }); - modifiedStoredItems = JSON.parse(window.localStorage.getItem(storageKey)); - }); - - it('removes the item from localStorage', () => { - expect(modifiedStoredItems.length).toBe(originalStoredItems.length - 1); - expect(modifiedStoredItems).not.toContainEqual(originalStoredItems[2]); - }); - - it('returns the resulting stored structure', () => { - expect(items).toEqual(modifiedStoredItems); - }); - }); - - describe('when given an unknown item to delete', () => { - let items; - let modifiedStoredItems; - - beforeEach(() => { - items = removeItemFromLocalStorage({ storageKey, item: { id: 'does-not-exist' } }); - modifiedStoredItems = JSON.parse(window.localStorage.getItem(storageKey)); - }); - - it('does not change the stored value', () => { - expect(modifiedStoredItems).toEqual(originalStoredItems); - }); - - it('returns the stored structure', () => { - expect(items).toEqual(originalStoredItems); - }); - }); - - describe('when localStorage has unparseable data', () => { - let items; - - beforeEach(() => { - window.localStorage.setItem(storageKey, 'unparseable'); - items = removeItemFromLocalStorage({ storageKey, item: { id: 3 } }); - }); - - it('logs an error to Sentry', () => { - expect(Sentry.captureException).toHaveBeenCalled(); - }); - - it('returns an empty array', () => { - expect(items).toEqual([]); - }); - }); - }); - describe('ariaCurrent', () => { it.each` isActive | expected |