diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue index b701dfb7d97ea59762e167a91106c7a31639af62..2fe1e5528ec1e293b82e5291d5c4918d0f0041ba 100644 --- a/app/assets/javascripts/repository/components/header_area.vue +++ b/app/assets/javascripts/repository/components/header_area.vue @@ -369,7 +369,11 @@ export default { :show-web-ide-button="showWebIdeButton" :show-gitpod-button="isGitpodEnabledForInstance" /> - + - diff --git a/app/assets/javascripts/repository/components/header_area/permalink_dropdown_item.vue b/app/assets/javascripts/repository/components/header_area/permalink_dropdown_item.vue index b692c37138be216c9f8b616fc7edbc5ba1d501c8..abdd463db5e1ca99a8c53d5235de56b19b6d064d 100644 --- a/app/assets/javascripts/repository/components/header_area/permalink_dropdown_item.vue +++ b/app/assets/javascripts/repository/components/header_area/permalink_dropdown_item.vue @@ -21,6 +21,11 @@ export default { required: true, }, }, + data() { + return { + mousetrap: null, + }; + }, computed: { permalinkShortcutKey() { return keysFor(PROJECT_FILES_GO_TO_PERMALINK)[0]; @@ -39,10 +44,11 @@ export default { }, }, mounted() { - Mousetrap.bind(keysFor(PROJECT_FILES_GO_TO_PERMALINK), this.triggerCopyPermalink); + this.mousetrap = new Mousetrap(); + this.mousetrap.bind(keysFor(PROJECT_FILES_GO_TO_PERMALINK), this.triggerCopyPermalink); }, beforeDestroy() { - Mousetrap.unbind(keysFor(PROJECT_FILES_GO_TO_PERMALINK)); + this.mousetrap.unbind(keysFor(PROJECT_FILES_GO_TO_PERMALINK)); }, methods: { triggerCopyPermalink() { diff --git a/app/assets/javascripts/repository/components/header_area/repository_overflow_menu.vue b/app/assets/javascripts/repository/components/header_area/repository_overflow_menu.vue index 5d0846384233bc3723c99c1cc65ce669a372b5b6..8707676b6367f2e0803c10d38382fc0561768a31 100644 --- a/app/assets/javascripts/repository/components/header_area/repository_overflow_menu.vue +++ b/app/assets/javascripts/repository/components/header_area/repository_overflow_menu.vue @@ -1,6 +1,10 @@ @@ -42,6 +98,7 @@ export default { :toggle-text="$options.i18n.dropdownLabel" text-sr-only > + diff --git a/app/assets/javascripts/repository/queries/permalink_path.query.graphql b/app/assets/javascripts/repository/queries/permalink_path.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..65bd5253dff058ff33a49f15a50ab7b5dde8af4d --- /dev/null +++ b/app/assets/javascripts/repository/queries/permalink_path.query.graphql @@ -0,0 +1,13 @@ +query getPermalinkPath($fullPath: ID!, $path: String!, $ref: String!) { + project(fullPath: $fullPath) { + id + repository { + paginatedTree(path: $path, ref: $ref) { + nodes { + __typename + permalinkPath + } + } + } + } +} diff --git a/spec/frontend/repository/components/header_area/mock_data.js b/spec/frontend/repository/components/header_area/mock_data.js index 34aa456100d066406794e44f0b9edaabaeff23b9..3aecb35faf252a7f405c7fc0682229567a505756 100644 --- a/spec/frontend/repository/components/header_area/mock_data.js +++ b/spec/frontend/repository/components/header_area/mock_data.js @@ -64,3 +64,47 @@ export const openMRsDetailResult = jest.fn().mockResolvedValue({ }, }, }); + +export const mockPermalinkResult = jest.fn().mockResolvedValue({ + data: { + project: { + id: '1', + repository: { + paginatedTree: { + nodes: [ + { + __typename: 'Tree', + permalinkPath: + '/gitlab-org/gitlab-shell/-/tree/5059017dea6e834f2f86fc670703ca36cbae98d6/cmd', + }, + ], + __typename: 'TreeConnection', + }, + __typename: 'Repository', + }, + __typename: 'Project', + }, + }, +}); + +export const mockRootPermalinkResult = jest.fn().mockResolvedValue({ + data: { + project: { + id: '2', + repository: { + paginatedTree: { + nodes: [ + { + __typename: 'Tree', + permalinkPath: + '/gitlab-org/gitlab-shell/-/tree/5059017dea6e834f2f86fc670703ca36cbae98d6/', + }, + ], + __typename: 'TreeConnection', + }, + __typename: 'Repository', + }, + __typename: 'Project', + }, + }, +}); diff --git a/spec/frontend/repository/components/header_area/permalink_dropdown_item_spec.js b/spec/frontend/repository/components/header_area/permalink_dropdown_item_spec.js index 2b079eadd1acdbd5ff494bb6c489f0754db6c221..0f1d69f353d8a81e7fea9e341fe83f1155ee5dd1 100644 --- a/spec/frontend/repository/components/header_area/permalink_dropdown_item_spec.js +++ b/spec/frontend/repository/components/header_area/permalink_dropdown_item_spec.js @@ -71,9 +71,14 @@ describe('PermalinkDropdownItem', () => { it('triggers copy permalink when shortcut is used', async () => { const clickSpy = jest.spyOn(findPermalinkLinkDropdown().element, 'click'); - Mousetrap.trigger('y'); + const mousetrapInstance = wrapper.vm.mousetrap; + + const triggerSpy = jest.spyOn(mousetrapInstance, 'trigger'); + mousetrapInstance.trigger('y'); + await nextTick(); + expect(triggerSpy).toHaveBeenCalledWith('y'); expect(clickSpy).toHaveBeenCalled(); expect(mockToastShow).toHaveBeenCalledWith('Permalink copied to clipboard.'); }); @@ -81,8 +86,8 @@ describe('PermalinkDropdownItem', () => { describe('lifecycle hooks', () => { it('binds and unbinds Mousetrap shortcuts', () => { - const bindSpy = jest.spyOn(Mousetrap, 'bind'); - const unbindSpy = jest.spyOn(Mousetrap, 'unbind'); + const bindSpy = jest.spyOn(Mousetrap.prototype, 'bind'); + const unbindSpy = jest.spyOn(Mousetrap.prototype, 'unbind'); createComponent(); expect(bindSpy).toHaveBeenCalledWith( diff --git a/spec/frontend/repository/components/header_area/repository_overflow_menu_spec.js b/spec/frontend/repository/components/header_area/repository_overflow_menu_spec.js index 3f9c2483e3ec9b2456f2bb5857b788d87510855c..278146caecb9e3015fb7ed4b5cdc871370e978ad 100644 --- a/spec/frontend/repository/components/header_area/repository_overflow_menu_spec.js +++ b/spec/frontend/repository/components/header_area/repository_overflow_menu_spec.js @@ -1,7 +1,27 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; import { RouterLinkStub } from '@vue/test-utils'; import { GlDisclosureDropdownItem } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RepositoryOverflowMenu from '~/repository/components/header_area/repository_overflow_menu.vue'; +import PermalinkDropdownItem from '~/repository/components/header_area/permalink_dropdown_item.vue'; +import permalinkPathQuery from '~/repository/queries/permalink_path.query.graphql'; +import { logError } from '~/lib/logger'; +import { + mockPermalinkResult, + mockRootPermalinkResult, +} from 'jest/repository/components/header_area/mock_data'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; + +Vue.use(VueApollo); +jest.mock('~/lib/logger'); +jest.mock('~/sentry/sentry_browser_wrapper'); + +const path = 'cmd'; +const projectPath = 'gitlab-org/gitlab-shell'; +const ref = '5059017dea6e834f2f86fc670703ca36cbae98d6'; const defaultMockRoute = { params: { @@ -18,18 +38,34 @@ const defaultMockRoute = { describe('RepositoryOverflowMenu', () => { let wrapper; - + let permalinkQueryHandler; const findDropdownItems = () => wrapper.findAllComponents(GlDisclosureDropdownItem); const findDropdownItemWithText = (text) => findDropdownItems().wrappers.find((x) => x.props('item').text === text); const findCompareItem = () => findDropdownItemWithText('Compare'); - const createComponent = (route = {}, provide = {}) => { + const findPermalinkItem = () => wrapper.findComponent(PermalinkDropdownItem); + + const createComponent = ({ + route = {}, + provide = {}, + props = {}, + mockResolver = mockPermalinkResult, + } = {}) => { + permalinkQueryHandler = mockResolver; + const mockApollo = createMockApollo([[permalinkPathQuery, mockResolver]]); + return shallowMountExtended(RepositoryOverflowMenu, { provide: { comparePath: null, ...provide, }, + propsData: { + fullPath: projectPath, + path, + currentRef: ref, + ...props, + }, stubs: { RouterLink: RouterLinkStub, }, @@ -39,6 +75,7 @@ describe('RepositoryOverflowMenu', () => { ...route, }, }, + apolloProvider: mockApollo, }); }; @@ -50,26 +87,72 @@ describe('RepositoryOverflowMenu', () => { expect(wrapper.exists()).toBe(true); }); - describe('Compare item', () => { - it('does not render Compare button for root ref', () => { - wrapper = createComponent({ params: { path: '/-/tree/new-branch-3' } }); - expect(findCompareItem()).toBeUndefined(); + describe('computed properties', () => { + it('computes queryVariables correctly', () => { + expect(permalinkQueryHandler).toHaveBeenCalledWith({ + fullPath: 'gitlab-org/gitlab-shell', + path: 'cmd', + ref: '5059017dea6e834f2f86fc670703ca36cbae98d6', + }); }); - it('renders Compare button for non-root ref', () => { - wrapper = createComponent( - { params: { path: '/-/tree/new-branch-3' } }, - { comparePath: 'test/project/-/compare?from=master&to=new-branch-3' }, - ); - expect(findCompareItem().exists()).toBe(true); - expect(findCompareItem().props('item')).toMatchObject({ - href: 'test/project/-/compare?from=master&to=new-branch-3', + describe('Compare item', () => { + it('does not render Compare button for root ref', () => { + wrapper = createComponent({ route: { params: { path: '/-/tree/new-branch-3' } } }); + expect(findCompareItem()).toBeUndefined(); + }); + + it('renders Compare button for non-root ref', () => { + wrapper = createComponent({ + route: { + params: { path: '/-/tree/new-branch-3' }, + }, + provide: { comparePath: 'test/project/-/compare?from=master&to=new-branch-3' }, + }); + expect(findCompareItem().exists()).toBe(true); + expect(findCompareItem().props('item')).toMatchObject({ + href: 'test/project/-/compare?from=master&to=new-branch-3', + }); + }); + + it('does not render compare button when comparePath is not provided', () => { + wrapper = createComponent(); + expect(findCompareItem()).toBeUndefined(); }); }); - it('does not render compare button when comparePath is not provided', () => { - wrapper = createComponent(); - expect(findCompareItem()).toBeUndefined(); + describe('Permalink item', () => { + it('renders Permalink button for non-root route', async () => { + wrapper = createComponent(); + await waitForPromises(); + expect(findPermalinkItem().props('permalinkPath')).toBe( + '/gitlab-org/gitlab-shell/-/tree/5059017dea6e834f2f86fc670703ca36cbae98d6/cmd', + ); + }); + + it('renders Permalink button with projectPath for root route', async () => { + wrapper = createComponent({ + props: { path: undefined }, + mockResolver: mockRootPermalinkResult, + }); + await waitForPromises(); + expect(findPermalinkItem().props('permalinkPath')).toBe( + '/gitlab-org/gitlab-shell/-/tree/5059017dea6e834f2f86fc670703ca36cbae98d6/', + ); + }); + + it('handles errors when fetching permalinkPath', async () => { + const mockError = new Error(); + wrapper = createComponent({ mockResolver: jest.fn().mockRejectedValueOnce(mockError) }); + await waitForPromises(); + + expect(findPermalinkItem().exists()).toBe(false); + expect(logError).toHaveBeenCalledWith( + 'Failed to fetch permalink. See exception details for more information.', + mockError, + ); + expect(Sentry.captureException).toHaveBeenCalledWith(mockError); + }); }); }); }); diff --git a/spec/frontend/repository/components/header_area_spec.js b/spec/frontend/repository/components/header_area_spec.js index fda1fe13dac559ea8f8337c24567b559370cdc56..0834c6285696292198bb3b8ffa773bb30e89be84 100644 --- a/spec/frontend/repository/components/header_area_spec.js +++ b/spec/frontend/repository/components/header_area_spec.js @@ -209,19 +209,27 @@ describe('HeaderArea', () => { }); describe('RepositoryOverflowMenu', () => { - it('does not render RepositoryOverflowMenu component on default ref', () => { - expect(findRepositoryOverflowMenu().exists()).toBe(false); + it('renders RepositoryOverflowMenu component with correct props when on default branch', () => { + wrapper = createComponent({ + route: { name: 'treePathDecoded' }, + }); + expect(findRepositoryOverflowMenu().props()).toStrictEqual({ + currentRef: 'main', + fullPath: 'test/project', + path: 'index.js', + }); }); - it('renders RepositoryOverflowMenu component with correct props when on ref different than default branch', () => { + it('renders RepositoryOverflowMenu component with correct props when on non-default branch', () => { wrapper = createComponent({ route: { name: 'treePathDecoded' }, provided: { comparePath: 'test/project/compare' }, }); - expect(findRepositoryOverflowMenu().exists()).toBe(true); - expect(findRepositoryOverflowMenu().props('comparePath')).toBe( - headerAppInjected.comparePath, - ); + expect(findRepositoryOverflowMenu().props()).toStrictEqual({ + currentRef: 'main', + fullPath: 'test/project', + path: 'index.js', + }); }); }); });