Commit a3f963d9 authored by Mike Greiling's avatar Mike Greiling

Merge branch 'peterhegman/add-copied-confirmation-to-clipboard-button' into 'master'

Update clipboard tooltip to say `Copied` after clicking

See merge request gitlab-org/gitlab!74856
parents c8045e3b 3a67e397
import Clipboard from 'clipboard';
import ClipboardJS from 'clipboard';
import $ from 'jquery';
import { sprintf, __ } from '~/locale';
import { fixTitle, add, show, once } from '~/tooltips';
import { parseBoolean } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { fixTitle, add, show, hide, once } from '~/tooltips';
const CLIPBOARD_SUCCESS_EVENT = 'clipboard-success';
const CLIPBOARD_ERROR_EVENT = 'clipboard-error';
const I18N_ERROR_MESSAGE = __('Copy failed. Please manually copy the value.');
function showTooltip(target, title) {
const { title: originalTitle } = target.dataset;
......@@ -9,20 +15,31 @@ function showTooltip(target, title) {
once('hidden', (tooltip) => {
if (tooltip.target === target) {
target.setAttribute('title', originalTitle);
target.setAttribute('aria-label', originalTitle);
fixTitle(target);
}
});
target.setAttribute('title', title);
target.setAttribute('aria-label', title);
fixTitle(target);
show(target);
setTimeout(() => target.blur(), 1000);
setTimeout(() => {
hide(target);
}, 1000);
}
function genericSuccess(e) {
// Clear the selection and blur the trigger so it loses its border
// Clear the selection
e.clearSelection();
showTooltip(e.trigger, __('Copied'));
e.trigger.focus();
e.trigger.dispatchEvent(new Event(CLIPBOARD_SUCCESS_EVENT));
const { clipboardHandleTooltip = true } = e.trigger.dataset;
if (parseBoolean(clipboardHandleTooltip)) {
// Update tooltip
showTooltip(e.trigger, __('Copied'));
}
}
/**
......@@ -30,17 +47,16 @@ function genericSuccess(e) {
* See http://clipboardjs.com/#browser-support
*/
function genericError(e) {
let key;
if (/Mac/i.test(navigator.userAgent)) {
key = '⌘'; // Command
} else {
key = 'Ctrl';
e.trigger.dispatchEvent(new Event(CLIPBOARD_ERROR_EVENT));
const { clipboardHandleTooltip = true } = e.trigger.dataset;
if (parseBoolean(clipboardHandleTooltip)) {
showTooltip(e.trigger, I18N_ERROR_MESSAGE);
}
showTooltip(e.trigger, sprintf(__(`Press %{key}-C to copy`), { key }));
}
export default function initCopyToClipboard() {
const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
const clipboard = new ClipboardJS('[data-clipboard-target], [data-clipboard-text]');
clipboard.on('success', genericSuccess);
clipboard.on('error', genericError);
......@@ -74,6 +90,8 @@ export default function initCopyToClipboard() {
clipboardData.setData('text/plain', json.text);
clipboardData.setData('text/x-gfm', json.gfm);
});
return clipboard;
}
/**
......@@ -89,3 +107,5 @@ export function clickCopyToClipboardButton(btnElement) {
btnElement.click();
}
export { CLIPBOARD_SUCCESS_EVENT, CLIPBOARD_ERROR_EVENT, I18N_ERROR_MESSAGE };
......@@ -13,9 +13,23 @@
* />
*/
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { __ } from '~/locale';
import {
CLIPBOARD_SUCCESS_EVENT,
CLIPBOARD_ERROR_EVENT,
I18N_ERROR_MESSAGE,
} from '~/behaviors/copy_to_clipboard';
export default {
name: 'ClipboardButton',
i18n: {
copied: __('Copied'),
error: I18N_ERROR_MESSAGE,
},
CLIPBOARD_SUCCESS_EVENT,
CLIPBOARD_ERROR_EVENT,
directives: {
GlTooltip: GlTooltipDirective,
},
......@@ -72,6 +86,13 @@ export default {
default: 'default',
},
},
data() {
return {
localTitle: this.title,
titleTimeout: null,
id: null,
};
},
computed: {
clipboardText() {
if (this.gfm !== null) {
......@@ -79,25 +100,50 @@ export default {
}
return this.text;
},
tooltipDirectiveOptions() {
return {
placement: this.tooltipPlacement,
container: this.tooltipContainer,
boundary: this.tooltipBoundary,
};
},
},
created() {
this.id = uniqueId('clipboard-button-');
},
methods: {
updateTooltip(title) {
this.localTitle = title;
this.$root.$emit('bv::show::tooltip', this.id);
clearTimeout(this.titleTimeout);
this.titleTimeout = setTimeout(() => {
this.localTitle = this.title;
this.$root.$emit('bv::hide::tooltip', this.id);
}, 1000);
},
},
};
</script>
<template>
<gl-button
v-gl-tooltip.hover.blur.viewport="{
placement: tooltipPlacement,
container: tooltipContainer,
boundary: tooltipBoundary,
}"
:id="id"
ref="copyButton"
v-gl-tooltip.hover.focus.click.viewport="tooltipDirectiveOptions"
:class="cssClass"
:title="title"
:title="localTitle"
:data-clipboard-text="clipboardText"
data-clipboard-handle-tooltip="false"
:category="category"
:size="size"
icon="copy-to-clipboard"
:aria-label="__('Copy this value')"
:variant="variant"
:aria-label="localTitle"
aria-live="polite"
@[$options.CLIPBOARD_SUCCESS_EVENT]="updateTooltip($options.i18n.copied)"
@[$options.CLIPBOARD_ERROR_EVENT]="updateTooltip($options.i18n.error)"
v-on="$listeners"
>
<slot></slot>
......
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import Clipboard from 'clipboard';
import ClipboardJS from 'clipboard';
import { uniqueId } from 'lodash';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
......@@ -69,7 +69,7 @@ export default {
},
mounted() {
this.$nextTick(() => {
this.clipboard = new Clipboard(this.$el, {
this.clipboard = new ClipboardJS(this.$el, {
container:
document.querySelector(`${this.modalDomId} div.modal-content`) ||
document.getElementById(this.container) ||
......
......@@ -50,7 +50,7 @@ module ButtonHelper
data: data,
type: :button,
title: title,
aria: { label: title },
aria: { label: title, live: 'polite' },
itemprop: item_prop
}
......
<script>
import { GlModal, GlSprintf, GlLink } from '@gitlab/ui';
import Clipboard from 'clipboard';
import ClipboardJS from 'clipboard';
import { getBaseURL, setUrlParams, redirectTo } from '~/lib/utils/url_utility';
import { sprintf, s__, __ } from '~/locale';
import { CODE_SNIPPET_SOURCE_URL_PARAM } from '~/pipeline_editor/components/code_snippet_alert/constants';
......@@ -74,7 +74,7 @@ export default {
},
copySnippet(andRedirect = true) {
const id = andRedirect ? 'copy-yaml-snippet-and-edit-button' : 'copy-yaml-snippet-button';
const clipboard = new Clipboard(`#${id}`, {
const clipboard = new ClipboardJS(`#${id}`, {
text: () => this.yaml,
});
clipboard.on('success', () => {
......
import { GlModal, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Clipboard from 'clipboard';
import ClipboardJS from 'clipboard';
import { merge } from 'lodash';
import ConfigurationSnippetModal from 'ee/security_configuration/components/configuration_snippet_modal.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
......@@ -87,7 +87,7 @@ describe('EE - SecurityConfigurationSnippetModal', () => {
it('on primary event, text is copied to the clipbard and user is redirected to CI editor', async () => {
findModal().vm.$emit('primary');
expect(Clipboard).toHaveBeenCalledWith('#copy-yaml-snippet-and-edit-button', {
expect(ClipboardJS).toHaveBeenCalledWith('#copy-yaml-snippet-and-edit-button', {
text: expect.any(Function),
});
expect(redirectTo).toHaveBeenCalledWith(
......@@ -98,7 +98,7 @@ describe('EE - SecurityConfigurationSnippetModal', () => {
it('on secondary event, text is copied to the clipbard', async () => {
findModal().vm.$emit('secondary');
expect(Clipboard).toHaveBeenCalledWith('#copy-yaml-snippet-button', {
expect(ClipboardJS).toHaveBeenCalledWith('#copy-yaml-snippet-button', {
text: expect.any(Function),
});
});
......
......@@ -9673,6 +9673,9 @@ msgstr ""
msgid "Copy evidence SHA"
msgstr ""
msgid "Copy failed. Please manually copy the value."
msgstr ""
msgid "Copy file contents"
msgstr ""
......@@ -9712,9 +9715,6 @@ msgstr ""
msgid "Copy this registration token."
msgstr ""
msgid "Copy this value"
msgstr ""
msgid "Copy to clipboard"
msgstr ""
......@@ -26665,9 +26665,6 @@ msgstr ""
msgid "Preferences|Use relative times"
msgstr ""
msgid "Press %{key}-C to copy"
msgstr ""
msgid "Prev"
msgstr ""
......
import initCopyToClipboard, {
CLIPBOARD_SUCCESS_EVENT,
CLIPBOARD_ERROR_EVENT,
I18N_ERROR_MESSAGE,
} from '~/behaviors/copy_to_clipboard';
import { show, hide, fixTitle, once } from '~/tooltips';
let onceCallback = () => {};
jest.mock('~/tooltips', () => ({
show: jest.fn(),
hide: jest.fn(),
fixTitle: jest.fn(),
once: jest.fn((event, callback) => {
onceCallback = callback;
}),
}));
describe('initCopyToClipboard', () => {
let clearSelection;
let focusSpy;
let dispatchEventSpy;
let button;
let clipboardInstance;
afterEach(() => {
document.body.innerHTML = '';
clipboardInstance = null;
});
const title = 'Copy this value';
const defaultButtonAttributes = {
'data-clipboard-text': 'foo bar',
title,
'data-title': title,
};
const createButton = (attributes = {}) => {
const combinedAttributes = { ...defaultButtonAttributes, ...attributes };
button = document.createElement('button');
Object.keys(combinedAttributes).forEach((attributeName) => {
button.setAttribute(attributeName, combinedAttributes[attributeName]);
});
document.body.appendChild(button);
};
const init = () => {
clipboardInstance = initCopyToClipboard();
};
const setupSpies = () => {
clearSelection = jest.fn();
focusSpy = jest.spyOn(button, 'focus');
dispatchEventSpy = jest.spyOn(button, 'dispatchEvent');
};
const emitSuccessEvent = () => {
clipboardInstance.emit('success', {
action: 'copy',
text: 'foo bar',
trigger: button,
clearSelection,
});
};
const emitErrorEvent = () => {
clipboardInstance.emit('error', {
action: 'copy',
text: 'foo bar',
trigger: button,
clearSelection,
});
};
const itHandlesTooltip = (expectedTooltip) => {
it('handles tooltip', () => {
expect(button.getAttribute('title')).toBe(expectedTooltip);
expect(button.getAttribute('aria-label')).toBe(expectedTooltip);
expect(fixTitle).toHaveBeenCalledWith(button);
expect(show).toHaveBeenCalledWith(button);
expect(once).toHaveBeenCalledWith('hidden', expect.any(Function));
expect(hide).not.toHaveBeenCalled();
jest.runAllTimers();
expect(hide).toHaveBeenCalled();
onceCallback({ target: button });
expect(button.getAttribute('title')).toBe(title);
expect(button.getAttribute('aria-label')).toBe(title);
expect(fixTitle).toHaveBeenCalledWith(button);
});
};
describe('when value is successfully copied', () => {
it(`calls clearSelection, focuses the button, and dispatches ${CLIPBOARD_SUCCESS_EVENT} event`, () => {
createButton();
init();
setupSpies();
emitSuccessEvent();
expect(clearSelection).toHaveBeenCalled();
expect(focusSpy).toHaveBeenCalled();
expect(dispatchEventSpy).toHaveBeenCalledWith(new Event(CLIPBOARD_SUCCESS_EVENT));
});
describe('when `data-clipboard-handle-tooltip` is set to `false`', () => {
beforeEach(() => {
createButton({
'data-clipboard-handle-tooltip': 'false',
});
init();
emitSuccessEvent();
});
it('does not handle success tooltip', () => {
expect(show).not.toHaveBeenCalled();
});
});
describe('when `data-clipboard-handle-tooltip` is set to `true`', () => {
beforeEach(() => {
createButton({
'data-clipboard-handle-tooltip': 'true',
});
init();
emitSuccessEvent();
});
itHandlesTooltip('Copied');
});
describe('when `data-clipboard-handle-tooltip` is not set', () => {
beforeEach(() => {
createButton();
init();
emitSuccessEvent();
});
itHandlesTooltip('Copied');
});
});
describe('when there is an error copying the value', () => {
it(`dispatches ${CLIPBOARD_ERROR_EVENT} event`, () => {
createButton();
init();
setupSpies();
emitErrorEvent();
expect(dispatchEventSpy).toHaveBeenCalledWith(new Event(CLIPBOARD_ERROR_EVENT));
});
describe('when `data-clipboard-handle-tooltip` is set to `false`', () => {
beforeEach(() => {
createButton({
'data-clipboard-handle-tooltip': 'false',
});
init();
emitErrorEvent();
});
it('does not handle error tooltip', () => {
expect(show).not.toHaveBeenCalled();
});
});
describe('when `data-clipboard-handle-tooltip` is set to `true`', () => {
beforeEach(() => {
createButton({
'data-clipboard-handle-tooltip': 'true',
});
init();
emitErrorEvent();
});
itHandlesTooltip(I18N_ERROR_MESSAGE);
});
describe('when `data-clipboard-handle-tooltip` is not set', () => {
beforeEach(() => {
createButton();
init();
emitErrorEvent();
});
itHandlesTooltip(I18N_ERROR_MESSAGE);
});
});
});
......@@ -15,11 +15,14 @@ exports[`FileSha renders 1`] = `
foo
<gl-button-stub
aria-label="Copy this value"
aria-label="Copy SHA"
aria-live="polite"
buttontextclasses=""
category="tertiary"
data-clipboard-handle-tooltip="false"
data-clipboard-text="foo"
icon="copy-to-clipboard"
id="clipboard-button-1"
size="small"
title="Copy SHA"
variant="default"
......
......@@ -4,6 +4,8 @@ import FileSha from '~/packages_and_registries/infrastructure_registry/details/c
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1));
describe('FileSha', () => {
let wrapper;
......
......@@ -15,11 +15,14 @@ exports[`FileSha renders 1`] = `
foo
<gl-button-stub
aria-label="Copy this value"
aria-label="Copy SHA"
aria-live="polite"
buttontextclasses=""
category="tertiary"
data-clipboard-handle-tooltip="false"
data-clipboard-text="foo"
icon="copy-to-clipboard"
id="clipboard-button-1"
size="small"
title="Copy SHA"
variant="default"
......
......@@ -4,6 +4,8 @@ import FileSha from '~/packages_and_registries/package_registry/components/detai
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1));
describe('FileSha', () => {
let wrapper;
......
import { GlButton } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import initCopyToClipboard from '~/behaviors/copy_to_clipboard';
import { nextTick } from 'vue';
import initCopyToClipboard, {
CLIPBOARD_SUCCESS_EVENT,
CLIPBOARD_ERROR_EVENT,
I18N_ERROR_MESSAGE,
} from '~/behaviors/copy_to_clipboard';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1));
describe('clipboard button', () => {
let wrapper;
......@@ -15,6 +23,42 @@ describe('clipboard button', () => {
const findButton = () => wrapper.find(GlButton);
const expectConfirmationTooltip = async ({ event, message }) => {
const title = 'Copy this value';
createWrapper({
text: 'copy me',
title,
});
wrapper.vm.$root.$emit = jest.fn();
const button = findButton();
expect(button.attributes()).toMatchObject({
title,
'aria-label': title,
});
await button.trigger(event);
expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith('bv::show::tooltip', 'clipboard-button-1');
expect(button.attributes()).toMatchObject({
title: message,
'aria-label': message,
});
jest.runAllTimers();
await nextTick();
expect(button.attributes()).toMatchObject({
title,
'aria-label': title,
});
expect(wrapper.vm.$root.$emit).toHaveBeenCalledWith('bv::hide::tooltip', 'clipboard-button-1');
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
......@@ -99,6 +143,32 @@ describe('clipboard button', () => {
expect(findButton().props('variant')).toBe(variant);
});
describe('confirmation tooltip', () => {
it('adds `id` and `data-clipboard-handle-tooltip` attributes to button', () => {
createWrapper({
text: 'copy me',
title: 'Copy this value',
});
expect(findButton().attributes()).toMatchObject({
id: 'clipboard-button-1',
'data-clipboard-handle-tooltip': 'false',
'aria-live': 'polite',
});
});
it('shows success tooltip after successful copy', () => {
expectConfirmationTooltip({
event: CLIPBOARD_SUCCESS_EVENT,
message: ClipboardButton.i18n.copied,
});
});
it('shows error tooltip after failed copy', () => {
expectConfirmationTooltip({ event: CLIPBOARD_ERROR_EVENT, message: I18N_ERROR_MESSAGE });
});
});
describe('integration', () => {
it('actually copies to clipboard', () => {
initCopyToClipboard();
......
......@@ -3,7 +3,7 @@
exports[`Package code instruction multiline to match the snapshot 1`] = `
<div>
<label
for="instruction-input_3"
for="instruction-input_1"
>
foo_label
</label>
......@@ -23,7 +23,7 @@ multiline text
exports[`Package code instruction single line to match the default snapshot 1`] = `
<div>
<label
for="instruction-input_2"
for="instruction-input_1"
>
foo_label
</label>
......@@ -37,7 +37,7 @@ exports[`Package code instruction single line to match the default snapshot 1`]
<input
class="form-control gl-font-monospace"
data-testid="instruction-input"
id="instruction-input_2"
id="instruction-input_1"
readonly="readonly"
type="text"
/>
......@@ -47,9 +47,12 @@ exports[`Package code instruction single line to match the default snapshot 1`]
data-testid="instruction-button"
>
<button
aria-label="Copy this value"
aria-label="Copy npm install command"
aria-live="polite"
class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon"
data-clipboard-handle-tooltip="false"
data-clipboard-text="npm i @my-package"
id="clipboard-button-1"
title="Copy npm install command"
type="button"
>
......
......@@ -3,6 +3,8 @@ import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
jest.mock('lodash/uniqueId', () => (prefix) => (prefix ? `${prefix}1` : 1));
describe('Package code instruction', () => {
let wrapper;
......
......@@ -167,6 +167,7 @@ RSpec.describe ButtonHelper do
expect(element.attr('class')).to eq('btn btn-clipboard btn-transparent')
expect(element.attr('type')).to eq('button')
expect(element.attr('aria-label')).to eq('Copy')
expect(element.attr('aria-live')).to eq('polite')
expect(element.attr('data-toggle')).to eq('tooltip')
expect(element.attr('data-placement')).to eq('bottom')
expect(element.attr('data-container')).to eq('body')
......
......@@ -3435,19 +3435,10 @@ cli-boxes@^2.2.0:
resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.0.tgz#538ecae8f9c6ca508e3c3c95b453fe93cb4c168d"
integrity sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w==
clipboard@^1.7.1:
version "1.7.1"
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-1.7.1.tgz#360d6d6946e99a7a1fef395e42ba92b5e9b5a16b"
integrity sha1-Ng1taUbpmnof7zleQrqStem1oWs=
dependencies:
good-listener "^1.2.2"
select "^1.1.2"
tiny-emitter "^2.0.0"
clipboard@^2.0.0:
version "2.0.6"
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.6.tgz#52921296eec0fdf77ead1749421b21c968647376"
integrity sha512-g5zbiixBRk/wyKakSwCKd7vQXDjFnAMGHoEyBogG/bw9kTD9GvdAvaoRR1ALcEzt3pVKxZR0pViekPMIS0QyGg==
clipboard@^2.0.0, clipboard@^2.0.8:
version "2.0.8"
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.8.tgz#ffc6c103dd2967a83005f3f61976aa4655a4cdba"
integrity sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ==
dependencies:
good-listener "^1.2.2"
select "^1.1.2"
......
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