From 262bf3dd37e37c544697d52f290323ba2ebe9c4c Mon Sep 17 00:00:00 2001 From: Chaoyue Zhao Date: Thu, 24 Apr 2025 13:33:30 -0400 Subject: [PATCH 1/4] Add copy permalink to repository overflow menu This MR adds the new copy permalink item to the repository overflow menu. This feature currently only exists on blob page. Changelog: added --- .../repository/components/header_area.vue | 17 ++- .../header_area/repository_overflow_menu.vue | 57 +++++++++ .../queries/permalink_path.query.graphql | 13 ++ .../components/header_area/mock_data.js | 42 +++++++ .../repository_overflow_menu_spec.js | 117 +++++++++++++++--- .../repository/components/header_area_spec.js | 22 ++-- 6 files changed, 241 insertions(+), 27 deletions(-) create mode 100644 app/assets/javascripts/repository/queries/permalink_path.query.graphql diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue index b701dfb7d97ea5..ee89e8cb25de2f 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/repository_overflow_menu.vue b/app/assets/javascripts/repository/components/header_area/repository_overflow_menu.vue index 5d0846384233bc..8707676b6367f2 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 00000000000000..65bd5253dff058 --- /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 34aa456100d066..726c35115ecfe8 100644 --- a/spec/frontend/repository/components/header_area/mock_data.js +++ b/spec/frontend/repository/components/header_area/mock_data.js @@ -64,3 +64,45 @@ export const openMRsDetailResult = jest.fn().mockResolvedValue({ }, }, }); + +export const mockPermalinkResult = jest.fn().mockResolvedValue({ + data: { + project: { + 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: { + 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/repository_overflow_menu_spec.js b/spec/frontend/repository/components/header_area/repository_overflow_menu_spec.js index 3f9c2483e3ec9b..278146caecb9e3 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 fda1fe13dac559..0834c628569629 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', + }); }); }); }); -- GitLab From 54b630d3df5aecbb24c27b193bb6ec42dc8bd9ad Mon Sep 17 00:00:00 2001 From: Chaoyue Zhao Date: Fri, 25 Apr 2025 12:10:20 -0400 Subject: [PATCH 2/4] Fix jest test failures --- spec/frontend/repository/components/header_area/mock_data.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/spec/frontend/repository/components/header_area/mock_data.js b/spec/frontend/repository/components/header_area/mock_data.js index 726c35115ecfe8..3aecb35faf252a 100644 --- a/spec/frontend/repository/components/header_area/mock_data.js +++ b/spec/frontend/repository/components/header_area/mock_data.js @@ -68,6 +68,7 @@ export const openMRsDetailResult = jest.fn().mockResolvedValue({ export const mockPermalinkResult = jest.fn().mockResolvedValue({ data: { project: { + id: '1', repository: { paginatedTree: { nodes: [ @@ -89,6 +90,7 @@ export const mockPermalinkResult = jest.fn().mockResolvedValue({ export const mockRootPermalinkResult = jest.fn().mockResolvedValue({ data: { project: { + id: '2', repository: { paginatedTree: { nodes: [ -- GitLab From e8025e27007b32d5b6ab0403879c50844d334613 Mon Sep 17 00:00:00 2001 From: Chaoyue Zhao Date: Mon, 28 Apr 2025 21:12:01 -0400 Subject: [PATCH 3/4] Fix permalink when feature flags are off When navigate from repository to blob and `blob_overflow_menu` disabled, the blob shortcut gets unbinded from repository lifecycle hook. This fix stores the mousetrap instance within the component data so only that instance gets cleaned up. When `directory_code_dropdown` is disabled, we render two instances of permalink_dropdown_item.vue (one for desktop, one for mobile) and shortcuts are being triggered twice. This fix adds a prop to stop supporting shortcut in the mobile version. --- .../repository/components/header_area.vue | 1 + .../header_area/permalink_dropdown_item.vue | 17 +++++++++++-- .../header_area/repository_overflow_menu.vue | 11 ++++++++- .../permalink_dropdown_item_spec.js | 24 +++++++++++++++---- .../repository/components/header_area_spec.js | 2 ++ 5 files changed, 48 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue index ee89e8cb25de2f..8ca028bfcd6c04 100644 --- a/app/assets/javascripts/repository/components/header_area.vue +++ b/app/assets/javascripts/repository/components/header_area.vue @@ -410,6 +410,7 @@ export default { :full-path="projectPath" :path="currentPath" :current-ref="currentRef" + :support-shortcut="false" /> 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 b692c37138be21..976d096d596f97 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 @@ -20,6 +20,16 @@ export default { type: String, required: true, }, + supportShortcut: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + mousetrap: null, + }; }, computed: { permalinkShortcutKey() { @@ -39,10 +49,13 @@ export default { }, }, mounted() { - Mousetrap.bind(keysFor(PROJECT_FILES_GO_TO_PERMALINK), this.triggerCopyPermalink); + if (!this.supportShortcut) return; + this.mousetrap = new Mousetrap(); + this.mousetrap.bind(keysFor(PROJECT_FILES_GO_TO_PERMALINK), this.triggerCopyPermalink); }, beforeDestroy() { - Mousetrap.unbind(keysFor(PROJECT_FILES_GO_TO_PERMALINK)); + if (!this.supportShortcut) return; + 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 8707676b6367f2..3e8695ec913bea 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 @@ -36,6 +36,11 @@ export default { type: String, required: true, }, + supportShortcut: { + type: Boolean, + required: false, + default: true, + }, }, apollo: { permalinkPath: { @@ -98,7 +103,11 @@ export default { :toggle-text="$options.i18n.dropdownLabel" text-sr-only > - + 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 2b079eadd1acdb..f99a8a8a84342b 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,18 +71,23 @@ 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.'); }); }); describe('lifecycle hooks', () => { - it('binds and unbinds Mousetrap shortcuts', () => { - const bindSpy = jest.spyOn(Mousetrap, 'bind'); - const unbindSpy = jest.spyOn(Mousetrap, 'unbind'); + it('binds and unbinds Mousetrap shortcuts when `supportShortcut` is `true`', () => { + const bindSpy = jest.spyOn(Mousetrap.prototype, 'bind'); + const unbindSpy = jest.spyOn(Mousetrap.prototype, 'unbind'); createComponent(); expect(bindSpy).toHaveBeenCalledWith( @@ -93,6 +98,17 @@ describe('PermalinkDropdownItem', () => { wrapper.destroy(); expect(unbindSpy).toHaveBeenCalledWith(keysFor(PROJECT_FILES_GO_TO_PERMALINK)); }); + + it('do not bind or unbind Mousetrap shortcuts when `supportShortcut` is `false`', () => { + const bindSpy = jest.spyOn(Mousetrap.prototype, 'bind'); + const unbindSpy = jest.spyOn(Mousetrap.prototype, 'unbind'); + + createComponent({ supportShortcut: false, permalinkPath: relativePermalinkPath }); + expect(bindSpy).not.toHaveBeenCalled(); + + wrapper.destroy(); + expect(unbindSpy).not.toHaveBeenCalled(); + }); }); it('displays the shortcut key when shortcuts are not disabled', () => { diff --git a/spec/frontend/repository/components/header_area_spec.js b/spec/frontend/repository/components/header_area_spec.js index 0834c628569629..a3709ed4cf7f06 100644 --- a/spec/frontend/repository/components/header_area_spec.js +++ b/spec/frontend/repository/components/header_area_spec.js @@ -217,6 +217,7 @@ describe('HeaderArea', () => { currentRef: 'main', fullPath: 'test/project', path: 'index.js', + supportShortcut: true, }); }); @@ -229,6 +230,7 @@ describe('HeaderArea', () => { currentRef: 'main', fullPath: 'test/project', path: 'index.js', + supportShortcut: true, }); }); }); -- GitLab From 226235b060fab6e1d343caf8b76fd6a8c0d726f3 Mon Sep 17 00:00:00 2001 From: Chaoyue Zhao Date: Tue, 29 Apr 2025 17:19:27 -0400 Subject: [PATCH 4/4] Remove dobule instance of repository_overflow_menu in header_area It turns out all we need is some css surgery --- .../repository/components/header_area.vue | 32 ++++++++----------- .../header_area/permalink_dropdown_item.vue | 7 ---- .../header_area/repository_overflow_menu.vue | 11 +------ .../permalink_dropdown_item_spec.js | 13 +------- .../repository/components/header_area_spec.js | 2 -- 5 files changed, 15 insertions(+), 50 deletions(-) diff --git a/app/assets/javascripts/repository/components/header_area.vue b/app/assets/javascripts/repository/components/header_area.vue index 8ca028bfcd6c04..2fe1e5528ec1e2 100644 --- a/app/assets/javascripts/repository/components/header_area.vue +++ b/app/assets/javascripts/repository/components/header_area.vue @@ -385,17 +385,19 @@ export default { :current-path="currentPath" :directory-download-links="downloadLinks" /> -
- - +
+
+ + +
-
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 976d096d596f97..abdd463db5e1ca 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 @@ -20,11 +20,6 @@ export default { type: String, required: true, }, - supportShortcut: { - type: Boolean, - required: false, - default: true, - }, }, data() { return { @@ -49,12 +44,10 @@ export default { }, }, mounted() { - if (!this.supportShortcut) return; this.mousetrap = new Mousetrap(); this.mousetrap.bind(keysFor(PROJECT_FILES_GO_TO_PERMALINK), this.triggerCopyPermalink); }, beforeDestroy() { - if (!this.supportShortcut) return; this.mousetrap.unbind(keysFor(PROJECT_FILES_GO_TO_PERMALINK)); }, methods: { 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 3e8695ec913bea..8707676b6367f2 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 @@ -36,11 +36,6 @@ export default { type: String, required: true, }, - supportShortcut: { - type: Boolean, - required: false, - default: true, - }, }, apollo: { permalinkPath: { @@ -103,11 +98,7 @@ export default { :toggle-text="$options.i18n.dropdownLabel" text-sr-only > - + 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 f99a8a8a84342b..0f1d69f353d8a8 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 @@ -85,7 +85,7 @@ describe('PermalinkDropdownItem', () => { }); describe('lifecycle hooks', () => { - it('binds and unbinds Mousetrap shortcuts when `supportShortcut` is `true`', () => { + it('binds and unbinds Mousetrap shortcuts', () => { const bindSpy = jest.spyOn(Mousetrap.prototype, 'bind'); const unbindSpy = jest.spyOn(Mousetrap.prototype, 'unbind'); @@ -98,17 +98,6 @@ describe('PermalinkDropdownItem', () => { wrapper.destroy(); expect(unbindSpy).toHaveBeenCalledWith(keysFor(PROJECT_FILES_GO_TO_PERMALINK)); }); - - it('do not bind or unbind Mousetrap shortcuts when `supportShortcut` is `false`', () => { - const bindSpy = jest.spyOn(Mousetrap.prototype, 'bind'); - const unbindSpy = jest.spyOn(Mousetrap.prototype, 'unbind'); - - createComponent({ supportShortcut: false, permalinkPath: relativePermalinkPath }); - expect(bindSpy).not.toHaveBeenCalled(); - - wrapper.destroy(); - expect(unbindSpy).not.toHaveBeenCalled(); - }); }); it('displays the shortcut key when shortcuts are not disabled', () => { diff --git a/spec/frontend/repository/components/header_area_spec.js b/spec/frontend/repository/components/header_area_spec.js index a3709ed4cf7f06..0834c628569629 100644 --- a/spec/frontend/repository/components/header_area_spec.js +++ b/spec/frontend/repository/components/header_area_spec.js @@ -217,7 +217,6 @@ describe('HeaderArea', () => { currentRef: 'main', fullPath: 'test/project', path: 'index.js', - supportShortcut: true, }); }); @@ -230,7 +229,6 @@ describe('HeaderArea', () => { currentRef: 'main', fullPath: 'test/project', path: 'index.js', - supportShortcut: true, }); }); }); -- GitLab