Commit 071a1c40 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch 'nfriend-move-copy-branch-shortcut' into 'master'

Update "copy branch" shortcut to click on MR sidebar button

See merge request gitlab-org/gitlab!45436
parents 3e9be677 58f32807
......@@ -79,3 +79,33 @@ export default function initCopyToClipboard() {
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';
import Shortcuts from './shortcuts';
import { CopyAsGFM } from '../markdown/copy_as_gfm';
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 {
constructor() {
......@@ -14,6 +16,7 @@ export default class ShortcutsIssuable extends Shortcuts {
Mousetrap.bind('l', () => ShortcutsIssuable.openSidebarDropdown('labels'));
Mousetrap.bind('r', ShortcutsIssuable.replyWithSelectedText);
Mousetrap.bind('e', ShortcutsIssuable.editIssue);
Mousetrap.bind('b', ShortcutsIssuable.copyBranchName);
}
static replyWithSelectedText() {
......@@ -98,4 +101,18 @@ export default class ShortcutsIssuable extends Shortcuts {
Sidebar.instance.openDropdown(name);
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) =>
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>
/* eslint-disable vue/no-v-html */
import Mousetrap from 'mousetrap';
import { escape } from 'lodash';
import {
GlButton,
......@@ -84,17 +83,6 @@ export default {
: '';
},
},
mounted() {
Mousetrap.bind('b', this.copyBranchName);
},
beforeDestroy() {
Mousetrap.unbind('b');
},
methods: {
copyBranchName() {
this.$refs.copyBranchNameButton.$el.click();
},
},
};
</script>
<template>
......@@ -110,7 +98,6 @@ export default {
class="label-branch label-truncate js-source-branch"
v-html="mr.sourceBranchLink"
/><clipboard-button
ref="copyBranchNameButton"
data-testid="mr-widget-copy-clipboard"
:text="branchNameClipboardData"
:title="__('Copy branch name')"
......
---
title: Update copy branch keyboard shortcut to click sidebar button
merge_request: 45436
author:
type: changed
import $ from 'jquery';
import 'mousetrap';
import Mousetrap from 'mousetrap';
import initCopyAsGFM, { CopyAsGFM } from '~/behaviors/markdown/copy_as_gfm';
import ShortcutsIssuable from '~/behaviors/shortcuts/shortcuts_issuable';
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.requireActual('~/lib/utils/common_utils'),
getSelectedFragment: jest.fn().mockName('getSelectedFragment'),
}));
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 => {
initCopyAsGFM();
......@@ -27,25 +26,27 @@ describe('ShortcutsIssuable', () => {
.catch(done.fail);
});
beforeEach(() => {
loadFixtures(fixtureName);
$('body').append(
`<div class="js-main-target-form">
<textarea class="js-vue-comment-form"></textarea>
</div>`,
);
document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
window.shortcut = new ShortcutsIssuable(true);
});
describe('replyWithSelectedText', () => {
const FORM_SELECTOR = '.js-main-target-form .js-vue-comment-form';
beforeEach(() => {
loadFixtures(snippetShowFixtureName);
$('body').append(
`<div class="js-main-target-form">
<textarea class="js-vue-comment-form"></textarea>
</div>`,
);
document.querySelector('.js-new-note-form').classList.add('js-main-target-form');
window.shortcut = new ShortcutsIssuable(true);
});
afterEach(() => {
$(FORM_SELECTOR).remove();
afterEach(() => {
$(FORM_SELECTOR).remove();
delete window.shortcut;
});
delete window.shortcut;
});
describe('replyWithSelectedText', () => {
// Stub getSelectedFragment to return a node with the provided HTML.
const stubSelection = (html, invalidNode) => {
getSelectedFragment.mockImplementation(() => {
......@@ -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 {
canScrollUp,
canScrollDown,
parseBooleanDataAttributes,
isElementVisible,
isElementHidden,
} from '~/lib/utils/dom_utils';
const TEST_MARGIN = 5;
......@@ -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 Mousetrap from 'mousetrap';
import mountComponent from 'helpers/vue_mount_component_helper';
import headerComponent from '~/vue_merge_request_widget/components/mr_widget_header.vue';
jest.mock('mousetrap', () => ({
bind: jest.fn(),
unbind: jest.fn(),
}));
describe('MRWidgetHeader', () => {
let vm;
let Component;
......@@ -136,35 +130,6 @@ describe('MRWidgetHeader', () => {
it('renders target branch', () => {
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', () => {
......
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