Commit 58f32807 authored by Nathan Friend's avatar Nathan Friend Committed by Andrew Fontaine

Make copy branch shortcut click sidebar button

This commit updates the "copy source branch" keyboard shortcut to click
the "copy source branch" button found in the MR sidebar instead of the
button in the MR widget. This keeps the shortcut from interefering with
the page's scroll.
parent 5b57bea1
...@@ -79,3 +79,33 @@ export default function initCopyToClipboard() { ...@@ -79,3 +79,33 @@ export default function initCopyToClipboard() {
clipboardData.setData('text/x-gfm', json.gfm); clipboardData.setData('text/x-gfm', json.gfm);
}); });
} }
/**
* Programmatically triggers a click event on a
* "copy to clipboard" button, causing its
* contents to be copied. Handles some of the messiniess
* around managing the button's tooltip.
* @param {HTMLElement} btnElement
*/
export function clickCopyToClipboardButton(btnElement) {
const $btnElement = $(btnElement);
// Ensure the button has already been tooltip'd.
// If the use hasn't yet interacted (i.e. hovered or clicked)
// with the button, Bootstrap hasn't yet initialized
// the tooltip, and its `data-original-title` will be `undefined`.
// This value is used in the functions above.
$btnElement.tooltip();
btnElement.dispatchEvent(new MouseEvent('mouseover'));
btnElement.click();
// Manually trigger the necessary events to hide the
// button's tooltip and allow the button to perform its
// tooltip cleanup (updating the title from "Copied" back
// to its original title, "Copy branch name").
setTimeout(() => {
btnElement.dispatchEvent(new MouseEvent('mouseout'));
$btnElement.tooltip('hide');
}, 2000);
}
...@@ -4,6 +4,8 @@ import Sidebar from '../../right_sidebar'; ...@@ -4,6 +4,8 @@ import Sidebar from '../../right_sidebar';
import Shortcuts from './shortcuts'; import Shortcuts from './shortcuts';
import { CopyAsGFM } from '../markdown/copy_as_gfm'; import { CopyAsGFM } from '../markdown/copy_as_gfm';
import { getSelectedFragment } from '~/lib/utils/common_utils'; import { getSelectedFragment } from '~/lib/utils/common_utils';
import { isElementVisible } from '~/lib/utils/dom_utils';
import { clickCopyToClipboardButton } from '~/behaviors/copy_to_clipboard';
export default class ShortcutsIssuable extends Shortcuts { export default class ShortcutsIssuable extends Shortcuts {
constructor() { constructor() {
...@@ -14,6 +16,7 @@ export default class ShortcutsIssuable extends Shortcuts { ...@@ -14,6 +16,7 @@ export default class ShortcutsIssuable extends Shortcuts {
Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels')); Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels'));
Mousetrap.bind('r', ShortcutsIssuable.replyWithSelectedText); Mousetrap.bind('r', ShortcutsIssuable.replyWithSelectedText);
Mousetrap.bind('e', ShortcutsIssuable.editIssue); Mousetrap.bind('e', ShortcutsIssuable.editIssue);
Mousetrap.bind('b', ShortcutsIssuable.copyBranchName);
} }
static replyWithSelectedText() { static replyWithSelectedText() {
...@@ -98,4 +101,18 @@ export default class ShortcutsIssuable extends Shortcuts { ...@@ -98,4 +101,18 @@ export default class ShortcutsIssuable extends Shortcuts {
Sidebar.instance.openDropdown(name); Sidebar.instance.openDropdown(name);
return false; return false;
} }
static copyBranchName() {
// There are two buttons - one that is shown when the sidebar
// is expanded, and one that is shown when it's collapsed.
const allCopyBtns = Array.from(document.querySelectorAll('.sidebar-source-branch button'));
// Select whichever button is currently visible so that
// the "Copied" tooltip is shown when a click is simulated.
const visibleBtn = allCopyBtns.find(isElementVisible);
if (visibleBtn) {
clickCopyToClipboardButton(visibleBtn);
}
}
} }
...@@ -47,3 +47,25 @@ export const parseBooleanDataAttributes = ({ dataset }, names) => ...@@ -47,3 +47,25 @@ export const parseBooleanDataAttributes = ({ dataset }, names) =>
return acc; return acc;
}, {}); }, {});
/**
* Returns whether or not the provided element is currently visible.
* This function operates identically to jQuery's `:visible` pseudo-selector.
* Documentation for this selector: https://api.jquery.com/visible-selector/
* Implementation of this selector: https://github.com/jquery/jquery/blob/d0ce00cdfa680f1f0c38460bc51ea14079ae8b07/src/css/hiddenVisibleSelectors.js#L8
* @param {HTMLElement} element The element to test
* @returns {Boolean} `true` if the element is currently visible, otherwise false
*/
export const isElementVisible = element =>
Boolean(element.offsetWidth || element.offsetHeight || element.getClientRects().length);
/**
* The opposite of `isElementVisible`.
* Returns whether or not the provided element is currently hidden.
* This function operates identically to jQuery's `:hidden` pseudo-selector.
* Documentation for this selector: https://api.jquery.com/hidden-selector/
* Implementation of this selector: https://github.com/jquery/jquery/blob/d0ce00cdfa680f1f0c38460bc51ea14079ae8b07/src/css/hiddenVisibleSelectors.js#L6
* @param {HTMLElement} element The element to test
* @returns {Boolean} `true` if the element is currently hidden, otherwise false
*/
export const isElementHidden = element => !isElementVisible(element);
<script> <script>
/* eslint-disable vue/no-v-html */ /* eslint-disable vue/no-v-html */
import Mousetrap from 'mousetrap';
import { escape } from 'lodash'; import { escape } from 'lodash';
import { import {
GlButton, GlButton,
...@@ -84,17 +83,6 @@ export default { ...@@ -84,17 +83,6 @@ export default {
: ''; : '';
}, },
}, },
mounted() {
Mousetrap.bind('b', this.copyBranchName);
},
beforeDestroy() {
Mousetrap.unbind('b');
},
methods: {
copyBranchName() {
this.$refs.copyBranchNameButton.$el.click();
},
},
}; };
</script> </script>
<template> <template>
...@@ -110,7 +98,6 @@ export default { ...@@ -110,7 +98,6 @@ export default {
class="label-branch label-truncate js-source-branch" class="label-branch label-truncate js-source-branch"
v-html="mr.sourceBranchLink" v-html="mr.sourceBranchLink"
/><clipboard-button /><clipboard-button
ref="copyBranchNameButton"
data-testid="mr-widget-copy-clipboard" data-testid="mr-widget-copy-clipboard"
:text="branchNameClipboardData" :text="branchNameClipboardData"
:title="__('Copy branch name')" :title="__('Copy branch name')"
......
---
title: Update copy branch keyboard shortcut to click sidebar button
merge_request: 45436
author:
type: changed
import $ from 'jquery'; import $ from 'jquery';
import 'mousetrap'; import Mousetrap from 'mousetrap';
import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm'; import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable'; import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
import { getSelectedFragment } from '~/lib/utils/common_utils'; import { getSelectedFragment } from '~/lib/utils/common_utils';
const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form';
jest.mock('~/lib/utils/common_utils', () => ({ jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'), ...jest.requireActual('~/lib/utils/common_utils'),
getSelectedFragment: jest.fn().mockName('getSelectedFragment'), getSelectedFragment: jest.fn().mockName('getSelectedFragment'),
})); }));
describe('ShortcutsIssuable', () => { describe('ShortcutsIssuable', () => {
const fixtureName = 'snippets/show.html'; const snippetShowFixtureName = 'snippets/show.html';
const mrShowFixtureName = 'merge_requests/merge_request_of_current_user.html';
preloadFixtures(fixtureName); preloadFixtures(snippetShowFixtureName, mrShowFixtureName);
beforeAll(done => { beforeAll(done => {
initCopyAsGFM(); initCopyAsGFM();
...@@ -27,25 +26,27 @@ describe('ShortcutsIssuable', () => { ...@@ -27,25 +26,27 @@ describe('ShortcutsIssuable', () => {
.catch(done.fail); .catch(done.fail);
}); });
beforeEach(() => { describe('replyWithSelectedText', () => {
loadFixtures(fixtureName); const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form';
$('body').append(
`<div class="js-main-target-form"> beforeEach(() => {
<textarea class="js-vue-comment-form"></textarea> loadFixtures(snippetShowFixtureName);
</div>`, $('body').append(
); `<div class="js-main-target-form">
document.querySelector('.js-new-note-form').classList.add('js-main-target-form'); <textarea class="js-vue-comment-form"></textarea>
</div>`,
window.shortcut = new ShortcutsIssuable(true); );
}); document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
window.shortcut = new ShortcutsIssuable(true);
});
afterEach(() => { afterEach(() => {
$(FORM_SELECTOR).remove(); $(FORM_SELECTOR).remove();
delete window.shortcut; delete window.shortcut;
}); });
describe('replyWithSelectedText', () => {
// Stub getSelectedFragment to return a node with the provided HTML. // Stub getSelectedFragment to return a node with the provided HTML.
const stubSelection = (html, invalidNode) => { const stubSelection = (html, invalidNode) => {
getSelectedFragment.mockImplementation(() => { getSelectedFragment.mockImplementation(() => {
...@@ -319,4 +320,55 @@ describe('ShortcutsIssuable', () => { ...@@ -319,4 +320,55 @@ describe('ShortcutsIssuable', () => {
}); });
}); });
}); });
describe('copyBranchName', () => {
let sidebarCollapsedBtn;
let sidebarExpandedBtn;
beforeEach(() => {
loadFixtures(mrShowFixtureName);
window.shortcut = new ShortcutsIssuable();
[sidebarCollapsedBtn, sidebarExpandedBtn] = document.querySelectorAll(
'.sidebar-source-branch button',
);
[sidebarCollapsedBtn, sidebarExpandedBtn].forEach(btn => jest.spyOn(btn, 'click'));
});
afterEach(() => {
delete window.shortcut;
});
describe('when the sidebar is expanded', () => {
beforeEach(() => {
// simulate the applied CSS styles when the
// sidebar is expanded
sidebarCollapsedBtn.style.display = 'none';
Mousetrap.trigger('b');
});
it('clicks the "expanded" version of the copy source branch button', () => {
expect(sidebarExpandedBtn.click).toHaveBeenCalled();
expect(sidebarCollapsedBtn.click).not.toHaveBeenCalled();
});
});
describe('when the sidebar is collapsed', () => {
beforeEach(() => {
// simulate the applied CSS styles when the
// sidebar is collapsed
sidebarExpandedBtn.style.display = 'none';
Mousetrap.trigger('b');
});
it('clicks the "collapsed" version of the copy source branch button', () => {
expect(sidebarCollapsedBtn.click).toHaveBeenCalled();
expect(sidebarExpandedBtn.click).not.toHaveBeenCalled();
});
});
});
}); });
...@@ -3,6 +3,8 @@ import { ...@@ -3,6 +3,8 @@ import {
canScrollUp, canScrollUp,
canScrollDown, canScrollDown,
parseBooleanDataAttributes, parseBooleanDataAttributes,
isElementVisible,
isElementHidden,
} from '~/lib/utils/dom_utils'; } from '~/lib/utils/dom_utils';
const TEST_MARGIN = 5; const TEST_MARGIN = 5;
...@@ -160,4 +162,35 @@ describe('DOM Utils', () => { ...@@ -160,4 +162,35 @@ describe('DOM Utils', () => {
}); });
}); });
}); });
describe.each`
offsetWidth | offsetHeight | clientRectsLength | visible
${0} | ${0} | ${0} | ${false}
${1} | ${0} | ${0} | ${true}
${0} | ${1} | ${0} | ${true}
${0} | ${0} | ${1} | ${true}
`(
'isElementVisible and isElementHidden',
({ offsetWidth, offsetHeight, clientRectsLength, visible }) => {
const element = {
offsetWidth,
offsetHeight,
getClientRects: () => new Array(clientRectsLength),
};
const paramDescription = `offsetWidth=${offsetWidth}, offsetHeight=${offsetHeight}, and getClientRects().length=${clientRectsLength}`;
describe('isElementVisible', () => {
it(`returns ${visible} when ${paramDescription}`, () => {
expect(isElementVisible(element)).toBe(visible);
});
});
describe('isElementHidden', () => {
it(`returns ${!visible} when ${paramDescription}`, () => {
expect(isElementHidden(element)).toBe(!visible);
});
});
},
);
}); });
import Vue from 'vue'; import Vue from 'vue';
import Mousetrap from 'mousetrap';
import mountComponent from 'helpers/vue_mount_component_helper'; import mountComponent from 'helpers/vue_mount_component_helper';
import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue'; import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue';
jest.mock('mousetrap', () => ({
bind: jest.fn(),
unbind: jest.fn(),
}));
describe('MRWidgetHeader', () => { describe('MRWidgetHeader', () => {
let vm; let vm;
let Component; let Component;
...@@ -136,35 +130,6 @@ describe('MRWidgetHeader', () => { ...@@ -136,35 +130,6 @@ describe('MRWidgetHeader', () => {
it('renders target branch', () => { it('renders target branch', () => {
expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master'); expect(vm.$el.querySelector('.js-target-branch').textContent.trim()).toEqual('master');
}); });
describe('keyboard shortcuts', () => {
it('binds a keyboard shortcut handler to the "b" key', () => {
expect(Mousetrap.bind).toHaveBeenCalledWith('b', expect.any(Function));
});
it('triggers a click on the "copy to clipboard" button when the handler is executed', () => {
const testClickHandler = jest.fn();
vm.$refs.copyBranchNameButton.$el.addEventListener('click', testClickHandler);
// Get a reference to the function that was assigned to the "b" shortcut key.
const shortcutHandler = Mousetrap.bind.mock.calls[0][1];
expect(testClickHandler).not.toHaveBeenCalled();
// Simulate Mousetrap calling the function.
shortcutHandler();
expect(testClickHandler).toHaveBeenCalledTimes(1);
});
it('unbinds the keyboard shortcut when the component is destroyed', () => {
expect(Mousetrap.unbind).not.toHaveBeenCalled();
vm.$destroy();
expect(Mousetrap.unbind).toHaveBeenCalledWith('b');
});
});
}); });
describe('with an open merge request', () => { describe('with an open merge request', () => {
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment